プログラミング
Tokio/Rayonの落とし穴とAsync/Awaitが並行処理に失敗する理由
The Tokio/Rayon Trap and Why Async/Await Fails Concurrency (pmbanugo.me)
要約
async/awaitは開発者にとって容易に書ける構文を提供しますが、その裏には構造的な複雑さが隠されています。この記事では、async/awaitが非同期処理と並行処理を混同し、CPUバウンドなタスクがI/O処理をブロックしてパフォーマンスを低下させる「コンピュートの落とし穴」や、無制限のタスク実行がメモリ枯渇を引き起こす問題、そして開発者が手動でスケジューラのように振る舞う必要が生じる状況を解説します。代替案として、Project Tinaのようなスレッド・パー・コア、共有なしのフレームワークが提案されています。
全文翻訳
Tokio/Rayonの落とし穴とAsync/Awaitが並行処理に失敗する理由
2026年4月22日
過去10年間で、async/awaitは並行処理の戦いを制しました。なぜなら、それは驚くほど容易だからです。開発者は、同期コードとほぼ見分けがつかない非同期コードを書くことができます。しかし、その馴染み深い構文の下には、巨大な構造的複雑さが隠されています。それは制御フローを隠蔽し、ハードウェアの現実を不明瞭にし、最終的にはスケジューリングの負担を開発者に押し戻します。Rich Hickeyは、彼の講演「Simple Made Easy」でこれを完璧に表現しました。「簡単」とは、馴染みがあり、すぐに手が届くものですが、「シンプル」とは、構造的に絡み合っていないものです1。async/awaitは書くのは簡単ですが、運用するのは非常に複雑です。Rob Pikeは、2023年のGopherConAUでの講演で、このアーキテクチャのシフトについて語りました。goroutine、チャネル、selectと比較して、async/awaitは言語実装者にとって構築が容易で小規模です…しかし、それはプログラマに複雑さの一部を押し戻し、しばしばBob Nystromが「色付き関数」と呼んだものにつながります。[…]しかし、どのような並行処理モデルを提供するにしても、それを一度だけ正確に行うことが重要です。なぜなら、複数の並行処理実装を提供する環境は問題を引き起こす可能性があるからです2。Pike氏の「複数の並行処理実装」とasync/awaitに関する発言は、まさに今日本番環境で失敗していることです。
本番環境の落とし穴:非同期処理と並行処理の混同
async/awaitの根本的な落とし穴は、非同期処理(I/Oを待っている間にyieldすること)と並行処理(複数のことを一度に扱うこと)を混同していることです。この構文は、インターリーブされたステートマシンを、孤立した逐次的なスレッドとして偽装するため、落とし穴となります。この幻想に油断した開発者は、ブロッキングコードを書くのと同じようにasync関数を書きます――ネットワーク経由でデータベースレコードを取得し、すぐにデータを処理します。しかし、そのデータ処理が10MBのJSONペイロードの解析、巨大なコレクションの走査、あるいは計算負荷の高い暗号証明の実行を含む場合、何が起こるでしょうか?協調型エグゼキュータが停止します。
期待:高速なI/Oタスクはすぐにyieldする。高いスループット。
受信ネットワークトラフィック タスクキュー(安定/低)
エグゼキュータ(OSスレッド1つ)
I/O yield
I/O yield
現実:コンピュートの落とし穴
1つのCPUタスクがエグゼキュータを停止させる。キューが爆発する。
受信ネットワークトラフィック タスクキュー(無制限)
OOMクラッシュ
エグゼキュータ(OSスレッド1つ)
高負荷コンピュートタスク(bcrypt、解析など)
スレッド停止
RustのTokioやNode.jsのような協調型ランタイムでは、スレッドはawaitポイントに到達するまでyieldしません。関数内の50ミリ秒のCPUバウンドタスクは、実行スレッド全体を停止させます。突然、数千もの無関係なネットワークリクエストのレイテンシが急増し、システムは応答しなくなります。その間、ハードウェアはほとんど利用されていません。
約束の破綻:ループ内の人間スケジューラ
これらのレイテンシスパイクが発生した場合、答えは常に同じです:ランタイムを分離してください。I/OにはTokioを使用し、CPUバウンドな作業はRayonのような専用スレッドプールに送信してください。最近のポストモーテムは、その結果生じる悲劇を強調しています。PostHog3とMeilisearch4のエンジニアリングチームは、本番環境でこれらの複雑さを解きほぐすという痛ましい現実を文書化しています。開発者は、各関数が「I/Oプール」または「コンピュートプール」のどちらに属するかを注意深く分析し、それらの間のメッセージパッシング境界を手動で調整する必要があります。開発者がI/Oとコンピュートを手動でパーティション分割し、デッドロックを防ぐために境界を厳密に監視し、2つの異なるメンタルモデルを持つ2つの異なるランタイム間でデータを転送する必要がある場合、async抽象化は失敗したことになります。言語機能は並行処理の複雑さを隠すことを約束しました。その代わりに、アプリケーション開発者をループ内の人間スケジューラに変えてしまったのです。
デフォルトで無制限は、デフォルトでOOM
async/awaitランタイムの2番目の障害モードは、無制限の容量をどれほどスムーズに実現するかです。tokio::spawn(...)を呼び出すのは安価です。トラフィックの急増中に下流のデータベースが遅くなった場合、イングレスネットワークループは接続を受け入れ、タスクを生成し続けます。これらのエコシステムでは、asyncタスクとメモリ割り当ては通常デフォルトで無制限であるため、システムは抵抗しません。インフライトタスクは無期限にキューイングされます。アプリケーションは、OSのメモリ不足(OOM)キラーがプロセスを強制終了するまでRAMを消費します。主要プラットフォームのポストモーテムは、一貫して同じ根本原因を明らかにしています:キューはオーバーロードを修正せず、クラッシュを遅延させるだけで、それを壊滅的なものにします。無限の容量は嘘であり、そうでないふりをするデフォルトは危険です。
ワークスティーリングの神話
システムがこれらのボトルネックに達すると、開発者はしばしば、負荷を分散するために、よりスマートで、プリエンプティブな、ワークスティーリングスケジューラを要求します。コアがアイドル状態であれば、公平性を保証するためにビジーコアからタスクを盗むべきだという仮定です。しかし、大規模なスケールでは、公平性はスループットの敵です。ワークスティーリングはCPUキャッシュの局所性を破壊します。WhatsAppが100コア以上のマシンでErlang BEAM仮想マシンを限界までプッシュしたとき、システムは詰まりました。Robin Morissetが詳述したように、アイドル状態のスレッドが作業を盗もうとすると、CPUサイクルのすべてをグローバルなrunq_lock5(スケジューラの実行キューへのアクセスを同期するために使用されるロック)との戦いに費やしました。
ワークスティーリングのコスト:キャッシュ局所性の喪失
CPUコア1
過負荷 L1 / L2 キャッシュ(ホット)
タスクAの状態データ
タスクBの状態データ
CPUコア2
アイドル L1 / L2 キャッシュ(コールド)
タスクBの状態はここにない
メインメモリ(DRAM)
すべてのコアで共有
タスクB
1. コア2がタスクBを「盗む」
2. キャッシュミス!
3. 約100ナノ秒のRAMフェッチペナルティ
最適化されたロックを使用しても、ステートマシンを別のCPUコアに移動すると、L1およびL2キャッシュを放棄することになります。盗まれた各タスクが100ナノ秒以上のメインメモリフェッチペナルティを発生させる場合、公平性は関係ありません。本番環境を乗り切るために、I/OタスクとCPUタスクを分離するためにすでに手動でスレッドをパーティション分割することを余儀なくされている場合、汎用ワークスティーリングアルゴリズムはすでにあなたを失望させています。あなたはワークロードのトポロジーをランタイムよりもよく理解しています。
代替案
私はasync/awaitの複雑さと落とし穴にうんざりしていました。BEAMの堅牢な耐障害性を求めていましたが、ガベージコレクションやグローバルワークスティーリングの不透明な操作なしで。Leslie Lamportが長年主張してきたように、ステートマシンは並行プログラミングの数学的に健全な基盤です。async/awaitは、ステートマシンを貧弱に隠そうとするコンパイラマジックにすぎません。ステートマシンを隠す代わりに、それを公開し、ユーザーにより良い制御プリミティブを与えるのはどうでしょうか?その結果がProject Tinaです。これは、意見を強く持った、共有なしの、スレッド・パー・コアの並行処理フレームワークです。
Tina:スレッド・パー・コア(共有なし)
ワークスティーリングなし • ミューテックスなし • 厳密なキャッシュ局所性
SHARD 0(コア0)
OSスレッド1つ
スケジューラーループ
アイソレートA(TCP接続)
アイソレートB(HTTP)
アイソレートC(ワーカー)
↻厳密なティックシーケンス
SHARD 1(コア1)
OSスレッド1つ
スケジューラーループ
アイソレートX(ルーター)
アイソレートY(TCP接続)
アイソレートZ(ワーカー)
↻厳密なティックシーケンス
メールボックス(ロックフリー)
メールボックス(ロックフリー)
共有メモリなし
Tinaは、高いスループットと信頼性を保証するために厳密な制約を採用しています。
1つのプリミティブ。1つのメンタルモデル。
asyncもawaitも、PromiseもFutureもありません。あなたはIsolate(並行作業の単位)を書きます。ハンドラは、メッセージに応答してEffectを返す標準的な同期関数です。
スレッド・パー・コア(共有なし)。TinaはワークロードをOSスレッド全体にシャードします。ワークスティーリングはありません。アイソレートは移動しません。すべてのクロス・コア通信はメッセージングサブシステムを介して行われます。
厳密に制限されている。メモリはプロセス起動時に事前割り当てられます。メールボックスは厳密に制限されています。トラフィックの急増が発生し、メールボックスがいっぱいになった場合、呼び出し元はすぐに通知されます。システムは、プロセスをOOMクラッシュさせるのではなく、予測可能に負荷を軽減します。
アーキテクチャの決定論。最新のasyncランタイムでは、タスクのポーリング順序とスレッドプールのスケジューリングは、不透明で非決定的なカオスの源です。タスクがいつ、どこでウェイクアップするかを正確に知ることはめったにありません。Tinaはこれを剥ぎ取ります。スケジューラは、コアごとの厳密で、可視的で、シングルスレッドのループです。