はじめに
C#er は呼吸するように使っている async/await。
そんな async/await について、先日 Stephen Toub 氏 (.NET の中の人。中心人物の一人。) が How Async/Await Really Works in C# という非常に面白い記事を投稿していました。 この記事では Stephen 氏の記事をベースに、C# において async/await は実際どうやって動いてるの?というお話をしていきます。 以前に C#での非同期メソッドの分析。 という翻訳記事を書いたのですが、元になった記事が 2017/11 (.NET Core 2 リリース直後) に書かれたもので、2023/03 時点 (.NET 7 リリース後) の実装とはだいぶ異なっていました(圧倒的にパフォーマンス改善/最適化がなされていました)。
ちなみに、async/await 関係ないマルチスレッド関連については、以前 【C#】マルチスレッド関連操作の詳説。 という記事を書いたので、気になる方は是非読んでみてください。
なお、記事中では .NET Core と記述した場合、.NET (5,6,7) も含む事とします。 .NET Framework と .NET で呼び分けるとちょっと分かりづらいので、.NET Framework と .NET Core で呼び分けます。
また記事中に書いてあるコードは理解のしやすさを優先しており、いろいろ省いていたりするため実際の dotnet/runtime のコードとは異なる場合があります。
登壇版
.NET ラボ 2023/05/27 で発表した資料です。 30 分登壇版なので、この記事よりはアッサリ目に纏まっています。
そして以下が登壇時の録画です(再生位置は合わせてます)。 ちなみに、結構な速度で喋っていますが (自覚はしてます) 「全体の雰囲気をなんとなく 30 分で知ってもらう」 という感じのつもりで喋っているので、まぁそのつもりで聞いていただくのがいいかと思います。 大事なのは雰囲気です、雰囲気。 ちゃんと理解するのに 30 分は足りなさすぎるので、まぁそんな割り切りを。 そして 30 分でなんとなく雰囲気をつかんだ後、細かいところが気になったらスライドを睨むか、このブログの記事の該当箇所を読んでいただくのがいいかなーと思います。
Taskの本質
ごくごく基本的な話から入りますが、そもそも Task
ってなんでしょう?
まずは async/await 抜きにして見ていきます。
Task
とは、言ってしまえば「完了のシグナルの受信と設定を調整するためのデータ構造」にすぎません。実際の Task
は複雑ですが、大事なところだけ抜き出して単純化してしまえば、以下の MyTask
程度になります。
class MyTask { // タスクが完了したかどうかを知るためのフィールド private bool _completed; // タスクが失敗する原因となったエラーを保存するためのフィールド private Exception? _error; // Task 完了後に呼び出されるデリゲート private Action<MyTask>? _continuation; // 実行コンテキスト。詳細は後述 private ExecutionContext? _ec; ... }
MyTask
が処理の結果を持つ場合、MyTask<TResult>
となり、TResult _result
というフィールドも必要になるかもしれませんが、とりあえず結果は持たない方向で。
さて、MyTask
完了後の continuation (継続処理) のコールバックを登録するためのメソッド(ContinueWith
)が必要です。
class MyTask { // 省略 public void ContinueWith(Action<MyTask> action) { lock (this) { if (_completed) { ThreadPool.QueueUserWorkItem(_ => action(this)); } else if (_continuation is not null) { throw new InvalidOperationException("Unlike Task, this implementation only supports a single continuation."); } else { _continuation = action; _ec = ExecutionContext.Capture(); } } } }
さらに、処理が完了した事を通知するため、以下のようなメソッドも実装する必要もあります。
class MyTask { // 省略 public void SetResult() => Complete(null); public void SetException(Exception error) => Complete(error); private void Complete(Exception? error) { lock (this) { if (_completed) { throw new InvalidOperationException("Already completed"); } _error = error; _completed = true; if (_continuation is not null) { ThreadPool.QueueUserWorkItem(_ => { if (_ec is not null) { ExecutionContext.Run(_ec, _ => _continuation(this), null); } else { _continuation(this); } }); } } } }
最後に、task で発生した例外を伝播する方法が必要です(もし MyTask<TResult>
であれば、例外だけでなく、TResult _result
を返せる必要があります)。以下のように、Wait
メソッドを実装することで、task の終了まで待機(ブロック)し、もし task 内の処理で例外が起きたらそれを伝播させる事ができるようになります。
class MyTask { // 省略 public void Wait() { ManualResetEventSlim? mres = null; lock (this) { if (!_completed) { mres = new ManualResetEventSlim(); ContinueWith(_ => mres.Set()); } } mres?.Wait(); if (_error is not null) { ExceptionDispatchInfo.Throw(_error); } } }
単純化してしまえば、本質はこれだけです!
しかし、continuation (継続処理) は未だコールバックです。 async control flow をエンコードするために、コールバックを使う事を強制させられています。 脱コールバックし、同期的な書き心地で非同期どうしたらいいんでしょう?
なお、今回例として書いた
MyTask
ですが、これにはSetResult/SetException
が用意されています。 一方で、実際のTask
(System.Threading.Tasks.Task) にはSetResult/SetException
は存在しません。
Task
にはTrySetResult/TrySetException
といったメソッドがありますが、これは public にはなっていない、internal なメソッドです。SetResult/SetException
を使って行うような事はTaskCompletionSource
を用いて実装する必要があります。TaskCompletionSource
はTask
オブジェクトの作成とその完了の producer として機能しています。 これは技術的に必要だからではなく、task から完了シグナルを送信できないようにするためです。 完了シグナルは、task を作成した側の実装の詳細であり、task を消費している側は知る必要がありませんし、完了シグナルを送れるべきではありません。 なので、task 作成側はTaskCompletionSource
を表に出さないように事で、task の完了の権を他人に握らせないようにする事ができるのです。
C# のイテレータ
突然のイテレータに、「イテレータってIEnumerable<T>
?今まで非同期処理の話をしていたのに、突然の何??」と困惑するかもしれませんが、脱コールバックを実現するには C# のイテレータに解決の糸口があります。
C# のイテレータは C# 2.0 で導入され、以下のような感じで書けるようになりました。
public static IEnumerable<int> Fib() { int prev = 0, next = 1; yield return prev; yield return next; while (true) { int sum = prev + next; yield return sum; prev = next; next = sum; } }
これを使う側は、いろいろな書き方ができます。
foreach (int i in Fib()) { if (i > 100) break; Console.Write($"{i} "); } foreach (int i in Fib().Take(12)) { Console.Write($"{i} "); } using IEnumerator<int> e = Fib().GetEnumerator(); while (e.MoveNext()) { int i = e.Current; if (i > 100) break; Console.Write($"{i} "); }
これに関して面白い事は、上記のような書き方を実現するためには、Fib メソッドに入ったり出たりできるようにする必要がある、という事です。
どういう事かというと、まず MoveNext
を呼び出すと Fib メソッドに入り、メソッドは yield return
に遭遇するまで実行されます。
そして yield return
に遭遇した時点で MoveNext
の呼び出しは true を返し、その後の Current
は yield return
された値を返す必要があります。
そして再び MoveNext
を呼び出すと、前回 yield return
された行の直後から実行される必要があります。
つまり、前回呼び出したときの状態をそのままにしておく必要があります。
そのため、C# のイテレータは事実上のコルーチンであり、コンパイラはこれらをステートマシンとして展開します。
たとえば、上記の Fib メソッドは以下のように展開されます。
public static IEnumerable<int> Fib() => new <Fib>d__0(-2); [CompilerGenerated] private sealed class <Fib>d__0 : IEnumerable<int>, IEnumerable, IEnumerator<int>, IEnumerator, IDisposable { private int <>1__state; private int <>2__current; private int <>l__initialThreadId; private int <prev>5__2; private int <next>5__3; private int <sum>5__4; int IEnumerator<int>.Current => <>2__current; object IEnumerator.Current => <>2__current; public <Fib>d__0(int <>1__state) { this.<>1__state = <>1__state; <>l__initialThreadId = Environment.CurrentManagedThreadId; } private bool MoveNext() { switch (<>1__state) { default: return false; case 0: <>1__state = -1; <prev>5__2 = 0; <next>5__3 = 1; <>2__current = <prev>5__2; <>1__state = 1; return true; case 1: <>1__state = -1; <>2__current = <next>5__3; <>1__state = 2; return true; case 2: <>1__state = -1; break; case 3: <>1__state = -1; <prev>5__2 = <next>5__3; <next>5__3 = <sum>5__4; break; } <sum>5__4 = <prev>5__2 + <next>5__3; <>2__current = <sum>5__4; <>1__state = 3; return true; } IEnumerator<int> IEnumerable<int>.GetEnumerator() { if (<>1__state == -2 && <>l__initialThreadId == Environment.CurrentManagedThreadId) { <>1__state = 0; return this; } return new <Fib>d__0(0); } IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable<int>)this).GetEnumerator(); void IEnumerator.Reset() => throw new NotSupportedException(); void IDisposable.Dispose() { } }
ちなみに、C# では
<>1__state
みたいな<>
とかの記号を含むフィールド名はつけられませんが、IL 自体にはそのあたりの制限がないのでコンパイラが生成した型やフィールド名には<>
がよく使われています。
ステートマシン自体には、ステートマシンとして状態がどこまで進んだかを保存するためのフィールド(ここでは <>1__state
) が生成されています。
そして Fib メソッドのロジックはすべて MoveNext
メソッドの中にあり、また MoveNext
には最後にサスペンドした場所にジャンプするための分岐なども追加されています。
また Fib メソッド内のローカル変数として書いた prev
/next
/sum
などの変数は、MoveNext
の呼び出しにまたがって持続するように、ステートマシンのフィールドと化しています。
さて、先ほど Fib メソッドを foreach ではなく、手動で MoveNext
を叩いて、適切なタイミングでイテレータを進ませることが出来る事を示しました。
// 再掲 using IEnumerator<int> e = Fib().GetEnumerator(); while (e.MoveNext()) { int i = e.Current; if (i > 100) break; Console.Write($"{i} "); }
ここで、もし、非同期処理が完了したとき実行される continuation としてイテレータの MoveNext
を呼び出せるとしたら、どうでしょうか?
つまり、もし、yield return
で非同期処理を表す task を返し、consumer 側のコードがその yield return
された task に continuation をフックして、
その continuation が イテレータの MoveNext
を実行してイテレータを進めるとしたらどうでしょう?
このようなアプローチでは、次のようなヘルパーメソッドを書くことができます。
static Task IterateAsync(IEnumerable<Task> tasks) { var tcs = new TaskCompletionSource(); IEnumerator<Task> e = tasks.GetEnumerator(); void Process() { try { if (e.MoveNext()) { e.Current.ContinueWith(t => Process()); return; } } catch (Exception e) { tcs.SetException(e); return; } tcs.SetResult(); }; Process(); return tcs.Task; }
task のイテレータがパラメータとして渡され、その Current
が返した task に continuation を登録する(ContinueWith(t => Process())
) 事で、その task が完了したらイテレータの MoveNext
が叩かれます。そして次の新しい task を取得し、それに対して再度 continuation 登録し、それが完了したらまた MoveNext
が叩かれ...というのを繰り返す感じです。
より端的に説明すると、イテレータの Current
で取得した task が完了したら、引数で渡されたイテレータの MoveNext()
が呼び出されるようにするためのメソッドです。
具体的にどう使うかをみると、何が嬉しいかが一目瞭然です。
static Task CopyStreamToStreamAsync(Stream source, Stream destination) { // 上記の IterateAsync を使う return IterateAsync(Impl(source, destination)); static IEnumerable<Task> Impl(Stream source, Stream destination) { var buffer = new byte[0x1000]; while (true) { Task<int> read = source.ReadAsync(buffer, 0, buffer.Length); yield return read; int numRead = read.Result; if (numRead <= 0) { break; } Task write = destination.WriteAsync(buffer, 0, numRead); yield return write; write.Wait(); } } }
まず、Impl
関数で返されたイテレータ(IEnumerable<Task>
) を IterateAsync
が MoveNext
する事で進め、Current
(一番最初の実行であれば、source.ReadAsync
の返り値である Task
オブジェクト) を取得します。
その Current
に対して、イテレータを進めるための continuation として、t => Process()
を登録する事で、Current
で取得した task が完了し次第、イテレータが MoveNext
で前に進むことによって destination.WriteAsync
までが実行されます。
そして destination.WriteAsync
の返り値が次の Current
として IterateAsync
の内の Process
で取得され、またイテレータを進めるための continuation として、t => Process()
がその task に登録され...という感じです。
というわけで、CopyStreamToStreamAsync
において、脱コールバックが出来ています。これが嬉しい。
そして CopyStreamToStreamAsync
の yield return
を await
に置き換えるつもりでじーっと睨んでみてください。
await を使った場合とほぼ同じである事に気付くはずです。
これが C# と.NETにおける async/await の始まりです。
C# コンパイラのイテレータと async/await をサポートするロジックの約95%は共有されています。
異なる構文、異なる型が関係しますが、基本的には同じ変換を行います。
async/await が登場する前、つまり C# 5.0 がリリースされるより前の時代では、実際このような書き方をしている開発者もいたようです。
async/await
ようやく async/await のお話です! 実際に、async/await がどのように動作するのか、見ていきましょう。
まず同期のメソッドである CopyStreamToStream
はこんな感じで、
public void CopyStreamToStream(Stream source, Stream destination) { var buffer = new byte[0x1000]; int numRead; while ((numRead = source.Read(buffer, 0, buffer.Length)) != 0) { destination.Write(buffer, 0, numRead); } }
非同期である CopyStreamToStreamAsync
はこんな感じ。
public async Task CopyStreamToStreamAsync(Stream source, Stream destination) { var buffer = new byte[0x1000]; int numRead; while ((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) != 0) { await destination.WriteAsync(buffer, 0, numRead); } }
同期版から非同期版への変更点は以下のこれだけです。
- シグネチャが
void
からasync Task
に変わり Read/Write
の代わりにReadAsync/WriteAsync
をそれぞれ呼び、その両方の操作にawait
が付く。
非同期にするためのその他諸々の作業は全てコンパイラとコアライブラリが行い、コードが実際にどのように実行されるかを根本的に変えます。 それでは、それがどのようなものかを掘り下げてみましょう。
Compiler Transform
先ほどの CopyStreamToStreamAsync
は、コンパイラによって以下のように展開されます。
[AsyncStateMachine(typeof(<CopyStreamToStreamAsync>d__0))] public Task CopyStreamToStreamAsync(Stream source, Stream destination) { // ステートマシン (struct) の初期化 <CopyStreamToStreamAsync>d__0 stateMachine = default; // ステートマシン に必要なパラメータを保存していく // まずは Builder (struct) stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create(); // パラメータなどもステートマシンに格納 stateMachine.source = source; // stateMachine.destination = destination; // 初期状態は -1 stateMachine.<>1__state = -1; // ステートマシンを開始 stateMachine.<>t__builder.Start(ref stateMachine); return stateMachine.<>t__builder.Task; } private struct <CopyStreamToStreamAsync>d__0 : IAsyncStateMachine { public int <>1__state; public AsyncTaskMethodBuilder <>t__builder; public Stream source; public Stream destination; // CopyStreamToStreamAsync 内のローカル変数はここに。 private byte[] <buffer>5__2; private TaskAwaiter <>u__1; private TaskAwaiter<int> <>u__2; ... }
ここで重要なのは、async メソッドが同期的に終了した場合、zero allocation であるという事です。
コンパイラによって生成されたステートマシンは struct であり、AsyncTaskMethodBuilder
も struct ですからね。
また、コンパイラが出力したこのステートマシンは開発者が記述したロジックを全て変換した物だけでなく、そのメソッドの現在の進行状態を保存するためのフィールド(この例では int <>1__state
) や、ローカル変数 (ここでは byte[] <buffer>5__2
) なども生成されています。
C# のイテレータ(IEnumerable <T>
) で生成されるステートマシンと要領はほぼ同じですね!
ちなみにこのステートマシンは debug build では struct ではなく、class で定義されるので、生成されたコードを眺める場合は気を付けてください。
CopyStreamToStreamAsync
内でステートマシン(<CopyStreamToStreamAsync>d__0 stateMachine
)を default
で初期化した後、AsyncTaskMethodBuilder.Create()
が呼ばれている事が分かります。
AsyncMethodBuilder (AsyncTaskMethodBuilder
の他にも、AsyncValueTaskMethodBuilder
や PoolingAsyncValueTaskMethodBuilder
等が存在します。以後「ビルダー」と書いてあったらこれらのような AsyncMethodBuilder を指します) の役割は、Task
プロパティとして露出する Task
やValueTask
等の型のオブジェクトを作成し、
SetResult
/SetException
を用いて適切な結果もしくは例外をそのオブジェクトに設定することで完了状態にし、
まだ完了していない待機状態のオブジェクトに対しては continuation の登録などの処理を行ったりする事です(AwaitOnCompleted
/AwaitUnsafeOnCompleted
等を用いる)。
すこし脇道に逸れますが CopyStreamToStreamAsync
の返り値の型は Task
で、ビルダーとして AsyncTaskMethodBuilder
が使われていました。
実はこれ、一番最初の例で出していたような MyTask
などの自分で定義した Task
っぽい型 (Task-like) に対しても独自にビルダーを定義してあげることができ、
コンパイラが async メソッドを展開する時にその独自のビルダーを使うようにしたりする事も出来たりします。
それはどうやるかというと、Task-like な型にたいして、[AsyncMethodBuilder(typeof(T))]
属性で適切なビルダーを紐づけて上げるだけです。
たとえば以下のように[AsyncMethodBuilder(typeof(MyTaskMethodBuilder))]
とする事で、async Task CopyStreamToStreamAsync(...)
ではなくasync MyTask CopyStreamToStreamAsync(...)
といったメソッドを定義した際に、AsyncTaskMethodBuilder
ではなく、MyTaskMethodBuilder
が使用されるようになったりします。
[AsyncMethodBuilder(typeof(MyTaskMethodBuilder))] // <- 自前で定義したビルダーを指定 public class MyTask { ... } // ビルダーに必要な定義を頑張って自前で実装する public struct MyTaskMethodBuilder { public static MyTaskMethodBuilder Create() { ... } public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine { ... } public void SetStateMachine(IAsyncStateMachine stateMachine) { ... } public void SetResult() { ... } public void SetException(Exception exception) { ... } public void AwaitOnCompleted<TAwaiter, TStateMachine>( ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : INotifyCompletion where TStateMachine : IAsyncStateMachine { ... } public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>( ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : ICriticalNotifyCompletion where TStateMachine : IAsyncStateMachine { ... } public MyTask Task { get { ... } } }
とはいえ、この機能は殆ど ValueTask
のために(C# 7.0 から)導入されたものなので、普通の開発者がビルダーを自前で実装したりする事はまず無いし、すべきでもないと思っておいてください。
ちなみに、普通の Task
は特別扱いされているので、[AsyncMethodBuilder(typeof(AsyncTaskMethodBuilder))]
とかは付いていません。
また、[AsyncMethodBuilder]
は型に対してアノテーションするだけじゃなくて、メソッド単位でもアノテーションする事ができます。
先ほどから見てきた通り、async メソッドはコンパイラによってステートマシンとして展開され、メソッド本体はステートマシンとビルダーの作成と初期化がなされたりするように展開されるわけですが、
その際に使われるビルダーをカスタムできます。
namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2; internal sealed class Http2MessageBody : MessageBody { // ~~ 省略 ~~ // 以下のように、メソッドレベルで [AsyncMethodBuilder] を適用する事も可能。 // このメソッドをコンパイラが展開する時、 // 指定したビルダーが使われるようになる。 // PoolingAsyncValueTaskMethodBuilder については後述 [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] public override async ValueTask<ReadResult> ReadAsync(CancellationToken cancellationToken = default) { // ~~ 省略 ~~ } }
脇道から帰ってきて、本題に戻りましょう。
ビルダーが作成された後、ステートマシンにこのメソッド (CopyStreamToStreamAsync
) の引数 (source
, destination
) が格納されています。
これらの引数はステートマシンの MoveNext
が呼び出され、状態が進んでも、メソッド本体の実装から利用できる必要があります。
そのためこれらの引数をステートマシンに格納しなければいけません。
また、ステートマシンは初期状態 (int <>1__state
) は -1 で初期化されています。
MoveNext
が呼び出されたときに状態が -1 であれば、メソッドを初めて最初から実行する事と同義になります。
その次に、非常に地味だけど大事な行があります。
ビルダーの Start
メソッドの呼び出しです。
Start
メソッドは、実質的にはこれだけです。
public struct AsyncTaskMethodBuilder { public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine { // 本当はもうちょっといろいろあるが、実質的にはこれだけ。 // そのもうちょっといろいろの部分は後述 stateMachine.MoveNext(); } // ~~ 省略 ~~ }
stateMachine.<>t__builder.Start(ref stateMachine)
を呼び出すことは、実質的にはstateMachine.MoveNext()
を呼び出すだけです。
にも関わらず、なぜコンパイラはそのように直接実行しないのでしょうか?なぜ Start
があるのでしょうか?
その答えは、Start
には私が説明した以上のものがあるからです。
しかし、それを理解するにはまず ExecutionContext
を理解する必要があります。
ExecutionContext
我々はメソッドからメソッドへ状態を受け渡す事に慣れ親しんでいます。 メソッドを呼ぶ際、そのメソッドにパラメータ(仮引数)が定義されていたら、呼び出し側はメソッドに対して(実)引数を渡して呼び出します。 これは明示的にデータを受け渡す事です。
しかし、もっと暗黙的にデータを渡す事も出来ます。 例えば、メソッドAは static field にデータを格納し、次にBを呼び出し、Cを呼び出し、Dを呼び出し、最終的にEを呼び出して A が格納したデータを取得するため static field の値を読み取る...といったような感じで、メソッドのパラメータを介さずに、どこかで static field を格納し、どこかでその static field を読み出して利用するといった形のデータの受け渡し方が暗黙的なデータの渡し方です。 これはよく ambient data と呼ばれるもので、パラメータで渡されるのではなく、ただどこかしらに存在し、必要であれば利用することができるものです。 このような場合、呼び出し元と呼び出し先の間には、呼び出し元がどこかにデータを格納し、呼び出し先がそのデータをどこかしらから読み込むという暗黙の了解があるだけです。
static field の利用以外にも、thread local 状態を使用する事を考える事もできます。
thread local 状態は、.NET では [ThreadStatic]
属性が適用された static field や ThreadLocal<T>
型によって実現できます。
thread local なデータへのアクセスはそれぞれの実行スレッドだけに制限されており、各スレッド事に独立及び分離しています。
これを使えば、thread static なフィールド等にデータを入れてメソッドを呼び出し、メソッドが完了したら変更を thread static なフィールドに戻すことで、暗黙的にやりとりされているデータをスレッド毎に完全に分離した形で利用できるようになります。
しかし、非同期の場合はどうでしょう? async メソッドを呼び出し、その async メソッド内のロジックが ambient data にアクセスしたい場合には一筋縄ではいきません。 まずは通常の static field に ambient data 格納されていれた場合はどうでしょう? そもそも async メソッドは平行ないし並列で実行されます。 なので、ある async control flow で書き換えた static フィールドは、別の flow で書き換えられてしまうわけです。 結果的に、競合を防ぐためには、1つのメソッドしか実行することができない、という、非同期処理的には受け入れられない制約が発生してしまうので、ambient data の渡し方として成立しないわけです。 thread local 状態を使用した場合はどうでしょう? もしデータが thread static field に格納されていれば、async メソッドはそれにアクセスすることができますが、それが期待する挙動をするのは呼び出し元のスレッドで同期的に実行されなくなる時点までです。 何故なら await した task が同期的に完了せずサスペンドした場合、その task が完了し次第 continuation の実行がスケジュールされますが、continuation がどこのスレッドで実行されるかは分かりません。
これは非常に多くの人が勘違いしている事です。 WPF などの GUI フレームワークでは await で待機していたものが完了し、continuation が実行される際に UI スレッドに戻ってくる挙動をするので勘違いする人が多いわけですが、async/await 自体に「もともと実行されていたスレッドに戻ってくる」という機能はありません。UI スレッドに戻ってくるのは GUI フレームワーク側で
SynchronizationContext
がそのような仕事をするように実装されているから、というだけの事です。「戻ってくる」というよりも「continuation が UI スレッドで実行される」と言った方が誤解が生まれないかもしれないですね。結果的に戻っているように見えるだけなのです。
そのため async メソッドには async メソッドに適した ambient data をフローさせるための仕組みが必要です。 つまり、同時実行可能で、スレッドが変更しても問題のない、async メソッド用の論理フローを実現させるための仕組みです。
ここで、ExecutionContext
の登場です。
ExecutionContext
は ambient data を非同期操作から非同期操作へフローさせるためのモノです。
ある非同期操作が開始されると、ExecutionContext
はキャプチャ & 保存され、その非同期操作の continuation が実行されるタイミングでキャプチャしておいた ExecutionContext
をリストアする事で、
ambient data をフローさせる事ができるようになります。
ExecutionContext
は .NET Framework と .NET Core ではかなり乖離があります。
.NET Framework における ExecutionContext
は、LogicalCallContext
や SecurityContext
など様々なコンテキストを内包し、それら全てのコンテキストをフローさせていました。
一方で、.NET Core においては、AsyncLocal<T>
のみを格納し、それをフローするだけのものになりました。
少し ExecutionContext
を具体的な挙動を見てみましょう。
以下のような AsyncLocal<T>
を使うコードがあった時、Console.WriteLine
は何を表示するでしょうか?
var number = new AsyncLocal<int>(); number.Value = 42; ThreadPool.QueueUserWorkItem(_ => Console.WriteLine(number.Value)); number.Value = 0;
答えは、必ず 42
です。必ずです。
なぜなら QueueUserWorkItem
が呼び出された際、内部では ExecutionContext
をキャプチャしており、そのキャプチャされた ExecutionContext
には AsyncLocal<int> number
が含まれています。
そして、実際にスレッドプールで実行する際には、キャプチャされた ExecutionContext
が復元されるので、必ず 42
が表示されるようになります。number.Value = 0
はキャプチャされた後に実行されてるので、キャプチャした時点の AsyncLocal<int> number
の値には影響がないわけです。
以下のように独自のスレッドプールっぽいものを実装すると、より分かりやすくなるでしょう。
using System.Collections.Concurrent; var number = new AsyncLocal<int>(); number.Value = 42; MyThreadPool.QueueUserWorkItem(() => Console.WriteLine(number.Value)); number.Value = 0; Console.ReadLine(); class MyThreadPool { private static readonly BlockingCollection<(Action, ExecutionContext?)> s_workItems = new(); public static void QueueUserWorkItem(Action workItem) { // キューに入れるまえに、ExecutionContext をキャプチャし、workItem と合わせてエンキューする。 var context = ExecutionContext.Capture(); s_workItems.Add((workItem, context)); } static MyThreadPool() { for (int i = 0; i < Environment.ProcessorCount; i++) { var thread = new Thread(() => { while (true) { (Action action, ExecutionContext? ec) = s_workItems.Take(); if (ec is null) { action(); } else { // キャプチャした ExecutionContext 上で実行する! ExecutionContext.Run(ec, s => ((Action)s!)(), action); } } }); thread.IsBackground = true; thread.UnsafeStart(); } } }
余談ですが、MyThreadPool
の static constructor で UnsafeStart
を呼び出したことにお気づきでしょうか?
新しいスレッドの開始は ExecutionContext
をフローさせるべきところです。
実際、Thread
の Start
メソッドは ExecutionContext.Capture
を使って現在の ExecutionContext
をキャプチャ & Thread
のフィールドに保存し、Thread
のコンストラクタで渡した ThreadStart
デリゲートを呼び出すときにそのキャプチャしたコンテキストをリストアして使っています。
しかし、MyThreadPool
の例では、static constructor が実行された時にたまたま存在した ExecutionContext
を Thread
に取り込ませたくないので (デモがより複雑になるため)、Start
メソッドの代わりに UnsafeStart
メソッドを使用しています。
Unsafeで始まるスレッド関連メソッドは、ExecutionContext
をキャプチャしなくなります。その事を除けば、Unsafe 接頭辞を持たないメソッドは全く同じ動作をします。
たとえば、Thread.Start
が ExecutionContext
をキャプチャするのに対して、Thread.UnsafeStart
は ExecutionContext
をキャプチャしませんが、それ以外の動作は同じです。
builder.Start() の重要性
ExecutionContext
のお話はここまでにしましょう。
もともとなんのお話をしていたかというと、async メソッドはコンパイラがステートマシン作ったりなんだりして、そのステートマシンはビルダーの Start
メソッドで MoveNext
されている、というお話をしていました。
そしてこの Start
メソッドは何故重要か?というお話でした。
なぜ重要かを理解するためには、ExecutionContext
を避けて通れないので解説をしていたわけです。
先ほど Start
メソッドは本質的には stateMachine.MoveNext()
をするだけ、と言いました。
public struct AsyncTaskMethodBuilder { public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine { // 本当はもうちょっといろいろあるが、実質的にはこれだけ。 // そのもうちょっといろいろの部分は、実は ExecutionContext に関わることだったのです! stateMachine.MoveNext(); } // ~~ 省略 ~~ }
上記のものは非常に簡素化したものであり、実際には以下のような形になっています。
public struct AsyncTaskMethodBuilder { public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine { // ExecutionContext.Capture() と実質的に同じ。 ExecutionContext previous = Thread.CurrentThread._executionContext; try { stateMachine.MoveNext(); } finally { ExecutionContext.Restore(previous); } } // ~~ 省略 ~~ }
そうです、実際には stateMachine.MoveNext()
を呼び出すだけでなく、現在の ExecutionContext
を取得してから MoveNext
を実行し、終了時に現在の ExecutionContext
を MoveNext
実行前の状態に戻すという処理を行っています。
この理由は、async メソッドの呼び出し元へ ambient data のリークを防ぐためです。 具体例を見ていきましょう。
async Task ElevateAsAdminAndRunAsync() { using (WindowsIdentity identity = LoginAdmin()) { // Impersonation : 「偽装」 の意 // WindowsImpersonationContext は .NET Framework にしかない class. // https://learn.microsoft.com/en-us/dotnet/api/system.security.principal.windowsimpersonationcontext?view=netframework-4.8.1 using (WindowsImpersonationContext impersonatedUser = identity.Impersonate()) { await DoSensitiveWorkAsync(); } } }
Impersonation (偽装) とは、現在のユーザーに関する情報を、他の誰かの情報に変更することです。
これによりコードは現在のユーザではない、他の誰かとして行動し、そのアクセス権を使用することができます。
(参考: 偽装とは何か?)
.NET でこのような偽装は非同期操作としてフローしますが、それは ExecutionContext
に ambient data が格納される事を意味します。
ここで、Start
メソッドが finaly で実行前の ExecutionContext
を復元しなかった場合の事を想像してみてください。
Task t = ElevateAsAdminAndRunAsync(); PrintUser(); await t;
上記のコードでは、ElevateAsAdminAndRunAsync
内部で ExecutionContext
に変更がかかり、
DoSensitiveWorkAsync
やその内部の完了していない Task
オブジェクト等を初めて await したタイミングでサスペンドが発生し、メソッドの呼び出し元に同期的に返ってきます。
この時、もし ExecutionContext
を ElevateAsAdminAndRunAsync
メソッド呼び出し前のものに復元していなかったら、どうなるでしょうか?
そう、PrintUser
メソッドで、ElevateAsAdminAndRunAsync
内部で変更された ExecutionContext
を読めてしまいますから、当然意図しない形で ambient data が読み込めてしまいます。
これは問題です。
そのため、Start
メソッドでは、ExecutionContext
への変更が同期メソッドの呼び出し元に漏れないようにするため、finaly で実行前の ExecutionContext
を復元するような防衛を行っています。
IAsyncStateMachine.MoveNext
async メソッドが実行されたら、ステートマシンの構造体が初期化され、ビルダーの Start
メソッドが呼ばれ、ステートマシンの MoveNext
メソッドが呼び出されます。
MoveNext
メソッドは開発者のメソッドにあったロジックをすべて含みつつも、多くの変更(どこまで状態が進んだかを保存しているフィールドの更新や、それの状態に応じてジャンプする switch 文等)が加えられているメソッドです。
まず、このメソッドの基本的なところから見てみましょう。
コンパイラがCopyStreamToStreamAsync
メソッドをコンパイルしたものを逆コンパイルし、どのようなコードが生成されているか見て生きましょう。
以下は、逆コンパイルしたコードのうち try ブロックをすべて省略したものです。
private void MoveNext() { try { // ~~ 省略 ~~ } catch (Exception exception) { <>1__state = -2; <buffer>5__2 = null; <>t__builder.SetException(exception); return; } <>1__state = -2; <buffer>5__2 = null; <>t__builder.SetResult(); }
MoveNext
メソッドには、全ての作業が終了した時点で async メソッドから返される Task
オブジェクトを必ず完了(Task.IsCompleted
が true
の状態)させるという責務を負っています。
もし try ブロック本体で処理されない例外が投げられたら、Task
オブジェクトはその例外によって失敗させられる事になります。
また、async メソッドが正常に終了に至った場合、返された Task
オブジェクトは正常に完了します。
いずれの場合も、ステートマシンの状態を async メソッドの成功/失敗に関わらず、完了状態になるよう設定しているのです。
またこの Task
オブジェクトに対する完了は、ビルダーの SetException
と SetResult
メソッドを経由している事に注意してください。
async メソッドが以前にサスペンドしていた場合、ビルダーはそのサスペンドの処理の一部として既に Task
オブジェクトを作成しているはずです。
この場合、既にある Task
オブジェクトに対して SetException
/SetResult
を呼び出した時にその Task
が完了します。
しかし、async メソッドが以前にサスペンドしていない場合は、まだ Task
オブジェクトを作成しておらず、呼び出し元にも何も返していないため、ビルダーは Task
オブジェクトを作成する方法についてより柔軟性を持っています。例えばこの柔軟性のお陰で、毎度 Task
オブジェクトを allocate したりせず、Task.CompletedTask
等キャッシュされたオブジェクトを返して無駄な allocation を避けたりする事ができます。
また、ビルダーは作成する Task
オブジェクトを適切な状態にする責務も担っています。
Task
オブジェクトには最終状態として、RanToCompletion
(success) / Faulted
/ Canceled
の3つの状態があります。
AsyncTaskMethodBuilder
の SetException
メソッドは、 OperationCanceledException
を特別扱いし、渡された例外がOperationCanceledException
もしくはOperationCanceledException
から派生したクラスだった場合は Task
オブジェクトの状態を TaskStatus.Canceled
に遷移させ、それ以外は TaskStatus.Faulted
としてタスクを終了します。
ここまでで、ライフサイクルの側面を理解したので、次に try ブロックを埋めた MoveNext
メソッドを見て行きましょう。
private void MoveNext() { try { int num = <>1__state; TaskAwaiter<int> awaiter; if (num != 0) { if (num != 1) { <buffer>5__2 = new byte[4096]; goto IL_008b; } awaiter = <>u__2; <>u__2 = default(TaskAwaiter<int>); num = (<>1__state = -1); goto IL_00f0; } TaskAwaiter awaiter2 = <>u__1; <>u__1 = default(TaskAwaiter); num = (<>1__state = -1); IL_0084: awaiter2.GetResult(); IL_008b: awaiter = source.ReadAsync(<buffer>5__2, 0, <buffer>5__2.Length).GetAwaiter(); if (!awaiter.IsCompleted) { num = (<>1__state = 1); <>u__2 = awaiter; <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); return; } IL_00f0: int result; if ((result = awaiter.GetResult()) != 0) { awaiter2 = destination.WriteAsync(<buffer>5__2, 0, result).GetAwaiter(); if (!awaiter2.IsCompleted) { num = (<>1__state = 0); <>u__1 = awaiter2; <>t__builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this); return; } goto IL_0084; } } catch (Exception exception) { <>1__state = -2; <buffer>5__2 = null; <>t__builder.SetException(exception); return; } <>1__state = -2; <buffer>5__2 = null; <>t__builder.SetResult(); }
ステートマシンのステート(<>1__state
)は -1 で初期化されていたことを思い出してください。
MoveNext
の中ですが、まず ①このステート(現在は num
ローカルに格納されている) が 0 でも 1 でもないことを確認し、②一時バッファを作成するコードを実行し、③ラベル IL_008b
に分岐し、④stream.ReadAsync
を呼び出すことになります。
つまり開発者のコードが CopyStreamToStreamAsync
を呼び出し、このメソッドの最終的な完了を表す task をまだ返していない状態では、まだ同期的に実行されているのです。
Stream.ReadAsync
を呼び出すと、Task<int>
型のオブジェクトが返されます。
ReadAsync
は同期的に完了したのかもしれませんし、非同期で実行されたものの、あまりの速さで既に完了しているかもしれませんし、まだ完了していないのかもしれません。
いずれにせよ、Task<int>
オブジェクトは完了していたりしなかったりするわけですが、コンパイラはそのTask<int>
オブジェクトの状態を検査して、どのように処理を進めるかを決定するコードを生成します。
Task<int>
オブジェクトがすでに完了していれば(同期的に完了したか、チェックした時点で完了していたかは関係ない)、このメソッドのコードは同期的に実行し続けることができます。しかし Task<int>
オブジェクトが完了しない場合のために、コンパイラは Task に continuation をフックするコードを作成する必要があります。
そのため、task に「終わったか?」と尋ねるコードを生成する必要があります。
しかし、それを直接 Task
オブジェクトに尋ねるべきなのでしょうか?
C# で await することができるのが System.Threading.Tasks.Task
だけだとしたら、それは制限的です。
同様に、C# コンパイラが await 可能なすべての型について知らなければならないとしたら、それもまた制限的です。
直接 Task
オブジェクトに「終わったか?」と尋ねるその代わり、あるいは await 可能な全ての型にコンパイラを対応させたりする代わりに、C# は 「awaiter パターン」という API のパターンを採用しています。
適切な「enumerable パターン」を提供するものであれば、何でも foreach できるのと同じように、
C# コード上で適切な「awaiter パターン」を実装しているものであれば、何でも await できるようになっています。
例えば、先ほど書いた MyTask
型を拡張して「awaiter パターン」を実装することができます。
class MyTask { // ~~ 省略 ~~ public MyTaskAwaiter GetAwaiter() => new MyTaskAwaiter { _task = this }; public struct MyTaskAwaiter : ICriticalNotifyCompletion { internal MyTask _task; public bool IsCompleted => _task._completed; public void OnCompleted(Action continuation) => _task.ContinueWith(_ => continuation()); public void UnsafeOnCompleted(Action continuation) => _task.ContinueWith(_ => continuation()); public void GetResult() => _task.Wait(); } }
GetAwaiter()
メソッドを公開しており、適切な awaiter を返している任意の型は await することができます。
適切な awaiter には、IsCompleted
プロパティと GetResult
/OnCompleted
(/UnsafeOnCompleted
) メソッドが定義されている必要があります。
そして IsCompleted
プロパティは、IsCompleted
が呼び出された時点で、操作がすでに完了したかどうかをチェックするために使用されます(どのように実行されたかは関係ありません)。
コンパイラによって展開された MoveNext
メソッド内では IL_008b
にジャンプした後、ReadAsync
から返された Task
オブジェクトに対して GetAwaiter
が呼び出され、その awaiter の IsCompleted
が呼び出されています。
IsCompleted
が true
を返した場合、IL_00f0
に進み、awaiter の別のメンバー GetResult()
を呼び出すことになります。
操作が失敗した場合、GetResult()
は例外を投げて async メソッドの外に例外を伝播させる役割を果たします。
それ以外の場合、つまり処理の結果があれば、GetResult()
はそれを返す役割を果たします。
ReadAsync
の場合、処理の結果(つまり GetResult()
が返した結果)が 0 であれば、読み書きループから抜け出し、SetResult()
を呼び出すメソッドの末尾に移動して完了です。
ここで本当に興味深いのは、IsCompleted
チェックが false
を返した場合に実際どうなるかです。
IsCompleted
が false
を返した場合、await された処理が完了するまで async メソッドの実行をサスペンドする必要があります。
これは MoveNext
から Start
に返るという事であり、そしてメソッドの呼び出し元に Task
オブジェクトを返すことを意味します。
しかし Task
オブジェクトを返す前に、待機中の Task
オブジェクトに continuation をフックする必要があります(なお、スタックが積まれ続ける事を避けるために、IsCompleted
が false を返した後かつ、continuation をフックする前に非同期処理が完了した場合、continuation はスレッドプールから非同期に呼び出す必要があり、キューに投げられる事に注意しましょう)。
また、(適切に実装さえすれば) 何でも await することができるようにするため、Task
オブジェクトに直接アクセスするような事はしません。
その代わりに何らかの方法を用いて continuation のフックを行う必要があります。
つまり Task
はそれ自体が ContinueWith
メソッドなどを持っており、continuation をサポートしていますが、continuation を設定できるメソッドを公開するのは、GetAwaiter
メソッドから返された TaskAwaiter
であるべきという事でしょうか? continuation をフックするためのメソッドが awaiter にあるという事でしょうか?
はい、その通りです。
「awaiter パターン」では、awaiter が INotifyCompletion
interface を実装する必要があり、そのインターフェイスには1つのメソッド void OnCompleted(Action continuation)
が定義されています。
また、awaiter パターンには ICriticalNotifyCompletion
interface を実装するという選択肢もあります。
ICriticalNotifyCompletion
は INotifyCompletion
を継承し、void UnsafeOnCompleted(Action continuation)
というメソッドが追加で定義されています。
前に 「Unsafe」 というプレフィックスが付くものの ExecutionContext
の扱いについて説明しましたが、この2つのメソッドの違いは何かというと、どちらも continuation をフックするものですが、OnCompleted
が ExecutionContext
をフローさせる必要があるのに対し、UnsafeOnCompleted
は ExecutionContext
をフローさせる必要がないのです。
INotifyCompletion.OnCompleted
と ICriticalNotifyCompletion.UnsafeOnCompleted
という2つの異なるメソッドが必要なのは、主にコードアクセスセキュリティ (CAS) に関連する歴史的なものです。
CASはもはや .NET Core には存在せず、.NET Framework ではデフォルトでオフになっており、レガシーな partial trust 機能を選択した場合にのみ現われます。
partial trust を用いる場合、CAS 情報は ExecutionContext
の一部としてフローするため、それをフローさせないことは「安全ではない」ことになり、そのため ExecutionContext
をフローしないメソッドには「Unsafe」というプレフィックスが付けられていました。
また、そのようなメソッドは[SecurityCritical]
とされ、部分的に信頼されたコードは [SecurityCritical]
メソッドを呼び出すことができません。
その結果、OnCompleted
の2つのバリアントが作成され、コンパイラは UnsafeOnCompleted
が提供された場合はそれを使用することを好みますが、OnCompleted
バリアントは awaiter が partial trust をサポートする必要がある場合に備えて常に提供されています。
しかし、async メソッドの観点からは、ビルダーは常に await ポイントで ExecutionContext
をフローするので、awaiter でも ExecutionContext
をフローするのは不要で重複した処理となります。
awaiter は continuation をフックするメソッドを公開しています。 コンパイラはこのメソッドを直接使うことができますが、非常に重要な問題があります。 それは continuation をどうするべきか、オブジェクトをどう関連付けるか、という問題です。
ステートマシン構造体はスタック上にあり、現在実行中の MoveNext
はそのステートマシンに対するメソッド呼び出しであることを思い出してください。
つまり、サスペンド時にはこのステートマシンをヒープ上のどこかにコピーする必要があります。
なぜならスタックはこのスレッド上で実行される、現在のフローとは全く無関係な後続の作業に使用されることになるからです。
そして、continuation では、ヒープ上にコピーされたステートマシンの MoveNext
メソッドを呼び出す必要があります。
さらに、ここでも ExecutionContext
が関係してきます。
ステートマシンは、ExecutionContext
に格納された ambient data をサスペンドされた時点で確実にキャプチャし、再開時点で復元する必要があり、つまり continuation にキャプチャした ExecutionContext
を取り込む必要があります。
要するに、ステートマシンの MoveNext
を指すデリゲートを作るだけでは不十分なのです。
またサスペンド時にステートマシンの MoveNext
を指すデリゲートを都度作成すると、そのたびにステートマシン構造体をボックス化して(他のオブジェクトの一部としてすでにヒープ上にある場合でも)、追加のデリゲートに対する allocation が発生します(デリゲートのこのオブジェクト参照は、新たにボックス化した構造体のコピーとなる)。
これらは望ましくないオーバーヘッドです。
そのため、async メソッドが最初に実行がサスペンドされた時のみ構造体をスタックからヒープに boxing し、それ以外のときは既にヒープにいるオブジェクトを MoveNext
のターゲットとして使用し、その過程で正しいコンテキストをキャプチャし、再開時にそのキャプチャしたコンテキストを使用して処理を実行するという、複雑な挙動をする必要があります。
これは、コンパイラが出力するよりも多くのロジックを必要とします。 いくつかの理由から、これらは代わりのヘルパーにカプセル化したいと考えています。 まず第一に、各ユーザーのアセンブリに複雑なコードをたくさん含ませることになってしまうから。 第二に、ビルダーパターンの実装の一環として、そのロジックをカスタマイズできるようにしたいから(後でプーリングについて話すときに、その理由の例を見ることになります)。 そして第三に、このロジックを進化させ、改善することで、以前にコンパイルされたバイナリがより良く実行されるようにしたいからです。 これは仮説ではありません。 実際に .NET Core 2.1 ではこのサポートのためのライブラリコードが完全に見直され、.NET Framework のときよりもはるかに効率的な実行ができるようになりました。 まず、.NET Framework でどのように動作していたかを正確に調べ、次に.NET Coreでどうなっているかを見ていきます。
C# コンパイラが生成したコードをみると、サスペンドが必要な時に以下が起こる事が分かります。
if (!awaiter.IsCompleted) { <>1__state = 1; <>u__2 = awaiter; // ビルダーに、awaiter が continuation としてこのステートマシンを呼ぶようように接続するよう依頼する <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); return; }
ステートフィールド(<>1__state
)には、メソッドの再開時にジャンプすべき場所を示すステートIDを格納しています。
そして、再開後に GetResult
を呼び出すために使用できるように、awaiter 自体をフィールドに永続化しています。
そして、MoveNext
の呼び出し元に戻る直前に、<>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this)
を呼び出し、awaiter にこのステートマシンの continuation をフックするようビルダーに要求します。
(ビルダーの AwaitOnCompleted
ではなくビルダーの AwaitUnsafeOnCompleted
を呼んでいるのは、awaiter がICriticalNotifyCompletion
を実装しているからです。
ステートマシンはフローしている ExecutionContext
を処理するので awaiter にもそれを求める必要はありません。
先に述べたように、それは重複して不要なオーバーヘッドになるだけです)
その AwaitUnsafeOnCompleted
メソッド (ビルダーのメソッド)の実装は、ここにコピーして示すには複雑すぎるので、.NET Framework 上で何をしているのかを以下に要約します。
ExecutionContext.Capture()
を使って、現在のコンテキストを取得。- キャプチャされた
ExecutionContext
とボックス化されたステートマシンの両方をラップするためのMoveNextRunner (class)
オブジェクトを allocate します(このメソッドが初めてサスペンドする場合はまだ持っていないので、null を placeholder として使用します)。 - そして、その
MoveNextRunner
のRun
メソッドに対するAction
デリゲートを作成します。こうして、キャプチャしたExecutionContext
のコンテキストで、ステートマシンのMoveNext
を呼び出すデリゲートを取得することができるのです。 - このメソッドが初めてサスペンドする場合、まだボックス化されたステートマシンを持っていないので、この時点でボックス化します。これは
IAsyncStateMachine
interface として型付けされたローカル変数にオブジェクトを格納することによってヒープ上にもっていきます。そして、このボックス化されたステートマシンは、MoveNextRunner
に保存されます。 - ここからが難しいステップです。 まず、ステートマシン構造体の定義を見ると、
public AsyncTaskMethodBuilder <>t__builder
というビルダーのフィールドが含まれています。そしてビルダーの定義を見ると、内部にはIAsyncStateMachine m_stateMachine
というフィールドが存在します。ビルダーはボックス化されたステートマシンを参照する必要があります。これにより、その後のサスペンド時に、すでにステートマシンをボックス化したことがわかり、再度ボックス化する必要がありません。しかし、先ほどステートマシンをボックス化しましたが、ビルダーに含まれるステートマシンであるm_stateMachine
フィールドは null です。そのボックス化されたステートマシンに含まれるビルダーのm_stateMachine
を変更し、適切なステートマシンを指すようにする必要があります。これを実現するため、コンパイラが生成したステートマシン構造体が実装するIAsyncStateMachine
インターフェースには、void SetStateMachine(IAsyncStateMachine stateMachine)
メソッドが定義されています(参考: https://gist.github.com/nenoNaninu/41f8fa066dfd7dd7ef5377e2a2816747)。 そこで、ビルダーはまずステートマシンをボックス化し、そのボックス化されたものをボックス化されたオブジェクトのSetStateMachine
メソッドに渡し、ビルダーのSetStateMachine
メソッドを呼び出し、そのボックスをフィールドに格納します。 - 最後に、continuation を表す
Action
デリゲートを用意し、awaiter のUnsafeOnCompleted
メソッドに渡します。TaskAwaiter
の場合、task はそのAction
デリゲートを task の continuation リストに格納し、task が完了すると、Action
デリゲートを呼び出し、MoveNextRunner.Run
を呼び出し、ExecutionContext.Run
を呼び出し、最後にステートマシンのMoveNext
メソッドを呼び出し、ステートマシンをサスペンドしたところから実行します。
これは .NET Framework 上で起こることで、プロファイラでその結果を見る事ができます。 例えば、allocation profiler を実行して、各 await で何が allocation されているかを見ることができます。 以下の無為なプログラムを見てみましょう。 (以下の例は、関係する allocation cost を強調するためだけに書いたものです。)
using System.Threading; using System.Threading.Tasks; class Program { static async Task Main() { var al = new AsyncLocal<int>() { Value = 42 }; for (int i = 0; i < 1000; i++) { await SomeMethodAsync(); } } static async Task SomeMethodAsync() { for (int i = 0; i < 1000; i++) { await Task.Yield(); } } }
なぜこんなにも allocation が発生しているのでしょう?原因は幾つかあります。
ExecutionContext
。これは 100万個以上 allocation が発生してます。なぜでしょう?それは .NET Framework では、ExecutionContext
は ミュータブルなデータ構造だからです。非同期処理がフォークされた時点で存在していた ambient data はフローさせたいし、フォーク後に行われた変更には影響されたくない、ExecutionContext
を毎回コピーする必要があります。フォークされた操作の一つ一つにこのようなコピーが必要なので、SomeMethodAsync
を 1000回 呼び出すと、それぞれが 1000回 サスペンド/リジュームするので、合計で 100万個 (1000 x 1000) のExecutionContext
オブジェクトになります。つらいですね...。Action
。これも100万個以上 allocation が発生してます。まだ完了していないものを待つたびに、その awaiter のUnsafeOnCompleted
メソッドに渡すために新しいAction
デリゲートを allocate することになります。MoveNextRunner
。サスペンドするたびに新しいMoveNextRunner
を allocate して、Action とExecutionContext
を保存し、これらを適切に実行するようにしているので、やはりこれも 100万個の allocation が発生します。LogicalCallContext
。これも 100万回 allocation が発生しています。LogicalCallContext
は、.NET Framework のAsyncLocal<T>
の実装の詳細であり、ExecutionContext
の一部として保存されています。つまり、ExecutionContext
のコピーを100万個作っていると、LogicalCallContext
のコピーも100万個作っていることになります。QueueUserWorkItemCallback
。各Task.Yield()
はスレッドプールに work item を queueing しており、その結果、100万回の操作を表現するために使用される work item オブジェクトの allocation が100万個発生します。Task<VoidResult>
。これは今までみてきたやつとは異なり、100万個 allocation が発生しているわけではありません。非同期に完了する task 呼び出しはすべて、その呼び出しの最終的な完了を表すために新しい task を allocate する必要があります。<SomeMethodAsync>d__1
. これは、コンパイラが生成したステートマシン構造体のボックスです。 1000個のメソッドがサスペンドし、1000個のボックス化が発生します。QueueSegment/IThreadPoolWorkItem[]
。これらは数千個 allocation されており、技術的には特に async メソッドとは関係なく、むしろ一般的にスレッドプールにキューイングされる作業と関連しています。.NET Framework では、スレッドプールのキューは非循環連結リストです。長さNのセグメントに対して、N個のワークアイテムがそのセグメントに enqueue され、dequeue されると、セグメントは破棄されるため、ガベージコレクションの回収対象となります。
対して、.NET Core だとこう!
圧倒的!圧倒的改善ですよ!!
.NET Framework のこのサンプルでは、500万以上の allocation があり、合計で ~145MB のメモリが使われていました。 一方で .NET Core の同じサンプルでは、わずか ~1000 の allocation があり、合計 ~109KB しかメモリも使われていません。なぜこんなに少ないのでしょうか?
ExecutionContext
。.NET Coreでは、ExecutionContext
が immutable になりました。 その欠点は、AsyncLocal<T>
に値をセットするなど、コンテキストを変更するたびに、 新しいExecutionContext
の allocation が発生する事です。 しかしコンテキストをフローさせることは、それを変更することよりも遥かに一般的であり、ExecutionContext
が不変になったので、コンテキストをフローする事の一部としてExecutionContex
を clone する必要がなくなったということです。コンテキストをキャプチャするということは、文字通りフィールドからコンテキストを読み出すことであり、コンテキストを読み出してその内容のクローンを作成することではありません。つまり、変更するよりもフローさせる方が遥かに一般的であるだけでなく、遥かに安価なのです。LogicalCallContext
。これはもはや .NET Core には存在しなくなりました。 .NET CoreではExecutionContext
が存在するのはAsyncLocal<T>
のストレージのためだけです。 .NET Framework でExecutionContext
に独自の特別の格納場所が存在した他のものは、AsyncLocal<T>
を使うような形でモデル化されています。たとえば、.NET Framework ではSecurityContext
はExecutionContext
の一部でしたが、.NET Core でSecurityContext
はなくなり、AsyncLocal<SafeAccessTokenHandle>
になったりしました。QueueSegment/IThreadPoolWorkItem[]
。.NET Coreでは、ThreadPool の global queue はConcurrentQueue<T>
として実装され、ConcurrentQueue<T>
は非固定長の循環連結リストに書き直されました。そのためセグメントのサイズが十分に大きくなり、セグメントが埋まることがなくなれば、追加のセグメントを allocate する必要はなく、十分に大きいセグメントをただ延々と使いまわすことになります。
しかし、まだ謎は残ります。
プロファイラを見る限り、awaiter に continuation として渡すための Action
デリゲートや、MoveNextRunner
、<SomeMethodAsync>d__1
の boxing などで発生するはずの allocation はどうなったのでしょうか?
それらの allocation がどのように消えたかを理解するには、現在の .NET Core でどのように動作しているかを知る必要があります。
ここで、サスペンド時に何が起きていたかを振り返ってみましょう。
if (!awaiter.IsCompleted) // we need to suspend when IsCompleted is false { <>1__state = 1; <>u__2 = awaiter; <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); return; }
ここで生成されるコード(IL)は .NET Framework か .NET Core かに関わらず同じものです。
しかし、AwaitUnsafeOnCompleted
メソッドの実装は .NET Framework と .NET Core で大きく異なっています。
- .NET Framework / .NET Core の双方とも最初は同じで、
ExecutionContext.Capture()
で現在の ExecutionContext を取得します。 - そしてここから .NET Framework と .NET Core で処理が異なってきます。
.NET Core のビルダーには、たった一つのフィールドがあるだけです。
// .NET Core のビルダー public struct AsyncTaskMethodBuilder { private Task<VoidTaskResult>? m_task; // ~~ 略 ~~ }
ExecutionContext
をキャプチャした後、m_task
フィールドに AsyncStateMachineBox<TStateMachine>
(TStateMachine
はコンパイラが生成したステートマシン構造体の型)のオブジェクトがあるかチェックする。
このAsyncStateMachineBox<TStateMachine>
型が「マジック」であり、次のように定義されています。
private class AsyncStateMachineBox<TStateMachine> : Task<TResult>, IAsyncStateMachineBox where TStateMachine : IAsyncStateMachine { private Action? _moveNextAction; public TStateMachine? StateMachine; public ExecutionContext? Context; ... }
AsyncStateMachineBox<TStateMachine>
はその名の通り、スタックに存在する構造体をヒープに box 化するためのクラスです。
重要な事は、ステートマシンを IAsyncStateMachine
として boxing するのではなく、TStateMachine
として強く型付けされたフィールドとしてヒープにコピーする点です。
そして、別の Task
オブジェクトを持つのではなく、これが Task
オブジェクトそのものです(ベースクラスに注目してください!Task<TResult>
を継承しています!)。
また、Action
と ExecutionContext
の両方を格納するために別の MoveNextRunner
を持つのではなく、この型のフィールドを使用するだけです。
ExecutionContext
が変更されても、新しいコンテキストでフィールドを上書きすればいいだけなので、他に何も allocation は発生しません。
そして Action
は一度作成してしまえば、適切な場所を指し続けるのでキャッシュしておくことが出来ます。
つまり ExecutionContext
をキャプチャした後、すでにAsyncStateMachineBox<TStateMachine>
のオブジェクトがあれば、 このメソッドがサスペンドされるのは初めてではないので、新しくキャプチャした ExecutionContext
をフィールドに格納すればいいだけです。もしまだ null ならば、AsyncStateMachineBox<TStateMachine>
を allocate する必要があります。
// https://github.com/dotnet/runtime/blob/v7.0.5/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskMethodBuilderT.cs#L152-L240 public struct AsyncTaskMethodBuilder<TResult> { private static IAsyncStateMachineBox GetStateMachineBox<TStateMachine>( ref TStateMachine stateMachine, [NotNull] ref Task<TResult>? taskField) where TStateMachine : IAsyncStateMachine { // 抜粋 // いろいろ実装があって var box = new AsyncStateMachineBox<TStateMachine>(); // ここで allocate taskField = box; // important: this must be done before storing stateMachine into box.StateMachine! box.StateMachine = stateMachine; box.Context = currentContext; } // ~~ 略 ~~ }
上記のソースコードで「important」としている行に注目してください。
これは、.NET Framework の複雑な SetStateMachine
ダンスの代わりになるもので、.NET Core では SetStateMachine
は全く使用されません。
box
が代入されている taskField
は、AsyncTaskMethodBuilder
の m_task
フィールドへの参照です。
我々は AsyncStateMachineBox<TStateMachine>
を allocate し、taskField
を通じてビルダー(スタック上にあるステートマシン構造体に含まれるビルダー)の m_task
に box を保存し、そして
そのスタック上にあるステートマシン(box への参照をすでに含む)を、ヒープ上の AsyncStateMachineBox<TStateMachine>
にコピーします。このようにする事で、AsyncStateMachineBox<TStateMachine>
は適切かつ再帰的に、自身を参照するようになります。
これでもまだ難しいですが、それでも .NET Framework の複雑な SetStateMachine
ダンスに比べればだいぶ良くなりました。
- そして、
StateMachine
のMoveNext
を呼び出す前に、適切なExecutionContext
の復元を行うAsyncStateMachineBox<TStateMachine>
のMoveNext
メソッドを呼び出す、このオブジェクトのメソッドへのAction
デリゲートを取得します。そのAction
は_moveNextAction
フィールドにキャッシュされ、それ以降に使用する場合は初回にキャッシュしたAction
である_moveNextAction
を再利用することができるようになります。そのAction
は awaiter のUnsafeOnCompleted
に渡され、continuation をフックします。
private class AsyncStateMachineBox<TStateMachine> : Task<TResult>, IAsyncStateMachineBox where TStateMachine : IAsyncStateMachine { // ExecutionContext.RunInternal 等にデリゲートを投げる際に // 効率的に行うためのデリゲートのキャッシュ private static readonly ContextCallback s_callback = ExecutionContextCallback; private static void ExecutionContextCallback(object? s) { ((AsyncStateMachineBox<TStateMachine>)s).StateMachine!.MoveNext(); } internal sealed override void ExecuteFromThreadPool(Thread threadPoolThread) => MoveNext(threadPoolThread); public void MoveNext() => MoveNext(threadPoolThread: null); private void MoveNext(Thread? threadPoolThread) { ExecutionContext? context = Context; if (context == null) { Debug.Assert(StateMachine != null); StateMachine.MoveNext(); } else { // MoveNext の呼び方に差はあれど、どちらにせ // ExecutionContext を復元してから実行 if (threadPoolThread is null) { // s_callback は StateMachine.MoveNext() を読んでいるだけ。 ExecutionContext.RunInternal(context, s_callback, this); } else { ExecutionContext.RunFromThreadPoolDispatchLoop(threadPoolThread, context, s_callback, this); } } } public Action MoveNextAction => (Action)(_moveNextAction ??= new Action(MoveNext)); // ~~ 略 ~~ }
上記の内容だと、<SomeMethodAsync>d__1
はボックス化されず、AsyncStateMachineBox<TStateMachine>
のフィールド(Task
それ自体のフィールドともいえる)として存在するようになり、MoveNextRunner
は Action
と ExecutionContext
を保存するためだけに存在していたため AsyncStateMachineBox<TStateMachine>
の存在のお陰で不要になりました。
しかし、この説明からすると、awaiter に渡す _moveNextAction
が必要なため、1回のメソッド呼び出しごとに、1000個の Action
の allocation が発生しているはずです。
しかし、プロファイラにはそれが表示されていません。
なぜでしょうか?
また、QueueUserWorkItemCallback
オブジェクトについてはどうでしょうか。
Task.Yield()
の一部として queueing しているのに、なぜプロファイラに表示されないのでしょうか。
前述のように、実装の詳細をコアライブラリに押し出すことの良い点の1つは、時間の経過とともに実装を進化させることができることで、.NET Framework から .NET Core への進化はすでに見てきたとおりです。
また、.NET Core への最初の書き換えからさらに進化し、システム内の主要なコンポーネントに内部アクセスできることによるメリットを生かした最適化が追加されています。
特に、非同期インフラストラクチャは、Task
や TaskAwaiter
などのコアとなる型について知っています。
知っている上に、内部アクセス権を持っているため、public に定義されたルールに従う必要がありません。
public に定義されたルール上、C# の awaiter パターンでは、awaiter に OnCompleted
または UnsafeOnCompleted
メソッドを要求し、どちらも continuation を Action として受け取ります。
つまり、任意の awaiter が正しく動作するためには、非同期インフラストラクチャは continuation を表す Action
デリゲートを作成する必要があります。
しかし、もしインフラストラクチャが知っている awaiter に出会った場合は、同じコードパスを取る義務はありません。
System.Private.CoreLib
で定義されているすべての awaiter は、Action
を全く必要としない、より無駄のない経路をとることができます。
これらの awaiter はすべて IAsyncStateMachineBox
について知っており、box オブジェクトそのものを continuation として取り扱うことができるのです。
例えば、Task.Yield()
が返す YieldAwaitable
は、IAsyncStateMachineBox
自体を work item としてスレッドプールに直接キューイングすることができ、Task
オブジェクトを await するときに使う TaskAwaiter
は、IAsyncStateMachineBox
自体を直接 task の continuation リストに格納できるようになっています。 Action
は必要なく、QueueUserWorkItemCallback
も必要ありません。
public readonly struct YieldAwaitable { // ~~ 略 ~~ public YieldAwaiter GetAwaiter() { return default; } public readonly struct YieldAwaiter : ICriticalNotifyCompletion, IStateMachineBoxAwareAwaiter { // ~~ 略 ~~ // UnsafeOnCompleted に Action を渡すのではなく、 // AwaitUnsafeOnCompleted で直接 box を受け取れるようになっている。 void IStateMachineBoxAwareAwaiter.AwaitUnsafeOnCompleted(IAsyncStateMachineBox box) { Debug.Assert(box != null); // If tracing is enabled, delegate the Action-based implementation. if (TplEventSource.Log.IsEnabled()) { QueueContinuation(box.MoveNextAction, flowContext: false); return; } // Otherwise, this is the same logic as in QueueContinuation, except using // an IAsyncStateMachineBox instead of an Action, and only for flowContext:false. SynchronizationContext? syncCtx = SynchronizationContext.Current; if (syncCtx != null && syncCtx.GetType() != typeof(SynchronizationContext)) { syncCtx.Post(static s => ((IAsyncStateMachineBox)s!).MoveNext(), box); } else { TaskScheduler scheduler = TaskScheduler.Current; if (scheduler == TaskScheduler.Default) { // public になっている ThreadPool.QueueUserWorkItem ではデリゲートを渡すのが普通。 // 一方で内部では、ThreadPool に デリゲートではなく box を直接投げ込めるようになっている。 // このおかげで、デリゲートの allocation を防ぐ事が出来ている。 ThreadPool.UnsafeQueueUserWorkItemInternal(box, preferLocal: false); } else { Task.Factory.StartNew(static s => ((IAsyncStateMachineBox)s!).MoveNext(), box, default, TaskCreationOptions.PreferFairness, scheduler); } } } } }
public struct AsyncTaskMethodBuilder<TResult> { // ~~ 略 ~~ internal static void AwaitUnsafeOnCompleted<TAwaiter>( ref TAwaiter awaiter, IAsyncStateMachineBox box) where TAwaiter : ICriticalNotifyCompletion { // 既知の awaiter については以下のような最適化が行われている。 // ちなみに null != (object?)default(TAwaiter) は TAwaiter が構造体かの最速チェック手法 // ITaskAwaiter 等は internal な marker interface. // is を使った interface の実装チェックも JIT 時最適化で消えて定数に変わる(boxing とか起きない)。 if ((null != (object?)default(TAwaiter)) && (awaiter is ITaskAwaiter)) { ref TaskAwaiter ta = ref Unsafe.As<TAwaiter, TaskAwaiter>(ref awaiter); // relies on TaskAwaiter/TaskAwaiter<T> having the same layout TaskAwaiter.UnsafeOnCompletedInternal(ta.m_task, box, continueOnCapturedContext: true); } else if ((null != (object?)default(TAwaiter)) && (awaiter is IConfiguredTaskAwaiter)) { ref ConfiguredTaskAwaitable.ConfiguredTaskAwaiter ta = ref Unsafe.As<TAwaiter, ConfiguredTaskAwaitable.ConfiguredTaskAwaiter>(ref awaiter); TaskAwaiter.UnsafeOnCompletedInternal(ta.m_task, box, ta.m_continueOnCapturedContext); } else if ((null != (object?)default(TAwaiter)) && (awaiter is IStateMachineBoxAwareAwaiter)) { try { ((IStateMachineBoxAwareAwaiter)awaiter).AwaitUnsafeOnCompleted(box); } catch (Exception e) { System.Threading.Tasks.Task.ThrowAsync(e, targetContext: null); } } else { // The awaiter isn't specially known. Fall back to doing a normal await. try { awaiter.UnsafeOnCompleted(box.MoveNextAction); } catch (Exception e) { System.Threading.Tasks.Task.ThrowAsync(e, targetContext: null); } } } }
したがって、async メソッドが System.Private.CoreLib
由来の型 (Task
, Task<TResult>
, ValueTask
, ValueTask<TResult>
, YieldAwaitable
, ConfigureAwait
など) のオブジェクトを await するという最も一般的なケースでは、最悪のケースで、async メソッドのライフサイクル全体に関連するオーバーヘッドが1回だけ allocate されることになります。
つまり、async メソッドがサスペンドすることがあれば、他のすべての必要な状態を格納する単一の Task
派生型が allocate され、メソッドが停止しなければ、追加の allocation が発生することはありません。
また、必要であれば amortized fashion(amortized fashion: pool から rent して return するようなスタイル)を用いる事で、最後の allocation を取り除くこともできます。
これまで示したように、Task
には既定のビルダー (AsyncTaskMethodBuilder
) があり、同様に Task<TResult>
には既定のビルダー(AsyncTaskMethodBuilder<TResult>
)、 ValueTask
と ValueTask<TResult>
には それぞれ AsyncValueTaskMethodBuilder
、AsyncValueTaskMethodBuilder<TResult>
が対応して存在します。
ValueTask
/ValueTask<TResult>
の場合、ビルダー自体は同期的かつ正常に完了するケースのみを処理するため、実際には非常に単純です。
この場合、async メソッドはサスペンドされることなく完了し、ビルダーは ValueTask.Completed
もしくは結果をラップする ValueTask<TResult>
を返すだけで済みます。
それ以外の場合は、AsyncTaskMethodBuilder
/AsyncTaskMethodBuilder<TResult>
に委譲され、返されるValueTask
/ValueTask<TResult>
は Task
/Task<TResult>
をラップしているだけなので、同じロジックをすべて共有しています。
しかし、.NET 6 と C# 10 では、メソッドごとに使用するビルダーをオーバーライドする機能が導入され、ValueTask
/ValueTask<TResult>
用のいくつかの特殊なビルダーが導入されました。それらは Task
を使用するのではなく、IValueTaskSource
/IValueTaskSource <TResult>
を用いてプールを活用を実現しています。
実際のコードを見ていきましょう。
まず、以下のようなコードがあったとします。
static async ValueTask SomeMethodAsync() { for (int i = 0; i < 1000; i++) { await Task.Yield(); } }
この時、以下のようなコードがコンパイラによって生成されます。
// デフォルトならこう。 [AsyncStateMachine(typeof(<SomeMethodAsync>d__1))] private static ValueTask SomeMethodAsync() { <SomeMethodAsync>d__1 stateMachine = default; stateMachine.<>t__builder = AsyncValueTaskMethodBuilder.Create(); stateMachine.<>1__state = -1; stateMachine.<>t__builder.Start(ref stateMachine); return stateMachine.<>t__builder.Task; }
ここで、メソッドに対して [AsyncMethodBuilder]
属性をつけることで、ビルダーをカスタマイズできます。
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] // <- 追加 static async ValueTask SomeMethodAsync() { for (int i = 0; i < 1000; i++) { await Task.Yield(); } }
その場合、以下のようなコードがコンパイラによって生成されます。 用いられるビルダーが変わっている事に注目してください。
[AsyncStateMachine(typeof(<SomeMethodAsync>d__1))] [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] private static ValueTask SomeMethodAsync() { <SomeMethodAsync>d__1 stateMachine = default; stateMachine.<>t__builder = PoolingAsyncValueTaskMethodBuilder.Create(); // <- 指定したビルダーが使われるようになっている stateMachine.<>1__state = -1; stateMachine.<>t__builder.Start(ref stateMachine); return stateMachine.<>t__builder.Task; }
AsyncValueTaskMethodBuilder
と PoolingAsyncValueTaskMethodBuilder
の重要な違いは、メソッドが最初にサスペンドしたときに、new AsyncStateMachineBox<TStateMachine>()
を行う代わりに StateMachineBox<TStateMachine>.RentFromCache()
を行い、async メソッド (SomeMethodAsync
) が終了し完了した ValueTask
が呼び出し元に返される際に、RentFromCache()
した box を ReturnToCache()
し、キャッシュから借りた box をキャッシュに返却する事です。
これは (amortized) zero allocation を意味します。
キャッシュ自体がちょっと面白いものだったりします。 Object pooling は、良いアイデアにも悪いアイデアにもなり得ます。 作成にコストがかかるオブジェクトほど、プールする価値が高くなります。 したがって、たとえば、非常に小さな配列をプールするよりも、非常に大きな配列をプールする方がはるかに価値があります。 大きな配列は、初期化するために多くのCPUサイクルとメモリアクセスを必要とするだけでなく、GC に負担をかけるからです。 しかし、非常に小さなオブジェクトの場合、プールすることは実質的にマイナスになることがあります。 プールは GC と同様、単なる memory allocator です。 そのため、プールするときは、あるアロケータに関連するコストと別のアロケータに関連するコストをトレードオフすることになります。 また GC は、多数の小さく寿命の短いオブジェクトを処理する際に非常に効率的です。 オブジェクトのコンストラクタで多くの処理を行う場合、プールする事でその処理を回避することができるため、pooling の価値が高まります。 しかし、オブジェクトのコンストラクタで大した処理を行わずにそれをプールする場合は「GC よりもプールを使った方が効率的である」という仮定に基づいているはずなわけですが、多くの場合それは誤った結果になります。 場合によっては、GC のヒューリスティックな最適化に逆らうことになるかもしれません。 たとえば、GC はより高い世代(gen2)のオブジェクトからより低い世代(gen0)のオブジェクトへの参照は比較的少ないいう前提に基づいて最適化されていますが、オブジェクトの pooling はこれらの前提を無効にする可能性があります。 そのため pooling によって、トータルでのパフォーマンスは落ちるかもしれません。
さて、async メソッドで生成されるオブジェクトは小さくありませんし、スーパーホットなパス上にあることもあるので、プーリングは合理的な場合もあります。
しかし、それを可能な限り価値あるものにするために、可能な限りオーバーヘッドを避けたいとも考えています。
そのため、PoolingAsyncValueTaskMethodBuilder
ではアグレッシブにキャッシュを利用する事に比べたらより多くの allocate を行う可能性があったとしても、非常にシンプルで競合がほとんどない状態で非常に高速にオブジェクトを rent/return するようなプールを実装する選択をしています。
ステートマシンの型ごとに、スレッドごとに最大 1 つのステートマシンボックス、CPU のコアごとに 1 つのステートマシンボックスをプールします。
これにより、最小限のオーバーヘッドと競合で、rent と return を行うことができます。
あるスレッドが同時に他のスレッド固有のキャッシュにアクセスすることはできず、他のスレッドが同時にコア固有のキャッシュにアクセスする事は稀です。(このあたりのお話は PoolingAsyncValueTaskMethodBuilder
の 実装を読んだ方がすんなり入ってくるかもしれません。下に抜粋しましたが、allocation を削減するためのアグレッシブなプール(zero allocation を指向するプール)よりも、高速に rent/return する事を選択しているというのが如実に分かります。)
public struct PoolingAsyncValueTaskMethodBuilder<TResult> { // ~~ 略 ~~ private sealed class StateMachineBox<TStateMachine> : StateMachineBox, IValueTaskSource<TResult>, IValueTaskSource, IAsyncStateMachineBox, IThreadPoolWorkItem where TStateMachine : IAsyncStateMachine { // ~~ 略 ~~ [MethodImpl(MethodImplOptions.AggressiveInlining)] // only one caller internal static StateMachineBox<TStateMachine> RentFromCache() { // First try to get a box from the per-thread cache. StateMachineBox<TStateMachine>? box = t_tlsCache; if (box is not null) { t_tlsCache = null; } else { // If we can't, then try to get a box from the per-core cache. ref StateMachineBox<TStateMachine>? slot = ref PerCoreCacheSlot; if (slot is null || (box = Interlocked.Exchange<StateMachineBox<TStateMachine>?>(ref slot, null)) is null) { // If we can't, just create a new one. box = new StateMachineBox<TStateMachine>(); } } return box; } /// <summary>Returns this instance to the cache.</summary> [MethodImpl(MethodImplOptions.AggressiveInlining)] // only two callers private void ReturnToCache() { // Clear out the state machine and associated context to avoid keeping arbitrary state referenced by // lifted locals, and reset the instance for another await. ClearStateUponCompletion(); _valueTaskSource.Reset(); // If the per-thread cache is empty, store this into it.. if (t_tlsCache is null) { t_tlsCache = this; } else { // Otherwise, store it into the per-core cache. ref StateMachineBox<TStateMachine>? slot = ref PerCoreCacheSlot; if (slot is null) { // Try to avoid the write if we know the slot isn't empty (we may still have a benign race condition and // overwrite what's there if something arrived in the interim). Volatile.Write(ref slot, this); } } } } }
なお、これは比較的小さなプールのように見えるかもしれませんが、プールが現在使用されていないオブジェクトの格納のみを担当している事を考えると、定常状態 (steady state) での allocation を大幅に削減するのに非常に効果的です。
なぜならプールは現在使用されていないオブジェクトのみを格納する責任を持っているからです。 これは当たり前のことのように感じるかもしれませんが、非常に重要な事です。 100万の非同期メソッドがすべて同時に実行されていたとしても、プールはスレッドごとおよびコアごとに最大1つのオブジェクトしか格納できません。 それでも allocation を大幅に削減するのに非常に効果的です。 なぜなら、オブジェクトはある operation から別の operation への転送に必要な時間のみプールに保持される必要があり、box が operation によって使用されている間はプールに保持する必要がないからです。
おわりに
一番はじめに書いたように、この記事は Stephen Toub 氏の How Async/Await Really Works in C# という記事をベースにしています。 本当に素晴らしい記事で、私はこれを読んだ時かなりキャッキャしていました。 ありがとう Stephen 氏。 そしてこの記事の読者が C# の async/await について理解が深まれば C# 推しの私としても幸いです。 楽しい C# コーディングライフを送っていきましょう...!