CHAPTER 01

Глава 1: Ментальная модель асинхронности: триада Future, Waker и исполнителя

Проект: tokio-rs/tokio · Прогресс книги: глава 1 / 14 · Статус верификации: FACT — реальная привязка к номерам строк

Асинхронное программирование в Rust — это не библиотека, а протокол языкового уровня. Tokio стал production-grade рантаймом не потому, что изобрёл Future, а потому, что точно реализовал граничные условия каждого контракта этого протокола. В этой главе мы не будем спешить с погружением в код планировщика Tokio, а сначала досконально разберём «триаду» — Future, Waker, Executor — их границы ответственности и обратный поток управления. Понимание того, как эти три компонента сцепляются, даст опору для последующих глав: сборки Runtime, work-stealing планирования и I/O-драйвера.

1.1 От блокировки к опросу: почему Rust выбрал poll, а не колбэки

Интуитивная модель

Представьте, что вы заказали в ресторане блюдо, которое готовят на месте. Асинхронность на колбэках (как в раннем Node.js) — это когда вы оставляете номер телефона, и повар, приготовив,сам звонит вам— контроль у повара, ваш код лишь пассивно реагирует. Асинхронность на опросе (выбор Rust) — это когда вы получаете талон на выдачу блюда исами решаетекогда подойти к окну и спросить «готово?»: не готово — идёте заниматься другими делами, готово — забираете.

Это различие кажется незначительным, но оно определяет форму всей системы. В модели колбэков каждая асинхронная операция должна нести замыкание «что делать после завершения», замыкания вкладываются друг в друга, образуя callback hell, а отмена операции крайне затруднена — вы не можете «отозвать» уже зарегистрированный колбэк. В модели опроса Future — это просто конечный автомат,poll— это чистое действие запроса, без продвижения не потребляются ресурсы, отмена — это drop, чисто и аккуратно.

Ключевой контракт модели опроса

Определённый в стандартной библиотеке RustFuturetrait содержит всего два элемента: методpollи ассоциированный типOutput. Tokio не переопределяет этот trait, а напрямую переиспользует реализацию из стандартной библиотеки. Это явно отражено в исходном коде:

rust
// tokio/src/future/mod.rs
cfg_not_trace! {
    cfg_rt! {
        pub(crate) use std::future::Future;
    }
}

📎 tokio/src/future/mod.rs:24-28

Этот код раскрывает важный факт: когда функцияtracingне включена, внутреннийFutureв Tokio — этоstd::future::Futureпсевдоним без какой-либо обёртки. Только при включенииtracingон заменяется наInstrumentedFuture:

rust
cfg_trace! {
    mod trace;
    #[allow(unused_imports)]
    pub(crate) use trace::InstrumentedFuture as Future;
}

📎 tokio/src/future/mod.rs:18-22

〔Проектные предположения и архитектурные компромиссы〕

Этот подход «по умолчанию нулевые накладные расходы, инструментирование по требованию» — последовательная философия Tokio: критический путь не вводит никаких дополнительных слоёв абстракции, а наблюдаемость накладывается как опциональная возможность.InstrumentedFutureСуществование

показывает, что команда Tokio считает: стоимость инструментирования tracing не должна ложиться на всех пользователей.

pollТри неявных ограничения контракта pollfn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>Сигнатура метода

. В этой сигнатуре скрыты три контракта, нарушение любого из которых приводит к неопределённому поведению или логической ошибке: Pin<&mut Self>Контракт первый: Pin гарантирует безопасность самоссылок.

означает, что после первого poll Future его адрес в памяти не может быть перемещён. Это связано с тем, что async-блок после компиляции порождает конечный автомат, содержащий самоссылки — локальные переменные могут держать ссылки на другие поля того же конечного автомата. Если разрешить перемещение, эти ссылки станут висячими.Контракт второй: Pending требует уже зарегистрированного пробуждения.pollКогдаPoll::Pendingвозвращаетcx.waker()Получен и сохранён Waker, либо Waker уже зарегистрирован в некотором источнике событий. В противном случае исполнитель никогда не узнает, когда этот Future может быть снова опрошен, что приведёт к永久ному зависанию задачи.

Контракт третий: после Ready повторный poll не должен выполняться.Как толькоpollвозвращаетPoll::Ready, повторный poll того же Future является логической ошибкой (хотя это не приводит к UB, поведение не определено). Исполнитель обязан после получения Ready больше не планировать эту задачу.

Из этих трёх контрактов второй — наиболее подверженный ошибкам, и именно он является фундаментальной причиной существования Waker.

1.2 Waker: носитель обратного потока управления

Интуитивная модель

Waker — это «вибропейджер» для получения заказа, который выдаёт вам ресторан. Вам не нужно стоять у окна и repeatedly спрашивать «готово ли» — это тратило бы ваше время. Вам достаточно при первом подходе к окну передать пейджер повару (зарегистрировать Waker), а затем спокойно заниматься другими делами. Когда блюдо готово, повар нажимает кнопку, пейджер вибрирует (вызываетсяwake), вы получаете сигнал и идёте к окну забирать заказ (повторный poll).

Без Waker у исполнителя есть только два варианта: либо занято опрашивать все задачи (трата CPU), либо никогда не опрашивать задачи, вернувшие Pending (голодание задач). Waker — единственный механизм, разрывающий этот тупик.

Структура памяти Waker и дизайн виртуальной таблицы

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

rust
// 标准库中的定义(非 Tokio 源码,此处为背景说明)
pub struct RawWaker {
    data: *const (),
    vtable: &'static RawWakerVTable,
}

pub struct RawWakerVTable {
    clone: unsafe fn(*const ()) -> RawWaker,
    wake: unsafe fn(*const ()),
    wake_by_ref: unsafe fn(*const ()),
    drop: unsafe fn(*const ()),
}
〔Проектные предположения и архитектурные компромиссы〕

Изящество этого дизайна в том, что:Wakerсам по себе не заботится о том, что конкретно означает «пробуждение». Он лишь носитель четырёх указателей на функции. Tokio может предоставить Waker, чьяwakeфункция повторно помещает задачу в очередь планирования; а другая среда выполнения (например,futurescrateblock_on) может предоставить совершенно иную реализацию Waker. Эта модель «данные + виртуальная таблица» позволяет передавать Waker между разными средами выполнения без потери семантики.

wakeРазличие междуwake_by_refиwakeкритически важно:wake_by_refпотребляет владение Waker (после вызова Waker уничтожается), аwake_by_refтолько заимствует. Исполнитель обычно реализуетwakeкак «пометить задачу готовой и поставить в очередь», а

дополнительно обрабатывает уменьшение счётчика ссылок. В структуре задач Tokio указатель на данные Waker указывает на заголовок счётчика ссылок задачи; каждый clone увеличивает счётчик, drop уменьшает, а при обнулении счётчика память задачи освобождается.

Полная временная диаграмма пробуждения

mermaid
sequenceDiagram
    participant App as 应用任务
    participant Exec as 调度器 Worker
    participant Future as TcpStream::read Future
    participant Reactor as I/O 驱动 (epoll)
    participant Kernel as 操作系统内核

    App->>Future: poll(cx) 携带 Waker
    Future->>Reactor: 注册可读兴趣 + 保存 Waker
    Reactor->>Kernel: epoll_ctl(ADD, fd, EPOLLIN)
    Future-->>Exec: 返回 Poll::Pending
    Note over Exec: 任务挂起,Worker 去执行其他任务
    Kernel-->>Reactor: epoll_wait 返回 fd 就绪
    Reactor->>Reactor: 查找 fd 对应的 Waker
    Reactor->>Exec: waker.wake_by_ref()
    Note over Exec: 任务重新入队
    Exec->>Future: 再次 poll(cx)
    Future->>Kernel: read(fd, buf) 非阻塞读取
    Kernel-->>Future: 返回数据
    Future-->>App: 返回 Poll::Ready(n)

КопироватьКлючевой момент этой диаграммы:Waker — единственный канал, способный обратно достичь Executor из Reactor

. Reactor не хранит никакой другой информации о задаче; он знает только «когда этот fd готов, вызвать этот Waker». Такая развязка позволяет реализовать драйвер I/O независимо от планировщика; они взаимодействуют только через узкий интерфейс Waker.

Ложные пробуждения: серая зона контракта

Normally, tasks are scheduled only if they have been woken by calling wake on their waker. However, this is not guaranteed, and Tokio may schedule tasks that have not been woken under some circumstances.

📎 tokio/src/runtime/mod.rs:306-309

Документация Tokio явно признаёт существование ложных пробуждений:

〔Проектные предположения и архитектурные компромиссы〕pollЭто означает, что реализация

должна допускать ситуацию «повторный poll без пробуждения». Корректный Future после возврата Pending при повторном poll, даже если никаких событий не произошло, должен снова вернуть Pending, а не panic или ошибочный результат. Это ограничение кажется мягким, но фактически предъявляет требования к дизайну конечного автомата: нельзя предполагать, что «между двумя poll обязательно происходит событие».

1.3 Executor: инкапсуляция от Future к задаче

Интуитивная модель

Executor — это диспетчер ресторана. У него есть стопка заказов (очередь задач), он решает, какой заказ готовить первым и кто будет готовить. Когда вибропейджер срабатывает, он повторно ставит соответствующий заказ в очередь. Без диспетчера повара не знали бы, какое блюдо готовить и когда переключаться между работами.Но обязанности Executor далеко не ограничиваются «опросом Future». Он должен решить три ключевые проблемы:управление жизненным циклом задач(создание, планирование, завершение, отмена),гарантия справедливости(предотвращение голодания одних задач из-за других),интеграция с драйверами ресурсов

(как события I/O и таймеров преобразуются в пробуждения).

Структура памяти задачи: от Future к Tasktokio::spawnПри вызовеTaskпереданный Future не помещается напрямую в очередь. Он оборачивается в структуру

rust
/// Boundary value to prevent stack overflow caused by a large-sized
/// Future being placed in the stack.
pub(crate) const BOX_FUTURE_THRESHOLD: usize = if cfg!(debug_assertions)  {
    2048
} else {
    16384
};

pub(crate) struct AutoBox<T>(std::marker::PhantomData<T>);

impl<T> AutoBox<T> {
    /// `true` if a value of type `T` is larger than [`BOX_FUTURE_THRESHOLD`].
    pub(crate) const SHOULD_BOX: bool = std::mem::size_of::<T>() > BOX_FUTURE_THRESHOLD;
}

📎 tokio/src/runtime/mod.rs:649-673

КопироватьAutoBoxЭтот код решает очень конкретную проблему: если Future слишком велик (более 16 КБ, в debug-режиме 2 КБ), прямое встраивание в структуру Task приведёт к переполнению стека или расточительному расходу памяти.SHOULD_BOXЧерез константу времени компиляции

решается, упаковывать ли Future в бокс.

〔Проектные предположения и архитектурные компромиссы〕if» причина: если использовать проверку во время выполнения, компилятор для каждогоTодновременно инстанцирует код обеих ветвей (одна обрабатываетT, другая обрабатываетPin<Box<T>>), что приводит к раздуванию кода. При использовании константных ветвей сборщик мономорфизации вырезает недостижимые ветви и генерирует код только для фактически используемых типов. Это типичная оптимизация «замена проверки во время выполнения системой типов».

Справедливость планирования: магические числа 31 и 61

В документации планировщика Tokio определено формальное гарантирование справедливости:

If the total number of tasks does not grow without bound, and no task is blocking the thread, then it is guaranteed that tasks are scheduled fairly.

📎 tokio/src/runtime/mod.rs:279-281

Реализация этой гарантии зависит от двух ключевых параметров. Для runtime с текущим потоком:

The runtime will prefer to choose the next task to schedule from the local queue, and will only pick a task from the global queue if the local queue is empty, or if it has picked a task from the local queue 31 times in a row.

📎 tokio/src/runtime/mod.rs:328-333

The runtime will check for new IO or timer events whenever there are no tasks ready to be scheduled, or when it has scheduled 61 tasks in a row.

📎 tokio/src/runtime/mod.rs:335-337

Эти два числа (31 и 61) выбраны не случайно. 31 — это 2 в 5-й степени минус 1, что позволяет быстро проверять с помощью битовых операций; 61 же нужно для того, чтобы гарантировать, что события I/O не будут бесконечно откладываться — даже если очередь задач никогда не пуста, каждые 61 планирование необходимо проверять I/O.

〔Предположения о дизайне и архитектурные компромиссы〕

Почему 31, а не 32? Потому что счётчик начинается с 0, увеличивается на 1 при каждом планировании, и когда счётчик достигает 31, запускается проверка глобальной очереди. Использованиеcounter & 31 == 31для проверки эффективнее, чемcounter % 32 == 0(хотя современные компиляторы оптимизируют это автоматически). Выбор 61 более тонок: он должен быть достаточно большим, чтобы избежать накладных расходов на частые системные вызовы epoll_wait, и достаточно малым, чтобы гарантировать приемлемую задержку I/O.

Оптимизация LIFO-слота в многопоточном runtime

Многопоточный runtime добавляет к справедливости ещё одну оптимизацию производительности — LIFO-слот:

The multi thread runtime uses the lifo slot optimization: Whenever a task wakes up another task, the other task is added to the worker thread's lifo slot instead of being added to a queue.

📎 tokio/src/runtime/mod.rs:373-377

Интуиция этой оптимизации такова: когда одна задача пробуждает другую, пробуждённая задача, скорее всего, имеет зависимость по данным с текущей задачей (например, в шаблоне производитель-потребитель). Помещая её в LIFO-слот, текущая задача после завершения немедленно выполняет её, что позволяет использовать горячие данные в кэше CPU.

Но у LIFO-слота есть механизм защиты от злоупотребления:

if a worker thread uses the lifo slot three times in a row, it is temporarily disabled until the worker thread has scheduled a task that didn't come from the lifo slot.

📎 tokio/src/runtime/mod.rs:380-382

〔Предположения о дизайне и архитектурные компромиссы〕

Это правило «отключение после трёх последовательных использований» предназначено для предотвращения livelock, когда две задачи пробуждают друг друга. Если задача A пробуждает задачу B, а B пробуждает A, то без этого ограничения LIFO-слот был бы навсегда занят этими двумя задачами, и другие задачи никогда не получили бы планирование. Ограничение в три раза даёт другим задачам шанс вклиниться.

Отмена задач: истинная семантика abort

JoinHandle::abortПоведение

Be aware that calls to JoinHandle::abort just schedule the task for cancellation, and will return before the cancellation has completed.

📎 tokio/src/task/mod.rs:146-148

часто понимается неправильно. В документации явно указано:abortЭто означает, что.awaitне является синхронным. Он лишь устанавливает флаг, и задача проверит этот флаг в следующей.awaitточке и завершится самостоятельно. Если задача выполняет участок CPU-интенсивного кода безabort, то

не сработает немедленно.

Note that aborting a task does not guarantee that it fails with a cancelled error, since it may complete normally first.

📎 tokio/src/task/mod.rs:134-138

Что ещё более тонко:

〔Предположения о дизайне и архитектурные компромиссы〕spawn_blockingМотивация этой семантики: отмена — это операция «по мере возможности». Tokio не принудительно убивает задачи (в Rust нет безопасного механизма принудительного завершения), а кооперативно просит задачу завершиться самостоятельно. Это согласуется с дизайном, согласно которому.awaitзадачи не могут быть отменены — у блокирующих задач нет

точек, они не могут проверить флаг отмены.

1.4 Размышления о дизайне: границы и цена триады

Почему Future не содержит ExecutorFutureТрейт

в Rust намеренно не содержит информации о том, «как планировать себя». Это тщательно продуманное решение о разделении ответственности. Если бы Future знал свой Executor, то:

1. Один и тот же Future не мог бы выполняться на разных runtime (например, при миграции с Tokio на async-std)block_on2. При тестировании нельзя было бы использовать простой

для управленияselect!、join!3. Комбинаторы (такие как

) не могли бы работать между runtime

Наличие Waker как раз и предназначено для того, чтобы, сохраняя это разделение, всё же позволить Future уведомлять Executor. Waker — это «токен способности»: Future знает только «я могу вызвать это, чтобы запросить перепланирование», но не знает, как именно происходит планирование.

Цена кооперативного планирования.awaitЗадачи в Tokio кооперативны: задача уступает управление только в

code that spends a long time without reaching an .await will prevent other tasks from running.

📎 tokio/src/lib.rs:178-179

точках. Это означает:

〔Предположения о дизайне и архитектурные компромиссы〕.awaitЭто фундаментальная цена кооперативного планирования. Операционная система может вытеснить поток на любой границе инструкции, но Tokio может переключить задачу только в.awaitточке. Если задача выполняет 10-секундный CPU-интенсивный цикл безspawn_blockingв середине, то все остальные задачи на том же worker-потоке будут заблокированы на 10 секунд. Стратегия Tokio — предоставитьblock_in_placeи

, чтобы перенести такую работу в специализированный пул потоков. Но это ответственность пользователя, runtime не может обнаружить это автоматически.

Граничные условия гарантии справедливости

  • Гарантия справедливости Tokio имеет два предварительных условия: общее количество задач ограничено сверху, и ни одна задача не блокирует поток. Эти два условия часто нарушаются в реальной производственной среде:
  • Если задачи постоянно порождают новые задачи и не перерабатываются, общее количество задач не ограничено сверху, и гарантия справедливости перестаёт действовать
Если какая-то задача выполняет блокирующий системный вызов (например, синхронный файловый I/O), она блокирует весь worker-поток

〔Предположения о дизайне и архитектурные компромиссы〕

Именно поэтому документация Tokio неоднократно подчёркивает: «не выполняйте блокирующие операции в асинхронных задачах». Гарантия справедливости — это не жёсткая гарантия runtime, а гарантия «при условии правильного использования». Runtime не обнаруживает нарушения, потому что само обнаружение требует накладных расходов.

1.5 Краткое содержание главы

Future — это вытягивающий конечный автомат. poll— это чистое действие запроса, возвращающееPendingпри возврате должен быть уже зарегистрирован waker, возвратReadyпосле этого не должен больше опрашиваться через poll. Tokio напрямую переиспользуетstd::future::Future, не делая дополнительной обёртки (если только не включён tracing).

Waker — единственный канал обратного управления потоком.Он через дизайн «указатель на данные + таблица виртуальных методов» обеспечивает независимость от среды выполнения.wakeпотребляет владение,wake_by_refтолько заимствует. Ложные пробуждения допустимы, Future обязан их терпеть.

Executor отвечает за жизненный цикл, справедливость и интеграцию ресурсов.Он оборачивает Future в Task, черезAutoBoxна этапе компиляции решает, упаковывать ли в heap, через два магических числа 31/61 балансирует планирование локальной и глобальной очередей, через LIFO-слот оптимизирует производительность в сценариях с зависимостями данных.

Эти три компонента разделены через узкие интерфейсы: Future знает толькоpoll, Waker знает толькоwake, Executor знает только «опрашивать до Pending или Ready». Именно это разделение позволяет Tokio без изменения определения Future реализовывать work-stealing планирование, интеграцию с I/O-драйвером, кооперативные бюджеты и другие продвинутые возможности.

Вопросы для размышления и самопроверки в этой главе

Q1: ЕслиAutoBox::SHOULD_BOXпроверку из константы времени компиляции изменить на время выполненияif size_of::<T>() > THRESHOLD, какое влияние это окажет на артефакт компиляции? Почему комментарий Tokio особо это подчёркивает?

Справочный разбор: Согласно📎 tokio/src/runtime/mod.rs:657-667комментарию, если использовать время выполненияif, компилятор для каждогоTодновременно инстанцирует код обеих ветвей — одну для случаяTс прямым инлайном, другую для случаяPin<Box<T>>. Это означает, что для каждого spawn-типа Future будет сгенерировано две копии кода управления задачей (task harness), что приведёт к удвоению размера бинарника. А при использовании ассоциированной константыSHOULD_BOX, поскольку она после определенияTявляется константой времени компиляции, сборщик мономорфизации отсечёт недостижимые ветви и сгенерирует код только для фактически используемого пути. Это типичная оптимизация «замены runtime-проверки системой типов», ценой которой является то, чтоAutoBoxдолжен быть обобщённой структурой, а не обычной функцией.

Q2: Предположим, задача вpollвернулаPending, но забыла зарегистрировать Waker. Что произойдёт с этой задачей в current-thread runtime и multi-thread runtime? Есть ли у Tokio механизм обнаружения такой ситуации?

Справочный разбор: Согласно📎 tokio/src/runtime/mod.rs:306-309, Tokio допускает ложные пробуждения, что означает, что задача может быть перепланирована без пробуждения. Но это не значит, что забыть зарегистрировать Waker безопасно. В current-thread runtime, если и локальная, и глобальная очереди пусты, runtime переходит вparkсостояние ожидания событий I/O или таймеров. Задача, забывшая зарегистрировать Waker, никогда не будет повторно поставлена в очередь, что приведёт к вечному зависанию. В multi-thread runtime ситуация аналогична, но если другие задачи постоянно пробуждаются, эта задача может случайно быть перепланирована из-за ложного пробуждения — но на это нельзя полагаться. У Tokio нет механизма runtime-обнаружения для выявления случая «возврат Pending без регистрации Waker», так как это потребовало бы проверки использования Waker после каждого poll, что слишком накладно. Это ответственность реализатора Future.

Q3: Правило LIFO-слота «отключать после трёх последовательных использований» предназначено для предотвращения какого конкретного сценария? Если убрать это ограничение, при каком паттерне зависимостей задач другие задачи будут голодать?

Справочный разбор: Согласно📎 tokio/src/runtime/mod.rs:380-382, LIFO-слот после трёх последовательных использований временно отключается до тех пор, пока не будет запланирована задача из не-LIFO источника. Это правило предотвращает сценарий: две задачи взаимно пробуждают друг друга, образуя плотный цикл. Например, задача A, обработав партию данных, пробуждает задачу B, задача B, обработав, немедленно пробуждает задачу A. Без ограничения в три раза A и B навсегда займут LIFO-слот, worker-поток будет бесконечно переключаться между этими двумя задачами, а другие задачи в локальной и глобальной очередях никогда не получат шанса на выполнение. Ограничение в три раза гарантирует, что после каждых трёх раундов «взаимного пробуждения» будет запланирована хотя бы одна другая задача, разрывая livelock. Выбор этого числа эмпирический: слишком маленькое снизит выгоду от LIFO-оптимизации, слишком большое увеличит задержку других задач.

На этом границы ответственности и механизм взаимодействия Future, Waker и Executor уже ясны: Future определяет вычисление, Waker отвечает за пробуждение, Executor управляет выполнением. Но отдельный компонент не может работать самостоятельно, они должны быть собраны в единую среду выполнения. В следующей главе мы проследим полную цепочку сборки Runtime::new и Builder::build, увидим, как планировщик, I/O-драйвер, драйвер времени и пул блокирующих потоков внедряются в один экземпляр Runtime, и раскроем фундаментальные различия между формами current_thread и multi_thread на этапе сборки.

CHAPTER 02

Глава 2: Сборка Runtime: как Builder собирает драйверы, планировщик и пул потоков

Проект: tokio-rs/tokio · Прогресс по книге: Глава 2 / 14 · Статус проверки: строки FACT реально привязаны

ОтBuilderдоRuntime: полный путь одной сборки

В предыдущей главе мы ясно объяснили границы ответственности Future, Waker и Executor. Но реально используемый runtime — это далеко не только «один Executor» — ему также нужны цикл событий I/O, таймеры, пул блокирующих потоков, и все эти компоненты должны разделять один и тот же набор дескрипторов и один и тот же жизненный цикл. В этой главе мы прослеживаем полную цепочку сборкиBuilder::buildи отвечаем на ключевой вопрос:Какие компоненты на самом деле находятся внутриRuntime, как они собираются вместе и разделяют дескрипторы。

Точка входа сборки Tokio — этоBuilder. Сам по себе он является чистым контейнером конфигурации, все его поля — это «декларации намерений», он не содержит никаких ресурсов runtime. Реальное создание ресурсов происходит при вызовеbuild().

Интуитивная модель: Builder — это «чертёж ремонта», Runtime — это «дом после сдачи»

Builderподобен чертежу ремонта: вы отмечаете на нём «сколько комнат нужно (worker_threads)», «нужно ли подводить воду (enable_io)», «нужно ли подводить электричество (enable_time)», «лимит наёмных рабочих (max_blocking_threads)». Сам чертёж не создаёт никаких физических объектов. Только при вызовеbuild()строительная бригада начинает работать по чертежу, реально возводит «комнаты» — планировщик, драйверы, пул потоков — и сдаёт экземплярRuntime.

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

Полное разделение «конфигурации» и «конструирования», что позволяет сосредоточить в процессе конструирования валидацию, очистку при ошибках и совместное использование дескрипторовBuilderРаскладка памяти:

Builderразделение полейПоля:kindможно разделить по ответственности на четыре группы. Первая группа —enable_io / enable_timeФорма и переключатели

📎 tokio/src/runtime/builder.rs:55-68

rust
pub struct Builder {
    kind: Kind,
    name: Option<String>,
    enable_io: bool,
    nevents: usize,
    nevents_busy: Option<usize>,
    enable_time: bool,
    start_paused: bool,
    // ...
}

определяет, создавать ли соответствующий драйвер.Копировать:worker_threadsВторая группа —Option<usize>,NoneПараметры пула потоковmax_blocking_threads— это

📎 tokio/src/runtime/builder.rs:73-79

rust
worker_threads: Option<usize>,
max_blocking_threads: usize,

по умолчанию 512.КопироватьТретья группа —Option<Arc<dyn Fn ...>>Хуки обратного вызоваArc, все они являютсяBox. Обратите внимание, что они используютConfig。

📎 tokio/src/runtime/builder.rs:87-97

rust
pub(super) after_start: Option<Callback>,
pub(super) before_stop: Option<Callback>,
pub(super) before_park: Option<Callback>,
pub(super) after_unpark: Option<Callback>,

, потому что эти обратные вызовы должны быть клонированы вкаждого рабочего потока:global_queue_interval、event_interval、disable_lifo_slot、seed_generator。

📎 tokio/src/runtime/builder.rs:116-134

rust
pub(super) global_queue_interval: Option<u32>,
pub(super) event_interval: u32,
pub(super) disable_lifo_slot: bool,
pub(super) seed_generator: RngSeedGenerator,

Четвёртая группа —KindЭвристика планирования и случайное зерноCopyКопировать

📎 tokio/src/runtime/builder.rs:261-265

rust
#[derive(Clone, Copy)]
pub(crate) enum Kind {
    CurrentThread,
    #[cfg(feature = "rt-multi-thread")]
    MultiThread,
}

MultiThread— этоrt-multi-threadнебольшой enum, содержащий всего два варианта.rtКопироватьKindВариантbuild()управляется feature-флагомmatch. Это означает, что в сборке, где включён только feature,。

имеет только один вариант,

Builder::newиenable_ioизenable_timeбудет оптимизирован компилятором в одну ветку —false。

📎 tokio/src/runtime/builder.rs:309-318

rust
// I/O defaults to "off"
enable_io: false,
nevents: 1024,
nevents_busy: None,

// Time defaults to "off"
enable_time: false,

// The clock starts not-paused
start_paused: false,
Философия значений по умолчанию: почему I/O и time по умолчанию отключены

— это общая точка входа для всех конструкций. Он устанавливает#[tokio::main]иenable_all()。

enable_all()в

📎 tokio/src/runtime/builder.rs:398-419

rust
pub fn enable_all(&mut self) -> &mut Self {
    #[cfg(any(
        feature = "net",
        all(unix, feature = "process"),
        all(unix, feature = "signal")
    ))]
    self.enable_io();

    #[cfg(all(
        tokio_unstable,
        feature = "io-uring",
        // ...
    ))]
    self.enable_io_uring();

    #[cfg(feature = "time")]
    self.enable_time();

    self
}

〔Проектные выводы и архитектурные компромиссы〕enable_io()Этот выбор значений по умолчанию сделан намеренно: создание I/O-драйвера требует запроса у операционной системы дескрипторов epoll/kqueue, создание time-драйвера требует запуска инфраструктуры таймеров. Если пользователю нужен только планировщик задач для чистых вычислений (например, для запуска CPU-интенсивной async-логики), принудительное создание этих драйверов — чистая трата ресурсов.net、processМакросsignalработает «из коробки», потому что внутри он вызываетtime feature,enable_all()Реализация

раскрывает, как feature-гейтинг влияет на семантику «всё включено».build()Копировать

build()Обратите внимание, чтоkindвызывается только при включённом feature

📎 tokio/src/runtime/builder.rs:1146-1152

rust
pub fn build(&mut self) -> io::Result<Runtime> {
    match &self.kind {
        Kind::CurrentThread => self.build_current_thread_runtime(),
        #[cfg(feature = "rt-multi-thread")]
        Kind::MultiThread => self.build_threaded_runtime(),
    }
}

. Если пользователь включил только

,

build_current_thread_runtimeне включит I/O-драйвер — потому что в скомпилированном артефакте вообще нет кода I/O-драйвера.build_current_thread_runtime_componentsОсновной путь сборки:Runtime。

📎 tokio/src/runtime/builder.rs:1725-1736

rust
fn build_current_thread_runtime(&mut self) -> io::Result<Runtime> {
    use crate::runtime::runtime::Scheduler;

    let (scheduler, handle, blocking_pool) =
        self.build_current_thread_runtime_components(None)?;

    Ok(Runtime::from_parts(
        Scheduler::CurrentThread(scheduler),
        handle,
        blocking_pool,
    ))
}

— это начальная точка сборки, он разветвляется поbuild_current_thread_runtime_componentsна два совершенно разных пути.

📎 tokio/src/runtime/builder.rs:1760-1766

rust
let mut cfg = self.get_cfg();
cfg.timer_flavor = TimerFlavor::Traditional;
let (driver, driver_handle) = driver::Driver::new(cfg)?;

// Blocking pool
let blocking_pool = blocking::create_blocking_pool(self, self.max_blocking_threads, 0);
let blocking_spawner = blocking_pool.spawner().clone();

Различия между этими двумя путями далеко не ограничиваются «один поток против нескольких потоков». Ниже мы рассмотрим каждый отдельно.driverПуть первый: сборка current_thread(driver, driver_handle)сам по себе очень тонкий, он делегирует?, а затем оборачивает возвращённый кортеж из трёх элементов вbuildКопироватьErrНастоящая логика сборки находится в

. Порядок её выполнения критически важен:spawnerКопироватьspawnerПервый шаг создаёт

, возвращая пару

📎 tokio/src/runtime/builder.rs:1768-1770

rust
let seed_generator_1 = self.seed_generator.next_generator();
let seed_generator_2 = self.seed_generator.next_generator();
напрямую распространяет ошибку вверх — если инициализация I/O-драйвера не удалась (например, не удалось создать epoll), весь

возвращаетseed_generator_1, и в этот момент blocking pool ещё не создан, очистка не требуется.ConfigВторой шаг создаёт blocking pool и сразу извлекает его клонselect!. Этотseed_generator_2будет внедрён в планировщик, давая планировщику возможность отправлять блокирующие задачи в пул потоков.CurrentThread::newТретий шаг генерирует два независимых генератора зерна RNG.rng_seedКопировать

〔Проектные выводы и архитектурные компромиссы〕ConfigПочему нужны два?CurrentThread::new。

📎 tokio/src/runtime/builder.rs:1776-1807

rust
let (scheduler, handle) = CurrentThread::new(
    driver,
    driver_handle,
    blocking_spawner,
    seed_generator_2,
    Config {
        before_park: self.before_park.clone(),
        after_unpark: self.after_unpark.clone(),
        // ...
        global_queue_interval: self.global_queue_interval,
        event_interval: self.event_interval,
        // ...
        enable_eager_driver_handoff: false,
        seed_generator: seed_generator_1,
        // ...
    },
    local_tid,
    self.name.clone(),
);

для внутреннего использования планировщиком (например,enable_eager_driver_handoffслучайный порядок ветвления);false。

📎 tokio/src/runtime/builder.rs:1795-1798

rust
// This setting never makes sense for a current thread runtime,
// as it only configures how the I/O driver is stolen across
// workers.
enable_eager_driver_handoff: false,
для использования на стороне задач. Разделение двух генераторов позволяет избежать влияния потребления случайных чисел внутри планировщика на видимую пользователю случайную последовательность, тем самым обеспечивая воспроизводимость

Этот комментарий раскрывает суть данной опции: она описывает, «как несколько worker'ов конкурируют за I/O-драйвер», а в current_thread есть только один поток, конкуренции нет, поэтому принудительно отключается. Это типичный пример «семантической привязки элемента конфигурации к его форме» — одно и то жеBuilderполе в разных формах имеет разное значение.

Наконец,CurrentThread::newвозвращаемыйhandleзаворачивается вscheduler::Handle::CurrentThread, затем заворачивается в публичныйHandle。

📎 tokio/src/runtime/builder.rs:1816-1822

rust
let handle = Handle {
    inner: scheduler::Handle::CurrentThread(handle),
};

Ok((scheduler, handle, blocking_pool))

Путь второй: сборка multi_thread

build_threaded_runtimeСкелет аналогичен current_thread, но есть три принципиальных различия. Первое — определение числа worker-потоков:

📎 tokio/src/runtime/builder.rs:2185

rust
let worker_threads = self.worker_threads.unwrap_or_else(num_cpus);

NoneЗдесь разрешается вnum_cpus(). Это и есть точка реализации «отложенного автоматического определения» — определение происходит во время build, а не во времяBuilder::new, поскольку привязка к CPU может измениться между ними.

Второе различие — в вычислении ёмкости blocking pool:

📎 tokio/src/runtime/builder.rs:2189-2192

rust
let blocking_pool =
    blocking::create_blocking_pool(self, self.max_blocking_threads + worker_threads, worker_threads);
let blocking_spawner = blocking_pool.spawner().clone();

Обратите вниманиеmax_blocking_threads + worker_threads. В отличие от пути current_thread, куда передаютсяself.max_blocking_threadsи0。

📎 tokio/src/runtime/builder.rs:1765

rust
let blocking_pool = blocking::create_blocking_pool(self, self.max_blocking_threads, 0);
〔Проектные предположения и архитектурные компромиссы〕

Это различие раскрывает семантику ёмкости blocking pool: в multi_threadmax_blocking_threads— это верхняя граница «дополнительных» блокирующих потоков, а фактическая общая верхняя граница потоков должна включать число worker-потоков. Третий параметр (в current_thread передаётся 0, в multi_thread —worker_threads) скорее всего является подсказкой «число зарезервированных потоков» или «начальное число потоков». Такое решение сохраняет семантикуmax_blocking_threadsсогласованной в обеих формах: она описывает «сколько дополнительных блокирующих потоков можно открыть сверх основных worker'ов».

Третье различие —MultiThread::newвозвращает тройку, а не пару:

📎 tokio/src/runtime/builder.rs:2198-2226

rust
let (scheduler, handle, launch) = MultiThread::new(
    worker_threads,
    driver,
    driver_handle,
    blocking_spawner,
    seed_generator_2,
    Config {
        // ...
        enable_eager_driver_handoff: self.enable_eager_driver_handoff,
        // ...
    },
    self.timer_flavor,
    self.name.clone(),
);

Лишнийlaunch— это «дескриптор запуска».MultiThread::newОтвечает только за конструирование структуры планировщика,но не запускает worker-потоки немедленно. Настоящий запуск происходит позже:

📎 tokio/src/runtime/builder.rs:2228-2234

rust
let handle = Handle { inner: scheduler::Handle::MultiThread(handle) };

// Spawn the thread pool workers
let _enter = handle.enter();
launch.launch();

Ok(Runtime::from_parts(Scheduler::MultiThread(scheduler), handle, blocking_pool))

handle.enter()Входит в контекст времени выполнения, и только затемlaunch.launch()действительно порождает все worker-потоки. Этот двухфазный дизайн «сначала конструирование, потом запуск» крайне важен.

〔Проектные предположения и архитектурные компромиссы〕

Почему нельзя запускать одновременно с конструированием? Потому что worker-потоки, будучи запущенными, немедленно начинают poll'ить задачи, а задачи могут ссылаться наhandle. Еслиhandleещё не сконструирован до конца, возникает гонка «worker держит полуфабрикат дескриптора». Двухфазный дизайн гарантирует:к моменту запуска всех worker-потоков полныйHandleуже готов。_enter, а guard гарантирует, что worker-потоки в момент запуска находятся в правильном контексте времени выполнения.

Схема сборки

Приведённая ниже схема объединяет порядок сборки обоих путей, ключевые ветвления и пути ошибок. Обратите внимание: при неудачеdriver::Driver::newпроисходит немедленный возвратErr, при этом blocking pool ещё не создан.

mermaid
flowchart TD
    start["Builder::build()"] --> match_kind{"self.kind?"}

    match_kind -->|CurrentThread| ct_cfg["get_cfg() + timer_flavor=Traditional"]
    match_kind -->|MultiThread| mt_workers["worker_threads = self.worker_threads.unwrap_or_else(num_cpus)"]

    ct_cfg --> ct_driver["driver::Driver::new(cfg)?"]
    mt_workers --> mt_driver["driver::Driver::new(self.get_cfg())?"]

    ct_driver -->|Err| ret_err["return Err(io::Error)"]
    mt_driver -->|Err| ret_err

    ct_driver -->|Ok driver, driver_handle| ct_pool["create_blocking_pool(self, max_blocking_threads, 0)"]
    mt_driver -->|Ok driver, driver_handle| mt_pool["create_blocking_pool(self, max_blocking_threads + worker_threads, worker_threads)"]

    ct_pool --> ct_seed["next_generator() x2"]
    mt_pool --> mt_seed["next_generator() x2"]

    ct_seed --> ct_new["CurrentThread::new(driver, driver_handle, blocking_spawner, ...)"]
    mt_seed --> mt_new["MultiThread::new(worker_threads, driver, ...) -> (scheduler, handle, launch)"]

    ct_new --> ct_wrap["Handle { inner: CurrentThread(handle) }"]
    mt_new --> mt_wrap["Handle { inner: MultiThread(handle) }"]

    ct_wrap --> ct_rt["Runtime::from_parts(Scheduler::CurrentThread, handle, blocking_pool)"]
    mt_wrap --> mt_enter["handle.enter()"]
    mt_enter --> mt_launch["launch.launch() 启动 worker 线程"]
    mt_launch --> mt_rt["Runtime::from_parts(Scheduler::MultiThread, handle, blocking_pool)"]

Совместное использование дескрипторов:Handleкак

становится «пропуском» между компонентамиRuntimeПосле завершения сборкиscheduler、handle、blocking_poolвладеет набором из трёх элементовhandle. Среди них

📎 tokio/src/runtime/scheduler/mod.rs:29-41

rust
#[derive(Debug, Clone)]
pub(crate) enum Handle {
    #[cfg(feature = "rt")]
    CurrentThread(Arc<current_thread::Handle>),

    #[cfg(feature = "rt-multi-thread")]
    MultiThread(Arc<multi_thread::Handle>),

    #[cfg(not(feature = "rt"))]
    #[allow(dead_code)]
    Disabled,
}

КопироватьArcОбратите внимание, что оба варианта обёрнуты вHandle. Это означает, что клонированиеHandle— дешёвое увеличение счётчика ссылок, которое можно свободно распространять на любой поток.matchпредоставляет унифицированный интерфейс доступа, инкапсулируя различия форм внутриdriver():

📎 tokio/src/runtime/scheduler/mod.rs:53-64

rust
pub(crate) fn driver(&self) -> &driver::Handle {
    match *self {
        #[cfg(feature = "rt")]
        Handle::CurrentThread(ref h) => &h.driver,

        #[cfg(feature = "rt-multi-thread")]
        Handle::MultiThread(ref h) => &h.driver,

        #[cfg(not(feature = "rt"))]
        Handle::Disabled => unreachable!(),
    }
}

blocking_spawner()Копироватьmatch_flavor!использует

📎 tokio/src/runtime/scheduler/mod.rs:96-98

rust
pub(crate) fn blocking_spawner(&self) -> &blocking::Spawner {
    match_flavor!(self, Handle(h) => &h.blocking_spawner)
}

Копироватьdriver()После раскрытия этот макрос превращается в такойmatch, как вышеmatch_flavor!. Его ценность в том, что при добавлении нового аксессора, требующего диспетчеризации по форме, достаточно одной строкиmatch, а не писать вручную две ветви

.HandleПубличныйscheduler::Handle— это тонкая обёртка над внутренним

📎 tokio/src/runtime/handle.rs:13-15

rust
pub struct Handle {
    pub(crate) inner: scheduler::Handle,
}

КопироватьHandleПолученный пользователемspawnможно клонировать между потоками, можноblock_on。spawn, можноAutoBox. Реализация

📎 tokio/src/runtime/handle.rs:197-208

rust
pub fn spawn<F>(&self, future: F) -> JoinHandle<F::Output>
where
    F: Future + Send + 'static,
    F::Output: Send + 'static,
{
    let fut_size = mem::size_of::<F>();
    if AutoBox::<F>::SHOULD_BOX {
        self.spawn_named(Box::pin(future), SpawnMeta::new_unnamed(fut_size))
    } else {
        self.spawn_named(future, SpawnMeta::new_unnamed(fut_size))
    }
}

AutoBox::<F>::SHOULD_BOXна этапе компиляции:size_of::<F>()Копировать

📎 tokio/src/runtime/mod.rs:668-673

rust
pub(crate) struct AutoBox<T>(std::marker::PhantomData<T>);

impl<T> AutoBox<T> {
    pub(crate) const SHOULD_BOX: bool = std::mem::size_of::<T>() > BOX_FUTURE_THRESHOLD;
}
с порогом.

Копироватьif〔Проектные предположения и архитектурные компромиссы〕spawn_namedВ комментарии объясняется, почему используется ассоциированная константа, а не проверка во время выполненияF: если бы проверка была во время выполнения,Pin<Box<F>>мономорфизировался бы дважды (один раз для

, один раз для

), что привело бы к генерации двух копий task harness для каждого spawn'нутого future и удвоению объёма кода. При использовании константного ветвления сборщик мономорфизации сохраняет только фактически достигнутую ветвь.Проектные размышления: порядок сборки, восстановление после ошибок и подводные камни в продакшенеdriver -> blocking_pool -> schedulerПорядок — это контракт

. Порядок сборкиlocal_tidне случаен. Driver создаётся первым, поскольку это единственный шаг, который может завершиться неудачей из-за нехватки ресурсов ОС и при неудаче не требует очистки других компонентов. blocking_pool идёт после driver, но до scheduler, потому что scheduler нуждается в blocking_spawner. Если создание blocking_pool завершится неудачей (на практике это маловероятно), driver будет автоматически очищен через drop.。build_localВетвьbuild_current_thread_local_runtimeдля current_thread

📎 tokio/src/runtime/builder.rs:1738-1751

rust
fn build_current_thread_local_runtime(&mut self) -> io::Result<LocalRuntime> {
    use crate::runtime::local_runtime::LocalRuntimeScheduler;

    let tid = std::thread::current().id();

    let (scheduler, handle, blocking_pool) =
        self.build_current_thread_runtime_components(Some(tid))?;

    Ok(LocalRuntime::from_parts(
        LocalRuntimeScheduler::CurrentThread(scheduler),
        handle,
        blocking_pool,
    ))
}

, передавая туда ID текущего потока:tidКопироватьHandleЭтотcan_spawn_local_on_local_runtimeсохраняется в

📎 tokio/src/runtime/scheduler/mod.rs:140-147

rust
pub(crate) fn can_spawn_local_on_local_runtime(&self) -> bool {
    match self {
        Handle::CurrentThread(h) => h.local_tid.is_some_and(|x| std::thread::current().id() == x),

        #[cfg(feature = "rt-multi-thread")]
        Handle::MultiThread(_) => false,
    }
}
использует его для проверки «вызывается ли spawn_local в owner-потоке»:

КопироватьLocalRuntime〔Проектные предположения и архитектурные компромиссы〕!SendЭто краеугольный камень безопасностиlocal_tid: future из!Sendможет быть poll'нут только в своём owner-потоке, и

— это точка проверки данного ограничения во время выполнения. Если убрать эту проверку, кросс-поточный spawn_local приведёт к конкурентному доступу к даннымworker_threads(0)и вызовет UB.。worker_threadsПодводный камень в продакшене первый:

📎 tokio/src/runtime/builder.rs:582-586

rust
pub fn worker_threads(&mut self, val: usize) -> &mut Self {
    assert!(val > 0, "Worker threads cannot be set to 0");
    self.worker_threads = Some(val);
    self
}

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

Производственная проблема вторая:max_blocking_threadsЕсли задать слишком маленьким, произойдёт зависание. Документация явно предупреждает:

📎 tokio/src/runtime/builder.rs:600-601

rust
/// It's recommended to not set this limit too low in order to avoid hanging on operations
/// requiring [`spawn_blocking`].
〔Проектные выводы и архитектурные компромиссы〕

Поскольку очередь blocking pool не имеет обратного давления — задачи будут накапливаться до тех пор, пока не появится доступный поток. Если все блокирующие потоки ожидают операцию, «для завершения которой требуется новый блокирующий поток», возникнет взаимоблокировка. Фраза из документации «the queue does not apply any backpressure, it could potentially grow unbounded» как раз является примечанием к этому риску.

Производственная проблема третья:UnhandledPanic::ShutdownRuntimeПоддерживается только current_thread。

📎 tokio/src/runtime/builder.rs:1374-1381

rust
pub fn unhandled_panic(&mut self, behavior: UnhandledPanic) -> &mut Self {
    if !matches!(self.kind, Kind::CurrentThread) && matches!(behavior, UnhandledPanic::ShutdownRuntime) {
        panic!("UnhandledPanic::ShutdownRuntime is only supported in current thread runtime");
    }

    self.unhandled_panic = behavior;
    self
}
〔Проектные выводы и архитектурные компромиссы〕

Причина этого ограничения: в multi_thread «немедленное завершение runtime» требует координации остановки всех worker-потоков, реализация сложна, а семантика размыта (что делать с другими задачами, которые в данный момент выполняются в poll?). В current_thread есть только один поток, и семантика завершения ясна.

Итоги главы

В этой главе прослеженBuilder::buildполный путь сборки. Ключевые выводы:

1. Builder— это чистый контейнер конфигурации,build()только создаёт ресурсы. Порядок сборкиdriver -> blocking_pool -> schedulerопределяется требованиями восстановления после ошибок.

2. Различия между current_thread и multi_thread не ограничиваются числом потоков: расчёт ёмкости blocking pool различается (max_blocking_threads vs max_blocking_threads + worker_threads), в multi_thread есть дополнительныйlaunchдвухфазный запуск,enable_eager_driver_handoffв current_thread принудительно отключён.

3. Handle— это ядро, разделяемое между компонентами, внутри используетArcдля обёртки специфичных для формы дескрипторов, доступ черезmatchилиmatch_flavor!макрос унифицирован.

4. AutoBoxс помощью ассоциированных констант определяет на этапе компиляции, нужно ли упаковывать future в Box, избегая удвоения объёма кода.

5. local_tid— этоLocalRuntimeконтрольная точка безопасности во время выполнения.

В следующей главе мы перейдём к жизненному циклу задачи:spawnкак превратить Future в планируемую сущность,JoinHandleкак взаимодействовать с конечным автоматом задачи, и как задача переходит между состояниямиPENDING / RUNNING / COMPLETE.

Вопросы для размышления и самопроверки по этой главе

Q1: Если вbuild_threaded_runtimeпараметр ёмкостиcreate_blocking_poolизменить сself.max_blocking_threads + worker_threadsнаself.max_blocking_threads, в каком сценарии это приведёт к голоданию блокирующих задач? Почему в пути current_thread можно передатьself.max_blocking_threads?

Справочный разбор: Согласно📎 tokio/src/runtime/builder.rs:2189-2192, в пути multi_thread передаётсяself.max_blocking_threads + worker_threads, а в пути current_thread📎 tokio/src/runtime/builder.rs:1765передаётсяself.max_blocking_threads. Корень различия в том, что в multi_thread worker-потоки сами также выполняют блокирующие задачи (например,block_in_placeвременно превращает worker-поток в блокирующий), поэтому общий бюджет блокирующих потоков должен включать число worker-потоков. Если изменить на передачу толькоself.max_blocking_threads, когдаmax_blocking_threadsзадано малым (например, 1) и уже есть worker-потоки, занимающие бюджет вblock_in_place, новыеspawn_blockingзадачи не получат доступных потоков, накопятся в очереди без обратного давления, что приведёт к вечному зависанию async-задач, зависящих от этих блокирующих задач. В current_thread есть только один поток и не поддерживается семантика преобразования worker вblock_in_place, поэтому добавлять число worker-потоков не нужно.

Q2: MultiThread::newвозвращаетlaunchдескриптор, а фактически запускает worker-потокиlaunch.launch(). Если убратьhandle.enter()эту строку и напрямую вызватьlaunch.launch(), что произойдёт?

Справочный разбор: Согласно📎 tokio/src/runtime/builder.rs:2230-2232, перед запуском естьlet _enter = handle.enter();и только затемlaunch.launch()。handle.enter()Назначение — установить thread-local контекст, чтобы текущий поток «выглядел» находящимся внутри runtime. Worker-потоки после запуска немедленно начинают poll задач, а код задач может вызыватьHandle::current()、tokio::spawnи другие API, зависящие от контекста. Если убрать_enter, установка контекста в момент запуска worker-потока может быть неполной (в зависимости от того, устанавливает лиlaunchего самостоятельно внутри), в худшем случае инициализационный код, выполняемый на worker-потоке, вызоветHandle::current()и приведёт к panic (CONTEXT_MISSING_ERROR). Даже еслиlaunchвнутри устанавливает контекст для каждого worker,_enterгарантирует, что «само действие запуска» происходит в правильном контексте, избегая гонки в процессе запуска.

Q3: AutoBox::<F>::SHOULD_BOXиспользует ассоциированные константы вместо runtimeif size_of::<F>() > THRESHOLD. Предположим, что заменили на runtime-проверку: помимо удвоения объёма кода, в каких случаях это приведёт к деградации производительности?

Справочный разбор: Согласно📎 tokio/src/runtime/mod.rs:657-673комментарию, runtimeifзаставитspawn_namedмономорфизировать каждыйTдважды (TиPin<Box<T>>по одному разу). Помимо удвоения объёма кода, деградация производительности проявляется в: 1) увеличении давления на кэш инструкций (i-cache), поскольку оба набора кода harness должны находиться в памяти; 2) компилятор не может оптимизировать «фактически выполняется только одна ветвь», предсказание ветвлений во время выполнения обычно точно, но сама ветвь и различия в распределении регистров между двумя наборами кода накапливаются; 3) более скрыто то, чтоPin<Box<T>>путь принудительно выделяет память в куче, и если runtime-проверка по какой-то причине (например,size_ofв обобщённом контексте не полностью свёрнута в константу) ошибочно определит, маленькие future тоже будут упакованы в Box, добавляя одно выделение в куче при каждом spawn. Ассоциированные константы позволяют сборщику мономорфизации отсечь невыполняемые ветви уже на этапе компиляции, обеспечивая нулевые накладные расходы во время выполнения.

CHAPTER 03

Глава 3: Жизнь задачи (часть 1): как spawn превращает Future в планируемую сущность

Проект: tokio-rs/tokio · Прогресс книги: глава 3 из 14 · Статус проверки: FACT — реальная привязка к номерам строк

В предыдущей главе мы завершили сборку Runtime: I/O driver, time driver, blocking pool и планировщик были внедрены в один экземплярRuntime,Handleстановясь общим дескриптором для доступа к этим компонентам из разных потоков. Но собранный runtime пока остаётся пустой оболочкой — у него есть движок для управления задачами, но нет ни одной задачи, которой можно управлять. Вопрос, на который отвечает эта глава: когда вы набираетеtokio::spawn(async { ... }), что именно происходит с блокомasync, прежде чем он из обычного кода на Rust превращается в сущность, «которую может подхватить планировщик, можно разбудить и можно join». Это первая половина «жизни задачи», мы сосредоточены на рождении: отHandle::spawn, через выделение с подсчётом ссылокnew_task, до раскладки памятиCell<T, S>, и наконец видим, как задача попадает в локальную очередь какого-либо worker или в глобальную очередь внедрения. Вторая половина (глава 4) войдёт в цикл планирования и замкнутый контур poll/wake.

3.1 Future — это не задача: что на самом деле создаёт один spawn

Интуитивная модель

ПредставьтеFutureкак «рецепт», а задачу — как «блюдо, которое сейчас готовится на кухне». Сам рецепт статичен, копируем и не имеет никакого состояния выполнения; только когда кухня (планировщик) решает «готовим это блюдо сейчас», выделяет ему плиту (worker), номер заказа (TaskId) и окно выдачи (JoinHandle), он становится «блюдом в процессе приготовления». Без этой обёртки планировщик не может знать «на каком шаге блюдо», «кто его ждёт», «кого уведомить, когда готово» — он видит только рецепт и не может управлять.

Структуры данных и раскладка памяти

Tokio используетTask<S>для представления «ссылки на задачу, которой владеет runtime», это прозрачная обёртка надRawTask:

rust
#[repr(transparent)]
pub(crate) struct Task<S: 'static> {
    raw: RawTask,
    _p: PhantomData<S>,
}

📎 tokio/src/runtime/task/mod.rs:233-238

#[repr(transparent)]означает, чтоTask<S>иRawTaskполностью совпадают в памяти, без дополнительных накладных расходов.PhantomData<S>— это только маркер типа времени компиляции, отмечающий, к какому типу планировщика принадлежит задачаS。

Реально всё состояние задачи несётCell<T, S>, и его раскладка — это краеугольный камень всего модуля задач:

rust
#[repr(C)]
pub(super) struct Cell<T: Future, S> {
    pub(super) header: Header,
    pub(super) core: Core<T, S>,
    pub(super) trailer: Trailer,
}

📎 tokio/src/runtime/task/core.rs:126-136

Три поля упорядочены по принципу «горячее — тёплое — холодное».Header— горячие данные (доступ на каждом планировании, каждом переходе состояния),Core— тёплые данные (доступ при poll),Trailer— холодные данные (доступ только при создании и уничтожении). В комментарии явно сказано:Headerдолжен быть первым полем, потому что структура задачи одновременно будет*mut Cellи*mut Headerссылаться📎 tokio/src/runtime/task/core.rs:37-43。

Что ещё важнее — выравнивание по кэш-линии.Cellнесёт длинную цепочку#[cfg_attr(..., repr(align(...)))], выбирая число байт выравнивания в зависимости от целевой архитектуры: x86_64/aarch64/powerpc64 используют 128 байт, arm/mips/sparc/hexagon — 32 байта, m68k — 16 байт, s390x — 256 байт, остальные по умолчанию 64 байта📎 tokio/src/runtime/task/core.rs:64-125. В комментарии объясняется, почему x86_64 должен использовать 128, а не 64: начиная с Intel Sandy Bridge, пространственный префетчер за один раз подтягиваетпарные64-байтовые кэш-линии, поэтому нужно выравнивание на 128 байт, чтобы избежать ложного разделения📎 tokio/src/runtime/task/core.rs:45-53。

〔Проектное предположение и архитектурный компромисс〕

Цена этой стратегии выравнивания — потеря как минимум одной кэш-линии на каждую задачу. Но биты состояния задачи (state) часто читаются и записываются несколькими worker-потоками — один поток при poll устанавливает бит RUNNING, другой при пробуждении читает бит NOTIFIED — если биты состояния двух задач попадут в одну кэш-линию, каждый переход состояния будет вызывать перебрасывание кэш-линии между ядрами (cache line ping-pong), и потери производительности намного превысят трату памяти. Tokio выбирает обмен пространства на время.

Headerсам ограничен размером в 8 указателей:

rust
#[test]
#[cfg(not(loom))]
fn header_lte_cache_line() {
    assert!(std::mem::size_of::<Header>() <= 8 * std::mem::size_of::<*const ()>());
}

📎 tokio/src/runtime/task/core.rs:591-593

Этот тест гарантирует, чтоHeaderне превысит 64 байта (8 × 8), тем самым на архитектурах с 64-байтовой кэш-линией полностью поместится в одну линию.Headerвключает поля:state: State(атомарные биты состояния),queue_next: UnsafeCell<Option<NonNull<Header>>>(указатель связного списка очереди внедрения),vtable: &'static Vtable(таблица указателей на функции),owner_id: UnsafeCell<Option<NonZeroU64>>(ID спискаOwnedTasks, которому принадлежит),scheduled_at: UnsafeCell<ScheduleLatencyInstant>(измерение задержки планирования)📎 tokio/src/runtime/task/core.rs:169-198。

Core<T, S>содержит дескриптор планировщикаscheduler: S, ID задачиtask_id: Id, и самое главноеstage: CoreStage<T> 📎 tokio/src/runtime/task/core.rs:148-165。Stage— это трёхсостоянийное перечисление:

rust
#[repr(C)]
pub(super) enum Stage<T: Future> {
    Running(T),
    Finished(super::Result<T::Output>),
    Consumed,
}

📎 tokio/src/runtime/task/core.rs:225-229

Именно это и есть ключ к «Future и Output используют одну и ту же память»: во время выполнения задачаStage::Runningсодержит future, после завершения на месте заменяется наStage::Finished(output), после того как его заберётJoinHandle, становитсяStage::Consumed。#[repr(C)]Комментарий указывает на issue Miri, объясняя, что эта раскладка предъявляет жёсткие требования к корректности unsafe-кода📎 tokio/src/runtime/task/core.rs:225-229。

Trailerхранит холодные данные:owned: linked_list::Pointers<Header>(OwnedTasksуказатель связного списка),waker: UnsafeCell<Option<Waker>>(waker потребителя, ожидающего завершения задачи),hooks: TaskHarnessScheduleHooks 📎 tokio/src/runtime/task/core.rs:205-213。

Пошагово: от spawn до постановки в очередь

Возьмём конкретный сценарий: в multi_thread runtime worker-поток A выполняетtokio::spawn(async { 42 })。

Шаг первый: построение трио задачи. new_task— единственная точка входа для рождения задачи:

rust
fn new_task<T, S>(
    task: T,
    scheduler: S,
    id: Id,
    spawned_at: SpawnLocation,
) -> (Task<S>, Notified<S>, JoinHandle<T::Output>)

📎 tokio/src/runtime/task/mod.rs:336-346

Он вызываетRawTask::new::<T, S>для выделенияCell, затем из того же указателяrawпорождает три ссылки:Task(owned-ссылка, обычно немедленно помещается вOwnedTasks)、Notified(ссылка уведомления, передаётся планировщику),JoinHandle(дескриптор чтения результата)📎 tokio/src/runtime/task/mod.rs:347-363. Обратите внимание, что все три совместно используют один и тот жеraw, и каждый хранит собственный счётчик ссылок.

Шаг второй: выделитьCellи записать начальное состояние. Cell::newВыделить всю структуру в куче:

rust
let result = Box::new(Cell {
    trailer: Trailer::new(scheduler.hooks()),
    header: new_header(state, vtable, ...),
    core: Core {
        scheduler,
        stage: CoreStage {
            stage: UnsafeCell::new(Stage::Running(future)),
        },
        task_id,
        ...
    },
});

📎 tokio/src/runtime/task/core.rs:261-278

vtableсоздаётся изraw::vtable::<T, S>(), представляет собой таблицу указателей на функции, мономорфизированную для конкретныхTиS. future перемещается непосредственно в📎 tokio/src/runtime/task/core.rs:260, без дополнительной упаковки.Stage::RunningШаг третий: проверка макета с помощью debug-assert.

В,debug_assertionsвызываетCell::newфункцию, используяcheckи другие арифметические операции с указателями на основе смещений vtable, чтобы поочерёдно утверждать, что «адрес поля, полученный через header» совпадает с «фактическим адресом поля»Header::get_trailer、Header::get_scheduler、Header::get_id_ptr. Это самопроверка корректности смещений vtable во время выполнения.📎 tokio/src/runtime/task/core.rs:280-321Шаг четвёртый: отправить планировщику.

Планировщик, получив, вызываетNotified<S>. В multi_thread это приводит кSchedule::schedule 📎 tokio/src/runtime/task/mod.rs:315, задача помещается в локальную очередь текущего worker'а, а при переполнении очереди перетекает в очередь инъекции.push_back_or_overflowСледующая диаграмма описывает поток управления и ветвления от

до постановки в очередь:new_taskКопировать

mermaid
flowchart TD
    spawn_call["Handle::spawn(future)"] --> new_task["new_task::<T,S>(future, scheduler, id)"]
    new_task --> raw_new["RawTask::new::<T,S>"]
    raw_new --> cell_new["Cell::new: Box::new(Cell{header, core, trailer})"]
    cell_new --> vtable["raw::vtable::<T,S>() 生成函数指针表"]
    cell_new --> stage["Stage::Running(future) 移入"]
    cell_new --> debug_check{"debug_assertions?"}
    debug_check -->|是| check_layout["check(): 断言 trailer/scheduler/id 偏移量"]
    debug_check -->|否| skip_check["跳过"]
    check_layout --> triple["派生 (Task, Notified, JoinHandle)"]
    skip_check --> triple
    triple --> owned["Task 存入 OwnedTasks"]
    triple --> sched["Notified 交给 Schedule::schedule"]
    sched --> push{"本地队列有容量?"}
    push -->|是| local_push["push_back_finish: 写入 buffer[tail & MASK]"]
    push -->|否| overflow_check{"steal == real?"}
    overflow_check -->|否, 有并发窃取| inject_only["overflow.push(task) 仅注入"]
    overflow_check -->|是| push_overflow["push_overflow: CAS 认领后半批"]
    push_overflow --> cas_ok{"CAS 成功?"}
    cas_ok -->|是| inject_batch["overflow.push_batch(后半批 + 当前 task)"]
    cas_ok -->|否| retry["返回 Err(task), 重试 push_back_or_overflow"]
    retry --> push

), и если он есть, только текущая задача помещается в очередь инъекции, поскольку освобождённое вором пространство скоро станет доступным.steal != realРазмышление о дизайне: почему три ссылки, а не одна

возвращает три ссылки, а не одну. Это ядро дизайна подсчёта ссылок:

new_taskпредставляет «время выполнения владеет этой задачей»,Taskпредставляет «эта задача была уведомлена и ожидает планирования»,Notifiedпредставляет «кто-то заинтересован в её результате». Все три имеют независимые жизненные циклы —JoinHandleможет быть drop (задача продолжает выполняться, результат отбрасывается),JoinHandleисчезает после poll,Notifiedосвобождается после завершения задачи и удаления изTask. Если бы была только одна ссылка, нельзя было бы выразить состояние «задача ещё выполняется, но никто не делает join».OwnedTasks— ещё одно важное ветвление: он хранит

UnownedTaskдвасчётчика ссылок, используемых для blocking-задач (не сохраняются вфункция черезOwnedTasks)📎 tokio/src/runtime/task/mod.rs:286-295。unownedиmem::forget(task)объединяет две ссылки вmem::forget(notified). Мотивация дизайна «двух ссылок» такова: у blocking-задач нетUnownedTask 📎 tokio/src/runtime/task/mod.rs:388-397списка для хранения owned-ссылки, поэтому нужен дополнительный счётчик ссылок, чтобы гарантировать, что задача не будет освобождена во время выполнения.OwnedTasks3.2 Биты состояния: как один usize кодирует весь жизненный цикл задачи

Интуитивная модель

Представьте состояние задачи как «бланк медицинского осмотра» с несколькими независимыми флажками: выполняется ли poll, завершена ли, уведомлена ли, отменена ли, есть ли join. Tokio не использует несколько булевых полей, а упаковывает эти флаги в

один. Так каждое изменение состояния требует лишь одного CAS, а не множества блокировок. Без этого дизайна переходы состояний задачи превратились бы во вложенность нескольких блокировок, а риск взаимоблокировок и накладные расходы резко возросли бы.AtomicUsizeРаскладка битовых полей

Битовые поля

Stateполностью определены в документации модуля📎 tokio/src/runtime/task/mod.rs:32-53:

  • RUNNING: выполняется ли poll задачи или её отмена.Этот бит одновременно служит блокировкой задачи 📎 tokio/src/runtime/task/mod.rs:37-38。
  • COMPLETE: future полностью завершён и удалён. После установки никогда не сбрасывается и никогда не устанавливается одновременно сRUNNING📎 tokio/src/runtime/task/mod.rs:40-41。
  • NOTIFIED: существует ли в данный момент объектNotified📎 tokio/src/runtime/task/mod.rs:43。
  • CANCELLED: задача должна быть отменена как можно скорее📎 tokio/src/runtime/task/mod.rs:45-46。
  • JOIN_INTEREST: существуетJoinHandle 📎 tokio/src/runtime/task/mod.rs:48。
  • JOIN_WAKER: управляющий бит доступа как waker для join handle📎 tokio/src/runtime/task/mod.rs:50-51。

Остальные биты используются для счётчика ссылок📎 tokio/src/runtime/task/mod.rs:53。

RUNNINGТот факт, что бит служит блокировкой, заслуживает подробного рассмотрения. В разделе Safety документации модуля указано: любой изменяемый доступ к future должен происходить после получения блокировки путём измененияRUNNINGбита, что гарантирует эксклюзивный доступ📎 tokio/src/runtime/task/mod.rs:130-133. Это означает, что при poll задачи поток сначала CAS устанавливаетRUNNING, и в случае успеха получает эксклюзивный доступ к future; при неудаче это означает, что другой поток уже выполняет poll, и текущий poll немедленно возвращается. Это объединяет «взаимное исключение poll» и «переход состояния» в одну атомарную операцию, избегая отдельного мьютекса.

Протокол управления доступом JOIN_WAKER

JOIN_WAKERБитwaker— самая изящная часть всего конечного автомата. Он решает проблему:Trailerполе (в) может одновременно быть доступен двум потокам — среда выполнения при завершении задачичитаетJoinHandleего, чтобы разбудить join'ера,при pollзаписывает📎 tokio/src/runtime/task/mod.rs:75-120:

1. JOIN_WAKERего для регистрации waker. Документация модуля приводит 7 правил

изначально равен 0.JoinHandle2. Когда равен 0,

имеет эксклюзивный (изменяемый) доступ к полю waker.JoinHandle3. Когда равен 1,

имеет только разделяемый (только для чтения) доступ.COMPLETE4. Когда равен 1 и

5. JoinHandleравен 1, среда выполнения имеет разделяемый (только для чтения) доступ к полю waker.JOIN_WAKERЧтобы записать waker, необходимо: (i) успешно установитьJOIN_WAKERв 0 для получения эксклюзивного права, (ii) записать waker, (iii) успешно установить

6. JoinHandleв 1.COMPLETEможет изменятьJOIN_WAKERтолько когдаCOMPLETEравен 0; среда выполнения может изменять только когда

равен 1.JOIN_INTEREST7. ЕслиCOMPLETEравен 0 и

равен 1, среда выполнения имеет эксклюзивный доступ к полю waker (для drop waker).COMPLETEПравило 6 подразумевает гонку: шаг (i) или (iii) может завершиться неудачей. Если (i) не удался, запись waker отменяется; если (iii) не удался (другой поток за это время установил📎 tokio/src/runtime/task/mod.rs:110-120), то поле waker очищается

. Суть этого протокола: с помощью одного атомарного бита динамически передавать владение между «писателем» и «читателем», избегая отдельной блокировки для поля waker.

TaskДва способа уменьшения счётчика ссылокUnownedTaskdrop уменьшается дважды:

rust
impl<S: 'static> Drop for Task<S> {
    fn drop(&mut self) {
        if self.header().state.ref_dec() {
            self.raw.dealloc();
        }
    }
}

📎 tokio/src/runtime/task/mod.rs:580-586

rust
impl<S: 'static> Drop for UnownedTask<S> {
    fn drop(&mut self) {
        if self.raw.header().state.ref_dec_twice() {
            self.raw.dealloc();
        }
    }
}

📎 tokio/src/runtime/task/mod.rs:590-596

ref_decвозвращаетtrueуказывает, что это последняя ссылка, и только тогда真正 освобождаетCellпамять.ref_dec_twiceэтоUnownedTaskпрямое проявление удержания двух счётчиков.

Размышление о дизайне: почему биты состояния и счётчик ссылок используют один атомарный

〔Проектные выводы и архитектурные компромиссы〕

Размещение битов состояния и счётчика ссылок в одномAtomicUsizeсделано для того, чтобы две операции — «уменьшение счётчика ссылок» и «установка бита состояния» — могли быть выполнены заодин CAS. В документации модуля в комментарии кSchedule::releaseявно сказано: «Модуль задач будет пакетно обрабатывать ref-dec и установку других опций»📎 tokio/src/runtime/task/mod.rs:302-304. Если бы биты состояния и счётчик ссылок находились в двух разных атомарных переменных, то между «освобождением последней ссылки» и «отметкой завершения» возникло бы окно, требующее дополнительной синхронизации. После объединенияref_decможет атомарно выполнить «уменьшение счётчика + проверку обнуления», избегая проблем класса ABA.

3.3 JoinHandle: как результат передаётся через границы задачи

Интуитивная модель

JoinHandleподобен «талону на получение блюда», который выдаёт вам ресторан. Когда задача (кухня) завершается, она кладёт блюдо (output) на раздачу (Stage::Finished), а затем активирует ваш пейджер (waker). Вы приходите с талоном, чтобы забрать его; сам талон не содержит блюда, это лишь указатель на раздачу. Если вы потеряете талон (dropJoinHandle), блюдо будет сразу выброшено (output будет drop), но кухня из-за этого не остановится.

Структура данных

JoinHandle<T>также является прозрачной обёрткой надRawTask:

rust
pub struct JoinHandle<T> {
    raw: RawTask,
    _p: PhantomData<T>,
}

📎 tokio/src/runtime/task/join.rs:163-166

PhantomData<T>помечает тип вывода.JoinHandle<T>только вT: SendявляетсяSend/Sync 📎 tokio/src/runtime/task/join.rs:169-170, это гарантирует, что non-Send output не будет перемещён между потоками.

Пошагово: await для JoinHandle

JoinHandleреализуетFuture, егоpollявляется ядром передачи результата:

rust
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
    ready!(crate::trace::trace_leaf());
    let mut ret = Poll::Pending;
    let coop = ready!(crate::task::coop::poll_proceed(cx));
    unsafe {
        self.raw.try_read_output(&mut ret, cx.waker());
    }
    if ret.is_ready() {
        coop.made_progress();
    }
    ret
}

📎 tokio/src/runtime/task/join.rs:327-354

Обратите внимание на несколько деталей:trace_leafиспользуется для инструментирования tracing;coop::poll_proceedрасходует бюджет кооперации (подробно в главе 12);try_read_outputчерез vtable стирает обобщения, размещает возвращаемое значение на стеке и с помощью*mut ()передаёт в📎 tokio/src/runtime/task/join.rs:327-354. Этот приём «размещения возвращаемого значения на стеке» нужен потому, что функция vtable не может обобщённо возвращать типT, и может только записывать обратно через сырой указатель.

〔Проектные выводы и архитектурные компромиссы〕

try_read_outputвнутренняя логика (в raw.rs, исходный код в этой главе не предоставлен): сначала проверяется битCOMPLETE, если он уже установлен, вызываетсяtake_outputдля извлечения результата изStage::Finished; иначеcx.waker()регистрируется в полеTrailer::wakerи возвращаетсяPending. Процесс регистрации как раз следует протоколуJOIN_WAKERиз раздела 3.2.

Передача владения результатом

Раздел «Non-Send output» документации модуля точно описывает правила владения результатом📎 tokio/src/runtime/task/mod.rs:151-170:

  • При завершении задачи output помещается вStage, затем выполняется переход «установить COMPLETE» и считывается текущее значениеJOIN_INTEREST.
  • ЕслиJOIN_INTERESTравно 0 (нетJoinHandle), output немедленно drop📎 tokio/src/runtime/task/mod.rs:157-158。
  • ЕслиJOIN_INTERESTравно 1,JoinHandleотвечает за очистку output📎 tokio/src/runtime/task/mod.rs:160-161。

Для non-Send output документация приводит трёхшаговое доказательство: output создаётся в потоке, который выполняет poll future;JoinHandle<Output>также не Send, когда Output не Send, поэтому он тоже находится в потоке spawn; следовательно,JoinHandleпри извлечении или drop output не перемещается между потоками📎 tokio/src/runtime/task/mod.rs:164-170。

drop для JoinHandle: быстрый и медленный пути

rust
impl<T> Drop for JoinHandle<T> {
    fn drop(&mut self) {
        if self.raw.state().drop_join_handle_fast().is_ok() {
            return;
        }
        self.raw.drop_join_handle_slow();
    }
}

📎 tokio/src/runtime/task/join.rs:358-364

drop_join_handle_fastпытается одним CAS выполнить «очистку битаJOIN_INTEREST+ уменьшение счётчика ссылок». Если не удаётся (например, задача завершается, бит состояния занят), то идёт по медленному путиdrop_join_handle_slow. Это типичный шаблон «оптимистичный быстрый путь + пессимистичный медленный путь».

Размышление о дизайне: почему JoinHandle не хранит output напрямую

〔Проектные выводы и архитектурные компромиссы〕

ЕслиJoinHandleнапрямую хранит output, то output должен быть перемещён в поток, где находитсяJoinHandle, при завершении задачи. НоJoinHandleможет быть перемещён в любой поток (при условииT: Send), а поток, создающий output, — это поток poll. Прямое хранение привело бы к перемещению между потоками, когда «output создаётся в потоке poll, но должен быть drop в потоке join», что для non-Send output напрямую нарушает систему типов. Tokio выбирает оставить output вCell(Stage::Finished),JoinHandleхранит толькоCell, указывающий наRawTask, и при получении результата извлекает его на месте черезtake_output. Таким образом, drop output происходит в потоке, где находитсяJoinHandle, но при условии, что этот поток совпадает с потоком poll (выполняется в сценарии non-Send).

3.4 Локальная очередь: структура производитель-потребитель для work-stealing

Интуитивная модель

У каждого worker есть «личный список дел» (локальная очередь) ёмкостью 256. Сам worker берёт задачи сголовы(LIFO, используя локальность кэша), другие worker'ыкрадутзадачи с хвоста (FIFO, забирая самые старые, наиболее вероятно уже завершённые задачи). Без локальной очереди все задачи толпились бы в глобальной очереди, и при каждом взятии задачи приходилось бы конкурировать за глобальную блокировку, что разрушило бы масштабируемость на многоядерных системах.

Раскладка памяти: разделение head и tail

rust
pub(crate) struct Inner<T: 'static> {
    head: AtomicUnsignedLong,
    tail: AtomicUnsignedShort,
    buffer: Box<[UnsafeCell<MaybeUninit<task::Notified<T>>>; LOCAL_QUEUE_CAPACITY]>,
}

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:36-57

headэтоAtomicUnsignedLong(64 бита, если платформа поддерживает u64),tailэтоAtomicUnsignedShort(32 бита). Комментарий объясняет, почему индексы шире, чем фактически необходимо: для смягчения ABA и различения «полного» и «пустого» буфера📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:37-49。

headвнутри упаковываетдва UnsignedShort:младшие биты — это «реальная голова» (real head), старшие — «первая позиция, обрабатываемая вором» (steal head). Когда они равны, активных воров нет📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:39-49. Эта двойная упаковка — ключевой приём очереди work-stealing: вор сначала через CAS обновляет значение steal, чтобы «застолбить» партию задач, а завершив, догоняет значение steal до real, обозначая конец кражи.

LOCAL_QUEUE_CAPACITYВне loom это 256, под loom сокращается до 4, чтобы протестировать больше граничных случаев📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:62-69。MASK = LOCAL_QUEUE_CAPACITY - 1, используется для индексации кольцевого буфера📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:71。

Пошагово: полное ветвление push_back_or_overflow

Это самая сложная функция локальной очереди, разберём её по ветвям:

rust
pub(crate) fn push_back_or_overflow<O: Overflow<T>>(
    &mut self,
    mut task: task::Notified<T>,
    overflow: &O,
    stats: &mut Stats,
) {
    let tail = loop {
        let head = self.inner.head.load(Acquire);
        let (steal, real) = unpack(head);
        let tail = unsafe { self.inner.tail.unsync_load() };

        if tail.wrapping_sub(steal) < LOCAL_QUEUE_CAPACITY as UnsignedShort {
            break tail;
        } else if steal != real {
            overflow.push(task);
            return;
        } else {
            match self.push_overflow(task, real, tail, overflow, stats) {
                Ok(_) => return,
                Err(v) => { task = v; }
            }
        }
    };
    self.push_back_finish(task, tail);
}

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:188-223

Три ветви:

1. Есть ёмкость(tail - steal < CAPACITY):break tail, после выхода из цикла вызываетсяpush_back_finishзапись в буфер.

2. Нет ёмкости, но есть конкурентные воры(steal != real): воры освободят место, поэтому текущая задача просто помещается в очередь инъекции и сразу возвращается📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:204-208。

3. Нет ёмкости и нет воров: вызываетсяpush_overflowпереполнение второй половины партии задач в очередь инъекции📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:209-219. Если CAS не удался (проиграли конкурентному вору),push_overflowвозвращаетErr(task), цикл повторяется.

push_back_finishзапись задачи и обновление tail:

rust
fn push_back_finish(&self, task: task::Notified<T>, tail: UnsignedShort) {
    let idx = tail as usize & MASK;
    self.inner.buffer[idx].with_mut(|ptr| {
        unsafe { ptr::write((*ptr).as_mut_ptr(), task); }
    });
    self.inner.tail.store(tail.wrapping_add(1), Release);
}

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:226-244

Releaseпорядок гарантирует видимость записанной задачи для воров.

push_overflow: почему переполняется вторая половина партии

rust
const NUM_TASKS_TAKEN: UnsignedShort = (LOCAL_QUEUE_CAPACITY / 2) as UnsignedShort;

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:265

При переполнении забираются 128 задач. Комментарий подробно объясняет, почему берётсявторая половина, а не первая📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:295-306: при извлечении задач из очереди инъекции они всегда помещаются в первую половину. Поэтому если задача находится во второй половине, можно быть уверенным, что она не была только что взята из очереди инъекции. Это гарантирует, что «задача, извлечённая из очереди инъекции, не будет немедленно возвращена обратно в очередь инъекции» (по крайней мере, до того, как её хотя бы раз опросят через poll).

CAS-застолбление второй половины партии:

rust
if self.inner.head.compare_exchange_weak(
    pack(head, head), pack(tail, tail), Release, Relaxed
).is_err() {
    return Err(task);
}

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:283-293

Обновлениеheadс(head, head)до(tail, tail), то есть одновременное продвижение steal и real до tail, застолбление всех задач. После успеха tail откатывается доtail + NUM_TASKS_TAKEN, что означает, что первая половина партии остаётся в локальной очереди📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:314-316。

pop и steal_into: два пути извлечения задач

pop— это извлечение задачи самим worker'ом (с головы, LIFO):

rust
pub(crate) fn pop(&mut self) -> Option<task::Notified<T>> {
    let mut head = self.inner.head.load(Acquire);
    let idx = loop {
        let (steal, real) = unpack(head);
        let tail = unsafe { self.inner.tail.unsync_load() };
        if real == tail { return None; }
        let next_real = real.wrapping_add(1);
        let next = if steal == real {
            pack(next_real, next_real)
        } else {
            assert_ne!(steal, next_real);
            pack(steal, next_real)
        };
        let res = self.inner.head.compare_exchange_weak(head, next, AcqRel, Acquire);
        match res {
            Ok(_) => break real as usize & MASK,
            Err(actual) => head = actual,
        }
    };
    Some(self.inner.buffer[idx].with(|ptr| unsafe { ptr::read(ptr).assume_init() }))
}

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:361-399

Ключевая ветвь: еслиsteal == real(нет воров), продвигаются оба; иначе продвигается только real, steal остаётся неизменным📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:377-384。assert_ne!(steal, next_real)гарантирует, что real не будет продвинут до позиции steal, иначе будет нарушено состояние застолбления воров.

steal_into— это путь кражи, сначала проверяется, достаточно ли места в целевой очереди:

rust
if dst_tail.wrapping_sub(steal) > LOCAL_QUEUE_CAPACITY as UnsignedShort / 2 {
    return None;
}

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:431-435

Если целевая очередь заполнена более чем наполовину, кража не выполняется, чтобы избежать немедленного переполнения после кражи.

steal_into2— ядро кражи, вычисляется количество для кражи:

rust
let n = src_tail.wrapping_sub(src_head_real);
let n = n - n / 2;

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:487-488

Крадётся половина (с округлением вверх). Затем через CAS обновляется значение steal в head для застолбления:

rust
let steal_to = src_head_real.wrapping_add(n);
next_packed = pack(src_head_steal, steal_to);
let res = self.0.head.compare_exchange_weak(prev_packed, next_packed, AcqRel, Acquire);

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:496-506

Обратите внимание, что здесь обновляется только значение real (pack(src_head_steal, steal_to)steal остаётся неизменным), real продвигается доsteal_to. Это означает «эти задачи застолблены, другие воры не могут их трогать». После завершения кражи steal догоняется до real:

rust
loop {
    let head = unpack(prev_packed).1;
    next_packed = pack(head, head);
    let res = self.0.head.compare_exchange_weak(prev_packed, next_packed, AcqRel, Acquire);
    match res {
        Ok(_) => return n,
        Err(actual) => prev_packed = actual,
    }
}

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:548-561

Приведённая ниже временная диаграмма описывает трёхстороннее конкурентное взаимодействие «производитель push, потребитель pop, вор steal»:

mermaid
sequenceDiagram
    participant P as "Worker A (生产者)"
    participant Q as "Local 队列 Inner"
    participant C as "Worker A (消费者 pop)"
    participant S as "Worker B (窃取者)"

    P->>Q: "load head (Acquire)"
    P->>Q: "unsync_load tail"
    Note over P: "tail - steal < 256?"
    P->>Q: "push_back_finish: buffer[idx] = task"
    P->>Q: "store tail+1 (Release)"

    C->>Q: "load head (Acquire)"
    C->>Q: "unsync_load tail"
    Note over C: "real == tail? 空则返回 None"
    C->>Q: "CAS head: pack(real+1, real+1)"
    Q-->>C: "Ok, 读取 buffer[real & MASK]"

    S->>Q: "load head (Acquire)"
    S->>Q: "load tail (Acquire)"
    Note over S: "src_head_steal != src_head_real? 返回 0"
    S->>Q: "CAS head: pack(steal, real+n) 认领一半"
    Q-->>S: "Ok, 拷贝 n 个任务到 dst"
    S->>Q: "CAS head: pack(real+n, real+n) 完成窃取"
    Q-->>S: "返回 n"

Размышления о дизайне: почему локальная очередь LIFO, а кража FIFO

〔Дизайнерские выводы и архитектурные компромиссы〕

Worker сам извлекает с головы (LIFO), потому что последняя добавленная задача с наибольшей вероятностью ещё находится в кэше CPU и с наибольшей вероятностью является задачей «только что разбуженной, данные ещё горячие». Воры извлекают с хвоста (FIFO), потому что самая старая задача с наибольшей вероятностью уже выполнила большую часть работы, и её кража быстрее всего снизит нагрузку жертвы. Эта комбинация «LIFO локально + FIFO кража» — классический дизайн work-stealing планирования, сочетающий локальность кэша и балансировку нагрузки.

На этом задача завершила превращение из Future в планируемую сущность: ей назначен счётчик ссылок, она помещена вCellраскладку памяти и успешно доставлена в локальную очередь worker'а или глобальную очередь инъекции. Но помещение задачи в очередь — это только начало; по-настоящему заставляет её работать цикл планирования потока worker'а. В следующей главе мы войдём во вторую половину «жизни задачи», проследим, как worker извлекает задачу из очереди, вызываетFuture::poll, и при возвратеPendingчерезWakerрегистрирует пробуждение, в конечном итоге запускаяscheduleповторную постановку в очередь — полный путь вызовов замкнутого цикла «пробуждение → постановка в очередь → повторный poll», а также стратегия work-stealing и оптимизация LIFO-слотов будут раскрыты там.

CHAPTER 04

Глава 4: Жизнь задачи (часть 2): цикл планирования, poll и замкнутый цикл пробуждения

Проект: tokio-rs/tokio · Прогресс книги: Глава 4 / 14 · Статус верификации: FACT номера строк реально привязаны

От очереди к выполнению: скелет главного цикла worker'а

В предыдущей главе мы отправили задачу вLocalочередь или глобальную очередь инъекции. Но очередь — это лишь «список дел», а по-настоящему заставляет задачи выполняться тот бесконечный цикл в потоке worker'а. В этой главе мы проследимContext::run— это сердце всего многопоточного планировщика.

Сначала построим интуицию: поток worker'а подобен повару, перед которым лежит стопка своих заказов (run_queue), а рядом ещё есть общая стойка заказов (inject). Повар сначала смотрит на ближайший к себе лист (lifo_slot), если нет — берёт из своей стопки, если и там нет — хватает горсть с общей полки, а если и это не помогло — крадёт несколько штук из стопки другого повара. Только когда всё пусто, он идёт отдыхать, но даже во время отдыха держит ухо востро — стоит появиться заказу, как он тут же просыпается.

Без этого цикла задача после постановки в очередь навсегда останется лежать в очереди,Future::pollникогда не будет вызвана, и всё runtime — просто куча мёртвых данных.

Структура памяти и поля состояния Core

Всё изменяемое состояние worker хранится вCore, оноBoxвыделено в куче и передаётся черезAtomicCell<Core>междуWorkerи локальным для потокаContext.

CoreКлючевые поля📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:113-167:

  • tick: u32следующие: счётчик, инкрементируемый на каждой итерации цикла, используется для периодического запуска обслуживания (maintenance) и проверки глобальной очереди.
  • lifo_slot: Option<Notified>:LIFO-слот, это самое изящное решение в данной главе. Когда worker сам планирует задачу, она не попадает вrun_queue, а кладётся в этот слот, и при следующем взятии задачив первую очередьберётся оттуда.
  • lifo_enabled: bool: переключатель LIFO-слота, используется для предотвращения голодания в сценариях ping-pong.
  • run_queue: queue::Local<Arc<Handle>>: локальная очередь, структураLocal, разобранная в предыдущей главе.
  • is_searching: bool: ищет ли worker задачи, которые можно украсть.
  • is_shutdown: bool / is_traced: bool: флаги завершения и трассировки.
  • park: Option<Parker>: паркер, обёрнутый вOptionдля удобного извлечения/возврата под проверкой заимствования.
  • global_queue_interval: u32: как часто проверять глобальную очередь.
  • rand: FastRand: быстрый генератор случайных чисел, используется для случайного выбора начальной точки кражи.
〔Проектные предположения и архитектурные компромиссы〕

Обратите внимание, чтоlifo_slot— этоOption<Notified>, а не очередь — он храниттолько однузадачу. Мотивация этого решения ясно описана в комментариях к исходному коду📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:117-121: задачи, запланированные самим worker, сохраняются в этот слот, и worker проверяет егоrun_queue передпроверкой

Почему LIFO снижает задержку? Рассмотрим типичный сценарий передачи сообщений: задача A, обработав сообщение, пробуждает задачу B, B, обработав, пробуждает A. Если после пробуждения B со стороны A задача B сразу запускается, данные, нужные B, скорее всего, ещё находятся в кэше CPU (так как A только что к ним обращалась). Если же B помещается в хвост очереди, то пока выполнятся десятки задач перед ней, кэш давно будет вытеснен.

Но у LIFO есть риск голодания. В исходном коде используетсяMAX_LIFO_POLLS_PER_TICK = 3для ограничения📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:263-263: на каждом тике LIFO-слот получает приоритет максимум 3 раза, после чего отключается, давая возможность выполниться другим задачам.

Разбор основного цикла: один полный цикл планирования

Представим конкретный сценарий: worker 0 только что проснулся изpark,run_queueсодержит 5 задач,lifo_slotсодержит 1 задачу, в глобальной очереди 3 задачи.

Точка входа основного цикла —Context::run 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:570-642. Сначала он сбрасываетlifo_enabled(так как core мог быть украден черезblock_in_place, состояние нужно вернуть на место)📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:571-573, затем входит в циклwhile !core.is_shutdown.

Каждая итерация цикла делает четыре вещи:

Шаг первый: tick и обслуживание. core.tick()инкрементирует счётчик📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:587. Затемself.maintenance(core)проверяетtick % event_interval == 0, и если да, вызываетpark_yieldдля управления I/O и таймерами с нулевым таймаутом📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:809-826。

Шаг второй: получение задачи. core.next_task(&self.worker)— это основная логика получения задачи📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1090-1156. Она имеет два пути:

  • Когдаtick % global_queue_interval == 0,в первую очередьберёт из глобальной очереди, если не удаётся — из локальной📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1091-1098. Это делается для предотвращения голодания задач в глобальной очереди.
  • Иначев первую очередьберёт локальные задачи📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1090-1156。

Локальное получение задачи выполняется черезnext_local_task📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1158-1160:

rust
fn next_local_task(&mut self) -> Option<Notified> {
    self.lifo_slot.take().or_else(|| self.run_queue.pop())
}

сначала берётся LIFO-слот, затем голова очереди (LIFO-извлечение). Это и есть «локальный LIFO», о котором говорилось в предыдущей главе.

Если локальная очередь пуста, но глобальная не пуста, workerпакетнозабирает задачи из глобальной очереди📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1110-1154. Размер пакетаnвычисляется очень продуманно:min(inject.len() / remotes.len() + 1, cap), гдеcapв свою очередь берётmin(remaining_slots, max_capacity / 2). Комментарий в исходном коде объясняет, почему ограничение составляет половину ёмкости очереди📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1120-1131: чтобы гарантировать, что извлечённые задачи попадут впервую половинулокальной очереди, и таким образом, даже если впоследствии произойдёт переполнение, эти задачи не будут вытолкнуты обратно в глобальную очередь (переполнение затрагивает только вторую половину).

Шаг третий: запуск задачи.Получив задачу, вызываетrun_task 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:647-796. Это самая сложная функция в данной главе, мы разберём её отдельно в следующем разделе.

Шаг четвёртый: кража или парковка.Еслиnext_taskвозвращаетNone, это означает, что ни локально, ни глобально работы нет, вызываетсяsteal_work 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1167-1195. Если кража не удалась, происходит вход вparkилиpark_yield 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:613-621。

Весь поток управления выглядит так:

mermaid
flowchart TD
    start["Context::run 进入循环"] --> tick["core.tick() 自增"]
    tick --> maint{"tick % event_interval == 0?"}
    maint -->|是| park_yield["park_yield 驱动 I/O 与定时器"]
    maint -->|否| next
    park_yield --> next["core.next_task()"]
    next --> has_task{"取到任务?"}
    has_task -->|是| run_task["run_task 执行 poll"]
    run_task --> cont{"core 还在?"}
    cont -->|是| tick
    cont -->|否| ret["return 退出"]
    has_task -->|否| steal["core.steal_work()"]
    steal --> steal_ok{"窃取成功?"}
    steal_ok -->|是| run_task
    steal_ok -->|否| defer_check{"defer 非空?"}
    defer_check -->|是| py["park_yield"]
    defer_check -->|否| pk["park 阻塞等待"]
    py --> tick
    pk --> tick

run_task: poll и замкнутый цикл LIFO-слота

run_task— это место, где задача действительноpoll, и одновременно точка замыкания цикла «пробуждение → постановка в очередь → повторный poll».

Первое, что делается после входа в функцию —assert_owner 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:648, преобразуетNotifiedвTask, одновременно утверждая (debug-assert), что текущий поток действительно является владельцем этой задачи.

Затемtransition_from_searching 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:652— если worker ранее находился в состоянии поиска, теперь, когда задача найдена, нужно выйти из состояния поиска и, возможно, разбудить других припаркованных worker'ов.

Далее идёт ключевая обёртка budget📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:695-795:

rust
coop::budget(|| {
    task.run();
    let mut lifo_polls = 0;
    loop {
        let mut core = match self.core.borrow_mut().take() {
            Some(core) => core,
            None => return ControlFlow::Break(()),
        };
        let task = match core.lifo_slot.take() {
            Some(task) => task,
            None => {
                self.reset_lifo_enabled(&mut core);
                core.stats.end_poll();
                return ControlFlow::Continue(core);
            }
        };
        if !coop::has_budget_remaining() {
            core.run_queue.push_back_or_overflow(task, ...);
            return ControlFlow::Continue(core);
        }
        lifo_polls += 1;
        if lifo_polls >= MAX_LIFO_POLLS_PER_TICK {
            core.lifo_enabled = false;
        }
        let task = self.worker.handle.shared.owned.assert_owner(task);
        *self.core.borrow_mut() = Some(core);
        task.run();
    }
})

Этот фрагмент кода раскрывает полный замкнутый цикл LIFO-слота:task.run()выполняетFuture::poll, и если в процессе poll задача пробудила себя или другую задачу,schedule_localпомещает новую задачу вlifo_slot 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1396-1408. После возврата из poll цикл сразу проверяетlifo_slot, и если есть задача, продолжает выполнение —не возвращаясь в основной цикл, напрямую выполняя последовательные poll в рамках одного budget.

Это и есть проявление «пробуждение → постановка в очередь → повторный poll» на пути LIFO: при пробуждении задача помещается вlifo_slot, после возврата из poll немедленно извлекается и снова poll'ится, образуя плотный замкнутый цикл.

Обратите внимание на веткуself.core.borrow_mut().take()None📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:716-724: если core был украден (например, внутри задачи был вызванblock_in_place), worker должен вернутьControlFlow::Break(()), чтобыContext::runвышел. Этоblock_in_placeТочки взаимодействия с циклом планирования.

Путь пробуждения: как Waker запускает повторную постановку в очередь

КогдаFuture::pollвозвращаетPending, задача должна зарегистрироватьWaker, чтобы быть разбуженной при готовности события. РеализацияWakerв Tokio чрезвычайно лаконична — это просто необработанный указатель наHeaderзадачи плюс таблица vtable.

waker_refСоздаётсяWakerRef 📎 tokio/src/runtime/task/waker.rs:11-34, оборачивается вManuallyDrop, чтобы избежать уменьшения счётчика ссылок при drop. vtable — статическаяWakerКопирование📎 tokio/src/runtime/task/waker.rs:119-119:

rust
static WAKER_VTABLE: RawWakerVTable =
    RawWakerVTable::new(clone_waker, wake_by_val, wake_by_ref, drop_waker);

, а затем вызывают соответствующий методHeader. Например,RawTaskв конечном итоге вызывает📎 tokio/src/runtime/task/waker.rs:70-116. Семантика такова: перевести состояние задачи изwake_by_refвraw.wake_by_ref() 📎 tokio/src/runtime/task/waker.rs:106-116。

wake_by_ref, и если преобразование успешно (то есть ранее действительно было PENDING), вызватьPENDINGдля повторной постановки задачи в очередь.SCHEDULEDДля многопоточного планировщикаSchedule::scheduleреализация находится в

КопированиеscheduleЛогика разделяется на две ветви:Handle::schedule_task 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1353-1376:

rust
pub(super) fn schedule_task(&self, task: Notified, is_yield: bool) {
    with_current(|maybe_cx| {
        if let Some(cx) = maybe_cx {
            if self.ptr_eq(&cx.worker.handle) {
                if let Some(core) = cx.core.borrow_mut().as_mut() {
                    self.schedule_local(core, task, is_yield);
                    return;
                }
            }
        }
        self.push_remote_task(task);
        self.notify_parked_remote();
    });
}

— помещает в LIFO-слот или локальную очередь.

  • Иначе (пробуждение из внешнего потока или core украден), идёт черезschedule_local 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1385-1417— помещает в глобальную очередь инъекций и
  • будит parked workerpush_remote_taskВнутри снова две ветвиnotify_parked_remote: если это📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1379-1383。

schedule_localили LIFO отключён, помещает в📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1385-1417хвост; иначе помещает вyield, а задачу из исходного слота вытесняет в хвост очереди.run_queueКопированиеlifo_slotpark и unpark: атомарность машины состояний и пробуждения

mermaid
sequenceDiagram
    participant Future as "Future::poll"
    participant Waker as "Waker(wake_by_ref)"
    participant RawTask as "RawTask::wake_by_ref"
    participant Handle as "Handle::schedule_task"
    participant Core as "Core(schedule_local)"
    participant Inject as "InjectQueue"
    participant Parker as "Unparker"

    Future->>Waker: "возвращает Pending, регистрирует waker"
    Note over Future: "событие готово (например, epoll)"
    Waker->>RawTask: "raw.wake_by_ref()"
    RawTask->>RawTask: "state: PENDING -> SCHEDULED"
    RawTask->>Handle: "schedule(Notified)"
    alt текущий поток — тот же worker и владеет core
        Handle->>Core: "schedule_local: поместить в lifo_slot"
    else внешний поток или core украден
        Handle->>Inject: "push_remote_task"
        Handle->>Parker: "notify_parked_remote().unpark()"
    end

машины состояний плюс

как запасной вариант.AtomicUsizeПоляCondvar. Констант состояния четыре

Inner: не припаркован.📎 tokio/src/runtime/scheduler/multi_thread/park.rs:31-43:state: AtomicUsize、mutex: Mutex<()>、condvar: Condvar、shared: Arc<Shared>: припаркован на condvar.📎 tokio/src/runtime/scheduler/multi_thread/park.rs:36-45:

  • EMPTY = 0: припаркован на I/O driver.
  • PARKED_CONDVAR = 1: уже разбужен.
  • PARKED_DRIVER = 2Это явная машина состояний, мы используем её для построения диаграммы состояний (это единственное место в главе, соответствующее критериям
  • NOTIFIED = 3— в исходном коде действительно есть эти четыре константы состояния):

КопированиеstateDiagram-v2Реализация

mermaid
stateDiagram-v2
    [*] --> Empty
    Empty --> ParkedCondvar : "park_condvar() CAS(EMPTY->PARKED_CONDVAR)"
    Empty --> ParkedDriver : "park_driver() CAS(EMPTY->PARKED_DRIVER)"
    Empty --> Notified : "unpark() swap(NOTIFIED)"
    ParkedCondvar --> Empty : "condvar 唤醒后 CAS(NOTIFIED->EMPTY)"
    ParkedCondvar --> Empty : "超时 swap(EMPTY)"
    ParkedDriver --> Empty : "driver 返回后 swap(EMPTY)"
    Notified --> Empty : "park() CAS(NOTIFIED->EMPTY) 消费通知"
    Notified --> Notified : "再次 unpark() swap(NOTIFIED)"

unpark, а не CAS; комментарий в исходном коде объясняет причину📎 tokio/src/runtime/scheduler/multi_thread/park.rs:277-290: необходимо выполнить release-операцию, чтобы припаркованный поток увидел записи до unpark, поэтому даже если state ужеswap, нужно записать ещё раз.📎 tokio/src/runtime/scheduler/multi_thread/park.rs:277-290Сначала пытается потребить уже имеющееся уведомлениеNOTIFIED: если CAS

parkуспешен, значит, ранее уже был разбужен, и сразу возвращается без блокировки. Иначе пытается захватить блокировку driver; если удалось — паркуется на driver, если нет — использует condvar как запасной вариант📎 tokio/src/runtime/scheduler/multi_thread/park.rs:132-149Внутри есть классическая двойная проверкаNOTIFIED -> EMPTY: сначала CAS📎 tokio/src/runtime/scheduler/multi_thread/park.rs:143-148。

park_condvar, если неудачно и это📎 tokio/src/runtime/scheduler/multi_thread/park.rs:162-180, значит, был разбужен до установки состояния, и в этот момент необходимоEMPTY -> PARKED_CONDVARдля синхронизации записи unparkNOTIFIED. Комментарий особо подчёркивает: даже зная, что этоswap(EMPTY), нужно прочитать один раз, потому что unpark мог быть вызван ещё раз после нашего чтения📎 tokio/src/runtime/scheduler/multi_thread/park.rs:167-177.NOTIFIEDКомментарийNOTIFIEDуказывает на классическую ловушку condvar: между установкой состояния

unpark_condvarприпаркованным потоком и фактическим📎 tokio/src/runtime/scheduler/multi_thread/park.rs:292-307есть окно, и если в этот период произойдёт notify, он будет проигнорирован. Решение: припаркованный поток в этот момент удерживаетPARKED, а поток unpark сначалаwaitзахватывает блокировку (тем самым ожидая освобождения припаркованным потоком), затемmutexРазмышления о дизайне: почему LIFO-слот — одиночный, а не очередьdrop(self.mutex.lock())〔Проектные выводы и архитектурные компромиссы〕notify_one。

Одиночный слот — намеренный компромисс. Если бы использовалась очередь, каждая побудка требовала бы постановки в очередь, а каждое извлечение задачи — извлечения из очереди, что дороже; к тому же очередь накапливала бы несколько задач, нарушая предположение локальности «последний разбуженный выполняется первым». Семантика одиночного слота — «помнить только последний»; вытесненная задача идёт в обычную очередь — это как раз соответствует закону убывающей отдачи локальности: последняя задача самая горячая, вторая — менее, а начиная с третьей выгода очень мала.

Это магическое число

также эмпирическое. Комментарий в исходном коде говорит: «несколько прогонов LIFO-слота, похоже, достаточно для выгоды от локальности; более 3 раз может чрезмерно перевешивать». Это предотвращает сценарий ping-pong, когда A будит B, а B будит A, что могло бы заморить другие задачи.

MAX_LIFO_POLLS_PER_TICK = 3Ещё один заслуживающий внимания дизайн — стратегия «поиска половины»📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:263-263: новый worker действительно пытается красть только тогда, когда ищет менее половины worker'ов. Это избегает CAS-конкуренции, вызванной тем, что все worker'ы одновременно бешено крадут.

Координируется черезsteal_workКража начинается со случайной точки📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1158-1160, обходит все remote, пропускает себяtransition_to_searching, вызываетidle.transition_worker_to_searching()для попытки кражи. После полной неудачи откатывается к глобальной очереди📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1197-1203。

Резюме главы📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1172-1174Главный цикл worker📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1179-1182— сердце планировщика: после каждого tick сначала берёт задачу (LIFO-слот → локальная очередь → глобальная очередь), если взял —steal_intoвыполняет poll, если не взял — крадёт, если кража не удалась — park.📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1197-1203。

Внутренний LIFO-цикл сжимает «пробуждение → постановка в очередь → повторный poll» в рамках одного budget, образуя замкнутый контур с низкой задержкой.

— это необработанный указатель плюс статическая vtable,Context::runчерез переход состояния запускаетrun_task, в зависимости от того, является ли текущий поток тем же worker, решает идти в локальную очередь или глобальную.run_taskИспользует четырёхсостоянийную атомарную машину плюс condvar как запасной вариант, решая классическую гонку потери пробуждения.WakerВ следующей главе мы покинем планировщик и войдём в мир I/O: как Reactor переводит события epoll вwake_by_refпробуждение, превращаяscheduleвpark/unparkВопросы для размышления и самопроверки в этой главе

Q1: Если изменитьWakerтак, чтобы сначала братьAsyncFdизPendingпревращается вReady。

Вопросы для размышления и самопроверки к этой главе

Q1: Еслиnext_local_taskизменить на сначала взятьrun_queueЗатем берёмlifo_slot, какие последствия будут в сценариях с интенсивной передачей сообщений?

Справочный разбор:next_local_taskТекущая реализация —self.lifo_slot.take().or_else(|| self.run_queue.pop()) 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1158-1160, сначала берётся LIFO-слот. Если наоборот сначала братьrun_queue, то только что разбуженные задачи, данные которых ещё горячие, будут поставлены в очередь на выполнение после других задач. В режиме передачи сообщений A→B→A, после пробуждения B не запустится немедленно, а будет ждать завершения других задач в очереди; к этому моменту данные, записанные A, могут быть вытеснены из кэша CPU, и выгода от локальности теряется. Что ещё серьёзнее,lifo_slotзадачи внутри будут ждать, покаrun_queueне опустеет, прежде чем выполняться, и задержка заметно возрастёт. Комментарий в исходном коде📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:117-121явно указывает, что этот порядок нужен для «улучшения локальности, использования преимуществ шаблона передачи сообщений и снижения задержки».

Q2: park_condvar, если убратьErr(NOTIFIED)в веткеself.state.swap(EMPTY, SeqCst), оставив толькоreturn, какие будут проблемы?

Справочный разбор: исходный код в веткеErr(NOTIFIED)выполняетlet old = self.state.swap(EMPTY, SeqCst) 📎 tokio/src/runtime/scheduler/multi_thread/park.rs:167-177. Комментарий объясняет📎 tokio/src/runtime/scheduler/multi_thread/park.rs:168-173: unpark мог быть вызван ещё раз после того, как мы прочиталиNOTIFIED, и необходимо выполнить операцию acquire, чтобы синхронизироваться с тем unpark и увидеть все записи до него. Если толькоreturnбез swap, state останется вNOTIFIED, при следующем park CASNOTIFIED -> EMPTYзавершится успешно и немедленно вернётся (потребив уже устаревшее уведомление), но хуже то, что release-запись unpark не будет синхронизирована, и park-поток может не увидеть данные, записанные до unpark, что приведёт к проблеме видимости памяти. Это типичный двойной баг «потерянное пробуждение + порядок памяти».

Q3: run_task, когдаself.core.borrow_mut().take()возвращаетNone, почему возвращаетсяControlFlow::Break(()), а неContinue?

Справочный разбор:self.core.borrow_mut().take()возвратNoneозначает, что core уже украден📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:716-724. Единственный способ украсть core — вызов внутри задачиblock_in_place, который черезmaybe_move_runtimeизвлекает core изcx.coreи передаёт новому потоку📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:473-497. В этот момент текущий поток уже не обладает способностью к планированию; если вернутьContinue,Context::run, он продолжит цикл и вызоветcore.next_task()и другие методы, требующие core, но core уже нет вself.core, что приведёт к panic или несогласованному состоянию. ВозвратBreakзаставляетContext::runнапрямуюreturn 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:594-597, передавая управление обратно вrunфункцию, которая обработает дальнейшее (например,cx.defer.wake() 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:564). Комментарий также поясняет📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:719-721: в этот момент нельзя вызыватьreset_lifo_enabled, потому что core украден, и похититель обработает это вContext::runв начале.

CHAPTER 05

Глава 5: Уведомление о готовности I/O: как Reactor переводит события epoll в пробуждение Waker

Проект: tokio-rs/tokio · Прогресс книги: Глава 5 / 14 · Статус проверки: FACT — реальная привязка номеров строк

В предыдущей главе мы проследили главный цикл worker-потока: задача poll-ится, при возврате Pending Waker сохраняется куда-то, после готовности события Waker срабатывает, и задача снова ставится в очередь. Но что это за «куда-то»? Как Waker находится при поступлении события epoll? Именно на это должен ответить Reactor. Сначала построим интуитивную модель: представьте весь механизм уведомления о готовности I/O как систему вызова по номеру в ресторане — клиент (задача), сделав заказ, не стоит и не ждёт у окна, а берёт пейджер (Waker) и возвращается на место; кухня (ядро epoll), приготовив блюдо, сообщает стойке (Reactor), которая по номеру заказа (Token) находит соответствующий пейджер и нажимает кнопку. Без этой системы каждой задаче пришлось бы опрашивать socket, и CPU сгорел бы; либо использовались бы блокирующие потоки — один поток на соединение, и масштабирование было бы невозможно. Reactor в Tokio состоит из трёх файлов, образующих трёхуровневую структуру со строгим разделением обязанностей: driver.rs — это сам цикл событий, владеющий mio::Poll, отвечающий за вызов poll() для блокирующего ожидания событий ядра и перевод событий в чтение/запись ScheduledIo; registration.rs — это пользовательский регистрационный дескриптор, который хранится внутри TcpStream и предоставляет API poll_read_ready / poll_write_ready и т. п.; scheduled_io.rs — это слот состояния каждого fd, хранящий биты готовности чтения/записи и список Waker, являясь мостом между событиями и задачами. Связи сборки модулей можно посмотреть в tokio/src/runtime/io/mod.rs:5-16: driver экспортирует Driver, Handle, ReadyEvent, registration экспортирует Registration, scheduled_io экспортирует ScheduledIo. Следующая схема фиксирует полный поток данных, который нужно проследить в этой главе: TcpStream → Registration → ScheduledIo → Handle/Driver → ядро → обратно в ScheduledIo → Waker. Далее мы разберём это слой за слоем.

Уровень драйвера:DriverиHandleразделение обязанностей

Интуитивная модель

Driver— этоединственная сущность, владеющаяmio::Poll, к ней можно обращаться только из одного потока— это требование эксклюзивности цикла событий. А&mut— этоHandleклонируемая точка входа для регистрации, разделяемая между потокамиклонируемая, разделяемая между потоками точка регистрации, любой поток, желающий зарегистрировать новый fd, делает это через него. Без этого разделения пришлось бы либоmio::Pollблокировать, либо возвращать все регистрации в поток driver (что вводит межпоточную очередь сообщений). Tokio выбирает, чтобыHandleнапрямую владел клономmio::Registry, регистрация может выполняться параллельно, и только фактическое ожидание событий требует эксклюзивного доступа.

Раскладка памяти и поля

Сначала рассмотримDriverполя📎 tokio/src/runtime/io/driver.rs:25-38:

  • signal_ready: bool: пришло ли событие Unix-сигнала, используется для signal-драйвера.
  • events: mio::Events: основной буфер событий, переиспользуется между вызовамиturn, чтобы избежать выделения памяти каждый раз.
  • events_busy: Option<mio::Events>:Специальный буфер для неблокирующего poll, существует только когдаmax_io_events_per_busy_tickустановлен.
  • poll: mio::Poll: обёртка над очередью событий ядра.

Теперь рассмотримHandle 📎 tokio/src/runtime/io/driver.rs:41-75:

  • registry: mio::Registry:mio::Poll::registry()клон, используемый дляregister/deregister。
  • registrations: RegistrationSet: множество всех активных регистраций, отвечает за выделениеTokenиScheduledIo。
  • synced: Mutex<registration_set::Synced>: защищает синхронизированное состояниеRegistrationSet.
  • waker: mio::Waker: используется для пробуждения driver, заблокированного вturn, из любого потока.
  • metrics: IoDriverMetrics: подсчитывает количество fd, количество готовых событий.

Здесь есть ключевое проектное решение:events_busyсуществование📎 tokio/src/runtime/io/driver.rs:25-38предназначено для решенияпроблемы, когда неблокирующий poll поглощает события. Комментарий📎 tokio/src/runtime/io/driver.rs:189-190ясно говорит: если события, извлечённые неблокирующим poll, остаются в основном буфере, следующий poll их не увидит; используя отдельный буфер, необработанные события остаются в очереди ядра, и следующий poll вернёт их снова.

Пошагово: одно выполнениеturn

turn— это основная функция driver📎 tokio/src/runtime/io/driver.rs:184-261. Предположим, worker-поток обнаружил, что нет задач для выполнения, и вызываетpark → turn(handle, None)для блокирующего ожидания:

Шаг первый: утверждается, что не shutdown📎 tokio/src/runtime/io/driver.rs:185, и освобождаются регистрации, ожидающие очистки📎 tokio/src/runtime/io/driver.rs:187。release_pending_registrationsпроверяетneeds_release(), и если есть, вызываетregistrations.release() 📎 tokio/src/runtime/io/driver.rs:336-340。

Шаг второй: выбирается буфер событий📎 tokio/src/runtime/io/driver.rs:191-194. Еслиmax_waitравен нулю иevents_busyсуществует, используется busy-буфер; иначе используется основной буфер.

Шаг третий: вызываетсяself.poll.poll(events, max_wait) 📎 tokio/src/runtime/io/driver.rs:198. Это место, где происходит реальная блокировка на epoll_wait. Обработка ошибок очень сдержанная:Interruptedпросто игнорируется (прерывание сигналом — это нормально)📎 tokio/src/runtime/io/driver.rs:200, под WASIInvalidInputтакже игнорируется📎 tokio/src/runtime/io/driver.rs:201-205, другие ошибки приводят к panic📎 tokio/src/runtime/io/driver.rs:206。

Шаг четвёртый: перебираются события📎 tokio/src/runtime/io/driver.rs:211-233. Для каждогоevent:

  • еслиtoken == TOKEN_WAKEUP(значение 0)📎 tokio/src/runtime/io/driver.rs:214, ничего не делается — этоunparkиспользуется для прерывания блокировки.
  • Еслиtoken == TOKEN_SIGNAL(значение 1)📎 tokio/src/runtime/io/driver.rs:216, устанавливаетсяsignal_ready = true。
  • иначе это обычное событие I/O📎 tokio/src/runtime/io/driver.rs:218-231: преобразуетсяmio::Readyв Tokio-представлениеReady, с помощьюEXPOSE_IO.from_exposed_addr(token.0)token восстанавливается в указатель*const ScheduledIo, затемset_readiness(Tick::Set, |curr| curr | ready)накапливаются биты готовности, и далееio.wake(ready)запускается соответствующее направлениеWaker。

ЗдесьEXPOSE_IO— этоPtrExposeDomain<ScheduledIo> 📎 tokio/src/runtime/io/mod.rs:21-22, который «экспонирует» указатель какusizeв качествеmio::Token. Комментарий о безопасности📎 tokio/src/runtime/io/driver.rs:222-225объясняет, почему это unsafe-преобразование безопасно: указатель не освобождается до тех пор, пока не будет отменена регистрация в mioиdriver больше не выполняет параллельный poll, и driver владеетArc<ScheduledIo>.

Шаг пятый: обработка очереди завершений io_uring (только Linux + tokio_unstable)📎 tokio/src/runtime/io/driver.rs:235-258, включая цикл flush при переполнении CQ.

Шаг шестой: накопление метрик📎 tokio/src/runtime/io/driver.rs:265-267。

mermaid
flowchart TD
    start["turn(handle, max_wait)"] --> assert["debug_assert!(!is_shutdown)"]
    assert --> release["release_pending_registrations()"]
    release --> pick{"max_wait == 0<br/>и events_busy существует?"}
    pick -->|да| busy["events = events_busy"]
    pick -->|нет| main["events = events"]
    busy --> poll["poll.poll(events, max_wait)"]
    main --> poll
    poll --> pollres{"poll вернул?"}
    pollres -->|"Ok / Interrupted"| iter["обход events.iter()"]
    pollres -->|"другие Err"| panic["panic!(unexpected error)"]
    iter --> tok{"event.token()?"}
    tok -->|"TOKEN_WAKEUP"| skip["игнорировать, используется только для прерывания блокировки"]
    tok -->|"TOKEN_SIGNAL"| sig["signal_ready = true"]
    tok -->|"обычный fd token"| cast["EXPOSE_IO.from_exposed_addr(token.0)"]
    cast --> setr["io.set_readiness(Tick::Set, curr | ready)"]
    setr --> wake["io.wake(ready)"]
    wake --> iter
    skip --> iter
    sig --> iter
    iter --> uring["dispatch_completions() (io-uring)"]
    uring --> metrics["metrics.incr_ready_count_by(ready_count)"]

Проектное размышление: почемуHandleдолжен владетьmio::Waker

unpark 📎 tokio/src/runtime/io/driver.rs:280-283вызываетself.waker.wake(). Этотmio::WakerприDriver::newрегистрируется черезTOKEN_WAKEUPс помощью📎 tokio/src/runtime/io/driver.rs:124. Когда driver заблокирован вpoll.poll(), другой поток, вызываяunpark, помещает в epoll событиеTOKEN_WAKEUP,pollнемедленно возвращается, при переборе видит этот token и просто пропускает его📎 tokio/src/runtime/io/driver.rs:214-215。

〔Проектные выводы и архитектурные компромиссы〕

Этот механизм используется вderegister_sourceпри📎 tokio/src/runtime/io/driver.rs:315-334: после отмены регистрации source, еслиregistrations.deregisterвозвращает true (означая, что это последняя ссылка), выполняетсяunpark(). Почему? Потому что driver может быть заблокирован вpollв ожидании события для этого fd, а fd уже отменён, и ядро больше не сгенерирует событие; необходимо активно разбудить driver, чтобы он перепроверил набор регистраций и, возможно, вышел из блокировки. Иначе driver будет спать до тайм-аутаmax_wait, задерживая shutdown.

Ещё одна деталь:deregister_sourceсначала вызываетсяself.registry.deregister(source) 📎 tokio/src/runtime/io/driver.rs:322, затем очищаетсяregistrations 📎 tokio/src/runtime/io/driver.rs:315-334. Комментарий📎 tokio/src/runtime/io/driver.rs:320-321говорит «Cleanup ALWAYS happens» — даже если отмена регистрации на уровне ОС не удалась, внутреннее состояние всё равно очищается, и только потом возвращается ошибка ОС📎 tokio/src/runtime/io/driver.rs:336-340. Это типичныйпаттерн, где очистка ресурсов имеет приоритет над распространением ошибки.

Слой регистрации:RegistrationкакWakerсохраняется вScheduledIo

Интуитивная модель

Registration— этоконтракт между задачей и fd. Он содержит две вещи: одинscheduler::Handle(используется для доступа к runtime при необходимости), одинArc<ScheduledIo>(слот состояния fd). Когда задача вызываетpoll_read_ready,RegistrationпередаётWakerна хранение вScheduledIo; когда driver получает событие, он извлекаетScheduledIoизWakerи пробуждает.

Раскладка памяти и поля

Registrationсодержит только два поля📎 tokio/src/runtime/io/registration.rs:46-54:

  • handle: scheduler::Handle: дескриптор runtime, комментарий📎 tokio/src/runtime/io/registration.rs:46-54говорит «TODO: this can probably be moved into ScheduledIo», что указывает на то, что автор считает расположение этого поля возможным для оптимизации.
  • shared: Arc<ScheduledIo>: разделяемое состояние,Arcгарантирует, что и driver, и задача могут получить к нему доступ.
〔Проектные выводы и архитектурные компромиссы〕

Обратите внимание, чтоRegistrationвручную реализуетSendиSync 📎 tokio/src/runtime/io/registration.rs:57-58. Зачем нужен unsafe impl? Потому чтоscheduler::Handleвнутри может содержать поля, не являющиесяSend/Sync(например,Rc), но сценарий использованияRegistrationтребует, чтобы он мог пересекать потоки. Комментарий к документации📎 tokio/src/runtime/io/registration.rs:28-33задаёт ключевое ограничение:вызывающий должен гарантировать, что не более двух задач одновременно используют один и тот жеRegistration, одна на чтение, одна на запись. Нарушение этого ограничения хотя и безопасно с точки зрения памяти, но приводит к потере уведомлений и зависанию задач.

Step-by-Step:poll_read_readyЦепочка вызовов

Предположим, задача вTcpStream::poll_readобнаруживается, что в socket нет данных, необходимо зарегистрировать интерес чтения. Цепочка вызовов:TcpStream::poll_read_priv → PollEvented::poll_read → Registration::poll_read_io → poll_io → poll_ready。

poll_ready— это ядро📎 tokio/src/runtime/io/registration.rs:155-171:

Первый шаг:trace_leaf() 📎 tokio/src/runtime/io/registration.rs:160, используется для tracing-инструментирования.

Второй шаг:coop::poll_proceed(cx) 📎 tokio/src/runtime/io/registration.rs:155-171. Это механизм кооперативного бюджета, о котором пойдёт речь в главе 12. Если бюджет исчерпан, возвращаетсяPendingи регистрируется специальныйWaker, чтобы задача была перепланирована в следующем раунде.

Третий шаг:self.shared.poll_readiness(cx, direction) 📎 tokio/src/runtime/io/registration.rs:155-171. Это место, где происходит реальное взаимодействие сScheduledIo: проверяется текущий бит готовности, и если уже готово — немедленно возвращаетсяReady; иначеcx.waker()сохраняется вScheduledIoв соответствующий слот направления, возвращаетсяPending。

Четвёртый шаг: проверяетсяev.is_shutdown 📎 tokio/src/runtime/io/registration.rs:155-171. Если runtime завершается, возвращаетсяRUNTIME_SHUTTING_DOWN_ERROR。

Пятый шаг:coop.made_progress() 📎 tokio/src/runtime/io/registration.rs:169, отмечается расход бюджета, возвращается событие готовности.

poll_ioповерхpoll_readyдобавляет слой цикла повторных попыток📎 tokio/src/runtime/io/registration.rs:173-192:

rust
loop {
    let ev = ready!(self.poll_ready(cx, direction))?;
    match f() {
        Ok(ret) => return Poll::Ready(Ok(ret)),
        Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {
            self.clear_readiness(ev);
        }
        Err(e) => return Poll::Ready(Err(e)),
    }
}

Здесь отраженаreadiness — это подсказка, а не гарантияключевая идея:poll_readyговорит, что доступно для чтения, но при реальномread()может вернутьсяWouldBlock(например, другой поток успел забрать данные). В этом случае необходимоclear_readiness(ev) 📎 tokio/src/runtime/io/registration.rs:187сбросить бит готовности и затем в цикле снова ждать. Если не сбросить, задача попадёт в busy-loop «думает, что можно читать → read fails → снова думает, что можно читать».

Размышления о дизайне:try_ioиasync_ioразделение обязанностей

try_io 📎 tokio/src/runtime/io/registration.rs:194-213— синхронная версия: сначалаready_event(interest)проверяется бит готовности, если пусто — сразу возвращаетсяWouldBlock 📎 tokio/src/runtime/io/registration.rs:194-213; иначе выполняетсяf(), и еслиf()возвращаетWouldBlock, то сбрасывается бит готовности📎 tokio/src/runtime/io/registration.rs:207-210. Онне регистрирует Waker, подходит дляtry_readтаких сценариев «попробовал и ушёл».

async_io 📎 tokio/src/runtime/io/registration.rs:225-245— асинхронная версия:readiness(interest).awaitрегистрирует Waker и ждёт, затем при выполненииf(),WouldBlockсбрасывает бит готовности и зацикливается. Обратите внимание, что в цикле также вызываетсяcoop::poll_proceed 📎 tokio/src/runtime/io/registration.rs:233, чтобы не исчерпать бюджет при множественныхWouldBlockповторных попытках.

Производственные подводные камни:DropОчистка Waker в

Registration::drop 📎 tokio/src/runtime/io/registration.rs:253-262вызываетself.shared.clear_wakers(). Комментарий📎 tokio/src/runtime/io/registration.rs:253-262объясняет причину:ScheduledIoхранящийся вWakerможет держатьArc<driver::Inner>, аdriver::Innerв свою очередь держитScheduledIo, образуя циклическую ссылку. Очистка Waker — это способ разорвать цикл. Но комментарий также признаёт, что это «imperfect solution» — еслиRegistrationсам сохранён вWaker, цикл всё равно остаётся. Это проблема, обсуждаемая в tokio-rs/tokio#3481.

〔Проектные выводы и архитектурные компромиссы〕

В production поведение таково: если множество соединений было drop, но runtime не завершился, память не освобождается немедленно до следующегоclear_wakersили завершение работы runtime. Для сервисов с длительными соединениями это обычно не проблема; но для сценариев с короткими соединениями и частым созданием/уничтожением нужно следить за моментом освобожденияScheduledIo.

ОтTcpStream::readдоWakerполная цепочка пробуждения

Интуитивная модель

Теперь свяжем три слоя вместе. Пользователь наTcpStreamвызывает.read().await, фактически выполняетсяAsyncRead::poll_read → PollEvented::poll_read → Registration::poll_read_io. Когда данных нет,Wakerсохраняется вScheduledIo; когда epoll сообщает о готовности к чтению, driver извлекает изScheduledIoWakerи пробуждает, задача перепланируется, и при повторном pollpoll_readinessобнаруживает, что бит готовности установлен, и сразу возвращаетReady,read()успешно.

Step-by-Step: одно полное ожидание чтения

Этап первый: регистрация интереса。TcpStream::new 📎 tokio/src/net/tcp/stream.rs:166-169вызываетPollEvented::new(connected), который внутри вызываетRegistration::new_with_interest_and_handle 📎 tokio/src/runtime/io/registration.rs:73-81, а затемhandle.driver().io().add_source(io, interest) 📎 tokio/src/runtime/io/registration.rs:73-81。

add_source 📎 tokio/src/runtime/io/driver.rs:288-312делает три вещи:

1. registrations.allocate(&mut synced.lock())выделяетScheduledIo, получаетtoken 📎 tokio/src/runtime/io/driver.rs:293-294。

2. self.registry.register(source, token, interest.to_mio())регистрирует📎 tokio/src/runtime/io/driver.rs:298в ядре. Если неудача,необходимоудалить только что выделенныйScheduledIoиз множества📎 tokio/src/runtime/io/driver.rs:300-303, иначе утечка.

3. metrics.incr_fd_count()подсчитывает📎 tokio/src/runtime/io/driver.rs:309。

Этап второй: ожидание готовности. Задача pollTcpStream::poll_read 📎 tokio/src/net/tcp/stream.rs:1492-1498 → poll_read_priv 📎 tokio/src/net/tcp/stream.rs:1451-1458 → PollEvented::poll_read → Registration::poll_read_io 📎 tokio/src/runtime/io/registration.rs:133-139 → poll_io → poll_ready → ScheduledIo::poll_readiness. Если в этот момент не готово,Wakerсохраняется вScheduledIoслот чтения, возвращаетсяPending。

Этап третий: приход события. driverturnизpoll.poll()получает событие📎 tokio/src/runtime/io/driver.rs:198, при обходе для каждого события fd выполняетio.set_readiness(Tick::Set, |curr| curr | ready)иio.wake(ready) 📎 tokio/src/runtime/io/driver.rs:228-229。wakeвнутри извлекаетWakerсоответствующего направления и вызываетwake()。

Этап четвёртый: перепланирование задачи。Waker::wake()повторно ставит задачу в локальную очередь worker (рассказывалось в предыдущей главе). worker снова poll-ит эту задачу,poll_readinessобнаруживает, что бит готовности установлен, возвращаетReady,read()успешно.

mermaid
sequenceDiagram
    participant Task as "Задача (worker-поток)"
    participant Reg as "Registration"
    participant SIO as "ScheduledIo"
    participant Drv as "Driver (поток ввода-вывода)"
    participant OS as "epoll/kqueue"

    Task->>Reg: "poll_read_ready(cx)"
    Reg->>SIO: "poll_readiness(cx, Read)"
    SIO-->>Reg: "Pending (Waker сохранён в слоте чтения)"
    Reg-->>Task: "Poll::Pending"
    Note over Task: задача уступает, worker идёт выполнять другие задачи
    Drv->>OS: "poll.poll(events, max_wait)"
    OS-->>Drv: "event(token=fd_ptr, READABLE)"
    Drv->>SIO: "set_readiness(Tick::Set, curr | READABLE)"
    Drv->>SIO: "wake(READABLE)"
    SIO->>Task: "Waker::wake() повторная постановка в очередь"
    Note over Task: worker снова опрашивает эту задачу
    Task->>Reg: "poll_read_ready(cx)"
    Reg->>SIO: "poll_readiness(cx, Read)"
    SIO-->>Reg: "Ready(ReadyEvent{ready: READABLE})"
    Reg-->>Task: "Poll::Ready(Ok(ev))"
    Task->>Task: "read() успешно возвращает данные"

Важное ответвление:assume_readyоптимизация

TcpStream::new_accepted 📎 tokio/src/net/tcp/stream.rs:174-181— заметная оптимизация.acceptвозвращаемый socket естественно доступен для записи и обычно уже содержит первую партию байтов от peer. Если ждать первого события driver, при высокой нагрузке это событие может оказаться позади событий всех установленных соединений, вызывая задержку. Поэтомуnew_acceptedнапрямую вызываетassume_ready(Ready::READABLE | Ready::WRITABLE) 📎 tokio/src/net/tcp/stream.rs:174-181。

assume_ready, комментарий📎 tokio/src/runtime/io/registration.rs:103-105гласит: «A wrong guess costs oneWouldBlock, which clears the readiness again.» — цена ошибочной догадки — всего одинWouldBlock,poll_ioцикл очистит бит готовности и снова будет ждать. Это дизайноптимистичной догадки + быстрого исправления ошибок.

Размышления о дизайне: почему I/O-драйвер отделён от планировщика

〔Проектные выводы и архитектурные компромиссы〕

Судя по структуре исходников,Driverи worker-потоки разделены:Driverразмещается в некотором выделенном месте runtime (обычноblock_onпоток или специальный I/O-поток), а worker-потоки держат толькоHandle. Такое разделение даёт несколько преимуществ:

1. Регистрация без блокировок:Handleдержитmio::Registryклон, любой worker может параллельно регистрировать новые fd, не возвращаясь в поток driver.

2. Централизация ожидания событий: только один поток блокируется наepoll_wait, что позволяет избежать проблемы thundering herd при одновременном poll одного и того же epoll fd из нескольких потоков.

3. Короткий путь пробуждения: driver, получив событие, напрямую оперируетScheduledIoи вызываетWaker::wake(),wake()внутри ставит задачу в очередь worker, без межпоточной передачи сообщений.

Цена — необходимостьScheduledIoобрабатывать конкурентный доступ (set_readinessиpoll_readinessмогут происходить одновременно), что решается атомарными операциями и внутренними блокировками.

Производственные подводные камни:is_shutdownиRUNTIME_SHUTTING_DOWN_ERROR

poll_readyпроверяетev.is_shutdown 📎 tokio/src/runtime/io/registration.rs:155-171, если истина — возвращаетgone() 📎 tokio/src/runtime/io/registration.rs:265-267, то естьRUNTIME_SHUTTING_DOWN_ERROR。

〔Проектные выводы и архитектурные компромиссы〕

Смысл этой проверки в том, что: при завершении runtime drivershutdown 📎 tokio/src/runtime/io/driver.rs:174-182обойдёт все регистрации и вызоветio.shutdown(), установитis_shutdownи разбудит всех ожидающих. Если не проверять этот флаг, задача может попытаться прочитать сокет после того, как runtime уже прекратил планирование, что приведёт к неопределённому поведению или зависанию. В производственной среде, если вы видитеRUNTIME_SHUTTING_DOWN_ERROR, это обычно означает, что какая-то задача всё ещё выполняется после drop runtime — проверьте, нет лиspawnзадач, которые не были корректно присоединены через join.

Ещё одна ловушка —deregister_sourceизunpark 📎 tokio/src/runtime/io/driver.rs:328. Если driver в данный момент заблокирован вpoll, и в этот момент последнийRegistrationбыл drop,unparkразбудит driver. Но если driver не находится в состоянии блокировки (например, обрабатывает другие события),unparkлишь заставит следующийturnнемедленно вернуть📎 tokio/src/runtime/io/driver.rs:280-283. Эта семантика описана в комментариях к документацииHandle::unpark.

Проектное решение: три ключевых компромисса Reactor

Компромисс первый:Tokenиспользовать указатели вместо индексов。EXPOSE_IO.from_exposed_addr(token.0) 📎 tokio/src/runtime/io/driver.rs:220Рассматриватьmio::Tokenнапрямую как адрес*const ScheduledIo. Это позволяет избежать поддержкиToken → ScheduledIoтаблица сопоставления, поиск за O(1) и без блокировок. Цена — безопасность зависит от строгого управления жизненным циклом: указатель может быть освобождён только после дерегистрации и прекращения опроса драйвером📎 tokio/src/runtime/io/driver.rs:222-225。

Компромисс второй: два слота Waker для чтения и записи。RegistrationДокументация📎 tokio/src/runtime/io/registration.rs:24-26гласит: «A registration instance represents two separate readiness streams» — для чтения и записи имеется независимыйWakerслот. Это позволяет задачам чтения и записи одного и того же сокета регистрироваться отдельно, не мешая друг другу. Ноpoll_read_readyкомментарий📎 tokio/src/net/tcp/stream.rs:549-552предупреждает: при многократном вызовеpoll_read_ready/poll_read/poll_peekсохраняется только последнийWaker— для направления чтения существует только один слот.

Компромисс третий:events_busyнезависимый буфер. Тест📎 tokio/src/runtime/io/driver.rs:364-386подтвердил это поведение:Driver::new(16, Some(2))создаётся драйвер с ёмкостью busy равной 2, после регистрации 5 источников, доступных для чтения, неблокирующийturnизвлекает только 2 события📎 tokio/src/runtime/io/driver.rs:375-376, остальные 3 остаются в очереди ядра и будут заблокированы в следующий разturnполучено📎 tokio/src/runtime/io/driver.rs:379-380. Это предотвращает ситуацию, когда неблокирующий poll за один раз поглощает все события, что приводит к голоданию последующих poll.

Резюме главы

В этой главе прослеженTcpStream::readполный путь Reactor, стоящий за

  • Уровень драйвера:Driverэксклюзивноmio::Poll,turnблокирующее ожидание события, с помощьюEXPOSE_IOвосстановитьTokenвScheduledIoуказатель, вызватьset_readiness + wakeзапуститьWaker。Handleпредоставляет точку регистрации, доступную для межпоточного использования,unparkиспользуется для прерывания блокировки.
  • Слой регистрации:RegistrationСодержитArc<ScheduledIo>,poll_readyПроверяет бит готовности или сохраняетWaker,poll_ioИспользуетWouldBlockЦикл повторных попыток для обработки ложных срабатываний,try_io/async_ioОбслуживает синхронные и асинхронные сценарии по отдельности.
  • Слой состояния:ScheduledIoЯвляется слотом состояния fd, хранит биты готовности чтения/записи и двойнойWakerСлот, является единственным мостом между событиями и задачами.

Вопросы для размышления и самопроверки в этой главе

Q1: Если удалитьpoll_ioвWouldBlockветвиself.clear_readiness(ev)то в каком сценарии это приведёт к busy-loop задачи? Почему?

Справочный анализ:poll_ioцикл📎 tokio/src/runtime/io/registration.rs:173-192вf()возвратWouldBlockвызывается приclear_readiness(ev) 📎 tokio/src/runtime/io/registration.rs:187。evявляетсяpoll_readyвозвращаемымReadyEvent, содержит текущие биты готовности.clear_readinessудалит эти биты изScheduledIo.

Если не очистить, при следующем вызове циклаpoll_ready → poll_readiness,ScheduledIoпо-прежнему сохраняются старые биты «доступно для чтения»,poll_readinessнемедленно вернётReady(поскольку биты готовности не пусты), затемf()снова выполнитread(), если в socket действительно нет данных, и снова возвращаетсяWouldBlock, цикл продолжается. Поскольку бит готовности никогда не сбрасывается, этот цикл никогда не войдёт вPending, задача будет постоянно занимать CPU опросом.

Сценарий срабатывания: несколько задач совместно используют одно направление чтения одного socket (хотяRegistrationдокументация📎 tokio/src/runtime/io/registration.rs:28-33говорит, что максимум две задачи, но для направления чтения есть только один слот), илиtry_readиpoll_readиспользуются совместно. Более распространённый случай: после того как epoll сообщил о готовности к чтению, другой поток успел первым вычитать данные, иread()текущей задачи возвращаетWouldBlock, в этот момент необходимо сбросить бит готовности, иначе будут бесконечные повторные попытки.

Q2: add_sourceПриregistry.registerпочему при сбое вызываетсяregistrations.remove? Что произойдёт, если не вызвать?

Справочный анализ:add_source 📎 tokio/src/runtime/io/driver.rs:288-312Сначалаregistrations.allocateВыделитьScheduledIo 📎 tokio/src/runtime/io/driver.rs:293, затемregistry.registerзарегистрировать в ядре📎 tokio/src/runtime/io/driver.rs:298. Если регистрация не удалась,ScheduledIoуже выделен, но не связан ни с одним fd; если не удалить, он навсегда останется вRegistrationSet.

Комментарий📎 tokio/src/runtime/io/driver.rs:296-297явно говорит: «we should remove thescheduled_io from the registrations set if registering the source with the OS fails. Otherwise it will leak the scheduled_io.» — это утечка памяти.

removeВызов📎 tokio/src/runtime/io/driver.rs:300-303обёрнут в блок unsafe, потому чтоScheduledIoявляетсяRegistrationSetчастью, и операция удаления должна гарантировать отсутствие других ссылок. Последствия утечки:RegistrationSetпостоянно растёт,Tokenпространство расходуется впустую, что в конечном итоге может привести кallocateСбой или исчерпание памяти. В сценариях с частым созданием/уничтожением соединений (например, серверы с короткими соединениями), если вероятность сбоя регистрации высока (например, исчерпание fd), утечка ускоряет исчерпание ресурсов.

Q3: deregister_source, почемуunpark()только приregistrations.deregisterвозвращает true? Какие проблемы возникнут, если вызывать безусловно?

Справочный анализ:deregister_source 📎 tokio/src/runtime/io/driver.rs:315-334Логика такова: сначалаregistry.deregister(source)отменяет регистрацию в ядре📎 tokio/src/runtime/io/driver.rs:322, затемregistrations.deregisterочищает внутреннее состояние📎 tokio/src/runtime/io/driver.rs:315-334, если возвращает true, тоunpark() 📎 tokio/src/runtime/io/driver.rs:328。

registrations.deregisterвозврат true означает, что это последняя ссылка,ScheduledIoдействительно удалён. В этот момент driver может быть заблокирован вpollв ожидании события для этого fd, но fd уже отменён, и ядро больше не будет генерировать события.unparkчерезmio::Wakerпомещает в epollTOKEN_WAKEUPСобытие📎 tokio/src/runtime/io/driver.rs:280-283, позволяяpollнемедленно вернуться, driver повторно проверяет набор регистраций и может выйти из блокировки.

Если безусловно вызыватьunpark: каждая отмена регистрации не последней ссылки пробуждает driver, вызывая ненужные пробуждения. В сценариях, где множество соединений совместно используют один и тот жеScheduledIo(например,TcpStreamизsplitпосле разделения на половины чтения и записи), каждое удаление одной половины пробуждает driver, увеличивая нагрузку на CPU. Что ещё серьёзнее

本章我们拆解了 Reactor 如何把 epoll 事件翻译成 Waker 唤醒:从 TcpStream 的 poll_read_ready 出发,经过 Registration 的注册与查询,落到 ScheduledIo 的就绪位与 Waker 槽位,再由 Driver 在事件循环中根据 Token 定位并触发唤醒。关键设计包括:Token 即指针实现 O(1) 查找,读写双 Waker 槽位支持并发读写分离,events_busy 独立缓冲区防止事件饥饿,assume_ready 乐观猜测优化 accept 场景。至此,I/O 就绪通知的闭环已经完整。但异步运行时还需要处理另一类「就绪」——时间。下一章我们将剖析 tokio::time::sleep 与 timeout 的实现:定时器如何被插入时间轮、时间轮如何按到期时间分级、driver 如何计算下一次 park 的超时并触发到期任务。你会看到「时间也是一种 I/O 事件」这一统一抽象,以及 start_paused 与 test clock 如何让时间在测试中可控。

CHAPTER 06

Глава 6: Драйвер времени: как колесо времени, Sleep и тайм-ауты пробуждают задачи

Проект: tokio-rs/tokio · Прогресс книги: Глава 6 / 14 · Статус проверки: номера строк FACT реально привязаны

В предыдущей главе мы проследили полный путь TcpStream::read и увидели, как ScheduledIo преобразует события готовности fd из epoll в пробуждение через Waker. Однако асинхронной среде выполнения необходимо обрабатывать ещё один тип «готовности»: Future от sleep(100ms) должен быть разбужен через 100 мс. Такие события исходят не от файловых дескрипторов ядра, а от «самого времени». Архитектурное решение Tokio заключается в том, чтобы рассматривать время как разновидность событий ввода-вывода: в структуре Driver есть только одно поле park: IoStack, которое переиспользует механизм park/unpark драйвера ввода-вывода. Когда колесо времени вычисляет «момент следующего срабатывания», драйвер вызывает park_timeout, чтобы усыпить поток до этого момента; после пробуждения он извлекает из колеса времени сработавшие записи и активирует их Waker. Таким образом, планировщику достаточно единой точки входа park, чтобы одновременно ожидать события двух типов: «готовность fd» и «истечение таймера». В этой главе мы ответим на три вопроса: как таймеры вставляются в колесо времени? Как колесо времени распределяет таймеры по уровням в зависимости от времени срабатывания? Как драйвер вычисляет тайм-аут следующего park и активирует сработавшие задачи?

I. Колесо времени: шестиуровневая хеш-структура с 64 слотами на каждом уровне

Интуитивная модель

Представьте механические часы: секундная стрелка, совершая оборот, движет минутную, а минутная, совершая оборот, движет часовую. Если бы была только одна секундная стрелка, для отображения «через 12 дней» пришлось бы отсчитать миллион делений; но с многоуровневой структурой секундная стрелка отвечает лишь за точность в пределах 64 секунд, минутная — за 64 минуты, часовая — за 64 часа — каждому уровню достаточно всего 64 слотов, чтобы охватить период более 2 лет.

Без многоуровневой структуры вставка отдалённого таймера потребовала бы либо обхода за O(N), либо огромного массива. Колесо времени использует «распределение по уровням в зависимости от времени срабатывания», сводя и вставку, и срабатывание к почти O(1).

Структура памяти и поля

WheelОсновных полей всего три📎 tokio/src/runtime/time/wheel/mod.rs:22-40:

rust
pub(crate) struct Wheel {
    elapsed: u64,                          // 自 wheel 创建以来经过的毫秒数
    levels: Box<[Level; NUM_LEVELS]>,      // 6 层,每层 64 槽
    pending: LinkedList<TimerShared>,      // 已到期、待触发的条目
}

NUM_LEVELS = 6,BITS_PER_LEVEL = 6(то есть по 64 слота на каждом уровне)📎 tokio/src/runtime/time/wheel/mod.rs:45-47。MAX_DURATION = 1 << (6 * 6) = 1 << 36миллисекунд, примерно 2 года📎 tokio/src/runtime/time/wheel/mod.rs:50。

Гранулярность шести уровней согласно комментариям в документации:📎 tokio/src/runtime/time/wheel/mod.rs:22-40:

УровеньГранулярность слотаДиапазон покрытия
01 ms64 ms
164 ms~4 s
2~4 s~4 min
3~4 min~4 hr
4~4 hr~12 day
5~12 day~2 yr

pendingпредставляет собой интрузивный связный список (LinkedList<TimerShared>), хранящий записи, уже извлечённые из очереди и ожидающие пробуждения Waker. Обратите внимание, что этоLinkedList, а неVec: сами записи встроены вTimerShared, вставка/удаление не требует выделения памяти.

Сценарий: вставка sleep на 100 мс

Когдаsleep(100ms)впервые подвергается poll,Sleep::poll_elapsedконструируетTimer::newи вызываетinit 📎 tokio/src/time/sleep.rs:436-440。initв конечном итоге вызываетHandle::reregister, что в свою очередь вызываетWheel::insert。

insert, первым шагом является проверка, истёк ли срок📎 tokio/src/runtime/time/wheel/mod.rs:90-98:

rust
let when = unsafe { item.sync_when() };
if when <= self.elapsed {
    return Err((item, InsertError::Elapsed));
}

Еслиwhenуже попал вelapsedРанее (например, deadline уже прошёл), возвращаемся напрямуюElapsed, вызывающая сторона немедленно запустит этот таймер.

В противном случае вычисляем, в какой уровень следует поместить данную запись📎 tokio/src/runtime/time/wheel/mod.rs:90-114:

rust
let level = self.level_for(when);
unsafe { self.levels[level].add_entry(item); }

level_forявляется ядром каскадного алгоритма📎 tokio/src/runtime/time/wheel/mod.rs:276-289:

rust
fn level_for(elapsed: u64, when: u64) -> usize {
    const SLOT_MASK: u64 = (1 << BITS_PER_LEVEL) - 1;
    let masked = elapsed ^ when | SLOT_MASK;
    if masked >= MAX_DURATION {
        return NUM_LEVELS - 1;
    }
    masked.ilog2() as usize / BITS_PER_LEVEL
}

Здесь используетсяelapsed ^ whenвместоwhen - elapsed, это изящный приём: старший значащий бит XOR отражает «с какого бита два временных штампа начинают различаться», то есть «насколько грубая гранулярность нужна, чтобы их различить».| SLOT_MASKПринудительно устанавливаем младшие 6 бит в 1, чтобы избежатьilog2вычисления слишком малого уровня при попадании в один и тот же слот.ilog2() / 6Отображаем разрядность в номер уровня. Если результат XOR превышаетMAX_DURATION(то есть превышает 2 года), принудительно помещаем в самый верхний уровень — это и есть «fudge the timer into the top level».

Для sleep длительностью 100ms, предположим,elapsedблизко к 0,when ≈ 100,elapsed ^ when ≈ 100,ilog2(100) = 6,6 / 6 = 1, поэтому он попадает на уровень 1 (гранулярность 64 мс). Это означает, что он будет ждать в одном из слотов уровня 1, пока время не дойдёт до границы этого слота, и только тогда будет опущен на уровень 0.

Каскадное опускание: process_expiration

Когдаpoll(now)продвигает время,Wheel::pollциклически вызываетnext_expirationиprocess_expiration 📎 tokio/src/runtime/time/wheel/mod.rs:142-166:

rust
pub(crate) fn poll(&mut self, now: u64) -> Option<TimerHandle> {
    loop {
        if let Some(handle) = self.pending.pop_back() {
            return Some(handle);
        }
        match self.next_expiration() {
            Some(ref expiration) if expiration.deadline <= now => {
                self.process_expiration(expiration);
                self.set_elapsed(expiration.deadline);
            }
            _ => {
                self.set_elapsed(now);
                break;
            }
        }
    }
    self.pending.pop_back()
}

process_expirationотвечает за «опускание» просроченных записей с одного уровня на следующий, или (на уровне 0) помечает их как pending📎 tokio/src/runtime/time/wheel/mod.rs:218-251:

rust
let mut entries = self.take_entries(expiration);
while let Some(item) = entries.pop_back() {
    match unsafe { item.mark_pending(expiration.deadline) } {
        Ok(()) => {
            self.pending.push_front(item);   // 真正到期
        }
        Err(expiration_tick) => {
            let level = level_for(expiration.deadline, expiration_tick);
            unsafe { self.levels[level].add_entry(item); }  // 下沉到更低层
        }
    }
}

mark_pending— ключевая: она проверяет, действительно ли наступил фактический deadline записи. Если наступил, возвращаетOk(()), запись попадает вpendingсвязный список; если ещё нет (просто наступила граница слота), возвращаетErr(expiration_tick), запись перевставляется в более мелкий уровень.

Обратите внимание на момент, подчёркнутый в комментарии📎 tokio/src/runtime/time/wheel/mod.rs:219-228: необходимо сначала извлечь все записи из слота целиком, а затем обрабатывать их, потому что некоторые записи могут быть перевставлены в тот же слот (когда время вставки превышаетMAX_DURATION, происходит переполнение). Если извлекать и вставлять одновременно, можно попасть в бесконечный цикл.

Вычисление следующего момента истечения

next_expirationсканирует от низкого уровня к высокому и возвращает первую непустую точку истечения📎 tokio/src/runtime/time/wheel/mod.rs:169-191:

rust
fn next_expiration(&self) -> Option<Expiration> {
    if !self.pending.is_empty() {
        return Some(Expiration { level: 0, slot: 0, deadline: self.elapsed });
    }
    for (level_num, level) in self.levels.iter().enumerate() {
        if let Some(expiration) = level.next_expiration(self.elapsed) {
            debug_assert!(self.no_expirations_before(level_num + 1, expiration.deadline));
            return Some(expiration);
        }
    }
    None
}

Еслиpendingнепуст, значит есть просроченные записи, ожидающие срабатывания, и немедленно возвращается текущийelapsedв качестве deadline (так driver выполнит park с нулевым таймаутом и сразу вернётся для обработки). Иначе сканирует уровень за уровнем и возвращает deadline первого непустого слота.debug_assertпроверяет инвариант: на более высоком уровне не может быть более ранней точки истечения, чем на текущем.

mermaid
flowchart TD
    start["Wheel::poll(now)"] --> check_pending{"pending не пуст?"}
    check_pending -->|да| pop["pop_back возвращает TimerHandle"]
    check_pending -->|нет| next_exp{"next_expiration() есть точка истечения?"}
    next_exp -->|нет| set_elapsed["set_elapsed(now) затем break"]
    next_exp -->|да| cmp{"expiration.deadline <= now?"}
    cmp -->|нет| set_elapsed
    cmp -->|да| proc["process_expiration(expiration)"]
    proc --> take["take_entries извлекает весь слот"]
    take --> mark{"item.mark_pending()"}
    mark -->|Ok истёк| push_pending["pending.push_front(item)"]
    mark -->|Err не истёк| reinsert["level_for затем add_entry с понижением"]
    push_pending --> set_elapsed2["set_elapsed(expiration.deadline)"]
    reinsert --> set_elapsed2
    set_elapsed2 --> check_pending
    set_elapsed --> pop2["pending.pop_back() возврат"]

---

II. Цикл park в Driver: подключение timing wheel к стеку I/O

Интуитивная модель

Само timing wheel не «идёт» самостоятельно. Ему нужен внешний цикл, который repeatedly спрашивает его: «Когда следующее истечение?» — затем спит до этого момента, просыпается и продвигает время. Этот цикл —Driver::park_internal. Он переводит «следующее истечение timing wheel» в длительностьpark_timeoutи передаёт её нижележащему стеку I/O для сна.

Без этого цикла таймеры никогда не сработают — timing wheel это лишь статическая структура данных, нужен кто-то, кто будет её «заводить».

Структуры данных: Driver и InnerState

Driverимеет только одно полеpark: IoStack 📎 tokio/src/runtime/time/mod.rs:90-93. Настоящее состояние находится вHandle, различается черезInnerперечисление для традиционной и экспериментальной реализации📎 tokio/src/runtime/time/mod.rs:95-127. Традиционная реализацияInnerStateсодержит два поля📎 tokio/src/runtime/time/mod.rs:130-136:

rust
struct InnerState {
    next_wake: Option<NonZeroU64>,   // 承诺的最早唤醒时刻
    wheel: wheel::Wheel,
}

next_wakeиспользуетNonZeroU64вместоOption<u64>вложенности, чтобы задействовать niche-оптимизацию —Option<NonZeroU64>иu64одного размера. Он записывает «до какого tick driver обещает проснуться», используется приreregisterдля определения, нужен лиunpark。

is_shutdown— независимыйAtomicBool, в комментарии объясняется, почему его вынесли из Mutex📎 tokio/src/runtime/time/mod.rs:90-93:Handleнужно проверятьis_shutdownбез блокировки mutex. Это типичная оптимизация «много чтений, мало записей» — shutdown происходит только один раз, но проверка может быть частой.

Сценарий: полный процесс одного park

park_internal— ядро📎 tokio/src/runtime/time/mod.rs:213-256:

rust
fn park_internal(&mut self, rt_handle: &driver::Handle, limit: Option<Duration>) {
    let handle = rt_handle.time();
    let mut lock = handle.inner.lock();
    assert!(!handle.is_shutdown());

    let next_wake = lock.wheel.next_expiration_time();
    lock.next_wake = next_wake.map(|t| NonZeroU64::new(t).unwrap_or_else(|| NonZeroU64::new(1).unwrap()));
    drop(lock);

    match next_wake {
        Some(when) => {
            let now = handle.time_source.now(rt_handle.clock());
            let mut duration = handle.time_source.tick_to_duration(when.saturating_sub(now));
            if duration > Duration::from_millis(0) {
                if let Some(limit) = limit {
                    duration = std::cmp::min(limit, duration);
                }
                self.park_thread_timeout(rt_handle, duration);
            } else {
                self.park.park_timeout(rt_handle, Duration::from_secs(0));
            }
        }
        None => {
            if let Some(duration) = limit {
                self.park_thread_timeout(rt_handle, duration);
            } else {
                self.park.park(rt_handle);
            }
        }
    }

    handle.process(rt_handle.clock());
}

Пошаговый разбор:

1. Взять блокировку, прочитать следующее истечение:lock.wheel.next_expiration_time()возвращаетOption<u64>, то есть следующий tick истечения. Одновременно записывает его вlock.next_wake, дляreregisterчтобы определить, нужен ли unpark.

2. Освободить блокировку:drop(lock)должен быть до park, иначе во время park другие потоки не смогут вставить таймер.

3. Вычислить длительность park:when.saturating_sub(now)получает оставшееся число tick,tick_to_durationпреобразует вDuration. В комментарии указано, что здесь фактически округление вверх до 1 мс📎 tokio/src/runtime/time/mod.rs:228-230, чтобы микросекундный sleep не был воспринят OS как нулевой длины.

4. Обработка limit: если вызывающий передалlimit(например,park_timeoutявный таймаут), берётсяmin(limit, duration), чтобы гарантировать, что не проспит слишком долго.

5. Особый случай: еслиduration == 0(уже истёк), используетсяpark_timeout(0)для немедленного возврата, без реального сна.

6. Когда нет таймеров: еслиnext_wakeравноNone, при наличииlimitвыполняетсяpark_thread_timeout(limit), иначе бесконечныйpark。

7. Обработка после пробуждения:handle.process(clock)продвигает timing wheel и запускает просроченные записи.

process_at_time: запуск просроченных записей

processвызываетprocess_at_time 📎 tokio/src/runtime/time/mod.rs:296-337:

rust
pub(self) fn process_at_time(&self, mut now: u64) {
    let mut waker_list = WakeList::new();
    let mut lock = self.inner.lock();

    if now < lock.wheel.elapsed() {
        // 时间倒流保护
        now = lock.wheel.elapsed();
    }

    while let Some(entry) = lock.wheel.poll(now) {
        debug_assert!(unsafe { entry.is_pending() });
        if let Some(waker) = unsafe { entry.fire(Ok(())) } {
            waker_list.push(waker);
            if !waker_list.can_push() {
                drop(lock);
                waker_list.wake_all();
                lock = self.inner.lock();
            }
        }
    }

    lock.next_wake = lock.wheel.poll_at()
        .map(|t| NonZeroU64::new(t).unwrap_or_else(|| NonZeroU64::new(1).unwrap()));
    drop(lock);
    waker_list.wake_all();
}

Несколько ключевых моментов:

  • Защита от обратного хода времени 📎 tokio/src/runtime/time/mod.rs:301-309: еслиnow < wheel.elapsed(), значит часы идут назад. В комментарии указано, что обычно этого не должно происходить (Rust гарантируетInstantмонотонность), но случается в Linux VM на Windows-хосте, потому что std ошибочно доверяет монотонности аппаратных часов. Защита заключается в приведенииnowкelapsed。
  • Пакетное пробуждение:WakeListсобирает Waker, и когда он заполнен (!can_push()), временно освобождает блокировку, пробуждает пачку, затем снова захватывает блокировку. В комментарии подчёркивается, что это для избежания дедлока📎 tokio/src/runtime/time/mod.rs:319. Если вызвать Waker, удерживая блокировку, а Waker попытается работать с timing wheel (например, перерегистрировать таймер), возникнет дедлок.
  • Обновление next_wake: после обработки пересчитываетсяpoll_at(), обновляетсяnext_wake。

reregister: перерегистрация и unpark

КогдаSleep::resetвызывается, таймер нужно перерегистрировать.reregisterобрабатывает этот сценарий📎 tokio/src/runtime/time/mod.rs:398-450:

rust
pub(self) unsafe fn reregister(&self, unpark: &IoHandle, new_tick: u64, entry: NonNull<TimerShared>) {
    let waker = unsafe {
        let mut lock = self.inner.lock();
        if unsafe { entry.as_ref().might_be_registered() } {
            lock.wheel.remove(entry);
        }
        let entry = entry.as_ref().handle();
        if self.is_shutdown() {
            unsafe { entry.fire(Err(crate::time::error::Error::shutdown())) }
        } else {
            entry.set_expiration(new_tick);
            match unsafe { lock.wheel.insert(entry) } {
                Ok(when) => {
                    if lock.next_wake.is_none_or(|next_wake| when < next_wake.get()) {
                        unpark.unpark();
                    }
                    None
                }
                Err((entry, crate::time::error::InsertError::Elapsed)) => unsafe {
                    entry.fire(Ok(()))
                },
            }
        }
    };
    if let Some(waker) = waker {
        waker.wake();
    }
}

Ключевая логика: после успешной вставки, если новый момент истечения раньшеnext_wake, вызываетсяunpark.unpark()для пробуждения driver. Это потому, что driver может спать до более позднего момента и должен быть разбужен досрочно для пересчёта длительности park.

Обратите внимание:unparkвызывается приудержании блокировки, аwaker.wake()вызывается послеосвобождения блокировки. В комментарии объясняется📎 tokio/src/runtime/time/mod.rs:441: необходимо освободить блокировку перед вызовом Waker во избежание дедлока. Ноunparkотличается — он просто помещает событие в epoll, не вызывает пользовательский код, поэтому вызов с удержанием блокировки безопасен.

mermaid
sequenceDiagram
    participant Sleep as Sleep::poll
    participant Handle as time::Handle
    participant Wheel as Wheel
    participant Driver as Driver::park_internal
    participant IoStack as IoStack

    Sleep->>Handle: reregister(unpark, new_tick, entry)
    Handle->>Handle: lock.inner.lock()
    Handle->>Wheel: wheel.remove(entry) [если уже зарегистрирован]
    Handle->>Wheel: wheel.insert(entry)
    Wheel-->>Handle: Ok(when)
    alt when < next_wake
        Handle->>IoStack: unpark.unpark()
    end
    Handle->>Handle: drop(lock)
    Handle-->>Sleep: возврат waker (если есть)

    Note over Driver: другой поток
    Driver->>Handle: lock.inner.lock()
    Driver->>Wheel: next_expiration_time()
    Wheel-->>Driver: Some(when)
    Driver->>Driver: drop(lock)
    Driver->>IoStack: park_timeout(duration)
    IoStack-->>Driver: разбужен unpark или таймаут
    Driver->>Handle: process(clock)
    Handle->>Wheel: poll(now)
    Wheel-->>Handle: TimerHandle
    Handle->>Sleep: waker.wake()

---

III. Sleep и Timeout: видимый пользователю слой API

Интуитивная модель

Sleep— это Future, который пользователь напрямую.await,Timeout— это адаптер, оборачивающий другой Future. Они сами не управляют колесом времени, а лишь переводят «deadline» в tick и делегируютTimerиHandle。

Раскладка памяти Sleep

Sleepиспользуетpin_project!макрос для определения📎 tokio/src/time/sleep.rs:221-227:

rust
pub struct Sleep {
    deadline: Instant,
    driver: scheduler::Handle,
    inner: Inner,
    #[pin]
    timer: Option<Timer>,
}

timerявляетсяOption<Timer>и с#[pin]: до первого poll —None, при первом poll создаётсяTimerи регистрируется. Эта «ленивая инициализация» избегает обращения к runtime при вызовеsleep()—sleep()можно вызывать вне runtime, если только фактическая регистрация происходит при.await.

PinnedDropреализация гарантирует отмену таймера при drop📎 tokio/src/time/sleep.rs:230-235:

rust
impl PinnedDrop for Sleep {
    fn drop(this: Pin<&mut Self>) {
        let this = this.project();
        if let Some(timer) = this.timer.as_pin_mut() {
            timer.cancel(this.driver);
        }
    }
}

Полный поток poll_elapsed

poll_elapsed— этоSleepядро📎 tokio/src/time/sleep.rs:396-454:

rust
fn poll_elapsed(self: Pin<&mut Self>, cx: &mut task::Context<'_>) -> Poll<Result<(), Error>> {
    ready!(crate::trace::trace_leaf());
    let mut this = self.project();

    // coop 预算
    let coop = ready!(crate::task::coop::poll_proceed(cx));

    let handle = this.driver;
    let timer = match this.timer.as_mut().as_pin_mut() {
        Some(timer) => timer,
        None => {
            let time_source = handle.driver().time().time_source();
            let deadline = time_source.deadline_to_tick(*this.deadline);
            let timer = Timer::new(handle, deadline);
            this.timer.set(Some(timer));
            let mut timer = this.timer.as_pin_mut().unwrap();
            timer.as_mut().init(handle, deadline);
            timer
        }
    };

    let result = timer.poll_elapsed(cx, handle).map(move |r| {
        coop.made_progress();
        r
    });
    result
}

Пошагово:

1. проверка бюджета coop:poll_proceed(cx)расходует одну единицу кооперативного бюджета. Если бюджет исчерпан, возвращаетсяPendingи управление уступается. Это механизм Tokio, предотвращающий голодание других задач одной задачей.

2. Ленивое создание Timer: еслиtimerравноNone, преобразоватьdeadlineв tick, создатьTimerи вызватьinitдля регистрации в колесе времени.

3. Делегирование Timer::poll_elapsed: фактическая проверка истечения выполняетсяTimer.

4. Отметка прогресса после успеха:coop.made_progress()означает, что данный poll имел реальный прогресс.

poll Timeout: сначала poll значения, затем poll задержки

Timeoutпорядок poll в📎 tokio/src/time/timeout.rs:210-224:

rust
fn poll(self: Pin<&mut Self>, cx: &mut task::Context<'_>) -> Poll<Self::Output> {
    let me = self.project();
    let had_budget_before = coop::has_budget_remaining();

    // 先 poll 被包裹的 future
    if let Poll::Ready(v) = me.value.poll(cx) {
        return Poll::Ready(Ok(v));
    }

    match me.delay.as_pin_mut() {
        Some(delay) => poll_delay(had_budget_before, delay, cx).map(Err),
        None => Poll::Pending,
    }
}

Комментарий явно указывает📎 tokio/src/time/timeout.rs:24-26: future опрашивается первым, и только затем проверяется таймаут. Поэтому если future завершается без yield, он может вернутьOkдаже после превышения timeout. Это проектное решение, а не баг.

poll_delayобрабатывает тонкий сценарий📎 tokio/src/time/timeout.rs:229-251:

rust
fn poll_delay(had_budget_before: bool, delay: Pin<&mut Sleep>, cx: &mut task::Context<'_>) -> Poll<Elapsed> {
    let delay_poll = || match delay.poll(cx) {
        Poll::Ready(()) => Poll::Ready(Elapsed::new()),
        Poll::Pending => Poll::Pending,
    };

    let has_budget_now = coop::has_budget_remaining();

    if let (true, false) = (had_budget_before, has_budget_now) {
        // 如果预算是被底层 future 耗尽的,用无约束预算 poll delay
        coop::with_unconstrained(delay_poll)
    } else {
        delay_poll()
    }
}

Логика: если при входе вpollбюджет ещё есть, но после poll value бюджет исчерпан, значит, именно value израсходовал бюджет. В этом случае при poll delay с ограниченным бюджетом delay может немедленно вернутьPending, из-за чего невозможно определить, наступил ли таймаут. Поэтому используетсяwith_unconstrainedдля временного снятия ограничения бюджета. Комментарий называет это «pathological cases»📎 tokio/src/time/timeout.rs:243-246。

Обработка переполнения deadline в timeout

timeoutфункция используетchecked_addдля обработки переполнения📎 tokio/src/time/timeout.rs:86-99:

rust
Timeout {
    value: future.into_future(),
    delay: match Instant::now().checked_add(duration) {
        Some(deadline) => Some(Sleep::new_timeout(deadline, trace::caller_location())),
        None => None,
    },
}

ЕслиInstant::now() + durationпереполняется (duration чрезвычайно велик),delayстановитсяNone, и при poll сразу возвращаетсяPoll::Pending 📎 tokio/src/time/timeout.rs:222. Это эквивалентно «никогда не истекает» и является разумным поведением деградации.

---

Проектные соображения и подводные камни в production

Почему для вычисления уровня используется XOR, а не вычитание? elapsed ^ whenстарший значащий бит напрямую отражает «с какого бита два временных штампа начинают различаться», что и является мерой «насколько грубая гранулярность нужна». Вычитаниеwhen - elapsedприelapsedблизком кwhenдаёт все старшие биты равными 0,ilog2вычислит слишком малый уровень. XOR естественным образом обрабатывает сценарии с переполнением.

Необходимость защиты от обратного хода времени 📎 tokio/src/runtime/time/mod.rs:301-309: Rust гарантирует монотонностьInstant, но нижележащая ОС может не гарантировать. В Linux VM на хосте Windows std доверяет аппаратным часам, что приводит к откатуInstant. Tokio используетnow = lock.wheel.elapsed()для ограничения, избегая сбоя assert вset_elapsed.

Пакетное пробуждение и взаимоблокировка 📎 tokio/src/runtime/time/mod.rs:319: вызов Waker при удержании блокировки колеса времени опасен — Waker может вызвать повторный poll задачи, который затем вызоветSleep::reset, пытаясь снова захватить блокировку колеса времени, что приводит к взаимоблокировке.WakeListпакетный механизм

next_wakeвременно освобождает блокировку, когда она заполнена, что является стандартным паттерном «callback вне блокировки». 📎 tokio/src/runtime/time/mod.rs:130-136:Option<NonZeroU64>niche-оптимизацияu64того же размера, что иNone, поскольку 0 используется как niche дляNonZeroU64::new(t).unwrap_or_else(|| NonZeroU64::new(1).unwrap()). Но tick 0 — допустимое значение, поэтому код использует📎 tokio/src/runtime/time/mod.rs:221для отображения 0 в 1

process_expiration. Это тонкая обработка граничного случая: tick 0 трактуется как tick 1, что приводит максимум к дополнительному пробуждению на 1 мс. 📎 tokio/src/runtime/time/wheel/mod.rs:219-228«сначала извлечь, потом обработать»MAX_DURATION: необходимо сначала извлечь все записи слота, а затем обрабатывать, поскольку записи, превышающие

Timeout, заворачиваются и повторно вставляются в тот же слот. Если вставлять во время извлечения, получится бесконечный цикл. 📎 tokio/src/time/timeout.rs:24-26ловушка порядка pollOk: future опрашивается первым, таймаут проверяется после. Если future является CPU-интенсивным и не делает yield, он может вернутьtimeoutдаже после превышения timeout. В production не полагайтесь на

---

для принудительного прерывания некооперативных future.

Резюме главы

1. В этой главе разобрана трёхуровневая структура драйвера времени Tokio:(WheelКолесо времениelapsed ^ when): шестиуровневая хеш-иерархическая структура из 64 слотов, где разрядностьpendingопределяет уровень записи, вставка и срабатывание приблизительно O(1).process_expirationсвязанный список хранит истёкшие записи,

2. Driver(Driver::park_internalотвечает за пошаговый спуск по уровням.next_expiration_time): переводитpark_timeoutколеса времени в длительностьprocess_at_time, переиспользуя park/unpark стека I/O.

3. после пробуждения продвигает колесо времени, пакетно запускает Waker и обрабатывает обратный ход времени и защиту от взаимоблокировок.(Sleep / Timeout):SleepПользовательский APITimerлениво создаётTimeoutи регистрирует,with_unconstrainedсначала poll value, затем poll delay, используя

для обработки сценария исчерпания бюджета.next_wakeКлючевой дизайн — «время тоже является событием I/O»: у driver только одна точка входа park, которая одновременно ожидает готовности fd и истечения таймера.reregisterзаписывает обещанный момент пробуждения,unparkпри вставке более раннего таймера

пробуждает driver для пересчёта.Mutex、SemaphoreВ следующей главе мы перейдём к примитивам синхронизации:

и как каналы реализуют асинхронное ожидание. Вы увидите, как они переиспользуют механизм Waker из этой главы, а также как «счётчик разрешений» и «очередь ожидания» взаимодействуют.

Вопросы для размышления и самопроверки в этой главеWheel::insertQ1: Если вif when <= self.elapsedзаменитьif when < self.elapsed(убрать знак равенства), в каких сценариях таймер никогда не будет срабатывать?

Справочный разбор:when == self.elapsedозначает, что момент истечения таймера точно равен текущему продвинутому времени. Исходный код использует<=и считает этоElapsed, вызывающая сторона немедленно запускает📎 tokio/src/runtime/time/wheel/mod.rs:96-98. Если изменить на<, эта запись будет вставлена в слой, вычисленныйlevel_for(elapsed, when). Посколькуelapsed ^ when == 0,masked = 0 | SLOT_MASK = 63,ilog2(63) = 5,5 / 6 = 0, она попадает в слой 0. Ноnext_expirationслоя 0 вернёт слотdeadline >= elapsed, а условиеWheel::poll— этоexpiration.deadline <= now. Еслиnow == elapsed, условие выполняется,process_expirationизвлечёт эту запись,mark_pending(elapsed)проверит, достигнут ли фактический deadline — в этот моментwhen == elapsed,mark_pendingвозвращаетOk, запись переходит в pending. Так что фактически она всё равно будет запущена, но с лишним кругом. Настоящий риск в том, что еслиelapsedуже продвинулось послеwhen(when < elapsed), исходный код возвращаетElapsedи запускает немедленно, а после изменения запись вставляется в уже прошедший слот,next_expirationможет вернутьdeadline < elapsed,set_elapsed, assertelapsed <= whenзавершится panic📎 tokio/src/runtime/time/wheel/mod.rs:253-264. Поэтому этот знак равенства — ключевая граница, предотвращающая сбой assert.

Q2: process_at_timeВWakeListпосле заполненияdrop(lock)почему нужноwake_all()сноваlockи заново

? Если убрать этот drop, в каких сценариях конкурентности возникнет дедлок?:WakeListСправочный разбор📎 tokio/src/runtime/time/mod.rs:318-325собирает Waker, после заполнения необходимо разбудить партию, чтобы освободить местоself.inner.lock(). Если, удерживаяwaker.wake(), вызватьSleep::reset, разбуженная задача может немедленно запуститься в другом потоке (или в планировщике того же потока), вызватьSleep::poll_elapsedилиHandle::reregister, затем вызватьreregister, а первое, что делаетself.inner.lock() 📎 tokio/src/runtime/time/mod.rs:405— этоstd::sync::Mutex. Посколькуprocess_at_timeне реентерабелен, тот же поток попадёт в дедлок; даже в другом потоке он будет блокироваться до тех пор, покаprocess_at_timeне освободит блокировку, аwake_allкак раз ждёт возврата📎 tokio/src/runtime/time/mod.rs:319, образуя циклическое ожидание. Комментарий явно говорит: «To avoid deadlock, we must do this with the lock temporarily dropped»while let Some(entry) = lock.wheel.poll(now). Когда после drop снова выполняется lock, состояние временного колеса могло быть изменено другим потоком (например, вставлен новый таймер), поэтому

Q3: Timeout::pollпродолжит брать записи из нового состояния — это безопасно.had_budget_beforeВhas_budget_nowкомбинация(true, false)иwith_unconstrainedпочему используется только когда «при входе бюджет есть, после poll value бюджета нет»(false, true)? Что будет, если наоборот

?:had_budget_beforeСправочный разбор📎 tokio/src/time/timeout.rs:208-208,has_budget_nowзаписывает📎 tokio/src/time/timeout.rs:239。(true, false)до poll value,poll_proceedзаписываетPendingпосле poll value.with_unconstrainedозначает, что бюджет был исчерпан во время poll value, то есть value — «потребитель бюджета». В этом случае, если poll delay выполняется с ограниченным бюджетом,📎 tokio/src/time/timeout.rs:247。(false, true)немедленно вернётwith_unconstrained, delay никогда не будет реально проверен, и определение тайм-аута перестанет работать. Поэтому используется(false, false)для временного снятия ограниченияPending.poll_proceedневозможно — бюджет может только расходоваться, но не восстанавливаться (если только явно не(true, true), но здесь этого нет).

означает, что при входе бюджета уже не было; в этот момент poll value мог уже вернуть

CHAPTER 07

Глава 7: Примитивы синхронизации: как Mutex, Semaphore и каналы реализуют асинхронное ожидание

← Предыдущая глава: Глава 5 · Вернуться наверх ↑ · Следующая глава: Глава 7 →

Глава 7: Примитивы синхронизации: как Mutex, Semaphore и каналы реализуют асинхронное ожидание

Проект: tokio-rs/tokio

Прогресс книги: Глава 7 / 14

std::sync::MutexСтатус проверки: строки FACT реально привязаныlock()Предыдущая глава показала, как время абстрагируется в событие I/O, позволяя таймерам и готовности fd совместно использовать одну точку ожидания park/unpark. Однако когда несколько задач конкурируют за одну и ту же блокировку или передают сообщения через каналы, объектом ожидания становится уже не fd или часы, а изменение состояния другой задачи. Эта глава входит в семейство tokio::sync и выясняет, куда именно lock().await или recv().await сохраняет Waker при блокировке и как он затем перепланируется при пробуждении.Почему асинхронный Mutex не может повторно использовать реализацию stdИнтуитивная модель: от «занимаю место» к «уступаю место»Впри занятой блокировкеPendingблокирует текущий поток

— поток приостанавливается операционной системой до освобождения блокировки. В асинхронном рантайме это катастрофично: один worker-поток может одновременно обслуживать сотни и тысячи задач, и если он блокируется из-за ожидания блокировки, все остальные задачи, которые он несёт, останавливаются. Основное требование асинхронного Mutex: при ожидании блокировкиMutexуступить потокПолностью построен на семафоре。

Структура данных и размещение в памяти

Mutex<T>Поля минимальны:

📎 tokio/src/sync/mutex.rs:133-138

rust
pub struct Mutex<T: ?Sized> {
    #[cfg(all(tokio_unstable, feature = "tracing"))]
    resource_span: tracing::Span,
    s: semaphore::Semaphore,
    c: UnsafeCell<T>,
}

Три поля выполняют каждая свою роль:s— этосемафор с количеством разрешений 1,c— этоUnsafeCell<T>защищённые данные, обёрнутые в . Обратите внимание, что здесьsemaphore— этоbatch_semaphoreпсевдоним для📎 tokio/src/sync/mutex.rs:3-3, то есть низкоуровневая реализация, а неsync::Semaphoreтот публичный слой обёртки.

MutexGuard<'a, T>же хранит только ссылку наMutex:

📎 tokio/src/sync/mutex.rs:151-157

rust
pub struct MutexGuard<'a, T: ?Sized> {
    #[cfg(all(tokio_unstable, feature = "tracing"))]
    resource_span: tracing::Span,
    lock: &'a Mutex<T>,
}

Здесь есть ключевое проектное решение:MutexGuard не хранит объект разрешения семафора, а хранит только&Mutex. Действие освобождения блокировки происходит вDrop, напрямую вызываяself.lock.s.release(1) 📎 tokio/src/sync/mutex.rs:959-961. Это отличается отSemaphorePermit, который хранитpermits: usizeсчётчик и возвращает его при Drop — у Mutex количество разрешений всегда равно 1, счётчик не нужен.

Send/SyncГраницы

📎 tokio/src/sync/mutex.rs:258-259

rust
unsafe impl<T> Send for Mutex<T> where T: ?Sized + Send {}
unsafe impl<T> Sync for Mutex<T> where T: ?Sized + Send {}

Syncтребует толькоT: Send, а неT: Sync— это разумно, поскольку взаимное исключение гарантирует, что только один поток одновременно может касатьсяT, передача владенияTмежду потоками (Send) достаточна, не требуется, чтобыTсам по себе был разделяемым (Sync). Именно поэтомуMutex<T>может превратить не-SyncTвSync.

Пошагово: полное путешествие одногоlock().await

Сценарий: задача A вызываетmutex.lock().await, в этот момент блокировка свободна.

Первый шаг,lock()конструирует async-блок, внутри сначалаself.acquire().await, после успеха конструируетMutexGuard 📎 tokio/src/sync/mutex.rs:434-443。

Второй шаг,acquire()напрямую делегирует семафору:

📎 tokio/src/sync/mutex.rs:655-663

rust
async fn acquire(&self) {
    crate::trace::async_trace_leaf().await;
    self.s.acquire(1).await.unwrap_or_else(|_| {
        unreachable!()
    });
}

unwrap_or_else(|_| unreachable!())Этот комментарий выражает проектное ограничение: Mutex никогда явно не закрывает семафор и монопольно владеет им, поэтомуacquireникогда не вернётErr. Это исключает путь ошибки «закрытие семафора» на уровне типов.

Третий шаг, если блокировка занята,s.acquire(1)возвращаетPending, Waker текущей задачи регистрируется в очереди ожидания семафора.Где хранится Waker?Ответ вbatch_semaphoreочереди ожидания (в исходных материалах этой главы этот файл не раскрыт, но его роль такова: каждый ожидающий хранит один Waker, очередь FIFO).

Четвёртый шаг, когда задача B, удерживающая блокировку, освобождает её,MutexGuard::dropвызываетs.release(1) 📎 tokio/src/sync/mutex.rs:965-975, семафор передаёт разрешение первому в очереди ожидающему и пробуждает его Waker, задача A перепланируется,acquireвозвращаетOk, конструируетсяMutexGuard。

Весь процесс можно описать следующей диаграммой последовательности:

mermaid
sequenceDiagram
    participant TaskA as Задача A
    participant Mutex as Mutex.s (batch_semaphore)
    participant TaskB as Задача B (держатель блокировки)
    participant Exec as Executor

    TaskA->>Mutex: acquire(1).await
    Mutex-->>TaskA: Pending (Waker в очереди)
    TaskA->>Exec: уступает, планирует другие задачи
    Note over TaskB: выполняет критическую секцию с блокировкой
    TaskB->>Mutex: MutexGuard::drop -> release(1)
    Mutex->>TaskA: пробуждает Waker из головы очереди
    Exec->>TaskA: повторный poll
    TaskA->>Mutex: acquire(1) повтор
    Mutex-->>TaskA: Ok(()) получено разрешение
    TaskA->>TaskA: создаёт MutexGuard

Проектное размышление: FIFO-справедливость и безопасность отмены

Документация явно заявляет, что Mutex в Tokio гарантирует FIFO📎 tokio/src/sync/mutex.rs:20-22. Эта справедливость исходит из семантики очереди нижележащего семафора. Цена справедливости: однаlockотмена (например, проигрыш вselect!) заставит васпотерять место в очереди 📎 tokio/src/sync/mutex.rs:415-419. Это не баг, а неизбежность FIFO-очереди — отмена означает удаление из очереди, повторныйlockтребует повторной постановки в очередь.

Ещё одно контринтуитивное проектное решение —не отравляет(no poisoning)。std::sync::Mutexпомечается как poisoned при panic в потоке, удерживающем блокировку, последующийlockвозвращаетErr. Mutex в Tokio так не делает: при panic удерживающего блокировка нормально освобождается📎 tokio/src/sync/mutex.rs:122-125. Документация предупреждает, что если panic перехвачен, защищённые данные могут оказаться в несогласованном состоянии. Это прагматичный компромисс в асинхронном сценарии — panic в асинхронной задаче обычно означает завершение задачи, а механизм отравления лишь добавляет сложность.

MutexGuard::mapСерия методовMutexGuard<T>заслуживает упоминания. Она позволяет понизить весьMappedMutexGuard<U>доdata, защищающего только определённое подполе. В реализации сначала через замыкание вычисляется указатель на подполеskip_drop, затем черезMutexGuardInnerисходный guard разбирается на📎 tokio/src/sync/mutex.rs:869-883。skip_drop, не вызывающий Drop, и наконец конструируется новый guardManuallyDrop + ptr::readс помощьюDropпереносится владение полем, избегая📎 tokio/src/sync/mutex.rs:827-836двойного вызова

. Это классический приём в Rust для «передачи владения без вызова деструктора».

Semaphore: как реализуются счётчик разрешений и очередь ожидания для обратного давления

Интуитивная модель: парковочные местаacquireСемафор похож на парковку:release— это въезд, если есть свободное место — заезжаешь, если нет — стоишь в очереди у входа;acquire_many(n)— это выезд, освобождается место — уведомляется первая машина в очереди на въезд. Количество разрешений — это общее число мест,

— это большая машина, занимающая n мест.

Структура данных и размещение в памятиSemaphoreПубличныйbatch_semaphore::Semaphore— лишь тонкая обёртка над низкоуровневым

📎 tokio/src/sync/semaphore.rs:427-432

rust
pub struct Semaphore {
    ll_sem: ll::Semaphore,
    #[cfg(all(tokio_unstable, feature = "tracing"))]
    resource_span: tracing::Span,
}

SemaphorePermit<'a>Копировать

📎 tokio/src/sync/semaphore.rs:442-445

rust
pub struct SemaphorePermit<'a> {
    sem: &'a Semaphore,
    permits: usize,
}

permitsКопироватьforget/merge/splitПолеforget— ключ к пониманиюpermits.📎 tokio/src/sync/semaphore.rs:1193-1195обнуляетsplit, так что при Drop возвращается 0 разрешений — эквивалентно «постоянному расходованию» этих разрешений.📎 tokio/src/sync/semaphore.rs:1260-1271。mergeвырезает n из текущих разрешений для нового permit📎 tokio/src/sync/semaphore.rs:1230-1240。

объединяет счётчик другого permit и утверждает, что оба происходят от одного семафора

MAX_PERMITS〔Проектные выводы и архитектурные компромиссы〕usize::MAX >> 3 📎 tokio/src/sync/semaphore.rs:476-479— этоbatch_semaphore. Почему сдвиг вправо на 3 бита? Низкоуровневомуusizeнужно кодировать флаги состояния (например, флаг закрытия) в старших битах, поэтому количество доступных разрешений ограничивается младшими битами, а старшие оставляются под флаги. Это распространённый приём упаковки «счётчик + состояние» в один

.

Пошагово: поток разрешений при acquire и releaseacquire()Сценарий: семафор изначально с 2 разрешениями, задача Aacquire_many(2)。

acquire(), задача Bll_sem.acquire(1)делегируетSemaphorePermit { permits: 1 } 📎 tokio/src/sync/semaphore.rs:614-631。acquire_many(2), после успеха конструирует📎 tokio/src/sync/semaphore.rs:661-679。

аналогично, но передаёт 2ll_sem.acquire(n)Если разрешений недостаточно,Pendingвозвращаетacquire_many(5), Waker ставится в очередь. Здесь есть деталь справедливости: документация указывает, что если во главе очереди стоитacquire(1), а осталось всего 3 разрешения, то даже если позади есть📎 tokio/src/sync/semaphore.rs:19-24, которое можно немедленно удовлетворить, оно должно ждать — потому что большая машина во главе занимает очередь

. Это цена строгого FIFO, избегающая голодания.

📎 tokio/src/sync/semaphore.rs:1402-1404

rust
impl Drop for SemaphorePermit<'_> {
    fn drop(&mut self) {
        self.sem.add_permits(self.permits);
    }
}

add_permitsКопироватьll_sem.release(n) 📎 tokio/src/sync/semaphore.rs:568-570делегирует

, низкоуровневый уровень возвращает разрешение в очередь ожидания и пробуждает ожидающего, которому хватает разрешений.AcqRelЧто касается порядка памяти, документация даёт сильную гарантию: acquire, release, close — всеAcqRel 📎 tokio/src/sync/semaphore.rs:35-42. Это означает, что запись, выполняемая по принципу «сначала записать данные, затем release разрешения», видна задаче, которая «позже acquire разрешение» — семафор может безопасно передавать данные между задачами.

Размышления о дизайне: close и обратное давление

close()заставляет всех ожидающих получитьAcquireError, и последующиеtry_acquireвозвращаютClosed 📎 tokio/src/sync/semaphore.rs:1161-1163. Это основа элегантного завершения: когда принимающая сторона больше не нуждается в данных, close семафора позволяет всем заблокированным отправителям немедленно завершиться с ошибкой, а не ждать вечно.

Суть обратного давления наиболее ясно проявляется в mpsc. В следующем разделе будет показано, что управление ёмкостью mpsc реализовано с помощью семафора, число разрешений которого равно размеру буфера.

Семейство каналов: различные компромиссы между очередью ожидающих и пробуждением через Waker

Интуитивная модель: четыре типа каналов, четыре стратегии ожидания

oneshot— это «одноразовый конверт» — можно отправить только одно письмо, отправитель не ждёт (sendсинхронный), получательawaitждёт письмо.mpsc— это «ограниченная лента конвейера» — отправитель ждёт, когда лента заполнена, получатель ждёт, когда она пуста, ёмкость контролируется семафором.broadcastиwatch— это «громкоговоритель» — один отправитель, несколько получателей, но оба совершенно по-разному обрабатывают «отставание».

Исходные материалы этого раздела сосредоточены наoneshotиmpsc::bounded, мы разберём их по очереди.

oneshot: минималистичное рукопожатие, закодированное в битах состояния

oneshotструктураInnerявляется ядром понимания его дизайна:

📎 tokio/src/sync/oneshot.rs:386-409

rust
struct Inner<T> {
    state: AtomicUsize,
    value: UnsafeCell<Option<T>>,
    tx_task: Task,
    rx_task: Task,
}

state— этоAtomicUsize, использующий битовые флаги для кодирования состояния всего канала. Четыре флага определены в конце файла:

📎 tokio/src/sync/oneshot.rs:1488-1505

rust
const RX_TASK_SET: usize = 0b00001;
const VALUE_SENT: usize = 0b00010;
const CLOSED: usize = 0b00100;
const TX_TASK_SET: usize = 0b01000;

value— этоUnsafeCell<Option<T>>,tx_taskиrx_task— этоTaskтипы, внутриUnsafeCell<MaybeUninit<Waker>> 📎 tokio/src/sync/oneshot.rs:411-411. Обратите внимание, чтоMaybeUninit— Waker может быть неинициализирован, его валидность определяется битомstateвRX_TASK_SET/TX_TASK_SETСуть этого дизайна в том, что бит📎 tokio/src/sync/oneshot.rs:396-399。

не только указывает, что «значение отправлено», но и определяет, кому принадлежит право доступа к:VALUE_SENT. Комментарий написан очень чёткоUnsafeCell: если📎 tokio/src/sync/oneshot.rs:1491-1496установлен,VALUE_SENTдоступен только получателю; если не установлен, доступен только отправителю. Таким образом, один атомарный бит реализует бесслодочную передачу владения, избегая дополнительных блокировок.UnsafeCellПроцесс

send:

📎 tokio/src/sync/oneshot.rs:622-646

rust
pub fn send(mut self, t: T) -> Result<(), T> {
    let inner = self.inner.take().unwrap();
    inner.value.with_mut(|ptr| unsafe {
        *ptr = Some(t);
    });
    if !inner.complete() {
        unsafe {
            return Err(inner.consume_value().unwrap());
        }
    }
    Ok(())
}

Сначала значение записывается вUnsafeCell(в этот моментVALUE_SENTне установлен, получатель не будет обращаться), затем вызываетсяcomplete()для попытки установкиVALUE_SENT。complete()— это цикл CAS:

📎 tokio/src/sync/oneshot.rs:1516-1549

rust
fn set_complete(cell: &AtomicUsize) -> State {
    let mut state = cell.load(Ordering::Relaxed);
    loop {
        if State(state).is_closed() {
            break;
        }
        match cell.compare_exchange_weak(
            state, state | VALUE_SENT, Ordering::AcqRel, Ordering::Acquire,
        ) {
            Ok(_) => break,
            Err(actual) => state = actual,
        }
    }
    State(state)
}

Почему используется CAS, а не простойfetch_or? Комментарий объясняет это очень ясно📎 tokio/src/sync/oneshot.rs:1517-1529: если канал ужеCLOSED, тонельзяснова устанавливатьVALUE_SENT. Поскольку после установки получатель будет считать, что может обращаться кUnsafeCell, а в это время отправитель готовится забрать значение обратно (consume_value), одновременный доступ с обеих сторон приведёт к гонке данных. Поэтому цикл CAS при обнаруженииCLOSEDдосрочно прерывается, не устанавливая флаг.

complete()После возврата, если установка прошла успешно иRX_TASK_SETуже установлен, получатель пробуждается:

📎 tokio/src/sync/oneshot.rs:1300-1315

rust
fn complete(&self) -> bool {
    let prev = State::set_complete(&self.state);
    if prev.is_closed() {
        return false;
    }
    if prev.is_rx_task_set() {
        unsafe {
            self.rx_task.with_task(Waker::wake_by_ref);
        }
    }
    true
}

получателяpoll_recvявляется ядром конечного автомата:

📎 tokio/src/sync/oneshot.rs:1317-1384

Сначала он загружает состояние, еслиis_complete()то сразуconsume_valueвозвращает; еслиis_closed()возвращаетErr; иначе переходит в ветку «регистрация Waker». При регистрации сначала проверяетсяis_rx_task_set(), если уже установлен иwill_wakeопределяет, что это тот же Waker, повторная установка не производится; если другой, то сначала unset, затем set. Здесь есть тонкая обработка гонки: после unset, если обнаруживается, чтоis_complete()стало истинным, нужноснова установить флаг обратно 📎 tokio/src/sync/oneshot.rs:1342-1344, иначе Waker будет утечён при Drop (поскольку Drop зависит от флага для определения, нужно ли дропать Waker).

Этот паттерн «unset, затем повторный set» также встречается вpoll_closed📎 tokio/src/sync/oneshot.rs:839-848, это стандартный приём oneshot для обработки конкурентных пробуждений.

mpsc::bounded: обратное давление, управляемое семафором

Управление ёмкостью mpsc полностью передано семафору.channelФункция создаёт семафор с числом разрешений, равным размеру буфера:

📎 tokio/src/sync/mpsc/bounded.rs:159-171

rust
pub fn channel<T>(buffer: usize) -> (Sender<T>, Receiver<T>) {
    assert!(buffer > 0, "mpsc bounded channel requires buffer > 0");
    let semaphore = Semaphore {
        semaphore: semaphore::Semaphore::new(buffer),
        bound: buffer,
    };
    let (tx, rx) = chan::channel(semaphore);
    let tx = Sender::new(tx);
    let rx = Receiver::new(rx);
    (tx, rx)
}

Semaphore— это внутренняя обёртка mpsc, одновременно содержащая базовый семафор иbound(максимальная ёмкость)📎 tokio/src/sync/mpsc/bounded.rs:176-179。boundиспользуется для запросаmax_capacity, аavailable_permitsдаёт текущую ёмкость📎 tokio/src/sync/mpsc/bounded.rs:591-593。

Путь отправкиsendсначалаreserveзатемsend:

📎 tokio/src/sync/mpsc/bounded.rs:816-824

rust
pub async fn send(&self, value: T) -> Result<(), SendError<T>> {
    match self.reserve().await {
        Ok(permit) => {
            permit.send(value);
            Ok(())
        }
        Err(_) => Err(SendError(value)),
    }
}

reserveвнутри вызываетreserve_inner(1), который сначала проверяетn > max_capacityи сразу возвращает ошибку, затемacquire(n) 📎 tokio/src/sync/mpsc/bounded.rs:1272-1311. Здесь есть изящный стражWakeReceiverOnDrop:

📎 tokio/src/sync/mpsc/bounded.rs:1286-1301

rust
struct WakeReceiverOnDrop<'a, T> {
    chan: &'a chan::Tx<T, Semaphore>,
}
impl<T> Drop for WakeReceiverOnDrop<'_, T> {
    fn drop(&mut self) {
        use chan::Semaphore;
        let semaphore = self.chan.semaphore();
        if semaphore.is_closed() && semaphore.is_idle() {
            self.chan.wake_rx();
        }
    }
}

Комментарий объясняет мотивацию📎 tokio/src/sync/mpsc/bounded.rs:1279-1285: еслиreserveпосле получения части разрешений отменяется (например,select!проигрывает), базовыйAcquireпри Drop вернёт эти разрешения, нонетак, какPermit, уведомит получателя. Если в этот момент канал уже закрыт и простаивает, получатель может никогда не дождаться уведомления «канал закрыт». Этот страж при Drop восполняет это пробуждение. При успехе используетсяmem::forget(guard)для отмены стража📎 tokio/src/sync/mpsc/bounded.rs:1306-1306, поскольку на успешном путиPermitберёт на себя ответственность за уведомление.

PermitDrop также делает то же самое:

📎 tokio/src/sync/mpsc/bounded.rs:1732-1745

rust
impl<T> Drop for Permit<'_, T> {
    fn drop(&mut self) {
        use chan::Semaphore;
        let semaphore = self.chan.semaphore();
        semaphore.add_permit();
        if semaphore.is_closed() && semaphore.is_idle() {
            self.chan.wake_rx();
        }
    }
}

Permit::sendиспользуетmem::forgetдля пропуска Drop, избегая возврата разрешений📎 tokio/src/sync/mpsc/bounded.rs:1721-1728。

Путь полученияrecvиспользуетpoll_fnдля обёрткиchan.recv(cx) 📎 tokio/src/sync/mpsc/bounded.rs:243-246。poll_recvнапрямую делегирует📎 tokio/src/sync/mpsc/bounded.rs:650-652. Настоящая логика очереди ожидания находится в модулеchan(в этой главе не раскрывается), но можно предположить: Waker получателя хранится вchan::Rx, и пробуждается, когда отправительsend.

try_sendдемонстрирует неблокирующий путь:

📎 tokio/src/sync/mpsc/bounded.rs:924-934

rust
pub fn try_send(&self, message: T) -> Result<(), TrySendError<T>> {
    match self.chan.semaphore().semaphore.try_acquire(1) {
        Ok(()) => {}
        Err(TryAcquireError::Closed) => return Err(TrySendError::Closed(message)),
        Err(TryAcquireError::NoPermits) => return Err(TrySendError::Full(message)),
    }
    self.chan.send(message);
    Ok(())
}

try_acquireДве ошибки точно отображаются наClosedиFull, различая два типа сбоев: «канал закрыт» и «буфер заполнен».

Размышления о дизайне: безопасность отмены и потеря сообщений

Документация mpsc неоднократно подчёркивает безопасность отмены📎 tokio/src/sync/mpsc/bounded.rs:776-784:sendпри проигрыше вselect!,сообщение будет отброшено. Чтобы избежать потери, необходимо использоватьreserveполучитьPermitзатемsend— посколькуPermitуже зарезервировал ёмкость,sendсинхронный и не может быть прерван.

recvже является безопасным от отмены📎 tokio/src/sync/mpsc/bounded.rs:199-204: еслиrecvпроигрывает вselect!, гарантируется, что ни одно сообщение не будет потреблено. Это потому, чтоrecvвозвращаетpoll_recvтолько при фактическом получении сообщения, а приReady,Pendingне трогает очередь.

oneshotкак Future также безопасен от отменыReceiver. Но обратите внимание:📎 tokio/src/sync/oneshot.rs:246-251синхронный, поэтому не существует проблемы «send отменён» — либо отправлено, либоoneshotвозвращает исходное значение.sendРазмышления о дизайне и подводные камни в продакшенеErrВозвращает исходное значение.

Проектные соображения и подводные камни в продакшене

Ошибка первая: использование асинхронного Mutex для защиты чистых данных.Документация явно рекомендует📎 tokio/src/sync/mutex.rs:26-36: если защищаемые данные являются чистыми (без.awaitтребований), использоватьstd::sync::Mutexилиparking_lotбыстрее. Накладные расходы асинхронного Mutex заключаются в атомарных операциях семафора и возможном планировании задач. Только когда необходимо удерживать блокировку во время.await(например, при доступе к соединению с базой данных с удержанием блокировки), следует использовать асинхронный Mutex.

Ошибка вторая: удержание блокировки через.awaitприводит к взаимоблокировке.Это самая опасная ловушка асинхронного Mutex. Если задача A, удерживая блокировку,.awaitожидает события, которое должно быть выполнено задачей B, а задача B в свою очередь ждёт эту блокировку, возникает взаимоблокировка.std::sync::Mutexguard не являетсяSend(в перемещаемых задачах), компилятор предотвратит удержание через.await; но guard асинхронного Mutex являетсяSend 📎 tokio/src/sync/mutex.rs:314-314, компилятор не остановит вас, нужно самостоятельно гарантировать отсутствие циклического ожидания.

Ошибка третья:reserveпосле забытогоsend。 PermitDrop вернёт разрешение📎 tokio/src/sync/mpsc/bounded.rs:1732-1745, поэтому ёмкость не будет утеряна. Но если канал уже закрыт и простаивает, Drop разбудит получателя — это пробуждение необходимо, иначе получатель может никогда не дождаться уведомления о закрытии.

Ошибка четвёртая:oneshotуpollможет быть ложнымPending。Документация поясняет📎 tokio/src/sync/oneshot.rs:236-242: даже если сообщение отправлено,pollможет вернутьPending. Это не баг, а нормальное явление в условиях конкурентной гонки — вызывающий будет разбужен для повторной попытки, сообщение не потеряется, лишь задержится.

Ошибка пятая:forget_permitsсемантика. forget_permits(n)Пытается уменьшить n разрешений, возвращает фактически уменьшенное количество📎 tokio/src/sync/semaphore.rs:576-578. Он не блокируется и не будит ожидающих — просто «поглощает» разрешения. Используется для динамического сокращения ёмкости семафора.

Резюме главы

Эта глава раскрываетtokio::syncключевые паттерны:все асинхронные примитивы ожидания построены на «очереди ожидающих + пробуждении через Waker», а конкретная реализация очереди зависит от сценария。

  • Mutexпереиспользует семафор с количеством разрешений 1,MutexGuardхранит только ссылку, при Droprelease(1), FIFO-справедливость без отравления.
  • Semaphore— это счётчик разрешений + очередь ожидания,SemaphorePermitиспользуетpermitsсчётчик для поддержкиforget/merge/split,MAX_PERMITSсдвиг вправо на 3 бита оставляет место для флагов состояния.
  • oneshotиспользует одинAtomicUsizeбитовый флаг для кодирования состояния,VALUE_SENTбит одновременно определяет принадлежность права доступа кUnsafeCell, цикл CAS предотвращает установку послеCLOSED.
  • mpsc::boundedиспользует семафор с количеством разрешений, равным buffer, для реализации обратного давления,WakeReceiverOnDropguard обрабатывает компенсацию пробуждения при отмене.

Вопросы для размышления и самопроверки

В: Если заменитьset_completeцикл CAS на простойfetch_or(VALUE_SENT), в каких сценариях конкурентности возникнет гонка данных?

Разбор ответа:set_completeПричина использования цикла CAS вместоfetch_orуказана в комментарии📎 tokio/src/sync/oneshot.rs:1517-1529: необходимо перед установкойVALUE_SENTпроверитьCLOSED. Если заменить на безусловныйfetch_or, рассмотрим такую последовательность: получатель сначала вызываетclose()устанавливаетCLOSED 📎 tokio/src/sync/oneshot.rs:1569-1574, отправитель затемsendзаписывает значение иfetch_or(VALUE_SENT). В этот моментVALUE_SENTиCLOSEDустановлены одновременно, получатель вpoll_recvвидит, чтоis_complete()истинно, и вызоветconsume_valueдля извлечения значения📎 tokio/src/sync/oneshot.rs:1325-1330; а отправитель после возврата изcomplete(), посколькуprev.is_closed()истинно, вызоветconsume_valueдля возврата значения обратно📎 tokio/src/sync/oneshot.rs:1300-1315. Обе стороны одновременно обращаются кUnsafeCell, гонка данных. Цикл CAS при обнаруженииCLOSEDдосрочно прерывается, не устанавливаяVALUE_SENT, тем самым гарантируя инвариант «после закрытия отправитель обладает эксклюзивным правом доступа».

Q: reserve_innerвWakeReceiverOnDropguard на успешном пути используетmem::forgetдля пропуска, что произойдёт, если убрать этотforget?

Разбор ответа: логика Drop guard — «если семафор закрыт и простаивает, разбудить получателя»📎 tokio/src/sync/mpsc/bounded.rs:1290-1298. На успешном путиacquire(n)возвращаетOk, вызывающий получает разрешение и создастPermit, за последующие уведомления отвечаетPermit. Если не убрать guard, guard при возврате функции выполнит Drop и дополнительно проверит «закрыт и простаивает» — но в этот момент разрешение уже удерживается вызывающимreserve_inner, семафор не простаивает (is_idleложно), поэтому фактически повторного пробуждения не произойдёт. Но важнее ясность семантики: ответственность за пробуждение на успешном пути должна полностью лежать наPermit, guard отвечает только за компенсацию на пути «отмены/ошибки».mem::forgetявно выражает намерение «этот путь не нуждается в guard». Если убратьforgetи семафор окажется в граничном состоянии «закрыт и простаивает» (например,acquireвернулOk, но разрешение ещё не перехваченоPermit), может возникнуть лишнее пробуждение — хотя это не приведёт к ошибке, но потратит одно планирование.

В: Если изменитьMutexGuardна хранение объекта разрешения семафора (какSemaphorePermit), какие проблемы это внесёт?

Разбор ответа: ТекущийMutexGuardхранит только&Mutex, при Drop вызываетself.lock.s.release(1) 📎 tokio/src/sync/mutex.rs:959-961. Если изменить на хранение объекта разрешения, возникнет несколько проблем. Во-первых,MutexGuard::mapсерия методов требует разбора guard наMappedMutexGuard, защищая только подполя📎 tokio/src/sync/mutex.rs:869-883. При текущем дизайнеMappedMutexGuardнужно хранить только&Semaphoreи указатель на подполе📎 tokio/src/sync/mutex.rs:190-199, при Dropself.s.release(1) 📎 tokio/src/sync/mutex.rs:1252-1262. Если guard хранит объект разрешения, при map придётся передавать владение объектом разрешения, аMappedMutexGuardразметка полей станет сложнее. Во-вторых, объект разрешения обычно несётpermits: usizeсчётчик, для Mutex этот счётчик всегда равен 1, что избыточно. В-третьих,MutexGuardграницыSend/Syncуже точно контролируются черезunsafe impl, хранение объекта разрешения внесёт дополнительные ограничения трейтов. Текущий дизайн «только ссылка + ручной release» легче и проще поддерживает📎 tokio/src/sync/mutex.rs:260-263На этом мы увиделиmap。

какtokio::syncиспользует единый паттерн «очередь ожидающих + пробуждение через Waker» для поддержки асинхронного ожидания в Mutex, Semaphore и различных каналах. Но не все блокировки можно сделать асинхронными — некоторые операции (например, вызовы файловой системы, CPU-интенсивные вычисления) по своей природе блокируют поток. В следующей главе мы перейдём к границе междуspawn_blockingпулом потоков иblock_on, и посмотрим, как Tokio строит мост между асинхронным рантаймом и синхронными блокировками.

Место хранения Waker зависит от примитива: в Mutex/Semaphore он находится в очереди ожидания базового семафора, в oneshot — в полях tx_task/rx_task структуры Inner, в mpsc — в очередях отправки и получения модуля chan. Но механизм пробуждения единообразен: при изменении состояния извлекается Waker и вызывается wake_by_ref, после чего исполнитель перепланирует задачу. На этом внутренний механизм ожидания и пробуждения в асинхронных примитивах становится полностью ясным. Однако не весь код можно сделать асинхронным — в следующей главе мы рассмотрим, как использовать spawn_blocking для моста к блокирующим операциям и как block_on управляет Future вне асинхронного контекста.

CHAPTER 08

Глава 8: Блокировки и мосты: пул потоков spawn_blocking и границы block_on

Проект: tokio-rs/tokio · Прогресс книги: Глава 8 / 14 · Статус верификации: FACT — номера строк реально привязаны

В предыдущей главе мы увидели, что асинхронные Mutex и каналы не занимают поток во время ожидания благодаря тому, что Waker сохраняется в очередь ожидания, а после выполнения условия пробуждающий перепланирует задачу. Но всё это работает только при условии, что задача может добровольно уступить поток в состоянии Pending. Как только код вызывает std::fs::read, libsqlite3 или чистый цикл сжатия на CPU, он монополизирует рабочий поток до возврата, и все остальные задачи на этом потоке голодают. Решение Tokio — вынести такую работу в отдельный пул блокирующих потоков и использовать block_on для управления Future вне асинхронного контекста. В этой главе мы разберём обе эти границы.

8.1 Структура памяти пула блокирующих потоков: Inner и очередь с двумя реализациями

Интуитивная модель:spawn_blockingПул потоков подобен «пулу внешних подработчиков» в ресторане. Официанты (рабочие потоки) только принимают заказы и разносят блюда; столкнувшись с блюдом, требующим долгого тушения, они пишут наряд и бросают его в окно выдачи на кухне (очередь), а подработчики (блокирующие потоки) берут наряды из окна. Без этого пула официантам пришлось бы самим вставать к плите, и весь ресторан остановился бы.

Ключевые структуры. Весь пул удерживаетсяBlockingPoolОн хранит только две вещи: клонируемыйSpawner(точка входа для отправки) иshutdown_rx(приёмник сигнала завершения)📎 tokio/src/runtime/blocking/pool.rs:20-23。SpawnerВнутри —Arc<Inner>, все отправители разделяют одно состояние📎 tokio/src/runtime/blocking/pool.rs:26-28。

Inner— это всё состояние пула, и каждое поле заслуживает отдельного рассмотрения📎 tokio/src/runtime/blocking/pool.rs:77-104:

  • inner_impl: InnerImpl: реализация топологии «очередь + уведомление + блокировка» представляет собой перечисление с двумя вариантами —LockedиSharded📎 tokio/src/runtime/blocking/pool.rs:107-110. Это ключевая абстракция главы — она объединяет две топологии, «очередь с единой блокировкой» и «сегментированная очередь», под одним интерфейсом.
  • thread_cap: usize: верхняя граница числа потоков, то естьmax_blocking_threads。
  • scheduler_threads: usize: число рабочих потоков планировщика, используется для вычитания в метриках, чтобыnum_blocking_threadsучитывал только блокирующие потоки📎 tokio/src/runtime/blocking/pool.rs:455-460。
  • keep_alive: Duration: время жизни простаивающего потока, по умолчаниюKEEP_ALIVE = 10s 📎 tokio/src/runtime/blocking/pool.rs:231。
  • metrics: SpawnerMetrics: три атомарных счётчика —num_threads、num_idle_threads、queue_depth 📎 tokio/src/runtime/blocking/pool.rs:31-35。
〔Проектные соображения и архитектурные компромиссы〕

Почему используются атомарные счётчики, а не поля под блокировкой? num_idle_threadsчитается на горячем путиspawn_task(для определения, нужно ли будить простаивающий поток); если бы он находился внутриMutex, при каждой отправке пришлось бы сначала захватывать блокировку, а затем читать. Сделав егоMetricAtomicUsize, путь отправки может выполнить быструю проверку, не удерживая блокировку очереди. Цена — отсутствие атомарной согласованности между этими счётчиками и состоянием очереди, поэтому в коде используется счётчикnum_notifyдля компенсации — см. ниже.

Состояние управления потоками。ThreadManagementStateвынесено отдельно для повторного использования обеими реализациями очереди📎 tokio/src/runtime/blocking/pool.rs:135-150:

  • shutdown: bool: флаг завершения.
  • shutdown_tx: Option<shutdown::Sender>: каждый рабочий поток держит клон; после drop всех клоновshutdown_rxполучает уведомление.
  • last_exiting_thread: Option<JoinHandle<()>>: дескриптор предыдущего потока, завершившегося по тайм-ауту.
  • worker_threads: HashMap<usize, JoinHandle<()>>: дескрипторы всех живых рабочих потоков.
  • worker_thread_index: usize: монотонно возрастающий распределитель ID потоков.

last_exiting_threadМотивация дизайна ясно описана в комментариях: поток, завершившийся по тайм-ауту, выполняет join предыдущего потока, завершившегося по тайм-ауту, чтобы избежать ложных срабатываний Valgrind📎 tokio/src/runtime/blocking/pool.rs:135-150。worker_timed_outИменно это и есть реализация цепочки join — он удаляет свой дескриптор и возвращает старыйlast_exiting_threadвызывающему для выполнения join📎 tokio/src/runtime/blocking/pool.rs:172-178。

Обёртка задачи. В очереди хранитсяTask, который оборачиваетUnownedTask<BlockingSchedule>и флагMandatory📎 tokio/src/runtime/blocking/pool.rs:187-191。Mandatoryопределяет, будет ли задача отброшена или принудительно выполнена при завершении:shutdown_or_run_if_mandatoryприNonMandatoryвызываетshutdown(), приMandatoryвызываетrun() 📎 tokio/src/runtime/blocking/pool.rs:223-228. В этом и заключается разница междуspawn_blocking(непринудительный) иspawn_mandatory_blocking(принудительный, используется для fs)📎 tokio/src/runtime/blocking/pool.rs:233-265。

Структура памяти реализации с единой блокировкой。LockedImpl— это самая примитивная топология: одинMutex<LockedInner>плюс одинCondvar 📎 tokio/src/runtime/blocking/pool.rs:113-116。LockedInnerсодержитVecDeque<Task>、num_notify: u32иthread_mgmt_state 📎 tokio/src/runtime/blocking/pool.rs:118-124. Обратите внимание, чтоnum_notifyиthread_mgmt_stateнаходятся под одной блокировкой, аnum_idle_threads— атомарная величина вне блокировки. Эта гибридная структура, где «часть состояния под блокировкой, часть вне неё», и есть источник всех последующих тонкостей конкурентности.

8.2 Путь отправки: от spawn_blocking до пробуждения потока

Сценарий: асинхронная задача вызываетtokio::task::spawn_blocking(move || heavy_compute(data)), что происходит в этот момент?

Шаг первый: решение об упаковке и создание задачи。Spawner::spawn_blockingсначала измеряет размер замыканияfn_size, затем на основеAutoBox::<F>::SHOULD_BOXрешает, упаковывать ли замыкание вBox📎 tokio/src/runtime/blocking/pool.rs:359-389. Это общая стратегия Tokio «автоматическая упаковка больших Future»: при слишком большом замыкании оно упаковывается, чтобы избежать раздувания структуры задачи.

Войдя вspawn_blocking_inner, сначала выделяется ID задачи, затем с помощьюblocking_taskзамыкание упаковывается в Future, и наконец с помощьюtask::unownedконструируютсяUnownedTaskиJoinHandle 📎 tokio/src/runtime/blocking/pool.rs:440-449. Обратите внимание, что здесь возвращается(JoinHandle<R>, Result<(), SpawnError>)— кортеж из двух элементов: дескриптор и результат отправки возвращаются отдельно.

Шаг второй: три варианта обработки результата отправки. Возвращаясь кspawn_blocking, выполняется сопоставлениеspawn_result📎 tokio/src/runtime/blocking/pool.rs:381-388:

  • Ok(()): нормально — возвращается дескриптор.
  • Err(ShuttingDown):не паниковать, всё равно возвращает дескриптор. Комментарий поясняет, что это сделано для совместимости — дескриптор никогда не будет разрешён, но вызывающая сторона не упадёт из-за того, что runtime завершается.
  • Err(NoThreads(e)): ОС не может создать поток, и никто в пуле не берёт задачу — прямая паника.

Шаг третий: постановка в очередь и решение о пробуждении。spawn_taskпередаётon_no_idleзамыкание вInnerImpl::spawn_task, а конкретная реализация решает, когда его вызвать📎 tokio/src/runtime/blocking/pool.rs:462-506. СмотримLockedImpl::spawn_taskкритическую секцию📎 tokio/src/runtime/blocking/pool.rs:603-639:

rust
let mut locked = self.mutex.lock();

if locked.thread_mgmt_state.shutdown {
    task.task.shutdown();
    return Err(SpawnError::ShuttingDown);
}

locked.queue.push_back(task);
metrics.inc_queue_depth();

if metrics.num_idle_threads() == 0 {
    on_no_idle(&mut locked.thread_mgmt_state)?;
} else {
    metrics.dec_num_idle_threads();
    locked.num_notify += 1;
    self.condvar.notify_one();
}

Здесь два ключевых момента. Во-первых, проверка закрытия выполняется до постановки в очередь, и даже если задачаMandatory, она сразуshutdown()— комментарий объясняет: она была запланирована уже после начала закрытия, поэтому отбрасывание допустимо📎 tokio/src/runtime/blocking/pool.rs:614-620. Во-вторых, решение о пробуждении зависит отnum_idle_threadsвне блокировки: если оно равно 0, вызываетсяon_no_idleдля попытки запустить новый поток; иначе уменьшается счётчик простаивающих, увеличиваетсяnum_notify、notify_one。

num_notifyПочему оно должно существовать?Потому чтоCondvarможет вызывать ложные пробуждения (spurious wakeup). Если использовать толькоnotify_oneбез счётчика, ложно пробуждённый поток ошибочно решит, что есть задача, которую можно взять, обнаружит, что очередь пуста, и снова заснёт, а действительно разбуженный поток может так и не получить уведомление.num_notifyПревращает «легитимное пробуждение» в подсчитываемый токен: отправитель+1, а пробуждаемая сторона только приnum_notify != 0считает пробуждение легитимным и-1 📎 tokio/src/runtime/blocking/pool.rs:674-684。

Шаг четвёртый: запуск нового потока。on_no_idleЗамыкание выполняется при удержании блокировки очереди📎 tokio/src/runtime/blocking/pool.rs:462-506. Сначала оно проверяетnum_threads == thread_cap, и при достижении лимита просто возвращаетOk(())— задача остаётся в очереди и ждёт обработки существующими потоками, это и есть backpressure. Иначе клонируетсяshutdown_tx, вызываетсяspawn_threadдля создания потока, после успеха увеличиваетсяnum_threads, увеличиваетсяworker_thread_index, дескриптор вставляется вworker_threads。

spawn_threadС помощьюthread::Builderзадаются имя потока и размер стека, затем порождается замыкание: вход в контекст runtimert.enter(), вызовinner.run(id), в конце dropshutdown_tx 📎 tokio/src/runtime/blocking/pool.rs:508-528。

Отказоустойчивость при ошибке создания потока ОС。spawn_threadможет завершиться ошибкой. Код классифицирует ошибки📎 tokio/src/runtime/blocking/pool.rs:488-500: если этоWouldBlock(временная ошибка, определяемаяis_temporary_os_thread_error) и в пуле уже есть заблокированные потоки, то📎 tokio/src/runtime/blocking/pool.rs:750-752молча игнорируется— задача в конечном итоге будет взята каким-нибудь текущим занятым потоком. Иначе возвращается, что в итоге приводит к панике.SpawnError::NoThreadsСводка ветвлений решений на пути доставки в виде графа потока управления:

Копировать

mermaid
flowchart TD
    call["Spawner::spawn_blocking(func)"] --> box{"AutoBox::SHOULD_BOX?"}
    box -->|да| boxed["Box::new(func)"]
    box -->|нет| raw["func"]
    boxed --> inner["spawn_blocking_inner"]
    raw --> inner
    inner --> unowned["task::unowned -> Task + JoinHandle"]
    unowned --> spawn_task["InnerImpl::spawn_task"]
    spawn_task --> lock["LockedImpl: mutex.lock()"]
    lock --> shutting{"thread_mgmt_state.shutdown?"}
    shutting -->|да| discard["task.task.shutdown()"]
    discard --> err_sd["Err(ShuttingDown)"]
    shutting -->|нет| push["queue.push_back(task)"]
    push --> idle{"num_idle_threads == 0?"}
    idle -->|да| on_no_idle["on_no_idle(thread_mgmt_state)"]
    on_no_idle --> cap{"num_threads == thread_cap?"}
    cap -->|да| backpressure["возврат Ok, задача остаётся в очереди"]
    cap -->|нет| spawn_th["spawn_thread(shutdown_tx, rt, id)"]
    spawn_th --> th_ok{"spawn успешен?"}
    th_ok -->|да| reg["inc_num_threads, регистрация JoinHandle"]
    th_ok -->|нет| tmp{"WouldBlock и есть потоки?"}
    tmp -->|да| ignore["игнорировать, ждать пока занятый поток заберёт"]
    tmp -->|нет| err_nt["Err(NoThreads)"]
    idle -->|нет| notify["dec_num_idle_threads, num_notify+=1, notify_one"]
    err_sd --> ret["возврат JoinHandle"]
    backpressure --> ret
    reg --> ret
    ignore --> ret
    err_nt --> panic_os["panic: OS не может создать рабочий поток"]

Интуитивная модель

: каждый блокирующий поток — это «дежурный помощник». При наличии заказов он непрерывно работает (BUSY), без заказов дремлет (IDLE), а если дремлет дольше, то уходит с работы (выход по таймауту). Без переработки по таймауту пул навсегда сохранял бы все потоки, созданные на пике, тратя память и накладные расходы планировщика ядра.keep_aliveСтруктура главного цикла

— это цикл。LockedImpl::run_worker, внутри которого попеременно чередуются две фазы: BUSY и IDLE'main. Замечание: здесь BUSY/IDLE — это📎 tokio/src/runtime/blocking/pool.rs:642-735фазывнутри цикла, а не явные состояния перечисления, поэтому ниже они описываются блок-схемой, а не диаграммой состояний.Фаза BUSY

: внутреннийнепрерывно берёт задачиwhile let Some(task) = locked.queue.pop_front(). После получения уменьшается📎 tokio/src/runtime/blocking/pool.rs:655-661drop блокировкиqueue_depth,, выполняется, затем блокировка снова захватывается. Шаг drop блокировки критически важен — блокирующая задача может выполняться очень долго, и её ни в коем случае нельзя выполнять с удержанием блокировки.task.run()Фаза IDLE

: очередь пуста, увеличивается, устанавливаетсяnum_idle_threads, затем начинается цикл ожиданияis_counted_idle = true. В основе лежит📎 tokio/src/runtime/blocking/pool.rs:663-696, после возврата проверяются три вещи:condvar.wait_timeout(locked, keep_alive): легитимное пробуждение. Уменьшается

1. num_notify != 0, устанавливаетсяnum_notify(поскольку отправитель уже уменьшилis_counted_idle = false), break обратно в BUSYnum_idle_threads2. Не закрыто и таймаут: вызывается📎 tokio/src/runtime/blocking/pool.rs:674-684。

для получения дескриптора предыдущего завершившегося потока,worker_timed_outвыход из циклаbreak 'main3. Иначе это ложное пробуждение, продолжаем ждать.📎 tokio/src/runtime/blocking/pool.rs:689-693。

Опустошение очереди при закрытии

. Еслиистинно, входим в логику опустошенияthread_mgmt_state.shutdown: по одному извлекаются задачи, drop блокировки, вызывается📎 tokio/src/runtime/blocking/pool.rs:698-710— необязательные задачи отбрасываются, обязательные выполняются как обычно. Затем break для выхода из главного цикла.task.shutdown_or_run_if_mandatory()Очистка при выходе

. Перед завершением поток уменьшает. Еслиnum_threads 📎 tokio/src/runtime/blocking/pool.rs:714истинно, дополнительно уменьшаетсяis_counted_idle, и с помощьюnum_idle_threadsутверждается отсутствие underflowassert_ne!(prev_idle, 0). Это утверждение — страховка на время отладки: как только учёт📎 tokio/src/runtime/blocking/pool.rs:716-726окажется неверным, здесь немедленно произойдёт паника, а не тихое распространение ошибки.num_idle_threadsНаконец, если идёт закрытие и

(последний поток),num_threads == 0будит возможного инициатора закрытия, ожидающегоnotify_one. Возвращается📎 tokio/src/runtime/blocking/pool.rs:728-730, аjoin_on_threadперед выходом выполняет joinInner::runРукопожатие при закрытии📎 tokio/src/runtime/blocking/pool.rs:755-771。

сначала вызывает。BlockingPool::shutdownдля получения всех дескрипторов workerbegin_shutdownустанавливает флаг закрытия, drop📎 tokio/src/runtime/blocking/pool.rs:310-312。LockedImpl::begin_shutdownбудит все ожидающие потокиshutdown_tx、notify_all. Затем📎 tokio/src/runtime/blocking/pool.rs:740-745блокирующе ожидаетshutdown_rx.wait(timeout)Реализация📎 tokio/src/runtime/blocking/pool.rs:324。

shutdown::Receiver::waitочень продумана📎 tokio/src/runtime/blocking/shutdown.rs:37-70: сначала обрабатываетсяtimeout == 0быстрый путь сразу возвращает false; затем вызываетсяtry_enter_blocking_region()для входа в блокирующую область, и если это не удаётся и в данный момент происходит паника, возвращается false, иначе возникает паника с подсказкой «нельзя drop runtime в асинхронном контексте»📎 tokio/src/runtime/blocking/shutdown.rs:44-57. Наконец, в зависимости от timeout вызываетсяblock_on_timeoutилиblock_onдля управления тем oneshot.

shutdown_txМеханизмArc<oneshot::Sender<()>>таков: каждый поток worker владеет клоном📎 tokio/src/runtime/blocking/shutdown.rs:12-14. После завершения всех потоков все клоны уничтожаются,Arcсчётчик обнуляется,oneshot::Senderуничтожается,Receiverполучает уведомление. Это классический шаблон «после drop всех Sender пробуждается Receiver».

mermaid
sequenceDiagram
    participant App as "Поток приложения (drop Runtime)"
    participant Pool as "BlockingPool::shutdown"
    participant Locked as "LockedImpl"
    participant Worker as "Поток блокирующего worker"
    participant Rx as "shutdown::Receiver"

    App->>Pool: shutdown(timeout)
    Pool->>Locked: begin_shutdown()
    Locked->>Locked: thread_mgmt_state.begin_shutdown() устанавливает shutdown=true, shutdown_tx=None
    Locked->>Worker: condvar.notify_all()
    Locked-->>Pool: Some((last_exited_thread, workers))
    Pool->>Rx: wait(timeout)
    Worker->>Worker: просыпается из wait_timeout, видит shutdown=true
    Worker->>Worker: опустошает очередь shutdown_or_run_if_mandatory()
    Worker->>Worker: dec_num_threads, выход из run_worker
    Worker->>Worker: drop(shutdown_tx) клона
    Worker-->>Rx: последний Sender уничтожен, oneshot завершён
    Rx-->>Pool: возврат true
    Pool->>Worker: join всех дескрипторов worker

8.4 block_on: управление Future в неасинхронном контексте

Интуитивная модель:block_on— это «парадный вход» runtime. Он превращает текущий поток во временный исполнитель, многократно опрашивая переданный Future до завершения. Без негоmainфункция не смогла бы запустить никакой асинхронный код.

Точка входа и упаковка。Runtime::block_onтакже сначала измеряет размер, поSHOULD_BOXрешает, нужно лиBox::pin, затем входит вblock_on_inner 📎 tokio/src/runtime/runtime.rs:343-350。block_on_innerВнутри есть два условно компилируемых trace-обёртки (taskdump и tracing), затемself.enter()входит в контекст runtime, и наконец по типу планировщика выполняется диспетчеризация📎 tokio/src/runtime/runtime.rs:353-383:

rust
let _enter = self.enter();

match &self.scheduler {
    Scheduler::CurrentThread(exec) => exec.block_on(&self.handle.inner, future),
    Scheduler::MultiThread(exec) => exec.block_on(&self.handle.inner, future),
}

Два типа планировщиковblock_onСемантика различается, в документации это чётко сказано📎 tokio/src/runtime/runtime.rs:302-320:

  • Многопоточный планировщик: Future выполняется в контексте драйвера I/O и таймера,block_onпосле возврата уже запущенные задачи продолжают выполняться.
  • Планировщик текущего потока:block_onможет вызываться несколькими потоками одновременно, первый вызывающий получает владение драйвером I/O и таймера, остальные потоки «подключаются» к нему. После завершения первогоblock_onостальные потоки могут «украсть» драйвер.block_onПосле возврата уже запущенные задачи приостанавливаются, повторный вызовblock_onвозобновляет их.

Ключевое ограничение: нельзя вызывать в асинхронном контексте. Документация явно указываетblock_onвызов в асинхронном контексте выполнения приведёт к panic📎 tokio/src/runtime/runtime.rs:321-324. Причина очевидна:block_onблокирует текущий поток до завершения Future, и если текущий поток сам является worker-потоком, это заблокирует весь исполнитель — именно эту проблемуspawn_blockingи призван решить, поэтому они взаимоисключающи.

Путь завершения。Runtime::dropдиспетчеризуется по типу планировщика📎 tokio/src/runtime/runtime.rs:506-521: планировщику текущего потока нужно сначалаtry_set_currentвойти в контекст, затем shutdown (чтобы гарантировать drop задач в контексте выполнения); многопоточный планировщик завершается напрямую (worker-потоки уже находятся в контексте).shutdown_timeoutСначала закрыть планировщик, затем пул блокировок📎 tokio/src/runtime/runtime.rs:457-461,shutdown_backgroundэквивалентноshutdown_timeout(Duration::from_nanos(0)) 📎 tokio/src/runtime/runtime.rs:494-496。

Проектные соображения, восстановление после ошибок и подводные камни в production

Почемуspawn_blockingизShuttingDownне вызывает panic? 📎 tokio/src/runtime/blocking/pool.rs:383-384В комментарии сказано, что это сделано для совместимости.spawn_blockingвозвращаетJoinHandleа неResult, если бы при завершении происходил panic, это превратило бы предсказуемое состояние «runtime завершается» в крах. Возврат дескриптора, который никогда не resolve, приведёт к тому, что вызывающая сторона приawaitбудет вечно висеть — но в этот момент runtime уже закрыт, весьblock_onтакже завершится, так что фактической утечки не будет.

max_blocking_threadsСемантика backpressure. Значение по умолчанию очень велико (512), потому чтоspawn_blockingчасто используется для файлового I/O. Но документация предупреждает: при выполнении CPU-интенсивных задач нужно использовать семафор для ограничения параллелизма, иначе будет создано множество потоков📎 tokio/src/task/blocking.rs:94-100. После достижения лимита задачи встают в очередь, формируя backpressure — но обратите внимание, что этот backpressure действует только на пул блокировок и не передаётся обратно в асинхронный планировщик.

spawn_blockingНельзя отменить. Документация явно указывает:abortне действует на уже запущенные блокирующие задачи, задача продолжит выполняться до конца📎 tokio/src/task/blocking.rs:106-120. Только ещё не начатые задачи могут быть остановлены через abort. При завершении runtime будет ждать все уже начатые блокирующие задачи,shutdown_timeoutпо истечении таймаута эти потоки будут утечены.

num_idle_threadsЛовушка учёта。is_counted_idleНаличие флагаnum_idle_threadsуказывает на то, что этот счётчик легко может дать сбой. Отправитель при пробуждении уменьшаетnum_notify != 0, пробуждаемый, увидевis_counted_idle = false, устанавливает📎 tokio/src/runtime/blocking/pool.rs:679-682, избегая повторного уменьшенияassert_ne!(prev_idle, 0). Если на этом пути есть баг,📎 tokio/src/runtime/blocking/pool.rs:722-725при выходе вызовет panicnum_idle_threads. Если в production вы видите «

underflowed on thread exit», это означает, что логика учёта пула нарушена.

last_exiting_thread〔Проектные выводы и архитектурные компромиссы〕Цена цепочки join📎 tokio/src/runtime/blocking/pool.rs:172-178. Поток, завершившийся по таймауту, присоединяется к предыдущему потоку, завершившемуся по таймауту

InnerImpl. Это образует цепочку join: каждый завершающийся поток должен дождаться реального завершения предыдущего. В сценариях с высокой частотой создания/уничтожения блокирующих потоков эта цепочка может удлиняться, вызывая накопление задержки завершения потоков. Это компромисс, сделанный для избежания ложных срабатываний Valgrind, в обычных production-условиях влияние ограничено, но при нагрузках с частыми таймаутами потоков заслуживает внимания.Смысл абстракции перечисленияLocked. В комментарии указано, что поведение вариантаShardedполностью совпадает с поведением до рефакторинга, а вариант📎 tokio/src/runtime/blocking/pool.rs:537-539。spawn_task、run_worker、begin_shutdownзарезервировал симметричный слот для будущей конкурентной очереди📎 tokio/src/runtime/blocking/pool.rs:548-582все три метода диспетчеризуются через перечисление

. Такая архитектура «диспетчеризация через перечисление + собственная критическая секция для каждого варианта» позволяет добавлять новые топологии очередей без изменения вызывающей стороны.

Итоги главыspawn_blockingВ этой главе разобраны две границы, через которые Tokio вмещает синхронный код.Innerдоставляет замыкания в отдельный пул блокирующих потоков:LockedImplвладеет очередью, лимитом потоков, временем жизни и атомарными метриками;Condvarиспользует одну блокировку +num_notifyдля реализации очереди,max_blocking_threadsсчётчик компенсирует ложные пробуждения; worker циклически переходит между BUSY/IDLE, после таймаута простоя завершается через цепочку join;block_onпосле достижения лимита задачи встают в очередь, формируя backpressure.shutdown_txже управляет Future в неасинхронном контексте, семантика многопоточного и текущего потокового планировщиков различается, и категорически запрещено вызывать его в асинхронном контексте. Путь завершения черезArcобнуление счётчикаoneshotзапускает

, реализуя рукопожатие «пробудить инициатора завершения после выхода всех worker-потоков».

Вопросы для размышления и самопроверки к главеLockedImpl::spawn_taskQ1: Если вif metrics.num_idle_threads() == 0изменить проверкуon_no_idleна всегда истинную (то есть каждый раз вызывать

), что произойдёт в сценарии с высокой конкурентностью доставки? Почему?:on_no_idleЭталонный разборnum_threads == thread_capпроверяет📎 tokio/src/runtime/blocking/pool.rs:471-487, и если лимит не достигнут, создаёт новый потокthread_cap. Если проверка всегда истинна, даже при наличии свободных потоков будет предпринята попытка запустить новый поток, что приведёт к быстрому достижениюnotify_one. Что ещё серьёзнее, свободные потоки не будут разбужены черезon_no_idle(поскольку пойдёт по веткеelse, а не по веткеnum_notify += 1; notify_one 📎 tokio/src/runtime/blocking/pool.rs:627-636), и задачи в очереди могут остаться без обработки, пока какой-нибудь новый поток не запустится и не обнаружит, что очередь не пуста. Это создаст состояние ложного зависания «потоки переполнены, но задачи всё ещё в очереди». Смысл исходной проверки как раз в том, чтобы при наличии свободных потоков в первую очередь разбудить их, избегая бессмысленного создания потоков.

Q2: LockedImpl::run_workerНа этапе BUSY перед выполнениемtask.run()происходитdrop(locked) 📎 tokio/src/runtime/blocking/pool.rs:657-658. Если убрать этотdrop, в каком сценарии возникнет дедлок?

Эталонный разбор:task.run()выполняет пользовательское замыкание, и внутри замыкания вполне может снова вызватьсяspawn_blockingдля доставки новой задачи. Путь доставкиLockedImpl::spawn_taskпервым делом делаетself.mutex.lock() 📎 tokio/src/runtime/blocking/pool.rs:612. Если worker удерживает блокировку при выполнении замыкания, доставка внутри замыкания попытается захватить ту же блокировку, иstd::sync::MutexНе реентерабельно, приводит к прямой взаимоблокировке. Кроме того, выполнение длительной задачи с удержанием блокировки заблокирует все операции извлечения задач другими отправителями и worker'ами; даже если взаимоблокировки не произойдёт, весь пул станет последовательным.drop(locked)Обязательно.

Q3: shutdown::Receiver::waitВtry_enter_blocking_region()возвращает false при неудаче и текущей панике, иначе паникует📎 tokio/src/runtime/blocking/shutdown.rs:44-57. Почему нужна специальная обработка при панике? Если убрать эту ветвь, в каких сценариях возникнут проблемы?

Справочный разбор:try_enter_blocking_regionНеудача означает, что текущий контекст асинхронный и блокировка недопустима. В нормальной ситуации следует паниковать, сообщая пользователю «нельзя выполнять drop runtime в асинхронном контексте». Но если текущий поток уже находится в состоянии паники (std::thread::panicking()истинно), повторная паника приведёт к двойной панике, и поведение Rust по умолчанию — немедленный abort процесса. Сценарий: пользователь выполняет drop Runtime внутри асинхронной задачи, а сама задача по другой причине уже паникует; тогда shutdown, вызванный drop, вызовет вторичную панику. Возврат false заставляет shutdown отказаться от ожидания, предотвращая abort процесса и сохраняя пользователю возможность увидеть исходную информацию о панике. Это типичная обработка «panic safety».

Блокирующий пул потоков и block_on определяют границы возможностей асинхронного runtime: первый изолирует работу, которая не может уступить поток, в выделенные потоки, второй позволяет неасинхронным точкам входа управлять Future. Но эти две границы в коде часто не пишутся вручную — в следующей главе мы войдём в мир макросов и посмотрим, как #[tokio::main], select! и join! генерируют этот runtime-код на этапе компиляции.

CHAPTER 09

Глава 9: Магия макросов: кодогенерация за #[tokio::main], select! и join!

Проект: tokio-rs/tokio · Прогресс книги: Глава 9 / 14 · Статус проверки: FACT — реальная привязка номеров строк

В предыдущей главе мы увидели,block_onи как блокирующий пул потоков определяет границы возможностей асинхронного runtime, причём пользователи почти никогда не пишут эти границы вручную — они пишут#[tokio::main]、select!、join!, позволяя макросу развернуть этот шаблонный код на этапе компиляции. Макросы — первый слой синтаксического сахара, который Tokio даёт пользователю, и именно там на этапе компиляции действительно генерируется runtime-код. Эта глава сосредоточена наtokio-macroscrate иtokio/src/macros/select.rs, разбирает три наиболее часто используемых пути раскрытия макросов и отвечает главным образом на один вопрос: как выглядит реальная цепочка вызовов после раскрытия макроса и почемуselect!семантику cancel safety необходимо отдельно учитывать.

9.1 #[tokio::main]: переписывание async fn в Runtime::block_on

Интуитивная модель:#[tokio::main]Это как «договор на ремонт». Вы сдаёте черновую квартиру (async fn main), а он прокладывает вам водопровод и электрику (создаёт Runtime), устанавливает двери и окна (enable_all), а затем вносит вашу прежнюю мебель (тело функции). Без него каждыйmainпришлось бы писать вручнуюBuilder::new_multi_thread().enable_all().build().unwrap().block_on(...), и шаблонный код затопил бы бизнес-логику.

Структуры данных и размещение в памяти

Сам макрос не создаёт runtime-структуры данных, но разобранная им конфигурация помещается в две структуры.Configuration— это «изменяемый аккумулятор на этапе разбора», все поля которого имеют типOption, потому что параметры атрибута могут отсутствовать, повторяться или быть недопустимыми📎 tokio-macros/src/entry.rs:74-84. Обратите внимание, чтоworker_threads、start_paused、unhandled_panicоба имеютSpan— это сделано для того, чтобы при ошибке указать на строку, написанную пользователем, а не внутри макроса📎 tokio-macros/src/entry.rs:74-84。FinalConfigже — это «неизменяемый результат после проверки»,flavorбольше неOption, потому чтоbuild()уже используетdefault_flavorдля подстраховки📎 tokio-macros/src/entry.rs:55-62。

RuntimeFlavorимеет только три варианта:CurrentThread、Threaded、Local 📎 tokio-macros/src/entry.rs:10-14。from_strспециально даёт дружелюбные ошибки для исторически унаследованных имён:single_threadподсказывает, что следует называтьcurrent_thread,basic_schedulerподсказывает, что имя изменено,threaded_schedulerподсказывает, что имя изменено на📎 tokio-macros/src/entry.rs:17-27. Это типичный дизайн макроса как «первой точки контакта с пользователем»: сообщение об ошибке — это документация.

Пошаговый процесс раскрытия

Подставим сценарий: пользователь пишет#[tokio::main(flavor = "multi_thread", worker_threads = 4)] async fn main() { ... }。

Первый шаг,mainточка входа сначала разбирает item в собственныйItemFn 📎 tokio-macros/src/entry.rs:577-580. ЭтотItemFn— неsyn::ItemFn, а собственный парсер Tokio; причина указана в комментарии: он не хочет рекурсивно разбирать всё выражение, а выполняет «лёгкий разбор с буферизацией по token tree и разбиением по точке с запятой»📎 tokio-macros/src/entry.rs:720-764. Это позволяет избежать накладных расходов на полное построение AST тела функции внутри макроса.

Второй шаг,build_configпроверяет, присутствует ли ключевое словоasync, и при отсутствии сообщает "theasync keyword is missing" 📎 tokio-macros/src/entry.rs:346-349. Затем перебирает параметры атрибута, направляяworker_threads、flavor、start_paused、crate、unhandled_panic、nameв соответствующий setter📎 tokio-macros/src/entry.rs:369-399. Обратите внимание, чтоcore_threadsявно отклоняется с подсказкой, что имя изменено на📎 tokio-macros/src/entry.rs:379-382。

Третий шаг,Configuration::buildвыполняет проверку согласованности между полями. Здесь есть три ключевых ограничения:worker_threadsдопускает толькоmulti_thread 📎 tokio-macros/src/entry.rs:197-217;start_pausedдопускает толькоcurrent_thread/local 📎 tokio-macros/src/entry.rs:219-229;unhandled_panicтакже допускает толькоcurrent_thread/local 📎 tokio-macros/src/entry.rs:231-241. Если пользователь выбралmulti_thread, ноrt-multi-threadfeature не включён, сообщение об ошибке будет различаться в зависимости от того, указан ли flavor явно📎 tokio-macros/src/entry.rs:209-216。

Четвёртый шаг,parse_knobsгенерирует код. Сначала он стираетasyncness 📎 tokio-macros/src/entry.rs:441, затем в зависимости от flavor выбирает начальную точку builder:CurrentThread/LocalиспользуетBuilder::new_current_thread(),ThreadedиспользуетBuilder::new_multi_thread() 📎 tokio-macros/src/entry.rs:468-477。Localособенность в том, что вызов build являетсяbuild_local(Default::default()), а неbuild() 📎 tokio-macros/src/entry.rs:479-483. Затем по мере необходимости цепочно добавляет.worker_threads(#v)、.start_paused(#v)、.unhandled_panic(...)、.name(#v) 📎 tokio-macros/src/entry.rs:485-497。

Пятый шаг — генерация окончательного тела функции. В основе лежитlast_block:return #rt.enable_all().#build.expect("Failed building the Runtime").block_on(body) 📎 tokio-macros/src/entry.rs:509-522. Обратите внимание на явныйreturn, комментарий указывает на tokio-rs/tokio#4636, это сделано для исправления проблемы вывода типов📎 tokio-macros/src/entry.rs:508。

Шестой шаг — тело функции оборачивается вasync #bodyи проходит проверку типов. На не-test пути, если возвращаемый тип не!и не содержитimpl Trait, вставляетсяif false { let _: &dyn Future<Output = #output_type> = &body; }для проверки на этапе компиляции📎 tokio-macros/src/entry.rs:551-571. На test-пути используетсяpin!Закрепить body на стеке и преобразовать вPin<&mut dyn Future>, комментарий объясняет, что это делается для уменьшенияblock_onзатрат на компиляцию при мономорфизации обобщений📎 tokio-macros/src/entry.rs:526-548。

mermaid
flowchart TD
    entry["main(args, item)"] --> parse_item{"syn::parse2(item) успешен?"}
    parse_item -->|нет| err_ret["token_stream_with_error возвращает исходный item + ошибка компиляции"]
    parse_item -->|да| check_main{"ident == main и есть параметры?"}
    check_main -->|да| err_args["ошибка: main не может принимать параметры"]
    check_main -->|нет| parse_args["AttributeArgs::parse_terminated"]
    parse_args --> build_cfg["build_config проверяет async и поля"]
    build_cfg --> cfg_ok{"config собран успешно?"}
    cfg_ok -->|нет| fallback["parse_knobs(DEFAULT_ERROR_CONFIG) + ошибка"]
    cfg_ok -->|да| knobs["parse_knobs генерирует цепочку Builder + block_on"]
    knobs --> out["вывод синхронной fn main"]

Размышления о дизайне и подводные камни в продакшене

mainиtestсовместно используютparse_knobs, но flavor по умолчанию различается:testпо умолчаниюCurrentThread,mainпо умолчаниюThreaded 📎 tokio-macros/src/entry.rs:91-94. Это объясняет, почему#[tokio::test]по умолчанию однопоточный — тестам обычно не нужны многоядерные процессоры, и в однопоточном режиме легче воспроизвести проблему.

Легко упускаемый подводный камень: после раскрытия макроса каждый вызов функции создаёт новый Runtime. Документация явно предупреждает, что если функция вызывается часто, следует использовать Builder для переиспользования Runtime📎 tokio-macros/src/lib.rs:31-35. Использование#[tokio::main]на обычной функции допустимо, но каждый вызов оплачивает стоимость создания Runtime.

Другой подводный камень —crateпереименование. Когда пользовательuse tokio as tokio1, сгенерированный внутри макроса по умолчаниюtokio::runtime::Builderне сможет найти путь, необходимо явноcrate = "tokio1" 📎 tokio-macros/src/lib.rs:239-264。parse_knobsвcrate_pathзначение по умолчанию —Ident::new("tokio", ...) 📎 tokio-macros/src/entry.rs:456-462, и именно это является источником ошибок в сценариях переименования.

9.2 select!: многовариантный опрос, битовая маска и случайная справедливость

Интуитивная модель:select!похож на «официанта, который одновременно следит за несколькими окнами выдачи заказов». Из какого окна блюдо появится первым, то он и заберёт, а очереди в остальных окнах аннулируются. Без него пользователю пришлось бы вручную писатьpoll_fnчтобы запихнуть несколько Future в кортеж и опрашивать их по одному, а также самостоятельно обрабатывать логику «после готовности одной ветки остальные ветки должны быть отброшены».

Структура данных и размещение в памяти

select!после раскрытия генерирует локальный модуль__tokio_select_util, внутри которого есть перечислениеOutи псевдоним типаMask 📎 tokio/src/macros/select.rs:615-619。Outимена вариантов —_0、_1……по одному на каждую ветку, плюсDisabledобозначающий, что все ветки недействительны📎 tokio-macros/src/select.rs:33-39。Maskбазовый тип динамически выбирается по количеству веток: ≤8 используетсяu8, ≤16 используетсяu16, ≤32 используетсяu32, ≤64 используетсяu64, более 64 — сразу panic📎 tokio-macros/src/select.rs:17-31. Эта битовая маска —select!ключевое состояние: если i-й бит равен 1, значит i-я ветка отключена.

Все Future сохраняются в кортежfutures, каждый элемент сначала проходит черезIntoFuture::into_futureпреобразование📎 tokio/src/macros/select.rs:654-656. Обратите внимание, что здесь сначала конструируетсяfutures_initа затем поочерёдноinto_future, комментарий объясняет, что это делается для использования продления времени жизни временных значений📎 tokio/src/macros/select.rs:641-646. Затемlet mut futures = &mut futures;понижает кортеж до изменяемой ссылки, чтобы избежатьpoll_fnзахвата владения замыканием📎 tokio/src/macros/select.rs:658-662。

Пошаговый процесс опроса

Подставим сценарий:select! { v = stream1.next() => ..., v = stream2.next() => ..., else => break }。

Первый шаг — сопоставление правил входа макроса. Если есть префиксbiased;,start=0 📎 tokio/src/macros/select.rs:801-803; иначеstart— это случайное выражениеthread_rng_n(BRANCHES) 📎 tokio/src/macros/select.rs:805-809. Это и есть источник справедливости, о котором говорит документация: «по умолчанию случайно выбирается ветка для первой проверки»📎 tokio/src/macros/select.rs:61-65。

Второй шаг — нормализация. tt-muncher приводит каждую ветку к форме(skip) pat = fut, if cond => handler,,skip— это последовательность_, длина которой равна количеству branch перед данной веткой📎 tokio/src/macros/select.rs:770-793。skipиспользуется как для генерации доступа к полям кортежаfutures_init.$($skip)*, так и дляcount!вычисления индекса ветки.

Третий шаг — вычисление предусловий. Для каждогоif $cветки, если false, тоdisabled |= 1 << index 📎 tokio/src/macros/select.rs:631-636. Обратите внимание: даже если ветка отключена, её$futвыражение всё равно вычисляется, просто не будет опрошено📎 tokio/src/macros/select.rs:39-41。

Четвёртый шаг — вход в замыканиеpoll_fn. Сначала проверяется бюджет кооперации:ready!(poll_budget_available(cx)), при исчерпании бюджета сразу возвращаетсяPending 📎 tokio/src/macros/select.rs:664-667. Это гарантирует, чтоselect!не монополизирует worker.

Пятый шаг — циклfor i in 0..BRANCHES,branch = (start + i) % BRANCHES 📎 tokio/src/macros/select.rs:680-685. Для каждой branch: сначала проверяетсяdisabled & mask == mask, если отключена, тоcontinue 📎 tokio/src/macros/select.rs:694-699; иначе из кортежа извлекается соответствующий Future и оборачивается вPin::new_unchecked(безопасность зависит от того, что Future хранится на стеке и не перемещается)📎 tokio/src/macros/select.rs:701-707; выполняется poll,Ready(out)тогда сначалаdisabled |= maskзатем сопоставление с шаблоном📎 tokio/src/macros/select.rs:710-730。

Шестой шаг — сопоставление с шаблоном. Еслиoutсовпадает с$bind, возвращаетсяPoll::Ready(Out::_i(out)) 📎 tokio/src/macros/select.rs:727-733; если не совпадает,continueпродолжается опрос других веток — именно это документация называет в шаге 5: «при несовпадении шаблона текущая ветка отключается»📎 tokio/src/macros/select.rs:44-47。

Седьмой шаг — завершение цикла. Еслиis_pendingистинно, возвращаетсяPending, иначе все ветки недействительны, возвращаетсяOut::Disabled 📎 tokio/src/macros/select.rs:740-745. Внешнийmatch outputотображаетOut::_iна соответствующий handler,Disabledотображается наelseвыражение📎 tokio/src/macros/select.rs:749-755。

mermaid
flowchart TD
    start["замыкание poll_fn вызвано"] --> budget{"poll_budget_available(cx)?"}
    budget -->|нет| pending_budget["возврат Pending"]
    budget -->|да| init["is_pending = false; start = $start"]
    init --> loop{"i < BRANCHES?"}
    loop -->|нет| check_pending{"is_pending?"}
    check_pending -->|да| pending["возврат Pending"]
    check_pending -->|нет| disabled_out["возврат Out::Disabled"]
    loop -->|да| branch["branch = (start+i) % BRANCHES"]
    branch --> is_disabled{"disabled & mask == mask?"}
    is_disabled -->|да| next_i["i += 1"]
    is_disabled -->|нет| poll_fut["Pin::new_unchecked(fut).poll(cx)"]
    poll_fut --> poll_res{"результат Poll?"}
    poll_res -->|Pending| set_pending["is_pending = true; i += 1"]
    poll_res -->|Ready| disable["disabled |= mask"]
    disable --> pat_match{"out соответствует $bind?"}
    pat_match -->|нет| next_i
    pat_match -->|да| ready_out["возврат Out::_i(out)"]
    next_i --> loop
    set_pending --> loop

Размышления о дизайне и подводные камни в продакшене

Почему используется битовая маска, а неVec<bool>?битовая маска — это одно целое число на стеке, без выделения в куче, иdisabled |= mask— это одна инструкция. Дляselect!на горячем пути это позволяет избежать обращения к куче на каждой итерации.

Почему при несовпадении шаблона ветка отключается?Это ключевое отличиеselect!от «простого race». РассмотримSome(v) = stream.next() => ..., еслиstream.next()возвращаетNone(конец потока), шаблон не совпадает, эта ветка навсегда отключается, что позволяет избежать бесконечного опроса завершённого потока. Пример из документации как раз опирается на эту семантику для сбора двух потоков до их завершения📎 tokio/src/macros/select.rs:198-223。

Истинное значение cancel safety:select!Как только одна ветка готова, Future остальных веток будут drop. Если drop-аемый Future уже потребил данные, но ещё не вернул результат, данные теряются. Документация явно перечисляетread_exact、read_to_end、write_allкак не cancel safe📎 tokio/src/macros/select.rs:119-124, аMutex::lock、Semaphore::acquireиз-за справедливости очереди при отмене теряет позицию в очереди📎 tokio/src/macros/select.rs:126-133. Метод определения: найти.awaitточку, если перезапуск функции в.awaitвсё ещё корректен, то cancel safe📎 tokio/src/macros/select.rs:135-139。

ifЛовушка гонки в предусловиях: документация приводит классический пример ошибки — использованиеif !sleep.is_elapsed()для защиты веткиsleep, ноis_elapsed()может стать true между проверкойwhileиselect!, что приводит к пропуску таймаута📎 tokio/src/macros/select.rs:336-376. Правильный вариант — убратьif, позволить веткеsleepвсегда участвовать в опросе, а после таймаутаbreak 📎 tokio/src/macros/select.rs:378-405。

biased;стоимость: случайный RNG имеет затраты CPU, и в некоторых сценариях требуется детерминированный порядок опроса📎 tokio/src/macros/select.rs:67-74. Ноbiased;перекладывает ответственность за справедливость на пользователя: если одна ветка всегда готова, последующие ветки будут голодать📎 tokio/src/macros/select.rs:75-81。

9.3 join! и инженерные ограничения раскрытия макросов

Интуитивная модель:join!похож на «одновременное ожидание прибытия всех посылок». Он, в отличие отselect!, не отменяет остальные при первой готовности, а агрегируетReadyзначения всех Future в кортеж. Без него пользователю пришлось бы вручную писатьpoll_fnдля поддержания состояния завершения каждого Future.

Структура данных и размещение в памяти

join!раскрытие также основано на хранении Future в кортеже, но состояние — не битовая маска, а кортеж «завершённых значений». После завершения каждого Future его значение извлекается и сохраняется в результирующий кортеж, а соответствующий слот помечается как завершённый. В отличие отselect!,join!не выполняет drop незавершённых Future — он должен дождаться завершения всех Future, прежде чем вернуть результат.

Пошаговый процесс

join!логика опроса разделяет сselect!каркас «кортеж хранит Future +poll_fnуправляет», но семантика противоположна:select!— «возврат при готовности любого»,join!— «возврат только при готовности всех». На каждой итерации poll перебираются все незавершённые Future; если любой возвращаетPending, то весьPending; если всеReady, то агрегированный возврат.

mermaid
flowchart LR
    subgraph input["Вход"]
        f1["Future A"]
        f2["Future B"]
        f3["Future C"]
    end
    subgraph poll["управление poll_fn"]
        tuple["кортеж (A, B, C)"]
        state["кортеж состояний завершения"]
    end
    subgraph output["Выход"]
        result["(A::Output, B::Output, C::Output)"]
    end
    f1 --> tuple
    f2 --> tuple
    f3 --> tuple
    tuple --> state
    state -->|"все Ready"| result
    state -->|"любой Pending"| pending["возврат Pending"]

Размышления о дизайне и подводные камни в продакшене

join!семантика безопасности отмены отличается отselect!:join!при drop все незавершённые Future также будут drop'нуты, что тоже может привести к потере данных. Но посколькуjoin!не отменяет активно ни одну из ветвей, он не ведёт себя какselect!— «отмена этой ветви из-за готовности другой ветви». Реальный риск заключается в том, чтоjoin!целиком отменяется внешнимselect!или таймаутом.

join!иtry_join!различие заслуживает внимания:try_join!немедленно возвращает управление, когда любой Future возвращаетErr, отменяя остальные Future, поэтому он наследуетselect!риск безопасности отмены.

Размышления о дизайне

Границы макроса как генератора кода на этапе компиляции。#[tokio::main]проверка конфигурации выполняется на этапе компиляции; недопустимые комбинации (например,multi_thread + start_paused) приводят к ошибке компиляции, а не к panic во время выполнения. Это ключевое преимущество макроса перед Builder: ошибки выявляются раньше.

Гибридная архитектура: декларативный макрос + процедурный макрос。select!основная часть —macro_rules!, но две ключевые логики делегированы процедурным макросам:select_priv_declare_output_enumгенерируетOutперечисление иMaskтип📎 tokio-macros/src/lib.rs:658-660,select_priv_clean_patternочистка в шаблонеref/mut 📎 tokio-macros/src/lib.rs:666-668. Почему? В комментариях объясняется: декларативным макросам сложно генерировать код, «динамически выбирающий целочисленный тип в зависимости от количества ветвей», а также сложно выполнять очистку на уровне токенов в позиции шаблона📎 tokio/src/macros/select.rs:577-579。

clean_patternнеобходимость。select!сопоставляетoutв форме&outс шаблоном📎 tokio/src/macros/select.rs:727; если пользователь напишетref v, это превратится в&ref vи вызовет ошибку типа.clean_patternрекурсивно удаляетby_ref、mutability, а такжеReferenceшаблонаmutability 📎 tokio-macros/src/select.rs:68-73📎 tokio-macros/src/select.rs:100-103. Это компромисс макроса между «интуицией пользователя» и «проверкой заимствований».

Инженерная реальность ограничения в 64 ветви。count!、count_field!、select_variant!три макроса вручную прописывают правила сопоставления от 0 до 64📎 tokio/src/macros/select.rs:821-1017📎 tokio/src/macros/select.rs:1021-1217📎 tokio/src/macros/select.rs:1221-1414. В комментариях прямо сказано «I'm not happy about it either»📎 tokio/src/macros/select.rs:816-817. Это цена того, что декларативные макросы не умеют выполнять арифметику: приходится жёстко кодировать отображение количества токенов в целое число.

Итоги главы

Вопросы для размышления и самопроверки к этой главе

Q1: select!изdisabledбитовая маска при каждом входе вselect!повторно инициализируется вDefault::default() 📎 tokio/src/macros/select.rs:627. Что произойдёт, если переместить эту строку внутрь замыканияpoll_fnв сценарии «циклический вызов select! и несовпадение шаблона одной из ветвей»?

Эталонный разбор:disabledЕсли инициализировать внутри замыкания, то при каждом poll состояние будет сбрасываться, что приведёт к повторному участию в опросе ветви, отключённой в предыдущей итерации из-за несовпадения шаблона. РассмотримSome(v) = stream.next() => ...иstreamуже завершён (возвращаетNone); после несовпадения шаблона эта ветвь должна быть навсегда отключена. Еслиdisabledсбрасывается, следующий poll снова опросит этот завершённый поток; если поток не fused (то есть повторный poll после завершения может вызвать panic или вернуть неопределённое поведение), возникнет проблема. Даже если поток fused, это будет напрасной тратой CPU на повторный poll потока, который всегда возвращаетNone. В документации явно сказано «Re-entering select! due to a loop clears the disabled state»📎 tokio/src/macros/select.rs:37-38, что означает повторный вход вselect!макрос (новый виток цикла), а не многократный poll внутри одногоselect!.disabledдолжен инициализироваться вне замыкания, чтобы сохранять состояние между несколькими poll в рамках одного вызоваselect!.

Q2: select!после poll доReady(out)сначала выполняетdisabled |= mask, а затем сопоставляет с шаблоном📎 tokio/src/macros/select.rs:720-730. Что произойдёт, если убратьdisabled |= mask, в сценарии, когда шаблон не совпадает и этот Future при каждом poll немедленно возвращаетReady?

Эталонный разбор: после удаленияdisabled |= mask, еслиoutне совпадает с$bind, код переходит кcontinueи продолжает опрашивать другие ветви. Но при следующем вызовеpoll_fn(например, после того как другая ветвь вернулаPendingи произошёл повторный poll) эта ветвь всё ещё не отключена и будет опрошена снова. Если этот Future при каждом poll немедленно возвращаетReadyи значение не совпадает с шаблоном, возникает livelock: «poll -> Ready -> несовпадение -> continue -> другие ветви Pending -> возврат Pending -> снова poll -> снова Ready -> ...», CPU простаивает вхолостую.disabled |= maskустанавливается сразу послеReady, гарантируя, что даже при несовпадении шаблона эта ветвь не будет опрошена повторно. Обратите внимание, что установка происходит до сопоставления с шаблоном, поэтому оба случая — «Ready, но шаблон не совпадает» и «Ready и шаблон совпадает» — отключают эту ветвь: первый предотвращает livelock, второй — повторное потребление.

Q3: parse_knobsв не-test пути вставляетсяif false { let _: &dyn Future<Output = #output_type> = &body; }для проверки типов📎 tokio-macros/src/entry.rs:557-561, но для типов, возвращающих!или содержащихimpl Trait, проверка пропускается📎 tokio-macros/src/entry.rs:551-556. Почемуimpl Traitнужно пропускать? Что произойдёт при принудительной проверке?

Эталонный разбор:impl Traitв позиции возврата — «непрозрачный тип»; компилятор не позволяет привести его к&dyn Future<Output = impl Trait>, посколькуdynтребует конкретного типа, аimpl Traitконкретный тип не виден за пределами функции. Если принудительно вставить проверку, возникнет ошибка «the size for values of typeimpl Futurecannot be known at compilation time» или «cannot be made into an object». Возвращаемый!тип аналогично:!можно привести к любому типу, но&dyn Future<Output = !>сам по себеOutput = !может вызвать проблему с нестабильной особенностью never type. Цена пропуска проверки: если пользователь написалasync fn main() -> impl Traitно фактический возвращаемый тип не соответствуетimpl Traitошибка проявится только вblock_onи сообщение об ошибке может быть менее ясным, чем при явной проверке. Это компромисс между «полнотой проверок на этапе компиляции» и «ограничениями системы типов».

Макрос берёт на себя шаблонный код и проверки на этапе компиляции, но генерирует всё те же обычные Future и вызовыpollВ следующей главе мы покинем мир макросов на этапе компиляции и перейдём на уровень абстракции I/O времени выполнения, чтобы посмотреть, какAsyncRead/AsyncWriteразбивает поток байтов на кадры и какFramedфреймворк кодеков корректно работает в условиях ограничений безопасности отменыselect!

#[tokio::main]по сути представляет собой «разбор конфигурации + генерацию цепочки Builder +block_onобёртку», проверка конфигурации выполняется на этапе компиляции, flavor определяет начальную точку builder и метод build.select!В основе лежит «хранение Future в кортеже + битовая маска для отметки отключённых + случайная начальная точка для справедливости»: при несовпадении шаблона ветвь отключается, безопасность отмены зависит от того, можно ли перезапустить отброшенный Future в.awaitjoin!иselect!разделяют общий каркас, но имеют противоположную семантику: первый ждёт завершения всех, второй возвращается при готовности любого. Все три вместе демонстрируют ключевой компромисс в дизайне макросов Tokio: шаблонный код и проверки на этапе компиляции передаются макросу, а сложность семантики времени выполнения (особенно безопасность отмены) остаётся на явное понимание пользователя. Поняв, как макросы генерируют код времени выполнения, естественно задаться вопросом: какие абстракции предоставляет Tokio, когда этот код действительно начинает читать и записывать потоки байтов? В главе 10 мы разберёмAsyncRead/AsyncWriteи фреймворк кодеков, посмотрим, какBufReader/BufWriterсокращает системные вызовы,copy_bidirectionalуправляет двунаправленной передачей,Framedразбивает поток байтов на кадры, и тем самым ответим на вопрос «где проходят границы абстракции асинхронного I/O».

CHAPTER 10

Глава 10: Абстракции потокового I/O: AsyncRead, AsyncWrite и фреймворк кодеков

Проект: tokio-rs/tokio · Прогресс книги: Глава 10 / 14 · Статус проверки: строки FACT реально привязаны

В предыдущей главе мы разобрали процесс раскрытия tokio-macros и увидели, как #[tokio::main], select!, join! берут на себя шаблонный код и проверки на этапе компиляции. Но макросы всё равно генерируют обычные Future и вызовы poll — когда эти Future действительно начинают читать и записывать байты, Tokio предоставляет лишь две низкоуровневые абстракции-трейта: AsyncRead и AsyncWrite. Их проблема в том, что они «слишком низкоуровневые»: один poll_read гарантирует лишь «прочитано сколько-то байтов», но не «прочитано целое сообщение». А подавляющее большинство протоколов (HTTP, Redis, gRPC, пользовательские RPC) ориентированы на «кадры», а не на «поток байтов». Ключевой вопрос этой главы: где следует провести границу абстракции асинхронного I/O? Ответ Tokio состоит из двух уровней: tokio::io предоставляет трейты и инструменты уровня потока байтов (BufReader/BufWriter/copy_bidirectional), а фреймворк codec в tokio-util поверх этого предоставляет адаптацию Stream/Sink уровня кадров (Framed/LengthDelimitedCodec). Поняв разделение труда этих двух уровней, вы поймёте, «почему реализации протоколов почти всегда начинаются с Framed».

I. AsyncRead/AsyncWrite: почему нельзя напрямую переиспользовать std::io::Read

Интуитивная модель

std::io::Read::read— это «блокирующее получение товара»: вы стоите у окна и ждёте, пока товар не прибудет, поток приостанавливается.AsyncRead::poll_read— это «получение по талону»: вы спрашиваете «готово?», если нет (Poll::Pending), то идёте заниматься другими делами, оставив Waker, чтобы система разбудила вас, когда товар будет готов. Без этого трейта весь асинхронный I/O пришлось бы писать вручную —epollрегистрация и отображение Waker — именно это делает Reactor из главы 5, аAsyncRead— это унифицированный фасад, который он предоставляет верхним уровням.

Структуры данных и размещение в памяти

AsyncReadОпределение предельно лаконично, есть только один метод:

rust
pub trait AsyncRead {
    fn poll_read(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
        buf: &mut ReadBuf<'_>,
    ) -> Poll<io::Result<()>>;
}

📎 tokio/src/io/async_read.rs:44-60

У каждого из трёх параметров есть свои причины.self: Pin<&mut Self>а не&mut self: потому чтоAsyncReadчасто удерживается Future, сгенерированнымasync fnа Future после poll не может перемещаться (самоссылочность),Pin— это контракт, навязываемый компилятором.cx: &mut Context<'_>несёт Waker — это канал передачи «устройства получения заказа».buf: &mut ReadBuf<'_>— это обёртка Tokio над&mut [u8]она одновременно хранит «заполненную длину» и «неинициализированную ёмкость», избегаяstd::io::ReadТа двусмысленность «возвращает количество прочитанных байт, но буфер может быть неинициализирован».

Документация явно перечисляет три семантики возврата📎 tokio/src/io/async_read.rs:15-32:Ready(Ok(()))означает, что данные записаныbuf, объём чтения определяется приращением длиныReadBuf::filled; если приращение равно 0, то это либо EOF, либоbuf.remaining() == 0(буфер нулевой ёмкости);Pendingозначает, что в данный момент чтение невозможно, но пробуждение зарегистрировано;Ready(Err(e))— это ошибка нижележащего I/O. Здесь есть легко упускаемая ловушка:«объём чтения равен 0» не равно EOF— если вызывающий передаёт буфер нулевой ёмкости,poll_readнемедленно вернётReady(Ok(())), но ничего не прочитает. Если верхний уровень обработает «0 байт» как EOF, он ошибочно решит, что соединение закрыто.

Сценарно-ориентированный Walkthrough: чтение фрагмента байтов из&[u8]Рассмотрим простейшую реализацию — копию

для&[u8]Пошаговый разбор:AsyncRead:

rust
impl AsyncRead for &[u8] {
    fn poll_read(
        mut self: Pin<&mut Self>,
        _cx: &mut Context<'_>,
        buf: &mut ReadBuf<'_>,
    ) -> Poll<io::Result<()>> {
        let amt = std::cmp::min(self.len(), buf.remaining());
        let (a, b) = self.split_at(amt);
        buf.put_slice(a);
        *self = b;
        Poll::Ready(Ok(()))
    }
}

📎 tokio/src/io/async_read.rs:98-108

— остаточная ёмкость целевого буфера, берётся меньшее из двух значенийself.len()Срез делится на «buf.remaining(), который нужно скопировать сейчас» и «оставшийсяamt。split_at(amt)для чтения»aКопируемb」。buf.put_slice(a)вaи продвигаем его указатель filled.ReadBufПродвигаем сам срез к оставшейся части — это ключ к тому, что*self = bработает как «курсор»: после каждого poll&[u8]указывает на непрочитанную часть. В конце возвращаетсяself, потому что срез в памяти всегда «готов», не будетReady(Ok(()))Обратите внимание, чтоPending。

игнорируется: источнику данных в памяти Waker не нужен. Это контрастирует с сетевым сокетом — последний при отсутствии данных вернёт_cxи зарегистрирует интерес к чтению.PendingВ реализации

io::Cursor<T>есть дополнительный слой проверки границ📎 tokio/src/io/async_read.rs:113-134: сначала берётсяposition(), еслиpos > slice.len()(позиция за пределами) сразу возвращаетсяReady(Ok(()))без panic📎 tokio/src/io/async_read.rs:113-134. Это защитный дизайн:Cursorposition уset_positionможет быть установлен внешним

в произвольное значение; при выходе за границы трактовка как «уже прочитано» больше соответствует семантике I/O, чем panic.

AsyncReadРазмышления о дизайне: макрос deref и распространение PinBox<T>、&mut T、Pin<P>предоставляет реализации пересылки дляderef_async_read!. Первые две генерируют📎 tokio/src/io/async_read.rs:64-70через макросPin::new(&mut **self).poll_read(cx, buf), суть вPin<&mut Box<T>>— разыменоватьPin<&mut T>вPin<P>и затем переслать.📎 tokio/src/io/async_read.rs:87-93Реализацияcrate::util::pin_as_deref_mut(self)более тонкаяPin<&mut Pin<P>>: она вызываетPin<&mut P::Target>, проецируяPinв

. Этот слой проекции необходим, иначе вложенный

приведёт к несовпадению типов.Box<dyn AsyncRead>、&mut T〔Проектные выводы и архитектурные компромиссы〕poll_readМотивация дизайна здесь — «абстракция с нулевой стоимостью»: реализации пересылки позволяют таким типам-обёрткам, какPin, не писать вручную

---

, сохраняя при этом корректную семантику

. Цена — каждый слой пересылки вносит один косвенный вызов, который компилятор обычно устраняет через инлайнинг.

copy_bidirectionalII. copy_bidirectional: машина состояний двунаправленной пересылкиcopyИнтуитивная модельselect!— это «двусторонний официант»: он одновременно следит за направлениями A→B и B→A, и как только с одной стороны читаются данные, записывает их на противоположную. Без него для реализации TCP-прокси пришлось бы вручную писать дваselect!Future и комбинировать их черезcopy_bidirectional— а ограничение безопасности отмены у

(глава 9) привело бы к потере данных, «прочитанных наполовину и отменённых».

использует явную машину состояний, чтобы сохранить промежуточные состояния «чтение-запись-закрытие», тем самым обеспечивая безопасность отмены.

rust
enum TransferState {
    Running(CopyBuffer),
    ShuttingDown(u64),
    Done(u64),
}

📎 tokio/src/io/util/copy_bidirectional.rs:10-14

RunningВ основе — трёхсостоянийный enum:CopyBufferКопированиеShuttingDown(u64)содержитDone(u64)(внутри 8KB буфер и счётчики чтения-записи), означает «идёт перенос данных».несёт число скопированных байт, означает «читающая сторона достигла EOF, идёт закрытие пишущей стороны».。

CopyBufferозначает «закрытие завершено, зафиксировано итоговое число байт». Этот enum — ключ к безопасности отмены:copy.rsпри drop в любой момент состояние сохраняется в enum, и следующий poll может продолжить с точки остановаDEFAULT_BUF_SIZEпроисходит из📎 tokio/src/io/util/copy_bidirectional.rs:76-88, размер по умолчанию определяетсяCopyBuffer(8KB)

. Каждое направление держит независимый

copy_bidirectional_impl, поэтому накладные расходы памяти — 16KB.poll_fnСценарно-ориентированный Walkthrough: полный жизненный цикл одной двунаправленной пересылки

rust
let mut a_to_b = TransferState::Running(a_to_b_buffer);
let mut b_to_a = TransferState::Running(b_to_a_buffer);
poll_fn(|cx| {
    let a_to_b = transfer_one_direction(cx, &mut a_to_b, a, b)?;
    let b_to_a = transfer_one_direction(cx, &mut b_to_a, b, a)?;
    let a_to_b = ready!(a_to_b);
    let b_to_a = ready!(b_to_a);
    Poll::Ready(Ok((a_to_b, b_to_a)))
})
.await

📎 tokio/src/io/util/copy_bidirectional.rs:127-151

для объединения машин состояний двух направлений:transfer_one_directionКопированиеPoll。ready!Обратите внимание на порядок вызововPending: сначала продвигается a→b, затем b→a, оба возвращаютМакрос при незавершённости любого направления немедленно возвращает— но📎 tokio/src/io/util/copy_bidirectional.rs:143-144состояние другого направления уже продвинутоready!. Именно это подчёркивает комментарийDone(count): даже если

transfer_one_directionвернётся досрочно, другое направление при следующем poll всё равно вернётloop, прогресс не потеряется.

rust
loop {
    match state {
        TransferState::Running(buf) => {
            let count = ready!(buf.poll_copy(cx, r.as_mut(), w.as_mut()))?;
            *state = TransferState::ShuttingDown(count);
        }
        TransferState::ShuttingDown(count) => {
            ready!(w.as_mut().poll_shutdown(cx))?;
            *state = TransferState::Done(*count);
        }
        TransferState::Done(count) => return Poll::Ready(Ok(*count)),
    }
}

📎 tokio/src/io/util/copy_bidirectional.rs:29-42

Running— этоpoll_copy, продвигается по состояниям:ShuttingDown。ShuttingDownКопированиеpoll_shutdownВ состоянииDone。Doneвызывается

, который внутри циклически «читает блок, пишет блок», пока читающая сторона не достигнет EOF или пишущая сторона не заблокируется. При EOF возвращается общее число скопированных байт, состояние переходит в

mermaid
flowchart TD
    start["transfer_one_direction входит в loop"] --> match_state{"текущий TransferState?"}
    match_state -->|Running| poll_copy["buf.poll_copy(cx, r, w)"]
    poll_copy --> copy_ready{"результат poll_copy?"}
    copy_ready -->|Pending| ret_pending["возврат Poll::Pending<br/>состояние остаётся Running"]
    copy_ready -->|Err| ret_err["возврат Poll::Ready(Err)<br/>ошибка распространяется вверх"]
    copy_ready -->|Ok(count)| to_shutdown["state = ShuttingDown(count)"]
    to_shutdown --> match_state
    match_state -->|ShuttingDown| poll_shutdown["w.poll_shutdown(cx)"]
    poll_shutdown --> shutdown_ready{"результат shutdown?"}
    shutdown_ready -->|Pending| ret_pending2["возврат Poll::Pending<br/>состояние остаётся ShuttingDown"]
    shutdown_ready -->|Err| ret_err
    shutdown_ready -->|Ok| to_done["state = Done(count)"]
    to_done --> match_state
    match_state -->|Done| ret_done["возврат Poll::Ready(Ok(count))"]

для закрытия пишущей стороны (отправка FIN), после завершения переход в

напрямую возвращает счётчик.

Приведённая ниже блок-схема показывает логику продвижения однонаправленной машины состояний и ветви ошибок:transfer_one_directionКопированиеasync fnРазмышления о дизайне: почему явная машина состояний, а не async fnCopyBuffer〔Проектные выводы и архитектурные компромиссы〕copy_bidirectionalЕслинаписать как, компилятор сгенерирует Future, внутреннее состояние которого (async fn, счётчик скопированного) скрыто в сгенерированной машине состояний. При однонаправленном использовании это нормально, ноselect!нужно вTransferStateодном и том же цикле pollpoll_fnодновременно продвигать оба направления — если использовать два

плюсpoll_copy, при завершении одного направления другой будет drop, его внутренний буфер и счётчик потеряются, что нарушает безопасность отмены. ЯвныйErrвыставляет состояние на стеке,?при каждом повторном входе состояние всё ещё на месте, что гарантирует «восстановление с точки останова после отмены».📎 tokio/src/io/util/copy_bidirectional.rs:32В обработке ошибок📎 tokio/src/io/util/copy_bidirectional.rs:67-70возвращаемыйнемедленно распространяется вверх через. Документация явно указываетcopy_bidirectional: прерванные чтения-записи будут повторены, другие ошибки возвращаются немедленно, и

copy_bidirectional_with_sizesчасть уже прочитанных данных может быть потеряна📎 tokio/src/io/util/copy_bidirectional.rs:99-125(не записана на противоположную сторону). Это момент, требующий внимания в продакшене:poll_copyвсегда возвращаетReady(Ok(0))ошибочно определяется как EOF, образуя busy loop.

---

III. Framed: нарезка байтового потока на кадры

Интуитивная модель

Framed— это «колбасная машина»: выше по потоку — непрерывный поток воды (AsyncRead/AsyncWrite), ниже — нарезанные куски колбасы (Stream<Item = Frame> / Sink<Frame>)。Decoderотвечает за «вырезание одного сегмента из потока»,Encoderотвечает за «упаковку сегмента в поток». БезFramedкаждая реализация протокола должна была бы вручную писать «управление буфером + обработка полупакетов + разделение склеенных пакетов» — именно эту повторяющуюся работу и призван устранить фреймворк codec.

Структуры данных и размещение в памяти

Framedсам по себе — лишь тонкая обёртка:

rust
pub struct Framed<T, U> {
    #[pin]
    inner: FramedImpl<T, U, RWFrames>
}

📎 tokio-util/src/codec/framed.rs:38-41

Настоящее состояние находится вFramedImplизstate: RWFrames, включаяread: ReadFrameиwrite: WriteFrameдве части.ReadFrameПоляwith_capacityвидны в📎 tokio-util/src/codec/framed.rs:107-126:eof: bool(достигнут ли EOF на стороне чтения),is_readable: bool(зарегистрирован ли интерес к чтению),buffer: BytesMut(буфер чтения),has_errored: bool(была ли ошибка, чтобы предотвратить повторное чтение).WriteFrameПоля📎 tokio-util/src/codec/framed.rs:119-122:buffer: BytesMut(буфер записи),backpressure_boundary: usize(порог backpressure).

backpressure_boundaryявляется ключом к механизму backpressure: когда буфер записи превышает этот порог,poll_readyвернётPendingдо тех пор, пока данные не будут сброшены, тем самым оказывая backpressure на вышестоящийSink. По умолчанию равенcapacity 📎 tokio-util/src/codec/framed.rs:121, можно настроить черезset_backpressure_boundaryизменить📎 tokio-util/src/codec/framed.rs:271-273。

Сценарий-ориентированный Walkthrough: чтение одного кадра из socket

FramedизStreamреализация просто перенаправляет вFramedImpl::poll_next 📎 tokio-util/src/codec/framed.rs:309-311. Настоящая логика находится вFramedImpl(этот файл не предоставлен в данной главе, но цепочку вызовов можно вывести из интерфейсаFramed):

1. poll_nextсначала проверяетread.buffer, есть ли уже полный кадр (вызовcodec.decode);

2. ЕслиdecodeвозвращаетSome(frame), сразу выдаёт, не трогая нижележащий I/O;

3. Если возвращаетNone(полупакет), проверяетread.eof: если уже EOF и буфер не пуст, значит есть остаточные данные, которые невозможно декодировать, возвращает ошибку илиNone;

4. Иначе вызывает нижележащийAsyncRead::poll_readчитает больше байтов вread.buffer;

5. Прочитанные байты снова пытаетсяdecode, цикл до тех пор, пока не будет выдан кадр илиPending。

Этот порядок «сначала decode, потом read» важен: он гарантирует, чтоодно read может выдать несколько кадров(склеенные пакеты), иодин кадр может охватывать несколько read(полупакет).is_readableфлаг предотвращает повторную регистрацию интереса к чтению — если предыдущий poll уже зарегистрирован и не готов, в этот раз сразу возвращаетPendingбез повторного вызова нижележащего.

Sinkцепочка вызовов реализации📎 tokio-util/src/codec/framed.rs:315-338:start_sendвызываетcodec.encode(item, &mut write.buffer)кодирует кадр в буфер записи;poll_flushсбрасываетwrite.bufferв нижележащийAsyncWrite;poll_readyпроверяетwrite.buffer.len() >= backpressure_boundary, при превышении порога сначала flush, затем возвращает готовность.

Следующая диаграмма последовательности показываетFramedвзаимодействие между компонентами в одном цикле «чтение кадра — запись кадра»:

mermaid
sequenceDiagram
    participant App as Прикладной уровень
    participant F as FramedImpl
    participant C as Decoder/Encoder
    participant IO as AsyncRead/AsyncWrite

    App->>F: poll_next(cx)
    F->>C: decode(&mut read.buffer)
    alt в буфере уже есть полный кадр
        C-->>F: Some(frame)
        F-->>App: Poll::Ready(Some(frame))
    else полупакет
        C-->>F: None
        F->>IO: poll_read(cx, &mut read.buffer)
        alt данные готовы
            IO-->>F: Ready(Ok(()))
            F->>C: decode(&mut read.buffer)
            C-->>F: Some(frame) или None
        else нет данных
            IO-->>F: Pending
            F-->>App: Poll::Pending
        end
    end

    App->>F: start_send(frame)
    F->>C: encode(frame, &mut write.buffer)
    C-->>F: Ok(())
    App->>F: poll_flush(cx)
    F->>IO: poll_write(cx, &write.buffer)
    IO-->>F: Ready(Ok(n))
    F->>IO: poll_flush(cx)
    IO-->>F: Ready(Ok(()))

Безопасность отмены: предупреждение в документации Framed

Framedдокументация специально перечисляет семантику безопасности отмены📎 tokio-util/src/codec/framed.rs:23-30:SinkExt::sendЕсли вselect!будет вытеснен другой веткой,сообщение гарантированно не отправлено, но само сообщение теряется— потому чтоsendвнутри сначалаpoll_readyзатемstart_send, если на этапеpoll_readyбудет drop,itemуже потреблён, но не закодирован. АStreamExt::nextбезопасен для отмены: он лишь держит ссылку на нижележащий stream, drop не потеряет уже декодированные кадры.

〔Проектные выводы и архитектурные компромиссы〕

Эта асимметрия проистекает из различий путей чтения и записи: состояние пути чтения (read.buffer) хранится внутриFramed,nextпри drop лишь отказывается от действия «взять кадр», буфер не затрагивается; состояние пути записи (ожидающий отправкиitem) находится в стеке Futuresend, drop означает потерю. В production-коде, если вselect!использоватьsend, необходимо обеспечить возможность повторной отправки сообщения или принять его потерю.

Размышления о дизайне:into_partsиmap_codec

Framedпредоставляютinto_parts/from_partsдля «смены codec с сохранением буфера»📎 tokio-util/src/codec/framed.rs:290-298 📎 tokio-util/src/codec/framed.rs:155-166。map_codecреализован на основе этой пары методов📎 tokio-util/src/codec/framed.rs:221-234: сначалаinto_partsизвлекаетio/codec/read_buf/write_buf, затемmapфункция преобразует codec, наконецfrom_partsпересобирает. Этот дизайн позволяет при обновлении протокола (например, переход с открытого текста на TLS) сохранить уже буферизованные данные, избегая повторного чтения.

FramedPartsполе_priv: ()из📎 tokio-util/src/codec/framed.rs:373-375— это приём «неисчерпывающей структуры»: приватные поля препятствуют прямому конструированию извне, принуждая использоватьnew/from_parts, что позволяет в будущем добавлять поля без нарушения совместимости.

---

IV. LengthDelimitedCodec: конечный автомат кодирования/декодирования с префиксом длины

Интуитивная модель

LengthDelimitedCodec— это специализированный нож для «нарезки колбасы по длине»: он предполагает, что перед каждым кадром есть поле длины фиксированного числа байтов, сначала читается длина, затем payload. Без него реализация протокола с префиксом длины требовала бы вручную писать конечный автомат «прочитать 4 байта → разобрать длину → прочитать N байтов → цикл» — именно это внутриDecodeStateи делает.

Структуры данных и размещение в памяти

rust
pub struct LengthDelimitedCodec {
    builder: Builder,
    state: DecodeState,
}

enum DecodeState {
    Head,
    Data(usize),
}

📎 tokio-util/src/codec/length_delimited.rs:451-457

DecodeState— это явный конечный автомат:Headозначает «читается поле длины»,Data(n)означает «длина n разобрана, читается payload». Это состояние сохраняется между вызовамиdecode, поэтомув сценарии полупакета прогресс не теряется。

Builderсодержит всю конфигурацию📎 tokio-util/src/codec/length_delimited.rs:416-435:max_frame_len(по умолчанию 8MB),length_field_len(по умолчанию 4 байта),length_field_offset(по умолчанию 0),length_adjustment(по умолчанию 0),num_skip(по умолчаниюNone, то естьoffset + len)、length_field_is_big_endian(по умолчанию true).

Сценарий-ориентированный Walkthrough: декодирование кадра с префиксом длины

decode— точка входа конечного автомата:

rust
fn decode(&mut self, src: &mut BytesMut) -> io::Result<Option<BytesMut>> {
    let n = match self.state {
        DecodeState::Head => match self.decode_head(src)? {
            Some(n) => {
                self.state = DecodeState::Data(n);
                n
            }
            None => return Ok(None),
        },
        DecodeState::Data(n) => n,
    };

    match self.decode_data(n, src) {
        Some(data) => {
            self.state = DecodeState::Head;
            src.reserve(self.builder.num_head_bytes().saturating_sub(src.len()));
            Ok(Some(data))
        }
        None => Ok(None),
    }
}

📎 tokio-util/src/codec/length_delimited.rs:579-603

Headв состоянииdecode_headвызываетNone. Если возвращаетOk(None)(недостаточно данных), сразу возвращаетSome(n)ожидая больше данных; если возвращаетData(n)。Data, состояние переходит вdecode_data(n, src)в состоянииsplit_to(n)напрямую берёт n. Затем вызываетHead: если в буфере уже есть n байтов,Noneвырезает кадр, состояние возвращается в

decode_head, и резервируется место для заголовка следующего кадра; иначе возвращает

rust
let head_len = self.builder.num_head_bytes();
let field_len = self.builder.length_field_len;

if src.len() < head_len {
    return Ok(None);
}

let n = {
    let mut src = Cursor::new(&mut *src);
    src.advance(self.builder.length_field_offset);
    let n = if self.builder.length_field_is_big_endian {
        src.get_uint(field_len)
    } else {
        src.get_uint_le(field_len)
    };

    if n > self.builder.max_frame_len as u64 {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            LengthDelimitedCodecError { _priv: () },
        ));
    }

    let n = n as usize;
    let n = if self.builder.length_adjustment < 0 {
        n.checked_sub(-self.builder.length_adjustment as usize)
    } else {
        n.checked_add(self.builder.length_adjustment as usize)
    };

    match n {
        Some(n) => n,
        None => {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                "provided length would overflow after adjustment",
            ));
        }
    }
};

src.advance(self.builder.get_num_skip());
src.reserve(n.saturating_sub(src.len()));
Ok(Some(n))

📎 tokio-util/src/codec/length_delimited.rs:504-562

— основная логика разбора:src.len() >= head_lenКопироватьNone 📎 tokio-util/src/codec/length_delimited.rs:499-502Пошаговый разбор: сначала проверяетCursor, если недостаточно, возвращаетsrc. Используетadvance/get_uintдля обёрткиadvance(length_field_offset), чтобы📎 tokio-util/src/codec/length_delimited.rs:517операция не потребляла исходный буфер.field_lenпропускает префикс заголовка📎 tokio-util/src/codec/length_delimited.rs:520-524。

. Читает в порядке байтовзначение длиныn > max_frame_lenКлючевая защитаInvalidData: если📎 tokio-util/src/codec/length_delimited.rs:526-531, немедленно возвращает

ошибкуchecked_sub/checked_add. Это предотвращает отправку злонамеренным пиром кадра с «полем длины 4GB», вызывающего исчерпание памяти — это самая классическая поверхность DoS-атаки для протоколов с префиксом длины.📎 tokio-util/src/codec/length_delimited.rs:537-541Корректировка длины используетInvalidInputошибка, а не panic.get_num_skip()возвращаетnum_skipили значение по умолчаниюoffset + len 📎 tokio-util/src/codec/length_delimited.rs:1070-1073, пропуская оставшуюся часть заголовка. В концеreserve(n.saturating_sub(src.len()))резервирует место для payload📎 tokio-util/src/codec/length_delimited.rs:559— используетсяsaturating_sub, потому чтоsrcможет уже содержать часть payload.

Приведённая ниже блок-схема показываетdecodeполный путь принятия решений:

mermaid
flowchart TD
    entry["decode(src)"] --> check_state{"self.state?"}
    check_state -->|Head| head["decode_head(src)"]
    head --> head_result{"результат?"}
    head_result -->|Ok(None)| ret_none1["возврат Ok(None)<br/>ожидание дополнительных данных"]
    head_result -->|Err| ret_err1["возврат Err<br/>превышение длины или переполнение"]
    head_result -->|Ok(Some(n))| set_data["state = Data(n)"]
    set_data --> decode_data
    check_state -->|Data(n)| decode_data["decode_data(n, src)"]
    decode_data --> data_result{"src.len() >= n?"}
    data_result -->|нет| ret_none2["возврат Ok(None)<br/>ожидание дополнительных данных"]
    data_result -->|да| split["src.split_to(n)<br/>state = Head<br/>reserve заголовка следующего кадра"]
    split --> ret_frame["возврат Ok(Some(frame))"]

Проектное размышление: обрезка max_frame_len и защита от переполнения

Builder::adjust_max_frame_lenпри создании codec обрезаетmax_frame_lenдо максимального значения, которое может представить поле длины📎 tokio-util/src/codec/length_delimited.rs:1075-1081。max_allowed_frame_lenвычисляетmax_length_field_value + length_adjustment 📎 tokio-util/src/codec/length_delimited.rs:1083-1089, гдеmax_length_field_valueиспользуетchecked_shlдля обработкиlength_field_len == 8переполнения сдвига📎 tokio-util/src/codec/length_delimited.rs:1091-1096. Эта обрезка предотвращает противоречивую конфигурацию, когда пользователь задаёт «поле длины 2 байта, но max_frame_len = 1MB» — 2 байта могут представить максимум 65535, после обрезки max_frame_len становится 65535.

Симметричная защита на пути кодирования:encodeпроверяетn > max_frame_lenвозвращаетInvalidInput 📎 tokio-util/src/codec/length_delimited.rs:607-607, корректировка длины также используетchecked_add/checked_sub 📎 tokio-util/src/codec/length_delimited.rs:620-631. Обратите внимание, что направление корректировки при кодировании противоположно декодированию: при декодировании «прочитанная длина ± adjustment = длина payload», при кодировании «длина payload ∓ adjustment = записываемое поле длины»📎 tokio-util/src/codec/length_delimited.rs:620-624。

〔Проектные выводы и архитектурные компромиссы〕

Такая симметричная схема «при декодировании прибавляем, при кодировании вычитаем» нужна для того, чтобыlength_adjustmentимел единую семантику: он представляет «разность между значением поля длины и длиной payload». Когда поле длины протокола включает заголовок (как в Example 3),adjustment = -2, при декодированииn - (-2) = n + 2получаем длину payload, при кодированииpayload - (-2) = payload + 2записываем обратно в поле длины.

---

Проектное размышление: три уровня абстрактной границы

Оглядываясь на эту главу, абстракция I/O в Tokio демонстрирует чёткую трёхуровневую структуру:

Первый уровень: trait байтового потока (AsyncRead/AsyncWrite). Обещает только «прочитать/записать некоторые байты», не гарантирует границы кадров. Это минимальный интерфейс, который может реализовать любой источник I/O (сокет, файл, срез памяти). Цена — верхний уровень должен сам обрабатывать частичные/склеенные пакеты.

Второй уровень: утилиты байтового потока (BufReader/BufWriter/copy_bidirectional). Предоставляет поверх trait такие общие возможности, как «уменьшение системных вызовов» и «двунаправленная пересылка».copy_bidirectionalявный конечный автомат демонстрирует, как «безопасность отмены» реализуется на уровне утилит — состояние хранится в стеке, а не внутри Future.

Третий уровень: адаптация кадров (Framed/Decoder/Encoder). Поднимает байтовый поток доStream<Frame>/Sink<Frame>, позволяя реализации протокола заботиться только о «кодировании/декодировании кадров», а не об «управлении буферами».LengthDelimitedCodec— эталонный пример этого уровня, егоDecodeStateконечный автомат иmax_frame_lenзащита — это шаблон, который должны переиспользовать все протоколы с префиксом длины.

〔Проектные выводы и архитектурные компромиссы〕

Разделение на эти три уровня не случайно: оно соответствует трём градиентам «утечки абстракции». Чем ниже уровень, тем универсальнее, но сложнее в использовании; чем выше, тем удобнее, но специализированнее. Tokio решил сделать «кадр» полноценным гражданином вtokio-util, а не вtokioядре, потому что определение кадра зависит от протокола —tokioпредоставляет только байтовый поток,tokio-utilпредоставляет каркас кадров, а конкретные протоколы (HTTP/Redis/gRPC) реализуютDecoder/Encoder。

---

в своих crate

  • AsyncRead::poll_readИтоги главыPin<&mut Self> + Context + ReadBufиспользуетstd::io::Read::readтри параметра вместоReady(Ok(())), превращая «блокирующее ожидание» в «регистрацию Waker + возврат Pending».
  • copy_bidirectionalи при нулевом объёме чтения нужно различать EOF и буфер нулевой ёмкости.TransferStateиспользуетRunning/ShuttingDown/Doneтрёхсостоянийное перечисление (select!) для сохранения промежуточного состояния, чтобы двунаправленная пересылка могла восстановиться при
  • Framedотмене. При возникновении ошибки часть данных может быть потеряна.AsyncRead/AsyncWriteадаптируетStream/Sink,ReadFrame/WriteFrameкSinkExt::send, раздельно управляя буферами чтения/записи и обратным давлением.StreamExt::nextне безопасен при отмене (потеря сообщений),
  • LengthDelimitedCodecбезопасен при отмене.DecodeState(Head/Data(n)используетmax_frame_len) конечный автомат для обработки частичных пакетов,checked_add/checked_subзащищает поле длины от DoS,

защищает от переполнения при корректировке.

Q1: copy_bidirectionalВопросы для размышления и самопроверки по главеtransfer_one_directionвTransferState::ShuttingDown, если заменитьready!(w.as_mut().poll_shutdown(cx))?ветки*state = TransferState::Done(*count)на прямой

(пропуск shutdown), в каких сценариях это приведёт к невозможности корректного закрытия соединения на противоположной стороне?:poll_shutdownСправочный разборDoneслужит для отправки противоположной стороне пакета FIN, уведомляющего «у меня больше нет данных». Если пропустить его и сразу перейти кread, записывающая сторона не закроется, противоположная сторона будет бесконечно ждать данных, образуя «полуоткрытое соединение» — противоположная сторона может навсегда заблокироваться наShuttingDownдо тайм-аута. В сценарии TCP-прокси это приведёт к утечке соединений: клиент уже отключился, но соединение прокси с бэкендом всё ещё поддерживается. Наличие состояния📎 tokio/src/io/util/copy_bidirectional.rs:35-39в исходном кодеpoll_shutdownкак раз для того, чтобы гарантировать явное закрытие записывающей стороны после EOF. Обратите внимание, чтоPendingсам может вернутьready!(например, при заполненном буфере отправки), поэтому нужно использовать

Q2: LengthDelimitedCodec::decode_headдля ожидания, а не игнорировать.if n > self.builder.max_frame_len as u64в📎 tokio-util/src/codec/length_delimited.rs:526-531, если убрать проверку0xFFFFFFFF, какие последствия вызовет отправка злонамеренным клиентом заголовка кадра с полем длиныlength_adjustment(4GB)? Почему эта проверка должна быть до

?Справочный разборn: после удаления проверкиusizeбудет преобразовано вdecode_data。decode_dataи передано вsrc.len() < nпри проверкеNoneвозвращаетdecode_head, ноsrc.reserve(n.saturating_sub(src.len())) 📎 tokio-util/src/codec/length_delimited.rs:559в концеlength_adjustmentпопытается зарезервировать 4GB памяти, что приведёт к OOM или panic при неудачном выделении. Проверка должна быть доlength_adjustment, потому что-2может быть отрицательным (например,0xFFFFFFFF - 2), если сначала скорректировать, а потом проверять,checked_subвсё ещё близко к 4GB, проверка становится фиктивной; к тому же отрицательная корректировка может заставить

Итак, мы разобрались с двумя уровнями абстракции Tokio между байтовыми потоками и кадрами сообщений: tokio::io отвечает за перемещение байтов, а фреймворк codec из tokio-util — за разбиение на кадры и кодирование/декодирование. Framed стал отправной точкой для реализации протоколов именно потому, что он инкапсулирует часто встречающуюся потребность «прочитать одно полное сообщение» в переиспользуемую адаптацию Stream/Sink. Но кадр — это лишь контейнер данных; когда протоколу требуется работать с динамическим набором задач, структурированной отменой или более сложными потоковыми композициями, одного Framed недостаточно. В следующей главе мы перейдём к механизмам расширения tokio-stream и tokio-util и посмотрим, как комбинаторы StreamExt, StreamMap/JoinSet/TaskTracker и CancellationToken переиспользуют нижележащие Waker и механизмы планирования, предоставляя более высокоуровневые инструменты для асинхронной итерации и управления задачами.

CHAPTER 11

Глава 11: Экосистема Stream и утилиты: механизмы расширения tokio-stream и tokio-util

Проект: tokio-rs/tokio · Прогресс книги: Глава 11 / 14 · Статус проверки: FACT — номера строк реально привязаны

В предыдущей главе мы разобрали байтовый механизм Framed: Decoder нарезает BytesMut на кадры, Sink записывает кадры обратно — так становится ясна абстрактная граница асинхронного ввода-вывода. Но кадр — это лишь контейнер данных, и реальная реализация протокола сразу же сталкивается с тремя проблемами, которые не решают ни tokio::io, ни Framed: асинхронная итерация — Framed реализует Stream, но у Stream есть только poll_next, нет next().await, filter, take, merge; писать poll_fn вручную и многословно, и легко ошибиться в безопасности отмены; динамический набор задач — чат-сервису нужно одновременно подписаться на N каналов, каналы присоединяются и покидают в любой момент, а количество ветвей select! фиксировано на этапе компиляции и не может выразить набор потоков, изменяющийся во время выполнения; структурированная отмена — select! может отменить одну ветвь, но не может распространить остановку всего дерева задач вниз и не может дождаться фактического завершения всех задач. tokio-stream и tokio-util созданы именно для этих трёх вещей, и их ключевой принцип проектирования — не изобретать заново: каждый комбинатор StreamExt — это лишь обёртка над poll_next, StreamMap переиспользует семантику регистрации Waker, CancellationToken напрямую строится поверх tokio::sync::Notify, а TaskTracker кодирует всё состояние одним AtomicUsize. Понимание их — это по сути понимание того, как делать абстракции с нулевой стоимостью поверх существующих Waker и механизмов планирования. Эта глава последовательно продвигается по трём слоям: итерация, коллекции, отмена: сначала посмотрим, как StreamExt превращает poll_next в комбинируемый итератор, затем — как StreamMap и TaskTracker управляют динамическими коллекциями, и наконец — как CancellationToken с помощью дерева распространяет сигнал отмены на всё дерево задач.

StreamExt: превращаем poll_next в комбинируемый итератор

Интуитивная модель

Streamпо отношению кFuture, какIteratorпо отношению к значению:Futureпроизводит «одно значение»,Streamпроизводит «последовательность значений». НоStreamопределяет толькоpoll_nextэтот один примитив, какIteratorопределяет толькоnext. БезStreamExtкаждая фильтрация, отображение, усечение требовали бы ручного написания замыканияpoll_fnи ручного управленияPin— именно это было самым болезненным местом для ранних пользователей cratefutures.StreamExtРольStream— снабдитьIteratorтакой экосистемой комбинаторов, как у

. Без неё система сталкивается не с отсутствием функциональности, а ссистемным обрушением безопасности отмены: каждый написанный вручнуюpoll_fnможет при отмене черезselect!потерять ужеpollэлемент.

Структуры данных и размещение в памяти

StreamExt— эторасширяющий trait, сам по себе не хранящий данных:

📎 tokio-stream/src/stream_ext.rs:106-106

rust
pub trait StreamExt: Stream {

Все его методы возвращаютконкретную структуру-комбинатор, а неBox<dyn Stream>. Это ключевое проектное решение:mapвозвращаетMap<Self, F>,filterвозвращаетFilter<Self, F>,takeвозвращаетTake<Self>. Эти структуры — обобщённые обёртки с нулевым выделением памяти в куче; компилятор может встроить всю цепочку в слой за слоем вызововpoll_next.

Обратите внимание на blanket impl трейта:

📎 tokio-stream/src/stream_ext.rs:1213-1213

rust
impl<St: ?Sized> StreamExt for St where St: Stream {}

ЛюбойStreamавтоматически получает все комбинаторы без ручной реализации.?Sizedпозволяетdyn Streamтакже пользоваться методами расширения.

Объявления модулей комбинаторов раскрывают полную поверхность возможностей этого трейта:

📎 tokio-stream/src/stream_ext.rs:4-59

rust
mod all; use all::AllFuture;
mod any; use any::AnyFuture;
mod chain; pub use chain::Chain;
pub(crate) mod collect; use collect::{Collect, FromStream};
mod filter; pub use filter::Filter;
mod filter_map; pub use filter_map::FilterMap;
mod fold; use fold::FoldFuture;
mod fuse; pub use fuse::Fuse;
mod map; pub use map::Map;
mod map_while; pub use map_while::MapWhile;
mod merge; pub use merge::Merge;
mod next; use next::Next;
mod skip; pub use skip::Skip;
mod skip_while; pub use skip_while::SkipWhile;
mod take; pub use take::Take;
mod take_while; pub use take_while::TakeWhile;
mod then; pub use then::Then;
mod try_next; use try_next::TryNext;
mod peekable; pub use peekable::Peekable;

Здесь есть值得注意 различие:next、try_next、all、any、fold、collectвозвращаетFuture(Next、TryNext、AllFuture…), потому что они потребляют весь поток в одно значение; аmap、filter、takeи т.п. возвращаютStream, потому что сохраняют форму потока.nextТип возвращаемого значения —Next<'_, Self>, с параметром времени жизни, потому что он лишь заимствует поток:

📎 tokio-stream/src/stream_ext.rs:144-149

rust
fn next(&mut self) -> Next<'_, Self>
where
    Self: Unpin,
{
    Next::new(self)
}

Self: UnpinОграничениеnextнамеренно:Pinне получает владение потоком, только заимствует, поэтому нельзя!Unpinпоток. Если поток —Box::pin, пользователь должен сначалаpin_mut!или

📎 tokio-stream/src/stream_ext.rs:116-121

rust
/// Note that because `next` doesn't take ownership over the stream,
/// the [`Stream`] type must be [`Unpin`]. If you want to use `next` with
/// a [`!Unpin`](Unpin) stream, you'll first have to pin the stream. This can
/// be done by boxing the stream using [`Box::pin`] or
/// pinning it to the stack using the `pin_mut!` macro from the `pin_utils`
/// crate.

копироватьmergeопрос

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

📎 tokio-stream/src/stream_ext.rs:319-321

rust
/// simultaneously, the merge stream alternates between them. This provides
/// some level of fairness. You should not chain calls to `merge`, as this
/// will break the fairness of the merging.

mergeСигнатура требует, чтобы оба потока имели одинаковыйItemтип:

📎 tokio-stream/src/stream_ext.rs:398-404

rust
fn merge<U>(self, other: U) -> Merge<Self, U>
where
    U: Stream<Item = Self::Item>,
    Self: Sized,
{
    Merge::new(self, other)
}

Когда вызывающая сторона.next().await, поток выполнения следующий:

1. Next::pollвызовMerge::poll_next。

2. MergeВнутри поддерживается булев флаг «чей черёд был в прошлый раз». Сначалаpollтот поток, который не выдал значение в прошлый раз; еслиPending, затемpollдругой.

3. Если обаPending,MergeвозвращаютPending, ноWaker каждого из двух потоков уже зарегистрированы— готовность любого разбудит текущую задачу.

4. Если один поток возвращаетReady(None)(завершение),Mergeзаписывает, что этот поток завершён, и после этогоpollтолько другой поток, пока и он не завершится.

Ключевой момент здесь:Mergeне имеет собственной логики управления Waker, он передаётcxкак есть внутренним двум потокамpoll_next。Регистрация Waker полностью лежит на нижележащих потоках,Mergeтолько решает, «кого спросить первым на этот раз». Это и есть буквальный смысл «переиспользования механизма Waker нижележащих потоков».

merge_size_hintsВспомогательная функция показывает, как комбинатор объединяет подсказки о ёмкости:

📎 tokio-stream/src/stream_ext.rs:1216-1226

rust
fn merge_size_hints(
    (left_low, left_high): (usize, Option<usize>),
    (right_low, right_high): (usize, Option<usize>),
) -> (usize, Option<usize>) {
    let low = left_low.saturating_add(right_low);
    let high = match (left_high, right_high) {
        (Some(h1), Some(h2)) => h1.checked_add(h2),
        _ => None,
    };
    (low, high)
}

Обратите внимание на выбор междуsaturating_addиchecked_add: для нижней границы используется насыщающее сложение (лучше недооценить, чем переполниться с panic), для верхней — проверяемое сложение (если хоть одно неизвестно, то всё неизвестно). Это типичный способ обработки контрактаsize_hint.

Размышления о дизайне: безопасность отмены и защита от panic вchunks_timeout

StreamExtДокументация помечает для каждого методаCancel safety. Возьмёмnextв качестве примера:

📎 tokio-stream/src/stream_ext.rs:123-127

rust
/// # Cancel safety
///
/// This method is cancel safe. The returned future only
/// holds onto a reference to the underlying stream,
/// so dropping it will never lose a value.

nextбезопасен для отмены, потому что он только заимствует поток и не потребляет элементы —Nextкогда future уничтожается, состояние самого потока не меняется, и следующийnextзановоpoll。

Но не все комбинаторы безопасны для отмены.chunks_timeoutвыполняет проверку параметров уже при конструировании:

📎 tokio-stream/src/stream_ext.rs:1178-1185

rust
#[track_caller]
fn chunks_timeout(self, max_size: usize, duration: Duration) -> ChunksTimeout<Self>
where
    Self: Sized,
{
    assert!(max_size > 0, "`max_size` must be non-zero.");
    ChunksTimeout::new(self, max_size, duration)
}
〔Проектные соображения и архитектурные компромиссы〕

#[track_caller]заставляет место panic указывать на вызывающую сторону, а не на внутренности библиотеки,assert!отвергаетmax_size == 0уже на этапе конструирования. Почему проверка обязана быть на этапе конструирования? Если разрешитьmax_size == 0,ChunksTimeoutлогика пакетной обработки попадёт в бесконечный цикл «никогда не набрать полный пакет» или будет выдавать пустые пакеты, а такие баги крайне трудно локализовать во время выполнения. Panic на этапе конструирования переносит ошибку в самую раннюю наблюдаемую точку.

timeoutРазличие междуtimeout_repeatingиtimeoutтакже заслуживает внимания:возвращает ошибку после тайм-аута, но;timeout_repeatingпродолжает опрашивать внутренний потокIntervalже в соответствии с

📎 tokio-stream/src/stream_ext.rs:985-1001

rust
/// Once a timeout error is received, no further events will be received
/// unless the wrapped stream yields a value (timeouts do not repeat).

📎 tokio-stream/src/stream_ext.rs:1071-1072

rust
/// Timeout errors will be continuously produced at the specified interval
/// until the wrapped stream yields a value.

---

копия

StreamMap: динамический набор потоков и справедливый опрос

select!Интуитивная модельStreamMapКоличество ветвей фиксировано на этапе компиляции. Но количество каналов, на которые нужно подписаться чат-сервису, и количество соединений, которые должен отслеживать краулер, становятся известны только во время выполнения.select!— это «next, который можно добавлять и удалять во время выполнения»: он помещает произвольное количество потоков в набор, и каждый(key, value)возвращаетmpsc, сообщая, из какого потока пришло значение. Без него вам пришлось бы запихнуть все потоки в один

канал, что добавило бы лишние накладные расходы на пересылку.

StreamMapСтруктура данных и размещение в памятиVec:

📎 tokio-stream/src/stream_map.rs:204-208

rust
#[derive(Debug)]
pub struct StreamMap<K, V> {
    /// Streams stored in the map
    entries: Vec<(K, V)>,
}

предельно простое — один

📎 tokio-stream/src/stream_map.rs:38-44

rust
/// `StreamMap` is backed by a `Vec<(K, V)>`. There is no guarantee that this
/// internal implementation detail will persist in future versions, but it is
/// important to know the runtime implications. In general, `StreamMap` works
/// best with a "smallish" number of streams as all entries are scanned on
/// insert, remove, and polling. In cases where a large number of streams need
/// to be merged, it may be advisable to use tasks sending values on a shared
/// [`mpsc`] channel.
Документация явно описывает цену этого выбора:

копияHashMap〔Проектные соображения и архитектурные компромиссы〕StreamMapПочему не используется? Потому что ключевая операция—Vecопрос всех потоковswap_remove, а не поиск по ключу.HashMapЛинейное сканирование дружественно к кэшу CPU, иpoll_nextимеет сложность O(1). Если бы использовалсяinsert, каждыйremoveтребовал бы обхода хэш-корзин, что ухудшило бы локальность кэша.

insertи

📎 tokio-stream/src/stream_map.rs:446-454

rust
pub fn insert(&mut self, k: K, stream: V) -> Option<V>
where
    K: Hash + Eq,
{
    let ret = self.remove(&k);
    self.entries.push((k, stream));

    ret
}

removeРеализацияswap_removeвоплощает семантику «сначала удалить, потом вставить»:

📎 tokio-stream/src/stream_map.rs:471-483

rust
pub fn remove<Q>(&mut self, k: &Q) -> Option<V>
where
    K: Borrow<Q>,
    Q: Hash + Eq + ?Sized,
{
    for i in 0..self.entries.len() {
        if self.entries[i].0.borrow() == k {
            return Some(self.entries.swap_remove(i).1);
        }
    }

    None
}

использует

StreamMapчтобы обменять удаляемый элемент с последним и затем извлечь его, избегая O(n) перемещений:poll_next_entryкопияСценарный Walkthrough: случайная начальная точка и коррекция курсора в poll_next_entryЯдро

📎 tokio-stream/src/stream_map.rs:515-550

rust
fn poll_next_entry(&mut self, cx: &mut Context<'_>) -> Poll<Option<(usize, V::Item)>> {
    let start = self::rand::thread_rng_n(self.entries.len() as u32) as usize;
    let mut idx = start;

    for _ in 0..self.entries.len() {
        let (_, stream) = &mut self.entries[idx];

        match Pin::new(stream).poll_next(cx) {
            Poll::Ready(Some(val)) => return Poll::Ready(Some((idx, val))),
            Poll::Ready(None) => {
                // Remove the entry
                self.entries.swap_remove(idx);

                // Check if this was the last entry, if so the cursor needs
                // to wrap
                if idx == self.entries.len() {
                    idx = 0;
                } else if idx < start && start <= self.entries.len() {
                    // The stream being swapped into the current index has
                    // already been polled, so skip it.
                    idx = idx.wrapping_add(1) % self.entries.len();
                }
            }
            Poll::Pending => {
                idx = idx.wrapping_add(1) % self.entries.len();
            }
        }
    }

    // If the map is empty, then the stream is complete.
    if self.entries.is_empty() {
        Poll::Ready(None)
    } else {
        Poll::Pending
    }
}

. Он начинает опрос со

случайной начальной точки thread_rng_n, чтобы гарантировать справедливость — если всегда начинать с индекса 0, первый поток заморит голодом остальные:FastRandкопияxorshift64+В этом коде три тонких момента, разберём их по порядку:

📎 tokio-stream/src/stream_map.rs:765-768

rust
/// Implement `xorshift64+`: 2 32-bit `xorshift` sequences added together.
/// Shift triplet `[17,7,16]` was calculated as indicated in Marsaglia's
/// `Xorshift` paper

fastrand_nиспользует потоково-локальный% n:

📎 tokio-stream/src/stream_map.rs:787-792

rust
pub(crate) fn fastrand_n(&self, n: u32) -> u32 {
    // This is similar to fastrand() % n, but faster.
    // See https://lemire.me/blog/2016/06/27/a-fast-alternative-to-the-modulo-reduction/
    let mul = (self.fastrand() as u64).wrapping_mul(n as u64);
    (mul >> 32) as u32
}

:swap_removeкопияиспользует умножение и взятие по модулю Лемьера вместоidxкопияNoneВторой —swap_removeкоррекция курсора послеidx. Когда поток с индексомвозвращаети удаляется,startперемещает последний элемент на местоidx < start && start <= self.entries.len(). Этот перемещённый элемент можетidx = idx.wrapping_add(1) % lenуже быть опрошеннымidx == len(если его исходный индекс был до

). Код используетPoll::Pendingдля обнаружения этого случая и, если так, пропускает его (). Если удалён последний элемент (Pending), курсор сбрасывается на 0.

poll_nextТретий —poll_next_entryсемантика

📎 tokio-stream/src/stream_map.rs:676-683

rust
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
    if let Some((idx, val)) = ready!(self.poll_next_entry(cx)) {
        let key = self.entries[idx].0.clone();
        Poll::Ready(Some((key, val)))
    } else {
        Poll::Ready(None)
    }
}

. В этот момент Waker всех потоков уже зарегистрированы, и готовность любого разбудит задачу.ready!поверхpoll_next_entryдобавляет key:Pendingкопияpoll_nextОбратите внимание на макросPending。K: Clone: еслиkey.clone()。

возвращает

next_many, весьStreamMapнемедленно возвращает

📎 tokio-stream/src/stream_map.rs:581-583

rust
pub async fn next_many(&mut self, buffer: &mut Vec<(K, V::Item)>, limit: usize) -> usize {
    poll_fn(|cx| self.poll_next_many(cx, buffer, limit)).await
}

Размышления о дизайне: пакетная семантика next_many и безопасность отмены

📎 tokio-stream/src/stream_map.rs:573-578

rust
/// # Cancel safety
///
/// This method is cancel safe. If `next_many` is used as the event in a
/// [`tokio::select!`] statement and some other branch completes first,
/// it is guaranteed that no items were received on any of the underlying
/// streams.

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

poll_next_manyбезопасен для отмены? Потому что онpoll_next_entryнемедленно помещает элементы в предоставленный вызывающей стороной

📎 tokio-stream/src/stream_map.rs:597-666

rust
pub fn poll_next_many(
    &mut self,
    cx: &mut Context<'_>,
    buffer: &mut Vec<(K, V::Item)>,
    limit: usize,
) -> Poll<usize> {
    if limit == 0 || self.entries.is_empty() {
        return Poll::Ready(0);
    }

    let mut added = 0;

    let start = self::rand::thread_rng_n(self.entries.len() as u32) as usize;
    let mut idx = start;

    while added < limit {
        // Indicates whether at least one stream returned a value when polled or not
        let mut should_loop = false;

        for _ in 0..self.entries.len() {
            let (_, stream) = &mut self.entries[idx];

            match Pin::new(stream).poll_next(cx) {
                Poll::Ready(Some(val)) => {
                    added += 1;

                    let key = self.entries[idx].0.clone();
                    buffer.push((key, val));

                    should_loop = true;

                    idx = idx.wrapping_add(1) % self.entries.len();

                    if added == limit {
                        break;
                    }
                }
                Poll::Ready(None) => {
                    // Remove the entry
                    self.entries.swap_remove(idx);

                    // Check if this was the last entry, if so the cursor needs
                    // to wrap
                    if idx == self.entries.len() {
                        idx = 0;
                    } else if idx < start && start <= self.entries.len() {
                        // The stream being swapped into the current index has
                        // already been polled, so skip it.
                        idx = idx.wrapping_add(1) % self.entries.len();
                    }
                }
                Poll::Pending => {
                    idx = idx.wrapping_add(1) % self.entries.len();
                }
            }
        }

        if !should_loop {
            break;
        }
    }

    if added > 0 {
        Poll::Ready(added)
    } else if self.entries.is_empty() {
        Poll::Ready(0)
    } else {
        Poll::Pending
    }
}

и не теряются. Но это также означает: при уничтоженииwhile added < limitможет уже содержать часть элементов — вызывающая сторона должна это знать.forСтруктура цикла вshould_loop = trueсложнее, чем вlimit, потому что он должен собрать как можно больше за один проход:

📎 tokio-stream/src/stream_map.rs:588-591

rust
/// * `Poll::Pending` if no items are available but the `StreamMap` is not empty.
/// * `Poll::Ready(count)` where `count` is the number of items successfully received and
///   stored in `buffer`. This can be less than, or equal to, `limit`.
/// * `Poll::Ready(0)` if `limit` is set to zero or when the `StreamMap` is empty.

size_hintреализация демонстрирует, как агрегировать подсказки о ёмкости нескольких потоков:

📎 tokio-stream/src/stream_map.rs:685-701

rust
fn size_hint(&self) -> (usize, Option<usize>) {
    let mut ret: (usize, Option<usize>) = (0, Some(0));

    for (_, stream) in &self.entries {
        let hint = stream.size_hint();

        ret.0 = ret.0.saturating_add(hint.0);

        match (ret.1, hint.1) {
            (Some(a), Some(b)) => ret.1 = a.checked_add(b),
            (Some(_), None) => ret.1 = None,
            _ => {}
        }
    }

    ret
}

Такой же шаблон, как иmerge_size_hints: насыщающее сложение для нижней границы, проверяемое сложение для верхней границы, и если любая из них неизвестна, то всё неизвестно.

Ниже приведена блок-схема, иллюстрирующаяpoll_next_entryпуть принятия решений:

mermaid
)
    Pipe->>RtA: событие доступности для чтения
    Pipe->>RtB: событие доступности для чтения
    Note over RtA,RtB: оба Runtime конкурируют за чтение, прочитать байт может только один

---

TaskTracker: кодирование всего состояния в одном AtomicUsize

Интуитивная модель

Для корректного завершения нужны две вещи:Уведомить задачи о необходимости остановиться(CancellationTokenотвечает за это), а такжеДождаться фактического выхода задач(TaskTrackerотвечает за это).TaskTrackerподобен объединению «счётчика задач + переключателя завершения»: он не вернётся, пока есть работающие задачи или пока не вызванclose,wait()Без него пришлось бы использоватьJoinSet, ноJoinSetнакапливает возвращаемые значения каждой задачи, что при длительной работе сервиса приведёт к OOM.

Структура данных и размещение в памяти

TaskTrackerпредставляет собой обёртку надArc:

📎 tokio-util/src/task/task_tracker.rs:158-178

rust
pub struct TaskTracker {
    inner: Arc<TaskTrackerInner>,
}

/// Represents a task tracked by a [`TaskTracker`].
#[must_use]
#[derive(Debug)]
pub struct TaskTrackerToken {
    task_tracker: TaskTracker,
}

struct TaskTrackerInner {
    /// Keeps track of the state.
    ///
    /// The lowest bit is whether the task tracker is closed.
    ///
    /// The rest of the bits count the number of tracked tasks.
    state: AtomicUsize,
    /// Used to notify when the last task exits.
    on_last_exit: Notify,
}

Это самое изящное размещение в памяти в данной главе:ОдинAtomicUsizeодновременно кодирует «закрыто ли» и «количество задач». Младший бит — флаг закрытия, остальные биты — количество задач (поскольку счётчик задач каждый раз+2, младший бит всегда равен 0). Таким образом,is_closed_and_emptyтребует всего одной атомарной загрузки:

📎 tokio-util/src/task/task_tracker.rs:216-222

rust
fn is_closed_and_empty(&self) -> bool {
    // If empty and closed bit set, then we are done.
    //
    // The acquire load will synchronize with the release store of any previous call to
    // `set_closed` and `drop_task`.
    self.state.load(Ordering::Acquire) == 1
}
〔Проектные выводы и архитектурные компромиссы〕

state == 1означает «бит закрытия равен 1, счётчик равен 0». Почему бы не использовать две атомарные переменные? Две переменные требуют двух загрузок и не позволяют атомарно определить «одновременное выполнение обоих условий». Кодирование в одной переменной делаетis_closed_and_emptyоднойAcquireзагрузкой и не требует блокировки на быстром путиwait.

Сценарий-ориентированный разбор: гонка между close и drop_task

Рассмотрим типичный сценарий: главный поток вызываетtracker.close(), одновременно последняя задача завершается (TaskTrackerToken::dropвызываетdrop_task). Оба могут выполняться параллельно, и необходимо гарантировать, что независимо от того, кто первый,wait()будет разбужен.

Сначала рассмотримset_closed:

📎 tokio-util/src/task/task_tracker.rs:225-249

rust
fn set_closed(&self) -> bool {
    // The AcqRel ordering makes the closed bit behave like a `Mutex<bool>` for synchronization
    // purposes. ...
    let state = self.state.fetch_or(1, Ordering::AcqRel);

    // If there are no tasks, and if it was not already closed:
    if state == 0 {
        self.notify_now();
    }

    (state & 1) == 0
}

fetch_or(1, AcqRel)атомарно устанавливает бит закрытия и возвращает старое значение. Если старое значение равно 0 (ранее не закрыто и нет задач), это означает «после закрытия немедленно выполнено условие пусто+закрыто», вызываетсяnotify_now. Возвращаемое значение(state & 1) == 0означает «этот вызов действительно изменил состояние».

Теперь рассмотримdrop_task:

📎 tokio-util/src/task/task_tracker.rs:264-271

rust
fn drop_task(&self) {
    let state = self.state.fetch_sub(2, Ordering::Release);

    // If this was the last task and we are closed:
    if state == 3 {
        self.notify_now();
    }
}

fetch_sub(2, Release)уменьшает счётчик. Если старое значение равно 3 (двоичное11: бит закрытия 1 + счётчик 1), это означает «это последняя задача и уже закрыто», вызываетсяnotify_now。

Анализ гонки двух путей:

  • close выполняется первым:set_closedвидит старое значение2(счётчик 1, не закрыто), не уведомляет. Затемdrop_taskвидит старое значение3, уведомляет. ✓
  • drop_task выполняется первым:drop_taskвидит старое значение2(счётчик 1, не закрыто), не уведомляет. Затемset_closedвидит старое значение0(счётчик 0, не закрыто), уведомляет. ✓
  • Параллельно:fetch_orиfetch_subатомарны, независимо от порядка чередования, всегда найдётся один, который увидит комбинацию «закрыто + пусто» и уведомит. ✓

notify_nowВнутриAcquireесть легко упускаемая

📎 tokio-util/src/task/task_tracker.rs:274-285

rust
#[cold]
fn notify_now(&self) {
    // Insert an acquire fence. This matters for `drop_task` but doesn't matter for
    // `set_closed` since it already uses AcqRel.
    //
    // This synchronizes with the release store of any other call to `drop_task`, and with the
    // release store in the call to `set_closed`. That ensures that everything that happened
    // before those other calls to `drop_task` or `set_closed` will be visible after this load,
    // and those things will also be visible to anything woken by the call to `notify_waiters`.
    self.state.load(Ordering::Acquire);

    self.on_last_exit.notify_waiters();
}

Копироватьdrop_taskПочемуReleaseиспользуетAcqRel, а неdrop_task? Потому чтоfetch_subдляnotify_nowтребует лишь «сделать предыдущие записи видимыми для последующих читателей» (семантика Release), а не «увидеть записи других потоков, сделанные ранее» (семантика Acquire). Ноwait()требует Acquire для установления happens-before: гарантировать, что вся работа по очистке, выполненная до выхода задачи, видна коду после возвратаload. Результат этой

отбрасывается исключительно ради её побочного эффекта на порядок памяти — это типичное использование «fence-подобной загрузки» в атомарных операциях Rust.

waitРазмышления о дизайне: устойчивость wait к ABA и семантика drop у TrackedFutureTaskTrackerWaitFutureвозвращаетNotified:

📎 tokio-util/src/task/task_tracker.rs:318-327

rust
pub fn wait(&self) -> TaskTrackerWaitFuture<'_> {
    TaskTrackerWaitFuture {
        future: self.inner.on_last_exit.notified(),
        inner: if self.inner.is_closed_and_empty() {
            None
        } else {
            Some(&self.inner)
        },
    }
}

КопироватьinnerОбратите внимание на полеNone,poll: если при создании уже «закрыто и пусто», оно сразу устанавливается вReadyи немедленно возвращает

. Это быстрый путь.

📎 tokio-util/src/task/task_tracker.rs:304-307

rust
/// The `wait` future is resistant against [ABA problems][aba]. That is, if the `TaskTracker`
/// becomes both closed and empty for a short amount of time, then it is guarantee that all
/// `wait` futures that were created before the short time interval will trigger, even if they
/// are not polled during that short time interval.

КопироватьNotify::notified()Эта гарантия следует из семантикиNotified:notify_waitersfuture при создании регистрирует себя как «ожидающий», поэтому даже еслиpollбыл вызван до того, как он былpoll, он увидит уведомление при первомTaskTrackerWaitFuture::poll.

📎 tokio-util/src/task/task_tracker.rs:697-712

rust
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
    let me = self.project();

    let inner = match me.inner.as_ref() {
        None => return Poll::Ready(()),
        Some(inner) => inner,
    };

    let ready = inner.is_closed_and_empty() || me.future.poll(cx).is_ready();
    if ready {
        *me.inner = None;
        Poll::Ready(())
    } else {
        Poll::Pending
    }
}

:pollКопироватьis_closed_and_empty()Каждый разpoll Notifiedсначала проверяетNotified, затем

TrackedFuture. Этот порядок гарантирует: даже еслиTaskTrackerпо какой-то причине не был разбужен, проверка состояния послужит запасным вариантом.JoinSetСемантика drop у

📎 tokio-util/src/task/task_tracker.rs:488-494

rust
/// The task is removed from the collection when it is dropped, not when [`poll`] returns
/// [`Poll::Ready`].

иReady:TrackedFutureКопироватьTaskTrackerЭто означает: даже если future уже вернул

📎 tokio-util/src/task/task_tracker.rs:33-35

rust
/// When a call to [`wait`] returns, it is guaranteed that all tracked tasks have exited and that
/// the destructor of the future has finished running. However, there might be a short amount of
/// time where [`JoinHandle::is_finished`] returns false.

TaskTrackerTokenсам ещё не был drop,Dropсчитает, что задача всё ещё выполняется. В документации объясняется, почему этот дизайн важен:

📎 tokio-util/src/task/task_tracker.rs:670-672

rust
impl Drop for TaskTrackerToken {
    /// Dropping the token indicates to the [`TaskTracker`] that the task has exited.
    #[inline]
    fn drop(&mut self) {
        self.task_tracker.inner.drop_task();
    }
}

TrackedFutureдляpin_project!является точкой запуска уменьшения счётчика:tokenКопироватьfutureчерезtokenупаковываетspawn_blockingи

📎 tokio-util/src/task/task_tracker.rs:452-464

, и drop

автоматически запускает уменьшение счётчика. же управляет токеном явно: На этом StreamExt превращает poll_next в композируемый итератор, StreamMap и TaskTracker дают принадлежность динамическим наборам задач, а CancellationToken с помощью дерева распространяет сигнал отмены на всё дерево задач. Общая черта этих трёх расширений: они не вводят новых примитивов планирования, а перекомбинируют существующие механизмы — Waker, Notify и атомарные счётчики — в абстракции более высокого уровня. Но тут возникает ключевой вопрос: когда эти комбинаторы, наборы задач и дерево отмены выполняются параллельно на одном планировщике, как гарантировать, что какая-то задача не будет голодать из-за длительного невысвобождения других задач? В следующей главе мы углубимся в механизм кооперативного бюджета coop в Tokio, чтобы увидеть, как каждая задача расходует бюджет в течение одного цикла планирования, как по исчерпании она добровольно уступает, и как budget передаётся через локальное хранилище потока, решая эту классическую проблему.
CHAPTER 12

Глава 12: Кооперативное планирование и бюджет: как coop предотвращает голодание планировщика

Проект: tokio-rs/tokio · Прогресс книги: Глава 12 / 14 · Статус проверки: FACT — реальные привязки к номерам строк

В предыдущей главе мы увидели, как tokio-stream и tokio-util повторно используют низкоуровневые Waker и механизм планирования для расширения базовых возможностей. Но сколько бы комбинаторов ни было создано, основное противоречие асинхронного рантайма остаётся: планировщик должен справедливо распределять процессорное время между задачами, а сами задачи не являются вытесняемыми — как только poll некоторого Future начинает выполняться, планировщик не может прервать его извне. Если задача в одном poll обрабатывает в цикле сто тысяч сообщений или в цикле многократно ожидает Future, который всегда готов, она монополизирует рабочий поток, и другие задачи на том же потоке никогда не получат возможности быть опрошенными. Это и есть классическая проблема «голодания планировщика задачами». Решение Tokio — не вытеснение, а кооперация: каждой задаче на один цикл планирования выделяется ограниченный бюджет, ресурсные операции расходуют бюджет, и после его исчерпания задача обязана добровольно уступить. В этой главе мы глубоко разберём реализацию этого механизма coop.

12.1 Носитель бюджета: локальное хранилище потока и структура Budget

〔Проектные предположения и архитектурные компромиссы〕

Если представить планировщик единственным официантом в ресторане, а задачи — постоянно заказывающими блюда посетителями, то бюджет coop — это правило «каждый посетитель может заказать не более N блюд»: официанту не нужно силой прерывать посетителя, достаточно после N блюд сказать «отдохните немного, я обслужу следующего». Без этого правила один болтливый посетитель парализует весь ресторан.

Бюджет должен удовлетворять двум ограничениям: во-первых, он должен быть доступен из стека вызововpollлюбой глубины без передачи параметров через все уровни; во-вторых, он должен различать «находимся ли мы сейчас внутри рантайма Tokio» — вызов вне рантаймаblock_onне должен ограничиваться бюджетом. Tokio выбрал для хранения бюджеталокальное хранилище потока (TLS)и управляет им через модульcontext.

Основной тип бюджета —coop::Budget. Хотя фрагмент исходного кода в этой главе не приводит полное определениеcoop.rs, из точек использованияworker.rsможно восстановить его интерфейсный контракт:

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:695-795

rust
coop::budget(|| {
    // ... 轮询任务 ...
    task.run();
    // ...
    loop {
        // ...
        if !coop::has_budget_remaining() {
            // 预算耗尽,把 LIFO 任务推回队列
            core.run_queue.push_back_or_overflow(task, ...);
            return ControlFlow::Continue(core);
        }
        // ...
    }
})

Здесь появляются три ключевых API:coop::budget(closure)создаёт область действия бюджета,coop::has_budget_remaining()запрашивает оставшийся бюджет, а также упомянутые далееcoop::stop()иcoop::set()。budgetСемантика такова: при входе в замыкание бюджет текущего потока сбрасывается до полного значения (по умолчанию 128), во время выполнения замыкания все ресурсные операции совместно используют этот лимит, а при выходе из замыкания восстанавливается внешний бюджет.

〔Проектные предположения и архитектурные компромиссы〕

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

BudgetВ TLS обычно существует в формеCell<Option<Budget>>.OptionВнешняя семантикаNoneозначает «находится ли текущий поток в контексте рантайма Tokio»:block_onозначает, что мы не внутри рантайма (например, вызов вне рантайма

), и тогда все проверки бюджета пропускаются. 12.2 Точки расходования бюджета: как ресурсные операции его вычитают

Бюджет не расходуется сам по себе, толькоресурсные операцииего уменьшают. Под ресурсными операциями понимаются API, которые могут вызываться в бесконечном цикле и взаимодействуют с внешним миром —send/recvу channel, чтение/запись I/O,yield_nowи т. д. Возьмёмmpsc::Sender::reserve— это общая точка входа для всех путей отправки:

📎 tokio/src/sync/mpsc/bounded.rs:1272-1311

rust
async fn reserve_inner(&self, n: usize) -> Result<(), SendError<()>> {
    crate::trace::async_trace_leaf().await;

    if n > self.max_capacity() {
        return Err(SendError(()));
    }
    // ... WakeReceiverOnDrop guard ...
    let guard = WakeReceiverOnDrop { chan: &self.chan };
    let result = self.chan.semaphore().semaphore.acquire(n).await;
    // ...
}

reserve_innerПеред фактическим получением разрешения семафораcrate::trace::async_trace_leaf()проходит черезasync_trace_leaf. Этот вызов, выглядящий как обычная трассировка, на самом деле является одной из точек привязки вычитания бюджета.coop::poll_proceedВнутри вызывается функция вродеProceed: если бюджета достаточно, вычитается 1 и возвращаетсяPending; если бюджет исчерпан, регистрируется действие «уступки» — Waker текущей задачи передаётся планировщику и возвращается

, что заставляет задачу досрочно завершить этот poll.В этом и заключается изящество coop:Pendingисчерпание бюджета — не ошибка, а маскировка «уступки» под обычныйPending. Верхний Future, увидев

yield_now, естественно возвращается, планировщик ставит задачу обратно в очередь, и при следующем планировании бюджет уже сброшен, а задача продолжает с места прерывания. Весь процесс полностью прозрачен для бизнес-кода.— самое прямое воплощение механизма бюджета: он не расходует бюджет, а:

📎 tokio/src/task/yield_now.rs:38-60

rust
pub async fn yield_now() {
    let mut yielded = false;
    poll_fn(|cx| {
        ready!(crate::trace::trace_leaf());

        if yielded {
            return Poll::Ready(());
        }

        yielded = true;

        // Don't wake the task immediately, as that would push it right back
        // onto the run queue and it could be polled again before other tasks
        // or the IO/timer driver get a chance to run. Instead, hand the waker
        // to the scheduler, which wakes deferred tasks only after it has run
        // out of ready tasks and polled the driver. When polled from outside
        // a Tokio runtime, the waker is woken immediately.
        context::defer(cx.waker());

        Poll::Pending
    })
    .await
}

Копироватьcontext::defer(cx.waker())Обратите внимание на строкуwake. Здесь нет прямого, вместо этого Waker передаётся вочередь defer

планировщика. Почему? Комментарий в исходном коде объясняет ясно: при немедленном пробуждении задача сразу вернётся в очередь выполнения и может быть опрошена снова до того, как отработают драйверы I/O/timer, что лишает уступку смысла. Семантика очереди defer — «разбудить эти задачи после того, как текущий worker выполнит все готовые задачи и опросит драйверы».ContextОчередь defer определена в

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:247-257

rust
pub(crate) struct Context {
    worker: Arc<Worker>,
    core: RefCell<Option<Box<Core>>>,
    /// Tasks to wake after resource drivers are polled. This is mostly to
    /// handle yielded tasks.
    pub(crate) defer: Defer,
}

deferКопировать

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:613-621

rust
} else {
    // Wait for work
    core = if !self.defer.is_empty() {
        self.park_yield(core)
    } else {
        self.park(core)
    };
    core.stats.start_processing_scheduled_tasks();
}

Если очередь defer не пуста, worker вызываетpark_yield— park с нулевым таймаутом, что запускает I/O и таймеры, а затем пробуждает задачи в defer. Это гарантирует, что «уступленная» задача будет перепланирована только после того, как драйвер отработает.

12.3 Создание и восстановление области бюджета: run_task и block_in_place

Область бюджета создаётся вrun_task. При опросе каждой задачиcoop::budgetоборачивает весь процесс опроса:

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:691-704

rust
// Make the core available to the runtime context
*self.core.borrow_mut() = Some(core);

// Run the task
coop::budget(|| {
    // ...
    task.run();
    // ...
})

coop::budgetПри входе устанавливает бюджет в TLS на полный, при выходе восстанавливает. Это означает, чтокаждая задача при каждом опросе получает совершенно новый бюджет. Внутри задачи, независимо от того, сколько разawaitвыполнялись операции с ресурсами, если за одинpollпотребление превысило 128, задача будет принудительно уступлена.

Но здесь есть тонкая проблема: задачи в LIFO slot опрашиваются внутритого же самогоbudgetзамыкания. Посмотрим на циклrun_task:

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:709-750

rust
let mut lifo_polls = 0;

// As long as there is budget remaining and a task exists in the
// `lifo_slot`, then keep running.
loop {
    let mut core = match self.core.borrow_mut().take() {
        Some(core) => core,
        None => {
            return ControlFlow::Break(());
        }
    };

    let task = match core.lifo_slot.take() {
        Some(task) => task,
        None => {
            self.reset_lifo_enabled(&mut core);
            core.stats.end_poll();
            return ControlFlow::Continue(core);
        }
    };

    if !coop::has_budget_remaining() {
        core.stats.end_poll();
        // Not enough budget left to run the LIFO task, push it to
        // the back of the queue and return.
        core.run_queue.push_back_or_overflow(task, ...);
        debug_assert!(core.lifo_enabled);
        return ControlFlow::Continue(core);
    }
    // ...
}

Ключевой момент: задачи в LIFO slotразделяют бюджет внешней задачи. Комментарий в началеrun_taskгласит: «Tasks from the LIFO slot inherit the "parent"'s limits». Это намеренное решение — если бы каждая LIFO-задача сбрасывала бюджет, то в сценарии ping-pong (задача A пробуждает B, B пробуждает A) две задачи бесконечно планировали бы друг друга, бюджет никогда бы не сбрасывался, и проблема голодания сохранялась бы. Общий бюджет означает, что A и B вместе могут потребить максимум 128 операций с ресурсами, после чего обязаны уступить.

У самого LIFO slot также есть независимый ограничительMAX_LIFO_POLLS_PER_TICK:

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:756-766

rust
// Disable the LIFO slot if we reach our limit
//
// In ping-ping style workloads where task A notifies task B,
// which notifies task A again, continuously prioritizing the
// LIFO slot can cause starvation as these two tasks will
// repeatedly schedule the other. To mitigate this, we limit the
// number of times the LIFO slot is prioritized.
if lifo_polls >= MAX_LIFO_POLLS_PER_TICK {
    core.lifo_enabled = false;
    super::counters::inc_lifo_capped();
}

MAX_LIFO_POLLS_PER_TICKЗначение

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:263-263

rust
/// Value picked out of thin-air. Running the LIFO slot a handful of times
/// seems sufficient to benefit from locality. More than 3 times probably is
/// over-weighting. The value can be tuned in the future with data that shows
/// improvements.
const MAX_LIFO_POLLS_PER_TICK: usize = 3;

равно 3:Этовторая линия защиты

: даже если бюджет ещё не исчерпан, LIFO slot после 3 последовательных приоритетных использований будет отключён, и последующие задачи пойдут в обычную очередь. Бюджет управляет «общим объёмом операций с ресурсами», ограничитель LIFO управляет «количеством взаимных пробуждений одной и той же пары задач» — они дополняют друг друга.block_in_placeУ области бюджета вblock_in_placeесть одно важное исключение.передаёт worker core другому потоку, текущий поток переходит в блокированное состояние. Блокирующий код не подчиняется бюджету, поэтому необходимоприостановить

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:406-417

rust
if had_entered {
    // Unset the current task's budget. Blocking sections are not
    // constrained by task budgets.
    let _reset = Reset {
        take_core,
        budget: coop::stop(),
    };

    crate::runtime::context::exit_runtime(f)
} else {
    f()
}

coop::stop()КопироватьNoneвозвращает текущий бюджет и устанавливает его вReset(то есть «вне runtime»),Dropа

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:374-397

rust
impl Drop for Reset {
    fn drop(&mut self) {
        with_current(|maybe_cx| {
            if let Some(cx) = maybe_cx {
                if self.take_core {
                    let core = cx.worker.core.take();
                    // ...
                    *cx_core = core;
                }

                // Reset the task budget as we are re-entering the
                // runtime.
                coop::set(self.budget);
            }
        });
    }
}

coop::set(self.budget)Копироватьstop()восстанавливает ранее сохранённыйblock_in_placeбюджет. Таким образом, синхронный блокирующий код внутри

не потребляет бюджет и не вызывает ложное уступление из-за исчерпания бюджета; после завершения блокировки задача продолжает выполнение с прежним остатком бюджета.

mermaid
flowchart TD
    start["Context::run главный цикл"] --> next["core.next_task()"]
    next --> has_task{"есть локальная задача?"}
    has_task -->|да| run_task["run_task(task, core)"]
    has_task -->|нет| steal["core.steal_work()"]
    steal --> stolen{"удалось украсть задачу?"}
    stolen -->|да| run_task
    stolen -->|нет| defer_check{"очередь defer не пуста?"}
    defer_check -->|да| park_yield["park_yield: пробудить после обработки IO/timer"]
    defer_check -->|нет| park["park: блокирующее ожидание"]
    park_yield --> start
    park --> start

    run_task --> budget["coop::budget создаёт полный бюджет"]
    budget --> poll["task.run() опрос"]
    poll --> lifo_check{"в lifo_slot есть задача?"}
    lifo_check -->|нет| done["вернуть ControlFlow::Continue"]
    lifo_check -->|да| budget_rem{"coop::has_budget_remaining()?"}
    budget_rem -->|нет| push_back["push_back_or_overflow вернуть в очередь"]
    push_back --> done
    budget_rem -->|да| lifo_limit{"lifo_polls >= 3?"}
    lifo_limit -->|да| disable["core.lifo_enabled = false"]
    lifo_limit -->|нет| poll_lifo["task.run() опрос LIFO-задачи"]
    disable --> poll_lifo
    poll_lifo --> lifo_check
    done --> start

Копироватьpush_back_or_overflowНа диаграмме видны два пути уступки: при исчерпании бюджета LIFO-задача возвращается в очередь (

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

12.4 Размышления о дизайне, восстановление после ошибок и подводные камни в продакшенеПочему используется TLS, а не явная передача параметров?BudgetТочки проверки бюджета разбросаны в глубине различных модулей — channel, I/O, time и т.д. При явной передаче параметров каждый API должен был бы иметь дополнительный#[thread_local]параметр, что загрязнило бы весь публичный интерфейс. TLS делает бюджет полностью прозрачным для бизнес-кода, ценой одного обращения к TLS при каждой проверке. Tokio использует

или платформенно-специфичный быстрый TLS, чтобы снизить эти накладные расходы.Взаимодействие исчерпания бюджета с безопасностью отмены.reserve_innerКогда исчерпание бюджета приводит к тому, чтоPendingвозвращаетselect!, задача может находиться в одной из ветвейselect!. Если в этот момент другая ветвь готова,reserve_innerотменит текущую ветвь —WakeReceiverOnDropguard в

📎 tokio/src/sync/mpsc/bounded.rs:1286-1299

rust
struct WakeReceiverOnDrop<'a, T> {
    chan: &'a chan::Tx<T, Semaphore>,
}

impl<T> Drop for WakeReceiverOnDrop<'_, T> {
    fn drop(&mut self) {
        use chan::Semaphore;

        let semaphore = self.chan.semaphore();
        if semaphore.is_closed() && semaphore.is_idle() {
            self.chan.wake_rx();
        }
    }
}

КопироватьPendingНаличие этого guard показывает: вызванный бюджетомPendingи настоящий «нет разрешения»

на пути отмены должны вести себя одинаково, иначе принимающая сторона может никогда не получить уведомление «channel закрыт».Подводные камни в продакшене: скрытые задержки из-за исчерпания бюджета.spawnЧастое явление: некоторая задача внезапно начинает обрабатывать сообщения медленнее, но загрузка CPU невысока. При диагностике легко заподозрить конкуренцию за блокировки или I/O, но на самом деле задача могла обработать более 128 сообщений за один poll, вызвав уступку по бюджету, и каждая уступка требует полного цикла «возврат в очередь → перепланирование → опрос драйвера». Если обработка сообщений сама по себе быстрая, эти накладные расходы на планирование могут занимать большую долю. Решение — разбить массовую обработку на несколькоyield_now。

задач или явно вставить в циклblock_in_placeГраница между бюджетом и.block_in_placeРанее мы видели, чтоcoop::stop()приостанавливает бюджет. Но обратите внимание:coop::stop()вызывается только когдаhad_enteredистинно, то есть только когда действительно находимся на worker-потоке runtime. Еслиblock_in_placeвызывается вне runtime,f()выполняется напрямую, состояние бюджета не меняется. Эта проверка ветвления выполняется вmaybe_move_runtime:

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:424-464

rust
with_current(|maybe_cx| {
    match (
        crate::runtime::context::current_enter_context(),
        maybe_cx.is_some(),
    ) {
        (context::EnterRuntime::Entered { .. }, true) => {
            had_entered = true;
        }
        (
            context::EnterRuntime::Entered {
                allow_block_in_place,
            },
            false,
        ) => {
            if allow_block_in_place {
                had_entered = true;
                return Ok(());
            } else {
                return Err(
                    "can call blocking only when running on the multi-threaded runtime",
                );
            }
        }
        (context::EnterRuntime::NotEntered, true) => {
            return Ok(());
        }
        (context::EnterRuntime::NotEntered, false) => {
            return Ok(());
        }
    }
    // ...
})

Четыре комбинации соответствуют: внутри worker-потока,block_onвход в пул потоковblock_in_place, вложенный

, вне runtime. Только первые две требуют приостановки бюджета и передачи core.

〔Проектные предположения и архитектурные компромиссы〕Значение бюджета не конфигурируемо.Builderвариант. Это сделано намеренно: значение бюджета влияет на компромисс между справедливостью планирования и пропускной способностью. Если позволить пользователям произвольно его настраивать, легко получить конфигурацию, где «слишком большой бюджет приводит к голоданию» или «слишком маленький бюджет приводит к взрывным накладным расходам на планирование». Tokio решил сделать его внутренним инвариантом.

Резюме главы

Механизм coop решает проблему справедливости невытесняющего планировщика с помощью трёхуровневого дизайна:

1. Носитель бюджета:coop::Budgetхранится в TLS,Optionвнешний уровень различает нахождение внутри и вне runtime,coop::budgetсоздаёт область с полным бюджетом,coop::stop/coop::setподдерживает приостановку и возобновление (block_in_placeсценарий).

2. Точки потребления: ресурсные операции (отправка/получение через channel, I/O,yield_now) черезcoop::poll_proceedуменьшают бюджет, а при исчерпании маскируют «уступку» подPending, прозрачно для бизнес-логики.

3. Путь уступки:yield_nowчерезcontext::deferпередаёт Waker в очередь defer, гарантируя, что перепланирование произойдёт только после опроса драйвера; задачи в LIFO slot разделяют бюджет родительской задачи и имеют независимое ограничениеMAX_LIFO_POLLS_PER_TICK = 3.

Ключевое озарение этого механизма:справедливость не требует вытеснения, достаточно лишь естественного прерывания «бесконечного цикла» после конечного числа шагов. Бюджет — это мера такого «конечного числа шагов».

Вопросы для размышления и самопроверки в этой главе

Q1: Если вrun_taskвнутри замыканияcoop::budgetизменить цикл LIFO так, чтобы перед каждым опросом LIFO-задачи вызывалсяcoop::budgetдля сброса бюджета, что произойдёт в сценарии ping-pong (задача A пробуждает B, B пробуждает A)? Почему исходный код выбирает разделение бюджета родительской задачи для LIFO-задач?

Разбор ответа: В комментариях кrun_taskявно сказано: «Tasks from the LIFO slot inherit the "parent"'s limits»📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:679-682. Если бы каждая LIFO-задача сбрасывала бюджет, то в сценарии ping-pong A→B→A→B каждый опрос получал бы полный бюджет, и две задачи могли бы бесконечно перепланировать друг друга, никогда не уступая из-за исчерпания бюджета. Хотя ограничениеMAX_LIFO_POLLS_PER_TICK = 3отключит LIFO slot после 3 раз📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:756-766, после отключения LIFO задачи пойдут в обычную очередь, и если в очереди только A и B, они всё равно будут поочерёдно планироваться, просто без приоритета LIFO. Разделяемый бюджет же ограничивает общий объём ресурсных операций: A и B вместе могут выполнить не более 128 ресурсных операций, после чего обязаны уступить, давая возможность другим задачам и драйверу. Две линии защиты дополняют друг друга, и ни одну нельзя убрать.

Q2: yield_nowиспользуетcontext::defer(cx.waker())вместоcx.waker().wake_by_ref(). Предположим,deferзаменён на прямойwake, в сценарии с одним worker и множеством задач: каковы будут последствия, если задача в цикле многократно вызываетyield_now? Проанализируйте с учётом веткиpark_yieldглавного цикла worker.

Разбор ответа:yield_now: Комментарии к📎 tokio/src/task/yield_now.rs:49-54объясняют причину: прямой wake немедленно возвращает задачу в очередь выполнения, и она может быть опрошена снова до запуска драйвера I/O/timeryield_now. В сценарии с одним worker, если задача в цикле многократноnext_taskи каждый раз напрямую wake, главный цикл workerpark_yieldнемедленно возьмёт эту задачу и опросит её снова,📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:613-621ветка (отвечающая за драйвер I/O и timer)deferникогда не будет выполнена, потому что очередь defer пуста, а в локальной очереди всегда есть задачи. В результате события I/O и timer никогда не обрабатываются, и весь runtime «имитирует жизнь» — задачи выполняются, но события внешнего мира не могут продвинуться.

Q3: block_in_placeОчередьcoop::stop()гарантирует, что уступившая задача будет пробуждена только после опроса драйвера, тем самым давая драйверу окно для выполнения.None,Reset::dropВcoop::set(self.budget)устанавливает бюджет вblock_in_placeвосстанавливает вf. Если внутри замыканияblock_in_placeснова вызватьmaybe_move_runtime(вложенный вызов), что произойдёт с состоянием бюджета?

Какая веткаобрабатывает эту ситуацию?block_in_placeРазбор ответаmaybe_move_runtime: Вложенный(context::EnterRuntime::NotEntered, true)обрабатывается веткой📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:454-458вreturn Ok(()). Эта ветка напрямуюhad_entered, не устанавливаетblock_in_place, поэтому внешняя проверкаif had_enteredвcoop::stop()даёт ложь, и повторный вызовResetили создание новогоf()не происходит. Комментарий поясняет: «This is a nested call to block_in_place (we already exited). All the necessary setup has already been done.» — внешний уровень уже приостановил бюджет и передал core, внутреннему остаётся лишь напрямую выполнитьcoop::stop(). Если бы внутренний уровень сноваNone, он сохранил бы бюджет, который уже являетсяReset::drop, и при восстановленииNoneмог бы восстановиться в неверное значение (

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

CHAPTER 13

Глава 13: Ловушки в продакшене: безопасность отмены, паники и порядок завершения

Глава 13: Производственные подводные камни и граничные условия: безопасность отмены, распространение panic и порядок завершения · Проект: tokio-rs/tokio · Прогресс книги: Глава 13 / 14

В предыдущей главе мы разобрали бюджет кооперации coop: каждая задача в течение одного цикла планирования имеет ограниченный бюджет, исчерпав который она обязана уступить, что предотвращает голодание других задач из-за одной задачи. Однако механизм бюджета решает лишь проблему «справедливого планирования». В реальной производственной среде существует ещё один класс более скрытых ловушек — безопасность отмены, распространение panic и порядок завершения. Когда select! отменяет Future, когда panic задачи перехватывается, когда Runtime начинает завершение, граничное поведение кода часто противоречит интуиции. В этой главе мы начнём с безопасности отмены и сначала посмотрим, что именно теряется у Future, подвергнутого drop.

13.2 Распространение panic: как JoinError перехватывает сбои

Интуитивная модель

Panic в задаче Tokio не приводит к падению всего процесса (если только panic=abort), а перехватывается, упаковывается вJoinError, возвращается черезJoinHandle::await. Это как авария на одном из рабочих мест сборочной линии: страховочная сеть ловит рабочего, но продукт утилизируется — вы получаете «отчёт об аварии», а не продукт.

Структура данных и состояния

JoinHandle<T>УFuture::Output— этоsuper::Result<T>, то естьResult<T, JoinError> 📎 tokio/src/runtime/task/join.rs:325。JoinErrorимеет две формы: panic и cancelled. Пример из документации демонстрирует сценарий panic:

rust
let join_handle = tokio::spawn(async { panic!("boom"); });
let err = join_handle.await.unwrap_err();
assert!(err.is_panic());

📎 tokio/src/runtime/task/join.rs:121-127

Механизм перехвата panic находится в пути pollRawTask: при poll задачи используетсяcatch_unwindдля обёртки; после возникновения panic payload сохраняется в выходной слот задачи, состояние помечается как complete, затем пробуждается join waker.JoinHandle::pollЧерезtry_read_outputчитаетсяErr(JoinError::panic(payload))。

Пошаговый разбор на основе сценария: цепочка распространения panic

mermaid
sequenceDiagram
    participant App as Прикладная задача
    participant Worker as Поток Worker
    participant Raw as RawTask
    participant JH as JoinHandle

    App->>Worker: spawn(async { panic!("boom") })
    Worker->>Raw: опрос Future задачи
    Raw->>Raw: catch_unwind перехватывает panic
    Raw->>Raw: сохранение panic payload в выходной слот
    Raw->>Raw: state помечается как complete
    Raw->>JH: пробуждение join waker
    JH->>App: await возвращает Err(JoinError::panic)

Ключевой момент: payload panic сохраняется полностью,JoinErrorреализуетstd::error::Error, можно черезinto_panic()извлечьBox<dyn Any + Send>, а затем с помощьюdowncast_ref::<&str>()извлечь сообщение panic.

Проектные соображения и подводные камни

Камень 1:JoinHandleуUnwindSafeреализован вручную.

rust
impl<T> UnwindSafe for JoinHandle<T> {}
impl<T> RefUnwindSafe for JoinHandle<T> {}

📎 tokio/src/runtime/task/join.rs:176-181

Это безусловная реализация, не требующаяT: UnwindSafe. Причина:JoinHandleсам по себе не содержитT,TВ размещении задачи в куче при panic уже был изолированcatch_unwind. Поэтому даже еслиTне являетсяUnwindSafe,JoinHandle, это безопасно.

Камень 2: panic не распространяется на родительскую задачу автоматически.Если задача A породила задачу B, и B запаниковала, A не получит уведомления автоматически, если только A не ожидалаJoinHandleB. Если A не ожидала, panic B молча проглатывается. Это один из самых скрытых источников багов в производственной среде.

Камень 3:spawn_blockingpanic также перехватывается.Рабочий поток пула блокирующих потоков также оборачивает задачу вcatch_unwind; после panic поток не умирает, а возвращается в пул и продолжает брать работу. Но если вы удерживаетеMutexв блокирующей задаче и не освобождаете её при panic, это приведёт к отравлению блокировки — этоstd::sync::Mutexвнутреннее поведение, Tokio не вмешивается.

Камень 4: panic при drop Runtime.Если задача паникует во время drop Runtime,catch_unwindвсё ещё срабатывает, но в этот момент join waker может быть уже недействителен, и payload panic будет отброшен. Это подмножество проблемы порядка завершения, рассматривается в следующем разделе.

13.3 Порядок завершения: очистка блокирующих потоков и ресурсов I/O

Интуитивная модель

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

Структура данных и путь завершения

RuntimeТри поля

rust
pub struct Runtime {
    scheduler: Scheduler,
    handle: Handle,
    blocking_pool: BlockingPool,
}

📎 tokio/src/runtime/runtime.rs:97-106

DropКопировать

rust
impl Drop for Runtime {
    fn drop(&mut self) {
        match &mut self.scheduler {
            Scheduler::CurrentThread(current_thread) => {
                let _guard = context::try_set_current(&self.handle.inner);
                current_thread.shutdown(&self.handle.inner);
            }
            Scheduler::MultiThread(multi_thread) => {
                multi_thread.shutdown(&self.handle.inner);
            }
        }
    }
}

📎 tokio/src/runtime/runtime.rs:506-521

КопироватьDropПримечание:scheduler,обрабатывает толькоblocking_pool。blocking_poolявно не обрабатываетDropзавершение происходит в его собственномRuntime::drop, после возвратаscheduler → handle → blocking_poolзапускается порядком drop полей. Порядок drop полей — это порядок объявления:

. Поэтому блокирующий пул завершается последним.shutdown_timeoutНо

rust
pub fn shutdown_timeout(mut self, duration: Duration) {
    self.handle.inner.shutdown();
    self.blocking_pool.shutdown(Some(duration));
}

📎 tokio/src/runtime/runtime.rs:457-461

Копироватьhandle.inner.shutdown()Сначалаblocking_pool.shutdown(Some(duration))уведомляет планировщик и драйвер I/O об остановке, затемduration。

ожидает блокирующие задачи, максимум

blocking/shutdown.rsМеханизм завершения блокирующего пула

rust
pub(super) struct Sender {
    _tx: Arc<oneshot::Sender<()>>,
}

pub(super) struct Receiver {
    rx: oneshot::Receiver<()>,
}

📎 tokio/src/runtime/blocking/shutdown.rs:13-19

КопироватьSenderКаждый блокирующий worker держит клонArc<oneshot::Sender>(внутриSender). Когда все worker'ы завершаются и всеReceiverподвергаются drop,waitполучает уведомление.

rust
pub(crate) fn wait(&mut self, timeout: Option<Duration>) -> bool {
    use crate::runtime::context::try_enter_blocking_region;

    if timeout == Some(Duration::from_nanos(0)) {
        return false;
    }

    let mut e = match try_enter_blocking_region() {
        Some(enter) => enter,
        _ => {
            if std::thread::panicking() {
                return false;
            } else {
                panic!(
                    "Cannot drop a runtime in a context where blocking is not allowed. \
                    This happens when a runtime is dropped from within an asynchronous context."
                );
            }
        }
    };

    if let Some(timeout) = timeout {
        e.block_on_timeout(&mut self.rx, timeout).is_ok()
    } else {
        let _ = e.block_on(&mut self.rx);
        true
    }
}

📎 tokio/src/runtime/blocking/shutdown.rs:37-70

Копировать

1. timeout == Some(0)Пошаговый разбор:shutdown_backgroundсразу возвращает false — это путь

2. try_enter_blocking_region(), без ожидания.None。

пытается войти в блокирующую область. Если текущий контекст асинхронный (например, drop Runtime внутри async-задачи), возвращает

3. При неудачном входе, если происходит panic, возвращает false (не паниковать во время panic); иначе паникует с чётким сообщением об ошибке.block_on_timeout4. При наличии timeout используется

, при тайм-ауте возвращает false; без timeout ожидает бесконечно.

mermaid
flowchart TD
    start["Runtime::drop или shutdown_timeout"] --> sched{"тип scheduler?"}
    sched -->|CurrentThread| ct["try_set_current + current_thread.shutdown"]
    sched -->|MultiThread| mt["multi_thread.shutdown"]
    ct --> handle_drop["drop поля handle"]
    mt --> handle_drop
    handle_drop --> bp_drop["drop поля blocking_pool"]
    bp_drop --> bp_wait{"shutdown_timeout был вызван?"}
    bp_wait -->|да| explicit["blocking_pool.shutdown(Some(duration))"]
    bp_wait -->|нет| implicit["BlockingPool::drop ожидание по умолчанию"]
    explicit --> wait_check{"try_enter_blocking_region успешно?"}
    implicit --> wait_check
    wait_check -->|нет и в panic| skip["вернуть false, не ждать"]
    wait_check -->|нет и не в panic| panic_err["panic: Cannot drop a runtime in async context"]
    wait_check -->|да| block_on["block_on ожидает drop всех Sender"]

Копировать

Проектные соображения и подводные камниСообщение об ошибке очень чёткое: «Cannot drop a runtime in a context where blocking is not allowed»📎 tokio/src/runtime/blocking/shutdown.rs:51-54. Решение — использоватьshutdown_background(), что эквивалентноshutdown_timeout(Duration::from_nanos(0)) 📎 tokio/src/runtime/runtime.rs:494-496, без ожидания блокирующих задач.

Ловушка 2:shutdown_backgroundприводит к утечке блокирующих задач.Документация явно предупреждает: «this may result in a resource leak (in that any blocking tasks are still running until they return)»📎 tokio/src/runtime/runtime.rs:470-472. Блокирующие задачи продолжат выполняться до естественного возврата, но Runtime уже уничтожен, и ресурсы, которые они удерживают, могут стать недействительными.

Ловушка 3: ресурсы ввода-вывода становятся недействительными после уничтожения Runtime.Документация поясняет: «Once the runtime has been dropped, any outstanding I/O resources bound to it will no longer function»📎 tokio/src/runtime/runtime.rs:52-54。is_rt_shutdown_errФункция предназначена для обнаружения таких ошибок📎 tokio/src/runtime/runtime.rs:585-593。

Ловушка 4:Dropпо умолчанию ожидает бесконечно.Документация указывает: «TheDrop implementation waits forever for this」📎 tokio/src/runtime/runtime.rs:43-44. Если блокирующая задача зависнет (например, бесконечный цикл), уничтожение Runtime приведёт к вечному зависанию. В production следует использоватьshutdown_timeoutс установкой лимита.

13.4 Обработка сигналов и конфликты между несколькими Runtime

Интуитивная модель

Unix-сигналы являются процессными, но TokioSignalпривязан к Runtime. Это как если бы в здании был один общий пожарный звонок, но в каждой комнате стоял отдельный приёмник — первый, кто установил приёмник, изменил схему подключения звонка, и все остальные вынуждены пользоваться этим изменением.

Структуры данных и глобальное состояние

signal_enable— это точка входа для регистрации обработчика сигналов:

rust
fn signal_enable(signal: SignalKind, handle: &Handle) -> io::Result<()> {
    let signal = signal.0;
    if signal <= 0 || signal_hook_registry::FORBIDDEN.contains(&signal) {
        return Err(Error::other(format!(
            "Refusing to register signal {signal}"
        )));
    }

    handle.check_inner()?;

    let globals = globals();
    let siginfo = match globals.storage().get(signal as EventId) {
        Some(slot) => slot,
        None => return Err(io::Error::other("signal too large")),
    };

    siginfo
        .init
        .get_or_init(|| {
            unsafe { signal_hook_registry::register(signal, move || action(globals, signal)) }
                .map(|_| ())
                .map_err(|e| e.raw_os_error())
        })
        .map_err(|e| {
            e.map_or_else(
                || Error::other("registering signal handler failed"),
                || Error::from_raw_os_error,
            )
        })
}

📎 tokio/src/signal/unix.rs:266-296

Ключевые моменты:

1. signal <= 0 || FORBIDDEN.contains(&signal)отклоняет недопустимые сигналы.

2. handle.check_inner()проверяет, запущен ли драйвер сигналов — если Runtime уже закрыт, здесь произойдёт ошибка.

3. siginfo.init.get_or_init(...)ИспользуетOnceLockчтобы гарантировать, что для каждого сигнала OS handler регистрируется только один раз.get_or_initЗамыкание вызываетsignal_hook_registry::register— это глобальная регистрация на уровне процесса.

4. Зарегистрированный handler — этоaction(globals, signal), он делает две вещи:globals.record_event(signal)записывает событие, затем пишет один байт в pipe для пробуждения драйвера📎 tokio/src/signal/unix.rs:252-259。

Корень конфликтов между несколькими Runtime

globals()возвращает глобальный на уровне процессаGlobals,OsExtraDataвнутриUnixStreamпара также является глобальной:

rust
pub(crate) struct OsExtraData {
    sender: UnixStream,
    pub(crate) receiver: UnixStream,
}

📎 tokio/src/signal/unix.rs:61-64

Defaultреализация создаёт паруUnixStream 📎 tokio/src/signal/unix.rs:61-64. Этот pipe глобально уникален, и все драйверы сигналов всех Runtime используют его совместно.

Возникает проблема:signal_enableвнутриhandle.check_inner()проверяетсятекущего Runtimeдрайвер сигналов. Ноsignal_hook_registry::registerзарегистрированный handler являетсяпроцессным, и он пишет вглобальныйpipe. Если Runtime A первым зарегистрировал SIGINT, а затем Runtime B тоже регистрирует SIGINT,get_or_initпросто вернёт существующийOk(()), не выполняя повторную регистрацию. Но драйвер сигналов Runtime B будет читать данные из глобального pipe — два Runtime будут конкурировать за байты одного и того же pipe.

Сценарный Walkthrough: конкуренция сигналов между несколькими Runtime

mermaid
sequenceDiagram
    participant OS as Операционная система
    participant Handler as Глобальный signal handler
    participant Pipe as Глобальный UnixStream pipe
    participant RtA as Драйвер сигналов Runtime A
    participant RtB as Драйвер сигналов Runtime B

    Note over RtA: регистрация signal(SIGINT)
    RtA->>Handler: signal_hook_registry::register(SIGINT, action)
    Note over RtB: регистрация signal(SIGINT)
    RtB->>Handler: get_or_init возвращает существующий Ok, повторная регистрация не выполняется
    OS->>Handler: доставка SIGINT
    Handler->>Pipe: write(&

Размышления о дизайне и подводные камни

Ловушка 1: обработчик сигналов никогда не выгружается.Документация явно предупреждает: «Once a signal handler is registered with the process the underlying libc signal handler is never unregistered»📎 tokio/src/signal/unix.rs:379-380. Даже если экземплярSignalуничтожен, последующие сигналы всё равно будут перехватываться Tokio, и поведение по умолчанию не восстановится📎 tokio/src/signal/unix.rs:338-340。

Ловушка 2: сигналы объединяются.Документация поясняет: «beforepoll is called, all signal notifications are coalesced into one item returned from poll」📎 tokio/src/signal/unix.rs:312-315. Если вы получили 10 SIGINT, но выполнили poll только один раз, вы увидите только одно событие. Это свойство самих Unix-сигналов (стандартные сигналы не ставятся в очередь), Tokio не выполняет дополнительного объединения.

Ловушка 3: при нескольких Runtime сигналы могут теряться.Поскольку глобальный pipe читается несколькими Runtime конкурентно, один Runtime может забрать байты, а другой никогда их не дождётся. В production следует обрабатывать сигналы только в одном Runtime или использоватьsignal_hookдля самостоятельного управления.

Ловушка 4:signalусловия panic функции.Документация поясняет: «This function panics if there is no current reactor set, or if thert feature flag is not enabled」📎 tokio/src/signal/unix.rs:398-405. Вызовsignal()вне Runtime приведёт к panic.

Ловушка 5:recv()безопасность отмены.Документация гарантирует: «This method is cancel safe. If you use it as a branch intokio::select! and another branch completes first, then it is guaranteed that no signal is lost」📎 tokio/src/signal/unix.rs:423-427. Это потому, что события сигналов хранятся в глобальномEventInfo,recv()только читает, не потребляя нижележащее состояние.

Размышления о дизайне

Три темы этой главы разделяют один базовый паттерн:Владение состоянием определяет безопасность отмены/закрытия/сигналов。

  • JoinHandleбезопасен для отмены, потому что вывод находится в куче, а handle — лишь ссылка.
  • Порядок закрытия Runtime критичен, потому что пул блокирующих задач и планировщик совместно используютHandle, неправильный порядок приведёт к взаимной блокировке или панике.
  • Сигналы конфликтуют между несколькими Runtime, потому что handler и pipe — это глобальное состояние уровня процесса, аSignal— это представление уровня Runtime.

Поняв этот паттерн, список подводных камней можно свести к трём принципам:

1. Безопасность отмены = состояние находится вне Future.Если внутри Future есть буфер, drop приведёт к потере данных.JoinHandle、Signal::recv、tokio::sync::mpsc::Receiver::recvвсе удовлетворяют этому условию.

2. Порядок закрытия = обратный порядок зависимостей.Кто зависит от кого, тот, от кого зависят, закрывается первым. Планировщик зависит от драйвера I/O, поэтому планировщик закрывается первым; пул блокирующих операций независим, он закрывается последним.

3. Глобальное состояние = конфликт между несколькими экземплярами.Любой ресурс уровня процесса (обработчик сигналов, pipe, таблица файловых дескрипторов) при нескольких Runtime будет конфликтовать; либо ограничьтесь одним Runtime, либо используйте внешнюю синхронизацию.

Краткое содержание главы

Вопросы для размышления и самопроверки к этой главе

Q1: Если изJoinHandle::pollубратьcoop::poll_proceed(cx), в каких сценариях это приведёт к голоданию других задач? Почемуtry_read_outputсам по себе не расходует бюджет?

Разбор ответа:coop::poll_proceed(cx)расходует бюджет кооперации в📎 tokio/src/runtime/task/join.rs:325-325. Если его убрать, задача, которая в цикле многократноselect!несколькоJoinHandle, сможет за один цикл планирования бесконечно опрашивать все handle, никогда не возвращаяPending, тем самым доводя до голодания другие задачи на том же worker.try_read_outputсам по себе не расходует бюджет, потому что это всего лишь одно чтение из памяти + возможное сохранение waker, без I/O и борьбы за блокировки, накладные расходы крайне малы. Механизм бюджета задуман для ограничения «операций, которые могут выполняться долго», а не для взимания платы за каждый poll. Обратите внимание, чтоcoop.made_progress()вызываетсяret.is_ready()только при📎 tokio/src/runtime/task/join.rs:349-351, то есть бюджет возвращается только тогда, когда действительно получен результат — это делается для того, чтобы операции «опросили, но результата нет» не накапливали расход бюджета.

Q2:blocking/shutdown.rsВ методеwait, еслиtry_enter_blocking_region()возвращаетNoneи в данный момент происходит panic, почему выбирается возвратfalseвместо продолжения ожидания? Что произойдёт, если изменить на продолжение ожидания?

Разбор ответа:try_enter_blocking_region()возвратNoneозначает, что мы находимся в асинхронном контексте, где блокировка📎 tokio/src/runtime/blocking/shutdown.rs:44-57недопустима. Если в этот момент происходит panic, код выбирает возвратfalseбез ожидания📎 tokio/src/runtime/blocking/shutdown.rs:47-49. Причина: повторная паника во время разворачивания panic приводит к abort процесса (double panic). Если изменить на продолжение ожидания, потребуется вызватьblock_on, а в асинхронном контекстеblock_onвызовет panic — а panic во время разворачивания panic приведёт к немедленному abort процесса с потерей всей диагностической информации. Возвратfalseпозволяет drop завершиться, и информация о panic сохраняется. Это дизайн «изящной деградации»: неполное закрытие лучше, чем крах процесса.

Q3: Предположим, вы создали в Runtime ASignalдля прослушивания SIGTERM, а затем переместилиSignalв Runtime B для poll.signal_enableвнутриhandle.check_inner()какой Runtime проверяет? Если Runtime A будет drop-нут первым, сможет лиSignalв Runtime B всё ещё получать сигналы?

Разбор ответа:signal_enableвыполняется при вызовеsignal(), в этот моментhandleпринадлежит Runtime A.📎 tokio/src/signal/unix.rs:398-405。check_inner()проверяет драйвер сигналов Runtime A.📎 tokio/src/signal/unix.rs:275。Signalвнутри — этоRxFuture, обёртывающийwatch::Receiver<()> 📎 tokio/src/signal/unix.rs:366-368; этот receiver зарегистрирован на глобальномGlobalsвEventInfo. Если Runtime A будет drop-нут, его драйвер сигналов перестанет читать данные из глобального pipe, но глобальный handler по-прежнему будетrecord_eventи писать в pipe. Если драйвер сигналов Runtime B тоже работает, он прочитает данные из pipe и вызоветEventInfo, тем самым разбудив wakerSignal. ПоэтомуSignal в Runtime B, возможно,всё ещё сможет получать сигналы, но это зависит от того, работает ли в Runtime B драйвер сигналов. Если в Runtime B нет драйвера сигналов (например, не включена signal feature или драйвер уже закрыт), данные из pipe никто не читает,Signalникогда не дождётся пробуждения. В этом и заключается хрупкость обработки сигналов при нескольких Runtime.

Переход к концу главы

Безопасность отмены, распространение panic, порядок закрытия, конфликты сигналов — общий корень этих четырёх проблем в размытости «владения состоянием» на асинхронных границах. Tokio, размещая состояние в куче, управляя временем жизни через подсчёт ссылок, изолируя panic с помощьюcatch_unwindи разделяя состояние сигналов через глобальныйGlobals, даёт инженерно пригодные ответы. Но у всех этих ответов есть граничные условия, и в production их необходимо обрабатывать явно.

Следующая глава перейдёт к архитектурным компромиссам и будущей эволюции: от io_uring к подключаемым драйверам. Мы увидим, как Tokio, сохраняя стабильность API, резервирует пространство для расширения под I/O-интерфейсы нового поколения, а также какие архитектурные решения являются историческим багажом, а какие — заделом на будущее.

На этом мы завершили обзор самых коварных пограничных случаев в production-среде Tokio: зависимость cancel safety от размещения выходных данных в куче и атомарность try_read_output; JoinHandle::drop не отменяет задачу, abort действительно отменяет, но не работает для spawn_blocking; panic перехватывается catch_unwind и упаковывается в JoinError, а без await молча теряется; завершение Runtime имеет строгий порядок, и drop в async-контексте вызовет panic; обработчик сигналов — это глобальное состояние уровня процесса, которое после регистрации никогда не выгружается. За этими правилами стоит постоянный компромисс Tokio между корректностью и производительностью. В следующей главе мы выйдем за рамки конкретных механизмов, посмотрим на эти компромиссы с высоты архитектуры и рассмотрим, куда io_uring, рефакторинг драйверов и интерфейс пользовательских executor'ов приведут Tokio.

CHAPTER 14

Глава 14: Архитектурные компромиссы и будущая эволюция: от io_uring к подключаемым драйверам

Проект: tokio-rs/tokio · Прогресс книги: Глава 14 / 14 · Статус проверки: FACT — реальные привязки к строкам

В предыдущей главе мы разобрали четыре категории production-ловушек: cancel safety, распространение panic, порядок завершения и конфликты сигналов. На первый взгляд они разрознены, но на самом деле все указывают на одну архитектурную проблему: как чётко разделить владение состоянием на асинхронных границах. А способ разделения владения как раз определяется тремя самыми глубинными архитектурными решениями рантайма — как планируются задачи, как распространяются события I/O и как проверяется корректность конкурентности. В этой главе мы не будем углубляться в детали реализации конкретной функции, а поднимемся на уровень архитектуры, вспомним компромиссы Tokio в этих решениях и, следуя уже заложенным в официальной документации и исходном коде эволюционным подсказкам, посмотрим, куда io_uring, рефакторинг драйверов и интерфейс пользовательских executor'ов приведут Tokio. Прочитав эту главу, вы должны уметь ответить на практический вопрос: когда стоит расширять Tokio, а когда — обходить его.

I. Три исторических компромисса: почему всё именно так

Интуитивная модель

Представьте Tokio как ресторан, который работает уже десять лет. Способ составления расписания на кухне (work-stealing), отдельный штат официантов (разделение I/O-драйвера и планировщика) и система санитарного контроля на кухне (проверка конкурентности через loom) — всё это не было продумано в первый день открытия, а постепенно эволюционировало в процессе «гостей становится больше, блюда — сложнее». Понимание этой эволюции позволяет судить, какие решения были дальновидной закладкой, а какие — историческим багажом.

Компромисс первый: work-stealing вместо глобальной очереди

〔Проектное предположение и архитектурный компромисс〕

Глобальная очередь реализуется проще всего: все задачи попадают в однуMutex<VecDeque>, worker-потоки захватывают блокировку и берут задачи. Но конкуренция за блокировку ухудшается с ростом числа ядер, а локальность кэша плохая — на каком ядре задача создана и на каком выполнена, полностью случайно.

Компромисс work-stealing таков: каждый worker держит локальную очередь,spawnпри этом сначала кладёт в локальную очередь (без блокировок, дружественно к кэшу), и только когда локальная пуста, идёт воровать из хвоста чужой очереди. Цена — задержка в балансировке нагрузки, а само воровство требует атомарных операций и барьеров памяти. Tokio выбрал второе, потому что современные серверы легко имеют десятки ядер, и стоимость конкуренции за блокировку намного выше эпизодических затрат на воровство.

〔Проектное предположение и архитектурный компромисс〕

Граничное условие этого решения:гранулярность задач не должна быть слишком мелкой. Если каждая задача выполняет работу всего на несколько микросекунд, доля накладных расходов на воровство и планирование становится неконтролируемой. Именно поэтому Tokio помимоspawn_blockingтребует, чтобы длительные задачи самостоятельноyield_now()— кооперативное планирование по сути подстраховывает work-stealing.

Компромисс второй: I/O-драйвер независим от планировщика

Это самое интересное место в исходных материалах этой главы. Посмотрите на структуру модулейtokio/src/runtime/io/mod.rs:

📎 tokio/src/runtime/io/mod.rs:5-22

rust
mod driver;
use driver::{Direction, Tick};
pub(crate) use driver::{Driver, Handle, ReadyEvent};

mod registration;
pub(crate) use registration::Registration;

mod registration_set;
use registration_set::RegistrationSet;

mod scheduled_io;
use scheduled_io::ScheduledIo;

mod metrics;
use metrics::IoDriverMetrics;

use crate::util::ptr_expose::PtrExposeDomain;
static EXPOSE_IO: PtrExposeDomain<ScheduledIo> = PtrExposeDomain::new();

Обратите внимание, чтоdriver、registration、scheduled_io— это три независимых модуля, и наружу экспонируются толькоDriver、Handle、ReadyEvent、Registrationэти несколько типов.ScheduledIoявляетсяpub(crate)— он обёрнут вPtrExposeDomainдля того, чтобы в loom-тестах предоставлять сырые указатели проверке конкурентности.

〔Проектное предположение и архитектурный компромисс〕

Почему I/O-драйвер не встроен напрямую в планировщик? Потому что у них разные жизненные циклы и модели конкурентности. Планировщик заботится о том, «какая задача должна выполняться», I/O-драйвер — о том, «какой fd готов». Если их связать, то каждое изменение стратегии планирования потребует правок в I/O-пути, и наоборот. Что ещё важнее,block_onоднопоточному рантайму тоже нужен I/O-драйвер, но не нужен work-stealing планировщик — разделение позволяет обоим рантаймам переиспользовать одну и ту же реализацию I/O.

Компромисс третий: loom для проверки модели конкурентности

tokio/src/loom/mod.rsзанимает всего 14 строк, но раскрывает стратегию проверки корректности конкурентности в Tokio:

📎 tokio/src/loom/mod.rs:1-14

rust
//! This module abstracts over `loom` and `std::sync` depending on whether we
//! are running tests or not.

#![allow(unused)]

#[cfg(not(all(test, loom)))]
mod std;
#[cfg(not(all(test, loom)))]
pub(crate) use self::std::*;

#[cfg(all(test, loom))]
mod mocked;
#[cfg(all(test, loom))]
pub(crate) use self::mocked::*;

Ключевое — условие#[cfg(all(test, loom))]: только когда одновременно включены два cfg —testиloom— модульmockedподменяется наstd. Это означает, что в production-сборке кода loom вообще нет, нулевые накладные расходы во время выполнения.

〔Проектное предположение и архитектурный компромисс〕

Ценность loom в том, что он позволяет исчерпывающе перебрать «все возможные порядки чередования потоков». Такие вещи, какScheduledIoвAtomicUsizeчтение-модификация-запись,WaitersВставка и удаление в связном списке на реальном оборудовании могут выполняться миллион раз без ошибок, но loom способен за несколько секунд построить чередование, вызывающее гонку. Цена — медленный запуск тестов и высокое потребление памяти, поэтому это применимо только для модульных тестов, но не в production.

Размышления о дизайне

У этих трёх компромиссов есть общая черта:Все они выбрали «более сложное, но более масштабируемое» решение и ограничили сложность внутри. Сложность work-stealing спрятана в планировщике, сложность I/O-драйвера спрятана вScheduledIo, сложность loom спрятана в условиях cfg. Наружу всегда выставляютсяspawn、TcpStream::readэти простые интерфейсы.

〔Выводы о дизайне и архитектурные компромиссы〕

Это также первый критерий для определения «когда следует расширять Tokio»:Если вашу потребность можно выразить через существующий API, не трогайте внутренние структуры. Как только вы начинаете зависеть отpub(crate)типов илиtokio_unstablecfg, это означает, что вы привязали себя к внутренней реализации Tokio и заплатите за это при обновлении.

---

II. Рефакторинг драйвера: от «один waker — одно направление» к «произвольному набору интересов»

Интуитивная модель

У ранних типов I/O в Tokio было жёсткое ограничение:async fn read(&mut self)требует&mut self. Это как в ресторане с единственным окном выдачи: одновременно может стоять в очереди только один человек — потому что waker хранится внутри I/O-ресурса, а не в Future, соответствующем операции.tokio/docs/reactor-refactor.mdПолностью описывает причины этого ограничения и план рефакторинга.

Болевые точки старой архитектуры

Документ с самого начала указывает на проблему:

📎 tokio/docs/reactor-refactor.md:16-20

rust
Currently, I/O types require `&mut self` for `async` functions. The reason for
this is the task's waker is stored in the I/O resource's internal state
(`ScheduledIo`) instead of in the future returned by the `async` function.
Because of this limitation, I/O types limit the number of wakers to one per
direction (a direction is either read-related events or write-related events).
〔Выводы о дизайне и архитектурные компромиссы〕

Хранение waker внутри ресурса означает, что «у одного направления может быть только один ожидающий». Если вы одновременно хотите читать и писать в один и тот жеTcpStream, придётсяsplit()на две половины, каждая со своим независимым слотом waker. Именно поэтомуTcpStream::split()существует — это не предпочтение в дизайне API, а прямое ограничение внутренней структуры данных.

Новая архитектура: перенос waker в Future

Ключевая идея рефакторинга — «перенести waker из состояния ресурса в Future операции», чтобы поддерживать регистрацию нескольких waker для каждой операции:

📎 tokio/docs/reactor-refactor.md:22-25

rust
Moving the waker from the internal I/O resource's state to the operation's
future enables multiple wakers to be registered per operation. The "intrusive
wake list" strategy used by `Notify` applies to this case, though there are some
concerns unique to the I/O driver.

НоваяScheduledIoструктура выглядит так:

📎 tokio/docs/reactor-refactor.md:97-134

rust
#[derive(Debug)]
pub(crate) struct ScheduledIo {
    /// Resource's known state packed with other state that must be
    /// atomically updated.
    readiness: AtomicUsize,

    /// Tracks tasks waiting on the resource
    waiters: Mutex<Waiters>,
}

#[derive(Debug)]
struct Waiters {
    // List of intrusive waiters.
    list: LinkedList<Waiter>,

    /// Waiter used by `AsyncRead` implementations.
    reader: Option<Waker>,

    /// Waiter used by `AsyncWrite` implementations.
    writer: Option<Waker>,
}

// This struct is contained by the **future** returned by `readiness()`.
#[derive(Debug)]
struct Waiter {
    /// Intrusive linked-list pointers
    pointers: linked_list::Pointers<Waiter>,

    /// Waker for task waiting on I/O resource
    waiter: Option<Waker>,

    /// Readiness events being waited on. This is
    /// the value passed to `readiness()`
    interest: mio::Ready,

    /// Should not be `Unpin`.
    _p: PhantomPinned,
}

Здесь есть несколько изящных моментов, которые стоит разобрать:

Во-первых,readinessэтоAtomicUsize,waitersэтоMutex<Waiters>。Почему бы не защитить оба одним мьютексом? Потому что чтениеreadinessпроисходит чрезвычайно часто (проверяется при каждом вызовеreadiness()), а запись происходит только при получении события mio. Использование атомарных переменных для безблокировочного пути чтения — типичная оптимизация разделения чтения и записи.

Во-вторых,Waiterэто узел интрузивного связного списка. pointers: linked_list::Pointers<Waiter>позволяетWaiterсамому быть частью списка, без дополнительного выделения узла._p: PhantomPinnedявно помечает его как неUnpin— потому что как только адрес узла интрузивного списка перемещается, список рвётся.

В-третьих,readerиwriterдваOption<Waker>предназначены дляAsyncRead/AsyncWrite.Документ объясняет причину:

📎 tokio/docs/reactor-refactor.md:210-213

rust
The `AsyncRead` and `AsyncWrite` traits use a "poll" based API. This means that
it is not possible to use an intrusive linked list to track the waker.
Additionally, there is no future associated with the operation which means it is
not possible to cancel interest in the readiness events.
〔Выводы о дизайне и архитектурные компромиссы〕

Это компромиссное сосуществование двух механизмов — старого и нового:async fnпуть использует интрузивный связный список (поддерживает несколько ожидающих, отменяемый),pollпуть использует фиксированные слоты (не поддерживает отмену, но совместим с trait). Такое «сосуществование двух механизмов» — типичная цена постепенного рефакторинга.

Состояния гонки и механизм tick

Самая сложная проблема при рефакторинге — гонки. Документ приводит конкретный сценарий дедлока:

📎 tokio/docs/reactor-refactor.md:175-175

rust
If care is not taken, if between `mio_socket.read(buf)` returning and
`clear_readiness(event)` is called, a readiness event arrives, the `read()`
function could deadlock. This happens because the readiness event is received,
`clear_readiness()` unsets the readiness event, and on the next iteration,
`readiness().await` will block forever as a new readiness event is not received.

Решение — ввести механизм tick, разбивreadinessэтотAtomicUsizeна несколько битовых сегментов:

📎 tokio/docs/reactor-refactor.md:199-199

code
| shutdown | generation |  driver tick | readiness |
|----------+------------+--------------+-----------|
|   1 bit  |   7 bits   +    8 bits    +  16 bits  |
〔Выводы о дизайне и архитектурные компромиссы〕

Эта раскладка битов — классический пример «обмена пространства на корректность».tickинкрементируется при каждомmio::poll(),ReadyEventнесёт tick, прочитанный в момент чтения.clear_readiness()очищает состояние готовности только при совпадении tick — если tick не совпадает, значит за это время пришло новое событие, и очищать нельзя. Так гонка между «очисткой» и «приходом нового события» устраняется в одной атомарной операции чтения-изменения-записи.

Приведённая ниже блок-схема описывает путь принятия решений междуreadiness()иclear_readiness():

mermaid
flowchart TD
    start["readiness(interest).await"] --> check_ready{"известная readiness<br/>пересекается с interest?"}
    check_ready -->|да| ret_event["вернуть ReadyEvent<br/>с текущим tick"]
    check_ready -->|нет| wait["зарегистрировать Waiter в<br/>ScheduledIo.waiters"]
    wait --> mio_poll["mio.poll() получает событие<br/>tick инкрементируется"]
    mio_poll --> notify["обход waiters<br/>пробуждение совпадающих по interest"]
    notify --> ret_event
    ret_event --> do_read["mio_socket.read(buf)"]
    do_read --> read_ok{"результат read?"}
    read_ok -->|Ok| done["вернуть Ok(v)"]
    read_ok -->|WouldBlock| clear["clear_readiness(event)"]
    read_ok -->|другая Err| err["вернуть Err(e)"]
    clear --> tick_match{"event.tick ==<br/>текущий readiness.tick?"}
    tick_match -->|да| clear_ok["сбросить биты readiness"]
    tick_match -->|нет| skip["пропустить сброс<br/>сохранить новое событие"]
    clear_ok --> start
    skip --> start

Ключевое ветвление на этой схеме —tick_match: если tick не совпадает,clear_readinessдолжен отказаться от очистки, иначе потеряет только что пришедшее событие, что приведёт к вечной блокировке следующегоreadiness().

Отмена интереса и утечка памяти

Интрузивный связный список порождает новую проблему: еслиreadiness()возвращённый Future будет досрочно drop, узел списка должен быть удалён. Документ явно предупреждает:

📎 tokio/docs/reactor-refactor.md:144-148

rust
The future returned by `readiness()` uses an intrusive linked list to store the
waker with `ScheduledIo`. Because `readiness()` can be called concurrently, many
wakers may be stored simultaneously in the list. If the `readiness()` future is
dropped early, it is essential that the waker is removed from the list. This
prevents leaking memory.
〔Выводы о дизайне и архитектурные компромиссы〕

Это именно проявление «безопасности отмены» из предыдущей главы на уровне I/O.readiness()FutureDropдолжен в реализацииScheduledIoудалить себя из списка, иначе узел навсегда останется в

, что приведёт и к утечке памяти, и к ошибочному пробуждению при следующем событии.

Размышления о дизайне и подводные камни в productionVec<Waker>Почему не, а интрузивный связный список?&ResourceДокумент даёт ответ при обсуждении реализации

📎 tokio/docs/reactor-refactor.md:228-233

rust
It is only possible to implement `AsyncRead` and `AsyncWrite` for resource types
themselves and not for `&Resource`. Implementing the traits for `&Resource`
would permit concurrent operations to the resource. Because only a single waker
is stored per direction, any concurrent usage would result in deadlocks. An
alternate implementation would call for a `Vec<Waker>` but this would result in
memory leaks.
Копировать

Vec<Waker>〔Выводы о дизайне и архитектурные компромиссы〕

Проблема:TcpStream::by_ref()в том, что после drop Future соответствующий waker остаётся в Vec и его невозможно найти и удалить, приходится ждать следующего события, чтобы обнаружить «этот waker уже недействителен». Интрузивный связный список делает адрес узла адресом поля внутри Future, что позволяет точно удалить его при drop.TcpStreamRefПодводные камни в productionread_waiterвозвращаемыйwrite_waiterсодержит два узла:

📎 tokio/docs/reactor-refactor.md:238-244

rust
struct TcpStreamRef<'a> {
    stream: &'a TcpStream,

    // `Waiter` is the node in the intrusive waiter linked-list
    read_waiter: Waiter,
    write_waiter: Waiter,
}
Копировать

〔Выводы о дизайне и архитектурные компромиссы〕TcpStreamRefЭто означает, что как толькоselect!будет drop, оба узла waiter одновременно станут недействительными. Если вы вby_ref()используете ссылку наTcpStreamRefдля совместного использования между ветвями, будьте осторожны с временем жизни —TcpStreamне может жить дольшеselect!, и не может одновременно заимствоваться между несколькими ветвями

---

.

Интуитивная модель

Иногда вы не хотите использовать планировщик Tokio, а хотите воспользоваться только его I/O и таймерами. Это как если вы не хотите есть в ресторане, а хотите воспользоваться только окном доставки.examples/custom-executor.rsдемонстрирует такой «гибридный режим»: используяfutures::executor::ThreadPoolдля планирования, а Tokio для I/O.

Ключевой механизм: TokioContext

Весь пример держится наTokioContextэтом типе-обёртке:

📎 examples/custom-executor.rs:51-54

rust
impl ThreadPool {
    fn spawn(&self, f: impl Future<Output = ()> + Send + 'static) {
        let handle = self.rt.handle().clone();
        self.inner.spawn_ok(TokioContext::new(f, handle));
    }
}
〔Проектные выводы и архитектурные компромиссы〕

TokioContext::new(f, handle)связывает Future сHandleTokio. Когда внешний исполнитель poll-ит эту обёртку Future,TokioContextсначала входит в контекст runtime Tokio (устанавливая thread-localHandle), затем poll-ит внутреннийf. Таким образом,fпри вызовеTcpListener::bindсможет найти I/O-драйвер Tokio.

Посмотрим на структуру всего примера:

📎 examples/custom-executor.rs:38-48

rust
static EXECUTOR: Lazy<ThreadPool> = Lazy::new(|| {
    // Spawn tokio runtime on a single background thread
    // enabling IO and timers.
    let rt = tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap();
    let inner = futures::executor::ThreadPool::builder().create().unwrap();

    ThreadPool { inner, rt }
});
〔Проектные выводы и архитектурные компромиссы〕

Здесь runtime Tokio создаётся, нонеblock_onуправляется— он просто «существует», предоставляя I/O-драйвер и таймеры. Реальное планирование задач выполняетfutures::executor::ThreadPool. В этом режиме worker-потоки Tokio фактически простаивают (ожидая событий I/O), а выполнение задач происходит в пуле потоков futures.

Поток данных: путешествие TcpListener::bind между исполнителями

mermaid
sequenceDiagram
    participant App as "Приложение (main)"
    participant FE as "futures::ThreadPool"
    participant TC as "TokioContext"
    participant TR as "tokio::Runtime (фоновый поток)"
    participant IO as "Драйвер I/O (mio)"

    App->>FE: spawn_ok(TokioContext::new(f, handle))
    FE->>TC: poll(cx)
    TC->>TC: enter(handle) устанавливает локальный контекст потока
    TC->>TC: f.poll(cx) выполняет TcpListener::bind
    TC->>TR: доступ к драйверу I/O через Handle
    TR->>IO: Registration::new регистрирует fd
    IO-->>TR: регистрация завершена
    TR-->>TC: возврат Pending или Ready
    TC-->>FE: возврат результата poll
    Note over FE,TR: при готовности I/O драйвер Tokio пробуждает waker<br/>FE перепланирует эту задачу

Ключевой момент этой диаграммы последовательности:poll задачи происходит в пуле потоков futures, но ожидание событий I/O происходит в фоновом потоке Tokio. Оба соединяются черезHandleи waker.

Проектные размышления: когда стоит обходить Tokio

〔Проектные выводы и архитектурные компромиссы〕

Само существование этого примера — сигнал: архитектура Tokio допускает «использовать только I/O-драйвер, без планировщика». Критерии можно свести к трём:

1. Если вам нужно интегрироваться с существующей экосистемой исполнителей(например, некоторые фреймворки требуютfutures::executor), использованиеTokioContext— минимально инвазивное решение.

2. Если вам нужен полный контроль над стратегией планирования(например, системы реального времени требуют детерминированного планирования), work-stealing Tokio не подходит, но его I/O-драйвер всё ещё доступен.

3. Если вам просто кажется, что API Tokio слишком сложный, то обходить не стоит —TokioContextвведённая граница между исполнителями принесёт новые сложности отладки, что не оправдает себя.

Подводные камни в продакшене:TokioContextВ режимеblock_onникогда не вызывается, что означает, что логика очисткиRuntime::shutdownне сработает автоматически. Вы должны явно dropRuntimeперед завершением программы, иначе фоновый поток I/O-драйвера может не завершиться корректно.

Связь с io_uring

〔Проектные выводы и архитектурные компромиссы〕

tokio/src/runtime/io/mod.rsУсловие cfg в начале раскрывает способ подключения io_uring:

📎 tokio/src/runtime/io/mod.rs:1-4

rust
#![cfg_attr(
    not(all(feature = "rt", feature = "net", feature = "io-uring", tokio_unstable)),
    allow(dead_code)
)]

Обратите внимание, чтоfeature = "io-uring"иtokio_unstableпоявляются одновременно. Это означает, что поддержка io_uring сейчасэкспериментальная, и для компиляции необходимо одновременно включить unstable-функции.allow(dead_code)же означает: когда эти функции не включены, часть кода в модуле не используется, и компилятор выдаст предупреждение — подавляемое с помощьюallow.

〔Проектные выводы и архитектурные компромиссы〕

Принципиальное различие между io_uring и epoll: epoll — это «уведомление о готовности», io_uring — «уведомление о завершении». Первый требует, чтобы приложение само инициировало системный вызовread/write, второй — ядро само выполняет I/O и возвращает результат. Это огромный удар по моделиScheduledIoTokio —readiness()семантика

---

больше не применима в io_uring, нужна совершенно новая абстракция «отправка-завершение». Именно поэтому поддержка io_uring так долго остаётся в unstable: это не просто добавление бэкенда, а рефакторинг всего абстрактного слоя I/O-драйвера.

Итоги главы

Исторические компромиссы:

  • work-stealing обменивает сложность планирования на масштабируемость по ядрам, граница — гранулярность задач не должна быть слишком мелкой;
  • I/O-драйвер независим от планировщика, что позволяетblock_onи многопоточному runtime использовать одну и ту же реализацию I/O;
  • loom полностью исчезает из продакшен-сборки через cfg-условия, перебирая чередования потоков только при тестировании.

Рефакторинг драйвера(reactor-refactor.md):

  • перенос waker из внутренностейScheduledIoв Future операции, использование интрузивного связного списка для поддержки множества ожидающих;
  • использование битовой раскладкиAtomicUsize(shutdown/generation/tick/readiness) для устранения гонкиclear_readiness;
  • AsyncRead/AsyncWriteиз-за семантики poll невозможно использовать интрузивный связный список, сохраняетсяreader/writerс фиксированными слотами как компромисс.

Будущая эволюция:

  • io_uring требует новой абстракции «отправка-завершение», сейчас защищеноtokio_unstable;
  • TokioContextпозволяет использовать только I/O-драйвер без планировщика, но требует ручного управления жизненным циклом Runtime;
  • критерий «расширять или обходить»: если можно выразить через существующий API — не трогать внутренние структуры.

Вопросы для размышления и самопроверки

Q1: ВScheduledIoбитовой раскладкеreadiness, если сократить полеtickс 8 бит до 4 бит, в каких сценариях возникнет ошибка? Проанализируйте с учётом логики сопоставления tick вclear_readiness.

Справочный разбор:tickувеличивается при каждомmio::poll(),📎 tokio/docs/reactor-refactor.md:185-185。clear_readinessсбрасывает биты готовности только приevent.tick == 当前 readiness.tick.📎 tokio/docs/reactor-refactor.md:199-199Если tick занимает всего 4 бита, то каждые 16 poll-ов произойдёт переполнение. Предположим, некоторыйReadyEventнесёт tick=15, и когда он будетclear_readinessРанее mio снова выполнил poll 1 раз, tick вернулся к 0. В этот моментclear_readinessобнаруживается несовпадение tick (15 != 0), и очистка ошибочно пропускается — но на самом деле за это время новых событий могло и не поступить, просто tick завернулся. Это приводит к тому, что бит готовности сохраняется навсегда, и последующиеreadiness()немедленно возвращаются, ноreadпо-прежнемуWouldBlock, что приводит к busy loop. 8-битного tick достаточно при нормальной нагрузке (цикл read-clear завершается за 256 poll), но при экстремально высокой конкурентности всё ещё есть риск переполнения — это неотъемлемое ограничение битовой компоновки.

Q2: examples/custom-executor.rs, среда выполнения Tokio создаётся, но никогда неblock_on. Что произойдёт, если в этот момент вызватьrt.shutdown_timeout()? Почему в этом примере решено не вызывать?

Справочный разбор:rt.shutdown_timeout()будет ждать завершения всех задач и закроет драйвер I/O. Но в этом примере задачи фактически выполняются наfutures::executor::ThreadPoolна📎 examples/custom-executor.rs:51-54, в среде выполнения Tokio нет задач — она предоставляет только драйвер I/O. Если вызватьshutdown_timeout, он немедленно вернётся (поскольку задач нет), но фоновый поток драйвера I/O может всё ещё работать. В примере решено не вызывать, потому чтоEXECUTOR— этоLazyстатическая переменная, и при завершении программы она обрабатывается механизмом статического разрушения Rust. Настоящая ловушка в том, что еслиTokioContextобёрнутый Future всё ещё выполняется, аRuntimeбудет drop, то операции I/O внутри Future вызовут panic (контекст среды выполнения не найден). В production необходимо убедиться, что всеTokioContextFuture завершены, прежде чем drop Runtime.

Q3: Предположим, вы хотите добавить в Tokio бэкенд I/O на основе io_uring. Согласноreactor-refactor.mdвreadiness()семантике

, какие части можно напрямую переиспользовать, а какие необходимо переписать?Справочный разборRegistration: можно напрямую переиспользоватьScheduledIoинтерфейс регистрации иwaitersструктуру связного спискаreadiness()— они управляют тем, «кто ждёт», и не зависят от того, лежит ли в основе epoll или io_uring. Необходимо переписать семантикуclear_readiness: под epoll он возвращает «fd готов», под io_uring понятия «готовности» нет, есть только «отправленный SQE завершён».readiness()Механизм tick также требует переработки — события завершения io_uring несут собственный идентификатор user_data, и tick не нужен для различения новых и старых событий. Самое фундаментальное изменение:Waiterвозвращаемый Future под io_uring должен стать «отправить SQE и ждать CQE», что означает, что структуреinterestнужно нести параметры SQE, а не толькоtokio_unstable. Именно поэтому поддержка io_uring защищена📎 tokio/src/runtime/io/mod.rs:1-4— это не замена бэкенда, а изменение абстрактного контракта драйвера I/O.

На этом мы завершили восхождение от конкретных ловушек к архитектурным компромиссам. Оглядываясь на всю книгу — от ленивого вычисления Future до справедливости планировщика, от безопасности отмены до порядка завершения, и до io_uring и подключаемых драйверов в этой главе — все обсуждения вращаются вокруг одного ядра: чёткое разделение владения состоянием на асинхронных границах. Архитектура Tokio не является неизменной: zero-copy I/O в io_uring, развязка уровня драйверов, открытость интерфейса пользовательских исполнителей — всё это движет её к более гибкой и эффективной эволюции. Когда вы закроете эту книгу, пусть останется не набор способов использования API, а набор суждений: знать, когда следует доверять среде выполнения, когда вмешиваться в нижний уровень и как избегать в production тех комбинаций, которые кусаются. Экосистема асинхронного Rust всё ещё быстро растёт, и отслеживание исходного кода и официальной документации важнее, чем запоминание любых выводов.

Чтобы понять любой сложный проект, на самом деле нужна всего одна хорошая книга

Эта книга «Tokio 源码深度解读:从 Future 到生产级异步运行时» была полностью автоматически составлена AiReadCode путём сканирования официального открытого репозитория. Независимо от того, имеете ли вы дело с крупным открытым проектом в сотни тысяч строк или со сложным внутренним корпоративным проектом, вы можете одним нажатием сгенерировать столь же чётко структурированную персональную монографию.

Бесплатно скачать клиент AiReadCode Просмотреть больше хороших открытых книг →
🇨🇳 Китайский · 🇺🇸 EN · 🇯🇵 Японский · 🇰🇷 한국어 · 🌐 Традиционный китайский · 🇪🇸 ES · 🇩🇪 DE · 🇫🇷 FR · 🇧🇷 PT · 🇷🇺 RU