プログラミング
Async/Awaitのデザイン空間を探る
A Design Space Exploration of Async/Await (cel.cs.brown.edu)
要約
多くのプログラミング言語が提供するasync/await構文は、並行処理を直線的なコードのように記述することを目指していますが、その実装は言語間で大きく異なっています。本稿では、7つの主要なランタイムにおけるasync/awaitの9つの設計次元を分析し、その多様性と、それがプログラムの出力にどのように影響するかを詳細に解説しています。
全文翻訳
多くのプログラミング言語が現在、並行処理を表現するためにasync/awaitキーワードを提供しています。その設計思想は非常に一貫しており、並行プログラムを直線的なコードのように見せることを目的としています(例:Python、Rust、Swift)。そのため、イベントループやコールバックを使用するのではなく、このパラダイムを直線的非同期処理と呼んでいます。
直線的非同期処理のための言語設計は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
コルーチンをさらに実行せずに評価する。
Python · Rust
Eager
現在のスレッドで評価し、await時にタスクとしてスケジュールする。
C# · JavaScript
サスペンション (Suspension)
awaitポイントが中断することを保証するかどうか。
Static
Awaitポイントは中断が保証される。
JavaScript
Dynamic
タスクを待機することについて保証はない。
C# · Swift · Tokio · Smol · Asyncio · Trio
ライフの終了
範囲 (Extent)
タスクが存在できるデフォルトの時間間隔。
Indefinite
タスクはデフォルトでランタイムの終了まで存在する可能性がある。
JavaScript · C# · Tokio · Smol · Asyncio
Dynamic
タスクはデフォルトで、それらが生成されたスコープの終了まで存在する可能性がある。
Swift · Trio
参照強度 (Reference Strength)
[Indefinite Extentの場合] ランタイムがタスクに対して保持する参照の種類。
Strong
ランタイムは強い参照を保持する。
JavaScript · C# · Tokio
Weak
ランタイムは弱い参照を保持する。
Asyncio · Smol
破棄 (Destruction)
範囲の終わりにタスクがどのようにクリーンアップされるか。
Awaited
タスクは完了まで待機される。
JavaScript · Trio
Cancelled
タスクはキャンセルされ、その後可能であれば待機される。
Swift · Tokio · Smol · Asyncio
Terminated
プログラムが終了する。
C#
伝播 (Propagation)
未待機タスク内の例外がどうなるか。
Destructive
例外は依存関係によって再発生する。
Trio
Never
例外はタスク内に保持される。
JavaScript · C# · Tokio · Smol · Asyncio · Swift
キャンセル認識 (Cancellation Awareness)
タスクがキャンセルに応答できるかどうか。
Unaware
タスクはキャンセルに応答できない。
Rust
Aware
タスクはキャンセルに応答できる。
Asyncio · Trio · Swift
方向 (Direction)
キャンセルがタスクグラフをどのように通信するか。
Top-Down
ルートタスクから開始し、依存関係から依存関係へと通信する。
Rust
Bottom-Up
ルートの依存関係から開始し、依存関係へと通信する。
Asyncio · Trio
Simultaneous
すべての推移的依存関係に同時に通信する。
Swift
永続性 (Persistence)
[Aware Cancellationの場合] キャンセルの持続時間。
Transient
タスクはキャンセルを無視して通常通り続行できる。
Asyncio
Persistent
タスクはキャンセルを無視できるが、キャンセルされたままである。
Trio · Swift
これら2つの軸は、私たちの例のプログラムに特に重要です。Dynamic Extentを持つ言語では、タスクが生成された関数よりも長く存在することを許可しません。他の言語とは異なり、SwiftとPython+TrioはDynamic Extentを選択しました。これは、fire_and_forget関数内で、write_to_logに関連付けられたタスクがfire_and_forget関数よりも長く存在できないことを意味します。
SwiftとTrioはどちらもDynamic Extentを選択しましたが、Destructionの選択が異なります。fire_and_forget関数のスコープの終わりに、SwiftはCancelled Destructionを使用しタスクをキャンセルしますが、TrioはAwaited Destructionを使用し、write_to_logが完了するのを丁寧に待ちます。ExtentとDestructionの選択が、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())
out: εacjkmrsC[ spawn write_to_log() ]
out: εacjkmrsC[ task ]
T: (2, write_to_log, print "B")
out: AcjC[ task; cancel task; try await task catch e -> ()]
Q: print "A"; await sleep(2); print "B"
out: εsC[ spawn coro ]
out: εakmrC[ task ]
Q: print "A"; await sleep(2); print "B"
out: εakmC[ task; await task ]
Q: print "A"; await sleep(2); print "B"
out: εr
block_on(())
T: (2, write_to_log, print "B")
out: AC
acjkC[ throw "cancelled" ]
out: As(); await sleep(1); print "C"
out: As(); await sleep(1); print "C"
out: εm(); await sleep(1); print "C"
out: ABC
rBlock-WaitAsync-AppAwaitAsync-AppeagerOS-IOAsync-Appsemi-eagerAsync-ApplazySpawndynamicSpawnindefiniteOS-IOSignalScheduleScheduleOS-IOSignalCancel-UnstartedOS-IOSignalScheduleBlock-DoneScheduleOS-IOSignalAwait-TaskOS-IOSignalScheduleBlock-DoneScheduleOS-IOAwaitCatch-ExnOS-IOSignalScheduleBlock-DoneBlock-DoneterminatedSignalScheduleBlock-Doneawaited
languages
a = Asyncio
c = C#
j = JavaScript
k = Tokio
m = Smol
r = Trio
s = Swift
この図と、お気に入りの言語のasync/awaitシステムを設計する際に考慮された決定事項を理解するには、新しい論文「A Design Space Exploration of Async/Await」をお読みください!