1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

async voidはなぜ避けた方がいいの?|イベントハンドラだけ例外と言われる理由

1
Posted at

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が危ない理由

👉 asyncを付けたのに速くならないのはなぜ?

awaitした後はどうなる?

👉 awaitした後、なぜそのままUIを更新できるの?

👉 awaitした後、UIを更新できないことがあるのはなぜ?|ConfigureAwait(false)を理解しよう

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?