golang

Как добиться 8 Гбит/с видеотрафика на одном ядре CPU в Go

  • суббота, 8 августа 2026 г. в 00:00:12
https://habr.com/ru/articles/1067834/

Дисклеймер: Это вторая публикация моей статьи, прошлый вариант удалила модерация по п.п 4. Каюсь, грешен, ну, не умею я красиво писать (я же все-таки технарь J), поэтому засунул сырой и страшный текст в нейронку чтобы она сделала «красиво».  Так что все было удалено честно, но небольшой крик души к администрации Хабра – ребят, полностью сгенерированный текст это 100% плохо, вопросов нет. Но хорошо отредаченный текст нейронкой, вроде бы не абсолютное зло? Ладно, это все лирика, приступаем.

Предисловие

Есть небольшой побочный профиль, которым я занимаюсь – ретрансляция сырых потоков RTSP, в HLS. По сути задача простая, чтобы любой простой юзер мог увидеть картинку со своих камер, не пользуясь проприетарным облачным ПО от производителя камер. Во-первых, это много денег, т.к многие клиенты хотят хранить достаточно долго записи с камер. Во-вторых, почти у всех производителей камер разное ПО, и это тупо не удобно иметь 10 разных приложений и/или порталов, где это все можно посмотреть. Ну и в-третьих мало где есть интеграции с ИИ (что для моих клиентов, критически важно). Даже если эта интеграция есть, она либо узкоспециализирована, либо опять-таки проприетарна, либо стоит много денег, а иногда и все это вместе. Решение - использование сырых RTSP потоков, их поддерживают и отдают 90% всех камер на рынке.

Так вот, схема была достаточно проста: несколько камер, простой бэк, выдаем на едином ресурсе для клиентов картинку по HLS, используя плеер hls.js. Всех все устраивало, всем все нравилось. Быстро, просто, удобно и не дорого. Тесты работали идеально, клиенты довольны. С каждым был чат, куда выдавали ссылку на просмотр, а также был общий чат со всеми клиентами – где обсуждались вопросы подключения, финансовые ну и прочее. И вот настает момент Х, кто-то по ошибке скидывает в общий чат ссылку…и конец. По ссылке кликнул видимо весь чат, 3 минуты, полетели алерты в боте, алярм, ахтунг, тревога. Смотрим железо, сервер в ауте, ООМ, все потоки отвалились. Через 20 минут чат разрывался от гневных сообщений, о неработоспособности у всех. Занавес.

Добро пожаловать в проблему Thundering Herd (грохочущего стада).

Если кто-то из вас пробовал раздавать live-видео, то вам точно знакома эта проблема. Когда сотни или даже тысячи зрителей пытаются смотреть один и тот же поток, то у стандартного сервера схема проста – открыть новое соединение, или спавнить отдельный процесс для каждого зрителя. В целом итог, этого действия всегда один – ООМ, процессор лежит.

Самым простым способом решения было аренда более мощного облака, или покупка нового железа. Есть такое понятие что «если проблему можно решить, деньгами – то это не проблема», но денег то нет. Да и мы же инженеры, считай это брошенный вызов твоему профессионализму. Появилась идея написать свой движок на GO, это и дешево и можно выжать максимум производительности. Да и вообще, свое на GO – круто, модно, молодёжно. Спойлер не будет долгим: в итоге выжали 8.8 Гбит/с на одном ядре процессора, причем сборщик мусора в этот момент вообще отдыхал.

Рассказываю, как собирался Ruseon Core.

Почему не великий и могучий FFmpeg?

Давайте скажем честно, FFmpeg это «аксиома» в мире любой обработки видео. Он классный, правда. По сути это стандарт на века. Но господи, как же он любит «кушать». Запустить даже сотню процессов FFmpeg, для рестрима равносильно самоубийству. Мы же как раз хотим уйти от тех же проблем что и у него. Даже если его использовать как ingest-прокси, накладные расходы сведут всю пользу в ноль.

В поисках решения, находится MediaMTX. Это шикарный проект, и по сути решает нашу «основную» проблему. Но (помоему «но» становится моим любимым словом, хехе), нужна бесшовная интеграция с пайплайном для ИИ, а также запись архива. Из коробки, у него этого нет, а писать плагины или обертки, долго, да и смысл если уже взялись решить проблему концептуально. К тому же «понтануться» перед клиентами, что мы используем только свою собственную разработку – милое дело. Да и веса прибавляет, в глазах других инженеров и компаний.

Поэтому мы написали движок с нуля, при этом постаравшись сохранить «модульность». То есть, возможность встраивания, или использования как SDK. Главное правило при разработке – никакого транскодирования (мы просто перекладываем байты), минимум накладных расходов, максимум производительности.

Главная фича Zero‑Copy RingBuffer

Когда с камеры прилетает новый кадр (обычно это H.264, хотя реализовали и 265 кодек), это всего лишь очередной кусок байтов.

Представьте себе «гениальную» идею: рассылаем этот кусок тысячи зрителям, методом копирования байтов в отдельные буферы ответа. Представили, да? В мире GO – это путь к успеху (сарказм). Сборщик мусора будет в полном восторге, пытаясь хоть как-то справиться с горой мусора. Процессор же вместо стриминга, будет заниматься только уборкой.

Было решено пойти другим путем. Сделали Zero-Copy RingBuffer и прикрутили к нему sync.Pool.

Как это работает в голове:

  1. Прилетает кадр с камеры.

  2. Мы берем пустой байтовый буфер из заранее выделенного пула.

  3. Записываем туда кадр (один раз!).

  4. Раздаем указатель на этот буфер тысяче зрителям.

  5. Когда все прочитали — возвращаем буфер обратно в пул.

Аллокаций нет. Пауз на сборку мусора нет. 250 МБ ОЗУ, 1-2% нагрузка на процессор, при 100 потоках.

В бенчмарках это выглядит так:

BenchmarkWriteFrame-12    13.9 ns/op      0 B/op       0 allocs/op

Тут можно испытать настоящий инженерный «оргазм». Когда при потоковой передачи видео, видишь нулевой объем данных на операцию. Когда процессор фигачит десятки тысяч кадров, а куча как была минимальна – так и остается.

Тесты и нагрузочное тестирование, куда же без них?

Я вам тут все рассказываю, как это реализовывалось, но все мы любим цифры (особенное, цифры счета в банке J). Писать быстрый код — это круто, но нужно понимать, как он в реальности работает и где у него предел. Для тестов был выбран k6 от Grafana, он как раз позволит сэмулировать ту проблему из-за которой весь этот проект и был затеян.

Сценарий теста: 1000 юзеров, долбят наш муксер, постоянно качая плейлист (index.m3u8), и тыря куски .ts по мегабайту так быстро, как позволяет сервер.

Были мысли что «бутылочное горлошко» начнется на 300 юзеров…но спасибо что я ошибся.

70 секунд теста, на одном ядре рабочего Ryzen 5600x:

data_received..................: 81 GB  1.1 GB/s

http_req_failed................: 0.00%  ✓ 0 ✗ 60822

http_req_duration..............: avg=3.13ms p(95)=6.13ms

Это 8.8 Гбит/с пропускной способности(карл!). 60 тысяч успешных HTTP-ответов. Ни одного обрыва соединения.

Большие цифры, красиво, но в чем тут дело? Тут все просто, HLS-сегменты, тупо лежат в ОЗУ. Когда происходит массовый запрос сегмента, сервер просто отдает одни и те же байты из кеша. Все. Диск отдыхает, переупаковки нет, процессор заказывает виски с колой.

Нюансы

Встает вопрос бесконечного накопления буффера, но бесконечного накопления буфера (и впоследствии ООМ) не будет, реализована некоторая защита от этого:

Базовый сетевой стек go (net/http), который по сути работает на уровне доставки. Сервер просто отдает статический кусок памяти в сокет. Все. Быстро ли, медленно ли качает - без разницы.

А вот на уровне ядра(Ring.go) реализация защиты уже интереснее - внутри ядра подписчики (HLS Muxer или ИИ-воркеры) читают кадры через каналы в Go. Запись в канал реализована как неблокирующая отправка (через select + default).  Канал имеет жестко заданную глубину. Если подписчик тормозит, его канал забивается, ядро не ждет его и не выделяет новую память. Тут работает ветка default - кадр дропается для этого конкретного подписчика. Тут к сожалению приходятся "жертовать" плохим клиентом, ради условных 1000 "хороших".

Так же есть распространённая проблема ресинхронизации и запаздывание, решена она следующим образом. Если у подписчика внутри ядра случится дроп кадра из-за тормозов, ему нельзя давать следующий P-кадр т.к картинка рассыплется (к слову об этом даже есть упоминание в gortsplib, используемой в проекте либы для rtsp). В таком случае ядро ставит флаг NeedsIFrame в true, для него. Подписчик начинает молчать пока не прилетит следующий I-кадр(ключевой), с него чтение возобновится чисто и с минимальными потерями. В реальной работе, это милисекунды, для конечного зрителя по сути незаметна. Тем более что муксер хранит в памяти "скользящее окно" в 5 последний сегментов. Если у конечного зрителя сильно плохая связь, то при попытке скачать устаревший сегмент он получит 404. Плеер на клиенте (в 99% случаев все плеер у пользователей это hls.js, или его реализации) ловит 404, понимает, что отстал от лайва, и перепрыгивает на актуальный сегмент, синхронизировавшись с остальными.

И важный момент, HLS протокол, это реализация PULL. То есть накопления лага до бесконечности, не произойдёт. Это когда сервер пытается всунуть данные в сокет, сокет блокируется из-за плохой сети клиента, пакеты копятся в очереди, и когда сеть "просирается", клиент начинает смотреть видео 10-минутной давности. Здесь другая механика:

  1. Муксер в памяти держит скользящее окно (Live Playlist) — например, только 5 последних сегментов (допустим, это 10 секунд видео). Более старые сегменты удаляются навсегда.

  2. Клиент с ужасной связью, тянет сегмент 100 больше 3 минут.

  3. Скачал, плеер просит следующий сегмент №101.

  4. А на сервере самый свежий 200. 101 в памяти уже физически нет.

  5. Сервер отдает честную 404.

  6. Плеер ловит 404, качает свежий index.m3u8, видит, что актуальный сегмент уже 200, и делает прыжок на Live-край.

Клиент с плохой сетью просто будет видеть постоянные «прыжки» вперед и буфер (что логично при убитой сети), но он не заставит наш сервер хранить его персональный 10 минутный кэш.

Почему это не идеальный механизм?

В этой реализации ваш CPU по сути отдыхает, нагрузка 1-2%. Но при этом нагрузка на сеть будет колоссальная. По тестам 9 Гбитс, это считай предел 10 гигабитного интерфейса. Код выдержит, но сетевуха начнет дропать пакеты. У физики есть свои лимиты. Так же не стоит забывать, что, когда пишется про 0 B/op, и потребления 250 МБ ОЗУ на 100 потоках, имеется ввиду только юзерспейс память(куча), та за которую отвечает сборщик мусора. Буфер сокетов никуда не девается. Сотни или тысячи коннектов, сожрут свои законные мегабайты памяти под tcp_mem. Тут скорее описывается, что это проблема не удваивается на уровне ПО, т.е мы не аллоцируем еще гигабайты структур внутри GO. Надо понимать, что память ОС – это неизбежный налог на сеть.

Самый тонкий момент – не накосячить с реализацией sync.Pool. Иначе ваша картинка просто рассыплется, и часть кадров будет тупо зеленой. Все это из-за того что другой поток, уже пишет туда новые байты.

Короче, смысл этой статьи в том, что если вам не нужно транскодирование, или нужна минимальная нагрузка на железо – то не стоит использовать тяжелую артиллерию в виде FFmpeg и ему подобных. Контролируйте память. Перекладывайте байты без копирования.

Исходники и скрипты нагрузочного тестирования лежат в репозитории Ruseon Core в папке benchmarks/. Идите и попробуйте положить свой комп (я свой убил не раз).

Исходники: https://github.com/RUSEGAL/ruseon-core