awaitでスレッドを切り替える

追記: 現在は推奨していません。

awaitでスレッドを切り替えるための簡単なヘルパーメソッドを作ったので紹介します。

実装とサンプルはここにあります: await-for-context-switching.cs

前提: Awaitable パターン

OK: await → UI 処理

まず await が1つだけの非同期メソッドでは、Task に対する await が自動的に同期コンテクスト (SynchronizationContext) を捕捉する機能を用いることで、以下のように簡単にかけます。

public async Task Do()
{
    // Do は UI スレッドで起動されるとする。

    // 重い処理を非同期で実行する。
    var x = await HeavyTask();

    // UI スレッドで返り値を使う。
    UIOperation(x);
}

問題: await → await → UI 操作

しかし await が2回以上ある場合に同様に書くと、必要以上に早く UI スレッドに戻ってしまいます。以下の例では、2つ目の重たい非同期処理である SecondHeavyTask が完了するまで、UI スレッドがブロックされます。

public async Task Do()
{
    // Do は UI スレッドで起動されるとする。

    // 重たい非同期処理
    var x = await HeavyTask();

    // もう1つ重たい非同期処理 (!)
    var y = await SecondHeavyTask(x);

    // ここは UI スレッドに戻って処理したい。
    UIOperation(x, y);
}

これを避けるには、 Task.Run や ContinueWith などを使って、await を1つにまとめる必要があります。

public async Task Do()
{
    var a =
        await Task.Run(async () =>
        {
            var x = await HeavyTask();
            var y = await SecondHeavyTask(x);
            return new { x, y };
        });

    UIOperation(a.x, a.y);
}

インデントが2段階深くなることと、変数を匿名型経由で渡していることが気になります。

冒頭のヘルパーメソッドを使うと次のようにできます。

public async Task Do()
{
    var context = await TaskModule.SwitchToTaskPool();
    var x = await HeavyTask();
    var y = await SecondHeavyTask();
    await context;
    UIOperation(x, y);
}

これには

  • インデントが浅くなった。
  • 同期コンテクストを使うこと (UI 操作の直前で UI スレッドに戻ること) が明確になった。

という利点があります。

仕組み

ヘルパーメソッドの仕組みを簡単に説明しておきます。

継続

await には継続を取り出す機能があります。どういうことかというと、例えば次の「task を await して、その値を使って何か処理をする」コードは:

    var x = await task;
    F(x);

await の時点で task が完了していなかったとすると、次のようなコードと同様の振る舞いになります:

    var awaiter = task.GetAwaiter();
    awaiter.OnCompleted(() =>
    {
        var x = awaiter.GetResult();
        F(x);
    });
    return nextTask;  // ←コンパイラーが生成するタスク

※実際にこのように変換されるわけではありません。雰囲気大事。

ここで awaiter.OnCompleted にラムダ式が渡されていますが、これが継続です。

GetAwaiter メソッド経由で生成される awaiter を自作することで、この継続を好きなように使えます。

SwitchToTaskPool

TaskModule.SwitchToTaskPool は awaitable のインスタンスを生成するだけのメソッドです。ついでにここで同期コンテクストを捕まえています。

awaitable/awaiter の実装は これ です。GetAwaiter が起動されたときにすることが特にないので、awaitable と awaiter を同じインスタンスにしています。先述の通り、この awaitable の OnCompleted メソッドに継続が渡されるわけですが、これは継続をタスクプール上で実行させるために Task.Run に渡します。

public void OnCompleted(Action continuation)
{
    Task.Run(continuation);
}

そのため、 await TaskModule.SwitchToTaskPool より後ろの部分がタスクプールで実行されることになります。

また、await 式の値は awaiter.GetResult から取得されます。 Task<_> の場合はタスクの結果の値がそうです。SwitchToTaskPoolAwaitable の場合は保存しておいた同期コンテクストを返すようになっています。

同期コンテクストを await する

await 式には、Task に限らず GetAwaiter メソッドを提供する任意の値を渡せますが、 GetAwaiter は拡張メソッドでもかまいません。

SynchronizationContext はいかにも await 可能な感じなので、次のように GetAwaiter を生やしています。

public static SynchronizationContextAwaiter GetAwaiter(this SynchronizationContext @this)
{
    return new SynchronizationContextAwaiter(@this);
}

これが生成する SynchronizationContextAwaiter は、先ほどの SwitchToTaskPoolAwaitable とほぼ同じで、継続をタスクプールではなく同期コンテクストに放り込むものです。

関連記事