
2026/07/28 0:10
東京進捗報告:100 万タスクのスケジュール化ではなく、注文ではない
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
この記事は、「Rust 製サービスがメモリリークしているわけではない。むしろ、アロケータの問題かもしれない」という記事の前編として機能し、Kafka、Redis Streams、NATS からデータを読み取るイベント駆動型の Tokio サービスを分析する。アプリケーションは各イベントトークンごとにタスクを生成しており(1 つのイベントあたり最大 1000 タスク)、
JoinSet を用いて応答を収集していたが、並列タスク数の上限を強制しなかった。約 1000 のイベント(合計約 1M のタスク)が集中して発生した際、固定サイズのワーカースレッドを持ち、256 キャパシティのローカルキューとグローバルオーバーフローキューを使用するマルチスレッドスケジューラーである Tokio がスケジューリング遅延を示した。スケジューラは、生成された順序とは異なるタイミングでポーリングを行うことが多く、これはランタイムがタスク総数が有界な場合にのみ公平性を保証するためである。しかし、このアプリケーションの設計ではそのようなバウンドが存在しなかった。メモリ使用量は、リークではなく、状態を保持する生きたタスクの純粋に多数によって急激に増加した。核心的な教訓は、タスクの作成が直ちにポーリングや完了を意味するわけではないこと、また明示的なバウンドがない限り順序が保たれないことである。エンジニアたちは、この問題を解決するために Semaphore を導入してイベント処理の並列性を制限し、イベントレベルの公平性を達成するとともにピークメモリ使用量を大幅に削減しつつも元のスループットを維持することに成功した。この問題はランタイムのバグではなく、Tokio のスケジューリング保証に対する誤解が原因であり、高スループットシステムにおけるファンアウトパターンを開発者が能動的に制限する必要性を強調している。本文
Rust アプリケーションでのメモリーリークの原因:アロケーターとタスクの生命周期
前回の投稿では、特定のメモリ・アロケーターの振る舞いについて触れました。以前はアプリケーション側の最適化を試みましたが、根本的な原因はアロケーター(タスクの寿命管理)にありました。
当サービスはイベント駆動型アーキテクチャを採用しており、以下のフローで動作していました:
- Kafka、Redis Streams、NATS などのメッセージキューからイベントを読み取る
- 各イベントに対して
で新しいタスクを生成し、処理を実行するtokio::spawn
ワークロードの課題:Fan-out と Fan-in
各イベントには最大 1000 ユーザー・トークン が含まれており、すべてのトークンに対して外部 API を呼び出して結果を集約する必要があるため、非同期処理が必須でした。
以下は簡略化したコード例です:
struct Event { payload: Bytes, // およそ 4KB user_tokens: Vec<String>, // 最大 1000 トークン // 他のフィールド } // メインループ内 loop { let event: Event = fetch_next_event().await; tokio::spawn(async move { let data = event.payload.clone(); let mut tasks = JoinSet::new(); for token in &event.user_tokens { let token = token.clone(); let data = data.clone(); tasks.spawn(async move { // 外部 API を呼び出し process(token, data).await }); } // 結果を集約 let mut responses = Vec::with_capacity(event.user_tokens.len()); while let Some(res) = tasks.join_next().await { responses.push(res); } generate_response_event(event, responses); }); }
当初の目標はシンプルでした:「すべての外部呼び出しを可能な限り速く行い、バースト(大量発生)時間を満たすこと」。スループットが最優先であり、タスク間の完了順序や遅れには配慮していなかったためです。
ログから見える異常な振る舞い
100 万個のタスクを生成したバースト時のログを見ると、以下のような現象が確認できました:
started: event 1, user 5 started: event 1, user 8 ... finished: event 779 <-- 早期イベントが完了 finished: event 976 ... started: event 900, user 42 started: event 900, user 261 started: event 1, user 974 <-- すでに完了しているイベントのタスクがここから始まる?? started: event 1, user 831 ... finished: event 5 <-- イベント番号が小さいのに遅く完了 finished: event 3
ログからは明確に以下の事実が読み取れます:
- バーストは時間内に完了した。
- 早期に開始されたイベントのタスクが、大幅に遅れて処理・完了していた。
- タスク間の厳密な順序性は意図していなかったものの、「提出された直後にポーリングされる」という仮定が崩壊していた。
Tokio スケジューラの中身と課題
Tokio のマルチスレッド・ランタイムは、以下のようなアーキテクチャを持っています:
- 固定数の ワーカースレッド
- 各ワーカーのローカルキュー(最大 256 タスク)
- すべてのワーカで共有するグローバルキュー
スケジューリングの流れ
グローバルキュー +------------------------------+ | * ローカルキューからのオーバーフロー | | * 遠隔スケジューリングされたタスク | | * 古い/新しい作業が混在 | +--------------+---------------+ | +-------------------------+-------------------------+ | | | v v v worker 0 worker 1 worker 2 +-------------+ +-------------+ +-------------+ | ローカルキュー| | ローカルキュー | | ローカルキュー | | 最大 256 | | 最大 256 | | 最大 256 | +------+------+ +------+------+ +------+------+ | | | v v v タスクをポーリング タスクをポーリング タスクをポーリング
このアーキテクチャにおいて、以下のことが起こります:
- イベントから Fan-out されたタスクは、他のイベントからのタスクや
で待機している親タスクと混ざり合う。JoinSet - Tokio は**「早期に提出したタスクを先にポーリングする」という保証を行わない**。
- キューのオーバーフローによる移動や、ワークスティーリング(他ワーカーからタスクを奪う)により、タスクが取り出される順序が乱される。
重要な区別:3 つの状態
ここで最も重要なのは以下の事実認識です:
- タスクが生成されたこと
- タスクがポーリングされたこと(実行が始まったこと)
- タスクが完了すること
これらは互いに等しくありません。Tokio はすべてのタスクを公平に進行させますが、メモリー使用量は**「同時に生きている(完了していない)タスクの数」**によって決まります。
メモリー使用量の増大理由
- 一部の早期イベントから生成されたタスクが、バースト終了まで生存し続けた。
- これに伴い、親イベントタスクも生存し続け、イベントの状態データをメモリー上に保持し続けた。
- その結果、ピークメモリー使用量が異常に増大した。
解決策:タスク生成数の有界化(Semaphore の導入)
Tokio は「タスク数が有界」かつ「ブロッキングしない」という前提で公平なスケジューリングを保証します。しかし、初期コードではタスク生成には上限がありませんでした。
要件は**「イベントレベルの公平性」(同一イベント内のタスクを速く完了させる)でした。これを実現するため、 Semaphore を使用して同時処理可能なイベント数を制限**しました。
[解決策] 同時に処理中のイベント数を N に制限する (Semaphore) ├── 各イベントごとに 1 つのイベントタスクを生成(許可された場合のみ) └── 各イベントごとに最大 1000 のトークンタスクを生成
この対策により:
- バーストは依然として想定内の時間内に完了した。
- ピークメモリー使用量が大幅に削減できた。
- スループットへの影響は無視できるほど小さかった。
結論と教訓
今回の問題は Tokio のバグではなく、アプリケーション設計上の限界でした:
- 「早期に生成された」=「早期に最初にポーリングされる」とは限らない
- 「早期に最初にポーリングされた」=「早期に完了する」とも限らない
- ランタイムはアプリケーションの公平性ユニット(今回は「イベント」)を意識していません。
開発者に必要なアクション:
- 有界性の追加: アプリケーション側で、スケジューラブルなタスクの数や深さを制限する必要があります。
- メモリー影響の考慮: タスク生成前に、「同時に生きている最大タスク数」と「それがメモリーに与える負荷」をシミュレーションしてください。
- 関連リソースの確認: タスクが生存している間、ファイルディスクリプターやネットワークコネクションなどの他のリソースも同様に増加していないか確認してください。