C#の非同期処理を待つ方法|await・Wait・WhenAllの違いとデッドロックしない正しい使い方

はじめに

C#で非同期処理を扱うときに、多くの人が最初につまずくのが「非同期処理をどう待つのか」という点です。

たとえば、APIを呼び出す、ファイルを読み込む、データベースにアクセスする、といった処理は時間がかかることがあります。これらを同期的に実行すると、処理が終わるまでスレッドが止まり、UIが固まったり、サーバーの応答性能が落ちたりします。

C#では、非同期処理を待つ代表的な方法として、awaitWait()ResultTask.WhenAll()Task.WhenAny()などがあります。

しかし、これらは同じ「待つ」でも動き方が大きく異なります。特にWait()Resultを安易に使うと、デッドロックやスレッドブロックの原因になります。

この記事では、C#の非同期処理を待つ方法について、awaitWaitWhenAllの違い、デッドロックが起きる理由、安全な書き方、実務での使い分けまで詳しく解説します。

1. C#で非同期処理を「待つ」とは?まず押さえるべき基本

1-1. 非同期処理を待つとはTaskの完了を待って次の処理へ進むこと

C#における非同期処理の多くは、TaskまたはTask<T>として表現されます。

Taskは「将来完了する処理」を表すオブジェクトです。非同期メソッドを呼び出すと、処理結果そのものではなく、まずTaskが返されます。

C#
Task task = DoSomethingAsync();

この時点では、処理がまだ完了していない可能性があります。

非同期処理を「待つ」とは、このTaskの完了を待ち、完了後に次の処理へ進むことです。

C#
await DoSomethingAsync();

Console.WriteLine("非同期処理が完了しました");

このようにawaitを使うと、DoSomethingAsync()が完了してから次の行が実行されます。

1-2. async・await・Taskの関係

C#の非同期処理では、主に次の3つをセットで理解する必要があります。

asyncは、メソッドの中でawaitを使えるようにするための修飾子です。

C#
public async Task SampleAsync()
{
await Task.Delay(1000);
}

awaitは、Taskの完了を非同期的に待つためのキーワードです。

Taskは、非同期処理そのものを表す戻り値です。

つまり、基本的な関係は次のようになります。

C#
public async Task MethodAsync()
{
await SomeAsyncOperation();
}

asyncを付けたメソッドの中で、awaitを使ってTaskの完了を待つ、というのがC#の非同期処理の基本形です。

1-3. 同期処理の待機と非同期処理の待機の違い

同期処理の待機では、現在のスレッドが処理完了までブロックされます。

C#
Thread.Sleep(1000);
Console.WriteLine("1秒待ちました");

この場合、Thread.Sleep中はスレッドが完全に止まります。

一方、非同期処理の待機では、awaitを使うことでスレッドを占有せずに完了を待てます。

C#
await Task.Delay(1000);
Console.WriteLine("1秒待ちました");

Task.Delayは、Thread.Sleepのようにスレッドを止めるのではなく、指定時間が経過した後に処理を再開します。

この違いは非常に重要です。

UIアプリでは、スレッドをブロックすると画面が固まります。Webアプリでは、スレッドをブロックすると同時リクエストを処理しにくくなります。

そのため、C#で非同期処理を待つ場合は、基本的にawaitを使うのが正しい方法です。

1-4. C#で非同期処理を待つ主な方法一覧

C#で非同期処理を待つ主な方法は次のとおりです。

方法特徴主な用途
await非同期的に待つ基本的な待機方法
Task.Wait()同期的に待つ例外的に同期コードから待つ場合
Task.Result結果を取得しながら同期的に待つ原則非推奨
Task.WhenAll()複数のTaskをすべて待つ並列実行した複数処理の待機
Task.WhenAny()複数のTaskのうち最初の完了を待つタイムアウトや先着処理
Task.WaitAll()複数Taskを同期的に待つ原則非推奨
WaitAsync()タイムアウトやキャンセル付きで待つ.NET 6以降の待機制御

この中で、通常の業務コードで最もよく使うべきなのはawaitTask.WhenAll()です。

1-5. 結論:基本はWaitではなくawaitを使う

C#で非同期処理を待つ場合、基本はWait()ではなくawaitを使います。

C#
await DoSomethingAsync();

Wait()Resultは、現在のスレッドをブロックします。UIアプリでは画面フリーズ、ASP.NETではスレッド枯渇やパフォーマンス低下、古いASP.NETではデッドロックの原因になることがあります。

一方、awaitはスレッドをブロックせず、非同期処理が終わったタイミングで続きの処理を再開します。

迷った場合は、まずawaitを使うと考えて問題ありません。

2. awaitで非同期処理を待つ正しい書き方

2-1. awaitの基本構文

awaitの基本構文は次のとおりです。

C#
await 非同期メソッド();

具体例を見てみます。

C#
public async Task SampleAsync()
{
await Task.Delay(1000);
Console.WriteLine("完了しました");
}

このコードでは、Task.Delay(1000)が完了するまで待ち、その後にConsole.WriteLineが実行されます。

awaitを使うメソッドには、通常asyncを付けます。

C#
public async Task MethodAsync()
{
await SomeAsyncMethod();
}

2-2. Taskをawaitして完了を待つ方法

戻り値がない非同期処理は、Taskを返します。

C#
public async Task SaveAsync()
{
await Task.Delay(1000);
Console.WriteLine("保存しました");
}

呼び出し側では、次のようにawaitします。

C#
await SaveAsync();
Console.WriteLine("保存後の処理");

この場合、SaveAsync()が完了してから「保存後の処理」が実行されます。

Taskを返す非同期メソッドは、「完了したかどうか」を待つためのものです。戻り値はありません。

2-3. Task<T>をawaitして戻り値を受け取る方法

戻り値がある非同期処理は、Task<T>を返します。

C#
public async Task<string> GetMessageAsync()
{
await Task.Delay(1000);
return "こんにちは";
}

呼び出し側では、awaitの結果を変数に代入できます。

C#
string message = await GetMessageAsync();
Console.WriteLine(message);

Task<string>awaitすると、最終的にstringの結果が取り出されます。

C#
Task<string> task = GetMessageAsync();
string result = await task;

このように、Task<T>は「将来T型の値を返す処理」と考えると理解しやすくなります。

2-4. await後に次の処理を実行する流れ

awaitを書くと、その非同期処理が完了するまで以降の処理は実行されません。

C#
Console.WriteLine("開始");

await Task.Delay(1000);

Console.WriteLine("終了");

実行順序は次のようになります。

開始
約1秒待つ
終了

ただし、await中にスレッドがずっと止まっているわけではありません。

awaitに到達すると、未完了のTaskに対して「完了したら続きを実行してほしい」と登録し、呼び出し元へ制御を返します。

そのため、UIスレッドやサーバースレッドを不要に占有しません。

2-5. asyncメソッドの戻り値はTaskまたはTask<T>にする

asyncメソッドの戻り値は、基本的にTaskまたはTask<T>にします。

戻り値がない場合はTaskです。

C#
public async Task SaveAsync()
{
await Task.Delay(1000);
}

戻り値がある場合はTask<T>です。

C#
public async Task<int> GetCountAsync()
{
await Task.Delay(1000);
return 10;
}

呼び出し側は、それぞれ次のように待ちます。

C#
await SaveAsync();

int count = await GetCountAsync();

非同期メソッドは、呼び出し元もawaitできる形にしておくことが大切です。

2-6. async voidを避けるべき理由

async voidは、原則として避けるべきです。

C#
public async void BadAsync()
{
await Task.Delay(1000);
throw new Exception("エラー");
}

async voidには次の問題があります。

呼び出し元が完了を待てません。

C#
BadAsync(); // awaitできない

例外を呼び出し元で捕捉しにくくなります。

C#
try
{
BadAsync();
}
catch
{
// ここで捕捉できないことがある
}

テストもしにくくなります。

そのため、イベントハンドラーを除き、async voidではなくasync Taskを使います。

C#
public async Task GoodAsync()
{
await Task.Delay(1000);
}

Windows FormsやWPFのボタンクリックなど、イベントハンドラーでは例外的にasync voidを使います。

C#
private async void Button_Click(object sender, EventArgs e)
{
await SaveAsync();
}

それ以外の通常メソッドでは、async Taskまたはasync Task<T>を使いましょう。

3. Wait・Resultで非同期処理を待つ方法と注意点

3-1. Task.Wait()で同期的に待つ書き方

Task.Wait()を使うと、非同期処理の完了を同期的に待てます。

C#
Task task = DoSomethingAsync();
task.Wait();

または、次のようにも書けます。

C#
DoSomethingAsync().Wait();

この書き方では、DoSomethingAsync()が完了するまで現在のスレッドがブロックされます。

見た目は簡単ですが、awaitとは動き方が大きく異なります。

3-2. Task.Resultで結果を取得しながら待つ書き方

Task<T>の場合、Resultプロパティを使うと、結果を取得しながら同期的に待てます。

C#
Task<string> task = GetMessageAsync();

string message = task.Result;

Resultにアクセスした時点で、まだTaskが完了していなければ、完了するまで現在のスレッドがブロックされます。

これは次のようなawaitとは違います。

C#
string message = await GetMessageAsync();

awaitは非同期的に待ちますが、Resultは同期的に待ちます。

3-3. WaitとResultは現在のスレッドをブロックする

Wait()Resultの最大の注意点は、現在のスレッドをブロックすることです。

C#
var result = GetDataAsync().Result;

このコードでは、GetDataAsync()が終わるまで、現在のスレッドは他の処理をできません。

UIアプリでこれを行うと、画面が固まる可能性があります。

C#
private void Button_Click(object sender, EventArgs e)
{
var data = GetDataAsync().Result;
label.Text = data;
}

このコードは、UIスレッドをブロックします。さらに、非同期処理の続きがUIスレッドに戻ろうとすると、UIスレッドが空かないためデッドロックすることがあります。

3-4. Wait・Resultを使ってよいケース

Wait()Resultは原則避けるべきですが、使ってよいケースもあります。

たとえば、完全に同期的なコンソールアプリの古いエントリポイントで、どうしても非同期メソッドを呼び出す必要がある場合です。

C#
static void Main(string[] args)
{
MainAsync().GetAwaiter().GetResult();
}

static async Task MainAsync()
{
await DoSomethingAsync();
}

また、非同期処理がすでに完了していることが保証されている場合も、限定的には使えます。

C#
Task<int> task = Task.FromResult(10);
int result = task.Result;

ただし、実務では「本当に同期的に待つ必要があるのか」を慎重に判断する必要があります。

3-5. Wait・Resultを避けるべきケース

次のような場面では、Wait()Resultを避けるべきです。

UIアプリのUIスレッド上では避けます。

C#
var result = LoadAsync().Result;

ASP.NETやASP.NET Coreのリクエスト処理中でも避けます。

C#
public IActionResult Index()
{
var data = _service.GetDataAsync().Result;
return View(data);
}

非同期メソッドの中でも避けます。

C#
public async Task SampleAsync()
{
OtherAsync().Wait();
}

このようなコードは、awaitに置き換えるべきです。

C#
public async Task SampleAsync()
{
await OtherAsync();
}

3-6. awaitとWait・Resultの違いを比較

awaitWait()Resultの違いは、次のとおりです。

比較項目awaitWait / Result
待ち方非同期的に待つ同期的に待つ
スレッドブロックしないブロックする
例外元の例外として扱いやすいAggregateExceptionになりやすい
デッドロック起きにくい起きやすい
推奨度基本的に推奨原則避ける
主な用途通常の非同期処理例外的な同期境界

基本的には、次のように書きます。

C#
var result = await GetDataAsync();

次のような書き方は、特別な理由がない限り避けます。

C#
var result = GetDataAsync().Result;

4. 複数の非同期処理を待つならTask.WhenAllを使う

4-1. Task.WhenAllとは

Task.WhenAllは、複数の非同期処理がすべて完了するまで待つためのメソッドです。

C#
await Task.WhenAll(task1, task2, task3);

複数のAPI呼び出し、複数ファイルの読み込み、複数DBクエリなどを並行して実行したい場合に使います。

1つずつawaitすると順番に待つことになりますが、Task.WhenAllを使うと複数の処理を同時に開始して、すべての完了を待てます。

4-2. 複数のTaskを並列に実行してすべて待つ方法

次の例では、3つの非同期処理を並列に開始し、すべて完了するまで待ちます。

C#
Task task1 = ProcessAsync("A");
Task task2 = ProcessAsync("B");
Task task3 = ProcessAsync("C");

await Task.WhenAll(task1, task2, task3);

Console.WriteLine("すべて完了しました");

ポイントは、先にTaskを作成してからTask.WhenAllで待つことです。

C#
Task task1 = ProcessAsync("A");
Task task2 = ProcessAsync("B");
Task task3 = ProcessAsync("C");

この時点で処理が開始されます。

その後、次の行で全体の完了を待ちます。

C#
await Task.WhenAll(task1, task2, task3);

4-3. Task<T>の配列から複数の戻り値を受け取る方法

Task<T>を複数待つ場合、Task.WhenAllの戻り値から結果の配列を受け取れます。

C#
Task<int> task1 = GetCountAsync("A");
Task<int> task2 = GetCountAsync("B");
Task<int> task3 = GetCountAsync("C");

int[] results = await Task.WhenAll(task1, task2, task3);

Console.WriteLine(results[0]);
Console.WriteLine(results[1]);
Console.WriteLine(results[2]);

この場合、resultsには各Task<int>の結果が格納されます。

複数のAPI結果をまとめて取得する場合などに便利です。

C#
var userTask = GetUserAsync(userId);
var ordersTask = GetOrdersAsync(userId);
var pointsTask = GetPointsAsync(userId);

await Task.WhenAll(userTask, ordersTask, pointsTask);

var user = await userTask;
var orders = await ordersTask;
var points = await pointsTask;

このように、型が異なる複数のTask<T>を待つ場合は、WhenAllで完了を待った後、それぞれのTaskから結果を取り出すことがよくあります。

4-4. foreach内でawaitする場合との違い

foreachの中で毎回awaitすると、処理は基本的に順番に実行されます。

C#
foreach (var url in urls)
{
var result = await GetAsync(url);
Console.WriteLine(result);
}

このコードでは、1つ目の処理が終わってから2つ目、2つ目が終わってから3つ目、という流れになります。

一方、Task.WhenAllを使うと、複数の処理をまとめて開始できます。

C#
var tasks = urls.Select(url => GetAsync(url));

var results = await Task.WhenAll(tasks);

foreach (var result in results)
{
Console.WriteLine(result);
}

複数の処理が互いに依存していない場合は、Task.WhenAllを使うことで待ち時間を短縮できます。

ただし、外部APIやDBに大量のリクエストを同時に送ると負荷が高くなるため、必要に応じて同時実行数を制限することも大切です。

4-5. Task.WhenAllで例外が発生した場合の挙動

Task.WhenAllで待っている複数のTaskのうち、1つでも例外が発生すると、await Task.WhenAll(...)の箇所で例外が発生します。

C#
try
{
await Task.WhenAll(task1, task2, task3);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}

複数のTaskで例外が発生した場合、Task.WhenAllが返すTaskには複数の例外が保持されます。

C#
var allTask = Task.WhenAll(task1, task2, task3);

try
{
await allTask;
}
catch
{
foreach (var ex in allTask.Exception!.InnerExceptions)
{
Console.WriteLine(ex.Message);
}
}

通常はcatch (Exception ex)で十分なことも多いですが、複数例外をすべて確認したい場合はException.InnerExceptionsを確認します。

4-6. Task.WhenAnyとの違い

Task.WhenAllは、すべてのTaskが完了するまで待ちます。

C#
await Task.WhenAll(task1, task2, task3);

一方、Task.WhenAnyは、どれか1つのTaskが完了するまで待ちます。

C#
Task completedTask = await Task.WhenAny(task1, task2, task3);

使い分けは次のとおりです。

メソッド待つ条件主な用途
Task.WhenAllすべて完了複数処理の完了待ち
Task.WhenAnyどれか1つ完了タイムアウト、先着処理、最速レスポンス

たとえば、タイムアウト処理ではTask.WhenAnyがよく使われます。

C#
var workTask = DoWorkAsync();
var timeoutTask = Task.Delay(3000);

var completed = await Task.WhenAny(workTask, timeoutTask);

if (completed == timeoutTask)
{
Console.WriteLine("タイムアウトしました");
}
else
{
await workTask;
}

5. C#の非同期処理でデッドロックが起きる原因

5-1. デッドロックとは処理が完了せず止まる状態

デッドロックとは、複数の処理がお互いの完了を待ち続け、先に進めなくなる状態です。

C#の非同期処理では、特にawaitWait()Resultを混在させたときに起こりやすくなります。

よくある例は、次のようなコードです。

C#
var result = GetDataAsync().Result;

一見すると、非同期処理の結果を同期的に取得しているだけに見えます。

しかし、UIアプリや古いASP.NETでは、このコードがデッドロックを引き起こすことがあります。

5-2. awaitとWait・Resultの混在でデッドロックが起きる仕組み

デッドロックが起きる典型的な流れは次のようなものです。

まず、UIスレッド上で非同期メソッドを呼び出し、Resultで同期的に待ちます。

C#
var result = GetDataAsync().Result;

呼び出された非同期メソッドの中では、awaitが使われています。

C#
public async Task<string> GetDataAsync()
{
await Task.Delay(1000);
return "data";
}

awaitは、完了後に元のコンテキスト、つまりUIスレッドに戻って続きを実行しようとします。

しかし、UIスレッドは.Resultでブロックされています。

その結果、非同期処理は「UIスレッドで続きを実行したい」と待ち、UIスレッドは「非同期処理が終わるまで待つ」となり、どちらも進めなくなります。

これが典型的なデッドロックです。

5-3. UIアプリでデッドロックしやすい理由

Windows FormsやWPFなどのUIアプリでは、UI操作は基本的にUIスレッド上で行われます。

awaitはデフォルトでは、await前のコンテキストに戻って続きを実行しようとします。

C#
private void Button_Click(object sender, EventArgs e)
{
var result = LoadAsync().Result;
label.Text = result;
}

このコードでは、ボタンクリックイベントがUIスレッドで実行されています。

その中で.Resultを使うと、UIスレッドがブロックされます。

一方、LoadAsync()の中のawait後の処理もUIスレッドに戻ろうとします。

C#
public async Task<string> LoadAsync()
{
await Task.Delay(1000);
return "完了";
}

しかし、UIスレッドはすでに.Resultで塞がっているため、処理が再開できません。

そのため、UIアプリではWait()Resultを使わず、イベントハンドラーをasyncにしてawaitします。

C#
private async void Button_Click(object sender, EventArgs e)
{
var result = await LoadAsync();
label.Text = result;
}

5-4. ASP.NETでWait・Resultが問題になりやすい理由

古いASP.NETでは、リクエストごとにSynchronizationContextが関係するため、UIアプリと似た理由でWait()Resultによるデッドロックが起きることがあります。

C#
public ActionResult Index()
{
var data = GetDataAsync().Result;
return View(data);
}

非同期メソッドの中でawaitが使われている場合、継続処理が元のコンテキストに戻ろうとします。

しかし、リクエスト処理スレッドが.Resultでブロックされていると、継続処理が実行できず、デッドロックすることがあります。

ASP.NET Coreでは、古いASP.NETのようなSynchronizationContextによる典型的なデッドロックは起きにくくなっています。

ただし、Wait()Resultでスレッドをブロックすると、スレッドプールを圧迫し、パフォーマンス低下やスレッド枯渇の原因になります。

そのため、ASP.NET Coreでも次のようにasync/awaitでつなげるべきです。

C#
public async Task<IActionResult> Index()
{
var data = await GetDataAsync();
return View(data);
}

5-5. SynchronizationContextと継続処理の関係

SynchronizationContextは、「await後の処理をどこで再開するか」に関係する仕組みです。

UIアプリでは、UIスレッドで画面を更新する必要があるため、await後にUIスレッドへ戻ることが重要です。

C#
private async void Button_Click(object sender, EventArgs e)
{
var text = await LoadTextAsync();
label.Text = text;
}

このコードでは、await後にUIスレッドへ戻るため、label.Textを安全に更新できます。

しかし、この仕組みとResultWait()が組み合わさると問題が起きます。

C#
var text = LoadTextAsync().Result;

現在のスレッドをブロックしているのに、非同期処理の続きがそのスレッドに戻ろうとするため、デッドロックにつながります。

5-6. デッドロックを起こすNGコード例

次のコードは、UIアプリでデッドロックを起こす典型例です。

C#
private void Button_Click(object sender, EventArgs e)
{
string result = GetMessageAsync().Result;
label.Text = result;
}

private async Task<string> GetMessageAsync()
{
await Task.Delay(1000);
return "完了しました";
}

Button_ClickはUIスレッドで実行されます。

その中でGetMessageAsync().Resultを呼ぶと、UIスレッドがブロックされます。

一方、GetMessageAsyncawait後の処理はUIスレッドに戻ろうとします。

その結果、UIスレッドが空かず、処理が止まる可能性があります。

正しくは、次のように書きます。

C#
private async void Button_Click(object sender, EventArgs e)
{
string result = await GetMessageAsync();
label.Text = result;
}

private async Task<string> GetMessageAsync()
{
await Task.Delay(1000);
return "完了しました";
}

6. デッドロックしないための正しい使い方

6-1. 非同期処理は呼び出し元までasync/awaitでつなげる

デッドロックを避ける最も重要な考え方は、「asyncは呼び出し元までつなげる」ということです。

悪い例です。

C#
public string GetMessage()
{
return GetMessageAsync().Result;
}

public async Task<string> GetMessageAsync()
{
await Task.Delay(1000);
return "Hello";
}

非同期処理を無理に同期メソッドへ押し込めています。

良い例です。

C#
public async Task<string> GetMessageAsync()
{
await Task.Delay(1000);
return "Hello";
}

呼び出し側もasyncにします。

C#
public async Task ExecuteAsync()
{
string message = await GetMessageAsync();
Console.WriteLine(message);
}

非同期処理を途中で同期化せず、上位までasync/awaitでつなげることが基本です。

6-2. Wait・Resultで無理に同期化しない

非同期メソッドを同期メソッドから呼びたい場面はよくあります。

しかし、次のように書くのは避けます。

C#
var result = GetDataAsync().Result;

または、

C#
GetDataAsync().Wait();

これらは現在のスレッドをブロックし、デッドロックやパフォーマンス低下の原因になります。

可能であれば、メソッド自体を非同期に変更します。

C#
public async Task<Data> GetDataAsync()
{
return await _repository.FindAsync();
}

呼び出し側も非同期にします。

C#
var data = await GetDataAsync();

「非同期処理を待つならawait」を徹底することで、多くの問題を避けられます。

6-3. ConfigureAwait(false)を使う場面

ConfigureAwait(false)を使うと、await後に元のコンテキストへ戻る必要がないことを示せます。

C#
public async Task<string> GetDataAsync()
{
var data = await httpClient.GetStringAsync("https://example.com")
.ConfigureAwait(false);

return data;
}

ライブラリや共通処理では、UIスレッドへ戻る必要がないことが多いため、ConfigureAwait(false)を使う場面があります。

ただし、UIを更新する処理では使い方に注意が必要です。

C#
private async void Button_Click(object sender, EventArgs e)
{
var text = await LoadTextAsync();

label.Text = text;
}

このようなUI更新がある場合、await後にUIスレッドへ戻る必要があります。

ConfigureAwait(false)は、主にライブラリコードやUIに依存しない処理で使うものです。

6-4. Task.Runで回避できる場合と注意点

Task.Runを使うと、処理をスレッドプール上で実行できます。

C#
var result = await Task.Run(() => HeavyCalculation());

CPU負荷の高い処理をUIスレッドから逃がす場合には有効です。

しかし、Task.Runはデッドロック回避の万能薬ではありません。

たとえば、非同期API呼び出しを無理にTask.Runで包むのは、根本的な解決ではないことがあります。

C#
var result = await Task.Run(() => GetDataAsync().Result);

このようなコードは避けるべきです。

非同期処理は、素直にawaitします。

C#
var result = await GetDataAsync();

Task.Runは、CPUバウンドな処理を別スレッドで実行したい場合に使います。I/Oバウンドな非同期処理では、基本的にawaitだけで十分です。

6-5. ライブラリ側で意識すべき非同期処理の設計

ライブラリ側では、呼び出し元が安全にawaitできるように設計することが重要です。

良い設計例です。

C#
public async Task<User> GetUserAsync(int id)
{
return await _repository.FindUserAsync(id).ConfigureAwait(false);
}

戻り値はTaskまたはTask<T>にします。

C#
public Task SaveAsync(User user)
{
return _repository.SaveAsync(user);
}

避けるべき設計です。

C#
public User GetUser(int id)
{
return GetUserAsync(id).Result;
}

ライブラリ内部で.Result.Wait()を使って同期化すると、呼び出し元の環境によってデッドロックや性能問題が発生しやすくなります。

非同期メソッドは非同期のまま公開し、呼び出し側にawaitしてもらう設計が安全です。

6-6. デッドロックを避ける安全なコード例

安全なコード例は次のとおりです。

C#
public class UserService
{
private readonly HttpClient _httpClient;

public UserService(HttpClient httpClient)
{
_httpClient = httpClient;
}

public async Task<string> GetUserJsonAsync(int userId)
{
var url = $"https://example.com/users/{userId}";

var json = await _httpClient.GetStringAsync(url)
.ConfigureAwait(false);

return json;
}
}

呼び出し側です。

C#
public async Task ExecuteAsync()
{
var json = await _userService.GetUserJsonAsync(1);

Console.WriteLine(json);
}

UIアプリの場合です。

C#
private async void Button_Click(object sender, EventArgs e)
{
try
{
var json = await _userService.GetUserJsonAsync(1);
textBox.Text = json;
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
}

ポイントは、Wait()Resultを使わず、呼び出し元までawaitでつなげていることです。

7. 実践パターン別:C#で非同期処理を待つ書き方

7-1. コンソールアプリで非同期処理を待つ

最近のC#では、Mainメソッドをasync Taskにできます。

C#
internal class Program
{
static async Task Main(string[] args)
{
await RunAsync();
}

static async Task RunAsync()
{
await Task.Delay(1000);
Console.WriteLine("完了しました");
}
}

この書き方であれば、Mainから自然に非同期処理を待てます。

古いC#でasync Mainが使えない場合は、同期境界として次のように書くことがあります。

C#
static void Main(string[] args)
{
RunAsync().GetAwaiter().GetResult();
}

static async Task RunAsync()
{
await Task.Delay(1000);
}

ただし、可能であればstatic async Task Mainを使う方が自然です。

7-2. Windows Forms・WPFで非同期処理を待つ

Windows FormsやWPFでは、イベントハンドラーをasync voidにしてawaitします。

C#
private async void Button_Click(object sender, EventArgs e)
{
button.Enabled = false;

try
{
string result = await LoadDataAsync();
label.Text = result;
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
finally
{
button.Enabled = true;
}
}

非同期処理の中身です。

C#
private async Task<string> LoadDataAsync()
{
await Task.Delay(1000);
return "読み込み完了";
}

UIアプリで次のように書くのは避けます。

C#
private void Button_Click(object sender, EventArgs e)
{
string result = LoadDataAsync().Result;
label.Text = result;
}

UIスレッドをブロックし、画面フリーズやデッドロックの原因になります。

7-3. ASP.NET Coreで非同期処理を待つ

ASP.NET Coreでは、コントローラーやAPIエンドポイントも非同期にできます。

C#
[HttpGet]
public async Task<IActionResult> Get()
{
var data = await _service.GetDataAsync();

return Ok(data);
}

サービス側です。

C#
public async Task<string> GetDataAsync()
{
await Task.Delay(1000);
return "data";
}

次のようにResultで待つのは避けます。

C#
[HttpGet]
public IActionResult Get()
{
var data = _service.GetDataAsync().Result;
return Ok(data);
}

ASP.NET Coreでは典型的なデッドロックは起きにくいものの、スレッドをブロックすることでスループット低下やスレッドプール枯渇につながります。

Webアプリでは、リクエスト処理全体をasync/awaitでつなげるのが基本です。

7-4. API呼び出しの完了を待つ

HTTP APIを呼び出す場合は、HttpClientの非同期メソッドをawaitします。

C#
public async Task<string> GetApiResponseAsync()
{
using var httpClient = new HttpClient();

string response = await httpClient.GetStringAsync("https://example.com/api/items");

return response;
}

JSONを取得する例です。

C#
public async Task<string> GetJsonAsync(string url)
{
using var httpClient = new HttpClient();

using var response = await httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();

return await response.Content.ReadAsStringAsync();
}

複数APIを同時に呼び出す場合は、Task.WhenAllを使えます。

C#
var userTask = httpClient.GetStringAsync(userUrl);
var orderTask = httpClient.GetStringAsync(orderUrl);

await Task.WhenAll(userTask, orderTask);

string userJson = await userTask;
string orderJson = await orderTask;

7-5. ファイル読み書きの完了を待つ

ファイル読み込みも非同期で待てます。

C#
public async Task<string> ReadFileAsync(string path)
{
string text = await File.ReadAllTextAsync(path);
return text;
}

ファイル書き込みの例です。

C#
public async Task WriteFileAsync(string path, string content)
{
await File.WriteAllTextAsync(path, content);
}

複数ファイルを読み込む場合は、Task.WhenAllを使えます。

C#
string[] paths = { "a.txt", "b.txt", "c.txt" };

var tasks = paths.Select(path => File.ReadAllTextAsync(path));

string[] contents = await Task.WhenAll(tasks);

ファイルI/Oは時間がかかることがあるため、UIアプリやWebアプリでは非同期メソッドを使うと効果的です。

7-6. DBアクセスの非同期処理を待つ

Entity Framework Coreなどでは、DBアクセス用の非同期メソッドが用意されています。

C#
public async Task<List<User>> GetUsersAsync()
{
return await _dbContext.Users.ToListAsync();
}

1件取得する例です。

C#
public async Task<User?> GetUserAsync(int id)
{
return await _dbContext.Users
.FirstOrDefaultAsync(x => x.Id == id);
}

保存処理も非同期で待てます。

C#
public async Task SaveUserAsync(User user)
{
_dbContext.Users.Add(user);
await _dbContext.SaveChangesAsync();
}

ASP.NET Coreのコントローラーでは次のように呼び出します。

C#
public async Task<IActionResult> Index()
{
var users = await _userService.GetUsersAsync();
return View(users);
}

DBアクセスでは、.Result.Wait()で同期的に待つのではなく、ToListAsync()FirstOrDefaultAsync()SaveChangesAsync()などをawaitするのが基本です。

8. 非同期処理を待つときの例外処理

8-1. awaitで例外を捕捉する方法

awaitで非同期処理を待つ場合、例外は通常のtry-catchで捕捉できます。

C#
try
{
var result = await GetDataAsync();
Console.WriteLine(result);
}
catch (Exception ex)
{
Console.WriteLine($"エラー: {ex.Message}");
}

非同期メソッドの中で例外が発生しても、awaitした箇所で例外として扱えます。

C#
public async Task<string> GetDataAsync()
{
await Task.Delay(1000);

throw new InvalidOperationException("取得に失敗しました");
}

呼び出し側です。

C#
try
{
string data = await GetDataAsync();
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}

awaitを使うと、同期コードと近い感覚で例外処理を書けます。

8-2. Wait・ResultでAggregateExceptionになる理由

Wait()Resultで非同期処理を待つと、例外がAggregateExceptionに包まれることがあります。

C#
try
{
DoSomethingAsync().Wait();
}
catch (AggregateException ex)
{
foreach (var inner in ex.InnerExceptions)
{
Console.WriteLine(inner.Message);
}
}

Taskは複数の例外を保持できるため、同期的に待った場合はAggregateExceptionとして扱われることがあります。

一方、awaitの場合は、元の例外として捕捉しやすくなります。

C#
try
{
await DoSomethingAsync();
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}

この点でも、例外処理の書きやすさはawaitの方が優れています。

8-3. Task.WhenAllで複数例外を扱う方法

Task.WhenAllでは、複数のTaskが失敗する可能性があります。

C#
var task1 = Task.Run(() => throw new InvalidOperationException("エラー1"));
var task2 = Task.Run(() => throw new ApplicationException("エラー2"));

var allTask = Task.WhenAll(task1, task2);

try
{
await allTask;
}
catch
{
foreach (var ex in allTask.Exception!.InnerExceptions)
{
Console.WriteLine(ex.Message);
}
}

await allTaskでは例外が発生します。

複数例外をすべて確認したい場合は、allTask.Exception.InnerExceptionsを確認します。

ただし、実務では単純にログ出力して終了するだけなら、次のような書き方でも十分な場合があります。

C#
try
{
await Task.WhenAll(tasks);
}
catch (Exception ex)
{
_logger.LogError(ex, "非同期処理でエラーが発生しました");
throw;
}

8-4. try-catchを書く位置

try-catchは、awaitする箇所を囲むように書きます。

C#
try
{
await SaveAsync();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}

次のようにTaskを作成しただけでは、例外がまだ発生していない場合があります。

C#
Task task = SaveAsync();

try
{
await task;
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}

多くの場合は、呼び出しとawaitをまとめてtryの中に書くのがわかりやすいです。

C#
try
{
var result = await GetResultAsync();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}

8-5. 例外処理を含めた安全なサンプルコード

API呼び出しを安全に待つサンプルです。

C#
public async Task<string?> GetContentAsync(string url)
{
try
{
using var httpClient = new HttpClient();

using var response = await httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();

return await response.Content.ReadAsStringAsync();
}
catch (HttpRequestException ex)
{
Console.WriteLine($"HTTPエラー: {ex.Message}");
return null;
}
catch (TaskCanceledException ex)
{
Console.WriteLine($"タイムアウトまたはキャンセル: {ex.Message}");
return null;
}
catch (Exception ex)
{
Console.WriteLine($"予期しないエラー: {ex.Message}");
throw;
}
}

呼び出し側です。

C#
string? content = await GetContentAsync("https://example.com");

if (content is not null)
{
Console.WriteLine(content);
}

awaitを使うことで、非同期処理の例外も自然に扱えます。

9. タイムアウト・キャンセル付きで非同期処理を待つ方法

9-1. CancellationTokenで非同期処理をキャンセルする

C#の非同期処理では、CancellationTokenを使ってキャンセル可能にできます。

C#
public async Task DoWorkAsync(CancellationToken cancellationToken)
{
await Task.Delay(5000, cancellationToken);
}

呼び出し側です。

C#
using var cts = new CancellationTokenSource();

cts.CancelAfter(TimeSpan.FromSeconds(3));

try
{
await DoWorkAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("キャンセルされました");
}

CancelAfterを使うと、指定時間後に自動でキャンセルできます。

C#
cts.CancelAfter(3000);

キャンセル可能な非同期メソッドを設計するときは、CancellationTokenを引数として受け取り、内部の非同期メソッドにも渡すのが基本です。

9-2. Task.DelayとWhenAnyでタイムアウトを実装する

Task.WhenAnyを使うと、タイムアウト処理を実装できます。

C#
public async Task<string?> GetWithTimeoutAsync()
{
var workTask = GetDataAsync();
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(3));

var completedTask = await Task.WhenAny(workTask, timeoutTask);

if (completedTask == timeoutTask)
{
return null;
}

return await workTask;
}

このコードでは、GetDataAsync()が3秒以内に終われば結果を返します。

3秒を超えた場合はnullを返します。

ただし、この方法では、タイムアウト後も元のworkTaskが裏で動き続ける可能性があります。

本当に処理を止めたい場合は、CancellationTokenと組み合わせる必要があります。

C#
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));

try
{
var result = await GetDataAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("タイムアウトしました");
}

9-3. .NETのWaitAsyncを使う方法

.NET 6以降では、Task.WaitAsyncを使ってタイムアウト付きで待てます。

C#
try
{
await DoWorkAsync().WaitAsync(TimeSpan.FromSeconds(3));
}
catch (TimeoutException)
{
Console.WriteLine("タイムアウトしました");
}

CancellationTokenを渡すこともできます。

C#
using var cts = new CancellationTokenSource();

try
{
await DoWorkAsync().WaitAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("キャンセルされました");
}

WaitAsyncは、既存のTaskに対して「指定時間だけ待つ」「キャンセルされるまで待つ」といった制御を追加できる便利なメソッドです。

9-4. タイムアウト時の例外処理

タイムアウトとキャンセルでは、発生する例外が異なります。

WaitAsync(TimeSpan)でタイムアウトした場合は、TimeoutExceptionを捕捉します。

C#
try
{
await SomeAsync().WaitAsync(TimeSpan.FromSeconds(5));
}
catch (TimeoutException)
{
Console.WriteLine("タイムアウトしました");
}

CancellationTokenでキャンセルした場合は、OperationCanceledExceptionを捕捉します。

C#
try
{
await SomeAsync(cancellationToken);
}
catch (OperationCanceledException)
{
Console.WriteLine("キャンセルされました");
}

API呼び出しなどでは、タイムアウト、キャンセル、通信エラーを分けて扱うと、原因を特定しやすくなります。

C#
try
{
var result = await GetDataAsync(cancellationToken)
.WaitAsync(TimeSpan.FromSeconds(5));
}
catch (TimeoutException)
{
Console.WriteLine("処理が時間内に完了しませんでした");
}
catch (OperationCanceledException)
{
Console.WriteLine("処理がキャンセルされました");
}
catch (HttpRequestException ex)
{
Console.WriteLine($"通信エラー: {ex.Message}");
}

9-5. キャンセル可能な非同期メソッドの設計

キャンセル可能な非同期メソッドを作る場合は、引数にCancellationTokenを用意します。

C#
public async Task<string> DownloadAsync(
string url,
CancellationToken cancellationToken = default)
{
using var httpClient = new HttpClient();

return await httpClient.GetStringAsync(url, cancellationToken);
}

メソッド内部で別の非同期メソッドを呼ぶ場合も、CancellationTokenを渡します。

C#
public async Task SaveAsync(
string path,
string content,
CancellationToken cancellationToken = default)
{
await File.WriteAllTextAsync(path, content, cancellationToken);
}

長いループ処理では、定期的にキャンセル要求を確認します。

C#
public async Task ProcessItemsAsync(
IEnumerable<string> items,
CancellationToken cancellationToken = default)
{
foreach (var item in items)
{
cancellationToken.ThrowIfCancellationRequested();

await ProcessItemAsync(item, cancellationToken);
}
}

キャンセル可能にしておくことで、タイムアウト、ユーザー操作による中断、アプリ終了時の停止などに対応しやすくなります。

10. await・Wait・WhenAllの使い分け早見表

10-1. 1つの非同期処理を待つならawait

1つの非同期処理を待つなら、基本はawaitです。

C#
var result = await GetDataAsync();

戻り値がない場合も同じです。

C#
await SaveAsync();

awaitはスレッドをブロックせず、非同期処理が終わった後に続きを実行します。

通常のアプリケーションコードでは、まずawaitを使うと考えて問題ありません。

10-2. 複数の非同期処理をすべて待つならTask.WhenAll

複数の非同期処理を並行して実行し、すべての完了を待つならTask.WhenAllです。

C#
var task1 = GetDataAsync("A");
var task2 = GetDataAsync("B");
var task3 = GetDataAsync("C");

var results = await Task.WhenAll(task1, task2, task3);

互いに依存しない処理であれば、順番にawaitするより効率的です。

C#
foreach (var id in ids)
{
var data = await GetDataAsync(id);
}

上記のように逐次実行する必要がない場合は、次のようにできます。

C#
var tasks = ids.Select(id => GetDataAsync(id));
var results = await Task.WhenAll(tasks);

10-3. どれか1つの完了を待つならTask.WhenAny

複数の処理のうち、どれか1つが完了した時点で処理したい場合はTask.WhenAnyを使います。

C#
var completedTask = await Task.WhenAny(task1, task2, task3);

タイムアウト処理にもよく使います。

C#
var workTask = DoWorkAsync();
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(5));

var completed = await Task.WhenAny(workTask, timeoutTask);

if (completed == timeoutTask)
{
Console.WriteLine("タイムアウト");
}
else
{
await workTask;
}

WhenAnyは「最初に終わったもの」を扱いたいときに使います。

10-4. 同期的に待つ必要がある場合のみWait・Result

Wait()Resultは、同期的に待つ必要がある場合のみ使います。

C#
DoWorkAsync().Wait();
C#
var result = GetDataAsync().Result;

ただし、これらは現在のスレッドをブロックします。

UIアプリ、ASP.NET、非同期メソッド内では原則避けます。

どうしても同期境界で待つ必要がある場合は、次のようにGetAwaiter().GetResult()を使うこともあります。

C#
var result = GetDataAsync().GetAwaiter().GetResult();

ただし、これもスレッドをブロックする点は同じです。

Wait()より安全」ではなく、「例外がAggregateExceptionに包まれにくい同期待機方法」と考えるべきです。

10-5. 実務で迷ったときの判断基準

実務で迷ったときは、次の基準で判断します。

やりたいこと使う方法
1つの非同期処理を待ちたいawait
戻り値を受け取りたいawait Task<T>
複数処理をすべて待ちたいawait Task.WhenAll
どれか1つの完了を待ちたいawait Task.WhenAny
タイムアウト付きで待ちたいWaitAsyncまたはWhenAny
キャンセル可能にしたいCancellationToken
同期メソッドからどうしても待ちたい最小限の同期境界でGetAwaiter().GetResult()
UIやWebで待ちたいWaitResultではなくawait

基本方針はシンプルです。

非同期処理は非同期のまま扱い、async/awaitで呼び出し元までつなげます。

11. C#の非同期処理を待つときによくある疑問

11-1. awaitするとスレッドは止まるのか

awaitしても、基本的にスレッドはブロックされません。

C#
await Task.Delay(1000);

このコードでは、1秒間スレッドを止めているわけではありません。

Task.Delayが完了したら続きを実行するように登録し、いったん呼び出し元へ制御を返します。

一方、次のコードはスレッドを止めます。

C#
Task.Delay(1000).Wait();

awaitは非同期的に待つ、Wait()は同期的に待つ、という違いがあります。

11-2. awaitを書かないとどうなるのか

非同期メソッドを呼び出してawaitしない場合、処理の完了を待たずに次の処理へ進みます。

C#
DoSomethingAsync();

Console.WriteLine("次の処理");

このコードでは、DoSomethingAsync()の完了前に「次の処理」が実行される可能性があります。

また、例外を適切に捕捉できないこともあります。

基本的には、非同期処理の完了が必要な場合はawaitします。

C#
await DoSomethingAsync();

Console.WriteLine("次の処理");

意図的に待たない場合でも、例外処理やライフサイクル管理を慎重に設計する必要があります。

11-3. asyncメソッドを同期メソッドから呼ぶにはどうするか

理想は、同期メソッド側も非同期に変更することです。

悪い例です。

C#
public string GetData()
{
return GetDataAsync().Result;
}

良い例です。

C#
public async Task<string> GetDataAsync()
{
return await LoadAsync();
}

呼び出し側もawaitします。

C#
string data = await GetDataAsync();

どうしても同期メソッドから呼ぶ必要がある場合は、アプリケーションのエントリポイントなど、限定された場所でのみ同期的に待ちます。

C#
var result = GetDataAsync().GetAwaiter().GetResult();

ただし、UIスレッドやASP.NETのリクエスト処理中では避けるべきです。

11-4. Task.Runをawaitすればよいのか

Task.Runは、CPU負荷の高い同期処理を別スレッドで実行したい場合に使います。

C#
var result = await Task.Run(() => HeavyCalculation());

しかし、I/O処理を非同期で待つために、何でもTask.Runで包む必要はありません。

悪い例です。

C#
var result = await Task.Run(() => httpClient.GetStringAsync(url).Result);

良い例です。

C#
var result = await httpClient.GetStringAsync(url);

API呼び出し、ファイルI/O、DBアクセスなど、もともと非同期メソッドが用意されている場合は、そのままawaitします。

Task.Runは非同期処理を待つための基本手段ではなく、CPUバウンド処理を別スレッドに逃がすための手段です。

11-5. Task.WaitAllとTask.WhenAllの違いは何か

Task.WaitAllは、複数のTaskを同期的に待ちます。

C#
Task.WaitAll(task1, task2, task3);

現在のスレッドをブロックするため、通常は避けるべきです。

一方、Task.WhenAllは、複数のTaskを非同期的に待ちます。

C#
await Task.WhenAll(task1, task2, task3);

使い分けは次のとおりです。

メソッド待ち方推奨度
Task.WaitAll同期的に待つ原則非推奨
Task.WhenAll非同期的に待つ推奨

複数の非同期処理を待つ場合は、基本的にTask.WhenAllを使います。

11-6. Mainメソッドでawaitは使えるのか

C#では、Mainメソッドをasync Taskにできます。

C#
class Program
{
static async Task Main(string[] args)
{
await DoWorkAsync();
}

static async Task DoWorkAsync()
{
await Task.Delay(1000);
Console.WriteLine("完了");
}
}

この書き方であれば、コンソールアプリの開始地点から自然にawaitできます。

戻り値が必要な場合は、Task<int>も使えます。

C#
static async Task<int> Main(string[] args)
{
await DoWorkAsync();
return 0;
}

古い環境ではasync Mainが使えない場合があります。その場合は、エントリポイントでのみ同期的に待つ方法を使うことがあります。

C#
static void Main(string[] args)
{
DoWorkAsync().GetAwaiter().GetResult();
}

ただし、現在のC#ではasync Task Mainを使うのが自然です。

まとめ

C#で非同期処理を待つ方法はいくつかありますが、基本はawaitです。

1つの非同期処理を待つ場合は、次のように書きます。

C#
await DoSomethingAsync();

戻り値がある場合は、Task<T>awaitして結果を受け取ります。

C#
var result = await GetDataAsync();

複数の非同期処理をすべて待つ場合は、Task.WhenAllを使います。

C#
var results = await Task.WhenAll(task1, task2, task3);

どれか1つの完了を待つ場合は、Task.WhenAnyを使います。

C#
var completed = await Task.WhenAny(task1, task2);

一方、Wait()Resultは現在のスレッドをブロックします。

C#
var result = GetDataAsync().Result;
C#
GetDataAsync().Wait();

これらは、UIアプリのフリーズ、古いASP.NETでのデッドロック、ASP.NET Coreでのスレッド枯渇やパフォーマンス低下につながる可能性があります。

実務では、非同期処理を途中で無理に同期化せず、呼び出し元までasync/awaitでつなげることが重要です。

C#
public async Task ExecuteAsync()
{
var data = await GetDataAsync();
await SaveAsync(data);
}

C#で非同期処理を待つときは、「Waitではなくawait」「複数ならWhenAll」「同期化は最小限」と覚えておくと、安全で読みやすいコードを書けます。