C# の async/await は「非同期処理を同期処理のように書ける」構文です。しかし「とりあえず動く」レベルを超えると、ConfigureAwait(false) や ValueTask、デッドロック、async void の罠など、知らないと痛い目を見るトピックが次々と現れます。
本記事では基本構文からコンパイラが生成するステートマシンの仕組み、IAsyncEnumerable(非同期ストリーム)、IProgress<T>(進捗報告)、実践パターン(RetryAsync / TimeoutAsync)まで体系的に解説します。CancellationToken によるキャンセルは非同期タスクのキャンセル方法|CancellationTokenを、Task と Parallel の使い分けは非同期処理と並列処理の違いを参照してください。
- async/await の基本構文
- コンパイラが生成するステートマシン
- async メソッドの戻り値の型
- async void の罠
- SynchronizationContext と ConfigureAwait(false)
- デッドロック — .Result / .Wait() の罠
- Task.Run — CPU バウンド処理のオフロード
- Task.WhenAll と Task.WhenAny
- IAsyncEnumerable<T> と await foreach(C# 8+)
- await using と IAsyncDisposable(C# 8+)
- IProgress<T> — 進捗報告パターン
- 実践パターン
- よくある質問
- まとめ
async/await の基本構文
async 修飾子を付けたメソッドの中でのみ await が使えます。await は「この非同期操作が完了するまでここで一時停止し、スレッドは他の処理に使わせる」という意味です。
using System.Net.Http;
// async メソッドは Task(値なし)か Task<T>(値あり)を返す
static async Task<string> FetchPageAsync(string url)
{
using var client = new HttpClient();
// await: 完了まで待機するが、スレッドはブロックしない
string html = await client.GetStringAsync(url);
return html;
}
static async Task Main()
{
Console.WriteLine("取得開始");
string content = await FetchPageAsync("https://example.com");
Console.WriteLine($"取得完了: {content.Length} 文字");
}
await は「待機中にスレッドを解放し、完了後に処理を再開する」という制御フローの中断・再開です。スレッドを占有し続ける同期的な .Wait()/.Result とは根本的に異なります。コンパイラが生成するステートマシン
async メソッドはコンパイル時にステートマシン(状態機械)に変換されます。各 await 地点が「状態」になり、コンパイラが再開ロジックを自動生成します。
// 書いたコード
static async Task<int> ComputeAsync()
{
int a = await GetAAsync(); // ← 状態0 → 状態1
int b = await GetBAsync(); // ← 状態1 → 状態2
return a + b;
}
// コンパイラが生成する(概念的な擬似コード)
// 実際はより複雑な IAsyncStateMachine 実装になる
class ComputeAsync_StateMachine : IAsyncStateMachine
{
private int _state = 0;
private int _a, _b;
private TaskAwaiter<int> _awaiter;
public AsyncTaskMethodBuilder<int> _builder;
public void MoveNext()
{
switch (_state)
{
case 0:
_awaiter = GetAAsync().GetAwaiter();
if (!_awaiter.IsCompleted)
{
_state = 1;
_awaiter.OnCompleted(MoveNext); // 完了後に MoveNext を再呼び出し
return;
}
goto case 1;
case 1:
_a = _awaiter.GetResult();
_awaiter = GetBAsync().GetAwaiter();
if (!_awaiter.IsCompleted)
{
_state = 2;
_awaiter.OnCompleted(MoveNext);
return;
}
goto case 2;
case 2:
_b = _awaiter.GetResult();
_builder.SetResult(_a + _b); // Task を完了状態にする
break;
}
}
}
①
await の数だけ状態が増える② 非同期操作が既に完了していれば
OnCompleted(コールバック)を登録せず即続行する(ゼロコスト)③
ValueTask はこの「既に完了」ケースを最適化するために存在するasync メソッドの戻り値の型
| 戻り値型 | 用途 | 使いどころ |
|---|---|---|
Task |
値を返さない非同期処理 | 一般的な非同期メソッド(void の代わり) |
Task<T> |
値を返す非同期処理 | 最も一般的。await で値を取り出せる |
ValueTask |
値を返さない・頻繁に同期完了する処理 | ヒープアロケーションを削減したい高頻度メソッド |
ValueTask<T> |
値を返す・頻繁に同期完了する処理 | キャッシュ返却・高スループット API |
void(async void) |
イベントハンドラー専用 | それ以外では使用禁止(後述) |
IAsyncEnumerable<T> |
非同期で連続して値を生成 | await foreach で消費する非同期ストリーム |
ValueTask vs Task — いつ使うか
// Task: 常にヒープにオブジェクトが生成される
public async Task<int> AlwaysAllocatesAsync()
{
await Task.Delay(10);
return 42;
}
// ValueTask: キャッシュから返すとき(同期完了)はアロケーションゼロ
private int _cachedValue = -1;
public ValueTask<int> GetCachedOrFetchAsync()
{
if (_cachedValue >= 0)
return new ValueTask<int>(_cachedValue); // アロケーションなし!
return new ValueTask<int>(FetchAndCacheAsync());
}
private async Task<int> FetchAndCacheAsync()
{
await Task.Delay(100); // 実際の非同期処理
_cachedValue = 42;
return _cachedValue;
}
// ValueTask の重要な制約: 1回しか await できない
// NG: ValueTask を複数回 await するとクラッシュする可能性がある
var vt = GetCachedOrFetchAsync();
var r1 = await vt; // OK
// var r2 = await vt; // 危険!もう一度 await してはいけない
// 複数箇所で使いたいなら .AsTask() で Task に変換する
var task = GetCachedOrFetchAsync().AsTask();
var r1b = await task;
var r2b = await task; // Task は何度でも await できる
async void の罠
async void はイベントハンドラー以外では使ってはいけません。例外がキャッチできず、アプリがクラッシュします。
// NG: async void は例外を呼び出し元に伝播できない
static async void DangerousAsync()
{
await Task.Delay(100);
throw new InvalidOperationException("この例外はキャッチできない!");
}
static async Task Main()
{
try
{
DangerousAsync(); // Fire and forget になる(await もできない)
await Task.Delay(500);
// 例外は SynchronizationContext に飛び、アプリが落ちる
}
catch (Exception ex)
{
// ここには到達しない!
Console.WriteLine(ex.Message);
}
}
// OK: async Task にすれば例外が await で伝播する
static async Task SafeAsync()
{
await Task.Delay(100);
throw new InvalidOperationException("この例外はキャッチできる");
}
static async Task MainSafe()
{
try
{
await SafeAsync(); // await しているので例外が伝播
}
catch (Exception ex)
{
Console.WriteLine(ex.Message); // "この例外はキャッチできる"
}
}
// 唯一の例外: イベントハンドラー(async Task にできない)
button.Click += async (sender, e) =>
{
await DoSomethingAsync(); // イベントハンドラーのみ async void が許容される
};
async void チェックリスト✗ 例外を呼び出し元に伝播できない
✗
await できない(完了を待てない)✗ テストが困難
✓
button.Click += async (s, e) => { ... } のみ許容SynchronizationContext と ConfigureAwait(false)
await が完了すると、デフォルトでは 元の SynchronizationContext(UIスレッド・ASP.NET リクエストコンテキスト等)に処理を戻そうとします。この「コンテキストへの復帰」がデッドロックの原因になることがあります。
// ライブラリコードの推奨パターン
public static async Task<string> ProcessDataAsync(string input)
{
// ConfigureAwait(false): 完了後に元のコンテキストに戻らなくてよい
// → スレッドプールのどのスレッドでも再開できる(高効率)
var result = await ComputeAsync(input).ConfigureAwait(false);
// ここは元の UI スレッドではなく、スレッドプールスレッドで実行される
return result.ToUpper();
}
// アプリコード(WPF / MAUI)では ConfigureAwait(false) を付けない
private async void Button_Click(object sender, EventArgs e)
{
// await 後に UI スレッドに戻る必要がある
var data = await FetchDataAsync(); // ConfigureAwait(false) を付けない
label.Text = data; // UI スレッドでないと失敗する
}
| コード種別 | ConfigureAwait(false) |
理由 |
|---|---|---|
| ライブラリ・共通処理 | 付ける | 呼び出し元のコンテキストに依存しない。パフォーマンス向上 |
| WPF / MAUI の UI 処理 | 付けない | await 後に UI スレッドで続きを実行する必要がある |
| ASP.NET Core | 付けても付けなくても OK | .NET Core 以降は SynchronizationContext が存在しないため実質同じ |
| コンソールアプリ | 付けても付けなくても OK | SynchronizationContext なし |
デッドロック — .Result / .Wait() の罠
非同期メソッドを同期的にブロックすると、クラシックなデッドロックが起きます。特に WPF や ASP.NET(非 Core)で頻発するパターンです。
// NG: UI スレッドや ASP.NET コンテキストで .Result/.Wait() を使う
static async Task<string> GetDataAsync()
{
await Task.Delay(1000); // ここで UI スレッドへの復帰を予約
return "data";
}
// デッドロック発生のメカニズム(WPF / ASP.NET 非 Core):
// 1. UI スレッドが GetDataAsync().Result でブロック
// 2. GetDataAsync は await 完了後に UI スレッドへ戻ろうとする
// 3. しかし UI スレッドは .Result でブロック中 → 永久待機
// NG パターン
// string result = GetDataAsync().Result; // デッドロック!
// GetDataAsync().Wait(); // デッドロック!
// 解決策 1: 全部 async にする(推奨)
static async Task CorrectAsync()
{
string result = await GetDataAsync(); // await で待つ
Console.WriteLine(result);
}
// 解決策 2: ConfigureAwait(false) を付ける(ライブラリ側の対応)
static async Task<string> GetDataSafeAsync()
{
await Task.Delay(1000).ConfigureAwait(false); // UI スレッドへ戻らない
return "data";
}
// ConfigureAwait(false) を付ければ .Result も(理論上)デッドロックしない
// ただし推奨はしない。全部 async にするのが最善
// 解決策 3: どうしても同期で待ちたい場合
static string GetDataSync()
{
// Task.Run でスレッドプール上で実行してから同期ブロック
// (コンテキストが関与しないので安全)
return Task.Run(() => GetDataAsync()).GetAwaiter().GetResult();
}
コードベースの一部だけを非同期にして、途中で
.Result/.Wait() で同期化するのが最も危険です。「async は感染する」と言われるように、呼び出し元も async にすることで安全に伝播させましょう。Task.Run — CPU バウンド処理のオフロード
async/await はI/Oバウンド処理(HTTP、DB、ファイル)に最適化されています。CPU バウンド処理(重い計算、画像処理など)をバックグラウンドで実行するには Task.Run を使います。
// I/O バウンド: await だけで OK(Task.Run 不要)
static async Task<string> FetchFromDbAsync(int id)
{
// DbContext.FindAsync などは内部で非同期 I/O を使う
await Task.Delay(50); // DB 呼び出しのシミュレーション
return $"Record-{id}";
}
// CPU バウンド: Task.Run でスレッドプールに移す
static async Task ProcessImageAsync(byte[] data)
{
// UI スレッドをブロックしないように Task.Run でオフロード
byte[] result = await Task.Run(() => HeavyImageProcess(data));
UpdateUI(result);
}
static byte[] HeavyImageProcess(byte[] data)
{
// 重いCPU処理(Task.Run 内なのでスレッドプールで実行される)
return data.Select(b => (byte)(255 - b)).ToArray();
}
// NG: ライブラリコードで Task.Run を使う
// ライブラリは呼び出し元が制御すべき。Task.Run を内部で使うと
// スレッドプールのスレッドを2重消費するリスクがある
// NG パターン(ライブラリ内では避ける)
public static Task<int> BadLibraryMethodAsync(int x)
=> Task.Run(() => ExpensiveCalculation(x)); // ライブラリが Task.Run を隠蔽するのは NG
// OK: 同期メソッドとして公開し、呼び出し側が Task.Run を判断
public static int GoodLibraryMethod(int x) => ExpensiveCalculation(x);
// 呼び出し元: var result = await Task.Run(() => GoodLibraryMethod(x));
static int ExpensiveCalculation(int x) => x * x;
static void UpdateUI(byte[] data) { }
Task.WhenAll と Task.WhenAny
static async Task<int> FetchScoreAsync(string name)
{
await Task.Delay(new Random().Next(500, 1500));
return name.Length * 10;
}
static async Task Main()
{
// NG: 逐次 await — 合計 3 秒以上かかる
var s1 = await FetchScoreAsync("Alice"); // ~1秒待つ
var s2 = await FetchScoreAsync("Bob"); // さらに ~1秒待つ
var s3 = await FetchScoreAsync("Charlie");// さらに ~1秒待つ
// OK: Task.WhenAll — 並列実行でほぼ最長の 1 件分だけかかる
var tasks = new[]
{
FetchScoreAsync("Alice"),
FetchScoreAsync("Bob"),
FetchScoreAsync("Charlie")
};
int[] scores = await Task.WhenAll(tasks);
Console.WriteLine(string.Join(", ", scores));
}
static async Task<int> FetchAsync(int id)
{
await Task.Delay(100);
if (id == 2) throw new HttpRequestException($"ID {id} の取得に失敗");
if (id == 3) throw new TimeoutException($"ID {id} がタイムアウト");
return id * 100;
}
static async Task Main()
{
var tasks = new[] { FetchAsync(1), FetchAsync(2), FetchAsync(3) };
try
{
int[] results = await Task.WhenAll(tasks);
}
catch (Exception ex)
{
// await Task.WhenAll は最初の例外しか再スローしない
Console.WriteLine($"最初の例外: {ex.Message}");
// すべての例外を確認するには tasks[i].Exception を見る
foreach (var t in tasks.Where(t => t.IsFaulted))
{
foreach (var inner in t.Exception!.InnerExceptions)
Console.WriteLine($" → {inner.GetType().Name}: {inner.Message}");
}
}
}
static async Task<string> FetchAsync(string name)
{
await Task.Delay(new Random().Next(500, 2000));
return $"{name} の結果";
}
// 最初に完了したタスクの結果を使う
static async Task WhenAnyExample()
{
var tasks = new[]
{
FetchAsync("サーバーA"),
FetchAsync("サーバーB"),
FetchAsync("サーバーC"),
};
Task<string> winner = (Task<string>)await Task.WhenAny(tasks);
Console.WriteLine($"最速: {await winner}");
}
// タイムアウトパターン: 3秒以内に完了しなければ諦める
static async Task<string?> WithTimeoutAsync(Task<string> task, TimeSpan timeout)
{
var timeoutTask = Task.Delay(timeout);
var completed = await Task.WhenAny(task, timeoutTask);
if (completed == timeoutTask)
{
Console.WriteLine("タイムアウト");
return null;
}
return await task; // 正常完了
}
// 使用例
var result = await WithTimeoutAsync(FetchAsync("サーバーA"), TimeSpan.FromSeconds(3));
Console.WriteLine(result ?? "取得失敗");
IAsyncEnumerable<T> と await foreach(C# 8+)
大量のデータを一括取得してから処理するのではなく、届いたそばから処理する(非同期ストリーム)が IAsyncEnumerable<T> です。サーバーサイドストリーミング・大量レコードの逐次処理に最適です。
using System.Runtime.CompilerServices;
// yield return を async メソッドで使う → IAsyncEnumerable<T> を生成
static async IAsyncEnumerable<int> GenerateNumbersAsync(
int count,
[EnumeratorCancellation] CancellationToken ct = default)
{
for (int i = 0; i < count; i++)
{
await Task.Delay(200, ct); // 非同期でデータ生成(API/DB呼び出しを想定)
yield return i * i;
}
}
// 消費側: await foreach で逐次処理
static async Task Main()
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
await foreach (var value in GenerateNumbersAsync(20, cts.Token))
{
Console.WriteLine(value); // 届いたそばから処理
}
}
// 実践例: DB の大量レコードを逐次ストリーミング
static async IAsyncEnumerable<Order> StreamOrdersAsync()
{
// EF Core 8 の ToAsyncEnumerable() や
// 手動 cursor ページングで実装する例
int page = 0;
while (true)
{
var batch = await FetchOrderPageAsync(page++);
if (batch.Count == 0) yield break;
foreach (var order in batch)
yield return order;
}
}
static async Task<List<Order>> FetchOrderPageAsync(int page)
{
await Task.Delay(50);
return page < 3 ? new List<Order> { new Order(page) } : new List<Order>();
}
record Order(int PageNum);
Task<List<T>>: 全件揃うまで待ってから一括処理。メモリに全件載る。IAsyncEnumerable<T>: 1件届くたびに逐次処理。メモリ効率が高く、ユーザーへの初回表示も速い。EF Core 8 の ToAsyncEnumerable()・gRPC サーバーストリーミングなどが返す型です。await using と IAsyncDisposable(C# 8+)
非同期でリソースを解放する必要があるクラス(DB 接続・ネットワークストリームなど)は IAsyncDisposable を実装します。await using で自動的に非同期 Dispose が呼ばれます。
// IAsyncDisposable を実装するクラス
public class AsyncResource : IAsyncDisposable
{
private bool _disposed;
public async Task<string> ReadAsync()
{
if (_disposed) throw new ObjectDisposedException(nameof(AsyncResource));
await Task.Delay(50);
return "データ";
}
// 非同期で解放処理を行う
public async ValueTask DisposeAsync()
{
if (_disposed) return;
Console.WriteLine("非同期クリーンアップ開始");
await Task.Delay(100); // DB 切断・接続プールへの返却などを想定
_disposed = true;
Console.WriteLine("非同期クリーンアップ完了");
}
}
static async Task Main()
{
// await using: using ブロック終了時に DisposeAsync() を自動呼び出し
await using var resource = new AsyncResource();
var data = await resource.ReadAsync();
Console.WriteLine(data);
} // ← ここで await resource.DisposeAsync() が自動実行される
// HttpClient のラッパーなど実際の例
await using var client = new SomeAsyncClient("endpoint");
var response = await client.GetAsync("/api/data");
IProgress<T> — 進捗報告パターン
ファイルダウンロード・バッチ処理などで進捗を UI に通知するには IProgress<T> インターフェースを使います。Progress<T> クラスは UI スレッドで自動的にコールバックを呼び出すため、スレッドセーフです。
// 進捗情報を表すクラス
public record DownloadProgress(int Percent, long BytesReceived, long TotalBytes);
// 処理側: IProgress<T> を受け取って Report で通知
static async Task DownloadFileAsync(
string url,
IProgress<DownloadProgress>? progress = null,
CancellationToken ct = default)
{
long total = 1_000_000; // 擬似的なファイルサイズ
for (int i = 0; i <= 10; i++)
{
await Task.Delay(200, ct);
long received = total * i / 10;
int percent = i * 10;
// progress が null でないときだけ報告(オプション引数のため)
progress?.Report(new DownloadProgress(percent, received, total));
}
}
// UI 側(コンソールアプリ例)
static async Task Main()
{
// Progress<T>: Report() の呼び出しを作成時のコンテキスト(UI スレッド)で実行
var progress = new Progress<DownloadProgress>(p =>
{
Console.WriteLine($"進捗: {p.Percent}% ({p.BytesReceived:N0} / {p.TotalBytes:N0} bytes)");
});
using var cts = new CancellationTokenSource();
await DownloadFileAsync("https://example.com/file.zip", progress, cts.Token);
Console.WriteLine("ダウンロード完了");
}
実践パターン
RetryAsync — リトライロジック
static async Task<T> RetryAsync<T>(
Func<Task<T>> operation,
int maxAttempts = 3,
TimeSpan? delay = null,
CancellationToken ct = default)
{
delay ??= TimeSpan.FromSeconds(1);
var exceptions = new List<Exception>();
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
return await operation();
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
exceptions.Add(ex);
if (attempt == maxAttempts) break;
Console.WriteLine($"試行 {attempt}/{maxAttempts} 失敗: {ex.Message}。{delay.Value.TotalSeconds}秒後にリトライ");
await Task.Delay(delay.Value, ct);
}
}
throw new AggregateException($"最大試行回数 {maxAttempts} 回に達しました", exceptions);
}
// 使用例
string result = await RetryAsync(
() => FetchFromUnstableApiAsync(),
maxAttempts: 3,
delay: TimeSpan.FromSeconds(2)
);
static async Task<string> FetchFromUnstableApiAsync()
{
await Task.Delay(100);
if (Random.Shared.Next(3) != 0)
throw new HttpRequestException("サーバーエラー");
return "成功";
}
並列度を制限した並列処理
// 一度に N 件だけ並列実行(スロットリング)
static async Task<T[]> ParallelForEachAsync<TSource, T>(
IEnumerable<TSource> source,
Func<TSource, Task<T>> operation,
int maxDegreeOfParallelism = 4,
CancellationToken ct = default)
{
var semaphore = new SemaphoreSlim(maxDegreeOfParallelism);
var tasks = source.Select(async item =>
{
await semaphore.WaitAsync(ct);
try { return await operation(item); }
finally { semaphore.Release(); }
});
return await Task.WhenAll(tasks);
}
// 使用例: 100 件の URL を最大 4 並列でフェッチ
var urls = Enumerable.Range(1, 100).Select(i => $"https://api.example.com/items/{i}");
var results = await ParallelForEachAsync(
urls,
async url =>
{
await Task.Delay(50); // HTTP 呼び出しのシミュレーション
return $"Result-{url}";
},
maxDegreeOfParallelism: 4
);
Console.WriteLine($"取得完了: {results.Length} 件");
よくある質問
async/await の恩恵はスレッドの解放(UI の応答性維持・サーバーのスループット向上)であって、個々の処理を速くするものではありません。CPU バウンド処理に単に await を付けても速くはなりません。CPU バウンドは Task.Run でスレッドプールに移すことで UI をブロックせずに済みます。await すると合計時間が各タスクの時間の合計になります。Task.WhenAll で並列実行すると合計時間は最も遅いタスク1つ分になります。独立した複数の非同期処理は Task.WhenAll に渡すのが基本です。await できないので完了を待てない(Fire and forget になる)。③ユニットテストが書けない。イベントハンドラー(button.Click += async (s, e) => { })だけが許容される唯一の例外です。IAsyncEnumerable<T> は「生産者から消費者へ順番に届ける」シンプルなストリームに適しています。Channel<T>(System.Threading.Channels)は「複数の生産者と複数の消費者」「バックプレッシャー制御」「有界バッファ」が必要なパイプライン処理向けです。Webスクレイピングやイベントパイプラインは Channel が向いています。まとめ
| トピック | ポイント |
|---|---|
| ステートマシン | await 地点が状態になる。既に完了なら即続行(ゼロコスト) |
| 戻り値の型 | 通常は Task/Task<T>。頻繁に同期完了するなら ValueTask<T> |
async void |
イベントハンドラー専用。それ以外では使用禁止 |
ConfigureAwait(false) |
ライブラリコードに付ける。UI コードには付けない |
| デッドロック | .Result/.Wait() を UI/ASP.NET スレッドで使うと発生。全部 async にする |
Task.Run |
CPU バウンド処理をスレッドプールにオフロード。I/O バウンドには不要 |
Task.WhenAll |
独立した複数タスクを並列実行。例外は全タスク完了後に確認 |
Task.WhenAny |
最初の完了を待つ。タイムアウトパターンに有効 |
IAsyncEnumerable<T> |
await foreach で逐次消費。大量データ・ストリーミングに最適 |
IAsyncDisposable |
await using で非同期 Dispose。DB 接続などに |
IProgress<T> |
UI スレッドセーフな進捗報告。Progress<T> で実装 |
SemaphoreSlim |
並列度を制限した並列処理(スロットリング) |
CancellationToken によるキャンセルの詳細は非同期タスクのキャンセル方法を、Task と Parallel の使い分けは非同期処理と並列処理の違いを参照してください。