従来の async/await
.NET の非同期処理といえば、長いあいだ async/await が中心だった。見た目は同期コードに近く、複雑になりやすい非同期処理を素直に書ける。
この async/await は、内部では CPS(Continuation-Passing Style、継続渡しスタイル)変換によって実現されている。
まず async キーワードで、非同期メソッドであることをコンパイラに知らせる。ここが CPS 変換の起点になる。ただし、async/await の仕組みそのものに async が欠かせないわけではない。C# で async が必要なのは、メソッド内の await が単なる識別子ではなく、処理の中断位置を示すキーワードだとコンパイラに伝えるためだ。同じ async/await モデルを採用する C++ では、 async キーワードを必要としない。
await は、コンパイラに「ここで処理が止まるかもしれない」と教える印で、コンパイラは await を境にメソッドをいくつかのコード片に分け、待っていた非同期処理が終わったあとに続きを実行する。
簡単な例を見てみよう。
public async Task<int> GetDataAsync()
{
// 非同期処理を想定して 1 秒待つ
await Task.Delay(1000);
return 42;
}
この例では、 Task.Delay(1000) がまだ完了していなければ、 await で GetDataAsync の実行をいったん止める。1 秒後に処理を再開し、42 を返す。ざっくり言えば、非同期メソッドを await ごとに分けておき、待っていた処理が終わったら続きを呼び出す仕組みだ。
class StateMachine
{
private int state = 0;
// 結果を保持する Task<int> を作る。
// 現在の非同期メソッドが完了すると、この Task<int> も完了状態になる。
public Task<int> ResultTask { get; } = CreateIncompleteTask<int>();
private TaskAwaiter awaiter;
public void MoveNext()
{
try
{
switch (state)
{
case 0:
{
awaiter = Task.Delay(1000).GetAwaiter();
if (!awaiter.IsCompleted)
{
// 再開位置を記録する。
state = 1;
// continuation を登録する。
// Task.Delay が完了すると、登録済みの continuation が呼ばれ、
// ステートマシンの MoveNext が再び実行される。
// continuation が最終的にどこで実行されるかは、awaiter のほか、
// 現在の SynchronizationContext や TaskScheduler などによって決まる。
awaiter.OnCompleted(MoveNext);
return;
}
goto case 1;
}
case 1:
{
state = -1;
// await した操作が正常に完了したことを確認する。
// 失敗していれば、ここで例外がスローされる。
awaiter.GetResult();
// Task<int> を完了させ、結果として 42 を設定する。
CompleteTask(ResultTask, 42);
return;
}
}
}
catch (Exception ex)
{
// MoveNext で例外が発生した場合は、Task<int> を失敗状態にする。
// これにより、呼び出し元は await GetDataAsync() で例外を受け取り、処理できる。
FailTask(ResultTask, ex);
}
}
}
このように、非同期メソッドはステートマシンに変換され、 await の位置ごとに状態が分かれる。待っていた処理が完了すると、ステートマシンが続きのコードを実行する。 GetDataAsync メソッドは、イメージとしては次のようにコンパイルされる。
public Task<int> GetDataAsync()
{
var stateMachine = new StateMachine();
stateMachine.MoveNext();
return stateMachine.ResultTask;
}
ここで使った CreateIncompleteTask と CompleteTask は、仕組みを説明するための疑似コードだ。実際に C# コンパイラが生成するコードは Task を直接操作せず、 AsyncTaskMethodBuilder<int> を使って、非同期メソッド全体を表す Task<int> を作り、完了させる。
従来の async の制約
async/await はとても便利だが、その便利さはタダではない。
まず、C# コンパイラが非同期メソッドを変換する時点では、その呼び出しが本当に中断するかどうか分からない。実際には、中断せずにそのまま完了する非同期メソッドも多い。
public async Task<int> GetDataAsync()
{
return await GetValueAsync();
}
public async Task<int> GetValueAsync()
{
return 42;
}
C# コンパイラは、どちらのメソッドにもステートマシンと Task<int> を生成する。変換はメソッド単位で行われるため、 GetDataAsync をコンパイルするときに GetValueAsync の中身まで見通せない。
ここで、「C# コンパイラには無理でも、実行時の JIT なら何とかしてくれるのでは?」と思うかもしれない。
残念ながら、そうはいかない。C# コンパイラは非同期メソッドをステートマシンに書き換え、 MoveNext 、awaiter、method builder を使って実行を進める。JIT の手元に届くころには、 A -> await B -> await C という元の呼び出し関係はばらばらになっている。JIT が見るのは、ステートマシン、 Task 、awaiter、continuation が複雑に連携するコード。これでは、本来ならメソッドをまたいで行えた最適化も難しい。
呼び出しチェーン全体が一度も中断しないなら、本来は通常の同期メソッドと同じように実行できる。ところが C# コンパイラが元の非同期処理を先に分解してしまうため、JIT があとから元の形を組み立て直すのは難しい。
しかも、 MoveNext には非同期メソッド全体のロジックが詰め込まれるため、大きなメソッドになりやすい。JIT はコードサイズを理由に MoveNext のインライン化を避けることが多く、呼び出しチェーン全体を見通せなくなる。複数の非同期呼び出しをまとめてインライン化するのも、同じ理由で難しい。
もう一つの問題はメモリ割り当て。async/await では、非同期メソッドが Task または Task<T> を返す。通常は呼び出すたびに新しい Task オブジェクトが作られるため、性能を追い込む場面では無視できない。そこで登場したのが ValueTask だ。値型の戻り値と IValueTaskSource による再利用で、余分な割り当てを減らしている。
もちろん、本当に中断する処理なら、ここまでの話はそれほど大きな問題ではない。JIT が呼び出しチェーン全体を見通せても、中断そのものは消せない。あとで処理を再開する以上、結果や完了状態を持つ Task オブジェクトも必要になる。
問題は、中断せずに完了する非同期メソッドが意外に多いっていう絶望的な事実が成り立っている。呼び出しチェーンが深いコードや、非同期モデルを使った分散計算システムでは、この傾向が特に強い。
- 呼び出しチェーンの一番奥だけが中断し、上位の非同期メソッドは結果を受け渡すだけということが多い。
- 非同期メソッドから同期メソッドを呼び、その同期メソッドが別の非同期メソッドを呼ぶなど、チェーン全体が同期的に動くこともある。
- 非同期モデルを採用した分散計算システムでも、見た目は非同期なのに、実際の負荷の大半は同期処理ということがある。
それでも従来の async/await は、非同期メソッドを見るたびにステートマシンを作るので、中断しないメソッドまで、きっちりオーバーヘッドという代金を請求されるわけ。
Green Thread
Runtime Async の話に入る前に、少し寄り道しよう。.NET チームは以前、Green Thread も試していた。これは goroutine や Java の Virtual Thread のような軽量スレッドをユーザーモードで実装し、スレッド切り替えのコストを抑える方式だった。
発想は魅力的だったが、実際に作ってみると避けにくい問題がいくつも出てきた。
Green Thread は軽量といっても、完全な実行コンテキストであることに変わりはない。少なくともレジスタの状態、コールスタック、ランタイムのスケジューリングに必要なメタデータを保存する必要がある。たとえば goroutine のユーザースタックは、初期状態で約 2 KB あり、必要に応じて動的に拡張される。2 KB あれば async ステートマシンを数百個作れるので、「軽量」の一言で片付けるには少々重い。
Green Thread では、ランタイムがユーザーモードでスレッドのスケジューリングまで引き受ける。言い換えると、スケジューリングのハンドルをランタイムが握ることになる。開発者の側から細かく制御するのは難しい。
システムコールでは、さらに雲行きが怪しくなる。Green Thread 自体は OS スレッドではないため、実際のシステムコールは Green Thread を支える OS スレッドが実行する。そのたびに Green Thread と OS スレッドの切り替え、中断、再開といった処理が増え、コストが通常の数十倍になることもある。.NET の実験では、システムコールを 1 億回実行する時間が約 300 ms から約 1,800 ms に増え、5 倍以上の低速化だった。
ハードウェアのセキュリティ機構との相性もよくない。たとえば Intel CET の Shadow Stack は、保護された戻りアドレス用のスタックをハードウェアが管理し、関数から戻るときに通常のコールスタック上の戻りアドレスと一致するか確認する。Green Thread はユーザーモードでコールスタックを切り替えるため、ランタイムは通常のスタックポインタだけでなく、実際に動いている OS スレッドの Shadow Stack まで正しく管理しなければならない。ハードウェアによる制御フロー保護とうまく連携するのは難しく、OS 側の特別なサポートが必要になる場合もある。
スレッド親和性も厄介だ。Green Thread はランタイムによってスケジュールされるため、再開後も元と同じ OS スレッドで動く保証はない。ところが GUI や一部の OS API、スレッドローカルな状態に依存するコードなど、特定の OS スレッドでなければ動かせない処理は山ほどある。その場合は Green Thread を特定の OS スレッドに固定するか、対象のコードを実行するたびに追加のスケジューリングと切り替えが必要になる。GUI アプリのメッセージループでは、スレッド親和性のある API を毎秒数万回呼ぶことも珍しくない。ここまで頻度が高いと、Green Thread のスケジューリングコストが膨らみ、OS スレッドを直接使うより遅くなることさえある。
そして決定打になったのが ASP.NET Core での結果だった。RPS(1 秒あたりのリクエスト数)は上がるどころか、まさかの低下だった。これだけの制約を飲み込んで、しかも従来の async/await より遅かったのでは割に合わなかった。.NET チームは Green Thread の実験を打ち切り、Runtime Async の開発へ進んだ。
Runtime Async
では、どうすればよいのか。答えは驚くほど単純だ。C# コンパイラにステートマシンを作らせず、元の非同期制御フローをそのまま JIT に渡せばよい。この発想から Runtime Async が生まれた。
Runtime Async は、.NET ランタイムに Async Calling Convention(非同期呼び出し規約)を新しく導入する。内部では MethodImplOptions.Async でマークされるが、これはユーザーが直接指定するものではない。私たちが書くコードは、これまでと同じ async/await のまま。
async Task<int> A()
{
return await B();
}
従来の async で JIT が見るのは、C# コンパイラが作った MoveNext ステートマシンだった。Runtime Async では、JIT がメソッド本来の非同期制御フローを直接見られる。そのうえで、特殊な Async Calling Convention に従うコードを生成する。この呼び出し規約では、通常の引数に加えて Continuation オブジェクトも渡す。
通常のメソッド呼び出しが、次の形だとしよう。
result = B(args);
Runtime Async では、ここに continuation が一つ増える。
(result, continuation) = B(continuation, args);
この continuation には、呼び出しチェーンが中断したあと、処理を再開するために必要な状態が入る。
非同期メソッドを最初に呼ぶときは、 Continuation に null を渡す。復元する状態はまだないので、メソッドは通常の同期メソッドと同じように先頭から動き始める。そのまま一度も中断しなければ、通常の結果と null の Continuation を返す。呼び出し元は、 Continuation が null なら同期的に完了したと判断できる。
一方、 await まで来ても待っている処理が終わっていなければ、呼び出しチェーンを中断する。ランタイムは再開に必要な状態を Continuation オブジェクトに保存し、呼び出し元に返す。呼び出し元は Continuation が null でないことを見て、「今回は中断した」と分かる。待っている処理が終わったら、この Continuation を使って実行を再開する。
待っていた処理が完了すると、ランタイムは Runtime Async メソッドをもう一度呼び、先ほど保存した Continuation を追加の引数として渡す。メソッドは前回止まった位置から再開し、呼び出しチェーン全体が終わるまで実行を続ける。
ここが Runtime Async の肝。通常の戻り値と追加の Continuation は、どちらも呼び出し規約に含まれる。わざわざオブジェクトに包まず、レジスタで直接受け渡せる。通常の戻り値の ABI は変えず、 Continuation をもう一つの戻り値として返す。対象アーキテクチャの呼び出し規約が許せば、両方ともレジスタに載る。
一度も中断しなかった呼び出しチェーンでは、データの受け渡し方が通常の同期メソッドとほとんど変わらない。引数も戻り値も Continuation もレジスタを通る。async 呼び出しのたびに結果を包むオブジェクトを作る必要がなく、その分のオーバーヘッドは消える。
つまり、宣言には Task<T> と書かれているのに、一度も中断しなければ Task<T> の実物は登場しない。呼び出しチェーンの中では、 T の値がそのまま返る。
JIT が非同期制御フローの全体を見られるのも大きい。メソッドをまたいだ最適化や、非同期呼び出しのインライン化まで狙えるようになる。
生成コードを見る
実際の生成コードも見てみよう。例えばフィボナッチ数を再帰的に計算する非同期メソッドがあるとしよう。
class Program
{
async Task<int> Fib(int n)
{
if (n <= 1)
return n;
return await Fib(n - 1) + await Fib(n - 2);
}
}
ここでまずいったん ILSpy を使って、コンパイル後の IL を C# 形式で見てみると
internal class Program
{
[MethodImpl(MethodImplOptions.Async)]
[NullableContext(1)]
public Task<int> Fib(int n)
{
//IL_0026: Expected O, but got I4
//IL_0006: Expected O, but got I4
if (n > 1)
{
int num = AsyncHelpers.Await(Fib(n - 1));
int num2 = AsyncHelpers.Await(Fib(n - 2));
return (Task<int>)(num + num2);
}
return (Task<int>)n;
}
}
もともとのような C# コード以外に、ステートマシンや Task の操作は一切見えない!
実行時に JIT が生成するコードは、おおむね次のようになる。まあ長いが見るべきところはあとで順に拾っていくのでまだひるむ必要はない。
Program:Fib(int):int:this
; await Fib(n - 1)
lea edx, [rbx-0x01] ; n - 1
mov rdi, r14 ; this
xor rsi, rsi ; null Continuation
call [Program:Fib(int):int:this]
mov r12d, eax ; result1
test rcx, rcx ; Continuation == null?
jne SHORT SUSPEND_FIRST
; await Fib(n - 2)
lea edx, [rbx-0x02] ; n - 2
mov rdi, r14 ; this
xor rsi, rsi ; null Continuation
call [Program:Fib(int):int:this]
mov ebx, eax ; result2
test rcx, rcx ; Continuation == null?
jne SHORT SUSPEND_SECOND
; 両方の呼び出しが同期的に完了したため、結果を直接返す。
add ebx, r12d
mov eax, ebx ; 戻り値
xor ecx, ecx ; null Continuation
ret
SUSPEND_FIRST:
; Fib(n - 1) が中断したため、現在の実行状態を保存する Continuation を作る。
mov rdi, rcx
mov rsi, 0x... ; Continuation
call [CORINFO_HELP_ALLOC_CONTINUATION]
mov r12, rax
mov dword ptr [r12+0x48], ebx ; n の値を保存する
; ... そのほかの必要な状態を保存する ...
mov rcx, r12 ; Continuation を返す
ret
SUSPEND_SECOND:
; Fib(n - 2) が中断したため、現在の実行状態を保存する Continuation を作る。
mov rdi, rcx
mov rsi, 0x... ; Continuation の型
call [CORINFO_HELP_ALLOC_CONTINUATION]
mov r15, rax
mov dword ptr [r15+0x4C], r12d ; Fib(n - 1) の結果を保存する
; ... そのほかの必要な状態を保存する ...
mov rcx, r15 ; Continuation を返す
ret
; --------------------------------------------
Program:Fib(int):Task<int>:this
mov rdi, rbx ; this
mov edx, r15d ; n
xor rsi, rsi ; null Continuation
call [Program:Fib(int):int:this] ; 実際の Runtime Async メソッドを呼ぶ
mov ebx, eax ; 結果
test rcx, rcx ; Continuation == null?
jne THUNK_SUSPENDED
; Task.FromResult(ebx) を返す
mov rax, <Task<int>>
ret
THUNK_SUSPENDED:
; var task = new RuntimeAsyncTask<int>();
; continuation を task に関連付ける。
; task を返す。
まず目につくのが、Async Calling Convention を使う内部版の Program:Fib(int):int:this というところ。戻り値の型は、元の Task<int> ではなく int になっている。
x64 では、 this ポインタ、 Continuation ポインタ、 n の値をそれぞれレジスタで渡す。
mov r14, rdi ; this
mov r15, rsi ; Continuation
mov ebx, edx ; n
Runtime Async メソッドを初めて呼ぶときは、復元する状態がまだない。そのため、 Continuation には null を渡す。
たとえば、最初の再帰呼び出しは次の部分だ。
await Fib(n - 1)
JIT はこれを次のようにコンパイルする。
lea edx, [rbx-0x01] ; n - 1
mov rdi, r14 ; this
xor rsi, rsi ; Continuation = null
call [Program:Fib(int):int:this]
この Fib(n - 1) は、実際には二つの値を返している。
eax = Fib の int 戻り値
rcx = Continuation
ここで返しているのは、 (int, Continuation) というタプルではない。x64 の ABI 上で、 int と Continuation を別々のレジスタに載せて返している。
呼び出し元は、次の数命令だけで戻り値を受け取り、中断したかどうかまで判断できる。
mov r12d, eax
test rcx, rcx ; Continuation は null か
jne SUSPEND ; null でなければ中断が発生した
rcx == null なら、呼び出しは同期的に完了している。 eax には有効な戻り値が入っているので、そのまま次の処理へ進めばよい。
lea edx, [rbx-0x02]
mov rdi, r14
xor rsi, rsi
call [Program:Fib(int):int:this] ; 2 回目の再帰呼び出し Fib(n - 2)
C# 風の疑似コードにすると、次のような流れだ。
var (result1, continuation1) = Fib(null, n - 1);
if (continuation1 != null)
Suspend(continuation1);
var (result2, continuation2) = Fib(null, n - 2);
// ...
では、 Continuation が null でなかったらどうなるのか。こちらも生成コードにそのまま表れている。
最初の再帰呼び出しの直後には、次の判定がある。
call [Program:Fib(int):int:this]
mov r12d, eax
test rcx, rcx
jne SHORT SUSPEND
rcx != null なら、呼び出した Fib は同期的に完了していない。そこで、現在実行中の Fib も中断する。
ここで初めて現在の実行状態を保存する Continuation が作られる。中断するかもしれないという理由だけで、先回りして作ることはない。
mov rdi, rcx
mov rsi, 0x... ; Continuation の型
call [CORINFO_HELP_ALLOC_CONTINUATION]
mov r12, rax
続いて、再開後も必要になるローカル変数などを Continuation に保存する。
mov dword ptr [r12+0x48], ebx
最後に、作成した Continuation を rcx に入れ、Async Calling Convention に従って一つ上の呼び出し元へ返す。
mov rcx, r12
ret
Green Thread と違い、Runtime Async の Continuation は小さい。保存するのは、 await の前後で値を保つ必要があるローカル変数、再開位置、待機中の処理の戻り値や例外など、ごく少量の情報だけ。必要なサイズは数十バイト程度。初期スタックだけで約 2 KB あった Green Thread と比べると、ずいぶん身軽になっている。
ここまでの流れを C# 風の疑似コードにまとめると、次のようになる。
var (result1, continuation1) = Fib(null, n - 1);
if (continuation1 != null)
Suspend(continuation1);
var (result2, continuation2) = Fib(null, n - 2);
if (continuation2 != null)
Suspend(continuation2);
return result1 + result2;
ただし、このフィボナッチの例では、すべての呼び出しが同期的に完了する。結局は再帰呼び出しを繰り返すだけなので、実際の動きは次の同期コードと同じだ。
var result1 = Fib(n - 1);
var result2 = Fib(n - 2);
return result1 + result2;
呼び出しチェーン全体で Task オブジェクトは一つも作られず、ステートマシンのコストもない。async/await のオーバーヘッドがきれいさっぱり消え、通常の同期メソッドと同じように動く。これは従来の async とは根本的に違う。
ここで、「 Program:Fib(int):int:this があるのに、なぜ Program:Fib(int):System.Threading.Tasks.Task`1[int]:this まで残っているのか」と疑問に思うかもしれない。
Runtime Async の内部では新しい Async Calling Convention を使うが、通常の C# コードから見た Fib のシグネチャは今も Task<int> Fib(int) のままだ。この二つをつなぐため、間に async thunk と呼ばれる薄いラッパーを挟む。この例では、Runtime Async 内部から返された通常の戻り値と Continuation を、外側の呼び出し元が期待する Task<int> に変換する。
thunk の中にも、先ほどとよく似たコードがある。
xor rsi, rsi
call [Program:Fib(int):int:this]
mov ebx, eax
test rcx, rcx ; Continuation は null か
まず本体の Runtime Async メソッドを呼び、返ってきた Continuation が null かどうかを確認する。 null なら同期的に完了しているので、結果を Task<int> オブジェクトで包んで返せばよい。将来この thunk がインライン化され、 Task が外へ逃げないと JIT が判断できれば、エスケープ解析でこの割り当てまで消せるはず。
ベンチマーク
ここで実際にベンチマークしてみた。テストコードは GitHub Gist で公開している。
テストケースは以下の通り。
-
Synchronous baseline:通常のメソッドを直接呼ぶ同期処理の基準値。 -
Async method, no suspension:中断せずに完了する非同期処理。 -
Completed Task await:完了したTaskをawaitする非同期処理。 -
Completed ValueTask await:完了したValueTaskをawaitする非同期処理。 -
Task.Yield suspension:Task.Yieldで中断する非同期処理。 -
ThreadPool continuation:スレッドプールに戻る非同期処理。 -
TaskCompletionSource continuation:TaskCompletionSourceによって中断する非同期処理。 -
Async state-machine chain:非同期呼び出しを何段も重ね、最も内側のTask.Yieldで中断する処理。
執筆時点で最新の .NET 11 デイリービルドに含まれる Runtime Async(Async2)と、.NET 10 の従来の async(Async1)でそれぞれウォームアップ後、各テストを 1 億回実行して比べた。
ベンチマーク結果のデータは以下の通り。
| Benchmark | Ops | Async1 Time/op | Async2 Time/op | Ratio | Async1 Throughput | Async2 Throughput | Async1 Total Alloc | Async2 Total Alloc | Async1 Bytes/op | Async2 Bytes/op | Async1 Gen0 | Async2 Gen0 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Synchronous baseline | 100.0M | 0.33 ns | 0.33 ns | 1.00× | 3.008B ops/s | 3.004B ops/s | 696 B | 696 B | 0 | 0 | 0 | 0 |
| Async method, no suspension | 100.0M | 6.58 ns | 0.34 ns | 19.63× | 152.0M ops/s | 2.984B ops/s | 7.20 GB | 696 B | 72.0000 | 0 | 459 | 0 |
| Completed Task await | 100.0M | 4.01 ns | 0.33 ns | 12.02× | 249.1M ops/s | 2.995B ops/s | 7.20 GB | 696 B | 72.0000 | 0 | 459 | 0 |
| Completed ValueTask await | 100.0M | 0.75 ns | 0.33 ns | 2.25× | 1.329B ops/s | 2.987B ops/s | 696 B | 696 B | 0 | 0 | 0 | 0 |
| Task.Yield suspension | 100.0M | 242.89 ns | 34.68 ns | 7.00× | 4.12M ops/s | 28.84M ops/s | 992 B | 1,000 B | 0 | 0 | 0 | 0 |
| ThreadPool continuation | 100.0M | 324.78 ns | 102.35 ns | 3.17× | 3.08M ops/s | 9.77M ops/s | 16.00 GB | 15.20 GB | 160.0000 | 152.0000 | 1,027 | 969 |
| TaskCompletionSource continuation | 100.0M | 455.50 ns | 114.16 ns | 3.99× | 2.20M ops/s | 8.76M ops/s | 16.00 GB | 16.00 GB | 160.0001 | 160.0000 | 1,027 | 1,021 |
| Async state-machine chain | 100.0M | 678.33 ns | 91.68 ns | 7.40× | 1.47M ops/s | 10.91M ops/s | 30.10 GB | 19.20 GB | 300.9802 | 192.0000 | 1,927 | 1,226 |
結果は衝撃的、、、中断しないケースでは、Runtime Async が従来の async のオーバーヘッドを完全に消している。実行速度は約 20 倍、同期メソッドの基準値とほとんど変わらない。
しかも、速くなるのは中断しない場合だけではない。ThreadPool continuation と TaskCompletionSource continuation は 3 倍から 4 倍、呼び出しチェーンの深い Async state-machine chain は 7.4 倍高速になった。チェーンが深いほど、Runtime Async の強みが効いてくる。
メモリ割り当ても、すべてのテストで従来の async より少ない。特に中断しないテストでは、1 回あたりの割り当てが 0 バイトになり、Gen0 GC も発生していない。呼び出しチェーン全体で Task オブジェクトを一つも作らず、ステートマシンのコストも払っていないことが数字から分かる。
おわりに
Runtime Async は、.NET 11 で導入される新しい非同期実行の仕組みだ。C# コンパイラが async メソッドを先回りしてステートマシンに変換するのではなく、元の非同期制御フローを実行時まで残し、JIT が直接処理して最適化する。
これで、いわゆる pay for play(使った機能の分だけコストがかかる設計)が本当の意味で実現する。中断しなければ、非同期処理のための余分なコストはほぼゼロ。中断した場合も、実際に保存する状態の分だけで済む。
止まらない async は同期コードのように走り、止まるときだけ必要な分を払う。Runtime Async の狙いは、突き詰めればそれだけだ。

