2014年10月15日水曜日

非同期処理とAggregateException

以前の記事で2048のGUIを作ろうとかオートソルバーを作ろうとか言っておきながら手が付いていないのですが、その件は一旦保留してまた別の記事を書いてみようと思います。

さて、以前、LINQ to Twitter v3の記事を書きましたが、ちょっとこれについてまたいじっていたら例外処理で躓きましたので、一般化して話をしていこうと思います。
例えばネットにつながっていないPCからLINQ to Twitterでタイムラインを読み込もうとしてしまうようなことがあるかもしれません。そういった場合、例外が吐かれます。 まあWebExceptionとかHttpRequestExceptionとかそのあたりが吐かれるんだろうなーと思って適当に例外処理していると痛い目に遭いました。AggregateExceptionとかいう見慣れない例外が吐かれます。

さて、AggregateExceptionが一体どのような例外なのかを調べるためにMSDNに行ってみます。

AggregateException クラス (System)

「アプリケーションの実行中に発生する1つ以上のエラーを表します。」って書いてありますがその本質はよくわかりません。どうもInnerExceptionsというコレクションがあり、複数の例外をこのクラスで保持することができるようですが、なぜそのような例外が吐かれるのかがわかりません。素直にWebExceptionとかじゃダメなの?複数の場所で例外が発生するような状況だったとしても、1つ目に遭遇した例外が吐かれればそれで問題ないよね?というか、通常の例外処理がそうでしょ?なのになぜ敢えてこのように複数の例外をまとめるような例外が吐かれるのでしょう。


その疑問に答えるべく、まずひとつこの例外を吐くメソッドを挙げてみます。
わかりやすい例としてParallel.Forが挙げられます。 指定した範囲の整数を受け取るデリゲートを同時並行で動かすメソッドです。もっと正確にいえば、同時並行に動かす可能性があるです。詳しい説明は省きますが、スレッドプールを利用して実行するため、環境や設定によっては単一スレッドでの実行になるかもしれませんし、その順序も、どの値同士が並列で実行されるかもわかりません。こんな運要素の大きなメソッドですが、この中で特定の値で例外を吐くようなプログラムを書いて実行してみます。

try {
    Parallel.For(0, 100, (i) => {
        if(i == 0)
            throw new InvalidOperationException();
        if(i == 1)
            throw new ArgumentException();
        Console.WriteLine(i);
    });
}
catch(AggregateException e) {
    ShowAggregateException(e);
}

ちなみにShowAggregateExceptionメソッドは次のように実装しています。

static void ShowAggregateException(AggregateException ae)
{
    ShowAggregateException(ae, 0);
}

static void ShowAggregateException(AggregateException ae, int depth)
{
    Console.WriteLine(new string(' ', depth) + ae.GetType().Name);
    foreach(var e in ae.InnerExceptions) {
        if(e is AggregateException)
            ShowAggregateException((AggregateException)e, depth + 1);
        else
            Console.WriteLine(new string(' ', depth + 1) + e.GetType().Name);
    }
}

いわゆる再帰ですね。AggregateExceptionに含まれるすべての内部例外を表示しますが、その時にその内部例外にAggregateExceptionが含まれていたら再帰的にこいつもこのメソッドで表示させています。そして、階層が深まるたびにインデントを追加しています。new string(' ', depth + 1)がそれにあたります。

さて、これを実行するわけですが、VisualStudioを使う場合は「デバッグなしで開始」で実行する必要があります。「デバッグ開始」でやると、下の図のようにthrow文のところで「ハンドルされていない例外としてVisualStudioにキャッチされてしまい、AggregateExceptionが活躍しているところが見られません。


同じコードでデバッグ時の挙動とデバッグしないときの挙動で違うのも困りものですが、まあ、AggregateException行きになる時点でハンドルされていない例外とも言えるのか…な?なんていうか、単にVisual Studioの対応力不足な気もしますが。
さて、話を戻します。実際にデバッグなしで実行してみると、例えば下図のような実行結果が得られます。


表示されている数字が昇順や降順でないあたり、並列処理が実行されていそうな雰囲気ですね。
そして、InvalidOperationExceptionとArgumentExceptionが同時に吐かれていることがわかると思います。なるほど、確かにこういう場面を想定するとAggregateExceptionも役に立ちそうですね。
ちなみにですが、先ほども言いましたがこのParallel.Forはかなり気まぐれなメソッドです。何回か実行してみると、表示される数字の順番は毎回異なりますし、InvalidOperationExceptionしか例外が吐かれないこともあります。おそらくどこか1つでも例外を吐いたら直ちに新しい処理を開始するのをやめてメインルーチンに処理を返す仕様になっているんでしょうね。たまたま0と1が別スレッドで同時に実行された時のみ、上記のコードではこのような実行結果を吐くでしょう。なので、もしかしたらシングルコアのCPUのマシンとかだったらこれは一生再現できないかもしれませんね。


さてさて、ここではParallel.Forを使ってみましたが、もともとはこれは非同期処理全般に使われている例外のようです。非同期処理の基礎で出てくるこのあたりのメソッドやプロパティでもAggregateExceptionが吐かれるようです。

Task.Wait メソッド  (System.Threading.Tasks)
Task(TResult).Result プロパティ  (System.Threading.Tasks)

Task.Resultのほうには明言されていませんが、これはタスクの終了を待ってから制御を返すプロパティなので、内部的にWaitを呼んでいるのか知りませんが、これもAggregateExceptionを吐くことがあるようです。

Task t = Task.Factory.StartNew(() => {
    throw new InvalidOperationException();
});
Thread.Sleep(1000);
try {
    t.Wait();
}
catch(AggregateException e) {
    ShowAggregateException(e);
}

さて、このように非同期処理中で例外を吐くプログラムを書いてみましょう。
タスクを開始してからメインスレッドでは怪しげな1秒のスリープを入れてみました。こうすることで、t.Wait()が呼ばれる前に例外を吐くスレッドのほうではいかにも例外を吐いたであろう状態にあることは明らかです(もちろん、スレッドプールが2スレッド以上あることが前提ですが)。ですが、その例外は即座にプログラムの実行に影響を及ぼしません。t.Wait()が呼び出されたときに例外が吐かれます。予想通り、AggregateExceptionの中にはInvalidOperationExceptionが1つ入っています。

では、この別スレッドの中でさらに別スレッドにを実行し、そこで例外を吐かせてみたらどうでしょう。

Task t = Task.Factory.StartNew(() => {
    Task.Factory.StartNew(() => {
        throw new ArgumentException();
    }, TaskCreationOptions.AttachedToParent);
    throw new InvalidOperationException();
});
try {
    t.Wait();
}
catch(AggregateException e) {
    ShowAggregateException(e);
}

ここで、子スレッドにはTaskCreationOptions.AttachedToParentオプションを付けていることに気を付けてください。これを付けないと、ArgumentExceptionを吐くスレッドが終わる前にInvalidOperationExceptionを吐くスレッドが終わってしまって、ArgumentExceptionのほうがAggregateExceptionに含まれなくなってしまいます。


この通り、見事にInvalidOperationExceptionとAggregateExceptionを含むAggregateExceptionが吐かれました。


さて、ここまで見てきたら、何となくなぜ非同期処理や並列処理でAggregateExceptionが必要なのかがわかってきたかと思います。非同期処理や並列処理は根本的に複数の例外を同時に吐く可能性をはらんでいる点があるからAggregateExceptionが必要というわけです。
しかし、よくよく考えれば、例えば別スレッド内での例外をそのままWait()呼び出し時に再スローするような仕組みをつくった場合、Waitメソッドが吐く例外の種類をメソッドのドキュメンテーションに定義できなくなりますし、Waitメソッドそのものが吐いた例外なのか、それとも別スレッド内のユーザーが書いた処理に起因する例外なのかを判別することもできません。そういう意味からも、例外をカプセル化してしまったほうが良い、といった判断なのかもしれません。

最後に、少し実用的なコードを書いてみようと思います。

using(WebClient wc = new WebClient()) {
    var t = wc.DownloadStringTaskAsync(@"http://www.google.co.jp");
    try {
        Console.WriteLine(t.Result);
    }
    catch(AggregateException ae) {
        ae.Handle((e)=>{
            if(e is WebException) {
                Console.WriteLine(e.GetType().Name);
                Console.WriteLine(" " + e.Message);
                return true;
            } else
                return false;
        });
    }
}

こんな感じでどうでしょうか。
非同期にGoogleのトップページのソースをダウンロードしています。正常にダウンロードできれば画面にそのテキストが表示されます。一方、正常にダウンロードできなかったらAggregateExceptionが発生します。このHandleメソッドでは、InnerExceptionsに含まれてるすべての例外についての例外処理のデリゲートを 書いています。処理した例外はtrueを返し、処理しなかったらelseを返します。
例えばデスクトップPCならLANケーブルを引っこ抜いて、ノートPCなら機内モードにするなどで正常にネットにつながらない状態にしてみましょう。そうすると、見事にAggregateExceptionが吐かれ、そのWebExceptionについての例外情報が画面に表示されます。

LINQ to Twitter v3でもクエリが並列非同期処理となっているためか例外はAggregateExceptionに内包される形で吐かれるようです。なので、上記のようにAggregateExceptionを処理する形に書き直しておけば、各々の例外に対応することができるようになります。


今回は非同期処理の例外処理について紹介しました。
非同期処理や並列処理の簡単さはC#5.0/.NET4.5あたりの目玉機能ですが、この辺りを使うときはぜひとも気を使ってみたいところですね。

0 件のコメント:

コメントを投稿