async / await は、C#を使う上で避けて通れない機能です。しかし実際には、「非同期に書ける記法」としてなんとなく使っているだけで、挙動を厳密には把握していない、ということも少なくありません。特に混乱しやすいのが、「メソッドの中で await していること」と、「そのメソッドを呼び出す側が await していること」が別だという点です。
ここでは、async / await の本質を中断と再開の仕組みとして捉えながら、よくある誤解も含めて整理します。
async/await の本質
まず結論を書くと、async / await は「別スレッドを作る仕組み」ではありません。より正確には、待ち時間をスレッドに持たせず、処理を途中で中断して後で再開する仕組みです。
async は「このメソッドの中では await を使います」という宣言に近く、await は「このTaskが終わるまで、ここでいったん処理を中断し、終わったら続きを実行してください」という意味を持っています。
最初に押さえるべきこと
理解のために、まずこれを切り分ける必要があります。
区別すべき2つの話
- メソッドの中で
awaitしているか - そのメソッドを呼ぶ側が
awaitしているか
この2つは似ていますが、意味が違います。前者は「そのメソッド自身が途中で中断するかどうか」、後者は「呼び出し元がその完了を待つかどうか」です。
await する場合の例
private async Task DoWorkAsync()
{
Console.WriteLine("A: 開始");
await Task.Delay(3000);
Console.WriteLine("B: 3秒後");
}
private async Task CallerAsync()
{
Console.WriteLine("1: 呼び出し前");
await DoWorkAsync();
Console.WriteLine("2: 呼び出し後");
}
この場合、CallerAsync は DoWorkAsync の完了を待ちます。出力順は次のようになります。
1: 呼び出し前
A: 開始
(3秒待つ)
B: 3秒後
2: 呼び出し後
ここで重要なのは、DoWorkAsync の中に await があるだけではなく、呼び出し側も await DoWorkAsync() していることです。だから CallerAsync の続きはすぐには進まず、完了後に再開されます。
await しない場合の例
private async Task DoWorkAsync()
{
Console.WriteLine("A: 開始");
await Task.Delay(3000);
Console.WriteLine("B: 3秒後");
}
private void Caller()
{
Console.WriteLine("1: 呼び出し前");
_ = DoWorkAsync();
Console.WriteLine("2: 呼び出し後");
}
この場合、出力はこうなります。
1: 呼び出し前
A: 開始
2: 呼び出し後
(3秒待つ)
B: 3秒後
DoWorkAsync は中で await していますが、呼び出し側の Caller は待っていません。そのため、DoWorkAsync を開始したあと、Caller はすぐ先へ進みます。
これが、「awaitしなければ、ただ非同期処理を開始しているだけ」 という説明の意味です。
asyncメソッドはどこまで即時に実行されるのか
ここも重要です。async メソッドは、呼び出した瞬間に全部が「どこか裏で」動き始めるわけではありません。await に到達するまでは、普通のメソッドのようにその場で同期的に進みます。
private async Task SampleAsync()
{
Console.WriteLine("ここはすぐ実行される");
Console.WriteLine("ここもすぐ実行される");
await Task.Delay(3000);
Console.WriteLine("これは3秒後");
}
これを呼び出すと、最初の2行はすぐに実行され、await に到達したところで中断されます。ここで未完了Taskなら、続きが保存され、呼び出し元の制御に戻ります。
つまり、async メソッドは「最初から全部非同期」なのではなく、
実際の流れ
awaitに到達するまでは同期的に実行awaitに到達すると中断できる- 完了後に続きが再開される
という挙動をします。
UIアプリではなぜUIスレッドに戻るのか
WPFのようなUIアプリで await を使うと、多くの場合、await の後の続きは元のUIスレッドで実行されます。これは SynchronizationContext によって、「元の文脈に戻る」ように調整されているためです。
そのため、ボタンクリックイベントの中で await を書いても、完了後の続きでそのままUI更新を書けることが多いわけです。逆にコンソールアプリでは、こうしたUI文脈がないため、再開先はThreadPool側になることがあります。
この違いを知らないと、「await の後は別スレッドだと思っていたのにそうではなかった」「コンソールだと挙動が少し違う」と感じやすくなります。
実例:DispatcherTimer と fire-and-forget
タイマー通知のコードでは、次のような書き方がよく出てきます。
_influxTimer.Tick += (_, __) => _ = NotifyInfluxDbAsync(_counter!.TodayTotal);
この書き方では、NotifyInfluxDbAsync は開始されますが、イベントハンドラ側はその完了を待ちません。つまり、Tickイベントの処理自体はすぐ終わり、HTTP送信の続きは裏で進行します。
NotifyInfluxDbAsync の中では await _influxNotifier.SendAsync(total); としていても、それはNotifyInfluxDbAsync 自身が待つというだけであり、Tickイベントが待つわけではありません。
ここで大事なのは、「中で await している」と「呼び出し側が待つ」は別だ、という点です。
async/await の使い方の基本方針
async / await は便利ですが、書き方を誤ると意図しない挙動になります。実務では、次のような方針を意識すると扱いやすくなります。
基本方針
- 待つべきところでは素直に
awaitする - 待たない処理は「本当に待たなくてよいか」を確認する
- fire-and-forget は例外処理と重複実行に注意する
.Resultや.Wait()で同期的に潰さない
特に fire-and-forget は簡単に書ける反面、失敗時の扱いや多重起動の問題が出やすいため、意図して使うべきです。
まとめ
async / await の本質は、処理を「別スレッドに飛ばす」ことではなく、待ち時間の間にスレッドを解放し、後で続きを再開することです。
その理解のためには、
- メソッドの中で
awaitしているか - 呼び出し側がそのメソッドを
awaitしているか
を分けて考える必要があります。前者は「そのメソッドが中断できるかどうか」、後者は「呼び出し元が待つかどうか」です。
この区別ができるようになると、タイマーイベント、UIイベント、HTTP通信、DBアクセスなどのコードを見たときに、「どこで処理が中断され、誰が待っているのか」を追いやすくなります。async / await は魔法ではなく、中断と再開を分かりやすく書くための構文だと捉えるのが一番理解しやすいと思います。