CodeGym /コース /C# SELF /Parallel.For

Parallel.ForParallel.ForEach の例外

C# SELF
レベル 61 , レッスン 2
使用可能

1. Parallel.ForParallel.ForEach における例外の動き

普通の for ループは単純だよ:ループ本体で例外が投げられたらループは終了して、例外は外に飛ぶ。並列ループではそうはならない。順を追って説明するね。

すべての例外は一つの「袋」に集められる

並列ループ(Parallel.For/ForEach)のあるイテレーションで例外が発生すると、それはすぐに外に飛ぶのではなくパッキングされる。他のイテレーションは続行するか、また例外を投げるかもしれない。最終的に並列ループの実行が完了(または強制終了)したとき、発生した全ての例外は一つの AggregateException オブジェクトとして外に投げられる。

AggregateException は、並列イテレーション中に発生したすべての例外を内部コレクションとして持つ「コンテナ」だ。これがあると、発生したすべてのエラー(少なくとも主要なスレッドが終了するまでに集まった分)を受け取れるので便利だよ。

実際の挙動

例:時々例外を投げる並列処理

using System;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {
        int[] numbers = { 1, 2, 0, 4, 0, 6, 7, 8 };

        try
        {
            Parallel.ForEach(numbers, number =>
            {
                // 我々は意図的に数で割っている、時々それはゼロだ!
                // これは DivideByZeroException を引き起こす
                int result = 100 / number;
                Console.WriteLine($"100 / {number} = {result}");
            });
        }
        catch (AggregateException ex)
        {
            Console.WriteLine("並列ループでエラーが見つかりました!");

            // 発生した全ての例外を列挙する
            foreach (var inner in ex.InnerExceptions)
            {
                Console.WriteLine($"型: {inner.GetType().Name} — メッセージ: {inner.Message}");
            }
        }
    }
}

何が起きるか:

  • メインの配列にはゼロが含まれていて、0での割り算は数学的にも(C#でも)禁忌だ:DivideByZeroException が発生する。
  • 並列ループが処理を開始する。どこかでゼロ除算が起きてもループは直ちには止まらず、既に開始しているイテレーションは続行される。
  • すべてのスレッドが終了したとき(エラーあり/なしに関わらず)、外側に全ての発生した例外を含む AggregateException が投げられる。

例外処理のメカニズムを可視化

flowchart LR
    A[スレッド 1]
    B[スレッド 2]
    C[スレッド 3]
    D[スレッド 4]
    E[Parallel.ForEach]
    F[例外 1]
    G[例外 2]
    H[AggregateException]
    subgraph イテレーション
      A --> F
      B --> G
      C --> E
      D --> E
      F --> H
      G --> H
      E --> H
    end

図からわかるように:異なるスレッドで別々のエラーが発生しても、最終的にはすべてが AggregateException にパックされる。

2. 実践的なエラー処理の注意点

AggregateException にどう対処するか?

AggregateException をキャッチするとき、普通は次の2つのシナリオがある:

  • ユーザーやログに全てのエラーを出力して原因追跡する。
  • どのエラーが致命的で、どれが些細かを判別して、操作全体を失敗とみなすか個別に無視するか決める。

典型パターン:Handle を使った処理

try
{
    Parallel.For(0, 10, i =>
    {
        if (i == 3 || i == 7)
            throw new InvalidOperationException($"イテレーション {i} のエラー");
        Console.WriteLine($"処理済み: {i}");
    });
}
catch (AggregateException ex)
{
    ex.Handle(e =>
    {
        if (e is InvalidOperationException)
        {
            Console.WriteLine("捕まえたエラー: " + e.Message);
            // true = エラーは処理済みとみなす
            return true;
        }
        // false = 処理していない、再スローされる
        return false;
    });
}

このやり方だと、自分が「許容できる」と思うエラーだけを処理して、その他の重大なエラーは上に投げ直すことができる。

実装上の興味深い(危険な)ニュアンス

ループはいつ止まる?
イテレーションで例外が発生しても、Parallel.For/ForEach は新しいイテレーションを起動しないようにしようとするが、既に開始しているものは続行される。すべてのアクティブなイテレーションが終わると AggregateException が投げられる。スレッド数が多いと「後続の処理の尻尾」が残るので、複数のエラーが集まる可能性が高い。

例外を捕まえないとアプリが落ちる。
Parallel.For/ForEachtry-catch で包まないと、すべてのイテレーション終了後にアプリはクラッシュする。ユーザーにやさしくないのでキャッチは必須に近い。

例外をループ内部に「押し込む」方法。
個々のイテレーションが全体を台無しにしないよう、ループ本体で例外を処理することもできる:

Parallel.ForEach(numbers, number =>
{
    try
    {
        int result = 100 / number;
        Console.WriteLine(result);
    }
    catch (Exception ex)
    {
        Console.WriteLine($"数 {number} でのエラー: {ex.Message}");
    }
});

この方法は、各失敗をその場で処理(例:ログ出力)したい場合に良い。ただし注意点として、こうすると AggregateException は発生しないので、全体として問題があったかどうかを後でまとめて知る手段がなくなる。

Break()Stop() が呼ばれた場合。
イテレーション内で ParallelLoopState.Break() または ParallelLoopState.Stop() が呼ばれると、ループは新しいイテレーションを停止しようとする:Break() は現在のインデックス以降のイテレーションを止める方向で、Stop() はすべてを止める方向で働く。しかし同時に例外が発生している場合、その例外は保持され、アクティブなイテレーションの終了後に AggregateException として投げられる。

3. 便利な注意点

通常のループと並列ループの例外の違い

通常のループでは、どんなエラーでも即座に全作業が停止する:例外は外に飛び、そこで処理される。

C# の並列ループは妥協的なアプローチをとる:既に開始したタスクは続行され、処理全体が終わったあとにまとめてエラーを外に出す。これにより全てのエラーを取りこぼさず収集でき、ループ完了後に判断できる。

4. Parallel.ForParallel.ForEach での例外処理に関する典型的なミス

ミス №1: AggregateException を無視すること。
AggregateException を捕まえないと、すべてのイテレーション終了後にアプリがクラッシュしてデータが失われる可能性がある。サーバやGUIアプリだと致命的だ。

ミス №2: .Wait()try-catch なしで使うこと。
Parallel.For/ForEach に対して .Wait() を呼んで、AggregateException を適切に処理しないと、未処理例外につながり調査が難しくなる。

ミス №3: 繰り返し発生するエラーを無視すること。
同じエラーが複数回出る(例:ゼロ除算)状況はデータに原因があるかもしれない。InnerExceptions を分析しないと根本原因を見逃す。

ミス №4: すべての例外を黙殺すること。
ループ内部で catch (Exception) { /* 空 */ } のようにすると、重要な情報が隠れて「幽霊バグ」を生む可能性がある。

異なるループでのエラー挙動まとめ

ケース 通常の for/foreach Parallel.For / ForEach
例外が処理されるタイミング 即時 すべてのイテレーション終了後
エラーの形式 単一の exception コレクションを持つ AggregateException
他のイテレーション 実行されない 既に開始したものは続行される
ループ内部でのキャッチ 可能 可能
外側でのキャッチ 可能 AggregateException で可能

「裏技」と面接向けの短問:

  • もし AggregateException を処理しなかったらどうなる?
    すべてのイテレーション終了後にアプリはクラッシュする — エラーがどこで起きたかに関係なく。
  • どのイテレーションでエラーが起きたか分かる?
    イテレーションのインデックスやデータを例外に含めるよう自分で仕込めば分かる。そうしていなければ分からない。
  • AggregateException が空になることはある?
    いいえ。内部に少なくとも1つの例外がある場合に作られる。エラーが無ければそもそも投げられない。
  • ループ内部で捕まえた場合、外側でのエラーはどうなる?
    内部で処理していれば外側には何も飛ばないし、AggregateException は発生しない。

これで、マルチスレッドでループを回すだけでなく、その「同時発生するトラブル」も上手くさばけるようになるはず。マルチスレッドはサプライズが好きだから、例外処理は念入りにね。

2
タスク
C# SELF, レベル 61, レッスン 2
ロック未解除
Parallel.Forのシンプルな使い方と例外処理の例
Parallel.Forのシンプルな使い方と例外処理の例
コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION