Upgrade to PRO for Only $50/Year—Limited-Time Offer! 🔥

C# の async/await は実際にどうやって動いているか

neno
May 27, 2023

C# の async/await は実際にどうやって動いているか

.NET ラボ 2023/05/27 での発表資料

↓↓↓ スライドより詳細なブログです ↓↓↓

C# の async/await は実際にどうやって動いているか

neno

May 27, 2023
Tweet

More Decks by neno

Other Decks in Technology

Transcript

  1. 自己紹介 1 • 所属: NTTコミュニケーションズ イノベーションセンター • 趣味: C#, OSS,

    ドール, 一眼(α7 IV) • 執心領域 • C# ⇔ TypeScript • SignalR 何縫ねの。 nenoNaninu nenoMake ブログ https://blog.neno.dev その他 https://neno.dev
  2. OSS 紹介 2 属性を付与するだけ Tapper • C# の型定義から TypeScript の型定義を生成する

    .NET Tool/ library • JSON / MessagePack 対応! https://github.com/nenoNaninu/Tapper
  3. OSS 紹介 3 • C# の SignalR Client を強く型付けするための Source

    Generator TypedSignalR.Client Before After (using TypedSignalR.Client) こんな SignalR の Hub と Receiver の interface が あったとして… 脱文字列! 全てが強く型付け! https://github.com/nenoNaninu/TypedSignalR.Client
  4. 4 • TypeScript の SignalR Client を強く型付けするための .NET Tool /

    library TypedSignalR.Client.TypeScript Before After (using TypedSignalR.Client.TypeScript) 脱文字列! 全てが強く型付け! TypeScript 用の型を C# から自動生成 MessagePack Hub Protocol 対応! https://github.com/nenoNaninu/TypedSignalR.Client.TypeScript 属性を付与するだけ! OSS 紹介
  5. 5 • SignalR 使ったアプリを快適に開発するための GUI を自動生成する library • 2 step

    で利用可能! • http pipeline に middleware の追加 • Hub と Receiver を定義してる interface に属性を付与 • JWT 認証 サポート • パラメータのユーザ定義型サポート • JSON で入力! SignalR 版 SwaggerUI TypedSignalR.Client.DevTools https://github.com/nenoNaninu/TypedSignalR.Client.DevTools OSS 紹介
  6. async/await 以前 8 • async/await が無かった時代は、ContinueWith メソッドを使って continuation を コールバックとして登録していた。

    その当時の非同期はコールバック… どうすれば脱コールバック化を果たせるだろうか? どうすれば同期的な書き心地を得られるだろうか?
  7. C# のイテレータ 15 • 手動で MoveNext を叩くことで、任意 & 適切なタイミングで コルーチンを進ませること可能。

    IEnumerable<T> 非同期処理が完了した際に呼ばれる continuation として MoveNext() を 呼び出せるとしたら、どうだろうか?
  8. C# のイテレータ 16 • こんなヘルパー関数を用意してあげる。 • IterateAsync • Process というローカル関数内で

    MoveNext() を呼び出しイテレータを進め、 Current で取得した Task インスタンスに continuation を ContinueWith() 経由で登録 IEnumerable<Task> イテレータの Current で取得した Task が完了したら、引数で渡された イテレータの MoveNext() が呼び出される
  9. C# のイテレータ 17 • Task インスタンスを yield return するメソッドを定義 •

    その返り値を IterateAsync に 渡してあげる。 IterateAsync の何が嬉しいのか?
  10. C# のイテレータ 18 • Task インスタンスを yield return するメソッドを定義 •

    その返り値を IterateAsync に 渡してあげる。 IterateAsync の何が嬉しいのか? 脱コールバック!!
  11. C# のイテレータ 19 • Task インスタンスを yield return するメソッドを定義 •

    その返り値を IterateAsync に 渡してあげる。 IterateAsync の何が嬉しいのか? 脱コールバック!! yield return を await に 置き換えて読んでみると、おや…?
  12. C# のイテレータ 20 • Task インスタンスを yield return するメソッドを定義 •

    その返り値を IterateAsync に 渡してあげる。 IterateAsync の何が嬉しいのか? 脱コールバック!! yield return を await に 置き換えて読んでみると、おや…? C# コンパイラのイテレータと async/await をサポートするための ロジックの約95%は共有されている
  13. Compiler Transform 22 • async メソッドは右の様に展開される • 非 async メソッド

    + ステートマシン になる。 • ステートマシンに実装されている interface コンパイラの仕事
  14. Compiler Transform 23 • ステートマシン (struct) • async メソッドに記述されたロジックを コンパイラがステートマシンに変換。

    • ステートマシンには元々のロジックに加え 進行状態を保存するフィールドなども 生えている (イテレータとほぼ同じ!) • IAsyncStateMachine を実装 • AsyncTaskMethodBuilder (struct) • Task プロパティとして露出する Task 型のオブジェクトを作成 • それに対する結果の設定 • SetResult / SetException • await した完了してない Task に対する continuation の登録 • AwaitOnCompleted / AwaitUnsafeOnCompleted 大事な登場人物
  15. Compiler Transform 24 • ステートマシン (struct) • async メソッドに記述されたロジックを コンパイラがステートマシンに変換。

    • ステートマシンには元々のロジックに加え 進行状態を保存するフィールドなども 生えている (イテレータとほぼ同じ!) • IAsyncStateMachine を実装 • AsyncTaskMethodBuilder (struct) • Task プロパティとして露出する Task 型のオブジェクトを作成 • それに対する結果の設定 • SetResult / SetException • await した完了してない Task に対する continuation の登録 • AwaitOnCompleted / AwaitUnsafeOnCompleted 大事な登場人物 双方共に struct サスペンドがなければ zero allocation
  16. Compiler Transform 25 1. ステートマシン (struct)の初期化 • メソッドの引数や初期状態を格納 2. AsyncTaskMethodBuilder

    (struct) の作成 • 内部的には default を返しているだけ。 • ステートマシンに格納される 3. AsyncTaskMethodBuilder.Start で ステートマシンのロジックを開始 4. Builder 経由で返り値である Task オブジェクトを取得し、return。 • stateMachine.<>t__builder.Task メソッドが呼ばれてからの処理内容
  17. Compiler Transform 26 1. ステートマシン (struct)の初期化 • メソッドの引数や初期状態を格納 2. AsyncTaskMethodBuilder

    (struct) の作成 • 内部的には default を返しているだけ。 • ステートマシンに格納される 3. AsyncTaskMethodBuilder.Start で ステートマシンのロジックを開始 4. Builder 経由で返り値である Task オブジェクトを取得し、return。 • stateMachine.<>t__builder.Task メソッドが呼ばれてからの処理内容
  18. Compiler Transform 27 • AsyncTaskMethodBuilder.Start メソッドは何をやっているのか? • ステートマシンの操作という点では、MoveNext を叩いているだけ。 •

    何故直接 ステートマシンの MoveNext メソッドを呼ばないのか? • Start メソッドでは、細かいけど大事な事をやっている。それはなにか? • ExecutionContext を理解する必要があります。 AsyncTaskMethodBuilder.Start(ref stateMachine)
  19. ExecutionContext 29 • 明示的なデータの渡し方 • メソッドからメソッドに引数でデータを渡すパターン。 • こちらは特に問題ない。 • 暗黙的なデータの渡し方

    • Static フィールドなどにデータを格納し、どこかでそれを読み出すパターン。 • こういったデータはよく ambient data とか呼ばれる。 データの渡し方のパターン
  20. ExecutionContext 30 • 明示的なデータの渡し方 • メソッドからメソッドに引数でデータを渡すパターン。 • こちらは特に問題ない。 • 暗黙的なデータの渡し方

    • Static フィールドなどにデータを格納し、どこかでそれを読み出すパターン。 • こういったデータはよく ambient data とか呼ばれる。 データの渡し方のパターン async メソッドの場合、 ambient data の渡し方で 様々な課題が発生する
  21. ExecutionContext 31 • 普通の static フィールドの利用 • async メソッドは通常、平行ないし並列で実行される。 •

    ある async control flow で書き換えた static フィールドは、別の flow で書き換えられてしまう。 • 競合を避けるためには1つのメソッドしか実行することができない。 • つまり ambient data の渡し方として成立しない。 Ambient data の渡し方のパターンと async メソッドでの問題点
  22. ExecutionContext 32 • 普通の static フィールドの利用 • async メソッドは通常、平行ないし並列で実行される。 •

    ある async control flow で書き換えた static フィールドは、別の flow で書き換えられてしまう。 • 競合を避けるためには1つのメソッドしか実行することができない。 • つまり ambient data の渡し方として成立しない。 • Thread local フィールドの利用 • [ThreadStatic] 属性が適用された static フィールドや ThreadLocal<T> で実現可能 • 同期メソッドであれば問題なし。 • 一方で、async メソッドは await した Task インスタンスが完了状態ではなかった場合 サスペンドが発生するが、それが完了した際の continuation はどのスレッドで 実行されるか分からない。 • つまり、アクセスした ambient data が同一の async control flow のものとは限らない。 Ambient data の渡し方のパターンと async メソッドでの問題点
  23. ExecutionContext 33 • 普通の static フィールドの利用 • async メソッドは通常、平行ないし並列で実行される。 •

    ある async control flow で書き換えた static フィールドは、別の flow で書き換えられてしまう。 • 競合を避けるためには1つのメソッドしか実行することができない。 • つまり ambient data の渡し方として成立しない。 • Thread local フィールドの利用 • [ThreadStatic] 属性が適用された static フィールドや ThreadLocal<T> で実現可能 • 同期メソッドであれば問題なし。 • 一方で、async メソッドは await した Task インスタンスが完了状態ではなかった場合 サスペンドが発生するが、それが完了した際の continuation はどのスレッドで 実行されるか分からない。 • つまり、アクセスした ambient data が同一の async control flow のものとは限らない。 Ambient data の渡し方のパターンと async メソッドでの問題点 これらの問題を解決するのが ExecutionContext
  24. ExecutionContext 34 .NET Framework • LogicalCallContext や SecurityContext など様々なコンテキストを内包し、 それら全てのコンテキストをフローさせていた。

    • Mutable だった。 .NET Core • AsyncLocal<T> を格納し、それをフローするだけのものになった。 • それ以外のものは綺麗さっぱり消えた。 • Immutable になった。 ExecutionContext の役割 このコンテキストの上に ambient data を乗っけて async control flow 間でデータをフローさせる ※ 本セッションでは NET Framework との呼び分けのため .NET Core に .NET 5,6,7 も含む事とします。
  25. ExecutionContext 35 .NET Framework • LogicalCallContext や SecurityContext など様々なコンテキストを内包し、 それら全てのコンテキストをフローさせていた。

    • Mutable だった。 .NET Core • AsyncLocal<T> を格納し、それをフローするだけのものになった。 • それ以外のものは綺麗さっぱり消えた。 • Immutable になった。 ExecutionContext の役割 このコンテキストの上に ambient data を乗っけて async control flow 間でデータをフローさせる 我々は ExecutionContext に直接 ambient data を格納したりはしない。 AsyncLocal<T> を経由する事。 ※ 本セッションでは NET Framework との呼び分けのため .NET Core に .NET 5,6,7 も含む事とします。
  26. Compiler Transform 37 • AsyncTaskMethodBuilder.Start メソッドは何をやっているのか? • 実質的には、ステートマシンの MoveNext を呼んでいるだけ。

    • 何故直接 ステートマシンの MoveNext メソッドを呼ばないのか? • Start メソッドでは、細かいけど大事な事をやっている。それはなにか? • ExecutionContext を理解する必要があります。 AsyncTaskMethodBuilder.Start(ref stateMachine) 再掲
  27. Compiler Transform 44 • メソッドの呼び出し元へ ambient data のリークを防ぐため。 何故こんな事をしているのか? ログインしたユーザ情報を

    AsyncLocal<T> を用いて ExecutionContext に格納 builder.Start() の finaly で呼び出し時点の ExecutionContext がリストアされて いなかった場合、変更された ExecutionContext から ambient data がリーク
  28. MoveNext 46 1. ステートマシン/ビルダーの構造体が初期化され 2. ビルダーの builder.Start(ref stateMachine) メソッドが呼ばれ 3.

    ステートマシンの MoveNext メソッドが呼び出される async メソッドが実行されたら いまここ
  29. MoveNext 47 • 開発者がメソッドに記述したあったロジックを すべて含み、かつ多くの変更も含む。 • どこまで状態が進んだか保存するフィールドと それに応じてジャンプする switch 等。

    • MoveNext メソッドは、全ての作業が終了したら async メソッドから返される Task オブジェクトを 必ず完了させる責務を負う。 • Task.IsCompleted が true の状態 MoveNext の中身 (try block 略) 終了時 or 例外発生時に ビルダーの SetException/SetResult を 経由し Task を完了状態にする
  30. MoveNext 48 • 先頭の source.ReadAsync は Task<int> を返す。 • この

    Task<int> オブジェクトの状態をみて分岐 • 完了している場合は GetResult() で結果を 取得して同期的(サスペンドなし)に継続。 • 完了してない場合は continuation をフックする 必要がある。 MoveNext の中身 (try block の一部)
  31. MoveNext 49 • 先頭の source.ReadAsync は Task<int> を返す。 • この

    Task<int> オブジェクトの状態をみて分岐 • 完了している場合は GetResult() で結果を 取得して同期的(サスペンドなし)に継続。 • 完了してない場合は continuation をフックする 必要がある。 MoveNext の中身 (try block の一部) 実際には Task オブジェクトに 直接アクセスはせず、 awaiter を介して状態の確認と continuation の登録を行う
  32. MoveNext 50 • 先頭の source.ReadAsync は Task<int> を返す。 • この

    Task<int> オブジェクトの状態をみて分岐 • 完了している場合は GetResult() で結果を 取得して同期的(サスペンドなし)に継続。 • 完了してない場合は continuation をフックする 必要がある。 MoveNext の中身 (try block の一部) 実際には Task オブジェクトに 直接アクセスはせず、 awaiter を介して状態の確認と continuation の登録を行う awaiter パターンに従えば何でも (Task 以外でも) await できる
  33. MoveNext 51 • 先頭の source.ReadAsync は Task<int> を返す。 • この

    Task<int> オブジェクトの状態をみて分岐 • 完了している場合は GetResult() で結果を 取得して同期的(サスペンドなし)に継続。 • 完了してない場合は continuation をフックする 必要がある。 MoveNext の中身 (try block の一部) 実際には Task オブジェクトに 直接アクセスはせず、 awaiter を介して状態の確認と continuation の登録を行う awaiter パターンに従えば何でも (Task 以外でも) await できる
  34. MoveNext 52 • 先頭の source.ReadAsync は Task<int> を返す。 • この

    Task<int> オブジェクトの状態をみて分岐 • 完了している場合は GetResult() で結果を 取得して同期的(サスペンドなし)に継続。 • 完了してない場合は continuation をフックする 必要がある。 MoveNext の中身 (try block の一部) 実際には Task オブジェクトに 直接アクセスはせず、 awaiter を介して状態の確認と continuation の登録を行う awaiter パターンに従えば何でも (Task 以外でも) await できる
  35. MoveNext 53 • 先頭の source.ReadAsync は Task<int> を返す。 • この

    Task<int> オブジェクトの状態をみて分岐 • 完了している場合は GetResult() で結果を 取得して同期的(サスペンドなし)に継続。 • 完了してない場合は continuation をフックする 必要がある。 MoveNext の中身 (try block の一部) 実際には Task オブジェクトに 直接アクセスはせず、 awaiter を介して状態の確認と continuation の登録を行う awaiter パターンに従えば何でも (Task 以外でも) await できる ビルダーを介して continuation をフック
  36. MoveNext 54 • awaiter と呼ばれるものは、以下のような interface を実装している。 • Task 完了時に呼び出される

    continuation を OnCompleted / UnsafeOnCompletedで渡す。 Awaiter のとりあえずコレだけは押さえて欲しいポイント!
  37. MoveNext • サスペンドが発生するまで • ステートマシンとビルダー の 双方はスタックに存在する • サスペンドするためには •

    ステートマシンとビルダーをスタックからヒープにコピーしないといけない…! • なぜ? • 使用中のスタックは同一のスレッド上で実行される、 現在のフローとは全く無関係な後続の作業に使用される。明け渡さないといけない。 • 明け渡した上で、正しく continuation が実行できないといけない。 • サスペンド発生後 • continuation では、ヒープ上にコピーされた ステートマシンの MoveNext メソッドを呼び出す必要がある。 builder.AwaitUnsafeOnCompleted でサスペンドが起きる前と後 ExecutionContext がついて回る
  38. MoveNext • ステートマシンは、ExecutionContext に格納された ambient data を サスペンドされた時点で確実にキャプチャし、再開時点で復元する必要がある。 • つまり

    continuation にキャプチャした ExecutionContext を取り込む必要がある。 MoveNext と ExecutionContext ステートマシンの MoveNext を指す デリゲートを作るだけでは足りない
  39. MoveNext • ステートマシンは、ExecutionContext に格納された ambient data を サスペンドされた時点で確実にキャプチャし、再開時点で復元する必要がある。 • つまり

    continuation にキャプチャした ExecutionContext を取り込む必要がある。 MoveNext と ExecutionContext ステートマシンの MoveNext を指す デリゲートを作るだけでは足りない 毎回デリゲートを作成した場合 毎回デリゲートの allocation + boxing コストがかかる
  40. MoveNext • ステートマシンは、ExecutionContext に格納された ambient data を サスペンドされた時点で確実にキャプチャし、再開時点で復元する必要がある。 • つまり

    continuation にキャプチャした ExecutionContext を取り込む必要がある。 MoveNext と ExecutionContext ステートマシンの MoveNext を指す デリゲートを作るだけでは足りない 毎回デリゲートを作成した場合 毎回デリゲートの allocation + boxing コストがかかる 初回のサスペンド時にのみ 構造体を boxing し、 以降は boxing されたヒープにある オブジェクトを MoveNext を 行う対象として使用し、 適切なタイミングで ExecutionContext をキャプチャし、 再開時にはその ExecutionContext を 使用する必要がある
  41. MoveNext • ステートマシンは、ExecutionContext に格納された ambient data を サスペンドされた時点で確実にキャプチャし、再開時点で復元する必要がある。 • つまり

    continuation にキャプチャした ExecutionContext を取り込む必要がある。 MoveNext と ExecutionContext ステートマシンの MoveNext を指す デリゲートを作るだけでは足りない 毎回デリゲートを作成した場合 毎回デリゲートの allocation + boxing コストがかかる 初回のサスペンド時にのみ 構造体を boxing し、 以降は boxing されたヒープにある オブジェクトを MoveNext を 行う対象として使用し、 適切なタイミングで ExecutionContext をキャプチャし、 再開時にはその ExecutionContext を 使用する必要がある 複雑!
  42. MoveNext • ステートマシンは、ExecutionContext に格納された ambient data を サスペンドされた時点で確実にキャプチャし、再開時点で復元する必要がある。 • つまり

    continuation にキャプチャした ExecutionContext を取り込む必要がある。 MoveNext と ExecutionContext ステートマシンの MoveNext を指す デリゲートを作るだけでは足りない 毎回デリゲートを作成した場合 毎回デリゲートの allocation + boxing コストがかかる 初回のサスペンド時にのみ 構造体を boxing し、 以降は boxing されたヒープにある オブジェクトを MoveNext を 行う対象として使用し、 適切なタイミングで ExecutionContext をキャプチャし、 再開時にはその ExecutionContext を 使用する必要がある 複雑! .NET Framework から .NET Core で このあたりがとても改善され パフォーマンスが向上している
  43. MoveNext 62 1. 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
  44. MoveNext 63 • 右のようなシンプルなコードで パフォーマンスを確認。 • 1000 x 1000 で合計

    100 万回の await Task.Yield() が走る。 .NET Framework での振る舞いをプロファイラで見てみる
  45. MoveNext 65 .NET Framework での振る舞いをプロファイラで見てみる .NET Framework での ExecutionContext は

    mutable. Capture されるタイミングで毎回コピーが発生 (100万回)
  46. MoveNext 66 .NET Framework での振る舞いをプロファイラで見てみる .NET Framework での ExecutionContext は

    mutable. Capture されるタイミングで毎回コピーが発生 (100万回) UnsafeOnCompleted に渡す デリゲートを作成 (100万回)
  47. MoveNext 67 .NET Framework での振る舞いをプロファイラで見てみる .NET Framework での ExecutionContext は

    mutable. Capture されるタイミングで毎回コピーが発生 (100万回) UnsafeOnCompleted に渡す デリゲートを作成 (100万回) サスペンド毎に MoveNextRunner を allocate (100万回)
  48. MoveNext 68 .NET Framework での振る舞いをプロファイラで見てみる .NET Framework での ExecutionContext は

    mutable. Capture されるタイミングで毎回コピーが発生 (100万回) UnsafeOnCompleted に渡す デリゲートを作成 (100万回) ExecutionContext に含まれる 別のコンテキストのコピー(100万回) サスペンド毎に MoveNextRunner を allocate (100万回)
  49. MoveNext 69 .NET Framework での振る舞いをプロファイラで見てみる .NET Framework での ExecutionContext は

    mutable. Capture されるタイミングで毎回コピーが発生 (100万回) UnsafeOnCompleted に渡す デリゲートを作成 (100万回) サスペンド毎に MoveNextRunner を allocate (100万回) ExecutionContext に含まれる 別のコンテキストのコピー(100万回) Task.Yield() は ThreadPool に work item を queueing している。 Yield 毎の操作を表現するために allocate (100 万回)
  50. MoveNext 70 .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 はどうだろうか?
  51. MoveNext 74 1. .NET Coreでは、ExecutionContext が immutable に。 キャプチャの度にコピーが不要 (参照を渡すだけで

    OK になった) 1. AsyncLocal<T> に値を設定する等したタイミングで ExecutionContext が新たに作成される。 キャプチャの方が高頻度に叩かれるので、キャプチャ時ではなく変更時にコストを寄せた。 2. LogicalCallContext は .NET Core から消えた。 1. .NET Core での ExecutionContext の役割は AsyncLocal<T> のストレージ。それだけ。 それ以外のコンテキストは消えたので無駄なコストがかからない。 .NET Framework から .NET Core での改善の要因 (の一部)
  52. MoveNext 75 1. .NET Coreでは、ExecutionContext が immutable に。 キャプチャの度にコピーが不要 (参照を渡すだけで

    OK になった) 1. AsyncLocal<T> に値を設定する等したタイミングで ExecutionContext が新たに作成される。 キャプチャの方が高頻度に叩かれるので、キャプチャ時ではなく変更時にコストを寄せた。 2. LogicalCallContext は .NET Core から消えた。 1. .NET Core での ExecutionContext の役割は AsyncLocal<T> のストレージ。それだけ。 それ以外のコンテキストは消えたので無駄なコストがかからない。 .NET Framework から .NET Core での改善の要因 (の一部) Action、MoveNextRunner、ステートマシンなどの allocation は???
  53. MoveNext 76 1. .NET Coreでは、ExecutionContext が immutable に。 キャプチャの度にコピーが不要 (参照を渡すだけで

    OK になった) 1. AsyncLocal<T> に値を設定する等したタイミングで ExecutionContext が新たに作成される。 キャプチャの方が高頻度に叩かれるので、キャプチャ時ではなく変更時にコストを寄せた。 2. LogicalCallContext は .NET Core から消えた。 1. .NET Core での ExecutionContext の役割は AsyncLocal<T> のストレージ。それだけ。 それ以外のコンテキストは消えたので無駄なコストがかからない。 .NET Framework から .NET Core での改善の要因 (の一部) Action、MoveNextRunner、ステートマシンなどの allocation は??? .NET Core でどのように 動いているかを 知らないと分からない。
  54. MoveNext 78 • builder.AwaitUnsafeOnCompleted で continuation が登録される。 ここまでは .NET Framework,

    .NET Core で共通。 AwaitUnsafeOnCompleted の内部実装が大幅に異なる。 MoveNext 内でサスペンド時に何が起きていたか?(復習)
  55. MoveNext 79 1. まず、ExecutionContext を capture. 2. その後 m_task が

    null なら AsyncStateMachineBox<TStateMachine> を allocate. .NET Core のビルダーと AwaitUnsafeOnCompleted
  56. MoveNext 80 1. まず、ExecutionContext を capture. 2. その後 m_task が

    null なら AsyncStateMachineBox<TStateMachine> を allocate. .NET Core のビルダーと AwaitUnsafeOnCompleted フィールドが一つあるだけ…!
  57. MoveNext 81 1. まず、ExecutionContext を capture. 2. その後 m_task が

    null なら AsyncStateMachineBox<TStateMachine> を allocate. .NET Core のビルダーと AwaitUnsafeOnCompleted フィールドが一つあるだけ…! 圧倒的改善を 実現するための鍵
  58. MoveNext 82 1. まず、ExecutionContext を capture. 2. その後 m_task が

    null なら AsyncStateMachineBox<TStateMachine> を allocate. .NET Core のビルダーと AwaitUnsafeOnCompleted フィールドが一つあるだけ…! 基底型に注目! 圧倒的改善を 実現するための鍵
  59. MoveNext 83 • その名の通り、スタックに存在する構造体をヒープに box 化するためのクラス • 重要な事は、IAsyncStateMachine として boxing

    するのではなく、 TStateMachine として強く型付けされたフィールドとしてヒープに。 • それでいながら、Task そのもの (基底型から分かる通り) • Action と ExecutionContext も フィールドに格納。 MoveNextRunner はいらない子に。 • 新たに ExecutionContext を キャプチャしてもフィールドを 書き換えればいいだけ。 AsyncStateMachineBox<TStateMachine>
  60. MoveNext 84 • awaiter に continuation を登録する際、 AsyncStateMachineBox の MoveNext

    を 叩けるようにする。 • 通常であれば、UnsafeOnCompleted に デリゲートを渡して登録するわけだが、 その際には MoveNext を指すデリゲートを 初回のみ作成しキャッシュしておく。 • Continuation 発火時、 AsyncStateMachineBox の MoveNext は ステートマシンの MoveNext を 呼び出す前に ExecutionContext の リストアを行う。 awaiter と continuation と AsyncStateMachineBox
  61. MoveNext 88 • 非同期インフラストラクチャは、Task や TaskAwaiter などのコアとなる 型について既知です。 • さらにそれらは内部アクセス権を持っています。

    • public に定義されたルールに従う必要はない…! • 非同期インフラストラクチャが既知の awaiter については より無駄のない経路 (デリゲートを作成しない) を辿る事が出来る。 IAsyncStateMachineBox に対して最適化されています。
  62. MoveNext 89 • 非同期インフラストラクチャは、Task や TaskAwaiter などのコアとなる 型について既知です。 • さらにそれらは内部アクセス権を持っています。

    • public に定義されたルールに従う必要はない…! • 非同期インフラストラクチャが既知の awaiter については より無駄のない経路 (デリゲートを作成しない) を辿る事が出来る。 IAsyncStateMachineBox に対して最適化されています。 • await したい対象のオブジェクトが GetAwaiter メソッドで awaiter を返す事 • awaiter には OnCompleted / UnsafeOnCompleted メソッドが必要でどちらも continuation を Action として受け取る。 Public に定義されたルール(の一部)
  63. MoveNext 90 • Task.Yield() は YieldAwaitable を返す。 • YieldAwaitable は

    GetAwaiter で YieldAwaiter を返す。 Task.Yield() の最適化例 AwaitUnsafeOnCompleted で box を受け取り ThreadPool に デリゲートではなく Box をそのまま渡す 非同期インフラストラクチャは デリゲートを作成せずに box の MoveNext を 直接呼び出すようになるので Allocation が発生していない
  64. まとめ 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 も含む事とします。
  65. References 92 Stephen Toub 氏 によるメチャ面白記事。 恐らく async/await の内部 (.NET

    7 時点) についての解説という点で 一番詳しく、一番分かりやすい。 How Async/Await Really Works in C# https://devblogs.microsoft.com/dotnet/how-async-await-really-works/