コンテンツへスキップ

【C#】非同期処理とasync/await

  • C#

今回の記事はasync/awaitについて。

C#に限らず、現在では多くのプログラミング言語が非同期処理を扱う言語機能としてasync/awaitを採用しています。現在の.NETでも至る所にasync/awaitが使われており、避けて通ることはできない重要な機能となっています。

そこで今回は、C#における非同期処理とasync/await、またC#8.0で導入された非同期ストリームとIAsyncEnumerable<T>について、基本的な使い方を解説していきます。

また、記事の後半では実際にasync/awaitがどのように動作しているかをコンパイル結果を通して説明していきます。この辺りはやや高度なトピックになるため読み飛ばしていただいても構いませんが、async/awaitをより深く理解したい方は是非そちらも読んでみてください。

同期処理 / 非同期処理

async/awaitに関する話に入る前に、まずは「同期処理と非同期処理の違い」についてから始めていきます。

「同期処理」とは1つずつ順番に(前の処理の完了を待ってから)実行する処理のことです。C#を含むほとんどの言語では、以下のように普通に書いたコードは同期的に実行されます。

// Aが終わったらB、Bが終わったらCというように順番に実行される
// 言い換えると、Aの処理が完了するまでBが実行されることはない
ExecuteA();
ExecuteB();
ExecuteC();

これに対し「非同期処理」とは処理の完了を待たずに実行される処理のことです。C#で例を挙げると、Task.Runで実行した処理はスレッドプール上で非同期的に実行されます。

// Task.Runを用いてスレッドプール上で処理を実行する
// 同期処理とは異なり、A、B、Cの完了を待たずに処理を続行する
Task.Run(() => ExecuteA());
Task.Run(() => ExecuteB());
Task.Run(() => ExecuteC());

一つ注意として、async/awaitにおいて非同期処理とスレッドを混同している説明が非常に多いのですが、非同期=マルチスレッドではありません。あくまで非同期処理が意味するのは「他の処理を待機せずに実行すること」だけであり、非同期処理自体はスレッドや並列処理とは無関係です。(async/awaitは別スレッドの処理を待機する際によく使われますが、別にそのための機能ではありませんし、そもそもasync/await自体はスレッドを移動させる機構を持ちません。)

例えばUnityのコルーチンは非同期処理のための仕組みですが、実行自体は全てメインスレッド上で行われます。これはUniTask(Unityで高性能なasync/awaitを実現するライブラリ)においても同様で、UniTask自体はメインスレッドのPlayerLoop上で実行されます。また、JavaScriptにもasync/awaitが登場しますが、あくまで実行はシングルスレッドです。

C#ではTaskがマルチスレッドとasync/awaitの両方で用いられるため非常に紛らわしいですが、この二つは明確に区別するようにしましょう。

async/await

ここからはasync/awaitの使い方、またそもそも非同期処理を扱うためになぜ言語機能としてasync/awaitを使用するのかを見ていきましょう。

C#5.0でasync/awaitが導入される前、非同期処理はコールバックを用いて実装するのが基本でした。しかし、コールバックによる実装は多段呼び出しを行うたびにネストが深くなり、コードの可読性が著しく下がります。(コールバック地獄)

またコールバックの内側で発生した例外は外部に伝搬されないため、例外処理も困難です。

// いわゆるコールバック地獄
FooAsync(x =>
{
    BarAsync(x, y =>
    {
        BazAsync(y, z =>
        {
            // 内部で例外が発生しても外側には伝搬されない
            throw new Exception("Error!");
        });
    });
});

このようなコールバック地獄を避けるため、言語レベルで非同期処理をサポートする機能としてasync/awaitが導入されました。

使用する際はメソッドにasyncキーワードをつけ、非同期処理を行う関数の戻り値をTask/Task<T>(または任意のTask-like型、詳細は後述)に変更するだけです。あとは待機したい非同期メソッドをawaitすれば処理の完了を簡単に待つことができるようになります。

// asyncキーワードをつけ、戻り値をTask<int>に
// C#において非同期メソッドは**Asyncのような命名が用いられる
async Task<int> FooAsync(int x)
{
    // 非同期メソッドをawaitで待機
    int y = await BarAsync(x);
    int z = await BazAsync(y);

    // 普通にreturnで値を返せる
    return x + y + z;
}

// 戻り値が不要(void)の場合は非ジェネリックなTaskを使用する
async Task QuxAsync()
{
   ...
}

例外処理を行う際は普通にtry-catch-finallyで行えばOKです。

// 同期処理と同じようにtry-catch-finallyが使える
try
{
    return await FooAsync(10);
}
catch (Exception ex)
{
    ...
}
finally
{
    ...
}

また、複数のTaskを同時に待機することも可能です。複数のTaskをまとめて待機するにはTask.WhenAllやTasl.WhenAnyが利用できます。

var taskA = FooAsync();
var taskB = BarAsync();
var taskC = BazAsync();

// 全てのタスクが完了するまで待機する
await Task.WhenAll(taskA, taskB, taskC);

// いずれかのタスクが完了するまで待機する
await Task.WhenAny(taskA, taskB, taskC);

このようにasync/awaitを用いることで煩雑なコールバックを完全に回避し、同期処理とほとんど変わらないコードで非同期処理を書くことが可能になります。async/awaitの強力さの一端を理解していただけたでしょうか。

[余談] Unityとコルーチン

非同期処理の項目でも挙げましたが、Unityには独自に非同期処理を行うための仕組みとして「コルーチン」が存在します。このコルーチンとasync/awaitの違いについても触れておきましょう。

Unityにおけるコルーチンとは、C#の言語機能であるイテレータを用いて実装された非同期処理のための仕組みです。IEnumeratorを戻り値にし、yield returnを用いて処理の待機を行います。また、コルーチンを開始する際には通常のメソッド呼び出しではなくStartCoroutineを使用する必要があります。

// IEnumeratorを戻り値に持つ
IEnumerator FooCoroutine()
{
    // 次のフレームまで待機
    yield return null;

    // 1秒間待機
    yield return new WaitForSeconds(1f);
}

// StartCoroutineでコルーチンを起動
StartCoroutine(FooCoroutine());

StartCoroutineでスケジュールされたIEnumeratorはフレーム毎にMoveNextが呼び出され、それによってイテレータの処理が進んでいくことになります。また、ライフタイムは呼び出し元のGameObjectに紐づけられ、GameObjectがDestroyされると同時にコルーチンも停止します。

詳細は後にasync/awaitの動作を説明する際に述べますが、async/awaitとイテレータの内部的な動作は非常によく似ています。上のコードだけを見てみても、TaskがIEnumeratorに、awaitがyield returnに変わっていること以外はasyncメソッドと似た見た目をしています。

ただし、あくまでイテレータは列挙処理用の機能であり、非同期処理を扱うためのものではありません。そのため「try-catchが使用できない」「戻り値を持てない」「複数のコルーチンをまとめて扱う術がない」などの制約がかかります。また、イテレータ自体のアロケーションやUnityエンジンとC#スクリプト間のオーバーヘッドなど、パフォーマンスの面でも難があります。

流石にコールバックよりは扱いやすいですが、C# 7.0を使用できる現在はUniTaskを用いてasync/awaitで非同期処理を扱うやり方が一般的でしょう。

キャンセル処理

何らかの非同期処理を行う場合、実行後にタスクをキャンセルしたい場面はよくあります。タスクのキャンセル処理は慣れるまで少々難しいですが、async/awaitを使いこなす上で必須となるため、是非とも扱えるようになっていきましょう。

CancellationToken

C#では非同期メソッドのキャンセル処理を扱うためのものとしてCanellationTokenを使用します。非同期メソッドを定義する際には、引数の最後でCancellationTokenを渡すのが一般的です。

// 引数の最後でCancellationTokenを渡す
async Task FooAsync(int a, int b, CancellationToken cancellationToken)
{
   ...
}

内部で別の非同期メソッドを呼び出す際には、渡されたCancellationTokenを再び渡してあげればOKです。こうしてCancellationTokenを伝搬させていくことによりキャンセル処理を実現します。

現代では多くの.NET向けフレームワークがasync/awaitを用いた非同期処理を念頭に置いて設計されているため、エントリーポイントの関数などでCancellationTokenが渡されることが多いです。そうではなく自身でCancellationTokenを作成したい場合はCancellationTokenSourceを利用します。

// CancellationTokenSourceを作成
var cts = new CancellationTokenSource();

// 作成したCancellationTokenSourceからCancelllationTokenを取得して渡す
await FooAsync(1, 2, cts.Token);

// キャンセル時にはCancelを呼ぶ
cts.Cancel();

// CancelAfterで一定時間後にキャンセルすることも可能
// cts.CancelAfter(1000);

// 使用後はDisposeを呼ぶ
cts.Dispose();

CancellationTokenの合成

外部からCancellationTokenを渡すことが非同期メソッドにおけるキャンセル処理の基本ですが、渡されたCancellationTokenを内部のCancellationTokenSourceと併用したい場面もあります。

CancellationTokenSource.CreateLinkedSource()を使用することで、複数のCancellationTokenを合成したCancellationTokenSourceを作成できます。これによって作成されたCancellationTokenSourceは、元となるCancellationTokenのいずれかがキャンセルされるとキャンセル扱いになります。

var cts1 = new CancellationTokenSource();
var cts2 = new CancellationTokenSource();

// 2つのCancellationTokenを合成したCancellationTokenSourceを作る
var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts1.Token, cts2.Token);

// 2つのうち1つのTokenをキャンセルする
cts1.Cancel();

// 作成したCancellationTokenSourceもキャンセルされる (trueが表示される)
Console.WriteLine(linkedCts.IsCancellationRequested);

OperationCanceledException

キャンセルが発火された際には、OperationCanceledExceptionという特殊な例外が発生します。キャンセル処理のハンドリングを行う際にはtry-catchを使用します。

try
{
   // awaitで非同期メソッドを待機
   await FooAsync(cancellationToken);
}
catch (OperationCanceledException ex) // キャンセル時にOperationCanceledExceptionがthrowされる
{
   // ここでキャンセル時の処理を行う
   ...
}

asyncメソッド内で例外処理を行う場合、キャンセル時の例外はcatchせずにスルーしたい場面はよくあります。その場合はOperationCanceledExceptionだけを除外したcatch式を書くと良いでしょう。

try
{
   await FooAsync(cancellationToken);
}
// OperationCanceledExceptionは既知の例外としてスルー
catch (Exception ex) when (!(ex is OperationCanceledException))
{
    Console.WriteLine(ex);
}

キャンセル処理が例外(OperationCanceledException)という点は初見では分かりづらいですが、この辺りの処理は定型パターンなので慣れていきましょう。

CancellationToken.ThrowIfCancellationRequested

対象のasyncメソッドにCancellationTokenを渡せない場合などには、CancellationToken.ThrowIfCancellationRequested()を呼ぶことでキャンセルのチェックとOperationCanceledExceptionのthrowを行うことができます。

async Task FooAsync(CancellationToken cancellationToken)
{
    await BarAsync();

    // ここでキャンセル状態になっていたらOperationCanceledExceptionをthrow
    cancellationToken.ThrowIfCancellationRequested();
    
    await BazAsync();
}

ただし、これをユーザーコード側で行うことはあまりありません。基本的には呼び出すasyncメソッドにCancellationTokenを渡してあげるだけで十分です。

async/awaitとスレッド

続いてはasync/awaitにおけるスレッドの扱いについて。最初の方にも説明した通り、async/await自体には実行スレッドを戻す機能はありません。await後の処理が別のスレッドで行われる可能性もあります。

しかし、GUIアプリケーションのUIスレッドでasync/awaitを利用する際など、await後も元のスレッドで動作してくれないと困る場面もあります。そこでawait後のスレッド移動を制御するためのものとしてSynchronizationContextが用意されています。

SynchronizationContext

SynchronizationContextは複数スレッド間の同期の制御を抽象化するためのオブジェクトです。SynchronizationContext自体は基本的に抽象化のためのclassであり、フレームワーク毎に様々な実装が用意されています。

SynchronizationContextのインスタンスはスレッド毎にnullまたは1つだけ存在し、現在のスレッドのSynchronizationContextはSynchronizationContext.Currentから取得が可能になっています。

Taskを用いてasyncメソッドを定義した場合、await後の処理はSynchronizationContext.Currentを経由して行われることになります。デフォルトではSynchronizationContext.Currentはnullであるため、await後は異なるスレッドで処理が行われる可能性があります。

// SynchronizationContext.Currentがnullの場合、await後のスレッドは同じにはならない

Console.WriteLine(Thread.CurrentThread.ManagedThreadId); // 1
await Task.Run(() => { });
Console.WriteLine(Thread.CurrentThread.ManagedThreadId); // 4

一方フレームワーク側でSynchronizationContextが設定されている環境ではawait後に実行を元のスレッドに戻してくれます。例えばUnityではUnitySynchronizationContextが使用されるため、await後もメインスレッド上で処理が続行します。

// メインスレッド
Debug.Log(Thread.CurrentThread.ManagedThreadId);
        
await Task.Run(() =>
{
    // ここは別スレッドで実行される
    Debug.Log(Thread.CurrentThread.ManagedThreadId);
});
        
// await後はメインスレッドに戻ってくる
Debug.Log(Thread.CurrentThread.ManagedThreadId);

ConfigureAwait

SynchronizationContextを経由することでスレッドを戻すことが可能になりますが、デッドロックなどの問題から元のスレッドへ戻って欲しくない場面もあります。ConfigureAwait(false)を使用することで、コンテキストを保持せずにawaitを実行することができます。

// SynchronizationContextをキャプチャしない
await Task.Run(() => { ... }).ConfigureAwait(false);
        
// await後も元のスレッドには戻ってこない
...

Task.Wait()とTask.Resultの危険性

TaskにはWait()やResultなど、実行を同期的に待機するメソッド/プロパティが用意されています。

// 同期的に待機
FooAsync().Wait();

// 同期的に待機し結果を取得
var result = BarAsync().Result;

しかし、これらは基本的に使用すべきではありません。WaitやResultはスレッドをブロックするため、もしFooAsyncやBarAsyncがawaitを含み、かつSynchronizationContextが設定されている場合、await後に元スレッドに戻ることができずデッドロックが発生します。

FooAsync().Wait();
BarAsync().Wait();

async Task FooAsync()
{
    await Task.Run(() => { ... });
    // await後に元のスレッドに戻る場合、Waitでブロックされているためデッドロックが発生
}

async Task BarAsync()
{
    await Task.Run(() => { ... }).ConfigureAwait(false);
    // await後に元のスレッドに戻らないため、デッドロックは起こらない
}

これはConfigureAwait(false)を用いることで回避が可能ですが、そもそもasyncメソッドを同期的に待機すべきではありません。可能な限りawaitで待機を行うようにしましょう。

TaskCompletionSource

Taskとasync/awaitを用いた非同期処理は非常に便利ですが、Task以外にもイベントなどを待機したい場面もあります。TaskCompletionSourceを用いることで、任意のタイミングで完了するTask/Task<T>を作成することが可能になります。

// 新たなTaskCompletionSourceを作成
var tcs = new TaskCompletionSource<int>();

// Taskを待機
await tcs.Task;

// 任意のタイミングでSetResultを呼ぶことで、結果を設定しTaskを完了させる
tcs.SetResult(10);

// キャンセルや失敗の場合は以下を呼び出す
tcs.SetCanceled(cancellationToken);
tcs.SetException(ex);

TaskCompletionSourceはコールバックで完了を通知するAPIをTaskに変換する用途でよく利用されます。以下のコードはAction<T>を引数にとるメソッドをTaskに変換し、awaitで待機する処理の例を示したものです。

// コールバックで完了を通知するメソッドがあったとする
void Foo(Action<FooResult> callback) { ... } 

// TaskCompletionSourceを作成
var tcs = new TaskCompletionSource<FooResult>();

// コールバック内でSetResultを呼びTaskを完了させる
Foo(result =>
{
   tcs.SetResult(result);
})

// awaitで待機
await tcs.Task;

ValueTask / ValueTask<T>

C#でasync/awaitが登場した当初は、asyncメソッドを定義する際には必ず戻り値をTask/Task<T>(またはvoid)にする必要がありました。

// 戻り値は必ずTask/Task<T>
async Task<int> FooAsync(CancellationToken cancellationToken = default)
{
   var x = await BarAsync(cancellationToken);
   var y = await BazAsync(cancellationToken);
   return x + y;
}

しかしTaskはclassであるため、asyncメソッドを呼び出す度にアロケーションが生じます。局所的に使用する分には大した問題ではありませんでしたが、.NET全体でasync/awaitが使われるようになるにつれて、Taskのアロケーションによるパフォーマンスの低下が目立つようになってきました。

そこでasyncメソッドのアロケーションを削減するため、値型のTaskである「ValueTask」が導入されました。ValueTask<T>はTまたはTask<T>をラップする構造体として登場し、Taskが不要な同期処理でのアロケーションを回避することに成功しました。

この説明では利点がわかりづらいので、実際にコードを見てみましょう。以下のような場合、ValueTaskを使用することで同期処理におけるTaskのアロケーションを回避することができます。

static async ValueTask<bool> FooAsync(float x)
{
    if (x == 0f)
    {
        // ここは非同期処理であるためTaskが必要
        await Task.Delay(100);
        return true;
    }

    // ここを通過する場合、一度もawaitを行わない(同期処理である)ためTaskは不要
    // そのためTaskのインスタンスは生成されない
    return false;
}

ValueTaskの制約

async/awaitのパフォーマンス向上を果たす上で重要な役割を担うValueTaskですが、Taskとは異なり、その利用にはいくつかの制約があります。

The following operations should never be performed on a ValueTask instance:

・Awaiting the instance multiple times.
・Calling AsTask multiple times.
・Using .Result or .GetAwaiter().GetResult() when the operation hasn’t yet completed, or using them multiple times.
・Using more than one of these techniques to consume the instance.

If you do any of the above, the results are undefined.

詳細はIValueTaskSourceの項目で説明しますが、ValueTaskは基本的に再利用ができないように設計されています。そのため、ValueTaskのインスタンスに対して「二度以上awaitする」「AsTaskを複数回呼ぶ」「.Resultを複数回使用する」などの操作を行なってはいけません。また、完了してないValueTaskに対する.Resultなどの呼び出しを行うことはできず、awaitで待機する必要があります。

IValueTaskSource

先ほど説明したように、初期のValueTaskは単にT | Task<T>をラップする構造体でした。しかし、さらにパフォーマンスを追求する上で、非同期処理におけるTask<T>のアロケーションすらも無視した高性能な実装を叩きこみたいという需要が生まれました。このような状況に対応するため生まれたのがIValueTaskSource<T>です。

public interface IValueTaskSource
{
    ValueTaskSourceStatus GetStatus(short token);
    void OnCompleted(Action<object?> continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags);
    void GetResult(short token);
}

public interface IValueTaskSource<out TResult>
{
    ValueTaskSourceStatus GetStatus(short token);
    void OnCompleted(Action<object?> continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags);
    TResult GetResult(short token);
}

定義のポイントは引数にshort型のtokenを要求する点です。IValueTaskSourceはプーリングによってインスタンスを再利用することを念頭に設計されており、渡されたTokenによってValueTaskが有効かどうかを判別することができます。前項ではValueTaskの再利用が禁止されていると述べましたが、それは内部のIValueTaskSourceがプーリングされる可能性があるからです。

こうしてIValueTaskSourceが導入されたため、現在のValueTask<T>はT | Task<T> | IValueTaskSource<T>をラップする構造体になっています。これによってシナリオ毎に特化したIValueTaskSourceを差し込めるようになり、ハイパフォーマンスなasync/awaitを実現することが可能になりました。

ManualResetValueTaskSourceCore

IValueTaskSourceの導入によりValueTaskはさらなるパフォーマンスの向上を果たしました。しかし、IValueTaskSourceは正しく実装することが難しく、下手な実装では逆に遅くなってしまう可能性すらあるため、少々扱いが難しい代物でした。そこで、共通の処理を委譲するための構造体としてManualResetValueTaskSourceCore<T>が追加されました。

以下のようにManualResetValueTaskSourceCoreに処理を委譲することで、簡単にIValueTaskSourceの実装を行うことができます。

class ExampleValueTaskSource<T> : IValueTaskSource<T>, IValueTaskSource
{
    // プーリングの処理はメインではないため適当、実際は安全性チェックなどを入れたほうが良さげ
    static ObjectPool<ExampleValueTaskSource<T>> pool = new();

    // mutable structであるためreadonlyでマークしてはいけない
    ManualResetValueTaskSourceCore<T> core;

    // あとはManualResetValueTaskSourceCoreに処理を委譲するだけでOK

    public bool RunContinuationsAsynchronously
    {
        get => core.RunContinuationsAsynchronously;
        set => core.RunContinuationsAsynchronously = value;
    }
    public short Version => core.Version;
    public void Reset() => core.Reset();
    public void SetResult(T result) => core.SetResult(result);
    public void SetException(Exception error) => core.SetException(error);

    public T GetResult(short token)
    {
        try
        {
            return core.GetResult(token);
        }
        finally
        {
            // GetResultでプールにインスタンスを返却する
            pool.Return(this);
        }
    }
    
    void IValueTaskSource.GetResult(short token)
    {
        try
        {
            core.GetResult(token);
        }
        finally
        {
            pool.Return(this);
        }
    }

    public ValueTaskSourceStatus GetStatus(short token) => core.GetStatus(token);
    public void OnCompleted(Action<object?> continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) => core.OnCompleted(continuation, state, token, flags);
}

[余談] UniTask

Unityでは専用の非同期ランタイムの実装としてUniTaskというライブラリが広く用いられていますが、このUniTaskの設計はValueTask / IValueTaskSourceが元になっています。

UniTask v2 – Unityのためのゼロアロケーションasync/awaitと非同期LINQ
https://tech.cygames.co.jp/archives/3417/
Cygames Engineers’ Blog

UniTaskでは内部でIUniTaskSourceを使用し、UniTaskのインスタンスをawaitした際に自動でプールへ返却されます。そのため、UniTaskを使用する際にはValueTaskと同様の制約(二度await、完了前の.Result禁止など)がかかります。

また、UniTaskは通常のTaskとは異なりSynchronizationContextを使用しません。これはUnityが基本的にシングルスレッドであり、わざわざスレッドを戻す機構が必要ないためです。このためUniTaskで別スレッドの処理を待機する際には、明示的にUniTask.RunOnThreadPoolなどで実行する必要があります。

UniTaskにはUniTaskCompletionSourceなど、Task/ValueTaskで利用できるユーティリティに対応した機能が多く用意されています。これらは非常に有益であるため、積極的に活用していくと良いでしょう。

非同期ストリーム

Task<T>/ValueTask<T>は単一の結果を待機するための型でしたが、C#8.0で新たに列挙を非同期で行う用途として「非同期ストリーム」が追加されました。

非同期ストリームにおいて中心となるインターフェースはIAsyncEnumerable<T>/IAsyncEnumerator<T>です。これはIEnumerable<T>/IEnumerator<T>の非同期版に相当します。

public interface IAsyncEnumerable<out T>
{
    IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default);
}
 
public interface IAsyncEnumerator<out T> : IAsyncDisposable
{
    T Current { get; }
    ValueTask<bool> MoveNextAsync();
}
 
public interface IAsyncDisposable
{
    ValueTask DisposeAsync();
}

IAsyncEnumerable<T>はawait foreach構文を用いて消費することができます。foreachがawait foreachに、列挙の処理が非同期になった以外はIEnumerable<T>と同じです。

async Task AwaitForeach<T>(IAsyncEnumerable<T> items)
{
    await foreach (var item in items)
    {
        Console.WriteLine(item);
    }
}

また、await foreachの追加に伴いawait using構文も追加されています。これはIDisposableとusing構文の非同期版に相当し、await usingステートメントを抜けた際にIAsyncDisposable.DisposeAsyncを呼び出します。また、await usingによるusing変数宣言も利用可能です。

IAsyncDisposable asyncDisposable;

// usingの非同期版、スコープを抜けた時点でリソースを破棄する
await using (asyncDisposable)
{
    ...
}

// 非同期using変数宣言も利用可能
await using var x = new AsyncDisposable();

非同期イテレータ

C#8.0以前はイテレータとasyncメソッドを同時に使用することはできませんでしたが、await foreachの追加に伴い、イテレータ内のawaitが可能になりました。

// awaitとyield returnを併用可能に
async IAsyncEnumerable<int> AsyncIterator(int count)
{
    for (int i = 0; i < count; i++)
    {
        yield return i;
        await Task.Delay(TimeSpan.FromSeconds(1));
    }
}

// 1秒毎に整数値が流れてくる
await foreach (var i in AsyncIterator(10))
{
    Console.WriteLine(i);
}

挙動に関しては、同期版イテレータがそのまま非同期になったようなイメージです。上のコードは渡されたcountの回数だけ1秒おきに整数値を流します。

また、非同期イテレータにはキャンセル処理用のCancellationTokenを渡すことができます。イテレータの引数にCancellationTokenを追加し、[EnumeratorCancellation]属性を追加すればOKです。

using System.Runtime.CompilerServices;

async IAsyncEnumerable<int> AsyncIterator(int count, [EnumeratorCancellation] CancellationToken cancellationToken)
{
    for (int i = 0; i < count; i++)
    {
        yield return i;
        await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
    }
}

[余談] IObservable<T> vs IAsyncEnumerable<T>

IAsyncEnumerableの登場によってasync/awaitでも複数の値を非同期的に扱うことが可能になりましたが、.NETには以前から似たような用途で使われているものがありました。そう、Rxです。

IObservable<T>とIAsyncEnumerable<T>は非同期ストリームを扱うことができるという点では似ていますが、最大の違いはObservableが「Push型」であるのに対し、AsyncEnumerableは「Pull型」であるという点です。Observableの場合は送信側が値の発行を行いますが、AsyncEnumerableは受信側が値を要求することで動作します。

Pull型の性質上、AsyncEnumerableはバッファの読み書きなどには向いている一方、Observableの得意とするイベント処理にはあまり向いていません。用途に応じて使い分けていくと良いでしょう。

async/awaitの仕組み

ここまではasync/awaitの使用方法について見てきましたが、ここからは実際にasync/awaitがどのようにして動作しているのかを説明していきます。async/awaitを使用する上で実装の詳細を知る必要があるわけではありませんが、興味深い部分も多いので是非とも見ていきましょう。

注意として、これから紹介するのは.NET Core以降の実装になります。asyncメソッドが生成するコードの実装は.NET Core以降大幅な改善が行われており、パフォーマンスも遥かに向上しています。実装の大まかな流れはあまり変わりませんが、細部の構造や最適化がランタイムのバージョンによって異なる点には注意してください。

また、この項の説明ではasync/awaitとコンパイラの生成コードに関する解説に留め、ExecutionContextなどの内容には触れません。この辺りを詳しく知りたい方は【C#】C# の async/await は実際にどうやって動いているか。や、その元の記事であるHow Async/Await Really Works in C#を読むことをお勧めします。

AsyncStateMachine

簡単なコードを例に見ていきましょう。例えば以下のようなコードがあったとします。

static class Foo
{
    public static async Task FooAsync()
    {
        await Task.Delay(TimeSpan.FromSeconds(1));
        Console.WriteLine("Hello!");
    }
}

このコードはコンパイル時に以下のような形に展開されます。(実際にはILであるものをC#コードとして起こしているため、C#では通常使用できない<, >などの記号がメンバー名に使用されています)

internal static class Foo
{
    [StructLayout(LayoutKind.Auto)]
    [CompilerGenerated]
    private struct <FooAsync>d__0 : IAsyncStateMachine
    {
        public int <>1__state;

        public AsyncTaskMethodBuilder <>t__builder;

        private TaskAwaiter <>u__1;

        private void MoveNext()
        {
            int num = <>1__state;
            try
            {
                TaskAwaiter awaiter;
                if (num != 0)
                {
                    awaiter = Task.Delay(TimeSpan.FromSeconds(1.0)).GetAwaiter();
                    if (!awaiter.IsCompleted)
                    {
                        num = (<>1__state = 0);
                        <>u__1 = awaiter;
                        <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
                        return;
                    }
                }
                else
                {
                    awaiter = <>u__1;
                    <>u__1 = default(TaskAwaiter);
                    num = (<>1__state = -1);
                }
                awaiter.GetResult();
                Console.WriteLine("Hello!");
            }
            catch (Exception exception)
            {
                <>1__state = -2;
                <>t__builder.SetException(exception);
                return;
            }
            
            <>1__state = -2;
            <>t__builder.SetResult();
        }

        void IAsyncStateMachine.MoveNext()
        {
            //ILSpy generated this explicit interface implementation from .override directive in MoveNext
            this.MoveNext();
        }

        [DebuggerHidden]
        private void SetStateMachine([Nullable(1)] IAsyncStateMachine stateMachine)
        {
            <>t__builder.SetStateMachine(stateMachine);
        }

        void IAsyncStateMachine.SetStateMachine([Nullable(1)] IAsyncStateMachine stateMachine)
        {
            //ILSpy generated this explicit interface implementation from .override directive in SetStateMachine
            this.SetStateMachine(stateMachine);
        }
    }

    [NullableContext(1)]
    [AsyncStateMachine(typeof(<FooAsync>d__0))]
    public static Task FooAsync()
    {
        <FooAsync>d__0 stateMachine = default(<FooAsync>d__0);
        stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create();
        stateMachine.<>1__state = -1;
        stateMachine.<>t__builder.Start(ref stateMachine);
        return stateMachine.<>t__builder.Task;
    }
}

ややこしい生成コードが表示されていますが、注目すべきはステートマシンが生成されている点です。(このあたりの仕組みはイテレータと非常によく似ています)

// コンパイラはasyncメソッドを駆動するためのステートマシンを生成する
// ステートマシンはDebugビルドではclass, Releaseビルドではstructになる
[StructLayout(LayoutKind.Auto)]
[CompilerGenerated]
private struct <FooAsync>d__0 : IAsyncStateMachine
{
    ...
}

MoveNextの中身はやや難解なコードになっていますが、基本的にはawaitで処理が分割され、MoveNextでstateが1つずつ進むと捉えればOKです。

private void MoveNext()
{
    int num = <>1__state;
    try
    {
        TaskAwaiter awaiter;
        if (num != 0)
        {
            // await Task.Delay(TimeSpan.FromSeconds(1));
            // GetAwaiterでAwaiterを取得する
            awaiter = Task.Delay(TimeSpan.FromSeconds(1.0)).GetAwaiter();

            // Awaiterが完了していない場合は次のMoveNextをUnsafeOnCompletedにセット
            if (!awaiter.IsCompleted)
            {
                num = (<>1__state = 0);
                <>u__1 = awaiter;
                <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
                return;
            }
        }
        else
        {
            awaiter = <>u__1;
            <>u__1 = default(TaskAwaiter);
            num = (<>1__state = -1);
        }

        // 既に完了している場合はそのままGetResultを呼ぶ
        awaiter.GetResult();

        Console.WriteLine("Hello!");
    }
    catch (Exception exception)
    {
        // 例外は失敗扱い、SetExceptionを呼ぶ
        <>1__state = -2;
        <>t__builder.SetException(exception);
        return;
    }
    
    // 完了した場合はSetResultを呼ぶ
    <>1__state = -2;
    <>t__builder.SetResult();
}

awaitの部分ではGetAwaiterでAwaiterを取得し、IsCompletedがtrueならそのままGetResultを呼び、falseなら自身のMoveNextをUnsafeOnCompletedにセットします。これはawaitで待機している処理が完了した際に呼び出されます。

そして、asyncメソッドの中身ではステートマシンの初期化と起動を行っています。

[AsyncStateMachine(typeof(<FooAsync>d__0))]
public static Task FooAsync()
{
    // ステートマシンを初期化する
    <FooAsync>d__0 stateMachine = default(<FooAsync>d__0);

    // AsyncMethodBuilderを設定 (Task型ではAsyncTaskMethodBuilderが使用される)
    stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create();

    // 開始時のstateを-1に設定
    stateMachine.<>1__state = -1;

    // ステートマシンを開始する
    stateMachine.<>t__builder.Start(ref stateMachine);

    // Taskを返す
    return stateMachine.<>t__builder.Task;
}

AsyncMethodBuilder

AsyncStateMachineのコードでは、一部の処理にAsyncMethodBuilderが用いられていました。AsyncMethodBuilderはTask/ValueTaskなどの戻り値の型と1:1で対応するBuilderであり、Task/ValueTask等の戻り値の生成、後続処理の登録、タスクの状態の設定などを担います。

例としてTask<T>のAsyncMethodBuilderであるAsyncTaskMethodBuilder<T>の定義を見てみましょう。

namespace System.Runtime.CompilerServices;

public struct AsyncTaskMethodBuilder<TResult>
{
    public static AsyncTaskMethodBuilder<TResult> Create() => default;

    public Task<TResult> Task
    {
        get { ... }
    }

    [DebuggerStepThrough]
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine
    {
        ...
    }

    public void SetStateMachine(IAsyncStateMachine stateMachine) { ... }

    public void AwaitOnCompleted<TAwaiter, TStateMachine>(
        ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : INotifyCompletion
        where TStateMachine : IAsyncStateMachine
    {
        ...
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(
        ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : ICriticalNotifyCompletion
        where TStateMachine : IAsyncStateMachine
    {
        ...
    }

    public void SetResult(TResult result) { ... }

    public void SetException(Exception exception) { ... }
}

実装まで記述すると長くなるので省略しますが、開始時にStart、後続処理の登録にAwaitOnCompleted/AwaitUnsafeOnCompleted、結果設定時にSetResult/SetExceptionを呼ぶ、といった流れになっていることが理解できれば十分です。

このAsyncMethodBuilderとasyncメソッド毎に生成されるAsyncStateMachineの2つが合わさることで、asyncメソッドの基本的な動作は実現されています。

Awaitableパターン

C#のasync/awaitでは、awaitできる型はTaskやValueTaskに限定されるわけではなく、ある条件さえ満たせばどんな型でもawaitで待機することが可能になっています。その条件がAwaitableパターンの実装です。

// 適当なawaitさせたい型
class FooAwatable
{
    // Awaiterを返すGetAwaiterメソッドを持つ
    public FooAwaiter GetAwaiter() { ... }
}

static class FooExtensions
{
    // インスタンスメソッドではなく拡張メソッドでもOK
    public static FooAwaiter GetAwaiter(this FooAwaitable foo) { ... }
}

Awaitableパターンを実装するには、awaitさせたい型に「GetAwaiter」メソッドを実装する必要があります。GetAwaiterはGetEnumeratorと同じくダックタイピングであるため、メソッド名さえ一致していれば良く、特定のインターフェースを実装する必要はありません(なんなら拡張メソッドでもOKです)。また、戻り値のAwaiterも任意の型で構いません。

一方、戻り値となるAwaiterは以下の条件を満たす必要があります。

  • IsCompletedプロパティを持つ
  • INotifyCompletionインターフェースを実装する
  • GetResultメソッドを持つ
public struct FooAwaiter : INotifyCompletion
{
    // タスクが完了したかどうかを返す
    public bool IsCompleted { get { ... } }

    // 完了時の継続処理を登録する
    public void OnCompleted(Action continuation) { ... }

    // 結果を取得して返す
    // 戻り値は任意の型 (voidでも可)
    public Foo GetResult() { ... }
}

ただし、AwaiterがICriticalNotifyCompletionを実装している場合、OnCompleteの代わりにUnsafeOnCompleteを代わりに呼び出します。

OnComplete / UnsafeOnCompleteは両方とも継続処理の登録に使用されるメソッドですが、わざわざ2つのメソッドが存在する理由はコードアクセスセキュリティに関する歴史的経緯です(参考: https://stackoverflow.com/questions/65529509/what-is-icriticalnotifycompletion-for)。
参考のStackOverFlowの回答にもある通り、実際にAwaiterを定義する場面では可能な限りICriticalNotifyCompletionを実装する方が良いでしょう。

試しにawait可能な型を作成してみましょう。

// Awaitableパターンを実装したクラス
public class Foo
{
    public Awaiter GetAwaiter()
    {
        return new Awaiter();
    }

    // とりあえず今回はINotifyCompletionの実装で
    public struct Awaiter : INotifyCompletion
    {
        // await時点では完了していないことにするためfalseを返す
        public bool IsCompleted => false;

        // 継続処理をそのまま呼び出すだけ
        public void OnCompleted(Action continuation)
        {
            continuation();
        }

        // awaitの結果として"Foo!"という文字列を返す
        public string GetResult() { return "Foo!"; }
    }
}

// Fooのインスタンスを作成
var foo = new Foo();

// awaitで待機できる
var result = await foo;

// "Foo!"がConsoleに表示される
Console.WriteLine(result);

このようにAwaitableパターンを実装することで任意の型をawaitできるようになります。

Task-like

Awaitableパターンを実装することで任意の型をawait可能にすることができましたが、C#ではさらに任意の型をasyncメソッドの戻り値にすることが可能になっています。

独自の型をasyncメソッドの戻り値にするには、対応するAsyncMethodBuilderを定義し、戻り値にしたい型を[AsyncMethodBuilder(Type)]属性でマークする必要があります。

// asyncの戻り値にしたい型
// AsyncMethodBuilder属性でマークし、定義したBuilderを引数に指定する
[AsyncMethodBuilder(typeof(AsyncMyTaskMethodBuilder))]
public struct MyTask { ... }

// AsyncMethodBuilderを実装する
public struct AsyncMyTaskMethodBuilder
{
    public static AsyncMyTaskMethodBuilder 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 MyTask Task
    {
        get { ... }
    }

    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
    {
        ...
    }
}

AsyncMethodBuilderの定義もダックタイピングであり、上のAsyncMyTaskMethodBuilderに定義されているメソッドがあれば動作します。

あとはTaskのようにasyncの戻り値に定義した型を指定することで、asyncメソッドの動作を独自のAsyncMethodBuilderの実装を置き換えることが可能になります。

async MyTask FooAsync()
{
    await Task.Delay(TimeSpan.FromSeconds(1.0));
}

ただし、このTask-likeの機能はほとんどValueTaskのために追加されたようなものであり、実際に活用されることはほとんどありません。基本的には.NET内部やライブラリ開発者用の機能でしょう。(活用例としてはUniTaskやUnityのAwaitableクラスはこれを用いてUnityに最適化されたasync/awaitを実現しています)

PoolingAsyncValueTaskMethodBuilder

.NET 6以降では、通常のValueTask用のAsyncValueTaskMethodBuilderの他にPoolingAsyncValueTaskMethodBuilderというBuilderが用意されています。これは生成するAsyncStateMachineをキャッシュして使い回す処理を行うAsyncMethodBuilderで、これを使用することでゼロアロケーションなasync呼び出しを実現することができます。

asyncメソッドに使用するAsyncMethodBuilderは、先ほども登場した[AsyncMethodBuilder(Type)]属性を用いてメソッド単位で差し替えることも可能です。この属性をメソッドに付加することによりPoolingAsyncValueTaskMethodBuilderを使用することができます。

// PoolingAsyncValueTaskMethodBuilderを利用するように変更する
// asyncメソッド呼び出しのアロケーションが問題となる場面での最適化に便利
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]
async ValueTask<int> FooAsync()
{
    for (int i = 0; i < 100; i++)
    {
        await Task.Delay(TimeSpan.FromSeconds(1));
    }
}

まとめ

今回はC#のasync/awaitについての内容でしたが、いかがだったでしょうか。async/awaitは強力な言語機能であり、複雑な非同期処理を簡単に記述することが可能になります。非同期処理の扱い方を理解し、async/awaitを使いこなせるようになっていきましょう。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です