レッスン2 — Tokio ランタイム内部
問い
#[tokio::main] を書き、あらゆる async 呼び出しに .await を散りばめてきた。Reth のコードベースには 200 以上の .await が散らばっていて、ピーク負荷では数千の peer 接続と数十のバックグラウンドタスクを 8 ワーカースレッド でさばいている。.await の真下に何があるか?
原理(最小モデル)
async fn→ コンパイラ生成のステートマシン →Futuretrait を実装. Executor がPoll::Ready(value)までpoll()を呼び続ける。- Work-stealing スケジューラ. 各ワーカーにローカルキュー(競合なし)+ グローバルキュー(フォールバック)+ 空いたワーカーは隣から「盗む」。
spawn_blockingを CPU 重い処理に使う. async コンテキストで重い同期処理を直接呼ぶとランタイムが飢えてノード全体が止まる。- チャネルは用途で選ぶ. mpsc(多生産 1 消費)/ broadcast(チェーンイベント)/ watch(最新値)/ oneshot(リクエスト・レスポンス)。
- Reth は
TaskExecutorで Tokio を包み「パニック監視」を追加.spawn_task(静かに死ぬ)vsspawn_critical_task(パニック → ノード停止 + タスク名ログ)。
具体例
ランタイムスタック:
+--------------------+
| 非同期コード | ← Future
+--------------------+
| Executor | ← Future を完了までpoll
| (work-stealing) |
+--------------------+
| I/O | ← epoll / kqueue / io_uring
+--------------------+
Work-stealing:
ワーカー A: [task1, task2, task3, task4] ← 忙しい
ワーカー B: [] ← アイドル、Aから盗む
ワーカー A: [task1, task2]
ワーカー B: [task3, task4]
spawn vs blocking:
// 並列実行:ランタイムにspawn
let h1 = tokio::spawn(async { fetch().await });
let h2 = tokio::spawn(async { fetch().await });
let (r1, r2) = (h1.await?, h2.await?);
// CPU重い処理:非同期ワーカーに乗せない
tokio::task::spawn_blocking(|| {
expensive_sync_calc() // 別のスレッドプールで動く
}).await?
チャネル選定:
| チャネル | 用途 |
|---|---|
tokio::sync::mpsc | 多生産者・1 消費者 |
tokio::sync::broadcast | 1 生産者・多消費者(例:チェーンイベント) |
tokio::sync::watch | 最新値ブロードキャスト(例:最新ブロック) |
tokio::sync::oneshot | 単一値、リクエスト/レスポンス |
カスタム Future poll:
use std::pin::Pin;
use std::task::{Context, Poll, Waker};
use std::future::Future;
let mut fut = Box::pin(my_async_fn());
let waker = Waker::noop();
let mut cx = Context::from_waker(&waker);
match fut.as_mut().poll(&mut cx) {
Poll::Ready(v) => /* 完了 */,
Poll::Pending => /* まだ — Wakerが起こすときに再poll */,
}
Reth の TaskExecutor(crates/tasks/src/runtime.rs):
pub fn spawn_task<F>(&self, fut: F) -> JoinHandle<()>
where
F: Future<Output = ()> + Send + 'static,
pub fn spawn_critical_task<F>(&self, name: &'static str, fut: F) -> JoinHandle<()>
where
F: Future<Output = ()> + Send + 'static,
失敗例(誤解)
「spawn_blocking なしで CPU 重い処理を async fn で呼んでも動く」— 動くが本番で詰まる。ランタイムワーカーが飢えて、症状は「クラッシュではなく、ノード全体が静かに減速」。Prometheus で task queue 深さが膨らみ、p99 が爆発する。
「spawn_task で起動すれば長期タスクが安全」— 間違い。spawn_task のパニックは静かに消える(Tokio デフォルト)。pruner 検証などクリティカルなタスクは spawn_critical_task で「大声で失敗」させる。
「ExEx は mpsc で十分」— 間違い。3 つの ExEx を登録すると mpsc では 1 つだけがイベントを受け取り他は飢える(1 消費者前提)。チェーンイベントは broadcast(全 ExEx が全イベント受信)。
🛑 予測。
async fn foo() { bar().await; }を書く。コンパイラは何を生成する?(① 結果の型が実装する trait、② 通常関数呼び出しと比較した実行時コスト、③ await 点を跨いだローカル変数の置き場所)。(答え: ①Futuretrait 実装のステートマシン struct、② アロケーション 1 回 + 各 poll で state 進める(マイクロコスト、ホット)、③ ステートマシン struct のフィールド(スタックではなく struct に saved state として)。)
ステップで組み立てる
Step 1: ステートマシンの正体を言える
async fn → コンパイラがステートマシン struct を生成 → Future trait 実装。Executor が poll() を Poll::Ready まで呼ぶ。
Step 2: work-stealing の理由
ローカルキュー = 競合なし、グローバルキュー = フォールバック、stealing = 負荷分散。シングルキュー + グローバル mutex のホット競合を回避。
Step 3: チャネル選定の判断軸
「何個の生産者、何個の消費者、最新値で十分か全イベント必要か」で 4 つに分岐。ExEx チェーンイベント = broadcast、設定変更通知 = watch、ジョブキュー = mpsc、RPC レスポンス = oneshot。
Step 4: TaskExecutor の規律
「静かに死ぬ」= spawn_task、「大声で失敗 → ノード停止」= spawn_critical_task。インフラでは大声 > 静か — 劣化状態でノードが動き続けるのを防ぐ。
答え合わせ
- work-stealing なしの代替: 単一共有キュー + グローバル mutex。8 ワーカーが mutex 競合 = 性能崩壊。stealing は競合を「忙しいワーカー全員」から「アイドルワーカーが盗みに来た瞬間」だけに減らす。
- CPU 重い処理を async で呼んだときの本番症状: クラッシュではなく task queue が詰まる。Prometheus で「task scheduling latency」「queue depth」が単調増加、p99 が爆発、新しいリクエストが処理されなくなる。oncall は「ノードが動いてるのに遅い」を発見、原因究明に数時間。
- ExEx + mpsc の失敗モード: mpsc は 1 消費者前提なので 3 ExEx 登録時、最初の 1 つだけがイベントを受け取り他は飢える。broadcast なら全 ExEx が全イベント受信、ExEx 数に依存せず動く。
合格基準
- async fn が生成する 3 要素(Future 実装 + ステートマシン + await 点のフィールド化)を言える。
- work-stealing がグローバル mutex を回避する仕組みを説明できる。
- 4 種チャネルを用途で即答できる。
- spawn_task / spawn_critical_task の使い分けと「大声で失敗」原則を言える。
まとめ(3行)
- Tokio = コンパイラ生成のステートマシンを work-stealing スケジューラで poll する仕組み。
.awaitは魔法ではなくステートマシン進行。 - CPU 重い処理は
spawn_blocking、チャネルは用途で 4 種から選ぶ、Reth は TaskExecutor でパニック監視を追加。 - 「大声で失敗 > 静かに死ぬ」原則 — クリティカルタスクは
spawn_critical_taskでノード停止 + タスク名ログ。