一、多线程环境为什么需要特殊处理异常
想象一下你正在指挥一个交响乐团,每个乐手就像是一个线程。如果小提琴手突然断弦(抛出异常),而指挥(主线程)没注意到,整个演出就会乱套。在多线程编程中,未捕获的异常就像这个断弦的小提琴,会让程序悄无声息地崩溃。
在C#中,线程池线程或手动创建的线程如果抛出未处理异常,会直接导致进程终止。比如下面这个简单的例子:
// 技术栈:.NET 6.0
using System;
using System.Threading;
class Program
{
static void Main()
{
// 创建一个会抛出异常的线程
var thread = new Thread(DoWork);
thread.Start();
// 主线程继续执行
Console.WriteLine("主线程还在快乐地运行...");
Thread.Sleep(2000); // 假装在干活
}
static void DoWork()
{
throw new InvalidOperationException("线程爆炸了!");
// 这里异常不会被捕获,程序直接崩溃
}
}
运行这段代码,你会看到程序直接退出,连句"Goodbye"都不说。这就是典型的线程异常"黑洞"问题。
二、捕获线程异常的四种实战方案
1. 传统的try-catch包裹大法
最直观的方法就是在线程方法内部加try-catch,就像给线程穿上防弹衣:
static void DoWork()
{
try
{
throw new InvalidOperationException("这次有防护罩!");
}
catch (Exception ex)
{
Console.WriteLine($"捕获到异常:{ex.Message}");
}
}
不过这种方法有个致命缺点——如果DoWork里调用了其他会抛出异常的方法,每个方法都得加try-catch,代码会变成千层饼。
2. 全局异常处理机制
.NET提供了全局异常捕获的终极武器,适合处理那些漏网之鱼:
// 在程序启动时注册全局处理器
AppDomain.CurrentDomain.UnhandledException += (sender, args) =>
{
var ex = (Exception)args.ExceptionObject;
Console.WriteLine($"全局捕获到异常:{ex.Message}");
Environment.Exit(1); // 优雅退出
};
注意这个事件虽然能捕获异常,但程序仍然会终止。它更适合记录日志或发送警报。
3. Task异常处理更优雅
如果使用Task(推荐),异常处理就友好多了:
var task = Task.Run(() =>
{
throw new FileNotFoundException("文件去哪了?");
});
try
{
task.Wait(); // 在这里会抛出AggregateException
}
catch (AggregateException ae)
{
ae.Handle(ex =>
{
Console.WriteLine($"处理了{ex.GetType().Name}");
return true; // 标记为已处理
});
}
Task会把所有异常打包成AggregateException,就像快递包裹一样统一处理。
4. async/await的现代战争
在异步方法中,异常处理就像同步代码一样自然:
async Task DoAsyncWork()
{
try
{
await Task.Run(() => throw new NotImplementedException());
}
catch (Exception ex)
{
Console.WriteLine($"异步捕获:{ex.Message}");
}
}
这是最推荐的方式,既保持了代码清晰度,又具备完整的异常处理能力。
三、高级技巧:异常传递与状态维护
有时候我们需要把子线程的异常传递回主线程,这时候可以考虑使用共享状态:
class ThreadResult
{
public Exception Exception { get; set; }
public bool Success => Exception == null;
}
static void Main()
{
var result = new ThreadResult();
var thread = new Thread(() =>
{
try
{
// 模拟工作
throw new DivideByZeroException();
}
catch (Exception ex)
{
result.Exception = ex; // 保存异常
}
});
thread.Start();
thread.Join(); // 等待线程结束
if (!result.Success)
{
Console.WriteLine($"主线程收到异常报告:{result.Exception.Message}");
}
}
这种方法特别适合需要收集多个线程执行结果的场景。
四、实战中的陷阱与最佳实践
- 不要吞掉异常:空catch块是万恶之源,至少记录日志
- 区分异常类型:文件不存在和内存不足的处理方式应该不同
- 资源清理:使用using或者finally确保资源释放
- 取消操作:配合CancellationToken实现优雅终止
- 日志记录:记录完整的调用栈信息
下面是个综合示例:
async Task ProcessFilesAsync(IEnumerable<string> files, CancellationToken ct)
{
var tasks = files.Select(async file =>
{
try
{
ct.ThrowIfCancellationRequested();
using var stream = File.OpenRead(file);
// 处理文件内容...
}
catch (OperationCanceledException)
{
Console.WriteLine("操作被取消");
throw;
}
catch (FileNotFoundException ex)
{
Console.WriteLine($"文件失踪:{ex.FileName}");
return false;
}
catch (Exception ex)
{
Console.WriteLine($"处理{file}时出错:{ex}");
return false;
}
return true;
});
try
{
await Task.WhenAll(tasks);
}
catch
{
// 这里可以处理聚合异常
}
}
五、总结与选型建议
对于现代C#开发,我的推荐优先级是:
- 首选async/await模式
- 其次使用Task.ContinueWith处理后续
- 必要时才用原始线程+全局处理
记住,多线程异常处理的核心思想是:永远不要让异常悄无声息地消失。就像飞机上的黑匣子,即使坠毁了也要知道原因在哪。
不同的应用场景需要不同的策略:
- 后台服务:全局捕获+日志+自动重启
- UI应用:捕获后显示友好提示
- 批处理:记录错误继续后续任务
评论