はじめに:タスク例外処理は「非同期の失敗を“なかったこと”にしない技術」
async/await や Task を使い始めると、最初にハマりやすいのが 「例外がどこに行ったか分からない問題」 です。
同期コードなら、try-catch の範囲が目で追いやすいのに、非同期になると
待っていないタスクの中で例外が起きていた
画面上は何も起きていないのに、裏で静かに失敗していたTask の例外が「未観測例外」としてプロセスを落とす
といった、イヤな落ち方をし始めます。
ここでは、プログラミング初心者向けに
タスクの例外がどう伝播するかawait した場合と、しなかった場合の違いTask.WhenAll など複数タスクの例外の扱い
未観測のタスク例外(UnobservedTaskException)とそのログ化ユーティリティ
を、例題を交えながら丁寧に解説します。
基本のキホン:await したタスクの例外は「普通の例外」と同じ
await すれば、例外はその場に飛んでくる
まずは一番大事な前提から。Task を 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 内の InvalidOperationException は、await DangerousAsync(); の行に飛んできて、catch で普通に捕まえられます。
ここでの重要ポイントは、
「await している限り、“例外の扱い”は同期コードとほぼ同じ感覚で考えてよい」
ということです。try { await ... } catch { ... } で囲めば、その範囲でちゃんと捕まえられます。
危険ゾーン:await していないタスクの例外は「宙ぶらりん」になる
async void や「投げっぱなし Task」は要注意
問題が起きやすいのは、「await していないタスク」です。
例えば、こんなコード。
public void FireAndForget()
{
Task.Run(async () =>
{
await Task.Delay(100);
throw new Exception("タスク内でエラー");
});
Console.WriteLine("タスクを起動したが、待たないで次へ進む");
}
C#この場合、呼び出し元はタスクを待っていません。
タスクの中で例外が発生しても、その場で try-catch していなければ、
「誰も見ていない例外」として扱われます。
環境や設定によっては、
未観測のタスク例外として、後から TaskScheduler.UnobservedTaskException が発火する
最悪、プロセスが落ちる
といった挙動になります。
ここでの重要ポイントは、
「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(() => throw new InvalidOperationException("t1 失敗"));
var t2 = Task.Run(() => 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 したときは、通常は「最初の例外」が表に出てきますが、ex を AggregateException にキャストすれば、InnerExceptions から全部の例外を取り出せます。
ここでの重要ポイントは、
「複数タスクの例外は“まとめて扱われる”ので、必要なら AggregateException を意識して中身を展開する」
ということです。
特にバッチ処理などでは、「どのタスクが落ちたか」をログに残したくなる場面が多いです。
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 Task を使い、呼び出し側で await できる形にしておくべきです。
ここでの重要ポイントは、
「async void は“例外の扱いが特殊”なので、イベントハンドラ以外では使わない」
というルールを自分の中に持つことです。
未観測タスク例外(UnobservedTaskException)とログユーティリティ
「誰も await しなかったタスクの例外」を最後に拾う
Task の例外で厄介なのが、「誰にも await されなかったタスク」の例外です。
これらは「未観測例外」として扱われ、ガベージコレクションのタイミングなどでTaskScheduler.UnobservedTaskException イベントが発火します。
ここでログを取るユーティリティを用意しておくと、
「どこかで投げっぱなしにされたタスクの例外」を後から追えるようになります。
public static class TaskExceptionLogger
{
public static void Register(ILogger logger)
{
TaskScheduler.UnobservedTaskException += (sender, e) =>
{
logger.LogError(e.Exception, "未観測のタスク例外が発生しました。");
e.SetObserved(); // これを呼ばないと、環境によってはプロセスが落ちることもある
};
}
}
C#アプリ起動時に一度だけ登録します。
var logger = loggerFactory.CreateLogger<Program>();
TaskExceptionLogger.Register(logger);
C#ここでの重要ポイントは二つです。
一つ目は、「未観測例外は“設計ミスのサイン”なので、ログに残して気づけるようにしておく」こと。
二つ目は、「e.SetObserved() を呼ぶことで、“この例外はちゃんと見たよ”とランタイムに伝えられる」ことです。
タスク例外処理ユーティリティの形を考える
「ログを書いてから再スロー」や「投げっぱなしタスクの安全ラッパー」
実務でよく使うパターンを、小さなユーティリティにしておくと便利です。
例えば、「タスクを実行して、例外が出たらログを書いて再スローする」ラッパー。
public static class TaskRunner
{
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;
}
}
}
C#使い方はこうです。
await TaskRunner.RunWithLoggingAsync(
() => DangerousAsync(),
_logger,
"ユーザー登録処理");
C#また、「投げっぱなしタスク」を安全に起動するラッパーも作れます。
public static class FireAndForget
{
public static void Run(Func<Task> func, ILogger logger, string context)
{
Task.Run(async () =>
{
try
{
await func();
}
catch (Exception ex)
{
logger.LogError(ex, "Fire-and-forget タスクで例外。Context={Context}", context);
}
});
}
}
C#ここでの重要ポイントは、
「“例外をどう扱うか”のパターンをユーティリティに閉じ込めておくと、書き忘れやバラつきが減る」
ということです。
毎回生で Task.Run や await を書くより、安全な型を通すイメージです。
まとめ:タスク例外処理は“非同期の失敗に、ちゃんと責任の通り道を作る”こと
タスク例外処理の本質は、
await しているタスクの例外は、その場で責任を持って処理しawait しないタスクの例外は、中で完結させてログを残し
それでも取りこぼしたものは、未観測例外として最後に拾う
という「責任の通り道」を設計することです。
押さえておきたいポイントをぎゅっとまとめると、こうなります。
await している限り、例外は同期と同じように try-catch で扱える。await しないタスクは、自分の中で try-catch してログを書く(外には届かない)。
複数タスクの例外は Task.WhenAll で AggregateException としてまとめて扱われる。async void はイベントハンドラ以外では使わず、async Task にして呼び出し側で await する。TaskScheduler.UnobservedTaskException にハンドラを登録して、未観測例外をログに残す。
ここまで腹落ちしていれば、
「非同期にしたら、どこで落ちてるのか分からない」というカオス状態から抜けて、
“非同期でも、失敗の足跡がきちんと追えるコード”を書けるようになります。

