Upgrade to PRO for Only $50/Year—Limited-Time Offer! 🔥
Speaker Deck
Features
Speaker Deck
PRO
Sign in
Sign up for free
Search
Search
C# の async/await は実際にどうやって動いているか
Search
neno
May 27, 2023
Technology
10
25k
C# の async/await は実際にどうやって動いているか
.NET ラボ 2023/05/27 での発表資料
↓↓↓ スライドより詳細なブログです ↓↓↓
C# の async/await は実際にどうやって動いているか
neno
May 27, 2023
Tweet
Share
More Decks by neno
See All by neno
C# 13 / .NET 9 の新機能 (RC 1 時点)
nenonaninu
1
1.8k
Re:ゼロから始める Observability
nenonaninu
3
780
Node-AI のリッチな WEB フロントエンドを支える技術
nenonaninu
3
1.5k
C# ではじめる OpenTelemetry
nenonaninu
0
4.3k
.NET 8 で既定で有効になった Dynamic PGO について
nenonaninu
3
7.6k
明日から使える ASP.NET Core ロギング術!
nenonaninu
1
9k
C# と HTTP/2 と gRPC
nenonaninu
3
8.2k
SignalR を使ったアプリケーション開発をより快適に!
nenonaninu
1
2.2k
Roslyn とその活用法
nenonaninu
2
1.4k
Other Decks in Technology
See All in Technology
開発者向けツールを魔改造してセキュリティ診断ツールを作っている話 - 第1回 セキュリティ若手の会 LT
pizzacat83
0
270
プロダクトマネージャーは 事業責任者の夢をみるのか pmconf2024
gimupop
1
4.6k
データ基盤の負債解消のためのリプレイス
livesense
PRO
0
150
クラウドネイティブへの小さな一歩!既存VMからコンテナまで、KubeVirtが実現する『無理しないペースの移行』とは!?
tsukaman
0
110
Kubernetesを知る
logica0419
17
4.6k
ONNX推論クレートの比較と実装奮闘記
emergent
0
280
241130紅白ぺぱ合戦LT「編集の技術」
toya524287
5
580
ドメインロジックで考えるテスタビリティ
leveragestech
1
270
Will multimodal language processing change the world?
keio_smilab
PRO
2
280
B11-SharePoint サイトのストレージ管理を考えよう
maekawa123
0
120
マルチプロダクト、マルチデータ基盤での Looker活用事例 〜BQじゃなくてもLookerはいいぞ〜
gappy50
0
130
40歲的我會給20歲的自己,關於軟體開發的7個建議
line_developers_tw
PRO
0
120
Featured
See All Featured
Testing 201, or: Great Expectations
jmmastey
40
7.1k
Practical Tips for Bootstrapping Information Extraction Pipelines
honnibal
PRO
10
770
Building Flexible Design Systems
yeseniaperezcruz
327
38k
The Art of Delivering Value - GDevCon NA Keynote
reverentgeek
8
1.2k
Templates, Plugins, & Blocks: Oh My! Creating the theme that thinks of everything
marktimemedia
27
2.1k
Site-Speed That Sticks
csswizardry
1
120
Let's Do A Bunch of Simple Stuff to Make Websites Faster
chriscoyier
507
140k
5 minutes of I Can Smell Your CMS
philhawksworth
202
19k
Chrome DevTools: State of the Union 2024 - Debugging React & Beyond
addyosmani
1
71
Bash Introduction
62gerente
608
210k
A Tale of Four Properties
chriscoyier
157
23k
The World Runs on Bad Software
bkeepers
PRO
65
11k
Transcript
C# の async/await は 実際にどうやって動いているか .NET ラボ 2023/05/27 何縫ねの。
自己紹介 1 • 所属: NTTコミュニケーションズ イノベーションセンター • 趣味: C#, OSS,
ドール, 一眼(α7 IV) • 執心領域 • C# ⇔ TypeScript • SignalR 何縫ねの。 nenoNaninu nenoMake ブログ https://blog.neno.dev その他 https://neno.dev
OSS 紹介 2 属性を付与するだけ Tapper • C# の型定義から TypeScript の型定義を生成する
.NET Tool/ library • JSON / MessagePack 対応! https://github.com/nenoNaninu/Tapper
OSS 紹介 3 • C# の SignalR Client を強く型付けするための Source
Generator TypedSignalR.Client Before After (using TypedSignalR.Client) こんな SignalR の Hub と Receiver の interface が あったとして… 脱文字列! 全てが強く型付け! https://github.com/nenoNaninu/TypedSignalR.Client
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 • 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 紹介
async/await 以前 (= C# 4.0 以前) 6
async/await 以前 7 • async/await が無かった時代は、ContinueWith メソッドを使って continuation を コールバックとして登録していた。
その当時の非同期はコールバック…
async/await 以前 8 • async/await が無かった時代は、ContinueWith メソッドを使って continuation を コールバックとして登録していた。
その当時の非同期はコールバック… どうすれば脱コールバック化を果たせるだろうか? どうすれば同期的な書き心地を得られるだろうか?
C# のイテレータ 9
C# のイテレータ 10 • C# のイテレータは 次のように書ける。 IEnumerable<T>
C# のイテレータ 11 • C# のイテレータは 次のように書ける。 IEnumerable<T> つまりコルーチンである
C# のイテレータ 12 • C# のイテレータは 次のように書ける。 IEnumerable<T> コンパイラはこれを ステートマシンとして展開
つまりコルーチンである
C# のイテレータ 13 • 元のロジックはすべて MoveNext メソッドの中に。 IEnumerable<T> 開発者が記述した全てのロジック +
ステートに応じた分岐
C# のイテレータ 14 • 手動で MoveNext を叩くことで、任意 & 適切なタイミングで コルーチンを進ませること可能。
IEnumerable<T>
C# のイテレータ 15 • 手動で MoveNext を叩くことで、任意 & 適切なタイミングで コルーチンを進ませること可能。
IEnumerable<T> 非同期処理が完了した際に呼ばれる continuation として MoveNext() を 呼び出せるとしたら、どうだろうか?
C# のイテレータ 16 • こんなヘルパー関数を用意してあげる。 • IterateAsync • Process というローカル関数内で
MoveNext() を呼び出しイテレータを進め、 Current で取得した Task インスタンスに continuation を ContinueWith() 経由で登録 IEnumerable<Task> イテレータの Current で取得した Task が完了したら、引数で渡された イテレータの MoveNext() が呼び出される
C# のイテレータ 17 • Task インスタンスを yield return するメソッドを定義 •
その返り値を IterateAsync に 渡してあげる。 IterateAsync の何が嬉しいのか?
C# のイテレータ 18 • Task インスタンスを yield return するメソッドを定義 •
その返り値を IterateAsync に 渡してあげる。 IterateAsync の何が嬉しいのか? 脱コールバック!!
C# のイテレータ 19 • Task インスタンスを yield return するメソッドを定義 •
その返り値を IterateAsync に 渡してあげる。 IterateAsync の何が嬉しいのか? 脱コールバック!! yield return を await に 置き換えて読んでみると、おや…?
C# のイテレータ 20 • Task インスタンスを yield return するメソッドを定義 •
その返り値を IterateAsync に 渡してあげる。 IterateAsync の何が嬉しいのか? 脱コールバック!! yield return を await に 置き換えて読んでみると、おや…? C# コンパイラのイテレータと async/await をサポートするための ロジックの約95%は共有されている
async/await 21
Compiler Transform 22 • async メソッドは右の様に展開される • 非 async メソッド
+ ステートマシン になる。 • ステートマシンに実装されている interface コンパイラの仕事
Compiler Transform 23 • ステートマシン (struct) • async メソッドに記述されたロジックを コンパイラがステートマシンに変換。
• ステートマシンには元々のロジックに加え 進行状態を保存するフィールドなども 生えている (イテレータとほぼ同じ!) • IAsyncStateMachine を実装 • AsyncTaskMethodBuilder (struct) • Task プロパティとして露出する Task 型のオブジェクトを作成 • それに対する結果の設定 • SetResult / SetException • await した完了してない Task に対する continuation の登録 • AwaitOnCompleted / AwaitUnsafeOnCompleted 大事な登場人物
Compiler Transform 24 • ステートマシン (struct) • async メソッドに記述されたロジックを コンパイラがステートマシンに変換。
• ステートマシンには元々のロジックに加え 進行状態を保存するフィールドなども 生えている (イテレータとほぼ同じ!) • IAsyncStateMachine を実装 • AsyncTaskMethodBuilder (struct) • Task プロパティとして露出する Task 型のオブジェクトを作成 • それに対する結果の設定 • SetResult / SetException • await した完了してない Task に対する continuation の登録 • AwaitOnCompleted / AwaitUnsafeOnCompleted 大事な登場人物 双方共に struct サスペンドがなければ zero allocation
Compiler Transform 25 1. ステートマシン (struct)の初期化 • メソッドの引数や初期状態を格納 2. AsyncTaskMethodBuilder
(struct) の作成 • 内部的には default を返しているだけ。 • ステートマシンに格納される 3. AsyncTaskMethodBuilder.Start で ステートマシンのロジックを開始 4. Builder 経由で返り値である Task オブジェクトを取得し、return。 • stateMachine.<>t__builder.Task メソッドが呼ばれてからの処理内容
Compiler Transform 26 1. ステートマシン (struct)の初期化 • メソッドの引数や初期状態を格納 2. AsyncTaskMethodBuilder
(struct) の作成 • 内部的には default を返しているだけ。 • ステートマシンに格納される 3. AsyncTaskMethodBuilder.Start で ステートマシンのロジックを開始 4. Builder 経由で返り値である Task オブジェクトを取得し、return。 • stateMachine.<>t__builder.Task メソッドが呼ばれてからの処理内容
Compiler Transform 27 • AsyncTaskMethodBuilder.Start メソッドは何をやっているのか? • ステートマシンの操作という点では、MoveNext を叩いているだけ。 •
何故直接 ステートマシンの MoveNext メソッドを呼ばないのか? • Start メソッドでは、細かいけど大事な事をやっている。それはなにか? • ExecutionContext を理解する必要があります。 AsyncTaskMethodBuilder.Start(ref stateMachine)
ExecutionContext 28
ExecutionContext 29 • 明示的なデータの渡し方 • メソッドからメソッドに引数でデータを渡すパターン。 • こちらは特に問題ない。 • 暗黙的なデータの渡し方
• Static フィールドなどにデータを格納し、どこかでそれを読み出すパターン。 • こういったデータはよく ambient data とか呼ばれる。 データの渡し方のパターン
ExecutionContext 30 • 明示的なデータの渡し方 • メソッドからメソッドに引数でデータを渡すパターン。 • こちらは特に問題ない。 • 暗黙的なデータの渡し方
• Static フィールドなどにデータを格納し、どこかでそれを読み出すパターン。 • こういったデータはよく ambient data とか呼ばれる。 データの渡し方のパターン async メソッドの場合、 ambient data の渡し方で 様々な課題が発生する
ExecutionContext 31 • 普通の static フィールドの利用 • async メソッドは通常、平行ないし並列で実行される。 •
ある async control flow で書き換えた static フィールドは、別の flow で書き換えられてしまう。 • 競合を避けるためには1つのメソッドしか実行することができない。 • つまり ambient data の渡し方として成立しない。 Ambient data の渡し方のパターンと async メソッドでの問題点
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 メソッドでの問題点
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
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 も含む事とします。
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 も含む事とします。
AsyncTaskMethodBuilder .Start(ref stateMachine) 36
Compiler Transform 37 • AsyncTaskMethodBuilder.Start メソッドは何をやっているのか? • 実質的には、ステートマシンの MoveNext を呼んでいるだけ。
• 何故直接 ステートマシンの MoveNext メソッドを呼ばないのか? • Start メソッドでは、細かいけど大事な事をやっている。それはなにか? • ExecutionContext を理解する必要があります。 AsyncTaskMethodBuilder.Start(ref stateMachine) 再掲
AsyncTaskMethodBuilder.Start(ref stateMachine) 38 実際には…
AsyncTaskMethodBuilder.Start(ref stateMachine) 39 実際には… ステートマシンの MoveNext を呼ぶ前に ExecutionContext をキャプチャしておき
AsyncTaskMethodBuilder.Start(ref stateMachine) 40 実際には… ステートマシンの MoveNext を呼ぶ前に ExecutionContext をキャプチャしておき 最後に、事前にキャプチャしておいた
ExecutionContext をリストアする
AsyncTaskMethodBuilder.Start(ref stateMachine) 41 実際には… ステートマシンの MoveNext を呼ぶ前に ExecutionContext をキャプチャしておき 最後に、事前にキャプチャしておいた
ExecutionContext をリストアする 何故こんな事を しているのか?
Compiler Transform 42 • メソッドの呼び出し元へ ambient data のリークを防ぐため。 何故こんな事をしているのか?
Compiler Transform 43 • メソッドの呼び出し元へ ambient data のリークを防ぐため。 何故こんな事をしているのか? ログインしたユーザ情報を
AsyncLocal<T> を用いて ExecutionContext に格納
Compiler Transform 44 • メソッドの呼び出し元へ ambient data のリークを防ぐため。 何故こんな事をしているのか? ログインしたユーザ情報を
AsyncLocal<T> を用いて ExecutionContext に格納 builder.Start() の finaly で呼び出し時点の ExecutionContext がリストアされて いなかった場合、変更された ExecutionContext から ambient data がリーク
IAsyncStateMachine.MoveNext 45
MoveNext 46 1. ステートマシン/ビルダーの構造体が初期化され 2. ビルダーの builder.Start(ref stateMachine) メソッドが呼ばれ 3.
ステートマシンの MoveNext メソッドが呼び出される async メソッドが実行されたら いまここ
MoveNext 47 • 開発者がメソッドに記述したあったロジックを すべて含み、かつ多くの変更も含む。 • どこまで状態が進んだか保存するフィールドと それに応じてジャンプする switch 等。
• MoveNext メソッドは、全ての作業が終了したら async メソッドから返される Task オブジェクトを 必ず完了させる責務を負う。 • Task.IsCompleted が true の状態 MoveNext の中身 (try block 略) 終了時 or 例外発生時に ビルダーの SetException/SetResult を 経由し Task を完了状態にする
MoveNext 48 • 先頭の source.ReadAsync は Task<int> を返す。 • この
Task<int> オブジェクトの状態をみて分岐 • 完了している場合は GetResult() で結果を 取得して同期的(サスペンドなし)に継続。 • 完了してない場合は continuation をフックする 必要がある。 MoveNext の中身 (try block の一部)
MoveNext 49 • 先頭の source.ReadAsync は Task<int> を返す。 • この
Task<int> オブジェクトの状態をみて分岐 • 完了している場合は GetResult() で結果を 取得して同期的(サスペンドなし)に継続。 • 完了してない場合は continuation をフックする 必要がある。 MoveNext の中身 (try block の一部) 実際には Task オブジェクトに 直接アクセスはせず、 awaiter を介して状態の確認と continuation の登録を行う
MoveNext 50 • 先頭の source.ReadAsync は Task<int> を返す。 • この
Task<int> オブジェクトの状態をみて分岐 • 完了している場合は GetResult() で結果を 取得して同期的(サスペンドなし)に継続。 • 完了してない場合は continuation をフックする 必要がある。 MoveNext の中身 (try block の一部) 実際には Task オブジェクトに 直接アクセスはせず、 awaiter を介して状態の確認と continuation の登録を行う awaiter パターンに従えば何でも (Task 以外でも) await できる
MoveNext 51 • 先頭の source.ReadAsync は Task<int> を返す。 • この
Task<int> オブジェクトの状態をみて分岐 • 完了している場合は GetResult() で結果を 取得して同期的(サスペンドなし)に継続。 • 完了してない場合は continuation をフックする 必要がある。 MoveNext の中身 (try block の一部) 実際には Task オブジェクトに 直接アクセスはせず、 awaiter を介して状態の確認と continuation の登録を行う awaiter パターンに従えば何でも (Task 以外でも) await できる
MoveNext 52 • 先頭の source.ReadAsync は Task<int> を返す。 • この
Task<int> オブジェクトの状態をみて分岐 • 完了している場合は GetResult() で結果を 取得して同期的(サスペンドなし)に継続。 • 完了してない場合は continuation をフックする 必要がある。 MoveNext の中身 (try block の一部) 実際には Task オブジェクトに 直接アクセスはせず、 awaiter を介して状態の確認と continuation の登録を行う awaiter パターンに従えば何でも (Task 以外でも) await できる
MoveNext 53 • 先頭の source.ReadAsync は Task<int> を返す。 • この
Task<int> オブジェクトの状態をみて分岐 • 完了している場合は GetResult() で結果を 取得して同期的(サスペンドなし)に継続。 • 完了してない場合は continuation をフックする 必要がある。 MoveNext の中身 (try block の一部) 実際には Task オブジェクトに 直接アクセスはせず、 awaiter を介して状態の確認と continuation の登録を行う awaiter パターンに従えば何でも (Task 以外でも) await できる ビルダーを介して continuation をフック
MoveNext 54 • awaiter と呼ばれるものは、以下のような interface を実装している。 • Task 完了時に呼び出される
continuation を OnCompleted / UnsafeOnCompletedで渡す。 Awaiter のとりあえずコレだけは押さえて欲しいポイント!
MoveNext • サスペンドが発生するまで • ステートマシンとビルダー の 双方はスタックに存在する • サスペンドするためには •
ステートマシンとビルダーをスタックからヒープにコピーしないといけない…! • なぜ? • 使用中のスタックは同一のスレッド上で実行される、 現在のフローとは全く無関係な後続の作業に使用される。明け渡さないといけない。 • 明け渡した上で、正しく continuation が実行できないといけない。 • サスペンド発生後 • continuation では、ヒープ上にコピーされた ステートマシンの MoveNext メソッドを呼び出す必要がある。 builder.AwaitUnsafeOnCompleted でサスペンドが起きる前と後 ExecutionContext がついて回る
MoveNext • ステートマシンは、ExecutionContext に格納された ambient data を サスペンドされた時点で確実にキャプチャし、再開時点で復元する必要がある。 • つまり
continuation にキャプチャした ExecutionContext を取り込む必要がある。 MoveNext と ExecutionContext
MoveNext • ステートマシンは、ExecutionContext に格納された ambient data を サスペンドされた時点で確実にキャプチャし、再開時点で復元する必要がある。 • つまり
continuation にキャプチャした ExecutionContext を取り込む必要がある。 MoveNext と ExecutionContext ステートマシンの MoveNext を指す デリゲートを作るだけでは足りない
MoveNext • ステートマシンは、ExecutionContext に格納された ambient data を サスペンドされた時点で確実にキャプチャし、再開時点で復元する必要がある。 • つまり
continuation にキャプチャした ExecutionContext を取り込む必要がある。 MoveNext と ExecutionContext ステートマシンの MoveNext を指す デリゲートを作るだけでは足りない 毎回デリゲートを作成した場合 毎回デリゲートの allocation + boxing コストがかかる
MoveNext • ステートマシンは、ExecutionContext に格納された ambient data を サスペンドされた時点で確実にキャプチャし、再開時点で復元する必要がある。 • つまり
continuation にキャプチャした ExecutionContext を取り込む必要がある。 MoveNext と ExecutionContext ステートマシンの MoveNext を指す デリゲートを作るだけでは足りない 毎回デリゲートを作成した場合 毎回デリゲートの allocation + boxing コストがかかる 初回のサスペンド時にのみ 構造体を boxing し、 以降は boxing されたヒープにある オブジェクトを MoveNext を 行う対象として使用し、 適切なタイミングで ExecutionContext をキャプチャし、 再開時にはその ExecutionContext を 使用する必要がある
MoveNext • ステートマシンは、ExecutionContext に格納された ambient data を サスペンドされた時点で確実にキャプチャし、再開時点で復元する必要がある。 • つまり
continuation にキャプチャした ExecutionContext を取り込む必要がある。 MoveNext と ExecutionContext ステートマシンの MoveNext を指す デリゲートを作るだけでは足りない 毎回デリゲートを作成した場合 毎回デリゲートの allocation + boxing コストがかかる 初回のサスペンド時にのみ 構造体を boxing し、 以降は boxing されたヒープにある オブジェクトを MoveNext を 行う対象として使用し、 適切なタイミングで ExecutionContext をキャプチャし、 再開時にはその ExecutionContext を 使用する必要がある 複雑!
MoveNext • ステートマシンは、ExecutionContext に格納された ambient data を サスペンドされた時点で確実にキャプチャし、再開時点で復元する必要がある。 • つまり
continuation にキャプチャした ExecutionContext を取り込む必要がある。 MoveNext と ExecutionContext ステートマシンの MoveNext を指す デリゲートを作るだけでは足りない 毎回デリゲートを作成した場合 毎回デリゲートの allocation + boxing コストがかかる 初回のサスペンド時にのみ 構造体を boxing し、 以降は boxing されたヒープにある オブジェクトを MoveNext を 行う対象として使用し、 適切なタイミングで ExecutionContext をキャプチャし、 再開時にはその ExecutionContext を 使用する必要がある 複雑! .NET Framework から .NET Core で このあたりがとても改善され パフォーマンスが向上している
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
MoveNext 63 • 右のようなシンプルなコードで パフォーマンスを確認。 • 1000 x 1000 で合計
100 万回の await Task.Yield() が走る。 .NET Framework での振る舞いをプロファイラで見てみる
MoveNext 64 .NET Framework での振る舞いをプロファイラで見てみる
MoveNext 65 .NET Framework での振る舞いをプロファイラで見てみる .NET Framework での ExecutionContext は
mutable. Capture されるタイミングで毎回コピーが発生 (100万回)
MoveNext 66 .NET Framework での振る舞いをプロファイラで見てみる .NET Framework での ExecutionContext は
mutable. Capture されるタイミングで毎回コピーが発生 (100万回) UnsafeOnCompleted に渡す デリゲートを作成 (100万回)
MoveNext 67 .NET Framework での振る舞いをプロファイラで見てみる .NET Framework での ExecutionContext は
mutable. Capture されるタイミングで毎回コピーが発生 (100万回) UnsafeOnCompleted に渡す デリゲートを作成 (100万回) サスペンド毎に MoveNextRunner を allocate (100万回)
MoveNext 68 .NET Framework での振る舞いをプロファイラで見てみる .NET Framework での ExecutionContext は
mutable. Capture されるタイミングで毎回コピーが発生 (100万回) UnsafeOnCompleted に渡す デリゲートを作成 (100万回) ExecutionContext に含まれる 別のコンテキストのコピー(100万回) サスペンド毎に MoveNextRunner を allocate (100万回)
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 万回)
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 はどうだろうか?
MoveNext 71 .NET Core での振る舞いをプロファイラで見てみる
MoveNext 72 .NET Core での振る舞いをプロファイラで見てみる とんでもないレベルの改善…!
MoveNext 73 .NET Core での振る舞いをプロファイラで見てみる とんでもないレベルの改善…! .NET Framework : 500
万以上の allocation (~145MB) .NET Core : 1000 程度の allocation (~109KB)
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 での改善の要因 (の一部)
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 は???
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 でどのように 動いているかを 知らないと分からない。
MoveNext 77 • builder.AwaitUnsafeOnCompleted で continuation が登録される。 MoveNext 内でサスペンド時に何が起きていたか?(復習)
MoveNext 78 • builder.AwaitUnsafeOnCompleted で continuation が登録される。 ここまでは .NET Framework,
.NET Core で共通。 AwaitUnsafeOnCompleted の内部実装が大幅に異なる。 MoveNext 内でサスペンド時に何が起きていたか?(復習)
MoveNext 79 1. まず、ExecutionContext を capture. 2. その後 m_task が
null なら AsyncStateMachineBox<TStateMachine> を allocate. .NET Core のビルダーと AwaitUnsafeOnCompleted
MoveNext 80 1. まず、ExecutionContext を capture. 2. その後 m_task が
null なら AsyncStateMachineBox<TStateMachine> を allocate. .NET Core のビルダーと AwaitUnsafeOnCompleted フィールドが一つあるだけ…!
MoveNext 81 1. まず、ExecutionContext を capture. 2. その後 m_task が
null なら AsyncStateMachineBox<TStateMachine> を allocate. .NET Core のビルダーと AwaitUnsafeOnCompleted フィールドが一つあるだけ…! 圧倒的改善を 実現するための鍵
MoveNext 82 1. まず、ExecutionContext を capture. 2. その後 m_task が
null なら AsyncStateMachineBox<TStateMachine> を allocate. .NET Core のビルダーと AwaitUnsafeOnCompleted フィールドが一つあるだけ…! 基底型に注目! 圧倒的改善を 実現するための鍵
MoveNext 83 • その名の通り、スタックに存在する構造体をヒープに box 化するためのクラス • 重要な事は、IAsyncStateMachine として boxing
するのではなく、 TStateMachine として強く型付けされたフィールドとしてヒープに。 • それでいながら、Task そのもの (基底型から分かる通り) • Action と ExecutionContext も フィールドに格納。 MoveNextRunner はいらない子に。 • 新たに ExecutionContext を キャプチャしてもフィールドを 書き換えればいいだけ。 AsyncStateMachineBox<TStateMachine>
MoveNext 84 • awaiter に continuation を登録する際、 AsyncStateMachineBox の MoveNext
を 叩けるようにする。 • 通常であれば、UnsafeOnCompleted に デリゲートを渡して登録するわけだが、 その際には MoveNext を指すデリゲートを 初回のみ作成しキャッシュしておく。 • Continuation 発火時、 AsyncStateMachineBox の MoveNext は ステートマシンの MoveNext を 呼び出す前に ExecutionContext の リストアを行う。 awaiter と continuation と AsyncStateMachineBox
MoveNext 85 .NET Core での振る舞いをプロファイラで見てみる (2回目)
MoveNext 86 .NET Core での振る舞いをプロファイラで見てみる (2回目) Action continuation が 求められている
MoveNext 87 .NET Core での振る舞いをプロファイラで見てみる (2回目) awaiter に登録するための デリゲートの allocation
が 発生していないぞ?何故?? Action continuation が 求められている
MoveNext 88 • 非同期インフラストラクチャは、Task や TaskAwaiter などのコアとなる 型について既知です。 • さらにそれらは内部アクセス権を持っています。
• public に定義されたルールに従う必要はない…! • 非同期インフラストラクチャが既知の awaiter については より無駄のない経路 (デリゲートを作成しない) を辿る事が出来る。 IAsyncStateMachineBox に対して最適化されています。
MoveNext 89 • 非同期インフラストラクチャは、Task や TaskAwaiter などのコアとなる 型について既知です。 • さらにそれらは内部アクセス権を持っています。
• public に定義されたルールに従う必要はない…! • 非同期インフラストラクチャが既知の awaiter については より無駄のない経路 (デリゲートを作成しない) を辿る事が出来る。 IAsyncStateMachineBox に対して最適化されています。 • await したい対象のオブジェクトが GetAwaiter メソッドで awaiter を返す事 • awaiter には OnCompleted / UnsafeOnCompleted メソッドが必要でどちらも continuation を Action として受け取る。 Public に定義されたルール(の一部)
MoveNext 90 • Task.Yield() は YieldAwaitable を返す。 • YieldAwaitable は
GetAwaiter で YieldAwaiter を返す。 Task.Yield() の最適化例 AwaitUnsafeOnCompleted で box を受け取り ThreadPool に デリゲートではなく Box をそのまま渡す 非同期インフラストラクチャは デリゲートを作成せずに box の MoveNext を 直接呼び出すようになるので Allocation が発生していない
まとめ 91 • コールバックによる continuation の解決の鍵は C# のイテレータにあった • C#
コンパイラのイテレータと async/await をサポートするための ロジックの約95%は共有されている • async/await で重要な型 • ExecutionContext • AsyncTaskMethodBuilder • IAsyncStateMachine • IAsyncStateMachineBox • AsyncStateMachineBox • .NET Framework から .NET Core で劇的な進化を遂げている。 ※ 本セッションでは NET Framework との呼び分けのため .NET Core に .NET 5,6,7 も含む事とします。
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/