Привет, Хабр! Уже давненько вокруг ходит тема про io_uring
— новую высокопроизводительную модель асинхронного I/O в Linux. В теории это обещает минимальные системные вызовы и очереди отправки/завершения, но как это в деле работает с привычными утилитами? Я решил разобраться на примере типичной задачи — копирования файлов (и немного упомянем хеширование) — переписав её под liburing
и сравнив с традиционной синхронной версией.
Начнем с основ. Напомню, что io_uring
(доступный с ядра 5.1+) это механизм, в котором приложение может подготовить сразу несколько запросов на I/O и отправить их в кольцевой буфер (SQ), а затем получать результаты из другого кольца (CQ). Благодаря общей памятью между ядром и юзерспейсом, снижается накладная на системные вызовы. Однако нужно понимать системные требования: io_uring
работает только с Linux 5.1 и выше, а многие продвинутые фичи вроде multishot‑приёма, поллинга и «naked» потоков (io‑wq) появились только в ядрах 5.10+. Это надо иметь в виду — проверяйте версию ядра перед попыткой использовать новейшие фишки. Также неплохо пользоваться liburing
— библиотекой обёрткой над сырыми вызовами, чтобы не писать тонны boilerplate‑кода.
Синхронная реализация копирования
Для начала возьмём самый простой случай — копирование файла в синхронном (блокирующем) режиме. Представим функцию‑утилиту, которая читает данные кусками и записывает их в другой файл. Вот как это можно написать на C (представляю только ядро алгоритма, без обработки аргументов):
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#define BLOCK_SIZE (64 * 1024) // размер буфера
int copy_sync(int infd, int outfd) {
char buf[BLOCK_SIZE];
ssize_t bytes;
off_t offset = 0;
while ((bytes = pread(infd, buf, sizeof(buf), offset)) > 0) {
if (pwrite(outfd, buf, bytes, offset) != bytes) {
perror("pwrite error");
return -1;
}
offset += bytes;
}
if (bytes < 0) {
perror("pread error");
return -1;
}
return 0;
}
Этот код делает ровно то, что ожидаешь: последовательно вызывает pread
для чтения и pwrite
для записи, пока не доберётся до конца файла. Такой простой подход будет примерно эквивалентен утилите cp
по скорости. Как оказалось, ванильный метод (read/write
или pread/pwrite
) часто оказывается быстрее эквивалентной реализации на io_uring
. Это неудивительно: в нашей реализации io_uring
выполняет практически те же блокирующие операции (ведь мы всё равно читаем и пишем), а сверху вешает дополнительную логику с потоками‑воркерами. Поэтому первые замеры с использованием time
наглядно показывают: простой cp
выигрывает по времени (например, 1.75s против 2.08s у io_uring
‑версии).
Тем не менее, синхронный код очень привычен и понятен. Он имеет минус системные вызовы на каждую операцию: для каждого куска мы вызываем два syscall (чтение+запись). Звучит как кандидат для io_uring
, ведь именно «пакетная» отправка операций была придумана для уменьшения системных вызовов.
Переход на io_uring (liburing)
Теперь перепишем ту же утилиту на liburing
. Идея в том, чтобы подготовить сразу несколько чтений, отправить их в очередь, а затем парой форм получить результаты и записать в выходной файл. Вот схематичный фрагмент кода на C с использованием liburing
(не забываем #include <liburing.h>
и проверку ошибок):
#define QD 4 // глубина очереди, можно настраивать
struct io_uring ring;
if (io_uring_queue_init(QD, &ring, 0) < 0) {
perror("io_uring_queue_init");
return -1;
}
off_t offset = 0;
off_t file_size = get_file_size(infd); // предварительно узнали размер
// Подготавливаем и отправляем чтения
while (offset < file_size) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
size_t sz = (file_size - offset > BLOCK_SIZE) ? BLOCK_SIZE : (file_size - offset);
// Выделяем буфер с выравниванием, чтобы безопасно передать ядру
char *buf = NULL;
posix_memalign((void**)&buf, 4096, sz);
io_uring_prep_read(sqe, infd, buf, sz, offset);
io_uring_sqe_set_data(sqe, buf); // сохраним указатель на буфер в данных SQE
offset += sz;
}
// Отправляем все заготовленные SQE на выполнение в ядре
io_uring_submit(&ring);
Создаём очередь ring
с глубиной QD
. Затем в цикле берём каждый свободный слот SQE (io_uring_get_sqe
), выделяем буфер (выравненную память нужно передавать ядру!), готовим в SQE команду чтения io_uring_prep_read
, и прикрепляем указатель на буфер через io_uring_sqe_set_data
— чтобы потом, при получении завершения, знать куда писать. Наконец, одним io_uring_submit()
шлём сразу QD
операций чтения разом. (В реальном коде нужно проверять, что SQE не кончились, ждать и отправлять по очереди, но для простоты здесь — пачка сразу.)
После этого нужно обработать завершения и записать данные во входной файл. Продолжим код:
int pending = QD;
while (pending > 0) {
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
char *buf = io_uring_cqe_get_data(cqe);
ssize_t res = cqe->res;
if (res >= 0) {
// запись в выходной файл: записали столько, сколько прочитали
write(outfd, buf, res);
} else {
fprintf(stderr, "io_uring read error: %s\n", strerror(-res));
}
free(buf);
io_uring_cqe_seen(&ring, cqe);
pending--;
}
io_uring_queue_exit(&ring);
Тут ждём завершение одного CQE (io_uring_wait_cqe
), достаём из него результат cqe->res
и тот самый указатель buf
, который сохранили. Если чтение успешно, просто делаем write()
в выходной файл. Затем освобождаем буфер и отмечаем CQE как прочитанный. И так повторяем, пока не обработаем все QD
завершений. В более сложном варианте мы бы снова отправляли следующие чтения, пока ещё остались данные в файле. Но суть ясна: мы отправили пачку SQE, получили все CQE, записали в файл.
В продакшене нужно быть внимательным:
Нужно проверять ошибки каждого syscalls и io_uring‑функций.
Размер очереди
QD
и chunk size (BLOCK_SIZE
) выбирать в зависимости от железа.Выделение выравненной памяти (через
posix_memalign
) — чтобы память была DMA‑friendly и попадала в регионы, понятные ядру.
Код с io_uring
выглядит хитрее, но нам удалось избавиться от цикла с pread/pwrite
в пользу одной‑двух отправок io_uring_submit
. Однако как покажут бенчмарки, эффект не всегда положительный.
Замеры производительности
Чтобы понять, есть ли выигрыш, нужно измерить. Мы собрали оба бинаря — copy_sync
и copy_uring
— и сравнили через time
, а затем профилировали в perf
. Примерный результат (Chris Gioran нашёл почти то же самое для Rust‑версии) таков: обычная cp
(или наша pread/pwrite
версия) копирует в 1.75 секунд, а io_uring
‑версия — в 2.08 секунд. То есть «не fancy» копирование быстрее » fancy» на ~15-20%. Неожиданно? Если подумать — нет. У традиционного метода нет дополнительных потоков‑воркеров: всё делает приложение и ядро по классической схеме, лишь вызывая несколько syscalls. У io_uring
же весь наш удобный конвейер добавляет накладные: потоки io_sq_thread
воркера постоянно создают‑убивают IO и требуют своих ресурсов. Даже график вызовов (flamegraph) это иллюстрирует.
Для более глубокого анализа мы воспользовались perf record
+flamegraph
. Результаты показали, что в io_uring
‑программе значительную часть CPU жрёт именно служебный поток ядра io_sq_thread
. Утверждается (и наша профилировка это подтвердила), что где‑то половина работы уходит на этот менеджмент очередей, а не на сами чтения/записи. В том же профиле синхронной версии большая часть работы — в read()/write()
без лишних посредников.
Вывод такой: снижение количества системных вызовов не всегда даёт выигрыш в скорости, если в компле хотелось именно последовательной, блочной работы. Наш тест просто копировал файл последовательно, и кластерное выполнение чтений через io_uring
в реальности превратилось в дополнительный контекст. По крайней мере при небольшом QD такого. Если бы данные копировались параллельно (или наём потоков был бы оправдан в реальных workload), может быть другое дело.
Профилирование и оптимизация
Однако io_uring
— не панацея, а средство. Ещё кое‑что можно учесть: с увеличением глубины очереди (QD) и размера пакетов может появляться параллелизм. Если вы заклините QD=1, вы почти имитируете блокирующее чтение. А вот QD=16 и больше позволит ядру подать несколько операций сразу на диск. В наших экспериментах замеры показали, что при очень большом QD замечается легкий прирост в ситуациях с HDD или при большой файловой нагрузке — просто потому, что диск может параллельно выполнять несколько запросов. Правда, чем больше QD, тем и больше работы у io_uring‑воркера. Тут нужно мерять самостоятельно: отличный инструмент — perf record -g
, а потом perf report
с stackcollapse
/flamegraph
(или Brendan Gregg«овские утилиты). Также можно обратить внимание на команду perf c2c
, чтобы искать false sharing на кэше, если кажется, что процессы воркеров „толкаются“ (но в нашем простом тесте такого не было).
Отдельно скажу про трассировку — для детального анализа без простоя подойдёт bcc/trace
или uftrace
в режиме UProbes (если хотите профилировать сами программные функции, а не syscalls). Но в обычных линейных утилитах это редко нужно: достаточно стандартных фреймграфов.
Ограничения и «подводные камни» io_uring
Несмотря на гибкость, у io_uring
есть ряд ограничений, о которых важно помнить при внедрении:
RLIMIT_MEMLOCK. Если вы регистрируете пользоватльские буферы (
io_uring_register_buffers
), ядро попытается залочить их в памяти. По дефолту у юзера лимит на это — примерно 64 КБ. То есть зарегистрировать в кольцо очень большой кусок памяти (или несколько малых) можно упереться в это ограничение и получить ENOMEM. Решение в статье на StackOverflow простое: увеличитьmemlock
(через ulimit илиprlimit
), или запустить код от root. Либо регистрировать один большой буфер и работать с его частями, как подсказано в комментариях. В любом случае, если регистрируете буферы — помните про лимит пиннинга памяти.RLIMIT_NOFILE. Аналогично и при регистрации файлов через
IORING_REGISTER_FILES
— вы не можете зафиксировать в кольце больше дескрипторов, чем позволяетRLIMIT_NOFILE
пользователя. Попытка превысить лимит дескрипторов вызовет EMFILE. Актуально, если утилита потенциально открывает сразу сотни файлов. Опять же — можно повысить лимит дескрипторов на пользователя или запускать с нужными правами.Размер регистрируемого буфера. В ядре прописан лимит 1 ГБ на единичный зарегистрированный буфер. То есть если вы захотите залочить куда‑то несколько гигабайт одним куском, не выйдет. Правда, регистрировать можно массив iovec‑буферов суммарно больше, но каждый iovec не может быть больше 1 ГБ.
Версия ядра и флаги фич. Уже упоминал про ядра ниже 5.10: там не будет современных оптимизаций (native io‑threads, к примеру, появились в 5.12). А вот IORING_OP_NOPOLL и прочие режимы требуют 5.10+. Если используете возможности из свежих мануалов, убедитесь, что на продакшен‑таргете такое ядро есть. Иначе приложение может банально завершиться с ошибкой неподдерживаемой функции.
Безопасность.
io_uring
расширяет возможности приложения, а значит и поверхность атаки. В контейнеризованных средах иногдаio_uring
блокируют или разрешают не всем подряд, чтобы снизить risk (см. обсуждения с использованием через Docker). Это стоит иметь в виду при деплое.
Несмотря на эти тонкости, никаких особых граблей в коде мы не встретили: всё, что нужно — аккуратно обрабатывать ошибки, управлять ресурсами (закрыть дескрипторы, выйти из io_uring в конце) и правильно выбирать параметры. Главное — тестировать производительность под реальной нагрузкой, потому что патч io_uring
может как помочь (при больших параллельных I/O), так и не дать выигрыша (при простом последовательном копировании).
Вывод
Что же в итоге? io_uring
— мощный инструмент, позволяющий убирать syscall‑overhead и строить асинхронные конвейеры I/O. В нашем же примере CLI‑утилиты копирования его применение показало: с точки зрения сырой скорости синхронная реализация даже лучше. Если цель — просто скопировать файл с максимальной скоростью, io_uring
здесь не выиграет — ядро просто соорудит несколько служебных потоков (io_sq_thread
), которые возьмут на себя блокирующие чтения/записи, и это добавит оверхед. Однако io_uring
может оказаться полезен, когда несколько операций реально можно выполнять параллельно или нужно распределить нагрузку (например, копирование на несколько дисков, реализация сетевых прокси с логикой «отправил‑память взята‑ожидание‑завершение» и тому подобное).
Важно смотреть на задачу комплексно. Для CLI‑утилит часто больше выигрыша даёт грамотное использование флагов ядра (например, sendfile
, O_DIRECT, mmap) и буферизация, чем «замена всех read/write на uring». Но если уже пишете асинхронное ядро (или делаете библиотеку высокопроизводительную), liburing
пригодится — учтите, что это все‑таки еще не обычные старые системные вызовы, а то, что общепринято считать бумажной реализацией кластера воркеров в ядре. Именно этот компонент (и его задержки) чаще всего доминирует на графиках профилей.
В общем, не пугайтесь экспериментировать, но всегда профилируйте! Для копирования/хеширования простые методы (без очередей) могут оказаться быстрее. А если вы хотите использовать io_uring
, начните с малого: измерьте свою утилиту, посмотрите flamegraph, и только потом оптимизируйте.
Во многом итог наших экспериментов показывает простую вещь: производительность и устойчивость в Linux‑среде зависят не только от новых механизмов вроде io_uring, но и от того, насколько глубоко вы понимаете базовые и продвинутые возможности системы.
Если вы хотите структурированно разбираться в устройстве Linux, настройке сервисов и управлении системами, обратите внимание на курсы Administrator Linux. Basic и Administrator Linux. Professional. Первый поможет освоить основы администрирования, необходимые для уверенной работы с системой, а второй позволит углубиться в продвинутые сценарии и тонкие настройки.
Чтобы узнать больше о курсах по Linux и получить доступ к записям открытых уроков, переходите в телеграм‑бот.
Regis
io_uring — *новую* высокопроизводительную...
— io_uring появился в 2019 году, если что.И обращу внимание на следующее, что следует упоминать каждый раз, когда речь идёт про io_uring: