我所理解的 Go 的 `panic` / `defer` / `recover` 異常處理機(jī)制
Go 語言中的錯(cuò)誤處理方式(Error Handle)常常因其顯式的 if err != nil 判斷而受到一些討論。但這背后蘊(yùn)含了 Go 的設(shè)計(jì)哲學(xué):區(qū)別于 Java、C++ 或 Python 等語言中常見的 try/catch 或 except 傳統(tǒng)異常處理機(jī)制,Go 語言鼓勵(lì)通過函數(shù)返回 error 對象來處理可預(yù)見的、常規(guī)的錯(cuò)誤。而對于那些真正意外的、無法恢復(fù)的運(yùn)行時(shí)錯(cuò)誤,或者嚴(yán)重的邏輯錯(cuò)誤,Go 提供了 panic、defer 和 recover 這一套機(jī)制來處理。
具體而言:
- panic 是一個(gè)內(nèi)置函數(shù),用于主動(dòng)或由運(yùn)行時(shí)觸發(fā)一個(gè)異常狀態(tài),表明程序遇到了無法繼續(xù)正常執(zhí)行的嚴(yán)重問題。一旦 panic 被觸發(fā),當(dāng)前函數(shù)的正常執(zhí)行流程會(huì)立即停止。
- defer 語句用于注冊一個(gè)函數(shù)調(diào)用,這個(gè)調(diào)用會(huì)在其所在的函數(shù)執(zhí)行完畢(無論是正常返回還是發(fā)生 panic)之前被執(zhí)行。defer 調(diào)用的執(zhí)行遵循“先進(jìn)后出”(LIFO, Last-In-First-Out)的原則。
- recover 是一個(gè)內(nèi)置函數(shù),專門用于捕獲并處理 panic。重要的是,recover 只有在 defer 注冊的函數(shù)內(nèi)部直接調(diào)用時(shí)才有效。
本文將深入探討 Go 語言中 panic、defer 和 recover 的概念、它們之間的交互流程以及一些內(nèi)部實(shí)現(xiàn)相關(guān)的細(xì)節(jié)。希望通過本文的闡述,能夠逐漸明晰一些圍繞它們的使用“規(guī)矩”所帶來的疑惑,例如:為什么 recover 必須直接在 defer 函數(shù)中調(diào)用?defer 是如何確保其“先進(jìn)后出”的執(zhí)行順序的?以及為什么在 defer 語句后常常推薦使用一個(gè)閉包(closure)?
panic 是什么
panic 是 Go 語言中的一個(gè)內(nèi)置函數(shù),用于指示程序遇到了一個(gè)不可恢復(fù)的嚴(yán)重錯(cuò)誤,或者說是一種運(yùn)行時(shí)恐慌。當(dāng) panic 被調(diào)用時(shí),它會(huì)立即停止當(dāng)前函數(shù)的正常執(zhí)行流程。緊接著,程序會(huì)開始執(zhí)行當(dāng)前 goroutine 中所有被 defer 注冊的函數(shù)。這個(gè)執(zhí)行 defer 函數(shù)的過程被稱為“恐慌過程”或“展開堆?!保╱nwinding the stack)。如果在執(zhí)行完所有 defer 函數(shù)后,該 panic 沒有被 recover 函數(shù)捕獲并處理,那么程序?qū)?huì)終止,并打印出 panic 的值以及相關(guān)的堆棧跟蹤信息。
panic 可以由程序主動(dòng)調(diào)用,例如 panic("something went wrong"),也可以由運(yùn)行時(shí)錯(cuò)誤觸發(fā),比如數(shù)組越界訪問、空指針引用等。
我們來看一個(gè)簡單的例子:
package main
import"fmt"
func main() {
fmt.Println("程序開始")
triggerPanic()
fmt.Println("程序結(jié)束 - 這行不會(huì)被執(zhí)行") // 因?yàn)?panic 未被恢復(fù),程序會(huì)終止
}
func triggerPanic() {
defer fmt.Println("defer in triggerPanic: 1") // 這個(gè) defer 會(huì)在 panic 發(fā)生后執(zhí)行
fmt.Println("triggerPanic 函數(shù)執(zhí)行中...")
var nums []int
// 嘗試訪問一個(gè) nil 切片的元素,這將引發(fā)運(yùn)行時(shí) panic
fmt.Println(nums[0]) // 這里會(huì) panic
defer fmt.Println("defer in triggerPanic: 2") // 這個(gè) defer 不會(huì)執(zhí)行,因?yàn)樗?panic 之后
fmt.Println("triggerPanic 函數(shù)即將結(jié)束 - 這行不會(huì)被執(zhí)行")
}
程序開始
triggerPanic 函數(shù)執(zhí)行中...
defer in triggerPanic: 1
panic: runtime error: index out of range [0] with length 0
goroutine 1 [running]:
main.triggerPanic()
/home/piperliu/code/playground/main.go:16 +0x8f
main.main()
/home/piperliu/code/playground/main.go:7 +0x4f
exit status 2
在上述代碼中,triggerPanic 函數(shù)中的 fmt.Println(nums[0]) 會(huì)因?yàn)閷?nbsp;nil 切片進(jìn)行索引操作而觸發(fā)一個(gè)運(yùn)行時(shí) panic。一旦 panic 發(fā)生:
- triggerPanic 函數(shù)的正常執(zhí)行立即停止。
- 在 panic 發(fā)生點(diǎn)之前注冊的 defer fmt.Println("defer in triggerPanic: 1") 會(huì)被執(zhí)行。
- 由于 panic 沒有在 triggerPanic 或 main 中被 recover,程序會(huì)終止,并輸出 panic 信息和堆棧。
- 因此,main 函數(shù)中的 fmt.Println("程序結(jié)束 - 這行不會(huì)被執(zhí)行") 以及 triggerPanic 函數(shù)中 panic 點(diǎn)之后的代碼都不會(huì)執(zhí)行。
defer 是什么?
defer 是 Go 語言中的一個(gè)關(guān)鍵字,用于將其后的函數(shù)調(diào)用(我們稱之為延遲函數(shù)調(diào)用)推遲到包含 defer 語句的函數(shù)即將返回之前執(zhí)行。這種機(jī)制非常適合用于執(zhí)行一些清理工作,例如關(guān)閉文件、釋放鎖、記錄函數(shù)結(jié)束等。
defer 的一個(gè)重要特性是其參數(shù)的求值時(shí)機(jī)。當(dāng) defer 語句被執(zhí)行時(shí),其后的函數(shù)調(diào)用所需的參數(shù)會(huì) 立即被求值并保存 ,但函數(shù)本身直到外層函數(shù)即將退出時(shí)才會(huì)被真正調(diào)用。這意味著,如果延遲函數(shù)調(diào)用的參數(shù)是一個(gè)變量,那么在 defer 語句執(zhí)行時(shí)該變量的值就被確定了,后續(xù)對該變量的修改不會(huì)影響到已注冊的延遲函數(shù)調(diào)用中該參數(shù)的值。
另一個(gè)關(guān)鍵特性是,如果一個(gè)函數(shù)內(nèi)有多個(gè) defer 語句,它們的執(zhí)行順序是“先進(jìn)后出”(LIFO)。也就是說,最先被 defer 的函數(shù)調(diào)用最后執(zhí)行,最后被 defer 的函數(shù)調(diào)用最先執(zhí)行,就像一個(gè)棧結(jié)構(gòu)。
考慮下面的代碼示例:
package main
import"fmt"
func main() {
fmt.Println("main: 開始")
value := 1
defer fmt.Println("第一個(gè) defer, value =", value) // value 的值 1 在此時(shí)被捕獲
value = 2
defer fmt.Println("第二個(gè) defer, value =", value) // value 的值 2 在此時(shí)被捕獲
value = 3
fmt.Println("main: value 最終為", value)
fmt.Println("main: 結(jié)束")
}
main: 開始
main: value 最終為 3
main: 結(jié)束
第二個(gè) defer, value = 2
第一個(gè) defer, value = 1
從輸出可以看出,defer 語句注冊的函數(shù)調(diào)用的參數(shù)是在 defer 語句執(zhí)行時(shí)就確定了的。并且,第二個(gè) defer 語句(最后注冊的)先于第一個(gè) defer 語句(最先注冊的)執(zhí)行,體現(xiàn)了 LIFO 的原則。
defer 語句常與匿名函數(shù)(閉包)結(jié)合使用,這可以方便地在延遲執(zhí)行的邏輯中訪問和修改其外層函數(shù)的命名返回值,或者執(zhí)行更復(fù)雜的邏輯。
recover 是什么?
recover 是 Go 語言中一個(gè)用于“恢復(fù)”程序從 panic 狀態(tài)的內(nèi)置函數(shù)。當(dāng)一個(gè) goroutine 發(fā)生 panic 時(shí),它會(huì)停止當(dāng)前函數(shù)的執(zhí)行,并開始執(zhí)行所有已注冊的 defer 函數(shù)。如果在這些 defer 函數(shù)中,有一個(gè)直接調(diào)用了 recover(),并且這個(gè) recover() 調(diào)用捕獲到了一個(gè) panic(即 recover() 的返回值不為 nil),那么這個(gè) panic 過程就會(huì)停止。
recover 的核心規(guī)則和調(diào)用時(shí)機(jī)非常關(guān)鍵:
- recover 必須在 defer 函數(shù)中直接調(diào)用才有效。 如果在 defer 調(diào)用的函數(shù)中再嵌套一層函數(shù)去調(diào)用 recover,那是無法捕獲 panic 的。
- 如果當(dāng)前 goroutine 沒有發(fā)生 panic,或者 recover 不是在 defer 函數(shù)中調(diào)用的,那么 recover() 會(huì)返回 nil,并且沒有任何其他效果。
- 如果 recover() 成功捕獲了一個(gè) panic,它會(huì)返回傳遞給 panic 函數(shù)的參數(shù)。此時(shí),程序的執(zhí)行會(huì)從調(diào)用 defer 的地方恢復(fù),恢復(fù)后函數(shù)就準(zhǔn)備返回了。原先的 panic 過程則被終止,程序不會(huì)崩潰。
可以認(rèn)為,recover 給予了程序一個(gè)在發(fā)生災(zāi)難性錯(cuò)誤時(shí)進(jìn)行“自救”的機(jī)會(huì)。它允許程序捕獲 panic,記錄錯(cuò)誤信息,執(zhí)行一些清理操作,然后可能以一種比直接崩潰更優(yōu)雅的方式繼續(xù)執(zhí)行或終止。
一個(gè)典型的使用 recover 的模式如下:
package main
import"fmt"
func main() {
fmt.Println("主函數(shù)開始")
safeDivide(10, 0)
safeDivide(10, 2)
fmt.Println("主函數(shù)結(jié)束")
}
func safeDivide(a, b int) {
deferfunc() {
// 這個(gè)匿名函數(shù)是一個(gè) defer 函數(shù)
if r := recover(); r != nil {
// r 是 panic 傳遞過來的值
fmt.Printf("捕獲到 panic: %v\n", r)
fmt.Println("程序已從 panic 中恢復(fù),繼續(xù)執(zhí)行...")
}
}() // 注意這里的 (),表示定義并立即調(diào)用該匿名函數(shù)(實(shí)際上是注冊)
fmt.Printf("嘗試 %d / %d\n", a, b)
if b == 0 {
panic("除數(shù)為零!") // 主動(dòng) panic
}
result := a / b
fmt.Printf("結(jié)果: %d\n", result)
}
主函數(shù)開始
嘗試 10 / 0
捕獲到 panic: 除數(shù)為零!
程序已從 panic 中恢復(fù),繼續(xù)執(zhí)行...
嘗試 10 / 2
結(jié)果: 5
主函數(shù)結(jié)束
在這個(gè)例子中,當(dāng) safeDivide(10, 0) 被調(diào)用時(shí),會(huì)觸發(fā) panic("除數(shù)為零!")。此時(shí),defer 注冊的匿名函數(shù)會(huì)被執(zhí)行。在該匿名函數(shù)內(nèi)部,recover() 捕獲到這個(gè) panic,打印信息,然后 safeDivide 函數(shù)結(jié)束。程序會(huì)繼續(xù)執(zhí)行 main 函數(shù)中的下一條語句 safeDivide(10, 2),而不會(huì)因?yàn)榈谝淮纬沐e(cuò)誤而崩潰。
panic/defer/recover 的交互流程
為了更清晰地理解 panic、defer 和 recover 之間的協(xié)同工作方式,我們通過一個(gè)稍微復(fù)雜一點(diǎn)的例子來追蹤程序的執(zhí)行流程。
假設(shè)我們有如下函數(shù) A、B、C 和 main:
package main
import"fmt"
func C(level int) {
fmt.Printf("進(jìn)入 C (層級 %d)\n", level)
defer fmt.Printf("defer in C (層級 %d)\n", level)
if level == 1 {
panic(fmt.Sprintf("在 C (層級 %d) 中發(fā)生 panic", level))
}
fmt.Printf("離開 C (層級 %d)\n", level)
}
func B() {
fmt.Println("進(jìn)入 B")
deferfunc() {
fmt.Println("defer in B (開始)")
if r := recover(); r != nil {
fmt.Printf("在 B 中恢復(fù): %v\n", r)
}
fmt.Println("defer in B (結(jié)束)")
}()
C(1) // 調(diào)用 C,這將觸發(fā) panic
fmt.Println("離開 B - 即便 C 中的 panic 被恢復(fù),這里也不會(huì)執(zhí)行,因?yàn)?defer 在之后調(diào)用")
}
func A() {
fmt.Println("進(jìn)入 A")
defer fmt.Println("defer in A")
C(2) // 調(diào)用 C,這次不會(huì) panic
fmt.Println("離開 A")
}
func main() {
fmt.Println("main: 開始")
A()
fmt.Println("=== 分割線 ===")
B()
fmt.Println("main: 結(jié)束")
}
main: 開始
進(jìn)入 A
進(jìn)入 C (層級 2)
離開 C (層級 2)
defer in C (層級 2)
離開 A
defer in A
=== 分割線 ===
進(jìn)入 B
進(jìn)入 C (層級 1)
defer in C (層級 1)
defer in B (開始)
在 B 中恢復(fù): 在 C (層級 1) 中發(fā)生 panic
defer in B (結(jié)束)
main: 結(jié)束
實(shí)現(xiàn)原理與數(shù)據(jù)結(jié)構(gòu)
要理解 panic/defer/recover 的工作機(jī)制,我們需要了解一些 Go 運(yùn)行時(shí)內(nèi)部與之相關(guān)的數(shù)據(jù)結(jié)構(gòu)。這些細(xì)節(jié)通常對日常編程是透明的,但有助于深入理解其行為。
關(guān)鍵的數(shù)據(jù)結(jié)構(gòu)主要與 goroutine(g)本身,以及 _defer 和 _panic 記錄相關(guān)聯(lián)。
g (Goroutine)
每個(gè) goroutine 在運(yùn)行時(shí)都有一個(gè)對應(yīng)的 g 結(jié)構(gòu)體(在 runtime/runtime2.go 中定義)。這個(gè)結(jié)構(gòu)體包含了 goroutine 的所有狀態(tài)信息,包括其棧指針、調(diào)度狀態(tài)等。與我們討論的主題密切相關(guān)的是,g 結(jié)構(gòu)體中通常會(huì)包含指向 _defer 記錄鏈表頭和 _panic 記錄鏈表頭的指針。
- _defer:一個(gè)指向 _defer 記錄鏈表頭部的指針。每當(dāng)執(zhí)行一個(gè) defer 語句,一個(gè)新的 _defer 記錄就會(huì)被創(chuàng)建并添加到這個(gè)鏈表的頭部。
- _panic:一個(gè)指向 _panic 記錄鏈表頭部的指針。當(dāng) panic 發(fā)生時(shí),一個(gè) _panic 記錄被創(chuàng)建并鏈接到這里。
_defer 結(jié)構(gòu)體
每當(dāng)一個(gè) defer 語句被執(zhí)行,運(yùn)行時(shí)系統(tǒng)會(huì)創(chuàng)建一個(gè) _defer 結(jié)構(gòu)體實(shí)例。這個(gè)結(jié)構(gòu)體大致包含以下信息:
- siz:參數(shù)和結(jié)果的總大小。
- fn:一個(gè)指向被延遲調(diào)用的函數(shù)(的函數(shù)值 funcval)的指針。
- sp:延遲調(diào)用發(fā)生時(shí)的棧指針。
- pc:延遲調(diào)用發(fā)生時(shí)的程序計(jì)數(shù)器。
- link:指向前一個(gè)(即下一個(gè)要執(zhí)行的)_defer 記錄的指針,形成一個(gè)單向鏈表。新的 _defer 總是被添加到鏈表的頭部,所以這個(gè)鏈表天然地實(shí)現(xiàn)了 LIFO 的順序。
- 參數(shù)區(qū)域:緊隨 _defer 結(jié)構(gòu)體的是實(shí)際傳遞給延遲函數(shù)的參數(shù)值。這些參數(shù)在 defer 語句執(zhí)行時(shí)就被復(fù)制并存儲(chǔ)在這里。
_panic 結(jié)構(gòu)體
當(dāng) panic 發(fā)生時(shí),運(yùn)行時(shí)會(huì)創(chuàng)建一個(gè) _panic 結(jié)構(gòu)體。它通常包含:
- argp:指向 panic 參數(shù)的接口值的指針(已廢棄,現(xiàn)在通常用 arg)。
- arg:傳遞給 panic 函數(shù)的參數(shù)(通常是一個(gè) interface{})。
- link:指向上一個(gè)(外層的)_panic 記錄。這用于處理嵌套 panic 的情況(例如,一個(gè) defer 函數(shù)本身也 panic 了)。
- recovered:一個(gè)布爾標(biāo)記,指示這個(gè) panic 是否已經(jīng)被 recover 處理。
- aborted:一個(gè)布爾標(biāo)記,指示這個(gè) panic 是否是因?yàn)檎{(diào)用了 runtime.Goexit() 而非真正的 panic。
這些結(jié)構(gòu)體在 Go 語言的 runtime 包中定義,它們是實(shí)現(xiàn) panic/defer/recover 機(jī)制的基石。通過在 g 中維護(hù) _defer 和 _panic 的鏈表,Go 運(yùn)行時(shí)能夠在 panic 發(fā)生時(shí)正確地展開堆棧、執(zhí)行延遲函數(shù),并允許 recover 來捕獲和處理這些 panic。
_defer 的入棧與調(diào)用流程
值得注意的是,我們應(yīng)該首先理解 return xxx 語句。實(shí)際上,這個(gè)語句會(huì)被編譯器拆分為三條指令:
- 返回值 = xxx
- 調(diào)用 defer 函數(shù)
- 空的 return
當(dāng)程序執(zhí)行到一個(gè) defer 語句時(shí),Go 運(yùn)行時(shí)會(huì)執(zhí)行 runtime.deferproc 函數(shù)(或類似功能的內(nèi)部函數(shù))。這個(gè)過程大致如下:
- 分配 _defer 記錄 :運(yùn)行時(shí)會(huì)分配一個(gè)新的 _defer 結(jié)構(gòu)體。這個(gè)結(jié)構(gòu)體的大小不僅包括 _defer 本身的字段,還包括了為延遲函數(shù)的參數(shù)所預(yù)留的空間。
- 參數(shù)立即求值與復(fù)制 :defer 語句后面跟著的函數(shù)調(diào)用的參數(shù),會(huì)在此時(shí)被立即計(jì)算出來,并將其值復(fù)制到新分配的 _defer 記錄的參數(shù)區(qū)域。這就是為什么 defer 函數(shù)能“記住”注冊它時(shí)參數(shù)的值,即使這些參數(shù)在后續(xù)代碼中被修改。
- 保存上下文信息 :_defer 記錄中會(huì)保存延遲調(diào)用的函數(shù)指針 (fn),以及當(dāng)前的程序計(jì)數(shù)器 (pc) 和棧指針 (sp)。
- 鏈接到 g 的 _defer 鏈表 :新的 _defer 記錄會(huì)被添加到當(dāng)前 goroutine (g) 的 _defer 鏈表的頭部。g.defer 指針會(huì)更新為指向這個(gè)新的 _defer 記錄,而新的 _defer 記錄的 link 字段會(huì)指向原先的鏈表頭(即上一個(gè) _defer 記錄)。由于總是從頭部插入,這自然形成了“先進(jìn)后出”(LIFO)的結(jié)構(gòu)。
調(diào)用流程(函數(shù)返回或 panic 時(shí))
當(dāng)包含 defer 語句的函數(shù)即將返回(無論是正常返回還是因?yàn)?nbsp;panic)時(shí),運(yùn)行時(shí)會(huì)檢查當(dāng)前 goroutine 的 _defer 鏈表。這個(gè)過程由 runtime.deferreturn(或類似函數(shù))處理:
- 從 g 的 _defer 鏈表頭部取出一個(gè) _defer 記錄。
- 如果鏈表為空,則沒有 defer 函數(shù)需要執(zhí)行。
- 如果取出的 _defer 記錄有效:
將其從鏈表中移除(即將 g.defer 指向該記錄的 link)。
將保存在 _defer 記錄中的參數(shù)復(fù)制到當(dāng)前棧幀,為調(diào)用做準(zhǔn)備。
調(diào)用 _defer 記錄中保存的函數(shù)指針 fn。
延遲函數(shù)執(zhí)行完畢后,重復(fù)此過程,直到 _defer 鏈表為空。
立即求值參數(shù)是什么?
正如前面強(qiáng)調(diào)的,defer 關(guān)鍵字后的函數(shù)調(diào)用,其參數(shù)的值是在 defer 語句執(zhí)行的時(shí)刻就被計(jì)算并存儲(chǔ)起來的,而不是等到外層函數(shù)結(jié)束、延遲函數(shù)真正被調(diào)用時(shí)才計(jì)算。
- 為什么推薦在 defer 后接一個(gè)閉包?
- 訪問外層函數(shù)作用域 :閉包可以捕獲其定義時(shí)所在作用域的變量。這使得 defer 的邏輯可以方便地與外層函數(shù)的狀態(tài)交互,例如修改命名返回值,或者訪問在 defer 語句時(shí)尚未聲明但在函數(shù)返回前會(huì)賦值的變量。
- 執(zhí)行復(fù)雜邏輯 :如果 defer 需要執(zhí)行的不僅僅是一個(gè)簡單的函數(shù)調(diào)用,而是一系列操作,閉包提供了一種簡潔的方式來封裝這些操作。
- 正確處理循環(huán)變量 :在循環(huán)中使用 defer 時(shí),如果不使用閉包并把循環(huán)變量作為參數(shù)傳遞給閉包,那么所有 defer 語句將共享同一個(gè)循環(huán)變量的最終值。通過閉包并傳遞參數(shù),可以捕獲每次迭代時(shí)循環(huán)變量的當(dāng)前值。
package main
import"fmt"
type Test struct {
Name string
}
func (t Test) hello() {
fmt.Printf("Hello, %s\n", t.Name)
}
func (t *Test) hello2() {
fmt.Printf("pointer: %s\n", t.Name)
}
func runT(t Test) {
t.hello()
}
func main() {
mapt := []Test{
{Name: "A"},
{Name: "B"},
{Name: "C"},
}
for _, t := range mapt {
defer t.hello()
defer t.hello2()
}
}
輸出如下:
piperliu@go-x86:~/code/playground$ gvm use go1.22.0
Now using version go1.22.0
piperliu@go-x86:~/code/playground$ go run main.go
pointer: C
Hello, C
pointer: B
Hello, B
pointer: A
Hello, A
piperliu@go-x86:~/code/playground$ gvm use go1.21.0
Now using version go1.21.0
piperliu@go-x86:~/code/playground$ go run main.go
pointer: C
Hello, C
pointer: C
Hello, B
pointer: C
Hello, A
你可以看到 go1.21.0 和 go1.22.0 的表現(xiàn)是不同的。在這個(gè)例子中,我們把兩次 defer 放到了 for 循環(huán)里,分別調(diào)用了接收者為值的方法 hello 和接收者為指針的方法 hello2。按 Go 的規(guī)范,每一個(gè) defer 語句都會(huì)生成一個(gè)“閉包”(closure),而這個(gè)閉包會(huì) 捕獲(capture) 循環(huán)變量 t。下面分兩部分來詳細(xì)說明其行為差異:
值接收者(func (t Test) hello())的 defer
- 當(dāng)你寫下 defer t.hello() 時(shí),編譯器會(huì)把這一調(diào)用包裝成一個(gè)閉包,并且在閉包內(nèi)部保存一份 拷貝 (copy)——也就是當(dāng)時(shí) t 的值。
- 因此,不管后續(xù)循環(huán)中 t 如何變化,已經(jīng)創(chuàng)建好的這些閉包都各自持有自己那一刻的獨(dú)立拷貝。等待 main 函數(shù)退出時(shí),它們會(huì)按 LIFO(后進(jìn)先出)的順序依次執(zhí)行,每個(gè)閉包都打印自己持有的那個(gè)副本的 Name 字段,結(jié)果正好是 C、B、A。
指針接收者(func (t *Test) hello2())的 defer
- 寫成 defer t.hello2() 時(shí),閉包并不拷貝 Test 結(jié)構(gòu)本身,而是拷貝了一個(gè) 指向循環(huán)變量 t 的指針 。
- 關(guān)鍵在于:在 Go 1.21 之前,循環(huán)變量 t 本身在每次迭代中都是 同一個(gè)變量 (地址不變),只是不斷被重寫(rewritten)成新的值。這樣,所有那些指針閉包實(shí)際上都指向同一個(gè)內(nèi)存地址——最后一次迭代結(jié)束時(shí),這個(gè)地址中存放的是 {Name: "C"}。
- 因此,當(dāng)程序末尾逐個(gè)執(zhí)行這些 defer 時(shí),hello2 全部都訪問的正是指向同一個(gè)變量的指針,輸出的名字也就全是最后一次給 t 賦的 "C"。
Go 1.22 中的變化
- 從 Go 1.22 起,規(guī)范做了一個(gè)重要的調(diào)整:* 循環(huán)頭部的迭代變量在每一輪都會(huì)被當(dāng)作“全新”的變量來處理* ,也就是說每次迭代編譯器都會(huì)隱式地為 t 重新聲明一次、分配一次新的內(nèi)存地址。
- 這樣一來,即便是拿指針去捕獲,每次也捕獲的是 不同 的變量地址,閉包就能各自綁定當(dāng)時(shí)那一輪迭代的 t,輸出也就跟值接收者那邊一樣,依次是 C、B、A。
總結(jié):
- 值接收者 的 defer 總是捕獲當(dāng)時(shí)的值拷貝,跟循環(huán)變量的重寫行為無關(guān);
- 指針接收者 的 defer 捕獲的是循環(huán)變量的地址,若循環(huán)變量重用同一地址(如 Go 1.21 及以前版本),所有閉包共用最終那次迭代的內(nèi)容;
- Go 1.22 以后 ,循環(huán)變量地址不再重用,從而讓指針閉包也能如值閉包般,捕獲每一輪獨(dú)立的變量,實(shí)現(xiàn)與 Go 1.21+ 值接收者一致的行為。
(上面這個(gè)例子搬運(yùn)自 StackOverflow: Golang defers in a for loop behaves differently for the same struct - https://stackoverflow.com/a/75908307/11564718 )
_panic 的傳播流程與內(nèi)部細(xì)節(jié)
當(dāng)程序執(zhí)行 panic(v) 或者發(fā)生運(yùn)行時(shí)錯(cuò)誤(如空指針解引用、數(shù)組越界)時(shí),Go 運(yùn)行時(shí)會(huì)調(diào)用 runtime.gopanic(interface{}) 函數(shù)。這個(gè)函數(shù)是 panic 機(jī)制的核心。
其大致流程如下:
- 創(chuàng)建 _panic 記錄
- 運(yùn)行時(shí)系統(tǒng)首先創(chuàng)建一個(gè) _panic 結(jié)構(gòu)體實(shí)例。
- 這個(gè)結(jié)構(gòu)體的 arg 字段會(huì)被設(shè)置為傳遞給 panic 的值 v。
- link 字段會(huì)指向當(dāng)前 goroutine (g) 可能已經(jīng)存在的 _panic 記錄(g._panic)。這種情況發(fā)生在 defer 函數(shù)執(zhí)行過程中又觸發(fā)了新的 panic(嵌套 panic)。新 panic 會(huì)覆蓋舊 panic,舊的 panic 信息會(huì)通過 link 鏈起來。
- recovered 字段初始化為 false。
- 新創(chuàng)建的 _panic 記錄會(huì)被設(shè)置為當(dāng)前 goroutine 的活動(dòng) panic,即 g._panic 指向這個(gè)新記錄。
- 開始棧展開(Stack Unwinding)與執(zhí)行 defer
- 對應(yīng)的延遲函數(shù)被調(diào)用。
- 關(guān)鍵點(diǎn) :如果在這個(gè)延遲函數(shù)內(nèi)部直接調(diào)用了 recover(),并且 recover() 成功捕獲了當(dāng)前的 panic(即 g._panic 所指向的 panic),那么 g._panic.recovered 標(biāo)記會(huì)被設(shè)為 true。gopanic 函數(shù)會(huì)注意到這個(gè)標(biāo)記,停止繼續(xù)展開 _defer 鏈,并開始執(zhí)行恢復(fù)流程(見下一節(jié) recover 的實(shí)現(xiàn))。
- 如果延遲函數(shù)執(zhí)行完畢后,panic 沒有被 recover,或者延遲函數(shù)本身又觸發(fā)了新的 panic,gopanic 會(huì)繼續(xù)處理(新的 panic 會(huì)取代當(dāng)前的,然后繼續(xù)執(zhí)行 defer 鏈)。
- 如果延遲函數(shù)正常執(zhí)行完畢且未 recover,則繼續(xù)循環(huán),處理下一個(gè) _defer。
- gopanic 進(jìn)入一個(gè)循環(huán),不斷地從當(dāng)前 goroutine 的 _defer 鏈表頭部取出 _defer 記錄并執(zhí)行它們。
- 對于每一個(gè)取出的 _defer:
- defer 鏈執(zhí)行完畢后
- 如果在所有 defer 函數(shù)執(zhí)行完畢后,g._panic.recovered 仍然是 false(即 panic 沒有被任何 recover 調(diào)用捕獲),那么 gopanic 會(huì)調(diào)用 runtime.fatalpanic。
- runtime.fatalpanic 會(huì)打印出當(dāng)前的 panic 值 (g._panic.arg) 和發(fā)生 panic 時(shí)的調(diào)用堆棧信息。
- 最后,程序會(huì)以非零狀態(tài)碼退出,通常是2。
匯編層面與棧展開的理解
雖然我們通常不直接接觸匯編,但理解其概念有助于明白“棧展開”。當(dāng)一個(gè)函數(shù)調(diào)用另一個(gè)函數(shù)時(shí),返回地址、參數(shù)、局部變量等會(huì)被壓入當(dāng)前 goroutine 的棧。發(fā)生 panic 時(shí),gopanic 的過程實(shí)際上就是在模擬函數(shù)返回的過程,但它不是正常返回,而是逐個(gè)“彈出”棧幀(邏輯上),并查找與這些棧幀關(guān)聯(lián)的 _defer 記錄來執(zhí)行。如果 panic 未被 recover,這個(gè)展開過程會(huì)一直持續(xù)到 goroutine 棧的最初始調(diào)用者,最終導(dǎo)致程序終止。這個(gè)過程由運(yùn)行時(shí)系統(tǒng)精心管理,確保 defer 的正確執(zhí)行和 recover 的有效性。
總的來說,_panic 的傳播是一個(gè)受控的?;厮葸^程,它給予了 defer 函數(shù)介入并可能通過 recover 來中止這一傳播的機(jī)會(huì)。
recover 的實(shí)現(xiàn)
recover 的實(shí)現(xiàn)與 panic 的流程緊密相連,它在 runtime.gorecover(argp unsafe.Pointer) interface{} 函數(shù)中實(shí)現(xiàn)。
recover 的執(zhí)行流程:
- 檢查調(diào)用上下文 :gorecover 首先會(huì)檢查它是否在正確的上下文中被調(diào)用。最關(guān)鍵的檢查是當(dāng)前 goroutine (g) 是否正處于 panic 狀態(tài)(即 g._panic != nil)并且這個(gè) panic 尚未被標(biāo)記為 recovered(g._panic.recovered == false)。
- 如果 g._panic 為 nil(沒有活動(dòng)的 panic),或者 g._panic.recovered 為 true(panic 已經(jīng)被其他 recover 調(diào)用處理過了),那么 gorecover 直接返回 nil。這解釋了為什么在沒有 panic 的情況下調(diào)用 recover 會(huì)返回 nil。
- 檢查是否直接在 defer 函數(shù)中調(diào)用 :Go 運(yùn)行時(shí)還需要確保 recover 是被 defer 調(diào)用的函數(shù)直接調(diào)用的,而不是在 defer 函數(shù)調(diào)用的更深層函數(shù)中調(diào)用。這是通過比較調(diào)用 gorecover 時(shí)的棧指針 (argp,它指向 recover 函數(shù)的參數(shù)在棧上的位置) 與 g._defer 鏈表頭記錄的棧指針 (d.sp) 是否匹配。
- 如果棧指針不匹配,意味著 recover 不是在最頂層的 defer 函數(shù)(即當(dāng)前正在執(zhí)行的 defer)中直接調(diào)用的,這種情況下 gorecover 也會(huì)返回 nil。這就是“recover 必須直接在 defer 函數(shù)中調(diào)用”規(guī)則的由來。
- 標(biāo)記 panic 為已恢復(fù) :如果上述檢查都通過,說明 recover 是在合法的時(shí)機(jī)和位置被調(diào)用的:
- gorecover 會(huì)將當(dāng)前活動(dòng)的 panic(即 g._panic)的 recovered 字段標(biāo)記為 true。
- 它會(huì)保存 panic 的參數(shù)值 (g._panic.arg)。
- 清除當(dāng)前 panic :為了防止后續(xù)的 defer 或同一個(gè) defer 中的其他 recover 再次處理同一個(gè) panic,gorecover 會(huì)將 g._panic 設(shè)置為 nil(或者在有嵌套 panic 的情況下,將其設(shè)置為 g._panic.link,即恢復(fù)到上一個(gè) panic 的狀態(tài))。實(shí)際上,在 gopanic 的循環(huán)中,當(dāng)它檢測到 recovered 標(biāo)志被設(shè)為 true 后,它會(huì)負(fù)責(zé)清理 g._panic 并調(diào)整控制流以正常返回。
- 返回 panic 的參數(shù) :最后,gorecover 返回之前保存的 panic 參數(shù)值。調(diào)用者(即 defer 函數(shù)中的代碼)可以通過檢查這個(gè)返回值是否為 nil 來判斷是否成功捕獲了 panic。
為什么 recover 要放在 defer 中?
從上述流程可以看出,panic 發(fā)生時(shí),正常的代碼執(zhí)行路徑已經(jīng)中斷。唯一還會(huì)被執(zhí)行的代碼就是 defer 鏈中的函數(shù)。因此,recover 只有在 defer 函數(shù)中才有機(jī)會(huì)被執(zhí)行并接觸到 panic 的狀態(tài)。運(yùn)行時(shí)通過 g._panic 和 g._defer 來協(xié)調(diào)這一過程,recover 正是這個(gè)協(xié)調(diào)機(jī)制中的一個(gè)鉤子,允許 defer 函數(shù)介入 panic 的傳播。
嵌套 panic 的情況
如果一個(gè) defer 函數(shù)在執(zhí)行過程中自己也調(diào)用了 panic(我們稱之為 panic2,而原始的 panic 為 panic1):
- panic2 會(huì)創(chuàng)建一個(gè)新的 _panic 記錄,這個(gè)新記錄的 link 字段會(huì)指向 panic1 對應(yīng)的 _panic 記錄。
- g._panic 會(huì)更新為指向 panic2 的記錄。
- 此時(shí),如果后續(xù)的 defer 函數(shù)(或者同一個(gè) defer 函數(shù)中位于新 panic 之后的 recover)調(diào)用 recover,它捕獲到的是 panic2。
- 如果 panic2 被成功 recover,那么 g._panic 會(huì)恢復(fù)為指向 panic1 的記錄(通過 link)。程序會(huì)繼續(xù)執(zhí)行 defer 鏈,此時(shí) panic1 仍然是活動(dòng)的,除非它也被后續(xù)的 recover 處理。
- 如果 panic2 沒有被 recover,那么 panic2 會(huì)取代 panic1 成為最終導(dǎo)致程序終止的 panic。
這種設(shè)計(jì)確保了最近發(fā)生的 panic 優(yōu)先被處理。
總結(jié)
panic、defer 和 recover 共同構(gòu)成了 Go 語言中處理嚴(yán)重錯(cuò)誤和執(zhí)行資源清理的補(bǔ)充機(jī)制。
defer 對性能的影響與技術(shù)取舍
defer 并非沒有成本。每次 defer 調(diào)用都會(huì)涉及到 runtime.deferproc 的執(zhí)行,包括分配 _defer 對象、復(fù)制參數(shù)等操作。在函數(shù)返回時(shí),還需要 runtime.deferreturn 來遍歷 _defer 鏈并執(zhí)行延遲調(diào)用。相比于直接的函數(shù)調(diào)用,這無疑會(huì)帶來一些額外的開銷。在性能極其敏感的內(nèi)層循環(huán)中,大量使用 defer 可能會(huì)成為瓶頸。
然而,這種開銷在大多數(shù)情況下是可以接受的,尤其是考慮到 defer 帶來的代碼清晰度和健壯性提升。它確保了資源(如文件句柄、網(wǎng)絡(luò)連接、鎖等)即使在函數(shù)發(fā)生 panic 或有多個(gè)返回路徑時(shí)也能被正確釋放,極大地減少了資源泄漏的風(fēng)險(xiǎn)。這是一種典型的在輕微性能開銷與代碼可維護(hù)性、可靠性之間的權(quán)衡。Go 的設(shè)計(jì)者認(rèn)為這種權(quán)衡是值得的。
設(shè)計(jì)哲學(xué)
Go 語言的設(shè)計(jì)哲學(xué)強(qiáng)調(diào)顯式和清晰。對于可預(yù)期的錯(cuò)誤(如文件不存在、網(wǎng)絡(luò)超時(shí)等),Go 推薦使用多返回值,將 error 作為最后一個(gè)返回值來顯式地處理。這種方式使得錯(cuò)誤處理成為代碼流程中正常的一部分,而不是通過異常拋出來打斷流程。
panic 和 recover 則被保留用于處理那些真正意外的、程序無法或不應(yīng)該繼續(xù)正常運(yùn)行的情況,例如嚴(yán)重的運(yùn)行時(shí)錯(cuò)誤(空指針解引用、數(shù)組越界,盡管很多這類情況運(yùn)行時(shí)會(huì)自動(dòng) panic)、或者庫代碼中不希望將內(nèi)部嚴(yán)重錯(cuò)誤以 error 形式暴露給調(diào)用者而直接中斷操作的情況。recover 的存在是為了給程序一個(gè)從災(zāi)難性 panic 中“優(yōu)雅”恢復(fù)的機(jī)會(huì),例如記錄日志、關(guān)閉服務(wù),而不是粗暴地崩潰,特別是在服務(wù)器應(yīng)用中,一個(gè) goroutine 的 panic 不應(yīng)該導(dǎo)致整個(gè)服務(wù)停止。
panic / recover 使用場景
- 不應(yīng)濫用 panic :不要用 panic 來進(jìn)行普通的錯(cuò)誤處理或控制程序流程。如果一個(gè)錯(cuò)誤是可預(yù)期的,應(yīng)該返回 error。
- panic 的合理場景 :
a.發(fā)生真正不可恢復(fù)的錯(cuò)誤,程序無法繼續(xù)執(zhí)行。例如,程序啟動(dòng)時(shí)關(guān)鍵配置加載失敗。
b.檢測到程序內(nèi)部邏輯上不可能發(fā)生的“不可能”狀態(tài),這通常指示一個(gè) bug。
- recover 的合理場景 :
a.頂層 panic 捕獲:在 main 函數(shù)啟動(dòng)的 goroutine 或 Web 服務(wù)器處理每個(gè)請求的 goroutine 的頂層,設(shè)置一個(gè) defer 和 recover 來捕獲任何未處理的 panic,記錄錯(cuò)誤日志,并可能向客戶端返回一個(gè)通用錯(cuò)誤響應(yīng),以防止單個(gè)請求的失敗導(dǎo)致整個(gè)服務(wù)崩潰。
b.庫代碼健壯性:當(dāng)編寫供他人使用的庫時(shí),如果內(nèi)部發(fā)生了某種不應(yīng)由調(diào)用者處理的 panic,庫自身可以在其公共 API 的邊界處使用 recover 將 panic 轉(zhuǎn)換為 error 返回,避免將內(nèi)部的 panic 泄露給庫的使用者。
總而言之,defer 是一個(gè)強(qiáng)大的工具,用于確保清理邏輯的執(zhí)行。panic 和 recover 則提供了一種處理程序級別嚴(yán)重錯(cuò)誤的機(jī)制,但應(yīng)謹(jǐn)慎使用,以符合 Go 語言的錯(cuò)誤處理哲學(xué)。