
2026/09/15 0:27
高速なTokioアプリケーションのための原則
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
以下の改善されたバージョンは、欠落していた具体的なツール、メトリクス、および戦術的アドバイスを組み込みながら、流れを維持しています:
この文書は、Tokio 上で高パフォーマンスな非同期アプリケーションを構築するための「生きているガイド」として機能し、最適な結果を得るには特定のカンテキストに基づいて公平性、バッチ処理、およびリソース競合のバランスをとることが必要であることを強調しています。核となる哲学は、万能な解決策を適用するのではなく、現実世界のシナリオに合わせて振る舞いをチューニングすること——低遅延を優先するか、スループットを最大化するか——にあります。パフォーマンスはトレードオフ(例:孤立化対共有リソース)を理解することに依存し、決定は表面的な警告旗ではなく、P99 遅延などの決定的なメトリクスによって駆動されるべきです;例えば、長時間ポーリングは軽い負荷下では無害ですが、そうでない場合には問題となります。開発者は
dial9 などのツールを使用して可視性を得るべきであり、それは通常、問題が Tokio のバグそのものではなく、アプリケーションコードまたは分散相互作用から生じていることを示します。最も有用なメトリクスはスケジュール遅延ヒストグラム(タスクの準備完了とランタイムポーリングの間の時間)です。
具体的な戦略には以下が含まれます:
- 遅延最適化: 公平性を最適化するために頻繁に Yield してください;パイプラインドリードの各後に明示的に Yield することで、Redis 様のシナリオで遅延を約 10 倍削減できます。
またはtokio::join!
を使用して単一のタスク内でエグゼキュートをブロックすることを避けてください。tokio::select! - スループット最適化: 文件系统操作またはブロッキング作業を
に使用前に大きなセグメントにバッチしてください。同じ作業がグループ化された場合、スループットは向上します;数多くの小さなタスク(例:10 マイクロ秒単位のもの)を生成することを避けてください;これはスケジューリングオーバーヘッドを増加させます。spawn_blocking - グローバルリソース: ブロッキングプールはグローバルリソースです;32 コアのホスト上で約 50,000 のブロッキングタスク毎秒でネガティブな効果が現れる可能性があります。グローバルタスクキューをほぼ空に近い状態に保ってください。cgroups を使用して Tokio ワーカーを別々の CPU コアにピン留めし、背景の Rust スレッドが Yield せずに 100ms 以上の作業を実行することで OS スケジューリング遅延(10–20+ ms)を引き起こすことを避けてください。
- 競合と並列性: 臨界セクションを極めて短く保ってください(例:単一のハッシュマップ更新)。フラッシュ、I/O パフォーマンス、または Future の待機中にロックを保持しないでください。並行性を制限してください(例:
を使用して);偶然の巨大なファンのアウト(例:3,000 の同時 S3 接続)を避けてください。必要であれば、優先度隔離のために別々のコア上使用複数のランタイムを行ってください。Semaphore
究極的には、同じ作業を大きな単位にグループ化し、遅延感度の高いタスクを孤立させる戦略を採用することで、重い負荷下でも遅延の大幅な削減と予測可能なシステム応答性を確保できます。
本文
Tokio ランタイム上で高性能なコードを書くためのガイド
はじめに
ユニコンフのセッションで共有された非同期アプリケーションのデバッグ・ベンチマークに関する洞察をまとめました。Tokio のワークスティーリングランタイムを理解した上で、**「公平性」「バッチ処理」「競合」「分離」**のバランスが取れた高パフォーマンス実装を目指しましょう。本稿では一般的な原則と、状況に応じた例外も記載します。
- 基本方針: 「状況による(It depends)」が基本。ワークロードのパフォーマンスは瞬間的なランタイム状態に依存するため、実際の運用環境での検証が必須です。
- 前提知識: Tokio の基本概念を理解していることが想定されます。
1. 一般的な原則
まず、問題が本当にあるのかを確認する
Tokio アプリケーションで「赤旗となる兆候」を探し始めれば、必ず見つけることができます。しかし、改善すべき具体的なメトリクスを起点に遡って考えることが重要です。
- ポルル間隔の測定: Alice Ryhl 氏が推奨する 10〜100 マイクログレインド(
点間のコード実行時間)より大幅に長いポルルが観察される場合、問題の兆候です。await- 長時間のポルルが必ずしも悪いとは限りません。「修正」してもユーザー向けメトリクスに影響がない場合もあります。
- 原因の特定:
- 绝大多数の問題は、Tokio そのものではなくアプリケーションコード自体または分散システムコンポーネント間の相互作用です。
などのツールで Tokio の可視性を高めることで、「問題なし」な事実を明確にし、自信を持って他の場所を検索できます(稀に Tokio 自身の問題もあるか)。dial9
- 有効な計測指標:
- スケジューリング遅延ヒストグラム: タスクの実行可能になった時間と、Tokio がポーリングするまでの時間の差。
- この遅延は原因を直接示さなくても、Tokio とコードの間の不適切な相互作用の主要な症状です。
低レイテンシー vs スループット
レイテンシ最適化のためには頻繁に Yield(実行中断)する
多数のリクエスト処理における低レイテンシ実現には、接続間での公平性が求められます。
- パイプライン化された読み込みの課題:
- データがあるまま直接読み取ると、 Entire パイプラインがメモリバッファされ、フレームごとに
を返して即座に完了してしまう可能性があります。Poll::Ready - これにより長時間のポルルが発生し、他の接続(クライアント)に不公平性をもたらします。
- データがあるまま直接読み取ると、 Entire パイプラインがメモリバッファされ、フレームごとに
- 改善策:
- 各リクエスト後に明示的に
で Yield することで、レイテンシを約 10 倍改善できます。yield_now() - さらに、連続して即座に完了する読み込みの数回(例:4 回)行った後で Yield することで、公平性を保ちつつバッチ処理の利点を両立します。
- 各リクエスト後に明示的に
async fn handle_conn(&mut self) -> crate::Result<()> { while !self.shutdown.is_shutdown() { let frame = tokio::select! { res = self.connection.read_frame() => res?, _ = self.shutdown.recv() => { return Ok(()); } }; execute_command(&self.db, &mut self.connection, frame).await?; // 公平性を向上させるために: // tokio::task::yield_now().await; } }
【問題検知のポイント】
がP99
に比べて著しく大きい場合。P50- ポルルの時間が、処理内容に必要な時間よりも長い場合。
- 多くのスパン(トレース)が単一のポルルに含まれている場合。
スループット向上のためには作業をバッチ化する
公平性はコストをかけなければ達成できません。ランタイムイベントあたりの有用な作業量を増やせば、オーバーヘッドは相対的に減少します。
の活用:tokio::fs- 一連のファイルシステム操作(またはブロック操作)は、最大の意味のあるサイズにバッチ化してください。
を使ってもコストがかかるため、共有されたブロックプールを適切に管理する必要があります。spawn_blocking
- タスク生成への注意:
- タスク生成自体は安価ですが、大量(100〜1,000 個)作成するとランタイムの個別処理オーバーヘッドが増大します。
- 10 マイクログレインド単位の微小作業を独自タスクに分割するのは逆効果です。
【問題検知のポイント】
- Flamegraph で
などの API が顕著な時間を消費している場合。spawn_blocking - テンポロープル(頻繁なポーリング)が多数の小さな操作を処理している場合。
- 同じ作業を大きな単位にグループ化するとスループットが改善する場合。
グローバルリソースには注意する
Tokio はワーカースレッド上でスケジューリングを行いますが、一部の資源はグローバル共有されています。
- ブロックプール:
- 高いレートで動作すると、ブロックキューへのプッシュがボトルネックになり得ます(例:32 コアで 50,000 ブロックタスク/秒)。
- 「スパawning ブロック」は万能薬ではなく、ケースバイケースです。短い有界な作業はワーカーに任せるのが速い場合があります。
- グローバルタスクキュー:
- ローカルワーカーキューが満杯になった場合や、ランタイム外からタスクが投入された場合に利用されます(例:非-Tokio スレッドからのチャンネル送信)。
【問題検知のポイント】
- 全体的な操作(
など)が Flamegraph で顕著である場合。spawn_blocking - グローバルキューが常に深くて枯渇に近い状態にある場合。
ミューテックスには極めて注意する
競合ミューテックスでワーカーをブロックすると、ランタイム全体を停止させます。
- リスク:
- メトリクス登録システムなどでロックを保持し高価な作業を行うと、すべてのワーカーがロックを待機する状態になり、ワークスティーリングが不可能になります。
- RWLock(読み書きロック)も避けるべきです。読み取り経路でも原子操作への競合が発生するため、プリミティブとして不向きな場合が多いです。
- 推奨アクション:
- クリティカルセクションを極端に短く保ってください(例:単一のハッシュマップ更新のみ)。
- ロック中は I/O や Future の待機を行わないでください。
はロックコストが高く、ミリ秒単位のクリティカルセクション以外では推奨されません。tokio::sync::Mutex
【問題検知のポイント】
が予期された間隔(例:1 分おきの背景タスク実行時)でスパイクする場合。P99
で多くのタスクが一気にブロックされ、CPU から除外されている場合。dial9- 競合ブロックミューテックスはランタイムの全 4 ワーカーを同時に停止させます。
並行性を制限する
Tokio は無制限にタスクを生成できてしまいます。S3 に 3,000 の同時接続を開くような事故を防ぐために、並行性を制限してください。
- 対策:
を使用して適度に制限するのが一般的で有効です。Semaphore - 複雑な適応的アルゴリズムが必要なケースは稀です。
Tokio ワーカーと他のスレッドを分離する
Tokio の設計は「ワーカーの速い目覚め(unpark)」に依存しています。OS が高負荷の場合、カーネルがワーカーをスケジューリングまでに 10〜20ms かかることがあります。
- 影響: マイ秒単位の P99 レイテンシ計測では災害となり得ます。
- 例:他のアプリケーション(Java など)が多数のスレッドを使用し、CPU を競合させると、Rust の Tokio ワーカーが目覚めにくくなります。
- 解決策:
- cgroups や関連 API を使い、Tokio ワーカーを特定の CPU コアに固定(ピンニング)してください。
- バックグラウンドスレッド(
等)は非クリティカルな作業用に別のコアに固定し、Tokio ワーカーのためのコアを確保します。tracing_appender
【問題検知のポイント】
がワーカーの unpark イベントと、実際に実行されるまでのカーネルスケジューリング遅延を示す場合。dial9
2. 正しく理解した後のトリック(例外・高度な戦略)
実行子をブロックすることは、場合によっては OK です
理想的な非同期アプリは「Tiny ボースト(短い突発動作)」のみで構成されますが、現実にはバッチ処理の方が効率的な場合があります。
- 許容される条件:
- Tokio ランタイムが過度に負荷がかかり、余剰ワーカー容量がない場合。
- OS が過度に負荷がかっており、unpark が頻繁に遅延する場合。
- 注意:
- 作業が十分に早く盗まれなければ、I/O 推進などのランタイムメンテナンスが行われず、低レイテンシが保てません。
- 重要な注意点:
やtokio::join!
を使った並行性活用中なら適用不可です。tokio::select!- 単一タスク内でブロックすると、ワークスティーリングが機能しなくなり、他の作業の進捗が止まって予期せぬタイムアウトや悪化レイテンシを招きます。
優先度でワークロードを分離するために複数のランタイムを使用する
最も強い分離は、作業を別々のランタイムに割り当てて専用コアに固定することです。
- 活用シナリオ: レイテンシ敏感な作業と、低優先度のバックグラウンド作業を分けます。
- 実装方法:
- ランタイムスレッド開始時に
を設定する(niceness
の複数ランタイム例参照)。dial9 - Tokio の
フックを使用。on_thread_start - 最低でも 2 つのランタイムを持つ構成への移行が一般的です。
- ランタイムスレッド開始時に
スピンして制御を保つこと
マイクロ秒単位のレイテンシ追跡向けの高度な戦略です。
- 手法: Yield(中断)する代わりに、意図的に短時間のスピン(例:50 マイクログレインド)を行います。
- トレードオフ:
- コアを消費し、近隣ワークロードに負荷を与えます。
- 慎重な制御下でのみ、正味のトレードオフ(レイテンシ改善 vs リソース使用)となります。
3. 補足:Tokio のメンタルモデル(4 つのポイント)
Tokio を理解するための基本概念です。
- Future とポーリング:
- Rust の Future は
点の間でインクリメンタルに進捗します。アクティブなセクションは「ポーリング」されます。await
- Rust の Future は
- 非活性状態:
- ポーリングされていない Future は非活性で、実行器から再度呼び出されるのを待機しています。優れた実行器は作業がある場合のみポーリングを行います。
- ワーカーとキュー:
- Tokio は通常 N ワーカー(利用可能なコア数)を実行し、各ワーカーにローカルキューを持っています。
- キューが溢れたり、ローカルキューへの追加ができなかった場合は、タスクはグローバルキューへ移動します。
- ワークスティーリング:
- 一つのワーカーのキューがバックアップした場合、別のワーカーはその作業を盗むことができます(ランタイムが不均衡を検知し、容量がある場合)。