io_uring_enter() — системный вызов, через который приложение отправляет запросы в кольцо и ждёт завершений. Часть операций, выполняемых по этим запросам, может заблокировать вызывающий поток: мьютекс, семафор, выделение памяти, синхронная отправка ввода-вывода. Заблокированный поток не может ни отправлять новые запросы, ни забирать готовые результаты, поэтому ядро вынуждено что-то с этим делать. Долгое время решением была передача такой операции отдельному рабочему потоку — с прогревом, переключением контекста и прочими накладными расходами, которые платятся даже тогда, когда операция в итоге не блокируется. Разбираем, как устроена текущая модель, что предлагается взамен и почему новая схема вызывает у разработчиков одновременно интерес и опасения.
Почему io_uring_enter() вообще блокируется
В текущих ядрах любая операция, которая может заставить io_uring_enter() заблокироваться, передаётся отдельному рабочему потокупоток из пула io_uring, которому поручают выполнение операции, способной заблокироваться. Это позволяет отправляющему потоку продолжить работу, а рабочему — блокироваться, если понадобится, не задерживая ничего другого.
Блокирующие операции чрезвычайно распространены. Каждый мьютекс или семафор, выделение памяти, синхронные отправки ввода-вывода (в зависимости от настройки) — всё это может остановить поток. Вместо того чтобы пытаться обойти блокировку, выбрана другая стратегия: выполнять потенциально блокирующую операцию, а затем обрабатывать случаи, которые действительно блокируются, не блокируя поток-отправитель.
Передача не бесплатна: ядро должно разбудить ожидающий рабочий поток и выполнить переключение контекста, среди прочих затрат. Если операция действительно блокируется, эти затраты могут оказаться незначительными. Но во многих случаях операция завершается без блокировки — и тогда дополнительные накладные расходы становятся существенной частью общей стоимости выполнения запроса.
Чтобы обнаружить реальную блокировку, в поле flags структуры task_structструктура ядра, представляющая поток добавляется новый флаг PF_IO_HANDOFF. Если поток собирается заблокироваться по любой причине и у него установлен этот флаг, планировщик вызовет новую функцию io_uring_task_sleeping(). Такой перехват в планировщике позволяет io_uring узнавать о блокировке в любом месте ядра без изменения самих кодовых путей.
Откатить уже проделанную работу до точки блокировки и перезапустить операцию в рабочем потоке — не вариант: блокировка может произойти почти в любом месте ядра. По общему правилу, начав запрошенную операцию с участием кодовых путей ядра, не рассчитанных на избегание блокировки, io_uring должен довести её до конца.
Существующая модель: передача работы в пул потоков
Решение о том, что операция действительно блокируется, принимает планировщик — через флаг PF_IO_HANDOFF и вызов io_uring_task_sleeping(). Получив уведомление, io_uring выбирает рабочий поток из своего пула и обменивает идентичности двух потоков.
Рабочий поток делается похожим на исходный отправляющий поток во всём, включая его thread IDидентификатор потока, который видит пользовательское пространство, и настройку обработки сигналов. Этот поток продолжает обработку submission ringкольца отправки — очереди, куда приложение кладёт запросы для ядра, а затем возвращается в пользовательское пространство. Исходный поток, который собирался заблокироваться, принимает идентичность рабочего, блокируется как обычно, а после пробуждения доводит операцию до завершения и занимает место в пуле рабочих потоков. Результат клиенту возвращается тем потоком, который возвращается из io_uring_enter(), хотя у него другой task_struct, чем у сделавшего вызов.
Передача идентичности возможна только при выполнении условий из thread_handoff_allowed() и thread_handoff_compatible(). Запрещают её, например, трассировка через ptrace(), использование perf events, отслеживание владения futex в ядре, работа под realtime-планировщиком и выполнение vfork(). В таких случаях операция выполняется старым способом.
Пул рабочих потоков настраивается пользователем двумя способами. Маска CPU для асинхронных рабочих потоков задаётся через io_uring_register_iowq_aff, который принимает cpu_set_t и размер маски в байтах; io_uring_unregister_iowq_aff её отменяет. По умолчанию рабочие потоки наследуют CPU-маску родителя. Максимальное число рабочих потоков на NUMA-узел задаёт io_uring_register_iowq_max_workers, принимающий массив из двух unsigned int: индекс 0 — число ограниченных потоков, индекс 1 — неограниченных. При успехе в массиве возвращаются прежние значения, а нулевое значение лишь запрашивает текущие максимумы без изменения.
Идея thread identity handoff: пошаговый механизм
Новая схема строится на том, что работа не передаётся другому потоку, а обмениваются идентичности. Вот как это происходит по шагам.
Планировщик обнаруживает блокировку через флаг PF_IO_HANDOFF в поле flags структуры task_struct. Если поток собирается заблокироваться и флаг установлен, планировщик вызывает io_uring_task_sleeping(). Получив это уведомление, io_uring выбирает рабочий поток из своего пула потоков и вместо передачи работы обменивает идентичности двух потоков: рабочий поток делается похожим на исходный отправляющий поток во всём, включая его thread ID и настройку обработки сигналов, и продолжает обработку submission ring, пока не вернётся в пространство пользователя. Исходный поток, который собирался заблокироваться, принимает идентичность рабочего потока и затем блокируется как обычно; проснувшись, он доводит операцию до завершения, а затем занимает место в пуле рабочих потоков.
Это и есть thread identity handoffПередача потенциально блокирующей операции отдельному рабочему потоку, чтобы отправляющий поток мог продолжить работу.: стоимость привлечения рабочего потока платится только если операция действительно блокируется, а потенциально блокирующие операции может выполнять сам отправляющий поток, если они не блокируются.
Перед обменом структурами task_struct ядро должно абсолютно удостовериться, что ничто другое в ядре не держит ссылок на эти структуры, иначе в итоге что-то будет сделано со ссылкой на неверный task_struct. Именно поэтому нужны проверки до начала потенциально блокирующей операции: нужно быть уверенным, что отправляющий поток сможет передать свою идентичность, если возникнет необходимость. Список условий, препятствующих передаче, находится в определениях thread_handoff_allowed() и thread_handoff_compatible(). Например, если поток трассируется через ptrace(), трассировщик держит ссылку на его task_struct. По той же логике, если поток сам трассирует другие задачи, те держат ссылки, поэтому передача невозможна. Передачу также предотвращают использование perf events, отслеживание владения futex в ядре, работа под realtime-планировщиком, удержание core scheduling cookieМаркер принадлежности потока к группе совместного планирования; его удержание запрещает передачу идентичности при handoff., выполнение vfork() и ряд других условий.
После завершения блокирующей операции идентичность возвращается обратно. Пробудившийся исходный поток продолжает операцию до завершения, а затем занимает место в пуле рабочих потоков. Рабочий поток, получивший идентичность исходного, продолжает обработку submission ring и в итоге возвращается в пользовательское пространство. Поток, возвращающийся из io_uring_enter(), имеет другой task_struct, чем сделавший вызов, но всё остальное выглядит так же. Результат операции доводится до завершения уже в потоке, который изначально собирался блокироваться, а не в отдельном рабочем потоке.
Пограничные случаи и корректность
Определение условий, при которых передача идентичности разрешена, названо самой страшной и хрупкой частью серии: task_struct широко доступен, и трудно знать, что все возможности ссылок в ядре покрыты. Именно поэтому проверки вынесены в отдельные функции и выполняются до старта операции.
Отдельно упоминаются регрессии при высокой глубине очереди. Немедленная отправка каждой операции в рабочий поток позволяет обрабатывать их параллельно, тогда как обработка до точки блокировки в отправляющем потоке сериализует работу и замедляет её. Худшие регрессии наблюдались именно при большей глубине очереди — когда накапливается длинный список операций, отправляемых разом.
Что видит клиент
При handoff ядро обменивает идентичности двух потоков: рабочий поток становится похож на исходный submitting-потокпоток, который отправил запрос в io_uring, включая thread ID и настройку обработки сигналов, и именно он продолжает обработку submission ring, а затем возвращается в user space; исходный поток принимает идентичность рабочего и блокируется. Поток, возвращающийся из io_uring_enter(), имеет другой task_struct, чем поток, сделавший вызов.
Для приложения это означает, что отправка запросов и ожидание завершений продолжают работать как обычно: io_uring_submit() отправляет подготовленные sqe, io_uring_submit_and_wait() дополнительно ждёт заданное число событий, io_uring_peek_cqe() отдаёт уже готовое завершение, а io_uring_wait_cqe() при его отсутствии переходит к ожиданию одного события. Семантика этих обёрток в связи с handoff не меняется.
Ошибки и таймауты при неудачном handoff или отмене операции в статье не разбираются. Известно лишь, что ожидание с таймаутом сводится к ожиданию одного события, а отмена оформляется как отдельный запрос IORING_OP_ASYNC_CANCEL либо как синхронная отмена через регистрацию.
Почему сделано именно так: компромиссы
Рассматривались несколько альтернатив. «Green threads» — комментатор Cyberax замечает, что теперь в ядре будет полноценный runtime зелёных потоков. Выделенный пул потоков-исполнителей io_uring: himi описывает пул выделенных пользовательских потоков, управляемых подсистемой io_uring, которые были бы единственными, кто может вызывать io_uring_enter(). Наконец, переработка всех системных вызовов ядра в неблокирующие — но это «not an easy or quick task».
Thread identity handoff предпочтителен потому, что откат уже проделанной работы до точки блокировки и перезапуск операции в рабочем потоке невозможны: блокировка может случиться почти в любом месте ядра, и, как правило, начатую операцию нужно довести до конца. Кроме того, стоимость привлечения рабочего потока оплачивается только если операция действительно блокируется.
В комментариях к статье обсуждается риск сложности сопровождения. На реплику «It sounds simple enough» отвечают: «No. No, it really doesn't. It sounds like a maintenance nightmare», а k3ninho добавляет, что читает это с сарказмом и «running away to the safety of Nope». himi называет идею «really dangerous in the context of a kernel that wasn't designed with this execution model in mind», поскольку task_struct — это «a big grab-bag of stuff used for so many things», и предлагает взамен пул выделенных пользовательских потоков, управляемых io_uring, которые только и могут вызывать io_uring_enter().
Влияние на планировщик и безопасность смены идентичности обсуждают через альтернативу alkbyby — делать переключение не в точке блокировки, а на io_uring_enter, как «coroutine-like switch», что «could be (more) immune to "is it safe to swap identities" questions»; ebiederm отвечает, что предлагал это и автор «was quite receptive to the idea». Axboe отвечает на возражение о shadow stack: он «thinks that the shadow stack can be moved with the rest of the thread's identity», а на регрессии при высокой глубине очереди у него «has ideas for addressing that problem», хотя он не уверен, стоит ли их преследовать. В целом разработчики ещё «mostly digesting this series», которую Gabriel Krisman Bertazi назвал «really cool and seems like very dangerous thing».
Статус и перспективы
Axboe явно не ожидает слияния этой серии в ближайшем будущем; его больше заботит определение того, имеет ли общий подход шанс быть жизнеспособным. Комментариев пока было мало. Peter Zijlstra указал, что одно из условий, блокирующих identity handoff — если задействованный поток работает с shadow stack — не даст использовать эту возможность на большинстве развёрнутых систем; Axboe считает, что shadow stack можно переместить вместе с остальной частью идентичности потока.
Если серия сможет убедить разработчиков, что с «опасной» частью разобрались, она в конечном счёте может привести к значительно лучшей производительности io_uring для ряда рабочих нагрузок.
Связь с публичным API io_uring
С обсуждаемым механизмом напрямую связаны системные вызовы io_uring_setup, io_uring_enter, io_uring_enter2 и io_uring_register. io_uring_setup создаёт submission queue и completion queue и возвращает файловый дескриптор для последующих операций над инстансом io_uring. io_uring_enter и io_uring_enter2 отправляют запросы и ждут завершения. io_uring_register регистрирует ресурсы (буферы, файлы, eventfd, personality, restrictions) для инстанса, на который указывает fd. Обёртка io_uring_queue_init_params инициализирует кольцо с заданными entries и параметрами io_uring_params, а io_uring_queue_init_mem делает то же с пользовательским буфером.
Регистрация выполняется через do_register, который при флаге INT_FLAG_REG_REG_RING добавляет к opcode флаг IORING_REGISTER_USE_REGISTERED_RING и использует ring->enter_ring_fd вместо ring->ring_fd.
Среди opcode-ов регистрации напрямую видны IORING_REGISTER_BUFFERS, IORING_REGISTER_BUFFERS2, IORING_REGISTER_BUFFERS_UPDATE, IORING_UNREGISTER_BUFFERS, IORING_REGISTER_FILES, IORING_REGISTER_FILES2, IORING_REGISTER_FILES_UPDATE, IORING_REGISTER_FILES_UPDATE2, IORING_UNREGISTER_FILES, IORING_REGISTER_EVENTFD, IORING_REGISTER_EVENTFD_ASYNC, IORING_UNREGISTER_EVENTFD, IORING_REGISTER_PROBE, IORING_REGISTER_PERSONALITY, IORING_UNREGISTER_PERSONALITY, IORING_REGISTER_ENABLE_RINGS, IORING_REGISTER_RESTRICTIONS, IORING_REGISTER_IOWQ_AFF, IORING_UNREGISTER_IOWQ_AFF, IORING_REGISTER_IOWQ_MAX_WORKERS, IORING_REGISTER_RING_FDS, IORING_UNREGISTER_RING_FDS, IORING_REGISTER_PBUF_RING, IORING_UNREGISTER_PBUF_RING, IORING_REGISTER_SYNC_CANCEL, IORING_REGISTER_FILE_ALLOC_RANGE, IORING_REGISTER_PBUF_STATUS, IORING_REGISTER_NAPI, IORING_UNREGISTER_NAPI, IORING_REGISTER_CLOCK, IORING_REGISTER_CLONE_BUFFERS, IORING_REGISTER_SEND_MSG_RING, IORING_REGISTER_RESIZE_RINGS, IORING_REGISTER_MEM_REGION, IORING_REGISTER_ZCRX_IFQрегистрирует интерфейсную очередь zero-copy receive для сетевых операций приёма, IORING_REGISTER_QUERYзапрашивает возможности io_uring и поддержку функций; не требует кольца и может вызываться с fd = -1 и IORING_REGISTER_ZCRX_CTRLвыполняет управляющие операции над ранее зарегистрированным контекстом zero-copy receive.
Расширениями механизма являются, например, IORING_REGISTER_MEM_REGION — регистрация регионов памяти, чтобы избежать копирования struct io_uring_getevents_arg при ожидании с таймаутом, — и IORING_REGISTER_RESIZE_RINGS — изменение размеров SQ и CQ.
Флаги возможностей io_uring_params->features включают IORING_FEAT_SINGLE_MMAP, IORING_FEAT_NODROP, IORING_FEAT_SUBMIT_STABLE, IORING_FEAT_RW_CUR_POS, IORING_FEAT_CUR_PERSONALITY, IORING_FEAT_FAST_POLL, IORING_FEAT_POLL_32BITS, IORING_FEAT_SQPOLL_NONFIXED, IORING_FEAT_EXT_ARG, IORING_FEAT_NATIVE_WORKERSуказывает, что ядро использует собственных рабочих исполнителей io_uring, а не унаследованных от старой схемы, IORING_FEAT_RSRC_TAGS, IORING_FEAT_CQE_SKIP, IORING_FEAT_LINKED_FILE, IORING_FEAT_REG_REG_RINGпозволяет зарегистрировать файловый дескриптор самого кольца и тем самым снизить накладные расходы io_uring_enter, IORING_FEAT_RECVSEND_BUNDLE, IORING_FEAT_MIN_TIMEOUT, IORING_FEAT_RW_ATTR и IORING_FEAT_NO_IOWAITсообщает, что ядро не выставляет флаг IOWAIT в задаче при ожидании завершений.
Флаги io_uring_setup(): IORING_SETUP_IOPOLL (io_context опрашивается), IORING_SETUP_SQPOLL (поток опроса SQ), IORING_SETUP_SQ_AFF (sq_thread_cpu действителен), IORING_SETUP_CQSIZE (приложение задаёт размер CQ), IORING_SETUP_CLAMP (ограничение размеров колец SQ/CQ), IORING_SETUP_ATTACH_WQ (присоединение к существующему wq), IORING_SETUP_R_DISABLED (старт с отключённым кольцом), IORING_SETUP_SUBMIT_ALL (продолжать отправку при ошибке), IORING_SETUP_COOP_TASKRUN, IORING_SETUP_TASKRUN_FLAG, IORING_SETUP_SQE128 (SQE по 128 байт), IORING_SETUP_CQE32 (CQE по 32 байта), IORING_SETUP_SINGLE_ISSUER (только одна задача может отправлять запросы), IORING_SETUP_DEFER_TASKRUN, IORING_SETUP_NO_MMAP (память для колец предоставляет приложение), IORING_SETUP_REGISTERED_FD_ONLY, IORING_SETUP_NO_SQARRAY (убирает косвенность через массив индексов SQ), IORING_SETUP_HYBRID_IOPOLL (гибридный опрос в iopoll), IORING_SETUP_CQE_MIXED (разрешает и 16-байтные, и 32-байтные CQE), IORING_SETUP_SQE_MIXED (разрешает и 64-байтные, и 128-байтные SQE), IORING_SETUP_SQ_REWIND (игнорирует SQ head/tail и берёт SQE с индекса 0).
Флаги SQE задаются в sqe->flags: IOSQE_FIXED_FILE (использовать фиксированный набор файлов), IOSQE_IO_DRAIN (выполнять после незавершённого IO), IOSQE_IO_LINK (связывает следующий SQE), IOSQE_IO_HARDLINK (как LINK, но сильнее), IOSQE_ASYNC (всегда идти асинхронно), IOSQE_BUFFER_SELECT (выбрать буфер из sqe->buf_group), IOSQE_CQE_SKIP_SUCCESS (не публиковать CQE при успехе). Флаг IORING_CQE_F_32 в cqe->flags указывает, что CQE 32-байтный, и io_uring_cqe_nr возвращает 1U << shift, где shift = !!(cqe->flags & IORING_CQE_F_32).
Ошибки настройки кольца в io_uring_setup(2): EFAULT — когда params находится вне доступного адресного пространства; EINVAL — при ненулевых данных в resv, неподдерживаемом флаге в p.flags, выходе entries за границы, а также при SQ_AFF без SQPOLL, CQSIZE с некорректным cq_entries и REGISTERED_FD_ONLY без NO_MMAP; EMFILE/ENFILE — при достижении лимитов дескрипторов; ENOMEM — при нехватке ресурсов ядра; EPERM — при SQPOLL без достаточных привилегий или при значении io_uring_disabled 2 (либо 1 без CAP_SYS_ADMIN/членства в io_uring_group); ENXIO — когда ATTACH_WQ задан, но wq_fd не ссылается на io_uring или ссылается на завершающийся экземпляр.
Ошибки регистрации ресурсов в io_uring_register(2): EACCES — opcode не разрешён из-за зарегистрированных ограничений; EBADF — недействительные fd в массиве; EBADFD — ENABLE_RINGS или RESTRICTIONS при неотключённом кольце; EBUSY — повторная регистрация буферов, файлов или ограничений; EEXIST — недействительный поток регистрации; EFAULT — буфер вне доступного адресного пространства или iov_len больше 1GiB; EINVAL — нулевой nr_args для BUFFERS/FILES, превышение UIO_MAXIOV, ненулевой nr_args или не-NULL arg при UNREGISTER, превышение числа ограничений или неверный opcode ограничения, а также PBUF_STATUS с группой не из PBUF_RING или NAPI без IOPOLL; EMFILE — превышение максимума файлов в фиксированном наборе или лимита RLIMIT_NOFILE без CAP_SYS_RESOURCE; ENOMEM — нехватка ресурсов ядра или превышение RLIMIT_MEMLOCK без CAP_IPC_LOCK; ENXIO — отсутствие зарегистрированных буферов/файлов при UNREGISTER или попытка регистрации на экземпляре, уже выполняющем регистрацию либо разрушаемом; EOPNOTSUPP/EFAULT — буферы указывают на память, backed файлом; ENOENT — PBUF_STATUS с недействительной группой буферов.
Что из этого следует на практике
- Потенциально блокирующие операции в io_uring сегодня всегда платят за передачу в рабочий поток — даже когда фактической блокировки не происходит. Именно эту плату и призван убрать thread identity handoff: стоимость привлечения рабочего потока оплачивается только если операция действительно блокируется.
- Обнаружение блокировки опирается на флаг
PF_IO_HANDOFFи вызовio_uring_task_sleeping()из планировщика. Это даёт io_uring информацию о блокировке в любом месте ядра без правки самих кодовых путей, но требует изменения планировщика. - Передача идентичности — не универсальная операция. Она запрещена при трассировке через
ptrace(), использовании perf events, отслеживании владения futex, работе под realtime-планировщиком, удержании core scheduling cookie, выполненииvfork()и ряде других условий; тогда операция выполняется старым способом. Определение этих условий названо самой хрупкой частью серии. - Обмен
task_structтребует, чтобы никакая другая часть ядра не держала ссылок на эти структуры. Отсюда и длинный список запрещающих состояний, и необходимость проверок до старта потенциально блокирующей операции. - Худшие регрессии ожидаются при высокой глубине очереди: немедленная отправка операций в рабочий поток позволяет обрабатывать их параллельно, а обработка до точки блокировки в отправляющем потоке сериализует работу.
- Для приложения, судя по liburing, семантика обёрток не меняется:
io_uring_submit()по-прежнему возвращает число отправленных sqe,io_uring_peek_cqe()— готовый CQE или-EAGAIN,io_uring_wait_cqe()при отсутствии готового CQE уходит в ожидание одного события. Меняется только то, какой именноtask_structвозвращается изio_uring_enter(). - Настройки пула рабочих потоков остаются в силе: маска CPU через
io_uring_register_iowq_aff, лимиты boundedОграниченные рабочие потоки io_uring, число которых по умолчанию зависит от размера SQ-кольца и числа CPU в системе./unboundedНеограниченные рабочие потоки io_uring, число которых по умолчанию ограничено значением RLIMIT_NPROC. потоков на NUMA-узел черезio_uring_register_iowq_max_workers.
Где смотреть в коде
- liburing.h: io_uring_peek_cqe
- queue.c: __io_uring_submit
- queue.c: __io_uring_submit_and_wait_timeout
- liburing.h: io_uring_enter
- liburing.h: io_uring_prep_timeout_remove
- liburing.h: io_uring_queue_init_mem
- queue.c: __io_uring_submit_timeout
- register.c: do_register