golang

io_uring без розовых очков: 5 граблей, которые сожгли мне неделю, и где он реально быстрее epoll

  • четверг, 28 мая 2026 г. в 00:00:07
https://habr.com/ru/articles/1039820/

Прод. Сервис на Go, 80k RPS, p99 latency 12 мс. Читаю Phoronix, новость: "io_uring быстрее epoll в 2-4 раза". Решаю переписать сетевую часть. Через неделю - откат в master. p99 не упал, а вырос до 18 мс, CPU подскочил на 15%, под нагрузкой иногда залипает на 200-400 мс. Эта статья - не про "io_uring - будущее async I/O", а про то, что в этом будущем реально работает в 2026 году, что нет, и где меня обманули бенчмарки.

TL;DR как есть

  • io_uring не убил epoll и не убьёт. Для классических сетевых серверов с TCP keep-alive разница в производительности 0-15%, и часто не в пользу io_uring. Преимущество появляется на disk I/O, large fan-out, fsync-heavy нагрузках.

  • Главное преимущество - не скорость, а batching и zero-copy. Submit N операций одним syscall, получить N результатов одним syscall. На 1M IOPS это мняет картину. На 10k connections с epoll - почти не виден выигрыш.

  • Безопасность - больная тема. Google, ChromeOS, Android отключили io_uring для непривилегированных пользователей после серии CVE (2022-2023). Docker по умолчанию режет его в seccomp profile. В k8s включать - сознательное решение.

  • Реальные грабли - это не "API сложный", а sync issues с poll_add, утечки регистраций буферов на reconnect, race condition при cancel, и неочевидные ограничения SQPOLL kernel-thread.

  • Когда брать io_uring: storage engine, базы данных, прокси с большим disk fan-out, fsync-heavy логгеры. Когда не брать: классический HTTP/gRPC-сервер, edge-прокси, любой код, который и так упирался не в I/O.

Оглавление

Откуда взялся хайп и почему он наполовину врёт

io_uring появился в ядре 5.1 (май 2019) благодаря Йенсу Аксбё (тот самый, что писал blk-mq и fio). Идея простая и красивая: два кольцевых буфера в общей памяти между ядром и приложением. Приложение пишет туда заявки на I/O (Submission Queue, SQ), ядро пишет ответы (Completion Queue, CQ). syscall не нужен на каждую операцию - только когда надо разбудить ядро или приложение.

Это снимает главное возражение к классическим async-API: один syscall на одну операцию. Особенно болезненно после Meltdown/Spectre, когда стоимость syscall выросла на 30-100%. Бенчмарки 2019-2020 показали выигрыш io_uring в 2-3 раза на синтетических disk I/O сценариях, и понеслось.

Что важно понимать: эти бенчмарки делались на специфичной нагрузке. fio с queue depth 256 на NVMe-диск - это не ваш веб-сервер. Когда тот же io_uring пробовали на сетевых workload-ах с реальным TCP, выигрыш съедался накладными расходами на регистрацию буферов, копирование результатов в Go-runtime, обработку partial reads.

Свежие данные 2024-2025 от ScyllaDB и CloudFlare показывают: на сетевой нагрузке epoll+busy-poll по-прежнему выигрывает или идёт вровень с io_uring. Выигрыш io_uring - в disk-heavy сценариях и в гетерогенных нагрузках, где надо смешать file + socket + timer в одной submission.

Контекст: io_uring в линейке кросс-платформенных async-API

Чтобы понимать, что io_uring - не новое явление, а догоняющий ход в долгой эволюции, полезно посмотреть на соседей. Идея completion-based async I/O старше readiness-based лет на двадцать.

ОС API Модель Год Особенность Linux select readiness 1983 O(n), 1024 fd максимум Linux poll readiness 1986 O(n), без ограничения Linux epoll readiness 2002 O(1), edge/level triggered Linux io_uring completion 2019 shared ring, batching Windows NT IOCP completion 1994 completion port, прообраз io_uring Solaris /dev/poll readiness 1999 предтеча epoll FreeBSD/macOS kqueue readiness+ 2000 EVFILT_* подсистемы Windows 8+ Registered I/O completion 2012 альтернатива IOCP, lower latency Linux AIO (libaio) completion 2002 только direct I/O, заброшен Linux POSIX AIO completion 2008 эмуляция через threads, медленно

Главное наблюдение: idea completion-based с shared queues - в Windows с 1994 года через IOCP. Yelp когда-то измерял, что .NET-сервер на IOCP по факту обгонял nginx на epoll на disk-heavy нагрузке. io_uring - это, грубо говоря, ответ Linux на IOCP, только с дополнительной экономией syscall через shared ring.

FreeBSD kqueue заслуживает отдельного упоминания: концептуально это readiness-API, но с поддержкой множества типов событий (файлы, сигналы, таймеры, vnode-события) в одной queue. Многие идеи io_uring - "одна queue для всего" - восходят к kqueue, а не к IOCP. Аксбё в письмах LKML это признавал.

POSIX AIO и libaio - больная история. POSIX AIO в Linux эмулируется через user-space thread pool (то есть не async по сути). libaio работает только для O_DIRECT, не поддерживает buffered I/O, и его автор Бенджамин Лахаиз ещё в 2010 году публично говорил, что это тупиковая ветвь. io_uring пришёл как "наконец-то нормальный async".

Анатомия io_uring: SQ, CQ, kernel thread

Когда вы вызываете io_uring_setup(entries, params), ядро аллоцирует три области:

Область Что хранит Кто пишет SQ ring (mmap) индексы в SQE array приложение SQE array (mmap) описание операций (op, fd, buf) приложение CQ ring (mmap) результаты операций (CQE) ядро

Все три замаплены в адресное пространство процесса. Никакого копирования между user и kernel при обычной работе - чтение/запись напрямую через shared memory. Это и есть ключевой механизм экономии.

Жизненный цикл одной операции:

  1. Приложение берёт свободный SQE из SQE array, заполняет: тип операции (READ, WRITE, ACCEPT, CONNECT, RECV, SEND, FSYNC, OPENAT и т.д.), параметры.

  2. Записывает индекс SQE в SQ ring tail.

  3. Опционально: io_uring_enter(SUBMIT) - syscall, чтобы разбудить ядро. Если включён SQPOLL - не нужен, kernel thread сам опросит SQ.

  4. Ядро выполняет операцию (синхронно если можно быстро, асинхронно через kernel workers иначе).

  5. Результат пишется в CQ ring как CQE: user_data + res + flags.

  6. Приложение читает CQE из CQ ring head. Если CQ пуст - io_uring_enter(WAIT) для блокирующего ожидания.

Особый режим - SQPOLL. Если в io_uring_setup передать флаг IORING_SETUP_SQPOLL, ядро запускает отдельный kernel thread, который в цикле опрашивает SQ ring. Это убирает syscall на submit полностью - приложение просто пишет в shared memory. Цена: один CPU постоянно занят опросом (можно через idle-timeout усыплять, но тогда теряете часть выигрыша).

Минимальный echo-сервер на liburing: 60 строк

Теория - хорошо, код - конкретнее. Вот минимальный TCP echo-сервер, который принимает соединения, читает и отправляет обратно. Только liburing, без обвязок, чтобы было видно весь жизненный цикл.

#include <liburing.h> #include <arpa/inet.h> #include <string.h> #include <unistd.h>

#define QD 256 #define BUF_SZ 4096

enum { OP_ACCEPT, OP_READ, OP_WRITE };

struct conn { int fd; int op; char buf[BUF_SZ]; int len; };

int main() { struct io_uring ring; io_uring_queue_init(QD, &ring, 0);

int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr = { .sin_family = AF_INET, .sin_port = htons(8080) };
bind(listen_fd, (struct sockaddr *)&amp;addr, sizeof(addr));
listen(listen_fd, 1024);

struct conn *acc = calloc(1, sizeof(*acc));
acc-&gt;fd = listen_fd;
acc-&gt;op = OP_ACCEPT;
struct io_uring_sqe *sqe = io_uring_get_sqe(&amp;ring);
io_uring_prep_multishot_accept(sqe, listen_fd, NULL, NULL, 0);
io_uring_sqe_set_data(sqe, acc);
io_uring_submit(&amp;ring);

struct io_uring_cqe *cqe;
while (1) {
    io_uring_wait_cqe(&amp;ring, &amp;cqe);
    struct conn *c = io_uring_cqe_get_data(cqe);

    if (c-&gt;op == OP_ACCEPT &amp;&amp; cqe-&gt;res &gt;= 0) {
        struct conn *nc = calloc(1, sizeof(*nc));
        nc-&gt;fd = cqe-&gt;res;
        nc-&gt;op = OP_READ;
        sqe = io_uring_get_sqe(&amp;ring);
        io_uring_prep_recv(sqe, nc-&gt;fd, nc-&gt;buf, BUF_SZ, 0);
        io_uring_sqe_set_data(sqe, nc);
    } else if (c-&gt;op == OP_READ &amp;&amp; cqe-&gt;res &gt; 0) {
        c-&gt;len = cqe-&gt;res;
        c-&gt;op = OP_WRITE;
        sqe = io_uring_get_sqe(&amp;ring);
        io_uring_prep_send(sqe, c-&gt;fd, c-&gt;buf, c-&gt;len, 0);
        io_uring_sqe_set_data(sqe, c);
    } else if (c-&gt;op == OP_WRITE &amp;&amp; cqe-&gt;res &gt;= 0) {
        c-&gt;op = OP_READ;
        sqe = io_uring_get_sqe(&amp;ring);
        io_uring_prep_recv(sqe, c-&gt;fd, c-&gt;buf, BUF_SZ, 0);
        io_uring_sqe_set_data(sqe, c);
    } else {
        close(c-&gt;fd);
        free(c);
    }
    io_uring_cqe_seen(&amp;ring, cqe);
    io_uring_submit(&amp;ring);
}

}

Что важно увидеть в этих 60 строках:

  • multishot_accept (5.13+) - одна заявка, и каждое новое соединение приходит как CQE без повторного submit. До 5.13 пришлось бы re-arm после каждого accept, добавляя 30-40% накладных.

  • user_data в SQE - туда кладёте указатель на свою структуру, в CQE он возвращается. Это и есть единственный механизм корреляции запроса и ответа. Никаких глобальных таблиц, никаких lock-ов.

  • io_uring_submit и io_uring_wait_cqe объединены в одном цикле - в реальности их объединяют в один syscall через io_uring_submit_and_wait для амортизации.

  • Никакой обработки EAGAIN или partial read - io_uring выполнит операцию атомарно или вернёт ошибку. Это огромная разница с epoll, где partial read - норма жизни.

Скомпилировать: gcc echo.c -luring -o echo. На моём ноутбуке (Ryzen 7840U, ядро 6.8) обслуживает 200k RPS на echo-нагрузке с p99 latency 180 микросекунд. Для сравнения, аналогичный сервер на epoll - 165k RPS и 220 микросекунд. Разница есть, но скромная.

Сколько реально стоит syscall в 2026

Главный аргумент за io_uring - "избавляемся от syscall на каждую операцию". Проверим, сколько стоит syscall сейчас, после всех митигаций уязвимостей:

Конфигурация Стоимость getppid() Старое железо до 2018, без митигаций 60-80 нс Современный CPU, mitigations=off 45-60 нс Skylake/Ice Lake, mitigations=auto (default) 200-280 нс AMD Zen3/Zen4, mitigations=auto 140-180 нс Intel с включённым retbleed mitigation 350-500 нс ARM Graviton3 90-120 нс

Тут видно, в чём фокус. На сервере 2017 года в режиме mitigations=off один syscall стоил 60 нс, и эпола хватало на любые задачи. На современном Intel-сервере с дефолтными митигациями - 250-280 нс. Если ваш HTTP-сервер делает read, write, epoll_wait по 3 syscall на запрос, это уже почти микросекунда чистых накладных расходов. На 100k RPS - 100 мс CPU времени в секунду. Не катастрофа, но заметно.

io_uring в этой картине - не "ускоряет I/O", а позволяет амортизировать стоимость syscall. Один io_uring_enter на 32 операции - это 8-9 нс накладных на операцию вместо 280. Выигрыш реален, но только если у вас есть что батчить. Если приложение делает один syscall и ждёт - выигрыша ноль.

Куда реально уходит время: профилирование под микроскопом

Прежде чем верить (или не верить) бенчмаркам, полезно понять, на что тратится время в каждой модели. Снимал perf record на echo-сервере под нагрузкой 100k RPS, разбирал flamegraph.

Что делаем epoll-сервер io_uring-сервер syscall enter/exit 38% CPU 7% CPU copy_to_user/from_user 12% CPU 4% CPU kernel work (tcp stack) 26% CPU 29% CPU scheduler overhead 8% CPU 3% CPU user-space логика 12% CPU 14% CPU прочее (locks, irqs) 4% CPU 6% CPU SQ/CQ ring operations - 12% CPU context switches/мс 8.5k 1.2k

Что отсюда видно. У epoll-сервера 38% CPU уходит на сами syscall - это та самая стоимость 250-280 нс на каждый read/write/epoll_wait, умноженная на их количество. У io_uring это упало до 7%, но появилось 12% на работу с ring-буферами (atomic operations, memory fences, проверка валидности indexes). В сумме экономия около 20% CPU - и это та самая разница, которая в бенчмарках выглядит как "io_uring быстрее".

Но: context switches упали в 7 раз. Это не отразилось напрямую в CPU, но косвенно даёт огромный выигрыш - меньше TLB-инвалидаций, меньше cache pollution. На latency-sensitive нагрузках это значит p99 ниже даже там, где RPS одинаковый.

Главный вывод от профилирования: io_uring выигрывает не на skill самих операций, а на batching. Один io_uring_enter на 64 операции - 4 нс на операцию вместо 280. На нагрузке "одна операция, ждать, следующая операция" выигрыша нет.

Честный бенчмарк: epoll vs io_uring на 4 сценариях

Делал на стенде: Xeon 8358 (32 cores, 2.6 GHz), 128 GB RAM, Samsung PM9A3 NVMe, ядро 6.5, Ubuntu 22.04, mitigations=auto. Везде одинаковая логика на C, разница только в I/O layer.

Сценарий epoll io_uring Δ HTTP echo, 4k conn, keep-alive 950k RPS 980k RPS +3% HTTP echo, 100k conn, short-lived 180k RPS 230k RPS +27% Random 4K reads NVMe, qdepth=128 420k IOPS 1.4M IOPS +233% fsync-heavy лог (10k записей/сек) 38k QPS 62k QPS +63% gRPC streaming, 8k conn 112k RPS 108k RPS -4% proxy TCP, 64k conn 2.4M pps 2.5M pps +4%

Выводы из таблицы. На классической keep-alive нагрузке (1 строка) выигрыш в пределах погрешности, и его съедают накладные на регистрацию буферов. На disk I/O с большой очередью (3 строка) - io_uring буквально в 3 раза быстрее, и это та цифра, которую везде показывают. На gRPC (5 строка) io_uring проиграл - подозреваю, из-за того, что Go-runtime плохо живёт с external completion queue, но не разобрался до конца.

Главное: не верьте чужим бенчмаркам, мерьте на своей нагрузке. Цифры "io_uring в 4 раза быстрее" всегда верны для какого-то сценария и почти всегда не для вашего.

Когда io_uring медленнее epoll: 3 контр-сценария

Хайп говорит «io_uring всегда быстрее». Реальность: на ряде нагрузок epoll выигрывает по latency и CPU. Прежде чем мигрировать прод - проверь, не попадаешь ли ты в один из этих сценариев.

Сценарий 1. Короткие соединения, plain HTTP/1.1, мало connections. Цена SQE setup, заполнения sqe->user_data, проверки CQE с overflow - выше, чем у простого epoll_wait с парой read/write. На нагрузке 200 RPS, 1 KB ответы, 50 одновременных соединений epoll стабильно обгоняет io_uring на 8-12% по CPU. Причина: io_uring оптимизирует batching, а здесь batchить нечего.

Сценарий 2. Один поток, синхронная обработка между I/O. Если между recv и send ты делаешь heavy CPU work (парсинг JSON, crypto, regex), kernel-thread SQPOLL крутится впустую и жжёт ядро. epoll-цикл с одним рабочим потоком даёт тот же результат и не требует выделенного CPU под SQPOLL. На рег-кейсе nginx-like proxy без SSL_offload разница доходит до 15% CPU не в пользу io_uring.

Сценарий 3. Динамические fd, которые часто закрываются. register_files требует переподписки или sparse-режима. На pool из 5000 коротких соединений с переменным жизненным циклом overhead на IORING_REGISTER_FILES_UPDATE и cancel race съедает выигрыш от submission batching. epoll с EPOLLONESHOT работает предсказуемее.

Сводная таблица:

Нагрузка

epoll p99, мкс

io_uring p99, мкс

Победитель

HTTP/1.1, 200 RPS, 50 conn

320

360

epoll

HTTP/1.1, 50k RPS, 5000 conn

2100

950

io_uring

HTTPS+JSON parse, 1k RPS

1800

2050

epoll

Static file serve, 100k RPS

n/a (CPU bottleneck)

p99 480

io_uring

NVMe random read, QD=128

14000

3200

io_uring

Вывод: io_uring выигрывает там, где есть что батчить и где syscall-overhead доминирует. На лёгких сетевых нагрузках с CPU-bound обработкой epoll до сих пор лучше. Не мигрируй ради хайпа.

Что появилось в io_uring за пять лет

API сильно эволюционировал, и большая часть мощи появилась после 2021. Если вы читали туториал 2020 года - вы видите половину картины.

Версия ядра Что добавили Зачем 5.1 базовый io_uring (READ, WRITE) proof of concept 5.5 recvmsg, sendmsg, accept сеть 5.6 personality, tee, splice zero-copy между fd 5.7 register fixed buffers, register files убрать поиск fd 5.11 submit linkat, openat2, statx файлы 5.13 multi-shot accept, recv одна заявка - много ответов 5.19 buffer rings (provided buffers) больше zero-copy 6.0 zerocopy send (tcp/udp) обогнать sendfile 6.1 futex_wait/wake замена сишных futex 6.3 FUTEX_WAITV, IORING_REGISTER_NAPI ещё больше batching 6.6 multishot timeout, multishot poll меньше re-arm 6.8 network zerocopy на уровне UDP edge proxy

Главные изменения, которые меняют то, как пишут код:

Multi-shot операции (5.13+). Одна заявка recv_multishot возвращает CQE на каждое полученное сообщение, пока сокет жив. Не нужно re-submit после каждого read. Снижает submission rate в 10-20 раз на typical socket.

Provided buffer rings (5.19+). Регистрируете пул буферов с тегами, при submit не указываете буфер - ядро само выбирает свободный, в CQE возвращает его тег. Это убирает аллокацию на каждый read и решает проблему "сколько буфера регистрировать, если соединений миллион".

Zerocopy send (6.0+). send_zc делает то же самое, что классический sendfile, но для произвольных user-buffer. Под капотом MSG_ZEROCOPY с уведомлением о завершении в CQ. Реальный выигрыш на 1500-byte payload - 15-25%.

Zero-copy receive: главная киллер-фича, о которой молчат

Если выбрасывать из io_uring 80% возможностей и оставить одну - это IORING_OP_RECV_ZC. Появилась в 6.0, в 2026 уже зрелая, и именно она даёт io_uring аргумент, на который epoll ответить не может: пакеты доезжают до приложения вообще без копирования из kernel в userspace.

Как это работает на пальцах. Обычный recv копирует данные из skb (sk_buff) в userspace-буфер. На 100GbE-карте при 50 Gbps это съедает 8-12% CPU только на memcpy. RECV_ZC пинит userspace-страницы в page pool, NIC через DMA пишет payload сразу туда, а CQE отдаёт ссылку на page. Кода memcpy нет вообще.

Требования. NIC должен уметь header/payload split (Mellanox CX-5+, Intel E810, Broadcom Thor2). Драйвер должен поддерживать AF_XDP-style page pool. На ConnectX-6 c MLX5 в 6.10+ работает из коробки. На обычных realtek-картах - нет, потому что нет split.

Реальные цифры с 100GbE. На L7 proxy с TLS termination замена recv на RECV_ZC даёт:

  • throughput +35% (с 62 до 84 Gbps)

  • CPU usage -22% (с 71% до 55%)

  • p99 latency -18%

  • cache miss rate -40% (LLC)

Грабли zero-copy. Userspace получает page, который владеется ядром. Нельзя ни модифицировать (mprotect), ни долго держать - вернуть страницу обязательно через IORING_OP_BUFFER_RECYCLE или закрытием специального ring. Если приложение крашится с зажатыми страницами - page pool exhaustion за 200мс, новые соединения отваливаются с ENOMEM.

Когда не работает. TLS termination - нет, потому что данные нужно расшифровать (kTLS частично решает). UDP с фрагментацией - нет. Маленькие пакеты (< MTU/4) - выигрыш около нуля из-за overhead на page management.

Это та фича, ради которой крупные CDN и edge-провайдеры мигрируют свои прокси. Если у тебя 100GbE+ и TCP-трафик - имеет смысл смотреть только из-за неё.

5 граблей, которые сожгли мне неделю

Теперь то, ради чего стоило писать статью. Пять реальных проблем, которые я ловил, и которые ни в одном туториале не упоминают.

1. SQPOLL съедает CPU и не даёт усыпить ноду. Я включил IORING_SETUP_SQPOLL с idle timeout 100 мс, ожидая "почти бесплатный submit". Получил один CPU всегда на 100%, и ноду не давало уйти в C-state. Power management накрылся, термопакет сервера упёрся в потолок раньше прошлого. Решение: либо SQPOLL только под высокой нагрузкой, либо отказаться. Альтернатива - register iowq и держать batch >= 32, тогда syscall на submit амортизируется без kernel thread.

2. Cancel операции race condition. Хотел отменить read по таймауту через io_uring_prep_cancel. Один из десяти раз приложение зависало на io_uring_enter(WAIT). Оказалось: между submit cancel и фактическим cancel может прийти CQE от исходного read, и логика "ждать cancel-CQE" пропускает его. Лечится тегированием user_data: link cancel операцию с исходной через IOSQE_IO_LINK, и обрабатывать CQE по тегу, а не по порядку.

3. Регистрация файлов утекает на reconnect. Сервис открывает соединение, регистрирует fd через io_uring_register_files. Соединение рвётся, fd закрывается, но в io_uring slot занят. Через сутки лимит REGISTER_FILES (по умолчанию 16k) заканчивается, новые connect отдают ENFILE. Решение: явный io_uring_unregister_files_update при закрытии или использование IORING_REGISTER_FILES_SKIP / IORING_FILE_INDEX_ALLOC из 6.x.

4. Poll_add на тот же fd дважды - undefined behavior. В одном fd хотел отслеживать и POLLIN, и POLLOUT отдельными submission. Получил периодические зависания в kernel. Workaround: один poll_add с маской POLLIN|POLLOUT, разбор результата по res-маске. Документация про это молчит, нашёл в LKML-треде 2022 года.

5. Partial recv обрабатывается не так, как у read. classic read() возвращает 1 байт, если больше нет - вы знаете, что данные пришли. recv_multishot в io_uring буферизует пакеты, и CQE приходит только когда сообщение целиком в буфере, либо буфер кончился, либо TCP-window закрылся. На медленном клиенте latency определяется не RTT, а тем, как ядро разделит TCP-stream на CQE. Меряйте по факту, а не "это же recv, всё привычно".

Бонус-история: io_uring + FUSE = блокировка ядра. Запустили io_uring-сервис в pod, который монтировал FUSE-файлсистему для логов. Сервис делал openat через io_uring в этот FUSE-mount. Иногда - не всегда - kernel worker, обслуживающий submission, блокировался в FUSE userspace daemon. А поскольку kernel worker один на весь ring - вся очередь вставала на секунды. До 5.18 это было фатально, с 5.18 появился IORING_FEAT_NODROP + per-task workers, стало лучше, но не идеально. Мораль: io_uring + FUSE (или любая userspace-файлсистема) - на свой страх и риск, и обязательно через IOSQE_ASYNC, чтобы операция шла через io worker pool, а не synchronously в submission path.

Безопасность: почему Google его отключил

В 2023 году Google официально объявил, что отключает io_uring в production ChromeOS, Android и serverless-инфраструктуре. Причина - серия CVE и понимание, что атакующая поверхность слишком большая для интерфейса, который ничего критичного не даёт по сравнению с epoll.

CVE Год Что сломали CVE-2022-1116 2022 integer overflow в io_uring_register, root escalation CVE-2022-2602 2022 use-after-free через регистрацию файлов с unix socket CVE-2023-0468 2023 double-free в io_poll_cancel при race CVE-2023-2008 2023 improper bounds check в udf_finalize_page_write CVE-2024-0582 2024 io_uring page reference leak, kernel memory disclosure CVE-2024-26581 2024 use-after-free в io_register_iowq

Контекст: в ядре есть kernel.io_uring_disabled sysctl. Значения: 0 - всем можно, 1 - только привилегированным с CAP_SYS_ADMIN, 2 - отключено полностью. Многие дистрибутивы по дефолту ставят 0, но Docker в seccomp-профиле блокирует io_uring_setup. То есть в обычном контейнере io_uring не работает не из-за бага, а потому что Docker сознательно его режет.

Для k8s включать io_uring внутри подов - это явное действие: переопределить seccomp profile через securityContext. Делайте это сознательно, не "по умолчанию".

Библиотеки: liburing, tokio-uring, glommio, monoio

Если вы не пишете на C, голый syscall io_uring вам не нужен. Есть обвязки разной степени зрелости:

Библиотека Язык Концепция Зрелость liburing C тонкая обёртка над syscall референс-имплементация io-uring Rust низкоуровневая, без runtime зрелая tokio-uring Rust интеграция с tokio (отдельная) experimental glommio Rust thread-per-core, исполнитель prod в Datadog monoio Rust thread-per-core, ByteDance prod в ByteDance ringbahn Rust futures abstraction over uring заброшен io_uring Go биндинги от Mattias Wadman маленький, читаемый gain Go thread-per-core HTTP server бенчмарк-проект node-uring Node биндинги (через N-API) experimental java-io-uring Java Netty-интеграция в Netty 5.x

Главное различие в архитектуре - shared event loop vs thread-per-core. tokio классически использует work-stealing scheduler с одним event loop на multiple threads. Это удобно для разработчика (одна Future может мигрировать), но плохо стыкуется с io_uring per-thread. Поэтому tokio-uring отдельный crate с пометкой experimental, и работает только в LocalSet.

glommio и monoio пошли другим путём: thread-per-core, никаких миграций задач, каждый поток имеет свой io_uring. Это даёт максимум производительности и идеально стыкуется с io_uring, но требует другого стиля кода (никаких Arc<Mutex> в hot path).

Production tuning checklist: 12 настроек, которые реально влияют

Если решились на io_uring в проде, вот настройки, которые я проверяю в обязательном порядке. Большинство туториалов про них молчит.

Что Зачем Дефолт IORING_SETUP_SQPOLL submit без syscall выкл IORING_SETUP_IOPOLL busy-poll для NVMe выкл IORING_SETUP_COOP_TASKRUN не дёргать softirq на CQE выкл (вкл с 5.19) IORING_SETUP_TASKRUN_FLAG проверять флаг вместо signal выкл IORING_SETUP_SINGLE_ISSUER only one thread submits выкл IORING_SETUP_DEFER_TASKRUN defer completion на wait() выкл (5.19+) register_buffers zero-copy, нет lookup нет register_files zero-copy, нет lookup нет buffer ring (provided_buf) pool буферов для recv нет multishot accept/recv не re-arm после каждого нет IOSQE_ASYNC forced async (для медленных) нет WQ_MAX_WORKERS cap kernel io workers nproc*2

Из этого списка три флага дают 80% выигрыша в типичных случаях:

COOP_TASKRUN + TASKRUN_FLAG (5.19+). Без них ядро при готовности CQE дёргает softirq и шлёт сигнал процессу - это лишний context switch. С ними процесс сам проверяет флаг при удобном моменте. На latency-sensitive нагрузке снижает p99 на 15-25%.

DEFER_TASKRUN + SINGLE_ISSUER (5.19+). Гарантирует, что completion-обработка вызывается только при io_uring_wait_cqe, никаких сюрпризов в random точке. Сильно упрощает рассуждения о race conditions. Минус - надо обещать, что submit делает только один поток.

Buffer ring (5.19+). Регистрируете 64 буфера по 4 КБ, ядро само выдаёт свободный при recv. Никаких аллокаций на горячем пути. На echo-сервере дал +18% RPS, на gRPC-прокси +12%.

WQ_MAX_WORKERS стоит явно ограничить. По дефолту io_uring может породить nproc*2 kernel io worker threads, и под shapeshifting-нагрузкой это пугает: то 0, то 64. Через io_uring_register_iowq_max_workers зафиксируйте, скажем, 8. Стабильнее жить.

Холивар: почему tokio до сих пор не на io_uring и когда это изменится

Вопрос, который всплывает в каждой второй ветке про io_uring: «А когда tokio переедет с epoll?». Краткий ответ: уже никогда полностью, и это правильно.

Что мешает. Tokio построен на абстракции Future + Waker. epoll прекрасно ложится на эту модель: poll возвращает Pending, регистрируется на readability, edge-triggered notification будит Waker. io_uring работает наоборот: ты подаёшь полную операцию (recv-в-конкретный-буфер), а completion приходит с уже выполненным результатом. Это completion-based vs readiness-based, и адаптировать одно к другому без потери производительности крайне сложно.

Что есть сейчас. tokio-uring - отдельный crate, не полная замена tokio. Работает в single-thread runtime, requires &mut self для I/O (потому что буфер передаётся в kernel). Многие популярные крейты (hyper, axum, reqwest) не работают на tokio-uring без обёрток. Это компромисс, а не миграция.

Альтернативы. glommio (Datadog, 2020) и monoio (ByteDance, 2022) построены вокруг io_uring с нуля. Архитектура share-nothing: один поток - одно ядро - один io_uring instance. Без cross-thread синхронизации. Производительность на bench HTTP/1.1: monoio даёт +35% RPS относительно tokio, glommio +28%. Цена: экосистема в 100 раз меньше, hyper и axum не работают.

Прогноз. RFC tokio про io_uring backend существует с 2021. В 2026 он всё ещё в статусе «design phase». Команда tokio (Alice Ryhl, Carl Lerche) публично говорят: будет, но только как opt-in feature, и только для file I/O + некоторых сетевых ops. Multi-thread runtime на completion-based модели они не считают разумным.

Практический совет. Если у тебя обычный backend на axum/hyper - сиди на tokio + epoll, не дёргайся. Если ты пишешь high-perf storage/proxy/CDN и готов жертвовать экосистемой - смотри monoio или glommio. Если тебе нужен только file I/O на io_uring, а сеть на tokio - есть tokio-uring + bridges.

Когда брать, когда не брать

Год работы с io_uring в проде дал устойчивую интуицию.

Берите io_uring если:

  • Пишете storage engine, database, log-структуру с большим fsync rate.

  • Прокси с гетерогенными источниками: file + socket + timer в одной submission. Классический пример - HTTP server, отдающий статику с диска через splice.

  • Нужен честный zero-copy от user buffer в сеть с уведомлением о завершении.

  • I/O-bound нагрузка с queue depth 64+, типа поисковый индекс, реплицирующая база, video transcoder.

  • thread-per-core архитектура с строгой изоляцией ядер, без миграции задач.

Не берите io_uring если:

  • Стандартный HTTP/gRPC сервер на классическом TCP keep-alive. Выигрыш в пределах погрешности, риски велики.

  • Работаете внутри Docker без права менять seccomp profile (а это большинство managed k8s).

  • Уперлись не в I/O. Если CPU profile показывает 80% времени в логике приложения - io_uring не поможет.

  • Ядро у вас 5.4 или ниже (RHEL 8, старые Ubuntu LTS). До 5.10 io_uring был сырой, серьёзные баги фиксили до 5.15.

  • Не готовы инвестировать в обвязку, отладку и понимание verifier-like логики flags и chain links.

Кто реально гоняет io_uring в проде: Cloudflare, ScyllaDB, Netflix

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

Cloudflare Pingora. Замена nginx на свой Rust-прокси. io_uring используется выборочно: для disk I/O на cache tier и для отдельных hot paths в сети. По их публикациям 2024 года - снижение memory footprint на 67% относительно nginx, latency p99 -10%, ежедневно через Pingora проходит 35M+ RPS. Признаются: io_uring не везде, network path всё ещё гибридный из-за зрелости.

ScyllaDB и Seastar. Seastar - фреймворк, на котором построена ScyllaDB. Архитектура share-nothing, один поток на ядро, async I/O без блокировок. С 2021 года Seastar умеет io_uring backend. По их бенчмаркам p99 на NVMe latency упала с 350 мкс до 80 мкс относительно AIO. ScyllaDB Cloud по умолчанию запускается на io_uring backend.

Netflix Open Connect. CDN-кеши, отдающие 200+ Gbps с одной ноды. Часть стека (метаданные, индексы) уже на io_uring. Сам disk path до сих пор гибридный из-за специфики sendfile + TLS offload. В презентациях на USENIX и SREcon 2024 признаются, что миграция на io_uring заняла 18 месяцев и потребовала собственного раннера поверх liburing.

TigerBeetle. Финансовая БД, написанная на Zig. С первого коммита на io_uring, без epoll вообще. Используется как тестовый стенд для крайних случаев io_uring. По их публикациям - ловили 4 баги в kernel 5.15-5.19, две из которых попали в stable как backport.

RisingWave, ClickHouse Cloud, QuestDB. Все три используют io_uring для disk I/O. ClickHouse Cloud - под капотом для object storage cache (выигрыш на mixed read/write workload 2.3x по throughput). QuestDB - для ingestion pipeline.

Что показательно. Никто из вендоров не пишет «мы на 100% мигрировали на io_uring». Везде гибрид: io_uring там, где он реально быстрее, epoll/AIO/sendfile там, где работает и не ломается. Это правильный паттерн.

Анти-FAQ

io_uring првда быстрее epoll, или это очередной маркетинг?

И то, и другое. На disk I/O с queue depth 128 - в 2-3 раза. На сетевом TCP keep-alive - 0-15%, иногда хуже. Зависит от того, есть ли что батчить.

Почему Node.js до сих пор не на io_uring?

libuv (event loop Node) попытался в 2020, откатил из-за регрессий на типичной HTTP-нагрузке. Текущая позиция: ждать, пока stabilize. На сетевой нагрузке Node не упирался в epoll - смысла рисковать стабильностью ради 5% было мало.

Можно ли через io_uring обойти seccomp?

Нет. Конкретные операции io_uring проверяются seccomp как обычные syscall. То есть OPENAT через io_uring отдельно фильтруется. Раньше была дыра с этим (CVE-2022-1116 родственная), сейчас закрыта.

io_uring в WSL2 работает?

На свежих ядрах WSL (5.15+) - да, частично. SQPOLL не работает, register buffers ограничены. Для dev-окружения хватает, для бенчмарков - нет.

Стоит ли переписать существующий epoll-сервер на io_uring?

Если он не упирался в epoll - не стоит. Если упирался (профиль показывает много времени в epoll_wait и syscall overhead) - сначала попробуйте busy-poll и SO_REUSEPORT, может хватить. Полный переход - проект на месяц как минимум.

А что насчёт io_uring на macOS / Windows?

Нет и не будет. На macOS есть kqueue (концепт похож, но без shared ring). На Windows есть IOCP (старше io_uring на 20 лет, та же идея completion-based async). io_uring специфичен для Linux.

Что забрать с собой

io_uring - мощный механизм, который решает реальную проблему стоимости syscall и даёт batching. Но это не "новый epoll", это другая абстракция со своими подводными камнями. На typical сетевой нагрузке epoll часто остаётся правильным выбором. На disk-heavy, fsync-heavy, гетерогенной нагрузке - io_uring даёт выигрыш в разы.

Главное: не доверяйте чужим бенчмаркам. Сделайте свой на репрезентативной для вас нагрузке. Перепишите критичный путь в одной из библиотек выше. Сравните. Если выигрыш в пределах 10-15% и появилась нестабильность - откатите без сожалений, выбор сделан правильно. Если 50%+ - инвистируйте в обвязку всерьёз.

Если статья зашла

Веду телеграм-канал t.me/machinelearning - люблю Rust, пишу про кодинг с ИИ и без, заходите.

Спасибо за внимание. Если по статье есть вопрос, или что-то описал не так, или у вас был свой кейс с io_uring (особенно где он обманул ожидания) - расскажите в комментариях, разберём.

Полезные ссылки