Async/Awaitのデザイン空間を探る
A Design Space Exploration of Async/Await (cel.cs.brown.edu)
要約
多くのプログラミング言語が提供するasync/await構文は、並行処理を直線的なコードのように記述することを目指していますが、その実装は言語間で大きく異なっています。本稿では、7つの主要なランタイムにおけるasync/awaitの9つの設計次元を分析し、その多様性と、それがプログラムの出力にどのように影響するかを詳細に解説しています。
全文翻訳
Async/Await の設計空間探索
多くのプログラミング言語が現在、並行処理を表現するために async/await キーワードを提供しています。その設計思想は一貫しており、並行プログラムを直線的なコードのように見せることです(例: Python, Rust, または Swift)。そのため、私たちは async/await を包含するパラダイムを、イベントループやコールバックを使用するのではなく、直線的非同期処理と呼ぶことにします。
直線的非同期処理のための言語設計は15年以上前から検討されてきました。このプロジェクトでは、次のことを理解したいと考えました: 言語間で async/await はどの程度似ているのか、あるいは異なっているのか? 短い答えは、予想よりもはるかに異なっているということです。その理由を説明するために、論文「“A Design Space Exploration of Async/Await”」を執筆しました。
Async/Await を知っていると思っていますか?
現代の言語がいかに多様化するかを示すために、擬似コードで書かれた小さな非同期プログラムを以下に示します。1つの関数がログに書き込み、もう1つの関数がログ書き込みをバックグラウンドタスクとして起動して先に進みます。
async fn write_to_log():
print("A")
// 遅いログ書き込みをシミュレート
await sleep(2)
print("B")
async fn fire_and_forget():
task = spawn write_to_log()
// タスクを待たずに戻る
async fn main():
await fire_and_forget()
await sleep(1)
print("C")
このプログラムは何を出力すると予想しますか?
本当に正しい答えはありません。なぜなら、あなたはある言語にとってはおそらく正しかったからです。以下は、7つの現代的な非同期ランタイムが実際にどのように動作するかを示しています。
4つの異なる答えが、バックグラウンドでログ行を書き込むだけのプログラムに対して得られました。そして、事態はさらに悪化します。論文では、7つのランタイム全体で、3つのバリエーションのこの単純なプログラムに対して、どの2つも同じ出力を生成しないことを示しています。
あなたは本当にあなたの言語の非同期セマンティクスを知っていますか?
なぜ意見が分かれるのか?
あるプログラミングインフルエンサーの動画を見ていると、「コールド」または「ホット」な非同期関数呼び出しという用語を聞いたことがあるかもしれません。「ホットスタート」はすぐにランタイムで実行されるタスクを返し、「コールドスタート」は待機されるまで何も行わない不活性なオブジェクトを返します。
ホット対コールド関数は、私たちが非同期の設計次元と呼ぶものです。これは、プログラム実行の観測可能なセマンティクスに影響を与える設計上の決定です(純粋なパフォーマンスの問題とは対照的です)。私たちはこの特定の次元を「積極性(Eagerness)」と呼び、論文では、現代の直線的非同期処理の実装から9つのそのような次元を特定しています。以下に、これらの9つの次元を、タスクのライフサイクルに大まかに対応する3つのカテゴリに分類しました: ライフの開始、ライフの終了、およびキャンセル。
言語をクリックすると、表を通してその設計上の選択をトレースできます。
ライフの開始
積極性(Eagerness)
非同期関数呼び出しを評価する方法。
- Lazy(遅延): コルーチンに評価され、それ以上の実行は行われない。
- Eager(積極的): 現在のスレッドで評価され、await時にタスクとしてスケジュールされる。
中断(Suspension)
await ポイントが中断することを保証するかどうか。
- Static(静的): await ポイントは中断することが保証される。
- Dynamic(動的): タスクの待機に関して保証はない。
ライフの終了
範囲(Extent)
タスクが存在できるデフォルトの時間間隔。
- Indefinite(無期限): デフォルトでは、タスクはランタイムの終了まで存在できる。
- Dynamic(動的): デフォルトでは、タスクはスポーンされたスコープの終了まで存在できる。
参照強度(Reference Strength)
(無期限の範囲の場合) ランタイムが保持するタスクへの参照のタイプ。
- Strong(強い): ランタイムは強い参照を保持する。
- Weak(弱い): ランタイムは弱い参照を保持する。
破棄(Destruction)
タスクの範囲の終わりにどのようにクリーンアップされるか。
- Awaited(待機): タスクは完了まで待機される。
- Cancelled(キャンセル): タスクはキャンセルされ、その後おそらく待機される。
- Terminated(終了): プログラムが終了する。
伝播(Propagation)
待機されていないタスク内の例外に何が起こるか。
- Destructive(破壊的): 例外は依存関係によって再発生する。
- Never(なし): 例外はタスク内に保持される。
キャンセル
認識(Awareness)
タスクがキャンセルに応答できるかどうか。
- Unaware(非認識): タスクはキャンセルに応答できない。
- Aware(認識): タスクはキャンセルに応答できる。
方向(Direction)
キャンセルがタスクグラフをどのように伝達されるか。
- Top-Down(トップダウン): ルートタスクから開始され、依存関係から依存関係へと伝達される。
- Bottom-Up(ボトムアップ): ルートの依存関係から開始され、依存関係へと伝達される。
- Simultaneous(同時): すべての推移的依存関係に同時に伝達される。
永続性(Persistence)
(認識されるキャンセルに対応) キャンセルの持続時間。
- Transient(一時的): タスクはキャンセルを無視して通常通り続行できる。
- Persistent(永続的): タスクはキャンセルを無視できるが、キャンセルされた状態を維持する。
これらの軸のうち2つは、私たちの例のプログラムに特に重要です。動的範囲(Dynamic Extent)を持つ言語は、タスクがスポーンされた関数よりも長く存続することを許可しません。他の言語とは異なり、SwiftとPython+Trioは動的範囲を選択しました。これは、fire_and_forget 関数内では、write_to_log に関連付けられたタスクは fire_and_forget 関数よりも長く存続できないことを意味します。
SwiftとTrioはどちらも動的範囲を選択しましたが、破棄(Destruction)の選択が異なります。fire_and_forget 関数のスコープの終わりに、Swiftはキャンセル破棄(Cancelled Destruction)を使用し、task をキャンセルします。一方、Trioは待機破棄(Awaited Destruction)を使用し、write_to_log が完了するのを丁寧に待ちます。範囲と破棄の選択が、Swiftが「AC」を出力し、Trioが「ABC」を出力する理由を説明しています。
各設計次元には、パフォーマンス、メモリ使用量、エルゴノミクス、セマンティクスなどのトレードオフがあります。正解も間違いもなく、各言語には独自の設計思想があります。しかし、これほど多くの決定事項があると、たとえ小さなプログラムであっても、その出力の説明は非常に複雑になります!
設計空間をより正確にするために、非同期プログラムのコア計算に形式意味論を適用しました。このモデルにより、実行をトレースすることで、サンプルプログラムがどのように分岐するかを正確に説明できます。
下の図は、この形式モデルが異なる実行結果を説明するためにどのように使用できるかを示しています。この図はモデルのトレースを示しており、異なる結果につながる意味論的な決定を強調しています。各ボックスは抽象マシンの状態です。各矢印は、発火したルールでラベル付けされた小ステップの削減です。ほとんどのルールはどのランタイムでも同じように動作します。ハイライトされたルールは設計上の決定であり、各ハイライトは分岐点です。
async fn write_to_log():
print “A”; await sleep(2); print “B"
async fn fire_and_forget():
task = spawn write_to_log()
async fn main():
await fire_and_forget();
await sleep(1); print “C"
block_on(main())
[図: Async/Await の設計空間探索における実行トレースの例。左側はプログラムの構造を示し、中央は抽象マシンの状態遷移とルールの適用を示し、右側は異なるランタイム(Asyncio, C#, JavaScript, Tokio, Smol, Trio, Swift)における実行結果の分岐を示している。]
この図を理解し、お気に入りの言語の非同期/awaitシステムの設計決定を理解するには、新しい論文「“A Design Space Exploration of Async/Await”」をお読みください!