はじめに
まず、同期コードと非同期コードについて少し話しましょう。
一般に、コードは「同期」と「非同期」に分けられます。どちらも最終的には処理の完了を待ちますが、同期は現在のスレッドをブロックして処理が終わるまで次へ進みません。一方、非同期はスレッドをブロックせず、開始時に完了後の処理を登録しておき、操作が終わったタイミングでその処理がトリガーされます。
ここで悩ましいのは、同期コードと非同期コードでは書き方がまるで違うことです。
async/await が登場する前は、非同期処理にコールバック関数を渡し、完了時にそのコールバックを呼んでもらうのが一般的でした。その結果、ロジックがコールバックに分散したり、入れ子が増えて「コールバック地獄」になったりします。さらに、コールバックは呼び出し側から渡す必要があるため、呼び出し側があらかじめ「完了時に何をするか」を知って持ち回らねばならず、自然な思考の流れに反します。ある操作の完了は複数の場所から関心を持たれうるのに、操作を始める側が「待つ側」の事情に依存してしまうのは本来望ましくありません。
async/await の登場は、こうした状況を根本から変えました。
async/await
今日、async/await は分類上は今も「スタックレス・コルーチン」の一種ですが、初期のように再帰・エラー処理・スタックトレースに大きな制約があった姿から大きく進化しています。これらの制約はかなりの部分で解消されました。
.NET における async/await の本質は、コンパイラが非同期メソッドに CPS(Continuation-Passing Style)風の変換を施し、再開可能なステートマシンとして具体化することにあります。
具体例として、次のようなコードがあったとします。
async Task Foo()
{
A();
await B();
C();
await E();
F();
}
コンパイラは await を区切りに複数の「継続(continuation)」を生成し、それぞれに必要なローカル変数や実行コンテキストをキャプチャします。これによって継続は独立にスケジュール可能でありながら、await 直前の状態にアクセスできます。待っている操作が完了したときに、次の継続をスケジューラへ渡して実行を進められるわけです。非同期メソッドは各 await に到達するといったん停止し、後続のロジックが再スケジュールされてから続きます。つまり await は、非同期メソッドの潜在的な停止ポイントでもあります。
C# の初期版 async/await では、コンパイル時に IAsyncStateMachine を実装するステートマシンが生成され、スケジューラ/同期コンテキストが MoveNext を駆動して、各コード片が前の非同期操作の完了後に正しくスケジュールされるようになっていました。
ただし長らく、C# の async/await 実装には“境界”に関する課題がありました。C# コンパイラはメソッド単位でコンパイルするため、呼び出し先メソッドの実装詳細を境界越しに完全に見通すことはできませんし、managed ABI を勝手に変えてメソッドシグネチャを改変することもありません。そのため、非同期呼び出しチェーンを形成するとき、通常は各 async メソッドが自前のステートマシンを持ちます。全量の情報を越境して得られない以上、呼び出し元は例外や停止をカバーする一般的なパスを用意せざるを得ません。たとえば、呼び出し先がほとんどのケースで例外を投げないとしても、呼び出し点では例外捕捉と復帰のパスを残します。あるいは停止しない可能性が高い場合でも、正しい意味論のために停止/再開の分岐を残します。さらに、チェーン上の各非同期呼び出しを await で直に待つだけなら、本来は結果を Task などに包む必要がないのに、managed ABI を保つために毎段 Task に包む必要が出てきます。また、同期コンテキストが実質的にない場合でも、バックアップ/復元のコードを出力せざるを得ません。
結果として、JIT による最適化は難しく、余計な Task の割り当ても発生しやすく、C# の非同期コードは同期コードに比べて性能が伸び悩んできました。割り当て削減専用の ValueTask が生まれたのも、この事情が背景にあります。
.NET チームは .NET 8 以降、この状況を改善しようとしてきました。最初は Green Thread(goroutine や Java の Virtual Thread と同系)の実験を行いましたが、既存の async/await に比べて性能向上は得られず、ランタイム境界を越える呼び出しでは受け入れがたい性能劣化やスケジューリングの問題が見つかりました。この実験を終了したのち、.NET 9 からは async/await そのものを改善する方向に全力で舵を切り、ついに「Runtime Async」が登場します。なお、初期名称は「Async 2」でした。
Runtime Async
Runtime Async では、C# の書き方に変更は……ほぼありません。Runtime Async に対応した新しい C# コンパイラで再コンパイルするだけで、既存の async コードは自動的に“新しい async”へとアップグレードされます。ソースレベルの破壊的変更はありません。ただし、再コンパイルしていないアセンブリは自動では Runtime Async に切り替わりません。
従来の「コンパイラによる CPS 変換」に依存する実装と異なり、新しい Runtime Async はメソッド本体を書き換える必要がありません。ランタイム層に新しい async の ABI を導入し、非同期の制御フローをランタイムが直接担います。
Runtime Async では、メソッドが async の ABI に従うことを、async
という属性で表します(通常の C# の属性構文とは異なり、メソッド署名に直接刻まれる特別な属性です)。
たとえば次のコードがあるとします。
async Task Test()
{
await Test();
}
従来の C# コンパイラに渡すとステートマシンが生成されますが、Runtime Async を有効にした新しい C# コンパイラでコンパイルすると、次のような IL になります。
.method public hidebysig
instance class [System.Runtime]System.Threading.Tasks.Task Test() cil managed async
{
ldarg.0
call instance class [System.Runtime]System.Threading.Tasks.Task Program::Test()
call void [System.Runtime]System.Runtime.CompilerServices.AsyncHelpers::Await(class [System.Runtime]System.Threading.Tasks.Task)
ret
}
ステートマシンは完全に姿を消し、残るのはランタイムのヘルパーを呼び出す参考実装と、メソッド署名上の目を引く async マークだけです。
また、戻り値として書かれた Task 型は“参考”にすぎず、実行時に必ずしも Task の実体が生成されるわけではありません。C# から IL へコンパイルされたコード自体も“参考実装”であり、その IL が直接実行されるとは限りません。実際に動くコードには IL 上の表現がなく、私たちが書いた C# の関数は、実行される本体コードのトランク(いわば「ランチャー」)に過ぎません。非同期呼び出しチェーンの中で実体としては存在しないのです。
新しい非同期モデルでは、ある非同期メソッドから別の非同期メソッドを待つとき、JIT は一時停止ロジックを生成し、現在の状態を「継続(continuation)」オブジェクトにキャプチャします。「停止の伝播」が必要な場合は、非 null の継続を返します。呼び出し元は非 null の継続を受け取ると自分自身を停止し、自分の継続を作って返す——こうして呼び出し階層に沿って継続が連なるチェーンが形成されます。
再開時は、引数で非 null の継続が渡され、そこに記録された停止点(再開ポイントの識別子と考えてよい)へ飛んで処理を続けます。null の継続が渡された場合は、メソッドの先頭から実行を始めます。
この実装で増えるランタイム上のコストは、「継続が null かどうかを判定する」わずかな分だけです。ほぼ無視できるレベルと言ってよいでしょう。
この仕組みにより、ランタイムは managed ABI の制約に縛られず、メソッド境界を越えて積極的な全体最適化を行えます。
- 呼び出し先の非同期メソッドが例外を投げない? 例外処理のパスを削除!
- 同期コンテキストを使っていない? 退避/復元のロジックを削除!
- 実際には停止しない? 停止/再開の分岐をスキップ!
- 後で使われないローカル変数? ライフタイムを早めに終わらせてメモリ解放!
- などなど……
また、多くの非同期待機チェーンでは、結果を Task に明示的に包む必要がありません。そこでチェーン全体から Task という抽象を丸ごと取り除けます。JIT がコード生成する際、Task ではなく“結果そのもの”を直接受け渡せるため、ホットパスでゼロ割り当て、あるいはそれに近い挙動が実現できます。さらに、JIT が非同期メソッドを丸ごとインライン化できる余地が広がり、大幅な性能向上につながります。
Runtime Async は、多くのシナリオで非同期コードの性能を大きく引き上げ、同期コードに迫る、あるいは肩を並べる水準にまで到達します。同時に、割り当てとメモリ使用量を抑え、GC の負担を軽減します。しかも、ランタイム境界をまたぐ相互運用やタスクスケジューリングに悪影響を与えません。欲張りな要件を、うまく両立できていると言えるでしょう。
「色付き」問題は?
async/await の話題になると、よく「色付き関数(coloring)問題」が繰り返し語られます。これは、同じコードで同期と非同期の二つの意味を同時に成立させたい、という欲求から生じるものです。
全面的にコールバック式の非同期に寄せると、ロジックが分散して読みづらく、保守性も落ち、直感にも合いません。一方、全面をコルーチン化(例: goroutine)すると、そのランタイム内部ではうまく働いても、ランタイムの外側──たとえば FFI でネイティブ世界とやり取りするとき──に性能やスケジューリング面の大きな課題が出がちです。多くのネイティブライブラリは OS スレッドを境界モデルにしているため、境界越しの呼び出しがブロックされると、同じスレッド上で他のタスクを走らせないよう配慮が必要になり、余計なコストが生じます。また、スケジューリングがランタイムに強く結びつくため、どの OS スレッドで実行されるかをきめ細かく制御しづらく、外部から逆方向のコールバックが来たときに“元のスレッドへ戻る”のも簡単ではありません。クライアントアプリやゲームのようなスレッド親和性に敏感な分野では、相性が良くない場面もあります。
async/await のアプローチは、「見た目は同期」の書き味でありながら、ABI は同期とは別ルートを通す、というものです。コールバック式の性能上の利点を残しつつ、スケジューリングの柔軟性を確保し、保守性のコストも下げます。ただし代償として、結果を Task などの非同期型で包み、呼び出しチェーンに沿って“色(非同期の型)が伝播する”必要があります。抽象的には、Monad で非同期をモデル化していると捉えることもでき、1 つの非同期結果を複数箇所から同時に待てるようにしつつ、完了後はいつでも結果へアクセスできるようにしています。
この意味で、async/await は性能・保守性・相互運用性の間で良いバランスを取りやすい設計です。書き味とデバッグ体験は同期コードに近く、合成(タイムアウト・キャンセル・WhenAll/WhenAny など)も豊富です。さらに Task と同期コンテキスト/スケジューラの力を借りれば、必要に応じてスレッド親和性をきめ細かく制御できますし、FFI 越しの呼び出しに明確な境界を残せます。こうした理由から、C++、C#、F#、Rust、Kotlin、JavaScript、Python など幅広い言語に採用されてきました。
使い始めるには
.NET 10 RC1 から、Runtime Async は実験的なプレビュー機能として公開されています。いち早く試してみたい方は、次の手順で有効化できます。
まず注意点として、現時点の Runtime Async は実験的プレビュー段階で、いくつかの不具合が残っています。まだ本番環境での利用には適しません。また、標準ライブラリ自体は Runtime Async で再コンパイルされていないため、Runtime Async が効くのは「自分で書いた非同期コード」に限られます。標準ライブラリの非同期 API を呼び出す部分は従来の実装のままです。加えて、まだ未実装の最適化も多く、現段階でも従来よりだいぶ速くはなっていますが、正式版の Runtime Async が目指す水準には届いていません。NativeAOT への対応も予定されていますが、現状は間に合っていません。
それでは .NET 10 で Runtime Async を試すには、どうすればよいでしょうか?
まず、C# のプロジェクトファイルを編集して、プレビュー機能を有効化し、C# コンパイラの Runtime Async サポートをオンにします。
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<EnablePreviewFeatures>true</EnablePreviewFeatures>
<Features>$(Features);runtime-async=on</Features>
<NoWarn>SYSLIB5007</NoWarn>
<LangVersion>preview</LangVersion>
</PropertyGroup>
次に、環境変数 DOTNET_RuntimeAsync=1
を設定して、ランタイム側のサポートを有効にします。
これで Runtime Async の効果を体験できます。
かんたんなテスト
ここでは、フィボナッチ数列を再帰で計算するメソッドを用意し、その async 版と速度を比べてみます。
class Program
{
static async Task Main()
{
// Fib と FibAsync を tier 1 までウォームアップ
for (var i = 0; i < 100; i++)
{
Fib(30);
await FibAsync(30);
await Task.Delay(1);
}
// テスト本番
var sw = Stopwatch.StartNew();
var result = Fib(40);
sw.Stop();
Console.WriteLine($"Fib(40) = {result} in {sw.ElapsedMilliseconds}ms");
sw.Restart();
result = await FibAsync(40);
sw.Stop();
Console.WriteLine($"FibAsync(40) = {result} in {sw.ElapsedMilliseconds}ms");
}
static async Task<int> FibAsync(int n)
{
if (n <= 1) return n;
return await FibAsync(n - 1) + await FibAsync(n - 2);
}
static int Fib(int n)
{
if (n <= 1) return n;
return Fib(n - 1) + Fib(n - 2);
}
}
dotnet run -c Release
で実行すると、次のような結果になりました。
Fib(40) = 102334155 in 250ms
FibAsync(40) = 102334155 in 730ms
従来の Async では次のとおりです。
FibAsync(40) = 102334155 in 1412ms
このテストでは、新しい Runtime Async は従来の Async と比べて、いきなり 2 倍(100% 向上)近いスコアになっています。
しかも、これは最終的な姿ではありません。前述のとおり、.NET 10 では Runtime Async 向けの最適化の一部がバグのために一時的に無効化されています。無効化前のソースで手元ビルドした際の測定では、次の結果を得ました。
FibAsync(40) = 102334155 in 255ms
はい、見間違いではありません。このテストでは、非同期コードが同期コードと肩を並べる性能に到達しています。しかも深い再帰がある状況で、ValueTask すら使っていません。従来の Async 比でおよそ 5 倍の向上です。
もちろん、現実の I/O ヘビーなアプリケーションでは、大半の時間は実際の I/O そのものに費やされるため、ここまで派手な差は出ません。ただ、async/await で並列計算をしたい方には、大きな追い風になるはずです。
おわりに
Runtime Async は .NET における新しい非同期の選択肢です。ソース互換性を保ったまま、async の実装をコンパイラからランタイムへ移すことで、すでに有望な性能改善を示しています。大規模な非同期 I/O、チェーンの長い呼び出し、マイクロサービス/クラウドネイティブといった場面で、レイテンシやスループットの改善、メモリ割り当てと GC 負荷の削減が見込めます。高性能な並列計算の世界でも、async/await が活躍できる場が広がるでしょう。
まとめると、開発者が親しんできた async/await の使い勝手は基本的にそのままに、同じ体験をより高い性能と開発効率へ押し上げる——それが Runtime Async です。