Когда обработка пакетов уходит из ядра Linux в пользовательское пространство, возникает вопрос, на котором раньше или позже спотыкается каждый: как передавать данные между ядрами и потоками без системных вызовов, блокировок и копирований. В DPDK ответ вынесен в отдельный примитив, кольцо rte_ring, и именно через кольца строятся все классические пайплайны: от приёма на выделенных ядрах до передачи в рабочие потоки и обратно. Примитив выглядит скромно, но внутри у него аккуратная механика с атомиками, режимами синхронизации и нулевым копированием, и от умения ею пользоваться зависит, будет ли пайплайн держать десятки миллионов пакетов в секунду или поплывёт под нагрузкой.

Разберём устройство кольца, режимы производителей и потребителей, практику создания и обмена, работу между процессами и ошибки, которые чаще всего портят продакшен.

Как устроено кольцо и почему размер в степени двойки

rte_ring это кольцевой буфер фиксированного размера для указателей, работающий по схеме FIFO: enqueue с одного конца, dequeue с другого. Позиции записи и чтения хранятся в четырёх счётчиках, отдельные пары head и tail для производителя и для потребителя, и каждая пара выровнена на собственную строку кэша, чтобы горячие счётчики разных потоков не устраивали соперничество за одну строку. Память под кольцо выделяется в memzone из огромных страниц, поэтому кольцо одновременно доступно и потокам, и отдельным процессам, что редко где встречается у самодельных очередей.

Классический размер обязан быть степенью двойки, и на то есть причина: индексация по модулю 2^n заменяется маской, а отличать полное кольцо от пустого помогает один незанятый слот, поэтому кольцо из 4096 записей вмещает 4095 элементов. Если такая потеря некритична, а вместимость обязана быть точной, флаг RING_F_EXACT_SZ даёт кольцо ровно на заказанное число слотов, внутри оставляя округлённое до степени двойки хранилище. Пара килобайт экономии памяти против потери слота, выбор обычно в пользу предсказуемой арифметики, но API даёт оба пути.

Служебных функций хватает для наблюдения: rte_ring_get_size и rte_ring_get_capacity показывают геометрию, rte_ring_count и rte_ring_free_count дают текущее заполнение, rte_ring_full и rte_ring_empty удобны в отладке, а rte_ring_dump печатает сводку по кольцу в файловый дескриптор, когда нужно быстро понять, где копится очередь. Имена колец стоит выстраивать системно, например worker_tx_2 или rx_to_worker_3, потому что поиск по имени остаётся главным способом найти чужое кольцо во вторичном процессе, а в логе дамп мгновенно объясняет, кто кому что передаёт.

Режимы производителей и потребителей от SP/SC до HTS

Кольца бывают четырёх классических режимов: один производитель с одним потребителем, много с одним, один со многими и много со многими. Режим задаётся флагами при создании, RING_F_SP_ENQ и RING_F_SC_DEQ, и это не просто метка, а настоящая экономия: в SP/SC не нужны атомарные обмены между конкурентами, каждая сторона крутит собственную пару счётчиков, и операция сводится к записи, барьеру памяти и обновлению индекса. Пайплайн из ядер, где каждое звено обслуживается одним читателем и одним писателем, собирается из SP/SC колец и показывает лучшую задержку.

Как только производителей или потребителей становится несколько, появляется конкуренция за tail, и здесь три эволюционных ступени. Базовый MP/MC обновляет head и tail через цикл сравнения с обменом, на x86 для пары head и tail применяется 128-битный обмен cmpxchg16b. Режим RTS, relaxed tail sync, вошедший в релиз 18.05, добавляет к индексам счётчики изменённого числа элементов, что страхует от классической проблемы ABA при повторном использовании значений индексов. Режим HTS, head tail sync, из релиза 19.11, идёт дальше: перед завершением батча он дожидается выравнивания head и tail, за счёт чего каждый производитель получает в распоряжение весь свободный хвост кольца и редкие батчи начинают уходить крупнее, что заметно на конвейерах из нескольких колец подряд. Флаги RING_F_MP_RTS_ENQ и RING_F_MP_HTS_ENQ, а также их пары для потребителей включают эти режимы по отдельности для каждой стороны.

Практическое правило простое: SP/SC всегда, когда топология позволяет; RTS как честный MP/MC без ABA; HTS там, где кольца выстроены в цепочку и важен крупный батч на выходе. Флаги задаются независимо для каждой стороны, поэтому кольцо с RING_F_MP_HTS_ENQ и RING_F_SC_DEQ допускает толпу производителей при единственном потребителе, и такая комбинация часто оказывается самым удачным выбором для сборных очередей.

Создание кольца и обмен данными на практике

Канонический пайплайн из ядра приёма и ядра передачи выглядит так:

#include <rte_ring.h>

struct rte_ring *tx;
tx = rte_ring_create("worker_tx", 4096, rte_socket_id(),
                     RING_F_SP_ENQ | RING_F_SC_DEQ);
if (tx == NULL)
    rte_exit(EXIT_FAILURE, "cannot create ring\n");

struct rte_mbuf *pkts[32];
uint16_t n = rte_eth_rx_burst(0, 0, pkts, 32);
unsigned sent = rte_ring_enqueue_burst(tx, (void **)pkts, n, NULL);
for (uint16_t i = sent; i < n; i++)
    rte_pktmbuf_free(pkts[i]);

На другом конце ядро-потребитель снимает пакеты тем же батчевым вызовом:

unsigned got = rte_ring_dequeue_burst(tx, (void **)pkts, 32, NULL);
if (got)
    rte_eth_tx_burst(1, 0, pkts, got);

Ключевая деталь, которую проглядывают чаще всего: enqueue возвращает число реально помещённых элементов, и всё, что не поместилось, остаётся на ответственности приложения. В примере лишние пакеты освобождаются обратно в пул, иначе через минуты работы дыра в mbuf-пуле остановит приём, а искать причину будут в драйвере. Варианты _bulk и _burst отличаются обещаниями: bulk либо перемещает весь батч, либо ноль, burst кладёт сколько влезло, а последний аргумент по желанию возвращает число свободных слотов, что удобно для адаптивного управления батчем.

Кольца хранят указатели, но с релиза 19.11 есть элементный API, rte_ring_enqueue_elem и rte_ring_dequeue_elem, где размер элемента передаётся в байтах и можно гонять компактные структуры без лишнего уровня косвенности. Для горячего пути, где хочется вообще без копирования, в релизе 20.11 появились вызовы нулевого копирования: rte_ring_enqueue_zc_bulk возвращает дескриптор с прямыми указателями на слоты кольца, приложение пишет данные прямо туда и подтверждает запись вызовом rte_ring_enqueue_zc_bulk_finish. Выигрыш заметен на крупных элементах, когда memcpy из временного массива в кольцо занимает сопоставимое с обработкой время.

Размер кольца, водяные метки и влияние на задержку

Диаметр кольца это рукоятка с двумя концами: задержка и устойчивость к всплескам. Маленькое кольцо держит данные в кэше и не даёт очереди разрастаться, но при всплеске трафика отбрасывает хвост, и приложение начинает платить за пропуски повторными обработками. Большое кольцо переживает всплески, зато пакет, пролежавший в глубине очереди, добавляет к задержке время обработки всей очереди впереди, а вылет из кэша бьёт по скорости обхода. На практике размеры подбираются под профиль нагрузки: типичные значения лежат в районе от 512 до 16384 слотов, а выбор проверяется замером задержки при целевой и пиковой нагрузке, а не на глаз.

Для контроля заполнения пригодится водяная метка, rte_ring_set_watermark задаёт уровень, при приближении к которому кольцо сигнализирует о приближении к переполнению, и приложение может заблаговременно сбросить нагрузку, прикрыть приём или переключить трафик. Дополняют картину счётчики заполнения из предыдущего раздела: цикл контроля, раз в сотню миллисекунд опрашивающий rte_ring_count по всем кольцам пайплайна, выявляет и затык потребителя, и перегруз производителя раньше, чем очередь упрётся в край.

Сами операции имеют цену, и она асимметрична. SP/SC проверяет счётчики и пишет индексы, это единицы наносекунд на элемент. MP/MC с 128-битным обменом на конкуренции превращает tail в точку спора: чем больше производителей, тем чаще повторные попытки обмена. Поэтому распределение ролей по ядрам, при котором каждое кольцо обслуживается одной стороной с одной стороны, не паранойя, а прямой способ сохранить предсказуемость.

Кольца между процессами и служебные механизмы

Кольца живут в memzone на огромных страницах, поэтому работают через границу процессов. Вторичный процесс запускается с --proc-type=secondary, после инициализации EAL находит кольцо по имени через rte_ring_lookup и получает тот же буфер. Так строятся схемы, где контрольный процесс собирает статистику или конфигурацию, а рабочие процессы гоняют трафик, не обмениваясь ни одним системным вызовом на пути данных.

Есть и готовый сетевой приём: драйвер-заглушка net_ring превращает кольцо в виртуальный порт Ethernet. Запуск testpmd с аргументом --vdev=net_ring0 даёт порт, между процессами через пару таких портов можно проверить пайплайн до подключения реальной сетевой карты, а внутри одного приложения связать ядра сгенерированным трафиком без вмешательства ядра Linux. Для чистки служат rte_ring_free, безопасная, пока кольцо не используется, и rte_ring_reset для сброса счётчиков на живой системе при перезапуске звеньев.

Сборка всего этого стандартна для DPDK: проект собирается через meson setup build и ninja, приложение линкуется с pkg-config:

gcc app.c $(pkg-config --cflags --libs libdpdk) -o app
./app -l 2-5 --proc-type=auto -- --rings=worker_tx

Порядок внедрения кольцевого пайплайна

Схема, которая сходится с первого раза, выглядит так:

  1. нарисовать топологию ядер и звеньев, определив для каждого кольца единственного производителя и единственного потребителя;
  2. завести кольца с запасом под всплески, отталкиваясь от замеров пикового батча, а не от среднего;
  3. подключить элементный API или нулевое копирование, если элементы тяжелее указателя;
  4. завести контрольный цикл, опрашивающий rte_ring_count и собирающий статистику дропов по каждому звену;
  5. замерить сквозную задержку, отмечая время приёма пакета и сравнивая с моментом передачи;
  6. закрепить режимы и размеры в конфигурации узла, чтобы переживали рестарт без правок кода.

Про пятый пункт стоит сказать отдельно. Сквозная задержка мерится легко: на приёме в служебное поле пакета пишется rte_rdtsc, на передаче снимается разница с текущим счётчиком, а перевод в наносекунды даёт rte_get_tmr_hz. Такой замер честнее любого графика загрузки процессора, потому что показывает очередь изнутри: рост хвоста на звене виден по всплескам задержки раньше, чем по счётчикам отброшенных пакетов.

Ошибки, которые чаще всего портят продакшен

Первая и самая частая беда, это проигнорированное возвращаемое значение enqueue: батч молча режется, пакеты утекут в никуда, а мбуфы не возвращаются в пул. Правило жёсткое: каждое помещение в кольцо проверяется, каждый непомещённый элемент либо ждёт следующего захода, либо явно освобождается. Вторая беда, это смешение режимов: кольцо, созданное с SP/SC флагами, но используемое двумя производителями, работает ровно до первой гонки, и ловится такая ошибка только под нагрузкой, потому что флаги не проверяются во время работы.

Третья категория связана с планировкой пайплайна. Кольцо на каждое ядро без промежуточного буферизования превращает конвейер в сериал из простаиваний, а кольцо-сборная солянка из десятков производителей на одном MP/MC превращает tail в узкое место и съедает выигрыш DPDK. Лекарство известное: SP/SC звенья с локальными буферами на ядре, укрупнение батчей по пути и по возможности HTS на стыках, где несколько источников неизбежны. И последнее, к чему стоит приучить глаз при чтении чужого кода: кольцо под задачу надо освободить явно, утечка memzone с кольцом на 16384 слотов это мегабайт огромных страниц, которых в системе не так много, как кажется.

При аккуратном обращении rte_ring оказывается тем самым примитивом, который держит годами: фиксированная память, отсутствие системных вызовов, наблюдаемость через счётчики и режимы синхронизации под каждую топологию. Пайплайны на таких кольцах проходят нагрузочные испытания в десятки миллионов пакетов в секунду на узел, и весь секрет здесь не в тайном знании, а в дисциплине: правильный режим, проверенный результат enqueue и размер, подобранный по замерам, а не по привычке.