Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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. C# の async/await は
    実際にどうやって動いているか
    .NET ラボ 2023/05/27
    何縫ねの。

    View Slide

  2. 自己紹介
    1
    • 所属: NTTコミュニケーションズ
    イノベーションセンター
    • 趣味: C#, OSS, ドール, 一眼(α7 IV)
    • 執心領域
    • C# ⇔ TypeScript
    • SignalR
    何縫ねの。
    nenoNaninu
    nenoMake
    ブログ https://blog.neno.dev
    その他 https://neno.dev

    View Slide

  3. OSS 紹介
    2
    属性を付与するだけ
    Tapper
    • C# の型定義から TypeScript の型定義を生成する .NET Tool/ library
    • JSON / MessagePack 対応!
    https://github.com/nenoNaninu/Tapper

    View Slide

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

    View Slide

  5. 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 紹介

    View Slide

  6. 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 紹介

    View Slide

  7. async/await 以前
    (= C# 4.0 以前)
    6

    View Slide

  8. async/await 以前
    7
    • async/await が無かった時代は、ContinueWith メソッドを使って continuation を
    コールバックとして登録していた。
    その当時の非同期はコールバック…

    View Slide

  9. async/await 以前
    8
    • async/await が無かった時代は、ContinueWith メソッドを使って continuation を
    コールバックとして登録していた。
    その当時の非同期はコールバック…
    どうすれば脱コールバック化を果たせるだろうか?
    どうすれば同期的な書き心地を得られるだろうか?

    View Slide

  10. C# のイテレータ
    9

    View Slide

  11. C# のイテレータ
    10
    • C# のイテレータは 次のように書ける。
    IEnumerable

    View Slide

  12. C# のイテレータ
    11
    • C# のイテレータは 次のように書ける。
    IEnumerable
    つまりコルーチンである

    View Slide

  13. C# のイテレータ
    12
    • C# のイテレータは 次のように書ける。
    IEnumerable
    コンパイラはこれを
    ステートマシンとして展開
    つまりコルーチンである

    View Slide

  14. C# のイテレータ
    13
    • 元のロジックはすべて MoveNext メソッドの中に。
    IEnumerable
    開発者が記述した全てのロジック
    +
    ステートに応じた分岐

    View Slide

  15. C# のイテレータ
    14
    • 手動で MoveNext を叩くことで、任意 & 適切なタイミングで
    コルーチンを進ませること可能。
    IEnumerable

    View Slide

  16. C# のイテレータ
    15
    • 手動で MoveNext を叩くことで、任意 & 適切なタイミングで
    コルーチンを進ませること可能。
    IEnumerable
    非同期処理が完了した際に呼ばれる
    continuation として MoveNext() を
    呼び出せるとしたら、どうだろうか?

    View Slide

  17. C# のイテレータ
    16
    • こんなヘルパー関数を用意してあげる。
    • IterateAsync
    • Process というローカル関数内で
    MoveNext() を呼び出しイテレータを進め、
    Current で取得した Task インスタンスに
    continuation を ContinueWith() 経由で登録
    IEnumerable
    イテレータの Current で取得した
    Task が完了したら、引数で渡された
    イテレータの MoveNext() が呼び出される

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  22. async/await
    21

    View Slide

  23. Compiler Transform
    22
    • async メソッドは右の様に展開される
    • 非 async メソッド + ステートマシン になる。
    • ステートマシンに実装されている interface
    コンパイラの仕事

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  28. Compiler Transform
    27
    • AsyncTaskMethodBuilder.Start メソッドは何をやっているのか?
    • ステートマシンの操作という点では、MoveNext を叩いているだけ。
    • 何故直接 ステートマシンの MoveNext メソッドを呼ばないのか?
    • Start メソッドでは、細かいけど大事な事をやっている。それはなにか?
    • ExecutionContext を理解する必要があります。
    AsyncTaskMethodBuilder.Start(ref stateMachine)

    View Slide

  29. ExecutionContext
    28

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  33. ExecutionContext
    32
    • 普通の 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 メソッドでの問題点

    View Slide

  34. ExecutionContext
    33
    • 普通の 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

    View Slide

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

    View Slide

  36. ExecutionContext
    35
    .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 も含む事とします。

    View Slide

  37. AsyncTaskMethodBuilder
    .Start(ref stateMachine)
    36

    View Slide

  38. Compiler Transform
    37
    • AsyncTaskMethodBuilder.Start メソッドは何をやっているのか?
    • 実質的には、ステートマシンの MoveNext を呼んでいるだけ。
    • 何故直接 ステートマシンの MoveNext メソッドを呼ばないのか?
    • Start メソッドでは、細かいけど大事な事をやっている。それはなにか?
    • ExecutionContext を理解する必要があります。
    AsyncTaskMethodBuilder.Start(ref stateMachine)
    再掲

    View Slide

  39. AsyncTaskMethodBuilder.Start(ref stateMachine)
    38
    実際には…

    View Slide

  40. AsyncTaskMethodBuilder.Start(ref stateMachine)
    39
    実際には…
    ステートマシンの MoveNext を呼ぶ前に
    ExecutionContext をキャプチャしておき

    View Slide

  41. AsyncTaskMethodBuilder.Start(ref stateMachine)
    40
    実際には…
    ステートマシンの MoveNext を呼ぶ前に
    ExecutionContext をキャプチャしておき
    最後に、事前にキャプチャしておいた
    ExecutionContext をリストアする

    View Slide

  42. AsyncTaskMethodBuilder.Start(ref stateMachine)
    41
    実際には…
    ステートマシンの MoveNext を呼ぶ前に
    ExecutionContext をキャプチャしておき
    最後に、事前にキャプチャしておいた
    ExecutionContext をリストアする
    何故こんな事を
    しているのか?

    View Slide

  43. Compiler Transform
    42
    • メソッドの呼び出し元へ ambient data のリークを防ぐため。
    何故こんな事をしているのか?

    View Slide

  44. Compiler Transform
    43
    • メソッドの呼び出し元へ ambient data のリークを防ぐため。
    何故こんな事をしているのか?
    ログインしたユーザ情報を
    AsyncLocal を用いて
    ExecutionContext に格納

    View Slide

  45. Compiler Transform
    44
    • メソッドの呼び出し元へ ambient data のリークを防ぐため。
    何故こんな事をしているのか?
    ログインしたユーザ情報を
    AsyncLocal を用いて
    ExecutionContext に格納
    builder.Start() の finaly で呼び出し時点の
    ExecutionContext がリストアされて
    いなかった場合、変更された
    ExecutionContext から ambient data がリーク

    View Slide

  46. IAsyncStateMachine.MoveNext
    45

    View Slide

  47. MoveNext
    46
    1. ステートマシン/ビルダーの構造体が初期化され
    2. ビルダーの builder.Start(ref stateMachine) メソッドが呼ばれ
    3. ステートマシンの MoveNext メソッドが呼び出される
    async メソッドが実行されたら
    いまここ

    View Slide

  48. MoveNext
    47
    • 開発者がメソッドに記述したあったロジックを
    すべて含み、かつ多くの変更も含む。
    • どこまで状態が進んだか保存するフィールドと
    それに応じてジャンプする switch 等。
    • MoveNext メソッドは、全ての作業が終了したら
    async メソッドから返される Task オブジェクトを
    必ず完了させる責務を負う。
    • Task.IsCompleted が true の状態
    MoveNext の中身 (try block 略)
    終了時 or 例外発生時に
    ビルダーの SetException/SetResult を
    経由し Task を完了状態にする

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  55. MoveNext
    54
    • awaiter と呼ばれるものは、以下のような interface を実装している。
    • Task 完了時に呼び出される continuation を
    OnCompleted / UnsafeOnCompletedで渡す。
    Awaiter のとりあえずコレだけは押さえて欲しいポイント!

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  63. 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

    View Slide

  64. MoveNext
    63
    • 右のようなシンプルなコードで
    パフォーマンスを確認。
    • 1000 x 1000 で合計 100 万回の
    await Task.Yield() が走る。
    .NET Framework での振る舞いをプロファイラで見てみる

    View Slide

  65. MoveNext
    64
    .NET Framework での振る舞いをプロファイラで見てみる

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  70. 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 万回)

    View Slide

  71. 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 はどうだろうか?

    View Slide

  72. MoveNext
    71
    .NET Core での振る舞いをプロファイラで見てみる

    View Slide

  73. MoveNext
    72
    .NET Core での振る舞いをプロファイラで見てみる
    とんでもないレベルの改善…!

    View Slide

  74. MoveNext
    73
    .NET Core での振る舞いをプロファイラで見てみる
    とんでもないレベルの改善…!
    .NET Framework : 500 万以上の allocation (~145MB)
    .NET Core : 1000 程度の allocation (~109KB)

    View Slide

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

    View Slide

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

    View Slide

  77. MoveNext
    76
    1. .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 でどのように
    動いているかを
    知らないと分からない。

    View Slide

  78. MoveNext
    77
    • builder.AwaitUnsafeOnCompleted で continuation が登録される。
    MoveNext 内でサスペンド時に何が起きていたか?(復習)

    View Slide

  79. MoveNext
    78
    • builder.AwaitUnsafeOnCompleted で continuation が登録される。
    ここまでは .NET Framework, .NET Core で共通。
    AwaitUnsafeOnCompleted の内部実装が大幅に異なる。
    MoveNext 内でサスペンド時に何が起きていたか?(復習)

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  84. MoveNext
    83
    • その名の通り、スタックに存在する構造体をヒープに box 化するためのクラス
    • 重要な事は、IAsyncStateMachine として boxing するのではなく、
    TStateMachine として強く型付けされたフィールドとしてヒープに。
    • それでいながら、Task そのもの (基底型から分かる通り)
    • Action と ExecutionContext も
    フィールドに格納。
    MoveNextRunner はいらない子に。
    • 新たに ExecutionContext を
    キャプチャしてもフィールドを
    書き換えればいいだけ。
    AsyncStateMachineBox

    View Slide

  85. MoveNext
    84
    • awaiter に continuation を登録する際、
    AsyncStateMachineBox の MoveNext を
    叩けるようにする。
    • 通常であれば、UnsafeOnCompleted に
    デリゲートを渡して登録するわけだが、
    その際には MoveNext を指すデリゲートを
    初回のみ作成しキャッシュしておく。
    • Continuation 発火時、
    AsyncStateMachineBox の MoveNext は
    ステートマシンの MoveNext を
    呼び出す前に ExecutionContext の
    リストアを行う。
    awaiter と continuation と AsyncStateMachineBox

    View Slide

  86. MoveNext
    85
    .NET Core での振る舞いをプロファイラで見てみる (2回目)

    View Slide

  87. MoveNext
    86
    .NET Core での振る舞いをプロファイラで見てみる (2回目)
    Action continuation が
    求められている

    View Slide

  88. MoveNext
    87
    .NET Core での振る舞いをプロファイラで見てみる (2回目)
    awaiter に登録するための
    デリゲートの allocation が
    発生していないぞ?何故??
    Action continuation が
    求められている

    View Slide

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

    View Slide

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

    View Slide

  91. MoveNext
    90
    • Task.Yield() は YieldAwaitable を返す。
    • YieldAwaitable は GetAwaiter で YieldAwaiter を返す。
    Task.Yield() の最適化例
    AwaitUnsafeOnCompleted で box を受け取り
    ThreadPool に
    デリゲートではなく
    Box をそのまま渡す
    非同期インフラストラクチャは
    デリゲートを作成せずに
    box の MoveNext を
    直接呼び出すようになるので
    Allocation が発生していない

    View Slide

  92. まとめ
    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 も含む事とします。

    View Slide

  93. 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/

    View Slide