← Все статьи

Статья 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 в том же Viewer

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 и поведение при перегрузке.