【C#】非同期処理と並列処理の違い完全ガイド|Task・Parallel・PLINQ・Dataflow・Channelsの使い分け

【C#】非同期処理と並列処理の違い|TaskとParallelの使い分け C#

「非同期(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 / awaitTask.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);
    });
I/O-bound に Task.Run を使うのは典型的なアンチパターン
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 な場合の決定版
4つの API の実例
// ① 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 の並列化

AsParallel で 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 は「CPU-bound かつ独立な計算」に限る
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 — プロデューサ/コンシューマ

Channel で非同期プロデューサ-コンシューマ
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 → 制限なし(通常は避ける)
I/O と CPU で並列度の基準が違う
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));
False Sharing — 「別々の変数なのに遅い」現象
CPU は「キャッシュライン」(通常 64 バイト)単位でメモリをキャッシュするため、別スレッドが書き込む異なる変数が同じキャッシュラインに載っていると、キャッシュが相互に無効化されて性能が激減します。配列の隣接要素をスレッドごとに書き込むような設計はこの問題に陥りがちで、「スレッドローカル変数で集計して最後だけ共有更新」のパターンが安全です。Parallel.ForlocalInit / localFinally が正にこの最適化を表現しています。

キャンセルと進捗通知

CancellationToken と Progress
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);
    });

よくある落とし穴

落とし穴① — async void
// 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); }
}
落とし穴② — Sync over Async(.Result / .Wait)
// 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 にする
落とし穴③ — Parallel 内の 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 の例外はまとめて集約
// 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);
}

よくある質問

Qasync Task と Task.Run はどちらを使うべきですか?
AI/O-bound なら async Task、CPU-bound なら Task.Runが基本です。async Task は I/O 待機中にスレッドを解放するため、少ないスレッドで大量の並行処理を捌けます。Task.Run はスレッドプールから1本確保して CPU 処理を走らせるため、I/O 処理に使うとスレッドを無駄に占有します。ただし UI スレッドから重い計算を逃がしたいときは Task.Run が正解です。
QParallel.For と Task.WhenAll(Enumerable.Select(…)) の違いは?
AParallel.For は CPU-bound 用、Task.WhenAll は I/O-bound 用です。Parallel.For は同期メソッドを並列実行するもので、内部で適切にパーティショニングし CPU コア数に応じた並列度を自動調整します。一方 Task.WhenAll(Select) は非同期メソッドの完了をまとめて待つパターンで、I/O の同時並行に向いています。混在ケース(非同期を並列化)では Parallel.ForEachAsync(.NET 6+)が最適です。
Qスレッドプールのサイズはどう調整すべきですか?
A通常は調整不要です。.NET のスレッドプールは自動でスケーリングし、CPU コア数を基準に適切なスレッド数を維持します。I/O-bound な処理が大量にあっても async ベースで書けばスレッドはほとんど消費しません。ThreadPool.SetMinThreads をむやみに上げると、逆にコンテキストスイッチ増でパフォーマンスが悪化します。本当に必要なのは「sync over async」の排除や「ブロッキング呼び出しを Task.Run に逃がす」など、スレッドの使い方の見直しです。
QThread クラスは今でも使うべきですか?
A基本的には使いません。現代の C# では TaskParallelasync/await でほとんどの用途がカバーされ、Thread を直接生成する必要はほぼありません。例外的に「長時間実行・専用スレッドで回したい・COM STA が必要」などの特殊要件がある場合のみ new Thread(...) { IsBackground = true }.Start() を使います。通常の並列処理には ParallelTask、バックグラウンドの長期処理は BackgroundService / IHostedService が適しています。
Q並列度を上げても速くなりません。なぜですか?
Aいくつか原因があります: 処理が I/O-bound で、ネットワークや DB がボトルネック → 並列度を上げても相手が捌けない。 共有ロックがあり、スレッドがロック待ちで直列化している。 False Sharing で CPU キャッシュが相互無効化。 GC 圧迫(大量のアロケーション)。 Context Switch コストが利得を上回っている。dotnet-countersPerfViewBenchmarkDotNet で計測し、どのリソースが飽和しているかを特定してから対策してください。

まとめ

判断軸 選ぶツール
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、InterlockedConcurrent*

より詳細な関連機能は以下を参照してください。async/await 完全ガイドでステートマシン・ConfigureAwait・ValueTask・IAsyncEnumerable・デッドロック対策の詳細、Queue と Stack 完全ガイドで Channels や ConcurrentQueue の詳細、CancellationToken 完全ガイドでキャンセル処理の実装パターン、LINQ 完全ガイドで PLINQ との使い分けを解説しています。