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 full-size slide

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

    View full-size slide

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

    View full-size 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 full-size 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 full-size 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  10. C# のイテレータ
    9

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  22. async/await
    21

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  29. ExecutionContext
    28

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size 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 full-size 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 full-size 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 full-size 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 full-size slide

  37. AsyncTaskMethodBuilder
    .Start(ref stateMachine)
    36

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  46. IAsyncStateMachine.MoveNext
    45

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size 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 full-size 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size 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 full-size 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 full-size 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size 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 full-size 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 full-size slide