В RDMA путь данных полностью уходит из ядра: приложение кладёт дескрипторы работы в очереди, сетевой контроллер исполняет их сам, и ни одного системного вызова на пересылке нет. Взамен приложение обязано вовремя узнавать, что работа завершилась, что линк упал или что очередь ушла в ошибку. Всю эту сторону обслуживают асинхронные события: очереди завершений с их каналами событий, асинхронные события самого устройства и события менеджера соединений RDMA CM. От того, насколько аккуратно выстроен цикл обработки этих событий, зависит и задержка, и устойчивость приложения к авариям, потому что в RDMA ошибка, замеченная с опозданием, обычно означает уже потерянный трафик.
Разберём три источника событий, классический цикл с уведомлениями и без них, умерение прерываний и диагностику с готовыми утилитами.
Каналы завершений и цикл с уведомлениями
Базовый объект, очередь завершений CQ, создаётся вместе с каналом событий завершений:
struct ibv_comp_channel *ch = ibv_create_comp_channel(ctx);
struct ibv_cq *cq = ibv_create_cq(ctx, 4096, NULL, ch, 0);
ibv_req_notify_cq(cq, 0);
Канал это файловый дескриптор, в который драйвер пишет уведомление, когда очередь получила запись после постановки в уведомлённое состояние. Обработчик выглядит одинаково в любых приложениях: получить событие, подтвердить его, снова поставить очередь в уведомлённое состояние и вычитать всё, что накопилось:
struct ibv_cq *ecq;
void *ectx;
struct ibv_wc wc[16];
int n;
for (;;) {
ibv_get_cq_event(ch, &ecq, &ectx);
ibv_ack_cq_events(ecq, 1);
ibv_req_notify_cq(ecq, 0);
while ((n = ibv_poll_cq(ecq, 16, wc)) > 0)
for (int i = 0; i < n; i++)
if (wc[i].status != IBV_WC_SUCCESS)
handle_error(&wc[i]);
}
Три детали в этом цикле обязательны, и пропуск любой из них превращается в подвешенное приложение. Подтверждение событий ibv_ack_cq_events нужно, чтобы драйвер не копил счётчики неубранных уведомлений, постановка в уведомлённое состояние заново после каждого события обязательна, потому что уведомление разовое, а после постановки надо сделать контрольный опрос, иначе между последним опросом и постановкой можно пропустить записи, которые уже лежат в очереди, и спать до следующего события, которого не будет.
Поскольку канал это обычный файловый дескриптор, он спокойно живёт в epoll вместе с сокетами управления и таймерами, что позволяет собрать один цикл на всё приложение вместо отдельного потока на каждую очередь.
Устройство очереди завершений и статусы записей
Каждая запись в CQ, структура ibv_wc, рассказывает судьбу одного дескриптора работы. Поле wr_id возвращает приложению его собственный тег, opcode называет операцию: IBV_WC_SEND и IBV_WC_RECV для сообщений, IBV_WC_RDMA_WRITE и IBV_WC_RDMA_READ для прямых обращений к памяти, IBV_WC_COMP_SWAP и IBV_WC_FETCH_ADD для атомиков. Поле status сообщает результат, и список статусов читается как карта типовых аварий: IBV_WC_LOC_LEN_ERR означает обращение за границы зарегистрированного региона, IBV_WC_LOC_PROT_ERR нарушение прав доступа, IBV_WC_REM_ACCESS_ERR отказ на стороне удалённого узла, IBV_WC_RETRY_EXC_ERR исчерпание попыток доставки, что почти всегда означает недоступность узла или неверную настройку сети.
Полезная мелочь, о которой забывают: с флагом IBV_SEND_INLINE отправка мелкого сообщения уходит прямо из дескриптора без регистрации памяти и без отдельного чтения данных контроллером, а на принимающей стороне IBV_WC_WITH_IMM доставляет 32 бита данных вместе с завершением приёма, экономя отдельную синхронизацию. Размер CQ тоже не декорация: очередь обязана вместить завершения всех работ в полёте, иначе контроллер поймает переполнение, и это уже не статус отдельной записи, а авария уровня очереди. Завершения можно и отключать: флаг IBV_SEND_SIGNALED ставится не на каждую отправку, а через одну или на каждую N-ю, что на чисто исходящих потоках режет число записей в CQ в разы, лишь бы последнее сообщение каждой пачки оставалось сигнальным, иначе приложение не узнает, что пачка ушла.
Асинхронные события устройства и реакция на них
Второй канал, события самого устройства, приходит на контекст через ibv_get_async_event:
struct ibv_async_event event;
while (ibv_get_async_event(ctx, &event) == 0) {
switch (event.event_type) {
case IBV_EVENT_PORT_ACTIVE:
break;
case IBV_EVENT_PORT_ERR:
break;
case IBV_EVENT_QP_FATAL:
break;
case IBV_EVENT_CQ_ERR:
break;
case IBV_EVENT_DEVICE_FATAL:
break;
default:
break;
}
ibv_ack_async_event(&event);
}
Смысл событий стоит выучить, потому что каждый из них диктует своё действие. IBV_EVENT_PORT_ACTIVE и IBV_EVENT_PORT_ERR сообщают о подъёме и падении линка, и на них строится переподключение на уровне приложения. IBV_EVENT_QP_FATAL означает, что очередь пар перешла в состояние ошибки безвозвратно, дальше её только уничтожать и создавать заново. IBV_EVENT_CQ_ERR это переполнение очереди завершений, и после него очередь выведена из строя, лечится только пересозданием с большим размером. IBV_EVENT_QP_LAST_WQE_REACHED приходит в режиме RDMA по транспортному протоколу со shared receive queue при завершении последней работы, IBV_EVENT_SRQ_LIMIT_REACHED сигнализирует, что приёмный пул SRQ опустился до заданного уровня и пора пополнять. На RoCE отдельного внимания заслуживает IBV_EVENT_GID_CHANGE, смена GID на порту требует пересоздания адресных связей, иначе соединения молча уводятся в никуда.
Для приёмного пула SRQ настраивается порог уведомления вызовом ibv_modify_srq с атрибутом srq_limit, и событие IBV_EVENT_SRQ_LIMIT_REACHED приходит один раз до следующего изменения порога, что удобно для автоматического пополнения буферов. На InfiniBand живёт и семейство событий подсети: IBV_EVENT_PKEY_CHANGE при смене ключей разделов, IBV_EVENT_LID_CHANGE при перенумерации, IBV_EVENT_SM_CHANGE при смене менеджера подсети, а автоматический выбор маршрута добавляет IBV_EVENT_PATH_MIG и IBV_EVENT_PATH_MIG_ERR для удачной и неудачной миграции пути. Дисциплина обращения одна: события читают в выделенном месте, разбирают и обязательно подтверждают. Потерянное подтверждение накапливает счётчик в драйвере, а проигнорированный QP_FATAL оставляет приложение полировать очередь, которая уже никогда ничего не вернёт.
Опрос против прерываний и умерение очереди
Цикл с уведомлениями экономит процессор, но добавляет путь через ядро и пробуждение потока, что стоит микросекунды. Чистый опрос без уведомлений даёт минимальную задержку, но сжигает ядро целиком, поэтому горячие приложения комбинируют оба подхода: опрос с бюджетом, а после исчерпания бюджета постановка в уведомление и сон. Схема с повторным контрольным опросом после постановки закрывает гонку пропущенных записей. Ориентир по цифрам простой: реакция через прерывание и пробуждение потока стоит единицы микросекунд, а готовый опрос подхватывает завершение за сотни наносекунд, поэтому на путях с жёстким бюджетом задержки опрос остаётся безальтернативным, а на фоновых потоках уведомления экономят целые ядра. На стороне Ethernet умерение смотрят и правят в том же духе:
ethtool -C eth0 rx-usecs 25 rx-frames 32
Между этими крайностями лежит умерение очереди, регулировка того, после скольких записей и какого периода генерируется одно прерывание:
struct ibv_cq_attr attr = {
.cq_count = 64,
.cq_period = 10
};
ibv_modify_cq(cq, &attr, IBV_CQ_ATTR_MODERATION);
Здесь прерывание приходит не раньше, чем накопится 64 записи или пройдёт 10 микросекунд, что на пакетной нагрузке режет частоту прерываний в десятки раз без заметной потери задержки. На стороне Ethernet-портов то же самое делается ethtool с ключами -C и параметрами rx-usecs, а на InfiniBand политику умерения задают драйверу. Отдельно помогает привязка обработчиков к тем же NUMA-узлам, где живут очереди: завершения с удалённого узла дороже не только опросом, но и самим доступом к памяти записей.
События менеджера соединений при установлении связи
Установка и разрыв соединений живут в третьем канале, у RDMA CM свой цикл:
struct rdma_event_channel *cm_ch = rdma_create_event_channel();
struct rdma_cm_id *id;
struct rdma_cm_event *ev;
rdma_create_id(cm_ch, &id, NULL, RDMA_PS_TCP);
rdma_get_cm_event(cm_ch, &ev);
switch (ev->event) {
case RDMA_CM_EVENT_ADDR_RESOLVED:
rdma_resolve_route(id, 2000);
break;
case RDMA_CM_EVENT_ROUTE_RESOLVED:
break;
case RDMA_CM_EVENT_ESTABLISHED:
break;
case RDMA_CM_EVENT_DISCONNECTED:
break;
case RDMA_CM_EVENT_ADDR_ERROR:
case RDMA_CM_EVENT_ROUTE_ERROR:
break;
}
rdma_ack_cm_event(ev);
Последовательность событий на клиенте идёт от разрешения адреса через разрешение маршрута к установлению, и каждое событие нужно подтверждать вызовом rdma_ack_cm_event, поскольку менеджер держит память события до подтверждения. На сервере к этому добавляются RDMA_CM_EVENT_CONNECT_REQUEST, на котором создаётся новый идентификатор соединения и принимается решение о допуске клиента, после чего идёт rdma_accept. Пассивная сторона обязана отвечать на каждое событие своим подтверждением, а события ошибок вроде RDMA_CM_EVENT_UNREACHABLE или RDMA_CM_EVENT_REJECTED закрывают идентификатор, чтобы не копить полуживые соединения. Таймаут разрешения маршрута задаётся в миллисекундах, как в примере выше, и слишком щедрый таймер превращает недоступный узел в минуту тишины вместо быстрого отказа.
Диагностика готовыми утилитами и счётчики портов
Готовые утилиты закрывают большую часть вопросов без отладчика. ibv_devinfo показывает устройства, порты и флаги, ibv_rc_pingpong из состава libibverbs-utils проверяет связку узлов за секунды и печатает пиковую полосу, perfquery снимает счётчики порта InfiniBand, а на RoCE то же самое лежит в sysfs:
ibv_devinfo -v
ibv_rc_pingpong -d mlx5_0 -g 3
cat /sys/class/infiniband/mlx5_0/ports/1/counters/port_xmit_data
cat /sys/class/infiniband/mlx5_0/ports/1/counters/port_rcv_errors
ethtool -S eth0 | grep -E 'rx_discards|cqe'
Счётчики ошибок приёма и отброшенных пакетов первые свидетели проблем с Physical layer или потоком, а рост счётчика переполнений очередей завершений прямо указывает на недомеренный CQ. Свежие контроллеры добавляют сжатие записей CQE, когда несколько завершений пакуются в одну запись, и это видно в выводе ethtool по счётчикам сжатия: снижаются и прерывания, и число опросов, а приложение вычитывает пачку за один вызов. Для глубокой проверки связей годится ibdiagnet, который прогоняет широкую батарею тестов фабрики, а perfquery с ключом -R обнуляет счётчики порта перед контролируемым замером. Журнал ядра дополняет картину: сообщения драйверов mlx5 и ib_core о сбросах портов и ошибках команд появляются в dmesg раньше, чем приложение успевает заметить пропажу трафика.
Практический порядок надёжного цикла событий
Собрать всё в устойчивую конструкцию помогает фиксированный порядок действий:
- выделить обработку завершений в отдельный поток или epoll-петлю, привязав её к NUMA-узлу очередей;
- для горячих очередей применить опрос с бюджетом, для фоновых уведомления с умерением cq_count и cq_period;
- завести единый обработчик статусов ibv_wc с журналированием, чтобы каждая ошибка оставляла след с wr_id;
- создать поток чтения асинхронных событий устройства с реакцией на QP_FATAL и PORT_ERR как на повод переподнять соединение;
- на RoCE отслеживать IBV_EVENT_GID_CHANGE и пересоздавать адресные записи при смене GID;
- следить за размером CQ относительно работ в полёте и за счётчиками переполнений в sysfs, растущие числа лечить увеличением очереди до появления аварий.
Итог у асинхронной стороны RDMA простой: событий немного, но каждое из них меняет состояние приложения, и ни одно нельзя молча съесть. Правильно собранный цикл событий превращает RDMA в предсказуемый транспорт с микросекундными задержками, а небрежный оставляет приложение в неведении ровно до момента, когда пользователь сам заметит, что данные перестали ходить.