关于错误处理的几点看法

2020-07-19 05:00:48

在这篇文章中,我总结了一些有经验的工程师对错误处理的看法。由于程序员忽略了错误而导致的错误和代表真实错误情况的错误是有区别的。错误检查的粒度也有待讨论:每个函数?每模块?跳转到主消息循环中的对话处理程序?是否终止进程并重新启动?

Joe Duffy在ErrorModel中描述了在Midori中设计错误处理的注意事项。他说他们是以这些原则为依归的:

可用。对于开发人员来说,面对错误做“正确”的事情必须很容易,几乎就像是偶然的。一位朋友和同事把这件事称为“落入成功之坑”,这是出了名的。模型不应该过分拘泥于编写习惯代码。理想情况下,它是我们的目标受众所熟知的。

可靠。误差模型是整个系统可靠性的基础。毕竟,我们是在构建操作系统,所以可靠性是最重要的。你甚至可能会指责我们痴迷于追求它的极端水平。我们指导大部分编程模型开发的口头禅是“通过构建来正确的”。

表演者。通常情况下,需要非常快。这意味着成功路径的开销尽可能接近于零。任何为故障路径增加的成本必须完全是“按需付费”的。与许多愿意过度惩罚错误路径的现代系统不同,我们有几个性能关键的组件,这对它们来说是不可接受的,所以错误也必须相当快。

并发。我们的整个系统是分布式的,高度并发。这引起了在其他错误模型中通常是事后考虑的问题,在我们的错误模型中,它们需要处于最重要的位置。

沉着的。在核心,错误模型是一种编程语言功能,位于开发人员代码表达的中心。因此,它必须提供熟悉的正交性和与系统的其他特性的可组合性。集成单独编写的组件必须是自然、可靠和可预测的。

最后,他选择了Checked Exception,但将所有程序员错误案例分开。这些都是通过遗弃-致命的断言来处理的。编译器可以更好地优化代码,因为它确切地知道哪些路径可以抛出(与C++相反,在C++中,您必须注释每个不能抛出的函数)。语法类似于现在在SWIFT和Rust中找到的语法。

而二郎族则更为铁杆一些。他们不会陷入关于句法结构的讨论。乔·阿姆斯特朗在“错误处理的做与不做”一书中说:“如果你的计算机被闪电击中,你的正确性定理不会对你有任何帮助。他的意思是,没有一个系统是孤立运行的,失败的可能性总是存在的。因此,当确实发生错误时,它们会将受影响的进程重新启动到已知状态,然后重试。

弗雷德·赫伯特在“二郎禅”中描述了“让它崩溃”的座右铭。Erlang进程是完全隔离的,不共享任何内容。因此,如果检测到错误,系统只会终止该进程并重新启动。但是这怎么能解决任何问题呢?同样的错误不会一次又一次地发生吗?如何处理内容错误的配置文件?

弗雷德指的是吉姆·格雷斯1985年的论文“计算机为什么停止,可以做什么?”,格雷在那篇论文中引入了海森虫和波尔虫的概念。用弗雷德·赫伯特的话说:

基本上,牛虫是一种坚固的、可观察到的、容易重复的虫子。他们往往是相当简单的理由。相比之下,海森虫的不可靠行为在一定条件下会显露出来,而这种不可靠的行为可能会被试图观察它们的简单行为所掩盖。例如,当使用可能强制系统中的每个操作都被序列化的调试器时,并发错误以消失而臭名昭著。

海森虫是千万亿亿亿万次才会发生一次的令人讨厌的虫子。你知道,一旦你看到有人打印出几页代码,然后拿着一堆记号笔进城,就会有一段时间致力于找出一个解决方案。

因此,一个可重复的(玻尔)错误很容易复制,而一个暂时性的(海森错误)将很难重现。现在,赫伯特认为,如果你的系统的核心功能中有一个错误,那么在投入生产之前应该很容易找到它。由于是可重复的,并且经常在关键路径上,您迟早会遇到它们,并在发货前修复它们。

现在,吉姆·格雷(Jim Gray)的论文报告说,暂时性错误(Heisenbug)一直都在发生。它们通常通过重新启动来修复。只要您通过对您的版本进行适当的测试来剔除bohrbug,其余的bug通常可以通过重新启动并回滚到已知状态来解决。

致命的异常不是你的错,你不能阻止它们,你也不能理智地清除它们。它们几乎总是会发生,因为这个过程已经病入膏肓,即将摆脱它的痛苦。内存不足、线程中止等。

愚蠢的异常是您自己的错误,您本可以防止它们,因此它们是您代码中的错误。您不应该捕获它们;这样做是在您的代码中隐藏错误。相反,您应该编写代码,使异常在一开始就不可能发生,因此不需要捕获。该参数为空,类型转换错误,索引超出范围,您正试图除以零。

令人烦恼的异常是不幸的设计决策的结果。棘手的异常是在完全非异常的情况下抛出的,因此必须始终捕获和处理。Avexing异常的经典示例是Int32.Parse,如果您给它一个不能解析为整数的字符串,它就会抛出。Eric建议改为调用这些函数的试用版本。

外部异常看起来有点像令人烦恼的异常,只是它们不是不幸的设计选择的结果。相反,它们是不整洁的外部现实冲击着你美丽、清晰的程序逻辑的结果。

尝试{使用(File f=OpenFile(filename,Forread)){use(F);}}catch(FileNotFoundException){//处理找不到文件名}。

不,你不能!。新代码有争用条件。Eric建议您咬紧牙关,始终处理指示意外的额外条件的异常。

Rob Pike在错误值中写道,如果err!=nil{...}总是在GO代码中,如何避免写入。错误处理可以集成到类型中,而不是分散在if语句中。他以Bufio Packages的扫描仪为例:

Scanner:=bufio.NewScanner(Input)for scanner.Scan(){Token:=scanner.Text()//进程Token}if err:=scanner.Err();err!=nil{//处理错误}。

错误检查只进行一次。Rob还提到,archive/zip和net/http包使用相同的模式。Bfiopackage的编剧也是这样做的。

Fabien Giesen为BufferCentric I/O中的错误处理描述了一个类似的模式,该模式在Qt框架的核心类中被广泛使用,它的另一个名称是粘性错误或错误累加器。

Per Vognsen讨论了如何使用setjmp/long jmp在C中进行过程粒度错误处理,其中有竞技场分配和深度嵌套的递归解析器的用例。这与C++处理异常的方式非常相似,但没有堆栈展开时代价高昂的C++内存释放的缺点。他接着说,某些面向推送的API类具有明确的命令-查询分离,不需要进行细粒度的错误处理。这与上一节的想法相同。

Fabien Giesen在一个要点说明的旁白中描述了他是如何看待错误处理的。他指出,只提供一小组错误代码可能是有益的,这些错误代码的选择应该由“我下一步应该做什么?”这个问题来决定。例如,网络连接失败的方式有很多种,但是提供大量的错误代码分类法并不能帮助调用代码决定要做什么。日志记录应该尽可能具体,但是API的用户只需要决定下一步做什么。

Fabien在一篇博客评论中写道,让堆栈展开来清理错误是一个耗费大量资源且难以控制的糟糕设计。

基于“清理堆栈”的展开在每个单个函数上都会产生成本,这意味着它等同于检查每个单个函数中的错误条件。这是实现错误处理的一种非常糟糕的方式;一种效果好得多的方法是只记住发生了错误,但尽快替换有效数据。

也就是说,将“战术性”错误处理(只需确保您的程序最终处于安全一致的状态)与“策略性”错误处理(通常在应用程序中处于相当高的级别,可能涉及用户交互)分开,并尽量使大多数中间层都不知道这两个错误处理。

总的来说,我认为这是很好的做法,尤其是因为立即升级的错误条件不仅很难理解控制流,而且会带来糟糕的用户体验。以断开的P4连接为例,复制大目录,诸如此类。问我每一个问题都是拙劣的设计,但它反映了应用程序对每个错误代码做出反应的底层模型。除非你不能理智地继续下去,否则就把出错的事情列出来,最后给我看。这不仅是更好的用户体验,如果从一开始就设计好,也会相当容易。

在设计错误处理时要问自己的第一件事是,需要什么粒度?如果您有一个10KLOC解析器,并且允许它在遇到第一个错误时放弃,那么与应该在某个同步点继续解析的解析器相比,这是一个更容易解决的问题。您可以直接丢弃堆栈,或者退出进程,这比试图通过展开堆栈将进程恢复到已知状态要容易得多。

错误码可以忽略!这在Rust中已经解决了问题,但是另外两种最新的系统编程语言GO和SWIFT没有提供AFAICT强制检查返回类型的机制。

程序员错误和真正错误之间的界限很难区分:Java检查了异常,但引入了RuntimeException来处理越界索引、非法参数等。Go和Rust都有针对严重错误的单独的PanicStatement。

不出所料,错误和常规返回值之间的界限有很多错误。C#和Java使用异常来通知无法解析整数!那些“恼人的例外”本不应该是例外。

很难达到乔·达菲的所有标准。Erlang是可用的、可靠的、并发的、可诊断的和可组合的,但与其他替代方案相比速度较慢。C和GO是可靠且并发的,但是它在可用标准上失败:很容易忽略返回值。至于可组合性:许多语言都引入了特殊的语法形式来处理错误,但是如果不使用Stick错误模式,仅使用返回值就可以实现很多令人惊讶的结果。C++异常不符合可诊断标准(Nostacktraces)和可用性(很容易编写不处理应该处理的异常的代码)。