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 个 APIfor (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).
问题来了:
- 只看到了一个异常,但实际上可能有多个 API 都失败了
- 其他成功的 API 数据丢失了
- 如何记录所有失败的 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(); // 可能抛出 ArgumentExceptionConnectDatabase(); // 可能抛出 SqlExceptionSaveData(); // 可能抛出 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; }// 扁平化嵌套的 AggregateExceptionpublic AggregateException Flatten();// 按条件处理异常public void Handle(Func<Exception, bool> predicate);
}
核心特性:
- InnerExceptions:存储所有子任务的异常
- Flatten():处理嵌套的
AggregateException - 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 FoundCallApiAsync(3), // ✅ 成功CallApiAsync(4), // ❌ 失败:500 Internal Server ErrorCallApiAsync(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)
如果是长期运行的后台任务,推荐使用 IHostedService 或 BackgroundService。
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 扩展方法 |
| 后台任务 | 使用 SafeFireAndForget 或 BackgroundService |
| 嵌套异常 | 使用 Flatten() 扁平化 |
| 选择性处理 | 使用 Handle() 方法 |
| 记录日志 | 在 catch 块中使用 ILogger |
❌ Don'ts(不应该做的)
| 场景 | 问题 |
|---|---|
使用 .Wait() 或 .Result |
阻塞线程,可能死锁 |
| 吞掉异常(空 catch) | 隐藏问题,难以排查 |
| 忽略后台任务异常 | 资源泄漏,问题难以发现 |
| 只捕获第一个异常 | 丢失其他失败信息 |
| 异常信息不足 | 难以定位问题 |
7️⃣ 本章小结
核心知识点
-
AggregateException 的设计思想:
- 为并发任务设计的异常容器
- 可以包含多个子异常
- 提供
Flatten()和Handle()高级功能
-
await vs Wait/Result 的异常行为:
await:抛出第一个原始异常,简化处理.Wait()/.Result:抛出AggregateException,阻塞线程- 优先使用 await
-
Task.WhenAll 的异常处理:
await只抛出第一个异常- 通过
Task.Exception获取所有异常 - 使用
SafeWhenAll封装处理逻辑
-
后台任务的异常处理:
- 异常容易被吞掉
- 使用
SafeFireAndForget或BackgroundService - 注册全局的
UnobservedTaskException处理器
进阶思考
之前去面试,碰见了一道面试题,至今记忆犹新。现在拿出来,供大家思考一下:
有一组API,数量记为N,50<=N<=500。现在要设计一个功能,要求在最短的时间内找出这组API里成功响应时间最短的API,每次最大并发请求数量限制为10。
🎯 下一章预告
在下一章中,我们将深入探讨异步编程的最佳实践和反模式:
async void的危害与正确使用场景- 异步同步混用的陷阱
Task.Run的滥用问题using语句中的异步操作- 异步命名规范与代码审查清单
敬请期待!
