async/awaitで例外処理をするには?[C#/VB]:.NET TIPS
async/awaitキーワードを利用することで、非同期処理を簡潔に記述できる。ただし、それらをtry〜catch文で例外処理する際には注意すべき点もある。
async/awaitキーワードは、Visual Studio 2012+.NET Framework 4.5から利用可能になった、非同期処理の糖衣構文である。async/awaitのコードは、一般的にはTPL(タスク並列ライブラリ)を使ったコードに展開される(TPLでなくともGetAwaiterメソッドを実装していればawaitできる)。
async/awaitとTPLによって非同期処理が簡潔に書けるようになり、非同期処理が身近なものになった。とはいうものの、非同期処理に特有の注意点はある。本稿では、awaitを使う方法を中心に、TPLをそのまま使って待機する方法も含めて、例外をキャッチする方法について解説する。
特定のトピックをすぐに知りたいという方は以下のリンクを活用してほしい。
なお、本稿に掲載したサンプルコードをそのまま試すにはVisual Studio 2017(15.3以降)が必要である。サンプルコードはコンソールアプリの一部であり、コードの冒頭に以下の宣言が必要となる。
using System;
using System.Threading.Tasks;
using static System.Console;
Imports System.Console
本稿で使う非同期メソッド
最初に、以降のサンプルコードから呼び出す非同期メソッドを掲載しておこう(次のコード)。
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
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
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
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
タスクを返さない非同期メソッド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
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すれば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
ここではキャッチできた例外が何であるかを示すために変数exを定義しているが、実際には不要なので(foreeachループでは変数exを使っていない)、冒頭の「POINT」に示したようにただ「catch」とだけ書けばよい。
なお、この方法ではリスローできないので注意してほしい。リスローすると例外オブジェクトexを再送出することになるが、それは発生した例外のうちの1つだけなのだ。
まとめ
awaitしたとき、普通にtry〜catchすれば非同期メソッドで発生した例外もキャッチできる。ただし、複数のタスクを並列実行する場合に、発生した全ての例外を知るには工夫が必要になる。
利用可能バージョン:Visual Studio 2012以降
カテゴリ:C# 処理対象:言語構文
カテゴリ:Visual Basic .NET 処理対象:言語構文
カテゴリ:クラスライブラリ 処理対象:非同期処理
関連TIPS:適切に処理されなかった例外をキャッチするには?
関連TIPS:構文:キャッチした例外をリスローするには?[C#/VB]
関連TIPS:WPF:例外をまとめてトラップするには?[C#/VB]
関連TIPS:C# 7のローカル関数の使いどころとは?
関連TIPS:非同期:awaitを含むコードをロックするには?(SemaphoreSlim編)
関連TIPS:非同期:awaitを含むコードをロックするには?(AsyncLock編)
関連TIPS:ループをParallelクラスで並列処理にするには?[C#/VB]
関連TIPS:WPF/Windowsフォーム:時間のかかる処理をバックグラウンドで実行するには?(async/await編)[C#/VB]
関連TIPS:構文:クラス名を書かずに静的メソッドを呼び出すには?[C# 6.0]
関連TIPS:VB.NETでクラス名を省略してメソッドや定数を利用するには?
関連TIPS:数値を右詰めや0埋めで文字列化するには?[C#、VB]
関連TIPS:Visual Studioでコンソール・アプリケーションのデバッグ実行時にコマンド・プロンプトを閉じないようにするには?
Copyright© Digital Advantage Corp. All Rights Reserved.