PLINQ の潜在的な落とし穴

多くの場合、PLINQ では、LINQ to Objects のシーケンシャル クエリよりもパフォーマンスが大幅に向上します。 ただし、クエリ実行を並列化する作業では複雑さが生じ、シーケンシャル コードでは一般的ではないか、まったく発生しないという問題が発生する可能性があります。 このトピックでは、PLINQ クエリを記述するときに回避するいくつかのプラクティスを示します。

並列が常に高速であると想定しないでください

並列化により、PLINQ クエリの実行速度が LINQ to Objects と同等よりも遅くなる場合があります。 基本的な経験則として、ソース要素が少なく、高速なユーザー デリゲートを持つクエリの速度が大幅に向上する可能性は低いということです。 ただし、パフォーマンスには多くの要因が関係するため、PLINQ を使用するかどうかを決定する前に、実際の結果を測定することをお勧めします。 詳細については、「 PLINQ の高速化について」を参照してください。

共有メモリの場所への書き込みを避ける

逐次コードでは、静的変数またはクラス フィールドから読み取ることや、これらの場所に書き込むことはよくあります。 ただし、複数のスレッドからこのような変数に同時にアクセスしているときは、著しい競合状態になる場合がよくあります。 ロックを使用して変数へのアクセスを同期できる場合でも、同期のコストでパフォーマンスが低下する可能性があります。 そのため、PLINQ クエリの共有状態へのアクセスは、可能な限り回避 (または少なくとも制限) することをお勧めします。

例: 共有メモリを使用した競合状態

次の例は、複数のスレッドが共有変数に書き込むときに発生する競合状態を示しています。 変数 total は、同期なしで複数のスレッドによって同時にアクセスおよび変更され、予期しない結果が発生します。

static void DemonstrateRaceCondition()
{
    int total = 0;
    var numbers = Enumerable.Range(0, 10000);

    // UNSAFE: Multiple threads writing to shared variable
    numbers.AsParallel().ForAll(n => total += n);

    Console.WriteLine($"Total (with race condition): {total}");
    // Expected: 49,995,000 but result is unpredictable due to race condition
}
Shared Sub DemonstrateRaceCondition()
    Dim total As Integer = 0
    Dim numbers = Enumerable.Range(0, 10000)

    ' UNSAFE: Multiple threads writing to shared variable
    numbers.AsParallel().ForAll(Sub(n) total += n)

    Console.WriteLine($"Total (with race condition): {total}")
    ' Expected: 49,995,000 but result is unpredictable due to race condition
End Sub

このコードでは、 total += n 操作はアトミックではありません。 これには、 totalの現在の値の読み取り、 nの追加、結果の totalへの書き戻しが含まれます。 複数のスレッドがこの操作を同時に実行すると、同じ値を読み取り、別のスレッドに追加して、互いに上書きする結果を書き戻すことができます。 これにより、いくつかの追加が失われ、最終的な結果が不正確になります。

正しい方法は、共有変更可能な状態を必要としないスレッド セーフな操作を使用することです。

static void DemonstrateCorrectApproach()
{
    var numbers = Enumerable.Range(0, 10000);

    // SAFE: Use thread-safe aggregate operation
    int total = numbers.AsParallel().Sum();

    Console.WriteLine($"Total (correct): {total}");
    // Result is always 49,995,000
}
Shared Sub DemonstrateCorrectApproach()
    Dim numbers = Enumerable.Range(0, 10000)

    ' SAFE: Use thread-safe aggregate operation
    Dim total As Integer = numbers.AsParallel().Sum()

    Console.WriteLine($"Total (correct): {total}")
    ' Result is always 49,995,000
End Sub

Sumメソッドは、スレッド セーフな方法で内部的に並列化を処理し、明示的な同期を必要とせずに正しい結果を確保します。 その他の安全な方法としては、カスタム集計に Aggregate を使用する方法や、 ConcurrentBag<T>などのスレッド セーフなコレクションに結果を収集する方法があります。

過剰並列化を回避する

AsParallelメソッドを使用すると、ソース コレクションのパーティション分割とワーカー スレッドの同期のオーバーヘッド コストが発生します。 並列化の利点は、コンピューター上のプロセッサ数によってさらに制限されます。 1 つのプロセッサで複数の計算主体のスレッドを実行しても、高速化は実現しません。 そのため、クエリを過剰に並列化しないように注意する必要があります。

次のスニペットに示すように、過剰並列化が発生する可能性がある最も一般的なシナリオは、入れ子になったクエリです。

var q = from cust in customers.AsParallel()
        from order in cust.Orders.AsParallel()
        where order.OrderDate > date
        select new { cust, order };
Dim q = From cust In customers.AsParallel()
        From order In cust.Orders.AsParallel()
        Where order.OrderDate > aDate
        Select New With {cust, order}

この場合、次の条件の 1 つ以上が適用されない限り、外部データ ソース (顧客) のみを並列化することをお勧めします。

  • 内部データソース (cust.Orders) は非常に長いとされています。

  • 各注文でコストのかかる計算を実行している この例で示されている操作は高価ではありません。

  • ターゲット システムには、 cust.Ordersでクエリを並列化することによって生成されるスレッドの数を処理するのに十分なプロセッサがあることがわかっています。

どの場合も、最適なクエリの形式を決定する最善の方法は、テストおよび測定することです。 詳細については、「 方法: PLINQ クエリのパフォーマンスを測定する」を参照してください。

スレッド セーフでないメソッドの呼び出しを回避する

PLINQ クエリからスレッドセーフでないインスタンス メソッドに書き込むと、データが破損し、プログラムで検出される場合とされない場合があります。 例外が発生する可能性もあります。 次の例では、複数のスレッドが、クラスでサポートされていない FileStream.Write メソッドの同時呼び出しを試みます。

Dim fs As FileStream = File.OpenWrite(…)
a.AsParallel().Where(...).OrderBy(...).Select(...).ForAll(Sub(x) fs.Write(x))
FileStream fs = File.OpenWrite(...);
a.AsParallel().Where(...).OrderBy(...).Select(...).ForAll(x => fs.Write(x));

スレッド セーフメソッドへの呼び出しを制限する

.NETのほとんどの静的メソッドはスレッド セーフであり、複数のスレッドから同時に呼び出すことができます。 ただし、このような場合でも、関連する同期によっては、クエリの処理速度が大幅に低下する可能性があります。

これは、クエリに WriteLine の呼び出しをいくつか挿入することで自分でテストできます。 このメソッドは、デモンストレーションの目的でドキュメントの例で使用されますが、PLINQ クエリでは使用しないでください。

不要な順序付け操作を回避する

PLINQ は、クエリを並列で実行すると、ソース シーケンスを複数のスレッドで同時に操作できるパーティションに分割します。 既定では、パーティションが処理され、結果が配信される順序は予測できません ( OrderBy などの演算子を除く)。 ソース シーケンスの順序を保持するように PLINQ に指示できますが、パフォーマンスに悪影響を及ぼします。 可能な限りベスト プラクティスは、順序の保持に依存しないようにクエリを構成することです。 詳細については、「 PLINQ での注文の保持」を参照してください。

可能な場合は ForAll を ForEach に優先する

PLINQ は複数のスレッドでクエリを実行しますが、結果を foreach ループ (Visual Basic の For Each) で使用する場合、クエリ結果を 1 つのスレッドにマージし、列挙子によって順次アクセスする必要があります。 場合によっては、これは避けられません。ただし、可能な限り、 ForAll メソッドを使用して、たとえば、 System.Collections.Concurrent.ConcurrentBag<T>などのスレッド セーフなコレクションに書き込むことで、各スレッドが独自の結果を出力できるようにします。

Parallel.ForEachにも同じ問題が適用されます。 言い換えると、 source.AsParallel().Where().ForAll(...)Parallel.ForEach(source.AsParallel().Where(), ...)より強く優先される必要があります。

スレッド アフィニティの問題に注意する

Single-Threaded アパートメント (STA) コンポーネント、Windows Forms、Windows Presentation Foundation (WPF) の COM 相互運用性などの一部のテクノロジでは、特定のスレッドでコードを実行する必要があるスレッド アフィニティ制限が適用されます。 たとえば、Windows FormsとWPFの両方で、コントロールは作成されたスレッドでのみアクセスできます。 PLINQ クエリでWindows Forms コントロールの共有状態にアクセスしようとすると、デバッガーで実行している場合は例外が発生します。 (この設定はオフにできます)。ただし、クエリが UI スレッドで使用されている場合は、そのコードが 1 つのスレッドで実行されるため、クエリ結果を列挙する foreach ループから制御にアクセスできます。

ForEach、For、ForAll のイテレーションは常に並列で実行されると想定しないでください

Parallel.ForParallel.ForEach、またはForAll ループ内の個々の反復は、並列で実行する必要はない場合があることに注意することが重要です。 そのため、イテレーションの並列実行の正確性、または特定の順序でのイテレーションの実行の正確性に依存するコードを記述しないでください。

たとえば、次のコードはデッドロックが起こる可能性があります。

Dim mre = New ManualResetEventSlim()
Enumerable.Range(0, Environment.ProcessorCount * 100).AsParallel().ForAll(Sub(j)
   If j = Environment.ProcessorCount Then
       Console.WriteLine("Set on {0} with value of {1}", Thread.CurrentThread.ManagedThreadId, j)
       mre.Set()
   Else
       Console.WriteLine("Waiting on {0} with value of {1}", Thread.CurrentThread.ManagedThreadId, j)
       mre.Wait()
   End If
End Sub) ' deadlocks
ManualResetEventSlim mre = new ManualResetEventSlim();
Enumerable.Range(0, Environment.ProcessorCount * 100).AsParallel().ForAll((j) =>
{
    if (j == Environment.ProcessorCount)
    {
        Console.WriteLine("Set on {0} with value of {1}", Thread.CurrentThread.ManagedThreadId, j);
        mre.Set();
    }
    else
    {
        Console.WriteLine("Waiting on {0} with value of {1}", Thread.CurrentThread.ManagedThreadId, j);
        mre.Wait();
    }
}); //deadlocks

この例では、1 つのイテレーションでイベントを設定し、その他のすべてのイテレーションでイベントを待機します。 待機のイテレーションは、イベント設定のイテレーションが完了するまで完了できません。 ただし、待機のイテレーションによって、並列ループの実行に使用されるすべてのスレッドがブロックされ、イベント設定のイテレーションがまったく実行されなくなる可能性があります。 これにより、イベント設定のイテレーションが実行されず、待機のイテレーションが開始されないままの状態になるデッドロックが発生します。

具体的には、処理を適切に進めるには、並列ループの特定のイテレーションでそのループの別のイテレーションを待機するのは避ける必要があります。 並列ループで、イテレーションが逆の順序で順次スケジュールされた場合、デッドロックが発生します。

こちらも参照ください