TokioはRustの非同期処理を支える定番ランタイムですが、速く動かすための「万能の正解」はありません。今回の元記事は、その前提をかなり率直に書いた実践メモです。遅い原因をTokioそのものに決めつける前に、何を測り、どこを疑い、どこで割り切るべきかを整理しています。読みどころは、抽象論ではなく「現場で本当に効く判断」が並んでいるところでしょう。
元記事は、RustConfのUnconfで交わした議論と、自身の経験をもとにした「fast Tokio applications」の原則をまとめたものだ。著者は、Tokioアプリの性能問題にはハードルの低い決まりごとがほとんどなく、「it depends」が多すぎると認める。そのうえで、まず確認すべきなのは本当に問題があるのか、という点だと強調する。多くの実アプリでは、.await に戻るまでの「poll」がAlice Ryhlの勧める 10〜100マイクロ秒よりずっと長いが、それが必ずしもユーザー体験やKPIの悪化に直結するとは限らない。見つけた赤信号を片っ端から直すのではなく、改善したい実際の指標から逆算せよ、という姿勢だ。
性能を見るうえで特に役立つのは、最近追加された schedule latency histogram だという。これは、タスクが実行可能になってからTokioが実際にfutureをpollするまでの遅れを示す。原因までは教えてくれないが、Tokioとアプリの相性が悪いときに最もよく出る症状だと説明している。そこから著者は、低レイテンシと高スループットは同じではない、と話を進める。たとえばRedisのようにリクエストをまとめて受けるアプリでは、ネットワークから続けて読めるデータをひたすら処理すると、同じ接続内でpollが長くなり、他のクライアントとの公平性が崩れる。スループットはあまり落ちなくても、待ち時間は大きく伸びうる。そこで、1件ごと、あるいは数件ごとに yield_now() で譲ると、事例によってはレイテンシを約10倍改善できるとしている。
一方で、何でも細かく分ければよいわけでもない。filesystem操作やblocking処理のように、Tokioの外側に仕事を逃がす場面では、細切れよりも大きくまとめたほうがいい。tokio::fs はio_uringがない環境ではblocking poolを使うため、spawn_blocking を何度も呼ぶとその分だけコストがかかる。taskをspawnすること自体も無料ではなく、10マイクロ秒程度の仕事を独立タスクにしてしまうのは逆効果になりうる。さらに、Tokioのblocking poolやglobal task queueといった共有資源は詰まりやすい。spawn_blocking は魔法の解決策ではなく、短く終わる処理ならworkersに任せたほうが速いこともあるという。
記事の後半では、mutexの危険性がかなり強い言葉で語られる。共有のmetrics registryをmutexやRWLockで守り、重いflushの途中でそのロックを握ると、全workerが同じロック待ちになって止まることがある。tokio::sync::Mutex も安易な代替ではなく、ロックコストが高く、FutureLock のような微妙な問題もあり、数ミリ秒以上続く臨界区間向きだという。さらに、並列化はしすぎないこと、Tokio workerを他のOSスレッドと分離することも勧めている。OSが混んでいると、workerを起こしてから実際に動くまで10〜20ms以上遅れることがあり、single-digit millisecondsのP99を狙うアプリには致命的だ。Javaプロセスと同居させたAmazonでの経験では、相手の負荷が下がるほどRust側が速くなったという話も出てくる。最後は、必要ならruntimeを分ける、場合によってはCPUをピン留めする、最終手段としてbusy spinで制御を保つ、といった「例外的な手」まで含めて、現実的な指針として締めている。
この文章で一番まっとうだと思ったのは、最初からTokioの善し悪しを論じていない点だ。性能劣化の原因はランタイムではなく、アプリ同士の噛み合わせにあることが多い、と著者はかなりはっきり言っている。実際、非同期アプリの遅さは「awaitが多いから」ではなく、ロック待ち、共有キューの詰まり、blocking処理の混在、外部サービスとの往復が積み重なって起きることが多い。ここをTokioの問題に見誤ると、改善策も的外れになる。
一方で、この主張は「まず計測しろ」という当たり前を、かなり実務的に言い換えているのがよい。schedule latency のような指標は、単に遅いことを示すだけで、どこに責任があるかは教えてくれない。それでも、症状を出せるだけで十分な場面は多い。遅いかどうかの議論より先に、遅さがユーザー影響に結びついているかを確かめろ、という順番は、急がば回れだがかなり効くと思う。
Redisの例は分かりやすい。パイプラインで届いたリクエストを、読める限り一気にさばけば、処理の総量は保てるかもしれない。でも、その接続の後ろに別のクライアントが並んでいたら、待ち時間は簡単に悪化する。ここで重要なのは、スループットが落ちないから問題なし、とはならないことだ。ユーザーが体感するのは平均処理量ではなく、自分のリクエストがいつ返るかだからだ。
ただし、著者が示しているのは「常に頻繁にyieldしろ」ではない。4回連続で即座に読めたら譲る、というような折衷案に触れていて、ここに現場感がある。公平性を上げすぎると今度はオーバーヘッドが増える。非同期処理の難しさは、正しさではなく、どこで譲るとどこでまとめるのが得かがワークロード次第で変わるところにある。Tokioの設計が悪いというより、設計が前提にしている「バランス」を壊すと、すぐ露呈するということだろう。
spawn_blocking と task spawn は、思ったより高くつく「非同期なのだから細かく分ければよい」と考えると、Tokioでは逆に遅くなることがある。記事はここをかなり強く釘刺ししている。filesystemアクセスやblocking処理を spawn_blocking に流すのは自然に見えるが、blocking pool は共有資源だし、taskを大量にspawnするたびにruntime側の管理コストが乗る。特に、10マイクロ秒しかない仕事を1件ずつタスク化するのは、分割のための分割になりやすい。
この話は、Rustの「軽いからたくさん作れる」という感覚への小さな警告にも見える。軽いことと、無限にばらまいてよいことは別だ。特にクラウド上では、CPUコア数やOSのスケジューリング、blocking poolの混雑が全部効く。著者が「50,000 blocking tasks per second」あたりで悪影響を見たと書いているのも象徴的で、理論より先に実測が世界を決める。私はここを、Tokioの性能問題というより、並列化の癖を矯正する話として読んだ。速くしたいなら、まず仕事を細かく切る発想を疑ったほうがいい。
この記事でいちばん怖いのはmutexの章かもしれない。単一のロックを重い処理の前後で長く持つと、Tokio workerが次々にそのロック待ちになって、スティールもできず、runtime全体が詰まる。これは「一部が遅い」ではなく「全部が巻き込まれる」話だ。しかも、metrics registry のように一見無害に見える共有データで起きやすい。計測のためのロックが、計測対象そのものになるわけだ。
ここから見えるのは、asyncアプリでは「臨界区間は短く」という原則が、普通の並行プログラミング以上に厳密になることだと思う。RWLockも万能ではなく、読み取り経路でもアトミック操作の競合が残る。tokio::sync::Mutex も、「async向けだから安全」ではない。著者が数ミリ秒以上の区間向けだと言っているのは、むしろ使いどころをかなり限定している。私はこの節を読むと、ロックを使うより先に、そもそも共有状態を減らせないか、別スレッドに追い出せないかを考えるべきだと感じる。ロックは最後の選択肢に近い。
元記事が面白いのは、Tokioの内側だけで閉じないことだ。workerは速く起き上がることが前提だが、OSが混んでいると、起こしたのに10〜20msも実行されないことがある。P99が1桁ミリ秒の世界では、これだけで台無しだ。しかも原因はRustコードの中にないかもしれない。Javaプロセスと同じホストで動かしていたら、相手の負荷が下がるほどRustが速くなった、という観察はかなり示唆的だ。
この指摘は、クラウドやコンテナ時代の非同期アプリにそのまま当てはまると思う。アプリ単体の性能評価だけでは見落としやすく、CPUの取り合い、バックグラウンドスレッド、ログ出力、監視エージェントまで効いてくる。だからこそ、cgroups で分ける、別coreに逃がす、必要なら複数runtimeに分離する、という発想が出てくるのだろう。Tokioを速くするとは、コードを磨くだけではなく、周囲の雑音を減らすことでもある。この記事は、その現実をかなり正直に書いている。