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

· 更新日期: · · Windows 开发, 异常处理, 设计, C# / .NET, 可靠性

更新记录(2 条,最后更新 2026年09月03日)

本文的修改记录。已保存的更新前版本,可通过带有 DOI 的永久链接阅读。

本文此前是日文原文的节译,缺少大量章节、表格、Mermaid 图、图题、脚注与 FAQ。现已改写为日文原文的完整译文,技术主张与日文版一致,并补上了此前缺失的图与表,同时统一了全篇的术语译法。 查看更新前的版本 (DOI: 10.5281/zenodo.22276823)
补充了日文原文中已有的咨询引导(consultation_services)。正文内容没有改动。 查看更新前的版本 (DOI: 10.5281/zenodo.21615481)
首次发布
引用本文(DOI: 10.5281/zenodo.21615480)

本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。

小村 豪(2026)。《意外异常发生时,应用该终止还是继续的判断表》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615480 https://comcomponent.com/zh-CN/blog/2026/03/16/005-unexpected-exception-exit-or-continue-decision-table/

DOI(最新版本)
10.5281/zenodo.21615480
DOI(此版本)
10.5281/zenodo.22281996

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

这个文件由 Checklist-ja 和 Checklist-en 两个工作表组成,把本文的判断轴拆成了 继续条件 / 终止条件 / 实现方针 / 判断顺序 4 个类别共 27 个条目。Status 与 Notes 两列留空,可以直接当作故障处理记录或设计评审的检查表使用。

一谈到意料之外的异常,人们往往会不自觉地落进“让它崩溃,还是 catch 住继续跑”的二选一。 但在实务中,这样划分二选一有点粗糙。

真正想看清的是:能不能把可能已经损坏的范围封闭起来。

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

按这个顺序去看,事情会好整理很多。

确认损坏范围的顺序表示发生意外异常时,按照能否只让这次操作失败、是否只需重新初始化子系统、整个进程的一致性是否可疑的顺序,确认可能已经损坏的范围的流程图。能否只让这次操作失败是否只需重新初始化界面或连接整个进程是否可疑

图1:按照操作、子系统、整个进程的顺序,确认可能已经损坏的范围。

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

1. 先说结论

  • 用 catch (Exception) 吞掉异常继续跑,大多是危险的。
  • 可以继续运行的,是能舍弃失败的单元、能把共享状态还原、能说明外部副作用这 3 点同时成立的时候。
  • UI 的 1 次操作、1 条输入、1 个 job 这类处理边界明确的场景,有时可以继续运行。
  • 反之,只要牵涉共享的可写状态、常驻循环、主线程、启动流程、原生边界,或者带有内存损坏的迹象,就偏向终止。
  • StackOverflowException、AccessViolationException、OutOfMemoryException 这类让人怀疑“整个进程健全性”的异常,最好不要以继续运行为前提去考虑。
  • WPF 和 Windows Forms 也提供了接住未处理异常、表面上继续跑下去的路子,但能继续运行和继续运行也安全是两回事。
  • 长时间运行的服务或监控类应用,与其带着半损坏的状态硬撑,不如崩溃后被重启,往往既好诊断也更安全。

归根到底,判断的主轴是能不能恢复不变式。

可以继续运行的三项条件表示只有能舍弃失败的单元、能把共享状态还原、能说明外部副作用这三项同时成立时才可以继续运行,而判断的主轴是能否恢复不变式的图。能舍弃失败的单元三项齐备才可以继续运行能把共享状态还原能说明外部副作用主轴是能否恢复不变式

图2:可以继续运行的,只有失败单元、共享状态、外部副作用这 3 项条件齐备的时候。

1.1 本文使用的术语

在读判断表之前,先把反复出现的 5 个词定下来。

术语 在本文中的含义
不变式 处理前后必须始终成立的状态约定。比如“明细合计与汇总值一致”“缓存与数据库的内容保持同步”“打开的连接一定登记在管理列表里”。这里一旦被打破还继续运行,后续的所有处理都会变得可疑
失败单元 失败时可以整块舍弃的范围。1 次操作、1 个界面、1 个 job、1 个连接等
外部副作用 已经跑到进程外面去的变更。数据库更新、文件写入、发送邮件、向设备发送指令等,catch 收不回来的东西
子系统 可以整体停止并重新初始化的单元。连接、界面、worker、子进程等
FailFast 指 Environment.FailFast。它既不运行 try / finally 也不运行 finalizer,直接立即终止进程,详情在 9.7 讨论

图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 21 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle

2. 本文所说的“意料之外的异常”

2.1 区分预期内与意料之外

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

比如下面这些,即使发生频率很低,也可以算作预期内。

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

它们属于可以在设计阶段就先决定好这类失败怎么处理的类型。

而本文主要讨论的意料之外的异常,是这样的。

  • 自己代码的前提被打破,抛出了 NullReferenceException 或 InvalidOperationException
  • 更新共享状态的中途飞出异常,不清楚已经生效到哪一步
  • 监控循环或消息处理的父循环崩溃了
  • COM / P/Invoke / 第三方 SDK 边界上出现了异常
  • 出现 AccessViolationException 或 StackOverflowException,也就是整个进程的体检结果本来就已经亮红灯

也就是说,它们是“这个异常发生之后,还能不能信任应用的状态,说不清楚”的那一类。

预期内与意料之外的分界表示能在设计阶段先决定处理方式的异常即使频率很低也可以归为预期内,而异常之后无法确定应用状态能否信任的才是本文所说的意料之外的图。能决定不能决定发生了异常能否在设计阶段先决定处理方式预期内〔频率低也无妨〕意料之外〔状态能否信任不明〕

图3:罕见的异常和意料之外的异常是两码事,分界在于能否在设计阶段先决定处理方式。

2.2 看着是二选一,其实有三选

把这件事搞乱的元凶,是把继续运行当成一种东西来看。

在实务中,大致会分成 3 个层次。

选项 含义
只让这次操作失败并继续运行 界面留着,只把这次保存或导入当作失败处理
只停掉子系统并继续运行 只重新初始化连接、界面、worker、子进程
终止进程 状态损坏的范围看不清,所以改成以重启为前提

同样说“让应用继续运行”,装作什么都没发生地照跑不误,和把损坏的部分切割出去再继续,分量完全不同。

同样是继续运行,分量并不相同表示同样说让应用继续运行,装作什么都没发生地照跑不误,与把损坏的部分切割出去再继续,两者的分量并不相同的图。让应用继续运行装作什么都没发生地照跑不误把损坏的部分切割出去再继续同样是继续,分量并不相同

图4:不要把继续运行当成一种东西,要和切割掉损坏部分的继续区分开。

3. 首先要看的判断表

3.1 总览

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

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

图5:按损坏的迹象、失败单元、共享状态、外部副作用的顺序确认并定下方针的流程。

图中第一个分支里的“内存损坏的迹象”在 3.4 展开,右上角的 FailFast 在 9.7 具体讨论。

3.2 比异常类型更该先看的事

最好不要只凭异常类型就立刻拍板。 先要确认的是这几点。

观察点 确认什么
在哪里发生 是 UI 事件、单条 job、父循环、启动流程,还是原生边界
进行到了哪一步 中途有没有改动内存状态、数据库、文件、设备状态
可能损坏的范围 只是那个对象,还是整个界面,还是整个进程
能否回滚 能否丢弃重建,能否用事务还原
外部副作用 是否已发送,重复执行是否安全,能否做补偿处理
监控与重启 崩溃之后是否有自动重启或恢复通道

3.3 危险度高的异常

没必要把细碎的异常类型都过一遍,但确实有一些不适合以继续运行为前提去看。

异常 / 征兆 首选方案 观察理由
StackOverflowException 偏向立即终止 调用栈已经崩坏,很难以正常恢复为前提
AccessViolationException 偏向立即终止 对受保护内存的非法访问,可疑的是原生边界或内存损坏
OutOfMemoryException 偏向终止 以追加分配为前提的恢复处理本身就容易不稳
意料之外的 NullReferenceException / InvalidOperationException 视上下文而定,但偏向终止 是自己的前提被打破,中途的改动可能还留着
从父循环泄漏出来的意外异常 偏向终止 功能的核心已经死掉,却只剩进程活着,有风险
由 COM / P/Invoke / 第三方 SDK callback 触发的异常 立即终止到强烈偏向终止 只看 managed 侧难以判断是否安全

3.4 “内存损坏的迹象”“僵尸化”到底是看什么说的

这两个说法看上去很主观,但实际观察的东西是固定的。

先看怀疑内存损坏的症状。

能看到什么 在哪里看
进程以异常代码 0xc0000005(访问违例)或 0xc0000374(堆损坏)崩溃 事件查看器 > Windows 日志 > Application 中的“应用程序错误”(事件 ID 1000)
崩溃的位置每次都不一样。在最近根本没碰过的代码里崩溃 日志、转储的调用栈
只有在碰到本该已经释放的对象或句柄时才崩溃 复现步骤、转储
在原生侧的释放处理(free / delete / COM 的释放)里崩溃 转储的调用栈(比如在 ntdll.dll 里面崩溃)
没碰过的值被改写了。同样的输入结果却变了 输入输出日志、重跑结果的比对

0xc0000005 是 STATUS_ACCESS_VIOLATION,0xc0000374 是 STATUS_HEAP_CORRUPTION,两者都是 Microsoft 的 NTSTATUS 列表中定义的值。堆损坏尤其如此:它不在破坏发生的瞬间崩溃,而是在下一个访问这块被破坏的堆的一方崩溃,所以崩溃的位置未必就是元凶。如果出现了这种形态的症状,最好认为在 managed 侧 catch 住继续跑没有意义。

堆损坏时崩溃位置的偏移表示堆损坏不会在破坏发生的瞬间崩溃,而是在下一个访问这块被破坏的堆的一方崩溃,因此崩溃的位置未必就是元凶的图。某处把堆破坏了当时并不会崩溃在下一个访问它的一方崩溃崩溃的位置未必是元凶

图6:堆损坏在下一个访问它的一方崩溃,因此崩溃位置与破坏位置会错开。

接着是怀疑僵尸化的症状。

能看到什么 在哪里看
进程活着,但最近一次处理的时刻不再更新 最后处理时刻的日志、heartbeat
只有队列或接收文件夹的积压件数一直往上涨 队列长度、未处理文件数
从某个时刻起日志戛然而止 应用日志
界面还能操作,但背后的更新已经停了 界面显示与实际数据的比对
worker 线程数比预期少了 诊断日志、Process Explorer 的线程列表

所谓僵尸化,不是指没有崩溃,而是指活着却没有在干活。像最后处理时刻、积压件数、heartbeat 这样“表明程序确实在运转的指标”,事先准备好,继续还是终止的判断以及事后的排查都会轻松很多。

捕捉僵尸化的指标表示僵尸化是活着却没有在干活的状态,事先准备好最后处理时刻、积压件数、heartbeat 这类表明程序确实在运转的指标,判断和事后排查都会轻松很多的图。最后处理时刻事先准备好表明在运转的指标积压件数heartbeat继续还是终止的判断变轻松事后的排查变轻松

图7:不看有没有崩溃,而是用表明确实在干活的指标来捕捉僵尸化。

4. 按“在哪里发生”来判断

4.1 UI 事件

按钮点击、界面切换、搜索、选择文件这类 UI 事件,可以继续运行的余地相对较大。 不过是有条件的。

比较容易继续运行的是这些情况。

  • 在读取之前就失败了,还没碰到业务状态
  • 只有对话框内部的临时状态坏了,关掉界面就能丢掉
  • 异常之后可以重新创建 ViewModel 或连接
  • 能诚实地告诉用户“这次操作失败了”

反之,到了下面这种程度就偏向终止。

  • 界面和领域状态都已经更新了一部分
  • 碰了 static / singleton / 缓存这类其他界面也会读的共享状态
  • 异常之后只剩按钮的启用状态或选中状态还留着,一致性说不清
  • 在 UI 线程上发生了意料之外的异常,绘制和通知进行到哪一步很可疑
UI 操作中异常的分界表示 UI 事件中的异常,如果只涉及关掉就能丢掉的临时状态就比较容易继续运行,而一旦碰到其他界面也会读的共享状态或中途的更新就偏向终止的图。只有能丢掉的临时状态共享状态或中途更新UI 操作中出现意外异常碰到了哪些状态只让这次操作失败并继续偏向终止

图8:UI 事件继续运行的余地较大,但一旦碰到共享状态,情况就不一样了。

4.2 逐条处理的 job / 请求

这里是比较容易继续运行的边界。

  • 1 条消息
  • 1 个文件
  • 1 个 HTTP 请求
  • 1 个导入 job
  • 1 个批处理对象

只要这类单元明确,就可以只让这 1 条失败,然后继续往下走。

不过有前提。

  • 失败单元从外面看得清楚
  • 中途的改动能靠事务或补偿处理理顺
  • 同一处理再跑一遍也不会把结果弄坏
  • 失败能转移到隔离队列或错误记录里

4.3 常驻循环 / 监控 / 队列处理

这里是随意继续运行后果最糟糕的地方。

比如:

  • 重连循环
  • 监控循环
  • 队列消费循环
  • 定期轮询
  • 设备状态监控
  • 托盘应用的常驻处理

这类处理最可怕的是:父循环因为一次意料之外的异常而死掉,却只剩进程活着。

这里最好把方针分开。

  • 在每个条目处理的边界上捕获预期内的异常
  • 父循环一旦泄漏出意料之外的异常,就偏向终止进程
常驻循环中方针的划分表示在常驻处理中,于每个条目处理的边界捕获预期内的异常,而父循环一旦泄漏出意料之外的异常就偏向终止进程这一方针划分的图。常驻循环每个条目处理的边界父循环捕获预期内的异常泄漏出意外异常就偏向终止防止只剩进程活着

图9:条目边界和父循环分开定方针,父循环的意外异常直接接到终止上。

4.4 启动流程

把启动时的失败当成“先启动起来再说”,结果就是带着缺了一部分功能的状态一直跑下去,事后很难把原因隔离出来。

  • 必需配置读不到
  • 版本迁移 / migration 失败
  • 缺少必需的文件夹或证书
  • 核心服务初始化失败
  • 依赖关系的组装坏了

这类情况,按启动失败处理并终止更清楚。

4.5 原生边界 / COM / P/Invoke / unsafe

这块最好单独划出来,看得严一些。

  • COM
  • P/Invoke
  • C++/CLI 的另一侧
  • 第三方 SDK
  • 通过 callback 回来的 native 侧代码
  • 含 unsafe 的处理

尤其看到下面这些,就偏向终止。

  • AccessViolationException
  • 怀疑堆破坏或重复释放(double free)的症状
  • 句柄异常、疑似释放后访问的迹象
  • 在 callback 边界上突然死掉
原生边界异常的处理方式表示在 COM 或 P/Invoke 等原生边界上,一旦看到 AccessViolationException、怀疑堆破坏的症状或 callback 边界上的突然死亡就偏向终止,并把这一边界本身单独划出来看得更严的图。AccessViolationException偏向终止怀疑堆破坏或重复释放的症状在 callback 边界上突然死掉原生边界单独划出来看得更严

图10:在原生边界上看到这些症状,就放弃继续运行的前提,转向终止。

5. 可以继续运行的条件

把可以继续运行的条件归纳一下,就是下面这些。前提是它们大致齐备。

条件 含义
失败单元明确 1 次操作、1 个界面、1 个 job、1 个连接等,要舍弃的单元分得清
状态可以舍弃 能丢弃重建,或者可以当作未生效来处理
共享状态受保护 污染不会扩散到其他功能
能说明外部副作用 分得清是已发送 / 未发送 / 可以重发
能诚实地告诉用户 能显示“这次处理失败了”
可监控 能靠日志、指标、转储做事后排查

6. 最好终止的条件

反过来,符合下面这些的就偏向终止。

  • 不清楚中途改动了什么
  • 碰了共享的可写状态,一致性看不清
  • 锁、队列、线程、监控循环的生命周期管理坏了
  • 外部副作用的重复 / 遗漏 / 做一半说不清楚
  • 启动流程或核心基础设施的初始化失败
  • 怀疑涉及原生边界或内存损坏

到了这个程度,比起在漂亮地继续运行上下功夫,在让它崩溃、让恢复更容易上下功夫更有效。

偏向终止时更有效的投入表示在符合偏向终止条件的这个程度上,比起在漂亮地继续运行上下功夫,在让它崩溃、让恢复更容易上下功夫更有效的图。符合偏向终止的条件在漂亮地继续运行上下功夫在让崩溃后更容易恢复上下功夫这个程度上效果有限这一边更有效

图11:在偏向终止的场景里,与其在继续运行上花心思,不如投资于恢复的便利性。

7. 按典型场景给出的建议

3.1 的判断表是按条件写的,所以往实际场景上套的时候有时会迷茫。这张表是把那些条件重新套到常见的场景上。迷茫时,先用 3.1 确认条件,再到这张表里找接近的行,这个顺序更快。

迷茫时表格的用法表示往场景上套用时如果迷茫,先用 3.1 的判断表确认条件,再到本章的表里找接近的场景所在行这一顺序的图。往场景上套用时迷茫用 3.1 的判断表确认条件到本章的表里找接近的行

图12:从条件的表走到场景的表,按这个顺序查不容易迷路。

场景 建议 理由
打开文件按钮指定了不存在的路径 只让这次操作失败并继续 因为状态损坏是局部的
CSV 导入中只有 1 行数据坏了 让 1 行失败或 1 个文件失败并继续 因为容易把失败单元封闭起来
界面保存到一半出现了意料之外的 NullReferenceException 从重建界面到偏向终止 因为 ViewModel / 业务状态改到哪一步很可疑
队列中的 1 条消息违反了业务规则 只让那条消息失败并继续 因为可以转移到隔离队列
队列消费的父循环因意外异常而崩溃 偏向终止进程 因为整个 worker 的生命周期坏了
启动时必需配置读不到 按启动失败处理并终止 因为半吊子启动更危险
第三方 SDK callback 附近出现 AccessViolationException 偏向立即终止 因为内存损坏的可能性不能无视
只有非核心的遥测发送失败了 只停用该功能并继续 因为主功能与故障域可以分开

8. 常见的 NG 做法

8.1 用 catch (Exception) 只打日志然后继续跑

这相当危险。 因为它既掩盖了原因,又容易让已经损坏的状态被延续下去。

8.2 想在最后的未处理异常处理程序里恢复

AppDomain.UnhandledException、Application.ThreadException、DispatcherUnhandledException 之类,作为最后的记录点很有用,但不是什么神奇的恢复点。

8.3 明明有外部副作用,却轻率地 retry

设备指令、发送邮件、计费、移动文件、更新数据库等,在同一处理没有重新执行安全性的情况下就 retry,接下来登场的主角就会是重复执行引发的事故。

轻率的 retry 招来的重复执行表示对带有外部副作用的处理,在同一处理没有重新执行安全性的情况下就 retry,重复执行引发的事故会成为主角的图。带有外部副作用的处理失败了没有重新执行安全性就 retry重复执行引发的事故成为主角设备指令、计费、发送等

图13:没有重新执行安全性就 retry,会招来重复执行引发的事故。

8.4 监控循环已经死了,却只留着 UI

只有外表活着、实际没在干活的应用,从用户看来一切正常,正因如此才发现得晚。最好让监控侧能捞到 3.4 里那些僵尸化的症状有没有出现。

8.5 没做过崩溃的设计,却说“不想崩溃”

如果不想崩溃,那在此之前有些东西要先备好。

  • 自动重启
  • 会话恢复
  • 中途成果的保存
  • 重新执行安全性
  • 故障域的分离

9. 实现时的梳理要点

9.1 把 catch 的位置往边界上靠

与其在深层什么都 catch,不如像

  • UI 操作边界
  • 单个请求边界
  • 单个 job 边界
  • 单个连接边界
  • 进程边界

这样,在能定义出失败单元的位置接住异常,更好梳理。

比如逐条处理的导入,catch 就放在循环里面。这里就是失败单元。

// C# / .NET 8。逐条处理的循环中,把失败单元封闭在循环内侧的例子。
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;

public sealed record ImportItem(string Id, string Payload);
public sealed record ImportFailure(string Id, string ExceptionType, string Message);
public sealed record ImportSummary(int Succeeded, IReadOnlyList<ImportFailure> Failed);

public interface IImportStore
{
    /// <summary>
    /// 单条数据的失败(校验错误、重复、格式不正确等)用 ImportItemException 抛出。
    /// 除此以外的都不属于“这一条的问题”,直接让它向外传播。
    /// </summary>
    Task SaveAsync(ImportItem item, CancellationToken cancellationToken);
}

/// <summary>可以舍弃并继续下一条的、单条数据的失败。</summary>
public sealed class ImportItemException(string message, Exception? inner = null)
    : Exception(message, inner);

public sealed class ImportRunner(IImportStore store, ILogger<ImportRunner> logger)
{
    public async Task<ImportSummary> RunAsync(
        IReadOnlyList<ImportItem> items,
        CancellationToken cancellationToken)
    {
        int succeeded = 0;
        List<ImportFailure> failed = [];

        foreach (ImportItem item in items)
        {
            cancellationToken.ThrowIfCancellationRequested();

            try
            {
                await store.SaveAsync(item, cancellationToken);
                succeeded++;
            }
            catch (ImportItemException ex)
            {
                // 单条数据的状态可以舍弃,所以记录下来并继续处理下一条。
                //
                // 这里不要写成 catch (Exception)。如果连 NullReferenceException 和
                // OutOfMemoryException 都被当作“不过是坏数据”吞掉,那么 4.3 中
                // 决定“连宿主一起停掉”的意外异常就到不了父循环。
                // 结果就是在状态已经不可信之后,还继续把剩下的数据写下去
                logger.LogError(ex, "Import failed. ItemId={ItemId}", item.Id);
                failed.Add(new ImportFailure(item.Id, ex.GetType().Name, ex.Message));
            }
        }

        return new ImportSummary(succeeded, failed);
    }
}

反过来,驱动这套处理的父循环一侧不吞异常,是与之成对的判断。正如 4.3 所写,父循环死掉却只剩进程活着是最糟糕的形态,所以意料之外的异常要接到宿主的停止上。

// 常驻循环一侧。停止请求作为正常路径退出,其余意料之外的异常连宿主一起停掉。
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

public interface IImportQueue
{
    Task<IReadOnlyList<ImportItem>> DequeueBatchAsync(CancellationToken cancellationToken);
}

public sealed class ImportWorker(
    IImportQueue queue,
    ImportRunner runner,
    IHostApplicationLifetime lifetime,
    ILogger<ImportWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        try
        {
            using PeriodicTimer timer = new(TimeSpan.FromSeconds(10));

            while (await timer.WaitForNextTickAsync(stoppingToken))
            {
                IReadOnlyList<ImportItem> batch = await queue.DequeueBatchAsync(stoppingToken);
                ImportSummary summary = await runner.RunAsync(batch, stoppingToken);

                logger.LogInformation(
                    "Batch finished. Succeeded={Succeeded} Failed={Failed}",
                    summary.Succeeded,
                    summary.Failed.Count);
            }
        }
        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
        {
            // 停止请求属于正常路径。在这里安静地结束。
        }
        catch (Exception ex)
        {
            // 父循环坏了 = 整个 worker 的生命周期坏了。
            // 不要吞掉它、造出“只剩进程活着”的状态,而是停下来交给重启。
            logger.LogCritical(ex, "Worker loop failed unexpectedly. Stopping the host.");

            // StopApplication 是“正常关停”的请求。只有它的话,对于只在失败时
            // 才重启的监控侧(服务的恢复操作、systemd 的 Restart=on-failure、
            // 容器的 restart 策略)来说,与“干完活正常退出”区分不开,
            // 于是再也起不来了。
            //
            // 只设置 Environment.ExitCode 也不够。作为 Windows 服务运行时,
            // 宿主正常停止会向 SCM 报告 SERVICE_STOPPED,ExitCode 不会反映到
            // 服务的结束状态里,因此恢复操作不会启动。
            // 要以非 0 的退出码结束进程
            Environment.Exit(1);
        }
    }
}

要点在于用了 Environment.Exit(1)。IHostApplicationLifetime.StopApplication() 是正常停止的请求,所以作为 Windows 服务运行时,向 SCM 报告的是 SERVICE_STOPPED。进程的 Environment.ExitCode 不会反映到服务的结束状态里,因此在服务属性里配置的恢复操作(“重新启动服务”)不会执行。官方的辅助角色服务教程也明确写着:默认的 BackgroundServiceExceptionBehavior.StopHost 会“干净地停止”,所以 Windows 的服务管理侧不会重启;要让恢复操作生效,必须以非 0 的退出码调用 Environment.Exit(见第 11 章的参考资料)。

Environment.Exit 会结束当前进程,所以崩溃前想留下的日志,要在这一行之前写完。如果用的是带缓冲的日志器,就在中间插入 flush。反过来,如果对手不是 Windows 服务,只有 systemd 或容器,那么先设置 Environment.ExitCode 再用 StopApplication() 关停,监控侧也能当作失败来处理。请按运行环境来选。

另外还要记住,循环的内侧和外侧,catch 的职责是不同的。内侧是记录失败单元,外侧是结束生命周期。把这两者搞反,就会出现 1 条失败就让应用崩溃,或者反过来 worker 已经死了进程还活着的情况。

内侧与外侧 catch 的职责表示循环内侧的 catch 负责记录失败单元、外侧的 catch 负责结束生命周期这一职责划分,一旦搞反就会因 1 条失败而崩溃,或者 worker 死了进程还活着的图。循环内侧的 catch记录失败单元循环外侧的 catch结束生命周期搞反就会因 1 条失败而崩溃搞反就会只剩进程活着

图14:catch 在循环内侧和外侧职责不同,搞反会造成两个方向的问题。

9.2 区分预期内异常与意料之外的异常

  • 预期内:validation、not found、timeout、cancel、违反业务规则
  • 意料之外:前提被打破、父循环泄漏、原生边界异常、内存损坏的迹象

9.3 把共享状态做小

共享的可写状态越大,能否继续运行的判断就越难。 反过来,越能封闭在 1 个界面、1 个会话、1 个 worker 之内,失败也就越容易封闭。

9.4 把危险处理挪到独立进程

COM / ActiveX / 第三方 SDK / unsafe / 重量级图像处理 / 外部设备控制等,不希望崩溃时殃及范围扩大的东西,拆成独立进程相当管用。

9.5 未处理异常处理程序重在“记录”而不是“恢复”

  • 异常信息
  • 操作上下文
  • 崩溃前的重要日志
  • 配置 / 版本 / 连接目标
  • 转储采集通道

把这些备齐,优先做成崩溃之后还能深究的形态,结果反而更稳定。

WPF 的话,记录用的处理程序做成下面这个样子就够了。

// WPF (.NET 8) 的 App.xaml.cs。处理程序用于“记录”而不是“恢复”。
using System;
using System.IO;
using System.Reflection;
using System.Threading.Tasks;
using System.Windows;

namespace SampleApp;

public partial class App : Application
{
    private static readonly string CrashLogPath = Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
        "SampleApp",
        "crash.log");

    protected override void OnStartup(StartupEventArgs e)
    {
        // 处理程序的注册要放在 base.OnStartup(e) 之前。
        // base.OnStartup 会触发 Startup 事件,如果它的订阅方抛出异常,
        // 而处理程序写在后面还没注册,就会变成
        // “启动时崩溃了却一行日志都没留下”这种最麻烦的形态。
        // 正因为最想记录的就是启动处理本身的失败,所以要先挂上。

        // 在 UI 线程上未经处理一路上来的异常
        DispatcherUnhandledException += (_, args) =>
        {
            Record("DispatcherUnhandledException", args.Exception);

            // 把 args.Handled 设为 true 就能继续运行,但能不能继续要按第 5 章的条件判断。
            // 判断不了就记录下来,交给默认行为(终止)。
            args.Handled = false;
        };

        // 包含 UI 线程以外在内的最后通知。这里已经拦不住了,所以只用于记录。
        AppDomain.CurrentDomain.UnhandledException += (_, args) =>
            Record("AppDomain.UnhandledException", args.ExceptionObject as Exception);

        // 未被 await 就被回收的 Task 的异常
        TaskScheduler.UnobservedTaskException += (_, args) =>
        {
            Record("UnobservedTaskException", args.Exception);
            args.SetObserved();
        };

        // 挂完之后,再进入默认的启动处理(触发 Startup 事件)
        base.OnStartup(e);
    }

    private static void Record(string source, Exception? exception)
    {
        try
        {
            Directory.CreateDirectory(Path.GetDirectoryName(CrashLogPath)!);

            string version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
            string text = string.Join(
                Environment.NewLine,
                $"[{DateTimeOffset.Now:O}] {source}",
                $"version={version} os={Environment.OSVersion} user={Environment.UserName}",
                exception?.ToString() ?? "(no exception object)",
                string.Empty);

            File.AppendAllText(CrashLogPath, text);
        }
        catch
        {
            // 即使记录失败,也不要挡住结束处理。
        }
    }
}

要点是不要在处理程序里试图修复状态。这里要做的只有一件事:留下下次能追查同一现象的材料。

记录专用处理程序的流程表示在 WPF 中先于 base.OnStartup 完成处理程序的注册再进入启动处理,并且在处理程序里不试图修复状态,只留下下次能追查同一现象的材料这一流程的图。先注册处理程序再进入 base.OnStartup记录未处理异常不试图修复状态

图15:注册先于启动处理完成,处理程序里只做记录。

9.6 不要过分信赖 WPF / WinForms 的未处理异常事件

在 WPF 中,把 DispatcherUnhandledException 的 Handled 设为 true,未处理异常之后继续跑下去这件事本身是能做到的。 Windows Forms 在主 UI 线程上,也可以通过 Application.ThreadException 或 SetUnhandledExceptionMode 的配置来选择停止方式。

不过,能不能因此继续运行,和恢复条件是否齐备,是两个问题。

9.7 Environment.FailFast 只用在“收尾反而更危险”的时候

3.1 的流程图里出现的 FailFast,指的是 Environment.FailFast。官方文档写明的行为如下。

  • 不运行正在执行的 try / finally,也不运行 finalizer,直接终止进程
  • 在 Windows 上,把传入的消息写到 Windows 的应用程序事件日志,并创建应用程序的转储之后再终止
  • 消息与异常信息也会经由 Windows 错误报告,包含在发给 Microsoft 的错误报告里
  • 在 Visual Studio 的调试器下调用时会变成 ExecutionEngineException,并触发 fatalExecutionEngineError 这个托管调试助手

不运行 finally 不是缺点,而是这个 API 的目的所在。因为状态已经损坏时去运行收尾代码,有可能把损坏的内容原样写进文件或数据库。文档里也说明:应用的状态已经无法修复地损坏,运行 try / finally 或 finalizer 会破坏资源时,不用 Environment.Exit 而用 FailFast。

用法的区分是这样的。

情况 选什么
不变式已被打破,运行收尾反而更危险 Environment.FailFast
状态健全,想收拾干净再结束 正常的结束处理(IHostApplicationLifetime.StopApplication 等)
只是想返回退出码然后结束 Environment.Exit 或从 Main 返回

写成代码,是用在下面这种地方。

// C# / .NET 8。检测到共享状态的不变式被打破的地方。
// 从这里往后,任何收尾处理都不可信。
if (cache.Count != store.Count)
{
    Environment.FailFast(
        $"Invariant broken: cache={cache.Count} store={store.Count}",
        new InvalidOperationException("Cache and store are out of sync."));
}

因为转储会被自动采集,所以传给 FailFast 的消息里最好写上是哪个不变式、在哪个数值上被打破的,后续调查会轻松很多。反过来,为输入错误或通信错误这类预期内的失败调用 FailFast 就过头了。那部分要回到 9.1 关于失败单元的讨论。

FailFast 不运行收尾的理由表示状态已经无法修复地损坏时运行收尾代码有可能把损坏的内容写出去,因此 Environment.FailFast 既不运行 try 与 finally 也不运行 finalizer 就终止,并在 Windows 上留下事件日志与转储的图。不变式被打破用 FailFast 立即终止不运行 finally 也不运行 finalizer留下转储与事件日志〔Windows〕防止把损坏的内容写出去

图16:FailFast 通过不运行收尾,防止把损坏的状态写出去。

10. 总结

发生意料之外的异常时该看的,不是“这个异常能不能 catch”,而是这之后应用的状态还能不能信任。

判断的顺序,大致这样就够了。

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

对这 4 点有把握就可以继续运行。 没有把握,就偏向终止。

总结中的判断顺序表示依次确认能否舍弃失败单元、能否还原共享状态、能否说明外部副作用、包含原生边界在内的健全性能否信任,四项都有把握就继续、没有把握就偏向终止的判断流程图。四项都有把握没有把握能否舍弃失败单元能否还原共享状态能否说明外部副作用健全性能否信任可以继续运行偏向终止

图17:依次回答这 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 之类的处理程序,作为最后的记录点很有用,却不是什么神奇的恢复点。把异常信息、操作上下文、转储采集通道准备齐全,优先做成崩溃之后还能调查的形态,结果反而更稳定。尤其是长时间运行的服务或监控类应用,与其带着半损坏的状态硬撑,不如崩溃后被重启,往往既好诊断也更安全。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表