高速Tokioアプリケーションのための原則
Principles for Fast Tokio Applications (dial9-rs.github.io)
要約
この記事は、Rustの非同期ランタイムであるTokioで高性能なアプリケーションを構築するための実践的な原則を概説しています。レイテンシを最適化するには頻繁なyield、スループットを最大化するにはバッチ処理、グローバルリソースやミューテックスの慎重な使用、並列性の制限などが推奨されています。問題の特定には、スケジューリングレイテンシなどのメトリクスを監視し、実際のパフォーマンス指標に基づいて最適化を行うことが重要です。
全文翻訳
高速なTokioアプリケーションのための原則
私はRustConfから帰るところです。Unconfでは、非同期アプリケーションのデバッグとベンチマークについて生産的な議論がありました。多くの興味深い洞察が共有されました。ここでは、それらのいくつかと私自身の経験の一部を列挙しようとしています。これは、ベストプラクティスの生きたドキュメントになることを願っているものの、最初のドラフトです。お気軽にイシューを提出したり、PRを開いてください。数日中には、これらの問題とdial9トレースの外観を示すサンプルアプリを追加したいと思っています。
— Russell
Tokioランタイムでパフォーマンスの良いコードを書くための厳格なルールはほとんどありません。多くの質問に対する答えは「状況によります」です。ワークロードのパフォーマンスは、その瞬間にランタイムで他に何が実行されているかに依存します。だからこそ、多くの問題が本番環境でしか現れないのです!パフォーマンスの良い非同期アプリケーションを書くことは、公平性とバッチ処理、競合と分離のバランスです。
この記事では、可能な限り例外をカバーしながら、一般的な原則をいくつか示します。Tokioのワークスティーリングランタイムに関する基本的な知識があることを前提としています。高レベルの概要は付録に含まれています。
一般的な原則
まず、問題があるかどうかを判断する
Tokioアプリケーションで赤信号を探し始めれば、それらを見つけるでしょう。私がこれまでに見たほとんどすべての実際のアプリケーションでは、Alice Ryhlが彼女の優れた記事What is Blocking?で推奨している10〜100マイクロ秒よりも長い時間、ポーリング(.awaitポイントの間でコードがランタイムに制御を戻す時間)が発生しています。これらの問題は、あなたが実際に気にかけているアプリケーションのメトリクスや動作に影響を与える場合もあれば、そうでない場合もあります(参照:長いポーリングでも問題ない場合がある)。改善したい実際のメトリクスから逆算して作業することが重要です。例えば、アプリケーションは完全に無害な長いポーリングを持つことがありますが、「修正」してもユーザーに表示されるメトリクスには測定可能な影響はありません。
私が遭遇した問題の圧倒的多数では、問題はアプリケーションコード自体にあり、多くの場合、分散システムの複数のコンポーネント間の相互作用にありました(そして実際にはTokioにはありませんでした)。dial9はTokioに多くの可視性を提供してきました。少なくともTokioの問題を見つけるのと同じくらい頻繁に、実際には問題がないことを明確に示しています(これにより、人々は他の場所を探す自信を得られます)。もちろん、時にはTokioの問題であることもあります。
Tokioのメトリクスに関して最も有用なのは、最近追加されたスケジュール遅延ヒストグラムです。スケジュール遅延とは、タスクが実行可能になった時点(例:ソケットにデータがあるため)から、Tokioが実際にFutureをポーリングするまでの時間です。これが原因を教えてくれるわけではありませんが、スケジュール遅延はTokioとコード間の相互作用の悪さの最も一般的な症状です。
レイテンシのために分割し、スループットのためにバッチ処理する
レイテンシを最適化するために、より頻繁にyieldする
多くのリクエストにわたる低レイテンシには、接続間の公平性が必要です。
Redis(またはリクエストパイプラインをサポートする任意のアプリケーション)を考えてみてください。単純な実装では、接続から直接データを読み取りますが、より多くのデータが利用可能です。リクエストがパイプライン化されている場合、パイプライン化されたリクエスト全体(またはその大部分)がメモリバッファに格納されます。そこからフレームを読み取るとき、各フレームはネットワークに戻ることなくPoll::Readyを返します。これは、長いポーリングとクライアント間の不公平さの両方を作り出します。
スループットへの影響は通常小さくなります。同じ数のリクエストが処理されます。しかし、レイテンシは劇的に変化します。なぜなら、1つのパイプライン全体が別のパイプラインの後ろで待機する可能性があるからです。各リクエストの後に明示的にyieldすると、この例ではレイテンシが約10倍減少する可能性があります。数回の連続した即時準備完了の読み取り後にのみyieldすることで、さらに改善できます。
async fn handle_conn(&mut self) -> crate::Result<()> {
while !self.shutdown.is_shutdown() {
// 接続にバッファされたデータがある場合、これはyieldせずに繰り返しPoll::Readyを返す可能性があります。
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;
}
}
4回の連続した即時準備完了の読み取り後にyieldすることで、バッチ処理を完全に犠牲にすることなく、パイプライン化されたリクエストをより公平にします。
この問題があるかどうかはどうすればわかりますか?
- P99がP50よりもはるかに大きい。
- ポーリングが、その中の作業に必要な時間よりも長くかかる。
- 多くのスパンが単一のポーリング内に収まる。
オーバーヘッドを償却するために作業をバッチ処理する
公平性にはコストがかかります。ランタイムイベント(タスクの切り替え、ポーリング、ワーカー間の移動、スレッドの切り替え)ごとに、より多くの有用な作業を実行できるほど、アプリケーションは効率的になります。
おそらく最良の例はtokio::fsです。私は時々、「tokio::fsは有害と見なされる」とまで言います。io_uringがない場合、Tokioは各ファイルシステム操作をブロッキングプールで実行します。spawn_blockingの各呼び出しにもコストがあり、すべてのランタイムには共有ブロッキングプールがあります。
一連のファイルシステム操作、または任意のブロッキング作業を実行することがわかっている場合は、それらを可能な限り大きな意味のあるブロッキングセグメントにバッチ処理してください。場合によっては、専用のOSスレッドの方が適しています。
この原則は、Tokioとやり取りするあらゆる場所に適用されます。グローバルキューに作業を送信することがわかっている場合、バッチ処理はその調整も償却できます。
タスクのスポーンでさえ、無料ではありません!タスクのスポーンは安価ですが、数百または数千のタスクをスポーンする場合、それぞれがランタイムが処理する必要のある作業を表します。それぞれがスケジューリング遅延の影響を受ける可能性を増やし、ランタイムが処理する必要のあるポーリングを増やし、一般的に全体的なオーバーヘッドを増やします。タスクをスポーンするときは、実際にどれだけの作業をスケジュールしているかを検討してください。10マイクロ秒の作業単位を独自のタスクにスポーンするのは、おそらく逆効果です。dial9やtokio-metricsのようなツールは、タスクのライフサイクルを追跡するのに役立ちます。
この問題があるかどうかはどうすればわかりますか?
spawn_blockingのようなTokio APIが、フレイムグラフで顕著な時間を消費する。- タイトなループが、個々に小さいファイルシステム操作またはブロッキング操作を多数実行する。
- 同じ作業をより大きな単位にグループ化すると、スループットが向上する。
グローバルリソースに注意する
Tokioランタイムは、ワーカー(準備完了タスクをポーリングする専用スレッド)で作業をスケジュールします。ワーカーはコア全体にスケールしますが、一部のランタイムリソースは依然として共有調整を必要とします。
ブロッキングプールは現在1グローバルリソースです。十分なレートが高い場合、ブロッキングキューに作業をプッシュすることがボトルネックになり、spawn_blockingがフレイムグラフで可視になる可能性があります。32コアホストで毎秒約50,000のブロッキングタスクのレートで、パフォーマンスへの悪影響を経験しました。あなたの環境では異なる場合があります。spawn_blockingは、すべてのブロッキングまたはCPU負荷の高いコードの万能薬ではありません。短く、制限された作業の場合、Tokioのワーカーとワークスティーリングで処理する方が速いかもしれませんが、いつものように「状況によります」。
Tokioにはグローバルタスクキューもあります。タスクは、ローカルワーカーキューがオーバーフローした場合(通常はまれ)、またはランタイムワーカーの外部から作業がスケジュールされた場合(一部のアプリケーションでは一般的)にここに配置されます。一例は、送信者が非Tokioスレッドで実行されているチャネルです。
この問題があるかどうかはどうすればわかりますか?
spawn_blockingのようなランタイム全体の操作が、フレイムグラフで目立つ。- グローバルキューが一貫して深い。健全なアプリケーションでは、通常、空に近い状態を保つはずです。飽和状態のアプリケーションでは、ドレインするのに時間がかかる場合があります。
ミューテックスには極めて注意する
ランタイム全体を停止させる最も簡単な方法の1つは、競合するミューテックスでワーカーをブロックすることです。
ミューテックスまたは読み書きロックの後ろに格納されたメトリクスレジストリのようなものは、この問題に特に脆弱です。フラッシュがロックを保持して高価な作業を行っている場合、すべてのTokioワーカーは、メトリクスを記録しようとして同じロックでブロックされるタスクを最終的にスケジュールする可能性があります。すべてのワーカーがスタックしているため、スティーリングは不可能になります!
非同期アプリケーションでは、クリティカルセクションを極めて短く保ちます(例:単一のハッシュマップ更新)。RWLockは、読み取りパスでもアトミックな競合を引き起こすため、ほとんど常に適切なプリミティブではありません。ロックを保持したままフラッシュしたり、I/Oを実行したり、別のFutureを待機したりしないでください。一般的なトリックは、ミューテックスをロックしてからデータをクローンすることです。古い読み取りを許容できる場合(およびデータを書き戻す必要がない場合)、これによりミューテックスを保持する時間を制限できます。
tokio::sync::Mutexは、1つの問題を別の問題と交換します。Tokio Mutexはロックするのがはるかに高価で、FutureLockのような微妙な問題の影響を受けやすく、クリティカルセクションが数ミリ秒続く場合にのみ本当に適しています。
ミューテックスの代わりに、チャネルのような他の同期プリミティブを検討してください。1つのタスクが共有データを所有でき、チャネルから来るメッセージは変更を示します。これらは多くの場合、アプリケーションの再設計に依存しますが、パフォーマンスを簡素化および向上させることができます。アクターパターンを実装するためにチャネルを使用するための詳細なガイドについては、Alice RyhlによるActors With Tokioを参照してください。チャネルを使用する場合でも、最高のパフォーマンスを得るためにチャネルへのアクセスを償却またはバッチ処理することを検討する必要があります。チャネル自体がグローバルリソースになります。
この問題があるかどうかはどうすればわかりますか?
- P99が、バックグラウンドタスクが実行されるたびに(例:1分に1回)予測可能な間隔でスパイクする。
- dial9では、多くのタスクが突然ブロックされ、非CPU状態になり、かなりの時間がかかる。
[画像: 4つのTokioワーカーすべてがミューテックスの競合によってブロックされ、その後カーネルスケジューリング遅延が発生し、アクティブなタスクが急激に減少したことを示すdial9トレース]
A contended blocking mutex stalls all four runtime workers at once.
並列処理を制限する—通常は
Tokioは、システムの他の部分が処理できるよりもはるかに多くのタスクを簡単にスポーンできます。ワークロードが無制限のタスクをファンアウトしたために、誤ってS3に3,000の同時接続を開いてしまうことは非常に一般的です。
答えは退屈です:並列処理を制限します。派手なアダプティブアルゴリズムが適切な場合もありますが、Semaphoreで十分な場合が多いです。
Tokioワーカーを他のスレッドから分離する
Tokioの設計は、ワーカーが迅速にウェイクアップすることに依存しています。しかし、オペレーティングシステムが高負荷の場合、Tokioがワーカーをウェイクアップしようとした後、カーネルがワーカーをスケジュールするのに10〜20ミリ秒、あるいはそれ以上かかることがあります。P99レイテンシが数ミリ秒単位で測定されている場合、これは悲惨です。私はAmazonでのJavaからRustへの段階的な移行中にこれを観察しました。両方のプロセスが同じホストで実行され、Rustプロセスが徐々に作業の大部分を引き継いでいました。
Javaプロセスが行う作業が少ないほど、Rustプロセスはより高速になりました。より多くの作業を処理していてもです。他のアプリケーションが多数のスレッドを使用する場合、この効果はさらに強くなります。
最も基本的な解決策は、cgroupsまたは関連APIを使用して、Tokioワーカーと他のコードを個別のCPUコアにピン留めすることです。
同じ問題が他のRustスレッドからも発生する可能性があります。tracing_appenderなどで使用されるバックグラウンドスレッドは、CPUをyieldせずに100ミリ秒以上の作業を行うことがあります。この時間中にTokioがワーカーをウェイクアップしようとすると、そのワーカーはカーネルが他のスレッドをプリエンプトするまで遅延する可能性があります。
これが起こっているのを見る場合、解決策は同じです。クリティカルではないバックグラウンド作業を独自のコアにピン留めし、Tokioワーカーを他のコアに移動します。Tokioには通常すべてのコアが必要なわけではなく、他の作業のためにコアを予約するとレイテンシが改善する傾向があります。
この問題があるかどうかはどうすればわかりますか?
- dial9は、ワーカーのアンパークイベントとワーカーが実際に実行されるまでの間にカーネルスケジューリング遅延を示す。
知っている方が良い場合のトリック
このセクションのパターンは、一般的に正しいことではありませんが、ワークロードが必要とするものにぴったりな場合があります。
Executorをブロックしても大丈夫な場合がある
理想的な非同期アプリケーションでは、すべての作業は小さなバーストで発生し、Tokioへの頻繁なyieldが行われます。現実の世界は常にそのようには機能せず、小さなバーストがソフトウェアを実行する最も速い方法であるとは限りません。作業をバッチ処理する方が効率的です。
実際には、長いポーリングは常に問題ではありません。低負荷では、1つのワーカーが通常より長く占有されている場合、Tokioのワークスティーリングが補償できます。これは、次の2つの条件で破綻し始めます。
- Tokioランタイムが高負荷であり、利用可能なワーカー容量が存在しない。
- オペレーティングシステムが高負荷であり、ワーカーのアンパーキングが頻繁に遅延する。
どちらの場合も、スティーリングに時間がかかります。作業が十分に速くスチールされない場合、I/Oの駆動のようなコアランタイムメンテナンスが、低レイテンシを維持するために十分に頻繁に行われない可能性があります。
重要なお知らせ! このアドバイスは、タスク内の並列処理を利用するtokio::join!やtokio::select!のようなものを使用している場合には適用されません。単一のタスク内ではワークスティーリングはありません。Executorをブロックすると、そのタスクで実行されている他の何も進歩できません。これは、予期しないタイムアウトや一般的な悪いレイテンシとして現れることがあります。
複数のランタイムを使用してワークロードを優先度別に分離する
最も強力な分離は、作業を個別のランタイムに割り当て、それらのランタイムを専用コアにピン留めすることから得られます。多くのネットワークサービスには、レイテンシに敏感な作業と優先度の低いバックグラウンド作業の両方があります。それらを別々のランタイムに配置すると、両者の間にスケジューリング境界が作成されます。
ランタイムスレッドが開始されるときに、OSレベルのnice値も設定できます。dial9のマルチランタイム例とTokioのon_thread_startフックを参照してください。
TokioConfでは、ほとんどのトークから得られた一般的な印象は、人々が少なくとも2つのランタイムを持つソリューションに移行したということです。
スピンして制御を維持する
これは、マイクロ秒単位のレイテンシを追求するための非常に高度な戦術です。最初にこれに頼ることはお勧めしませんが、間違いなく効果があります。
Tokioスケジューラにyieldを返すとき、またはTokioがワーカー・スレッドをパークしてオペレーティングシステムにyieldするとき、毎回、その作業が再びウェイクアップしたときに遅延する機会が生まれます。
極めてレイテンシに敏感な作業の場合、次の有用な作業を待つ間にyieldするのではなく、短いプリセット期間(おそらく50マイクロ秒)スピンすることを意図的に選択するオプションがあります。これはコアを消費し、隣接するワークロードに悪影響を与える可能性があるため、ほとんどのアプリケーションでは間違っている可能性が高いです。しかし、慎重に制御された条件下では、それは正しいトレードオフになる可能性があります。
付録:Tokioのメンタルモデル(4つの箇条書き)
- RustのFutureは、awaitポイントの間で段階的に進歩します。これらのアクティブなセクションは、
Future::pollメソッドにちなんでポーリングと呼ばれます。 - Futureがポーリングされていないとき、それらはアイドル状態であり、実行者が再び実行するのを待っています。優れたExecutorは、実行する作業がある場合にのみFutureをポーリングします。
- TokioはN個のワーカーを実行します。通常は利用可能なコアごとに1つです。各ワーカーにはローカルキューがあります。キューがオーバーフローした場合、またはローカルキューに作業を追加できない場合、タスクはグローバルキューに配置されます。
- 1つのワーカーのキューがバックアップした場合、別のワーカーがそこから作業をスチールできます。これは、ランタイムが不均衡を検出し、別のワーカーに容量がある場合です。
1
Tokio 1.52.0は一時的にシャーディングされたブロッキングキューをリリースしましたが、1.52.1で元に戻されました。これは、spawn_blockingがハングする可能性のあるリグレッションの後でした。Tokio PR #8337で、シャーディングされたキューは、デフォルトで無効になっている不安定な機能として再導入されました。
更新履歴
- 2026-09-15: ミューテックスセクションを更新し、代替同期プリミティブを呼び出すようにしました。Saghm Rossi、ありがとうございます!