Go语言中panic与recover的性能代价
TL;DR
Josh Bloch的《Effective Java》中的某些智慧同样适用于Go语言。panic和recover最好保留给异常情况使用。依赖panic和recover会显著减慢执行速度、导致堆分配,并阻止内联优化。但在内部处理失败情况时,使用panic和recover是可以接受的,有时甚至是有益的。
滥用Java异常进行控制流
尽管我的Java时代早已过去,Go语言已成为我的首选语言一段时间,但我仍会偶尔重温Joshua Bloch的开创性获奖著作《Effective Java》,并总能从中重新发现智慧的金块。
在该书第三版的第69条(标题为"仅在异常情况下使用异常")中,Bloch展示了一个滥用Java异常进行控制流的例子。我犹豫在此完整引用该部分内容,担心Bloch的出版公司会提出版权索赔,但这部分内容——事实上整本书——都非常值得一读。
Bloch以以下代码片段开场,展示了一种相当特殊的方式来迭代某个Mountain类对象的数组(名为range),以调用它们的climb方法:
1
2
3
4
5
6
|
try {
int i = 0;
while (true)
range[i++].climb();
} catch (ArrayIndexOutOfBoundsException e) {
}
|
注意变量i最终会递增到数组的长度,此时尝试访问索引i处的数组会引发ArrayIndexOutOfBoundsException,该异常被捕获并立即忽略。
当然,功能等效但更清晰、更符合习惯的方法是依赖"for-each"循环,这本身相当于经典的三子句循环:
1
2
3
|
for (int i = 0; i < range.length; i++) {
range[i].climb();
}
|
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的代价,并可能抑制新手在程序中不当依赖该机制的冲动。
滥用Go的panic/recover进行控制流
在本文的其余部分,我将假设使用Go 1.24,包括语言语义和Go编译器(gc):
1
2
|
$ go version
go version go1.24.0 darwin/amd64
|
大致翻译成Go并塑造成一个自包含的包,Bloch的代码片段变成以下程序(可在GitHub上获得):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
package main
type Mountain struct{
climbed bool
}
func (m *Mountain) Climb() {
m.climbed = true
}
func main() {
mountains := make([]Mountain, 8)
ClimbAllPanicRecover(mountains)
}
func ClimbAllPanicRecover(mountains []Mountain) {
defer func() {
recover()
}()
for i := 0; ; i++ {
mountains[i].Climb() // panics when i == len(mountains)
}
}
func ClimbAll(mountains []Mountain) {
for i := range mountains {
mountains[i].Climb()
}
}
|
(playground)
如其名称所示,函数ClimbAllPanicRecover滥用panic和recover来迭代输入切片,而函数ClimbAll代表更符合习惯的参考实现。
Bloch从未透露他的Mountain类由什么组成或其climb方法做什么。为了防止编译器进行任何死代码消除,我选择让我的(*Mountain).Climb方法改变其接收者的climbed字段。
panic和recover的开销不可忽视
以下是一些将ClimbAllPanicRecover与ClimbAll进行对比的基准测试:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
package main
import (
"fmt"
"testing"
)
var cases [][]Mountain
func init() {
for _, size := range []int{0, 1, 1e1, 1e2, 1e3, 1e4, 1e5} {
s := make([]Mountain, size)
cases = append(cases, s)
}
}
func BenchmarkClimbAll(b *testing.B) {
benchmark(b, "idiomatic", ClimbAll)
benchmark(b, "panic-recover", ClimbAllPanicRecover)
}
func benchmark(b *testing.B, impl string, climbAll func([]Mountain)) {
for _, ns := range cases {
f := func(b *testing.B) {
for b.Loop() {
climbAll(ns)
}
}
desc := fmt.Sprintf("impl=%s/size=%d", impl, len(ns))
b.Run(desc, f)
}
}
|
(顺便说一句,如果你还不熟悉新的(*testing.B).Loop方法,请查看Go 1.24发布说明。)
让我们在一个相对空闲的机器上运行这些基准测试,并将结果输入benchstat:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
$ go test -run '^$' -bench . -count 10 -benchmem > results.txt
$ benchstat -col '/impl@(idiomatic panic-recover)' results.txt
goos: darwin
goarch: amd64
pkg: github.com/jub0bs/panicabused
cpu: Intel(R) Core(TM) i7-6700HQ CPU @ 2.60GHz
│ idiomatic │ panic-recover │
│ sec/op │ sec/op vs base │
ClimbAll/size=0-8 2.239n ± 8% 193.900n ± 1% +8560.12% (p=0.000 n=10)
ClimbAll/size=1-8 2.638n ± 1% 196.400n ± 2% +7346.45% (p=0.000 n=10)
ClimbAll/size=10-8 5.424n ± 1% 199.300n ± 2% +3574.41% (p=0.000 n=10)
ClimbAll/size=100-8 44.69n ± 1% 238.65n ± 4% +434.01% (p=0.000 n=10)
ClimbAll/size=1000-8 371.6n ± 0% 565.8n ± 1% +52.27% (p=0.000 n=10)
ClimbAll/size=10000-8 3.646µ ± 1% 3.906µ ± 0% +7.15% (p=0.000 n=10)
ClimbAll/size=100000-8 36.27µ ± 0% 36.54µ ± 1% +0.73% (p=0.000 n=10)
geomean 95.10n 759.9n +699.03%
│ idiomatic │ panic-recover │
│ B/op │ B/op vs base │
ClimbAll/size=0-8 0.00 ± 0% 24.00 ± 0% ? (p=0.000 n=10)
ClimbAll/size=1-8 0.00 ± 0% 24.00 ± 0% ? (p=0.000 n=10)
ClimbAll/size=10-8 0.00 ± 0% 24.00 ± 0% ? (p=0.000 n=10)
ClimbAll/size=100-8 0.00 ± 0% 24.00 ± 0% ? (p=0.000 n=10)
ClimbAll/size=1000-8 0.00 ± 0% 24.00 ± 0% ? (p=0.000 n=10)
ClimbAll/size=10000-8 0.00 ± 0% 24.00 ± 0% ? (p=0.000 n=10)
ClimbAll/size=100000-8 0.00 ± 0% 24.00 ± 0% ? (p=0.000 n=10)
geomean ¹ 24.00 ?
¹ summaries must be >0 to compute geomean
│ idiomatic │ panic-recover │
│ allocs/op │ allocs/op vs base │
ClimbAll/size=0-8 0.000 ± 0% 1.000 ± 0% ? (p=0.000 n=10)
ClimbAll/size=1-8 0.000 ± 0% 1.000 ± 0% ? (p=0.000 n=10)
ClimbAll/size=10-8 0.000 ± 0% 1.000 ± 0% ? (p=0.000 n=10)
ClimbAll/size=100-8 0.000 ± 0% 1.000 ± 0% ? (p=0.000 n=10)
ClimbAll/size=1000-8 0.000 ± 0% 1.000 ± 0% ? (p=0.000 n=10)
ClimbAll/size=10000-8 0.000 ± 0% 1.000 ± 0% ? (p=0.000 n=10)
ClimbAll/size=100000-8 0.000 ± 0% 1.000 ± 0% ? (p=0.000 n=10)
geomean ¹ 1.000 ?
¹ summaries must be >0 to compute geomean
|
结果不言自明:对于足够小的输入切片,ClimbAllPanicRecover与ClimbAll相比缓慢得令人痛苦,对于这些切片,panic和recover的成本明显主导了执行时间。这一观察与Bloch的第一个反驳论点相呼应:panic和recover,因为它们的使用意图是真正的异常情况,没有理由特别快。
此外,每次调用ClimbAllPanicRecover都会导致24字节的分配(至少在我的64位系统上);尽管细节很少,但这种堆分配可以归因于runtime.boundsError,当变量i的值达到len(mountains)时,Go运行时最终会用它panic。相比之下,ClimbAll从不分配,因此不会对垃圾收集器施加任何不必要的压力。
只有当输入切片的长度增加时,两种实现之间的性能差距才会缩小,因为panic和recover的成本有效地淹没在其余工作负载中。
Recover阻止内联
在这个阶段,精明的读者可能会建议,ClimbAllPanicRecover的劣势可以至少部分地通过内联来解释。内联是一种编译器策略,可以粗略地描述为"用函数体替换函数调用"。在许多情况下,内联会导致执行速度加快。
然而,包含defer语句的函数不能被内联,包含recover调用的函数也不能被内联。因此,与ClimbAll相反,ClimbAllPanicRecover和它延迟调用的匿名函数都不能被内联。在构建我们的程序时,对编译器做出的优化决策的仔细检查证实了这一点:
1
2
3
4
5
6
7
8
|
$ go build -gcflags '-m=2'
# github.com/jub0bs/panicabused
./main.go:7:6: can inline (*Mountain).Climb with cost 4 as: method(*Mountain) func() { m.climbed = true }
./main.go:17:8: cannot inline ClimbAllPanicRecover.func1: call to recover
./main.go:16:6: cannot inline ClimbAllPanicRecover: unhandled op DEFER
./main.go:11:6: can inline main with cost 66 as: func() { mountains := make([]Mountain, 8); ClimbAllPanicRecover(mountains) }
./main.go:25:6: can inline ClimbAll with cost 14 as: func([]Mountain) { for loop }
-snip-
|
这一观察与Bloch的第二个反驳论点相呼应:依赖panic和recover会抑制Go编译器可能执行的其他优化。
但是,缺乏内联是ClimbAllPanicRecover表现不佳的原因吗?显然不是:我通过在ClimbAll上添加go:noinline指令选择性地禁用了它的内联,并重新运行了基准测试,但发现ClimbAll仍然在除了大输入切片之外的所有情况下都大大优于ClimbAllPanicRecover。
然而,请记住,在更现实的场景中,无法内联给定函数可能会明显损害性能。
非习惯用法实现没有边界检查消除
像Java一样,Go被认为是内存安全的;特别是,根据语言规范,如果切片索引操作越界,实现必须触发运行时panic。这样的边界检查相对便宜,但它们不是免费的。当编译器可以证明某些切片访问不可能越界时,它可能会为了更好的性能而从结果可执行文件中省略相应的边界检查。此外,存在先进的编程技术可以温和地推动编译器进行更多的边界检查消除。
在我们的小程序的特定情况下,编译器可以消除ClimbAll循环中的边界检查,但不能消除ClimbAllPanicRecover中的:
1
2
3
|
$ go build -gcflags '-d=ssa/check_bce/debug=1'
# github.com/jub0bs/panicabused
./main.go:17:12: Found IsInBounds
|
这一观察与Bloch的第三个反驳论点相呼应:习惯用法更有利于边界检查消除。
内部处理失败情况呢?
在这个阶段,我 facetious 的例子可能已经说服你,滥用panic和recover进行控制流不仅不符合习惯,而且对性能有害。
不过,更严重的是,你可能会遇到依赖panic和recover处理内部失败情况的开源项目。事实上,不用看太远,标准库中就有:这种风格在诸如text/template、encoding/json、encoding/gob和regexp/syntax等包中充分展示。
便利性似乎是主要动机。确实,当调用堆栈很深(可能由于大量递归调用)时,依赖panic和recover避免了许多样板代码的需要;错误处理逻辑可以集中在堆栈的更上方,在panic恢复的点,并且快乐路径可以保持焦点。
然而,panic不应该太 indiscriminately 恢复;如果recover调用无意中吞没了panic,触发panic的bug将保持 masked:
1
2
3
4
5
6
7
8
|
func ClimbAllPanic(mountains []Mountain) {
defer func() {
recover()
}()
for i := 0; ; i++ {
mountains[i-1].Climb() // off-by-one error
}
}
|
(playground)
但是,这种风格的另一个更令人惊讶的动机是……性能!例如,Max Hoffman和Raphael Poss分别报告了由于这种风格而实现的令人印象深刻的速度提升(至少在他们程序的快乐路径上)。解释包括:
- 减少了中间函数结果的需求,以及
- 相对较少的代码分支,因此分支预测错误的机会更少。
因此,似乎panic和recover在至少某些情况下可能对性能有益。
你应该尝试模仿这种风格吗?由你决定。然而,如果你走这条路,请用澄清性评论和 perhaps 一些基准测试结果来证明你的设计决策是合理的;如果你不能提供这样的理由,你可能是太聪明了。此外,确保将此设计决策作为包的实现细节;不要让应该保持内部的panic泄漏通过包的API,因为你的客户端将不幸地被迫处理它们。
致谢
感谢Gophers Slack工作区中潜伏在#performance频道的成员进行的有启发性的讨论,这为本文提供了素材。