C# Tips | ログ・例外・診断:非同期例外処理

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

はじめに:非同期例外処理は「時間差で起きる失敗を、ちゃんと捕まえる技術」

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#

Callertry-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 Taskawait で書く。

async void はイベントハンドラ専用。
業務ロジックでは使わず、呼び出し側が await できる形にする。

待たないタスク(Fire-and-forget)は、中で try-catch してログを書いて完結させる。
外に投げても誰も受け取らない。

複数タスクの例外は Task.WhenAll でまとめて扱われる。
必要なら AggregateException を展開して、どのタスクが落ちたかをログに残す。

未観測のタスク例外は TaskScheduler.UnobservedTaskException で最後に拾い、
「設計の穴」を見つけるためのログとして残す。

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

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