首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

有经验的工程师是怎样看待错误处理的?

本文最初发布于 Daniel Näslund的个人博客,经原作者授权由InfoQ中文站翻译并分享。

程序员忽略bug导致错误发生与那些代表真实错误情况的错误,这两者是有区别的。错误检查的粒度也存在争议:每个函数?每个模块?在主消息循环中跳转到对话处理程序?杀死进程并重启?

本文主要内容如下:

  • Midori错误模型
  • Erlang的方法:任其崩溃
  • 异常分类
  • 编写错误代码
  • 错误处理粒度
  • 总结

Midori错误模型

在《The Error Model》一文中,Joe Duffy描述了在Midori中设计错误处理的考虑因素。他强调,设计遵循以下原则:

可用性——面对错误,尤其是偶然发生的错误,开发人员必须很容易就能做“正确的”事情。我的一个朋友和同事将之称为掌握了“成功之核(The Pit of Success)”。模型不应该为了编写合乎习惯的代码而强加过多的繁文缛节。在理想情况下,我们要使用目标受众在认知上很熟悉的模型。

可靠性 ——错误模型(The Error Model)是整个系统可靠性的基础。毕竟,我们是在构建操作型系统,所以可靠性是最重要的。你甚至可能会指责我们过分追求极致的可靠。“构造正确性”是我们大部分编程模型开发恪守的箴言。

性能 ——通常情况下需要非常快。这意味着,成功的路径应尽可能接近于零开销。任何因为失败的路径而增加的额外成本必须在必要时才付出。不过,不像许多现代系统愿意过度处理错误路径,我们有几个性能关键的组件不能容忍这种情况,所以错误也必须相当快。

并发性 ——我们的整个系统是分布式、高并发的。通常,在其他错误模型中,这事后才引起关注。我们的模型将其前置并放在核心位置上。

可诊断性 ——故障调试,无论是在交互过程中还是在事后,都要简单高效。

可组合性 ——本质上,错误模型是一种编程语言特性,处于开发人员代码表达的中心位置。因此,与系统其他特性一样,它必须提供常见的正交性和可组合性。集成单独编写的组件必须简单自然、可靠和可预测。

Joe 根据这些标准比较了不同的错误模型,并汇总如下:

最后,他选择了检查异常,但将所有的程序错误情况分开。这些都是通过不同级别的断言处理的。编译器可以更好地优化代码,因为它确切地知道哪些路径会抛出异常(与 C++ 相反,你必须注明每个不能抛出异常的函数)。语法与现在的 Swift 和 Rust 类似。

Erlang 的方法:任其崩溃

Erlang 的开发者比较硬核。他们不会陷入关于句法结构的讨论中。Joe Armstrong 在“ 错误处理的规则与禁忌”中说:“如果你的计算机被闪电击中,正确性理论也帮不了你。”他的意思是,没有一个系统是孤立运行的,总会有失败的可能。因此,当错误确实发生时,它们会将受影响的进程重启到已知状态,然后重试。

Fred Hebert 在“ Erlang 之禅”中介绍了“任其崩溃”的箴言。Erlang 进程是完全隔离的,不共享任何东西。因此,如果检测到错误,系统就会终止进程并重新启动。但是这怎么能解决问题呢?同样的错误不会一次又一次地发生吗?如何处理包含错误内容的配置文件?

Fred 引用了 Jim Grays 在 1985 年发表的论文“ 计算机为什么会停止工作?我们能做些什么?”。在这篇论文中, Gray 引入了 Heisenbugs 和 Bohrbugs 的概念。用 Fred Hebert 的话来说:

基本上,Bohrbug 是稳定的、可观察的和容易重复的 bug。它们的推断往往相当简单。相比之下,Heisenbug 的行为是不稳定的,它会在特定条件下表现出来,但在人们试图观察它们时又隐藏了起来。例如,当使用强制系统中的每个操作都按顺序执行的调试器时,并发错误就会消失。 Heisenbug 是一种很讨厌的  Bug,它出现的机会只有千分之一、百万分之一、十亿分之一或万亿分之一。当你看到人们打印出一页页代码,并专心致志地做了一堆标记,你就知道有人已经花了很长时间来找 Bug 了。

因此,可重复的 (Bohr) bug 很容易再现,而短暂的 (Heisen)bug 则很难再现。现在,Hebert 认为,如果系统的核心特性中有 Bohrbug,那么在投入生产之前应该很容易找出来。由于是可重复的,并且经常是在关键路径上,所以你迟早会遇到它们,并在发布之前将其修复。

根据 Jim Gray 的论文,瞬态错误(Heisenbug)一直在发生。通常,它们可以通过重启来修复。你可以通过对发行版进行适当的测试来消除 Bohrbug,其余的 Bug 通常通过重启并回滚到一个已知状态来解决。

异常分类

Eric Lippert 在“ 令人烦恼的异常”中对异常做了如下分类:

  • 致命异常 。不是你的错,你无法阻止它们,也没有什么好办法将其消除。它们几乎总是会发生,因为这个进程的问题已经很严重,即将挂掉。此类异常包括内存不足、线程中止等等。
  • 无脑异常。 这是你自己的错误,你本可以避免它们,它们是你代码中存在的 Bug。你不应该捕获它们,那是在隐藏代码中的 Bug。相反,你应该在编写代码的时候保证异常不会发生,这样,就无需捕获了。此类异常包括参数为空、类型转换问题、索引超出范围、 除 数为零等等。
  • 令人烦恼的异常 。是由设计决策不当所导致。令人恼火的异常是完全可以预料的,因此,必须始终捕获并处理。关于这类异常,一个经典的例子是 Int32.Parse,如果给它一个无法解析为整数的字符串,就会抛出此类异常。Eric 建议调用这些函数的 Try 版本。
  • 外源异常 。似乎有点像令人烦恼的异常,但它们不是由设计选择不当所导致。相反,它们是因为不恰当的外部现实影响了你优美、清晰的程序逻辑。

Eric 提供的 C#伪代码示例:

代码语言:javascript
复制
try { 
	using ( File f = OpenFile(filename, ForReading) ) { 
	use(f); 
	} 
} catch (FileNotFoundException) { 
	// 处理未找到文件的情况 
} 

可以去掉上面这段代码中的 try-catch 吗?

代码语言:javascript
复制
if (!FileExists(filaname)) 
    // 处理未找到文件的情况 
else 
    using (File f = ... 

不,你不能!新代码具有竞争条件。Eric 建议我们坚持一下,始终处理这类异常。

编写错误代码

在“ 错误值”一文中,Rob Pike 介绍了在Go 代码中如何总是避免使用 if err != nil {...} 。可以将错误处理集成到类型中,而不是使用零散的 if 语句 。他以 bufio 软件包中的 Scanner 为例进行了说明:

代码语言:javascript
复制
scanner := bufio.NewScanner(input) 
for scanner.Scan() { 
    token := scanner.Text() 
    // 处理 token 
} 
if err := scanner.Err(); err != nil { 
    // 处理错误 
} 

错误检查只进行一次。Rob 还提到, archive/zipnet/http 包使用了相同的模式。 bufio 包的 Writer 也是如此。

代码语言:javascript
复制
b := bufio.NewWriter(fd) 
b.Write(x) 
b.Write(y) 
b.Write(z) 
// and so on 
if b.Flush() != nil { 
    return b.Flush() 
} 

Fabien Giesen 描述了 Buffer Centric I/O 中类似的错误处理模式。该模式在整个 Qt 框架的核心类中被广泛使用。它的另一个名字是粘接错误(stick error)或错误累加器。

错误处理粒度

Per Vognsen 讨论了 在C 语言中如何使用setjmp/longjmp 进行粗粒度错误处理。其用例包括Arena Allocation 和深度嵌套递归解析器。它与C++ 处理异常的方式非常相似,但没有C++ 栈展开时内存释放代价高的缺点。他接着说,对于某些类型的外推API,它们实现了明确的命令- 查询分离,无需进行细粒度的错误处理。这与上一节的思想相同。

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

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

以“清理栈”为目的的展开会增加每个函数的成本,这就相当于在每个函数中检查错误条件。这是一种非常糟糕的错误处理方式;一个更好的方法是记住发生了错误,尽快换成有效的数据。 就是将“战术”的错误处理(只需要确保你的程序最终处于一个安全一致的状态)和“战略”的错误处理(通常是在应用中一个非常高的层次上,可能涉及用户交互)分开,尽量使大部分的中间层不知道二者的存在。 总的来说,我认为这是一种良好的实践,这主要是因为,立即升级错误条件不仅会使控制流难以理解,而且还会带来糟糕的用户体验。中断的 P4 连接、大型目录的副本等就是例子。每个问题都询问是糟糕的设计,但它反映了应用程序对每个错误代码做出反应的底层模型。除非没有办法继续下去了,否则就把出错的地方记下来,最后给我看一看。这不仅可以提供更好的用户体验,而且如果从一开始就设计好的话,也会很容易。

总结

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

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

程序员 Bug 和真正的 Bug 之间的界限很难划分:Java 有检查异常,但是也引入了RuntimeException来处理索引超出范围、非法参数等等。对于严重的 Bug,Go 和 Rust 都有单独的panic语句。

不出所料,错误和常规返回值之间的界线已经绊倒了许多人。C#和 Java 使用异常来表示一个整数不能被解析!那些“令人烦恼的异常”本不应该是异常。

要达到 Joe Duffys 的所有标准相当困难。Erlang 具备可用性、可靠性、并发性、可诊断性、可组合性,但是与其他替代方案相比,它速度较慢。C 和 Go 都具备可靠性、并发性,但不符合可用性标准:很容易忽略返回值。至于可组合性:许多语言都引入了特殊的语法形式来处理错误,但令人惊讶的是,对于粘接错误模式,竟然有那么多语言仅使用返回值就可以实现。C++ 异常不满足可诊断性标准(没有堆栈跟踪)和可用性标准(很容易编写出不处理应该处理的异常的代码)。

原文链接:

https://dannas.name/error-handling

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/9oeMLSj4zMCR4Xg3KFvh
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券