Skip to content

필사 모드: .NET 11 runtime-async — async状態マシンをコンパイラからランタイムへ移す中間報告

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに — asyncはもともとランタイムが知らない機能だった

C#でasync Task M()と書いたとき実際に何が起きているか、一度は逆コンパイルして確かめたことがあるはずです。Roslynはそのメソッドを丸ごと消し去り、IAsyncStateMachineを実装する構造体一つとMoveNext()一つを作り、元のメソッドの場所にはその状態マシンを起動するスタブだけを残します。ローカル変数はフィールドになり、awaitのたびにstateという整数が一つ付き、例外はAsyncTaskMethodBuilderが受け止めます。

ここが核心です — ランタイムはこのメソッドがasyncだという事実を知りません。 JITから見えるのはただの普通のクラスと普通のMoveNextメソッドだけです。asyncは純粋にコンパイラによるソース書き換え(rewrite)であり、CLRにとっては存在しない概念でした。C# 5がこのモデルを2012年に世に出して以来、14年間そうだったのです。

この設計には現実的な代償があります。スタックトレースには状態マシンのインフラがそのまま露出し、デバッガはコンパイラが生成したコードをかき分けて進まなければならず、JITには「ここがasyncの中断点だ」という情報がないため最適化の余地もありません。runtime-asyncはこの前提を覆す作業です。仕様書の表現をそのまま借りれば、コンパイラの書き換えで実装するのも効果的だが、.NETランタイムに直接実装すれば、特に性能面での改善が見込まれる、ということです(runtime-async 仕様草案)。

本稿は2026年7月14日に出た.NET 11 プレビュー6までの状況整理です。.NET 11はSTSリリースで、2026年11月10日から2028年11月9日までサポートされる予定であり(リリースノートREADME)、いまはGAまで4か月を切った時点です。

runtime-asyncが実際に変えるもの — フラグ一つ

仕組みは驚くほど単純な地点から始まります。ECMA-335の改訂草案はメソッドを「sync」と「async」の二種類に分け、asyncメソッドをこう定義します — [MethodImpl(MethodImplOptions.Async)]が付いたメソッド定義。このフラグはMethodImplAttributesに値0x2000として追加され、ILではasyncキーワードで表現され、ilasm/ildasmが認識します。

そのためRoslynがやることはこう変わります。

// 開発者が書くコード
async Task M()
{
    // ...
}
// runtime-asyncでコンパイルしたときの形(概念的な表現)
[MethodImpl(MethodImplOptions.Async)]
Task M()
{
    // 状態マシンクラスなし。本体がそのまま残る。
}

状態マシンクラスが消え、メソッド本体がその場に残ります。中断点はSystem.Runtime.CompilerServices.AsyncHelpersのヘルパー呼び出しで示されます。

// 仕様が定義する中断ヘルパー(一部)
namespace System.Runtime.CompilerServices
{
    public static class AsyncHelpers
    {
        [MethodImpl(MethodImplOptions.Async)]
        public static void Await(Task task);
        [MethodImpl(MethodImplOptions.Async)]
        public static T Await<T>(Task<T> task);
        [MethodImpl(MethodImplOptions.Async)]
        public static void AwaitAwaiter<TAwaiter>(TAwaiter awaiter)
            where TAwaiter : INotifyCompletion;
        // ValueTask、ConfiguredTaskAwaitableなどの変種がさらにある
    }
}

こうしてawait C.M()は次のILに落ちます。

call     [System.Runtime]System.Threading.Tasks.Task C::M()
call     void [System.Runtime]System.Runtime.CompilerServices.AsyncHelpers::Await(class [System.Runtime]System.Threading.Tasks.Task)

仕様はこの「callが二つ連続する」形を明示的に好むと書いています — asyncメソッドを呼び出し、そのすぐ後にAwaitを呼び出すというILの並びが最大の性能を出すというのです。JITがこのパターンを認識し、中間のタスクオブジェクトなしに直接つなげられるからです。

中断点をまたぐローカル変数は「ホイスト」されて状態が保存されます。これはかつて状態マシン構造体のフィールドが担っていた役割で、いまはランタイムが管理するコンティニュエーション(continuation)というオブジェクトがその役目を果たします。プレビューノートに繰り返し出てくる「continuationの再利用」「変化していないローカルの保存回避」といった表現は、すべてこのオブジェクトの話です。

Roslyn側の設計文書が明記している原則を一つ覚えておく価値があります — この機能はユーザーレベルに露出しないことが目標であり、初期バインディングはほとんど影響を受けず、さらにコンパイラは参照先アセンブリのメソッドがruntime-asyncでコンパイルされたかどうかを知りません(Roslyn Runtime Async Design)。MethodImplOptions.Asyncを手で付けることも禁止されており、直接試すとコンパイラがエラーを出します。

目に見える最初の成果 — 生きたスタックトレース

性能の話より先にこちらが体感できます。プレビュー2のリリースノートに載っている例は、OuterAsyncMiddleAsyncInnerAsyncという三段階を経てnew StackTrace()を出力するコードです。

runtime-asyncなしでは13フレームになります。

   at Program.<<Main>$>g__InnerAsync|0_2() in Program.cs:line 24
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...)
   at Program.<<Main>$>g__InnerAsync|0_2()
   at Program.<<Main>$>g__MiddleAsync|0_1() in Program.cs:line 14
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...)
   at Program.<<Main>$>g__MiddleAsync|0_1()
   at Program.<<Main>$>g__OuterAsync|0_0() in Program.cs:line 8
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...)
   at Program.<<Main>$>g__OuterAsync|0_0()
   at Program.<Main>$(String[] args) in Program.cs:line 3
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](...)
   at Program.<Main>$(String[] args)
   at Program.<Main>(String[] args)

有効にすると5フレームです。

   at Program.<<Main>$>g__InnerAsync|0_2() in Program.cs:line 24
   at Program.<<Main>$>g__MiddleAsync|0_1() in Program.cs:line 14
   at Program.<<Main>$>g__OuterAsync|0_0() in Program.cs:line 8
   at Program.<Main>$(String[] args) in Program.cs:line 3
   at Program.<Main>(String[] args)

(両方の出力ともプレビュー2ランタイムノートからの引用です。メソッド名に付く$>g__はasyncだからではなくローカル関数だからです。)

ここでリリースノート自身が付けたヒントを見落とさないことが重要です。改善されるのは「生きた」スタックトレースです — プロファイラ、デバッガ、実行中に呼ばれたnew StackTrace()が見るもの。一方でcatch (Exception ex)で出力される例外のスタックトレースは、runtime-asyncの有無にかかわらずすでに同じ見た目です。コンパイラが生成したコードの既存のExceptionDispatchInfoのクリーンアップのおかげです。

つまり「asyncのスタックトレースがついに綺麗になる」という言い方は半分だけ正しいのです。あなたが日々ログで目にするその例外トレースはすでに問題なく、今回よくなるのはプロファイラやデバッガの呼び出しスタック画面のほうです。そしてEnvironment.StackTraceSystem.Diagnostics.StackFrameにruntime-asyncのフレームを含める件はまだAPI提案の段階にとどまっています。診断まわりはエピックissueでも依然として未完了項目です。

デバッグ面では、プレビュー2でruntime-asyncメソッド内のブレークポイントが正しくバインドされるようになり、await境界をコンパイラ生成インフラへ飛ばずにステップ実行できるようになりました(dotnet/runtime #123644)。

プレビュー1から6まで — 実際に起きたこと

日付とコミットを紐づけて見ると、この機能がいまどのあたりにいるのかがはるかに鮮明になります。

プレビュー1(2026-02-10)。 CoreCLRのランタイム側サポートが既定で有効になりました。環境変数を設定する必要がなくなったということです。NativeAOTもruntime-asyncコードをコンパイルできるようになりました。ただしノートははっきり書いています — この時点でコアランタイムライブラリのうちruntime-asyncでコンパイルされたものは一つもありません。

プレビュー2(2026-03-10)。 先に見たスタックトレースとデバッガの改善。

プレビュー3(2026-04-14)。 参入障壁が一段下がりました。APIに付いていた[RequiresPreviewFeatures]ゲートが外され、net11.0プロジェクトはruntime-async=onを有効にするためにEnablePreviewFeaturesまで有効化する必要がなくなりました(dotnet/runtime #124488)。NativeAOTとReadyToRunのサポートもこのプレビューで入りました。

 <PropertyGroup>
   <Features>runtime-async=on</Features>
-  <EnablePreviewFeatures>true</EnablePreviewFeatures>
 </PropertyGroup>

プレビュー4(2026-05-12)。 このサイクルで最大の出来事です。ランタイムライブラリ全体がruntime-async=onでビルドされ始めました。 リリースノートの表現では、ランタイムライブラリはもはやコンパイラ生成の状態マシンを含んでおらず、ランタイムが提供するasync機能に依存しています。これに加えてcrossgen2でruntime-asyncメソッドのインライン化を阻んでいた制約が取り除かれ、69件のasyncテストがcrossgen2とcomposite R2Rの両方で通過しました(dotnet/runtime #125472)。

プレビュー5(2026-06-09)。 中断・再開コストの最適化。詳細は次節で見ます。

プレビュー6(2026-07-14)。 JITが、同期的にタスクを返すメソッドをサンクで迂回させるのではなく、専用のruntime-async版を別途コンパイルするように変わりました(dotnet/runtime #128384)。そしてコンティニュエーションがExecutionContextのキャプチャ・復元を省略できるようになりました(dotnet/runtime #128323) — AsyncLocalの値を使っておらず復元がどうせ何もしない場合でも毎回コンテキストのスナップショットを取っていたのを、いまはランタイムが検知してまるごと省略します。

このプレビュー6の最初の変更には裏話があります。後段でまた触れます。

数字を正直に読む

プレビュー5の目玉数値はこれです — 中断が頻発するベンチマークがTook 6357.1 msからTook 457.1 msに改善した(dotnet/runtime #127074)。14倍近い数字です。これをどう読めばよいでしょうか。

まず何が変わったのか。OSR(On-Stack Replacement)は、長く走るメソッドが実行中にtier-0コードから最適化コードへ乗り換えられるようにするJITの機能です。問題は、OSRメソッドの中でasyncが再開するとき、一般的なOSR遷移経路(patchpointヘルパー)を通っていたことで、PR本文はそのヘルパーが安くはなく、オーバーヘッドがおおよそ10倍から20倍に達すると書いています。今回の変更はtier-0コードからOSRコードへ直接ジャンプし、ヘルパーをまるごと迂回します。

次にベンチマークの正体です。PRに添付された再現コードを見るとこうなっています — IsCompleted常にfalseを返すNullAwaiterを用意し、1000万回回るウォームアップループでそのメソッドをOSR対象にしたうえで、その中でawait na1000万回実行します。外側のループは絶えずna.Continue()を呼んで起こし続けます。

言い換えれば、これはすべてのawaitが100%中断する合成マイクロベンチマークです。実際のコードではawaitのかなりの割合がすでに完了したタスクに出会い、中断せずに通過します(仕様自身も「すべてのTask-likeオブジェクトがすでに完了していれば中断は不要」と書いています)。このベンチマークはその高速経路を意図的に0%に固定し、中断経路だけを測っています。したがってこの数字は「あなたのアプリが14倍速くなる」ではなく「中断経路の特定のボトルネックがこれだけ縮んだ」と読むべきです。ベンダー自己測定であり、条件は上記のとおりです。

プレビュー5の残りの数値も同じ性質のものです。中断コードサイズの縮小は、中断が頻発するマイクロベンチマークで約8パーセントの改善、PRサンプルの生成コードは766バイトから751バイトへ(#126041)。TLS操作と書き込みバリアの削減は、PRサンプルがTook 350.3 msからTook 291.3 msへ(#127336)。いずれもPRの著者が自分のサンプルで測った数値です。

では「実際のアプリはどれだけ速くなるのか」の答えは。Microsoftはまだ出していません。プレビュー4のリリースノートの一文がこの状況を最も正確にまとめています — ランタイムasyncがスループットとライブラリサイズの改善をもたらすと見込んでおり(expect)、変化の幅はasyncの使用度合いに比例するはずで、良い結果も悪い結果も報告を歓迎する、というものです。GAまで4か月の機能の公式文書が「悪い結果も知らせてほしい」と書いているのは珍しく、そして誠実なことです。ヘッドラインの数字がないのではなく、まだ出せる数字がない状態だと読むのが正確でしょう。

有効化しなくてもすでにあなたの足元で動いている

ここが本稿でもっとも重要な部分かもしれません。

runtime-asyncはあなたのコードに関してはオプトインのプレビュー機能です。有効にするにはプロジェクトファイルにこれを入れる必要があります。

<PropertyGroup>
  <Features>runtime-async=on</Features>
</PropertyGroup>

しかしプレビュー4以降、ランタイムライブラリはすでにこの方式でビルドされ出荷されています。つまり.NET 11でTask.WhenAllStream.ReadAsync、あるいはソケットのコードを呼んだ瞬間、あなたが何も有効化していなくてもBCLの内側のasyncはruntime-asyncで動いています。 プレビュー1の「コアライブラリのうちコンパイル済みのものは一つもない」からプレビュー4の「全部コンパイルされた」への転換が、これです。

これが抽象的な話ではないという証拠があります。dotnet/runtime #126925は、System.Private.CoreLibでruntime-asyncを無条件に有効化した後、IIS in-processでホストされたASP.NET Coreアプリがすべてのリクエストに対してHTTP 500を返すようになったissueです。アプリは正常に起動し、ANCMのログも成功と記録するのに、以降のすべてのHTTPリクエストが500で失敗しました。2026年4月14日に立てられ、7月1日の更新時点でも未解決のままです。dotnet/aspnetcoreへランタイムの変更を流し込むコードフローPRの中で発見されました。

このissue一つが物語っているのは、この機能が「試したい人だけが触るフラグ」ではなく、すでにプラットフォームの足元で荷重を受けている構造変更だということです。だから.NET 11のプレビューで説明のつかないasync関連の異常な挙動に出会ったとき、自分のコードでruntime-async=onを有効にしていないという事実はアリバイになりません。

参考までに、以前使われていたDOTNET_RuntimeAsyncUNSUPPORTED_RuntimeAsyncという環境変数は削除されました。公式ドキュメントは、プロジェクト単位で無効化するには<UseRuntimeAsync>false</UseRuntimeAsync>を使うよう案内しています(What's new in .NET 11 runtime、このページはプレビュー5時点で更新されています)。ただしこのプロパティはdotnet/runtimeリポジトリ自体のビルドファイルで確認できるものであり、一般的なユーザープロジェクトでの挙動は自分で検証することをお勧めします。

まだできないこと、そしてむしろ遅くなること

この節が本稿を書いた理由です。開かれたままのissueを見れば、この機能の現在地が正確にわかります。

プーリングビルダーは回帰しています。 [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]を付けたメソッドは、runtime-async以前と比べてアロケーションのために回帰を示しています(dotnet/runtime #129004、2026年6月4日)。対応は修正ではなく回避でした — 当面AsyncMethodBuilderが付いたメソッドに対してはruntime-asyncを無効にする、というもので、これがPR #128943です。プレビュー6のリリースノートはこの変更を「すでにプーリングされているメソッドはruntime-asyncをオプトアウトして重複作業を避ける」と柔らかく書いていますが、issue側の表現のほうが率直です。アロケーションに敏感でプーリングビルダーを使っていたなら、そのメソッド群はこの機能の恩恵を受ける側ではなく、回避される側です。

大きな構造体はヒープを往復することがあります。 dotnet/runtime #120855はruntime-asyncで性能が悪化するパターンを集めたissueで、そのうちの一つが、中断・再開が頻発すると大きな構造体がヒープへ繰り返しコピーされることがある、というものです。2025年10月に開かれ、2026年7月9日まで更新され続けている未解決のissueです。

同期的にタスクを返すラッパーは長らく逆最適化でした。 状態マシンを一つ節約するためにasyncメソッドを同期的なTask返却ラッパーで包む、というのはごくありふれたパターンです。dotnet/runtime #115771の文言は明確です — runtime-asyncのもとではこれはかなりの逆最適化だ、と。このissueは2025年5月に開かれ、先のプレビュー6の項で見たPR #128384が2026年6月22日にマージされたことで閉じられました。PR本文の最後の一行はFix #115771です。1年以上生きていた逆最適化がGAの4か月前に直されたわけです。良い知らせであると同時に、この機能の成熟度を示すシグナルでもあります。

ExecutionContext.SuppressFlow()が守られないことがあります。 dotnet/runtime #122052のタイトルそのままです。issue本文は、抑制されたはずの実行コンテキストが流れてしまうのは観測可能な挙動であり、抑制する理由の一つが秘密の値を隠すためであることがあると指摘しています。実際にSystem.Net.SocketsExecutionContextFlowTestが期待値0に対して実際値42で失敗したログが添付されています。2025年11月に開かれ、2026年7月3日まで更新中の未解決issueです。これは性能ではなく意味論(semantics)の問題である点で性格が異なります。

カスタムawaiterは中断時にボクシングされます。 dotnet/runtime #119842がこのボクシングを避けるべきだとして開かれています。自作のawaiterを使っているなら確認しておきたい点です。

Multicore JITはasyncの変種を除外します。 dotnet/runtime #115097によれば、根本的な理由があるわけではなく、単にまだ実装・テストが済んでいない状態(NYI)です。

プロファイラのReJITは疑問符付きです。 dotnet/runtime #128944は「runtime-asyncメソッド本体に対するプロファイラReJITは.NET 11でサポートされる想定なのか?」という問い自体が開かれたままの状態です。APMエージェントを付けて運用している環境なら注視しておく項目です。

async iteratorはまだです。 Roslynの設計文書にはIAsyncEnumerableを返すasync iteratorが依然としてTODOとして残っており、Roslynの機能状況表では「Runtime Async Streams」は別ブランチでの進行中の作業として扱われています。そして「Runtime Async」自体もその表でプレビューとしてmainへマージ済みという状態です。

エピックissue(#109632)は2024年11月に開かれ、2026年7月13日時点でも開いたままで、診断(StackTraceのフォーマット・出力)や「asyncメソッドへの関数ポインタをどう扱うか」といった項目が未解決のまま残っています。

仕様が明記した制約 — これは解決しないかもしれません

これまでのissueが「まだ」の問題だとすれば、仕様草案が恒久的である可能性が高い(likely to be permanent)と明記した制約もあります。

  • 戻り値の型はちょうど4種類だけ。 System.Threading.Tasks.TaskValueTaskTask<T>ValueTask<T>だけがruntime-asyncメソッドの戻り値の型になれます。
  • byrefローカル変数は中断点をまたいでホイストできません。 仕様はより具体的に、中断点の後でbyref変数を読むとnullが返り、byref-likeな構造体もホイストされず中断後には既定値になると書いています。固定(pinning)されたローカル変数も同様です。
  • 中断点は例外処理ブロックの中に現れることができません。

最後の項目を見て「ではtryの中のawaitはどうなるのか」と思うはずですが、答えはRoslynの設計文書にあります。コンパイラ生成の状態マシンとランタイム生成のasyncは一部の構成要素を共有しており、そのうちの一つがこれです — catch/finallyブロックの中のawaitは例外を保留(pend)させ、catch/finallyの領域の外側でawaitを実行したうえで、必要に応じて例外を復元するよう書き換えられます。この書き換えは以前から行われていたもので、そのまま残ります。つまりソース上のawaitがEHブロックの中にあっても、IL水準の中断点は外へ抜け出します。開発者の目には何も変わって見えません。

一時的な制約としては、tailプレフィックスの禁止とlocalloc命令の禁止があります。これらは将来解除されうると書かれています。

そしてもっとも静かながら重要な制約。Roslynの設計文書の一文です — 他のTask-like型を返すメソッドはruntime-asyncの形へ変換されず、C#が生成する状態マシンを使い続けます。 カスタムビルダーを付けた型を使っているなら、そのコードはこれまでどおりの方式で動き続けます。二つのモデルが同じプロセスの中で共存するということであり、これはバグではなく設計です。Monoランタイムもサポート対象ではありません。

では、いま何をすべきか

有効にする理由がある場合

  • asyncが重く中断が頻発するサービスを運用していて、.NET 11のGAを待って有効化するつもりなら、いまプレビューで自分のワークロードで測っておくのが得です。Microsoftが明示的に悪い結果の報告を求めていますし、GAまで4か月あります。いま見つかった回帰は直る余地がありますが、GA後に見つかった回帰はあなたが抱え込むことになります。
  • プロファイラやデバッガでasyncの呼び出しスタックを読む機会が多いなら、13フレームから5フレームへの縮小は体感できる確かな改善です。

有効にすべきでない場合

  • 本番環境。 プレビュー機能であり、エピックissueは開いたままで、IIS in-processの500のようなissueが未解決で残っており、ExecutionContext.SuppressFlowの意味論も確定していません。
  • アロケーションを切り詰めるためにプーリングビルダーを使っている場合。 回帰が確認済みで、ランタイム側の対応もそのメソッド群をまるごと除外する方向でした。
  • カスタムのTask-like型やカスタムビルダーがコードベースの中核をなしている場合。どのみち変換されません。
  • Monoを対象にしている場合。 サポート対象ではありません。
  • プロファイラやAPMエージェントがReJITに依存している場合。サポートする意図自体が問いとして開いたままです。

何も有効化しない人が知っておくべきこと

  • .NET 11へ上げるだけでBCLのasyncパスはruntime-asyncへ切り替わります。これはオプトインではありません。移行テストで説明のつかないasync挙動の違いに出会ったら、この可能性を候補に入れてください。

おわりに

runtime-asyncは「asyncを速くする最適化」ではなく、asyncが誰の機能なのかを変える作業です。14年間コンパイラのソース書き換えだったものをランタイムの第一級の概念へ移す仕事であり、だからこそ利得の出方も違います — 状態マシンという中間表現が消えると、JITがはじめて「ここが中断点だ」と知ることができ、そこからOSR再開経路の最適化や中断点のtail-mergeといったことが可能になります。プレビュー5と6の作業はすべてこの種のものです。状態マシンモデルのもとではそもそも試みることすらできなかった最適化です。

同時に、2026年7月現在の正直な現在地はこうです。ランタイムライブラリはすでに移行済みで、スタックトレースは実際に短くなり、1年以上生きていた逆最適化が先月直りました。そしてプーリングビルダーは回帰中で、IIS in-processのissueは未解決で、SuppressFlowの意味論は未定で、async iteratorはまだ来ていません。エピックは2024年11月から開いたままです。

GAは2026年11月10日です。そのときこのリストがどれだけ短くなっているかが、この機能を有効にすべきかどうかの本当の答えです。いまできる最も生産的なことは、プレビューで自分のワークロードで測ってみて、結果を — とりわけ悪い結果を — 送り返すことです。文書自身がそう頼んでもいます。

参考資料

현재 단락 (1/132)

C#で`async Task M()`と書いたとき実際に何が起きているか、一度は逆コンパイルして確かめたことがあるはずです。Roslynはそのメソッドを丸ごと消し去り、`IAsyncStateMach...

작성 글자: 0원문 글자: 15,945작성 단락: 0/132