はじめに:非同期例外処理は「時間差で起きる失敗を、ちゃんと捕まえる技術」
async/await を使い始めると、最初に戸惑うのが「例外がどこに飛んでいくのか分からない」という感覚です。
同期コードなら、その場で try-catch を書けばだいたい何とかなるのに、非同期になると
エラーが起きているのに画面は普通に動いている
ログにも出ていない
気づいたら裏で処理が止まっていた
という“静かな失敗”が起きやすくなります。
非同期例外処理の本質は、
「時間差で起きる失敗にも、ちゃんと責任の通り道を作ること」です。
ここでは、初心者向けに、async/await と例外の関係を、実務で使えるユーティリティの形まで落とし込んで説明します。
基本のキホン:await している限り、例外は「その場」に飛んでくる
await と try-catch の組み合わせは同期とほぼ同じ
まず一番大事な前提から押さえます。async メソッドの中で例外が発生しても、そのメソッドを await していれば、例外は await の行に飛んできます。
public async Task DangerousAsync()
{
await Task.Delay(100);
throw new InvalidOperationException("何かがおかしい");
}
public async Task RunAsync()
{
try
{
await DangerousAsync();
Console.WriteLine("ここには来ない");
}
catch (Exception ex)
{
Console.WriteLine($"捕まえた: {ex.GetType().Name} - {ex.Message}");
}
}
C#RunAsync を実行すると、DangerousAsync 内の例外は await DangerousAsync(); の行に飛び、catch で普通に捕まえられます。
ここでの重要ポイントは、
「await している限り、“例外処理の書き方”は同期コードとほぼ同じでよい」
ということです。try { await ... } catch { ... } で囲む、という基本形をまず体に染み込ませてください。
危険ポイント1:async void の例外は呼び出し元では捕まえられない
イベント以外で async void を使うと、例外が“野良化”する
次に、非同期例外処理で一番事故りやすいのが async void です。
public async void DoAsync()
{
await Task.Delay(100);
throw new Exception("async void 内でエラー");
}
public void Caller()
{
try
{
DoAsync();
}
catch (Exception ex)
{
Console.WriteLine("ここでは捕まえられない");
}
}
C#Caller の try-catch では、DoAsync 内の例外を捕まえられません。async void の例外は、呼び出し元に戻らず、環境によっては「未処理例外」としてアプリ全体のハンドラに飛びます。
これが許されるのは、ほぼ UI のイベントハンドラだけです。
ボタンクリックイベントなどは async void で定義するしかないので、その中で try-catch するのはアリです。
しかし、通常のメソッドは必ず async Task にして、呼び出し側で await できる形にしておくべきです。
ここでの超重要ポイントは、
「async void はイベントハンドラ専用。業務ロジックでは使わない」
というマイルールを持つことです。
これだけで、非同期例外の半分くらいの事故は防げます。
危険ポイント2:await していないタスクの例外は「宙ぶらりん」になる
Fire-and-forget タスクは、自分の中で完結させる
もう一つの落とし穴が、「起動したけど待っていないタスク」です。
public void FireAndForget()
{
Task.Run(async () =>
{
await Task.Delay(100);
throw new Exception("タスク内でエラー");
});
Console.WriteLine("タスクを起動したが、待たずに次へ進む");
}
C#この場合、タスクの中で例外が発生しても、呼び出し元は await していないので、
例外は呼び出し元には届きません。
誰も try-catch していなければ、「未観測のタスク例外」として扱われ、
環境によっては後からプロセスを落とすことすらあります。
こういう「待たないタスク」をどうしても使いたいときは、
タスクの中で自分で try-catch して、ログを書いて終わる形にします。
public void FireAndForgetWithLogging(ILogger logger)
{
Task.Run(async () =>
{
try
{
await Task.Delay(100);
throw new Exception("タスク内でエラー");
}
catch (Exception ex)
{
logger.LogError(ex, "Fire-and-forget タスクで例外が発生しました。");
// ここでは再スローしない。待っていないので意味がない。
}
});
}
C#ここでの重要ポイントは、
「待たないタスクの例外は“外には届かない”。だから中で完結させる」
という割り切りです。
外に投げても、受け取る人がいないのです。
複数の非同期処理:Task.WhenAll と例外の扱い
どれか一つでも失敗したら、WhenAll 自体が例外になる
複数の非同期処理を同時に走らせて、全部終わるのを待つときに使うのが Task.WhenAll です。
ここでも例外の挙動を理解しておく必要があります。
public async Task RunManyAsync()
{
var t1 = Task.Run(async () =>
{
await Task.Delay(100);
throw new InvalidOperationException("t1 失敗");
});
var t2 = Task.Run(async () =>
{
await Task.Delay(200);
throw new ArgumentException("t2 失敗");
});
try
{
await Task.WhenAll(t1, t2);
}
catch (Exception ex)
{
Console.WriteLine($"WhenAll で捕まえた: {ex.GetType().Name}");
if (ex is AggregateException agg)
{
foreach (var inner in agg.InnerExceptions)
{
Console.WriteLine($" 内側: {inner.GetType().Name} - {inner.Message}");
}
}
}
}
C#Task.WhenAll は、複数タスクの例外を AggregateException にまとめて投げます。await したときは、表向きは一つの例外に見えますが、AggregateException にキャストすれば、InnerExceptions から全部の例外を取り出せます。
ここでの重要ポイントは、
「複数タスクの例外は“まとめて扱われる”。必要なら AggregateException を展開してログに残す」
ということです。
特にバッチや並列処理では、「どのタスクが落ちたか」を知ることが重要になります。
非同期例外とログ:ユーティリティでパターンを固定する
「ログを書いてから再スロー」を共通化する
非同期処理でも、例外が飛んでくる場所さえ分かっていれば、やることは同期と同じです。
よくあるパターンは「ログを書いてから再スロー」です。
public static class AsyncExceptionHelper
{
public static async Task RunWithLoggingAsync(
Func<Task> func,
ILogger logger,
string context)
{
try
{
await func();
}
catch (Exception ex)
{
logger.LogError(ex, "非同期処理中に例外。Context={Context}", context);
throw;
}
}
public static async Task<T> RunWithLoggingAsync<T>(
Func<Task<T>> func,
ILogger logger,
string context)
{
try
{
return await func();
}
catch (Exception ex)
{
logger.LogError(ex, "非同期処理中に例外。Context={Context}", context);
throw;
}
}
}
C#使い方はこうなります。
await AsyncExceptionHelper.RunWithLoggingAsync(
() => DangerousAsync(),
_logger,
"ユーザー登録処理");
var result = await AsyncExceptionHelper.RunWithLoggingAsync(
() => LoadUserAsync(id),
_logger,
$"ユーザー取得 Id={id}");
C#ここでの重要ポイントは、
「“非同期処理の例外をどう扱うか”をユーティリティに閉じ込めておくと、書き忘れやバラつきが減る」
ということです。
毎回生で try { await ... } catch { ... } と書くより、安全な型を通すイメージです。
未観測の非同期例外:TaskScheduler.UnobservedTaskException で最後のログを取る
「誰も await しなかったタスクの例外」を最後に拾う
どうしても設計ミスやバグで、「誰にも await されなかったタスク」が出てきます。
その例外は「未観測例外」として扱われ、ガベージコレクションのタイミングなどでTaskScheduler.UnobservedTaskException イベントが発火します。
ここにハンドラを登録しておくと、
「非同期のどこかで投げっぱなしにされた例外」を最後にログに残せます。
public static class UnobservedTaskExceptionLogger
{
public static void Register(ILogger logger)
{
TaskScheduler.UnobservedTaskException += (sender, e) =>
{
logger.LogError(e.Exception, "未観測の Task 例外が発生しました。");
e.SetObserved(); // これを呼ぶことで「この例外はちゃんと見た」とランタイムに伝える
};
}
}
C#アプリ起動時に一度だけ呼びます。
var logger = loggerFactory.CreateLogger<Program>();
UnobservedTaskExceptionLogger.Register(logger);
C#ここでの重要ポイントは、
「未観測例外は“設計の穴”のサインなので、ログに残して気づけるようにしておく」
ということです。
ここで拾えた例外は、「どこかで await を忘れている」「Fire-and-forget の中で try-catch していない」などの改善ポイントになります。
まとめ:非同期例外処理は“時間差の失敗にも、責任のルートを用意する”こと
非同期例外処理の本質を、最後にもう一度整理します。
await しているタスクの例外は、その場で try-catch できる。
だから、重要な処理は必ず async Task + await で書く。
async void はイベントハンドラ専用。
業務ロジックでは使わず、呼び出し側が await できる形にする。
待たないタスク(Fire-and-forget)は、中で try-catch してログを書いて完結させる。
外に投げても誰も受け取らない。
複数タスクの例外は Task.WhenAll でまとめて扱われる。
必要なら AggregateException を展開して、どのタスクが落ちたかをログに残す。
未観測のタスク例外は TaskScheduler.UnobservedTaskException で最後に拾い、
「設計の穴」を見つけるためのログとして残す。
ここまで腹落ちしていれば、
「非同期にしたら、どこで落ちているのか分からない」という不安から抜けて、
“非同期でも、失敗の足跡がきちんと追えるコード”を書けるようになります。

