「非同期」「並列」「同時実行性」は似て非なる概念です。本記事では、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 => 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 =>
{
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<T> WithTimeoutAsync<T>(Task<T> 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<string> 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,
() => 0L, // ローカル初期値(各スレッド)
(i, state, local) => local + data[i], // 各要素の加算
local => 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) =>
{
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 () =>
{
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(_ => Task.Run(async () =>
{
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 () =>
{
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 =>
{
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(
() => DoCpuWork(1),
() => DoCpuWork(2),
() => 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 =>
{
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<byte> pixels, int width, int height)
{
int stride = width * 4; // RGBA
Parallel.For(0, height, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, y =>
{
var row = pixels.Slice(y * stride, stride);
for (int x = 0; x < 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 () =>
{
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(_ => Task.Run(async () =>
{
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” を卒業する置換パターン
| NG | OK | 理由 |
|---|---|---|
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<object>();
Parallel.ForEach(raws, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, raw =>
{
var p = Parse(raw);
parsed.Add(p);
});
// 3) 保存は非同期で束ねる
var saveTasks = parsed.Select(x => SaveAsync(x, ct));
await Task.WhenAll(saveTasks);
```
}
この 3 ステップこそが、非同期(I/O)と並列(CPU)を混同せずに最大スループットを引き出す基本形です。
用語の再定義(覚えて帰れる要約)
- 非同期:スレッドを眠らせず待つ。I/O バウンドで威力を発揮。
- 並列:複数コアで同時計算。CPU バウンドを速くする。
- 同時実行性:非同期と並列の 構成術。上限とバッファで安定させる。
チェックポイント付きの実装ガイドライン
- まず分類:処理を I/O と CPU に分割し、境界にキュー(Channel)を置く。
- I/O は await 徹底:外側から内側まで同期呼び出しを排除。必ずキャンセルを通す。
- CPU は Parallel/PLINQ:並列度は
Environment.ProcessorCountから着手し、計測で調整。 - 上限制御:外部 API・DB には
SemaphoreSlimや RateLimiter で 礼儀を守る。 - 例外の集約:
Task.WhenAll後に成功・失敗を分岐、再試行単位を切る。 - 計測ファースト:“速そう” ではなく数値で比較。小粒タスクはまとめる。
よくある質問(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", () => 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 か」の線を引き、本記事の該当セクションに飛んでください。設計の方向性が数分で固まります。

コメント