「非同期(async/await)」と「並列(Parallel)」は混同されがちですが、解決する問題が根本的に異なります。非同期はI/O 待ち中に CPU を別の仕事に回すことで応答性を高めますが、並列は複数の CPU コアを同時に使うことで処理時間を短縮します。誤った選択は「CPU は空いているのに遅い」「CPU が 100% なのに詰まる」など本末転倒のパフォーマンス問題を生みます。
本記事では「並行(Concurrency)・並列(Parallelism)・非同期(Asynchrony)・マルチスレッド」の4概念を整理し、I/O-bound と CPU-bound の判断基準、Task.Run の正しい使い所、Parallel.For/ForEach/Invoke/ForEachAsync、PLINQ、TPL Dataflow、Channels、MaxDegreeOfParallelism、スレッド間の状態共有と False Sharing、sync-over-async の落とし穴まで体系的に解説します。
4つの概念 — 並行・並列・非同期・マルチスレッド
| 用語 | 意味 | 目的 | 例 |
|---|---|---|---|
| 並行(Concurrency) | 複数タスクを論理的に同時進行させる | プログラム構造の抽象化 | イベントループ・協調的マルチタスク |
| 並列(Parallelism) | 複数タスクを物理的に同時実行する | CPU バウンド処理の高速化 | Parallel.For・マルチコア計算 |
| 非同期(Asynchrony) | 処理の完了を待たず他の仕事をする | I/O 待機中の CPU 解放・応答性向上 | async/await・Task |
| マルチスレッド | 複数の実行フロー(スレッド)を使う | 並列の実装手段の一つ | Thread・ThreadPool |
並行は「複数タスクを扱うプログラム構造」という広い概念で、その実装手段として並列(複数コア)と非同期(単一コア内でのインタリーブ)の両方があります。非同期処理はシングルスレッドでも成立します(JavaScript のイベントループが典型例)。C# の
async/await も必ずしも新しいスレッドを作るとは限りません。「非同期=マルチスレッド」と思い込むと、本記事で扱う誤用に陥ります。最重要 — I/O-bound と CPU-bound の判断
「非同期と並列どちらを使うか」は、処理が CPU を使っているか(CPU-bound)か、CPU を使わずに待っているか(I/O-bound)かで決まります。
| 種類 | 特徴 | 例 | 推奨手段 |
|---|---|---|---|
| I/O-bound | ほとんどが外部待機(待ち時間 >> CPU 時間) | HTTP API 呼び出し・DB クエリ・ファイル読み書き | async / await。Task.Run 不要 |
| CPU-bound | ほとんどが計算(CPU が忙しい) | 画像処理・数値計算・暗号化・パース | Parallel.* / PLINQ / Task.Run |
| 混在 | 重い CPU 処理と外部待機が両方ある | 大量ファイルを読んで各々を解析 | Parallel.ForEachAsync(.NET 6+) |
// ① I/O-bound: HTTP 呼び出し → CPU を一切使わずネットワーク待ち
// → async/await が正解。Task.Run は CPU スレッドを占有するだけで無意味
public async Task<string> FetchAsync(string url)
{
using var http = new HttpClient();
return await http.GetStringAsync(url); // OK: I/O 待ち中はスレッドが解放される
}
// ② CPU-bound: 数値計算 → CPU が忙しい
// → Parallel や Task.Run で別スレッドに逃がす
public double ComputePi(int iterations)
{
double sum = 0;
for (int i = 0; i < iterations; i++)
sum += 4.0 * (i % 2 == 0 ? 1 : -1) / (2 * i + 1);
return sum;
}
// CPU-bound 処理を UI スレッドから追い出すときだけ Task.Run が有用
public async Task OnButtonClickAsync()
{
double result = await Task.Run(() => ComputePi(100_000_000));
Label.Text = result.ToString(); // UI スレッドで更新
}
// ③ 混在: 大量 URL を並行取得し、各結果をパース
// → Parallel.ForEachAsync(.NET 6+)が最適
var urls = GetUrls();
await Parallel.ForEachAsync(urls,
new ParallelOptions { MaxDegreeOfParallelism = 10 },
async (url, ct) =>
{
string html = await FetchAsync(url); // I/O 部分は非同期
var parsed = ParseDocument(html); // CPU 部分
await SaveAsync(parsed, ct);
});
Task.Run(async () => await httpClient.GetAsync(url)) は「スレッドプールの貴重なスレッドを1本確保して、そこで I/O 待機」という二重に無駄な書き方です。async Task なら I/O 中はスレッドを解放するため、数千の同時 HTTP 呼び出しをわずか数スレッドで捌けます。Task.Run が正当化されるのは「CPU-bound 処理を現在のスレッドから追い出したい時」だけです。Parallel クラス — 4つの主要 API
| API | 用途 | 特徴 |
|---|---|---|
Parallel.For |
インデックスループの並列化 | for 文の置き換え |
Parallel.ForEach |
コレクション走査の並列化 | foreach 文の置き換え |
Parallel.Invoke |
複数の独立処理を並列実行 | 異なる関数を同時に呼ぶ |
Parallel.ForEachAsync |
非同期処理を並列化(.NET 6+) | 各イテレーションが async な場合の決定版 |
// ① Parallel.For: インデックス 0〜N-1 の処理を並列化
var sums = new double[1000];
Parallel.For(0, sums.Length, i =>
{
sums[i] = ExpensiveCompute(i); // CPU 負荷の高い計算
});
// ② Parallel.ForEach: 既存コレクションの並列走査
var images = GetImageList();
Parallel.ForEach(images, img =>
{
ProcessImage(img);
});
// ③ Parallel.Invoke: 異なる処理を同時実行
Parallel.Invoke(
() => GenerateThumbnails(),
() => UpdateDatabase(),
() => CalculateStatistics());
// 3つの処理が並列に走り、すべて終わるまでブロック
// ④ Parallel.ForEachAsync(.NET 6+): 非同期処理の並列化
// MaxDegreeOfParallelism で同時実行数を制御できる
var urls = GetUrls();
await Parallel.ForEachAsync(urls,
new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
async (url, ct) =>
{
await DownloadAsync(url, ct);
});
PLINQ — LINQ の並列化
using System.Linq;
var numbers = Enumerable.Range(1, 10_000_000);
// 通常 LINQ(シングルスレッド)
int count = numbers.Where(IsPrime).Count();
// PLINQ: AsParallel() で並列実行に切り替え
int countPar = numbers.AsParallel()
.Where(IsPrime)
.Count();
// 並列度を制御
int count2 = numbers.AsParallel()
.WithDegreeOfParallelism(4) // 4 スレッドまで
.WithCancellation(cts.Token) // キャンセル対応
.Where(IsPrime)
.Count();
// 順序を保持したい場合(ソート済みを保つ)
var sorted = numbers.AsParallel()
.AsOrdered() // 元の順序を保証
.Where(IsPrime)
.Take(100)
.ToArray();
// ForAll: 結果の列挙を並列化(副作用あり処理用)
numbers.AsParallel().ForAll(n => Process(n));
static bool IsPrime(int n)
{
if (n < 2) return false;
for (int i = 2; i * i <= n; i++)
if (n % i == 0) return false;
return true;
}
PLINQ は軽量で書きやすいですが、スレッド間同期のオーバーヘッドがあるため「1要素あたりの処理が軽い」ケースでは
AsParallel() をつけるとむしろ遅くなることがあります。1要素あたりの処理が数ミリ秒以上で、要素間に依存がない時に効果的です。迷ったら必ず BenchmarkDotNet で計測してから採用してください。TPL Dataflow — パイプライン並列処理
using System.Threading.Tasks.Dataflow;
// ダウンロード → パース → 保存 のパイプライン
var downloadBlock = new TransformBlock<string, string>(
async url =>
{
using var http = new HttpClient();
return await http.GetStringAsync(url);
},
new ExecutionDataflowBlockOptions
{
MaxDegreeOfParallelism = 5, // 同時ダウンロード数
BoundedCapacity = 20, // バッファ上限
});
var parseBlock = new TransformBlock<string, Document>(
html => ParseDocument(html), // CPU-bound
new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 4 });
var saveBlock = new ActionBlock<Document>(
async doc => await SaveAsync(doc),
new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 2 });
// ブロックをリンク(完了もパイプライン化)
var linkOpts = new DataflowLinkOptions { PropagateCompletion = true };
downloadBlock.LinkTo(parseBlock, linkOpts);
parseBlock.LinkTo(saveBlock, linkOpts);
// URLを投入
foreach (var url in GetUrls())
await downloadBlock.SendAsync(url);
downloadBlock.Complete();
// 最後のブロックの完了を待つ
await saveBlock.Completion;
Channels — プロデューサ/コンシューマ
using System.Threading.Channels;
var channel = Channel.CreateBounded<Work>(new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.Wait,
});
// プロデューサ(通常1本〜数本)
var producer = Task.Run(async () =>
{
foreach (var w in GetWorkItems())
await channel.Writer.WriteAsync(w);
channel.Writer.Complete();
});
// コンシューマ(複数本でスケール)
var consumers = Enumerable.Range(0, 4).Select(_ => Task.Run(async () =>
{
await foreach (var w in channel.Reader.ReadAllAsync())
await ProcessAsync(w);
})).ToArray();
await Task.WhenAll(new[] { producer }.Concat(consumers));
| ツール | 得意な場面 | 適さない場面 |
|---|---|---|
Task / async / await |
1つの I/O を非同期化 | 複数要素を並列計算する |
Parallel.For / ForEach |
CPU-bound なコレクション処理 | 各イテレーションが非同期(ForEachAsync を使う) |
Parallel.ForEachAsync |
非同期処理の並列化(I/O + CPU 混在) | 純粋な CPU-bound(Parallel.For の方が軽い) |
PLINQ |
独立な変換・集計の並列化 | 副作用を含む処理・順序が重要な処理 |
TPL Dataflow |
パイプライン処理・バッファリング | 一本道でよい処理には過剰 |
Channels |
非同期プロデューサ/コンシューマ | CPU-bound な分割処理 |
並列度の制御 — MaxDegreeOfParallelism
// デフォルト: Environment.ProcessorCount(論理コア数)
// CPU-bound 処理なら通常これが最適
var opts = new ParallelOptions
{
MaxDegreeOfParallelism = Environment.ProcessorCount, // CPU コア数
CancellationToken = cts.Token,
};
Parallel.ForEach(items, opts, item => ProcessCpu(item));
// I/O-bound 処理(Parallel.ForEachAsync)では CPU コア数より多くてよい
// → ネットワーク待ち中もスレッドが解放されるため、並列数を増やせる
await Parallel.ForEachAsync(urls,
new ParallelOptions { MaxDegreeOfParallelism = 50 }, // 同時 50 コネクション
async (url, ct) => await FetchAsync(url, ct));
// MaxDegreeOfParallelism = 1 → シーケンシャル実行(デバッグ用)
// MaxDegreeOfParallelism = -1 → 制限なし(通常は避ける)
CPU-bound 処理の並列度は「論理コア数(
Environment.ProcessorCount)」が上限目安です。これを超えるとコンテキストスイッチ増でむしろ遅くなります。一方 I/O-bound 処理はCPU を使わずに待つ時間があるため、コア数×数十の並列度でも効率よく動きます。外部 API へのリクエストはサーバー側のレートリミットや接続上限の方が律速になるので、相手の許容量を確認してから並列度を決めてください。並列処理での状態共有
// NG: 共有変数への競合書き込み → 非決定的な結果
int total = 0;
Parallel.For(0, 10_000_000, i =>
{
total += i; // データ競合!
});
Console.WriteLine(total); // 毎回違う値
// OK ①: Interlocked で原子的にインクリメント(軽量)
long totalSafe = 0;
Parallel.For(0, 10_000_000, i =>
{
Interlocked.Add(ref totalSafe, i);
});
// OK ②: lock で排他制御(重いが柔軟)
int total2 = 0;
object gate = new();
Parallel.For(0, 10_000_000, i =>
{
lock (gate) { total2 += i; }
});
// OK ③: Parallel.For のローカル状態パターン(最速)
// 各スレッドが自分のローカル変数で集計し、最後だけまとめる
long total3 = 0;
Parallel.For(0, 10_000_000,
localInit: () => 0L, // スレッドごとの初期値
body: (i, state, local) => local + i, // ローカル加算
localFinally: local => Interlocked.Add(ref total3, local)); // 最後に合算
// OK ④: 並行コレクションを使う(ConcurrentBag / ConcurrentDictionary 等)
var bag = new ConcurrentBag<int>();
Parallel.For(0, 1000, i => bag.Add(i * i));
CPU は「キャッシュライン」(通常 64 バイト)単位でメモリをキャッシュするため、別スレッドが書き込む異なる変数が同じキャッシュラインに載っていると、キャッシュが相互に無効化されて性能が激減します。配列の隣接要素をスレッドごとに書き込むような設計はこの問題に陥りがちで、「スレッドローカル変数で集計して最後だけ共有更新」のパターンが安全です。
Parallel.For の localInit / localFinally が正にこの最適化を表現しています。キャンセルと進捗通知
using var cts = new CancellationTokenSource();
// Ctrl+C で中止できる
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
// ① CancellationToken を全パスに伝える
try
{
await Parallel.ForEachAsync(items,
new ParallelOptions
{
MaxDegreeOfParallelism = 4,
CancellationToken = cts.Token,
},
async (item, ct) => await ProcessAsync(item, ct));
}
catch (OperationCanceledException)
{
Console.WriteLine("処理が中止されました");
}
// ② IProgress<T> で UI に進捗を通知
var progress = new Progress<int>(p => Console.WriteLine($"{p}% 完了"));
int completed = 0;
int total = items.Count;
await Parallel.ForEachAsync(items,
async (item, ct) =>
{
await ProcessAsync(item, ct);
int done = Interlocked.Increment(ref completed);
((IProgress<int>)progress).Report(done * 100 / total);
});
よくある落とし穴
// NG: async void は例外を呼び出し側で catch できない・テストしづらい
public async void OnClick(object sender, EventArgs e)
{
await Task.Delay(1000);
throw new Exception("エラー!"); // このまま AppDomain 全体が落ちる
}
// OK: イベントハンドラ以外は必ず async Task
public async Task OnClickHandler() { /* ... */ }
// イベントハンドラの async void でも try-catch で例外を握る
public async void OnClick2(object sender, EventArgs e)
{
try { await DoWorkAsync(); }
catch (Exception ex) { Logger.Error(ex); }
}
// NG: async メソッドを同期的に .Result で待つ
// → UI スレッドや ASP.NET Classic でデッドロック
public async Task<string> FetchAsync() { /* ... */ }
// 以下はデッドロックの可能性あり
string data = FetchAsync().Result; // NG
FetchAsync().Wait(); // NG
// OK: Task.Run でスレッドプールに逃がす、または全パスを async 化
string data2 = Task.Run(() => FetchAsync()).GetAwaiter().GetResult(); // やむを得ない時のみ
string data3 = await FetchAsync(); // 最良: 呼び出し側も async にする
// NG: Parallel.ForEach は同期メソッドを前提にしている
// async ラムダを渡すと「fire-and-forget」になり Wait されない
Parallel.ForEach(urls, async url =>
{
await DownloadAsync(url); // ← ループは await されずに即終了する!
});
// OK: 非同期処理の並列化には Parallel.ForEachAsync を使う(.NET 6+)
await Parallel.ForEachAsync(urls, async (url, ct) =>
{
await DownloadAsync(url, ct);
});
// OK: .NET 5 以前なら Task.WhenAll
await Task.WhenAll(urls.Select(DownloadAsync));
// Task.WhenAll は最初の例外だけ再スローするが
// AggregateException には全エラーが入っている
var tasks = urls.Select(FetchAsync);
try
{
await Task.WhenAll(tasks);
}
catch (Exception)
{
// この catch には最初の例外しか来ない
// 全部のエラーを見たい場合は Task のプロパティから
var all = tasks.Where(t => t.IsFaulted).Select(t => t.Exception!);
foreach (var ex in all)
Console.WriteLine(ex.InnerException!.Message);
}
よくある質問
async Task、CPU-bound なら Task.Runが基本です。async Task は I/O 待機中にスレッドを解放するため、少ないスレッドで大量の並行処理を捌けます。Task.Run はスレッドプールから1本確保して CPU 処理を走らせるため、I/O 処理に使うとスレッドを無駄に占有します。ただし UI スレッドから重い計算を逃がしたいときは Task.Run が正解です。Parallel.ForEachAsync(.NET 6+)が最適です。async ベースで書けばスレッドはほとんど消費しません。ThreadPool.SetMinThreads をむやみに上げると、逆にコンテキストスイッチ増でパフォーマンスが悪化します。本当に必要なのは「sync over async」の排除や「ブロッキング呼び出しを Task.Run に逃がす」など、スレッドの使い方の見直しです。Task・Parallel・async/await でほとんどの用途がカバーされ、Thread を直接生成する必要はほぼありません。例外的に「長時間実行・専用スレッドで回したい・COM STA が必要」などの特殊要件がある場合のみ new Thread(...) { IsBackground = true }.Start() を使います。通常の並列処理には Parallel か Task、バックグラウンドの長期処理は BackgroundService / IHostedService が適しています。dotnet-counters・PerfView・BenchmarkDotNet で計測し、どのリソースが飽和しているかを特定してから対策してください。まとめ
| 判断軸 | 選ぶツール |
|---|---|
| 1つの I/O を非同期化 | async / await |
| CPU-bound なコレクション処理 | Parallel.For / Parallel.ForEach |
| 独立した数個の処理を同時実行 | Parallel.Invoke |
| 非同期処理の並列化 | Parallel.ForEachAsync(.NET 6+) |
| LINQ で変換・集計を並列化 | PLINQ(AsParallel()) |
| パイプライン処理 | TPL Dataflow |
| プロデューサ/コンシューマ | Channel<T> |
| UI から CPU 処理を逃がす | Task.Run |
| キャンセル/進捗 | CancellationToken + IProgress<T> |
| スレッド間の集計 | Parallel.For の localInit/localFinally、Interlocked、Concurrent* |
より詳細な関連機能は以下を参照してください。async/await 完全ガイドでステートマシン・ConfigureAwait・ValueTask・IAsyncEnumerable・デッドロック対策の詳細、Queue と Stack 完全ガイドで Channels や ConcurrentQueue の詳細、CancellationToken 完全ガイドでキャンセル処理の実装パターン、LINQ 完全ガイドで PLINQ との使い分けを解説しています。

