VB.NET全局异常捕获:三条通道兜底与避坑指南 简介这份资源面向VB.NET开发者尤其是需要为WinForms或WPF项目构建稳定异常处理机制的中级程序员。它系统讲解如何通过订阅AppDomain.UnhandledException与Application.ThreadException事件集中捕获应用程序域级和UI线程级的未处理异常避免在每个函数中重复编写Try...Catch代码从而提升代码可维护性与程序健壮性。压缩包共34个文件约225KB包含9个vb源码文件、3个resx资源文件、3个exe可执行程序及resources、xml、pdb、dll等配套文件另有sln解决方案与vbproj工程文件构成可直接运行的完整示例项目。目前已有1206人学习下载。读者可从中获得全局异常处理的完整实现思路、事件注册与错误日志记录方法以及将异常处理逻辑封装为独立模块的工程化实践便于快速应用到实际项目中。1. 别等用户截图报错VB.NET 全局异常捕获到底在兜什么底线上一个 WinForms 小工具用户点了个按钮界面直接白屏卡死日志里干干净净——这种场景我见过太多次。问题不在业务代码写得多烂而在于异常从Form事件里抛出来之后没人接住它CLR 默认行为就是把进程干掉。VB.NET 的全局异常处理说白了就是给整个应用程序装一张兜底网不管异常从哪个线程、哪个事件、哪个没写Try...Catch的角落冒出来都先经过你指定的处理器让你有机会记日志、提示用户、决定是继续跑还是体面退出。它解决的不是「异常该不该发生」而是「异常发生后你能不能知道、能不能控制」。适合谁做 WinForms、WPF、控制台工具、Windows 服务的 VB.NET 开发者尤其是那种交付给客户、你没法盯着调试的桌面程序。这篇就把Application.ThreadException、AppDomain.UnhandledException、TaskScheduler.UnobservedTaskException这几条线怎么串起来讲透顺带把几个我踩过的坑摊开说。2. 三条异常通道先搞清异常从哪条路漏出去VB.NET 的全局异常不是一个开关而是三条互不重叠的通道。很多人只挂了一个Application.ThreadException结果后台线程一崩程序照样无声无息地没了。要兜住得先知道每条通道管什么。2.1 UI 线程异常Application.ThreadException 的管辖范围WinForms 程序里消息泵message loop在处理 Windows 消息时调用的那些事件处理器——按钮点击、定时器 Tick、绘制事件——如果抛出异常会先被Application.ThreadException捕获。注意关键词UI 线程、消息泵调用的代码。它管不到你Task.Run里抛的异常也管不到New Thread起的线程。挂载它必须在Application.Run之前而且要在Application.SetUnhandledExceptionMode之后顺序错了就白挂 Program.vb 或 ApplicationEvents.vb 的启动入口 Imports System.Threading Module Program STAThread Sub Main() 关键强制异常走 ThreadException而不是弹系统默认的 JIT 调试对话框 Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException) 挂 UI 线程异常处理器 AddHandler Application.ThreadException, AddressOf OnUiThreadException 挂非 UI 线程异常处理器AppDomain 级别 AddHandler AppDomain.CurrentDomain.UnhandledException, AddressOf OnDomainException 挂未观察的 Task 异常 AddHandler TaskScheduler.UnobservedTaskException, AddressOf OnTaskException Application.EnableVisualStyles() Application.SetCompatibleTextRenderingDefault(False) Application.Run(New MainForm()) End Sub Private Sub OnUiThreadException(sender As Object, e As ThreadExceptionEventArgs) e.Exception 就是那个漏出来的异常 HandleGlobalException(e.Exception, UI-Thread) End Sub End Module逻辑说明SetUnhandledExceptionMode(CatchException)是前提它告诉 WinForms「别自己处理交给我挂的处理器」。如果不设默认模式在调试器附加时会走ThrowException你的处理器根本不触发这就是很多人「本地调试能捕获、发布后捕获不到」的玄学来源。AddHandler三行分别对应三条通道缺一不可。参数说明UnhandledExceptionMode有三个值——Automatic默认看调试器脸色、CatchException强制走 ThreadException、ThrowException强制抛给系统。生产环境一律用CatchException。2.2 非 UI 线程异常AppDomain.UnhandledException 是最后一道闸后台线程、线程池线程里未捕获的异常会触发AppDomain.CurrentDomain.UnhandledException。这里有个残酷事实这个事件触发时进程通常已经准备终止了你没法阻止它退出。UnhandledExceptionEventArgs.IsTerminating在 .NET Framework 里基本恒为True。所以它的定位是「临终遗言」——把异常写进日志、落盘、发个告警而不是「救活程序」。Private Sub OnDomainException(sender As Object, e As UnhandledExceptionEventArgs) Dim ex As Exception TryCast(e.ExceptionObject, Exception) Dim msg As String If(ex IsNot Nothing, ex.ToString(), 非 Exception 类型的异常对象: e.ExceptionObject?.ToString()) 此时进程即将退出只做最可靠的落盘别做 UI 交互 Try File.AppendAllText(crash_dump.log, $[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] Terminating{e.IsTerminating}{Environment.NewLine}{msg}{Environment.NewLine}) Catch 落盘都失败就放弃别在这里再抛异常 End Try End Sub逻辑说明ExceptionObject是Object类型理论上可能是非Exception的东西比如某些 COM 互操作场景所以用TryCast加兜底。这里绝对不要弹MessageBox——进程正在死UI 线程状态不确定弹窗可能直接卡死。参数上IsTerminating用来判断是否还有救但在 Framework 下别指望它给你False。2.3 Task 异常UnobservedTaskException 的「延迟引爆」特性Task里抛的异常如果一直没人await或访问.Result它不会立刻炸而是被 Task 内部存着等到 GC 回收这个 Task 时才触发TaskScheduler.UnobservedTaskException。这个「延迟」是最大的坑——你可能在异常发生几分钟后才收到通知时间对不上排查时一脸懵。Private Sub OnTaskException(sender As Object, e As UnobservedTaskExceptionEventArgs) 标记为已观察避免在某些配置下升级为进程崩溃 e.SetObserved() For Each inner As Exception In e.Exception.Flatten().InnerExceptions HandleGlobalException(inner, Unobserved-Task) Next End Sub逻辑说明e.Exception是AggregateException一个 Task 可能聚合多个异常用Flatten()摊平再遍历。SetObserved()告诉运行时「我处理了」在 .NET Framework 4.5 之后默认不会因此崩溃但不调用它日志可能重复触发。参数上Flatten()比InnerExceptions更彻底嵌套 Task 场景下不会漏。三条通道挂完才算真正「全局」。只挂一条的都是漏勺。3. 把异常收进一个处理器日志、提示、退出的分工三条通道最终都该汇到一个HandleGlobalException里但汇进去之后做什么得按异常严重程度分档。全弹窗、全退出、全忽略都是偷懒。3.1 统一入口的参数设计先定义入口签名把「来源」和「异常」都带进来方便日志区分Private Sub HandleGlobalException(ex As Exception, source As String) If ex Is Nothing Then Return 1. 先落日志任何情况下都别省 WriteLog(ex, source) 2. 按严重程度决定后续动作 If IsFatal(ex) Then 致命异常提示后退出 ShowFatalAndExit(ex, source) Else 可恢复异常提示用户程序继续 ShowRecoverable(ex, source) End If End Sub 判断是否致命内存溢出、栈溢出、访问违例这类没法继续的 Private Function IsFatal(ex As Exception) As Boolean Return TypeOf ex Is OutOfMemoryException OrElse TypeOf ex Is StackOverflowException OrElse TypeOf ex Is AccessViolationException End Function逻辑说明IsFatal只挑真正没法继续的异常类型。StackOverflowException在 .NET 2.0 之后默认无法捕获能走到这里说明是特定场景仍然按致命处理。参数上source字符串建议用固定枚举值UI-Thread、Domain、Task别写自由文本否则日志没法聚合统计。3.2 日志要记什么别只记 Messageex.Message是最没用的信息它经常只有一句「对象引用未设置为对象的实例」。真正有用的是StackTrace、InnerException链、以及发生时的上下文。Private Sub WriteLog(ex As Exception, source As String) Dim sb As New StringBuilder() sb.AppendLine($ {DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{source}] ) sb.AppendLine($Type : {ex.GetType().FullName}) sb.AppendLine($Message: {ex.Message}) sb.AppendLine($HResult: 0x{ex.HResult:X8}) sb.AppendLine(--- StackTrace ---) sb.AppendLine(ex.StackTrace) 递归展开 InnerException Dim inner As Exception ex.InnerException Dim depth As Integer 1 While inner IsNot Nothing sb.AppendLine($--- Inner[{depth}] {inner.GetType().FullName} ---) sb.AppendLine(inner.Message) sb.AppendLine(inner.StackTrace) inner inner.InnerException depth 1 End While 追加线程信息区分 UI 线程和后台线程 sb.AppendLine($Thread : {Thread.CurrentThread.ManagedThreadId} $IsPool{Thread.CurrentThread.IsThreadPoolThread} $IsBg{Thread.CurrentThread.IsBackground}) Try File.AppendAllText(LogPath, sb.ToString() Environment.NewLine) Catch 日志写不进去也不能再抛 End Try End Sub逻辑说明HResult对排查 COM 互操作和系统级异常特别有用很多「未知错误」靠它定位。InnerException用While循环展开而不是只取一层因为 EF、HttpClient 这类库经常套三四层。线程信息能帮你判断异常到底来自哪条通道和source互相印证。参数说明LogPath建议放在%LOCALAPPDATA%或程序目录下的logs子目录别放C:\根目录——权限问题会让日志静默失败。文件名按天切分避免单文件无限增长。3.3 提示与退出别在主线程上做危险动作可恢复异常弹个MessageBox没问题但要注意如果异常本身就发生在 UI 线程此时消息泵状态可能已经乱了弹窗要尽量简单。致命异常退出时用Environment.Exit还是Application.Exit有讲究。Private Sub ShowRecoverable(ex As Exception, source As String) 只给用户看摘要细节在日志里 MessageBox.Show($操作出现异常已记录日志。{Environment.NewLine} $类型{ex.GetType().Name}{Environment.NewLine} $信息{ex.Message}, 提示, MessageBoxButtons.OK, MessageBoxIcon.Warning) End Sub Private Sub ShowFatalAndExit(ex As Exception, source As String) MessageBox.Show($程序遇到无法恢复的错误即将退出。{Environment.NewLine} $请把日志文件反馈给开发者。, 致命错误, MessageBoxButtons.OK, MessageBoxIcon.Error) 用 Environment.Exit 确保退出码可控方便外部监控 Environment.Exit(-1) End Sub逻辑说明ShowRecoverable只展示Message不展示StackTrace——给最终用户看堆栈既没用又吓人。ShowFatalAndExit用Environment.Exit(-1)而不是Application.Exit()因为后者只是请求消息泵退出如果消息泵本身已经坏了程序可能卡住不退。退出码-1让外部的进程监控或计划任务能识别异常退出。提示Environment.Exit会直接终止进程Finally块不保证执行。如果退出前必须释放资源比如关闭文件句柄、提交事务要在调用前手动处理。4. 避坑排查那些让全局捕获「看起来没生效」的坑全局异常处理最气人的地方是代码明明写了异常就是不进来。下面这几条是我和同事反复踩过的按「现象 → 原因 → 解决」列清楚。4.1 现象调试时能捕获发布后捕获不到原因UnhandledExceptionMode默认是Automatic它在检测到调试器附加时会走ThrowException模式把异常直接抛给调试器绕过你的ThreadException。发布后没有调试器行为就变了。解决显式调用Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException)且必须在Application.Run之前、AddHandler之前。顺序反了同样无效。4.2 现象后台线程崩了日志里什么都没有原因只挂了Application.ThreadException它只管 UI 线程。Task.Run、ThreadPool.QueueUserWorkItem、New Thread里的异常走的是AppDomain.UnhandledException。解决三条通道全挂。另外注意AppDomain.UnhandledException触发时进程已在终止流程日志要写得足够快、足够简单别在里面做网络请求或复杂序列化。4.3 现象Task 异常过了很久才报时间对不上原因UnobservedTaskException是 GC 触发的不是异常抛出时立即触发。Task 被回收的时机不确定可能延迟几秒到几分钟。解决别依赖它做实时告警。真正的做法是在业务代码里对每个 Task 都Await或.Wait()并处理异常UnobservedTaskException只作为最后兜底。如果发现它频繁触发说明代码里有大量「发了不管」的 Task该去修业务逻辑而不是加日志。4.4 现象异常处理器自己抛异常程序直接崩原因HandleGlobalException里如果调用了可能失败的操作写日志、弹窗、序列化而这些操作又抛异常就会在异常处理过程中二次抛出CLR 直接终止进程连遗言都留不下。解决处理器内部所有可能失败的操作都包Try...Catch且Catch块里绝不再抛。日志写入用Try包住弹窗用Try包住序列化用Try包住。原则是异常处理器必须比业务代码更「打不死」。4.5 现象StackOverflowException捕获不到原因.NET 2.0 之后StackOverflowException默认由运行时直接终止进程不经过任何托管异常处理器。这是设计如此不是 bug。解决接受现实别指望捕获它。能做的是在可能深递归的地方加深度计数主动抛InvalidOperationException而不是等栈溢出。全局处理器里对它的处理只是「理论上写一下」实际到不了。5. 进阶把全局异常接进日志框架与崩溃上报基础版跑通后下一步是让它和现有日志体系、崩溃上报打通而不是自己File.AppendAllText硬写。这里给一个把全局异常转发到结构化日志的思路以及一个验证捕获是否真的生效的自测技巧。5.1 转发到日志框架以常见 ILogger 抽象为例如果你项目里已经用了日志框架NLog、Serilog、log4net 都行全局处理器应该把异常交给它而不是自己写文件。这样日志格式统一、支持滚动、支持多目标输出。 假设已有一个 ILogger 实例 Private ReadOnly _logger As ILogger LogManager.GetLogger(Global) Private Sub HandleGlobalException(ex As Exception, source As String) 结构化日志把 source 作为字段方便按来源过滤 _logger.Fatal(ex, 全局异常捕获 Source{Source} Thread{ThreadId}, source, Thread.CurrentThread.ManagedThreadId) If IsFatal(ex) Then ShowFatalAndExit(ex, source) Else ShowRecoverable(ex, source) End If End Sub逻辑说明Fatal级别对应「程序可能无法继续」的异常和Error区分开方便告警规则按级别过滤。把source和ThreadId作为结构化字段传入而不是拼进消息字符串这样在日志平台里可以直接按字段查询。参数上不同框架的 API 略有差异NLog 用Logger.Fatal(Exception, String, Args)Serilog 用Log.Fatal(ex, ...)思路一致。5.2 验证捕获是否生效主动制造异常的自测法写完不验证等于没写。我一般会在开发阶段加一个隐藏的「自测」入口主动在三条通道各抛一个异常确认日志和提示都符合预期。 仅 DEBUG 下编译发布版不包含 #If DEBUG Then Private Sub RunGlobalExceptionSelfTest() 1. UI 线程异常 Throw New InvalidOperationException(自测UI 线程异常) 2. 后台线程异常注意这会终止进程单独测 Dim t As New Thread(Sub() Throw New Exception(自测后台线程异常)) t.Start() 3. Task 异常需要触发 GC 才能观察到 Dim task Task.Run(Sub() Throw New Exception(自测Task 异常)) GC.Collect() GC.WaitForPendingFinalizers() End Sub #End If逻辑说明UI 线程异常可以直接Throw因为调用它的按钮事件就在 UI 线程上。后台线程和 Task 的测试要分开跑因为后台线程异常会终止进程混在一起测会干扰判断。Task 异常必须手动GC.Collect()加WaitForPendingFinalizers()才能触发UnobservedTaskException否则你可能等半天没反应误以为没生效。参数说明#If DEBUG条件编译确保自测代码不进发布版。GC.WaitForPendingFinalizers()是必须的光Collect()不够终结器队列要等一轮。5.3 一个容易忽略的边界异常发生在 Application.Run 之前如果异常发生在Main里、Application.Run之前——比如配置文件读取失败、依赖注入容器初始化炸了——此时ThreadException还没挂上AppDomain.UnhandledException虽然挂了但进程会直接退出。这类异常的兜底要靠Main里自己包一层Try...Catch。STAThread Sub Main() Try Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException) AddHandler Application.ThreadException, AddressOf OnUiThreadException AddHandler AppDomain.CurrentDomain.UnhandledException, AddressOf OnDomainException AddHandler TaskScheduler.UnobservedTaskException, AddressOf OnTaskException 初始化代码也放进 Try比如读配置、建容器 Dim config LoadConfig() Application.Run(New MainForm(config)) Catch ex As Exception 启动阶段异常此时日志框架可能还没就绪用最原始的方式落盘 File.AppendAllText(startup_error.log, ex.ToString()) MessageBox.Show(程序启动失败 ex.Message) End Try End Sub逻辑说明把初始化和Application.Run都包进Try覆盖「处理器挂载前」和「处理器挂载后但消息泵未启动」两个阶段。Catch里用最原始的File.AppendAllText因为此时日志框架可能还没初始化完依赖它反而可能二次失败。从那以后我每次给桌面程序加全局异常都强制走一遍「三条通道各抛一次 启动阶段抛一次」的自测确认日志里四条记录都在、格式都对才敢交付。这套流程帮我省掉了无数次「用户说崩了但我没日志」的扯皮。希望帮到你。本文还有配套的精品资源点击获取