async/awaitで例外処理をするには?[C#/VB].NET TIPS

async/awaitキーワードを利用することで、非同期処理を簡潔に記述できる。ただし、それらをtry〜catch文で例外処理する際には注意すべき点もある。

» 2018年05月16日 05時00分 公開
[山本康彦BluewaterSoft/Microsoft MVP for Windows Development]
「.NET TIPS」のインデックス

連載「.NET TIPS」

 async/awaitキーワードは、Visual Studio 2012+.NET Framework 4.5から利用可能になった、非同期処理の糖衣構文である。async/awaitのコードは、一般的にはTPL(タスク並列ライブラリ)を使ったコードに展開される(TPLでなくともGetAwaiterメソッドを実装していればawaitできる)。

 async/awaitとTPLによって非同期処理が簡潔に書けるようになり、非同期処理が身近なものになった。とはいうものの、非同期処理に特有の注意点はある。本稿では、awaitを使う方法を中心に、TPLをそのまま使って待機する方法も含めて、例外をキャッチする方法について解説する。

POINT async/awaitの例外処理

async/awaitの例外処理まとめ async/awaitの例外処理まとめ


 特定のトピックをすぐに知りたいという方は以下のリンクを活用してほしい。

 なお、本稿に掲載したサンプルコードをそのまま試すにはVisual Studio 2017(15.3以降)が必要である。サンプルコードはコンソールアプリの一部であり、コードの冒頭に以下の宣言が必要となる。

using System;
using System.Threading.Tasks;
using static System.Console;

Imports System.Console

本稿のサンプルコードに必要な宣言(上:C#、下:VB)

本稿で使う非同期メソッド

 最初に、以降のサンプルコードから呼び出す非同期メソッドを掲載しておこう(次のコード)。

static async Task SampleMethod1Async()
{
  await Task.Delay(200);
  WriteLine("SampleMethod1Asyncで例外を発生");
  throw new InvalidOperationException("SampleMethod1Asyncの例外");
}

static async Task SampleMethod2Async()
{
  await Task.Delay(100);
  WriteLine("SampleMethod2Asyncで例外を発生");
  throw new InvalidOperationException("SampleMethod2Asyncの例外");
}

static async void SampleMethod3Async()
{
  await Task.Delay(100);
  WriteLine("SampleMethod3Asyncで例外を発生");
  throw new InvalidOperationException("SampleMethod3Asyncの例外");
}

Async Function SampleMethod1Async() As Task
  Await Task.Delay(200)
  WriteLine("SampleMethod1Asyncで例外を発生")
  Throw New InvalidOperationException("SampleMethod1Asyncの例外")
End Function

Async Function SampleMethod2Async() As Task
  Await Task.Delay(100)
  WriteLine("SampleMethod2Asyncで例外を発生")
  Throw New InvalidOperationException("SampleMethod2Asyncの例外")
End Function

Async Sub SampleMethod3Async()
  Await Task.Delay(100)
  WriteLine("SampleMethod3Asyncで例外を発生")
  Throw New InvalidOperationException("SampleMethod3Asyncの例外")
End Sub

本稿のサンプルコードから呼び出す非同期メソッド(上:C#、下:VB)
3つの非同期メソッドを用意した。いずれも、別スレッドで一定時間を待機してから例外を発生させるというものだ。
SampleMethod1AsyncメソッドとSampleMethod2Asyncメソッドはほとんど同じだが、例外を発生するまでの時間が異なっている。2つをほぼ同時に走らせると、先にSampleMethod2Asyncメソッドから例外が出てくることになる。
3つ目のSampleMethod3Asyncメソッドは、他とよく似ているが、返り値を持たない。

awaitはそのまま、WaitはAggregateException

 タスクを並列に実行しない場合、話はシンプルだ。普通にtry〜catchすれば、awaitでは想定通りの例外がそのままキャッチされる。TPLのTaskクラス(System.Threading.Tasks名前空間)のWaitメソッドで待機した場合は、タスクで発生した例外がAggregateException例外(System名前空間)にラップされ、それがキャッチされる。

 まず、awaitの例を示す(次のコード)。普通にtry〜catchすることで、予期したInvalidOperationException例外(System名前空間)がそのままキャッチされている。

static async Task Main(string[] args)
{
  try
  {
    await SampleMethod1Async();
    // 出力:SampleMethod1Asyncで例外を発生
    await SampleMethod2Async(); // この行は実行されない
  }
  catch (InvalidOperationException e)
  {
    WriteLine($"{e.GetType().Name} - {e.Message}");
    // 出力:InvalidOperationException - SampleMethod1Asyncの例外
  }
#if DEBUG
  ReadKey();
#endif
}

Sub Main()
  Dim mainTask = Task.Run(
    Async Function()
      Try
        Await SampleMethod1Async()
        ' 出力:SampleMethod1Asyncで例外を発生
        Await SampleMethod2Async() ' この行は実行されない
      Catch e As InvalidOperationException
        WriteLine($"{e.GetType().Name} - {e.Message}")
        ' 出力:InvalidOperationException - SampleMethod1Asyncの例外
      End Try
    End Function)
  mainTask.Wait()
#If DEBUG Then
  ReadKey()
#End If
End Sub

awaitをtry〜catchする例(上:C#、下:VB)
SampleMethod1Asyncメソッドは、非同期実行中にInvalidOperationException例外を発生する。awaitした場合は、そのままInvalidOperationException例外が出てくるので、素直にInvalidOperationException例外をキャッチすればよい。
なお、この最初の例だけは、Mainメソッドの全体を示した。C#の「async Task Main」という書き方は、C# 7.1以降のものだ。Visual Studio 2017でC# 7.1を利用するには、プロジェクトで設定する必要がある(「Dev Basics/Keyword:C# 7.1」参照)。VBではMainメソッドを非同期にできないので、代わりに非同期メソッドを定義して、TaskクラスのRunメソッドを使って実行している。以降のサンプルコードは、このtry〜catchの部分を置き換える分だけを示す(C#は#if〜#endifを除くMainメソッドの中身、VBはAsync Functionの中身)。

 上のサンプルコードではawaitを2回使っているが、その2つのタスクは並列ではなく直列に実行される。つまり、1つ目のタスクが完了してから、2つ目のタスクが始まるのである。そのため、1つ目のタスクの実行中に例外が発生すると、2つ目のタスクは実行されない。なお、finally句を書いた場合であるが、このようにawaitしたり、次の例のようにWaitメソッドを使ったりしてタスクの終了を待機しているときは、タスクが終了してから(あるいは、例外によってcatch句が実行されてから)finally句が実行される。

 次に、TaskクラスのWaitメソッドで待機した場合だ(次のコード)。タスクで発生したInvalidOperationException例外は、そのままの形ではキャッチされない。AggregateException例外にラップされた状態でキャッチされるので、実際に発生した例外を知るために、AggregateException例外のInnerExceptionsプロパティに含まれるExceptionオブジェクトを列挙している。

var task1 = SampleMethod1Async();
try
{
  task1.Wait();
  // 出力:SampleMethod1Asyncで例外を発生
}
catch (AggregateException ae)
{
  WriteLine($"{ae.GetType().Name} - {ae.Message}");
  // 出力:AggregateException - 1 つ以上のエラーが発生しました。

  foreach (Exception e in ae.InnerExceptions)
    WriteLine($"{e.GetType().Name} - {e.Message}");
    // 出力:InvalidOperationException - SampleMethod1Asyncの例外
}

Dim task1 = SampleMethod1Async()
Try
  task1.Wait()
  ' 出力:SampleMethod1Asyncで例外を発生
Catch ae As AggregateException
  WriteLine($"{ae.GetType().Name} - {ae.Message}")
  ' 出力:AggregateException - 1 つ以上のエラーが発生しました。

  For Each e As Exception In ae.InnerExceptions
    WriteLine($"{e.GetType().Name} - {e.Message}")
    ' 出力:InvalidOperationException - SampleMethod1Asyncの例外
  Next
End Try

タスクをWaitする例(上:C#、下:VB)
try〜catchすべきは、タスクの終了を待機している「task1.Wait()」の1行だけである。SampleMethod1Asyncメソッドを呼び出している行では、非同期実行中の例外は発生しない。
TaskクラスのWaitメソッドで待機した場合は、このようにAggregateException例外にラップされて例外が出てくる。InvalidOperationException例外が出てくると思ってそれをキャッチするコードを書いても駄目なので、注意してもらいたい。

 awaitではそのまま例外が出てくる。WaitするとAggregateExceptionになって出てくる。まずはこのことをしっかり押さえておいてほしい。

awaitしないときは?

 awaitもWaitもしないときは、タスクで発生した例外はどうなるのだろうか? 答えは、「try〜catchではキャッチできない」である(次のコード)。

 返り値を持たない非同期メソッドはawaitできない。もしもそのメソッドから例外が送出されると、例外をキャッチできずにプログラムは異常終了してしまう。逆にいうと、返り値を持たない非同期メソッドを書くときは、全ての例外をメソッド内で処理するようにすべきだということである。

AppDomain.CurrentDomain.UnhandledException += (s, e) =>
{
  var ex = e.ExceptionObject as Exception;
  WriteLine($"UnhandledException - {ex.Message}");
  // 出力:UnhandledException - SampleMethod3Asyncの例外
#if DEBUG
  ReadKey();
#endif
  Environment.Exit(1); // プログラム終了
};

try
{
  SampleMethod3Async();
  // 出力:SampleMethod3Asyncで例外を発生
}
catch (Exception e)
{
  WriteLine($"{e.GetType().Name} - {e.Message}");
  // ↑この行は実行されない
}

AddHandler AppDomain.CurrentDomain.UnhandledException,
  Sub(s, e)
    Dim ex = DirectCast(e.ExceptionObject, Exception)
    WriteLine($"UnhandledException - {ex.Message}")
    ' 出力:UnhandledException - SampleMethod3Asyncの例外
#If DEBUG Then
    ReadKey()
#End If
    Environment.Exit(1) ' プログラム終了
  End Sub

Try
  SampleMethod3Async()
  ' 出力:SampleMethod3Asyncで例外を発生
Catch e As Exception
  WriteLine($"{e.GetType().Name} - {e.Message}")
  ' ↑この行は実行されない
End Try

タスクの終了を待機できない場合(上:C#、下:VB)
タスクを返さない非同期メソッドSampleMethod3Asyncで発生した例外は、try〜catchでは捕捉できない。そのような例外はUnhandledExceptionイベントハンドラで捕捉できるものの、そこではもはやプログラムの継続は不可能である。
このサンプルコードには書いていないが、finally句を置いた場合、タスクの終了を待たずにfinally句が実行される。
なお、今回の例はタスクの開始後に例外が出るパターンだが、タスクを開始する前に(つまり、非同期メソッドの冒頭での引数チェックなどで)例外を出すパターンの場合には、C# 7のローカル関数を使って例外を出す部分とタスクの部分を分離するというテクニックがある。そうすれば、タスクを返さない非同期メソッドでも、タスクを開始する前に発生した例外をtry〜catchできる。詳しくは「.NET TIPS:C# 7のローカル関数の使いどころとは?〜asyncメソッドでの事前チェック」を参照していただきたい。

複数のタスクを並列実行したとき、例外の発生を知るには?

 複数のタスクを並列実行する場合は、例外処理で考慮すべきことが増える。

 例外が出たかどうかを知りたいだけなら、Task.WhenAllをawaitするところで普通にtry〜catchすればよい(次のコード)。

 複数のタスクを並列実行したときに全てのタスクの終了を待機するには、それぞれの非同期メソッドが返してきたTaskオブジェクトを引数としてTaskクラスのWhenAllメソッドを呼び出し、そのWhenAllメソッドが返してきたTaskオブジェクトを待機する。そのときawaitを使うと、try〜catchすれば前述したように例外がそのままキャッチできる。ただし、直接キャッチできるのは1つだけである。並列に実行させた複数のタスクから複数の例外が発生したとしても、そのうちの1つしかキャッチできないのだ。それでも、例外が出たかどうかを知りたいだけならば、このようなコードでも十分であろう。

var task1 = SampleMethod1Async();
var task2 = SampleMethod2Async();
try
{
  await Task.WhenAll(task1, task2);
  // 出力:SampleMethod2Asyncで例外を発生
  // 出力:SampleMethod1Asyncで例外を発生
}
catch (InvalidOperationException e)
{
  WriteLine($"{e.GetType().Name} - {e.Message}");
  // 出力:InvalidOperationException - SampleMethod1Asyncの例外
}

Dim task1 = SampleMethod1Async()
Dim task2 = SampleMethod2Async()
Try
  Await Task.WhenAll(task1, task2)
  ' 出力:SampleMethod2Asyncで例外を発生
  ' 出力:SampleMethod1Asyncで例外を発生
Catch e As InvalidOperationException
  WriteLine($"{e.GetType().Name} - {e.Message}")
  ' 出力:InvalidOperationException - SampleMethod1Asyncの例外
End Try

タスクを並列実行し、シンプルにawaitした場合(上:C#、下:VB)
2つの非同期メソッドの終了をTask.WhenAllで待機している。
2つの非同期メソッドはそれぞれ例外を発生させているのだが、直接キャッチできるのはそのうちの1つだけである。
なお、キャッチされるのは最初に開始したタスクからの例外のようであるが、日本マイクロソフトのドキュメントでは「どの例外が再スローされるかを予測することはできません」とされている。

複数のタスクを並列実行したとき、発生した全ての例外を知るには?

 それでは、複数のタスクを並列実行したときに全ての例外を知るには、どうしたらよいだろうか? 2通りの方法がある。

 まず、awaitをやめて、Waitする方法だ(次のコード)。Waitすれば、全ての例外が1つのAggregateException例外にラップされて出てくる。待機しているときにスレッドをブロックしても構わなければ、この方法が簡単だろう。

var task1 = SampleMethod1Async();
var task2 = SampleMethod2Async();
try
{
  Task.WhenAll(task1, task2).Wait();
  // 出力:SampleMethod2Asyncで例外を発生
  // 出力:SampleMethod1Asyncで例外を発生
}
catch (AggregateException ae)
{
  WriteLine($"{ae.GetType().Name} - {ae.Message}");
  // 出力:AggregateException - 1 つ以上のエラーが発生しました。

  foreach (Exception e in ae.InnerExceptions)
    WriteLine($"{e.GetType().Name} - {e.Message}");
    // 出力:InvalidOperationException - SampleMethod1Asyncの例外
    // 出力:InvalidOperationException - SampleMethod2Asyncの例外
}

Dim task1 = SampleMethod1Async()
Dim task2 = SampleMethod2Async()
Try
  Task.WhenAll(task1, task2).Wait()
  ' 出力:SampleMethod2Asyncで例外を発生
  ' 出力:SampleMethod1Asyncで例外を発生
Catch ae As AggregateException
  WriteLine($"{ae.GetType().Name} - {ae.Message}")
  ' 出力:AggregateException - 1 つ以上のエラーが発生しました。

  For Each e As Exception In ae.InnerExceptions
    WriteLine($"{e.GetType().Name} - {e.Message}")
    ' 出力:InvalidOperationException - SampleMethod1Asyncの例外
    ' 出力:InvalidOperationException - SampleMethod2Asyncの例外
  Next
End Try

タスクを並列実行し、Waitメソッドで待機した場合(上:C#、下:VB)
前述したように、WaitすればAggregateException例外としてキャッチされる。そのInnerExceptionsプロパティを列挙すれば、発生した全ての例外が分かる。

 もう1つは、awaitするタスクをローカル変数に保持しておいて、キャッチブロックでそのExceptionプロパティを見る方法だ(次のコード)。TaskオブジェクトのExceptionプロパティはAggregateException例外になっていて、そのInnerExceptionsプロパティを列挙すれば、発生した全ての例外が分かる。

var task1 = SampleMethod1Async();
var task2 = SampleMethod2Async();
var allTasks = Task.WhenAll(task1, task2);
try
{
  await allTasks;
  // 出力:SampleMethod2Asyncで例外を発生
  // 出力:SampleMethod1Asyncで例外を発生
}
catch (Exception ex)
{
  WriteLine($"[ex] {ex.GetType().Name} - {ex.Message}");
  // 出力:[ex] InvalidOperationException - SampleMethod1Asyncの例外

  foreach (Exception e in allTasks.Exception.InnerExceptions)
    WriteLine($"[InnerExceptions] {e.GetType().Name} - {e.Message}");
    // 出力:[InnerExceptions] InvalidOperationException - SampleMethod1Asyncの例外
    // 出力:[InnerExceptions] InvalidOperationException - SampleMethod2Asyncの例外
}

Dim task1 = SampleMethod1Async()
Dim task2 = SampleMethod2Async()
Dim allTasks = Task.WhenAll(task1, task2)
Try
  Await allTasks
  ' 出力:SampleMethod2Asyncで例外を発生
  ' 出力:SampleMethod1Asyncで例外を発生
Catch ex As Exception
  WriteLine($"[ex] {ex.GetType().Name} - {ex.Message}")
  ' 出力:[ex] InvalidOperationException - SampleMethod1Asyncの例外

  For Each e As Exception In allTasks.Exception.InnerExceptions
    WriteLine($"[InnerExceptions] {e.GetType().Name} - {e.Message}")
    ' 出力:[InnerExceptions] InvalidOperationException - SampleMethod1Asyncの例外
    ' 出力:[InnerExceptions] InvalidOperationException - SampleMethod2Asyncの例外
  Next
End Try

タスクを並列実行し、そのタスクを保持するローカル変数をawaitした場合(上:C#、下:VB)
ここではキャッチできた例外が何であるかを示すために変数exを定義しているが、実際には不要なので(foreeachループでは変数exを使っていない)、冒頭の「POINT」に示したようにただ「catch」とだけ書けばよい。
なお、この方法ではリスローできないので注意してほしい。リスローすると例外オブジェクトexを再送出することになるが、それは発生した例外のうちの1つだけなのだ。

まとめ

 awaitしたとき、普通にtry〜catchすれば非同期メソッドで発生した例外もキャッチできる。ただし、複数のタスクを並列実行する場合に、発生した全ての例外を知るには工夫が必要になる。

「.NET TIPS」のインデックス

.NET TIPS

Copyright© Digital Advantage Corp. All Rights Reserved.

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。