У io_uring репутация двойственная. С одной стороны, это самый быстрый способ работать с диском под Linux, интерфейс, ради которого PostgreSQL и RocksDB переписывают код своих движков хранения. С другой, именно его отключили на всех боевых серверах Google, выпилили из ChromeOS и заблокировали по умолчанию в Docker. Получается странная картина: технология, дающая лучшую в классе производительность, оказывается персоной нон грата там, где эту производительность ценят сильнее всего. Разобраться, почему так вышло, значит понять и сильные стороны io_uring, и его подводные камни в продакшене.
Появившийся в ядре 5.1 в 2019 году, io_uring задумывался как лекарство от боли старых интерфейсов асинхронного дискового ввода-вывода. Прежний механизм через семейство функций асинхронного чтения и записи был капризен, работал толком только с прямым вводом-выводом в обход кеша и регулярно срывался в синхронное поведение. io_uring обещал настоящую асинхронность для любого дискового доступа, и обещание это в целом сдержал. Но за производительность пришлось заплатить сложностью, а за сложность безопасностью.
Как кольцевые буферы убирают системные вызовы из горячего пути
В основе io_uring лежат два кольцевых буфера, расположенных в памяти, разделяемой между приложением и ядром. Очередь подачи, куда приложение складывает запросы на операции вроде чтения блока с диска, и очередь завершения, откуда забирает результаты выполненных операций. Поскольку память общая, приложению не нужно копировать запрос в ядро отдельным системным вызовом, оно просто записывает запрос в свою же память, которую ядро видит напрямую.
Это устраняет главную беду классического дискового доступа. Обычное чтение через pread или запись через pwrite это системный вызов на каждую операцию, переход в режим ядра и обратно. На потоке из сотен тысяч операций в секунду эти переходы съедают заметную долю процессорного времени. io_uring позволяет накопить пачку запросов и отправить их одним вызовом, а результаты забрать тоже пачкой, амортизируя стоимость перехода на множество операций. Минимальная отправка чтения с диска через библиотеку liburing выглядит так:
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, BLOCK_SIZE, offset); /* чтение блока с диска */
io_uring_submit(&ring); /* отправляем пачку запросов */
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe); /* ждём завершения */
if (cqe->res < 0) handle_error(-cqe->res); /* res < 0 это код ошибки */
io_uring_cqe_seen(&ring, cqe); /* освобождаем запись завершения */
Дальше в дело вступают режимы, ради которых интерфейс и стоит брать. Режим опроса очереди подачи, включаемый флагом IORING_SETUP_SQPOLL, запускает в ядре отдельный поток, который непрерывно сам вычитывает новые запросы из очереди. В установившемся режиме приложение не входит в ядро вообще, отправка работы не стоит ни одного системного вызова. В сочетании с опросным режимом устройств NVMe это даёт задержки, близкие к технологиям обхода ядра вроде SPDK, но без отказа от безопасности ядерного посредничества. Отдельно io_uring умеет сцеплять зависимые операции: прочитать файл, записать в сокет, сбросить на диск, и всё это подаётся единой связанной цепочкой, которую ядро выполняет по порядку, не возвращаясь в пользовательское пространство между шагами.
Где скрываются подводные камни производительности
Вот первое отрезвление: io_uring не панацея и на неподходящей нагрузке способен не дать ничего или даже навредить. Относиться к нему как к простой замене старых интерфейсов или epoll на нагрузке, не упирающейся в ввод-вывод, означает получить пренебрежимый или отрицательный эффект. Выигрыш появляется лишь там, где приложение действительно умеет пакетировать запросы и работать с памятью с умом.
Несколько граблей встречаются особенно часто. Первая это синхронная работа внутри цикла обработки. Даже несколько микросекунд вычислений, вставленных в поток подачи или приёма завершений, способны обрушить число операций в секунду на порядки. Поэтому разделение вычислений и обработки ввода-вывода по разным потокам становится фундаментом архитектуры, а не украшением. Вторая грабля это небрежность с буферами. io_uring передаёт ядру указатели на буферы, которые должны жить ровно столько, сколько живёт операция. Если приложение освободит или переиспользует буфер до прихода завершения, последствия варьируются от порчи данных до утечек, потому что ядро всё ещё пишет в эту память.
Третья тонкость касается чрезмерной пакетации и незарегистрированных буферов. Слишком агрессивное накопление запросов раздувает хвостовую задержку, ту самую задержку худших процентилей, которая решает судьбу отзывчивости сервиса. А крупные операции с буферами, не зарегистрированными заранее в кольце, заставляют ядро поднимать собственные рабочие потоки для их выполнения, и эти потоки добавляют задержку завершения. Лекарство в том, чтобы регистрировать горячие буферы заранее специальным вызовом, тогда ядро не тратится на их проверку и закрепление при каждой операции. Для приложений с предсказуемым низким джиттером рекомендуют режим отложенной обработки задач, а опрос очереди подачи добавляют только тогда, когда готовы пожертвовать выделенным ядром процессора под ядерный поток.
Опыт интеграции в PostgreSQL показателен. Систематический переход состоял в замене синхронных pread, pwrite и сброса на диск на подачу запросов через io_uring с обязательной регистрацией буферного пула. Без этой регистрации значительная часть выигрыша попросту не материализуется.
Ещё одна неочевидная ловушка кроется в обработке частичных операций. Запрос на чтение блока может вернуть меньше байт, чем просили, и это не ошибка, а штатное поведение, особенно на сокетах и каналах. Приложение обязано проверять поле результата в записи завершения и при недочитанном объёме повторно подавать запрос на оставшуюся часть, сдвинув смещение. Код, наивно считающий, что одна поданная операция чтения всегда возвращает ровно запрошенное, на боевой нагрузке рано или поздно получит обрезанные данные. По умолчанию io_uring к тому же прекращает подачу пачки запросов, наткнувшись на первую же ошибку в ней, поэтому при отправке независимых операций единым батчем нужен особый флаг, заставляющий ядро довести до конца все запросы пачки независимо от того, что часть из них завершилась ошибкой.
Почему один из самых быстрых интерфейсов признали угрозой безопасности
Теперь о главном парадоксе. io_uring оказался не просто сложным, а опасно сложным. Команда безопасности Google в 2023 году опубликовала отрезвляющую цифру: шестьдесят процентов эксплойтов ядра, поданных в их программу выплат за уязвимости в 2022 году, нацеливались именно на io_uring. Это не случайные баги, а системная проблема большой и запутанной поверхности атаки, и многие из этих уязвимостей вели к локальному повышению привилегий.
Реакция последовала жёсткая. Google отключил io_uring для приложений в Android, выключил его целиком в ChromeOS и на всех своих боевых серверах. Логика проста: интерфейс, дающий несколько процентов прироста производительности, не стоит риска, если через него пробивают защиту ядра.
Есть и вторая, более тонкая причина недоверия, особенно острая в контейнерах. io_uring способен выполнять операции в обход классической фильтрации системных вызовов через seccomp. Механизм seccomp перехватывает конкретные системные вызовы и решает, разрешить их или запретить, на этом строится песочница контейнеров. Но если приложение отправляет операцию чтения файла не прямым системным вызовом, а через кольцо io_uring, для seccomp эта операция невидима, потому что отдельного системного вызова на неё нет. Песочница, рассчитывающая на блокировку опасных вызовов, оказывается дырявой. Именно поэтому в стандартных профилях безопасности многих контейнерных сред системный вызов создания io_uring заблокирован по умолчанию, и без явного разрешения интерфейс просто не запускается. Docker Desktop, начиная с версии 4.42.0, блокирует системные вызовы io_uring в контейнерах ровно по этой причине, и обойти запрет привычным снятием ограничений seccomp не получается.
Как буферизованный и прямой доступ меняют поведение под нагрузкой
Дисковый ввод-вывод бывает двух сортов, и io_uring работает с обоими, но по-разному. Буферизованный доступ идёт через страничный кеш ядра: прочитанные блоки оседают в памяти, повторное чтение того же места обслуживается из кеша без обращения к диску. Прямой доступ через флаг открытия файла в обход кеша читает и пишет прямо на устройство, минуя страничный кеш целиком. Базы данных часто предпочитают прямой доступ, потому что управляют своим кешем сами и не хотят двойного кеширования, когда одни и те же данные лежат и в их пуле, и в кеше ядра.
Тонкость в том, что старые механизмы асинхронного ввода-вывода нормально умели только прямой доступ, а на буферизованном тихо срывались в синхронное поведение, сводя на нет всю асинхронность. io_uring снял это ограничение и честно асинхронен в обоих режимах. Но прямой доступ накладывает жёсткие требования выравнивания: смещение в файле, длина операции и адрес буфера в памяти обязаны быть кратны размеру логического блока устройства, обычно 512 байт или 4096. Нарушение выравнивания возвращает ошибку, и это частая причина загадочных сбоев при первом переходе на прямой доступ. Грамотный движок хранения выделяет буферы с выравниванием через специальный вызов и держит все смещения кратными размеру блока.
Отдельного внимания требует сброс на диск. Подтверждение записи завершением в очереди io_uring ещё не означает, что данные физически легли на пластину или флеш, они могли осесть в кеше устройства. Для гарантии долговечности нужна явная операция сброса, и io_uring умеет подавать её как обычный запрос наравне с чтением и записью, в том числе в составе цепочки. Это позволяет выстроить надёжный порядок: записать данные, затем сбросить, и только после подтверждения сброса считать транзакцию зафиксированной. Игнорирование этого шага ради скорости оборачивается потерей данных при внезапном отключении питания.
Чем версия ядра определяет надёжность и набор возможностей
io_uring молод, и это чувствуется в его эволюции. Ранние версии ядра несли io_uring-уязвимости и вели себя нестабильно, отдельные возможности появлялись постепенно. Известны и сугубо ядерные дефекты вроде взаимоблокировки при завершении процесса, когда поток опроса очереди подачи, созданный в отложенно-выключенном состоянии, и завершающийся процесс начинали ждать друг друга насмерть. Такие проблемы лечились заплатками в самом ядре, а значит надёжность интерфейса напрямую зависит от свежести ядра.
Граница зрелости проходит примерно по версии 5.15 и выше, где интерфейс значительно стабилизировался. После этого рубежа крупные проекты понесли io_uring в продакшен всерьёз: PostgreSQL, RocksDB, Nginx, асинхронная среда Tokio. Практический вывод отсюда такой: если приложению нужно поддерживать ядра старше 5.1, либо запускаться в окружениях, где io_uring заблокирован, разумнее либо отказаться от него, либо спрятать за слоем абстракции с откатом на классические механизмы.
Картина выгод и рисков выстраивается так. io_uring выигрывает на нагрузках, упирающихся в дисковый ввод-вывод, где можно пакетировать запросы, заранее регистрировать буферный пул, задействовать опрос NVMe и ядерный опрос очереди. Именно поэтому его так любят разработчики высоконагруженных баз данных и движков хранения. Но за скорость приходится платить тремя вещами сразу: завязкой на свежее ядро, кропотливым управлением временем жизни буферов и колец, и серьёзными вопросами безопасности, из-за которых интерфейс отключён в осторожных средах.
Итоговая рекомендация для боевого применения звучит трезво. Брать io_uring стоит для новых систем, упирающихся в производительность хранения, и обязательно на свежем ядре. Закладывать в архитектуру разделение вычислений и ввода-вывода по потокам, регистрировать горячие буферы, не переусердствовать с пакетацией ради хвостовой задержки, держать наготове откат на классический путь и трезво оценивать, не отключён ли интерфейс в целевом окружении из соображений безопасности. Тогда самый быстрый дисковый интерфейс Linux раскроется именно как ускоритель, а не как источник ночных дежурств и пробитых песочниц.