.NET ラボ 2023/05/27 での発表資料
↓↓↓ スライドより詳細なブログです ↓↓↓
C# の async/await は実際にどうやって動いているか
C# の async/await は実際にどうやって動いているか.NET ラボ 2023/05/27何縫ねの。
View Slide
自己紹介1• 所属: NTTコミュニケーションズイノベーションセンター• 趣味: C#, OSS, ドール, 一眼(α7 IV)• 執心領域• C# ⇔ TypeScript• SignalR何縫ねの。nenoNaninunenoMakeブログ https://blog.neno.devその他 https://neno.dev
OSS 紹介2属性を付与するだけTapper• C# の型定義から TypeScript の型定義を生成する .NET Tool/ library• JSON / MessagePack 対応!https://github.com/nenoNaninu/Tapper
OSS 紹介3• C# の SignalR Client を強く型付けするための Source GeneratorTypedSignalR.ClientBeforeAfter (using TypedSignalR.Client)こんな SignalR のHub と Receiver の interface があったとして…脱文字列!全てが強く型付け!https://github.com/nenoNaninu/TypedSignalR.Client
4• TypeScript の SignalR Client を強く型付けするための .NET Tool / libraryTypedSignalR.Client.TypeScriptBeforeAfter (using TypedSignalR.Client.TypeScript)脱文字列!全てが強く型付け!TypeScript 用の型をC# から自動生成MessagePack Hub Protocol 対応!https://github.com/nenoNaninu/TypedSignalR.Client.TypeScript属性を付与するだけ!OSS 紹介
5• SignalR 使ったアプリを快適に開発するための GUI を自動生成する library• 2 step で利用可能!• http pipeline に middleware の追加• Hub と Receiver を定義してるinterface に属性を付与• JWT 認証 サポート• パラメータのユーザ定義型サポート• JSON で入力!SignalR 版 SwaggerUITypedSignalR.Client.DevToolshttps://github.com/nenoNaninu/TypedSignalR.Client.DevToolsOSS 紹介
async/await 以前(= C# 4.0 以前)6
async/await 以前7• async/await が無かった時代は、ContinueWith メソッドを使って continuation をコールバックとして登録していた。その当時の非同期はコールバック…
async/await 以前8• async/await が無かった時代は、ContinueWith メソッドを使って continuation をコールバックとして登録していた。その当時の非同期はコールバック…どうすれば脱コールバック化を果たせるだろうか?どうすれば同期的な書き心地を得られるだろうか?
C# のイテレータ9
C# のイテレータ10• C# のイテレータは 次のように書ける。IEnumerable
C# のイテレータ11• C# のイテレータは 次のように書ける。IEnumerableつまりコルーチンである
C# のイテレータ12• C# のイテレータは 次のように書ける。IEnumerableコンパイラはこれをステートマシンとして展開つまりコルーチンである
C# のイテレータ13• 元のロジックはすべて MoveNext メソッドの中に。IEnumerable開発者が記述した全てのロジック+ステートに応じた分岐
C# のイテレータ14• 手動で MoveNext を叩くことで、任意 & 適切なタイミングでコルーチンを進ませること可能。IEnumerable
C# のイテレータ15• 手動で MoveNext を叩くことで、任意 & 適切なタイミングでコルーチンを進ませること可能。IEnumerable非同期処理が完了した際に呼ばれるcontinuation として MoveNext() を呼び出せるとしたら、どうだろうか?
C# のイテレータ16• こんなヘルパー関数を用意してあげる。• IterateAsync• Process というローカル関数内でMoveNext() を呼び出しイテレータを進め、Current で取得した Task インスタンスにcontinuation を ContinueWith() 経由で登録IEnumerableイテレータの Current で取得したTask が完了したら、引数で渡されたイテレータの MoveNext() が呼び出される
C# のイテレータ17• Task インスタンスをyield return するメソッドを定義• その返り値を IterateAsync に渡してあげる。IterateAsync の何が嬉しいのか?
C# のイテレータ18• Task インスタンスをyield return するメソッドを定義• その返り値を IterateAsync に渡してあげる。IterateAsync の何が嬉しいのか?脱コールバック!!
C# のイテレータ19• Task インスタンスをyield return するメソッドを定義• その返り値を IterateAsync に渡してあげる。IterateAsync の何が嬉しいのか?脱コールバック!!yield return を await に置き換えて読んでみると、おや…?
C# のイテレータ20• Task インスタンスをyield return するメソッドを定義• その返り値を IterateAsync に渡してあげる。IterateAsync の何が嬉しいのか?脱コールバック!!yield return を await に置き換えて読んでみると、おや…?C# コンパイラのイテレータとasync/await をサポートするためのロジックの約95%は共有されている
async/await21
Compiler Transform22• async メソッドは右の様に展開される• 非 async メソッド + ステートマシン になる。• ステートマシンに実装されている interfaceコンパイラの仕事
Compiler Transform23• ステートマシン (struct)• async メソッドに記述されたロジックをコンパイラがステートマシンに変換。• ステートマシンには元々のロジックに加え進行状態を保存するフィールドなども生えている (イテレータとほぼ同じ!)• IAsyncStateMachine を実装• AsyncTaskMethodBuilder (struct)• Task プロパティとして露出するTask 型のオブジェクトを作成• それに対する結果の設定• SetResult / SetException• await した完了してない Task に対するcontinuation の登録• AwaitOnCompleted / AwaitUnsafeOnCompleted大事な登場人物
Compiler Transform24• ステートマシン (struct)• async メソッドに記述されたロジックをコンパイラがステートマシンに変換。• ステートマシンには元々のロジックに加え進行状態を保存するフィールドなども生えている (イテレータとほぼ同じ!)• IAsyncStateMachine を実装• AsyncTaskMethodBuilder (struct)• Task プロパティとして露出するTask 型のオブジェクトを作成• それに対する結果の設定• SetResult / SetException• await した完了してない Task に対するcontinuation の登録• AwaitOnCompleted / AwaitUnsafeOnCompleted大事な登場人物双方共に structサスペンドがなければzero allocation
Compiler Transform251. ステートマシン (struct)の初期化• メソッドの引数や初期状態を格納2. AsyncTaskMethodBuilder (struct) の作成• 内部的には default を返しているだけ。• ステートマシンに格納される3. AsyncTaskMethodBuilder.Start でステートマシンのロジックを開始4. Builder 経由で返り値であるTask オブジェクトを取得し、return。• stateMachine.<>t__builder.Taskメソッドが呼ばれてからの処理内容
Compiler Transform261. ステートマシン (struct)の初期化• メソッドの引数や初期状態を格納2. AsyncTaskMethodBuilder (struct) の作成• 内部的には default を返しているだけ。• ステートマシンに格納される3. AsyncTaskMethodBuilder.Start でステートマシンのロジックを開始4. Builder 経由で返り値であるTask オブジェクトを取得し、return。• stateMachine.<>t__builder.Taskメソッドが呼ばれてからの処理内容
Compiler Transform27• AsyncTaskMethodBuilder.Start メソッドは何をやっているのか?• ステートマシンの操作という点では、MoveNext を叩いているだけ。• 何故直接 ステートマシンの MoveNext メソッドを呼ばないのか?• Start メソッドでは、細かいけど大事な事をやっている。それはなにか?• ExecutionContext を理解する必要があります。AsyncTaskMethodBuilder.Start(ref stateMachine)
ExecutionContext28
ExecutionContext29• 明示的なデータの渡し方• メソッドからメソッドに引数でデータを渡すパターン。• こちらは特に問題ない。• 暗黙的なデータの渡し方• Static フィールドなどにデータを格納し、どこかでそれを読み出すパターン。• こういったデータはよく ambient data とか呼ばれる。データの渡し方のパターン
ExecutionContext30• 明示的なデータの渡し方• メソッドからメソッドに引数でデータを渡すパターン。• こちらは特に問題ない。• 暗黙的なデータの渡し方• Static フィールドなどにデータを格納し、どこかでそれを読み出すパターン。• こういったデータはよく ambient data とか呼ばれる。データの渡し方のパターンasync メソッドの場合、ambient data の渡し方で様々な課題が発生する
ExecutionContext31• 普通の static フィールドの利用• async メソッドは通常、平行ないし並列で実行される。• ある async control flow で書き換えた static フィールドは、別の flow で書き換えられてしまう。• 競合を避けるためには1つのメソッドしか実行することができない。• つまり ambient data の渡し方として成立しない。Ambient data の渡し方のパターンと async メソッドでの問題点
ExecutionContext32• 普通の static フィールドの利用• async メソッドは通常、平行ないし並列で実行される。• ある async control flow で書き換えた static フィールドは、別の flow で書き換えられてしまう。• 競合を避けるためには1つのメソッドしか実行することができない。• つまり ambient data の渡し方として成立しない。• Thread local フィールドの利用• [ThreadStatic] 属性が適用された static フィールドや ThreadLocal で実現可能• 同期メソッドであれば問題なし。• 一方で、async メソッドは await した Task インスタンスが完了状態ではなかった場合サスペンドが発生するが、それが完了した際の continuation はどのスレッドで実行されるか分からない。• つまり、アクセスした ambient data が同一のasync control flow のものとは限らない。Ambient data の渡し方のパターンと async メソッドでの問題点
ExecutionContext33• 普通の static フィールドの利用• async メソッドは通常、平行ないし並列で実行される。• ある async control flow で書き換えた static フィールドは、別の flow で書き換えられてしまう。• 競合を避けるためには1つのメソッドしか実行することができない。• つまり ambient data の渡し方として成立しない。• Thread local フィールドの利用• [ThreadStatic] 属性が適用された static フィールドや ThreadLocal で実現可能• 同期メソッドであれば問題なし。• 一方で、async メソッドは await した Task インスタンスが完了状態ではなかった場合サスペンドが発生するが、それが完了した際の continuation はどのスレッドで実行されるか分からない。• つまり、アクセスした ambient data が同一のasync control flow のものとは限らない。Ambient data の渡し方のパターンと async メソッドでの問題点これらの問題を解決するのがExecutionContext
ExecutionContext34.NET Framework• LogicalCallContext や SecurityContext など様々なコンテキストを内包し、それら全てのコンテキストをフローさせていた。• Mutable だった。.NET Core• AsyncLocal を格納し、それをフローするだけのものになった。• それ以外のものは綺麗さっぱり消えた。• Immutable になった。ExecutionContext の役割 このコンテキストの上に ambient data を乗っけてasync control flow 間でデータをフローさせる※ 本セッションでは NET Framework との呼び分けのため .NET Core に .NET 5,6,7 も含む事とします。
ExecutionContext35.NET Framework• LogicalCallContext や SecurityContext など様々なコンテキストを内包し、それら全てのコンテキストをフローさせていた。• Mutable だった。.NET Core• AsyncLocal を格納し、それをフローするだけのものになった。• それ以外のものは綺麗さっぱり消えた。• Immutable になった。ExecutionContext の役割 このコンテキストの上に ambient data を乗っけてasync control flow 間でデータをフローさせる我々は ExecutionContext に直接ambient data を格納したりはしない。AsyncLocal を経由する事。※ 本セッションでは NET Framework との呼び分けのため .NET Core に .NET 5,6,7 も含む事とします。
AsyncTaskMethodBuilder.Start(ref stateMachine)36
Compiler Transform37• AsyncTaskMethodBuilder.Start メソッドは何をやっているのか?• 実質的には、ステートマシンの MoveNext を呼んでいるだけ。• 何故直接 ステートマシンの MoveNext メソッドを呼ばないのか?• Start メソッドでは、細かいけど大事な事をやっている。それはなにか?• ExecutionContext を理解する必要があります。AsyncTaskMethodBuilder.Start(ref stateMachine)再掲
AsyncTaskMethodBuilder.Start(ref stateMachine)38実際には…
AsyncTaskMethodBuilder.Start(ref stateMachine)39実際には…ステートマシンの MoveNext を呼ぶ前にExecutionContext をキャプチャしておき
AsyncTaskMethodBuilder.Start(ref stateMachine)40実際には…ステートマシンの MoveNext を呼ぶ前にExecutionContext をキャプチャしておき最後に、事前にキャプチャしておいたExecutionContext をリストアする
AsyncTaskMethodBuilder.Start(ref stateMachine)41実際には…ステートマシンの MoveNext を呼ぶ前にExecutionContext をキャプチャしておき最後に、事前にキャプチャしておいたExecutionContext をリストアする何故こんな事をしているのか?
Compiler Transform42• メソッドの呼び出し元へ ambient data のリークを防ぐため。何故こんな事をしているのか?
Compiler Transform43• メソッドの呼び出し元へ ambient data のリークを防ぐため。何故こんな事をしているのか?ログインしたユーザ情報をAsyncLocal を用いてExecutionContext に格納
Compiler Transform44• メソッドの呼び出し元へ ambient data のリークを防ぐため。何故こんな事をしているのか?ログインしたユーザ情報をAsyncLocal を用いてExecutionContext に格納builder.Start() の finaly で呼び出し時点のExecutionContext がリストアされていなかった場合、変更されたExecutionContext から ambient data がリーク
IAsyncStateMachine.MoveNext45
MoveNext461. ステートマシン/ビルダーの構造体が初期化され2. ビルダーの builder.Start(ref stateMachine) メソッドが呼ばれ3. ステートマシンの MoveNext メソッドが呼び出されるasync メソッドが実行されたらいまここ
MoveNext47• 開発者がメソッドに記述したあったロジックをすべて含み、かつ多くの変更も含む。• どこまで状態が進んだか保存するフィールドとそれに応じてジャンプする switch 等。• MoveNext メソッドは、全ての作業が終了したらasync メソッドから返される Task オブジェクトを必ず完了させる責務を負う。• Task.IsCompleted が true の状態MoveNext の中身 (try block 略)終了時 or 例外発生時にビルダーの SetException/SetResult を経由し Task を完了状態にする
MoveNext48• 先頭の source.ReadAsync は Task を返す。• この Task オブジェクトの状態をみて分岐• 完了している場合は GetResult() で結果を取得して同期的(サスペンドなし)に継続。• 完了してない場合は continuation をフックする必要がある。MoveNext の中身 (try block の一部)
MoveNext49• 先頭の source.ReadAsync は Task を返す。• この Task オブジェクトの状態をみて分岐• 完了している場合は GetResult() で結果を取得して同期的(サスペンドなし)に継続。• 完了してない場合は continuation をフックする必要がある。MoveNext の中身 (try block の一部)実際には Task オブジェクトに直接アクセスはせず、awaiter を介して状態の確認とcontinuation の登録を行う
MoveNext50• 先頭の source.ReadAsync は Task を返す。• この Task オブジェクトの状態をみて分岐• 完了している場合は GetResult() で結果を取得して同期的(サスペンドなし)に継続。• 完了してない場合は continuation をフックする必要がある。MoveNext の中身 (try block の一部)実際には Task オブジェクトに直接アクセスはせず、awaiter を介して状態の確認とcontinuation の登録を行うawaiter パターンに従えば何でも(Task 以外でも) await できる
MoveNext51• 先頭の source.ReadAsync は Task を返す。• この Task オブジェクトの状態をみて分岐• 完了している場合は GetResult() で結果を取得して同期的(サスペンドなし)に継続。• 完了してない場合は continuation をフックする必要がある。MoveNext の中身 (try block の一部)実際には Task オブジェクトに直接アクセスはせず、awaiter を介して状態の確認とcontinuation の登録を行うawaiter パターンに従えば何でも(Task 以外でも) await できる
MoveNext52• 先頭の source.ReadAsync は Task を返す。• この Task オブジェクトの状態をみて分岐• 完了している場合は GetResult() で結果を取得して同期的(サスペンドなし)に継続。• 完了してない場合は continuation をフックする必要がある。MoveNext の中身 (try block の一部)実際には Task オブジェクトに直接アクセスはせず、awaiter を介して状態の確認とcontinuation の登録を行うawaiter パターンに従えば何でも(Task 以外でも) await できる
MoveNext53• 先頭の source.ReadAsync は Task を返す。• この Task オブジェクトの状態をみて分岐• 完了している場合は GetResult() で結果を取得して同期的(サスペンドなし)に継続。• 完了してない場合は continuation をフックする必要がある。MoveNext の中身 (try block の一部)実際には Task オブジェクトに直接アクセスはせず、awaiter を介して状態の確認とcontinuation の登録を行うawaiter パターンに従えば何でも(Task 以外でも) await できるビルダーを介してcontinuation をフック
MoveNext54• awaiter と呼ばれるものは、以下のような interface を実装している。• Task 完了時に呼び出される continuation をOnCompleted / UnsafeOnCompletedで渡す。Awaiter のとりあえずコレだけは押さえて欲しいポイント!
MoveNext• サスペンドが発生するまで• ステートマシンとビルダー の双方はスタックに存在する• サスペンドするためには• ステートマシンとビルダーをスタックからヒープにコピーしないといけない…!• なぜ?• 使用中のスタックは同一のスレッド上で実行される、現在のフローとは全く無関係な後続の作業に使用される。明け渡さないといけない。• 明け渡した上で、正しく continuation が実行できないといけない。• サスペンド発生後• continuation では、ヒープ上にコピーされたステートマシンの MoveNext メソッドを呼び出す必要がある。builder.AwaitUnsafeOnCompleted でサスペンドが起きる前と後ExecutionContextがついて回る
MoveNext• ステートマシンは、ExecutionContext に格納された ambient data をサスペンドされた時点で確実にキャプチャし、再開時点で復元する必要がある。• つまり continuation にキャプチャした ExecutionContext を取り込む必要がある。MoveNext と ExecutionContext
MoveNext• ステートマシンは、ExecutionContext に格納された ambient data をサスペンドされた時点で確実にキャプチャし、再開時点で復元する必要がある。• つまり continuation にキャプチャした ExecutionContext を取り込む必要がある。MoveNext と ExecutionContextステートマシンの MoveNext を指すデリゲートを作るだけでは足りない
MoveNext• ステートマシンは、ExecutionContext に格納された ambient data をサスペンドされた時点で確実にキャプチャし、再開時点で復元する必要がある。• つまり continuation にキャプチャした ExecutionContext を取り込む必要がある。MoveNext と ExecutionContextステートマシンの MoveNext を指すデリゲートを作るだけでは足りない毎回デリゲートを作成した場合毎回デリゲートの allocation + boxing コストがかかる
MoveNext• ステートマシンは、ExecutionContext に格納された ambient data をサスペンドされた時点で確実にキャプチャし、再開時点で復元する必要がある。• つまり continuation にキャプチャした ExecutionContext を取り込む必要がある。MoveNext と ExecutionContextステートマシンの MoveNext を指すデリゲートを作るだけでは足りない毎回デリゲートを作成した場合毎回デリゲートの allocation + boxing コストがかかる初回のサスペンド時にのみ構造体を boxing し、以降は boxing されたヒープにあるオブジェクトを MoveNext を行う対象として使用し、適切なタイミングでExecutionContext をキャプチャし、再開時にはその ExecutionContext を使用する必要がある
MoveNext• ステートマシンは、ExecutionContext に格納された ambient data をサスペンドされた時点で確実にキャプチャし、再開時点で復元する必要がある。• つまり continuation にキャプチャした ExecutionContext を取り込む必要がある。MoveNext と ExecutionContextステートマシンの MoveNext を指すデリゲートを作るだけでは足りない毎回デリゲートを作成した場合毎回デリゲートの allocation + boxing コストがかかる初回のサスペンド時にのみ構造体を boxing し、以降は boxing されたヒープにあるオブジェクトを MoveNext を行う対象として使用し、適切なタイミングでExecutionContext をキャプチャし、再開時にはその ExecutionContext を使用する必要がある複雑!
MoveNext• ステートマシンは、ExecutionContext に格納された ambient data をサスペンドされた時点で確実にキャプチャし、再開時点で復元する必要がある。• つまり continuation にキャプチャした ExecutionContext を取り込む必要がある。MoveNext と ExecutionContextステートマシンの MoveNext を指すデリゲートを作るだけでは足りない毎回デリゲートを作成した場合毎回デリゲートの allocation + boxing コストがかかる初回のサスペンド時にのみ構造体を boxing し、以降は boxing されたヒープにあるオブジェクトを MoveNext を行う対象として使用し、適切なタイミングでExecutionContext をキャプチャし、再開時にはその ExecutionContext を使用する必要がある複雑!.NET Framework から .NET Core でこのあたりがとても改善されパフォーマンスが向上している
MoveNext621. ExecutionContext.Capture()を使って、現在の ExecutionContext (以後 ec)を取得。2. Ec と boxing されたステートマシンをラップする MoveNextRunner (class) を new.3. MoveNextRunner の Run メソッドに対するデリゲート(Action) を new.• キャプチャした ec 上でステートマシンの MoveNext を呼べるようになる4. 初回サスペンド時のみ、struct であるステートマシンを boxing してヒープに。• ビルダーはステートマシンをフィールドに持ち、ステートマシンはビルダーをフィールドに持つ。スタックからヒープに双方をコピーする都合上、参照関係を正しく設定する必要がある。このためにかなり複雑な操作を行う。5. 最後に continuation を表す Action デリーゲートを用意しawaiter の UnsafeOnCompleted メソッドに渡します。.NET Framework での builder.AwaitUnsafeOnCompleted
MoveNext63• 右のようなシンプルなコードでパフォーマンスを確認。• 1000 x 1000 で合計 100 万回のawait Task.Yield() が走る。.NET Framework での振る舞いをプロファイラで見てみる
MoveNext64.NET Framework での振る舞いをプロファイラで見てみる
MoveNext65.NET Framework での振る舞いをプロファイラで見てみる.NET Framework での ExecutionContext は mutable.Capture されるタイミングで毎回コピーが発生 (100万回)
MoveNext66.NET Framework での振る舞いをプロファイラで見てみる.NET Framework での ExecutionContext は mutable.Capture されるタイミングで毎回コピーが発生 (100万回)UnsafeOnCompleted に渡すデリゲートを作成 (100万回)
MoveNext67.NET Framework での振る舞いをプロファイラで見てみる.NET Framework での ExecutionContext は mutable.Capture されるタイミングで毎回コピーが発生 (100万回)UnsafeOnCompleted に渡すデリゲートを作成 (100万回)サスペンド毎にMoveNextRunner を allocate (100万回)
MoveNext68.NET Framework での振る舞いをプロファイラで見てみる.NET Framework での ExecutionContext は mutable.Capture されるタイミングで毎回コピーが発生 (100万回)UnsafeOnCompleted に渡すデリゲートを作成 (100万回)ExecutionContext に含まれる別のコンテキストのコピー(100万回)サスペンド毎にMoveNextRunner を allocate (100万回)
MoveNext69.NET Framework での振る舞いをプロファイラで見てみる.NET Framework での ExecutionContext は mutable.Capture されるタイミングで毎回コピーが発生 (100万回)UnsafeOnCompleted に渡すデリゲートを作成 (100万回)サスペンド毎にMoveNextRunner を allocate (100万回)ExecutionContext に含まれる別のコンテキストのコピー(100万回)Task.Yield() は ThreadPool に work item を queueing している。Yield 毎の操作を表現するために allocate (100 万回)
MoveNext70.NET Framework での振る舞いをプロファイラで見てみる.NET Framework での ExecutionContext は mutable.Capture されるタイミングで毎回コピーが発生 (100万回)UnsafeOnCompleted に渡すデリゲートを作成 (100万回)サスペンド毎にMoveNextRunner を allocate (100万回)ExecutionContext に含まれる別のコンテキストのコピー(100万回)Task.Yield() は ThreadPool に work item を queueing している。Yield 毎の操作を表現するために allocate (100 万回)他にもいろいろ allocation が発生。渋いポイントが多い。一方で .NET Core はどうだろうか?
MoveNext71.NET Core での振る舞いをプロファイラで見てみる
MoveNext72.NET Core での振る舞いをプロファイラで見てみるとんでもないレベルの改善…!
MoveNext73.NET Core での振る舞いをプロファイラで見てみるとんでもないレベルの改善…!.NET Framework : 500 万以上の allocation (~145MB).NET Core : 1000 程度の allocation (~109KB)
MoveNext741. .NET Coreでは、ExecutionContext が immutable に。キャプチャの度にコピーが不要 (参照を渡すだけで OK になった)1. AsyncLocal に値を設定する等したタイミングで ExecutionContext が新たに作成される。キャプチャの方が高頻度に叩かれるので、キャプチャ時ではなく変更時にコストを寄せた。2. LogicalCallContext は .NET Core から消えた。1. .NET Core での ExecutionContext の役割は AsyncLocal のストレージ。それだけ。それ以外のコンテキストは消えたので無駄なコストがかからない。.NET Framework から .NET Core での改善の要因 (の一部)
MoveNext751. .NET Coreでは、ExecutionContext が immutable に。キャプチャの度にコピーが不要 (参照を渡すだけで OK になった)1. AsyncLocal に値を設定する等したタイミングで ExecutionContext が新たに作成される。キャプチャの方が高頻度に叩かれるので、キャプチャ時ではなく変更時にコストを寄せた。2. LogicalCallContext は .NET Core から消えた。1. .NET Core での ExecutionContext の役割は AsyncLocal のストレージ。それだけ。それ以外のコンテキストは消えたので無駄なコストがかからない。.NET Framework から .NET Core での改善の要因 (の一部)Action、MoveNextRunner、ステートマシンなどのallocation は???
MoveNext761. .NET Coreでは、ExecutionContext が immutable に。キャプチャの度にコピーが不要 (参照を渡すだけで OK になった)1. AsyncLocal に値を設定する等したタイミングで ExecutionContext が新たに作成される。キャプチャの方が高頻度に叩かれるので、キャプチャ時ではなく変更時にコストを寄せた。2. LogicalCallContext は .NET Core から消えた。1. .NET Core での ExecutionContext の役割は AsyncLocal のストレージ。それだけ。それ以外のコンテキストは消えたので無駄なコストがかからない。.NET Framework から .NET Core での改善の要因 (の一部)Action、MoveNextRunner、ステートマシンなどのallocation は???.NET Core でどのように動いているかを知らないと分からない。
MoveNext77• builder.AwaitUnsafeOnCompleted で continuation が登録される。MoveNext 内でサスペンド時に何が起きていたか?(復習)
MoveNext78• builder.AwaitUnsafeOnCompleted で continuation が登録される。ここまでは .NET Framework, .NET Core で共通。AwaitUnsafeOnCompleted の内部実装が大幅に異なる。MoveNext 内でサスペンド時に何が起きていたか?(復習)
MoveNext791. まず、ExecutionContext を capture.2. その後 m_task が null なら AsyncStateMachineBox を allocate..NET Core のビルダーと AwaitUnsafeOnCompleted
MoveNext801. まず、ExecutionContext を capture.2. その後 m_task が null なら AsyncStateMachineBox を allocate..NET Core のビルダーと AwaitUnsafeOnCompletedフィールドが一つあるだけ…!
MoveNext811. まず、ExecutionContext を capture.2. その後 m_task が null なら AsyncStateMachineBox を allocate..NET Core のビルダーと AwaitUnsafeOnCompletedフィールドが一つあるだけ…!圧倒的改善を実現するための鍵
MoveNext821. まず、ExecutionContext を capture.2. その後 m_task が null なら AsyncStateMachineBox を allocate..NET Core のビルダーと AwaitUnsafeOnCompletedフィールドが一つあるだけ…!基底型に注目!圧倒的改善を実現するための鍵
MoveNext83• その名の通り、スタックに存在する構造体をヒープに box 化するためのクラス• 重要な事は、IAsyncStateMachine として boxing するのではなく、TStateMachine として強く型付けされたフィールドとしてヒープに。• それでいながら、Task そのもの (基底型から分かる通り)• Action と ExecutionContext もフィールドに格納。MoveNextRunner はいらない子に。• 新たに ExecutionContext をキャプチャしてもフィールドを書き換えればいいだけ。AsyncStateMachineBox
MoveNext84• awaiter に continuation を登録する際、AsyncStateMachineBox の MoveNext を叩けるようにする。• 通常であれば、UnsafeOnCompleted にデリゲートを渡して登録するわけだが、その際には MoveNext を指すデリゲートを初回のみ作成しキャッシュしておく。• Continuation 発火時、AsyncStateMachineBox の MoveNext はステートマシンの MoveNext を呼び出す前に ExecutionContext のリストアを行う。awaiter と continuation と AsyncStateMachineBox
MoveNext85.NET Core での振る舞いをプロファイラで見てみる (2回目)
MoveNext86.NET Core での振る舞いをプロファイラで見てみる (2回目)Action continuation が求められている
MoveNext87.NET Core での振る舞いをプロファイラで見てみる (2回目)awaiter に登録するためのデリゲートの allocation が発生していないぞ?何故??Action continuation が求められている
MoveNext88• 非同期インフラストラクチャは、Task や TaskAwaiter などのコアとなる型について既知です。• さらにそれらは内部アクセス権を持っています。• public に定義されたルールに従う必要はない…!• 非同期インフラストラクチャが既知の awaiter についてはより無駄のない経路 (デリゲートを作成しない) を辿る事が出来る。IAsyncStateMachineBox に対して最適化されています。
MoveNext89• 非同期インフラストラクチャは、Task や TaskAwaiter などのコアとなる型について既知です。• さらにそれらは内部アクセス権を持っています。• public に定義されたルールに従う必要はない…!• 非同期インフラストラクチャが既知の awaiter についてはより無駄のない経路 (デリゲートを作成しない) を辿る事が出来る。IAsyncStateMachineBox に対して最適化されています。• await したい対象のオブジェクトが GetAwaiter メソッドで awaiter を返す事• awaiter には OnCompleted / UnsafeOnCompleted メソッドが必要でどちらもcontinuation を Action として受け取る。Public に定義されたルール(の一部)
MoveNext90• Task.Yield() は YieldAwaitable を返す。• YieldAwaitable は GetAwaiter で YieldAwaiter を返す。Task.Yield() の最適化例AwaitUnsafeOnCompleted で box を受け取りThreadPool にデリゲートではなくBox をそのまま渡す非同期インフラストラクチャはデリゲートを作成せずにbox の MoveNext を直接呼び出すようになるのでAllocation が発生していない
まとめ91• コールバックによる continuation の解決の鍵は C# のイテレータにあった• C# コンパイラのイテレータと async/await をサポートするためのロジックの約95%は共有されている• async/await で重要な型• ExecutionContext• AsyncTaskMethodBuilder• IAsyncStateMachine• IAsyncStateMachineBox• AsyncStateMachineBox• .NET Framework から .NET Core で劇的な進化を遂げている。※ 本セッションでは NET Framework との呼び分けのため .NET Core に .NET 5,6,7 も含む事とします。
References92Stephen Toub 氏 によるメチャ面白記事。恐らく async/await の内部 (.NET 7 時点) についての解説という点で一番詳しく、一番分かりやすい。How Async/Await Really Works in C#https://devblogs.microsoft.com/dotnet/how-async-await-really-works/