C# Tips | ログ・例外・診断:タスク例外処理

C# C#
スポンサーリンク

はじめに:タスク例外処理は「非同期の失敗を“なかったこと”にしない技術」

async/awaitTask を使い始めると、最初にハマりやすいのが 「例外がどこに行ったか分からない問題」 です。
同期コードなら、try-catch の範囲が目で追いやすいのに、非同期になると

待っていないタスクの中で例外が起きていた
画面上は何も起きていないのに、裏で静かに失敗していた
Task の例外が「未観測例外」としてプロセスを落とす

といった、イヤな落ち方をし始めます。

ここでは、プログラミング初心者向けに

タスクの例外がどう伝播するか
await した場合と、しなかった場合の違い
Task.WhenAll など複数タスクの例外の扱い
未観測のタスク例外(UnobservedTaskException)とそのログ化ユーティリティ

を、例題を交えながら丁寧に解説します。


基本のキホン:await したタスクの例外は「普通の例外」と同じ

await すれば、例外はその場に飛んでくる

まずは一番大事な前提から。
Taskawait している場合、そのタスク内で発生した例外は、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 したときは、通常は「最初の例外」が表に出てきますが、
exAggregateException にキャストすれば、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#

Callertry-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.Runawait を書くより、安全な型を通すイメージです。


まとめ:タスク例外処理は“非同期の失敗に、ちゃんと責任の通り道を作る”こと

タスク例外処理の本質は、

await しているタスクの例外は、その場で責任を持って処理し
await しないタスクの例外は、中で完結させてログを残し
それでも取りこぼしたものは、未観測例外として最後に拾う

という「責任の通り道」を設計することです。

押さえておきたいポイントをぎゅっとまとめると、こうなります。

await している限り、例外は同期と同じように try-catch で扱える。
await しないタスクは、自分の中で try-catch してログを書く(外には届かない)。
複数タスクの例外は Task.WhenAllAggregateException としてまとめて扱われる。
async void はイベントハンドラ以外では使わず、async Task にして呼び出し側で await する。
TaskScheduler.UnobservedTaskException にハンドラを登録して、未観測例外をログに残す。

ここまで腹落ちしていれば、
「非同期にしたら、どこで落ちてるのか分からない」というカオス状態から抜けて、
“非同期でも、失敗の足跡がきちんと追えるコード”を書けるようになります。

タイトルとURLをコピーしました