意外异常发生时,应用该终止还是继续的判断表

· · Windows 开发, 异常处理, 设计, C# / .NET, 可靠性

下载附中日双语工作表的 Excel 检查清单

一谈到意外异常,很容易就直接陷入「让它崩溃,还是 catch 住继续运行」这样的二选一思维。 但在实务中,这种二选一的划分方式其实有点粗糙。

真正需要关注的是:能不能把可能已经损坏的范围封闭起来

  • 是不是只让那一次操作失败就够了
  • 是不是只重新初始化那个界面 / 连接 / worker 就够了
  • 还是整个进程的一致性已经变得不可信

按这个顺序去看,会整理得清晰很多。

本文以 C# / .NET 编写的 Windows 应用、常驻应用、Windows 服务、设备联动工具等为前提,把发生意外异常时可以继续运行的条件,以及应该终止的条件整理成一张判断表。

1. 先说结论

  • catch (Exception) 吞掉异常然后继续运行,大多是危险的做法。
  • 可以继续运行的前提是三项条件同时成立:能舍弃失败的单元能把共享状态还原能说明外部副作用
  • UI 的一次操作、一条输入、一个 job 之类处理边界明确的场景,有时可以继续运行。
  • 反之,如果涉及共享的可写状态、常驻循环、主线程、启动流程、原生边界,或者带有内存损坏的迹象,就更偏向于终止。
  • StackOverflowExceptionAccessViolationExceptionOutOfMemoryException 这类会让人怀疑「整个进程健全性」的异常,最好不要以继续运行为前提去考虑。
  • WPF 和 Windows Forms 都提供了捕获未处理异常后表面上继续运行的手段,但能继续运行继续运行也安全是两件不同的事。
  • 长时间运行的服务或监控类应用,与带着半损坏的状态硬撑下去相比,倒下后被重启往往更容易诊断,也更安全。

归纳起来,判断的主轴是能不能恢复不变式(invariant)

2. 本文所说的「意外异常」

2.1 先区分「预期内」与「意外」

首先,罕见的异常意外的异常并不是一回事。

比如下面这些,即便发生频率很低,也可以归为预期内

  • 用户选择了不存在的文件
  • 通信对象暂时超时
  • 导入的 CSV 中有一行数据损坏
  • 取消操作导致抛出 OperationCanceledException
  • 因业务规则被违反,只想让这一次处理失败

这些的共同点是:如何处理这类失败,可以在设计阶段就提前决定好

而本文主要讨论的意外异常,指的是这样的情况:

  • 自身代码的前提被打破,抛出了 NullReferenceExceptionInvalidOperationException
  • 在更新共享状态的过程中抛出异常,不清楚到底改到了哪一步
  • 监控循环或消息处理的父循环崩溃了
  • COM / P/Invoke / 第三方 SDK 边界出现了异常
  • 出现了 AccessViolationExceptionStackOverflowException 这类「整个进程的健康检查已经亮红灯」的状况

也就是说,这些异常发生后,「应用的状态还能不能被信任」变得不明确

2.2 看似二选一,实际是三选一

让这个话题变得混乱的元凶,是把「继续运行」当作单一的一种选项来看待。

在实务中,大致可以分为三个层次:

选项 含义
只让这次操作失败,然后继续运行 保留界面,只把这次保存或导入当作失败处理
只停掉子系统,然后继续运行 重新初始化连接、界面、worker、子进程
终止整个进程 因为状态损坏的范围无法确定,改为按重启处理

即便都叫「继续运行」,装作什么都没发生地继续跑下去,与把损坏的部分切割出去再继续运行,两者的分量完全不同。

3. 首先要看的判断表

3.1 总览

先看这张表,大致的方针就能定下来。

情况 首选方案 理由
只有一条输入、一次界面操作、一个 job 失败,且状态可以舍弃 偏向继续运行 能把失败的单元封闭起来
异常发生后可以丢弃相关对象或连接并重新创建 偏向重新初始化子系统 能把损坏范围局部化
共享状态更新到一半,但不清楚具体反映到了哪一步 偏向终止 不变式可能已经被打破
数据库 / 文件 / 设备指令等外部副作用执行到一半,无法说明是否重复或未生效 偏向终止 与外部世界的一致性已不明确
监控循环、重连循环、消息处理的父循环因意外异常而崩溃 偏向终止 静悄悄地只让部分功能死掉,容易变成僵尸进程
启动流程、配置加载、DI 组装、必需依赖的初始化失败 按启动失败处理,偏向终止 半途启动的风险更大
出现 AccessViolationExceptionStackOverflowException、严重的 OutOfMemoryException,或原生侧带有损坏迹象 偏向立即终止 整个进程的健全性存疑
危险处理已被隔离到另一个进程,主进程未受影响 主进程继续运行,重启子进程 故障范围已经被分离
意外异常是否有内存损坏 / 堆栈耗尽 / 致命资源耗尽的迹象?终止 / FailFast / 重启失败的单元能否舍弃?偏向终止共享状态能否回滚 / 重新初始化?停止子系统或终止外部副作用能否说明清楚?只把这次操作视为失败并继续

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.UnhandledExceptionApplication.ThreadExceptionDispatcherUnhandledException 之类的处理程序,作为最后的记录点很有用,但不是什么神奇的恢复点

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.ThreadExceptionSetUnhandledExceptionMode 的设置来选择停止方式。

但能不能继续运行是一个问题,恢复条件是否齐备是另一个问题。

10. 总结

发生意外异常时真正该看的,不是「这个异常能不能 catch」,而是这之后应用的状态还能不能被信任

判断顺序大致按下面这样就足够了:

  1. 失败的单元能不能舍弃
  2. 共享状态能不能恢复或重建
  3. 外部副作用能不能说明清楚
  4. 内存 / 线程 / 原生边界的健全性还值得信任吗

如果对这 4 点都有信心,就可以继续运行。 如果没有信心,就更偏向终止。

尤其是长时间运行的应用、监控应用、服务、设备联动工具,带着损坏的状态活下去往往比干脆崩溃更危险。

异常处理不是「不让程序崩溃的技术」。 它是一种让损坏范围变小、崩溃时诚实地停下来、并且更容易恢复的设计思路。

11. 参考资料

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

常见问题

汇总了咨询这一主题时常见的问题。

能不能用 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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表