Прод. Сервис на 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.
Оглавление
Production tuning checklist: 12 настроек, которые реально влияют
Кто реально гоняет io_uring в проде: Cloudflare, ScyllaDB, Netflix
Холивар: почему tokio до сих пор не на io_uring и когда это изменится
Откуда взялся хайп и почему он наполовину врёт
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. Это и есть ключевой механизм экономии.
Жизненный цикл одной операции:
Приложение берёт свободный SQE из SQE array, заполняет: тип операции (READ, WRITE, ACCEPT, CONNECT, RECV, SEND, FSYNC, OPENAT и т.д.), параметры.
Записывает индекс SQE в SQ ring tail.
Опционально:
io_uring_enter(SUBMIT)- syscall, чтобы разбудить ядро. Если включён SQPOLL - не нужен, kernel thread сам опросит SQ.Ядро выполняет операцию (синхронно если можно быстро, асинхронно через kernel workers иначе).
Результат пишется в CQ ring как CQE: user_data + res + flags.
Приложение читает 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 *)&addr, sizeof(addr)); listen(listen_fd, 1024); struct conn *acc = calloc(1, sizeof(*acc)); acc->fd = listen_fd; acc->op = OP_ACCEPT; struct io_uring_sqe *sqe = io_uring_get_sqe(&ring); io_uring_prep_multishot_accept(sqe, listen_fd, NULL, NULL, 0); io_uring_sqe_set_data(sqe, acc); io_uring_submit(&ring); struct io_uring_cqe *cqe; while (1) { io_uring_wait_cqe(&ring, &cqe); struct conn *c = io_uring_cqe_get_data(cqe); if (c->op == OP_ACCEPT && cqe->res >= 0) { struct conn *nc = calloc(1, sizeof(*nc)); nc->fd = cqe->res; nc->op = OP_READ; sqe = io_uring_get_sqe(&ring); io_uring_prep_recv(sqe, nc->fd, nc->buf, BUF_SZ, 0); io_uring_sqe_set_data(sqe, nc); } else if (c->op == OP_READ && cqe->res > 0) { c->len = cqe->res; c->op = OP_WRITE; sqe = io_uring_get_sqe(&ring); io_uring_prep_send(sqe, c->fd, c->buf, c->len, 0); io_uring_sqe_set_data(sqe, c); } else if (c->op == OP_WRITE && cqe->res >= 0) { c->op = OP_READ; sqe = io_uring_get_sqe(&ring); io_uring_prep_recv(sqe, c->fd, c->buf, BUF_SZ, 0); io_uring_sqe_set_data(sqe, c); } else { close(c->fd); free(c); } io_uring_cqe_seen(&ring, cqe); io_uring_submit(&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_interview - люблю Rust, пишу про кодинг с ИИ и без, заходите.
Спасибо за внимание. Если по статье есть вопрос, или что-то описал не так, или у вас был свой кейс с io_uring (особенно где он обманул ожидания) - расскажите в комментариях, разберём.
Полезные ссылки
Efficient IO with io_uring (Jens Axboe) - первоисточник, must-read.
Lord of the io_uring - подробный туториал на liburing.
axboe/liburing - референсная C-библиотека.
DataDog/glommio - thread-per-core Rust executor, лучший пример идиоматичного использования.
bytedance/monoio - альтернатива glommio, активнее развивается.
Nick Black: io_uring - неформальный, но точный разбор.
ScyllaDB blog: io_uring deep dive - реальный опыт production storage engine.
Cloudflare Pingora блог - архитектура и io_uring
ScyllaDB Seastar seastar.io - share-nothing на io_uring
TigerBeetle tigerbeetle.com - финансовая БД на Zig + io_uring