1. 介绍
写“永不崩溃”的程序就像相信 bug 会怕你的 IDE。实际上,代码越复杂(尤其是在多线程和异步场景),bug 越会搞花样,异常也越难处理。如果忽视线程和任务里的异常,很容易出现资源泄露、死锁、数据丢失,或者在运行好几个小时后突然崩溃。
这节课把在 C# 多线程和异步编程中处理异常的最佳实践组合起来。你会学到如何正确捕获异常,如何拆解同时发生的“一堆”错误(比如 AggregateException),怎么处理那些被“丢掉”的任务,以及为什么忽略异常既危险又没意义。
为什么事情没那么简单
- 发生错误的线程可能和你放 try-catch 的线程不一样。
- 异步任务不会马上抛出异常——它们会被“打包”,在 await(或同步等待)时才处理。
- 当同时处理多个任务(比如 Task.WhenAll)时,可能会有多个错误——需要把它们都考虑进去。
- fire-and-forget 类型的操作可能会完全“丢掉”异常,因为没有显式的处理者。
这个特性就是执行上下文分离。把程序想象成有好几个场地的马戏团:一个地方起火,别的地方不一定立刻能看到。重要的是学会正确追踪并扑灭这些“火”。
2. 任务里的异常: Task 和 Task<TResult>
任务如何报告错误
当任务里发生未处理异常时,它不会马上“抛”出来。任务会变成 Faulted,异常保存在内部。你可以通过以下方式获取它:
- 用 await 等待完成(或通过 task.Wait()/task.Result —— 但最好别这么做);
- 检查属性 task.Exception —— 那里会是一个 AggregateException。
示例
// 有错误的异步方法
async Task FailAsync()
{
await Task.Delay(100);
throw new InvalidOperationException("出现了问题");
}
async Task MainAsync()
{
try
{
await FailAsync();
}
catch (Exception ex)
{
Console.WriteLine($"错误: {ex.Message}");
}
}
如果不加 try-catch,程序会崩溃。如果加了 —— 异常会被正确捕获,即便错误发生在别的任务里。
AggregateException:批量错误
在用 await Task.WhenAll(tasks) 时,来自多个任务的错误会被收集到一个 AggregateException(在它的 InnerExceptions 里)。
async Task MultiFailAsync()
{
Task t1 = Task.Run(() => throw new InvalidOperationException("错误 1"));
Task t2 = Task.Run(() => throw new ArgumentException("错误 2"));
try
{
await Task.WhenAll(t1, t2);
}
catch (Exception ex)
{
if (ex is AggregateException agg)
{
foreach (var e in agg.InnerExceptions)
Console.WriteLine($"异常: {e.Message}");
}
else
{
Console.WriteLine($"单个错误: {ex.Message}");
}
}
}
注意:使用 await Task.WhenAll(tasks) 时,.NET 会“展开” AggregateException,在 catch 中你会拿到“第一个”异常。完整列表可以通过 task.Exception.InnerExceptions 获取(如果任务以错误结束)。
3. “Fire and Forget”:为什么不能直接忘了任务
“启动然后忘记”常常会导致隐蔽的失败。示例:
Task.Run(() => { throw new Exception("哐!"); }); // 错误飞到空处,没人观察到。
现代运行时会把错误保存在任务中,并且如果异常未被观察,可能会导致进程终止。最好的做法是保存任务引用和/或订阅事件 TaskScheduler.UnobservedTaskException。
怎么办才对?
- 保存任务引用,以便等待它完成并处理错误;
- 对于 fire‑and‑forget,在委托内部放上处理器。
Task.Run(() => {
try
{
// 可能抛出异常的代码
}
catch (Exception ex)
{
// 记录、通知,但不要把错误放到外面去
Console.WriteLine($"fire-and-forget 中的错误: {ex.Message}");
}
});
4. 线程 (Thread) 里的错误:“外面捕不到”吗?
新开一个 Thread 里的异常无法在外面用 try-catch 捕获 —— 只能在线程体内处理。
var thread = new Thread(() =>
{
try
{
throw new Exception("线程中的错误");
}
catch (Exception ex)
{
Console.WriteLine($"在线程内捕获到错误: {ex.Message}");
}
});
thread.Start();
如果不放处理器,异常只会结束那个线程(如果它是后台线程:thread.IsBackground = true)。对于非后台线程,未处理的异常可能导致整个进程终止。总是在线程内放 try-catch。
如何从线程返回结果和错误?
- 使用队列/集合来传递结果/错误;
- 事件模型;
- 最好转用任务 — 用 Task 更方便处理错误。
5. 并行循环:特殊方式捕获错误
在并行循环中,不同分支的错误会被聚合成 AggregateException。
try
{
Parallel.For(0, 5, i =>
{
if (i % 2 == 0)
throw new Exception($"第 {i} 次迭代出错");
});
}
catch (AggregateException ex)
{
foreach (var e in ex.InnerExceptions)
Console.WriteLine($"[并行循环] 错误: {e.Message}");
}
如果想记录局部失败并继续其它分支,就在每个分支里放单独的 try-catch。
6. 取消任务时的错误处理
通过 CancellationToken 取消时,约定会抛出 OperationCanceledException —— 这不是故障,而是正常的停止。
async Task DoWorkAsync(CancellationToken token)
{
for (int i = 0; i < 10; i++)
{
token.ThrowIfCancellationRequested();
await Task.Delay(100);
}
}
// 在代码某处:
var cts = new CancellationTokenSource();
var task = DoWorkAsync(cts.Token);
cts.Cancel(); // 大约 200 ms 后
try
{
await task;
}
catch (OperationCanceledException)
{
Console.WriteLine("操作已取消!");
}
除了 ThrowIfCancellationRequested() 外,很多支持 token 的方法(比如 Task.Delay, HttpClient.GetAsync)也会自己抛出 OperationCanceledException。确认你的 API 是否支持取消。
7. 实用细节
通用做法
- 在异步方法的顶层放 try-catch —— 这样能捕到“逃跑”的错误。
- 不要忽略任务:保存引用、等待完成、记录错误。
- 对并行操作(Task.WhenAll / Parallel.ForEach)要考虑 AggregateException。
- 把取消和故障区分开:单独捕获 OperationCanceledException。
- 记录错误,尤其是那些“不杀死应用”的安静错误。
- 保存细节:记录所有内层异常,而不仅仅是第一个。
- 在本地处理错误:如果不知道该怎么办,至少记录下来。
在多线程和异步代码中应该在哪里捕获异常
flowchart TD
A[主线程 / UI] -->|启动任务| B[Task/async]
B -->|任务内部| C[在异步方法内 try/catch]
B -->|在主线程 await| D[在 await 周围 try/catch]
A -->|启动 Thread| E[Thread]
E -->|内部| F[在线程内 try/catch]
B -->|多个任务| G[Task.WhenAll / Parallel.ForEach]
G -->|错误| H[AggregateException]
8. 常见错误和踩坑
错误 #1: 用 .Result 或 .Wait() 等待任务。可能会死锁和/或得到意外的 AggregateException。
错误 #2: 启动 fire‑and‑forget 而没有在内部放 try-catch —— 任务可能悄悄挂掉,没法诊断。
错误 #3: 忽略并行循环中的错误 —— 部分工作没做,你却浑然不觉。
错误 #4: 不区分取消(OperationCanceledException)和真实错误。
错误 #5: 只记录多个任务中的第一个错误 —— 其它错误被“埋”起来了。
错误 #6: 为所有任务复用同一个 Exception 对象 —— 每个错误都该有自己的实例。
错误 #7: UI 线程没有异常处理 —— 后台错误没被注意到,界面表现得很怪。
GO TO FULL VERSION