首發地址:https://mp.weixin.qq.com/s/FRa0A51DGQ6MiKO6PUu6wQ
Go 語言中的錯誤處理不僅僅只有 if err != nil,defer、panic 和 recover 這三個相對來説不不如 if err != nil 有名氣的控制流語句,也與錯誤處理息息相關。本文就來講解下這三者在 Go 語言中的應用。
Defer
defer 是一個 Go 中的關鍵字,通常用於簡化執行各種清理操作的函數。defer 後跟一個函數(或方法)調用,該函數(或方法)的執行會被推遲到外層函數返回的那一刻,即函數(或方法)要麼遇到了 return,要麼遇到了 panic。
語法
defer 功能使用語法如下:
defer Expression
其中 Expression 必須是函數或方法的調用。
defer 使用示例如下:
func f() {
defer fmt.Println("deferred in f")
fmt.Println("calling f")
}
func main() {
f()
}
執行示例代碼,得到輸出如下:
$ go run main.go
calling f
deferred in f
根據輸出可以發現,被 defer 修飾的 fmt.Println("deferred in f") 調用並沒有立即執行,而是先執行了 fmt.Println("calling f"),然後才會執行 defer 修飾的函數調用語句。
執行順序
一個函數中可以寫多個 defer 語句:
func f() {
defer fmt.Println("deferred in f 1")
defer fmt.Println("deferred in f 2")
defer fmt.Println("deferred in f 3")
fmt.Println("calling f")
}
執行示例代碼,得到輸出如下:
$ go run main.go
calling f
deferred in f 3
deferred in f 2
deferred in f 1
被 defer 修飾的函數調用,在外層函數返回後按後進先出順序執行,即 Last In First Out(LIFO)。
不僅如此,defer 可以寫在任意位置,並且還可以嵌套,即在被 defer 修飾的函數中再次使用 defer。
示例如下:
func f() {
fmt.Println("1")
defer func() {
fmt.Println("2")
defer fmt.Println("3")
fmt.Println("4")
}()
fmt.Println("5")
defer fmt.Println("6")
fmt.Println("7")
}
執行示例代碼,得到輸出如下:
$ go run main.go
1
5
7
6
2
4
3
這個輸出結果符合你的預期嗎?
先看外層函數 f 的代碼邏輯,有兩個 defer 語句,無論位置在哪,defer 都會使函數調用延遲執行,所以先輸出了 1、5、7。
然後根據 LIFO 原則,先執行第 2 個 defer 語句所修飾的函數調用,所以輸出 6。
接着執行第 1 個 defer 語句所修飾的函數調用,其內部同樣會按順序執行沒有被 defer 語句修飾的代碼,所以先輸出 2、4,然後執行 defer 語句所修飾的函數調用,輸出 3。
讀寫函數返回值
有時候,我們可以使用 defer 語句來讀取或修改函數的返回值。
有如下示例,試圖在 defer 中修改函數的返回值:
func f() int {
r := 2
defer func() {
fmt.Println("r:", r)
r *= 3
}()
return r
}
func main() {
fmt.Println(f())
}
執行示例代碼,得到輸出如下:
$ go run main.go
r: 2
2
看來沒有成功。
函數使用具名返回值再來看看:
func f() (r int) {
r = 2
defer func() {
fmt.Println("r:", r)
r *= 3
}()
return r
}
執行示例代碼,得到輸出如下:
$ go run main.go
r: 2
6
這次成功了。
如果改成這樣呢:
func f() (r int) {
defer func() {
fmt.Println("r:", r)
r *= 3
}()
return 2
}
現在,返回值直接寫成了 2,而非變量 r。
執行示例代碼,得到輸出如下:
$ go run main.go
r: 2
6
這次返回值依然修改成功了。
前面幾個示例,其實都算使用了閉包。因為被 defer 修飾的函數內部都引用了外部變量 r。
我們再看一個不使用閉包的示例:
func f() (r int) {
defer func(r int) {
fmt.Println("r:", r)
r *= 3
}(r)
return 2
}
執行示例代碼,得到輸出如下:
$ go run main.go
r: 0
2
這次返回值沒有修改成功,並且被 defer 修飾的函數內部讀到的 r 值為 0,並不是前面示例中的 2。
也就是説,實際上雖然被 defer 修飾的函數調用會延遲執行,但是我們傳遞給函數的參數,會被立即求值。
我們接着看下面這個示例:
func f() (r int) {
x := 2
defer func() {
fmt.Println("r:", r)
fmt.Println("x:", x)
x *= 3
}()
return x
}
執行示例代碼,得到輸出如下:
$ go run main.go
r: 2
x: 2
2
當代碼執行到 return x 時,r 值也會被賦值為 2,這沒什麼好解釋的。
然後在 defer 所修飾的函數內部,我們只修改了 x 變量,這對返回結果 r 沒有影響。
把函數返回值類型改成指針試試呢:
func f() (r *int) {
x := 2
defer func() {
fmt.Println("r:", *r)
fmt.Println("x:", x)
x *= 3
}()
return &x
}
func main() {
fmt.Println(*f())
}
執行示例代碼,得到輸出如下:
$ go run main.go
r: 2
x: 2
6
這次返回值又成功被修改了。
看到這裏,你是不是對 defer 語句的效果有點懵,沒關係,我們再來梳理下 defer 執行時機。
defer 語句的行為其實是可預測的,我們可以記住這三條規則:
- 在計算
defer語句時,將立即計算被defer修飾的函數參數。 - 被
defer修飾的函數,在外層函數返回後按後進先出的順序(LIFO)執行。 - 延遲函數可以讀取或賦值給外層函數的具名返回值。
現在,你再翻回去重新看看上面的幾個示例程序,是不是都能理解了呢?
釋放資源
defer 還常被用來釋放資源,比如關閉文件對象。
這裏有個示例程序,可以將一個文件內容複製到另外一個文件中:
func CopyFile(dstName, srcName string) (written int64, err error) {
src, err := os.Open(srcName)
if err != nil {
return
}
dst, err := os.Create(dstName)
if err != nil {
return
}
written, err = io.Copy(dst, src)
dst.Close()
src.Close()
return
}
不過這個程序存在 bug,如果 os.Create 執行失敗,函數返回後 src 並沒有被關閉。
而這種場景剛好適用 defer,示例如下:
func CopyFile(dstName, srcName string) (written int64, err error) {
src, err := os.Open(srcName)
if err != nil {
return
}
defer src.Close()
dst, err := os.Create(dstName)
if err != nil {
return
}
defer dst.Close()
return io.Copy(dst, src)
}
此時如果 os.Create 執行失敗,函數返回後 defer src.Close() 將會被執行,文件資源得以釋放。
切記,不要在 if err != nil 之前調用 defer 釋放資源,這很可能會觸發 panic。
src, err := os.Open(srcName)
defer src.Close()
if err != nil {
return
}
因為,如果調用 os.Open 報錯,src 值將為 nil,而 nil.Close() 會觸發 panic,導致程序意外終止而退出。
此外,在處理釋放資源的情況,你可能寫出如下代碼:
type fakeFile struct {
name string
}
func (f *fakeFile) Close() error {
fmt.Println("close:", f)
return nil
}
// 錯誤寫法:f 變量的值最終是 f2,所以 f2 會被關閉兩次,f1 沒關閉
func processFile() {
f := fakeFile{name: "f1"}
defer f.Close()
f = fakeFile{name: "f2"}
defer f.Close()
fmt.Println("calling processFile")
return
}
func main() {
processFile()
}
執行示例代碼,得到輸出如下:
$ go run main.go
calling processFile
close: &{f2}
close: &{f2}
可以發現,在函數 processFile 中,因為 f 被重複賦值,導致 f 變量的值最終是 f2,所以 f2 會被關閉兩次,f1 並沒有被關閉。
還記得我們前面講過的規則嗎:在計算 defer 語句時,將立即計算被 defer 修飾的函數參數。
所以,我們可以在 defer 處讓變量 f 先被計算出來:
func processFile1() {
f := fakeFile{name: "f1"}
defer func(f fakeFile) {
f.Close()
}(f)
f = fakeFile{name: "f2"}
defer func(f fakeFile) {
f.Close()
}(f)
fmt.Println("calling processFile1")
return
}
這樣就解決了問題。
當然,更簡單的方式是我們壓根就不要使用同一個變量來表示不同的文件對象:
func processFile2() {
f1 := fakeFile{name: "f1"}
defer f1.Close()
f2 := fakeFile{name: "f2"}
defer f2.Close()
fmt.Println("calling processFile2")
return
}
不過,有時候在在 for 循環中,就是會出現 f 被重複賦值的情況,在 for 循環中使用 defer 語句,我們可能還會踩到類似的坑,所以你一定要小心。
WithClose
文章讀到這裏,想必你也看出來了,defer 功能正是對標了 Python 中的 try...finally 或者 with 語句的效果。
Python 的 with 語法非常優雅,如何使用 defer 實現近似效果呢?
你可以在我的另一篇文章《在 Go 中如何實現類似 Python 中的 with 上下文管理器》中找到答案。
篇幅所限,我就不在這裏再廢話連篇的講一遍了。
如果你想用下面這種單獨的代碼塊作用域來實現:
func f() {
{
// defer 函數一定是在函數退出時才會執行,而不是代碼塊退出時執行
defer fmt.Println("defer done")
fmt.Println("code block")
}
fmt.Println("calling f")
}
很遺憾的告訴你,這並不能達到想要的效果,你可以思考後再點擊我的另一篇文章來對比下你我二人的實現是否相同。
結構體方法是否使用指針接收者
當結構體方法使用指針作為接收者時,也要小心。
示例如下:
type User struct {
name string
}
func (u User) Name() {
fmt.Println("Name:", u.name)
}
func (u *User) PointName() {
fmt.Println("PointName:", u.name)
}
func printUser() {
u := User{name: "user1"}
defer u.Name()
defer u.PointName()
u.name = "user2"
}
func main() {
printUser()
}
執行示例代碼,得到輸出如下:
$ go run main.go
PointName: user2
Name: user1
User.Name 方法接收者為結構體,在 defer 中被調用,最終輸出結果為初始 name 值 user1。
User.PointName 方法接收者為指針,在 defer 中被調用,最終輸出結果為修改後的 name 值 user2。
可見,defer 處不僅會計算函數參數,其實它會對其後面的表達式求值,並計算出最終將要執行的函數或方法。
也就是説,代碼執行到 defer u.Name() 時,變量 u 的值就已經計算出來了,相當於“複製”了一個新的變量,後面再通過 u.name = "user2" 修改其屬性,二者已經不是同一個變量了。
而代碼執行到 defer u.PointName() 時,其實這裏的 u 是指針類型,即使“複製”了一個新的變量,其內部保存的指針依然相等,所以可以被修改。
如果將代碼修改成如下這樣,執行結果又會怎樣呢?
func printUser() {
u := User{name: "user1"}
defer func() {
u.Name()
u.PointName()
}()
u.name = "user2"
}
這個就交給你自己去實驗了。
當 defer 遇到 os.Exit
當 defer 遇到 os.Exit 時會怎樣呢?
func f() {
defer fmt.Println("deferred in f")
fmt.Println("calling f")
os.Exit(0)
}
func main() {
f()
}
執行示例代碼,得到輸出如下:
$ go run main.go
calling f
可見,當遇到 os.Exit 時,程序直接退出,defer 並不會被執行,這一點平時開發過程中要格外注意。
一個過時的面試題
前幾年,有一個考察 defer 的面試題經常在網上出現:
func f() {
for i := 0; i < 3; i++ {
defer func() {
fmt.Println(i)
}()
}
}
問執行 f 以後,輸出什麼?
既然會成為面試題,執行結果就肯定有貓膩。
如果你使用 Go 1.22 以前的版本執行示例代碼,將得到如下結果:
$ go run main.go
3
3
3
而如果你使用 Go 1.22 及以後的版本執行示例代碼,將得到如下結果:
$ go run main.go
2
1
0
這是由於,在 Go 1.22 以前,由 for 循環聲明的變量只會被創建一次,並在每次迭代時更新。在 Go 1.22 中,循環的每次迭代都會創建新的變量,以避免意外的共享錯誤。
這在 Go 1.22 Release Notes 中有説明。
在舊版本的 Go 中要修復這個問題,只需要這樣寫即可:
func f() {
for i := 0; i < 3; i++ {
defer fmt.Println(i)
}
}
直接把 defer 放在外面,不要構成閉包。
又或者為 defer 函數增加參數:
func f() {
for i := 0; i < 3; i++ {
defer func(i int) {
fmt.Println(i)
}(i)
}
}
總之,解決方案就是不要出現閉包。
不要出現 defer nil 的情況
前文説過,defer 後面支持函數或方法的調用。
但是,如果計算 defer 後的表達式出現 nil 的情況,則會觸發 panic。
func deferNil() {
var f func()
defer f()
fmt.Println("calling deferNil")
}
func main() {
deferNil()
}
執行示例代碼,得到輸出如下:
calling deferNil
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x2 addr=0x0 pc=0x10264f88c]
goroutine 1 [running]:
main.deferNil()
/go/blog-go-example/error/defer-panic-recover/defer/main.go:363 +0x6c
main.main()
/go/blog-go-example/error/defer-panic-recover/defer/main.go:384 +0x1c
exit status 2
因為 nil 不可被調用。
至於到底什麼是 panic,咱們往下看。
Panic
在 Go 中,error 表示一個錯誤,錯誤通常會返給調用方,交由調用方來決定如何處理。而 panic 則表示一個無法挽回的異常,panic 會直接終止當前執行的控制流。
panic 是一個內置函數,它會停止程序的正常控制流並輸出 panic 相關信息。
有兩種方式可以觸發 panic,一種是非法操作導致運行時錯誤,比如訪問數組索引越界,此時會觸發運行時 panic。另一種是主動調用 panic 函數。
當在函數 F 中調用了 panic 後,程序執行流程如下:
函數 F 調用 panic 時,F 的執行會被停止,接下來會執行 F 中調用 panic 之前的所有 defer 函數,然後 F 返回給調用者。
接着,對於 F 的調用方 G 的行為也類似於對 panic 的調用。
該過程繼續向上返回,直到當前 goroutine 中的所有函數都返回,此時程序崩潰。
最後,你將在執行 Go 程序的控制枱看到程序執行異常的堆棧信息。
使用
panic 使用示例如下:
func f() {
defer fmt.Println("defer 1")
fmt.Println(1)
panic("woah")
defer fmt.Println("defer 2")
fmt.Println(2)
}
func main() {
f()
}
執行示例代碼,得到輸出如下:
$ go run main.go
1
defer 1
panic: woah
goroutine 1 [running]:
main.f()
/go/blog-go-example/error/defer-panic-recover/panic/main.go:10 +0xa0
main.main()
/go/blog-go-example/error/defer-panic-recover/panic/main.go:29 +0x1c
exit status 2
可以發現,panic 會輸出異常堆棧信息。
並且 1 和 defer 1 都被輸出了,而 2 和 defer 2 沒有輸出,説明 panic 調用之後的代碼不會執行,但它不影響 panic 之前 defer 函數的執行。
此外,如果你足夠細心,還可以發現 panic 後程序的退出碼為 2。
子 Goroutine 中 panic
如果在子 goroutine 中發生 panic,也會導致主 goroutine 立即退出:
func g() {
fmt.Println("calling g")
// 子 goroutine 中發生 panic,主 goroutine 也會退出
go f(0)
fmt.Println("called g")
}
func f(i int) {
fmt.Println("panicking!")
panic(fmt.Sprintf("i=%v", i))
fmt.Println("printing in f", i) // 不會被執行
}
func main() {
g()
time.Sleep(10 * time.Second)
}
執行示例代碼,程序並不會等待 10s 後才退出,而是立即 panic 並退出,得到輸出如下:
$ go run main.go
calling g
called g
panicking!
panic: i=0
goroutine 3 [running]:
main.f(0x0)
/go/blog-go-example/error/defer-panic-recover/panic/main.go:25 +0xa0
created by main.g in goroutine 1
/go/blog-go-example/error/defer-panic-recover/panic/main.go:19 +0x5c
exit status 2
panic 和 os.Exit
雖然 panic 和 os.Exit 都能使程序終止並退出,但它們有着顯著的區別,尤其在觸發時的行為和對程序流程的影響上。
panic 用於在程序中出現異常情況時引發一個運行時錯誤,通常會導致程序崩潰(除非被 recover 恢復)。當觸發 panic 時,defer 語句仍然會執行。panic 還會打印詳細的堆棧信息,顯示引發錯誤的調用鏈。panic 退出狀態碼固定為 2。
os.Exit 會立即終止程序,並返回指定的狀態碼給操作系統。當執行 os.Exit 時,defer 語句不會執行。os.Exit 直接通知操作系統退出程序,它不會返回給調用者,也不會引發運行時堆棧追蹤,所以也就不會打印堆棧信息。os.Exit 可以設置程序退出狀態碼。
因為 panic 比較暴力,所以一般只建議在 main 函數中使用,比如應用的數據庫初始化失敗後直接 panic,因為程序無法連接數據庫,程序繼續執行意義不大。而普通函數中推薦儘量返回 error 而不是直接 panic。
不過 panic 也不是沒有挽救的餘地,recover 就是來恢復 panic 的。
Recover
recover 也是一個函數,用來從 panic 所導致的程序崩潰中恢復執行。
使用
recover 使用示例如下:
func f() {
defer func() {
recover()
}()
defer fmt.Println("defer 1")
fmt.Println(1)
panic("woah")
defer fmt.Println("defer 2")
fmt.Println(2)
}
func main() {
f()
}
執行示例代碼,得到輸出如下:
$ go run main.go
1
defer 1
recover() 的調用捕獲了 panic 觸發的異常,並且程序正常退出。
recover 函數只在 defer 語句的上下文中才有效,直接調用的話,只會返回 nil。
如下兩種方式都是錯誤的用法:
recover()
defer recover()
可見,recover 必須與 defer 一同使用,來從 panic 中恢復程序。不過 panic 之後的代碼依舊不會執行,recover() 調用後只會執行 defer 語句中的剩餘代碼。
下面這個例子將會捕獲到 panic,並且輸出 panic 信息:
func f() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recover:", r)
}
}()
panic("woah")
}
執行示例代碼,得到輸出如下:
$ go run main.go
recover: woah
可以發現,recover 函數的返回值,正是 panic 函數的參數。
不要在 defer 中出現 panic
為了避免不必要的麻煩,defer 函數中最好不要有能夠引起 panic 的代碼。
正常來説,defer 用來釋放資源,不會出現大量代碼。如果 defer 函數中邏輯過多,則需要斟酌下有沒有更優解。
如下示例將輸出什麼?
func f() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recover:", r)
}
}()
defer func() {
panic("woah 1")
}()
panic("woah 2")
}
執行示例代碼,得到輸出如下:
$ go run main.go
recover: woah 1
看來,defer 中的 panic("woah 1") 覆蓋了程序正常控制流中的 panic("woah 2")。
如果我們將代碼順序稍作修改:
func f() {
defer func() {
panic("woah 1")
}()
defer func() {
if r := recover(); r != nil {
fmt.Println("recover:", r)
}
}()
panic("woah 2")
}
執行示例代碼,得到輸出如下:
$ go run main.go
recover: woah 2
panic: woah 1
goroutine 1 [running]:
main.f.func1()
/go/blog-go-example/error/defer-panic-recover/recover/main.go:68 +0x2c
main.f()
/go/blog-go-example/error/defer-panic-recover/recover/main.go:77 +0x68
main.main()
/go/blog-go-example/error/defer-panic-recover/recover/main.go:142 +0x1c
exit status 2
看來,調用 recover 的 defer 應該放在函數的入口處,成為第一個 defer。
recover 只能捕獲當前 Goroutine 中的 panic
需要額外注意的一點是,recover 只會捕獲當前 goroutine 所觸發的 panic。
示例如下:
func f() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recover:", r)
}
}()
go func() {
panic("woah")
}()
time.Sleep(1 * time.Second)
}
執行示例代碼,得到輸出如下:
$ go run main.go
panic: woah
goroutine 18 [running]:
main.f.func2()
/go/blog-go-example/error/defer-panic-recover/recover/main.go:91 +0x2c
created by main.f in goroutine 1
/go/blog-go-example/error/defer-panic-recover/recover/main.go:90 +0x40
exit status 2
子 goroutine 中觸發的 panic 並沒有被 recover 捕獲。
所以,如果你認為代碼中需要捕獲 panic 時,就需要在每個 goroutine 中都執行 recover。
將 panic 轉換成 error 返回
有時候,我們可能需要將 panic 轉換成 error 並返回,防止當前函數調用他人提供的不可控代碼時出現意外的 panic。
func g(i int) (number int, err error) {
defer func() {
if r := recover(); r != nil {
var ok bool
err, ok = r.(error)
if !ok {
err = fmt.Errorf("f returns err: %v", r)
}
}
}()
number, err = f(i)
return number, err
}
func f(i int) (int, error) {
if i == 0 {
panic("i=0")
}
return i * i, nil
}
func main() {
fmt.Println(g(1))
fmt.Println(g(0))
}
執行示例代碼,得到輸出如下:
$ go run main.go
1 <nil>
0 f returns err: i=0
net/http 使用 recover 優雅處理 panic
我們在開發 HTTP Server 程序時,即使某個請求遇到了 panic 也不應該使整個程序退出。所以,就需要使用 recover 來處理 panic。
來看一個使用 net/http 創建的 HTTP Server 程序示例:
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func handler(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/panic" {
panic("url is error")
}
// 打印請求的路徑
fmt.Fprintf(w, "Hello, you've requested: %s\n", r.URL.Path)
}
func main() {
// 創建一個日誌實例,寫到標準輸出
logger := log.New(os.Stdout, "http: ", log.LstdFlags)
// 自定義 HTTP Server
server := &http.Server{
Addr: ":8080",
ErrorLog: logger, // 設置日誌記錄器
}
// 註冊處理函數
http.HandleFunc("/", handler)
// 啓動服務器
fmt.Println("Starting server on :8080")
if err := server.ListenAndServe(); err != nil {
logger.Println("Server failed to start:", err)
}
}
啓動示例,程序會阻塞在這裏等待請求進來:
$ go run main.go
Starting server on :8080
使用 curl 命令分別對 HTTP Server 發送三次請求:
$ curl localhost:8080
Hello, you've requested: /
$ curl localhost:8080/panic
curl: (52) Empty reply from server
$ curl localhost:8080/hello
Hello, you've requested: /hello
可以發現,在請求 /panic 路由時,HTTP Server 觸發了 panic 並返回了空內容,然後第三個請求依然能夠得到正確的響應。
可見 HTTP Server 並沒有退出。
現在回去看一下執行 HTTP Server 的控制枱日誌:
Starting server on :8080
http: 2024/10/13 23:08:28 http: panic serving [::1]:50547: url is error
goroutine 34 [running]:
net/http.(*conn).serve.func1()
/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/net/http/server.go:1947 +0xb0
panic({0x10114c000?, 0x1011a4ba8?})
/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/runtime/panic.go:785 +0x124
main.handler({0x1011a8178?, 0x140001440e0?}, 0x1400010bb28?)
/workspace/projects/go/blog-go-example/error/defer-panic-recover/recover/http/main.go:12 +0x130
net/http.HandlerFunc.ServeHTTP(0x101348320?, {0x1011a8178?, 0x140001440e0?}, 0x1010999e4?)
/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/net/http/server.go:2220 +0x38
net/http.(*ServeMux).ServeHTTP(0x0?, {0x1011a8178, 0x140001440e0}, 0x14000154140)
/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/net/http/server.go:2747 +0x1b4
net/http.serverHandler.ServeHTTP({0x1400011ade0?}, {0x1011a8178?, 0x140001440e0?}, 0x6?)
/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/net/http/server.go:3210 +0xbc
net/http.(*conn).serve(0x140000a4120, {0x1011a8678, 0x1400011acf0})
/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/net/http/server.go:2092 +0x4fc
created by net/http.(*Server).Serve in goroutine 1
/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/net/http/server.go:3360 +0x3dc
panic 信息 url is error 被輸出了,並且打印了堆棧信息。
不過這 HTTP Server 依然在運行,並能提供服務。
這其實就是在 net/http 中使用了 recover 來處理 panic。
我們可以看下 http.Server.Serve 的源碼:
func (srv *Server) Serve(l net.Listener) error {
...
ctx := context.WithValue(baseCtx, ServerContextKey, srv)
for {
rw, err := l.Accept()
if err != nil {
...
return err
}
connCtx := ctx
if cc := srv.ConnContext; cc != nil {
connCtx = cc(connCtx, rw)
if connCtx == nil {
panic("ConnContext returned nil")
}
}
tempDelay = 0
c := srv.newConn(rw)
c.setState(c.rwc, StateNew, runHooks) // before Serve can return
go c.serve(connCtx)
}
}
可以發現,在 for 循環中,每接收到一個請求都會交給 go c.serve(connCtx) 開啓一個新的 goroutine 來處理。
那麼在 serve 方法中就一定會有 recover 語句:
// Serve a new connection.
func (c *conn) serve(ctx context.Context) {
if ra := c.rwc.RemoteAddr(); ra != nil {
c.remoteAddr = ra.String()
}
ctx = context.WithValue(ctx, LocalAddrContextKey, c.rwc.LocalAddr())
var inFlightResponse *response
defer func() {
if err := recover(); err != nil && err != ErrAbortHandler {
const size = 64 << 10
buf := make([]byte, size)
buf = buf[:runtime.Stack(buf, false)]
c.server.logf("http: panic serving %v: %v\n%s", c.remoteAddr, err, buf)
}
if inFlightResponse != nil {
inFlightResponse.cancelCtx()
inFlightResponse.disableWriteContinue()
}
if !c.hijacked() {
if inFlightResponse != nil {
inFlightResponse.conn.r.abortPendingRead()
inFlightResponse.reqBody.Close()
}
c.close()
c.setState(c.rwc, StateClosed, runHooks)
}
}()
...
}
果然,在 serve 方法源碼中發現了 defer + recover 的組合。
並且這行代碼:
c.server.logf("http: panic serving %v: %v\n%s", c.remoteAddr, err, buf)
可以在執行 HTTP Server 的控制枱日誌中得到印證:
http: 2024/10/13 23:08:28 http: panic serving [::1]:50547: url is error
panic(nil)
panic 函數簽名如下:
func panic(v any)
既然 panic 參數是 any 類型,那麼 nil 當然也可以作為參數。
可以寫出 panic(nil) 程序示例代碼如下:
func f() {
defer func() {
if err := recover(); err != nil {
fmt.Println(err)
}
}()
panic(nil)
}
執行示例代碼,得到輸出如下:
$ go run main.go
panic called with nil argument
這沒什麼問題。
但是在 Go 1.21 版本以前,執行上述代碼,將得到如下結果:
$ go run main.go
你沒看錯,我也沒寫錯誤,這裏什麼都沒輸出。
在舊版本的 Go 中,panic(nil) 並不能被 recover 捕獲,recover() 調用結果將返回 nil。
你可以在 issues/25448 中找到關於此問題的討論。
幸運的是,在 Go 1.21 發佈時,這個問題得以解決。
不過,這就破壞了 Go 官方承諾的 Go1 兼容性保障。因此,Go 團隊又提供了 GODEBUG=panicnil=1 標識來恢復舊版本中的 panic 行為。
使用方式如下:
$ GODEBUG=panicnil=1 go run main.go
其實,根據 panic 聲明中的註釋我們也能夠觀察到 Go 1.21 後 panic(nil) 行為有所改變:
// Starting in Go 1.21, calling panic with a nil interface value or an
// untyped nil causes a run-time error (a different panic).
// The GODEBUG setting panicnil=1 disables the run-time error.
func panic(v any)
panic 相關源碼實現如下:
// The implementation of the predeclared function panic.
func gopanic(e any) {
if e == nil {
if debug.panicnil.Load() != 1 {
e = new(PanicNilError)
} else {
panicnil.IncNonDefault()
}
}
...
}
在沒有指定 GODEBUG=panicnil=1 情況下,panic(nil) 調用等價於 panic(new(runtime.PanicNilError))。
數據庫事務
使用 defer + recover 來處理數據庫事務,也是比較常用的做法。
這裏有一個來自 GORM 官方文檔中的 示例程序:
type Animal struct {
Name string
}
func CreateAnimals(db *gorm.DB) error {
tx := db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
if err := tx.Error; err != nil {
return err
}
if err := tx.Create(&Animal{Name: "Giraffe"}).Error; err != nil {
tx.Rollback()
return err
}
if err := tx.Create(&Animal{Name: "Lion"}).Error; err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
}
在函數最開始開啓了一個事務,接着使用 defer + recover 來確保程序執行中間過程遇到 panic 時能夠回滾事務。
程序執行過程中使用 tx.Create 創建了兩條 Animal 數據,並且如果輸出,都將回滾事務。
如果沒有錯誤,最終調用 tx.Commit() 提交事務,並將其錯誤結果返回。
這個函數實現邏輯非常嚴謹,沒什麼問題。
但是這個示例代碼寫的過於囉嗦,還有優化的空間,可以寫成這樣:
func CreateAnimals(db *gorm.DB) error {
tx := db.Begin()
defer tx.Rollback()
if err := tx.Error; err != nil {
return err
}
if err := tx.Create(&Animal{Name: "Giraffe"}).Error; err != nil {
return err
}
if err := tx.Create(&Animal{Name: "Lion"}).Error; err != nil {
return err
}
return tx.Commit().Error
}
這裏在 defer 中直接去掉了 recover 的判斷,所以無論如何程序最終都會執行 tx.Rollback()。
之所以可以這樣寫,是因為調用 tx.Commit() 時事務已經被提交成功,之後執行 tx.Rollback() 並不會影響已經提交事務。
這段代碼看上去要簡潔不少,不必在每次出現 error 時都想着調用 tx.Rollback() 回滾事務。
你可能認為這樣寫有損代碼性能,但其實絕大多數場景下我們不需要擔心。我更願意用一點點可以忽略不計的性能損失,換來一段清晰的代碼,畢竟可讀性很重要。
panic 並不是都可以被 recover 捕獲
最後,咱們再來看一個併發寫 map 的場景,如果觸發 panic 結果將會怎樣?
示例如下:
func f() {
m := map[int]struct{}{}
go func() {
defer func() {
if err := recover(); err != nil {
fmt.Println("goroutine 1", err)
}
}()
for {
m[1] = struct{}{}
}
}()
go func() {
defer func() {
if err := recover(); err != nil {
fmt.Println("goroutine 2", err)
}
}()
for {
m[1] = struct{}{}
}
}()
select {}
}
這裏啓動兩個 goroutine 來併發的對 map 進行寫操作,並且每個 goroutine 中都使用 defer + recover 來保證能夠正常處理 panic 發生。
最後使用 select {} 阻塞主 goroutine 防止程序退出。
執行示例代碼,得到輸出如下:
$ go run main.go
fatal error: concurrent map writes
goroutine 3 [running]:
main.f.func1()
/go/blog-go-example/error/defer-panic-recover/recover/main.go:156 +0x4c
created by main.f in goroutine 1
/go/blog-go-example/error/defer-panic-recover/recover/main.go:149 +0x50
goroutine 1 [select (no cases)]:
main.f()
/go/blog-go-example/error/defer-panic-recover/recover/main.go:171 +0x84
main.main()
/go/blog-go-example/error/defer-panic-recover/recover/main.go:204 +0x1c
goroutine 4 [runnable]:
main.f.func2()
/go/blog-go-example/error/defer-panic-recover/recover/main.go:167 +0x4c
created by main.f in goroutine 1
/go/blog-go-example/error/defer-panic-recover/recover/main.go:160 +0x80
exit status 2
然而程序還是輸出 panic 信息 fatal error: concurrent map writes 並退出了。
但是根據輸出信息,我們無法知道具體原因。
在 Go 1.19 Release Notes 中有提到,從 Go 1.19 版本開始程序遇到不可恢復的致命錯誤(例如併發寫入 map,或解鎖未鎖定的互斥鎖)只會打印一個簡化的堆棧信息,不包含運行時元數據。不過這可以通過將環境變量 GOTRACEBACK 被設置為 system 或 crash 來解決。
所以我們可以使用如下兩種方式來輸出更詳細的堆棧信息:
$ GOTRACEBACK=system go run main.go
$ GOTRACEBACK=crash go run main.go
再次執行示例代碼,得到輸出如下:
$ GOTRACEBACK=system go run main.go
fatal error: concurrent map writes
goroutine 4 gp=0x14000003180 m=3 mp=0x14000057008 [running]:
runtime.fatal({0x104904795?, 0x0?})
/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/runtime/panic.go:1088 +0x38 fp=0x14000051750 sp=0x14000051720 pc=0x104898a28
runtime.mapassign_fast64(0x104938ee0, 0x1400007a0c0, 0x1)
/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/runtime/map_fast64.go:122 +0x40 fp=0x14000051790 sp=0x14000051750 pc=0x1048cb5d0
main.f.func1()
/go/blog-go-example/error/defer-panic-recover/recover/main.go:156 +0x4c fp=0x140000517d0 sp=0x14000051790 pc=0x1049017bc
runtime.goexit({})
/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/runtime/asm_arm64.s:1223 +0x4 fp=0x140000517d0 sp=0x140000517d0 pc=0x1048d4694
created by main.f in goroutine 1
/go/blog-go-example/error/defer-panic-recover/recover/main.go:149 +0x50
...
exit status 2
這裏省略了大部分堆棧輸出,只保留了重要部分。根據堆棧信息可以發現在 runtime/map_fast64.go:122 處發生了 panic。
相關源碼內容如下:
func mapassign_fast64(t *maptype, h *hmap, key uint64) unsafe.Pointer {
if h == nil {
panic(plainError("assignment to entry in nil map"))
}
if raceenabled {
callerpc := getcallerpc()
racewritepc(unsafe.Pointer(h), callerpc, abi.FuncPCABIInternal(mapassign_fast64))
}
if h.flags&hashWriting != 0 {
fatal("concurrent map writes") // 第 122 行
}
...
return elem
}
顯然是第 122 行代碼 fatal("concurrent map writes") 觸發了 panic,並且其參數內容 concurrent map writes 也正是輸出結果。
fatal 函數源碼如下:
// fatal triggers a fatal error that dumps a stack trace and exits.
//
// fatal is equivalent to throw, but is used when user code is expected to be
// at fault for the failure, such as racing map writes.
//
// fatal does not include runtime frames, system goroutines, or frame metadata
// (fp, sp, pc) in the stack trace unless GOTRACEBACK=system or higher.
//
//go:nosplit
func fatal(s string) {
// Everything fatal does should be recursively nosplit so it
// can be called even when it's unsafe to grow the stack.
systemstack(func() {
print("fatal error: ")
printindented(s) // logically printpanicval(s), but avoids convTstring write barrier
print("\n")
})
fatalthrow(throwTypeUser)
}
fatal 內部調用了 fatalthrow 來觸發 panic。看來由 fatalthrow 所觸發的 panic 無法被 recover 捕獲。
我們開發時要切記:併發讀寫 map 觸發 panic,無法被 recover 捕獲。
併發操作 map 一定要小心,這是一個比較危險的行為,在 Web 開發中,如果在某個接口 handler 方法中觸發了 panic,整個 http Server 會直接掛掉。
涉及併發操作 map,我們應該使用 sync.Map 來代替:
func f() {
m := sync.Map{}
go func() {
defer func() {
if err := recover(); err != nil {
fmt.Println("goroutine 1", err)
}
}()
for {
m.Store(1, struct{}{})
}
}()
go func() {
defer func() {
if err := recover(); err != nil {
fmt.Println("goroutine 2", err)
}
}()
for {
m.Store(1, struct{}{})
}
}()
select {}
}
這個示例就不會 panic 了。
總結
本文對錯誤處理三劍客 defer、panic 和 recover 進行了講解梳理,雖然這三者並不是 error,但它們與錯誤處理息息相關。
defer 可以推遲一個函數或方法的調用,通常用於簡化執行各種清理操作的函數。
panic 是一個內置函數,它會停止程序的正常控制流並輸出 panic 相關信息。相比於 error,panic 更加暴力,謹慎使用。
recover 用來從 panic 所導致的程序崩潰中恢復執行,並且要與 defer 一起使用。
本文示例源碼我都放在了 GitHub 中,歡迎點擊查看。
希望此文能對你有所啓發。
聯繫我
- 公眾號:Go編程世界
- 微信:jianghushinian
- 郵箱:jianghushinian007@outlook.com
- 博客:https://jianghushinian.cn