Go 语言中 panic 和 recover 的代价:性能与设计的权衡
在 Go 语言的编程世界里,panic
和recover
是处理异常情况的重要机制。然而,它们的使用是否得当,对程序的性能和设计有着深远的影响。今天,我们就来深入探讨一下 Go 语言中panic
和recover
的代价,以及在实际编程中该如何正确使用它们。
在深入了解 Go 语言的panic
和recover
之前,让我们先回顾一下 Java 中的异常处理机制。Joshua Bloch 在他的经典著作《Effective Java》中提到,有些开发者会滥用 Java 异常来控制程序流程。比如,在遍历数组时,他们可能会这样写代码:
try {
int i = 0;
while (true)
range[i++].climb();
} catch (ArrayIndexOutOfBoundsException e) { }
这种做法利用数组越界异常来结束循环,看似巧妙,实则存在诸多问题。相比之下,使用传统的for
循环更加清晰和高效:
for (int i = 0; i < range.length; i++) {
range[i].climb();
}
Bloch 指出,一些开发者选择异常驱动的方式,是因为他们认为数组访问的边界检查成本高且多余,因为 Java 编译器会为每次数组访问引入边界检查。但实际上,这种想法是错误的。首先,异常是为处理特殊情况设计的,JVM 实现者没有动力让异常处理像显式检查那样快速;其次,将代码放在try-catch
块中会抑制 JVM 的某些优化;最后,标准的数组遍历方式不一定会导致冗余检查,很多 JVM 实现会优化掉这些检查。经测试,基于异常的遍历方式比标准方式慢很多。
Go 语言的设计者有意避免为其配备像 Java 那样的异常系统。他们认为,将异常与控制结构耦合,会导致代码复杂,还容易让开发者把普通错误(如文件打开失败)当作异常处理。在 Go 语言中,对于普通的错误处理,多值返回使得报告错误变得简单,而无需重载返回值。同时,Go 语言提供了panic
和recover
这两个内置函数,用于处理真正的异常情况。不过,对于一些刚接触 Go 语言的开发者来说,他们可能难以适应将预期的失败情况作为值返回,而不是当作异常处理的习惯,甚至可能会滥用panic
和recover
来处理一些本可以正常处理的情况。
为了更直观地了解滥用panic
和recover
的影响,我们将 Bloch 的示例代码转换为 Go 语言,并进行性能测试。假设我们有一个Mountain
结构体,其中的Climb
方法用于标记山是否被攀登过:
package main
type Mountain struct{
climbed bool
}
func (m *Mountain) Climb() {
m.climbed = true
}
我们定义了两个遍历Mountain
切片的函数,ClimbAllPanicRecover
函数滥用panic
和recover
来实现遍历:
func ClimbAllPanicRecover(mountains []Mountain) {
defer func() {
recover()
}()
for i := 0; ; i++ {
mountains[i].Climb()
}
}
而ClimbAll
函数则采用更符合 Go 语言习惯的方式:
func ClimbAll(mountains []Mountain) {
for i := range mountains {
mountains[i].Climb()
}
}
接下来,我们对这两个函数进行基准测试:
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)
}
}
在相对空闲的机器上运行这些基准测试,并使用benchstat
工具分析结果:
$ go version
go version go1.24.0 darwin/amd64
$ 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
函数慢得多,每次调用ClimbAllPanicRecover
还会分配 24 字节的内存(在 64 位系统上),而ClimbAll
函数则不会分配内存。随着输入切片长度的增加,两者的性能差距才逐渐缩小。
细心的读者可能会认为,ClimbAllPanicRecover
函数性能不佳的部分原因是内联问题。内联是一种编译器优化策略,即 “用函数体替换函数调用”,通常能提高执行速度。然而,包含defer
语句或调用recover
的函数不能被内联。通过检查编译器的优化决策可以发现,ClimbAllPanicRecover
函数及其内部的匿名函数都不能被内联,而ClimbAll
函数可以被内联。为了验证内联是否是导致性能差异的主要原因,我们对ClimbAll
函数禁用内联后重新进行基准测试,发现ClimbAll
函数在除大输入切片外的情况下,仍然比ClimbAllPanicRecover
函数性能好得多。不过,在实际场景中,无法内联某个函数可能会显著影响性能。
与 Java 类似,Go 语言也保证内存安全,当切片索引操作越界时,会触发运行时恐慌。编译器在某些情况下可以消除边界检查以提高性能。在我们的示例中,编译器可以消除ClimbAll
函数循环中的边界检查,但不能消除ClimbAllPanicRecover
函数循环中的边界检查。这表明,符合习惯用法的方式更有利于边界检查的消除。
在实际的开源项目中,我们可能会看到一些项目使用panic
和recover
来处理内部失败情况,甚至 Go 语言的标准库中也有这样的例子,如text/template
、encoding/json
、encoding/gob
和regexp/syntax
等包。这样做的主要动机似乎是为了方便,当调用栈很深(可能由于大量递归调用)时,使用panic
和recover
可以避免大量样板代码,将错误处理逻辑集中在恐慌恢复点,让正常流程更加清晰。此外,这种方式在某些情况下还能提高性能,比如减少中间函数结果的需求,减少代码分支,从而降低分支预测错误的可能性。
但是,使用panic
和recover
处理内部失败情况也存在风险。如果recover
不小心吞没了恐慌,可能会掩盖触发恐慌的错误。例如:
func ClimbAllPanic(mountains []Mountain) {
defer func() {
recover()
}()
for i := 0; ; i++ {
mountains[i-1].Climb()
}
}
在encoding/json
包的 issue 23012 中就有这样的问题示例。
如果要在项目中采用这种方式,一定要通过注释和基准测试结果来证明设计决策的合理性,并且要将其作为包的实现细节,避免内部的恐慌泄漏到包的 API 中,给调用者带来不必要的麻烦。
Go 语言中的panic
和recover
是强大的工具,但使用时需要谨慎权衡。在处理异常情况时,要根据具体场景选择合适的方式,避免滥用panic
和recover
导致性能问题和代码维护困难。希望通过今天的分享,大家对 Go 语言的异常处理机制有更深入的理解,在今后的编程中能够更加合理地运用它们。你在使用panic
和recover
时遇到过哪些有趣的问题呢?欢迎在评论区分享你的经验!
科技脉搏,每日跳动。
与敖行客 Allthinker一起,创造属于开发者的多彩世界。
- 智慧链接 思想协作 -