先给个结论:线程异常不捕获,程序直接崩——这可不是“没报错”,而是根本没机会处理。

很多开发者在排查崩溃问题时,常常陷入一个误区:到处加 try-catch 就以为万事大吉。但对于C#的多线程场景,异常处理完全是另一套规则。哪一个线程、哪一类任务,都有自己专属的异常通道,漏掉一个环节,程序就等着崩给你看。
WinForms UI线程异常必须用 Application.ThreadException
按钮点击、Timer 的 Tick 事件、DataBinding 的更新操作——所有在 UI 线程上发生的同步异常,都不会被你写的 try-catch 捕获到,也不会触发全局的 AppDomain.UnhandledException。它们只认 Application.ThreadException 这一条路。为什么?因为 Windows 消息循环内部的异常处理机制,不允许外部拦截。
关键点在于:
- 必须在
Main()方法里、Application.Run()之前就注册好,否则注册了也白搭 - 注册后并不等于自动搞定,你仍然需要手动记录日志(通过
e.Exception)、向用户提示、以及决定是否要退出程序(Application.Exit()) - 这个事件只管 UI 线程,像
Task.Run或新开的Thread里抛出的异常,它一概不管
后台线程(Thread/ThreadPool)异常只能靠 AppDomain.UnhandledException
显式创建的 new Thread 或者未 await 的 Task.Run,一旦抛出未捕获的异常,就会触发 AppDomain.UnhandledException。但要注意,这个事件有一个“不可撤销”的特点:程序会在事件处理完以后立即终止,没有商量余地。
容易踩坑的地方:
- 不能用
try-catch包住线程入口方法来“兜底”——异常发生在子线程,主线程根本 catch 不到 - 注册位置同样要在
Main()开头,而且必须早于任何线程启动 - 如果是 .NET Core/.NET 5+ 的项目,这个事件的行为有变化,更推荐改用
TaskScheduler.UnobservedTaskException,配合Task.Wait()或await来显式观察异常
async Task 方法里的异常必须 await 才能捕获
这可能是最容易忽略的地方。async Task 方法内部 throw 不会立刻导致程序崩溃,而是让返回的 Task 进入 Faulted 状态。如果你不 await,这个异常就“沉到底”了,后续完全无感知。
错误写法:DoWorkAsync(); —— 异常被吞掉,无声无息。
正确写法:await DoWorkAsync();,然后外层用 try-catch 才能捕获到。
如果实在需要 fire-and-forget 的场景(比如日志上报),至少要做到:加 .ConfigureAwait(false),并且确保 Task 被 Wait() 或检查 task.Exception。
还有一个绝对要避免的:async void(除了事件处理器的特殊情况)。async void 的异常会直奔 AppDomain.UnhandledException,连拦截的机会都没有。
自定义异常类漏掉构造函数会导致反序列化失败
跨 AppDomain、WCF、旧版 Remoting 这些场景下,异常对象需要被序列化传递。如果自定义异常只写了 public class MyException : Exception,运行时反序列化就会静默失败,或者直接抛 SerializationException。
正确的做法:
- 必须实现三个构造函数:
MyException()、MyException(string message)、MyException(string message, Exception innerException) - 建议加上
[Serializable]特性,尤其是在 .NET Framework 项目中 - 不要在异常类里放非序列化的字段,比如
FileStream、委托、IDisposable实例,这些会让序列化过程直接翻车
说到底,异常处理不是“写个 catch 就完事”那么简单。UI 线程、后台线程、async 线程各自有独立的异常通道,漏掉任何一个环节,程序就在那里等着崩给你看。这才是真正的“硬功夫”。