Привет, Хабр! Уже давненько вокруг ходит тема про 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 и получить доступ к записям открытых уроков, переходите в телеграм‑бот.

Комментарии (1)


  1. Regis
    30.09.2025 16:36

    io_uring — *новую* высокопроизводительную... — io_uring появился в 2019 году, если что.

    И обращу внимание на следующее, что следует упоминать каждый раз, когда речь идёт про io_uring:

    In June 2023, Google's security team reported that 60% of the exploits submitted to their bug bounty program in 2022 were exploits of the Linux kernel's io_uring vulnerabilities. As a result, io_uring was disabled for apps in Android, and disabled entirely in ChromeOS as well as Google servers. Docker also consequently disabled io_uring from their default seccomp profile.