Linux io_uring: асинхронный I/O нового поколения — как работает и когда применять
Linux долго жил с двумя подходами к асинхронному I/O: epoll для сокетов и POSIX aio для файлов. Оба имеют фундаментальные ограничения. io_uring, появившийся в ядре 5.1 (2019), решает их через единый интерфейс с shared memory между userspace и ядром — без системных вызовов на каждую операцию.
Проблемы epoll и aio
epoll: каждая операция read/write — отдельный syscall; не работает с обычными файлами; нет batching. POSIX aio: только O_DIRECT (без page cache); только файловый I/O; неудобный API. io_uring объединяет оба мира: файлы, сокеты, таймеры — всё через единый асинхронный интерфейс.
Архитектура: SQ, CQ и shared memory
io_uring построен на двух кольцевых буферах в shared memory:
┌───────────────────────────────────────────┐
│ Userspace (приложение) │
│ ┌────────────────┐ ┌────────────────┐ │
│ │ Submission Queue│ │Completion Queue│ │
│ │ (SQ) — запись │ │ (CQ) — чтение │ │
│ └───────┬────────┘ └───────▲────────┘ │
│ │ shared memory │ │
├──────────┼────────────────────┼────────────┤
│ ▼ │ │
│ ┌────────────────┐ ┌───────┴────────┐ │
│ │ SQ — чтение │ │ CQ — запись │ │
│ └────────────────┘ └────────────────┘ │
│ Kernel (ядро Linux) │
└───────────────────────────────────────────┘
- Submission Queue (SQ) — приложение кладёт запросы, ядро забирает.
- Completion Queue (CQ) — ядро кладёт результаты, приложение забирает.
- SQPOLL mode — ядро крутит поток, опрашивающий SQ. Приложение вообще не делает syscall.
liburing: базовые операции
#include <liburing.h>
int main() {
struct io_uring ring;
io_uring_queue_init(256, &ring, 0);
int fd = open("data.bin", O_RDONLY);
char buf[4096];
// Готовим запрос на чтение
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0);
io_uring_sqe_set_data(sqe, (void *)42);
// Отправляем и ждём
io_uring_submit(&ring);
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
int bytes_read = cqe->res;
io_uring_cqe_seen(&ring, cqe);
io_uring_queue_exit(&ring);
return 0;
}
Linked operations — цепочка accept + read за одну отправку:
sqe = io_uring_get_sqe(&ring);
io_uring_prep_accept(sqe, server_fd, NULL, NULL, 0);
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, client_fd, buf, BUF_SIZE, 0);
sqe->flags |= IOSQE_IO_LINK;
io_uring_submit(&ring); // одна отправка — две операции
Бенчмарки: io_uring vs epoll vs aio
Случайное чтение 4KB с NVMe SSD (fio, iodepth=128):
Метод | IOPS | Avg lat | 99p lat | CPU%
─────────────────────────────────────────────────────
io_uring | 1,200K | 105 μs | 180 μs | 45%
libaio | 1,050K | 120 μs | 220 μs | 52%
epoll+read | 680K | 185 μs | 350 μs | 78%
sync read | 95K | 1,340 μs | 2,100 μs | 30%
Для сети (echo-сервер, 10K соединений) разница скромнее: io_uring ~890K RPS vs epoll ~820K RPS. Основной выигрыш — файловый I/O и batching.
Кто использует и ограничения
Продакшен: Tokio (Rust) — tokio-uring для файлов; RocksDB — параллельное чтение SST; ScyllaDB — весь дисковый I/O; NGINX Unit — статические файлы.
Ограничения:
- Ядро 5.1+, полная поддержка сети — с 5.19+.
- Безопасность. Отключён в Docker по умолчанию (seccomp). Google отключил в ChromeOS/Android из-за уязвимостей.
- Сложность отладки. Асинхронность + shared memory = трудно дебажить.
Вывод
io_uring — самое значительное изменение в Linux I/O за десятилетие. Для файлового I/O с высоким параллелизмом даёт 15-40% прироста IOPS при снижении CPU. Если сервис упирается в I/O на современном ядре — io_uring стоит рассмотреть.