Прод. Сервис на 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 R |
ust t |
hread-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 (особенно где он обманул ожидания) - расскажите в комментариях, разберём.
Полезные ссылки
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
Комментарии (5)

Apoheliy
27.05.2026 21:45В своё время Windows пыталась заигрывать с передачей данных прямо в user-mod (работа со звуком: пользователь выделяет память от винды (не куча) с правильными флагами и далее винда позволяет драйверам работать с этой памятью напрямую). Всё это вполне работало, редко-редко синий экран мог вылететь :)
НО: Как только начали минимально думать про безопасность - всё это быстренько свернули.
Складывается впечатление, что "скорость работы" и "удобство работы" (usermod и т.д.) - они в противоположных сторонах. Возможно, там, где нужна скорость - всё уйдёт в kernelmod? Какие-нибудь аналоги eBPF или полноценные модули ядра?

arteast
27.05.2026 21:45Раньше уходило в kernel mode (или вообще exoOS, где приложение было основной частью ядра). Сейчас наверное больше в сторону вообще отказа от kernel - типа-userspace, но максимально обособленно от ядра (DPDK, SPDK, залоченная память, прибитые к kernel-изолированным CPU ядрам потоки и вот это все)

vibecodingai Автор
27.05.2026 21:45Granulex, всё верно, спасибо за уточнение. В статье я сжал этот пункт, а зря - тема заслуживает развёрнуто.
Соглашусь по всем тезисам. Главная боль действительно не в перформансе, а в архитектуре доверия: shared ring между userspace и ядром стирает классическую syscall-границу, на которой раньше держалась львиная доля seccomp/SELinux-политик. Любая seccomp-фильтрация по номеру сискола после io_uring_enter теряет смысл - реальную операцию ядро уже взяло из SQE и проводит сама, минуя обычный путь.
Из конкретики, которая подкрепляет тезис: в Android начиная с 14 io_uring запрещён через seccomp по умолчанию для непривилегированных приложений; в ChromeOS он отключён в sandboxed renderer-процессах с 2023, после серии CVE; Project Zero в 2023 разбирали, как из найденных kernel exploit chains большинство пошло именно через io_uring - просто потому, что attack surface для непривилегированного процесса разом вырос на сотни новых kernel paths. В RHEL 9 он тоже отключён под дефолтной SELinux-политикой для сервисов, которым явно не разрешён.
То есть формулировка ровно та, к которой пришла индустрия: io_uring - инструмент привилегированных процессов в trusted-окружении. На мультитенантных хостах с непроверенным кодом его надо явно вырезать.
Если интересно, могу разобрать в отдельной заметке, как именно ломается seccomp-модель на io_uring - там есть несколько неочевидных моментов с регистрацией fd через IORING_OP_OPENAT2 в обход политик файловой системы.

SIISII
27.05.2026 21:45тобы понимать, что io_uring - не новое явление, а догоняющий ход в долгой эволюции, полезно посмотреть на соседей. Идея completion-based async I/O старше readiness-based лет на двадцать.
Не на двадцать, а очень-очень-очень намного больше. В частности, асинхронный ввод-вывод с уведомлением приложения о завершении операции -- основа ввода-вывода в OS/360, а её разработка началась ещё в первой половине 1960-х одновременно с разработкой самих машин -- первых мэйнфреймов Системы 360; первая, жутко обрезанная версия стала доступна пользователям в 1966-м. Правда, в прикладных программах обычно пользовались синхронным вводом-выводом, поскольку это проще для программиста, но асинхронщина была всегда доступной и использовалась, когда это было нужно.
Асинхронный ввод-вывод -- основа и "матери" Винды (VAX/VMS), и её "бабки" (RSX-11M), а это -- 1970-е годы.
Granulex
Google отключил io_uring не из-за производительности – из-за attack surface. В 2021–2023 годах серия CVE показала: shared memory ring между user-space и ядром – это плоскость атаки, которой у epoll просто нет. Когда untrusted код в sandbox делает io_uring-операцию, он получает прямой контакт с кольцом ядра, а не проходит через strace-видимые syscall-границы. Именно поэтому io_uring отключён в Android, ChromeOS и в дефолтных политиках многих SELinux-профилей – не потому что медленный.