TaskとTaskCompletionSource<TResult>

C#で非同期はとりあえずasyncなメソッドをawaitつけて呼んどきゃいい」

これだけではいけないコードを最近よく書いているので、 生のTaskTaskCompletionSource<TResult>について書いてみることにしました。

なお、UniTaskにもUniTaskCompletionSourceがあるので、この記事の内容はUniTaskでも活きるはずです、たぶん…

Task/TaskCompletionSource<TResult> 基礎編

そもそもTaskだとかawaitは「なんかの処理が終わったら続きを呼び出してくれる」機構です。 処理の終わりが戻り値相当の値を伴うこともあれば例外でエラー終了することもあります。

Task/Task<TResult>には、TaskCompletionSource<TResult>という相方クラスがあり 処理の終了は、TaskCompletionSource<TResult>.SetResult(result)TaskCompletionSource<TResult>.SetException(exception)で行い、 終了時に継続して欲しい処理は、Task.ContinueWith(continuation)で行います。

var tcs = new TaskCompletionSource<int>();
Console.WriteLine("#0");

//継続処理を登録する
var task = tcs.Task;
task.ContinueWith(t => Console.WriteLine("#1"), TaskContinuationOptions.ExecuteSynchronously);

Console.WriteLine("#2");

//結果を突っ込むことでTaskの待ちを終わらせ、ContinueWithに登録した継続処理を走らせる
tcs.SetResult(0);
#0
#2
#1

すでに処理が終了した(=Resultをもつ)TaskにContinueWithで継続を登録するとどうなるでしょうか。 これは同期実行されます。

var tcs = new TaskCompletionSource<int>();
Console.WriteLine("#0");

//継続処理を登録する
var task = tcs.Task;
//結果を突っ込むことでTaskの待ちを終わらせる
tcs.SetResult(0);

//終わっているTaskに継続を登録
task.ContinueWith(t => Console.WriteLine("#1"), TaskContinuationOptions.ExecuteSynchronously);
Console.WriteLine("#2");
#0
#1
#2

つまり、Taskに継続を登録するときに、完了しているかどうかを気にする必要はないわけです。

async/awaitを使うと、継続の登録をすごくよしなにやってくれます。 以下のコード例はAsyncStateMachineとかAwaiterとかSyncronizationContextを無視していてものすごく不正確ですが、動作は雰囲気こんな感じ。

このasyncは

async Task HogeAsync(int x)
{
    if (x > 0)
        return;

    await FugaAsync();
    Console.WriteLine("Hoge");
}

こう変換されます。(繰り返しますがすごい不正確ですからね。雰囲気掴みのためのコードです)

Task HogeAsync(int x)
{
    TaskCompletionSource<int> __tcs__ = new TaskCompletionSource<int>();
    if (x > 0)
    {
        __tcs__.SetResult(0);
        return __tcs__.Task;
    }

    FugaAsync().ContinueWith(_ => {
        Console.WriteLine("Hoge");
        __tcs__.SetResult(0);
    });
    return __tcs__.Task;    
}

asyncと書くとTaskCompletionSourceが暗黙のうちに作られて、 それ経由で戻り値や例外が伝搬するのだ、という感覚でOKです。

何もしない非同期メソッドがある場合

async Task NoWait()
{
}

と書くよりも

Task NoWait()
{
    return Task.CompletedTask;
}

と書く方が警告がウザくないです。

同期で値を返す場合は

async Task<int> NoWait()
{
    return 0;
}

と書くよりも

Task<int> NoWait()
{
    return Task.FromResult(0);
}

と書く方が警告がウザくないです。

Task/TaskCompletionSource<TResult> 応用編

コルーチンをTaskに変換してawait可能にする

コルーチンの〆にTaskCompletionSourceでSetResultしてあげるだけですね。簡単

public class Maintainer : MonoBehaviour
{
    async void Start()
    {
        bool sirannoka = true;
        while(sirannoka)
        {
            //メンテが終わるまでまつ。終わるとどうなる?
            await DoMaintenanceAsync();
        }
    }

    Task<string> DoMaintenanceAsync()
    {
        var tcs = new TaskCompletionSource<string>();
        StartCoroutine(DoMaintenanceYield(tcs));
        return tcs.Task;
    }

    IEnumerator DoMaintenanceYield(TaskCompletionSource<string> tcs)
    {
        yield return new WaitForSeconds(60 * 60 * 72);
        tcs.SetResult("メンテが終わる");
    }
}

余談ですが、非同期対応のループって言語の支援が無ければなかなか難しいのですが、 awaitなら簡単に同期っぽく書けますね。これにはコブラさんもにっこり。

Taskの終了をコルーチンでまつ

コルーチンにタスクを渡して状態をチェックします。 Task<TResult>の場合はResultプロパティから値を取れます。

void Start()
{
    var messageTask = DelayedMessage();
    StartCoroutine(WaitYield(messageTask));
}

async Task<string> DelayedMessage()
{
    await Task.Delay(1000 * 60 * 60 * 72);
    return "またせたな";
}

IEnumerator WaitYield(Task<string> task)
{
    while(!task.IsCompleted)
    {
        //Taskが終わるまではフレームスキップ
        yield return null;
    }
    Debug.Log(task.Result); //完了前にResultにアクセスするとスレッド止まるんで注意
}

これは別にコルーチンでなくてUpdateでも構いません。 Unity組み込みの毎フレーム処理と統合するには単にTaskに生えている各種フラグをチェックするだけでいい、ということです。

非同期の結果をキャッシュする

待たされる処理の結果をキャッシュして、待たされるのは一回だけにしたい。

//NGの例
SugoiHeavyItem _cached = null;
async Task<SugoiHeavyItem> LoadOrCached()
{
    if(_cached != null)
    {
        return _cached;
    }

    _cached = await LoadSugoiHeavyItem();
    return _cached;
}

この処理だと、LoadSugoiHeavyItem()が完了するまでの間にLoadOrCachedを複数回呼ばれるといけません。 awaitは時間が空くので、_cachedが埋まるまでに隙があります。awaitを使う場合はこの手の注意が必要です。

修正するには、awaitをやめてTask自体をキャッシュしてしまいます。

Task<SugoiHeavyItem> _cached = null;
Task<SugoiHeavyItem> LoadOrCached()
{
    if(_cached != null)
    {
        return _cached;
    }

    _cached = LoadSugoiHeavyItem();
    return _cached;
}

awaitがなく_cachedが埋まるまで同期処理なので、同じスレッドから呼ばれる限り隙はありません。 マルチスレッドを考慮してlockで囲うのも簡単です。

MessageBrokerのPublish先で長い処理をさせてそれを待つ

MessageBrokerに限らずeventでもなんでもいいですが、たまに欲しくなるんですよね。

//Publish先で複数フレームにまたがる処理をさせてそれをawaitしたいけど、
//Publishが同期メソッドだからawaitできないお
MessageBroker.Publish();

こういう時はTaskCompletionSourceを渡してしまいます。

//呼び出し側
var tcs = new TaskCompletionSource<int>();
MessageBroker.Publish(tcs);
//Publish先でTaskが完了するのを待つ
await tcs.Task;
//呼び出され側
MessageBroker.Register(tcs => StartCoroutine(DoCoroutine(tcs)));

IEnumerator DoCoroutine(TaskCompletionSource<int> tcs)
{
    yield return new WaitForSecondes(3);
    tcs.SetResult(0);
}

まとめ

  • awaitは非常に便利なので、特にループ、分岐、例外処理などスムーズに書ける。言語サポート万歳
  • 何も考えずにawaitするだけだとできないこともあり、それを生のTaskTaskCompletionSourceを触ることで超えられたりする