大家好,我是煎鱼。
Go 这一门编程语音的 if err != nil
错误处理机制一直饱受争议。既然争议那么多,想必有很多人想出各种奇思妙计,试图解决这个问题。
其中一个办法是使用 panic-recover 来替代 if err != nil
来做错误处理机制。
对于 Go 应用来讲,这种方式也是有一定的损耗。今天结合在网上看到的 The cost of Go’s panic and recover 和大家一起来看看。
Effective Java 例子
原作者 @Julien Cretel 是在阅读经典作品《Effective Java》中学习时,看到 Java 异常进行控制流的示例:
try {
int i = 0;
while (true)
range[i++].climb();
} catch (ArrayIndexOutOfBoundsException e) {
}
该程序的变量 i 最终会递增到数组的长度,此时如果试图访问索引 i 处的数组,就会引发 ArrayIndexOutOfBoundsException
异常,该异常会被捕获并立即忽略。
使用 panic/recover 做控制流
很多 Java、PHP 等其他很多编程语言转过来的同学,第一次接触 Go 时,会试图像上述例子去寻找能做一定的 try-catch 错误处理机制的例子。
而在 Go 这一门编程语言中,能做到的类似模式的只有 panic-recover 的机制,因此可能产生了一些滥用的情况。
下述是模拟 Java try-catch 示例粗略翻译成 Go panic-recover 示例的演示,以此验证性能基准的测试。
1、主程序:
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) {
deferfunc() {
recover()
}()
for i := 0; ; i++ {
mountains[i].Climb()
}
}
func ClimbAll(mountains []Mountain) {
for i := range mountains {
mountains[i].Climb()
}
}
2、基准测试:
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) {
b.ReportAllocs()
for b.Loop() {
climbAll(ns)
}
}
desc := fmt.Sprintf("impl=%s/size=%d", impl, len(ns))
b.Run(desc, f)
}
}
测试结果
从测试结果可以得知,即使是在处理小规模输入切片时,ClimbAllPanicRecover 的性能也明显劣于 ClimbAll 的实现。
如下测试报告:
➜ demo1 benchstat -col '/impl@(idiomatic panic-recover)' results.txt
goos: darwin
goarch: arm64
pkg: example.com/greet
cpu: Apple M3 Pro
│ idiomatic │ panic-recover │
│ sec/op │ sec/op vs base │
ClimbAll/size=0-11 1.046n ± 32% 94.320n ± 0% +8921.52% (p=0.000 n=10)
ClimbAll/size=1-11 1.612n ± 20% 94.400n ± 0% +5754.26% (p=0.000 n=10)
ClimbAll/size=10-11 4.202n ± 6% 97.565n ± 0% +2221.87% (p=0.000 n=10)
ClimbAll/size=100-11 26.69n ± 0% 120.20n ± 0% +350.36% (p=0.000 n=10)
ClimbAll/size=1000-11 255.0n ± 0% 354.8n ± 2% +39.14% (p=0.000 n=10)
ClimbAll/size=10000-11 2.479µ ± 0% 2.595µ ± 0% +4.68% (p=0.000 n=10)
ClimbAll/size=100000-11 24.72µ ± 0% 24.87µ ± 0% +0.61% (p=0.001 n=10)
geomean 60.46n 422.2n +598.25%
│ idiomatic │ panic-recover │
│ B/op │ B/op vs base │
ClimbAll/size=0-11 0.00 ± 0% 24.00 ± 0% ? (p=0.000 n=10)
ClimbAll/size=1-11 0.00 ± 0% 24.00 ± 0% ? (p=0.000 n=10)
ClimbAll/size=10-11 0.00 ± 0% 24.00 ± 0% ? (p=0.000 n=10)
ClimbAll/size=100-11 0.00 ± 0% 24.00 ± 0% ? (p=0.000 n=10)
ClimbAll/size=1000-11 0.00 ± 0% 24.00 ± 0% ? (p=0.000 n=10)
ClimbAll/size=10000-11 0.00 ± 0% 24.00 ± 0% ? (p=0.000 n=10)
ClimbAll/size=100000-11 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-11 0.000 ± 0% 1.000 ± 0% ? (p=0.000 n=10)
ClimbAll/size=1-11 0.000 ± 0% 1.000 ± 0% ? (p=0.000 n=10)
ClimbAll/size=10-11 0.000 ± 0% 1.000 ± 0% ? (p=0.000 n=10)
ClimbAll/size=100-11 0.000 ± 0% 1.000 ± 0% ? (p=0.000 n=10)
ClimbAll/size=1000-11 0.000 ± 0% 1.000 ± 0% ? (p=0.000 n=10)
ClimbAll/size=10000-11 0.000 ± 0% 1.000 ± 0% ? (p=0.000 n=10)
ClimbAll/size=100000-11 0.000 ± 0% 1.000 ± 0% ? (p=0.000 n=10)
geomean ¹ 1.000 ?
¹ summaries must be >0 to compute geomean
具体差异体现在:
执行效率差距:在小数据集场景下,panic/recover 的操作成本占据主导地位,导致 ClimbAllPanicRecover 运行迟缓。在 64 位系统中,每次调用会产生 24 字节的堆内存分配(推测源于运行时触发的 runtime.boundsError 边界错误)。
内存管理差异:标准实现 ClimbAll 完全避免了内存分配,因此不会对垃圾回收机制造成额外压力。
性能趋势变化:随着输入切片长度的增加,两种实现的性能差异逐渐缩小。这是因为 panic-recover 的固定开销被更大的计算量所稀释,不再成为主要性能瓶颈。
为什么 Go 不支持 try-catch
Go 官方早在《Error Handling — Problem Overview[1]》提案早已明确提过,Go 官方在设计上会有意识地选择使用显式错误结果和显式错误检查。
结合《language: Go 2: error handling meta issue[2]》可得知,要拒绝 try-catch 关键字的主要原因是:
会涉及到额外的流程控制,因为使用 try 的复杂表达式,会导致函数意外返回。
在表达式层面上没有流程控制结构,只有 panic 关键字,它不只是从一个函数返回。
说白了,就是设计理念不合,加之实现上也不大合理。在以往的多轮讨论中早已被 Go 团队拒绝了。
反之 Go 团队倒是一遍遍在回答这个问题,已经不大耐烦了,直接都整理了 issues 版的 FAQ 了。

总结
对于 Go 这一门编程语言来讲,if err != nil
是其提供的最基本的错误处理机制。
虽然很多开发同学略感不适,但官方依然是建议在其基础上做设计模式的设计和调整。这是较为推荐的。
在很多妙计中,也有像本文使用 panic-recover 的方式。但通过实际测试来讲,是会明确影响性能的。
官方也明确过不推荐该类错误处理机制的方式。
见仁见智了。
参考资料
[1]
Error Handling — Problem Overview: https://go.googlesource.com/proposal/+/master/design/go2draft-error-handling-overview.md
[2]language: Go 2: error handling meta issue: https://github.com/golang/go/issues/40432
关注和加煎鱼微信,
一手消息和知识,拉你进技术交流群👇
你好,我是煎鱼,出版过 Go 畅销书《Go 语言编程之旅》,再到获得 GOP(Go 领域最有观点专家)荣誉,点击蓝字查看我的出书之路。
日常分享高质量文章,输出 Go 面试、工作经验、架构设计,加微信拉读者交流群,和大家交流!