Go语言中panic与recover的性能代价
TL;DR
Josh Bloch的《Effective Java》中的某些智慧同样适用于Go语言。panic和recover最好保留给异常情况。依赖panic和recover会显著减慢执行速度、导致堆分配,并阻止内联优化。通过panic和recover内部处理失败情况是可接受的,有时甚至是有益的。
滥用Java异常进行控制流
尽管我的Java时代早已过去,Go已成为我偏爱的语言,但我仍会偶尔重温Joshua Bloch的开创性获奖著作《Effective Java》,并总能从中重新发现智慧 nuggets。在第三版的第69条(标题为“仅在异常情况下使用异常”)中,Bloch展示了一个滥用Java异常进行控制流的例子。我犹豫在此完整引用该节内容,以免遭到Bloch出版公司的版权打击,但该节——事实上整本书——都值得一读。
Bloch以以下代码片段开头,展示了一种相当奇特的方式来迭代某个Mountain类对象的数组(名为range),以调用它们的climb方法:
|
|
注意变量i最终会递增到数组的长度,此时尝试访问索引i处的数组会引发ArrayIndexOutOfBoundsException,该异常被捕获并立即忽略。当然,功能等效但更清晰、更符合习惯的方法是依赖“for-each”循环,这本身相当于经典的三子句循环:
|
|
Bloch耐心地解释了为什么一些误入歧途的实践者可能更喜欢基于异常的方法而不是更符合习惯的方法:他们不仅认为终止测试(i < range.length)成本高昂,而且认为它是多余的。为什么?因为他们相信Java编译器为每次数组访问(range[i])引入了边界检查。如果内存安全通过这些系统性边界检查得到保证,他们推理,为什么还要费心检查索引变量是否越界?
Bloch然后通过三个反驳论点揭穿了这个理论:
- 因为异常是为异常情况设计的,JVM实现者几乎没有动力使它们像显式测试一样快。
- 将代码放在try-catch块中会抑制JVM实现可能执行的某些优化。
- 遍历数组的标准习惯用法不一定会导致冗余检查。许多JVM实现会优化它们。
接着是这个经验观察:
[…] 基于异常的习语远比标准习语慢。在我的机器上,对于一百个元素的数组,基于异常的习语比标准习语慢大约两倍。
这与Go有何关系?
Go的设计者故意避开了为语言配备像Java那样的异常系统:
我们相信将异常与控制结构耦合,如try-catch-finally习语,会导致代码复杂化。它还倾向于鼓励程序员将太多普通错误(如打开文件失败)标记为异常。
Go采取了不同的方法。对于简单的错误处理,Go的多值返回使得报告错误而不重载返回值变得容易。规范的错误类型,加上Go的其他特性,使错误处理愉快但与其他语言截然不同。
Go还有几个内置函数来发出信号并从真正的异常情况中恢复。恢复机制仅作为函数状态在错误后拆除的一部分执行,这足以处理灾难但不需要额外的控制结构,并且如果使用得当,可以产生干净的错误处理代码。
然而,一些Go新手可能,至少最初,难以采用语言的习惯用法,即将预期的失败情况作为值而不是异常进行通信;他们可能 tempted 滥用Go的内置panic和recover函数来通信甚至良性的失败情况。
Go的生态系统(语言、编译器、运行时等)可能与Java的截然不同,但将Bloch的实验从Java转换到Go仍然是一种有教育意义和有趣的方式,来讨论panic和recover的代价,并 perhaps 抑制新手在程序中过度依赖该机制的冲动。
滥用Go的panic/recover进行控制流
在本文的其余部分,我将假设使用Go 1.24,包括语言语义和Go编译器(gc):
|
|
大致翻译成Go并塑造成一个自包含的包,Bloch的代码片段变成以下程序(可在GitHub上获得):
|
|
(playground)
如其名称所示,函数ClimbAllPanicRecover滥用panic和recover来迭代输入切片,而函数ClimbAll代表更符合习惯的参考实现。
Bloch从未揭示他的Mountain类由什么组成或其climb方法做什么。为了防止编译器进行任何死代码消除,我选择让我的(*Mountain).Climb方法改变其接收者的climbed字段。
panic和recover的开销不可忽略
以下是一些将ClimbAllPanicRecover与ClimbAll对比的基准测试:
|
|
(顺便说一句,如果您还不熟悉新的(*testing.B).Loop方法,请查看Go 1.24发布说明。)
让我们在一个相对空闲的机器上运行这些基准测试,并将结果输入benchstat:
|
|
结果不言自明:对于足够小的输入切片,ClimbAllPanicRecover与ClimbAll相比缓慢得令人难以忍受,panic和recover的成本明显主导了执行时间。这一观察呼应了Bloch的第一个反驳论点:panic和recover,因为它们的使用意图是真正的异常情况,没有理由特别快。
此外,每次调用ClimbAllPanicRecover都会导致24字节的分配(至少在我的64位系统上);尽管细节很少,但这种堆分配可归因于runtime.boundsError,当变量i的值达到len(mountains)时,Go运行时最终会 panic。相比之下,ClimbAll从不分配,因此不会对垃圾收集器施加任何不必要的压力。
只有当输入切片的长度增加时,两种实现之间的性能差距才会缩小,因为panic和recover的成本 effectively 淹没在其余工作负载中。
Recover阻止内联
在这个阶段,敏锐的读者可能会 suggest,ClimbAllPanicRecover的劣势可以至少部分地通过内联来解释。内联是一种编译器策略,可以粗略地描述为“用函数体替换函数调用”。在许多情况下,内联会导致执行加速。
然而,包含defer语句的函数不能被内联,包含recover调用的函数也不能被内联。因此,与ClimbAll相反,ClimbAllPanicRecover和它延迟调用的匿名函数都不能被内联。在构建我们的程序时,对编译器做出的优化决策的仔细检查证实了这一点:
|
|
这一观察呼应了Bloch的第二个反驳论点:依赖panic和recover会抑制Go编译器可能执行的某些优化。
然而,缺乏内联是ClimbAllPanicRecover性能不佳的原因吗?显然不是:我通过在其上添加go:noinline指令选择性地禁用了ClimbAll的内联,并重新运行了基准测试,但发现ClimbAll仍然在除大输入切片外的所有情况下 vastly 优于ClimbAllPanicRecover。
然而,请记住,在更现实的场景中,无法内联给定函数可能会显著损害性能。
非习惯用法实现没有边界检查消除
像Java一样,Go被认为是内存安全的;特别是,根据语言规范,如果切片索引操作 ever 越界,实现必须触发运行时 panic。这样的边界检查相对便宜,但它们不是免费的。当编译器可以证明某些切片访问不能越界时,它可能会为了更好的性能从结果可执行文件中省略相应的边界检查。此外,存在先进的编程技术来 gently 推动编译器进行更多的边界检查消除。
在我们的小程序的特定情况下,编译器可以消除ClimbAll循环中的边界检查,但不能消除ClimbAllPanicRecover中的:
|
|
这一观察呼应了Bloch的第三个反驳论点:习惯用法更有利于边界检查消除。
内部处理失败情况呢?
在这个阶段,我的 facetious 例子可能已经说服您,滥用panic和recover进行控制流不仅不符合习惯,而且对性能有害。
不过,更严肃地说,您可能会遇到依赖panic和recover处理内部失败情况的开源项目。事实上,不用看远处,标准库中就有:这种风格在诸如text/template、encoding/json、encoding/gob和regexp/syntax等包中 fully 展示。
便利性似乎是主要动机。确实,当调用堆栈很深(可能由于 numerous 递归调用)时,依赖panic和recover避免了大量样板代码的需要;错误处理逻辑可以集中在堆栈的更上方,在 panic 恢复点,并且快乐路径可以保持焦点。
然而,不应过于 indiscriminately 恢复 panics;如果recover调用无意中吞没了 panic,触发 panic 的 bug 将保持 masked:
|
|
(playground)
但另一种更令人惊讶的采用这种风格的动机是……性能!例如,Max Hoffman和Raphael Poss分别报告了由于这种风格而带来的令人印象深刻的加速(至少在他们程序的快乐路径上)。解释包括:
- 减少了对中间函数结果的需求,以及
- 相对较少的代码分支,因此分支预测错误的机会更少。
因此,似乎panic和recover在至少某些情况下可能对性能有益。
您应该尝试模仿这种风格吗?由您决定。然而,如果您走这条路,请用 clarifying 注释和 perhaps 一些基准测试结果来证明您的设计决策是合理的;如果您不能提供这样的理由,您可能太聪明了。此外,确保将此设计决策作为包的实现细节;不要让应该保持内部的 panics 泄漏通过包的API,因为您的客户端 then 遗憾地被迫处理它们。
致谢
感谢Gophers Slack工作区中潜伏在#performance频道的成员进行了一次有启发性的讨论,这 feed 入了本文。