C#でawaitが待たない原因と対処法|非同期処理のつまずきを実例で解決

はじめに

C#で非同期処理を書いていると、「awaitを付けているのに待たない」「処理が先に進んでしまう」「ログの順番が想定と違う」と感じる場面があります。

しかし、多くの場合、awaitそのものが待っていないわけではありません。実際には、呼び出し元でawaitしていない、async voidを使っている、Task.Runを投げっぱなしにしている、List.ForEachやLINQの中でasyncを使っている、といった書き方が原因になっています。

この記事では、C#で「awaitが待たない」と感じる代表的な原因と対処法を、実例コード付きで解説します。非同期処理の基本的な動きから、実務でつまずきやすいパターンまで順番に確認していきましょう。

1. C#で「awaitが待たない」と感じる原因をまず整理する

1-1. awaitは処理全体を止める命令ではなくTaskの完了を待つ仕組み

まず重要なのは、awaitは「プログラム全体」や「スレッド全体」を止める命令ではないという点です。

awaitは、指定したTaskの完了を待ち、その後の処理を続けるための仕組みです。

C#
await SomeAsync();
Console.WriteLine("SomeAsyncの完了後に実行される");

このコードでは、SomeAsync()が返すTaskの完了を待ってから、次のConsole.WriteLineが実行されます。

ただし、待っているのはあくまで「このasyncメソッド内の続き」です。アプリ全体の処理や別スレッドの処理まで止めるわけではありません。

そのため、別の処理が同時に動いている場合は、「awaitしているのに他の処理が先に進んでいる」と見えることがあります。

1-2. 呼び出し元がawaitしていないと非同期処理は待たれない

awaitが待たない原因として特に多いのが、呼び出し元でawaitしていないパターンです。

例えば、次のようなコードです。

C#
DoWorkAsync();
Console.WriteLine("完了");

DoWorkAsync()が非同期メソッドであっても、呼び出すだけでは完了を待ちません。Taskが開始され、そのまま次の行に進みます。

正しく待つには、次のように書く必要があります。

C#
await DoWorkAsync();
Console.WriteLine("完了");

C#で「awaitが待たない」と感じたときは、まず「非同期メソッドを呼び出している側もawaitしているか」を確認しましょう。

1-3. asyncメソッドの戻り値がvoid・Task・Task<T>で挙動が変わる

C#のasyncメソッドでは、主に次の戻り値を使います。

C#
async Task
async Task<T>
async void

基本的に、非同期メソッドはTaskまたはTask<T>で定義します。

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

戻り値がTaskであれば、呼び出し元はawait SaveAsync()のように完了を待てます。

値を返す場合はTask<T>を使います。

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

一方、async voidは呼び出し元が待てません。

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

このように書くと、呼び出し側でawait SaveAsync()ができません。そのため、処理の完了を制御しづらくなります。

async voidは、基本的にボタンクリックなどのイベントハンドラーだけで使うものと考えましょう。

1-4. 「待たない」の正体は処理順・例外・スレッドの誤解で起きやすい

awaitが待たない」と感じる状況には、いくつかのパターンがあります。

よくあるのは、ログの順番が想定と違うケースです。

C#
Console.WriteLine("A");
await SomeAsync();
Console.WriteLine("B");

この場合、同じメソッド内ではASomeAsyncの完了、Bの順に実行されます。

しかし、呼び出し元がawaitしていないと、別のログが先に出ることがあります。

C#
SomeAsync();
Console.WriteLine("C");

この場合、SomeAsync()の完了を待たずにCが出力されます。

また、非同期処理の中で例外が発生しているのに、呼び出し元でawaitしていないため気づかないケースもあります。

さらに、awaitはスレッドをブロックする仕組みではないため、「スレッドが止まっていないから待っていない」と誤解されることもあります。

C#の非同期処理では、「どのTaskを誰がawaitしているか」を追うことが重要です。

2. awaitしているのに待たないように見える基本例

2-1. awaitの前後にログを出して実行順序を確認する

まずは、awaitの動きをログで確認してみましょう。

C#
using System;
using System.Threading.Tasks;

class Program
{
static async Task Main()
{
Console.WriteLine("Main開始");

await DoWorkAsync();

Console.WriteLine("Main終了");
}

static async Task DoWorkAsync()
{
Console.WriteLine("DoWork開始");

await Task.Delay(2000);

Console.WriteLine("DoWork終了");
}
}

このコードの出力は次のようになります。

Main開始
DoWork開始
DoWork終了
Main終了

await DoWorkAsync()によって、DoWorkAsyncの完了後にMain終了が表示されています。

つまり、awaitは正しく待っています。

2-2. Task.Delayを使った最小コードでawaitの動きを理解する

Task.Delayは、非同期処理の動作確認によく使われます。

C#
static async Task SampleAsync()
{
Console.WriteLine("1");

await Task.Delay(1000);

Console.WriteLine("2");
}

このコードでは、1が表示されたあと、約1秒後に2が表示されます。

ここで大切なのは、Task.Delay中にスレッドを占有して待っているわけではないという点です。

Thread.Sleepはスレッドをブロックします。

C#
Thread.Sleep(1000);

一方、Task.Delayは非同期的に待機します。

C#
await Task.Delay(1000);

そのため、UIアプリやWebアプリでは、Task.Delayや非同期I/Oをawaitすることで、スレッドを無駄に止めずに処理を待てます。

2-3. await後の処理はTask完了後に実行されることを確認する

次のコードを見てください。

C#
static async Task Main()
{
Console.WriteLine("A");

await WaitAsync();

Console.WriteLine("C");
}

static async Task WaitAsync()
{
Console.WriteLine("B開始");

await Task.Delay(1000);

Console.WriteLine("B終了");
}

出力は次の順番になります。

A
B開始
B終了
C

await WaitAsync()しているため、WaitAsyncが完了してからCが実行されます。

もしCが先に表示される場合は、次のように呼び出し元でawaitしていない可能性があります。

C#
WaitAsync();
Console.WriteLine("C");

この場合、出力は次のようになることがあります。

A
B開始
C
B終了

この違いが、C#で「awaitが待たない」と感じる典型的な原因です。

2-4. デバッグ時に勘違いしやすい非同期処理の見え方

デバッグ中は、非同期処理の実行順が分かりにくく見えることがあります。

特に、次のような点で混乱しがちです。

awaitで待機している間、現在のメソッドは一時停止しますが、アプリ全体が止まるわけではありません。

UIアプリでは、await中でも画面が固まらず操作できることがあります。これは正常な挙動です。

ASP.NETなどのWebアプリでは、リクエスト処理中に非同期I/Oを待っている間、スレッドが解放されることがあります。これもawaitが待っていないわけではありません。

また、Visual Studioのデバッガーでは、非同期メソッドの呼び出し履歴やスレッドの移動が見えにくいことがあります。

そのため、非同期処理の確認では、次のようにログを入れて実行順序を見るのが有効です。

C#
Console.WriteLine($"開始: {DateTime.Now:HH:mm:ss.fff}");
await SomeAsync();
Console.WriteLine($"終了: {DateTime.Now:HH:mm:ss.fff}");

awaitが待たない」と感じたときは、まずログで実際の実行順序を確認しましょう。

3. 原因1:呼び出し元でawaitしていない

3-1. 非同期メソッドを呼ぶだけでは完了を待たない

次のコードは、非同期メソッドを呼び出していますが、完了を待っていません。

C#
static async Task Main()
{
DownloadAsync();

Console.WriteLine("ダウンロード完了");
}

static async Task DownloadAsync()
{
Console.WriteLine("ダウンロード開始");

await Task.Delay(2000);

Console.WriteLine("ダウンロード終了");
}

この場合、出力は次のようになる可能性があります。

ダウンロード開始
ダウンロード完了

その後に、

ダウンロード終了

が表示されることがあります。

DownloadAsync()を呼び出しただけでは、そのTaskの完了を待ちません。Taskを開始して、そのまま次の行へ進みます。

3-2. Taskを受け取ってawaitする正しい書き方

正しく待つには、呼び出し元でawaitします。

C#
static async Task Main()
{
await DownloadAsync();

Console.WriteLine("ダウンロード完了");
}

static async Task DownloadAsync()
{
Console.WriteLine("ダウンロード開始");

await Task.Delay(2000);

Console.WriteLine("ダウンロード終了");
}

出力は次のようになります。

ダウンロード開始
ダウンロード終了
ダウンロード完了

また、Taskを一度変数に受け取ってからawaitすることもできます。

C#
Task task = DownloadAsync();

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

await task;

Console.WriteLine("ダウンロード完了");

この書き方では、DownloadAsync()を開始したあとに別の処理を行い、その後で完了を待てます。

複数の非同期処理をまとめて待ちたい場合は、Task.WhenAllを使います。

C#
Task task1 = DownloadAsync();
Task task2 = SaveAsync();

await Task.WhenAll(task1, task2);

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

順番に待つ必要がない処理であれば、Task.WhenAllを使うと効率的です。

3-3. Mainメソッドでawaitする方法

C#では、Mainメソッドをasync Task Mainとして定義できます。

C#
using System;
using System.Threading.Tasks;

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

Console.WriteLine("終了");
}

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

古い書き方では、次のようにWait()Resultを使うコードもあります。

C#
RunAsync().Wait();

しかし、Wait()Resultはスレッドをブロックし、環境によってはデッドロックの原因になることがあります。

そのため、可能であればasync Task Mainを使い、awaitで待つ書き方を選びましょう。

3-4. ASP.NETやBlazorで呼び出し元のawait漏れを防ぐポイント

ASP.NETやBlazorでも、呼び出し元のawait漏れはよく起こります。

例えば、ASP.NET Coreのコントローラーで次のように書くのは危険です。

C#
public IActionResult Save()
{
_service.SaveAsync();

return Ok();
}

このコードでは、SaveAsync()の完了を待たずにレスポンスを返してしまいます。

正しくは、アクションメソッドも非同期にしてawaitします。

C#
public async Task<IActionResult> Save()
{
await _service.SaveAsync();

return Ok();
}

Blazorでも同様です。

C#
private void OnClick()
{
LoadAsync();
}

このように書くと、LoadAsync()の完了を待てません。

イベントハンドラーでは、次のようにasync Taskを使います。

C#
private async Task OnClick()
{
await LoadAsync();
}

ASP.NETやBlazorでは、非同期メソッドを呼ぶ側もasyncにし、awaitを伝播させることが重要です。

4. 原因2:async voidを使っている

4-1. async voidは呼び出し元から待てない

async voidは、C#で「awaitが待たない」と感じる大きな原因です。

次のようなメソッドは、呼び出し元から完了を待てません。

C#
static async void SaveAsync()
{
await Task.Delay(1000);

Console.WriteLine("保存完了");
}

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

C#
await SaveAsync(); // コンパイルエラー

voidは戻り値がないため、呼び出し元は処理の完了を表すTaskを受け取れません。

その結果、呼び出し元は非同期処理が終わったかどうかを判断できません。

4-2. イベントハンドラー以外ではasync Taskを使うべき理由

基本的に、非同期メソッドはasync Taskまたはasync Task<T>で書きます。

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

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

Console.WriteLine("保存完了");
}

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

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

return "読み込み完了";
}

async voidを使ってよい代表例は、イベントハンドラーです。

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

イベントハンドラーは、フレームワーク側の都合で戻り値がvoidになることがあります。このような場合はasync voidが必要です。

ただし、通常のメソッドではasync voidを避け、async Taskを使いましょう。

4-3. async voidで例外が捕捉しにくくなる問題

async voidには、例外を扱いにくいという問題もあります。

例えば、次のコードを見てください。

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

static async void SaveAsync()
{
await Task.Delay(1000);

throw new InvalidOperationException("保存に失敗しました");
}

この場合、呼び出し元のtry-catchでは例外を捕捉できないことがあります。

async voidTaskを返さないため、呼び出し元が非同期処理の完了や例外を受け取れないからです。

一方、async Taskであれば、awaitしたタイミングで例外を捕捉できます。

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

static async Task SaveAsync()
{
await Task.Delay(1000);

throw new InvalidOperationException("保存に失敗しました");
}

このように、例外処理の面でもasync Taskを使うべきです。

4-4. async voidをasync Taskへ修正する実例

問題のあるコードは次のとおりです。

C#
public async void SendMailAsync()
{
await Task.Delay(1000);

Console.WriteLine("メール送信完了");
}

public void Execute()
{
SendMailAsync();

Console.WriteLine("処理終了");
}

このコードでは、SendMailAsync()の完了を待たずに処理終了が表示されます。

修正後は次のようにします。

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

Console.WriteLine("メール送信完了");
}

public async Task ExecuteAsync()
{
await SendMailAsync();

Console.WriteLine("処理終了");
}

さらに、呼び出し元もawaitします。

C#
await ExecuteAsync();

非同期処理では、async Taskを上位の呼び出し元までつなげていくことが大切です。

5. 原因3:Task.RunやFire-and-Forgetで処理を投げっぱなしにしている

5-1. Task.Runをawaitしないとバックグラウンドで進んでしまう

Task.Runを使った処理でも、awaitしなければ完了を待ちません。

C#
Task.Run(async () =>
{
await Task.Delay(2000);
Console.WriteLine("Task.Run内の処理完了");
});

Console.WriteLine("メイン処理完了");

このコードでは、Task.Runで開始した処理の完了を待たずに、メイン処理完了が表示されます。

正しく待つには、awaitします。

C#
await Task.Run(async () =>
{
await Task.Delay(2000);
Console.WriteLine("Task.Run内の処理完了");
});

Console.WriteLine("メイン処理完了");

ただし、I/O待ちが中心の非同期処理では、無理にTask.Runを使う必要がない場合も多いです。

例えば、HTTP通信やファイルI/O、データベースアクセスなど、すでに非同期APIが用意されている処理では、次のように直接awaitします。

C#
await httpClient.GetStringAsync(url);

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

5-2. Fire-and-Forgetが必要な場面と危険な場面

Fire-and-Forgetとは、非同期処理を開始して、その完了を待たない書き方です。

C#
_ = SendLogAsync();

このような書き方は、「ログ送信」「通知」「キャッシュ更新」など、メイン処理を待たせたくない場面で使われることがあります。

しかし、Fire-and-Forgetには注意が必要です。

処理が完了する前にアプリケーションが終了する可能性があります。

例外が発生しても呼び出し元で捕捉できない可能性があります。

ASP.NETでは、リクエスト終了後に必要なスコープ付きサービスが破棄され、非同期処理内でエラーになる可能性があります。

そのため、単に「待ちたくないから」という理由でFire-and-Forgetにするのは危険です。

必要な処理であれば、基本的にはawaitして完了を確認しましょう。

5-3. 例外が握りつぶされる・ログに残らない問題

Fire-and-Forgetで特に問題になりやすいのが、例外処理です。

C#
_ = DoWorkAsync();

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

DoWorkAsync()の中で例外が発生しても、呼び出し元では通常のtry-catchで捕捉できません。

C#
try
{
_ = DoWorkAsync();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}

このcatchでは、DoWorkAsync()の非同期処理中に発生した例外を捕捉できないことがあります。

安全に扱うには、非同期メソッドの中で例外を捕捉してログを残します。

C#
_ = SafeRunAsync();

static async Task SafeRunAsync()
{
try
{
await DoWorkAsync();
}
catch (Exception ex)
{
Console.WriteLine($"エラー: {ex.Message}");
}
}

Fire-and-Forgetを使うなら、例外が見えなくならないようにすることが重要です。

5-4. 安全に非同期処理を起動するための実装例

基本的には、必要な処理はawaitします。

C#
await SendMailAsync();
await SaveLogAsync();

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

並列で実行して、すべての完了を待つ場合はTask.WhenAllを使います。

C#
Task mailTask = SendMailAsync();
Task logTask = SaveLogAsync();

await Task.WhenAll(mailTask, logTask);

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

どうしてもFire-and-Forgetにしたい場合は、専用のヘルパーを用意すると安全です。

C#
public static class TaskExtensions
{
public static void FireAndForget(this Task task, Action<Exception> onError)
{
_ = RunAsync(task, onError);
}

private static async Task RunAsync(Task task, Action<Exception> onError)
{
try
{
await task;
}
catch (Exception ex)
{
onError(ex);
}
}
}

使い方は次のとおりです。

C#
SendLogAsync().FireAndForget(ex =>
{
Console.WriteLine($"ログ送信エラー: {ex.Message}");
});

このようにすれば、少なくとも例外をログに残せます。

ただし、実務ではバックグラウンド処理専用の仕組みを使う方が適切な場合もあります。ASP.NET Coreであれば、BackgroundServiceやキューを使った設計を検討しましょう。

6. 原因4:List.ForEachやLINQの中でasyncを使っている

6-1. List.ForEach(async x => ...)が待たれない理由

C#で非常によくある落とし穴が、List.ForEachの中でasyncを使うパターンです。

C#
var items = new List<int> { 1, 2, 3 };

items.ForEach(async item =>
{
await Task.Delay(1000);
Console.WriteLine(item);
});

Console.WriteLine("完了");

このコードでは、完了が先に表示されることがあります。

理由は、List.ForEachが非同期処理を待つ仕組みを持っていないからです。

List.ForEachが受け取るのはAction<T>です。asyncラムダを渡すと、実質的にasync voidのような扱いになり、呼び出し元から待てません。

その結果、各要素の非同期処理が完了する前に、次の処理へ進んでしまいます。

LINQでも似た問題が起こります。

C#
items.Select(async item =>
{
await Task.Delay(1000);
Console.WriteLine(item);
});

このコードは、Selectしただけでは処理が実行されない、または生成されたTaskを待っていない、という問題があります。

非同期処理をコレクションに対して実行する場合は、foreachで順番に待つか、Task.WhenAllでまとめて待つ必要があります。

6-2. foreachとawaitを使って順番に待つ方法

順番に1つずつ処理したい場合は、foreachawaitを使います。

C#
var items = new List<int> { 1, 2, 3 };

foreach (var item in items)
{
await Task.Delay(1000);
Console.WriteLine(item);
}

Console.WriteLine("完了");

このコードでは、各要素の処理が完了してから次の要素に進みます。

出力は次のようになります。

1
2
3
完了

一方、順番を気にせず並列に処理したい場合は、Task.WhenAllを使います。

C#
var items = new List<int> { 1, 2, 3 };

var tasks = items.Select(async item =>
{
await Task.Delay(1000);
Console.WriteLine(item);
});

await Task.WhenAll(tasks);

Console.WriteLine("完了");

このコードでは、すべての非同期処理が完了してから完了が表示されます。

ただし、Task.WhenAllを使うと処理が同時に走るため、API呼び出しやデータベース処理では負荷に注意が必要です。

大量のデータを処理する場合は、同時実行数を制限する設計も検討しましょう。

C#
var semaphore = new SemaphoreSlim(3);

var tasks = items.Select(async item =>
{
await semaphore.WaitAsync();

try
{
await ProcessAsync(item);
}
finally
{
semaphore.Release();
}
});

await Task.WhenAll(tasks);

この例では、同時に実行される処理を最大3つに制限しています。

List.ForEach(async x => ...)は避け、目的に応じてforeach + awaitまたはTask.WhenAllを使い分けましょう。

まとめ

C#で「awaitが待たない」と感じるとき、多くの場合はawaitそのものではなく、呼び出し方やメソッド定義に原因があります。

まず確認すべきなのは、非同期メソッドを呼び出している側でもawaitしているかです。非同期メソッドを呼ぶだけでは、完了を待たずに次の処理へ進みます。

次に、async voidを使っていないか確認しましょう。async voidは呼び出し元から待てず、例外も捕捉しにくくなります。イベントハンドラー以外では、基本的にasync Taskまたはasync Task<T>を使うべきです。

また、Task.RunやFire-and-Forgetで処理を投げっぱなしにしている場合も、完了前に次の処理へ進みます。必要な処理はawaitし、並列に待つ場合はTask.WhenAllを使いましょう。

さらに、List.ForEach(async x => ...)やLINQの中でasyncを使う書き方にも注意が必要です。順番に処理したいならforeach + await、まとめて待ちたいならTask.WhenAllを使うのが基本です。

C#の非同期処理では、「どのメソッドがTaskを返しているか」「そのTaskを呼び出し元がawaitしているか」を意識することが重要です。

awaitが待たないように見えるときは、次のポイントを順番に確認してください。

C#
// 呼び出し元でawaitしているか
await SomeAsync();

// async voidではなくasync Taskになっているか
public async Task SomeAsync()
{
await Task.Delay(1000);
}

// Task.Runを投げっぱなしにしていないか
await Task.Run(() => DoWork());

// List.ForEachではなくforeachを使っているか
foreach (var item in items)
{
await ProcessAsync(item);
}

これらを押さえれば、C#のawaitが待たないように見える問題の多くは解決できます。