Назад к блогу

Смена идентичности потоков в io_uring: как ядро решает проблему блокировок

Смена идентичности потоков в io_uring: как ядро решает проблему блокировок

Ядро Linux решает давнюю проблему io_uring: что делать, когда операция, отправленная через `io_uring_enter()`, внезапно блокируется и «замораживает» вызывающий поток. Вместо привычной передачи работы в пул потоков с его накладными расходами предлагается новая схема — обмен идентичностями между потоками прямо в момент блокировки. Разбираем, как это устроено и почему у разработчиков одновременно вызывает интерес и опасения.

io_uring_enter() — системный вызов, через который приложение отправляет запросы в кольцо и ждёт завершений. Часть операций, выполняемых по этим запросам, может заблокировать вызывающий поток: мьютекс, семафор, выделение памяти, синхронная отправка ввода-вывода. Заблокированный поток не может ни отправлять новые запросы, ни забирать готовые результаты, поэтому ядро вынуждено что-то с этим делать. Долгое время решением была передача такой операции отдельному рабочему потоку — с прогревом, переключением контекста и прочими накладными расходами, которые платятся даже тогда, когда операция в итоге не блокируется. Разбираем, как устроена текущая модель, что предлагается взамен и почему новая схема вызывает у разработчиков одновременно интерес и опасения.

Почему io_uring_enter() вообще блокируется

В текущих ядрах любая операция, которая может заставить io_uring_enter() заблокироваться, передаётся отдельному рабочему потоку. Это позволяет отправляющему потоку продолжить работу, а рабочему — блокироваться, если понадобится, не задерживая ничего другого.

Блокирующие операции чрезвычайно распространены. Каждый мьютекс или семафор, выделение памяти, синхронные отправки ввода-вывода (в зависимости от настройки) — всё это может остановить поток. Вместо того чтобы пытаться обойти блокировку, выбрана другая стратегия: выполнять потенциально блокирующую операцию, а затем обрабатывать случаи, которые действительно блокируются, не блокируя поток-отправитель.

Передача не бесплатна: ядро должно разбудить ожидающий рабочий поток и выполнить переключение контекста, среди прочих затрат. Если операция действительно блокируется, эти затраты могут оказаться незначительными. Но во многих случаях операция завершается без блокировки — и тогда дополнительные накладные расходы становятся существенной частью общей стоимости выполнения запроса.

Чтобы обнаружить реальную блокировку, в поле 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, выполнение vfork() и ряд других условий.

После завершения блокирующей операции идентичность возвращается обратно. Пробудившийся исходный поток продолжает операцию до завершения, а затем занимает место в пуле рабочих потоков. Рабочий поток, получивший идентичность исходного, продолжает обработку submission ring и в итоге возвращается в пользовательское пространство. Поток, возвращающийся из io_uring_enter(), имеет другой task_struct, чем сделавший вызов, но всё остальное выглядит так же. Результат операции доводится до завершения уже в потоке, который изначально собирался блокироваться, а не в отдельном рабочем потоке.

Пограничные случаи и корректность

Определение условий, при которых передача идентичности разрешена, названо самой страшной и хрупкой частью серии: task_struct широко доступен, и трудно знать, что все возможности ссылок в ядре покрыты. Именно поэтому проверки вынесены в отдельные функции и выполняются до старта операции.

Отдельно упоминаются регрессии при высокой глубине очереди. Немедленная отправка каждой операции в рабочий поток позволяет обрабатывать их параллельно, тогда как обработка до точки блокировки в отправляющем потоке сериализует работу и замедляет её. Худшие регрессии наблюдались именно при большей глубине очереди — когда накапливается длинный список операций, отправляемых разом.

Что видит клиент

При handoff ядро обменивает идентичности двух потоков: рабочий поток становится похож на исходный submitting-поток, включая 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, IORING_REGISTER_QUERY и IORING_REGISTER_ZCRX_CTRL.

Расширениями механизма являются, например, 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, IORING_FEAT_RSRC_TAGS, IORING_FEAT_CQE_SKIP, IORING_FEAT_LINKED_FILE, IORING_FEAT_REG_REG_RING, IORING_FEAT_RECVSEND_BUNDLE, IORING_FEAT_MIN_TIMEOUT, IORING_FEAT_RW_ATTR и IORING_FEAT_NO_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/unbounded потоков на NUMA-узел через io_uring_register_iowq_max_workers.

Где смотреть в коде

Источники

Похожее