概要
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();
という記法があまりにも正しそうに見えるので、うっかり見逃してしまいがちです。気をつけましょう・・・。