xin9le.net

Microsoft の製品/技術が大好きな Microsoft MVP な管理人の技術ブログです。

TPL入門 (12) - タスクの例外処理

プログラムを作成するにあたっては、発生し得る例外というものを十分に考慮しなければなりません。しかしながら、非同期処理/並列処理の場合は例外処理がやりにくいのも実情です。特にタスクは呼び出し元スレッドとは非同期に実行されるので、その中で発生した例外は一体「いつ、誰が」捕捉すべきなのか、という問題にぶつかります。今回はこのようにタスク内で発生した例外の扱い方について見ていきます。

例外の捕捉

タスク中で発生し処理されなかった例外は、タスク自身によって捕捉され、コレクションとして保存されます。WaitメソッドResultプロパティが実行されると、これらのメンバーからSystem.AggregateExceptionがスローされます。タスクが捕捉した例外は、スローされるAggregateExceptionのInnerExceptionsプロパティで取得できます。

using System;
using System.Threading.Tasks;
 
namespace Sample22_TaskException
{
    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を格納します。動作としては以下のようになります。

using System;
using System.Threading.Tasks;
 
namespace Sample23_NestedException
{
    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メソッドを利用することで内部の例外を取得しています。

using System;
using System.Threading.Tasks;
 
namespace Sample24_ExceptionFlatten
{
    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
*/

未処理の例外

次の条件をすべて満たす場合、コード上で例外の発生を認識することはありません。

  1. Task.Waitメソッドを呼び出さない
  2. Task<TResult>.Resultプロパティを呼び出さない
  3. Task.Exceptionプロパティを呼び出さない

このような状況は、プログラム実行時に予期せぬ問題が発生してもその問題を認識しないことになるため、望ましくありません。そのため、タスクインスタンスガベージコレクションによって回収されるとき、Task.Finalizeメソッドは自身の例外が認識されているかどうかを確認します。認識されていないと判断される場合、FinalizeメソッドからAggregateExceptionがスローされます。FinalizeメソッドCLRの専用スレッドであるFinalizerスレッド上で実行されるため、その例外は捕捉することができず、プロセスが即座に終了してしまいます。ですので、本来ならば確実に例外を認識するようにしなければなりません。しかしながら、サードパーティ製のプラグインをホストする場合など、シナリオによっては必ずしも正しい例外処理ができなかったり、例外処理が複雑すぎて管理しきれなくなる場合があります。

この対処として、認識されなかった例外を検出できるようにするための手段が用意されています。それが、TaskSchedulerクラスの静的イベントであるUnobservedTaskExceptionを利用する方法です。

using System;
using System.Threading;
using System.Threading.Tasks;
 
namespace Sample25_UnobservedTaskException
{
    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メソッドからスローされた例外は、集約例外ハンドラの中ですべて「処理済み」としています。このようにすることで、未処理の例外に対応することができます。試しに、イベントの関連付けを行わなかったり、例外を処理済みとしてマークしなかったりすると、アプリケーションをダウンさせることができます。

ただし、上記のように「処理したことにする」のが良いかどうかは一概には言えませんので、アプリケーションやライブラリとしての挙動を見極めた上で利用してください

次回予告

今回はタスク内で発生する例外について見てきました。より詳細に学習する場合は、MSDNライブラリの記事、例外処理 (タスク並列ライブラリ)などを参考にすると良いと思います。次回はタスクをキャンセルする方法について触れたいと思います。