C# Parallel入門:For/ForEachの使い方から高速化のコツ・注意点まで徹底解説

はじめに

C#で大量データの計算、画像変換、ログ集計などを高速化したいときに候補になるのがParallelクラスです。「C# Parallel」「csharp parallel」で調べると、Parallel.ForParallel.ForEachのサンプルは多く見つかりますが、実際の業務コードで安全に使うには、単にforを置き換えるだけでは不十分です。

Parallelは、複数の処理を同時に実行するための便利なAPIです。特にCPUを多く使う処理では効果が出やすい一方、処理順序が保証されない、共有変数の競合が起きる、処理が軽すぎると逆に遅くなる、といった注意点もあります。

この記事では、Parallel.ForParallel.ForEachの基本から、高速化のコツ、例外処理、キャンセル、async/awaitTask.WhenAllとの違いまで、実務で迷いやすいポイントをまとめて解説します。

1. C#のParallelとは?並列処理でできること

1-1. Parallelクラスの概要

Parallelクラスは、System.Threading.Tasks名前空間に用意されている並列処理用のクラスです。代表的なメソッドには、インデックス範囲を並列処理するParallel.For、コレクションを並列処理するParallel.ForEach、複数の処理を同時実行するParallel.Invokeがあります。

Microsoft Learnでは、TPL、つまりTask Parallel Libraryは、System.ThreadingおよびSystem.Threading.Tasks名前空間に含まれる並列処理・並行処理用API群であり、使用可能なプロセッサを効率よく使うように動的にスケールすると説明されています。ParallelクラスもこのTPLの一部です。

たとえば、次のように書くと、0から9までの処理が並列に実行される可能性があります。

C#
using System;
using System.Threading.Tasks;

Parallel.For(0, 10, i =>
{
Console.WriteLine($"処理中: {i}");
});

ここで重要なのは、「必ず番号順に実行されるわけではない」という点です。Parallelは処理を複数のスレッドに分散するため、実行順序や完了順序は通常のfor文とは異なります。

1-2. 並列処理・非同期処理・マルチスレッドの違い

C#の並行処理を学ぶと、並列処理、非同期処理、マルチスレッドという言葉が出てきます。似ていますが、目的が少し違います。

並列処理は、複数の処理を同時に実行して全体の処理時間を短くする考え方です。CPUコアを複数使い、重い計算を分担させるイメージです。

非同期処理は、時間のかかる処理を待っている間に呼び出し元をブロックしないための仕組みです。ファイル読み込み、HTTP通信、データベースアクセスなどのI/O待ちでよく使います。C#のasyncawaitは、TaskTask<T>を使って非同期操作を扱うための仕組みです。

マルチスレッドは、複数のスレッドを使って処理を実行する実装上の仕組みです。Parallelは内部でThreadPoolなどを活用しますが、開発者が直接スレッドを作成・管理しなくても並列処理を書ける点が特徴です。

整理すると、CPUを使い切って計算を速くしたいならParallel、I/O待ちを効率化したいならasync/awaitTask.WhenAll、低レベルにスレッドを制御したい特殊なケースではThreadや専用の同期機構を検討する、という使い分けになります。

1-3. Parallel.For/ForEachが向いている処理

Parallel.ForParallel.ForEachが向いているのは、各要素の処理が独立していて、CPU負荷が高く、データ量が多い処理です。

たとえば、次のような処理は向いています。

  • 大量の数値データに対する計算

  • 画像のピクセル単位の加工

  • 大量ファイルの変換処理

  • ログ行の解析

  • 暗号化、ハッシュ計算、圧縮などのCPU負荷が高い処理

一方で、1件あたりの処理が非常に軽い場合、並列化の準備やスレッド切り替えのコストのほうが大きくなり、通常のfor文より遅くなることがあります。Microsoft Learnでも、並列化の最適な形はテストと計測で確認するのが最善だと説明されています。

1-4. Parallelを使うメリットとデメリット

Parallelを使うメリットは、比較的少ないコード変更でCPUバウンドな処理を高速化できることです。通常のforforeachに近い書き方で並列化できるため、初めて並列処理を書く場合でも導入しやすいです。

C#
// 通常のfor文
for (int i = 0; i < items.Length; i++)
{
Process(items[i]);
}

// Parallel.For
Parallel.For(0, items.Length, i =>
{
Process(items[i]);
});

一方、デメリットもあります。実行順序が保証されない、デバッグが難しくなる、共有データの扱いに注意が必要、例外が複数発生する可能性がある、環境によって効果が変わる、といった点です。

つまり、Parallelは「書けば必ず速くなる魔法」ではありません。処理の性質を見極め、計測しながら導入することが大切です。

2. Parallel.Forの基本的な使い方

2-1. Parallel.Forの基本構文

Parallel.Forは、指定した範囲のインデックスを並列に処理するメソッドです。基本構文は次のとおりです。

C#
Parallel.For(開始値, 終了値, i =>
{
// iを使った処理
});

終了値は通常のfor文と同じく、含まれません。たとえばParallel.For(0, 10, ...)なら、iは0から9までです。

Microsoft Learnでは、Parallel.For(Int32, Int32, Action<Int32>)は、イテレーションを並列で実行できるforループを実行するメソッドとして説明されています。

2-2. 通常のfor文との違い

通常のfor文は、基本的に1つずつ順番に処理します。

C#
for (int i = 0; i < 10; i++)
{
Console.WriteLine(i);
}

出力は通常、0、1、2、3……の順になります。

一方、Parallel.Forでは複数のイテレーションが同時に実行される可能性があります。

C#
Parallel.For(0, 10, i =>
{
Console.WriteLine(i);
});

この場合、出力順は0, 1, 2, 3...になるとは限りません。3, 0, 7, 1...のように順不同になることがあります。

処理結果を順番どおりに格納したい場合は、Console.WriteLineの順序に頼るのではなく、インデックスを使って配列に代入します。

C#
int[] results = new int[10];

Parallel.For(0, results.Length, i =>
{
results[i] = i * i;
});

Console.WriteLine(string.Join(", ", results));

このように、処理の実行順序はバラバラでも、結果の格納位置をインデックスで固定すれば、最終的な配列の順序は保てます。

2-3. インデックスを使った並列処理のサンプル

次の例では、大量の数値データに対して重い計算を行い、結果を別の配列に格納します。

C#
using System;
using System.Threading.Tasks;

double[] source = Enumerable.Range(1, 1_000_000)
.Select(x => (double)x)
.ToArray();

double[] results = new double[source.Length];

Parallel.For(0, source.Length, i =>
{
results[i] = Math.Sqrt(source[i]) * Math.Sin(source[i]);
});

Console.WriteLine(results[0]);
Console.WriteLine(results[^1]);

各インデックスの処理は独立しています。source[i]を読み取り、results[i]に書き込むだけなので、他のイテレーションとデータ競合が起きにくい構造です。

Parallel.Forでは、このように「入力配列のi番目を処理して、出力配列のi番目へ入れる」形にすると安全に書きやすくなります。

2-4. ParallelLoopResultで実行結果を確認する

Parallel.ForParallelLoopResultを返します。これは、ループが最後まで完了したか、BreakStopで途中終了したかを確認するために使えます。

C#
ParallelLoopResult result = Parallel.For(0, 100, (i, state) =>
{
if (i == 30)
{
state.Break();
return;
}

Console.WriteLine(i);
});

Console.WriteLine($"完了したか: {result.IsCompleted}");

if (result.LowestBreakIteration.HasValue)
{
Console.WriteLine($"Breakされた最小インデックス: {result.LowestBreakIteration.Value}");
}

ParallelLoopResultIsCompletedtrueなら全イテレーションが完了しています。IsCompletedfalseLowestBreakIterationnullならStopnullでない値ならBreakによって途中終了したことを示します。

3. Parallel.ForEachの基本的な使い方

3-1. Parallel.ForEachの基本構文

Parallel.ForEachは、配列やList<T>などのコレクションを並列に処理するメソッドです。

C#
Parallel.ForEach(コレクション, item =>
{
// itemを使った処理
});

Parallel.Forがインデックス範囲を扱うのに対して、Parallel.ForEachIEnumerable<T>などのデータソースをそのまま処理できます。Microsoft Learnでも、Parallel.ForEachIEnumerableまたはIEnumerable<T>データソースに対してデータ並列処理を行う方法として紹介されています。

3-2. Listや配列を並列処理するサンプル

次の例では、ファイル名の一覧を並列に処理します。

C#
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

List<string> files = new()
{
"a.txt",
"b.txt",
"c.txt",
"d.txt"
};

Parallel.ForEach(files, file =>
{
Console.WriteLine($"処理開始: {file}");
// ConvertFile(file);
Console.WriteLine($"処理終了: {file}");
});

大量のファイル変換や画像変換など、各ファイルを独立して処理できる場合はParallel.ForEachが使いやすいです。

配列にも使えます。

C#
string[] names = { "Alice", "Bob", "Carol", "Dave" };

Parallel.ForEach(names, name =>
{
Console.WriteLine(name.ToUpperInvariant());
});

ただし、出力順は元の配列順とは限りません。順序が必要な場合は、結果をインデックス付きで保持するなどの工夫が必要です。

3-3. foreach文との違い

通常のforeach文は、コレクションの要素を順番に取り出して処理します。

C#
foreach (var item in items)
{
Process(item);
}

Parallel.ForEachでは、複数の要素が同時に処理される可能性があります。

C#
Parallel.ForEach(items, item =>
{
Process(item);
});

そのため、次のようなコードは危険です。

C#
List<int> results = new();

Parallel.ForEach(items, item =>
{
results.Add(Process(item)); // 危険
});

List<T>への同時Addはスレッドセーフではありません。複数スレッドから同時に更新すると、データ破損や例外の原因になります。Microsoft Learnでも、並列ループから非スレッドセーフなインスタンスメソッドへ書き込むと、データ破損や例外につながる可能性があると説明されています。

安全に書くなら、ConcurrentBag<T>などのスレッドセーフなコレクションを使います。

C#
using System.Collections.Concurrent;

ConcurrentBag<int> results = new();

Parallel.ForEach(items, item =>
{
results.Add(Process(item));
});

System.Collections.Concurrent名前空間には、複数スレッドから安全かつ効率的に追加・削除できるコレクションが用意されています。

3-4. ForとForEachの使い分け

Parallel.ForParallel.ForEachは、処理対象によって使い分けます。

使いたい処理向いているAPI
0からNまでのインデックスを処理するParallel.For
配列のインデックスを使って結果を同じ位置に格納するParallel.For
List<T>IEnumerable<T>をそのまま処理するParallel.ForEach
ファイル一覧、オブジェクト一覧、ログ行一覧を処理するParallel.ForEach
LINQ風に絞り込みや集計も書きたいPLINQ

インデックスが重要ならParallel.For、要素そのものを処理したいならParallel.ForEachと考えると分かりやすいです。

4. Parallel処理を高速化するコツ

4-1. CPUバウンド処理で効果が出やすい理由

Parallelは、複数のCPUコアを使って処理を分担するため、CPUバウンドな処理で効果が出やすいです。

CPUバウンドとは、処理時間の大部分がCPU計算に使われる処理です。たとえば、画像フィルタ、数値計算、暗号化、圧縮、ハッシュ計算などが該当します。

反対に、HTTP通信やファイル読み込み、DBアクセスのようなI/O待ち中心の処理では、Parallelでスレッドを増やしても待ち時間が多く、効率が悪くなることがあります。その場合はasync/awaitTask.WhenAllを検討するほうが自然です。

4-2. 処理時間を計測して効果を確認する

並列化するときは、必ず処理時間を計測しましょう。Stopwatchを使うと簡単に比較できます。

C#
using System;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;

int[] data = Enumerable.Range(1, 5_000_000).ToArray();
double[] results = new double[data.Length];

var sw = Stopwatch.StartNew();

for (int i = 0; i < data.Length; i++)
{
results[i] = HeavyCalculation(data[i]);
}

sw.Stop();
Console.WriteLine($"通常for: {sw.ElapsedMilliseconds} ms");

sw.Restart();

Parallel.For(0, data.Length, i =>
{
results[i] = HeavyCalculation(data[i]);
});

sw.Stop();
Console.WriteLine($"Parallel.For: {sw.ElapsedMilliseconds} ms");

static double HeavyCalculation(int value)
{
double result = value;

for (int i = 0; i < 100; i++)
{
result = Math.Sqrt(result) * Math.Sin(result + i);
}

return result;
}

実際の速度差は、CPUコア数、データ量、処理内容、他のプロセスの負荷によって変わります。開発環境では速くても本番環境で同じ結果になるとは限らないため、なるべく本番に近い条件で測ることが重要です。

4-3. MaxDegreeOfParallelismで並列数を制御する

Parallelは自動的に並列度を調整しますが、必要に応じてMaxDegreeOfParallelismで同時実行数の上限を指定できます。

C#
var options = new ParallelOptions
{
MaxDegreeOfParallelism = 4
};

Parallel.ForEach(files, options, file =>
{
ConvertFile(file);
});

MaxDegreeOfParallelismは、ParallelOptionsを渡したParallelメソッド呼び出しで同時に実行される操作数に影響します。正の値を指定すると、その数に同時実行数が制限されます。

たとえば、CPUをすべて使い切ると他の処理に影響するバッチ処理では、MaxDegreeOfParallelism = Environment.ProcessorCount / 2のように控えめに設定することがあります。

C#
var options = new ParallelOptions
{
MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount / 2)
};

Parallel.For(0, data.Length, options, i =>
{
results[i] = HeavyCalculation(data[i]);
});

4-4. 処理単位が小さすぎる場合は逆に遅くなる

Parallelには、処理を分割し、スレッドへ割り当て、結果をまとめるためのオーバーヘッドがあります。そのため、1回あたりの処理が軽すぎると、並列化のコストが処理本体のコストを上回る場合があります。

悪い例は、単純な代入や足し算だけを大量に並列化するケースです。

C#
Parallel.For(0, data.Length, i =>
{
data[i] = data[i] + 1;
});

この程度の処理では、通常のfor文のほうが速い可能性があります。

並列化を検討するときは、次の観点で判断しましょう。

  • 1件あたりの処理は十分に重いか

  • データ件数は十分に多いか

  • 各処理は独立しているか

  • 共有データへのアクセスが少ないか

  • 実測で速くなっているか

特にネストしたループでは、内側も外側も並列化すると過剰な並列化になりやすいです。通常は外側だけを並列化し、必要に応じて計測しながら調整します。

4-5. Partitionerで負荷を分散する

処理時間が要素ごとに大きく異なる場合や、範囲をまとめて処理したい場合は、Partitionerを使うと便利です。

C#
using System.Collections.Concurrent;

var rangePartitioner = Partitioner.Create(0, data.Length, 10_000);

Parallel.ForEach(rangePartitioner, range =>
{
for (int i = range.Item1; i < range.Item2; i++)
{
results[i] = HeavyCalculation(data[i]);
}
});

この例では、0からdata.Lengthまでの範囲を10,000件単位に分割して処理しています。細かすぎるイテレーションをまとめることで、処理単位を調整できます。

TPLやPLINQでは既定のパーティショナーが透過的に使われますが、高度なシナリオでは独自のパーティショナーを使うこともできます。データソースを複数のスレッドが同時にアクセスできる複数の区分へ分割することは、並列化の重要なステップです。

5. Parallelを使うときの注意点

5-1. 実行順序は保証されない

Parallel.ForParallel.ForEachでは、実行順序は保証されません。

C#
Parallel.For(0, 10, i =>
{
Console.WriteLine(i);
});

この出力は、毎回異なる可能性があります。順序が必要な場合は、実行順序に頼るのではなく、結果を格納する場所を明確にする必要があります。

C#
string[] results = new string[items.Length];

Parallel.For(0, items.Length, i =>
{
results[i] = Process(items[i]);
});

この方法なら、処理の実行順序はバラバラでも、最終的なresultsの順序は元の配列と一致します。

5-2. 共有変数の更新は競合に注意する

並列処理では、複数のスレッドが同じ変数を同時に更新することがあります。次のコードは危険です。

C#
int total = 0;

Parallel.For(0, 100_000, i =>
{
total += i; // 危険
});

total += iは一見1つの処理に見えますが、内部的には読み取り、加算、書き込みに分かれます。複数スレッドが同時に実行すると、更新が失われる可能性があります。

安全に加算するには、Interlockedやローカル集計を使います。

C#
long total = 0;

Parallel.For<long>(
0,
100_000,
() => 0,
(i, state, localTotal) =>
{
return localTotal + i;
},
localTotal =>
{
Interlocked.Add(ref total, localTotal);
});

Console.WriteLine(total);

各スレッドでローカルに集計し、最後にInterlocked.Addで安全に合算することで、ロックの回数も減らせます。

5-3. List.Addなど非スレッドセーフな処理の危険性

Parallel.ForEach内でList<T>.Addを呼び出すのは避けましょう。

C#
List<string> results = new();

Parallel.ForEach(files, file =>
{
string result = ConvertFile(file);
results.Add(result); // 危険
});

List<T>は複数スレッドから同時に書き込むためのコレクションではありません。要素の追加中に内部配列の拡張が発生すると、状態が壊れたり例外が発生したりする可能性があります。

安全な書き方の一例は、ConcurrentBag<T>を使うことです。

C#
using System.Collections.Concurrent;

ConcurrentBag<string> results = new();

Parallel.ForEach(files, file =>
{
string result = ConvertFile(file);
results.Add(result);
});

順序が不要ならConcurrentBag<T>、キーで管理したいならConcurrentDictionary<TKey, TValue>、キューとして扱いたいならConcurrentQueue<T>など、用途に応じて選びます。

5-4. lock・Interlocked・Concurrentコレクションの使い分け

共有データを扱うときは、主に次の方法を使い分けます。

方法向いているケース
Interlockedカウンターや合計値など、単純な数値更新
lock複数の処理をひとまとまりで排他制御したい場合
ConcurrentBag<T>など複数スレッドからコレクションへ追加・削除したい場合
ローカル変数で集計共有アクセスを最小化したい場合

lockは、共有リソースへの読み書きを1つのスレッドだけに限定するための構文です。C#のlock文では、例外が発生してもロックが解放されるようにtry-finally相当の仕組みで扱われます。また、lock文の本体ではawaitを使えません。

C#
object gate = new();
int count = 0;

Parallel.For(0, 1000, i =>
{
lock (gate)
{
count++;
}
});

ただし、lockを多用すると並列化の効果が落ちます。可能なら、共有データを減らし、ローカルで計算して最後にまとめる設計を優先しましょう。

5-5. UIスレッドやASP.NETで使うときの注意点

Windows FormsやWPFなどのUIアプリでは、UI部品を作成したスレッド以外から直接更新できません。Microsoft Learnでも、Windows FormsやWPFにはスレッドアフィニティがあり、コントロールは作成されたスレッドからのみアクセスできると説明されています。

悪い例は次のようなコードです。

C#
Parallel.ForEach(items, item =>
{
// UIコントロールを直接更新するのは危険
// listBox.Items.Add(item);
});

UIを更新する場合は、並列処理で計算だけを行い、結果をUIスレッドに戻して反映します。

ASP.NETでも注意が必要です。リクエストごとにParallelでCPUを使い切る処理を走らせると、サーバ全体のスループットが落ちる可能性があります。Webアプリでは、1リクエストの処理時間だけでなく、同時アクセス時のCPU使用率、ThreadPool、タイムアウト、外部サービスへの負荷も含めて検討しましょう。

6. 例外処理・キャンセル・途中停止の方法

6-1. Parallel内で発生した例外の扱い

Parallel.ForParallel.ForEach内で例外が発生すると、通常のループとは少し違う見え方になります。複数のイテレーションで同時に例外が発生する可能性があるためです。

C#
try
{
Parallel.ForEach(files, file =>
{
ConvertFile(file);
});
}
catch (AggregateException ex)
{
foreach (var inner in ex.InnerExceptions)
{
Console.WriteLine(inner.Message);
}
}

Microsoft Learnでは、Parallel.ForおよびParallel.ForEachには例外を処理する特別な仕組みはなく、複数スレッドで同時に例外が起きるケースを考慮し、AggregateExceptionで扱う方法が紹介されています。

6-2. AggregateExceptionの基本

AggregateExceptionは、複数の例外を1つの例外オブジェクトにまとめるための型です。TPLやPLINQで広く使われます。

次のようにFlattenを使うと、ネストされた例外を平坦化できます。

C#
try
{
Parallel.For(0, 100, i =>
{
if (i % 20 == 0)
{
throw new InvalidOperationException($"エラー: {i}");
}
});
}
catch (AggregateException ex)
{
foreach (var inner in ex.Flatten().InnerExceptions)
{
Console.WriteLine(inner.Message);
}
}

実務では、すべての例外を握りつぶすのではなく、ログに記録し、再実行可能か、処理を中断すべきかを判断します。

6-3. CancellationTokenでキャンセルする方法

Parallel.ForParallel.ForEachは、ParallelOptionsCancellationTokenを渡すことでキャンセルできます。

C#
using System;
using System.Threading;
using System.Threading.Tasks;

using var cts = new CancellationTokenSource();

var options = new ParallelOptions
{
CancellationToken = cts.Token
};

try
{
Parallel.For(0, 1_000_000, options, i =>
{
options.CancellationToken.ThrowIfCancellationRequested();

DoWork(i);

if (i == 1000)
{
cts.Cancel();
}
});
}
catch (OperationCanceledException)
{
Console.WriteLine("キャンセルされました。");
}

Microsoft Learnでは、並列ループでキャンセルするには、ParallelOptionsCancellationTokenにトークンを渡し、並列呼び出しをtry-catchで囲むと説明されています。

キャンセルは強制終了ではなく、協調的な中断です。処理側がキャンセル要求を確認し、適切なタイミングで終了するように書く必要があります。

6-4. BreakとStopでループを途中終了する方法

ParallelLoopStateには、途中終了用のBreakStopがあります。

Breakは、順序のあるループで「現在のインデックスより後の処理は不要」と伝えるために使います。

C#
ParallelLoopResult result = Parallel.For(0, 100, (i, state) =>
{
if (i == 30)
{
state.Break();
return;
}

DoWork(i);
});

Breakは、現在のイテレーションより後のイテレーションを実行しなくてよいことを示します。ただし、すでに開始済みのイテレーションは停止しません。

一方、Stopは「これ以上のイテレーションは不要」と伝えるために使います。

C#
ParallelLoopResult result = Parallel.ForEach(items, (item, state) =>
{
if (IsTarget(item))
{
state.Stop();
return;
}

DoWork(item);
});

Stopも未開始のイテレーションを不要とするだけで、すでに開始済みのイテレーションを強制停止するわけではありません。検索処理など、1件見つかったら残りは不要という場面で使われます。

6-5. タイムアウト処理を組み合わせる方法

一定時間で処理を打ち切りたい場合は、CancellationTokenSourceCancelAfterを使います。

C#
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(5));

var options = new ParallelOptions
{
CancellationToken = cts.Token
};

try
{
Parallel.ForEach(items, options, item =>
{
options.CancellationToken.ThrowIfCancellationRequested();
DoWork(item);
});
}
catch (OperationCanceledException)
{
Console.WriteLine("タイムアウトによりキャンセルされました。");
}

バッチ処理やファイル変換などで「一定時間を超えたら中断したい」場合に有効です。キャンセル時には、途中まで作成したファイルの削除、ログ出力、再実行用の状態保存などもあわせて設計しましょう。

7. async/await・Task.WhenAll・PLINQとの違い

7-1. Parallelとasync/awaitの違い

Parallelは主にCPUバウンドな処理を複数コアで同時に実行するために使います。

一方、async/awaitは、I/O待ちなどの非同期処理を読みやすく書くための仕組みです。awaitを使うと、対象のタスクが完了するまで呼び出し元に制御を戻せます。Microsoft Learnでも、awaitを適用すると、タスクが完了するまで呼び出し元へ制御を戻すと説明されています。

CPU計算を速くしたいならParallel、HTTP通信やファイルI/Oを効率よく待ちたいならasync/await、と考えると整理しやすいです。

7-2. I/Oバウンド処理にはTask.WhenAllが向いている理由

複数のWeb APIを呼び出すようなI/Oバウンド処理では、Parallel.ForEachよりTask.WhenAllが向いていることが多いです。

C#
using HttpClient httpClient = new();

string[] urls =
{
"https://example.com/a",
"https://example.com/b",
"https://example.com/c"
};

Task<string>[] tasks = urls
.Select(url => httpClient.GetStringAsync(url))
.ToArray();

string[] responses = await Task.WhenAll(tasks);

Task.WhenAllは、渡されたすべてのTaskが完了したときに完了するタスクを作成します。

I/O待ち中心の処理では、待っている間にスレッドを占有し続けない非同期APIを使うほうが効率的です。Parallel.ForEachの中で同期的にHTTP通信を行うと、スレッドをブロックしやすくなります。

7-3. Parallel.ForEachAsyncとの違い

Parallel.ForEachAsyncは、非同期デリゲートを並列に実行したい場合に使えるAPIです。

C#
using HttpClient httpClient = new();

var options = new ParallelOptions
{
MaxDegreeOfParallelism = 8
};

await Parallel.ForEachAsync(urls, options, async (url, token) =>
{
string html = await httpClient.GetStringAsync(url, token);
Console.WriteLine($"{url}: {html.Length}");
});

Parallel.ForEachAsyncは、IEnumerable<T>などに対するfor-each操作を、各イテレーションが並列に実行される可能性のある形で実行します。

Task.WhenAllは「作成したタスクをすべて待つ」API、Parallel.ForEachAsyncは「コレクションを並列度つきで非同期処理する」APIと考えると分かりやすいです。大量のURLやメッセージを処理するときに、同時実行数を制御したいならParallel.ForEachAsyncが便利です。

7-4. PLINQを使うべきケース

PLINQは、LINQクエリを並列化するための仕組みです。AsParallel()を使うことで、LINQの読みやすさを保ちながら並列処理を書けます。

C#
var results = numbers
.AsParallel()
.Where(x => IsPrime(x))
.Select(x => x * x)
.ToArray();

Microsoft Learnでは、PLINQはLINQパターンの並列実装であり、LINQ構文のシンプルさと並列プログラミングの力を組み合わせるものと説明されています。

ただし、PLINQも必ず速くなるわけではありません。順序保持が必要な場合はAsOrdered()を使えますが、順序付けにはコストがかかります。PLINQは既定ではソースシーケンスの順序を保持しません。

7-5. 目的別の使い分け早見表

目的推奨される選択肢
大量データをインデックスで処理したいParallel.For
コレクションの各要素を処理したいParallel.ForEach
非同期I/Oをまとめて待ちたいTask.WhenAll
非同期処理を並列度つきで処理したいParallel.ForEachAsync
LINQクエリを並列化したいPLINQ
UIを固めずに待ちたいasync/await
CPU負荷を制限したいParallelOptions.MaxDegreeOfParallelism

迷ったときは、「CPUを使う処理か、待ち時間が長い処理か」を最初に見極めるのがポイントです。

8. 実践サンプルで学ぶParallelの活用例

8-1. 大量データの計算処理を高速化する

大量の数値を変換する処理は、Parallel.Forの典型的な利用例です。

C#
int count = 10_000_000;
double[] input = Enumerable.Range(1, count)
.Select(x => (double)x)
.ToArray();

double[] output = new double[count];

Parallel.For(0, count, i =>
{
output[i] = Calculate(input[i]);
});

static double Calculate(double value)
{
for (int i = 0; i < 50; i++)
{
value = Math.Sqrt(value + i) * Math.Cos(value);
}

return value;
}

このように、各要素の計算が独立していて、共有データへの書き込みがoutput[i]だけなら、並列化しやすい構造です。

8-2. 画像処理・ファイル変換を並列化する

画像処理では、ファイル単位や行単位で並列化できます。次の例は、画像ファイルの変換処理をイメージしたコードです。

C#
string[] imageFiles = Directory.GetFiles("images", "*.png");

var options = new ParallelOptions
{
MaxDegreeOfParallelism = Environment.ProcessorCount
};

Parallel.ForEach(imageFiles, options, file =>
{
string outputPath = Path.Combine("output", Path.GetFileName(file));
ConvertImage(file, outputPath);
});

ファイル変換では、CPU負荷だけでなくディスクI/Oも関係します。同時実行数を増やしすぎると、ディスクアクセスが詰まって逆に遅くなる場合があります。MaxDegreeOfParallelismを変えながら測定しましょう。

8-3. ログ解析や集計処理に使う

大量のログ行を解析する場合も、各行の解析が独立していれば並列化できます。

C#
using System.Collections.Concurrent;

string[] lines = File.ReadAllLines("app.log");
ConcurrentDictionary<string, int> counts = new();

Parallel.ForEach(lines, line =>
{
string level = ParseLogLevel(line);

counts.AddOrUpdate(
level,
1,
(_, current) => current + 1);
});

ConcurrentDictionary<TKey, TValue>を使うことで、複数スレッドから安全に集計できます。

ただし、すべてのログを一度にReadAllLinesでメモリへ読み込むと、巨大ファイルではメモリを圧迫します。ファイルサイズが大きい場合は、分割読み込みやストリーミング処理も検討しましょう。

8-4. 重い処理を並列化する前後で速度比較する

並列化の効果は、計測して初めて判断できます。次のように、通常版と並列版を同じ条件で比較します。

C#
var sw = Stopwatch.StartNew();

foreach (var item in items)
{
Process(item);
}

sw.Stop();
Console.WriteLine($"foreach: {sw.ElapsedMilliseconds} ms");

sw.Restart();

Parallel.ForEach(items, item =>
{
Process(item);
});

sw.Stop();
Console.WriteLine($"Parallel.ForEach: {sw.ElapsedMilliseconds} ms");

測定時は、1回だけの結果で判断しないようにしましょう。JITコンパイル、キャッシュ、OSの状態、他プロセスの負荷によって結果がぶれるため、複数回測定して平均や中央値を見るのがおすすめです。

8-5. よくある失敗例と改善例

よくある失敗例は、共有リストに直接追加するコードです。

C#
List<string> results = new();

Parallel.ForEach(files, file =>
{
results.Add(ConvertFile(file)); // 危険
});

改善例は、インデックスを使って配列に格納する方法です。

C#
string[] results = new string[files.Length];

Parallel.For(0, files.Length, i =>
{
results[i] = ConvertFile(files[i]);
});

順序が不要なら、ConcurrentBag<T>も使えます。

C#
ConcurrentBag<string> results = new();

Parallel.ForEach(files, file =>
{
results.Add(ConvertFile(file));
});

もう1つの失敗例は、Parallel.ForEachの中でasyncメソッドを呼ぶことです。

C#
Parallel.ForEach(urls, async url =>
{
await DownloadAsync(url); // 避けたい書き方
});

この書き方では、async voidに近い扱いになり、例外処理や完了待ちが意図どおりにならない可能性があります。非同期処理にはTask.WhenAllParallel.ForEachAsyncを使いましょう。

C#
await Parallel.ForEachAsync(urls, async (url, token) =>
{
await DownloadAsync(url, token);
});

9. C# Parallelに関するよくある質問

9-1. Parallelを使えば必ず速くなる?

必ず速くなるわけではありません。

Parallelが効果を発揮しやすいのは、CPU負荷が高く、データ量が多く、各処理が独立している場合です。処理が軽すぎる場合、共有データへのロックが多い場合、I/O待ちが中心の場合は、通常のfor文やasync/awaitのほうが適していることがあります。

最終判断は必ず計測で行いましょう。

9-2. Parallel.ForEachの順番を保つ方法はある?

Parallel.ForEach自体の実行順序は保証されません。順番を保ちたい場合は、インデックス付きで処理して配列に格納する方法が実用的です。

C#
string[] results = new string[items.Length];

Parallel.For(0, items.Length, i =>
{
results[i] = Process(items[i]);
});

LINQ風に書きたい場合はPLINQのAsOrdered()も候補になります。ただし、順序保持にはコストがかかるため、必要な場合だけ使いましょう。PLINQでは既定で順序が保持されず、順序を保持したい場合はAsOrderedを使用します。

9-3. 並列数は自動で決まる?

基本的にはTPLがシステムリソースやワークロードに応じて調整します。TPLは、使用可能なプロセッサを効率的に使うようにコンカレンシーの程度を動的にスケールし、パーティション分割やThreadPoolでのスケジューリングなども扱います。

ただし、常に任せればよいとは限りません。サーバ環境でCPUを使い切りたくない場合、外部リソースへの負荷を抑えたい場合、ディスクI/Oが詰まる場合などは、MaxDegreeOfParallelismで上限を設定します。

9-4. asyncメソッドをParallel.ForEach内で使ってよい?

基本的には避けるべきです。

Parallel.ForEachは同期的な処理を並列化するためのAPIです。asyncラムダを渡すと、完了待ちや例外処理が分かりにくくなります。非同期メソッドを複数実行したい場合は、Task.WhenAllまたはParallel.ForEachAsyncを使いましょう。

C#
await Task.WhenAll(urls.Select(url => DownloadAsync(url)));

または、並列度を制御したい場合は次のようにします。

C#
await Parallel.ForEachAsync(
urls,
new ParallelOptions { MaxDegreeOfParallelism = 8 },
async (url, token) =>
{
await DownloadAsync(url, token);
});

9-5. 本番環境で使う前に確認すべきこと

本番環境でParallelを使う前に、少なくとも次の点を確認しましょう。

  • 通常処理と比較して本当に速くなっているか

  • CPU使用率が高くなりすぎないか

  • 同時アクセス時にサーバ全体の性能が落ちないか

  • 共有変数やコレクションに競合がないか

  • 例外が正しくログ出力されるか

  • キャンセルやタイムアウトに対応しているか

  • 処理順序に依存していないか

  • MaxDegreeOfParallelismの調整が必要ないか

  • UIスレッドやASP.NETのリクエスト処理をブロックしていないか

特に業務システムでは、単体テストだけでなく、負荷テストや障害時の動作確認も重要です。

まとめ

C#のParallelは、CPUバウンドな処理を手軽に並列化できる強力な仕組みです。Parallel.Forはインデックス範囲の処理に、Parallel.ForEachはコレクションの各要素の処理に向いています。

ただし、Parallelを使えば必ず速くなるわけではありません。実行順序は保証されず、共有変数の更新には競合リスクがあります。List.Addのような非スレッドセーフな処理は避け、必要に応じてlockInterlockedConcurrentBag<T>ConcurrentDictionary<TKey, TValue>などを使い分けましょう。

高速化を狙うなら、まず処理がCPUバウンドかどうかを見極め、Stopwatchで通常版と並列版を比較します。並列数を調整したい場合はMaxDegreeOfParallelism、負荷分散を細かく制御したい場合はPartitionerも活用できます。

また、I/Oバウンド処理にはTask.WhenAllParallel.ForEachAsync、LINQクエリの並列化にはPLINQという選択肢もあります。

「C# Parallel」や「csharp parallel」を学ぶうえで大切なのは、構文を覚えることだけではありません。処理の性質、スレッドセーフ、例外処理、キャンセル、計測をセットで理解することが、安全で効果的な並列処理への近道です。