第1章:非同期のメンタルモデル:Future、Waker、Executor の三種の神器
Rust における非同期プログラミングはライブラリではなく、言語レベルのプロトコルです。Tokio が本番級ランタイムになり得たのは、Future を発明したからではなく、このプロトコルにおける各契約の境界条件を正確に実装したからです。本章では Tokio のスケジューラコードに急いで飛び込むのではなく、まず「三種の神器」——Future、Waker、Executor——の責務境界と逆方向の制御フローを徹底的に解説します。この三者がどのように噛み合うかを理解すれば、以降の章で Runtime の組み立て、work-stealing スケジューリング、I/O ドライバが地に足のついた議論となります。
1.1 ブロッキングからプルへ:Rust がコールバックではなく poll を選んだ理由
直感的モデル
レストランで調理が必要な料理を注文する場面を想像してください。コールバック型非同期(Node.js の初期スタイルなど)は、電話番号を残しておき、シェフが完成後に能動的に電話をかけてくる——制御権はシェフにあり、あなたのコードは受動的に応答するだけです。プル型非同期(Rust の選択)は、受け取り票を受け取り、あなた自身が決めるいつ窓口に行って「できましたか」と聞くか:まだなら別のことをし、できたら受け取る。
この違いは一見小さく見えますが、システム全体の形態を決定します。コールバック型モデルでは、各非同期操作が「完了後に何をするか」というクロージャを必ず伴い、クロージャが幾重にもネストしてコールバック地獄を形成し、さらにキャンセルが極めて困難です——登録済みのコールバックを「撤回」することはできません。プル型モデルでは、Future は単なる状態機械であり、pollは純粋な問い合わせ動作であり、進めなければリソースを消費せず、キャンセルは drop であり、クリーンで明快です。
プル型モデルの核心契約
Rust 標準ライブラリが定義するFuturetrait には二つの要素しかありません:一つのpollメソッドと、一つのOutput関連型です。Tokio はこの trait を再定義せず、標準ライブラリの実装を直接再利用しています。この点はソースコードに明確に現れています:
// tokio/src/future/mod.rs
cfg_not_trace! {
cfg_rt! {
pub(crate) use std::future::Future;
}
}📎 tokio/src/future/mod.rs:24-28
このコードは重要な事実を明らかにしています:tracingフィーチャーが有効でない場合、Tokio 内部のFutureはstd::future::Futureの別名であり、何のラッパーもありません。tracingが有効な場合にのみ、InstrumentedFutureで置き換えられます:
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 契約の三つの暗黙的制約
pollメソッドのシグネチャはfn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>です。このシグネチャには三つの契約が隠されており、いずれかに違反すると未定義動作や論理エラーを引き起こします:
契約一:Pin が自己参照の安全性を保証する。 Pin<&mut Self>は、Future が一度 poll されると、そのメモリアドレスが移動できなくなることを意味します。これは async ブロックがコンパイル後に自己参照を含む状態機械を生成するためです——ローカル変数が同一状態機械内の他のフィールドへの参照を保持する可能性があります。移動が許されれば、これらの参照はダングリングポインタになります。
契約二:Pending はウェイクが登録済みでなければならない。がpollを返すときPoll::Pending、Future はすでにcx.waker()Wakerを取得して保存したか、あるいはWakerを何らかのイベントソースに登録済みである。そうでなければ、実行器はそのFutureがいつ再びpoll可能になるかを永遠に知ることができず、タスクが永久にサスペンドされる。
契約三:Ready後は再びpollすべきではない。一度pollがPoll::Readyを返したら、同じFutureを再びpollするのは論理エラーである(UBにはならないが、動作は未定義)。実行器には、Readyを受け取った後にそのタスクを再スケジュールしない責任がある。
これら三つの契約のうち、契約二が最も間違いやすい箇所であり、Wakerが存在する根本的な理由でもある。
1.2 Waker:逆方向の制御フローの担い手
直感的モデル
Wakerはレストランが渡してくれる「振動呼び出しベル」である。窓口に立って何度も「できましたか」と聞く必要はない——それは時間の無駄だ。最初に窓口に行ったときにベルをシェフに渡し(Wakerを登録し)、あとは安心して別のことをしていればよい。料理ができたら、シェフがボタンを押し、ベルが振動する(wakeを呼び出す)。あなたは信号を受け取ってから再び窓口に料理を取りに行く(再pollする)。
Wakerがなければ、実行器には二つの選択肢しかない:すべてのタスクをビジーループでポーリングするか(CPUの無駄)、Pendingを返したタスクを永遠にpollしないか(タスクの餓死)である。Wakerはこの膠着状態を打破する唯一の仕組みである。
Wakerのメモリレイアウトと仮想テーブル設計
Wakerは標準ライブラリの型だが、その設計はTokioのタスク構造に直接影響を与えている。Waker本質的にはファットポインタである:一つのRawWaker構造体であり、データポインタと仮想テーブルポインタを含む。
// 标准库中的定义(非 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は、そのwake関数がタスクを再びスケジューリングキューに押し戻すWakerを提供でき、別のランタイム(例えばfuturesクレートのblock_on)は全く異なるWaker実装を提供できる。この「データ+仮想テーブル」のパターンにより、Wakerは異なるランタイム間で意味を失うことなく受け渡しできる。
wakeとwake_by_refの違いは極めて重要である:wakeはWakerの所有権を消費し(呼び出し後Wakerはdropされる)、一方wake_by_refは借用するだけである。実行器は通常、wake_by_refを「タスクを就緒としてマークしキューに入れる」として実装し、wakeはその上でさらに参照カウントのデクリメントを処理する。Tokioのタスク構造では、Wakerのデータポインタはタスクの参照カウントヘッダを指し、cloneごとにカウントが増え、dropでカウントが減り、カウントがゼロになるとタスクのメモリが解放される。
起床の完全なタイムライン
以下のシーケンス図は、TCP読み取り操作が開始されてから起床されるまでの完全な経路を示している。WakerがタスクコンテキストからI/Oドライバまでどのように伝達されるかに注目してほしい:
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はReactorからExecutorへ逆方向に到達できる唯一のチャネルである。Reactorはタスクに関する他の情報を一切保持せず、「このfdが就緒になったら、このWakerを呼び出す」ということだけを知っている。この分離により、I/Oドライバはスケジューラとは独立に実装でき、両者はWakerという狭いインターフェースを通じてのみ通信する。
偽の起床:契約のグレーゾーン
Tokioのドキュメントは偽の起床の存在を明確に認めている:
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
これはつまりpollの実装は「起床されていないのに再びpollされる」状況を許容できなければならない。正しいFutureは、Pendingを返した後、何のイベントも発生していなくても、再びpollされたときにはpanicしたり誤った結果を生じたりせず、Pendingを返すべきである。この制約は緩く見えるが、実際には状態機械の設計に要求を課す:「二回のpollの間に必ずイベントが発生する」と仮定してはならない。
1.3 Executor:Futureからタスクへのカプセル化
直感的モデル
Executorはレストランの配車係である。彼の手には注文の山(タスクキュー)があり、どの注文を先に作るか、誰が作るかを決める。呼び出しベルが振動すると、彼は対応する注文を再びキューに入れる。配車係がいなければ、シェフたちはどの料理を作ればよいか、いつ作業を切り替えるべきかもわからない。
しかしExecutorの責務は「Futureをポーリングする」だけにとどまらない。それは三つの中核的な問題を解決しなければならない:タスクのライフサイクル管理(作成、スケジューリング、完了、キャンセル)、公平性の保証(あるタスクが他のタスクを餓死させるのを防ぐ)、リソースドライバの統合(I/Oとタイマーイベントをどのように起床に変換するか)。
タスクのメモリレイアウト:FutureからTaskへ
を呼び出すとき、渡されたFutureは直接キューに入れられるのではない。それはtokio::spawn構造体にラップされ、参照カウントヘッダ、スケジューリングメタデータ、そしてFuture自体を含む。このラップ処理には重要な最適化の決定がある:Taskコピー
/// 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をボックス化するかどうかを決定する。SHOULD_BOX〔設計上の推論とアーキテクチャのトレードオフ〕
注释中特别强调了「用关联常量而非运行时 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
この保証の実装は2つの重要なパラメータに依存している。current-threadランタイムの場合:
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をチェックする。
なぜ32ではなく31なのか?カウンタは0から始まり、スケジューリングごとに1ずつ増加し、カウンタが31に達したときにグローバルキュー検査をトリガーする。counter & 31 == 31での判定はcounter % 32 == 0よりも効率的である(現代のコンパイラは自動的に最適化するが)。61の選択はより微妙である:頻繁なepoll_waitシステムコールのオーバーヘッドを避けるために十分大きく、かつI/O遅延を許容範囲内に保つために十分小さくする必要がある。
マルチスレッドランタイムのLIFOスロット最適化
マルチスレッドランタイムは公平性に加えて、さらに性能最適化——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
この「3回連続使用後に無効化」というルールは、2つのタスクが互いを起床させてライブロックを形成するのを防ぐためである。もしタスクAがタスクBを起床させ、BがAを起床させた場合、この制限がなければLIFOスロットはこの2つのタスクに永久に占有され、他のタスクは永遠にスケジューリングされない。3回の制限は他のタスクに割り込む機会を与える。
タスクキャンセル: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
このセマンティクスの設計動機は:キャンセルは「ベストエフォート」の操作である。Tokioはタスクを強制終了しない(Rustには安全な強制終了メカニズムがない)が、協調的にタスクに自ら終了するよう要求する。これはspawn_blockingタスクがキャンセル不可能という設計と一致している——ブロッキングタスクには.awaitポイントがなく、キャンセルフラグをチェックできない。
1.4 設計思考:三種の神器の境界とコスト
なぜFutureはExecutorを含まないのか
RustのFuturetraitは意図的に「自分をどのようにスケジューリングするか」という情報を含まない。これは熟慮された疎結合の決定である。もしFutureが自分のExecutorを知っていたら:
1. 同じFutureを異なるランタイムで実行できない(例えばTokioからasync-stdへの移行)
2. テスト時に単純なblock_onで駆動できない
3. コンビネータ(例えばselect!、join!)がランタイムをまたいで動作できない
Wakerの存在は、この疎結合を維持しながら、FutureがExecutorに通知できるようにするためのものである。Wakerは「能力トークン」である——Futureは「これを呼び出して再スケジューリングを要求できる」ことだけを知っており、スケジューリングが具体的にどのように行われるかは知らない。
協調的スケジューリングのコスト
Tokioのタスクは協調的である:タスクは.awaitポイントでのみ実行権を譲る。これは以下を意味する:
code that spends a long time without reaching an .await will prevent other tasks from running.
📎 tokio/src/lib.rs:178-179
これが協調的スケジューリングの根本的なコストである。OSは任意の命令境界でスレッドをプリエンプトできるが、Tokioは.awaitポイントでのみタスクを切り替える。もしタスクが途中に.awaitのない10秒間のCPU集約的ループを実行した場合、同じworkerスレッド上の他のすべてのタスクが10秒間ブロックされる。Tokioの対処戦略はspawn_blockingとblock_in_placeを提供し、このような作業を専用スレッドプールに移すことである。しかしこれはユーザーの責任であり、ランタイムは自動検出できない。
公平性保証の境界条件
Tokioの公平性保証には2つの前提条件がある:タスク総数に上限があり、スレッドをブロックするタスクがないこと。これら2つの条件は実際の本番環境ではしばしば違反される:
- もしタスクが絶えず新しいタスクをspawnし回収しなければ、タスク総数に上限がなくなり、公平性保証が無効になる
- もしあるタスクがブロッキングシステムコール(例えば同期ファイルI/O)を実行すると、workerスレッド全体をブロックする
これがTokioのドキュメントが繰り返し「非同期タスクでブロッキング操作を実行しないでください」と強調する理由である。公平性保証はランタイムのハード保証ではなく、「正しく使用する前提での」保証である。ランタイムは違反行為を検出しない。検出自体にオーバーヘッドが必要だからである。
1.5 本章のまとめ
本章ではTokioを理解するための3つの基石を確立した:
Future はプル型のステートマシンである。 pollは純粋なクエリ操作であり、返すPending時には既にウェイクアップが登録されている必要があり、返すReady後はもう poll されるべきではない。Tokio は直接std::future::Futureを再利用し、追加のラッピングは行わない(tracing を有効にしない限り)。
Waker は逆方向制御フローの唯一のチャネルである。それは「データポインタ + 仮想テーブル」の設計により、ランタイム非依存性を実現している。wakeは所有権を消費し、wake_by_refは借用のみを行う。偽のウェイクアップは許容され、Future はそれを容忍しなければならない。
Executor はライフサイクル、公平性、リソース統合を担当する。それは Future を Task にラップし、AutoBoxを通じてコンパイル時にボクシングの有無を決定し、31/61 という2つのマジックナンバーでローカルキューとグローバルキューのスケジューリングをバランスさせ、LIFO スロットを通じてデータ依存シナリオのパフォーマンスを最適化する。
これら3つのコンポーネントは狭いインターフェースを通じて分離されている: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に対して2つの分岐のコードを同時にインスタンス化する——1つはTが直接インライン化される場合を処理し、もう1つはPin<Box<T>>の場合を処理する。これは、spawn される各 Future 型に対して2份のタスク駆動コード(task harness)が生成され、バイナリサイズが倍増することを意味する。一方、関連定数SHOULD_BOXを使用すると、Tが確定した後はコンパイル時定数となるため、単相化コレクタが到達不可能な分岐を剪定し、実際に使用されるパスのみのコードを生成する。これは「型システムで実行時判定を置き換える」典型的な最適化であり、代償としてAutoBoxは通常の関数ではなくジェネリック構造体でなければならない。
Q2: あるタスクがpollでPendingを返したが、Waker の登録を忘れたと仮定する。current-thread ランタイムと multi-thread ランタイムでは、このタスクはそれぞれどうなるか?Tokio にはこの状況を検出するメカニズムがあるか?
参考解析:📎 tokio/src/runtime/mod.rs:306-309によると、Tokio は偽のウェイクアップを許可している。これは、タスクがウェイクアップされずに再スケジュールされる可能性があることを意味する。しかし、これは Waker の登録忘れが安全であることを意味しない。current-thread ランタイムでは、ローカルキューとグローバルキューの両方が空の場合、ランタイムはpark状態に入り、I/O またはタイマーイベントを待機する。Waker の登録を忘れたタスクは永遠に再エンキューされず、永久にサスペンドされる。multi-thread ランタイムでは状況は類似しているが、他のタスクが継続的にウェイクアップする場合、そのタスクは偽のウェイクアップにより偶然再スケジュールされる可能性がある——しかしこれは依存できない。Tokio には「Pending を返したが Waker が登録されていない」状況を発見する実行時検出メカニズムはない。これは各 poll 後に Waker が使用されたかどうかをチェックする必要があり、オーバーヘッドが大きすぎるためである。これは Future 実装者の責任である。
Q3: LIFO スロットの「3回連続使用後に無効化」ルールは、どのような具体的なシナリオを防ぐためか?もしこの制限を除去した場合、どのようなタスク依存パターンで他のタスクが餓死するか?
参考解析:📎 tokio/src/runtime/mod.rs:380-382によると、LIFO スロットは3回連続使用後に一時的に無効化され、非 LIFO ソースのタスクがスケジュールされるまで続く。このルールが防ぐシナリオは:2つのタスクが互いにウェイクアップして緊密なループを形成する場合である。例えば、タスク A がデータのバッチを処理した後タスク B をウェイクアップし、タスク B が処理後すぐにタスク A をウェイクアップする。3回の制限がなければ、A と B は永遠に LIFO スロットを占有し、worker スレッドはこの2つのタスク間で無限に切り替わり、ローカルキューとグローバルキューの他のタスクは永遠に実行機会を得られない。3回の制限により、「相互ウェイクアップ」の3ラウンドごとに少なくとも1つの他のタスクがスケジュールされ、ライブロックが打破される。この数字の選択は経験的である:小さすぎると LIFO 最適化の利益が減少し、大きすぎると他のタスクの遅延が増加する。
ここまでで、Future、Waker、Executor の3者の責務境界と協調メカニズムは明確になった:Future は計算を定義し、Waker はウェイクアップを担当し、Executor は実行を駆動する。しかし単一のコンポーネントは独立して動作できず、それらは統一されたランタイム環境に組み立てられなければならない。次章では、Runtime::new と Builder::build の完全な組み立てチェーンを追跡し、スケジューラ、I/O ドライバ、時間ドライバ、ブロッキングスレッドプールがどのように同じ Runtime インスタンスに注入されるかを見て、current_thread と multi_thread の2形態の組み立て段階における根本的な差異を明らかにする。
第2章:Runtime の組み立て:Builder がドライバ、スケジューラ、スレッドプールをどのように組み立てるか
からBuilderまでRuntime:一回の組み立ての完全な旅
前章では Future、Waker、Executor の三者の責務境界を明確にしました。しかし実際に使用可能なランタイムは「一つの Executor」だけでは遥かに不十分です——I/O イベントループ、タイマー、ブロッキングスレッドプールも必要であり、これらのコンポーネントは同一のハンドル、同一のライフサイクルを共有しなければなりません。本章ではBuilder::buildの完全な組み立てチェーンを追跡し、一つの核心的な問いに答えます:一つのRuntimeの内部には一体どのようなコンポーネントがあり、それらがどのように組み立てられ、ハンドルを共有するのか。
Tokio の組み立てエントリポイントはBuilderです。それ自体は純粋な設定コンテナであり、すべてのフィールドは「意図宣言」であって、ランタイムリソースを一切保持しません。実際のリソース生成はbuild()の呼び出し時に行われます。
直感的モデル:Builder は「内装設計図」、Runtime は「引き渡し後の家」
Builderはちょうど一枚の内装設計図のようなものです:その上に「部屋をいくつ(worker_threads)」「水道を通すか(enable_io)」「電気を通すか(enable_time)」「外注ヘルパーの上限(max_blocking_threads)」を書き込みます。設計図自体は何の実体も生み出しません。build()を呼び出すまで、施工隊は図面に従って施工し、スケジューラ、ドライバ、スレッドプールといった「部屋」を実際に建て、Runtimeインスタンスを引き渡します。
もしBuilderという層がなければ、ユーザーは各コンポーネントを手動で new し、手動で配線し、手動で失敗時のロールバックを処理しなければなりません——どこか一箇所でも順序を誤れば、ハンドルが宙に浮いたりリソースリークが発生します。Builderの価値は以下にあります:「設定」と「構築」を徹底的に分離し、構築プロセスで検証、失敗時のクリーンアップ、ハンドル共有を集中的に行えるようにする。
メモリレイアウト:Builderのフィールド区分
Builderのフィールドは責務ごとに四つのグループに分けられます。第一のグループは形態とスイッチ:kindがスケジューラの形態を決定し、enable_io / enable_timeが対応するドライバを作成するかどうかを決定します。
📎 tokio/src/runtime/builder.rs:55-68
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で「build 時に CPU コア数に応じて自動検出するまで遅延させる」ことを意味します;max_blocking_threadsデフォルトは 512。
📎 tokio/src/runtime/builder.rs:73-79
worker_threads: Option<usize>,
max_blocking_threads: usize,第三のグループはコールバックフック、すべてOption<Arc<dyn Fn ...>>です。これらがArcではなくBoxを使っていることに注意してください。これらのコールバックは各 worker スレッドのConfig。
📎 tokio/src/runtime/builder.rs:87-97
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
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
#[derive(Clone, Copy)]
pub(crate) enum Kind {
CurrentThread,
#[cfg(feature = "rt-multi-thread")]
MultiThread,
}MultiThreadコピーrt-multi-threadバリアントはrtfeature でゲートされています。これはKindfeature のみを有効にしたビルドでは、build()バリアントが一つしかなく、matchのがコンパイラによって単一分岐に最適化されることを意味します——。
型システムをランタイム判定の代わりに使うことで、マルチスレッドスケジューラのコードサイズを排除する
Builder::newデフォルト値の哲学:なぜ I/O と time はデフォルトで無効なのかenable_ioはすべての構築の共通エントリポイントです。それはenable_timeとfalse。
📎 tokio/src/runtime/builder.rs:309-318
// 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,コピー#[tokio::main]〔設計推論とアーキテクチャトレードオフ〕enable_all()。
enable_all()このデフォルト値の選択は意図的です:I/O ドライバの作成には OS に epoll/kqueue ハンドルを要求する必要があり、time ドライバの作成にはタイマー基盤を起動する必要があります。ユーザーが純粋な計算タスクスケジューラ(例えば CPU 集約型の async ロジックを実行する)だけを望んでいる場合、これらのドライバを強制的に作成するのは純粋な無駄です。
📎 tokio/src/runtime/builder.rs:398-419
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()の実装は feature ゲートが「全開」のセマンティクスにどのように影響するかを明らかにします。net、processコピーsignal注意time feature,enable_all()は
またはbuild()feature が有効な場合にのみ呼び出されます。ユーザーが
build()のみを有効にした場合、kindは I/O ドライバを開きません——コンパイル成果物に I/O ドライバのコードがそもそも存在しないためです。
📎 tokio/src/runtime/builder.rs:1146-1152
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に従って二つの全く異なるパスに分岐します。build_current_thread_runtime_componentsコピーRuntime。
📎 tokio/src/runtime/builder.rs:1725-1736
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,
))
}パス一:current_thread の組み立てbuild_current_thread_runtime_components自体は非常に薄く、
📎 tokio/src/runtime/builder.rs:1760-1766
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コピー(driver, driver_handle)実際の組み立てロジックは?にあります。その実行順序は極めて重要です:buildコピーErr第一步は
を作成し、一対のspawnerを返します。ここでspawnerはエラーをそのまま上に伝播することに注意してください——I/O ドライバの初期化が失敗した場合(例えば epoll の作成失敗)、
全体が
📎 tokio/src/runtime/builder.rs:1768-1770
let seed_generator_1 = self.seed_generator.next_generator();
let seed_generator_2 = self.seed_generator.next_generator();クローンを取り出します。このseed_generator_1はスケジューラに注入され、スケジューラがブロッキングタスクをスレッドプールに投入する能力を持てるようにします。Config第三步は二つの独立した RNG シード生成器を生成します。select!コピーseed_generator_2〔設計推論とアーキテクチャトレードオフ〕CurrentThread::newなぜ二つ必要なのか?rng_seedは
に置かれ、スケジューラ内部で使用されます(例えばConfigのランダム分岐順序);CurrentThread::new。
📎 tokio/src/runtime/builder.rs:1776-1807
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
// 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 にはスレッドが1つしかなく、奪い合いが存在しないため、強制的に無効化される。これは「設定項目のセマンティクスが形態と強く相関する」典型例である——同じBuilderフィールドでも形態によって意味が異なる。
最後に、CurrentThread::newが返すhandleはscheduler::Handle::CurrentThreadに包まれ、さらに公開されたHandle。
📎 tokio/src/runtime/builder.rs:1816-1822
let handle = Handle {
inner: scheduler::Handle::CurrentThread(handle),
};
Ok((scheduler, handle, blocking_pool))パス2:multi_thread の組み立て
build_threaded_runtimeの骨格は current_thread と似ているが、本質的に3つの差異がある。第一の差異は worker スレッド数の決定である:
📎 tokio/src/runtime/builder.rs:2185
let worker_threads = self.worker_threads.unwrap_or_else(num_cpus);Noneここでnum_cpus()に解析される。これが「遅延自動検出」の落地点である——検出はBuilder::new時ではなく build 時に発生する。CPU アフィニティが両者の間で変化する可能性があるためである。
第二の差異は blocking pool の容量計算にある:
📎 tokio/src/runtime/builder.rs:2189-2192
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
let blocking_pool = blocking::create_blocking_pool(self, self.max_blocking_threads, 0);この差異は blocking pool 容量のセマンティクスを明らかにする:multi_thread では、max_blocking_threadsは「追加の」ブロッキングスレッド上限であり、実際の総スレッド上限には worker スレッド数を加える必要がある。第三の引数(current_thread は 0、multi_thread はworker_threads)はおそらく「予約スレッド数」または「初期スレッド数」のヒントである。この設計によりmax_blocking_threadsのセマンティクスは両形態で一貫する:それは「コア worker を超えて追加でいくつのブロッキングスレッドを開けるか」を記述している。
第三の差異はMultiThread::newが二要素タプルではなく三要素タプルを返すことである:
📎 tokio/src/runtime/builder.rs:2198-2226
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
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 スレッドを spawn する。この「先に構築、後に起動」という二段階設計は非常に重要である。
なぜ構築しながら起動できないのか?worker スレッドは一度起動すると即座にタスクの poll を開始し、タスクがhandleを参照する可能性があるためである。もしhandleがまだ構築完了していなければ、「worker が半完成のハンドルを持つ」競合状態が発生する。二段階設計は以下を保証する:すべての worker スレッド起動時に、完全なHandleがすでに準備完了している。_enterガードは worker スレッドが起動瞬間に正しいランタイムコンテキストにあることを保証する。
組み立てフロー図
下の図は2つのパスの組み立て順序、重要な分岐、エラーパスをまとめて描いている。注意driver::Driver::new失敗時は直接Errを返し、この時点で blocking pool はまだ作成されていない。
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
#[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
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
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
pub struct Handle {
pub(crate) inner: scheduler::Handle,
}ユーザーが取得するHandleはスレッドを跨いでクローンでき、spawnでき、block_on。spawnの実装はAutoBoxのコンパイル期分岐を示す:
📎 tokio/src/runtime/handle.rs:197-208
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
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>>向け)、各 spawn の future が二份のタスクハーネスを生成し、コードサイズが倍増する。定数分岐を使えば、単相化コレクタは実際に到達した分岐のみを保持する。
設計思考:組み立て順序、エラー回復、本番の落とし穴
順序は契約である。組み立て順序driver -> blocking_pool -> schedulerは恣意的ではない。driver が最初に作成される。それは OS リソース不足で失敗する唯一の可能性があり、失敗後に他のコンポーネントをクリーンアップする必要がないステップだからである。blocking_pool は driver の後、scheduler の前にある。scheduler が blocking_spawner を必要とするためである。もし blocking_pool の作成が失敗した場合(実際にはほとんど失敗しない)、driver は drop により自動クリーンアップされる。
current_thread のlocal_tid分岐。build_localはbuild_current_thread_local_runtimeを通り、現在のスレッド ID を渡す:
📎 tokio/src/runtime/builder.rs:1738-1751
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,
))
}このtidはHandleに格納され、後続のcan_spawn_local_on_local_runtimeが「spawn_local が owner スレッド上で呼ばれたか」を検証するために使う:
📎 tokio/src/runtime/scheduler/mod.rs:140-147
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,
}
}これはLocalRuntimeの安全性の基石である:!Sendの future はその owner スレッド上でのみ poll でき、local_tidがこの制約の実行時チェックポイントである。このチェックを除去すると、スレッドを跨いだ spawn_local が!Sendデータの並行アクセスを引き起こし、UB を招く。
本番の落とし穴1:worker_threads(0)は panic する。worker_threadsメソッドにはアサーションがある:
📎 tokio/src/runtime/builder.rs:582-586
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
}このアサーションはbuildを待たずに設定段階で失敗する。利点はエラーの特定が早くなること、欠点はスレッド数が設定ファイルの動的な値に由来する場合、ユーザーが呼び出し前に自分で検証しなければならないことだ。
本番の落とし穴その2:max_blocking_threads小さく設定しすぎるとハングする。ドキュメントは明確に警告している:
📎 tokio/src/runtime/builder.rs:600-601
/// 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」はまさにこのリスクの注釈である。
本番の落とし穴その3:UnhandledPanic::ShutdownRuntimecurrent_threadのみサポート。
📎 tokio/src/runtime/builder.rs:1374-1381
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では「ランタイムを即座にシャットダウンする」にはすべてのworkerスレッドの停止を調整する必要があり、実装の複雑さが高くセマンティクスも曖昧になるためだ(poll中の他のタスクはどうするのか?)。current_threadはスレッドが1つだけなので、シャットダウンのセマンティクスが明確である。
本章のまとめ
本章では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の2段階起動が追加され、enable_eager_driver_handoffはcurrent_threadでは強制的に無効化される。
3. Handleはコンポーネント間で共有される核心であり、内部でArcが形態固有のハンドルを包み、matchまたはmatch_flavor!マクロを通じて統一的にアクセスされる。
4. AutoBoxは関連定数を用いてコンパイル時にfutureをボクシングするかどうかを決定し、コードサイズの倍増を避ける。
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:self.max_blocking_threads + worker_threadsによれば、multi_threadパスでは📎 tokio/src/runtime/builder.rs:1765が渡され、current_threadパスでは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はスレッドが1つだけでblock_in_placeのworker変換セマンティクスをサポートしないため、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()の役割はスレッドローカルコンテキストを設定し、現在のスレッドを「ランタイム内部にいるように見せる」ことである。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は実行時のif size_of::<F>() > THRESHOLDではなく関連定数を用いる。仮に実行時判断に変更した場合、コードサイズの倍増以外に、どのような状況で性能劣化が起こるか?
参考解析:📎 tokio/src/runtime/mod.rs:657-673のコメントによれば、実行時のifはspawn_namedを各Tに対して2回単相化させる(TとPin<Box<T>>がそれぞれ1回)。コードサイズの倍増以外に、性能劣化は以下に現れる:1) 命令キャッシュ(i-cache)のプレッシャーが増大する。2セットのharnessコードが常駐する必要があるため;2) コンパイラが「実際には1つの分岐しか通らない」ことを最適化できず、実行時の分岐予測は通常正確だが、分岐自体と2セットのコードのレジスタ割り当ての差異が累積する;3) より隠れた問題として、Pin<Box<T>>パスは強制的にヒープ割り当てを行い、実行時判断が何らかの理由(例えばsize_ofがジェネリックコンテキストで完全に定数畳み込みされない)で誤判定すると、小さなfutureもボクシングされ、spawnごとにヒープ割り当てが1回増える。関連定数は単相化コレクタにコンパイル時に通らない分岐を刈り取らせ、実行時オーバーヘッドをゼロにする。
第3章:タスクの一生(上):spawn がどのように Future をスケジュール可能な実体に変えるか
前章では Runtime の組み立てを完了しました:I/O driver、time driver、blocking pool、スケジューラが同一のRuntimeインスタンスに注入され、Handleこれらのコンポーネントにスレッドをまたいでアクセスするための共有ハンドルとなりました。しかし組み立て済みのランタイムはこの時点ではまだ空の殻にすぎません——タスクを駆動するエンジンは持っているものの、駆動すべきタスクが一つもないのです。本章で答える問いはまさにこれです: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>で「ランタイムに所有されるタスク参照」を表し、これはRawTaskの透過的なラッパーです:
#[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>であり、そのレイアウトはタスクモジュール全体の基盤です:
#[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 で64ではなく128を使うのかを説明しています:Intel Sandy Bridge 以降、空間プリフェッチャはペアの64バイトキャッシュラインを一度に取得するため、偽共有を避けるには128バイトにアラインしなければなりません📎 tokio/src/runtime/task/core.rs:45-53。
このアラインメント戦略の代償は、各タスクが少なくとも1キャッシュライン分の空間を浪費することです。しかしタスク状態ビット(state)は複数の worker スレッドによって高頻度で読み書きされます——あるスレッドが poll 時に RUNNING ビットを設定し、別のスレッドが起床時に NOTIFIED ビットを読む——もし二つのタスクの状態ビットが同じキャッシュラインに載ると、状態遷移のたびにキャッシュラインがコア間で往復バウンドし(cache line ping-pong)、性能損失はメモリ浪費をはるかに超えます。Tokio は空間を時間に換える選択をしました。
Header自体は8ポインタサイズ以内に制約されています:
#[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バイトキャッシュラインのアーキテクチャ上で完全に1行に収まります。Headerのフィールドには以下が含まれます:state: State(アトミック状態ビット)、queue_next: UnsafeCell<Option<NonNull<Header>>>(注入キューの連結リストポインタ)、vtable: &'static Vtable(関数ポインタテーブル)、owner_id: UnsafeCell<Option<NonZeroU64>>(所属するOwnedTasksリストの ID)、scheduled_at: UnsafeCell<ScheduleLatencyInstant>(スケジューリング遅延測定)📎 tokio/src/runtime/task/core.rs:169-198。
Core<T, S>はスケジューラハンドルscheduler: S、タスク IDtask_id: Id、そして最も核心的なstage: CoreStage<T> 📎 tokio/src/runtime/task/core.rs:148-165。Stageを保持します。
#[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)]になります。コメントは Miri issue を指し、このレイアウトが 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。
Step-by-Step:spawn からエンキューまで
具体的なシナリオを想定します:multi_thread ランタイムにおいて、worker スレッド A がtokio::spawn(async { 42 })。
を実行します。第一ステップ:タスク三種の神器を構築する。 new_taskはタスク誕生の唯一の入口です:
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。3つは同じrawを共有しており、それぞれが1つの参照カウントを保持している。
第2ステップ:Cellを割り当て、初期状態を書き込む。 Cell::newヒープ上に構造体全体を割り当てる:
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に対して単相化された関数ポインタテーブル📎 tokio/src/runtime/task/core.rs:260である。future は直接Stage::Runningにムーブされ、追加のボックス化はない。
第3ステップ:debug アサーションでレイアウトを検証する。ではdebug_assertionsの下で、Cell::newがcheck関数を呼び出し、Header::get_trailer、Header::get_scheduler、Header::get_id_ptrなどの vtable オフセットに基づくポインタ演算を用いて、「header から逆引きしたフィールドアドレス」と「実際のフィールドアドレス」が一致することを1つずつアサートする📎 tokio/src/runtime/task/core.rs:280-321。これは vtable オフセットの正確性に対する実行時セルフチェックである。
第4ステップ:スケジューラへ投入する。スケジューラはNotified<S>を受け取ると、Schedule::schedule 📎 tokio/src/runtime/task/mod.rs:315を呼び出す。multi_thread ではpush_back_or_overflowを通り、タスクを現在の worker のローカルキューにプッシュし、キューが満杯の場合はインジェクトキューにオーバーフローする。
次の図はnew_taskからエンキューまでの制御フローと分岐を描いている:
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この図はいくつかの重要な分岐を明らかにしている:debug アサーションはデバッグビルドでのみ有効;ローカルキューが満杯のときは直接オーバーフローするのではなく、まず並行スティーラーが存在するか(steal != real)を判定し、存在する場合は現在のタスクのみをインジェクトキューにプッシュする。スティーラーが空けたスペースはすぐに利用可能になるからである。
設計上の考察:なぜ1つではなく3つの参照なのか
new_taskは1つではなく3つの参照を返す。これが参照カウント設計の核心である:Taskは「ランタイムがこのタスクを所有している」ことを表し、Notifiedは「このタスクは通知済みで、スケジュール待ち」を表し、JoinHandleは「誰かがその結果を気にしている」を表す。3つのライフサイクルは独立している——JoinHandleは drop できる(タスクは実行を続け、結果は破棄される)。Notifiedは poll 後に消える。Taskはタスクが完了しOwnedTasksから削除された後に解放される。参照が1つしかなければ、「タスクはまだ実行中だが誰も join していない」という状態を表現できない。
UnownedTaskはもう1つの重要な分岐である:これは2つの参照カウントを保持し、blocking タスク用である(OwnedTasks)📎 tokio/src/runtime/task/mod.rs:286-295。unownedに格納されない)。mem::forget(task)関数はmem::forget(notified)とUnownedTask 📎 tokio/src/runtime/task/mod.rs:388-397を通じて2つの参照をOwnedTasksに統合する。この「2つの参照」の設計動機は:blocking タスクには owned 参照を保持する
リストがないため、タスクが実行中に解放されないことを保証するために追加の参照カウントが必要となる。
3.2 状態ビット:1つの usize でタスクの全ライフサイクルをどうエンコードするか
直感的モデルタスクの状態を「健康診断レポート」のようなものだと想像しよう。そこにはいくつかの独立したチェックボックスがある:poll 中かどうか、完了したかどうか、通知されたかどうか、キャンセルされたかどうか、誰かが join しているかどうか。Tokio は複数のブールフィールドを使わず、これらのチェックビットをAtomicUsize1つの
に押し込んでいる。こうすることで各状態遷移は複数回のロックではなく1回の CAS で済む。この設計がなければ、タスクの状態遷移は複数のロックのネストになり、デッドロックのリスクとオーバーヘッドが急増する。
Stateビットフィールドレイアウト📎 tokio/src/runtime/task/mod.rs:32-53:
RUNNINGのビットフィールドはモジュールドキュメントに完全な定義がある:タスクが poll 中かキャンセル中かどうか。 📎tokio/src/runtime/task/mod.rs:37-38。COMPLETEこのビットは同時にタスクのロックとしても機能するRUNNING:future が完全に完了し drop された。一度セットされると決してクリアされず、決して📎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:📎tokio/src/runtime/task/mod.rs:50-51。
が存在する📎 tokio/src/runtime/task/mod.rs:53。
RUNNING:join handle waker のアクセス制御ビットとしてRUNNING残りのビットは参照カウントに使用される📎 tokio/src/runtime/task/mod.rs:130-133ビットがロックとして機能する点は詳しく述べる価値がある。モジュールドキュメントの Safety セクションは次のように述べている:future への可変アクセスはRUNNINGビットを変更してロックを取得した後に行わなければならず、それによって排他的アクセスが保証される
。これは、タスクを poll するとき、スレッドがまず CAS で
JOIN_WAKERをセットし、成功すれば future を独占する;失敗すれば別のスレッドが poll 中であることを意味し、今回の poll は直接戻る。これは「poll の相互排他」と「状態遷移」を1回のアトミック操作に統合し、個別のミューテックスロックを回避している。wakerJOIN_WAKER のアクセス制御プロトコルTrailerビットは状態機械の中で最も精妙な部分である。これが解決する問題は:フィールド(内)が2つのスレッドから並行アクセスされる——ランタイムはタスク完了時にJoinHandleそれを読んで join 者を起こし、は poll 時に📎 tokio/src/runtime/task/mod.rs:75-120:
1. JOIN_WAKERそれを
書いて waker を登録する。モジュールドキュメントは7つのルールを示しているJoinHandle初期値は0。
2. が0のとき、JoinHandleは waker フィールドへの排他的(可変)アクセス権を持つ。
3. が1のとき、COMPLETEは共有(読み取り専用)アクセス権のみを持つ。
5. JoinHandle4. が1かつJOIN_WAKERが1のとき、ランタイムは waker フィールドへの共有(読み取り専用)アクセス権を持つ。JOIN_WAKERが waker を書くには:(i)
6. JoinHandleを0にセットして排他権を獲得することに成功し、(ii) waker を書き込み、(iii)COMPLETEを1にセットすることに成功する必要がある。JOIN_WAKERはCOMPLETEが0のときのみ
を変更できる;ランタイムはJOIN_INTERESTが1のときのみ変更できる。COMPLETE7. もし
が0かつCOMPLETEが1なら、ランタイムは waker フィールドへの排他アクセス権を持つ(waker の drop 用)。📎 tokio/src/runtime/task/mod.rs:110-120ルール6は競合を暗に含んでいる:ステップ (i) または (iii) が失敗する可能性がある。(i) が失敗したら waker の書き込みを諦める;(iii) が失敗したら(その間に別のスレッドが
をセットした)、waker フィールドをクリアする
Task。このプロトコルの本質は:1つのアトミックビットで「書き手」と「読み手」の間で動的に所有権を移転し、waker フィールド専用のロックを回避することである。UnownedTaskの drop が2回デクリメントされる:
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
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はUnownedTask2つのカウントを保持していることの直接的な現れである。
設計上の考察:なぜ状態ビットと参照カウントが1つのアトミックを共有するのか
状態ビットと参照カウントを同じAtomicUsizeに置くのは、「参照カウントのデクリメント」と「状態ビットの設定」という2つの操作を1回のCASで完了させるためである。モジュールドキュメントはSchedule::releaseのコメントで明確に述べている:「タスクモジュールはref-decとその他のオプションの設定をバッチ処理する」📎 tokio/src/runtime/task/mod.rs:302-304。もし状態ビットと参照カウントが2つの別々のアトミック変数に属していたら、「最後の参照の解放」と「完了のマーク」の間にウィンドウが生じ、追加の同期が必要になる。統合後は、ref_decが「カウントのデクリメント+ゼロかどうかのチェック」をアトミックに完了でき、ABA類の問題を回避できる。
3.3 JoinHandle:結果はどのようにタスク境界を越えて返されるか
直感的モデル
JoinHandleはレストランが渡す「呼び出しベル」のようなものである。タスク(厨房)が完了すると、料理(output)を受け渡し口(Stage::Finished)に置き、あなたの呼び出しベル(waker)を鳴らす。あなたは引換券を持って受け取りに来る。引換券自体は料理を保持せず、受け渡し口へのポインタにすぎない。もし引換券を失くしたら(dropJoinHandle)、料理はそのまま捨てられる(outputがdropされる)が、厨房はそれによって停止しない。
データ構造
JoinHandle<T>も同様にRawTaskへの透過的なラッパーである:
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の時のみであり、これにより非Send出力がスレッド間で移動されないことが保証される。
ステップバイステップ:JoinHandleをawaitする
JoinHandleはFutureを実装しており、そのpollが結果返却の核心である:
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を返す。登録プロセスはまさに3.2節のJOIN_WAKERプロトコルに従う。
結果の所有権の移動
モジュールドキュメントの「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。
非Send outputについて、ドキュメントは3段階の論証を示している:outputはpoll futureのスレッド上で作成される;JoinHandle<Output>はOutputが非Sendの時も非Sendであるため、これもspawnスレッド上にある;したがってJoinHandleがoutputを取り出すかdropする時にスレッド間で移動されることはない📎 tokio/src/runtime/task/mod.rs:164-170。
JoinHandleのdrop:高速パスと低速パス
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は1回のCASで「JOIN_INTERESTビットのクリア+参照カウントのデクリメント」を完了しようと試みる。失敗した場合(例えばタスクが完了中で、状態ビットが占有されている)、drop_join_handle_slowの低速パスに進む。これは典型的な「楽観的高速パス+悲観的低速パス」パターンである。
設計上の考察:なぜJoinHandleはoutputを直接保持しないのか
もしJoinHandleがoutputを直接保持していたら、outputはタスク完了時にJoinHandleのスレッドへ移動されなければならない。しかしJoinHandleは任意のスレッドに移動される可能性があり(T: Sendでありさえすれば)、outputの生成スレッドはpollスレッドである。直接保持すると「outputはpollスレッドで生成されるが、joinスレッドでdropされる」というスレッド間移動が生じ、非Send outputに対して型システムに直接違反する。TokioはoutputをCell内に留めることを選択した(Stage::Finished),JoinHandleはCellへのRawTaskのみを保持し、結果取得時にはtake_outputを通じてその場で取り出す。これによりoutputのdropはJoinHandleのスレッドで発生するが、その前提はそのスレッドがpollスレッドと同じであることである(非Sendシナリオでは成立する)。
3.4 ローカルキュー:work-stealingのプロデューサー-コンシューマー構造
直感的モデル
各workerは「プライベートなToDoリスト」(ローカルキュー)を持ち、容量は256。worker自身は先頭からタスクを取り出し(LIFO、キャッシュ局所性を活用)、他のworkerは末尾からタスクを盗む(FIFO、最も古く、最も完了している可能性が高いタスクを取る)。もしローカルキューがなければ、すべてのタスクがグローバルキューに集中し、タスク取得のたびにグローバルロックを競合することになり、マルチコアのスケーラビリティが崩壊する。
メモリレイアウト:headとtailの分離
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内部にパックされている2つの UnsignedShort:下位は「リアルヘッド」(real head)、上位は「窃取者が処理中の最初の位置」(steal head)。両者が等しいとき、アクティブな窃取者は存在しない📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:39-49。この二値パッキングは work-stealing キューの核心的なテクニックである:窃取者はまず CAS で steal 値を更新してタスクのバッチを「claim」し、完了後に 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。
Step-by-Step:push_back_or_overflow の完全な分岐
これはローカルキューの最も複雑な関数であり、分岐ごとに解析していく:
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 を更新:
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:なぜ後半バッチをオーバーフローさせるのか
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 で後半バッチを claim:
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 まで進め、全タスクを claim する。成功後に tail をtail + NUM_TASKS_TAKENに巻き戻し、前半バッチがまだローカルキューに残っていることを表す📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:314-316。
pop と steal_into:タスクを取る二つの経路
popは worker 自身がタスクを取る(先頭から、LIFO):
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 の位置まで進めないことを保証する。そうでなければ窃取者の claim 状態を破壊してしまう。
steal_intoは窃取経路であり、まず対象キューに十分なスペースがあるか確認する:
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は窃取の核心であり、窃取数を計算する:
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 で head の steal 値を更新して claim する:
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まで進める。これは「これらのタスクは claim 済みで、他の窃取者は触れられない」ことを表す。窃取完了後、steal を real まで追いつかせる:
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」の三者並行インタラクションを描いている:
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を通じて wake を登録し、最終的にscheduleの再エンキューをトリガーする——「wake → エンキュー → 再 poll」という閉ループの完全な呼び出しパス、そして work-stealing 戦略と LIFO スロット最適化がそこで明らかになる。
第 4 章:タスクの一生(下):スケジューリングループ、poll、wake の閉ループ
キューから実行へ:worker メインループの骨格
前の章でタスクをLocalキューまたはグローバル注入キューに入れた。しかしキューは「ToDo リスト」に過ぎず、実際にタスクを走らせるのは worker スレッド内の決して止まらないループである。この章ではContext::runを追跡する——これはマルチスレッドスケジューラ全体の心臓である。
まず直感を確立しよう:worker スレッドは料理人のようなもので、目の前に自分の注文の山(run_queue)があり、隣には共有の注文棚(inject)もある。料理人はまず手元の一番近い一枚を見る(lifo_slot)、自分の山から取れなければ、それもなければ公共の棚から一掴み取り、それでもダメなら他のシェフの山から何枚か盗む。全部空になって初めて休憩に入るが、休憩中も耳は立てたまま——注文が入ればすぐに目を覚ます。
このループがなければ、タスクはキューに入れられた後永遠にキューに横たわり、Future::poll永遠に呼び出されることはなく、ランタイム全体が死んだデータの山となる。
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>:park器。Optionでラップしているのは、借用チェッカーの下で簡単に取り出し/戻しを行うため。global_queue_interval: u32:グローバルキューをどのくらいの頻度でチェックするか。rand: FastRand:高速乱数生成器。盗みの開始点をランダムに選択するために使用。
注意lifo_slotはOption<Notified>でありキューではない——それは1つのタスクのみを格納する。この設計の動機はソースコードのコメントにはっきりと書かれている📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:117-121:workerが自分でスケジュールしたタスクはこのスロットに格納され、workerはrun_queue をチェックする前にまずこれをチェックする。効果は「最後にスケジュールされたタスクが次に実行される」(LIFO)。これは局所性を改善するためであり、メッセージパッシングパターンに特に有効で、レイテンシを低減できる。
なぜLIFOがレイテンシを低減できるのか?典型的なメッセージパッシングシナリオを考えよう:タスクAがメッセージを処理した後タスクBを起こし、Bが処理した後またAを起こす。もしAがBを起こした後Bがすぐに実行されれば、Bが必要とするデータはおそらくまだCPUキャッシュにある(Aがちょうど触ったばかりだから)。もしBがキューの末尾に押し込まれ、前の数十のタスクが実行されるのを待つと、キャッシュはとっくに追い出されている。
しかしLIFOには飢餓のリスクがある。ソースコードではMAX_LIFO_POLLS_PER_TICK = 3を使用して📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:263-263を制限している:各tickで最大3回までLIFOスロットを優先し、超えると無効化し、他のタスクに実行の機会を与える。
メインループのウォークスルー:1回の完全なスケジューリングサイクル
具体的なシナリオを想定しよう: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ループに入る。
各ループで4つのことを行う:
ステップ1:tickとメンテナンス。 core.tick()がカウンタ📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:587をインクリメントする。次にself.maintenance(core)がtick % event_interval == 0をチェックし、そうであればpark_yieldを呼び出して0タイムアウトでI/Oとタイマーを駆動する📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:809-826。
ステップ2:タスク取得。 core.next_task(&self.worker)は核心的なタスク取得ロジック📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1090-1156。それは2つのパスに分かれる:
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:
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を説明している:取得したタスクがローカルキューの前半部分に収まることを保証し、これにより後でオーバーフローが発生しても、これらのタスクがグローバルキューに押し戻されない(オーバーフローは後半部分にのみ影響する)。
ステップ3:タスク実行。がタスクを取得した後run_task 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:647-796を呼び出す。これは本章で最も複雑な関数であり、次の節で専門的に展開する。
ステップ4:盗みまたはpark。もし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。
に入る。制御フロー全体は以下の通り:
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 --> tickrun_task:pollとLIFOスロットの閉ループ
run_taskはタスクが実際にpollされる場所であり、「起床 → エンキュー → 再poll」閉ループの収束点でもある。
関数に入って最初のことはassert_owner 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:648で、NotifiedをTaskに変換し、同時に現在のスレッドが確かにこのタスクのownerであることをアサートする(debugアサート)。
次にtransition_from_searching 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:652——もしworkerが以前検索状態にあったなら、今タスクを見つけたので、検索状態を終了し、他のparked workerを起こす可能性がある。
そして重要なbudgetラップ📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:695-795:
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をチェックし、タスクがあれば実行を続ける——メインループに戻らず、同じbudget内で連続してpollする。
これが「起床 → エンキュー → 再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を登録する必要があり、イベントが準備完了になるとウェイクアップされる。Tokio のWaker実装は極めて簡潔——タスクのHeaderへの生ポインタと vtable だけである。
waker_refを構築し、WakerRef 📎 tokio/src/runtime/task/waker.rs:11-34でManuallyDropをラップしてWakerdrop 時の参照カウント減少を避ける。vtable は静的な📎 tokio/src/runtime/task/waker.rs:119-119:
static WAKER_VTABLE: RawWakerVTable =
RawWakerVTable::new(clone_waker, wake_by_val, wake_by_ref, drop_waker);4つの関数はすべて生ポインタを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からSCHEDULEDに変換し、変換が成功した場合(つまり以前が確かに PENDING だった場合)、Schedule::scheduleを呼び出してタスクを再エンキューする。
マルチスレッドスケジューラの場合、scheduleの実装はHandle::schedule_task 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1353-1376:
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();
});
}ロジックは2つの分岐に分かれる:
- 現在のスレッドがこのスケジューラの worker であり、かつ core を保持している場合、
schedule_local📎tokio/src/runtime/scheduler/multi_thread/worker.rs:1385-1417へ——LIFO スロットまたはローカルキューに入れる。 - そうでない場合(外部スレッドからのウェイクアップ、または core が盗まれた場合)、
push_remote_taskへ進みグローバル注入キューにプッシュし、notify_parked_remoteで parked worker をウェイクアップする📎tokio/src/runtime/scheduler/multi_thread/worker.rs:1379-1383。
schedule_local内部はさらに2つの分岐に分かれる📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1385-1417:もしyieldまたは LIFO が無効化されている場合、run_queueの末尾にプッシュする;そうでなければlifo_slotに入れ、元のスロットにあったタスクをキューの末尾に押し出す。
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()"
endpark と unpark:状態機械とウェイクアップの原子性
worker はやることがないときに park するが、park/unpark は最も競合が発生しやすい場所である。Tokio はAtomicUsize状態機械とCondvarフォールバックでこれを解決している。
Innerのフィールド📎 tokio/src/runtime/scheduler/multi_thread/park.rs:31-43:state: AtomicUsize、mutex: Mutex<()>、condvar: Condvar、shared: Arc<Shared>。状態定数は4つある📎 tokio/src/runtime/scheduler/multi_thread/park.rs:36-45:
EMPTY = 0:park されていない。PARKED_CONDVAR = 1:condvar 上で park する。PARKED_DRIVER = 2:I/O driver 上で park する。NOTIFIED = 3:すでにウェイクアップされている。
これは明示的な状態機械であり、これを使って状態図を描く(これは本章で唯一stateDiagram-v2の准入条件に合致する箇所である——ソースコードに確かにこれら4つの状態定数が存在する):
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の実装📎 tokio/src/runtime/scheduler/multi_thread/park.rs:277-290はswapではなく CAS を使用している。ソースコードのコメントがその理由を説明している📎 tokio/src/runtime/scheduler/multi_thread/park.rs:277-290:park スレッドが unpark 前の書き込みを観察できるように release 操作を実行する必要があるため、たとえ state がすでにNOTIFIEDであっても一度書き込む必要がある。
parkまず既存の通知を消費しようと試みる📎 tokio/src/runtime/scheduler/multi_thread/park.rs:132-149:もし CASNOTIFIED -> EMPTYが成功すれば、以前にウェイクアップされていたことを意味し、ブロックせずに直接戻る。そうでなければ driver ロックの取得を試み、取得できれば driver 上で park し、取得できなければ condvar でフォールバックする📎 tokio/src/runtime/scheduler/multi_thread/park.rs:143-148。
park_condvarには古典的な二重チェックがある📎 tokio/src/runtime/scheduler/multi_thread/park.rs:162-180:まず CASEMPTY -> PARKED_CONDVAR、もし失敗してかつNOTIFIEDであれば、状態設定前にウェイクアップされたことを意味し、このとき必ずswap(EMPTY)して unpark の書き込みを同期させる必要がある📎 tokio/src/runtime/scheduler/multi_thread/park.rs:167-177。コメントは特に強調している:たとえNOTIFIEDとわかっていても必ず一度読み取る必要がある。なぜなら unpark が我々がNOTIFIEDを読んだ後に再度呼び出される可能性があるからである。
unpark_condvarのコメント📎 tokio/src/runtime/scheduler/multi_thread/park.rs:292-307は condvar の古典的な罠を指摘している:parked スレッドがPARKED状態を設定してから実際にwaitするまでの間にウィンドウ期間があり、この間に notify されると無視される。解決策は、park スレッドがこのときmutexを保持しており、unpark スレッドがまずdrop(self.mutex.lock())でロックを取得し(それによって park スレッドの解放を待つ)、その後notify_one。
設計上の考察:なぜ LIFO スロットは単一スロットでありキューではないのか
単一スロット設計は意図的なトレードオフである。もしキューを使えば、毎回のウェイクアップでエンキュー、毎回のタスク取得でデキューが必要となり、オーバーヘッドが大きくなる;さらにキューは複数のタスクを蓄積し、「最近ウェイクアップされたものが最初に実行される」という局所性の仮定を壊す。単一スロットのセマンティクスは「直近の1つだけを覚える」であり、押し出されたタスクは通常のキューに入る——これはちょうど局所性の収益逓減の法則に合致する:直近の1つのタスクが最も熱く、2番目がその次、3番目以降は収益が非常に小さくなる。
MAX_LIFO_POLLS_PER_TICK = 3このマジックナンバー📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:263-263も経験値である。ソースコードのコメントには「LIFO スロットを数回実行すれば局所性の恩恵を受けるのに十分と思われ、3回を超えると過度に重み付けされる可能性がある」とある。これは A が B をウェイクアップし、B が A をウェイクアップする ping-pong シナリオが他のタスクを飢えさせるのを防ぐ。
もう一つの注目すべき設計はsteal_workの「半数探索」戦略である📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1158-1160:半分未満の worker が探索しているときにのみ、新しい worker が実際に盗みを試みる。これによりすべての worker が同時に狂ったように盗みを行い CAS 競合が発生するのを避ける。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盗みはランダムな開始点から始まり📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1179-1182、すべての remote を走査し、自分自身をスキップしsteal_into、📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1197-1203。
を呼び出して盗みを試みる。すべて失敗したらグローバルキューにフォールバックする
本章のまとめContext::runworker メインループrun_taskはスケジューラの心臓である:各ラウンドの tick 後にまずタスクを取得し(LIFO スロット → ローカルキュー → グローバルキュー)、取得できればrun_taskで poll を実行し、取得できなければ盗み、盗みに失敗すれば park する。Waker内部の LIFO ループは「ウェイクアップ → エンキュー → 再 poll」を同じ budget 内に圧縮し、低遅延の閉ループを形成する。wake_by_refは生ポインタと静的 vtable であり、scheduleは状態遷移を通じてpark/unparkをトリガーし、現在のスレッドが同じ worker かどうかでローカルキューかグローバルキューかを決定する。
は4状態の原子的機械と condvar フォールバックで、ウェイクアップ喪失の古典的な競合を解決している。Waker次章ではスケジューラを離れ、I/O の世界に入る:Reactor がどのように epoll イベントをAsyncFdのウェイクアップに変換し、PendingのReady。
を
本章の考察とセルフチェックnext_local_taskQ1: もし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を読んだ後に再度呼び出された可能性があり、その unpark と同期するために一度 acquire 操作を実行しなければ、その前のすべての書き込みを観測できない。もしreturnのみで swap しなければ、state はNOTIFIEDに留まり、次回 park 時に CASNOTIFIED -> EMPTYが成功して即座に戻る(すでに期限切れの通知を消費してしまう)。しかしさらに悪いことに、unpark の release 書き込みが同期されず、park スレッドが unpark 前に書き込まれたデータを見られない可能性があり、メモリ可視性の問題を引き起こす。これは典型的な「失われたウェイクアップ+メモリオーダー」の二重バグである。
Q3: run_taskにおいて、self.core.borrow_mut().take()がNoneを返すとき、なぜControlFlow::Break(())ではなくContinue?
を返すのか?:self.core.borrow_mut().take()参考解析Noneが📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:716-724を返すことは、core がすでに盗まれたことを意味するblock_in_place。core が盗まれる唯一の経路は、タスク内部でmaybe_move_runtimeが呼ばれ、cx.coreを通じて core を📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:473-497から取り出し、新しいスレッドContinue,Context::runに渡すことである。このとき現在のスレッドはもはやスケジューリング能力を保持しておらず、もしcore.next_task()を返すとループを続けてself.coreなど core を必要とするメソッドを呼び出すが、core はすでにBreakにないため、panic や状態の不整合を引き起こす。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と説明している:このときContext::runを呼んではならない。core が盗まれており、盗んだ側が
次の章:第 5 章 →
検証状態:FACT 行番号が実在にアンカー済み
前章では worker スレッドのメインループを追跡した:タスクが poll され、Pending を返すと Waker がどこかに保存され、イベントが準備完了すると Waker がトリガーされ、タスクが再びキューに入る。しかし「どこか」とは一体どこか? epoll イベントが到来したとき、Waker はどうやって見つけ出されるのか? これこそ Reactor が答えるべき問題である。まず直感モデルを構築しよう:I/O 準備完了通知メカニズム全体をレストランの呼び出しベルシステムと想像してほしい——顧客(タスク)は注文後、窓口でじっと待つのではなく、振動ベル(Waker)を持って席に戻る。厨房(カーネル epoll)が料理を完成させると、フロント(Reactor)が注文番号(Token)に基づいて対応する振動ベルを見つけ、ボタンを押す。このシステムがなければ、各タスクは socket をポーリングするしかなく、CPU は焼き尽くされる。あるいはブロッキングスレッドで待機すると、1 接続 1 スレッドとなり、規模が拡大しない。Tokio の Reactor は 3 つのファイルで 3 層構造を構成し、責務が厳密に分離されている:driver.rs はイベントループ本体であり、mio::Poll を保持し、poll() を呼び出してカーネルイベントをブロッキング待機し、イベントを ScheduledIo の読み書きに変換する責務を負う;registration.rs はユーザー向けの登録ハンドルであり、TcpStream が内部に保持しているのはこれで、poll_read_ready / poll_write_ready などの API を提供する;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:任意のスレッドからturnでブロックしている driver を起こすために使用。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 で再び返される。
Step-by-Step:一度の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:呼び出しInterrupted。ここが本当に epoll_wait でブロックする場所。エラー処理は控えめ:📎 tokio/src/runtime/io/driver.rs:200は直接無視(シグナル割り込みは正常)InvalidInput、WASI 下の📎 tokio/src/runtime/io/driver.rs:201-205も無視📎 tokio/src/runtime/io/driver.rs:206。
、その他のエラーは直接 panic第四步📎 tokio/src/runtime/io/driver.rs:211-233:イベントを走査event:
- 。各
token == TOKEN_WAKEUPについて📎tokio/src/runtime/io/driver.rs:214もしunpark(値が 0) - 、何もしない——これは
token == TOKEN_SIGNALがブロックを中断するために使用。📎tokio/src/runtime/io/driver.rs:216もしsignal_ready = true。 - (値が 1)📎
tokio/src/runtime/io/driver.rs:218-231、mio::Readyを設定Readyそうでなければ通常の I/O イベントEXPOSE_IO.from_exposed_addr(token.0):*const ScheduledIoを Tokio のset_readiness(Tick::Set, |curr| curr | ready)に変換し、io.wake(ready)で token をWaker。
ポインタに復元し、次にEXPOSE_IOで準備完了ビットを累積し、さらにPtrExposeDomain<ScheduledIo> 📎 tokio/src/runtime/io/mod.rs:21-22で対応する方向のusizeをトリガーmio::Tokenここで📎 tokio/src/runtime/io/driver.rs:222-225はであり、ポインタをとしてArc<ScheduledIo>に「露出」させる。安全性コメント
がこの unsafe 変換が安全である理由を説明している:ポインタは mio からの登録解除かつ📎 tokio/src/runtime/io/driver.rs:235-258driver がもはや並行 poll しない前に解放されず、driver が
の所有権を保持する。第五步📎 tokio/src/runtime/io/driver.rs:265-267。
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)"]、CQ オーバーフロー時の flush ループを含む。Handle第六步mio::Waker
unpark 📎 tokio/src/runtime/io/driver.rs:280-283:metrics を累積self.waker.wake()コピーmio::Waker設計思考:なぜDriver::newがTOKEN_WAKEUPを保持し📎 tokio/src/runtime/io/driver.rs:124を呼び出すのかpoll.poll()。このunparkはTOKEN_WAKEUP時にpollで📎 tokio/src/runtime/io/driver.rs:214-215。
。driver がderegister_sourceでブロックしているとき、別のスレッドが📎 tokio/src/runtime/io/driver.rs:315-334を呼び出すと epoll にregistrations.deregisterイベントを挿入し、unpark()が即座に返り、走査時にこの token を見て直接スキップpoll〔設計推論とアーキテクチャトレードオフ〕max_waitこのメカニズムは
で使用されるderegister_source:source を登録解除した後、もしself.registry.deregister(source) 📎 tokio/src/runtime/io/driver.rs:322が true を返す(これが最後の参照であることを示す)なら、registrations 📎 tokio/src/runtime/io/driver.rs:315-334。なぜか? driver が📎 tokio/src/runtime/io/driver.rs:320-321でこの fd のイベントを待ってブロックしている可能性があり、fd はすでに登録解除され、カーネルはもはやイベントを生成しない;能動的に driver を起こし、登録集合を再チェックさせ、ブロックを終了させる必要がある。そうでなければ driver は📎 tokio/src/runtime/io/driver.rs:336-340タイムアウトまで眠り続け、shutdown が遅延する。もう一つの詳細:は先に
を呼び出し、次にRegistrationをクリーンアップWaker。コメントScheduledIo
は「Cleanup ALWAYS happens」と述べている——OS 層の deregister が失敗しても、内部状態をクリーンアップし、最後に OS エラー
Registrationを返す。これは典型的なリソースクリーンアップがエラー伝播より優先されるscheduler::Handleパターン。Arc<ScheduledIo>登録層:poll_read_readyどのようにRegistrationをWakerに格納するかScheduledIo直感モデルScheduledIoはWakerタスクと fd の間の契約
。それは二つのものを保持する:一つは
Registration(必要時に runtime にアクセスするため)、一つは📎 tokio/src/runtime/io/registration.rs:46-54:
handle: scheduler::Handle(fd の状態スロット)。タスクが📎tokio/src/runtime/io/registration.rs:46-54を呼び出すとき、shared: Arc<ScheduledIo>はArcを
からRegistrationを取り出して起こす。SendメモリレイアウトとフィールドSync 📎 tokio/src/runtime/io/registration.rs:57-58は二つのフィールドのみscheduler::Handle:runtime ハンドル、コメントSend/Syncは「TODO: this can probably be moved into ScheduledIo」と述べており、著者がこのフィールド位置を最適化できると考えていることを示す。Rc:共有状態、Registrationが driver とタスクの両方がアクセスできることを保証。📎 tokio/src/runtime/io/registration.rs:28-33〔設計推論とアーキテクチャトレードオフ〕注意Registrationは手動で
Step-by-Step:poll_read_readyと
を実装している。なぜ unsafe impl が必要か?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_SHUTTING_DOWN_ERROR。
。runtime がシャットダウン中なら:coop.made_progress() 📎 tokio/src/runtime/io/registration.rs:169を返す
poll_io第五步poll_ready、予算消費をマークし、レディイベントを返す。📎 tokio/src/runtime/io/registration.rs:173-192:
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)),
}
}の上にリトライループを追加したコピーここにpoll_readyreadiness はヒントであり保証ではないread()という核心思想が表れている:WouldBlockは読み取り可能と言うが、実際にclear_readiness(ev) 📎 tokio/src/runtime/io/registration.rs:187すると
を返す可能性がある(例えば別のスレッドが先にデータを読み取った)。この場合try_ioレディビットをクリアし、ループして再び待機する必要がある。クリアしないと、タスクは「読み取り可能と思い込む → read 失敗 → また読み取り可能と思う」というビジーループに陥る。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がを返したらレディビットをクリアする。これはtry_readWaker を登録しない
async_io 📎 tokio/src/runtime/io/registration.rs:225-245、readiness(interest).awaitのような「試しにやってみてダメなら離れる」シナリオに適している。f(),WouldBlockは非同期版:coop::poll_proceed 📎 tokio/src/runtime/io/registration.rs:233は Waker を登録して待機し、その後WouldBlockを実行する際にレディビットをクリアしてループする。ループ内で
も呼び出していることに注意。大量のDropリトライで予算を使い果たすのを防ぐため。
Registration::drop 📎 tokio/src/runtime/io/registration.rs:253-262本番の落とし穴:self.shared.clear_wakers()内の Waker クリーンアップ📎 tokio/src/runtime/io/registration.rs:253-262はScheduledIoを呼び出す。コメントWakerが理由を説明している:Arc<driver::Inner>に格納されたdriver::InnerがScheduledIoを保持し、RegistrationがさらにWakerを保持して循環参照が形成される。Waker のクリーンアップは循環を断ち切る手段。ただしコメントはこれが「imperfect solution」であることも認めている——もし
に格納されたら、循環は依然として存在する。これは tokio-rs/tokio#3481 で議論された問題。clear_wakers〔設計推論とアーキテクチャトレードオフ〕ScheduledIo本番環境での挙動:大量の接続が drop されたが runtime が終了していない場合、メモリは即座に回収されず、次の
または runtime shutdown まで回収されない。長接続サービスでは通常問題にならないが、短接続の高頻度な作成/破棄が発生するシナリオではTcpStream::readの回収タイミングに注意が必要。Wakerから
への起床の完全なチェーン
直感モデルTcpStreamここで3層を繋げる。ユーザーが.read().awaitでAsyncRead::poll_read → PollEvented::poll_read → Registration::poll_read_ioを呼び出すと、実際に実行されるのはWaker。データが届いていない場合、ScheduledIoがScheduledIoに格納される。epoll が読み取り可能を報告すると、driver がWakerからpoll_readinessを取り出して起床し、タスクが再スケジュールされ、再度 poll する際にReady,read()がレディビットが立っているのを検出し、直接
を返して成功。
Step-by-Step:1回の完全な読み取り待機。TcpStream::new 📎 tokio/src/net/tcp/stream.rs:166-169フェーズ1:関心の登録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())が3つのことを行う:ScheduledIoを1つ割り当て、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。
をカウントフェーズ2:レディを待機TcpStream::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。タスクが pollWaker。この時点で未レディなら、ScheduledIoをPending。
の読み取りスロットに格納し、を返すturnフェーズ3:イベント到着poll.poll()。driver の📎 tokio/src/runtime/io/driver.rs:198がio.set_readiness(Tick::Set, |curr| curr | ready)からイベントio.wake(ready) 📎 tokio/src/runtime/io/driver.rs:228-229。wakeを取得し、走査時に各 fd イベントに対してWakerとwake()。
を実行。Waker::wake()内部で対応する方向のpoll_readinessを取り出し、Ready,read()を呼び出す
sequenceDiagram
participant Task as "任务 (worker 线程)"
participant Reg as "Registration"
participant SIO as "ScheduledIo"
participant Drv as "Driver (I/O 线程)"
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 再次 poll 该任务
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() 成功返回数据"がタスクを worker のローカルキューに再エンキューする(前章で説明済み)。worker が再びそのタスクを poll し、assume_readyがレディビットが立っているのを検出し、
TcpStream::new_accepted 📎 tokio/src/net/tcp/stream.rs:174-181を返して成功。acceptコピー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は注目に値する最適化。WouldBlockが返す socket は自然に書き込み可能で、通常はすでにピアの最初のバイト群を保持している。driver の最初のイベントを待つと、高負荷下ではこのイベントがすべての確立済み接続のイベントの後ろに並び、遅延を引き起こす可能性がある。そこでWouldBlock,poll_ioは直接を呼び出す。コメントはこう述べている:「A wrong guess costs one
, which clears the readiness again.」——推測を間違えてもコストは1回の
楽観的推測 + 迅速な誤り訂正Driverの設計。Driver設計上の考察:なぜ I/O ドライバとスケジューラが分離されているかblock_on〔設計推論とアーキテクチャトレードオフ〕Handleソースコード構造から見ると、
1. と worker スレッドは分離している::Handleは runtime の特定の専用位置(通常はmio::Registryスレッドまたは専用 I/O スレッド)に置かれ、worker スレッドは
2. のみを保持する。この分離にはいくつかの利点がある:登録のロックフリー化epoll_waitが
3. のクローンを保持し、どの worker も driver スレッドに戻ることなく並行して新しい fd を登録できる。イベント待機の集中化ScheduledIo:1つのスレッドだけがWaker::wake(),wake()でブロックし、複数スレッドが同時に同じ epoll fd を poll する thundering herd 問題を回避。
起床パスの短縮ScheduledIo:driver がイベントを受信したら直接set_readinessを操作し、poll_readinessを呼び出す
内部でタスクを worker キューにプッシュし、スレッド間メッセージパッシングが不要。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。
とshutdown 📎 tokio/src/runtime/io/driver.rs:174-182登録されたすべてを走査して呼び出すio.shutdown()、is_shutdownをセットし、すべての待機者を起床させる。このフラグをチェックしないと、タスクは runtime がすでにスケジューリングを停止した後に socket を読み取ろうとし、未定義動作やハングを引き起こす可能性がある。本番環境でRUNTIME_SHUTTING_DOWN_ERRORを見かけた場合、通常は runtime drop 後もタスクが実行中であることを意味する——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 の3つの重要なトレードオフ
トレードオフ1:Tokenインデックスではなくポインタを使用。EXPOSE_IO.from_exposed_addr(token.0) 📎 tokio/src/runtime/io/driver.rs:220をmio::Tokenのアドレスとして直接扱う。これにより*const ScheduledIoのマッピングテーブルの維持を避け、検索は O(1) かつロックフリーとなる。代償は安全性が厳密なライフサイクル管理に依存することである:ポインタは登録解除され、driver が poll しなくなった後にのみ解放できるToken → ScheduledIo 的映射表,查找是 O(1) 且无锁。代价是安全性依赖严格的生命周期管理:指针必须在注销且 driver 不再 poll 之后才能释放 📎 tokio/src/runtime/io/driver.rs:222-225。
トレードオフ2:読み書き二重 Waker スロット。Registrationドキュメント📎 tokio/src/runtime/io/registration.rs:24-26には「A registration instance represents two separate readiness streams」とあり、読みと書きがそれぞれ独立したWakerスロットを持つ。これにより同じ socket の読みタスクと書きタスクが互いに干渉せずに登録できる。しかしpoll_read_readyのコメント📎 tokio/src/net/tcp/stream.rs:549-552は次のように警告している:複数回poll_read_ready/poll_read/poll_peekを呼び出しても最後のWakerのみが保持される——読み方向にはスロットが1つしかない。
トレードオフ3:events_busyの独立バッファ。テスト📎 tokio/src/runtime/io/driver.rs:364-386がこの動作を検証している:Driver::new(16, Some(2))busy 容量2の driver を作成し、5つの読み取り可能な source を登録した後、非ブロッキング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リトライループで偽陽性を処理し、Wakerそれぞれ同期および非同期シナリオにサービスを提供する。
状態層
は fd の状態スロットであり、読み書き就緒ビットと二重poll_ioスロットを格納し、イベントとタスク間の唯一の橋渡しである。WouldBlock本章の考察とセルフチェックself.clear_readiness(ev)Q1: もし
内の:poll_io分岐の📎 tokio/src/runtime/io/registration.rs:173-192を削除した場合、どのようなシナリオでタスクのビジーループ(busy-loop)が発生するか?その理由は?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()には古い「読み取り可能」ビットが残ったままで、WouldBlockは即座にPendingを返し(就緒ビットが空でないため)、その後
が再びRegistrationを実行し、socket に実際にデータがなければ再び📎 tokio/src/runtime/io/registration.rs:28-33を返し、ループが続く。就緒ビットが決してクリアされないため、このループは決してtry_readに入らず、タスクは CPU を占有してポーリングし続ける。poll_readトリガーシナリオ:複数のタスクが同じ socket の読み方向を共有する場合(read()ドキュメントWouldBlockには最大2タスクとあるが、読み方向にはスロットが1つしかない)、または
Q2: add_sourceとregistry.registerを混用する場合。より一般的には:epoll が読み取り可能を報告した後、別のスレッドが先にデータを読み取ってしまい、現在のタスクの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をRegistrationSetに割り当て、次に
をカーネルに登録する📎 tokio/src/runtime/io/driver.rs:296-297。登録が失敗した場合、scheduled_io from the registrations set if registering the source with the OS fails. Otherwise it will leak the scheduled_ioはすでに割り当てられているが関連付けられた fd がない。削除しないと、永遠に
removeに残り続ける。📎 tokio/src/runtime/io/driver.rs:300-303コメントScheduledIoは明確に述べている:「we should remove theRegistrationSet.」——これはメモリリークである。RegistrationSet呼び出しTokenは unsafe ブロックで囲まれている。なぜならallocateは
Q3: deregister_sourceの一部であり、削除操作は他の参照がないことを保証する必要があるためである。リークの結果:unpark()が増え続け、registrations.deregister空間が浪費され、最終的に
の失敗やメモリ枯渇を引き起こす可能性がある。接続の作成/破棄が頻繁なシナリオ(短命接続サーバなど)で、登録失敗率が高い場合(例えば fd 枯渇)、リークはリソース枯渇を加速させる。:deregister_source 📎 tokio/src/runtime/io/driver.rs:315-334において、registry.deregister(source)が📎 tokio/src/runtime/io/driver.rs:322で true を返したときのみregistrations.deregisterを呼び出すのはなぜか?無条件に呼び出すとどのような問題があるか?📎 tokio/src/runtime/io/driver.rs:315-334参考解析unpark() 📎 tokio/src/runtime/io/driver.rs:328。
registrations.deregisterのロジックは:まずScheduledIoをカーネルから登録解除しpoll、次にunpark内部状態をクリーンアップしmio::Waker、true を返せばTOKEN_WAKEUPを呼び出す📎 tokio/src/runtime/io/driver.rs:280-283true を返すことはこれが最後の参照であることを意味し、pollが実際に削除される。このとき driver は
でこの fd のイベントを待ってブロックしている可能性があるが、fd はすでに登録解除されており、カーネルはもはやイベントを生成しない。unparkはScheduledIoを通じて epoll に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 がテストで時間を制御可能にする方法を目にするだろう。
第 6 章:時間駆動:時間輪、Sleep、タイムアウトがどのようにウェイクアップされるか
前章では TcpStream::read の完全なチェーンを追跡し、ScheduledIo が epoll の fd レディイベントをどのように Waker ウェイクアップに変換するかを見た。しかし非同期ランタイムはもう一つの「レディ」を処理する必要がある:sleep(100ms) の Future は、100ms 後に必ずウェイクアップされなければならない。この種のイベントはカーネル fd からではなく、「時間そのもの」から来る。Tokio の設計選択は、時間も一種の I/O イベントとして扱うことである:Driver 構造体には park: IoStack という一つのフィールドしかなく、I/O driver の park/unpark メカニズムを再利用している。時間輪が「次の満了時刻」を算出すると、driver は park_timeout を呼び出してスレッドをその時刻まで眠らせる。ウェイクアップされた後、時間輪から満了エントリを取り出し、それらの Waker をトリガーする。こうして、スケジューラは一つの統一された park エントリポイントだけで、「fd レディ」と「タイマー満了」の二種類のイベントを同時に待機できる。本章では三つの問いに答える:タイマーはどのように時間輪に挿入されるか?時間輪はどのように満了時間で階層化されるか?driver はどのように次の park のタイムアウトを計算し満了タスクをトリガーするか?
一、時間輪:六層 64 スロットのハッシュ階層構造
直感モデル
機械式時計を想像してほしい:秒針が一回転すると分針を動かし、分針が一回転すると時針を動かす。秒針が一本しかなければ、「12 日後」を表すには 100 万目盛りを数えなければならない。しかし階層化すれば、秒針は 64 秒以内の精度だけを担当し、分針は 64 分を、時針は 64 時間を担当する——各層は 64 スロットだけで、2 年以上先までカバーできる。
もし階層化がなければ、遠い将来のタイマーを挿入するには O(N) の走査か、巨大な配列が必要になる。時間輪は「満了時間による階層化」で、挿入とトリガーの両方をほぼ O(1) に抑える。
メモリレイアウトとフィールド
Wheelの核心フィールドは三つだけである📎 tokio/src/runtime/time/wheel/mod.rs:22-40:
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:
| 層 | スロット粒度 | カバー範囲 |
|---|---|---|
| 0 | 1 ms | 64 ms |
| 1 | 64 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に埋め込まれており、挿入/削除にアロケーションは不要。
シナリオ駆動:100ms の sleep を挿入する
が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:
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:
let level = self.level_for(when);
unsafe { self.levels[level].add_entry(item); }level_forは階層化アルゴリズムの核心である📎 tokio/src/runtime/time/wheel/mod.rs:276-289:
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」である。
100ms の sleep について、elapsedが 0 に近いと仮定すると、when ≈ 100,elapsed ^ when ≈ 100,ilog2(100) = 6,6 / 6 = 1したがって第1層(64ms粒度)に落ちます。これは、第1層のいずれかのスロットで待機し、時間がそのスロットの境界まで進むと第0層に降下されることを意味します。
段階的降下:process_expiration
ときにpoll(now)時間を進めると、Wheel::pollは繰り返し呼び出しますnext_expirationとprocess_expiration 📎 tokio/src/runtime/time/wheel/mod.rs:142-166:
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:
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:
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は0タイムアウトでparkし、すぐに戻って処理します)。そうでなければ層ごとにスキャンし、最初に内容のあるスロットのdeadlineを返します。debug_assertは不変条件を検証しています:より高層が現在の層より早い期限ポイントを持つことはあり得ません。
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() 返回"]---
二、Driverのparkループ:タイムホイールをI/Oスタックに接続する
直感的モデル
タイムホイール自体は「自分で動く」ことはありません。外部ループが繰り返し問いかける必要があります:「次の期限はいつですか?」そしてその時刻まで眠り、目覚めてから時間を進めます。このループがDriver::park_internalです。これは「タイムホイールの次の期限」をpark_timeoutの期間に変換し、基盤のI/Oスタックに渡して眠らせます。
このループがなければ、タイマーは永遠にトリガーされません——タイムホイールは静的なデータ構造にすぎず、誰かが「動かす」必要があります。
データ構造:DriverとInnerState
Driverにはフィールドが1つだけありますpark: IoStack 📎 tokio/src/runtime/time/mod.rs:90-93。実際の状態はHandleにあり、Inner列挙型を通じて従来実装と実験的実装を区別します📎 tokio/src/runtime/time/mod.rs:95-127。従来実装のInnerStateには2つのフィールドがあります📎 tokio/src/runtime/time/mod.rs:130-136:
struct InnerState {
next_wake: Option<NonZeroU64>, // 承诺的最早唤醒时刻
wheel: wheel::Wheel,
}next_wakeでNonZeroU64ではなくOption<u64>のネストを使うのは、niche最適化を利用するためです——Option<NonZeroU64>とu64は同じサイズです。これは「driverがどのtickまでに目覚めることを約束したか」を記録し、reregister時にunpark。
is_shutdownが必要かどうかを判断するために使われます。AtomicBoolは独立した📎 tokio/src/runtime/time/mod.rs:90-93:Handleで、コメントでMutexから分離した理由を説明していますis_shutdownはmutexをロックせずに
をチェックする必要があります。これは典型的な「読み多書き少」最適化です——shutdownは一度しか発生しませんが、チェックは頻繁に行われる可能性があります。
park_internalシナリオ駆動:1回のparkの完全なフロー📎 tokio/src/runtime/time/mod.rs:213-256:
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>はlock.next_wakeを返します。つまり次の期限tickです。同時にそれをreregisterに書き込み、
2. がunparkの必要があるか判断するために使います。:drop(lock)ロック解放
3. はparkの前に行う必要があります。そうでなければpark中に他のスレッドがタイマーを挿入できません。:when.saturating_sub(now)park期間の計算tick_to_durationで残りtick数を得て、Durationで📎 tokio/src/runtime/time/mod.rs:228-230に変換します。コメントによると、ここでは実際には1msに切り上げられ
4. 、マイクロ秒レベルのsleepがOSにゼロ長として扱われるのを防ぎます。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)覚醒後の処理
はタイムホイールを進め、期限切れエントリをトリガーします。
processprocess_at_time:期限切れエントリのトリガーprocess_at_time 📎 tokio/src/runtime/time/mod.rs:296-337:
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()時間逆流保護Instant:もしnowなら、時計が逆行したことを示します。コメントによると、これは通常発生すべきではありません(Rustはelapsed。 - の単調性を保証します)が、Windowsホスト上のLinux VMでは発生します。stdがハードウェアクロックの単調性を誤って信頼しているためです。保護方法は:
WakeListを!can_push()にクランプすることです📎tokio/src/runtime/time/mod.rs:319バッチ覚醒 - はWakerを収集し、いっぱいになると()一時的にロックを解放し、一批を覚醒させ、再びロックを取得します。コメントはこれがデッドロック
poll_at()を避けるためだと強調しています。ロック保持中にWakerを呼び出し、Wakerがタイムホイールを操作しようとすると(例えばタイマーの再登録)、デッドロックになります。next_wake。
next_wakeの更新
:処理後にSleep::resetを再計算し、reregisterを更新します📎 tokio/src/runtime/time/mod.rs:398-450:
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()はこのシナリオを処理します
コピーunpark重要なロジック:挿入成功後、新しい期限時刻がより早ければ、を呼び出してdriverを覚醒させます。これはdriverがより遅い時刻で眠っている可能性があり、park期間を再計算するために早めに起こす必要があるためです。waker.wake()注意:はロック保持時📎 tokio/src/runtime/time/mod.rs:441に呼び出され、unparkは
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()---
に呼び出されます。コメントは
を説明しています:デッドロックを避けるため、Wakerを呼び出す前にロックを解放する必要があります。しかし
Sleepは異なります——それはepollにイベントを入れるだけで、ユーザーコードをコールバックしないため、ロック保持中の呼び出しは安全です。.awaitコピーTimeoutは別の Future をラップするアダプタです。それ自体は時間ホイールを管理せず、「deadline」を tick に変換して委譲するだけです。TimerとHandle。
Sleep のメモリレイアウト
Sleepでpin_project!マクロを使用して📎 tokio/src/time/sleep.rs:221-227:
pub struct Sleep {
deadline: Instant,
driver: scheduler::Handle,
inner: Inner,
#[pin]
timer: Option<Timer>,
}timerはOption<Timer>であり、#[pin]を伴います:初回 poll 前はNoneで、初回 poll 時に初めてTimerを作成し登録します。この「遅延初期化」により、sleep()呼び出し時にランタイムへアクセスすることを避けられます——sleep()はランタイム外で呼び出せますが、.await時に実際に登録されるだけです。
PinnedDrop実装は drop 時にタイマーをキャンセルすることを保証します📎 tokio/src/time/sleep.rs:230-235:
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:
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)は協調予算を1回消費します。予算が尽きるとPendingを返して実行権を譲ります。これは Tokio が単一タスクによる他のタスクの餓死を防ぐ仕組みです。
2. 遅延 Timer 作成:もしtimerがNoneなら、deadlineを tick に変換し、Timerを作成してinitを呼び出し時間ホイールに登録します。
3. Timer::poll_elapsed への委譲:実際の期限チェックはTimerが行います。
4. 成功後に進捗をマーク:coop.made_progress()は今回の poll に実際の進捗があったことを示します。
Timeout の poll:先に値を poll し、次に遅延を poll
Timeoutの poll 順序は重要です📎 tokio/src/time/timeout.rs:210-224:
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 が先に poll され、その後でタイムアウトがチェックされます。したがって future が yield せずに完了すると、timeout を超えてもOkを返す可能性があります。これは設計上の選択であり、バグではありません。
poll_delayは微妙なシナリオを処理します📎 tokio/src/time/timeout.rs:229-251:
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に入る時にまだ予算があるが、value を poll した後に予算が尽きた場合、value が予算を消費したことを示します。この時、制限された予算で delay を poll すると、delay が即座にPendingを返す可能性があり、タイムアウトが到達したかどうかを永遠に判断できなくなります。そのためwith_unconstrainedで一時的に予算制限を解除します。コメントではこれを「pathological cases」と呼んでいます。📎 tokio/src/time/timeout.rs:243-246。
timeout の deadline オーバーフロー処理
timeout関数はchecked_addでオーバーフローを処理します📎 tokio/src/time/timeout.rs:86-99:
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を返します。これは「決してタイムアウトしない」ことに相当し、合理的なフォールバック動作です。
---
設計上の考察と本番での落とし穴
なぜ階層計算に減算ではなく XOR を使うのか? elapsed ^ whenの最上位ビットは「2つのタイムスタンプがどのビットから異なるか」を直接反映し、これは「どれだけ粗い粒度が必要か」の尺度です。減算when - elapsedはelapsedがwhenに近い時、上位ビットが全て 0 になり、ilog2は過小な階層を算出します。XOR はラップアラウンドのシナリオを自然に処理します。
時間逆行保護の必要性 📎 tokio/src/runtime/time/mod.rs:301-309:Rust はInstantの単調性を保証しますが、基盤 OS は保証しないかもしれません。Windows ホスト上の Linux VM では、std がハードウェアクロックを信頼することでInstantが逆行します。Tokio はnow = lock.wheel.elapsed()でクランプし、set_elapsedの assert 失敗を回避します。
バッチ起床とデッドロック 📎 tokio/src/runtime/time/mod.rs:319:時間ホイールロックを保持したまま Waker を呼ぶのは危険です——Waker がタスクの再 poll をトリガーし、さらにSleep::resetを呼び出して再び時間ホイールロックを取得しようとし、デッドロックを引き起こす可能性があります。WakeListのバッチ機構はロックが満杯の時に一時的にロックを解放します。これは標準的な「ロック外コールバック」パターンです。
next_wakeの niche 最適化 📎 tokio/src/runtime/time/mod.rs:130-136:Option<NonZeroU64>はu64と同じサイズです。0 がNoneの niche として使われるためです。しかし tick 0 は有効な値なので、コードはNonZeroU64::new(t).unwrap_or_else(|| NonZeroU64::new(1).unwrap())で 0 を 1 にマッピングします📎 tokio/src/runtime/time/mod.rs:221。これは微妙な境界処理です:tick 0 は tick 1 として扱われ、最大で 1ms の余分な起床を引き起こします。
process_expirationの「先に取得してから処理」 📎 tokio/src/runtime/time/wheel/mod.rs:219-228:まずスロット全体のエントリを取り出してから処理する必要があります。なぜならMAX_DURATIONを超えるエントリはラップアラウンドして同じスロットに再挿入されるためです。取得しながら挿入すると無限ループになります。
Timeoutの poll 順序の罠 📎 tokio/src/time/timeout.rs:24-26:future が先に poll され、タイムアウト後にチェックされます。future が CPU 集約的で yield しない場合、timeout を超えてもOkを返す可能性があります。本番環境ではtimeoutに非協力的な future の強制中断を依存しないでください。
---
本章のまとめ
本章では Tokio 時間ドライバの3層構造を分解しました:
1. 時間ホイール(Wheel):6層64スロットのハッシュ階層構造で、elapsed ^ whenのビット幅でエントリの階層を決定し、挿入と発火はほぼ O(1) です。pending連結リストは期限切れエントリを格納し、process_expirationが層ごとの降下を担当します。
2. Driver(Driver::park_internal):時間ホイールのnext_expiration_timeをpark_timeout期間に変換し、I/O スタックの park/unpark を再利用します。process_at_timeは起床後に時間ホイールを進め、Waker をバッチ発火し、時間逆行とデッドロック保護を処理します。
3. ユーザー API(Sleep / Timeout):Sleepは遅延作成Timerして登録し、Timeoutは先に value を poll してから delay を poll し、with_unconstrainedで予算枯渇シナリオを処理します。
核心設計は「時間もまた I/O イベントである」:driver は park エントリを1つだけ持ち、fd の準備完了とタイマー満了を同時に待ちます。next_wakeは約束された起床時刻を記録し、reregisterはより早いタイマーを挿入する際にunparkで driver を起床させ再計算します。
次章では同期プリミティブに入ります:Mutex、Semaphoreとチャネルがどのように非同期待機を実装するか。それらが本章の Waker 機構をどう再利用し、「許可カウント」と「待機キュー」がどう協調するかを見ていきます。
本章の考察とセルフチェック
Q1: もしWheel::insertの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層に落ちる。しかし第0層のnext_expirationは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はWakerを収集し、満杯になったらスペースを空けるために一批を起床させる必要がある📎 tokio/src/runtime/time/mod.rs:318-325。もし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の戻りを待っており、循環待ちが形成される。コメントには明確に「To avoid deadlock, we must do this with the lock temporarily dropped」とある📎 tokio/src/runtime/time/mod.rs:319。drop後に再lockすると、時間輪の状態は他のスレッドによって変更されている可能性がある(例えば新しいタイマーが挿入される)ため、while let Some(entry) = lock.wheel.poll(now)は新しい状態からエントリを取り出し続ける。これは安全である。
Q3: Timeout::pollにおいてhad_budget_beforeとhas_budget_nowの組み合わせ判定(true, false)はなぜ「進入時に予算があり、poll value後に予算がない」場合にのみ使われるのかwith_unconstrained?もし逆に(false, true)ならどうなるか?
参考解析:had_budget_beforeはpoll valueの前に📎 tokio/src/time/timeout.rs:208-208,has_budget_nowを記録し、poll valueの後に📎 tokio/src/time/timeout.rs:239。(true, false)を記録する。poll_proceedは予算がpoll value中に使い果たされたことを意味し、valueが「予算消費者」であることを示す。このとき制限付き予算でpoll delayすると、Pendingは即座にwith_unconstrainedを返し、delayは決して実際にチェックされず、タイムアウト判定が無効になる。したがって📎 tokio/src/time/timeout.rs:247。(false, true)で一時的に制限を解除するwith_unconstrainedは発生し得ない——予算は消費されるだけで、回復はできない(明示的な(false, false)がない限り。ここにはない)。Pendingは進入時にすでに予算がないことを意味し、このときpoll valueはすでにpoll_proceedを返している可能性があり((true, true)が失敗したため)、delayも制限付き予算でpollされ、両方pendingとなり、期待通りである。
は正常な場合で、予算は十分にあり、直接poll delayする。
第7章:同期プリミティブ:Mutex、Semaphore、チャネルがどのように非同期待機を実現するか
前章では時間がどのようにI/Oイベントとして抽象化され、タイマーとfdレディが同じpark/unpark待機入口を共有するかを明らかにした。しかし複数のタスクが同じロックを競合したり、チャネルを通じてメッセージを渡すとき、待機対象はもはやfdや時計ではなく、別のタスクの状態変化である。本章ではtokio::syncファミリーに入り、一度のlock().awaitやrecv().awaitがブロック時にWakerをどこに格納するのか、起床時にどのように再スケジュールされるのかを探る。
なぜ非同期Mutexはstdの実装を再利用できないのか
直感モデル:「席を占有する」から「席を譲る」へ
std::sync::Mutexのlock()はロックが占有されているとき現在のスレッドをブロックする——スレッドはOSによってサスペンドされ、ロックが解放されるまで待つ。これは非同期ランタイムでは致命的である:1つのworkerスレッドが同時に何百、何千ものタスクを駆動している可能性があり、もしロック待ちでブロックすると、それが担う他のすべてのタスクが停止する。非同期Mutexの核心的な要求は:ロック待ちのときスレッドを譲り、「私はこのロックを待っている」という事実をキューに登録し、そしてPendingを返し、実行者に他のタスクを実行させることである。
TokioのMutexは独自の待機キューを実装しておらず、代わりに完全にセマフォの上に構築されている。
データ構造とメモリレイアウト
Mutex<T>のフィールドは極めて簡潔:
📎 tokio/src/sync/mutex.rs:133-138
pub struct Mutex<T: ?Sized> {
#[cfg(all(tokio_unstable, feature = "tracing"))]
resource_span: tracing::Span,
s: semaphore::Semaphore,
c: UnsafeCell<T>,
}3つのフィールドがそれぞれ役割を担う:sは許可数が1のセマフォ,cはUnsafeCell<T>が包む保護されたデータ。ここでのsemaphoreはbatch_semaphoreの別名📎 tokio/src/sync/mutex.rs:3-3、つまり低レベル実装であり、sync::Semaphoreの公開ラッパーではないことに注意。
MutexGuard<'a, T>はMutexへの参照を1つだけ保持する:
📎 tokio/src/sync/mutex.rs:151-157
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
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は要求しない——これは合理的である。なぜなら相互排他的アクセスにより、同時に1つのスレッドだけがTに触れられることが保証されるため、スレッド間でTの所有権(Send)を転送すれば十分であり、T自体が共有可能(Sync)である必要はない。これこそがMutex<T>が非SyncのTをSyncに変えられる理由である。
Step-by-Step:1回の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
async fn acquire(&self) {
crate::trace::async_trace_leaf().await;
self.s.acquire(1).await.unwrap_or_else(|_| {
unreachable!()
});
}unwrap_or_else(|_| unreachable!())このコメントが設計制約を語っている:Mutexはセマフォを明示的にcloseせず、かつ排他的に保持するため、acquireは決してErrを返さない。これは「セマフォクローズ」というエラーパスを型レベルで排除している。
第三步、ロックが使用中の場合、s.acquire(1)はPendingを返し、現在のタスクのWakerがセマフォの待機キューに登録される。Wakerはどこに存在する?答えはbatch_semaphoreの待機キューの中にある(本章のソース資料ではこのファイルは展開されていないが、その役割は:各待機者が1つのWakerを保持し、FIFOでキューイングされる)。
第四步、ロックを保持するタスクBがロックを解放する時、MutexGuard::dropがs.release(1) 📎 tokio/src/sync/mutex.rs:965-975を呼び出し、セマフォが許可をキューの先頭の待機者に渡してそのWakerを起床させ、タスクAが再スケジュールされ、acquireがOkを返し、MutexGuard。
を構築する。全体の流れは以下のシーケンス図で描写できる:
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公平性とキャンセル安全性
ドキュメントはTokioのMutexがFIFO📎 tokio/src/sync/mutex.rs:20-22を保証すると明確に宣言している。この公平性は低レベルセマフォのキューイングセマンティクスに由来する。公平性の代償は:1回のlockがキャンセルされると(例えばselect!で敗北した場合)、あなたはキュー内の位置を失う 📎 tokio/src/sync/mutex.rs:415-419。これはバグではなく、FIFOキューの必然である——キャンセルはキューからの除去を意味し、再度lockするには再びキューに並ぶ必要がある。
もう一つの直感に反する設計はポイズニングしない(no poisoning)。std::sync::Mutexはロック保持スレッドがpanicするとpoisonedとマークされ、後続のlockはErrを返す。TokioのMutexはそうしない:ロック保持者がpanicするとロックは正常に解放される📎 tokio/src/sync/mutex.rs:122-125。ドキュメントは、panicがキャプチャされた場合、保護されたデータが不整合状態になる可能性があると警告している。これは非同期シナリオにおける実用的なトレードオフである——panicは非同期タスクでは通常タスク終了を意味し、ポイズニング機構はむしろ複雑さを増す。
MutexGuard::map系のメソッドは言及に値する。これはMutexGuard<T>全体を、あるサブフィールドのみを保護するMappedMutexGuard<U>に降格できる。実装上は、まずクロージャでサブフィールドポインタdataを計算し、次にskip_dropを通じて元のguardをDropをトリガーしないMutexGuardInnerに分解し、最後に新しいguard📎 tokio/src/sync/mutex.rs:869-883。skip_dropを構築する。ManuallyDrop + ptr::readでフィールド所有権を転送し、Dropが二度呼ばれるのを避ける📎 tokio/src/sync/mutex.rs:827-836。これはRustにおける「所有権を転送するがデストラクタをトリガーしない」古典的手法である。
Semaphore:許可カウントと待機キューがどのようにバックプレッシャーを実現するか
直感モデル:駐車場の駐車スペース
セマフォは駐車場のようなもの:acquireは車で入場し、空きがあれば入り、空きがなければ入口で並ぶ;releaseは車で退場し、1つ空きができたらキューの先頭の車に通知して入場させる。許可数は駐車スペースの総数、acquire_many(n)はn個のスペースを占める大型車。
データ構造とメモリレイアウト
公開されたSemaphoreは低レベルのbatch_semaphore::Semaphoreの薄いラッパーに過ぎない:
📎 tokio/src/sync/semaphore.rs:427-432
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
pub struct SemaphorePermit<'a> {
sem: &'a Semaphore,
permits: usize,
}permitsフィールドはforget/merge/splitを理解する鍵である。forgetはpermitsをゼロに設定し📎 tokio/src/sync/semaphore.rs:1193-1195、これによりDrop時に0個の許可を返却する——「永久消費」と等価。splitは現在の許可からn個を切り出して新しいpermitに与える📎 tokio/src/sync/semaphore.rs:1260-1271。mergeは別のpermitのカウントをマージし、両者が同じセマフォ由来であることをアサートする📎 tokio/src/sync/semaphore.rs:1230-1240。
MAX_PERMITSはusize::MAX >> 3 📎 tokio/src/sync/semaphore.rs:476-479である。なぜ3ビット右シフトするのか? 低レベルのbatch_semaphoreは高位ビットに状態フラグ(クローズフラグなど)をエンコードする必要があるため、利用可能な許可数を低位に制限し、高位をフラグ用に残す。これは「カウント+状態」を単一のusizeに詰め込む一般的なテクニックである。
Step-by-Step:acquireとreleaseの許可フロー
シナリオ:セマフォ初期2許可、タスクAがacquire()、タスクBがacquire_many(2)。
acquire()はll_sem.acquire(1)に委譲し、成功後にSemaphorePermit { permits: 1 } 📎 tokio/src/sync/semaphore.rs:614-631。acquire_many(2)を構築する。📎 tokio/src/sync/semaphore.rs:661-679。
も類似だが、2を渡すll_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
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 起床の異なるトレードオフ
直感的モデル:4 種類のチャネル、4 種類の待機戦略
oneshotは「使い捨ての封筒」——手紙を 1 通しか送れず、送信側は待たない(sendは同期である)、受信側はawait手紙を待つ。mpscは「有限のコンベアベルト」——送信側はベルトが満杯のとき待ち、受信側は空のとき待ち、容量はセマフォで制御される。broadcastとwatchは「放送スピーカー」——1 つの送信側、複数の受信側だが、両者の「遅れ」の扱いはまったく異なる。
本節のソース資料はoneshotとmpsc::boundedに焦点を当てており、1 つずつ分解していく。
oneshot:状態ビットで符号化された極めて簡潔なハンドシェイク
oneshotのInner構造がその設計を理解する核心である:
📎 tokio/src/sync/oneshot.rs:386-409
struct Inner<T> {
state: AtomicUsize,
value: UnsafeCell<Option<T>>,
tx_task: Task,
rx_task: Task,
}stateはAtomicUsizeであり、ビットフラグでチャネル全体の状態を符号化している。4 つのフラグビットはファイル末尾で定義されている:
📎 tokio/src/sync/oneshot.rs:1488-1505
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は受信側からのみアクセス可能であり、セットされていなければ送信側からのみアクセス可能である。こうして 1 つのアトミックビットでロックフリーな所有権の移転を実現し、余分なロックを避けている。
sendの流れ:
📎 tokio/src/sync/oneshot.rs:622-646
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
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)
}なぜ単純なfetch_orではなく CAS を使うのか?コメントが明確に説明している📎 tokio/src/sync/oneshot.rs:1517-1529:もしチャネルがすでにCLOSEDなら、してはならないを再度セットVALUE_SENTしてはならない。なぜなら一度セットされると、受信側はUnsafeCellにアクセスできるとみなすが、そのとき送信側は値を取り戻そうとしており(consume_value)、両側が同時にアクセスするとデータ競合が発生するからである。したがって CAS ループはCLOSEDを検出すると早期に break し、セットしない。
complete()が戻った後、セットに成功しRX_TASK_SETがすでにセットされていれば、受信側を起床させる:
📎 tokio/src/sync/oneshot.rs:1300-1315
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()が真になったことに気づいたら、フラグビットを再び set し直さなければならない 📎 tokio/src/sync/oneshot.rs:1342-1344。そうしないと Waker が Drop 時にリークする(Drop はフラグビットに依存して Waker を drop すべきか判断するため)。
この「unset 後に再 set」パターンはpoll_closedにも現れており📎 tokio/src/sync/oneshot.rs:839-848、oneshot が並行起床を扱う標準的な手法である。
mpsc::bounded:セマフォ駆動のバックプレッシャ
mpsc の容量制御は完全にセマフォに委ねられている。channel関数は許可数がバッファに等しいセマフォを生成する:
📎 tokio/src/sync/mpsc/bounded.rs:159-171
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
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
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が通知の責務を引き継ぐためである。
Permitの Drop も同じことを行う:
📎 tokio/src/sync/mpsc/bounded.rs:1732-1745
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。実際の待機キュー論理はchan::Rxモジュールにある(本章では展開しない)が、推測できる:受信側の Waker はsendに格納され、送信側が
try_sendしたときに起床する。
📎 tokio/src/sync/mpsc/bounded.rs:924-934
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の 2 種類のエラーは正確にFullと
にマッピングされ、「チャネル閉鎖」と「バッファ満杯」の 2 種類の失敗を区別する。
設計上の考察:キャンセル安全性とメッセージ損失📎 tokio/src/sync/mpsc/bounded.rs:776-784:sendmpsc のドキュメントはキャンセル安全性を繰り返し強調しているselect!がで敗北したとき、メッセージは破棄されるreserve。損失を避けるにはPermitでsendを取得してからPermitしなければならない——なぜならsendはすでに容量を予約しており、
recvは同期で、中断されないからである。📎 tokio/src/sync/mpsc/bounded.rs:199-204はキャンセル安全なrecvである:select!がrecvで敗北しても、メッセージが消費されないことが保証される。これはpoll_recvのReady,Pendingが実際にメッセージを取得したときのみ
oneshotを返し、Receiver時にはキューを動かさないためである。📎 tokio/src/sync/oneshot.rs:246-251のoneshotは Future としてもキャンセル安全であるsend。ただし注意:Errの
は同期なので、「send がキャンセルされる」問題は存在しない——送信されるか、
落とし穴1:非同期 Mutex で純粋なデータを保護する。ドキュメントは明確に推奨している📎 tokio/src/sync/mutex.rs:26-36:保護対象が純粋なデータ(.awaitの要件なし)の場合、std::sync::Mutexまたはparking_lotの方が高速である。非同期 Mutex のオーバーヘッドは、セマフォのアトミック操作とタスクスケジューリングの可能性にある。ロック保持中に.awaitが必要な場合(例えばロックを保持してデータベース接続にアクセスする場合)にのみ、非同期 Mutex を使うべきである。
落とし穴2:ロックを跨いで.awaitするとデッドロックが発生する。これは非同期 Mutex の最も危険な罠である。タスク A がロックを取得した後に.awaitタスク B の完了を必要とするイベントを待ち、タスク B がそのロックを待っていると、デッドロックになる。std::sync::Mutexの guard はSendではないため(移動可能なタスクにおいて)、コンパイラは.awaitを跨いだ保持を防ぐ。しかし非同期 Mutex の guard はSend 📎 tokio/src/sync/mutex.rs:314-314であり、コンパイラは止めてくれない。循環待ちを形成しないことを自分で保証する必要がある。
落とし穴3:reserve後にsend。 Permitを忘れる。📎 tokio/src/sync/mpsc/bounded.rs:1732-1745の Drop は許可を返却する
ため、容量はリークしない。しかしチャネルが閉じられていてアイドル状態の場合、Drop は受信側を起こす——この起床は必要である。そうでなければ、受信側は閉鎖通知を永遠に待つ可能性がある。oneshot落とし穴4:pollのPending。は偽の📎 tokio/src/sync/oneshot.rs:236-242の可能性がある。pollドキュメントにはPendingと記載されている:メッセージが送信済みであっても、
がforget_permitsを返す可能性がある。これはバグではなく、並行競合下での正常な現象である——呼び出し側は起こされてリトライし、メッセージは失われず、ただ遅延するだけである。 forget_permits(n)落とし穴5:📎 tokio/src/sync/semaphore.rs:576-578のセマンティクス。
は n 個の許可を減らそうと試み、実際に減らした数を返す
。ブロックもせず、待機者も起こさない——単に許可を「飲み込む」だけである。動的にセマフォの容量を縮小するために使われる。tokio::sync本章のまとめ本章は。
Mutexの核心パターンを明らかにした:MutexGuardすべての非同期待機プリミティブは「待機者キュー + Waker 起床」の上に構築されており、キューの具体的な実装はシナリオによって異なるrelease(1)許可数 1 のセマフォを再利用し、Semaphoreは参照のみを保持し、Drop 時にSemaphorePermit、FIFO 公平だがポイズニングしない。permitsは許可カウント + 待機キューであり、forget/merge/split,MAX_PERMITSはoneshotカウントでAtomicUsizeをサポートし、右に 3 ビットシフトして状態フラグの場所を確保する。VALUE_SENTは単一のUnsafeCellのビットフラグで状態をエンコードし、CLOSEDビットが同時にmpsc::boundedのアクセス権の帰属を決定し、CAS ループがWakeReceiverOnDrop後のセットを防ぐ。
は許可数が buffer と等しいセマフォでバックプレッシャーを実現し、
ガードがキャンセル時の起床補償を処理する。set_complete本章の考察とセルフチェックfetch_or(VALUE_SENT)Q: もし
の CAS ループを単純な:set_completeに変更した場合、どのような並行シナリオでデータ競合が発生するか?fetch_or参考解析📎 tokio/src/sync/oneshot.rs:1517-1529が CAS ループではなく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を呼び値を取り戻すCLOSED。両側が同時にVALUE_SENTにアクセスし、データ競合が発生する。CAS ループは
Q: reserve_innerを発見した時に早期 break し、WakeReceiverOnDropをセットしないことで、「閉鎖後は送信側が独占アクセス権を持つ」という不変条件を保証する。mem::forgetのforgetガードは成功パスで
を使ってスキップするが、このを削除すると何が起こるか?📎 tokio/src/sync/mpsc/bounded.rs:1290-1298参考解析acquire(n):ガードの Drop ロジックは「セマフォが閉じられていてアイドル状態なら受信側を起こす」Okである。成功パスでは、PermitがPermitを返し、呼び出し側が許可を取得してreserve_innerを構築し、is_idleが後続の通知責務を負う。もしガードを削除しない場合、ガードは関数リターン時に Drop され、「閉じられていてアイドル」を余分にチェックする——しかしこの時点で許可は既にPermitの呼び出し側が保持しており、セマフォはアイドルではない(mem::forgetが偽)ため、実際には重複起床は発生しない。しかしより重要なのはセマンティクスの明確さである:成功パスの起床責務は完全にforgetが担うべきであり、ガードは「キャンセル/失敗」パスの補償のみを担当する。acquireは「このパスにはガードが不要」という意図を明確に表現している。もしOkを削除し、かつセマフォが「閉じられていてアイドル」の境界状態にある場合(例えばPermitが
を返したが許可がまだMutexGuardに引き継がれていない場合)、余分な起床が 1 回発生する可能性がある——エラーにはならないが、スケジューリングを 1 回無駄にする。SemaphorePermitQ: もし
をセマフォ許可オブジェクトを保持するように変更した場合(のように)、どのような問題が導入されるか?MutexGuard参考解析&Mutex:現在のself.lock.s.release(1) 📎 tokio/src/sync/mutex.rs:959-961はMutexGuard::mapのみを保持し、Drop 時にMappedMutexGuardを呼び出す。もし許可オブジェクトを保持するように変更した場合、いくつかの問題が導入される。第一に、📎 tokio/src/sync/mutex.rs:869-883系メソッドは guard をMappedMutexGuardに分解し、サブフィールド&Semaphoreのみを保護する必要がある。現在の設計では、📎 tokio/src/sync/mutex.rs:190-199はself.s.release(1) 📎 tokio/src/sync/mutex.rs:1252-1262とサブフィールドポインタMappedMutexGuardのみを保持し、Drop 時にpermits: usizeする。もし guard が許可オブジェクトを保持する場合、map 時に許可オブジェクトの所有権を移転する必要があり、MutexGuardのフィールドレイアウトはより複雑になる。第二に、許可オブジェクトは通常Send/Syncカウントを持ち、Mutex にとってこのカウントは常に 1 であり、冗長である。第三に、unsafe implの📎 tokio/src/sync/mutex.rs:260-263境界は既にmap。
で正確にtokio::syncを制御しており、許可オブジェクトを保持すると追加の trait 制約が導入される。現在の「参照のみ保持 + 手動 release」の設計はより軽量で、spawn_blockingのサポートも容易である。block_onここまでで、我々は
Waker の格納場所はプリミティブによって異なる:Mutex/Semaphore は下層のセマフォの待機キュー、oneshot は Inner の tx_task/rx_task フィールド、mpsc は chan モジュールの送受信キューに存在する。しかし起床メカニズムは統一されている:状態変更時に Waker を取り出して wake_by_ref を呼び、実行器がタスクを再スケジュールする。ここまでで、非同期プリミティブ内部の待機と起床は明確に見えてきた。しかし、すべてのコードが非同期化できるわけではない——次の章では、spawn_blocking でブロッキング操作を橋渡しする方法と、block_on が非同期コンテキスト外で Future を駆動する方法を探る。
第 8 章:ブロッキングと橋渡し:spawn_blocking スレッドプールと block_on の境界
前章で見たように、非同期 Mutex とチャネルが待機中にスレッドを占有しないのは、Waker を待機キューに格納し、条件が満たされた後に起床者がタスクを再スケジュールするからである。しかし、このすべての前提は、タスクが Pending 時に自発的にスレッドを譲ることができることにある。一度コードが std::fs::read、libsqlite3、または純粋な CPU 圧縮ループを呼び出すと、戻るまで worker スレッドを占有し、その間そのスレッド上の他のタスクはすべて餓死する。Tokio の解決策は、このような作業を独立したブロッキングスレッドプールに外注し、block_on で非同期コンテキスト内の Future を駆動することである。本章ではこの二つの境界を分解する。
8.1 ブロッキングスレッドプールのメモリレイアウト:Inner と二重実装キュー
直感的モデル:spawn_blockingスレッドプールはレストランの「外注ヘルパープール」のようなものだ。フロントのウェイター(worker スレッド)は注文と配膳だけを担当し、じっくり煮込む必要のある料理に遭遇すると、伝票を書いて厨房の受け渡し窓(キュー)に投げ込み、ヘルパー(ブロッキングスレッド)が窓から伝票を取る。このプールがなければ、ウェイターが自分で料理することになり、レストラン全体が停止する。
核心構造。プール全体は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:スケジューラ worker スレッド数、メトリクスで差し引くために使用され、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>:各 worker スレッドがクローンを一つ保持し、すべて drop されるとshutdown_rxが通知を受け取る。last_exiting_thread: Option<JoinHandle<()>>:前回タイムアウト終了したスレッドハンドル。worker_threads: HashMap<usize, JoinHandle<()>>:すべての生存 worker のハンドル。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 自動ボクシング」戦略である:クロージャが大きすぎる場合にボクシングし、タスク構造体の膨張を避ける。
に入り、まずタスク ID を割り当て、次にspawn_blocking_innerでクロージャを Future に包み、最後にblocking_taskで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):パニックしない、それでもハンドルを返す。コメントにはこれは互換性のための考慮であると説明されている——ハンドルは決して resolve されないが、呼び出し側はランタイムがシャットダウン中だからといってクラッシュすることはない。Err(NoThreads(e)):OS がスレッドを作成できず、プール内に引き受ける者もいない場合、直接パニックする。
第三步:エンキューと起床の決定。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:
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をチェックし、上限に達したら直接 return するOk(())——タスクはキューに残り、既存のスレッドが処理するのを待つ。これがバックプレッシャーである。そうでなければshutdown_txをクローンし、spawn_threadを呼び出してスレッドを作成し、成功したらnum_threadsをインクリメント、worker_thread_indexをインクリメント、ハンドルをworker_threads。
spawn_threadに挿入するthread::Builderでスレッド名とスタックサイズを設定し、その後クロージャを spawn する:ランタイムコンテキストに入りrt.enter()、inner.run(id)を呼び出し、最後に drop するshutdown_tx 📎 tokio/src/runtime/blocking/pool.rs:508-528。
OS スレッド作成失敗時のフォールトトレランス。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を返し、最終的にパニックを引き起こす。
制御フロー図で配信パスの決定分岐をまとめる:
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 can't spawn worker thread"]8.3 worker メインループ:BUSY/IDLE 状態機械とタイムアウト回収
直感的モデル:各ブロッキングスレッドは「待機中の助っ人」である。注文があれば連続して働き(BUSY)、注文がなければうたた寝し(IDLE)、うたた寝がkeep_aliveを超えると退勤する(タイムアウト終了)。タイムアウト回収がなければ、プールはピーク時に作成されたすべてのスレッドを永久に保持し、メモリとカーネルスケジューリングのオーバーヘッドを浪費する。
メインループの構造。LockedImpl::run_workerは'mainループであり、内部的に BUSY と IDLE の二つのフェーズを交互に取る📎 tokio/src/runtime/blocking/pool.rs:642-735。注意:ここでの BUSY/IDLE はループ内のフェーズであり、明示的な列挙状態ではないので、以下では状態図ではなくフローチャートで説明する。
BUSY フェーズ:内側のwhile let Some(task) = locked.queue.pop_front()が絶えずタスクを取り出す📎 tokio/src/runtime/blocking/pool.rs:655-661。取得後queue_depth,をデクリメントし、ロックを drop し、task.run()を実行し、再びロックを取得する。ロックを drop するこのステップは極めて重要である——ブロッキングタスクは長時間実行される可能性があり、絶対にロックを保持したまま実行してはならない。
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を設定する(配信側が既にnum_idle_threadsをデクリメントしているため)、break して BUSY に戻る📎 tokio/src/runtime/blocking/pool.rs:674-684。
2. 未シャットダウンかつタイムアウト:worker_timed_outを呼び出して前回終了したスレッドのハンドルを取得し、break 'mainループを終了する📎 tokio/src/runtime/blocking/pool.rs:689-693。
3. それ以外は偽の起床であり、待機を続ける。
シャットダウン時のキュードレイン。もしthread_mgmt_state.shutdownが真なら、ドレインロジックに入る📎 tokio/src/runtime/blocking/pool.rs:698-710:タスクを一つずつポップし、ロックを drop し、task.shutdown_or_run_if_mandatory()を呼び出す——非強制タスクは破棄され、強制タスクは通常通り実行される。その後 break してメインループを終了する。
終了時のクリーンアップ。スレッド終了前にnum_threads 📎 tokio/src/runtime/blocking/pool.rs:714をデクリメントする。もしis_counted_idleが真なら、さらにnum_idle_threadsをデクリメントし、assert_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を返し、Inner::runが終了前に join する📎 tokio/src/runtime/blocking/pool.rs:755-771。
シャットダウンハンドシェイク。BlockingPool::shutdownはまずbegin_shutdownを呼び出してすべての worker ハンドルを取得し📎 tokio/src/runtime/blocking/pool.rs:310-312。LockedImpl::begin_shutdownシャットダウンフラグを設定し、shutdown_tx、notify_allを drop してすべての待機スレッドを起床する📎 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 を返し、そうでなければパニックし「非同期コンテキストで runtime を drop できません」というヒントを出す📎 tokio/src/runtime/blocking/shutdown.rs:44-57。最後に timeout に応じてblock_on_timeoutまたはblock_onを呼び出してその oneshot を駆動する。
shutdown_txのメカニズムは:各 worker スレッドがArc<oneshot::Sender<()>>のクローンを一つ保持する📎 tokio/src/runtime/blocking/shutdown.rs:12-14。すべてのスレッドが終了すると、すべてのクローンが drop され、Arcカウントがゼロになり、oneshot::Senderが drop され、Receiverが通知を受け取る。これが「すべての Sender が drop された後に Receiver が起床する」という古典的なパターンである。
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 drop, oneshot 完成
Rx-->>Pool: 返回 true
Pool->>Worker: join 所有 worker 句柄8.4 block_on:非同期コンテキストで Future を駆動する
直感的モデル:block_onはランタイムの「正門」である。現在のスレッドを一時的なエグゼキュータに変え、渡された Future を完了するまで繰り返し poll する。これがなければ、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()ランタイムコンテキストに入り、最後にスケジューラタイプに応じてディスパッチする📎 tokio/src/runtime/runtime.rs:353-383:
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戻った後、spawn 済みのタスクは実行を継続する。 - 現在のスレッドスケジューラ:
block_onは複数のスレッドから同時に呼び出すことができ、最初の呼び出し元が I/O とタイマードライバの所有権を取得し、他のスレッドはそれに「フック」する。最初のblock_onが完了すると、他のスレッドはドライバを「盗む」ことができる。block_onが戻った後、spawn 済みのタスクは中断され、再度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 されることを保証する);マルチスレッドスケジューラは直接 shutdown する(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。
と等価である
設計上の考察、エラー回復、本番環境での落とし穴spawn_blockingなぜShuttingDownの 📎 tokio/src/runtime/blocking/pool.rs:383-384は panic しないのか?spawn_blockingコメントには互換性の考慮と書かれている。JoinHandleはResultではなくawaitを返す。シャットダウン時に panic すると、「ランタイムがシャットダウン中」という予測可能な状態がクラッシュになってしまう。決して resolve しないハンドルを返すと、呼び出し元はblock_on時に永遠にハングする——しかしこの時点でランタイムは既に閉じているので、
max_blocking_threads全体も終了し、実際には永久にリークすることはない。のバックプレッシャセマンティクスspawn_blocking。デフォルト値は非常に大きい(512)。なぜなら📎 tokio/src/task/blocking.rs:94-100はファイル I/O によく使われるからである。しかしドキュメントは警告している:CPU 密集型タスクを実行する場合はセマフォで並行度を制限しなければならない。そうしないと大量のスレッドが作成される
spawn_blocking。上限に達するとタスクはキューで待機し、バックプレッシャが形成される——ただしこのバックプレッシャはブロッキングプールにのみ作用し、非同期スケジューラには逆圧をかけない。はキャンセル不可abort。ドキュメントは明確に述べている:📎 tokio/src/task/blocking.rs:106-120は既に実行を開始したブロッキングタスクには無効であり、タスクは最後まで実行され続けるshutdown_timeout。まだ開始されていないタスクのみが abort によって阻止されうる。シャットダウン時、ランタイムは既に開始されたすべてのブロッキングタスクを待機し、
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は終了時に panic するnum_idle_threads。本番環境で「
last_exiting_thread〔設計上の推論とアーキテクチャのトレードオフ〕チェーン join のコスト📎 tokio/src/runtime/blocking/pool.rs:172-178。タイムアウトで終了するスレッドは、前のタイムアウトで終了したスレッドを join する
InnerImpl。これにより join チェーンが形成される:各終了スレッドは前のスレッドが実際に終了するのを待たなければならない。ブロッキングスレッドの作成/破棄が高頻度なシナリオでは、このチェーンが長くなり、スレッド終了の遅延が累積する可能性がある。これは Valgrind の誤検出を避けるためのトレードオフであり、通常の本番環境では影響は限定的だが、スレッドが頻繁にタイムアウトする負荷では注目に値する。列挙型抽象化の意義Locked。コメントにはShardedバリアントの動作はリファクタリング前と完全に同一であり、📎 tokio/src/runtime/blocking/pool.rs:537-539。spawn_task、run_worker、begin_shutdownバリアントは将来の並行キュー用に対称的なスロットを予約していると説明されている📎 tokio/src/runtime/blocking/pool.rs:548-5823 つのメソッドはすべて列挙型でディスパッチされる
。この「列挙型ディスパッチ + バリアントごとの自己保持クリティカルセクション」という設計により、新しいキュートポロジを追加する際に呼び出し側を変更する必要がない。
本章のまとめspawn_blocking本章では、Tokio が同期コードを受け入れる 2 つの境界を分解した。Innerはクロージャを独立したブロッキングスレッドプールに投入する:LockedImplはキュー、スレッド上限、生存期間、アトミックメトリクスを保持する;Condvarは単一ロック +num_notifyでキューを実装し、max_blocking_threadsカウンタで偽のウェイクアップを補償する;worker は BUSY/IDLE 間を循環し、アイドルタイムアウト後にチェーン join で終了する;block_onは上限に達するとタスクがキューに並びバックプレッシャを形成する。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を通るため)。キューのタスクは誰も処理しない可能性があり、新しいスレッドが起動して初めてキューが空でないことに気づく。これにより「スレッドは満杯なのにタスクはまだキューに並んでいる」という偽のデッドロック状態が発生する。元の判定の意義はまさにこれである:アイドルスレッドがある場合はそれらを優先的にウェイクアップし、無駄なスレッド作成を避ける。task.run()BUSY 段階で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の最初の処理はstd::sync::Mutex再入不可のため、直接デッドロックする。さらに、ロックを保持したまま長時間タスクを実行すると、他のすべての投入者と worker のタスク取得操作をブロックし、デッドロックしなくてもプール全体が直列化される。drop(locked)必須である。
Q3: shutdown::Receiver::waitにおいてtry_enter_blocking_region()失敗し、かつ現在 panic 中である場合は false を返し、そうでなければ panic📎 tokio/src/runtime/blocking/shutdown.rs:44-57。なぜ panic 時に特別扱いするのか?この分岐を削除すると、どのような場面で問題が起きるのか?
参考解析:try_enter_blocking_region失敗は現在が非同期コンテキストであり、ブロッキングが許可されていないことを意味する。通常は panic してユーザーに「非同期コンテキストで runtime を drop できない」と知らせるべきである。しかし現在のスレッドがすでに panic 中の場合(std::thread::panicking()が真)、さらに panic すると二重 panic となり、Rust のデフォルト動作ではプロセスが直接 abort される。シナリオ:ユーザーが非同期タスク内で Runtime を drop し、そのタスク自体が別の理由で panic 中である場合、drop が引き起こす shutdown が二次 panic を起こす。false を返すことで shutdown は待機を諦め、プロセスの abort を避け、ユーザーが元の panic 情報を見られる機会を保つ。これは「panic 安全」の典型的な処理である。
ブロッキングスレッドプールと block_on は非同期ランタイムの能力境界を画定する。前者はスレッドを譲れない作業を専用スレッドに隔離し、後者は非同期でない入口からも Future を駆動できるようにする。しかしこれら二つの境界はコード内でしばしば手書きされるものではない。次の章ではマクロの世界に入り、#[tokio::main]、select!、join! がコンパイル時にこれらのランタイムコードをどのように生成するかを見ていく。
第 9 章:マクロの魔法:#[tokio::main]、select!、join! の背後にあるコード生成
前章ではblock_onとブロッキングスレッドプールが非同期ランタイムの能力境界をどのように画定するかを見たが、ユーザーはこれらの境界をほとんど手書きしない。彼らは#[tokio::main]、select!、join!を書き、マクロがコンパイル時にこれらの定型コードを展開する。マクロは Tokio がユーザーに提供する最初の糖衣であり、コンパイル時に実際にランタイムコードを生成する場所でもある。本章はtokio-macroscrate とtokio/src/macros/select.rsに焦点を当て、最もよく使われる三つのマクロ展開経路を分解し、一つの問いに重点的に答える:マクロ展開後の実際の呼び出しチェーンはどのようなものか、そしてなぜselect!のキャンセル安全セマンティクスは特に警戒しなければならないのか。
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(...)が必要になり、定型コードがビジネスロジックを埋め尽くしてしまう。
データ構造とメモリレイアウト
マクロ自体は実行時データ構造を生成しないが、それが解析した設定は二つの構造体に格納される。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は改名済みと提示する
。これはマクロが「ユーザーの第一接触面」である典型的な設計である:エラーメッセージがすなわちドキュメントである。
Step-by-Step 展開フロー#[tokio::main(flavor = "multi_thread", worker_threads = 4)] async fn main() { ... }。
シナリオを代入:ユーザーがmainと書く。第一ステップでは、ItemFn 📎 tokio-macros/src/entry.rs:577-580の入口がまず item をカスタムのItemFnとして解析する。このsyn::ItemFnは📎 tokio-macros/src/entry.rs:720-764ではなく、Tokio 自身が実装したパーサーであり、その理由はコメントに書かれている:文全体を再帰的に解析したくなく、「token tree ごとにバッファし、セミコロンで区切る」軽量解析のみを行う
。これによりマクロ内で関数本体に対して完全な AST 構築を行うオーバーヘッドを避けている。build_config第二ステップでは、asyncがasync keyword is missing" 📎 tokio-macros/src/entry.rs:346-349キーワードの存在を検証し、欠けていれば "theworker_threads、flavor、start_paused、crate、unhandled_panic、nameを報告する。その後属性パラメータを走査し、📎 tokio-macros/src/entry.rs:369-399を対応する setter に振り分ける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-threadのみを許可し、📎 tokio-macros/src/entry.rs:209-216。
も同様にparse_knobsのみを許可するasyncness 📎 tokio-macros/src/entry.rs:441。ユーザーがCurrentThread/Localを選んだがBuilder::new_current_thread(),Threadedfeature が有効でない場合、エラーメッセージは flavor を明示指定したかどうかで異なるBuilder::new_multi_thread() 📎 tokio-macros/src/entry.rs:468-477。Local第四ステップでは、build_local(Default::default())がコードを生成する。まずbuild() 📎 tokio-macros/src/entry.rs:479-483を消し、その後 flavor に応じて builder の起点を選ぶ:.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-macros/src/entry.rs:508。
を使用するasync #bodyの特殊な点は、build 呼び出しが!ではなくimpl Traitであることであるif false { let _: &dyn Future<Output = #output_type> = &body; }。その後必要に応じてチェーンで📎 tokio-macros/src/entry.rs:551-571を追加するpin!body をスタックに固定し、Pin<&mut dyn Future>に変換するblock_onこれは📎 tokio-macros/src/entry.rs:526-548。
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でtestを共有するが、デフォルトの flavor が異なる:CurrentThread,mainデフォルトはThreaded 📎 tokio-macros/src/entry.rs:91-94デフォルトは#[tokio::test]。これは
がデフォルトでシングルスレッドである理由を説明している——テストは通常マルチコアを必要とせず、シングルスレッドの方が再現しやすい。📎 tokio-macros/src/lib.rs:31-35見落とされがちな落とし穴:マクロ展開後、関数を呼び出すたびに新しい Runtime が作成される。ドキュメントは、関数が頻繁に呼び出される場合は Builder を使って Runtime を再利用すべきだと明確に警告している#[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内の
のデフォルト値は
であり、これがまさにリネームシナリオでエラーが発生する根源である。:select!9.2 select!:マルチブランチポーリング、ビットマスク、ランダムな公平性poll_fn直感的モデル
は「複数の配膳窓口を同時に監視するウェイター」のようなものである。どの窓口が先に料理を出すかによって、その料理を持っていき、他の窓口の行列は無効になる。これがなければ、ユーザーは手動で
select!を書いて複数の Future をタプルに入れて一つずつ poll し、「あるブランチが準備完了したら他のブランチを破棄する」ロジックも自分で処理しなければならない。__tokio_select_utilデータ構造とメモリレイアウトOut展開後、ローカルモジュールMask 📎 tokio/src/macros/select.rs:615-619。Outが生成され、その中に列挙型_0、_1と型エイリアスDisabledがある。📎 tokio-macros/src/select.rs:33-39。Maskのバリアント名はu8……各ブランチに一つずつ、加えてu16があり、すべてのブランチが無効であることを表すu32。u64の基盤となる型はブランチ数に応じて動的に選択される:≤8 なら📎 tokio-macros/src/select.rs:17-31、≤16 ならselect!、≤32 なら
、≤64 ならfutures、64 を超えると直接 panicIntoFuture::into_future。このビットマスクは📎 tokio/src/macros/select.rs:654-656の中核状態である:i 番目のビットが 1 なら i 番目のブランチが無効化されていることを意味する。futures_initすべての Future はタプル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。
シナリオを当てはめる:(skip) pat = fut, if cond => handler,第一ステップ、マクロのエントリルールマッチング。もしskipプレフィックスがあれば、_;そうでなければ📎 tokio/src/macros/select.rs:770-793。skipはランダムな式futures_init.$($skip)*。これがドキュメントで言う「デフォルトでランダムにブランチを選んで先にチェックする」公平性の源であるcount!。
第二ステップ、正規化。tt-muncher が各ブランチをif $cの形式に整形する。disabled |= 1 << index 📎 tokio/src/macros/select.rs:631-636は$futの並びで、長さはそのブランチより前の branch 数に等しい📎 tokio/src/macros/select.rs:39-41。
。poll_fnはタプルフィールドアクセスの生成ready!(poll_budget_available(cx))にも、Pending 📎 tokio/src/macros/select.rs:664-667でブランチインデックスを算出するのにも使われる。select!第三ステップ、前提条件の評価。各ブランチの
について、false ならfor i in 0..BRANCHES,branch = (start + i) % BRANCHES 📎 tokio/src/macros/select.rs:680-685。注意:ブランチが無効化されていても、そのdisabled & mask == mask式は依然として評価される。ただし poll はされないcontinue 📎 tokio/src/macros/select.rs:694-699。Pin::new_unchecked第四ステップ、📎 tokio/src/macros/select.rs:701-707クロージャに入る。まず協調予算をチェック:Ready(out)、予算を使い果たしたら直接disabled |= maskを返す。これにより📎 tokio/src/macros/select.rs:710-730。
が worker を独占しないことが保証される。out第五ステップ、ループ$bind。各 branch について:まずPoll::Ready(Out::_i(out)) 📎 tokio/src/macros/select.rs:727-733を確認し、無効化済みならcontinue;そうでなければタプルからその Future を取り出し、📎 tokio/src/macros/select.rs:44-47。
で一层ラップする(安全性は Future がスタック上にあり移動されないことに依存する)is_pending;poll して、PendingならまずOut::Disabled 📎 tokio/src/macros/select.rs:740-745してからパターンmatch outputをマッチするOut::_i。Disabled第六ステップ、パターンマッチング。もしelseが📎 tokio/src/macros/select.rs:749-755。
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を返す;マッチしなければ、
他のブランチのポーリングを続ける——これがまさにドキュメントのステップ 5 で言う「パターンがマッチしなければ現在のブランチを無効化する」Vec<bool>?。disabled |= mask第七ステップ、ループ終了。もしselect!が真なら
を返し、そうでなければすべてのブランチが無効となり、を返すselect!。外側のSome(v) = stream.next() => ...がstream.next()を対応する handler にマッピングし、Noneを📎 tokio/src/macros/select.rs:198-223。
式にマッピングする:select!。read_exact、read_to_end、write_allコピー📎 tokio/src/macros/select.rs:119-124設計上の考察と本番での落とし穴Mutex::lock、Semaphore::acquireなぜ📎 tokio/src/macros/select.rs:126-133ではなくビットマスクを使うのか?.awaitビットマスクはスタック上の単一の整数で、ヒープ割り当てがなく、.awaitは単一命令である。ホットパス上の📎 tokio/src/macros/select.rs:135-139。
ifにとって、これは各イテレーションでのヒープアクセスを避ける。なぜパターンがマッチしないとブランチを無効化するのか?if !sleep.is_elapsed()これはsleepと「単純な race」の決定的な違いである。例えばis_elapsed()を考える。whileがselect!(ストリーム終了)を返した場合、パターンがマッチせず、そのブランチは永久に無効化され、終了したストリームを無限にポーリングするのを避ける。ドキュメントの例はまさにこのセマンティクスによって二つのストリームを両方終了するまで収集している📎 tokio/src/macros/select.rs:336-376。ifキャンセル安全性の本当の意味sleepあるブランチが準備完了になると、他のブランチの Future は drop される。drop された Future がすでにデータを消費したがまだ返していない場合、データは失われる。ドキュメントはbreak 📎 tokio/src/macros/select.rs:378-405。
biased;がキャンセル安全でないことを明記しており、はキュー公平性のため、キャンセルするとキューの位置が失われる📎 tokio/src/macros/select.rs:67-74。判定方法:biased;ポイントを探し、📎 tokio/src/macros/select.rs:75-81。
で関数を再開しても正しければ、キャンセル安全である
。:join!前提条件の競合トラップselect!:ドキュメントは古典的な誤りの例を挙げている——Readyガードでpoll_fnブランチを
するが、
join!の展開も同様にタプルにFutureを格納するが、状態はビットマスクではなく「完了値」のタプルである。各Futureが完了すると、その値が取り出されて結果タプルに格納され、対応するスロットが完了済みとしてマークされる。select!とは異なり、join!は未完了のFutureをdropしない——すべてのFutureが完了するまで待ってから返す。
ステップバイステップの流れ
join!のポーリングロジックはselect!と「タプルにFutureを格納 +poll_fn駆動」の骨格を共有するが、セマンティクスは逆である:select!は「いずれかが準備完了で即返す」、join!は「すべて準備完了でのみ返す」。各ラウンドのpollですべての未完了Futureを走査し、いずれかがPendingを返せば全体がPending、すべてがReadyなら集約して返す。
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!だが、2箇所の重要なロジックは手続きマクロに委譲されている: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!3つのマクロがそれぞれ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でこの終了済みストリームを再びpollし、ストリームがfusedでなければ(つまり終了後の再pollでpanicや未定義動作を引き起こす可能性がある)、問題が発生する。たとえfusedでも、永遠にNoneを返すストリームを繰り返しpollするのはCPUの無駄である。ドキュメントには「Re-entering select! due to a loop clears the disabled state」📎 tokio/src/macros/select.rs:37-38と明記されており、これはselect!マクロに再進入する(新しいループラウンド)ことを指し、同一select!内での複数回のpollではない。disabledはクロージャの外で初期化しなければならず、それによって同一select!呼び出し内の複数回のpoll間で状態を保持できる。
Q2: select!はReady(out)をpollした後、まずdisabled |= maskを実行してからパターン📎 tokio/src/macros/select.rs:720-730をマッチさせる。もしdisabled |= maskを削除した場合、パターンがマッチせずかつそのFutureがpollのたびに即座にReadyを返すシナリオで何が起こるか?
参考解析:disabled |= maskを削除すると、outが$bindにマッチしない場合、コードはcontinueに進み他のブランチのポーリングを続ける。しかし次のラウンドでpoll_fnが呼ばれると(例えば他のブランチがPendingを返した後に再度poll)、このブランチはまだ無効化されておらず、再びpollされる。そのFutureがpollのたびに即座にReadyを返し値がパターンにマッチしない場合、「poll -> Ready -> 不一致 -> continue -> 他のブランチPending -> Pending返却 -> 再度poll -> 再度Ready -> ...」というライブロックが形成され、CPUが空回りする。disabled |= maskはReadyの直後にセットされ、パターンがマッチしなくてもそのブランチが再びpollされないことを保証する。セットはパターンマッチの前に行われるため、「Readyだがパターン不一致」と「Readyかつパターンマッチ」の両方でそのブランチが無効化される——前者はライブロック防止、後者は重複消費防止である。
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 を格納 + ビットマスクで無効化を記録 + ランダム起点で公平性を保証」であり、パターンが一致しなければ分岐が無効化され、キャンセル安全性は drop された Future が.awaitで再起動可能かどうかに依存します。join!とselect!は骨格を共有しますがセマンティクスは逆で、前者はすべての完了を待ち、後者はどれか一つが準備完了すれば即座に返ります。三者は共に Tokio マクロ設計の核心的なトレードオフを示しています:ボイラープレートコードとコンパイル時検証をマクロに任せ、実行時セマンティクスの複雑さ(特にキャンセル安全性)はユーザーの明示的な理解に委ねるということです。マクロがどのように実行時コードを生成するかを理解した後、次に自然に生じる疑問は:これらのコードが実際にバイトストリームの読み書きを開始するとき、Tokio はどのような抽象を提供するのか?第 10 章ではAsyncRead/AsyncWriteとコーデックフレームワークを剖析し、BufReader/BufWriterがどのようにシステムコールを削減するか、copy_bidirectionalがどのように双方向転送を駆動するか、Framedがどのようにバイトストリームをフレームに分割するかを見ることで、「非同期 I/O の抽象境界はどこにあるのか」に答えます。
第 10 章:ストリーミング I/O 抽象:AsyncRead/AsyncWrite とコーデックフレームワーク
前の章では tokio-macros の展開過程を分解し、#[tokio::main]、select!、join! がどのようにボイラープレートコードとコンパイル時検証をユーザーの手から引き受けるかを見ました。しかしマクロが生成するのは依然として通常の Future と poll 呼び出しです——これらの Future が実際にバイトの読み書きを開始するとき、Tokio が提供する低レベル抽象はわずか二つの trait だけです:AsyncRead と AsyncWrite。それらの問題は「低レベルすぎる」ことにあります:一度の poll_read は「いくつかのバイトを読んだ」ことしか保証せず、「完全なメッセージを読んだ」ことは保証しません。そして大多数のプロトコル(HTTP、Redis、gRPC、カスタム RPC)は「バイトストリーム」ではなく「フレーム」指向です。本章が答えるべき核心的な問いは:非同期 I/O の抽象境界はどこに引かれるべきか?Tokio の答えは二層に分かれます:tokio::io はバイトストリームレベルの trait とツール(BufReader/BufWriter/copy_bidirectional)を提供し、tokio-util の codec フレームワークはその上にフレームレベルの Stream/Sink 適配(Framed/LengthDelimitedCodec)を提供します。この二層の分業を理解すれば、「なぜプロトコル実装がほぼすべて Framed から始まるのか」が理解できます。
一、AsyncRead/AsyncWrite:なぜ std::io::Read を直接再利用できないのか
直感的モデル
std::io::Read::readは「ブロッキング式の荷物受け取り」です:あなたは窓口の前に立ち、荷物が届くまでずっと待ち、スレッドはサスペンドされます。AsyncRead::poll_readは「整理券式の荷物受け取り」です:あなたは「できましたか」と尋ね、まだできていなければ(Poll::Pending)先に他のことをし、同時に Waker を残して荷物が届いたときにシステムがあなたを呼び出すようにします。この trait がなければ、すべての非同期 I/O は手書きでepoll登録と Waker マッピングを書かなければなりません——これはまさに第 5 章の Reactor が行っていることであり、AsyncReadはそれが上位層に公開する統一ファサードです。
データ構造とメモリレイアウト
AsyncReadの定義は極めて簡潔で、メソッドは一つだけです:
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はしばしばasync fnが生成する Future に保持され、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:
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をReadBufにコピーし、そのfilledポインタを進める。*self = bスライス自体を残り部分へ進める——これが&[u8]が「カーソル」として機能する鍵:各poll後にselfは未読部分を指す。最後にReady(Ok(()))を返す。メモリスライスは常に「レディ」であり、Pending。
しないからである。_cxが無視されることに注意:メモリデータソースはWakerを必要としない。これはネットワークソケットと対照的——後者はデータがない場合に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。これは防御的設計:Cursorのpositionは外部のset_positionから任意の値に設定でき、範囲外時は「読み取り済み」として扱う方がpanicよりI/Oセマンティクスに合致する。
設計思考:derefマクロとPinの伝播
AsyncReadはBox<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_bidirectional:双方向転送のステートマシン
直感モデル
copy_bidirectionalは「双方向配膳係」:A→BとB→Aの両方向を同時に監視し、どちらかでデータを読めば反対側に書く。これがなければTCPプロキシを実装するには二つのcopyFutureを手書きしselect!で組み合わせる必要がある——そしてselect!のキャンセル安全制約(第9章)により「読み取り途中でキャンセル」されたデータが失われる。copy_bidirectionalは明示的ステートマシンで「読み-書き-クローズ」の中間状態を保存し、キャンセル安全を実現する。
データ構造とメモリレイアウト
核心は三状態のenum:
enum TransferState {
Running(CopyBuffer),
ShuttingDown(u64),
Done(u64),
}📎 tokio/src/io/util/copy_bidirectional.rs:10-14
RunningがCopyBuffer(8KBバッファと読み書きカウントを含む)を保持し、「データを運搬中」を表す。ShuttingDown(u64)はコピー済みバイト数を運び、「読み端がEOF、書き端をクローズ中」を表す。Done(u64)は「クローズ完了、最終バイト数を記録」を表す。このenumがキャンセル安全の鍵:いつdropされても状態はenumに保存され、次のpollでブレークポイントから継続できる。
CopyBufferはcopy.rsから来て、デフォルトサイズはDEFAULT_BUF_SIZEが決定する(8KB)📎 tokio/src/io/util/copy_bidirectional.rs:76-88。両方向がそれぞれ独立したCopyBufferを保持するため、メモリオーバーヘッドは16KB。
シナリオ駆動Walkthrough:双方向転送の完全なライフサイクル
copy_bidirectional_implはpoll_fnで両方向のステートマシンを組み合わせる:
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
の呼び出し順に注意:まずa→bを進め、次にb→aを進め、両方ともtransfer_one_directionを返すPoll。ready!マクロはどちらかの方向が未完了なら即座にPendingを返す——しかしもう一方の方向の状態は既に進められている。これこそコメントが強調する📎 tokio/src/io/util/copy_bidirectional.rs:143-144:たとえready!が早期リターンしても、もう一方の方向は次回pollでDone(count)を返し、進捗を失わない。
transfer_one_directionの内部はloopであり、状態に応じて進む:
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を呼び、内部で「一塊読み、一塊書く」を読み端EOFか書き端ブロックまでループする。EOF時はコピー総数を返し、状態はShuttingDown。ShuttingDownへpoll_shutdownを呼び書き端をクローズ(FIN送信)、完了後Done。Doneへ遷移し直接カウントを返す。
以下のフロー図は単方向ステートマシンの進行ロジックとエラー分岐を示す:
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))"]設計思考:なぜasync fnでなく明示的ステートマシンか
もしtransfer_one_directionをasync fnと書けば、コンパイラはFutureを生成し、その内部状態(CopyBuffer、コピー済みカウント)は生成されたステートマシンに隠される。単方向使用なら問題ないが、copy_bidirectionalは同じpoll周期内で同時に両方向を進める必要がある——二つのasync fnにselect!を加えると、どちらかが完了した時もう一方がdropされ、その内部バッファとカウントが失われ、キャンセル安全に違反する。明示的TransferStateは状態をスタック上に露出し、poll_fn再進入のたびに状態が残るため、「キャンセル後もブレークポイントから回復」を保証する。
エラー処理では、poll_copyが返す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 と誤判定され、ビジーループが発生する。
---
三、Framed:バイトストリームをフレームに分割する
直感的モデル
Framedは「ソーセージ製造機」である:上流は連続した水流(AsyncRead/AsyncWrite)であり、下流は切り分けられたソーセージの断片(Stream<Item = Frame> / Sink<Frame>)。Decoderは「水流から一段を切り出す」役割を担い、Encoderは「一段を水流に包み込む」役割を担う。もしFramedがなければ、各プロトコル実装は「バッファ管理 + 半パケット処理 + 粘着パケット分割」を手書きしなければならない——これこそが codec フレームワークが排除しようとする重複作業である。
データ構造とメモリレイアウト
Framed自体は単なる薄いラッパーである:
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_boundaryは背圧メカニズムの鍵である:書き込みバッファがこの閾値を超えると、poll_readyはPendingを返し、データがフラッシュされるまで、上流の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が一回の「フレーム読み取り-フレーム書き込み」往復におけるコンポーネント間の協調を示す:
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)はsendの Future スタック上にあり、drop すれば失われる。プロダクションコードで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を強制することで、将来フィールドを追加しても互換性を壊さない。
---
四、LengthDelimitedCodec:長さプレフィックスコーデックのステートマシン
直感的モデル
LengthDelimitedCodecは「長さでソーセージを切る」専用の刀具である:各フレームの前に固定バイト数の長さフィールドがあると仮定し、まず長さを読んでから payload を読む。もしこれがなければ、長さプレフィックスプロトコルを実装するには「4 バイト読む → 長さを解析 → N バイト読む → ループ」というステートマシンを手書きしなければならない——これこそが内部のDecodeStateが行っていることである。
データ構造とメモリレイアウト
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はステートマシンのエントリポイントである:
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)状態では直接 n を取得する。次にsplit_to(n)を呼び出す:バッファに既に n バイトあれば、Headがフレームを切り出し、状態はNoneに戻り、次のフレームヘッダのスペースを予約する;そうでなければ
decode_headを返して待つ。
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エラー
を返す。これは悪意ある相手が「長さフィールドが 4GB」のフレームを送信してメモリを枯渇させるのを防ぐ——これは長さプレフィックスプロトコルで最も古典的な DoS 攻撃面である。checked_sub/checked_add長さ調整には📎 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の完全な決定パスを示している:
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_lencodec を構築する際に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を使う。エンコード時の調整方向はデコードと逆であることに注意:デコードは「読み取った長さ ± adjustment = payload 長」、エンコードは「payload 長 ∓ adjustment = 書き込む長さフィールド」checked_add/checked_sub 📎 tokio-util/src/codec/length_delimited.rs:620-631〔設計上の推論とアーキテクチャのトレードオフ〕📎 tokio-util/src/codec/length_delimited.rs:620-624。
のセマンティクスを統一するためである:それは「長さフィールド値と payload 長の差」を表す。プロトコルの長さフィールドがヘッダーを含む場合(Example 3 など)、length_adjustment、デコード時はadjustment = -2で payload 長を取得し、エンコード時はn - (-2) = n + 2で長さフィールドに書き戻す。payload - (-2) = payload + 2設計上の考察:抽象化境界の三つの層
---
本章を振り返ると、Tokio の I/O 抽象は明確な三層構造を示している:
第一層:バイトストリーム trait(
。「バイトの読み書き」のみを約束し、フレーム境界は約束しない。これは最小インターフェースであり、あらゆる I/O ソース(socket、ファイル、メモリスライス)が実装できる。代償として、上位層が半パケット/粘着パケットを自ら処理しなければならない。AsyncRead/AsyncWrite)第二層:バイトストリームユーティリティ(
。trait の上に「システムコール削減」「双方向転送」などの汎用機能を提供する。BufReader/BufWriter/copy_bidirectional)の明示的なステートマシンは「キャンセル安全性」がユーティリティ層でどのように実現されるかを示している——状態は Future 内部ではなくスタック上に保存される。copy_bidirectional第三層:フレームアダプタ(
。バイトストリームをFramed/Decoder/Encoder)に昇格させ、プロトコル実装が「バッファ管理」ではなく「フレームのエンコード/デコード」のみを気にすればよいようにする。Stream<Frame>/Sink<Frame>はこの層の標準的な例であり、そのLengthDelimitedCodecステートマシンとDecodeState保護は、すべての長さプレフィックスプロトコルが再利用すべきパターンである。max_frame_len〔設計上の推論とアーキテクチャのトレードオフ〕
に置き、tokio-utilコアに置かなかったのは、フレームの定義がプロトコルによって異なるためである——tokioはバイトストリームのみを提供し、tokioはフレームフレームワークを提供し、具体的なプロトコル(HTTP/Redis/gRPC)はそれぞれの crate で実装するtokio-util本章のまとめDecoder/Encoder。
---
は
AsyncRead::poll_readの三引数でPin<&mut Self>+Context+ReadBufを置き換え、「ブロッキング待機」を「Waker 登録 + Pending 返却」に変える。std::io::Read::readかつ読み取り量が 0 の場合、EOF とゼロ容量バッファを区別する必要がある。Ready(Ok(()))はcopy_bidirectionalの三状態列挙(TransferState)で中間状態を保存し、双方向転送がRunning/ShuttingDown/Doneキャンセル下でも復旧できるようにする。エラー発生時には一部のデータが失われる可能性がある。select!はFramedをAsyncRead/AsyncWriteに適合させ、読み書きバッファとバックプレッシャーをそれぞれ管理する。Stream/Sink,ReadFrame/WriteFrameは非キャンセル安全(メッセージ損失)、SinkExt::sendはキャンセル安全。StreamExt::nextはLengthDelimitedCodec)ステートマシンで半パケットを処理し、DecodeState(Head/Data(n)は長さフィールドの DoS を防ぎ、max_frame_lenは調整のオーバーフローを防ぐ。checked_add/checked_sub本章の考察とセルフチェック
の
Q1: copy_bidirectionalにおいて、transfer_one_direction分岐のTransferState::ShuttingDownを直接ready!(w.as_mut().poll_shutdown(cx))?に変更した場合(shutdown をスキップ)、どのようなシナリオで対向コネクションが正常にクローズできなくなるか?*state = TransferState::Done(*count)参考解析
の役割は、対向に FIN パケットを送信し、「こちら側にはもうデータがない」ことを通知することである。これをスキップして直接:poll_shutdownに移行すると、書き込み側がクローズされず、対向はデータを待ち続け、「ハーフオープン接続」が形成される——対向はDoneで永遠にブロックする可能性があり、タイムアウトまで続く。TCP プロキシのシナリオでは、これによりコネクションリークが発生する:クライアントは既に切断されているが、プロキシからバックエンドへの接続は維持されたままである。ソースコードにread状態が存在するShuttingDownのはまさに、EOF 後に書き込み側を明示的にクローズすることを保証するためである。なお📎 tokio/src/io/util/copy_bidirectional.rs:35-39自体がpoll_shutdown(送信バッファ満杯など)を返す可能性があるため、Pendingで待機する必要があり、無視してはならない。ready!において、
Q2: LengthDelimitedCodec::decode_headのチェックif n > self.builder.max_frame_len as u64を削除した場合、悪意のあるクライアントが長さフィールドを📎 tokio-util/src/codec/length_delimited.rs:526-531(4GB)とするフレームヘッダーを送信すると何が起こるか?なぜこのチェックは0xFFFFFFFFの前でなければならないのか?length_adjustment参考解析
:チェックを削除すると、はnに変換されusizeに渡される。decode_data。decode_dataのチェック時にはsrc.len() < nが返るが、Noneの末尾のdecode_headが 4GB のメモリを予約しようとし、OOM または割り当て失敗による panic を引き起こす。チェックはsrc.reserve(n.saturating_sub(src.len())) 📎 tokio-util/src/codec/length_delimited.rs:559の前でなければならない。なぜならlength_adjustmentが負になる可能性があり(length_adjustmentなど)、先に調整してからチェックすると、-2は依然として 4GB に近く、チェックが形骸化する;また負の調整により0xFFFFFFFF - 2が先に失敗する可能性があり、エラーメッセージが「オーバーフロー」と誤って示され「フレームが大きすぎる」ではなくなる。ソースコードの順序 [FACT:tokio-util/src/codec/length_delimited.rs:526-checked_sub 先失败,错误信息会误导为「溢出」而非「帧过大」。源码顺序 [FACT:tokio-util/src/codec/length_delimited.rs:526-
ここまでで、Tokio におけるバイトストリームとメッセージフレーム間の2層抽象を整理した。tokio::io がバイト転送を担当し、tokio-util の codec フレームワークがフレーム分割とエンコード/デコードを担当する。Framed がプロトコル実装の出発点となるのは、「完全なメッセージを1つ読む」という高頻度の要求を再利用可能な Stream/Sink アダプタとしてカプセル化しているからに他ならない。しかしフレームはデータの容器にすぎず、プロトコルが動的タスク集合、構造化キャンセル、あるいはより複雑なストリーム合成を扱う必要がある場合、Framed だけでは不十分である。次章では tokio-stream と tokio-util の拡張メカニズムに入り、StreamExt コンビネータ、StreamMap/JoinSet/TaskTracker、そして CancellationToken がどのように基盤の Waker とスケジューリング機構を再利用し、非同期イテレーションとタスク管理のためのより高レベルなツールを提供するかを見ていく。
第11章:Stream エコシステムとツール層:tokio-stream と tokio-util の拡張メカニズム
前章では Framed のバイトレベル機構を分解した。Decoder が BytesMut をフレームに分割し、Sink がフレームを書き戻す。これにより非同期 I/O の抽象境界が明確になった。しかしフレームはデータの容器にすぎず、実際のプロトコル実装では直ちに tokio::io と Framed のどちらも解決しない3つの問題に直面する。非同期イテレーション——Framed は Stream を実装しているが、Stream には poll_next しかなく、next().await、filter、take、merge がない。手書きの poll_fn は冗長で、キャンセル安全性でつまずきやすい。動的タスク集合——チャットサービスが N 個のチャンネルを同時に購読し、チャンネルが随時参加・退出する場合、select! の分岐数はコンパイル時に固定され、実行時の増減するストリーム集合を表現できない。構造化キャンセル——select! は単一分岐をキャンセルできるが、タスクツリー全体の停止を伝播できず、すべてのタスクが実際に終了するのを待つこともできない。tokio-stream と tokio-util はまさにこの3つのために生まれ、その重要な設計原則は「別の釜戸を起こさない」ことである。StreamExt の各コンビネータは poll_next のラッパーにすぎず、StreamMap は Waker の登録セマンティクスを再利用し、CancellationToken は tokio::sync::Notify の上に直接構築され、TaskTracker は AtomicUsize 1つで全状態をエンコードする。これらを理解することは、本質的に既存の Waker とスケジューリング機構の上でゼロコスト抽象をどう作るかを理解することである。本章はイテレーション、集合、キャンセルの3層を順に進む。まず StreamExt が poll_next をどのように組み合わせ可能なイテレータに変えるかを見て、次に StreamMap と TaskTracker が動的集合をどう管理するかを見て、最後に CancellationToken が1本のツリーでキャンセル信号をタスクツリー全体にどう伝播するかを見る。
StreamExt:poll_next を組み合わせ可能なイテレータに変える
直感モデル
StreamはFutureにとって、Iteratorが値にとってそうであるのと同じである:Futureは「1つの値」を生成し、Streamは「一連の値」を生成する。しかしStreamはpoll_nextという1つのプリミティブしか定義しておらず、Iteratorがnextだけを定義しているのと同じである。もしStreamExtがなければ、フィルタ、マップ、切り詰めのたびにpoll_fnクロージャを手書きし、Pinを手動管理する必要がある——これこそがfuturescrate の初期ユーザーが最も苦しんだ点である。StreamExtの役割は、StreamにIteratorのようなコンビネータエコシステムを装着することである。
もしそれがなければ、システムが直面する災難は機能欠如ではなく、キャンセル安全性の体系的崩壊である:手書きのpoll_fnはそれぞれ、select!によってキャンセルされたときに、すでにpollされた要素を失う可能性がある。
データ構造とメモリレイアウト
StreamExtは拡張 traitであり、自身はデータを保持しない:
📎 tokio-stream/src/stream_ext.rs:106-106
pub trait StreamExt: Stream {そのすべてのメソッドは具体的なコンビネータ構造体を返し、Box<dyn Stream>ではない。これが重要な設計である:mapはMap<Self, F>,filterを返しFilter<Self, F>,takeはTake<Self>を返す。これらの構造体はすべてゼロヒープ割り当てのジェネリックラッパーであり、コンパイラはチェーン全体を何層ものpoll_next呼び出しにインライン化できる。
trait の blanket impl に注意:
📎 tokio-stream/src/stream_ext.rs:1213-1213
impl<St: ?Sized> StreamExt for St where St: Stream {}任意のStreamが自動的にすべてのコンビネータを獲得し、手動実装は不要。?Sizedはdyn Streamも拡張メソッドを享受できるようにする。
コンビネータのモジュール宣言はこの trait の完全な能力面を明らかにする:
📎 tokio-stream/src/stream_ext.rs:4-59
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……)、なぜならそれらはストリーム全体を1つの値に消費するからであり、map、filter、takeなどが返すのはStream、なぜならそれらはストリームの形態を保つからである。nextの戻り型はNext<'_, Self>であり、ライフタイムパラメータを持ち、ストリームを借用するだけである:
📎 tokio-stream/src/stream_ext.rs:144-149
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
/// 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.シナリオ駆動 Walkthrough:一度のmergeのポーリング
mergeは、コンビネータがどのように Waker を再利用するかを理解するための最良のサンプルである。これは2つのストリームを交互に産出し、かつ公平性を保証する——両方のストリームが同時に準備完了した場合、交互に産出する。ドキュメントは特にチェーン呼び出しを避けるよう警告しているmerge:
📎 tokio-stream/src/stream_ext.rs:319-321
/// 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のシグネチャは、2つのストリームのItem型が同じであることを要求する:
📎 tokio-stream/src/stream_ext.rs:398-404
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をそのまま内部の2つのストリームのpoll_next。に渡す。Waker の登録は完全に基層のストリームが担当する,Mergeは単に「今回どちらに先に問い合わせるか」を決めるだけである。これこそが「基層の Waker メカニズムを再利用する」という言葉の文字通りの意味である。
merge_size_hints補助関数は、コンビネータがどのように容量ヒントを統合するかを示している:
📎 tokio-stream/src/stream_ext.rs:1216-1226
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契約の典型的な処理方法である。
設計上の考察:キャンセル安全性とchunks_timeoutの panic 防護
StreamExtのドキュメントは、各メソッドにCancel safetyを注記している。nextを例にとると:
📎 tokio-stream/src/stream_ext.rs:123-127
/// # 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がキャンセル安全である理由は、ストリームを借用するだけで要素を消費しないため——Nextfuture が drop されたとき、ストリーム自体の状態は変わらず、次回のnextは再びpoll。
を呼び出す。しかし、すべてのコンビネータがキャンセル安全というわけではない。chunks_timeoutは構築時にパラメータ検証を行う:
📎 tokio-stream/src/stream_ext.rs:1178-1185
#[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を許可すると、バッチ処理ロジックが「永遠に1バッチ分貯まらない」無限ループに陥るか、空のバッチを産出することになり、この種のバグは実行時に特定するのが極めて困難である。構築段階での panic は、エラーを最も早い観測可能な時点に前倒しする。
timeoutとtimeout_repeatingの差異も注目に値する:timeoutはタイムアウト後にエラーを返すが、内側のストリームのポーリングを続ける;timeout_repeatingはIntervalに従ってタイムアウトエラーを産出し続け、内側のストリームが値を産出するまで続ける。ドキュメントは2つの例でこの違いを正確に描写している:
📎 tokio-stream/src/stream_ext.rs:985-1001
/// 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
/// Timeout errors will be continuously produced at the specified interval
/// until the wrapped stream yields a value.---
StreamMap:動的ストリーム集合と公平なポーリング
直感的モデル
select!の分岐数はコンパイル時に固定される。しかし、チャットサービスが購読するチャンネル数や、クローラーが追跡する接続数は、実行時にしかわからない。StreamMapは「実行時に追加・削除可能なselect!」である:任意の数のストリームを1つの集合に入れ、毎回nextが(key, value)を返し、この値がどのストリームから来たかを教えてくれる。これがなければ、すべてのストリームを1つのmpscチャネルに詰め込むしかなく、余分な転送オーバーヘッドが生じる。
データ構造とメモリレイアウト
StreamMapのストレージは極めて素朴である——1つのVec:
📎 tokio-stream/src/stream_map.rs:204-208
#[derive(Debug)]
pub struct StreamMap<K, V> {
/// Streams stored in the map
entries: Vec<(K, V)>,
}ドキュメントはこの選択の代償を明確に説明している:
📎 tokio-stream/src/stream_map.rs:38-44
/// `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の線形スキャンは CPU キャッシュに優しく、かつswap_removeは O(1) である。もしHashMapを使えば、毎回のpoll_nextでハッシュバケットを走査する必要があり、キャッシュ局所性が悪化する。insertとremoveの O(n) スキャンは「小規模なストリーム集合」という仮定の下では許容できる。
insertの実装は「先に削除してから挿入」というセマンティクスを体現している:
📎 tokio-stream/src/stream_map.rs:446-454
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を使って削除対象の要素を末尾要素と交換してからポップし、O(n) の移動を避ける:
📎 tokio-stream/src/stream_map.rs:471-483
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
}シナリオ駆動ウォークスルー:poll_next_entry のランダム開始点とカーソル修正
StreamMapの核心はpoll_next_entryである。これはランダムな開始点からポーリングを開始し、公平性を保証する——もし常にインデックス0から始めると、最初のストリームが後続のストリームを飢えさせる:
📎 tokio-stream/src/stream_map.rs:515-550
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
}
}このコードには3つの巧妙な点があり、一つずつ分解する:
第一に、ランダムな開始点。 thread_rng_nはスレッドローカルのFastRandを使用し、xorshift64+アルゴリズムに基づく:
📎 tokio-stream/src/stream_map.rs:765-768
/// Implement `xorshift64+`: 2 32-bit `xorshift` sequences added together.
/// Shift triplet `[17,7,16]` was calculated as indicated in Marsaglia's
/// `Xorshift` paperfastrand_nは Lemire の乗算剰余を% n:
📎 tokio-stream/src/stream_map.rs:787-792
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)、カーソルは0にラップアラウンドする。第三に、Pendingのセマンティクス。
poll_next一周走査してもどのストリームも準備完了でなく、かつ集合が空でない場合、poll_next_entryを返す。このときすべてのストリームの Waker はすでに登録済みであり、どれかが準備完了すれば起動される。
📎 tokio-stream/src/stream_map.rs:676-683
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)
}
}の上に key を補う:ready!複製poll_next_entry注意Pendingマクロ:もしpoll_nextがPending。K: Cloneを返した場合、全体のkey.clone()。
が直ちに
next_manyを返す。制約はここでのStreamMapに由来する
📎 tokio-stream/src/stream_map.rs:581-583
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
}は
📎 tokio-stream/src/stream_map.rs:573-578
/// # 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に push し、内部に一時保存しないからである。もし future が drop されても、すでに push された要素はpoll_next_entryに残っており、失われない。しかしこれはつまり:drop されたとき
📎 tokio-stream/src/stream_map.rs:597-666
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よりも複雑である。なぜなら1ラウンド内でできるだけ多く収集する必要があるから:for複製should_loop = true外側のlimitと内側の
📎 tokio-stream/src/stream_map.rs:588-591
/// * `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
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の決定パスを描写します:
flowchart TD
start["poll_next_entry(cx)"] --> rand["start = thread_rng_n(len)"]
rand --> loop{"遍历 len 次?"}
loop -->|"未完成"| poll["Pin::new(stream).poll_next(cx)"]
poll -->|"Ready(Some(val))"| ret_val["返回 Ready(Some((idx, val)))"]
poll -->|"Ready(None)"| remove["entries.swap_remove(idx)"]
remove --> wrap{"idx == entries.len()?"}
wrap -->|"是"| set_zero["idx = 0"]
wrap -->|"否"| check_swap{"idx < start && start <= len?"}
check_swap -->|"是"| skip["idx = idx.wrapping_add(1) % len"]
check_swap -->|"否"| loop
set_zero --> loop
skip --> loop
poll -->|"Pending"| advance["idx = idx.wrapping_add(1) % len"]
advance --> loop
loop -->|"遍历完成"| empty{"entries.is_empty()?"}
empty -->|"是"| ret_none["返回 Ready(None)"]
empty -->|"否"| ret_pending["返回 Pending"]---
TaskTracker:単一の AtomicUsize ですべての状態をエンコードする
直感モデル
優雅なシャットダウンには2つのことが必要です:タスクに停止を通知すること(CancellationTokenが担当)、およびタスクが実際に終了するのを待つこと(TaskTrackerが担当)。TaskTrackerは「タスクカウンター + シャットダウンスイッチ」の合体のようなものです:実行中のタスクがまだあるか、またはclose,wait()がまだ呼び出されていなければ、戻りません。これがなければ、JoinSetしか使えませんが、JoinSetは各タスクの戻り値を蓄積するため、長時間実行されるサービスでは OOM になります。
データ構造とメモリレイアウト
TaskTrackerはArcのラッパーです:
📎 tokio-util/src/task/task_tracker.rs:158-178
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,
}これは本章で最も精妙なメモリレイアウトです:1つのAtomicUsizeが「閉鎖済みかどうか」と「タスク数」を同時にエンコードします。最下位ビットは閉鎖フラグで、残りのビットはタスク数です(タスクカウントは毎回+2されるため、最下位ビットは常に 0 です)。これによりis_closed_and_emptyは1回のアトミックロードだけで済みます:
📎 tokio-util/src/task/task_tracker.rs:216-222
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」を意味します。なぜ2つのアトミック変数を使わないのか? 2つの変数は2回のロードが必要で、「両方の条件を同時に満たす」ことをアトミックに判定できません。単一変数エンコードによりis_closed_and_emptyは1回のAcquireロードとなり、waitの高速パス上でロックが不要になります。
シナリオ駆動ウォークスルー:close と drop_task の競合
典型的なシナリオを考えます:メインスレッドがtracker.close()を呼び出し、同時に最後のタスクが終了中です(TaskTrackerToken::dropがdrop_taskを呼び出す)。両者は並行する可能性があり、どちらが先でもwait()が起床できることを保証する必要があります。
まずset_closed:
📎 tokio-util/src/task/task_tracker.rs:225-249
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
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。
を呼び出します。2つのパスの競合分析:
- 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
#[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は「以前の書き込みを後続の読者に可視化する」(Release セマンティクス)だけで十分で、「以前の他のスレッドの書き込みを見る」(Acquire セマンティクス)は不要だからです。しかしnotify_nowは happens-before を確立するために Acquire が必要です:タスク終了前に行われたすべてのクリーンアップ作業が、wait()の戻り値以降のコードに可視であることを保証します。このloadの結果は破棄され、純粋にそのメモリ順序の副作用のためです——これは Rust のアトミック操作における「フェンス的ロード」の典型的な用法です。
設計思考:wait の ABA 耐性と TrackedFuture の drop セマンティクス
waitはTaskTrackerWaitFutureを返し、その内部はNotified:
📎 tokio-util/src/task/task_tracker.rs:318-327
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
/// 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.ドキュメントは特に ABA 耐性を強調しています:Notify::notified()コピーNotifiedこの保証はnotify_waitersのセマンティクスに由来します:pollfuture は作成時に「待機者」の身分を登録し、たとえpollがそれがTaskTrackerWaitFuture::pollされる前に呼び出されても、最初の
📎 tokio-util/src/task/task_tracker.rs:697-712
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
/// The task is removed from the collection when it is dropped, not when [`poll`] returns
/// [`Poll::Ready`].の核心的な違いです:ReadyコピーTrackedFutureこれは意味します:たとえ future がすでにTaskTrackerを返していても、
📎 tokio-util/src/task/task_tracker.rs:33-35
/// 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コピー
📎 tokio-util/src/task/task_tracker.rs:670-672
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 が自動的にカウント減算をトリガーします。
第12章:協調的スケジューリングと予算:coop メカニズムがどのようにタスクのスケジューラ飢餓を防ぐか
前章では、tokio-stream と tokio-util が基盤の Waker とスケジューリング機構をどのように再利用してコア機能を拡張するかを見た。しかし、どれだけ多くのコンビネータを拡張しても、非同期ランタイムの核心的な矛盾は常に存在する:スケジューラは複数のタスク間で CPU 時間を公平に配分しなければならないが、タスク自体は非プリエンプティブである——ある Future の poll が実行を開始すると、スケジューラは外部からそれを中断できない。もしタスクが1回の poll で10万件のメッセージをループ処理したり、loop 内で永遠に ready な Future を繰り返し await したりすると、worker スレッドを独占し、同じスレッド上の他のタスクは永遠にポーリングの機会を得られなくなる。これが古典的な「タスクがスケジューラを飢餓させる」問題である。Tokio の解決策はプリエンプションではなく協調である:各タスクに1回のスケジューリング周期内で限られた予算を割り当て、リソース操作が予算を消費し、予算を使い果たしたタスクは自発的に譲らなければならない。本章ではこの coop メカニズムの実装を深く掘り下げる。
12.1 予算の担い手:スレッドローカルストレージと Budget 構造体
スケジューラをレストランで唯一のウェイターに例え、タスクを次々と料理を注文する客とすれば、coop 予算は「各客は最大 N 品まで注文できる」というルールである——ウェイターは客を強制的に中断する必要はなく、客が N 品を注文し終えたら「少し休んでください、次の方を対応します」と言うだけでよい。このルールがなければ、おしゃべりな客一人でレストラン全体が麻痺してしまう。
予算は2つの制約を満たす必要がある:第一に、任意の深さのpoll呼び出しスタックからアクセスでき、引数を層ごとに渡す必要がないこと;第二に、「現在 Tokio ランタイム内にいるかどうか」を区別できること——ランタイム外でblock_onを呼び出すときは予算の制約を受けるべきではない。Tokio はスレッドローカルストレージ(TLS)で予算を保持し、contextモジュールを通じて統一的に管理することを選んだ。
予算の核心的な型はcoop::Budgetである。本章のソースコードスライスにはcoop.rsの完全な定義は直接示されていないが、worker.rsの使用箇所からそのインターフェース契約を逆推できる:
📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:695-795
coop::budget(|| {
// ... 轮询任务 ...
task.run();
// ...
loop {
// ...
if !coop::has_budget_remaining() {
// 预算耗尽,把 LIFO 任务推回队列
core.run_queue.push_back_or_overflow(task, ...);
return ControlFlow::Continue(core);
}
// ...
}
})ここに3つの重要な API が現れる:coop::budget(closure)は予算スコープを確立し、coop::has_budget_remaining()は残りの予算を照会し、そして後述するcoop::stop()とcoop::set()。budgetのセマンティクスは:クロージャに入るとき現在のスレッドの予算を満額値(デフォルト128)にリセットし、クロージャ実行中はすべてのリソース操作がこの枠を共有し、クロージャを抜けるとき外側の予算を復元する。
予算値128は経験値である:十分に大きく、通常のメッセージ処理ループ(例えば1回の poll で数十件のメッセージを処理)が頻繁に譲りを引き起こさない;また十分に小さく、制御不能なループが最大128回のリソース操作で必ず譲らなければならず、遅延を許容範囲に抑える。
Budgetは TLS 内で通常Cell<Option<Budget>>の形で存在する。Optionの外側のセマンティクスは「現在のスレッドが Tokio ランタイムコンテキスト内にあるかどうか」である:Noneはランタイム内にいないこと(例えばランタイム外のblock_on)を表し、このときすべての予算チェックはそのまま通過させる。
12.2 予算の消費点:リソース操作がどのように差し引くか
予算は無から消費されることはなく、リソース操作だけがそれを差し引く。いわゆるリソース操作とは、無限ループで呼ばれうる、外部世界と相互作用する API のことである——channel のsend/recv、I/O の読み書き、yield_nowなど。例えばmpsc::Sender::reserveは、すべての送信パスの共通エントリポイントである:
📎 tokio/src/sync/mpsc/bounded.rs:1272-1311
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()を経由する。これは一見 tracing だけの呼び出しに見えるが、実際には予算差し引きのマウントポイントの一つである。async_trace_leafの内部ではcoop::poll_proceedのような関数を呼び出す:予算が十分なら1を差し引いてProceedを返す;予算を使い果たしたなら「譲り」アクションを登録する——現在のタスクの Waker をスケジューラに渡し、Pendingを返して、タスクをこの poll で早期終了させる。
これが coop の巧妙さである:予算を使い果たすことはエラーを投げることではなく、「譲り」を普通のPendingに偽装することである。上位の Future はPendingを見て自然に返り、スケジューラはタスクを再びキューに入れ、次にスケジュールされたときには予算はリセットされており、タスクは前回中断したところから続行する。このプロセス全体はビジネスコードに対して完全に透過的である。
yield_nowは予算メカニズムの最も直接的な現れであり、予算を消費せず、能動的に譲りをトリガーする:
📎 tokio/src/task/yield_now.rs:38-60
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())するのではなく、Waker をスケジューラのwakedefer キューに渡している。なぜか?ソースコードのコメントが明確に述べている:もし即座に wake すると、タスクはすぐに実行キューに戻され、I/O/timer ドライバが実行される前に再びポーリングされる可能性があり、譲りの意味が失われる。defer キューのセマンティクスは「現在の worker が ready なタスクを実行し終え、かつドライバをポーリングした後に、これらのタスクを wake する」である。defer キューは worker の
で定義されている:Contextコピー
📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:247-257
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
} 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——タイムアウト0で park し、これが I/O と timer を駆動し、その後 defer 内のタスクを起床させる。これにより「譲った」タスクは必ず駆動が走った後に再スケジュールされることが保証される。
12.3 予算スコープの確立と復元:run_task と block_in_place
予算スコープはrun_taskで確立される。各タスクがポーリングされる際、coop::budgetがポーリングプロセス全体を包む:
📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:691-704
// 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
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 を起床する)では、2つのタスクが無限に互いをスケジュールし、予算が永遠にリセットされ、飢餓問題が依然として残る。予算を共有することで、A と B は合計で最大128回のリソース操作を消費し、その後は必ず譲らなければならない。
LIFO slot 自体にはさらに独立したレートリミッタがあるMAX_LIFO_POLLS_PER_TICK:
📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:756-766
// 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の値は3である:
📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:263-263
/// 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;これは第二の防衛線である:予算がまだ尽きていなくても、LIFO slot が連続して3回優先されると無効化され、後続のタスクは通常のキューに回される。予算は「リソース操作の総量」を管理し、LIFO レートリミットは「同一ペアのタスクが互いを起床させる回数」を管理し、両者は補完的である。
予算スコープにはblock_in_placeに重要な例外がある。block_in_placeは worker core を別のスレッドに引き渡し、現在のスレッドはブロッキング状態に入る。ブロッキングコードは予算の制約を受けないため、必ず一時停止予算を:
📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:406-417
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のDropはブロッキング終了後に復元する:
📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:374-397
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内の同期ブロッキングコードは予算を消費せず、予算枯渇による誤った譲りも発生しない;ブロッキング終了後、タスクは元の残り予算を持って実行を続ける。
次の図は、タスクがスケジュールされてから予算枯渇で譲るまでの完全な制御フローを示している:
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図から2つの譲りパスが見える:予算枯渇時に LIFO タスクをキューに戻す(push_back_or_overflow)、および LIFO 連続優先が上限を超えた時に LIFO slot を無効化する。両方ともメインループに戻り、worker が他のタスクや駆動を処理する機会を得る。
12.4 設計上の考察、エラー回復、本番での落とし穴
なぜ TLS を使い、明示的な引数渡しを使わないのか?予算チェックポイントは channel、I/O、time など各モジュールの深部に散在しており、もし明示的に引数を渡すと、すべての API にBudgetパラメータを追加することになり、公共インターフェース全体を汚染する。TLS は予算をビジネスコードに対して完全に透過的にし、代償として各チェックに TLS アクセスのオーバーヘッドがかかる。Tokio は#[thread_local]またはプラットフォーム固有の高速 TLS を使ってこのオーバーヘッドを抑えている。
予算枯渇とキャンセル安全性の相互作用。予算枯渇によりreserve_innerがPendingを返すとき、タスクはselect!のいずれかの分岐にいる可能性がある。このとき別の分岐が準備完了なら、select!は現在の分岐をキャンセルする——reserve_innerのWakeReceiverOnDropguard は drop 時に「セマフォが閉じられかつアイドル」をチェックし、受信側を起床させる:
📎 tokio/src/sync/mpsc/bounded.rs:1286-1299
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();
}
}
}この guard の存在は、予算トリガーのPendingと真の「許可なし」Pendingがキャンセルパス上で一貫して振る舞わなければならないことを示している。そうでなければ受信側は「channel が閉じられた」通知を永遠に待つ可能性がある。
本番での落とし穴:予算枯渇による隠れた遅延。よくある現象は:あるタスクのメッセージ処理速度が突然遅くなるが、CPU 使用率は高くない。調査時にロック競合や I/O を疑いがちだが、実際にはタスクが単一の poll 内で128件を超えるメッセージを処理し、予算の譲りをトリガーし、譲りのたびに完全な「キューに戻す → 再スケジュール → 駆動ポーリング」サイクルを経ている可能性がある。メッセージ処理自体が速い場合、このスケジューリングオーバーヘッドの割合が高くなることがある。解決策は、大量バッチ処理を複数のspawnタスクに分割するか、ループ内に明示的にyield_now。
予算とblock_in_placeの境界を挿入することである。先に見たようにblock_in_placeはcoop::stop()予算を一時停止する。しかし注意すべきは:coop::stop()はhad_enteredが真のときのみ呼び出され、つまり確かにランタイム worker スレッド上にいるときのみ一時停止する。もしblock_in_placeがランタイム外で呼び出された場合、f()は直接実行され、予算状態は変わらない。この分岐判断はmaybe_move_runtimeで行われる:
📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:424-464
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(());
}
}
// ...
})4つの組み合わせはそれぞれ対応する:worker スレッド内、block_onのスレッドプール入口、ネストされたblock_in_place、ランタイム外。最初の2つのみが予算を一時停止し core を引き渡す必要がある。
予算値は設定不可。ソースコードから見ると、予算満額値はハードコードされた定数(128)であり、公開されていないBuilderオプション。これは意図的である:予算値が影響するのはスケジューリングの公平性とスループットのトレードオフであり、ユーザーが自由に調整できると、「予算が大きすぎて飢餓を引き起こす」または「予算が小さすぎてスケジューリングオーバーヘッドが爆発する」設定を簡単に作れてしまう。Tokio はこれを内部不変条件として選択している。
本章のまとめ
coop メカニズムは三層設計で非プリエンプティブスケジューラの公平性問題を解決する:
1. 予算の保持場所:coop::Budgetは TLS に存在し、Option外層はランタイムの内外を区別し、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 スロットのタスクは親タスクの予算を共有し、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 タスクが予算をリセットするなら、A→B→A→B の ping-pong シナリオでは、各ポーリングで満額の予算を獲得し、二つのタスクは無限に互いをスケジュールし続け、予算枯渇による譲渡が永遠に発生しない。確かにMAX_LIFO_POLLS_PER_TICK = 3のレート制限は 3 回後に LIFO スロットを無効化する📎 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を呼び出すとどのような結果になるか?worker メインループのpark_yield分岐と組み合わせて分析せよ。
参考解析:yield_nowのコメントが理由を説明している:直接 wake するとタスクは即座に実行キューに戻され、I/O/timer ドライバが実行される前に再ポーリングされる可能性がある📎 tokio/src/task/yield_now.rs:49-54。単一 worker シナリオでは、タスクがループ内で繰り返しyield_nowを呼び出し、毎回直接 wake する場合、worker メインループのnext_taskは即座にこのタスクを取得して再ポーリングし、park_yield分岐(I/O と timer の駆動を担当)📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:613-621は永遠に実行されない。なぜなら defer キューは空で、ローカルキューには常にタスクがあるからである。結果として I/O イベントと timer は永遠に処理されず、ランタイム全体が「仮死状態」になる——タスクは動いているが、外部世界のイベントが進行できない。deferキューは譲渡されたタスクがドライバのポーリング後まで起床されないことを保証し、それによってドライバに実行ウィンドウを提供する。
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復元時に誤った値(外層の元の予算ではなく
)に復元される可能性があり、予算が永久に失われ、タスクの以降のすべてのリソース操作が制約を受けなくなる。
次章:第 13 章 →
前章では coop 協調予算を分解しました。各タスクは1回のスケジューリングサイクル内で限られた予算しか持たず、使い切ると必ず譲渡しなければならず、それによって単一タスクが他のタスクを飢えさせることを防ぎます。しかし予算メカニズムは「公平なスケジューリング」問題を解決するだけであり、実際の本番環境にはさらに隠れた罠のカテゴリがあります——キャンセル安全性、panic 伝播、シャットダウン順序です。select! が Future をキャンセルするとき、タスクの panic が捕捉されるとき、Runtime がシャットダウンを開始するとき、コードの境界動作はしばしば直感に反します。本章ではキャンセル安全性から切り込み、まず drop された Future が一体何を失うのかを見ていきます。
13.2 panic 伝播:JoinError がクラッシュをどのように捕捉するか
直感モデル
Tokio タスクの panic はプロセス全体をクラッシュさせません(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 の2つの形態があります。ドキュメントの例は panic シナリオを示しています:
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 が捕捉されるメカニズムはRawTaskの poll パスにあります:タスク poll 時にcatch_unwindでラップし、panic 発生後に payload をタスクの出力スロットに保存し、状態を complete にマークしてから join waker を起こします。JoinHandle::pollを通じてtry_read_outputで読み取られるのはErr(JoinError::panic(payload))。
シナリオ駆動の Walkthrough:panic 伝播チェーン
sequenceDiagram
participant App as 应用任务
participant Worker as Worker 线程
participant Raw as RawTask
participant JH as JoinHandle
App->>Worker: spawn(async { panic!("boom") })
Worker->>Raw: poll 任务 Future
Raw->>Raw: catch_unwind 捕获 panic
Raw->>Raw: 存储 panic payload 到输出槽
Raw->>Raw: state 标记 complete
Raw->>JH: 唤醒 join waker
JH->>App: await 返回 Err(JoinError::panic)重要な点:panic の payload は完全に保持され、JoinErrorはstd::error::Errorを実装しており、into_panic()を通じてBox<dyn Any + Send>を取り出し、さらにdowncast_ref::<&str>()で panic メッセージを抽出できます。
設計上の考察と落とし穴
落とし穴 1:JoinHandleのUnwindSafeは手動で実装されています。
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 を spawn し、B が panic した場合、A が B のJoinHandleを await しない限り、A は自動的に通知を受け取りません。A が await しなければ、B の panic は静かに飲み込まれます。これは本番環境で最も隠れたバグ源の一つです。
落とし穴 3:spawn_blockingの panic も同様に捕捉されます。ブロッキングスレッドプールの worker もcatch_unwindでタスクをラップし、panic 後もスレッドは死なず、プールに戻って作業を続けます。しかしブロッキングタスク内でMutexを保持し、panic 時に解放しなければ、ロックポイズニングを引き起こします——これはstd::sync::Mutexの固有の動作であり、Tokio は介入しません。
落とし穴 4:Runtime drop 時の panic。Runtime drop プロセス中にタスクが panic した場合、catch_unwindは依然として有効ですが、この時点で join waker がすでに無効になっている可能性があり、panic payload は破棄されます。これはシャットダウン順序問題のサブセットであり、次節で展開します。
13.3 シャットダウン順序:ブロッキングスレッドと I/O リソースのクリーンアップ
直感モデル
Runtime のシャットダウンはレストランの閉店のようなものです:まずフロントが客の受け入れを停止し(新規タスクの受付停止)、次にキッチンが手元の料理を完成させるのを待ち(非同期タスクが次の yield ポイントまで実行)、最後に外注のヘルパーが作業を終えるのを待ちます(ブロッキングスレッドの復帰)。順序を間違えると問題が発生します——例えば先にヘルパーを追い出すと、キッチンの料理は永遠に完成しません。
データ構造とシャットダウンパス
Runtimeの3つのフィールドがシャットダウン順序を決定します:
pub struct Runtime {
scheduler: Scheduler,
handle: Handle,
blocking_pool: BlockingPool,
}📎 tokio/src/runtime/runtime.rs:97-106
Drop実装:
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しかし
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ブロッキングプールシャットダウンの低レベルメカニズム
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が通知を受け取ります。
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。
ブロッキング領域への進入を試みます。現在非同期コンテキスト内にある場合(例えば async タスク内で Runtime を drop する場合)、
を返します。block_on_timeout3. 進入失敗時、panic 中であれば false を返します(panic 中にさらに panic しない);そうでなければ panic し、明確なエラーメッセージを出します。
4. timeout がある場合は
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["handle 字段 drop"]
mt --> handle_drop
handle_drop --> bp_drop["blocking_pool 字段 drop"]
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 等待所有 Sender drop"]シャットダウン順序の完全なフロー
コピーエラーメッセージは明確です:「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 はすでに drop されているため、それらが保持するリソースはすでに無効になっている可能性があります。
落とし穴 3:I/O リソースは Runtime drop 後に無効になります。ドキュメントは説明しています「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。ブロッキングタスクがスタックした場合(例えば無限ループ)、drop Runtime は永久にハングします。本番環境ではshutdown_timeoutで上限を設定すべきです。
13.4 シグナル処理とマルチ Runtime の競合
直感的モデル
Unix シグナルはプロセスレベルですが、Tokio のSignalは Runtime にバインドされています。これは建物全体で一つの火災警報ベルを共有しているのに、各部屋が独立した受信機を設置しているようなものです——最初に受信機を設置した人がベルの配線方法を変え、後から来た人はその変更を共有するしかありません。
データ構造とグローバル状態
signal_enableはシグナルハンドラを登録するエントリポイントです:
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 に 1 バイト書き込んでドライバを起動します📎 tokio/src/signal/unix.rs:252-259。
マルチ Runtime 競合の根源
globals()が返すのはプロセスレベルのグローバルなGlobals,OsExtraData内のUnixStreamペアもグローバルです:
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 シグナル競合
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])
Pipe->>RtA: 可读事件
Pipe->>RtB: 可读事件
Note over RtA,RtB: 两个 Runtime 竞争读取,只有一个能读到字节設計上の考察と落とし穴
落とし穴 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インスタンスが drop されても、後続のシグナルは依然として 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 を受信しても 1 回しか poll しなかった場合、1 つのイベントしか見えません。これは Unix シグナル自体の特性です(標準シグナルはキューに入らない)。Tokio は追加の合併を行いません。
落とし穴 3:マルチ Runtime 下でシグナルが失われる可能性があります。グローバル pipe が複数の Runtime によって競合して読み取られるため、ある Runtime がバイトを読み取り、別の Runtime が永遠に待つ可能性があります。本番環境では一つの 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。Runtime 外でsignal()を呼び出すと 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、順序を間違えるとデッドロックやpanicが発生します。 - シグナルはマルチRuntimeで競合します。なぜならhandlerとpipeはプロセスレベルのグローバル状態であり、
SignalはRuntimeレベルのビューだからです。
このパターンを理解すれば、落とし穴回避リストは三つの原則にまとめられます:
1. キャンセル安全 = 状態がFutureの外部にある。Futureの内部にバッファがあると、dropでデータが失われます。JoinHandle、Signal::recv、tokio::sync::mpsc::Receiver::recvはすべてこの条件を満たしています。
2. シャットダウン順序 = 依存方向の逆順。誰が誰に依存していても、依存される側を先に閉じます。スケジューラはI/Oドライバに依存しているので、スケジューラを先に閉じます。ブロッキングプールは独立しているので、最後に閉じます。
3. グローバル状態 = マルチインスタンスの競合。プロセスレベルのリソース(シグナルhandler、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展開中に再度panicするとプロセスがabort(double panic)するからです。もし待機を続けるように変更すると、block_onを呼び出す必要がありますが、非同期コンテキストではblock_onがpanicを起こします——panic展開中のpanicはプロセスを直接abortし、全ての診断情報を失います。falseを返すことでdropが完了を続け、panic情報が保持されます。これは「優雅なデグレード」の設計です:不完全なシャットダウンもプロセスクラッシュよりはマシです。
Q3:Runtime AでSignalを作成してSIGTERMを監視し、その後SignalをRuntime Bに移動してpollするとします。signal_enable内のhandle.check_inner()はどのRuntimeをチェックするでしょうか?もしRuntime Aが先にdropされた場合、Runtime BのSignalはまだシグナルを受信できるでしょうか?
参考解説:signal_enableはsignal()呼び出し時に実行され、この時handleはRuntime Aの📎 tokio/src/signal/unix.rs:398-405。check_inner()です。📎 tokio/src/signal/unix.rs:275。SignalがチェックするのはRuntime Aのシグナルドライバです。RxFutureの内部はwatch::Receiver<()> 📎 tokio/src/signal/unix.rs:366-368であり、ラップしているのはGlobalsです。このreceiverはグローバルEventInfoのrecord_eventに登録されています。もしRuntime Aがdropされると、そのシグナルドライバはグローバルpipeからのデータ読み取りを停止しますが、グローバルhandlerは依然としてEventInfoしてpipeに書き込みます。Runtime Bのシグナルドライバも実行中であれば、pipeデータを読み取りSignalをトリガーし、Signal のwakerを起床させます。したがってRuntime BのはおそらくSignalまだシグナルを受信できますが、Runtime Bにシグナルドライバが実行中かどうかに依存します。もしRuntime Bにシグナルドライバがなければ(例えばsignal featureが有効でない、またはドライバが閉じられている)、pipeデータを読む者がおらず、
は永遠に起床を待つことになります。これがマルチRuntimeシグナル処理の脆弱性です。
章末の橋渡しcatch_unwindキャンセル安全、panic伝播、シャットダウン順序、シグナル競合——これら四つの問題の共通の根源は「状態の所有権」が非同期境界上で曖昧であることです。Tokioは状態をヒープに置き、参照カウントでライフサイクルを管理し、Globalsでpanicを隔離し、グローバル
でシグナル状態を共有することで、エンジニアリング上使用可能な答えを提示しています。しかしこれらの答えにはすべて境界条件があり、本番環境では明示的に処理する必要があります。
ここまでで、私たちはTokioの本番環境で最もつまずきやすい境界地帯を歩き切った。キャンセル安全が依存する出力がヒープ上に保存されること、try_read_outputの原子性、JoinHandle::dropはタスクをキャンセルせず、abortだけが実際にキャンセルするがspawn_blockingには無効であること、panicがcatch_unwindに捕捉された後にJoinErrorとしてパッケージ化され、awaitしなければ静かに失われること、Runtimeのシャットダウンには厳密な順序があり、asyncコンテキストでdropするとpanicすること、シグナルハンドラはプロセスレベルのグローバル状態であり、登録後は決してアンロードされないこと。これらのルールの背後には、Tokioが正確性と性能の間で繰り返してきたトレードオフがある。次の章では、具体的なメカニズムから離れ、アーキテクチャの高みに立ってこれらのトレードオフの由来を振り返り、io_uring、ドライバの再構築、カスタムエグゼキュータインターフェースがTokioをどこへ導くのかを展望する。
第14章:アーキテクチャのトレードオフと将来の進化:io_uringからプラガブルドライバまで
前章では、キャンセル安全、panicの伝播、シャットダウン順序、シグナル競合という4種類の本番の落とし穴を整理した。それらは一見ばらばらに見えるが、実はすべて同じアーキテクチャ上の問題を指している。すなわち、状態の所有権が非同期の境界上でいかに明確に分割されるか、である。そして所有権の分割の仕方は、まさにランタイムの最下層にある3つのアーキテクチャ上の決定によって決まる——タスクがどのようにスケジュールされるか、I/Oイベントがどのようにディスパッチされるか、並行性の正確性がどのように検証されるか。本章ではもはや特定の関数の実装詳細に踏み込まず、アーキテクチャの高みに立って、Tokioがこれらの決定においてどのような取捨選択をしてきたかを振り返り、公式ドキュメントとソースコードにすでに埋め込まれている進化の手がかりに沿って、io_uring、ドライバの再構築、カスタムエグゼキュータインターフェースがTokioをどこへ導くのかを見ていく。本章を読み終えれば、あなたは一つの実践的な問いに答えられるはずだ。いつTokioを拡張すべきか、いつそれを回避すべきか。
一、三つの歴史的トレードオフ:なぜ今の姿なのか
直感モデル
Tokioを、すでに10年営業しているレストランだと想像してほしい。厨房のシフトの組み方(work-stealing)、配膳係の独立した編成(I/Oドライバとスケジューラの分離)、そして厨房の衛生検査制度(loomによる並行性検証)は、いずれも開業初日に設計されたものではなく、「客が増え、料理が複雑になる」過程で徐々に進化してきたものである。これらの進化を理解してこそ、どの設計が先を見据えた布石で、どの設計が歴史的な負債なのかを判断できる。
トレードオフ一:グローバルキューではなくwork-stealing
グローバルキューの実装は最も単純である。すべてのタスクが一つのMutex<VecDeque>に入り、workerスレッドがロックを奪い合ってタスクを取る。しかしロック競合はコア数の増加とともに悪化し、キャッシュ局所性も悪い——タスクがどのコアで生成され、どのコアで実行されるかは完全にランダムである。
work-stealingの取捨選択はこうである。各workerがローカルキューを持ち、spawn時には優先的にローカルキューに入れる(ロックフリー、キャッシュフレンドリー)。ローカルが空になって初めて他のworkerのキューの末尾から窃取する。代償は負荷分散に遅延があり、窃取自体にアトミック操作とメモリバリアが必要なことである。Tokioが後者を選んだのは、現代のサーバーが数十コアにもなるため、ロック競合のコストが偶発的な窃取のオーバーヘッドよりもはるかに高いからである。
この決定の境界条件は、タスクの粒度が細かすぎてはならないことである。もし各タスクが数マイクロ秒の仕事しかしないなら、窃取とスケジューリングのオーバーヘッドの割合が制御不能になる。これが、Tokioがspawn_blocking之外,还要求长任务主动yield_now()——協調的スケジューリングは本質的にwork-stealingを下支えしているのである。
トレードオフ二:I/Oドライバがスケジューラから独立
これは本章のソースコード資料の中で最も味わい深い箇所である。tokio/src/runtime/io/mod.rsのモジュール構造を見てみよう。
📎 tokio/src/runtime/io/mod.rs:5-22
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
//! 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))]という条件にある。testとloomの二つのcfgを同時に有効にした時だけ、mockedモジュールでstdを置き換える。これは、本番ビルドにはloomのコードがまったく存在せず、ランタイムオーバーヘッドがゼロであることを意味する。
loomの価値は、「スレッドのインターリーブのすべての可能な順序」を網羅的に列挙できることにある。ScheduledIoの中のAtomicUsizeの読み書き、Waiters連結リストの挿入と削除は、実際のハードウェアでは100万回実行してもエラーが出ないかもしれないが、loomは数秒で競合状態を引き起こすインターリーブを構築できる。代償としてテストの実行が遅く、メモリ使用量が高いため、ユニットテストにのみ使用でき、本番環境には投入できない。
設計上の考察
これら3つのトレードオフには共通の特徴がある:それらはすべて「より複雑だがより拡張可能」な方案を選択し、複雑さを内部に限定している。work-stealingの複雑さはスケジューラに隠され、I/O駆動の複雑さはScheduledIoに隠され、loomの複雑さはcfg条件に隠されている。外部に公開されるAPIは常にspawn、TcpStream::readこれらのシンプルなインターフェースである。
これもまた「いつTokioを拡張すべきか」を判断する第一の基準である:もしあなたの要件が既存のAPIで表現できるなら、内部構造に触れてはならない。一度pub(crate)の型やtokio_unstableのcfgに依存し始めたら、それは自分をTokioの内部実装に縛り付けたことを意味し、アップグレード時に代償を払うことになる。
---
二、ドライバのリファクタリング:「1つのwakerに1つの方向」から「任意の関心セット」へ
直感的モデル
初期のTokio I/O型には厳しい制限があった:async fn read(&mut self)には&mut selfが必要である。これはレストランに1つの受け取り窓口しかなく、同時に1人しか並べないようなものである——wakerが操作に対応するFutureではなく、I/Oリソースの内部に保存されていたからだ。tokio/docs/reactor-refactor.mdはこの制限の原因とリファクタリング方案を完全に記録している。
旧アーキテクチャの痛点
ドキュメントは冒頭で問題を指摘している:
📎 tokio/docs/reactor-refactor.md:16-20
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をリソース内部に保存することは、「1つの方向に1つの待機者しか持てない」ことを意味する。もし同じTcpStreamを同時に読み書きしたいなら、split()を2つに分割し、それぞれが独立したwakerスロットを持つ必要がある。これがTcpStream::split()が存在する理由である——それはAPI設計の好みではなく、内部データ構造の直接的な制約なのだ。
新アーキテクチャ:wakerをFuture内に移動する
リファクタリングの核心的な考え方は「wakerをリソース状態から操作Future内に移動する」ことであり、これにより各操作が複数のwakerを登録できるようになる:
📎 tokio/docs/reactor-refactor.md:22-25
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
#[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>。なぜ両方を1つのロックで保護しないのか?なぜならreadinessの読み取り操作は極めて頻繁であり(毎回のreadiness()呼び出しでチェックが必要)、書き込み操作はmioイベントを受信した時にのみ発生するからである。アトミック変数を使って読み取りパスをロックフリーにするのは、典型的な読み書き分離の最適化である。
第二に、Waiterは侵入型連結リストのノードである。 pointers: linked_list::Pointers<Waiter>によりWaiter自体が連結リストの一部となり、追加のノード割り当てが不要になる。_p: PhantomPinnedはそれがUnpin不可であることを明確にマークしている——侵入型連結リストのノードアドレスは一度移動すると、連結リストが切れてしまうからである。
第三に、readerとwriterの2つのOption<Waker>はAsyncRead/AsyncWriteのためにある。ドキュメントはその理由を説明している:
📎 tokio/docs/reactor-refactor.md:210-213
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.これは新旧2つのメカニズムの妥協的な共存である:async fnパスは侵入型連結リストを使用し(複数の待機者をサポート、キャンセル可能)、pollパスは固定スロットを使用する(キャンセル非サポート、ただしtraitと互換)。この「2つのメカニズムの並存」は漸進的リファクタリングの典型的な代償である。
競合状態とtickメカニズム
リファクタリングで最も厄介な問題は競合である。ドキュメントは具体的なデッドロックシナリオを示している:
📎 tokio/docs/reactor-refactor.md:175-175
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
| shutdown | generation | driver tick | readiness |
|----------+------------+--------------+-----------|
| 1 bit | 7 bits + 8 bits + 16 bits |このビットセグメントのレイアウトは「空間と引き換えに正確性を得る」古典的な事例である。tickは毎回mio::poll()インクリメントされ、ReadyEventは読み取り時のtickを保持する。clear_readiness()はtickが一致する時のみレディ状態をクリアする——もしtickが一致しなければ、その間に新しいイベントが到着したことを意味し、クリアしてはならない。これにより「クリア」と「新イベント到着」の競合を1つのアトミックな読み取り・変更・書き込みに解消している。
以下のフローチャートはreadiness()とclear_readiness()の間の決定パスを描写している:
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
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()のFutureはDrop実装内で自分自身を連結リストから摘除しなければならない。そうでなければノードは永久にScheduledIoに残り、メモリをリークするだけでなく、次にイベントが到着した時に誤って起床させられる。
設計上の考察と本番環境の落とし穴
なぜVec<Waker>を使わずに侵入型連結リストを使うのか?ドキュメントは&Resource実装を議論する際に答えを与えている:
📎 tokio/docs/reactor-refactor.md:228-233
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>の問題点は:Futureがdropされた後、対応するwakerがVec内に残り、位置を特定して削除できず、次にイベントが到着した時に初めて「このwakerはすでに無効である」と発見できることである。侵入型連結リストではノードアドレスがFuture内部フィールドのアドレスそのものになるため、drop時に正確に摘除できる。
本番環境の落とし穴:TcpStream::by_ref()が返すTcpStreamRefはread_waiterとwrite_waiterの2つのノードを保持する:
📎 tokio/docs/reactor-refactor.md:238-244
struct TcpStreamRef<'a> {
stream: &'a TcpStream,
// `Waiter` is the node in the intrusive waiter linked-list
read_waiter: Waiter,
write_waiter: Waiter,
}これはTcpStreamRefが一度dropされると、2つのwaiterノードが同時に無効になることを意味する。もしselect!内でby_ref()の参照を分岐をまたいで共有するなら、ライフタイムに注意が必要である——TcpStreamRefはTcpStreamより長く生きることはできず、複数のselect!分岐間で同時に借用されることもできない。
---
三、カスタムエグゼキュータ:TokioContextと「Tokioを迂回する」境界
直感モデル
Tokioのスケジューラを使わず、そのI/Oとタイマーだけを借りたいことがある。これはレストランで店内飲食せず、テイクアウト窓口だけを使うようなものだ。examples/custom-executor.rsこの「ハイブリッドモード」を示している:futures::executor::ThreadPoolでスケジューリングを行い、TokioでI/Oを行う。
核心メカニズム:TokioContext
この例全体の鍵はTokioContextというラッパー型にある:
📎 examples/custom-executor.rs:51-54
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とTokioのHandleを結びつける。外部エグゼキュータがこのラップされたFutureをpollすると、TokioContextはまずTokioのランタイムコンテキストに入り(スレッドローカルのHandleを設定)、次に内部のfをpollする。こうしてf内でTcpListener::bindを呼び出すと、TokioのI/Oドライバを見つけられる。
例全体の構造を見てみよう:
📎 examples/custom-executor.rs:38-48
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 }
});ここではTokioランタイムが作成されるが、によってblock_on駆動されない——それはただ「存在」し、I/Oドライバとタイマーを提供する。実際のタスクスケジューリングはfutures::executor::ThreadPoolが担当する。このモードでは、Tokioのワーカースレッドは実質的に空回りしており(I/Oイベントを待機)、タスク実行はfuturesのスレッドプールで行われる。
データフロー:TcpListener::bindのエグゼキュータを跨ぐ旅
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: 通过 Handle 访问 I/O 驱动
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. スケジューリング戦略を完全に制御する必要がある場合(例えばリアルタイムシステムが決定論的スケジューリングを要求する場合)、Tokioのwork-stealingは要件を満たさないが、そのI/Oドライバは依然として使用可能である。
3. 単にTokioのAPIが複雑だと感じるだけなら、迂回すべきではない——TokioContextが導入するエグゼキュータを跨ぐ境界は新たなデバッグの難しさをもたらし、割に合わない。
本番環境での落とし穴:TokioContextモードでは、Tokioランタイムのblock_onが決して呼び出されない。つまりRuntime::shutdownのクリーンアップロジックが自動的にトリガーされない。プログラム終了前に明示的にRuntimeをdropしなければならない。そうでなければI/Oドライバのバックグラウンドスレッドが優雅にシャットダウンされない可能性がある。
io_uringとの関係
tokio/src/runtime/io/mod.rs冒頭のcfg条件がio_uringの接続方法を明かしている:
📎 tokio/src/runtime/io/mod.rs:1-4
#![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を完了して結果を返す。これはTokioのScheduledIoモデルに大きな衝撃を与える——readiness()のセマンティクスはio_uringではもはや適用できず、全く新しい「submit-complete」抽象化が必要となる。これがio_uringサポートがなかなかunstableに留まっている理由でもある:単にバックエンドを追加するだけではなく、I/Oドライバの抽象化レイヤー全体を再構築する必要があるからだ。
---
本章のまとめ
本章ではアーキテクチャの高みからTokioの三つの核心的トレードオフを振り返り、三つの進化パスを展望した:
歴史的トレードオフ:
- work-stealingはスケジューリングの複雑さと引き換えにマルチコア拡張性を得る。境界はタスク粒度が細かすぎてはならないこと;
- I/Oドライバはスケジューラから独立し、
block_onとマルチスレッドランタイムが同じI/O実装を再利用できるようにする; - loomはcfg条件を通じて本番ビルドでは完全に消え、テスト時のみスレッドインターリーブを網羅する。
ドライバの再構築(reactor-refactor.md):
- wakerを
ScheduledIo内部から操作Futureへ移動し、侵入型リンクリストで複数の待機者をサポート; AtomicUsizeのビットフィールドレイアウト(shutdown/generation/tick/readiness)でclear_readinessの競合状態を解消;AsyncRead/AsyncWritereader/writerはpollセマンティクスにより侵入型リンクリストが使えないため、
固定スロットを妥協として維持。:
- 将来の進化
tokio_unstableio_uringは「submit-complete」の新しい抽象化が必要で、現在 TokioContextで保護されている;
はI/Oドライバのみの使用を許容し、スケジューラを使わないが、Runtimeのライフサイクルを手動管理する必要がある;
「拡張か迂回か」の判断基準:既存のAPIで表現できるなら内部構造に触れない。ScheduledIo本章の考察とセルフチェックreadinessQ1:tickのclear_readinessビットフィールドレイアウトにおいて、
フィールドを8ビットから4ビットに縮小した場合、どのようなシナリオでエラーが発生するか?:tickのtickマッチングロジックと合わせて分析せよ。mio::poll()参考解析📎 tokio/docs/reactor-refactor.md:185-185。clear_readinessevent.tick == 当前 readiness.tickは毎回の📎 tokio/docs/reactor-refactor.md:199-199時にReadyEventをインクリメントし、clear_readiness以前、mio はさらに 1 回 poll し、tick は 0 にラップアラウンドした。このときclear_readinesstick の不一致(15 != 0)を検出し、誤ってクリアをスキップする——しかし実際にはその間に新しいイベントは到着しておらず、単に tick がラップアラウンドしただけかもしれない。これによりレディビットが永久に保持され、以降readiness()は即座に戻るがreadは依然としてWouldBlockとなり、ビジーループに陥る。8 ビットの tick は通常負荷では十分である(256 回の poll 以内に read-clear サイクルが 1 回完了する)が、極端な高並行下では依然としてラップアラウンドのリスクがあり、これはビットフィールド配置の固有の限界である。
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 されると、Future 内の I/O 操作が panic する(ランタイムコンテキストが見つからない)ことである。本番環境では、すべてのTokioContextFuture が完了してから Runtime を drop することを必ず保証しなければならない。
Q3: Tokio に io_uring ベースの I/O バックエンドを追加すると仮定する。reactor-refactor.mdにおけるreadiness()のセマンティクスに基づき、どの部分がそのまま再利用でき、どの部分を書き直す必要があるか?
参考解析:そのまま再利用できるのはRegistrationの登録インターフェースとScheduledIoのwaiters連結リスト構造である——これらが管理するのは「誰が待っているか」であり、基盤が epoll か io_uring かには関係ない。書き直す必要があるのはreadiness()のセマンティクスである:epoll では「fd がレディ」を返すが、io_uring には「レディ」という概念がなく、「提出された SQE の完了」のみが存在する。clear_readinessの tick 機構も再設計が必要である——io_uring の完了イベントは user_data 識別子を備えているため、新旧イベントを区別するための tick は不要である。最も根本的な変更は:readiness()が返す Future は io_uring では「SQE を提出し CQE を待つ」に変わるべきであり、これはWaiter構造がinterestだけでなく SQE パラメータを保持する必要があることを意味する。これが io_uring のサポートがtokio_unstableによって保護される📎 tokio/src/runtime/io/mod.rs:1-4理由でもある——それはバックエンドの置き換えではなく、I/O ドライバの抽象契約を変更するのである。
ここまでで、具体的な落とし穴からアーキテクチャのトレードオフへの登りを完了した。本書全体を振り返ると、Future の遅延評価からスケジューラの公平性、キャンセル安全性からシャットダウン順序、そして本章の io_uring とプラガブルドライバに至るまで、すべての議論は一つの核心を巡っている:非同期境界において状態の所有権を明確に区分することである。Tokio のアーキテクチャは不変ではなく、io_uring のゼロコピー I/O、ドライバ層の分離、カスタムエグゼキュータインターフェースの開放が、それをより柔軟で効率的な方向へと進化させている。この本を閉じたとき、残るのが API の使い方の山ではなく、判断力のセットであることを願う:いつランタイムを信頼すべきか、いつ低層に介入すべきか、そして本番環境で人を噛むような組み合わせをどう避けるか。非同期 Rust のエコシステムは依然として急速に成長しており、ソースコードと公式ドキュメントを追跡し続けることが、どんな結論を覚えるよりも重要である。
どんなに複雑なプロジェクトも、実は一冊の良い本で読解できる
この『Tokio ソースコード深層解説:Future から本番級非同期ランタイムまで』は、AiReadCode が公式オープンソースリポジトリをスキャンして全自動で編纂したものである。 数十万行の大規模オープンソースの名作であれ、企業内部の複雑なプロジェクトであれ、同じように筋道立った専属の専門書をワンクリックで生成できる。