
Всем привет! Наверное, многие уже слышали о новом интерфейсе ядра 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-сертификаты, администрирование серверов и поддержка сайтов.
Комментарии (35)

Andy_U
17.11.2021 10:34А Вы так называемые "дополнительные секунды" (https://ru.wikipedia.org/wiki/%D0%94%D0%BE%D0%BF%D0%BE%D0%BB%D0%BD%D0%B8%D1%82%D0%B5%D0%BB%D1%8C%D0%BD%D0%B0%D1%8F_%D1%81%D0%B5%D0%BA%D1%83%D0%BD%D0%B4%D0%B0) учли?

Andy_U
17.11.2021 16:21+1Черт, это я совсем не на ту статью, куда хотел, комментарий написал. Надо было сначала проснуться. Прошу прощения.

AlexSky
17.11.2021 15:54+1Тоже думал написать серию статей по io_uring, но с переходом на новую работу руки так и не дошли.
Очень рад был увидеть такую статью. Технология очень интересная.

godzie Автор
17.11.2021 16:23+1Спасибо! Да технология действительно интересная, если судить по issues на гитхабе построить асинхронный бекенд с iouring пытаются и для питона и для шарпов и для go и вообще для всего что движется :) Правда все на стадии прототипов.

dmitry_rozhkov
17.11.2021 16:48+1Я сейчас прикручиваю io_uring к Envoy, и мне казалось, что в случае нулевых "params" никаких воркеров в ядре не запускается. Они запускаются, если указать флаг IORING_SETUP_SQPOLL (попробуйте свой бенчмарк с ним). Без него основной выигрыш в производительности происходит за счёт экономии на syscall'ах - меньше нужно переключений контекста, если сразу несколько системных операций (accept, writev, readv, connect, close) положить в буфер и один раз позвать io_uring_submit().

godzie Автор
17.11.2021 17:00Не совсем так, пул воркеров запускается в любом случае (кому то же нужно выполнять сисколы). Грепните вот так:
ps auxf | grep io_wqe_По поводу IORING_SETUP_SQPOLL - эта опция поднимает еще один дополнительный тред на одно кольцо. Его задача разгребать SQ, освобождая нас от ручного вызова io_uring_enter для подтверждения новых SQE (ну или submit в случае liburing). Но вообще эту тему (и более подробные бенчмарки) я как раз собираюсь оставить для будущих публикаций. Там можно много и по разному конфигурировать:
один io_uring
много io_uring
один ui_uring с IORING_SETUP_SQPOLL
много io_uring с IORING_SETUP_SQPOLL
много io_uring но на ограниченном пуле воркеров
и т.д.

dmitry_rozhkov
17.11.2021 19:30+1У меня греп ничего не показывает. Правда, и с IORING_SETUP_SQPOLL новых тредов в ps auxf тоже невидно. Возможно, у меня ps или ядро какие-то неправильные.
Я, грешным делом, думал, что сисколы выполняются в том же треде, что и приложение, но с переключением в контекст ядра. То есть контроль исполнения передаётся ядру на время выполнения сискола, а приложение стоит и ждёт возврата. С IORING_SETUP_SQPOLL нет нужды в сисколах - IO операции выполняются ядром асинхронно, по моим предположениям, в polling-треде. В моих нагрузочных тестах видно как с IORING_SETUP_SQPOLL один CPU core полностью нагружается ядром, а CPU core на который запинено приложение тоже полностью нагружено, но практически не переключается в контекст ядра. В общем, было бы здорово развеять мои заблуждения в новый статьях.
Вот ещё вопрос возник. Если IO операции выполняются отдельными ядерными тредами, они попадут в одну cgroup с приложением или нет? Если нет, то, наверно, в k8s может случиться проблема "noisy neighbor".

godzie Автор
17.11.2021 19:50Ну собственно поэтому этот момент в статье описан как туманный :) Например вот в недавних версиях появилась возможность IORING_REGISTER_IOWQ_MAX_WORKERS - ограничить размер пула. Но что это за пул и как работает - в доке одни намеки, приходится копать самому.
По поводу cgroup в целом ничего не могу сказать. Но в 5.12+ появилась такая фича:
IORING_FEAT_NATIVE_WORKERS If this flag is set, io_uring is using native workers for its async helpers. Previous kernels used kernel threads that assumed the identity of the original io_uring owning task, but later kernels will actively create what looks more like regular process threads instead. Available since kernel 5.12.возможно она решит проблему (если таковая имеется)?

DistortNeo
17.11.2021 21:15Почитал. Впечатлился.
Однако, я бы не согласился с тем, что это дальнейшая эволюция
epoll. У этих технологий имеется принципиальная разница:io_uring— это надстройка над блокирующими вызовами, тогда какepoll— над неблокирующими.В чём заключается разница:
-
Блокирующие вызовы невозможно отменить. Вызов будет висеть в очереди, пока не придут/отправятся данные, либо не случится ошибка. В случае же неблокирующего вызова можно отписаться от нотификации готовности к чтению/записи в любой момент. Отсюда следует, что использовать
io_uringв качестве бэкэнда для планировщика в C# без костылей не получится, т.к.CancellationTokenпопросту не будет работать.
-
В Linux нет асинхронной работы с дисками. Существующие решения работают с блокирующими вызовами в пуле потоков. Очевидно, что такие решения масштабируется довольно плохо. Технология
io_uringже решает эту проблему.

godzie Автор
17.11.2021 21:24+1Круто что впечатлились! По первому пункту, возможно я Вас не понял, но io_uring умеет отменять операции (даже если они уже засабмиченны из SQ в ядро) - для этого есть спец операция IORING_OP_ASYNC_CANCEL.

DistortNeo
17.11.2021 21:50Спасибо, тогда это решает все возможные проблемы. Просто с документацией по этой технологии пока всё плохо, и я тупо не смог найти эту информацию, т.к. функционал IORING_OP_ASYNC_CANCEL был добавлен в ядро сильно позже, в версии 5.5.

godzie Автор
17.11.2021 23:08По поводу документации так и есть. В данный момент лучшая дока по операциям - тесты liburing.
-

permeakra
01.12.2021 13:45объясните человеку не в теме - как обрабатывается переполнение буферов?

godzie Автор
01.12.2021 13:45О каких именно буферах речь?

permeakra
01.12.2021 14:35кажется, не туда ответил см тут https://habr.com/ru/company/itsoft/blog/589389/comments/#comment_23773249

DistortNeo
01.12.2021 14:41+1Очень просто:
Если у вас накопилось больше запросов, чем влазит в буфер, то вы должны ждать, пока ядро их прочитает, и только потом дописывать новые. Если вы не используете poll-режим, тогда просто достаточно вызывать
io_uring_submitпосле заполнения буфера.Если же у ядра накопилось ответов больше, чем влазит в буфер, то оно заполнит столько элементов, сколько влезет в буфер, и будет ждать, пока клиент не вычитает ответы. Если вы не используете poll-режим, тогда буфер заполнится при следующем вызове
io_uring_wait_*.
godzie Автор
01.12.2021 15:01Спасибо! добавлю что для в случае переполнения CQ поведение зависит от версии ядра, раньше лишние cqe дропались (надо проверять FEAT_NODROP вообщем)
а) не перезаписать свой (еще) необработанный запрос
в случае использования liburing не получится перезаписать, если работать с SQ руками то можно

permeakra
01.12.2021 15:14Если у вас накопилось больше запросов, чем влазит в буфер, то вы должны ждать, пока ядро их прочитает, и только потом дописывать новые
По какому признаку пользователь должен это отслеживать?
Если же у ядра накопилось ответов больше, чем влазит в буфер, то оно заполнит столько элементов, сколько влезет в буфер, и будет ждать, пока клиент не вычитает ответы.
По какому признаку ядро отслеживает, какие пакеты вычитаны и могут быть безопасно перезаписаны?

godzie Автор
01.12.2021 15:39+2По какому признаку пользователь должен это отслеживать?
В случае liburing - функция io_uring_get_sqe вернет null. Если пишите либу сами и работаете с чистыми сисколами то у Вас есть доступ к head и tail SQ + размер SQ тоже известен, так что просто сами смотрите
По какому признаку ядро отслеживает, какие пакеты вычитаны и могут быть безопасно перезаписаны?
В случае liburing - опять же мы подтверждаем прием cqe вызовом функции, под капотом - двигаем head CQ буфера (это значение шарится между юзер спейсом и ядром)

DistortNeo
01.12.2021 16:02В случае liburing — опять же мы подтверждаем прием cqe вызовом функции
Мне, кстати, логика работы функции
io_uring_cqe_seenсовершенно не нравится, т.к. делает она вовсе не то, что декларируется в её названии. Она двигает счётчик на 1, но никакой логики по отношению к cqe к ней нет:Например, мы можем считать сразу несколько cqe через
io_uring_peek_batch_cqe, а затем начать обрабатывать их не по порядку, тогда вызовio_uring_cqe_seenможет привести к тому, что первые cqe могут быть затёрты.
godzie Автор
01.12.2021 16:07Мне, кстати, логика работы функции
io_uring_cqe_seenсовершенно не нравитсясогласен с Вами, думаю это сделано из-за красивого api
Например, мы можем считать сразу несколько cqe через
io_uring_peek_batch_cqe, а затем начать обрабатывать их не по порядку, тогда вызовio_uring_cqe_seenможет привести к тому, что первые cqe могут быть затёрты.я даже об это слегка спотыкался, правда теперь, немного разобравшись, везде где батчим cqe (а батчим везде где нужен высокий throughput) использую io_uring_cq_advance

DistortNeo
01.12.2021 15:56Кольцевой буфер со всеми указателями (head, tail) шарится между ядром и приложением, есть общий алгоритм работы с ним, который описан в статье.

permeakra
01.12.2021 14:34О кольцевых. Ring buffer имеет конечную емкость.
Я, может быть, коряво выразился. Попробую переформулировать: за какими признаками должен следить пользователь io_uring чтобы
а) не перезаписать свой (еще) необработанный запрос
б) с гарантией успевать обрабатывать все ответы ядра.
Конкретные примеры и истории косяков приветствуются.

DistortNeo
01.12.2021 16:09б) с гарантией успевать обрабатывать все ответы ядра.
Мне кажется, ответа тут нет. У меня ядро 5.15, и в нём никакой проблемы с обработкой нет. Я запихивал в uring миллион различных таймеров, и весь этот миллион корректно складировался в ядре без потери ответов.
Но судя по документации, sumbit может вернуть -EBUSY. В этом случае нужно приостановить отправку запросов в ядро и вычитать результаты. Но я с таким не сталкивался.
csl
Рассматривали Haskell вместе с/вместо Go?
"Сложность простоты"
https://habr.com/ru/post/469441/
TL;DR поста "Сложность простоты": автору (как он сам указывает, C# (mostly) developer; хотя он Rust, Scala знает) больше понравился Haskell.
godzie Автор
Нет, не рассматривал, да и не уверен что Haskell удобен в контексте системного программирования.
csl
https://habr.com/ru/post/489136/
https://habr.com/ru/post/496370/
Например, системная утилита подсчёта символов, слов, строк wc на Haskell.
godzie Автор
Ну, к примеру, я попробовал по быстрому нагуглить как в Haskell сделать кастомный syscall. И с ходу не получилось найти. Конечно я тут не претендую на объективность, но кажется что язык и комьюнити не совсем об этом.
csl
Да, но вот ядро ОС на Haskell https://github.com/tathougies/hos
godzie Автор
Ну я все таки рассуждал в контексте линукса. А так why not.
csl
@0xd34df00d спасибо за ссылку на hos выше https://habr.com/ru/company/skillfactory/blog/585884/comments/#comment_23649442
Я знал только https://github.com/dls/house , единственный коммит которой был 13 лет назад.
develop7
Можно вызвать из liburing через FFI
Но лично я дождусь, когда поддержку io_uring просто втащат сразу в хаскельный RTS
godzie Автор
Не могу говорить за Haskell. Но, например, в GO FFI(cgo) имеет свою, довольно ощутимую цену на вызов С функции из GO (в десятки раз медленнее). И если мы рассматривает io_uring в контексте высокого throughput то таких вещей хочется по максимуму избегать.
develop7
в ответе на https://stackoverflow.com/questions/60334898/haskell-c-ffi-performance 25 миллионов вызовов утаптывают в 1.2 сек, что на мой взгляд навскидку приемлемо
а вообще, повторюсь, в контексте Haskell место io_uring — в RTS и прикладному пользователю трогать ее особо незачем