ねののお庭。

かりかりもふもふ。

【C#】C# の async/await は実際にどうやって動いているか。

はじめに

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 を用いて実装する必要があります。 TaskCompletionSourceTask オブジェクトの作成とその完了の 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 を返し、その後の Currentyield 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>) を IterateAsyncMoveNext する事で進め、Current (一番最初の実行であれば、source.ReadAsync の返り値である Task オブジェクト) を取得します。 その Current に対して、イテレータを進めるための continuation として、t => Process() を登録する事で、Current で取得した task が完了し次第、イテレータが MoveNext で前に進むことによって destination.WriteAsync までが実行されます。 そして destination.WriteAsync の返り値が次の Current として IterateAsync の内の Process で取得され、またイテレータを進めるための continuation として、t => Process()がその task に登録され...という感じです。

というわけで、CopyStreamToStreamAsync において、脱コールバックが出来ています。これが嬉しい。 そして CopyStreamToStreamAsyncyield returnawait に置き換えるつもりでじーっと睨んでみてください。 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 の他にも、AsyncValueTaskMethodBuilderPoolingAsyncValueTaskMethodBuilder 等が存在します。以後「ビルダー」と書いてあったらこれらのような AsyncMethodBuilder を指します) の役割は、Task プロパティとして露出する TaskValueTask 等の型のオブジェクトを作成し、 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 は、LogicalCallContextSecurityContext など様々なコンテキストを内包し、それら全てのコンテキストをフローさせていました。 一方で、.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 をフローさせるべきところです。 実際、ThreadStart メソッドは ExecutionContext.Capture を使って現在の ExecutionContext をキャプチャ & Thread のフィールドに保存し、Thread のコンストラクタで渡した ThreadStart デリゲートを呼び出すときにそのキャプチャしたコンテキストをリストアして使っています。 しかし、MyThreadPool の例では、static constructor が実行された時にたまたま存在した ExecutionContextThread に取り込ませたくないので (デモがより複雑になるため)、Start メソッドの代わりに UnsafeStart メソッドを使用しています。 Unsafeで始まるスレッド関連メソッドは、ExecutionContext をキャプチャしなくなります。その事を除けば、Unsafe 接頭辞を持たないメソッドは全く同じ動作をします。 たとえば、Thread.StartExecutionContext をキャプチャするのに対して、Thread.UnsafeStartExecutionContext をキャプチャしませんが、それ以外の動作は同じです。

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 を実行し、終了時に現在の ExecutionContextMoveNext 実行前の状態に戻すという処理を行っています。

この理由は、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 したタイミングでサスペンドが発生し、メソッドの呼び出し元に同期的に返ってきます。 この時、もし ExecutionContextElevateAsAdminAndRunAsync メソッド呼び出し前のものに復元していなかったら、どうなるでしょうか? そう、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.IsCompletedtrue の状態)させるという責務を負っています。 もし try ブロック本体で処理されない例外が投げられたら、Task オブジェクトはその例外によって失敗させられる事になります。 また、async メソッドが正常に終了に至った場合、返された Task オブジェクトは正常に完了します。 いずれの場合も、ステートマシンの状態を async メソッドの成功/失敗に関わらず、完了状態になるよう設定しているのです。 またこの Task オブジェクトに対する完了は、ビルダーの SetExceptionSetResult メソッドを経由している事に注意してください。

async メソッドが以前にサスペンドしていた場合、ビルダーはそのサスペンドの処理の一部として既に Task オブジェクトを作成しているはずです。 この場合、既にある Task オブジェクトに対して SetException/SetResult を呼び出した時にその Task が完了します。 しかし、async メソッドが以前にサスペンドしていない場合は、まだ Task オブジェクトを作成しておらず、呼び出し元にも何も返していないため、ビルダーは Task オブジェクトを作成する方法についてより柔軟性を持っています。例えばこの柔軟性のお陰で、毎度 Task オブジェクトを allocate したりせず、Task.CompletedTask 等キャッシュされたオブジェクトを返して無駄な allocation を避けたりする事ができます。

また、ビルダーは作成する Task オブジェクトを適切な状態にする責務も担っています。 Task オブジェクトには最終状態として、RanToCompletion(success) / Faulted / Canceled の3つの状態があります。 AsyncTaskMethodBuilderSetException メソッドは、 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 が呼び出されています。 IsCompletedtrue を返した場合、IL_00f0 に進み、awaiter の別のメンバー GetResult() を呼び出すことになります。 操作が失敗した場合、GetResult() は例外を投げて async メソッドの外に例外を伝播させる役割を果たします。 それ以外の場合、つまり処理の結果があれば、GetResult() はそれを返す役割を果たします。 ReadAsync の場合、処理の結果(つまり GetResult() が返した結果)が 0 であれば、読み書きループから抜け出し、SetResult() を呼び出すメソッドの末尾に移動して完了です。

ここで本当に興味深いのは、IsCompleted チェックが false を返した場合に実際どうなるかです。 IsCompletedfalse を返した場合、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 を実装するという選択肢もあります。 ICriticalNotifyCompletionINotifyCompletion を継承し、void UnsafeOnCompleted(Action continuation) というメソッドが追加で定義されています。 前に 「Unsafe」 というプレフィックスが付くものの ExecutionContext の扱いについて説明しましたが、この2つのメソッドの違いは何かというと、どちらも continuation をフックするものですが、OnCompletedExecutionContext をフローさせる必要があるのに対し、UnsafeOnCompletedExecutionContext をフローさせる必要がないのです。 INotifyCompletion.OnCompletedICriticalNotifyCompletion.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 上で何をしているのかを以下に要約します。

  1. ExecutionContext.Capture()を使って、現在のコンテキストを取得。
  2. キャプチャされた ExecutionContext とボックス化されたステートマシンの両方をラップするための MoveNextRunner (class) オブジェクトを allocate します(このメソッドが初めてサスペンドする場合はまだ持っていないので、null を placeholder として使用します)。
  3. そして、その MoveNextRunnerRun メソッドに対する Action デリゲートを作成します。こうして、キャプチャした ExecutionContext のコンテキストで、ステートマシンの MoveNext を呼び出すデリゲートを取得することができるのです。
  4. このメソッドが初めてサスペンドする場合、まだボックス化されたステートマシンを持っていないので、この時点でボックス化します。これは IAsyncStateMachine interface として型付けされたローカル変数にオブジェクトを格納することによってヒープ上にもっていきます。そして、このボックス化されたステートマシンは、MoveNextRunner に保存されます。
  5. ここからが難しいステップです。 まず、ステートマシン構造体の定義を見ると、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 メソッドを呼び出し、そのボックスをフィールドに格納します。
  6. 最後に、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 associated with asynchronous operations on .NET Framework

なぜこんなにも allocation が発生しているのでしょう?原因は幾つかあります。

  1. ExecutionContext。これは 100万個以上 allocation が発生してます。なぜでしょう?それは .NET Framework では、ExecutionContext は ミュータブルなデータ構造だからです。非同期処理がフォークされた時点で存在していた ambient data はフローさせたいし、フォーク後に行われた変更には影響されたくない、ExecutionContext を毎回コピーする必要があります。フォークされた操作の一つ一つにこのようなコピーが必要なので、SomeMethodAsync を 1000回 呼び出すと、それぞれが 1000回 サスペンド/リジュームするので、合計で 100万個 (1000 x 1000) のExecutionContext オブジェクトになります。つらいですね...。
  2. Action。これも100万個以上 allocation が発生してます。まだ完了していないものを待つたびに、その awaiter の UnsafeOnCompleted メソッドに渡すために新しい Action デリゲートを allocate することになります。
  3. MoveNextRunner。サスペンドするたびに新しい MoveNextRunner を allocate して、Action と ExecutionContext を保存し、これらを適切に実行するようにしているので、やはりこれも 100万個の allocation が発生します。
  4. LogicalCallContext。これも 100万回 allocation が発生しています。LogicalCallContext は、.NET Framework のAsyncLocal<T> の実装の詳細であり、ExecutionContext の一部として保存されています。つまり、ExecutionContextのコピーを100万個作っていると、LogicalCallContextのコピーも100万個作っていることになります。
  5. QueueUserWorkItemCallback。各 Task.Yield() はスレッドプールに work item を queueing しており、その結果、100万回の操作を表現するために使用される work item オブジェクトの allocation が100万個発生します。
  6. Task<VoidResult>。これは今までみてきたやつとは異なり、100万個 allocation が発生しているわけではありません。非同期に完了する task 呼び出しはすべて、その呼び出しの最終的な完了を表すために新しい task を allocate する必要があります。
  7. <SomeMethodAsync>d__1. これは、コンパイラが生成したステートマシン構造体のボックスです。 1000個のメソッドがサスペンドし、1000個のボックス化が発生します。
  8. QueueSegment/IThreadPoolWorkItem[]。これらは数千個 allocation されており、技術的には特に async メソッドとは関係なく、むしろ一般的にスレッドプールにキューイングされる作業と関連しています。.NET Framework では、スレッドプールのキューは非循環連結リストです。長さNのセグメントに対して、N個のワークアイテムがそのセグメントに enqueue され、dequeue されると、セグメントは破棄されるため、ガベージコレクションの回収対象となります。

対して、.NET Core だとこう!

Allocation associated with asynchronous operations on .NET Core

圧倒的!圧倒的改善ですよ!!

.NET Framework のこのサンプルでは、500万以上の allocation があり、合計で ~145MB のメモリが使われていました。 一方で .NET Core の同じサンプルでは、わずか ~1000 の allocation があり、合計 ~109KB しかメモリも使われていません。なぜこんなに少ないのでしょうか?

  1. ExecutionContext。.NET Coreでは、ExecutionContext が immutable になりました。 その欠点は、AsyncLocal<T> に値をセットするなど、コンテキストを変更するたびに、 新しい ExecutionContext の allocation が発生する事です。 しかしコンテキストをフローさせることは、それを変更することよりも遥かに一般的であり、ExecutionContext が不変になったので、コンテキストをフローする事の一部として ExecutionContex を clone する必要がなくなったということです。コンテキストをキャプチャするということは、文字通りフィールドからコンテキストを読み出すことであり、コンテキストを読み出してその内容のクローンを作成することではありません。つまり、変更するよりもフローさせる方が遥かに一般的であるだけでなく、遥かに安価なのです。
  2. LogicalCallContext。これはもはや .NET Core には存在しなくなりました。 .NET Coreでは ExecutionContext が存在するのは AsyncLocal<T> のストレージのためだけです。 .NET Framework で ExecutionContext に独自の特別の格納場所が存在した他のものは、AsyncLocal<T> を使うような形でモデル化されています。たとえば、.NET Framework では SecurityContextExecutionContext の一部でしたが、.NET Core で SecurityContext はなくなり、AsyncLocal<SafeAccessTokenHandle> になったりしました。
  3. 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 で大きく異なっています。

  1. .NET Framework / .NET Core の双方とも最初は同じで、ExecutionContext.Capture() で現在の ExecutionContext を取得します。
  2. そしてここから .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> を継承しています!)。 また、ActionExecutionContext の両方を格納するために別の 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 は、AsyncTaskMethodBuilderm_task フィールドへの参照です。 我々は AsyncStateMachineBox<TStateMachine> を allocate し、taskField を通じてビルダー(スタック上にあるステートマシン構造体に含まれるビルダー)の m_task に box を保存し、そして そのスタック上にあるステートマシン(box への参照をすでに含む)を、ヒープ上の AsyncStateMachineBox<TStateMachine> にコピーします。このようにする事で、AsyncStateMachineBox<TStateMachine> は適切かつ再帰的に、自身を参照するようになります。 これでもまだ難しいですが、それでも .NET Framework の複雑な SetStateMachine ダンスに比べればだいぶ良くなりました。

  1. そして、StateMachineMoveNext を呼び出す前に、適切な 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 それ自体のフィールドともいえる)として存在するようになり、MoveNextRunnerActionExecutionContext を保存するためだけに存在していたため AsyncStateMachineBox<TStateMachine> の存在のお陰で不要になりました。 しかし、この説明からすると、awaiter に渡す _moveNextAction が必要なため、1回のメソッド呼び出しごとに、1000個の Action の allocation が発生しているはずです。 しかし、プロファイラにはそれが表示されていません。 なぜでしょうか? また、QueueUserWorkItemCallback オブジェクトについてはどうでしょうか。 Task.Yield() の一部として queueing しているのに、なぜプロファイラに表示されないのでしょうか。

前述のように、実装の詳細をコアライブラリに押し出すことの良い点の1つは、時間の経過とともに実装を進化させることができることで、.NET Framework から .NET Core への進化はすでに見てきたとおりです。 また、.NET Core への最初の書き換えからさらに進化し、システム内の主要なコンポーネントに内部アクセスできることによるメリットを生かした最適化が追加されています。 特に、非同期インフラストラクチャは、TaskTaskAwaiter などのコアとなる型について知っています。 知っている上に、内部アクセス権を持っているため、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>)、 ValueTaskValueTask<TResult> には それぞれ AsyncValueTaskMethodBuilderAsyncValueTaskMethodBuilder<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;
}

AsyncValueTaskMethodBuilderPoolingAsyncValueTaskMethodBuilder の重要な違いは、メソッドが最初にサスペンドしたときに、new AsyncStateMachineBox<TStateMachine>() を行う代わりに StateMachineBox<TStateMachine>.RentFromCache() を行い、async メソッド (SomeMethodAsync) が終了し完了した ValueTask が呼び出し元に返される際に、RentFromCache() した box を ReturnToCache() し、キャッシュから借りた box をキャッシュに返却する事です。 これは (amortized) zero allocation を意味します。

Allocation associated with asynchronous operations on .NET Core with pooling

キャッシュ自体がちょっと面白いものだったりします。 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# コーディングライフを送っていきましょう...!