意外异常发生时,应用该终止还是继续的判断表
· 小村 豪 · Windows 开发, 异常处理, 设计, C# / .NET, 可靠性
一谈到意外异常,很容易就直接陷入「让它崩溃,还是 catch 住继续运行」这样的二选一思维。 但在实务中,这种二选一的划分方式其实有点粗糙。
真正需要关注的是:能不能把可能已经损坏的范围封闭起来。
- 是不是只让那一次操作失败就够了
- 是不是只重新初始化那个界面 / 连接 / worker 就够了
- 还是整个进程的一致性已经变得不可信
按这个顺序去看,会整理得清晰很多。
本文以 C# / .NET 编写的 Windows 应用、常驻应用、Windows 服务、设备联动工具等为前提,把发生意外异常时可以继续运行的条件,以及应该终止的条件整理成一张判断表。
1. 先说结论
- 用
catch (Exception)吞掉异常然后继续运行,大多是危险的做法。 - 可以继续运行的前提是三项条件同时成立:能舍弃失败的单元、能把共享状态还原、能说明外部副作用。
- UI 的一次操作、一条输入、一个 job 之类处理边界明确的场景,有时可以继续运行。
- 反之,如果涉及共享的可写状态、常驻循环、主线程、启动流程、原生边界,或者带有内存损坏的迹象,就更偏向于终止。
StackOverflowException、AccessViolationException、OutOfMemoryException这类会让人怀疑「整个进程健全性」的异常,最好不要以继续运行为前提去考虑。- WPF 和 Windows Forms 都提供了捕获未处理异常后表面上继续运行的手段,但能继续运行和继续运行也安全是两件不同的事。
- 长时间运行的服务或监控类应用,与带着半损坏的状态硬撑下去相比,倒下后被重启往往更容易诊断,也更安全。
归纳起来,判断的主轴是能不能恢复不变式(invariant)。
2. 本文所说的「意外异常」
2.1 先区分「预期内」与「意外」
首先,罕见的异常和意外的异常并不是一回事。
比如下面这些,即便发生频率很低,也可以归为预期内:
- 用户选择了不存在的文件
- 通信对象暂时超时
- 导入的 CSV 中有一行数据损坏
- 取消操作导致抛出
OperationCanceledException - 因业务规则被违反,只想让这一次处理失败
这些的共同点是:如何处理这类失败,可以在设计阶段就提前决定好。
而本文主要讨论的意外异常,指的是这样的情况:
- 自身代码的前提被打破,抛出了
NullReferenceException或InvalidOperationException - 在更新共享状态的过程中抛出异常,不清楚到底改到了哪一步
- 监控循环或消息处理的父循环崩溃了
- COM / P/Invoke / 第三方 SDK 边界出现了异常
- 出现了
AccessViolationException或StackOverflowException这类「整个进程的健康检查已经亮红灯」的状况
也就是说,这些异常发生后,「应用的状态还能不能被信任」变得不明确。
2.2 看似二选一,实际是三选一
让这个话题变得混乱的元凶,是把「继续运行」当作单一的一种选项来看待。
在实务中,大致可以分为三个层次:
| 选项 | 含义 |
|---|---|
| 只让这次操作失败,然后继续运行 | 保留界面,只把这次保存或导入当作失败处理 |
| 只停掉子系统,然后继续运行 | 重新初始化连接、界面、worker、子进程 |
| 终止整个进程 | 因为状态损坏的范围无法确定,改为按重启处理 |
即便都叫「继续运行」,装作什么都没发生地继续跑下去,与把损坏的部分切割出去再继续运行,两者的分量完全不同。
3. 首先要看的判断表
3.1 总览
先看这张表,大致的方针就能定下来。
| 情况 | 首选方案 | 理由 |
|---|---|---|
| 只有一条输入、一次界面操作、一个 job 失败,且状态可以舍弃 | 偏向继续运行 | 能把失败的单元封闭起来 |
| 异常发生后可以丢弃相关对象或连接并重新创建 | 偏向重新初始化子系统 | 能把损坏范围局部化 |
| 共享状态更新到一半,但不清楚具体反映到了哪一步 | 偏向终止 | 不变式可能已经被打破 |
| 数据库 / 文件 / 设备指令等外部副作用执行到一半,无法说明是否重复或未生效 | 偏向终止 | 与外部世界的一致性已不明确 |
| 监控循环、重连循环、消息处理的父循环因意外异常而崩溃 | 偏向终止 | 静悄悄地只让部分功能死掉,容易变成僵尸进程 |
| 启动流程、配置加载、DI 组装、必需依赖的初始化失败 | 按启动失败处理,偏向终止 | 半途启动的风险更大 |
出现 AccessViolationException、StackOverflowException、严重的 OutOfMemoryException,或原生侧带有损坏迹象 |
偏向立即终止 | 整个进程的健全性存疑 |
| 危险处理已被隔离到另一个进程,主进程未受影响 | 主进程继续运行,重启子进程 | 故障范围已经被分离 |
flowchart TD
A["意外异常"] --> B{"是否有内存损坏 / 堆栈耗尽 / 致命资源耗尽的迹象?"}
B -- "是" --> Z["终止 / FailFast / 重启"]
B -- "否" --> C{"失败的单元能否舍弃?"}
C -- "否" --> Y["偏向终止"]
C -- "是" --> D{"共享状态能否回滚 / 重新初始化?"}
D -- "否" --> X["停止子系统或终止"]
D -- "是" --> E{"外部副作用能否说明清楚?"}
E -- "否" --> X
E -- "是" --> W["只把这次操作视为失败并继续"]
3.2 比异常类型更应该先看的事项
不要只凭异常类型就立刻下结论。 先要确认的是下面这些:
| 观察点 | 要确认的内容 |
|---|---|
| 发生在哪里 | UI 事件、单条 job、父循环、启动流程、原生边界中的哪一处 |
| 进行到了哪一步 | 途中是否已经改动了内存状态、数据库、文件、设备状态 |
| 可能损坏的范围 | 仅限该对象、整个界面,还是整个进程 |
| 能否回滚 | 能否丢弃重建,能否用事务恢复 |
| 外部副作用 | 是否已发送、是否可以安全重复执行、能否进行补偿处理 |
| 监控与重启 | 崩溃之后是否有自动重启或恢复通道 |
3.3 高风险异常
没必要把所有异常类型都过一遍,但下面这些不适合以「继续运行」为前提:
| 异常 / 征兆 | 首选方案 | 观察理由 |
|---|---|---|
StackOverflowException |
偏向立即终止 | 调用栈已经损坏,很难假定能正常恢复 |
AccessViolationException |
偏向立即终止 | 对受保护内存的非法访问,疑似原生边界或内存损坏 |
OutOfMemoryException |
偏向终止 | 恢复流程本身也需要额外分配内存,容易再次出问题 |
意外发生的 NullReferenceException / InvalidOperationException |
视上下文而定,但偏向终止 | 自身前提被打破,中途的变更可能仍有残留 |
| 从父循环泄漏出的意外异常 | 偏向终止 | 核心功能已经死掉,但进程仍存活,存在风险 |
| 从 COM / P/Invoke / 第三方 SDK callback 触发的异常 | 偏向立即终止到偏向终止 | 仅看 managed 侧难以判断安全性 |
4. 按「发生地点」来判断
4.1 UI 事件
按钮点击、界面切换、搜索、选择文件等 UI 事件,可以继续运行的空间相对较大。 不过是有条件的。
比较容易继续运行的情况:
- 在读取之前就已经失败,尚未触及业务状态
- 只有对话框内的临时状态损坏,关闭对话框即可丢弃
- 异常发生后可以重新创建 ViewModel 或连接
- 能够诚实地告知用户「这次操作失败了」
反之,下面这些情况更偏向终止:
- 界面状态与领域(domain)状态都已经更新了一部分
- 触碰了 static / singleton / 缓存等其他界面也会读取的共享状态
- 异常发生后按钮的启用状态或选中状态还残留着,整体一致性不明
- 在 UI 线程上发生了意外异常,不清楚渲染或通知处理进行到了哪一步
4.2 逐条处理的 job / 请求
这是比较容易继续运行的边界。
- 1 条消息
- 1 个文件
- 1 个 HTTP 请求
- 1 个导入 job
- 1 个批处理对象
只要这类单元很明确,就可以只让那一条失败,然后继续处理下一条。
不过需要具备以下前提:
- 从外部能清楚看出失败单元
- 中途的变更可以通过事务或补偿处理复原
- 同一处理重新执行一次也不会破坏结果
- 能把失败转移到隔离队列或错误记录中
4.3 常驻循环 / 监控 / 队列处理
这是随意继续运行最容易出问题的地方。
例如:
- 重连循环
- 监控循环
- 队列消费循环
- 定期轮询
- 设备状态监控
- 托盘应用的常驻处理
这类处理中最可怕的是:父循环因一次意外异常而崩溃,但进程本身还活着。
这里应该分开处理方针:
- 在每个处理项的边界上捕获预期内的异常
- 如果父循环泄漏出意外异常,就更偏向于终止进程
4.4 启动流程
把启动时的失败当作「先启动起来再说」,大多会在后面吃亏。
- 必需配置读取不到
- 版本迁移 / migration 失败
- 缺少必需的文件夹或证书
- 核心服务初始化失败
- 依赖关系的组装已经损坏
这类情况下,按启动失败处理并终止会更清晰。
4.5 原生边界 / COM / P/Invoke / unsafe
这部分要单独划出来,看得更严格一些:
- COM
- P/Invoke
- C++/CLI 的另一侧
- 第三方 SDK
- 由 callback 返回到 native 侧的代码
- 包含
unsafe的处理
尤其出现下面这些迹象时更偏向终止:
AccessViolationException- 疑似堆损坏或重复释放(double free)
- 句柄异常,或疑似释放后访问(use-after-free)
- 在 callback 边界突然崩溃
5. 可以继续运行的条件
可以继续运行的条件归纳如下,前提是这些大致同时满足:
| 条件 | 含义 |
|---|---|
| 失败单元明确 | 能分辨出是 1 次操作、1 个界面、1 个 job、1 个连接等需要舍弃的单元 |
| 状态可以舍弃 | 能丢弃重建,或者可以当作未生效处理 |
| 共享状态受到保护 | 污染不会扩散到其他功能 |
| 能说明外部副作用 | 能说清是已发送 / 未发送 / 可以重新发送 |
| 能诚实告知用户 | 能显示「这次处理失败了」 |
| 可观测 | 能通过日志、指标、dump 进行事后排查 |
6. 应该终止的条件
反之,符合下面情况时更偏向终止:
- 不清楚中途具体改动了什么
- 触碰了共享的可写状态,一致性无法判断
- 锁、队列、线程、监控循环的生命周期管理已经损坏
- 外部副作用的重复 / 遗漏 / 未完成状态无法说明清楚
- 启动流程或核心基础设施的初始化失败
- 疑似涉及原生边界或内存损坏
到了这个程度,与其钻研如何漂亮地继续运行,不如转向让崩溃后更容易恢复的设计思路,效果会更好。
7. 按典型场景给出的建议
| 场景 | 建议 | 理由 |
|---|---|---|
| 打开文件按钮指定了不存在的路径 | 只让这次操作失败并继续 | 状态损坏范围是局部的 |
| CSV 导入中只有一行数据损坏 | 只让那一行或那个文件失败并继续 | 容易把失败单元封闭起来 |
保存界面过程中出现了意外的 NullReferenceException |
重建界面直至偏向终止 | 不清楚 ViewModel / 业务状态改到了哪一步 |
| 队列中的一条消息违反了业务规则 | 只让那条消息失败并继续 | 可以转移到隔离队列 |
| 队列消费的父循环因意外异常而崩溃 | 偏向终止进程 | worker 整体的生命周期已经损坏 |
| 启动时必需配置读取不到 | 按启动失败处理并终止 | 半途启动的风险更大 |
第三方 SDK callback 附近抛出了 AccessViolationException |
偏向立即终止 | 不能忽视内存损坏的可能性 |
| 非核心的遥测(telemetry)发送失败 | 只停用该功能并继续 | 主功能与故障范围可以分离 |
8. 常见的 NG 做法
8.1 用 catch (Exception) 只打日志然后继续运行
这相当危险。 既掩盖了原因,又容易让已经损坏的状态被延续下去。
8.2 试图在最后的未处理异常处理程序里进行恢复
AppDomain.UnhandledException、Application.ThreadException、DispatcherUnhandledException 之类的处理程序,作为最后的记录点很有用,但不是什么神奇的恢复点。
8.3 明明存在外部副作用,却轻率地进行 retry
设备指令、发送邮件、计费、移动文件、更新数据库等操作,在没有确保同一处理可以安全重复执行的情况下就进行 retry,接下来登场的就会是重复执行事故。
8.4 监控循环已经死了,UI 却还留着
只有外表还活着,实际上什么都没在做的应用,会给周围带来相当大的困扰。
8.5 没有设计好「崩溃」的方式,却坚持「不想崩溃」
如果真的不想崩溃,就应该先准备好下面这些:
- 自动重启
- session 恢复
- 中途成果的保存
- 重新执行的安全性
- 故障范围的隔离
9. 实现层面的整理要点
9.1 把 catch 放在边界处
与其在深层代码里到处 catch,不如在下面这些能够明确定义失败单元的位置接收异常,会更容易梳理:
- UI 操作边界
- 单个请求边界
- 单个 job 边界
- 单个连接边界
- 进程边界
9.2 区分预期内异常与意外异常
- 预期内:validation、未找到(not found)、超时(timeout)、取消(cancel)、违反业务规则
- 意外:前提被打破、父循环泄漏、原生边界异常、疑似内存损坏
9.3 缩小共享状态的范围
共享的可写状态越大,是否可以继续运行的判断就越困难。 反之,能封闭在一个界面、一个 session、一个 worker 之内,失败也就越容易局部化。
9.4 把危险处理转移到独立进程
COM / ActiveX / 第三方 SDK / unsafe / 重量级图像处理 / 外部设备控制等,不希望崩溃波及主体的部分,拆分到独立进程会非常有效。
9.5 未处理异常处理程序的重点是「记录」而不是「恢复」
- 异常信息
- 操作上下文
- 崩溃前的重要日志
- 配置 / 版本 / 连接目标
- dump 采集通道
把这些准备齐全,优先做好「崩溃之后能被彻查」这件事,结果上反而会更稳定。
9.6 不要过度信赖 WPF / WinForms 的未处理异常事件
在 WPF 中,通过在 DispatcherUnhandledException 里把 Handled 设为 true,确实可以在未处理异常之后继续运行。
在 Windows Forms 中,主 UI 线程上也可以通过 Application.ThreadException 或 SetUnhandledExceptionMode 的设置来选择停止方式。
但能不能继续运行是一个问题,恢复条件是否齐备是另一个问题。
10. 总结
发生意外异常时真正该看的,不是「这个异常能不能 catch」,而是这之后应用的状态还能不能被信任。
判断顺序大致按下面这样就足够了:
- 失败的单元能不能舍弃
- 共享状态能不能恢复或重建
- 外部副作用能不能说明清楚
- 内存 / 线程 / 原生边界的健全性还值得信任吗
如果对这 4 点都有信心,就可以继续运行。 如果没有信心,就更偏向终止。
尤其是长时间运行的应用、监控应用、服务、设备联动工具,带着损坏的状态活下去往往比干脆崩溃更危险。
异常处理不是「不让程序崩溃的技术」。 它是一种让损坏范围变小、崩溃时诚实地停下来、并且更容易恢复的设计思路。
11. 参考资料
- .NET: Best practices for exceptions
- .NET: System.Exception
- .NET: StackOverflowException
- .NET: System.AccessViolationException
- .NET: Environment.FailFast
- .NET: AppDomain.UnhandledException
- WPF: Application.DispatcherUnhandledException
- Windows Forms: Application.SetUnhandledExceptionMode
- .NET: Exceptions in Managed Threads
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
异常处理中,catch 与日志应该放在哪里
为了避免在深层 helper 中大范围 catch、各层重复记录日志,以及隐藏原因的结果化处理,本文整理捕获异常的边界、主日志记录点,以及恢复判断的职责划分。
Windows 应用程序开发安全性最低限度检查清单
本文以检查清单的形式,整理 WPF / WinForms / WinUI / C++ / C# 业务应用程序在权限、签名、更新、机密信息、HTTPS、输入验证、DLL 加载、日志等方面不想遗漏的基本要点。
在 Windows 应用中把「仅需管理员权限的处理」分离出来的具体写法
本文以 .NET 8 桌面应用为例,说明如何让 UI 保持 asInvoker,同时把仅需要管理员权限的处理分离到独立的 helper EXE,内容涵盖 UAC 判断、runas 启动、命名管道 ACL 设计、连接方 PID 验证与请求校验等具体做法。
Windows 应用程序不要把敏感信息以明文存进配置文件的最佳实践
本文整理 Windows 桌面应用保存连接凭证或 API Token 时的实务做法,说明为什么 DPAPI 与 ProtectedData 比明文或自制加密更能切断「文件外泄即等于敏感信息外泄」这条链条,并对比 CurrentUser 与 LocalMachine 的适用场...
文件集成互斥控制基础知识 - 文件锁与原子 claim 的最佳实践
本文从文件锁、原子 claim、temp -> rename、idempotency(幂等性)等角度整理文件集成的互斥控制,并总结在共享文件夹集成中避免事故的设计方法。
常见问题
汇总了咨询这一主题时常见的问题。
- 能不能用 catch (Exception) 吞掉意外异常然后继续运行?
- 只打日志然后继续运行大多是危险的做法,因为这会掩盖原因,还容易让已经损坏的状态被延续下去。可以继续运行的前提是三项条件同时成立:能舍弃失败的单元、能把共享状态还原、能说明外部副作用。判断的关键不在于「这个异常能不能被 catch」,而在于「这之后应用的状态还能不能被信任」。
- 出现哪些异常应该立即终止应用?
- 像 StackOverflowException、AccessViolationException,以及严重的 OutOfMemoryException 这类会让人怀疑整个进程健全性的异常,最好不要以「可以继续运行」为前提。StackOverflowException 意味着调用栈已经损坏,AccessViolationException 代表对受保护内存的非法访问,疑似发生了内存损坏。从 COM、P/Invoke、第三方 SDK 的 callback 触发的异常同样如此,因为仅从 managed 侧观察很难判断其安全性,所以更偏向于终止。
- 发生异常之后,应用可以继续运行的条件是什么?
- 前提是以下条件大致同时满足:失败单元明确(能分辨出是 1 次操作、1 个界面、1 个 job、还是 1 个连接需要舍弃)、状态可以丢弃后重建、污染不会扩散到共享状态、能说明外部副作用、能向用户诚实告知「这次处理失败了」、能通过日志或指标进行事后排查。像 UI 的一次操作或一个导入 job 这类处理边界明确的场景,往往可以继续运行;反之,如果是共享状态更新到一半、父循环、启动流程、原生边界发生异常,就更偏向于终止。
- 在 WPF 的 DispatcherUnhandledException 里把 Handled 设为 true,就可以继续运行了吗?
- 确实可以做到未处理异常之后仍然继续运行,但「能继续运行」和「继续运行也安全」是两个不同的问题。AppDomain.UnhandledException、DispatcherUnhandledException 之类的处理程序,作为最后记录信息的地方很有用,但并不是什么神奇的恢复点。优先把异常信息、操作上下文、dump 采集通道准备齐全,让应用倒下之后可以被调查清楚,结果上反而会更稳定。尤其是长时间运行的服务或监控类应用,与带着半损坏的状态硬撑下去相比,倒下后被重启往往更容易诊断,也更安全。
作者简介
本文作者的个人简介页面。
Go Komura
小村软件有限公司 代表
以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。