Всем привет! Наверное, многие уже слышали о новом интерфейсе ядра 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 и прикладному пользователю трогать ее особо незачем