用 panic-recover 做錯誤處理?會影響 Go 程序性能的
Go 這一門編程語音的 if err != nil 錯誤處理機(jī)制一直飽受爭議。既然爭議那么多,想必有很多人想出各種奇思妙計(jì),試圖解決這個問題。
其中一個辦法是使用 panic-recover 來替代 if err != nil 來做錯誤處理機(jī)制。
對于 Go 應(yīng)用來講,這種方式也是有一定的損耗。今天結(jié)合在網(wǎng)上看到的 The cost of Go’s panic and recover 和大家一起來看看。
Effective Java 例子
原作者 @Julien Cretel 是在閱讀經(jīng)典作品《Effective Java》中學(xué)習(xí)時,看到 Java 異常進(jìn)行控制流的示例:
try {
int i = 0;
while (true)
range[i++].climb();
} catch (ArrayIndexOutOfBoundsException e) {
}
該程序的變量 i 最終會遞增到數(shù)組的長度,此時如果試圖訪問索引 i 處的數(shù)組,就會引發(fā) ArrayIndexOutOfBoundsException 異常,該異常會被捕獲并立即忽略。
使用 panic/recover 做控制流
很多 Java、PHP 等其他很多編程語言轉(zhuǎn)過來的同學(xué),第一次接觸 Go 時,會試圖像上述例子去尋找能做一定的 try-catch 錯誤處理機(jī)制的例子。
而在 Go 這一門編程語言中,能做到的類似模式的只有 panic-recover 的機(jī)制,因此可能產(chǎn)生了一些濫用的情況。
下述是模擬 Java try-catch 示例粗略翻譯成 Go panic-recover 示例的演示,以此驗(yàn)證性能基準(zhǔn)的測試。
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、基準(zhǔn)測試:
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)
}
}
測試結(jié)果
從測試結(jié)果可以得知,即使是在處理小規(guī)模輸入切片時,ClimbAllPanicRecover 的性能也明顯劣于 ClimbAll 的實(shí)現(xiàn)。
如下測試報告:
? 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 1 24.00 ?
1 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 1.000 ?
1 summaries must be >0 to compute geomean
具體差異體現(xiàn)在:
- 執(zhí)行效率差距:在小數(shù)據(jù)集場景下,panic/recover 的操作成本占據(jù)主導(dǎo)地位,導(dǎo)致 ClimbAllPanicRecover 運(yùn)行遲緩。在 64 位系統(tǒng)中,每次調(diào)用會產(chǎn)生 24 字節(jié)的堆內(nèi)存分配(推測源于運(yùn)行時觸發(fā)的 runtime.boundsError 邊界錯誤)。
- 內(nèi)存管理差異:標(biāo)準(zhǔn)實(shí)現(xiàn) ClimbAll 完全避免了內(nèi)存分配,因此不會對垃圾回收機(jī)制造成額外壓力。
- 性能趨勢變化:隨著輸入切片長度的增加,兩種實(shí)現(xiàn)的性能差異逐漸縮小。這是因?yàn)?panic-recover 的固定開銷被更大的計(jì)算量所稀釋,不再成為主要性能瓶頸。
為什么 Go 不支持 try-catch
Go 官方早在《Error Handling — Problem Overview[1]》提案早已明確提過,Go 官方在設(shè)計(jì)上會有意識地選擇使用顯式錯誤結(jié)果和顯式錯誤檢查。
結(jié)合《language: Go 2: error handling meta issue[2]》可得知,要拒絕 try-catch 關(guān)鍵字的主要原因是:
- 會涉及到額外的流程控制,因?yàn)槭褂?try 的復(fù)雜表達(dá)式,會導(dǎo)致函數(shù)意外返回。
- 在表達(dá)式層面上沒有流程控制結(jié)構(gòu),只有 panic 關(guān)鍵字,它不只是從一個函數(shù)返回。
說白了,就是設(shè)計(jì)理念不合,加之實(shí)現(xiàn)上也不大合理。在以往的多輪討論中早已被 Go 團(tuán)隊(duì)拒絕了。
反之 Go 團(tuán)隊(duì)倒是一遍遍在回答這個問題,已經(jīng)不大耐煩了,直接都整理了 issues 版的 FAQ 了。
圖片
總結(jié)
對于 Go 這一門編程語言來講,if err != nil 是其提供的最基本的錯誤處理機(jī)制。
雖然很多開發(fā)同學(xué)略感不適,但官方依然是建議在其基礎(chǔ)上做設(shè)計(jì)模式的設(shè)計(jì)和調(diào)整。這是較為推薦的。
在很多妙計(jì)中,也有像本文使用 panic-recover 的方式。但通過實(shí)際測試來講,是會明確影響性能的。
官方也明確過不推薦該類錯誤處理機(jī)制的方式。
見仁見智了。
參考資料
[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