Асинхронный ввод/вывод (I/O) — один из краеугольных камней производительности современных серверов и систем. За последние двадцать лет Linux прошёл путь от select() и epoll() до совершенно новой модели — io_uring, способной сократить системные вызовы и достичь почти «zero-syscall» исполнения.

Эта статья объясняет:

  • как эволюционировали I/O-механизмы в Linux;

  • чем модель готовности отличается от модели завершения;

  • как устроен io_uring под капотом;

  • как на нём строить сетевые и дисковые подсистемы с минимальной латентностью;

  • какие подводные камни и ограничения есть в продакшене.


🧩 1. От select к io_uring: краткая эволюция

1.1 Классика: select, poll, epoll

Эти интерфейсы реализуют модель готовности (readiness model) — ядро сообщает, что дескриптор готов к чтению или записи, а само чтение/запись делает приложение.
epoll масштабируется по числу соединений, но каждое действие (accept, read, write) остаётся отдельным системным вызовом.
Для сетевых серверов это хорошо, но для дисков — не асинхронно: read() с SSD всё равно может блокировать.

1.2 POSIX AIO и libaio

POSIX-вызовы aio_read, aio_write — теоретически асинхронные, но в Linux glibc реализует их через пул потоков. Настоящий асинхронный доступ к блочным устройствам появился позже в виде Linux Native AIO (libaio), однако он ограничен: требует O_DIRECT, не работает с буферизованными файлами и не подходит для сетей.

1.3 Появление io_uring

В 2019 году (ядро 5.1) в Linux появился io_uring — новая модель ввода/вывода, основанная на очередях событий и модели завершения (completion model). Она решает ключевую проблему: минимизирует количество системных вызовов и избегает копирования данных между пространствами.


⚙️ 2. Как работает io_uring

io_uring строится вокруг двух кольцевых буферов, разделяемых между ядром и процессом:

  • Submission Queue (SQ) — куда приложение помещает запросы (read, write, accept, recvmsg и др.);

  • Completion Queue (CQ) — откуда оно получает результаты.

Механизм использует всего три системных вызова:

  1. io_uring_setup() — инициализация кольца;

  2. io_uring_register() — регистрация буферов, файлов, таймеров и т.д.;

  3. io_uring_enter() — передача пакета запросов в ядро (может быть batched).

2.1 Архитектура без лишних системных вызовов

Очереди мапятся (mmap) в память пользователя, поэтому запись запроса и чтение результата не требуют read()/write() — только атомарные записи в разделяемые структуры.
Это снимает лишние переключения контекста и ускоряет работу в десятки раз при высоких нагрузках.


🧠 3. Ключевые возможности и режимы

3.1 SQPOLL

Включает «polling thread» в ядре: он постоянно проверяет SQ на новые задачи.
Если поток активен, приложение вообще не делает системных вызовов — ядро само снимает SQE.
Настраивается через IORING_SETUP_SQPOLL и параметр sq_thread_idle (миллисекунды простоя до «сна»).
Полезно для сетевых серверов и высокочастотных операций.

3.2 IOPOLL

Режим активного ожидания завершения операций на уровне блочного устройства (например, NVMe).
Максимальная производительность, но нагрузка на CPU.
Используется с O_DIRECT-доступом и NVMe-устройствами.

3.3 Cooperative / Deferred taskrun

Позволяют избежать избыточных IPI-сигналов и переключений контекста между ядром и юзер-пространством.
Флаги IORING_SETUP_COOP_TASKRUN и IORING_SETUP_DEFER_TASKRUN снижают «шум» на CPU в системах с множеством потоков.


🔩 4. Механизмы повышения производительности

4.1 Registered и fixed-ресурсы

Можно заранее «прикрепить»:

  • Registered buffers — ядро знает, где физически лежат буферы, и не тратит время на их пинning/копирование;

  • Registered files (direct descriptors) — минует общую файловую таблицу, сокращая операции fget и блокировки.

Это уменьшает задержки и ускоряет I/O на десятки процентов.

4.2 Multi-shot операции

Позволяют одной операцией обслуживать несколько событий.
Например, accept_multishot или recv_multishot обрабатывают множество подключений/пакетов без повторной постановки запроса.
В CQE появится флаг IORING_CQE_F_MORE, если событие не последнее.

4.3 Linked операции

Флаг IOSQE_IO_LINK позволяет объединить операции в цепочку: например, read → process → write → timeout.
Если одно звено неуспешно, можно оборвать всю последовательность (IOSQE_IO_HARDLINK).
Это превращает io_uring в мини-планировщик I/O-тасков внутри ядра.


🌐 5. Сетевой I/O: от epoll к io_uring

Раньше типичный сервер выглядел так:

Раньше типичный сервер выглядел так:

epoll_wait()
  -> accept()
  -> recv()
  -> send()

Каждая операция — syscall. В io_uring всё иначе:

io_uring_prep_multishot_accept()
io_uring_prep_recv_multishot()
io_uring_prep_send_zc()

5.1 Преимущества:

  • Multi-shot accept/recv → меньше системных вызовов;

  • Provided buffers → ядро само выбирает буфер из заранее подготовленного пула;

  • Zero-copy send (send_zc) → данные передаются напрямую из пользовательского буфера в сетевой стек без копирования;

  • Poll как multi-shot → можно полностью заменить epoll.

Результат — десятки тысяч соединений при низкой латентности и минимальной нагрузке на ядро.


💾 6. Асинхронный доступ к дискам

Для дисковых операций io_uring работает с обоими типа��и файлов:

  • Буферизованные (RWF_NOWAIT + IOSQE_ASYNC) — если страница в кеше, операция немедленная;

  • Прямой доступ (O_DIRECT) — по-настоящему асинхронно.

6.1 Комбинации:

  • preadv2(..., RWF_NOWAIT) — не блокирует, если страница не в кеше;

  • io_uring_prep_splice() — передача данных между fd внутри ядра (например, pipe → socket);

  • io_uring_prep_fsync() — асинхронная синхронизация данных;

  • uring_cmd — прямой passthrough к NVMe и драйверам устройств.

6.2 Режим IOPOLL

Если устройство поддерживает polling-I/O (NVMe), можно полностью избавиться от IRQ и системных вызовов.
Однако CPU-затраты возрастут — используйте только на «горячих» путях.


🔬 7. Тюнинг и эксплуатация

8.1 Размеры колец

  • SQ и CQ задаются при инициализации (sq_entries, cq_entries).
    Закладывайте запас под пиковые bursts: например, 4096–8192.

8.2 SQPOLL

  • Контролируйте sq_thread_idle — не позволяйте polling-треду засыпать слишком быстро;

  • Следите за флагом SQ_NEED_WAKEUP: если активен — вызывайте io_uring_enter() с IORING_ENTER_SQ_WAKEUP.

8.3 Registered буферы

  • Для частых операций (recv/write) — используйте io_uring_register_buffers().
    Это сокращает overhead на сотни тысяч операций в секунду.

8.4 NUMA и CPU-Affinity

  • Один ring на ядро — лучший компромисс между параллелизмом и кэш-локализацией.

  • Для SQPOLL можно закрепить поток через IORING_REGISTER_IOWQ_AFF.


📎 Заключение

io_uring — один из самых амбициозных и успешных шагов Linux в сторону высокопроизводительного I/O.
Он объединяет преимущества epoll, AIO и libaio, убирая их слабые стороны.
Для разработчиков серверов, баз данных и сетевых систем io_uring — это ключ к миллионам операций в секунду без боли потоков и блокировок.

Но важно помнить: это мощный инструмент, а не серебряная пуля. Проверяйте поддержку ядра, планируйте fallback и следите за безопасностью.
В правильных руках io_uring действительно превращает Linux-систему в реактивную машину.