非同期処理の完了を待ってみる
【例1】
//非同期メソッドを使った例 void useMethod() { Task task = asyncMethod(); //非同期メソッド呼び出し task.Wait(); //非同期メソッドの完了を待つ Console.WriteLine("2:終了"); } //非同期メソッド async Task asyncMethod() { await Task.Delay(1000); //1秒待つ Console.WriteLine("1:非同期処理終了"); }
useMethodメソッドを呼び出すと、1:、2:の順に出力される。期待どーり!
で、非同期メソッドを、非同期ラムダ(asyncで修飾され内部にawaitを持つラムダ)に書き換えてみる。
【例2】
//非同期ラムダを使った例 void useLambda() { Task task = new Task(async () => { await Task.Delay(1000); //1秒待つ Console.WriteLine("1:非同期処理終了"); }); task.Start(); //非同期ラムダ実行 task.Wait(); //非同期ラムダの完了を待つ Console.WriteLine("2:終了"); }
出力は…
2:終了
1:非同期処理終了
えっ!メソッド本体とラムダ式は同じ内容なのになぜ……と、しらじらしい言い回しはやめて、あっさり結論を。非同期ラムダ版のどこがだめかというと
- 宣言するTaskの型を間違えている
- 待つべきTaskを間違えている
の2点。以下それについて。
1.宣言するTaskの型
例2のラムダ式内の処理を追ってみる
- 呼び出し元の同期コンテキストのままawaitまでを処理
- awaitに到達
- Delay()の非同期処理を始めて、処理を呼び出し元に返す
- Delay()が完了したら、呼び出し元の同期コンテキストでawait以降の処理を始める
というふうになっている。3.が実行された段階で、この非同期ラムダの入ったTaskは完了状態になる。非同期ラムダが最後まで実行されたとき、その完了を他に伝えるためには、非同期ラムダ自身がTaskを返さないといけない。
戻り値を持つデリゲートをTaskにするとTask<T>(Tは戻り値の型)になる。非同期ラムダが戻すのはTaskだから、この場合のTはTaskになる。つまり、非同期ラムダで作ったTaskの完了を待つためにはTask<Task>(入れ子のTask)にしなければならない。
ところで、非同期メソッドの場合と同様、非同期ラムダも、式の戻り値がvoidでもTaskでも、式本体に戻り値は書かない。
例1の非同期メソッドもメソッド本体に戻り値が書かれていないが
async Task asyncMethod()
と、戻り値がTaskであることを宣言しているので「戻り値Taskの非同期メソッド」となる。が、例2の非同期ラムダの場合はどうなるのか?
それは、非同期ラムダを入れるTaskがどう宣言されているかでコンパイラが判断する。この場合は
Task task // 悪い例
と宣言してしまっているので、「戻り値void」(Action<void>)としてコンパイルされ、Fire and forget(討ちっ放し)になる。完了を呼び出し元に伝えることはできない。
正しくは
Task<Task> task // 良い例
と宣言して、「戻り値Task」(Func<Task>)としてコンパイルしてもらわないといけなかった。
ちなみに、非同期ラムダの戻り値がTask<T>の場合(本体に戻り値がある場合)は、Task<T>が更にTaskにくるまれて、Task<Task<T>>になる。
2.待つべきTask
で、こうして非同期ラムダが返してくれたTaskをどう受け取るか。戻ってきた入れ子状態のTask<Task>は
- 外側のTaskはTaskのコンパイラが作ったTask
- 内側のTaskが非同期ラムダの戻り値のTask
なので、非同期ラムダからのTaskを受け取るためには
task.Wait(); // 悪い例(awaitの手前で戻ってきたTaskを受け取る)
ではなく
task.Result.Wait(); // 良い例(非同期ラムダから返されたTaskを受け取る)
が正しい。
まとめ
まとめると、非同期ラムダの完了を待つには
- 非同期ラムダを入れるTaskの型は入れ子のTaskにする
- 戻り値がない場合→ Task<Task>
- 戻り値(型T)がある場合→Task<Task<T>>
- Task自体を待つのではなく、Task.Resultを待つ
ようにしないといけない。
…しかし、戻り値Task<T>型の非同期ラムダを使う場合なら、戻ってきたTを使いたいからそもそも単なるTaskとは宣言しないし、Task<Task<T>>と宣言するまでインテリセンスがうるさく注意してくれる。
でも、戻り値Taskの非同期ラムダの場合はうっかりTaskとしてしまっても、インテリセンスどころかコンパイラもスルーして、「Fire and forget」な非同期処理になってしまう。戻り値Task型のとき(本体に戻り値を書かないとき)は、ほんと気を付けないと…。
まぁ、こうしてひとつ記事も書いたし、俺はもう間違わないと信じてる、信じたい。