C#/.NETの非同期・並列・同時実行性の違いと実践コード:async/await・Parallel・PLINQ完全ガイド

「非同期」「並列」「同時実行性」は似て非なる概念です。本記事では、C#/.NET の実務で迷いがちな三つのキーワードを、I/O バウンドと CPU バウンドという軸で整理し、そのまま持ち込めるコード例とチューニング指針を体系化します。async/await、Parallel、PLINQ、SemaphoreSlim、Channel、RateLimiter まで、落とし穴と回避策を含めて現場視点で解説します。

目次

C#/.NET における「非同期」「並列」「同時実行性」の整理

まずは用語の地図を揃えます。以下の表は「何に効くのか」「どんな API を使うのか」「何を勘違いしやすいのか」を 1 画面で俯瞰できるようにまとめたものです。

区分概念主な用途核心ポイント代表 API/構文
非同期 (Asynchrony)スレッドをブロックせず I/O 待機時間を他作業に充当I/O バウンド(HTTP・DB・ファイル)待機を重ね合わせる。スレッドプール枯渇を防ぐ。UI を固めない。async/await、Task.WhenAll、IAsyncEnumerable<T>
並列 (Parallelism)複数スレッドを同時稼働して CPU を使い切るCPU バウンド(数値計算・画像処理)真の同時実行。チャンク分割とスレッド数の調整が鍵。Parallel.For/ForEach、PLINQ (AsParallel)、ParallelOptions
同時実行性 (Concurrency)“同時に進行しているように見える” 仕組み全般(非同期+並列の構成術)アプリ全体のスループット最適化、パイプライン設計リソースに合わせて上限をかける(DB 接続数、外部 API、CPU コア)。SemaphoreSlim、System.Threading.Channels、RateLimiter

使い分けのクイック判定

  • 待つ時間が支配的(ネットワーク、ディスク、DB)なら → 非同期(async/await)。
  • 計算が支配的(ループ内で重い CPU 計算)なら → 並列(Parallel / PLINQ)。
  • 混在(取得→解析→保存の流れ)なら → 同時実行性(パイプライン+上限制御)。

非同期:I/O を待ちながら進める(async/await の実戦)

同時に複数 HTTP を投げて待機時間を重ねる

using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Linq;

public static async Task FetchAllAsync(CancellationToken ct)
{
var endpoints = new[] { "/api/slow/2", "/api/slow/3", "/api/slow/1" };

```
using var client = new HttpClient { BaseAddress = new Uri("http://localhost:5000") };

// 発行だけして待たない(ここではスレッドはブロックされない)
var tasks = endpoints.Select(path =&gt; client.GetStringAsync(path, ct));

// すべての完了を待つ。合計時間 ≒ 最も遅い I/O の時間。
return await Task.WhenAll(tasks);
```

} 
  • UI やサーバースレッドを塞がないため、スケーラビリティが大幅に向上します。
  • スレッドプールの不足(いわゆる “Starvation”)を避ける最良の手段は、ブロッキングをやめて非同期化することです。

非同期 I/O に上限をかける(外部 API、DB の礼儀)

無制限の同時実行は相手サーバーや DB プールを飽和させます。SemaphoreSlim で上限を設けます。

public static async Task<IReadOnlyList<string>> FetchWithLimitAsync(
    IEnumerable<string> paths, int maxConcurrency, CancellationToken ct)
{
    using var client = new HttpClient { BaseAddress = new Uri("http://localhost:5000") };
    using var gate = new SemaphoreSlim(maxConcurrency);
    var results = new List<string>();

```
var tasks = paths.Select(async path =&gt;
{
    await gate.WaitAsync(ct);
    try
    {
        var s = await client.GetStringAsync(path, ct);
        lock (results) results.Add(s); // スレッド安全に追加
    }
    finally
    {
        gate.Release();
    }
});

await Task.WhenAll(tasks);
return results;
```

} 

キャンセルとタイムアウトの併用

public static async Task&lt;T&gt; WithTimeoutAsync&lt;T&gt;(Task&lt;T&gt; task, TimeSpan timeout, CancellationToken ct)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
    var delayTask = Task.Delay(timeout, cts.Token);
    var first = await Task.WhenAny(task, delayTask);
    if (first == task)
    {
        cts.Cancel(); // Delay をキャンセル
        return await task; // 例外・キャンセルはここで伝播
    }
    throw new TimeoutException();
}

非同期ストリーム(IAsyncEnumerable)で巨大データを逐次処理

public static async IAsyncEnumerable&lt;string&gt; ReadLinesAsync(Stream stream, 
    [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
    using var reader = new StreamReader(stream);
    while (!reader.EndOfStream)
    {
        ct.ThrowIfCancellationRequested();
        yield return await reader.ReadLineAsync();
    }
}

ライブラリでの ConfigureAwait(false) と UI の注意

  • WPF/WinForms など 同期コンテキストがある UI では、長い処理を await した後に UI スレッドへ戻る必要がある箇所以外、ConfigureAwait(false) を検討します。
  • ASP.NET Core では SynchronizationContext が無いため、戻り先は通常気にしなくて構いません。ただしライブラリ(UI・サーバーどちらからも呼ばれる可能性がある)では ConfigureAwait(false) を付けるのが無難です。

Task.Run は “速くする魔法” ではない

Task.Run はUI スレッドを空ける目的では有効ですが、計算を速くするわけではありません。速度を上げたいなら Parallel 系やアルゴリズム最適化が必要です。


並列:CPU を使い切って速くする(Parallel / PLINQ)

PLINQ で素数を数える(CPU バウンド)

using System;
using System.Linq;

static bool IsPrime(int n)
{
if (n < 2) return false;
if (n % 2 == 0) return n == 2;
var limit = (int)Math.Sqrt(n);
for (int i = 3; i <= limit; i += 2)
if (n % i == 0) return false;
return true;
}

public static int CountPrimeParallel(int limit)
{
return Enumerable.Range(2, limit - 1)
.AsParallel()
.WithDegreeOfParallelism(Environment.ProcessorCount)
.Count(IsPrime);
} 
  • データは自動的にチャンク分割され、各コアで同時計算します。
  • 対象が小さい/処理が軽い場合はオーバーヘッドが勝ちます。ベンチマークで確認しましょう。

Parallel.For とローカル状態でロックを避ける

using System.Threading.Tasks;
using System.Threading;

public static long SumParallel(int[] data)
{
long total = 0;

```
Parallel.For(0, data.Length,
    () =&gt; 0L, // ローカル初期値(各スレッド)
    (i, state, local) =&gt; local + data[i], // 各要素の加算
    local =&gt; Interlocked.Add(ref total, local) // ローカル合算を一気に反映
);

return total;
```

} 

共有ロックを避け、集約は最後に 1 回だけ行うのが高速化の基本です。

Parallel の中断・キャンセル・上限

public static void WorkWithControl(Action<int> work, int n, CancellationToken ct)
{
    var options = new ParallelOptions
    {
        MaxDegreeOfParallelism = Environment.ProcessorCount,
        CancellationToken = ct
    };

```
try
{
    Parallel.For(0, n, options, (i, state) =&gt;
    {
        work(i);
        if (ShouldStop(i)) state.Stop(); // できるだけ早く停止
    });
}
catch (OperationCanceledException)
{
    // キャンセル時の後片付け
}
```

} 

PLINQ の制御ポイント

  • WithDegreeOfParallelism:並列度の上限。
  • AsOrdered():元順序を維持(ただし合流コスト増)。
  • WithMergeOptions:結果の合流タイミング(パイプ・バッファリング)。

同時実行性:非同期と並列を組み合わせた “流れ” の設計

プロデューサー&コンシューマー(Channel)で I/O+CPU の混在に強く

System.Threading.Channels は高効率なスレッドセーフキューです。ダウンロード(I/O)→解析(CPU)→保存(I/O)をパイプライン化し、各段に適切な並列度を設定します。

using System.Threading.Channels;

public static async Task RunPipelineAsync(IEnumerable paths, CancellationToken ct)
{
var ch1 = Channel.CreateBounded(new BoundedChannelOptions(256) { FullMode = BoundedChannelFullMode.Wait });
var ch2 = Channel.CreateBounded<(string raw, object parsed)>(new BoundedChannelOptions(256) { FullMode = BoundedChannelFullMode.Wait });

```
// Producer: I/O (非同期)
_ = Task.Run(async () =&gt;
{
    using var client = new HttpClient { BaseAddress = new Uri("http://localhost:5000") };
    foreach (var p in paths)
    {
        ct.ThrowIfCancellationRequested();
        var raw = await client.GetStringAsync(p, ct);
        await ch1.Writer.WriteAsync(raw, ct);
    }
    ch1.Writer.Complete();
}, ct);

// Parser workers: CPU(並列度 = 物理コア数)
var parserWorkers = Enumerable.Range(0, Environment.ProcessorCount).Select(_ =&gt; Task.Run(async () =&gt;
{
    await foreach (var raw in ch1.Reader.ReadAllAsync(ct))
    {
        var parsed = Parse(raw); // CPU バウンド
        await ch2.Writer.WriteAsync((raw, parsed), ct);
    }
}, ct)).ToArray();

// Saver: I/O(非同期、単一でも複数でもよい)
var saver = Task.Run(async () =&gt;
{
    await Task.WhenAll(parserWorkers);
    ch2.Writer.Complete();

    await foreach (var item in ch2.Reader.ReadAllAsync(ct))
    {
        await SaveAsync(item.parsed, ct); // 非同期 I/O
    }
}, ct);

await saver;
```

} 

各段にバックプレッシャー(バッファ上限)を設けることで、一番遅い段に合わせて全体が暴走しない設計になります。

RateLimiter で外部 API へ礼儀正しく

using System.Threading.RateLimiting;

public static async Task> FetchWithRateLimitAsync(IEnumerable paths, CancellationToken ct)
{
using var limiter = new ConcurrencyLimiter(new ConcurrencyLimiterOptions
{
PermitLimit = 8,           // 同時 8 件まで
QueueLimit = 1024,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
});

```
using var client = new HttpClient { BaseAddress = new Uri("http://localhost:5000") };
var tasks = paths.Select(async p =&gt;
{
    using var lease = await limiter.AcquireAsync(1, ct);
    return await client.GetStringAsync(p, ct);
});

return await Task.WhenAll(tasks);
```

} 

「落とし穴」×「対策」早見表

落とし穴症状対策
同期呼び出しの混入.Result / .Wait() で UI フリーズ、デッドロック「端から端まで」非同期。UI では await、ライブラリでは ConfigureAwait(false)
I/O を Parallel で回すスレッドプール枯渇、タイムアウト頻発非同期 I/O+SemaphoreSlim で上限制御
小粒タスク過多並列オーバーヘッド > 実行時間手動バッチ化、PLINQ のチャンク調整、局所変数で集約
共有リソースの争奪ロック競合・スループット低下不変・コピーオンライト・ローカル集約・Interlocked
例外の握り潰し失敗に気付けない/再実行できないTask.WhenAll の 後で例外を観測、AggregateException 展開
HTTP クライアント乱立ソケット枯渇・DNS キャッシュ無効HttpClient は再利用。必要ならファクトリを用意

例外処理とエラーハンドリングの定石

WhenAll で集約、個別結果も確保する

public sealed record FetchResult(string Path, string? Content, Exception? Error);

public static async Task> FetchAllRobustAsync(IEnumerable paths, CancellationToken ct)
{
using var client = new HttpClient { BaseAddress = new Uri("[http://localhost:5000](http://localhost:5000)") };
var tasks = paths.Select(async p =>
{
try
{
var s = await client.GetStringAsync(p, ct);
return new FetchResult(p, s, null);
}
catch (Exception ex)
{
return new FetchResult(p, null, ex);
}
});

```
return await Task.WhenAll(tasks);
```

} 

“全部終わってから失敗と成功を仕分ける” のが復旧や再試行の基本形です。

Parallel 系の例外は AggregateException

try
{
    Parallel.Invoke(
        () =&gt; DoCpuWork(1),
        () =&gt; DoCpuWork(2),
        () =&gt; DoCpuWork(3)
    );
}
catch (AggregateException ae)
{
    foreach (var ex in ae.Flatten().InnerExceptions)
    {
        // 失敗ログ・個別復旧
    }
}

パフォーマンスを数字で掴む:計測のひな形

using System.Diagnostics;

public static async Task MeasureAsync(string name, Func> work)
{
var sw = Stopwatch.StartNew();
var result = await work();
sw.Stop();
Console.WriteLine($"{name}: {sw.ElapsedMilliseconds} ms");
return result;
} 
  • 計測は「ウォームアップ → 本計測 → 繰り返し」の順で。JIT の影響を除きます。
  • サーバーでは RPS(1 秒間のリクエスト数)と P95/P99 レイテンシも併せて確認します。

現場でそのまま使えるレシピ集

大量ダウンロードを礼儀正しく高速に(I/O 非同期 + 上限制御)

public static async Task DownloadManyAsync(IEnumerable<Uri> uris, string dir, CancellationToken ct)
{
    Directory.CreateDirectory(dir);
    using var gate = new SemaphoreSlim(16); // 回線や相手の許容量に合わせて
    using var client = new HttpClient();

```
var tasks = uris.Select(async uri =&gt;
{
    await gate.WaitAsync(ct);
    try
    {
        await using var s = await client.GetStreamAsync(uri, ct);
        var file = Path.Combine(dir, Path.GetFileName(uri.LocalPath));
        await using var fs = File.Create(file);
        await s.CopyToAsync(fs, ct); // 完全非同期
    }
    finally
    {
        gate.Release();
    }
});

await Task.WhenAll(tasks);
```

} 

画像フィルターを並列化(CPU)

public static void ApplyFilterParallel(Span&lt;byte&gt; pixels, int width, int height)
{
    int stride = width * 4; // RGBA
    Parallel.For(0, height, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, y =&gt;
    {
        var row = pixels.Slice(y * stride, stride);
        for (int x = 0; x &lt; stride; x += 4)
        {
            row[x + 0] = (byte)(255 - row[x + 0]); // B
            row[x + 1] = (byte)(255 - row[x + 1]); // G
            row[x + 2] = (byte)(255 - row[x + 2]); // R
        }
    });
}

取得→解析→保存の 3 段パイプライン(混在)

上の Channel 例を小さくしたバージョンを掲載します。

public static async Task SmallPipelineAsync(IEnumerable<string> ids, CancellationToken ct)
{
    var ch = Channel.CreateBounded<string>(128);

```
// producer
_ = Task.Run(async () =&gt;
{
    foreach (var id in ids)
    {
        var raw = await FetchByIdAsync(id, ct);   // 非同期 I/O
        await ch.Writer.WriteAsync(raw, ct);
    }
    ch.Writer.Complete();
}, ct);

// CPU heavy consumers
var workers = Enumerable.Range(0, Environment.ProcessorCount).Select(_ =&gt; Task.Run(async () =&gt;
{
    await foreach (var raw in ch.Reader.ReadAllAsync(ct))
    {
        var parsed = Parse(raw);                  // CPU
        await SaveAsync(parsed, ct);              // 非同期 I/O
    }
}, ct));

await Task.WhenAll(workers);
```

} 

“とりあえず Task.Run” を卒業する置換パターン

NGOK理由
Task.Run(() => http.GetStringAsync(...).Result)await http.GetStringAsync(...)非同期 I/O は await だけでよい。スレッド浪費を防ぐ。
Task.Run(() => HeavyCpu())Parallel.Invoke(() => HeavyCpu())並列度や中断制御を含めたチューニングが可能。
Parallel.ForEach(urls, url => http.GetStringAsync(url).Result)await Task.WhenAll(urls.Select(u => http.GetStringAsync(u)))I/O を Parallel で回さない。完全非同期に。

設計チェックリスト(レビュー用)

  • 境界の確認:この処理は I/O バウンドか? CPU バウンドか? 混在か?
  • 非同期の徹底:同期呼び出し(.Result、.Wait()、Thread.Sleep)が紛れ込んでいないか?
  • 並列度の上限:CPU 系は MaxDegreeOfParallelism を、I/O 系は SemaphoreSlim / RateLimiter を設けたか?
  • 共有資源:ロックやコンテンドがボトルネックにならないよう設計したか?
  • エラー設計:失敗を集約し再試行の単位が分かるか?
  • キャンセル:CancellationToken が末端まで貫通しているか?
  • 計測:ベンチマークと P95 レイテンシを記録しているか?

より深い最適化のヒント

CPU 向け

  • キャッシュ局所性:データを連続メモリ(配列や Span<T>)で扱い、チャンクを大きめに分割。
  • SIMD:System.Numerics.Vector<T> を使うと一度に複数要素を処理できる。
  • False Sharing 回避:スレッド間で隣接メモリを更新しない(パディング、ローカル集約)。

I/O 向け

  • ストリーミング:まとめて読み込まず、Stream.CopyToAsync や IAsyncEnumerable で逐次処理。
  • プール:ArrayPool<T> で使い捨て配列を減らし GC 圧力を下げる。
  • 接続管理:HttpClient を再利用し、DNS・ソケットを枯渇させない。

UI・デスクトップ(WPF/WinForms)での注意

  • イベントハンドラ以外で async void は使わない。例外が捕まえられなくなります。
  • 重い CPU 処理は Parallel(もしくはバックグラウンドサービス)へ退避し、結果だけ UI スレッドへ SynchronizationContext.Post / Dispatcher で戻す設計に。
  • 進捗は IProgress<T> で UI に繋ぐと、キャンセルと合わせて UX が上がります。

ASP.NET Core の実務ポイント

  • コントローラー/ミドルウェアは 必ず非同期に。await を徹底し、ブロックを残さない。
  • 外部サービスの同時呼び出しは Task.WhenAll で束ね、SemaphoreSlim / RateLimiter で上限。
  • CPU バウンドな集計やテンプレートレンダリングは別のバックグラウンド処理へ寄せるか、Parallel でコアを活かす。
  • キューイング(Channel、キューサービス)を介してスパイクを平準化し、アプリの同時実行性を保つ。

ValueTask を選ぶか?

  • 非常に短い非同期操作で ほぼ同期完了 が期待できる場合のみ検討。一般には Task で十分。
  • API のシグネチャを安易に ValueTask にしない(呼び出し側が誤用しやすい)。

サンプル:I/O と CPU を “最短” で完了させる併用コード

public static async Task ProcessAllAsync(IEnumerable<string> paths, CancellationToken ct)
{
    // 1) I/O は完全非同期+上限
    var raws = await FetchWithLimitAsync(paths, maxConcurrency: 16, ct);

```
// 2) CPU は並列で処理
var parsed = new ConcurrentBag&lt;object&gt;();
Parallel.ForEach(raws, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, raw =&gt;
{
    var p = Parse(raw);
    parsed.Add(p);
});

// 3) 保存は非同期で束ねる
var saveTasks = parsed.Select(x =&gt; SaveAsync(x, ct));
await Task.WhenAll(saveTasks);
```

} 

この 3 ステップこそが、非同期(I/O)と並列(CPU)を混同せずに最大スループットを引き出す基本形です。


用語の再定義(覚えて帰れる要約)

  • 非同期:スレッドを眠らせず待つ。I/O バウンドで威力を発揮。
  • 並列:複数コアで同時計算。CPU バウンドを速くする。
  • 同時実行性:非同期と並列の 構成術。上限とバッファで安定させる。

チェックポイント付きの実装ガイドライン

  1. まず分類:処理を I/O と CPU に分割し、境界にキュー(Channel)を置く。
  2. I/O は await 徹底:外側から内側まで同期呼び出しを排除。必ずキャンセルを通す。
  3. CPU は Parallel/PLINQ:並列度は Environment.ProcessorCount から着手し、計測で調整。
  4. 上限制御:外部 API・DB には SemaphoreSlim や RateLimiter で 礼儀を守る。
  5. 例外の集約:Task.WhenAll 後に成功・失敗を分岐、再試行単位を切る。
  6. 計測ファースト:“速そう” ではなく数値で比較。小粒タスクはまとめる。

よくある質問(FAQ)

Q: 「async/await を使えば並列になりますか?」
A: いいえ。async/await は I/O の待機を重ねるための仕組みで、CPU を同時に走らせるわけではありません。CPU を速くしたいなら Parallel / PLINQ、またはアルゴリズム改善が必要です。

Q: 「Task.Run で I/O を包むのはアリ?」
A: ほぼ無し。非同期 I/O は await すれば十分で、Task.Run はスレッドを無駄に消費します。UI を空けたい CPU 仕事をオフロードする用途に限ります。

Q: 「ConfigureAwait(false) は常に必要?」
A: UI アプリやライブラリでは有効。ASP.NET Core 単体では必須ではありませんが、再利用可能なライブラリでは付けるほうが安全です。


まとめ

  • I/O 待ち → 非同期(async/await):スレッドを塞がず高効率。
  • CPU 集約 → 並列(Parallel/PLINQ):コアを使い切って高速化。
  • 同時実行性はこの二つの構成術:上限制御・バッファ・パイプラインで全体最適を図る。
  • まず分類、次に上限、最後に計測。これが「迷わない」順序です。

(付録)最小限のベンチ用コンソール骨格

static async Task Main(string[] args)
{
    using var cts = new CancellationTokenSource();
    Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };

```
await MeasureAsync("I/O async", () =&gt; FetchAllAsync(cts.Token));
Console.WriteLine();

var primes = CountPrimeParallel(2_500_000);
Console.WriteLine($"primes: {primes}");
```

} 

関連キーワード(用語集)

Task-based Asynchronous Pattern (TAP) / Task Parallel Library (TPL) / Parallel LINQ (PLINQ) / SynchronizationContext / ThreadPool Starvation / Backpressure / ConcurrencyLimiter / CancellationToken / AggregateException / IProgress / IAsyncEnumerable / ArrayPool / Span / SIMD


この記事の使い方

現場のレビュー・設計ドキュメントのテンプレートとして、そのままチェックリスト・表・コードを流用できます。迷ったら最初に「I/O か CPU か」の線を引き、本記事の該当セクションに飛んでください。設計の方向性が数分で固まります。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次