Статья 04
Rust для real-time-видео: владение кадрами и границы безопасности
Arc, move, Send и Sync на восьмимегабайтных кадрах — и граница между гарантиями компилятора и архитектурой системы.

В первой статье цикла я разобрал real-time-конвейер видеоаналитики на C++20, а в предыдущей статье — ту же серверную задачу на Python. Теперь посмотрим на Rust-версию: не как на ещё один перевод исходников, а как на эксперимент с другим набором инженерных гарантий.
Серверы намеренно совместимы на уровне продукта. Они принимают один RTSP-поток, выполняют Motion, Tracking и две ветки YOLO, формируют JPEG и HLS, записывают MP4, хранят историю в PostgreSQL и обслуживают один Viewer. Благодаря одинаковому REST-контракту сравнивать можно не демонстрационные функции, а три реализации одной системы.
Главный вывод сформулирую сразу: Rust не делает Motion, YOLO или FFmpeg автоматически быстрее. Его преимущество в другом — компилятор запрещает целый класс ошибок владения памятью и межпоточной передачи ещё до запуска программы. Но архитектуру, backpressure, корректную остановку и обработку сбоев он за разработчика не проектирует.

RustVideoAnalyticsServer 0.1.1 подключён к тому же браузерному Viewer. В строке состояния видны FPS сервера и клиента, потери, длина очереди и число перезапусков RTSP.
Это порт продукта, а не механический перевод C++
Rust- и C++-серверы дают Viewer одинаковое поведение, однако часть внутренних решений различается принципиально.
| Область | Rust | C++ |
|---|---|---|
| HTTP | Axum и Tokio | Drogon |
| Захват RTSP | внешний FFmpeg и raw BGR pipe | OpenCV VideoCapture |
| Кадр | Frame { width, height, Vec<u8> } |
cv::Mat |
| Совместное чтение кадра | Arc<Frame> |
ref-counted cv::Mat |
| Motion | собственная реализация на Rust | OpenCV |
| YOLO | crate ort, ONNX Runtime |
C++ API ONNX Runtime |
| Синхронизация | Arc, Mutex, Condvar, atomics |
shared_ptr, mutex, condition variable, atomics |
| Остановка источника | generation и кооперативное завершение | std::jthread, stop_token и generation |
| Сборка | Cargo и Cargo.lock |
CMake и vcpkg |
Общая схема конвейера при этом сохраняется:
IP-камера / RTSP
│
▼
FFmpeg capture ──raw BGR24──► capture thread
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
Motion/Tracking Full YOLO Tracking-YOLO
└─────────────────┼─────────────────┘
▼
Coordinator
┌──────────────┼──────────────┐
▼ ▼ ▼
JPEG/REST HLS/FFmpeg PostgreSQL
Rust-версия функционально повторяет сервер, но по-другому выражает владение, разделяемое состояние и допустимую передачу данных между потоками. Именно здесь сравнение становится интереснее синтаксиса.
Кому принадлежит восьмимегабайтный кадр
Исходный BGR24-кадр представлен простой структурой:
pub struct Frame {
pub width: u32,
pub height: u32,
pub data: Vec<u8>,
}
Для разрешения 2304×1296 размер буфера составляет:
2304 × 1296 × 3 = 8 957 952 байта ≈ 8,54 МиБ
Если копировать такой Vec<u8> для Motion, двух YOLO-workers, HLS и JPEG, только передача одного входного кадра создаст десятки мегабайт памяти. При 15–30 кадрах в секунду это уже не мелкая оптимизация, а свойство всей системы.
Вместо копирования кадр оборачивается в Arc:
let frame = Arc::new(Frame {
width,
height,
data: bytes,
});
let motion_frame = Arc::clone(&frame);
let yolo_frame = Arc::clone(&frame);
Arc::clone() не копирует пиксели. Он увеличивает атомарный счётчик владельцев. Motion и YOLO получают право читать один неизменяемый Frame, а память освобождается после исчезновения последнего Arc.
Здесь легко увидеть аналогию с cv::Mat: копирование заголовка также может оставить общий буфер пикселей. Разница не в самом подсчёте ссылок, а в ограничениях. Имея Arc<Frame>, нельзя просто получить изменяемую ссылку на данные, пока ими совместно владеют другие участники. Программист должен явно создать отдельное изображение для рисования либо применить синхронизацию.
В сервере coordinator создаёт RGB-изображение для рамок и подписей. Эта копия оправдана: исходный BGR-кадр в тот же момент ещё может читать worker. Rust заставляет такое решение выразить в типах, а не оставить в виде устного соглашения команды.
Move — это не обязательно копирование
Для C++-разработчика сообщение компилятора «value moved here» сначала выглядит как препятствие. Но перемещение владения часто означает лишь передачу нескольких машинных слов.
let frame = Arc::new(Frame {
width,
height,
data: bytes,
});
let task = MotionTask { frame };
// frame больше нельзя использовать: им владеет task
Сам Vec<u8> содержит указатель, длину и ёмкость. При move восьмимегабайтный буфер не переписывается; меняется владелец указателя. Если данные должны одновременно жить в нескольких ветках, используется Arc.
Получается полезное разделение:
- move передаёт единоличное владение;
&Tвременно заимствует объект только для чтения;&mut Tдаёт временное исключительное изменение;Arc<T>создаёт нескольких потокобезопасных владельцев;Mutex<T>добавляется лишь тогда, когда общее значение действительно нужно менять.
Компилятор проверяет время жизни ссылок и не позволит кадру исчезнуть, пока worker продолжает его читать. В C++ тот же результат достижим через shared_ptr, const-корректность и дисциплину, но больше условий остаётся на совести разработчика и code review.
Send и Sync как часть архитектуры
В многопоточном C++ можно передать в lambda указатель на объект, жизненный цикл которого окажется короче потока. Ошибка иногда проявится только под нагрузкой. В Rust thread::spawn потребует, чтобы захваченные значения могли безопасно пережить создавшую их функцию и пересечь границу потока.
let state_for_thread = Arc::clone(&state);
std::thread::spawn(move || {
capture_loop(state_for_thread);
});
Тип, который разрешено передать другому потоку, должен удовлетворять Send. Тип, к которому разрешено обращаться из нескольких потоков через общую ссылку, — Sync. Обычно эти свойства выводятся компилятором из состава структуры.
Это влияет на проектирование API. Обобщённый слот worker-а выглядит примерно так:
pub struct WorkerSlot<Task, Result> {
state: Mutex<SlotState<Task, Result>>,
condvar: Condvar,
}
Один тип используется для Motion, полного YOLO и tracking-YOLO. Он умеет принять задание, заменить ещё не начатое более свежим, дождаться работы, опубликовать результат и отдать последний результат без ожидания.
C++ позволяет написать такой же шаблонный класс. Специфическое преимущество Rust в том, что вместе с generic-кодом компилятор проверит допустимость передачи конкретных Task и Result между потоками. Абстракция не только убирает повторение полей и mutex-ов, но и переносит часть проверки контракта в систему типов.
Последний кадр важнее полной очереди
Rust не отменяет базовую проблему real-time-конвейера: камера может выдавать кадры быстрее, чем YOLO успевает их анализировать.
Если камера работает с 30 FPS, а inference занимает 500 мс, FIFO-очередь будет расти примерно на 28 кадров каждую секунду. Пользователь получит аккуратно обработанное прошлое вместо живого изображения.
Поэтому сервер использует не бесконечные очереди, а ограниченные слоты:
- capture заменяет не забранный кадр более свежим;
- Motion и полный YOLO принимают работу, только когда свободны;
- tracking-YOLO может заменить ожидающую задачу новой пачкой треков;
- coordinator использует последний готовый результат, не ожидая всех workers;
- HLS encoder и recorder также хранят последнее значение.
Такой backpressure не является преимуществом языка. Ту же архитектуру реализует C++-сервер. Rust лишь помогает безопасно оформить владение кадром и состоянием слота.
Это важное различие: низкую задержку обеспечивает решение «выбросить устаревшее», а не borrow checker.
Tokio и потоки ОС решают разные задачи
Слово async часто используют как синоним многопоточности, но в сервере есть два разных уровня конкурентности.
Tokio обслуживает:
- REST-соединения Axum;
- статическую раздачу HLS;
- асинхронный SQLx;
- периодическую очистку сессий;
- ожидание двух TCP listeners.
HTTP-обработчик проверяет токен, валидирует JSON, кратко меняет состояние и возвращает ответ. Пока future ждёт сеть или базу данных, runtime может продолжить другой запрос.
Видео использует блокирующий ввод-вывод и тяжёлые вычисления, поэтому выполняется в обычных потоках ОС: capture, Motion/Tracking, два YOLO, coordinator и display. Отдельные потоки есть у HLS encoder, JPEG-preview и активного recorder. Кроме того, собственные потоки могут создавать Tokio, SQLx и ONNX Runtime, а FFmpeg работает отдельными процессами.
async fn health(State(app): State<Arc<AppState>>) -> Response {
// короткая HTTP-операция
}
std::thread::spawn(move || {
// постоянный блокирующий видеоконвейер
capture_loop(capture_state);
});
Помещение YOLO в обычную async-задачу заблокировало бы worker Tokio. Можно использовать spawn_blocking, но для постоянного конвейера долгоживущий поток с явным однослотовым протоколом проще контролировать и профилировать.
Generation защищает от результатов старой камеры
Остановка потока — не мгновенное событие. В момент переключения камеры старый YOLO может ещё заканчивать inference. Если принять его результат без проверки, рамки из предыдущего источника попадут в новую сессию.
Для этого используется поколение источника:
let generation = Arc::new(AtomicU64::new(0));
let my_generation = generation.fetch_add(1, Ordering::SeqCst) + 1;
while generation.load(Ordering::SeqCst) == my_generation {
process_next_frame();
}
При открытии или остановке источника счётчик меняется. Каждый worker знает поколение, в котором был запущен, и прекращает публикацию, увидев несовпадение. Простое атомарное число отделяет старую сессию от новой без общей тяжёлой блокировки.
Но здесь Rust-реализация не во всём сильнее. Основные source threads завершаются кооперативно и не все их JoinHandle сохраняются для немедленного ожидания. В C++-версии std::jthread и stop_token дают более явную точку остановки и join. Безопасность памяти Rust не равна автоматически строгому управлению жизненным циклом фоновой работы.
Внешний FFmpeg: простой контракт с высокой ценой
Вместо OpenCV для RTSP-захвата Rust-сервер запускает FFmpeg и читает BGR24 из stdout:
ffmpeg -rtsp_transport tcp -fps_mode passthrough -i <RTSP> \
-an -f rawvideo -pix_fmt bgr24 -
Контракт понятен: прочитать ровно width × height × 3 байта — получить кадр. FFmpeg хорошо работает с H.264/HEVC, его можно независимо перезапускать и настраивать через CLI.
Цена решения тоже заметна:
- появляется дополнительный процесс;
- raw BGR передаётся между адресными пространствами;
- большой буфер проходит через pipe;
- размер и FPS приходится извлекать из диагностического вывода;
- зависание и корректное завершение дочернего процесса нужно контролировать отдельно.
На практике обнаружилась ещё одна тонкость. Камера сообщала одновременно 15 FPS и 28.58 tbr. Без -fps_mode passthrough FFmpeg выдавал 28,58 BGR-буфера в секунду, дублируя реальные изображения. Сервер считал копии новыми кадрами и повторно запускал Motion.
С passthrough число декодированных буферов стало соответствовать реальным кадрам. Это полезное исправление, но оно не является преимуществом Rust: C++-программа может запустить тот же FFmpeg с тем же параметром. Здесь победила диагностика границы процесса.
Где заканчивается безопасный Rust
В исходной серверной логике нет собственного unsafe, однако это не означает, что весь процесс состоит только из безопасного Rust.
Crate ort вызывает C API ONNX Runtime. Сетевой runtime обращается к системным API. Аллокатор взаимодействует с ОС. Декодирование и кодирование выполняет внешний FFmpeg. Внутри библиотек неизбежно существуют unsafe-блоки и native-код.
Безопасная оболочка полезна, если её контракт описан правильно. Ошибка в native-зависимости всё ещё может завершить процесс или повредить память. Rust также не предотвращает:
- deadlock из-за неверного порядка mutex-ов;
- ошибку в бизнес-логике;
- публикацию старого кадра при пропущенной generation-проверке;
- неправильный SQL;
- зависание камеры или FFmpeg;
- исчерпание памяти;
- явный
panic!.
Result<T, E> заставляет обработать ожидаемую ошибку, а Option<T> — отсутствие значения. Но unwrap() и expect() по-прежнему способны превратить нарушенное предположение в panic. Rust уменьшает область возможных ошибок, а не объявляет программу правильной.
Что действительно даёт Rust
Для этого сервера преимущества можно сформулировать конкретно.
Первое — проверка владения. Нельзя оставить обычную ссылку на кадр после уничтожения владельца или незаметно менять данные, которые параллельно читают другие ветки.
Второе — проверка межпоточной передачи. Тип, который нельзя безопасно переносить или разделять, не удовлетворит Send/Sync, и программа не соберётся.
Третье — ошибки и отсутствие данных являются частью сигнатуры:
fn open_model(path: Option<PathBuf>) -> Result<Model, ModelError>
Четвёртое — enum может хранить данные каждого состояния, а полный match заставляет разобрать все варианты:
enum AnalyticsResult {
Motion(Vec<Rect>),
Tracking(Vec<Track>),
Yolo(Vec<Detection>),
}
Пятое — безопасные абстракции вроде Option, iterator, generic WorkerSlot и RAII обычно компилируются в обычный машинный код без stop-the-world garbage collector.
Наконец, Cargo объединяет разрешение зависимостей, сборку, тесты и lockfile. Для чистого Rust-дерева это заметно проще, чем отдельная комбинация CMake и пакетного менеджера. В нашем случае преимущество неполное: FFmpeg и динамическая библиотека ONNX Runtime всё равно остаются внешними компонентами.
Что Rust не ускоряет автоматически
Полезно отделять язык от выбранных библиотек и архитектуры.
| Наблюдаемое свойство | Что его определяет |
|---|---|
| Низкая задержка | однослотовый backpressure и пропуск старых кадров |
| Скорость YOLO | ONNX Runtime, provider, input size, потоки и копирования |
| Качество HLS | FFmpeg, FPS, CRF, preset, GOP и масштабирование |
| Потребление памяти ONNX | настройки arena и число сессий |
| Устойчивость RTSP | параметры FFmpeg, таймауты и reconnect |
| Скорость Motion | алгоритм, SIMD, кэш и качество реализации |
Собственный Motion на Rust понятен и удобен для экспериментов, но зрелые SIMD-ядра OpenCV могут быть быстрее. Raw BGR pipe упрощает интеграцию с FFmpeg, но создаёт дополнительное копирование между процессами. Экосистема computer vision у C++ по-прежнему богаче. Borrow checker сначала повышает стоимость разработки для команды, которая только осваивает Rust.
Поэтому корректный вопрос звучит не «Rust быстрее C++?», а «какие риски мы хотим закрыть компилятором, за какие зависимости готовы отвечать и где находится измеренная горячая точка?»
Это всё ещё C++, переписанный на Rust?
На уровне продукта — почти да: один Viewer, REST-контракт, настройки, модель YOLO и ожидаемое поведение.
На уровне алгоритмов — частично: tracking, letterbox, NMS, области и схема workers перенесены из C++-версии.
На уровне реализации — нет. Захват, представление кадра, Motion, рисование, JPEG, HTTP-runtime, SQL-клиент, generic workers и правила владения реализованы иначе.
Точнее назвать Rust-сервер функционально совместимым портом с общей архитектурной идеей, но с другой моделью владения и несколькими существенно другими подсистемами.
Итог
Rust полезен в real-time-видеосервере не обещанием магической скорости. Он сокращает число допустимых способов неправильно владеть кадром, делить изменяемое состояние и передавать данные между потоками.
Но итоговая задержка всё равно определяется очередями, копиями, FFmpeg, ONNX Runtime и планированием workers. Типовая система не заменяет метрики, профилирование и продуманную остановку. Более того, в отдельных местах C++-версия с std::jthread и зрелым OpenCV остаётся практичнее.
Поэтому выбор между C++ и Rust — не выбор между «опасным быстрым» и «безопасным быстрым». Это выбор набора инженерных рисков. Rust переносит часть контроля из тестов и эксплуатации в компиляцию. И для многопоточного конвейера, где один кадр одновременно читают несколько независимых веток, это уже вполне измеримая ценность — даже если FPS не изменился.
Следующий логичный шаг — запустить C++-, Python- и Rust-серверы на одной камере и сравнить их по одинаковой методике: задержка Viewer, уникальные входные кадры, скорость Motion и YOLO, память, CPU и поведение при перегрузке.