概要
C#のeventは割と基本的な機能なので、気軽に使っていると思います。しかしawaitと組み合わせたら、意外なところで罠にはまったので、気をつけましょうという事で紹介です。
最初に結論まとめ
Taskを返すタイプのeventをawaitで待った場合、eventに登録されたデリゲート全ての完了を待たず、1つでも完了したら処理が進んでしまいます。これはおそらく、意図と違う動きになっていると思います。気をつけましょう。
良く考えてみれば当たり前の動きですが、見た目には次のような感じで普通に見えるので、コードをぱっと見ただけだとなかなか気付きませんでした。
event Func<Task> action1Async; await action1Async();
詳しく紹介
Taskを返すタイプのeventとawaitを組み合わせた場合、一見、eventに登録されたデリゲート全ての完了を待機しているように見えます。しかし、実際には1つしか待機していない。という話です。
C#のeventは、Observerパターン的なものなので、複数のObserverを登録できます。複数の登録がある場合は、Invoke()を呼び出した時に、登録されたメソッドが同期で順番に呼ばれ、全ての処理が終わったら制御を返します。こんな感じです。
event Action action1; action1 += () => Console.Write("1"); action1 += () => Console.Write("2"); //この状態でInvokeを呼び出すと action1(); Console.Write("3"); //必ず"1"と"2"が出力されてから制御を返すので、コンソール出力は //>123 //になる
※eventは本来はイベント登録とInvoke()を行える場所が異なりますが、本記事ではコードをシンプルにするため同じ場所から呼び出すコードを書きます
そのため、「Invoke()が制御を返したら、全ての登録済みデリゲートの処理は終わっている」と期待して使うと思います。
しかし、その感覚のままでawaitと組み合わせると、書き方によってはデリゲートの処理が終わる前に制御が返ってきてしまいます。こんな感じの場合です。
event Func<Task> action1Async; async Task Func1() { await Task.Delay(3000); Console.Write("1"); } async Task Func2() { Console.Write("2"); await Task.CompletedTask; } action1Async += Func1; action1Async += Func2; //ここで、action1Asyncの完了をawaitしようとすると・・・ await action1Async(); Console.Write("3"); //"1"が出力されるより前に制御が返ってくるため、コンソール出力がこうなる //>23
完了をawaitで待機しているはずなのに、なぜ完了前に制御が返ってきてしまうのでしょうか?
実は、よく考えてみると当然の動きだったりします。eventとawaitという便利な構文によって見えづらくなっているだけで、このコードはデリゲート1つ分の完了しか待機していません。ポイントは、eventをInvoke()した場合、全てのデリゲートの実行が終わった後に、「戻り値を1つだけ返す」という動作です。
このケースではFunc1とFunc2がasyncなので、「全てのデリゲートの実行が終わった」時点では、Taskを作成しただけであって処理は完了していません。返ってきたTask全ての完了を待つ必要があります。しかし、eventのInvokeは戻り値を1つしか返しません。つまりawait action1Async();
の部分を分解すると、次のようなコードになっていることになります。
Task result = Func1(); result = Func2(); await result;
Func1が返したTaskは待機せずに放置されています。当然、そちらで例外が発生したとしてもcatchできません。
このように考えていくと当たり前なのですが、 await action1Async();
という記法があまりにも正しそうに見えるので、うっかり見逃してしまいがちです。気をつけましょう・・・。