プログラムを作成するにあたっては、発生し得る例外というものを十分に考慮しなければなりません。しかしながら、非同期処理/並列処理の場合は例外処理がやりにくいのも実情です。特にタスクは呼び出し元スレッドとは非同期に実行されるので、その中で発生した例外は一体「いつ、誰が」捕捉すべきなのか、という問題にぶつかります。今回はこのようにタスク内で発生した例外の扱い方について見ていきます。
例外の捕捉
タスク中で発生し処理されなかった例外は、タスク自身によって捕捉され、コレクションとして保存されます。WaitメソッドかResultプロパティが実行されると、これらのメンバーからSystem.AggregateExceptionがスローされます。タスクが捕捉した例外は、スローされるAggregateExceptionのInnerExceptionsプロパティで取得できます。Try-Catch Sample
- using System;
- using System.Threading.Tasks;
- namespace ConsoleApplication
- {
- class Program
- {
- static void Main()
- {
- var task = Task.Factory.StartNew(() =>
- {
- throw new Exception("Test Exception");
- });
- try
- {
- task.Wait();
- }
- catch (AggregateException exception)
- {
- foreach (var inner in exception.InnerExceptions)
- Console.WriteLine(inner.Message);
- }
- }
- }
- }
- //----- 結果
- /*
- Test Exception
- */
例外の平坦化
前回、タスクは入れ子にしたり親子関係を作ることができると書きました。上記のサンプルでは単一のタスクの例ですが、入れ子になったタスクや子タスクで例外が発生した場合はどうなるでしょうか。ご想像の通り、子タスクからAggregateExceptionがスローされ、それを捕捉した親タスクは、自身がスローするAggregateExceptionのInnerExceptionsプロパティに子タスクのAggregateExceptionを格納します。動作としては以下のようになります。Nested Exception Sample
- using System;
- using System.Threading.Tasks;
- namespace ConsoleApplication
- {
- class Program
- {
- static void Main()
- {
- var task = Task.Factory.StartNew(() =>
- {
- Task.Factory.StartNew(() =>
- {
- throw new Exception("Task2 : Exception");
- }, TaskCreationOptions.AttachedToParent);
- throw new InvalidOperationException("Task1 : Exception");
- });
- try
- {
- task.Wait();
- }
- catch (AggregateException exception)
- {
- foreach (var inner in exception.InnerExceptions)
- {
- Console.WriteLine(inner.Message);
- Console.WriteLine("Type : {0}", inner.GetType());
- }
- }
- }
- }
- }
- //----- 結果
- /*
- Task1 : Exception
- Type : System.InvalidOperationException
- 1 つ以上のエラーが発生しました。
- Type : System.AggregateException
- */
AggregateExceptionがAggregateExceptionを持つようになるので、スローされた例外本体を確認するためには、含まれる例外がAggregateException型かどうかを判定しつつ再起しなければなりません。毎回そのような骨の折れることはしたくありませんので、AggregateExceptionには下位階層のAggregateExceptionを再帰的に検索して例外を平坦化するFlattenメソッドが用意されています。以下のサンプルでは、Flattenメソッドを利用することで内部の例外を取得しています。
AggregateException.Flatten Sample
- using System;
- using System.Threading.Tasks;
- namespace ConsoleApplication
- {
- class Program
- {
- static void Main()
- {
- var task = Task.Factory.StartNew(() =>
- {
- Task.Factory.StartNew(() =>
- {
- throw new Exception("Task2 : Exception");
- }, TaskCreationOptions.AttachedToParent);
- throw new InvalidOperationException("Task1 : Exception");
- });
- try
- {
- task.Wait();
- }
- catch (AggregateException exception)
- {
- foreach (var inner in exception.Flatten().InnerExceptions)
- {
- Console.WriteLine(inner.Message);
- Console.WriteLine("Type : {0}", inner.GetType());
- }
- }
- }
- }
- }
- //----- 結果
- /*
- Task1 : Exception
- Type : System.InvalidOperationException
- Task2 : Exception
- Type : System.Exception
- */
未処理の例外
次の条件をすべて満たす場合、コード上で例外の発生を認識することはありません。- Task.Waitメソッドを呼び出さない
- Task<TResult>.Resultプロパティを呼び出さない
- Task.Exceptionプロパティを呼び出さない
この対処として、認識されなかった例外を検出できるようにするための手段が用意されています。それが、TaskSchedulerクラスの静的イベントであるUnobservedTaskExceptionを利用する方法です。
TaskScheduler.UnobservedTaskException Sample
- using System;
- using System.Threading;
- using System.Threading.Tasks;
- namespace ConsoleApplication
- {
- class Program
- {
- static void Main()
- {
- TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;
- Task.Factory.StartNew(() => { throw new InvalidOperationException("Task1"); });
- Task.Factory.StartNew(() => { throw new InvalidCastException("Task2"); });
- Thread.Sleep(300); //--- タスクの完了を待機
- GC.Collect(); //--- タスクインスタンスを回収
- GC.WaitForPendingFinalizers(); //--- Finalizeを強制的に呼び出す
- Console.WriteLine("End");
- }
- static void TaskScheduler_UnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e)
- {
- foreach (var inner in e.Exception.Flatten().InnerExceptions)
- {
- Console.WriteLine(inner.Message);
- Console.WriteLine("Type : {0}", inner.GetType());
- }
- e.SetObserved(); //--- 処理済みとしてマークする
- }
- }
- }
- //----- 結果
- /*
- Task1
- Type : System.InvalidOperationException
- Task2
- Type : System.InvalidCastException
- End
- */
上記のサンプルでは、タスク上で発生した例外を捕捉しないで放置した上でFinalizerスレッドを強制的に呼び出しています。Finalizeメソッドからスローされた例外は、集約例外ハンドラの中ですべて「処理済み」としています。このようにすることで、未処理の例外に対応することができます。試しに、イベントの関連付けを行わなかったり、例外を処理済みとしてマークしなかったりすると、アプリケーションをダウンさせることができます。
ただし、上記のように「処理したことにする」のが良いかどうかは一概には言えませんので、アプリケーションやライブラリとしての挙動を見極めた上で利用してください。