TaskとTaskCompletionSource<TResult>
「C#で非同期はとりあえずasync
なメソッドをawait
つけて呼んどきゃいい」
これだけではいけないコードを最近よく書いているので、
生のTask
やTaskCompletionSource<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するだけだとできないこともあり、それを生の
Task
やTaskCompletionSource
を触ることで超えられたりする