【.NET并发编程 – 07】异步异常处理:AggregateException 的拆解与最佳实践

07. 异步异常处理:AggregateException 的拆解与最佳实践

本章 GitHub 仓库csharp-concurrency-cookbook

欢迎 Star 和 Fork!所有代码示例都可以在仓库中找到并运行。


本章导读

本文目标:掌握异步异常处理的正确姿势,理解 AggregateException 的设计思想,学会在复杂并发场景下优雅处理异常。

你是否遇到过这样的场景:

  • 为什么 await 抛出的是单个异常,而 .Result 抛出的是 AggregateException
  • 同时调用 10 个 API,其中 3 个失败了,如何获取所有失败信息?
  • Task.WhenAll 抛异常时,为什么只能捕获第一个?
  • 后台任务(Fire-and-forget)的异常去哪儿了?
  • 如何实现一个”容错”的并发任务执行器?

今天,我们就来彻底搞懂 .NET 异步编程中的异常处理机制,从 AggregateException 的设计理念到实战技巧,一网打尽。

重要提示:本文涉及异步编程的核心概念,建议先掌握前面章节的 Task、async/await 和 CancellationToken 基础。


0️⃣ 一个真实的故事:消失的异常

0.1 场景重现:批量调用 API

假设你正在写一个数据同步工具,需要同时调用 10 个微服务的 API,获取数据并汇总。

你写出了第一版代码:

public async Task<List<UserData>> GetAllUsersAsync()
{
    var tasks = new List<Task<UserData>>();

    // 并发调用 10 个 API
    for (int i = 1; i <= 10; i++)
    {
        tasks.Add(GetUserDataAsync(i));
    }

    // 等待所有任务完成
    var results = await Task.WhenAll(tasks);

    return results.ToList();
}

private async Task<UserData> GetUserDataAsync(int userId)
{
    using var client = new HttpClient();
    var response = await client.GetStringAsync($"https://api.example.com/users/{userId}");
    return JsonSerializer.Deserialize<UserData>(response);
}

测试一下:前面几次运行都正常,但突然有一天:

未处理的异常: System.Net.Http.HttpRequestException: Response status code does not indicate success: 404 (Not Found).

问题来了

  1. 只看到了一个异常,但实际上可能有多个 API 都失败了
  2. 其他成功的 API 数据丢失了
  3. 如何记录所有失败的 API,方便排查问题?

这就是我们今天要解决的核心问题。


0.2 新手的常见尝试

尝试 1:直接 try-catch(丢失了多个异常)

try
{
    var results = await Task.WhenAll(tasks);
}
catch (Exception ex)
{
    Console.WriteLine($"出错了: {ex.Message}");
    //  只能捕获第一个异常!其他失败的任务信息丢失
}

结果:只捕获到了第一个异常,其他失败的任务信息全部丢失。

尝试 2:改用 .Result(引入了 AggregateException)

try
{
    var whenAllTask = Task.WhenAll(tasks);
    whenAllTask.Wait(); // 或者 .Result
}
catch (AggregateException aggEx)
{
    foreach (var ex in aggEx.InnerExceptions)
    {
        Console.WriteLine($"出错了: {ex.Message}");
    }
    //  可以获取所有异常,但 Wait() 会阻塞线程!
}

结果:能获取所有异常了,但 .Wait() 会阻塞线程,而且可能导致死锁(回顾第 05 章)。

正确的做法:await + 手动检查 Task.Exception

var whenAllTask = Task.WhenAll(tasks);

try
{
    await whenAllTask;
}
catch (Exception firstEx)
{
    // 捕获第一个异常
    Console.WriteLine($"第一个异常: {firstEx.Message}");

    // 如果需要所有异常,从 Task.Exception 中获取
    if (whenAllTask.Exception != null)
    {
        Console.WriteLine("\n所有异常:");
        foreach (var ex in whenAllTask.Exception.InnerExceptions)
        {
            Console.WriteLine($"- {ex.Message}");
        }
    }
}

疑问:为什么 await 只抛出第一个异常,而 .Wait() 抛出 AggregateException?这就要从 AggregateException 的设计思想说起。


1️⃣ AggregateException:为什么需要它?

1.1 单个异常 vs 多个异常

在传统的同步编程中,一个方法只会抛出一个异常

public void ProcessData()
{
    ValidateInput();    // 可能抛出 ArgumentException
    ConnectDatabase();  // 可能抛出 SqlException
    SaveData();         // 可能抛出 IOException
    //  一旦抛出异常,后面的代码不会执行
}

但在并发编程中,情况完全不同:

var task1 = Task.Run(() => throw new InvalidOperationException("Task 1 failed"));
var task2 = Task.Run(() => throw new ArgumentException("Task 2 failed"));
var task3 = Task.Run(() => throw new IOException("Task 3 failed"));

await Task.WhenAll(task1, task2, task3);
//  三个任务都失败了,应该抛出哪个异常?

问题

  • 三个任务同时执行,都失败了
  • 传统的异常机制只能抛出一个异常
  • 如果只抛出第一个,其他两个异常信息就丢失了

解决方案AggregateException——一个可以包含多个异常的容器。


1.2 AggregateException 的设计结构

public class AggregateException : Exception
{
    // 存储所有内部异常
    public ReadOnlyCollection<Exception> InnerExceptions { get; }

    // 扁平化嵌套的 AggregateException
    public AggregateException Flatten();

    // 按条件处理异常
    public void Handle(Func<Exception, bool> predicate);
}

核心特性

  1. InnerExceptions:存储所有子任务的异常
  2. Flatten():处理嵌套的 AggregateException
  3. Handle():选择性处理某些异常,未处理的会重新抛出

示例

try
{
    var task1 = Task.Run(() => throw new InvalidOperationException("Task 1 failed"));
    var task2 = Task.Run(() => throw new ArgumentException("Task 2 failed"));
    var task3 = Task.Run(() => throw new IOException("Task 3 failed"));

    Task.WaitAll(task1, task2, task3); //  同步等待,会抛出 AggregateException
}
catch (AggregateException aggEx)
{
    Console.WriteLine($"捕获了 {aggEx.InnerExceptions.Count} 个异常:");

    foreach (var ex in aggEx.InnerExceptions)
    {
        Console.WriteLine($"- {ex.GetType().Name}: {ex.Message}");
    }
}

输出

捕获了 3 个异常:
- InvalidOperationException: Task 1 failed
- ArgumentException: Task 2 failed
- IOException: Task 3 failed

1.3 await vs Wait/Result 的异常行为差异

这是一个非常重要的知识点,很多开发者在这里踩坑。

场景:单个任务失败

var task = Task.Run(() => throw new InvalidOperationException("Something went wrong"));

方式 1:使用 await(推荐)

try
{
    await task;
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"捕获异常: {ex.Message}");
    //  直接抛出原始异常,简化异常处理
}

方式 2:使用 Wait() 或 .Result

try
{
    task.Wait(); // 或者 var result = task.Result;
}
catch (AggregateException aggEx)
{
    //  包装在 AggregateException 中,需要额外解包
    var innerEx = aggEx.InnerException;
    Console.WriteLine($"捕获异常: {innerEx.Message}");
}

对比表格

特性 await .Wait() / .Result
抛出的异常类型 原始异常(第一个) AggregateException
获取原始异常 直接捕获 aggEx.InnerException
多个异常 只抛出第一个 InnerExceptions 包含所有
线程阻塞 不阻塞 阻塞当前线程
死锁风险 安全 可能死锁(UI 线程)
推荐使用 强烈推荐 尽量避免

结论

  • 优先使用 await:代码更简洁,异常处理更直观
  • 需要所有异常:通过 Task.Exception 属性获取 AggregateException
  • 避免使用 .Wait().Result:会阻塞线程,可能导致死锁

2️⃣ Task.WhenAll 的异常陷阱与解决方案

2.1 问题:WhenAll 只抛出第一个异常

这是 Task.WhenAll 最容易踩坑的地方。

示例

public async Task CallMultipleApisAsync()
{
    var tasks = new[]
    {
        CallApiAsync(1), //  成功
        CallApiAsync(2), //  失败:404 Not Found
        CallApiAsync(3), //  成功
        CallApiAsync(4), //  失败:500 Internal Server Error
        CallApiAsync(5), //  失败:Timeout
    };

    try
    {
        await Task.WhenAll(tasks);
    }
    catch (Exception ex)
    {
        Console.WriteLine($"捕获异常: {ex.Message}");
        //  只能看到第一个失败的异常(404 Not Found)
        //  其他两个失败(500 和 Timeout)的信息丢失
    }
}

输出

捕获异常: Response status code does not indicate success: 404 (Not Found).

问题:只看到了第一个异常,其他失败信息丢失了!


2.2 解决方案 1:手动检查 Task.Exception

public async Task CallMultipleApisAsync()
{
    var tasks = new[]
    {
        CallApiAsync(1),
        CallApiAsync(2),
        CallApiAsync(3),
        CallApiAsync(4),
        CallApiAsync(5),
    };

    var whenAllTask = Task.WhenAll(tasks);

    try
    {
        await whenAllTask;
    }
    catch (Exception firstEx)
    {
        Console.WriteLine($"第一个异常: {firstEx.Message}");

        //  从 Task.Exception 获取所有异常
        if (whenAllTask.Exception != null)
        {
            Console.WriteLine($"\n总共 {whenAllTask.Exception.InnerExceptions.Count} 个任务失败:");

            foreach (var ex in whenAllTask.Exception.InnerExceptions)
            {
                Console.WriteLine($"- {ex.GetType().Name}: {ex.Message}");
            }
        }
    }
}

输出

第一个异常: Response status code does not indicate success: 404 (Not Found).

总共 3 个任务失败:
- HttpRequestException: Response status code does not indicate success: 404 (Not Found).
- HttpRequestException: Response status code does not indicate success: 500 (Internal Server Error).
- TaskCanceledException: The request was canceled due to the configured HttpClient.Timeout of 30 seconds elapsing.

优点: 可以获取所有异常信息

缺点: 代码略显冗长


2.3 解决方案 2:逐个 await(更简洁)

public async Task CallMultipleApisAsync()
{
    var tasks = new[]
    {
        CallApiAsync(1),
        CallApiAsync(2),
        CallApiAsync(3),
        CallApiAsync(4),
        CallApiAsync(5),
    };

    // 先启动所有任务(并发执行)
    var whenAllTask = Task.WhenAll(tasks);

    // 逐个 await,捕获每个任务的异常
    foreach (var task in tasks)
    {
        try
        {
            await task;
            Console.WriteLine(" 任务成功");
        }
        catch (Exception ex)
        {
            Console.WriteLine($" 任务失败: {ex.Message}");
        }
    }
}

输出

 任务成功
 任务失败: Response status code does not indicate success: 404 (Not Found).
 任务成功
 任务失败: Response status code does not indicate success: 500 (Internal Server Error).
 任务失败: The request was canceled due to the configured HttpClient.Timeout of 30 seconds elapsing.

优点

  • 可以单独处理每个任务的异常
  • 代码清晰,逻辑直观

注意

  • Task.WhenAll 仍然需要调用,确保所有任务并发执行
  • 逐个 await 时,已完成的任务会立即返回,不会重新执行

2.4 解决方案 3:实现 SafeWhenAll 扩展方法(最优雅)

目标:封装异常处理逻辑,返回成功和失败的结果。

public static async Task<(List<T> Successes, List<Exception> Failures)> SafeWhenAll<T>(
    this IEnumerable<Task<T>> tasks)
{
    var taskList = tasks.ToList();
    var successes = new List<T>();
    var failures = new List<Exception>();

    foreach (var task in taskList)
    {
        try
        {
            var result = await task;
            successes.Add(result);
        }
        catch (Exception ex)
        {
            failures.Add(ex);
        }
    }

    return (successes, failures);
}

使用示例

public async Task CallMultipleApisAsync()
{
    var tasks = new[]
    {
        CallApiAsync(1),
        CallApiAsync(2),
        CallApiAsync(3),
        CallApiAsync(4),
        CallApiAsync(5),
    };

    var (successes, failures) = await tasks.SafeWhenAll();

    Console.WriteLine($" 成功: {successes.Count} 个");
    Console.WriteLine($" 失败: {failures.Count} 个");

    if (failures.Any())
    {
        Console.WriteLine("\n失败详情:");
        foreach (var ex in failures)
        {
            Console.WriteLine($"- {ex.GetType().Name}: {ex.Message}");
        }
    }
}

输出

 成功: 2 个
 失败: 3 个

失败详情:
- HttpRequestException: Response status code does not indicate success: 404 (Not Found).
- HttpRequestException: Response status code does not indicate success: 500 (Internal Server Error).
- TaskCanceledException: The request was canceled due to the configured HttpClient.Timeout of 30 seconds elapsing.

优点

  • 封装良好,可复用
  • 同时获取成功和失败的结果
  • 不丢失任何异常信息

2.5 实战场景:并发调用 API + 容错处理

需求

  • 同时调用 10 个 API
  • 允许部分失败,只要有 5 个成功就算整体成功
  • 记录所有失败的 API,方便排查

实现

public class ApiAggregator
{
    private readonly HttpClient _httpClient;
    private readonly ILogger<ApiAggregator> _logger;

    public ApiAggregator(HttpClient httpClient, ILogger<ApiAggregator> logger)
    {
        _httpClient = httpClient;
        _logger = logger;
    }

    public async Task<AggregatedResult> GetAggregatedDataAsync(
        IEnumerable<string> apiUrls,
        int minSuccessCount = 5,
        CancellationToken cancellationToken = default)
    {
        var tasks = apiUrls.Select(url => CallApiWithLoggingAsync(url, cancellationToken)).ToList();

        var (successes, failures) = await tasks.SafeWhenAll();

        // 记录失败信息
        foreach (var ex in failures)
        {
            _logger.LogError(ex, "API 调用失败");
        }

        // 检查是否满足最低成功数量
        if (successes.Count < minSuccessCount)
        {
            throw new InvalidOperationException(
                $"API 调用失败过多:期望至少 {minSuccessCount} 个成功,实际只有 {successes.Count} 个成功");
        }

        return new AggregatedResult
        {
            Successes = successes,
            FailureCount = failures.Count,
            Errors = failures.Select(ex => ex.Message).ToList()
        };
    }

    private async Task<ApiResponse> CallApiWithLoggingAsync(string url, CancellationToken cancellationToken)
    {
        try
        {
            _logger.LogInformation("开始调用 API: {Url}", url);

            var response = await _httpClient.GetStringAsync(url, cancellationToken);
            var data = JsonSerializer.Deserialize<ApiResponse>(response);

            _logger.LogInformation("API 调用成功: {Url}", url);

            return data;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "API 调用失败: {Url}", url);
            throw; // 重新抛出,由 SafeWhenAll 捕获
        }
    }
}

public class AggregatedResult
{
    public List<ApiResponse> Successes { get; set; }
    public int FailureCount { get; set; }
    public List<string> Errors { get; set; }
}

使用示例

var apiUrls = new[]
{
    "https://api1.example.com/data",
    "https://api2.example.com/data",
    "https://api3.example.com/data",
    // ... 更多 API
};

try
{
    var result = await aggregator.GetAggregatedDataAsync(apiUrls, minSuccessCount: 5);

    Console.WriteLine($" 成功获取 {result.Successes.Count} 个数据");
    Console.WriteLine($" 失败 {result.FailureCount} 个请求");
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($" {ex.Message}");
}

3️⃣ 后台任务(Fire-and-Forget)的异常处理

3.1 问题:后台任务的异常会被吞掉

场景:启动一个后台任务,不等待它完成。

//  错误示例:异常会被吞掉
public void StartBackgroundTask()
{
    _ = DoWorkAsync(); // Fire-and-forget
}

private async Task DoWorkAsync()
{
    await Task.Delay(1000);
    throw new InvalidOperationException("后台任务失败了!");
    //  这个异常不会被捕获,程序不会崩溃,但异常信息丢失
}

问题

  • 异常被吞掉,无法排查问题
  • 可能导致未处理的异常(UnobservedTaskException)
  • 资源可能没有正确释放

3.2 解决方案 1:使用 TaskScheduler.UnobservedTaskException

// 在程序启动时注册全局异常处理器
TaskScheduler.UnobservedTaskException += (sender, e) =>
{
    Console.WriteLine($" 未观察到的异常: {e.Exception.Message}");

    // 标记为已观察,防止程序崩溃
    e.SetObserved();
};

缺点

  • 只在垃圾回收时触发,可能延迟很久
  • .NET Core 默认不会导致程序崩溃,容易忽略异常

3.2 解决方案 2:SafeFireAndForget 扩展方法(推荐)

public static async void SafeFireAndForget(
    this Task task,
    Action<Exception> onException = null)
{
    try
    {
        await task;
    }
    catch (Exception ex)
    {
        // 调用自定义异常处理器
        onException?.Invoke(ex);

        // 或者记录日志
        Console.WriteLine($" 后台任务异常: {ex.Message}");
    }
}

使用示例

public void StartBackgroundTask()
{
    DoWorkAsync().SafeFireAndForget(ex =>
    {
        _logger.LogError(ex, "后台任务失败");
        // 可以发送告警、记录到数据库等
    });
}

private async Task DoWorkAsync()
{
    await Task.Delay(1000);
    throw new InvalidOperationException("后台任务失败了!");
}

优点

  • 异常不会被吞掉
  • 可以自定义异常处理逻辑
  • 代码清晰,意图明确

3.3 解决方案 3:使用 BackgroundService(.NET Core)

如果是长期运行的后台任务,推荐使用 IHostedServiceBackgroundService

public class MyBackgroundService : BackgroundService
{
    private readonly ILogger<MyBackgroundService> _logger;

    public MyBackgroundService(ILogger<MyBackgroundService> logger)
    {
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await DoWorkAsync(stoppingToken);
            }
            catch (Exception ex)
            {
                //  异常会被记录,服务继续运行
                _logger.LogError(ex, "后台任务执行失败");

                // 等待一段时间后重试
                await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
            }
        }
    }

    private async Task DoWorkAsync(CancellationToken cancellationToken)
    {
        // 业务逻辑
        await Task.Delay(5000, cancellationToken);
    }
}

4️⃣ AggregateException 的高级用法

4.1 Flatten():扁平化嵌套异常

问题:嵌套的 Task.WhenAll 会产生嵌套的 AggregateException

var task1 = Task.Run(() => throw new InvalidOperationException("Task 1"));
var task2 = Task.Run(() => throw new ArgumentException("Task 2"));

var outerTask = Task.Run(() =>
{
    Task.WaitAll(task1, task2); // 内层 AggregateException
});

try
{
    outerTask.Wait(); // 外层 AggregateException
}
catch (AggregateException aggEx)
{
    // aggEx.InnerExceptions[0] 是另一个 AggregateException
    // 需要递归处理

    //  使用 Flatten() 扁平化
    var flattenedEx = aggEx.Flatten();

    foreach (var ex in flattenedEx.InnerExceptions)
    {
        Console.WriteLine($"- {ex.GetType().Name}: {ex.Message}");
    }
}

输出

- InvalidOperationException: Task 1
- ArgumentException: Task 2

4.2 Handle():选择性处理异常

场景:某些异常可以忽略,某些异常需要重新抛出。

try
{
    Task.WaitAll(tasks);
}
catch (AggregateException aggEx)
{
    aggEx.Handle(ex =>
    {
        // 如果是 TaskCanceledException,忽略它
        if (ex is TaskCanceledException)
        {
            Console.WriteLine("任务被取消,忽略");
            return true; // 标记为已处理
        }

        // 其他异常不处理,会重新抛出
        return false;
    });
}

行为

  • Handle() 返回 true:异常被处理,不会重新抛出
  • Handle() 返回 false:异常未处理,会重新抛出

5️⃣ 异常处理的最佳实践

5.1 优先使用 await + try-catch

//  推荐:简洁清晰
try
{
    var result = await SomeOperationAsync();
}
catch (HttpRequestException ex)
{
    // 处理网络异常
}
catch (JsonException ex)
{
    // 处理 JSON 解析异常
}
//  不推荐:引入 AggregateException,增加复杂度
try
{
    var result = SomeOperationAsync().Result;
}
catch (AggregateException aggEx)
{
    foreach (var ex in aggEx.InnerExceptions)
    {
        // ...
    }
}

5.2 Task.WhenAll 需要所有异常时

var whenAllTask = Task.WhenAll(tasks);

try
{
    await whenAllTask;
}
catch
{
    // 从 Task.Exception 获取所有异常
    if (whenAllTask.Exception != null)
    {
        foreach (var ex in whenAllTask.Exception.InnerExceptions)
        {
            _logger.LogError(ex, "任务失败");
        }
    }
}

5.3 后台任务必须有异常处理

//  错误:异常会被吞掉
_ = DoWorkAsync();

//  正确:使用 SafeFireAndForget
DoWorkAsync().SafeFireAndForget(ex =>
{
    _logger.LogError(ex, "后台任务失败");
});

5.4 库代码不要吞掉异常

//  错误:吞掉异常,调用者无法感知
public async Task<Result> TryGetDataAsync()
{
    try
    {
        return await GetDataAsync();
    }
    catch
    {
        return null; // 吞掉异常
    }
}

//  正确:让异常传播,或返回明确的错误状态
public async Task<Result<T, Error>> TryGetDataAsync()
{
    try
    {
        var data = await GetDataAsync();
        return Result.Success(data);
    }
    catch (Exception ex)
    {
        return Result.Failure(ex.Message);
    }
}

5.5 异常信息要足够详细

//  错误:异常信息不明确
throw new Exception("出错了");

//  正确:提供上下文信息
throw new InvalidOperationException(
    $"API 调用失败: URL={url}, StatusCode={statusCode}, ErrorMessage={errorMessage}");

6️⃣ 实战总结:异常处理清单

Do’s(应该做的)

场景 推荐做法
单个任务 使用 await + try-catch
多个任务(需要所有异常) await Task.WhenAll + 检查 Task.Exception
多个任务(容错) 使用 SafeWhenAll 扩展方法
后台任务 使用 SafeFireAndForgetBackgroundService
嵌套异常 使用 Flatten() 扁平化
选择性处理 使用 Handle() 方法
记录日志 catch 块中使用 ILogger

Don’ts(不应该做的)

场景 问题
使用 .Wait().Result 阻塞线程,可能死锁
吞掉异常(空 catch) 隐藏问题,难以排查
忽略后台任务异常 资源泄漏,问题难以发现
只捕获第一个异常 丢失其他失败信息
异常信息不足 难以定位问题

7️⃣ 本章小结

核心知识点

  1. AggregateException 的设计思想

    • 为并发任务设计的异常容器
    • 可以包含多个子异常
    • 提供 Flatten()Handle() 高级功能
  2. await vs Wait/Result 的异常行为

    • await:抛出第一个原始异常,简化处理
    • .Wait() / .Result:抛出 AggregateException,阻塞线程
    • 优先使用 await
  3. Task.WhenAll 的异常处理

    • await 只抛出第一个异常
    • 通过 Task.Exception 获取所有异常
    • 使用 SafeWhenAll 封装处理逻辑
  4. 后台任务的异常处理

    • 异常容易被吞掉
    • 使用 SafeFireAndForgetBackgroundService
    • 注册全局的 UnobservedTaskException 处理器

进阶思考

之前去面试,碰见了一道面试题,至今记忆犹新。现在拿出来,供大家思考一下:

有一组API,数量记为N,50<=N<=500。现在要设计一个功能,要求在最短的时间内找出这组API里成功响应时间最短的API,每次最大并发请求数量限制为10。


下一章预告

在下一章中,我们将深入探讨异步编程的最佳实践和反模式

  • async void 的危害与正确使用场景
  • 异步同步混用的陷阱
  • Task.Run 的滥用问题
  • using 语句中的异步操作
  • 异步命名规范与代码审查清单

敬请期待!

文章摘自:https://www.cnblogs.com/diamondhusky/p/20008918