C#の非同期処理について調べていると、こんな説明を見かけることがあります。
async voidは避けた方がいい。
なるほど。
では、WPFでボタンのクリックイベントを書いてみます。
private async void Button_Click(object sender, RoutedEventArgs e)
{
await SaveAsync();
}
……使ってるやん。
async voidは避けた方がいい。
でも、イベントハンドラでは普通に使われています。
では、何が問題なのでしょうか。
今回はasync Taskとasync voidの違いから、
- なぜ
async voidは避けた方がいいのか - なぜイベントハンドラでは
async voidが使われるのか
を整理してみます。
まずはasync Taskを見てみる
例えば、こんなメソッドがあるとします。
private async Task SaveAsync()
{
await SaveDataAsync();
}
呼び出す側では、
await SaveAsync();
と書けます。
ここで大事なのは、
SaveAsync()がTaskを返している
ということです。
呼び出し側は、そのTaskをawaitすることで、
SaveAsyncを開始
↓
Taskを受け取る
↓
await
↓
SaveAsyncが完了
↓
続きの処理
という形で、非同期処理の完了を待つことができます。
例えば、
await SaveAsync();
MessageBox.Show("保存が完了しました");
と書けば、
保存処理が完了してから
メッセージを表示できます。
ではasync voidにすると?
同じようなメソッドをasync voidにしてみます。
private async void SaveAsync()
{
await SaveDataAsync();
}
呼び出すこと自体はできます。
SaveAsync();
しかし、
await SaveAsync();
とは書けません。
voidなので、呼び出し側にTaskが返ってこないからです。
つまり、呼び出し側から見ると、
SaveAsyncを呼ぶ
↓
???
となります。
SaveAsync()の中では非同期処理が続いています。
しかし呼び出し側には、
- まだ処理中なのか
- いつ処理が終わったのか
を表すTaskがありません。
Taskは「処理を追うためのもの」でもある
Taskというと、
「非同期処理を実行するもの」
というイメージがあるかもしれません。
でも、もう一つ重要な役割があります。
非同期処理の完了や結果を、呼び出し側から追えるようにすることです。
例えば、
Task task = SaveAsync();
await task;
とすれば、呼び出し側はその処理が完了するまで待てます。
Task<T>なら、結果も受け取れます。
string data = await LoadDataAsync();
また、非同期処理中に発生した例外も、Taskを通して呼び出し側へ伝えることができます。
つまりTaskは、
「非同期処理と、その処理を呼び出した側をつなぐもの」
と考えると分かりやすいかもしれません。
async voidでは、そのつながりがなくなる
async voidではTaskが返ってきません。
そのため、呼び出し側から処理の完了をawaitすることができません。
例えば、
SaveAsync();
MessageBox.Show("保存が完了しました");
と書いた場合、
SaveAsync()の非同期処理が完了する前に、
保存が完了しました
と表示される可能性があります。
呼び出し側には、
SaveAsync()の完了を待つためのTaskがない
からです。
これが、通常のメソッドでasync voidを避ける大きな理由の一つです。
例外の扱いも変わる
もう一つ大きな違いがあります。
例外です。
async Taskなら、
try
{
await SaveAsync();
}
catch (Exception ex)
{
// エラー処理
}
のように書くことができます。
SaveAsync()の非同期処理で例外が発生した場合、その例外はTaskを通して伝わるため、awaitしている呼び出し側で扱うことができます。
では、async voidではどうでしょうか。
try
{
SaveAsync();
}
catch (Exception ex)
{
// async void内のawait後に発生した例外は、
// ここでは捕まえられない
}
SaveAsync()を呼び出したあとも、その中の非同期処理は続いている可能性があります。
そしてasync voidでは、呼び出し側にTaskが返ってきません。
そのため、
呼び出し側からawaitして、非同期処理中に発生した例外を受け取ることができません。
ここでは、
async Taskとasync voidでは、例外の扱い方も違う
ということだけ押さえておきます。
では、async voidで発生した例外はどこへ行くのでしょうか。
この話は少し深くなるので、また別の記事で整理してみたいと思います。
テストもしにくくなる
例えば、
public async Task SaveAsync()
{
await SaveDataAsync();
}
なら、テスト側でも、
await target.SaveAsync();
と書けます。
つまり、
処理が終わるまで待ってから結果を確認する
ことができます。
ところが、
public async void SaveAsync()
{
await SaveDataAsync();
}
では、その完了を直接awaitできません。
テスト側から見ると、
「いつ終わったの?」
を追いにくくなります。
これも、通常のメソッドでasync voidを避けた方がいい理由の一つです。
じゃあ、イベントハンドラはなぜasync voidなの?
ここまで読むと、
「じゃあイベントハンドラもasync voidをやめればいいのでは?」
と思うかもしれません。
でも、WPFのイベントハンドラは例えば、
private void Button_Click(object sender, RoutedEventArgs e)
{
}
という形になっています。
イベントハンドラとして求められている戻り値はvoidです。
その中でawaitを使いたければ、
private async void Button_Click(object sender, RoutedEventArgs e)
{
await SaveAsync();
}
となります。
つまり、
イベントハンドラは、もともとTaskを返すように作られていない。
そのため、イベントハンドラではasync voidが使われます。
イベントハンドラでは処理を抱え込みすぎない
イベントハンドラでasync voidを使えるからといって、すべての処理をそこへ書く必要はありません。
例えば、
private async void Button_Click(object sender, RoutedEventArgs e)
{
// DBアクセス
// ファイル処理
// 業務ロジック
// エラー処理
// その他いろいろ……
}
と、イベントハンドラがどんどん大きくなってしまうより、
private async void Button_Click(object sender, RoutedEventArgs e)
{
try
{
await SaveAsync();
MessageBox.Show("保存が完了しました");
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
}
private async Task SaveAsync()
{
await SaveDataAsync();
}
のように、
イベントハンドラだけasync voidにして、実際の非同期処理はTaskを返すメソッドへ分ける
ことができます。
こうしておけば、SaveAsync()は別の場所からもawaitできます。
テストもしやすくなります。
そして例外も、SaveAsync()が返したTaskをawaitしているイベントハンドラ側で扱えます。
「async voidは悪い」ではない
ここまで見ると、
async void = 悪
async Task = 正義
という単純な話ではないことが分かります。
async voidには、
呼び出し側がTaskを受け取れない
という特徴があります。
そのため、
- 完了を
awaitできない - 非同期処理の例外を呼び出し側で通常の
awaitと同じように扱えない - 他の非同期処理と組み合わせにくい
- テストしにくい
といった問題が出てきます。
一方、イベントハンドラのように、
もともとTaskを返せない場所
ではasync voidを使う理由があります。
だから、
async voidを使ってはいけない
ではなく、
Taskを返せる普通のメソッドなら、Taskを返す
と考えた方が分かりやすいと思います。
まとめ
async voidについて、
「使ってはいけない」
とだけ覚えると、WPFのイベントハンドラを見た瞬間、
「……使ってるやん。」
となります。
大切なのは、
なぜ避けた方がいいのか
を理解することです。
async Taskなら、呼び出し側はTaskを受け取れます。
そのため、
- 処理の完了を待てる
- 例外を受け取れる
- 他の非同期処理と組み合わせられる
- テストしやすい
というメリットがあります。
一方、async voidでは、そのTaskが返ってきません。
だから通常のメソッドでは、
Taskを返せるなら、Taskを返す。
そしてイベントハンドラのように、戻り値がvoidとして決まっている場所ではasync voidを使う。
asyncだから何となくTaskにするのではなく、
呼び出した側が、その非同期処理をどう扱う必要があるのか。
そこまで見ると、async voidがなぜ避けられているのかも、少し見えてくると思います。
関連記事
async / awaitについて、これまでの記事でも少しずつ整理しています。
まず基本から
👉 処理は順番に動くとは限らない?|新人・初級PG向け 同期・非同期の基本
👉 awaitは待つのに、なぜ画面は止まらないの?|C#のasync / awaitを理解しよう
「asyncなのに?」をもう少し深掘り
👉 非同期処理を書いたのに画面が固まるのはなぜ?|C#のasync / awaitで勘違いしやすいポイント
👉 Task.Runを使えば画面は固まらない? ― とりあえずTask.Runが危ない理由