プログラミング
高速Tokioアプリケーションのための原則
Principles for Fast Tokio Applications (dial9-rs.github.io)
要約
この記事は、Rustの非同期ランタイムであるTokioで高性能なアプリケーションを構築するための実践的な原則を概説しています。レイテンシを最適化するには頻繁なyield、スループットを最大化するにはバッチ処理、グローバルリソースやミューテックスの慎重な使用、並列性の制限などが推奨されています。問題の特定には、スケジューリングレイテンシなどのメトリクスを監視し、実際のパフォーマンス指標に基づいて最適化を行うことが重要です。
全文翻訳
目次
一般原則
まず、問題があるかどうかを判断する
レイテンシのために分割し、スループットのためにバッチ処理する
レイテンシ最適化のために、より頻繁にyieldする
オーバーヘッドを償却するために作業をバッチ処理する
グローバルリソースに注意する
ミューテックスには極めて慎重になる
並列性を制限する—通常は
Tokioワーカーを他のスレッドから分離する
より良く知っている場合のトリック
エグゼキュータをブロックしても良い—時々
ワークロードを優先度別に分離するために複数のランタイムを使用する
制御を維持するためにスピンする
付録: 4つの箇条書きで理解するTokioのメンタルモデル
私はRustConfから帰る途中です。Unconfでは、非同期アプリケーションのデバッグとベンチマークについて生産的な議論がありました。多くの興味深い洞察が共有されました。それらをここに列挙しようとしており、私自身の経験もいくつか含めます。これは、ベストプラクティスの生きたドキュメントになることを願っているものの、最初のドラフトです。問題やプルリクエストを提出することを自由にしてください。これらの問題と、dial9トレースがどのように見えるかを示すサンプルアプリケーションを数日中に追加したいと思っています。— Russell
Tokioランタイムでパフォーマンスの良いコードを書くための厳格なルールはほとんどありません。多くの質問に対する答えは「状況によります」です。ワークロードのパフォーマンスは、その瞬間にランタイムで他に何が実行されているかに依存します。だからこそ、多くの問題が本番環境でしか現れないのです!パフォーマンスの良い非同期アプリケーションを書くことは、公平性とバッチ処理、競合と分離のバランスです。この記事では、いくつかの一般的な原則を説明し、私ができる例外についても触れます。Tokioのワークスティーリングランタイムに基本的な知識があることを前提としています。高レベルの概要が付録に含まれています。
一般原則
まず、問題があるかどうかを判断する
Tokioアプリケーションで赤信号を探し始めると、それらを見つけるでしょう。私がこれまでに見たほとんどすべての実際のアプリケーションでは、Alice Ryhlが優れた投稿「Blockingとは何か?」で推奨している10〜100マイクロ秒よりも長いポール(コードがランタイムにyieldバックするまでの時間)があります。これらの問題は、実際に気にかけているアプリケーションのメトリクスや動作に影響を与える場合もあれば、そうでない場合もあります(参照:長いポールは時々問題ない)。改善しようとしている実際のメトリクスから逆算して作業することが重要です。例えば、完全に無害な長いポールを持つアプリケーションがあり、「修正」してもユーザーに表示されるメトリクスには測定可能な影響はありません。私が遭遇した問題の圧倒的多数では、問題はアプリケーションコード自体にあり、しばしば分散システムの複数のコンポーネント間の相互作用にありました(そして実際にはTokioにはありませんでした)。dial9はTokioに多くの可視性を提供しています。Tokioの問題を見つけるのと同じくらい頻繁に、実際には問題がないことを明確に示しています(これにより、人々は他の場所を探す自信を得られます)。もちろん、時にはTokioの問題であることもあります。Tokioのメトリクスに関して、最も有用なのは最近追加されたスケジューリングレイテンシヒストグラムです。スケジューリングレイテンシとは、タスクが実行可能になった時点(例:ソケットにデータがあるため)からTokioが実際にfutureをpollするまでの時間です。これは原因を教えてくれませんが、スケジューリングレイテンシは、Tokioとコード間の相互作用の悪さの最も一般的な症状です。
レイテンシのために分割し、スループットのためにバッチ処理する
レイテンシ最適化のために、より頻繁にyieldする
多くのリクエストにわたる低レイテンシには、接続間の公平性が必要です。Redis(またはリクエストパイプラインをサポートする任意のアプリケーション)を考えてみてください。単純な実装では、より多くのデータが利用可能な場合でも、接続から直接データを読み取ります。リクエストがパイプライン化されると、パイプライン化されたリクエスト全体(またはその大部分)がメモリバッファに格納されます。それからフレームを読み取るとき、各フレームはネットワークに戻ることなくPoll::Readyを返します。これは、長いポールとクライアント間の不公平さの両方を作り出します。スループットへの影響は通常小さくなります。同じ数のリクエストが処理されます。しかし、レイテンシは劇的に変化します。なぜなら、1つのパイプライン全体が別のパイプラインの後ろで待機する可能性があるからです。各リクエストの後に明示的にyieldすると、この例ではレイテンシが約10倍減少する可能性があります。数回の連続した即時準備完了の読み取り後にのみyieldすることで、さらに改善できます。
async fn handle_conn(&mut self) -> crate::Result<()> {
while !self.shutdown.is_shutdown() {
// 接続にバッファされたデータがある場合、これは繰り返しPoll::Readyを返す可能性があります
// ランタイムにyieldバックすることなく。
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ランタイムはワーカー(準備完了タスクをポーリングする専用スレッド)で作業をスケジュールします。ワーカーはコア全体にスケールしますが、一部のランタイムリソースは依然として共有調整を必要とします。ブロッキングプールは現在、グローバルリソースです。十分なレートになると、ブロッキングキューに作業をプッシュすることがボトルネックになり、spawn_blockingがフレイムグラフで目立つようになる可能性があります。32コアホストで毎秒約50,000のブロッキングタスクで否定的なパフォーマンス効果を確認しました。あなたの環境では異なる場合があります。spawn_blockingは、すべてのブロッキングまたはCPU負荷の高いコードの万能薬ではありません。短く、境界のある作業の場合、Tokioのワーカーとワークスティーリングに処理させる方が速いかもしれませんが、いつものように、「それは状況によります」。Tokioにはグローバルタスクキューもあります。タスクは、ローカルワーカーキューがオーバーフローした場合(通常はまれ)、またはランタイムワーカーの外部から作業がスケジュールされた場合(一部のアプリケーションでは一般的)にそこに配置されます。一例は、送信者が非Tokioスレッドで実行されているチャネルです。
この問題があるかどうかはどうやってわかりますか?
spawn_blockingのようなランタイム全体の操作が、フレイムグラフで目立つ。
グローバルキューが一貫して深い。
健全なアプリケーションでは、一般的に空に近い状態を保つべきです。飽和状態のアプリケーションでは、ドレインするのに時間がかかる場合があります。
ミューテックスには極めて慎重になる
ランタイム全体を停止させる最も簡単な方法の1つは、ワーカーをミューテックスでブロックすることです。