
Всем привет! Наверное, многие уже слышали о новом интерфейсе ядра Linux — io_uring. Это новый способ работы с асинхронным I/O (и не только) в Linux. Кстати, новый он не только из-за даты выхода в свет, но и в плане подходов, которые предлагает разработчику.
Заинтересовало? Более подробно разберемся под катом.
Дисклеймер
Это первая статья из серии посвященной io_uring. Данный материал — вводный, поэтому основной упор будет сделан на основы работы с io_uring и примеры программ с комментариями.
В этой статье я буду только вскользь касаться темы специфических настроек и опций io_uring. Также сегодня не будет практических примеров применения этой технологии. Но не беспокойтесь, эти темы будут освещены будущих публикациях.
Кстати, если вас смутило нахождение статьи в хабе GO — причина будет в конце публикации.
Долгожданные гости
IO_URING это новый интерфейс ядра Linux для асинхронного ввода/вывода, разработанный Jens Axboe. Доступен для использования с версии ядра 5.1 (но замечу, что примеры статьи проверялись в версии 5.11 и точно не будут работать в версиях до 5.5).
Тень прошлого
И прежде чем мы действительно разберемся, что это за интерфейс, предлагаю немного освежить память и вспомнить инструменты Linux для асинхронного программирования:
-
select, poll, epoll — вообще говоря, эти семейства системных вызовов не дают асинхронность как таковую, но позволяют следить за набором файловых дескрипторов и реагировать на готовность определенных дескрипторов к чтению/записи:
-
select — обладает крайне неудобным API, не работает с файлами и проигрывает коллегам по перформансу
-
poll — так же как и select позволяет разработчику следить за готовностью файловых дескрипторов. В отличие от select имеет более приятный API (хотя и не без огрехов, которые были устранены в epoll), не умеет в файлы
-
epoll — усовершенствованный poll, доступен только в linux, существенно улучшает перформанс предшественника, все так же не умеет работать с файлами
-
-
AIO — семейство системных вызовов. Стоит несколько особняком, поскольку предоставляет интерфейс, который действительно похож на нечто асинхронное (ну колбеки там, javascript, вы понимаете). Правда данный инструмент имеет столько вопросов по производительности, API и внутренней реализации, что в реальности сложно найти человека, который им пользовался.
В общем, как видите, даже epoll, хоть и используется повсеместно, имеет свои ограничения.
Самая короткая дорога к асинхронности

И как уже несложно догадаться, задача io_uring — снять эти ограничения, а также дать новый интерфейс для работы с асинхронным I/O в linux.
По своей сути io_uring — это два кольцевых буфера (отсюда и ring в названии):
-
Submission queue (далее SQ) — сюда пишем операции, которые должно выполнить ядро ОС (например: прочитать файл, принять соединение, закрыть сокет). Операция — это syscall который система выполнит в фоне, не блокируя нашу программу. Элемент SQ — submission queue entry (SQE). Ниже приведена структура, которая описывает SQE. Выглядит довольно страшно, поэтому наиболее часто используемые поля будут описаны отдельно:
io_uring_sqe
/* * IO submission data structure (Submission Queue Entry) */ struct io_uring_sqe { __u8 opcode; /* type of operation for this sqe */ __u8 flags; /* IOSQE_ flags */ __u16 ioprio; /* ioprio for the request */ __s32 fd; /* file descriptor to do IO on */ union { __u64 off; /* offset into file */ __u64 addr2; }; union { __u64 addr; /* pointer to buffer or iovecs */ __u64 splice_off_in; } __u32 len; /* buffer size or number of iovecs */ union { __kernel_rwf_t rw_flags; __u32 fsync_flags; __u16 poll_events; /* compatibility */ __u32 poll32_events; /* word-reversed for BE */ __u32 sync_range_flags; __u32 msg_flags; __u32 timeout_flags; __u32 accept_flags; __u32 cancel_flags; __u32 open_flags; __u32 statx_flags; __u32 fadvise_advice; __u32 splice_flags; __u32 rename_flags; __u32 unlink_flags; __u32 hardlink_flags; }; /* op_code flags */ __u64 user_data; /* data to be passed back at completion time */ union { struct { union { __u16 buf_index; __u16 buf_group; } __u16 personality; union { __s32 splice_fd_in; __u32 file_index; }; }; __u64 __pad2[3]; }; };-
opcode — код операции, можно сказать, набор поддерживаемых io_uring системных вызовов. Но так же есть такие операции, как отмена операции или Nop операция (полезно в тестах)
-
flags — набор флагов, но не для выбранного syscall’а (операции), а для самого SQE. Например, с помощью флага IOSQE_IO_LINK гарантируется последовательное исполнение двух или более SQE
-
fd — файловый дескриптор к которому применяется операция
-
addr, len — сюда обычно помещается буфер для чтения/записи
-
op_code flags — union в котором хранятся флаги специфичные для выбранного syscall’а
-
user_data — это поле разберем чуть позже, при разборе CQE
-
-
Completion queue (далее CQ) — это очередь из которой вычитываются результаты. Элемент CQ — completion queue event (CQE). Структура описывающая CQE:
io_uring_cqe
struct io_uring_cqe { __u64 user_data; /* sqe->data submission passed back */ __s32 res; /* result code for this event */ __u32 flags; };-
res — результат работы системного вызова. Например, количество прочитанных байт в случае ReadV или дескриптор сокета для Accept. В случае ошибки — содержит значение -errno
-
flags — пока не используется
-
user_data — концептуально важное поле. Как вы понимаете, порядок получения CQE никак не зависит от порядка, в котором добавлялись SQE (асинхронность же). Возникает вопрос, как совместить некий результат (CQE) и соответствующий ему запрос (SQE)? Ответ: используем поле user_data которое есть как у SQE, так и у CQE. Значение из поля SQE.user_data будет скопировано в результат работы этой операции — CQE.user_data
-
Оба буфера шарятся между ядром и userspace для избежания затрат на копирование данных. Пользователь заносит операции в tail SQ буфера, а ядро читает из head. После выполнения операции ядро положит результат в tail CQ буфера, а пользователь должен читать результаты из head:

В ходе дальнейшего изложения будем говорить о SQ и CQ просто как о двух очередях. Чтобы избежать путаницы, мы абстрагируемся от реализации этих очередей через кольцевые буфера.
Начинаем работу с io_uring
Простейший алгоритм работы с io_uring выглядит примерно так:
-
Инициализировать инстанс io_uring.
-
Добавить в SQ операцию на выполнение (queue SQE).
-
Сообщить ядру что в SQ появились новые элементы.
-
Подождать, пока ядро выполнит операцию.
-
Извлечь из CQ результат выполнения (dequeue CQE).
Для реализации подобного алгоритма понадобится ряд системных вызовов: io_uring_setup, io_uring_enter и io_uring_register.
io_uring_setup
io_uring_setup — создает и конфигурирует экземпляр io_uring. Конфигурация io_uring это отдельная тема для разговора (которую обязательно коснемся в будущих статьях) — есть куча опций, которые могут повлиять как на поведение, так и на производительность системы (в худшую и в лучшую сторону само собой).
Помимо самого вызова io_uring_setup, для работы необходимо замапить к себе память, которую уже выделило ядро под SQ и CQ, делается это вызовом mmap с флагом MAP_SHARED.
Пример:
// создаем инстанс io_uring, размер CQ и SQ устанавливается параметром entries, // конфигурация в структуре io_uring_params int io_uring_setup(unsigned entries, struct io_uring_params *p) { return (int) syscall(__NR_io_uring_setup, entries, p); }
io_uring_enter
У этого системного вызова есть две основных функции:
-
Сообщить ядру о том в SQ появились новые SQE.
-
Подождать, пока в CQ не появится n результатов выполнения операций.
Можно или ждать CQE или сабмитить SQE, а можно делать обе эти вещи в рамках одного syscall’a.
Пример:
// отправляем 3 операции на выполнение в кольцо ring_fd, возврат блокируется пока io_uring не выполнит 2 операции syscall(__NR_io_uring_enter, ring_fd, 3, 2, IORING_ENTER_GETEVENTS, NULL, 0);
io_uring_register
Используется для управления ресурсами связанными с io_uring. Например:
-
для регистрации (обновления и дерегистрации) буферов которые будут использоваться нашим приложение и ядром совместно. Теоретически это позволит устранить некоторые копирования данных из userspace в kernel и обратно
-
для регистрации (обновления и дерегистрации) набора файловых дескрипторов. Не знаю зачем это нужно, но в старых версиях ядра это требуется делать, чтобы файловый дескриптор был «рабочим» в некоторых режимах работы io_uring
-
для получения probe — информации по фичам, которые поддерживает текущая версия io_uring
Пример:
// регистрируем буфера в ядре, передаем набор vectors - указателей на структуры iovec syscall(__NR_io_uring_register, fd, IORING_REGISTER_BUFFERS, vectors, vectors_len)
Возвращаясь к нашему простейшемуtm алгоритму: естественно он может быть сильно модифицирован. Например, чтение из CQ и запись в SQ могут производиться параллельно, в разных потоках. Или можно писать в SQ не по одной операции, а сразу пачку, для уменьшения количества системных вызовов io_uring_enter. Тут уже все зависит от разработчика, как использовать эти строительные кирпичики для реализации таких концепций как, например, event loop.
В гостях у liburing
Конечно, работать напрямую с системными вызовами не только неудобно, но и не рекомендуется. Поэтому стоит использовать библиотеку liburing. Причина — устранение бойлерплейта и более приятный API. Кроме того, так как обе очереди используются и приложением, и ядром — реализации queue в SQ и dequeue из CQ должны синхронизироваться с ядром. Эти обязанности берет на себя liburing.
Рассмотрим основные функции, которые предлагает эта библиотека:
-
io_uring_queue_init — создает io_uring + отображает CQ и SQ в userspace
-
io_uring_get_sqe — возвращает указатель на следующее, готовое к использованию, SQE в SQ
-
io_uring_prep_* (пример: io_uring_prep_writev, io_uring_prep_accept) — семейство функций, принимают на вход SQE которую конфигурируют в соответствии с выбранной операцией
-
io_uring_submit — сообщает ядру о том, что в SQ появились новые SQE
-
io_uring_wait_cqes — ждет, пока в CQ не появится заданное число не просмотренных CQE
-
io_uring_cqe_seen — помечаем CQE как просмотренное
-
io_uring_register_*— обертки над системным вызовом io_uring_register. Позволяют зарегистрировать буфера, файлы, файловые дескрипторы для поллинга, «взять пробу» и так далее
Вот с таким нехитрым набором функций нам и предлагается писать асинхронные приложения. Что же, давайте напишем что-то простое, для разминки.
Hello world
Выведем заветные 13 символов в STDOUT:
hello_world.c
#include <liburing.h> #include <assert.h> #include <unistd.h> #include <string.h> int main() { struct io_uring_params params; struct io_uring ring; memset(¶ms, 0, sizeof(params)); /** * Создаем инстанс io_uring, не используем никаких кастомных опций. * Емкость SQ и CQ буфера указываем как 4096 вхождений. */ int ret = io_uring_queue_init_params(4, &ring, ¶ms); assert(ret == 0); char hello[] = "hello world!\n"; // Добавляем операцию write в очередь SQ. struct io_uring_sqe *sqe = io_uring_get_sqe(&ring); io_uring_prep_write(sqe, STDOUT_FILENO, hello, 13, 0); // Сообщаем io_uring о новых SQE в SQ. io_uring_submit(&ring); // Ждем пока в CQ появится новое CQE. struct io_uring_cqe *cqe; ret = io_uring_wait_cqe(&ring, &cqe); assert(ret == 0); // Проверяем отсутствие ошибок. assert(cqe->res > 0); // Dequeue из очереди CQ. io_uring_cqe_seen(&ring, cqe); io_uring_queue_exit(&ring); return 0; }
Да уж, кода получилось немало. Да и где же тут асинхронность? Асинхронность заключается в том, что вывод в терминал происходит в фоне от потока приложения, в момент после подтверждения SQE (io_uring_submit) и перед получением результата операции (io_uring_wait_cqe). Итак, сам по себе системный вызов write (pwrite если быть точным) происходит в одном из тредов ядра. Как? Я об этом не рассказывал? Исправляемся!
Туман над kernel workers
Это, наверное, наиболее «туманная» сторона io_uring. Операции, помещенные в очередь, будут выполнены в «фоне» от нашего приложения. Но кто их выполнит?
Выполнять будут потоки ядра. Для каждого экземпляра io_uring создается пул воркеров io_wqe_worker-*. Управление этим пулом скрыто от прикладного программиста (к сожалению, и в документации нет явного описания алгоритма работы, так что только сурцы и практика).
Но, все-таки, есть рычаги для косвенного управления. Например, в недавней версии ядра появилась возможность указать максимальное количество воркеров в пуле. Кроме того, ряд опций влияет на то, как io_uring управляет пулом воркеров.
Ну и наконец, можно использовать несколько экземпляров io_uring — таким образом, поднимая несколько пулов (хотя это поведение можно изменить, попросив несколько экземпляров io_uring работать на одном пуле).
Зеркало трафика, пишем tcp-echo сервер
Предлагаю финализировать сегодняшнюю информацию и разобрать реализацию tcp-echo сервера написанного с использованием io_uring. Задача tcp-echo сервера — ретрансляция всех входящих данных обратно клиенту. За основу был взят код из этого проекта, слегка модифицирован и снабжен необходимыми комментариями.
tcp-echo.c
#include <liburing.h> #include <stdio.h> #include <string.h> #include <strings.h> #include <assert.h> #include <stdlib.h> #include <netinet/in.h> #include <sys/socket.h> #define MAX_CONNECTIONS 4096 #define BACKLOG 512 #define MAX_MESSAGE_LEN 2048 #define IORING_FEAT_FAST_POLL (1U << 5) void add_accept(struct io_uring *ring, int fd, struct sockaddr *client_addr, socklen_t *client_len); void add_socket_read(struct io_uring *ring, int fd, size_t size); void add_socket_write(struct io_uring *ring, int fd, size_t size); /** * Каждое активное соединение в нашем приложение описывается структурой conn_info. * fd - файловый дескриптор сокета. * type - описывает состояние в котором находится сокет - ждет accept, read или write. */ typedef struct conn_info { int fd; unsigned type; } conn_info; enum { ACCEPT, READ, WRITE, }; // Буфер для соединений. conn_info conns[MAX_CONNECTIONS]; // Для каждого возможного соединения инициализируем буфер для чтения/записи. char bufs[MAX_CONNECTIONS][MAX_MESSAGE_LEN]; int main(int argc, char *argv[]) { /** * Создаем серверный сокет и начинаем прослушивать порт. * Обратите внимание что при создании сокета мы НЕ УСТАНАВЛИВАЕМ флаг O_NON_BLOCK, * но при этом все чтения и записи не будут блокировать приложение. * Происходит это потому, что io_uring спокойно превращает операции над блокирующими сокетами в non-block системные вызовы. */ int portno = strtol(argv[1], NULL, 10); struct sockaddr_in serv_addr, client_addr; socklen_t client_len = sizeof(client_addr); int sock_listen_fd = socket(AF_INET, SOCK_STREAM, 0); const int val = 1; setsockopt(sock_listen_fd, SOL_SOCKET, SO_REUSEADDR, &val, sizeof(val)); memset(&serv_addr, 0, sizeof(serv_addr)); serv_addr.sin_family = AF_INET; serv_addr.sin_port = htons(portno); serv_addr.sin_addr.s_addr = INADDR_ANY; assert(bind(sock_listen_fd, (struct sockaddr *) &serv_addr, sizeof(serv_addr)) >= 0); assert(listen(sock_listen_fd, BACKLOG) >= 0); /** * Создаем инстанс io_uring, не используем никаких кастомных опций. * Емкость очередей SQ и CQ указываем как 4096 вхождений. */ struct io_uring_params params; struct io_uring ring; memset(¶ms, 0, sizeof(params)); assert(io_uring_queue_init_params(4096, &ring, ¶ms) >= 0); /** * Проверяем наличие фичи IORING_FEAT_FAST_POLL. * Для нас это наиболее "перформящая" фича в данном приложении, * фактически это встроенный в io_uring движок для поллинга I/O. */ if (!(params.features & IORING_FEAT_FAST_POLL)) { printf("IORING_FEAT_FAST_POLL not available in the kernel, quiting...\n"); exit(0); } /** * Добавляем в SQ первую операцию - слушаем сокет сервера для приема входящих соединений. */ add_accept(&ring, sock_listen_fd, (struct sockaddr *) &client_addr, &client_len); /* * event loop */ while (1) { struct io_uring_cqe *cqe; int ret; /** * Сабмитим все SQE которые были добавлены на предыдущей итерации. */ io_uring_submit(&ring); /** * Ждем когда в CQ буфере появится хотя бы одно CQE. */ ret = io_uring_wait_cqe(&ring, &cqe); assert(ret == 0); /** * Положим все "готовые" CQE в буфер cqes. */ struct io_uring_cqe *cqes[BACKLOG]; int cqe_count = io_uring_peek_batch_cqe(&ring, cqes, sizeof(cqes) / sizeof(cqes[0])); for (int i = 0; i < cqe_count; ++i) { cqe = cqes[i]; /** * В поле user_data мы заранее положили указатель структуру * в которой находится служебная информация по сокету. */ struct conn_info *user_data = (struct conn_info *) io_uring_cqe_get_data(cqe); /** * Используя тип идентифицируем операцию к которой относится CQE (accept/recv/send). */ unsigned type = user_data->type; if (type == ACCEPT) { int sock_conn_fd = cqe->res; /** * Если появилось новое соединение: добавляем в SQ операцию recv - читаем из клиентского сокета, * продолжаем слушать серверный сокет. */ add_socket_read(&ring, sock_conn_fd, MAX_MESSAGE_LEN); add_accept(&ring, sock_listen_fd, (struct sockaddr *) &client_addr, &client_len); } else if (type == READ) { int bytes_read = cqe->res; /** * В случае чтения из клиентского сокета: * если прочитали 0 байт - закрываем сокет * если чтение успешно: добавляем в SQ операцию send - пересылаем прочитанные данные обратно, на клиент. */ if (bytes_read <= 0) { shutdown(user_data->fd, SHUT_RDWR); } else { add_socket_write(&ring, user_data->fd, bytes_read); } } else if (type == WRITE) { /** * Запись в клиентский сокет окончена: добавляем в SQ операцию recv - читаем из клиентского сокета. */ add_socket_read(&ring, user_data->fd, MAX_MESSAGE_LEN); } io_uring_cqe_seen(&ring, cqe); } } } /** * Помещаем операцию accept в SQ, fd - дескриптор сокета на котором принимаем соединения. */ void add_accept(struct io_uring *ring, int fd, struct sockaddr *client_addr, socklen_t *client_len) { // Получаем указатель на первый доступный SQE. struct io_uring_sqe *sqe = io_uring_get_sqe(ring); // Хелпер io_uring_prep_accept помещает в SQE операцию ACCEPT. io_uring_prep_accept(sqe, fd, client_addr, client_len, 0); // Устанавливаем состояние серверного сокета в ACCEPT. conn_info *conn_i = &conns[fd]; conn_i->fd = fd; conn_i->type = ACCEPT; // Устанавливаем в поле user_data указатель на socketInfo соответствующий серверному сокету. io_uring_sqe_set_data(sqe, conn_i); } /** * Помещаем операцию recv в SQ. */ void add_socket_read(struct io_uring *ring, int fd, size_t size) { // Получаем указатель на первый доступный SQE. struct io_uring_sqe *sqe = io_uring_get_sqe(ring); // Хелпер io_uring_prep_recv помещает в SQE операцию RECV, чтение производится в буфер соответствующий клиентскому сокету. io_uring_prep_recv(sqe, fd, &bufs[fd], size, 0); // Устанавливаем состояние клиентского сокета в READ. conn_info *conn_i = &conns[fd]; conn_i->fd = fd; conn_i->type = READ; // Устанавливаем в поле user_data указатель на socketInfo соответствующий клиентскому сокету. io_uring_sqe_set_data(sqe, conn_i); } /** * Помещаем операцию send в SQ буфер. */ void add_socket_write(struct io_uring *ring, int fd, size_t size) { // Получаем указатель на первый доступный SQE. struct io_uring_sqe *sqe = io_uring_get_sqe(ring); // Хелпер io_uring_prep_send помещает в SQE операцию SEND, запись производится из буфера соответствующего клиентскому сокету. io_uring_prep_send(sqe, fd, &bufs[fd], size, 0); // Устанавливаем состояние клиентского сокета в WRITE. conn_info *conn_i = &conns[fd]; conn_i->fd = fd; conn_i->type = WRITE; // Устанавливаем в поле user_data указатель на socketInfo соответсвующий клиентскому сокету. io_uring_sqe_set_data(sqe, conn_i); }
Производительность
Для оценки производительности будем использовать сравнение с таким же tcp-echo сервером, написанным с использованием epoll. Считать RPS будем вот этим инструментом, варьируем количество клиентских соединений (c) и объем передаваемых данных (bytes).
Ну и характеристики стенда:
-
Linux 5.11
-
Intel(R) Core(TM) i5-7500 CPU @ 3.40GHz (4 ядра)
-
16gb RAM
Компилируем и запускаем приложение:
gcc tcp-echo.c -o ./tcp-echo -Wall -O2 -D_GNU_SOURCE -luring ./tcp-echo 8080
Затем бенчмарк:
cargo run --release -- --address "127.0.0.1:8080" --number {c} --duration 60 --length {bytes}
|
c: 50 bytes: 128 |
c: 50 bytes: 512 |
c: 500 bytes: 128 |
c: 500 bytes: 512 |
c: 1000 bytes: 128 |
c: 1000 bytes: 512 |
|
|
io_uring tcp-echo server |
249297 |
252822 |
193452 |
179966 |
158911 |
163111 |
|
epoll tcp-echo server |
223135 |
227143 |
173357 |
173772 |
156449 |
155492 |
В таблице выше представлены request per second полученные в ходе тестов. Нагрузка на процессор в обоих случаях была примерно одинаковая. Можно сделать вывод — io_uring как минимум является достойным конкурентом epoll в плане производительности.
Промежуточные итоги, а также содержание следующих статей

Данная статья является введением в io_uring. За рамками этого материала осталась гора нюансов связанных, в первую очередь, с настройками io_uring. Но, надеюсь, некоторые из них получится осветить в последующих статьях.
Важно заметить, что механизм сам по себе довольно новый, поэтому:
-
Все еще можно наткнуться на неприятные баги (особенно в «старых» версиях ядра).
-
Фичи активно добавляются.
-
Есть небезосновательные надежды на то, что в последующих версиях производительность будет еще лучше.
Ну и напоследок, наверное, стоит осветить вопрос, при чем тут вообще GO и почему будущие статьи будут касаться в том числе и этого языка?
Ну, во-первых, потому что автор GO разработчик. А во-вторых, и это наиболее важно, мы говорим об асинхронном I/O, работать с которым так удобно в GO. В основе GO-шного I/O лежит такая штука как netpoller который является частью рантайма. А что если попробовать написать свой netpoller или альтернативу ему с использованием io_uring и повоевать с рантаймом? И сделать это, например, в рамках http сервера?
Думаю может получиться интересно, а по дороге еще раз посмотрим на внутреннее устройство некоторых механизмов GO рантайма. Stay tuned!
Немного полезных ссылок
-
https://kernel.dk/io_uring.pdf — whitepaper
-
https://unixism.net/loti/index.html — блог с примерами реализаций простых приложений
-
https://github.com/axboe/liburing — liburing
Дата-центр ITSOFT — размещение и аренда серверов и стоек в двух дата-центрах в Москве. За последние годы UPTIME 100%. Размещение GPU-ферм и ASIC-майнеров, аренда GPU-серверов, лицензии связи, SSL-сертификаты, администрирование серверов и поддержка сайтов.
ссылка на оригинал статьи https://habr.com/ru/articles/589389/
Добавить комментарий