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).
问题来了:
- 只看到了一个异常,但实际上可能有多个 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(); // 可能抛出 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);
}
核心特性:
- 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 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)
如果是长期运行的后台任务,推荐使用 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语句中的异步操作- 异步命名规范与代码审查清单
敬请期待!
文章摘自:https://www.cnblogs.com/diamondhusky/p/20008918
