Адаптер с поддержкой RDMA сообщает приложению о выполненных операциях через очередь завершений, и способ, которым программа ловит эти уведомления, напрямую определяет задержку и загрузку процессора. Самый частый перекос встречается у сервисов, где на канал событий выделен отдельный поток с бесконечным блокирующим ожиданием: в простое процессор свободен, зато каждый пакет добавляет лишние микросекунды на пробуждение. Второй перекос, зеркальный, это непрерывный опрос очереди в спин-цикле, который отъедает целое ядро даже без трафика. Между крайностями лежит выверенная схема: взвод уведомления, дозированный досмотр очереди, пакетное подтверждение событий и перевод канала в неблокирующий режим. Дальше разбирается устройство канала завершений в libibverbs, точный код корректного цикла, числовой расчет нагрузки на скорости 100 Гбит/с и приемы диагностики перегруженного канала.
Канал событий завершений в libibverbs и его место в цепочке уведомлений
Завершение работы запроса фиксируется адаптером в очереди завершений CQ, а канал событий, структура struct ibv_comp_channel, служит мостом между ядром и приложением. По сути это обертка над файловым дескриптором, который открыт для каждого канала и виден в /proc/self/fd вместе с остальными дескрипторами процесса. Канал создается вызовом ibv_create_comp_channel, после чего очереди привязываются к нему прямо при создании через ibv_create_cq.
struct ibv_comp_channel *ch = ibv_create_comp_channel(ctx);
if (!ch) {
fprintf(stderr, "ibv_create_comp_channel failed\n");
return -1;
}
struct ibv_cq *cq = ibv_create_cq(ctx, 4096, NULL, ch, 0);
if (!cq) {
fprintf(stderr, "ibv_create_cq failed\n");
return -1;
}
Второй аргумент ibv_create_cq задает глубину очереди в записях, здесь 4096, предпоследний параметр привязывает канал, последний выбирает вектор завершений, ноль означает первый. Каждая готовая запись ibv_wc извлекается вызовом ibv_poll_cq прямо из памяти очереди, без системного вызова, поэтому сам по себе опрос дешев. Дорогая часть начинается там, где приложение решает, ждать ли ему события из канала или проверять очередь вслепую в плотном цикле.
Перед правкой кода стоит убедиться, что адаптер вообще виден библиотеке, а каналы открыты там, где ожидались:
ibv_devices
ls -l /proc/4213/fd | grep uverbs
Первая команда перечисляет устройства, вторая показывает открытые дескрипторы процесса, связанные с устройством. Канал это ровно один дескриптор, поэтому потоков обработки заводят не больше, чем ядер, и каждый получает собственный канал: иначе потокам пришлось бы делить один дескриптор и конкурировать за события.
Три модели обработки завершений и цена каждой из них
Первый вариант, непрерывный опрос: поток безостановочно дергает ibv_poll_cq и обрабатывает записи сразу. Задержка минимальна, доли микросекунды, но ядро загружено на сто процентов круглосуточно, даже когда трафика нет неделями. Второй вариант, чистое ожидание события: поток спит внутри ibv_get_cq_event на чтении файлового дескриптора, процессор в простое свободен, зато каждое пробуждение стоит порядка единиц микросекунд на переключение контекста и возврат из ядра. Третий вариант, гибрид: после события поток крутится в ограниченном спин-цикле несколько микросекунд и лишь потом засыпает, что снимает большую часть стоимости пробуждения и при этом почти ничего не тратит в паузах.
Масштаб задачи показывает простой расчет. Линия 100 Гбит/с дает 12,5 ГБ полезных данных в секунду, и при сообщениях по 4 КиБ это около 3,05 млн завершений в секунду. Чистый опрос здесь занимает ровно одно ядро полностью, а событийная модель с батчем в 64 записи рождает примерно 47,7 тыс. пробуждений в секунду, что при цене каждого около 3,5 мкс выливается в 17 процентов того же ядра. Выбор между моделями, по сути, выбор между постоянной тратой ядра и дозированной.
Гибрид настраивается двумя числами, длиной спин-фазы и глубиной сна. Рабочая точка для многих сервисов лежит около тысячи итераций опроса после пробуждения и таймаута сна в единицы миллисекунд. При неравномерном трафике настройки пересматривают по метрикам хвостовой задержки, иначе спин-фаза отнимает ядро в паузах, а сон добавляет микросекунды в пиках.
Корректный цикл ожидания события с повторным взводом уведомления
Событие в канал само не приходит: очередь нужно взвести вызовом ibv_req_notify_cq, и после каждого сработавшего уведомления взвод требуется заново. Из-за этого между чтением записей и повторным взводом существует окно, в котором завершение может приехать незаметно, поэтому канонический цикл всегда завершается еще одним досмотром очереди. Скелет основного потока выглядит так:
struct ibv_wc wc[64];
while (1) {
struct ibv_cq *ev_cq;
void *ev_ctx;
if (ibv_get_cq_event(ch, &ev_cq, &ev_ctx))
break;
ibv_ack_cq_events(ev_cq, 1);
ibv_req_notify_cq(ev_cq, 0);
int n;
while ((n = ibv_poll_cq(ev_cq, 64, wc)) > 0) {
for (int i = 0; i < n; i++)
handle_wc(&wc[i]);
}
if (n < 0) {
fprintf(stderr, "ibv_poll_cq error\n");
break;
}
}
Порядок действий внутри цикла стабильный и выглядит следующим образом:
- поток читает одно событие из канала и подтверждает его вызовом ibv_ack_cq_events;
- заново взводит уведомление для той же очереди через ibv_req_notify_cq;
- извлекает из очереди все накопившиеся записи пачкой до 64 штук за один вызов ibv_poll_cq;
- возвращается к ожиданию следующего события, пока канал не закроется и чтение не вернет ошибку.
Каждая извлеченная запись содержит статус, код операции и размер: поле wc.status сверяется с IBV_WC_SUCCESS, поле opcode подсказывает, чтение это или отправка, а wc.byte_len дает точный объем. Отдельный инструмент экономии пробуждений, взвод только по запрошенным событиям: вызов ibv_req_notify_cq(cq, 1) будит поток лишь на завершениях с флагом solicited, которым обычно помечают последнюю операцию в цепочке RDMA-чтений, и длинная цепочка получает одно пробуждение вместо одного на каждую операцию.
Внутренний цикл по ibv_poll_cq обязан выгребать до нуля: если остановиться после первой записи, остаток придется обслуживать следующим пробуждением, а это лишние микросекунды на каждый пакет. На один канал допустимо повесить несколько очередей: контекст ev_ctx, переданный при создании очереди, подскажет, какая из них сработала. События при этом способны сливаться: два срабатывания подряд могут приехать как одно, и только пара взвод плюс досмотр гарантирует, что ни одна запись не зависнет в очереди до следующего уведомления.
Подтверждение событий и защита от утечки ресурсов
Каждое полученное событие должно быть подтверждено вызовом ibv_ack_cq_events, иначе ядро будет копить учетные записи по событиям до бесконечности. Очередь с неподтвержденными событиями отказывается удаляться: ibv_destroy_cq вернет EBUSY, и ресурсы останутся занятыми до конца жизни процесса. Подтверждение нельзя назвать бесплатным: внутри библиотеки на каждый вызов берется мьютекс, поэтому выгоднее накопить несколько событий и закрыть их одним вызовом, ibv_ack_cq_events(cq, 5) подтверждает пять событий за цену одного.
Типовой финальный участок программы, где канал разбирается после остановки потока обработки, выглядит так:
struct ibv_cq *ev_cq;
void *ev_ctx;
int pending = 0;
while (ibv_get_cq_event(ch, &ev_cq, &ev_ctx) == 0)
pending++;
if (pending)
ibv_ack_cq_events(cq, pending);
ibv_destroy_cq(cq);
ibv_destroy_comp_channel(ch);
Если основной цикл подтверждает события по одному, финальный досмотр закроет редкий хвост из нуля или пары записей, а для плотного трафика выгоднее вести счетчик и подтверждать раз в сотню событий, снимая по одному вызову библиотеки с каждой пачки. Счет здесь быстро растет: при 47,7 тыс. пробуждений в секунду и подтверждении по одному это 47,7 тыс. захватов мьютекса ежесекундно, а при накоплении до 128 событий за раз счет падает до 373 захватов. Ошибкой обратного порядка будет попытка удалить канал раньше очередей: сначала закрываются очереди, привязанные к каналу, потом сам канал, иначе ibv_destroy_comp_channel завершится с ошибкой. Проверку кодов возврата здесь стоит оставлять в боевом коде: молчаливый провал удаления превращает поиск утечки в охоту за иголкой.
Неблокирующий канал и обслуживание нескольких очередей через poll
Файловая природа канала дает бонус: дескриптор ch->fd переводится в неблокирующий режим, после чего канал обслуживается обычными средствами poll или epoll. В неблокирующем режиме ibv_get_cq_event при пустом канале возвращает -1 с кодом EAGAIN вместо блокировки, и поток получает возможность заниматься таймерами и управлением между пакетами. Схема с секундным таймаутом выглядит так:
int fl = fcntl(ch->fd, F_GETFL);
fcntl(ch->fd, F_SETFL, fl | O_NONBLOCK);
struct pollfd pfd = { .fd = ch->fd, .events = POLLIN };
while (1) {
int rc = poll(&pfd, 1, 1000);
if (rc == 0)
continue; /* пауза: таймеры и служебные задачи */
struct ibv_cq *ev_cq;
void *ev_ctx;
while (ibv_get_cq_event(ch, &ev_cq, &ev_ctx) == 0) {
ibv_ack_cq_events(ev_cq, 1);
ibv_req_notify_cq(ev_cq, 0);
while (ibv_poll_cq(ev_cq, 64, wc) > 0) { /* обработка */ }
}
}
Внешний poll с таймаутом в одну секунду отдает потоку служебным задачам, внутренний цикл выкачивает канал до EAGAIN, снимая все копившиеся события за один проход. В нагруженном сервисе вызов ibv_ack_cq_events стоит вынести из внутреннего цикла и подтвердить накопленный счетчик один раз после опустошения канала. Когда управляющих дескрипторов много, poll заменяется на epoll с подпиской EPOLLIN, логика опустошения не меняется, а регистрация канала занимает три строки:
int ep = epoll_create1(0);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = ch->fd };
epoll_ctl(ep, EPOLL_CTL_ADD, ch->fd, &ev);
Дальше поток сидит в epoll_wait и обслуживает в одном месте и каналы завершений, и управляющие сокеты, и таймерные дескрипторы.
Расчет нагрузки на канал при линейной скорости сети
Числовой пример помогает выбрать батч и глубину очереди без экспериментов на живом кластере. Линия 100 Гбит/с дает 12,5 ГБ в секунду, при сообщениях по 4 КиБ это 12 500 000 000 / 4096 = около 3,05 млн завершений в секунду. При батче 64 записи канал получает 3 050 000 / 64 = примерно 47,7 тыс. пробуждений в секунду, при батче 16 уже 190 тыс., а при батче 8 почти 381 тыс.
Каждое пробуждение обходится в 2-5 мкс на взвод, досмотр и возврат ко сну, и при цене 3,5 мкс выходит 47 700 × 3,5 мкс = 0,167 секунды загруженности в секунду, то есть 17 процентов одного ядра. Батч 16 поднимает цифру до 67 процентов, и разница между соседними батчами ощущается сильнее, чем разница между моделями ожидания. Глубина очереди 4096 записей при полной скорости переполняется за 4096 / 3 050 000 = 1,3 мс, поэтому обработчик обязан успевать в это окно, а лучше просто не подходить к границе. На скорости 25 Гбит/с все цифры делятся на четыре: 763 тыс. завершений, 12 тыс. пробуждений и переполнение за 5,4 мс.
Размер сообщения меняет картину еще сильнее длины линии. При пакетах по 1 КиБ та же линия 100 Гбит/с дает 12 500 000 000 / 1024 = около 12,2 млн завершений в секунду, и батч 64 уже рождает 191 тыс. пробуждений, две трети ядра при той же цене пробуждения. Мелкие сообщения требуют укрупненного батча или гибридной спин-фазы.
Диагностика канала профилировщиком и правка компоновки потоков
Перегруженный канал виден в стандартных инструментах без единой правки кода. Команда strace -c -p PID покажет счетчик системных вызовов чтения из дескриптора канала: сотни тысяч чтений в секунду означают, что поток просыпается слишком часто. Утилита perf top выведет наверх имена ibv_get_cq_event и ibv_poll_cq, когда канал съедает ядро, а mpstat по ядрам укажет, где именно сидит поток обработки:
strace -c -p 4213
perf top -p 4213
mpstat -P ALL 1
taskset -c 2 ./rdma_server
Типичный вывод mpstat для ядра с зацикленным потоком выглядит так:
02:31:05 CPU %usr %sys %irq %soft %idle
02:31:06 2 3,1 0,6 0,4 90,2 5,7
Мягкие прерывания около 90 процентов в паре с низким idle выдают поток, застрявший в опросе. Лечение начинается с перевода цикла на событийную схему из раздела выше, затем вектор завершений comp_vector при создании очереди разносит прерывания по ядрам, а taskset закрепляет поток за конкретным ядром, чтобы он не мигал между кэшами. Дескриптор канала в выводе strace узнаваем по связи с uverbs, а переход от батча 8 к батчу 64 на том же профиле нагрузки снимает со счетчика чтений примерно порядок. Финальный штрих, замер задержки до и после: разница между чистым опросом и гибридом на коротких сообщениях обычно укладывается в пару микросекунд, а экономия составляет целое ядро.