Slide 1

Slide 1 text

C# の async/await は 実際にどうやって動いているか .NET ラボ 2023/05/27 何縫ねの。

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

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

Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

C# のイテレータ 9

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

async/await 21

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

ExecutionContext 28

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

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 メソッドでの問題点

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

AsyncTaskMethodBuilder .Start(ref stateMachine) 36

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

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

Slide 46

Slide 46 text

IAsyncStateMachine.MoveNext 45

Slide 47

Slide 47 text

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

Slide 48

Slide 48 text

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

Slide 49

Slide 49 text

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

Slide 50

Slide 50 text

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

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

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

Slide 55

Slide 55 text

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

Slide 56

Slide 56 text

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

Slide 57

Slide 57 text

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

Slide 58

Slide 58 text

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

Slide 59

Slide 59 text

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

Slide 60

Slide 60 text

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

Slide 61

Slide 61 text

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

Slide 62

Slide 62 text

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

Slide 63

Slide 63 text

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

Slide 64

Slide 64 text

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

Slide 65

Slide 65 text

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

Slide 66

Slide 66 text

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

Slide 67

Slide 67 text

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

Slide 68

Slide 68 text

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

Slide 69

Slide 69 text

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

Slide 70

Slide 70 text

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

Slide 71

Slide 71 text

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

Slide 72

Slide 72 text

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

Slide 73

Slide 73 text

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

Slide 74

Slide 74 text

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

Slide 75

Slide 75 text

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

Slide 76

Slide 76 text

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 は???

Slide 77

Slide 77 text

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

Slide 78

Slide 78 text

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

Slide 79

Slide 79 text

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

Slide 80

Slide 80 text

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

Slide 81

Slide 81 text

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

Slide 82

Slide 82 text

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

Slide 83

Slide 83 text

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

Slide 84

Slide 84 text

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

Slide 85

Slide 85 text

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

Slide 86

Slide 86 text

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

Slide 87

Slide 87 text

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

Slide 88

Slide 88 text

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

Slide 89

Slide 89 text

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

Slide 90

Slide 90 text

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

Slide 91

Slide 91 text

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

Slide 92

Slide 92 text

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

Slide 93

Slide 93 text

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/