入れ子のTask  ―  非同期ラムダの待ちかた

非同期処理の完了を待ってみる

【例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:非同期処理終了

えっ!メソッド本体とラムダ式は同じ内容なのになぜ……と、しらじらしい言い回しはやめて、あっさり結論を。非同期ラムダ版のどこがだめかというと

  1. 宣言するTaskの型を間違えている
  2. 待つべきTaskを間違えている

の2点。以下それについて。

1.宣言するTaskの型

例2のラムダ式内の処理を追ってみる

  1. 呼び出し元の同期コンテキストのままawaitまでを処理
  2. awaitに到達
  3. Delay()の非同期処理を始めて、処理を呼び出し元に返す
  4. 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型のとき(本体に戻り値を書かないとき)は、ほんと気を付けないと…。

まぁ、こうしてひとつ記事も書いたし、俺はもう間違わないと信じてる、信じたい。

参考ページ