探究 Go 源碼中 panic & recover 有哪些坑?
轉載請聲明出處哦~,本篇文章發佈於luozhiyun的部落格: //www.luozhiyun.com/archives/627
本文使用的go的源碼1.17.3
前言
寫這一篇文章的原因是最近在工作中有位小夥伴在寫程式碼的時候直接用 Go 關鍵字起了一個 Goroutine,然後發生了空指針的問題,由於沒有 recover 導致了整個程式宕掉的問題。程式碼類似這樣:
func main() {
defer func() {
if err := recover(); err !=nil{
fmt.Println(err)
}
}()
go func() {
fmt.Println("======begin work======")
panic("nil pointer exception")
}()
time.Sleep(time.Second*100)
fmt.Println("======after work======")
}
返回的結果:
======begin work======
panic: nil pointer exception
goroutine 18 [running]:
...
Process finished with the exit code 2
需要注意的是,當時在 Goroutine 的外層是做了統一的異常處理的,但是很明顯的是 Goroutine 的外層的 defer 並沒有 cover 住這個異常。
之所以會出現上面的情況,還是因為我們對 Go 源碼不甚了解導致的。panic & recover 是有其作用範圍的:
- recover 只有在 defer 中調用才會生效;
- panic 允許在 defer 中嵌套多次調用;
- panic 只會對當前 Goroutine 的 defer 有效
之所以 panic 只會對當前 Goroutine 的 defer 有效是因為在 newdefer 分配 _defer 結構體對象的時,會把分配到的對象鏈入當前 Goroutine 的 _defer 鏈表的表頭。具體的可以再去 深入 Go 語言 defer 實現原理 這篇文章裡面回顧一下。
源碼分析
_panic 結構體
type _panic struct {
argp unsafe.Pointer // pointer to arguments of deferred call run during panic; cannot move - known to liblink
arg interface{} // argument to panic
link *_panic // link to earlier panic
pc uintptr // where to return to in runtime if this panic is bypassed
sp unsafe.Pointer // where to return to in runtime if this panic is bypassed
recovered bool // whether this panic is over
aborted bool // the panic was aborted
goexit bool
}
- argp 是指向 defer 調用時參數的指針;
- arg 是我們調用 panic 時傳入的參數;
- link 指向的是更早調用
runtime._panic
結構,也就是說 painc 可以被連續調用,他們之間形成鏈表; - recovered 表示當前
runtime._panic
是否被 recover 恢復; - aborted 表示當前的 panic 是否被強行終止;
對於 pc、sp、goexit 這三個關鍵字的主要作用就是有可能在 defer 中發生 panic,然後在上層的 defer 中通過 recover 對其進行了恢復,那麼恢復進程實際上將恢復在Goexit框架之上的正常執行,因此中止Goexit。
pc、sp、goexit 三個欄位的討論以及程式碼提交可以看看這裡://github.com/golang/go/commit/7dcd343ed641d3b70c09153d3b041ca3fe83b25e 以及這個討論 runtime: panic + recover can cancel a call to Goexit。
panic 流程
- 編譯器會將關鍵字 panic 轉換成
runtime.gopanic
並調用,然後在循環中不斷從當前 Goroutine 的 defer 鏈表獲取 defer 並執行; - 如果在調用的 defer 函數中有 recover ,那麼就會調用到
runtime.gorecover
,它會修改runtime._panic
的 recovered 欄位為 true; - 調用完 defer 函數之後回到
runtime.gopanic
主邏輯中,檢查 recovered 欄位為 true 會從runtime._defer
結構體中取出程式計數器pc
和棧指針sp
並調用runtime.recovery
函數恢復程式。runtime.recvoery
在調度過程中會將函數的返回值設置成 1; - 當
runtime.deferproc
函數的返回值是 1 時,編譯器生成的程式碼會直接跳轉到調用方函數返回之前並執行runtime.deferreturn
,然後程式就已經從panic
中恢復了並執行正常的邏輯; - 在
runtime.gopanic
執行完所有的 _defer 並且也沒有遇到 recover,那麼就會執行runtime.fatalpanic
終止程式,並返回錯誤碼2;
所以整個過程分為兩部分:1. 有recover ,panic 能恢復的邏輯;2. 無recover,panic 直接崩潰;
觸發 panic 直接崩潰
func gopanic(e interface{}) {
gp := getg()
...
var p _panic
// 創建新的 runtime._panic 並添加到所在 Goroutine 的 _panic 鏈表的最前面
p.link = gp._panic
gp._panic = (*_panic)(noescape(unsafe.Pointer(&p)))
for {
// 獲取當前gorourine的 defer
d := gp._defer
if d == nil {
break
}
...
d._panic = (*_panic)(noescape(unsafe.Pointer(&p)))
// 運行defer調用函數
reflectcall(nil, unsafe.Pointer(d.fn), deferArgs(d), uint32(d.siz), uint32(d.siz), uint32(d.siz), ®s)
d._panic = nil
d.fn = nil
gp._defer = d.link
// 將defer從當前goroutine移除
freedefer(d)
// recover 恢復程式
if p.recovered {
...
}
}
// 列印出全部的 panic 消息以及調用時傳入的參數
preprintpanics(gp._panic)
// fatalpanic實現了無法被恢復的程式崩潰
fatalpanic(gp._panic)
*(*int)(nil) = 0
}
我們先來看看這段邏輯:
- 首先會獲取當前的 Goroutine ,並創建新的
runtime._panic
並添加到所在 Goroutine 的 _panic 鏈表的最前面; - 接著會進入到循環獲取當前 Goroutine 的 defer 鏈表,並調用 reflectcall 運行 defer 函數;
- 運行完之後會將 defer 從當前 Goroutine 移除,因為我們這裡假設沒有 recover 邏輯,那麼,會調用 fatalpanic 中止整個程式;
func fatalpanic(msgs *_panic) {
pc := getcallerpc()
sp := getcallersp()
gp := getg()
var docrash bool
systemstack(func() {
if startpanic_m() && msgs != nil {
printpanics(msgs)
}
docrash = dopanic_m(gp, pc, sp)
})
if docrash {
crash()
}
systemstack(func() {
exit(2)
})
*(*int)(nil) = 0 // not reached
}
fatalpanic 它在中止程式之前會通過 printpanics 列印出全部的 panic 消息以及調用時傳入的參數,然後調用 exit 並返回錯誤碼 2。
觸發 panic 恢復
recover 關鍵字會被調用到 runtime.gorecover
中:
func gorecover(argp uintptr) interface{} {
gp := getg()
p := gp._panic
if p != nil && !p.goexit && !p.recovered && argp == uintptr(p.argp) {
p.recovered = true
return p.arg
}
return nil
}
如果當前 Goroutine 沒有調用 panic,那麼該函數會直接返回 nil;p.Goexit
判斷當前是否是 goexit 觸發的,上面的例子也說過,recover 是不能阻斷 goexit 的;
如果條件符合,那麼最終會將 recovered 欄位修改為 ture,然後在 runtime.gopanic
中執行恢復。
func gopanic(e interface{}) {
gp := getg()
...
var p _panic
// 創建新的 runtime._panic 並添加到所在 Goroutine 的 _panic 鏈表的最前面
p.link = gp._panic
gp._panic = (*_panic)(noescape(unsafe.Pointer(&p)))
for {
// 獲取當前gorourine的 defer
d := gp._defer
...
pc := d.pc
sp := unsafe.Pointer(d.sp)
// recover 恢復程式
if p.recovered {
// 獲取下一個 panic
gp._panic = p.link
// 如果該panic是 goexit 觸發的,那麼會恢復到 goexit 邏輯程式碼中執行 exit
if gp._panic != nil && gp._panic.goexit && gp._panic.aborted {
gp.sigcode0 = uintptr(gp._panic.sp)
gp.sigcode1 = uintptr(gp._panic.pc)
mcall(recovery)
throw("bypassed recovery failed") // mcall 會恢復正常的程式碼邏輯,不會走到這裡
}
...
gp._panic = p.link
for gp._panic != nil && gp._panic.aborted {
gp._panic = gp._panic.link
}
if gp._panic == nil {
gp.sig = 0
}
gp.sigcode0 = uintptr(sp)
gp.sigcode1 = pc
mcall(recovery)
throw("recovery failed") // mcall 會恢復正常的程式碼邏輯,不會走到這裡
}
}
...
}
這裡包含了兩段 mcall(recovery) 調用恢復。
第一部分 if gp._panic != nil && gp._panic.goexit && gp._panic.aborted
判斷主要是針對 Goexit,保證 Goexit 也會被 recover 住恢復到 Goexit 執行時,執行 exit;
第二部分是做 panic 的 recover,從runtime._defer
中取出了程式計數器 pc 和 sp 並調用 recovery 觸發程式恢復;
func recovery(gp *g) {
sp := gp.sigcode0
pc := gp.sigcode1
...
gp.sched.sp = sp
gp.sched.pc = pc
gp.sched.lr = 0
gp.sched.ret = 1
gogo(&gp.sched)
}
這裡的 recovery 會將函數的返回值設置成 1,然後調用 gogo 會跳回 defer
關鍵字調用的位置,Goroutine 繼續執行;
func deferproc(siz int32, fn *funcval) {
...
// deferproc returns 0 normally.
// a deferred func that stops a panic
// makes the deferproc return 1.
// the code the compiler generates always
// checks the return value and jumps to the
// end of the function if deferproc returns != 0.
return0()
}
通過注釋我們知道,deferproc 返回返回值是 1 時,編譯器生成的程式碼會直接跳轉到調用方函數返回之前並執行runtime.deferreturn
。
runtime 中有哪些坑?
panic 我們在實現業務的時候是不推薦使用的,但是並不代表 runtime 裡面不會用到,對於不了解 Go 底層實現的新人來說,這無疑是挖了一堆深坑。如果不熟悉這些坑,是不可能寫出健壯的 Go 程式碼。
下面我將 runtime 中的異常分一下類,有一些異常是 recover 也捕獲不到的,有一些是正常的 panic 可以被捕獲到。
無法捕獲的異常
記憶體溢出
func main() {
defer errorHandler()
_ = make([]int64, 1<<40)
fmt.Println("can recover")
}
func errorHandler() {
if r := recover(); r != nil {
fmt.Println(r)
}
}
在調用 alloc 進行記憶體分配的時候記憶體不夠會調用 grow 從系統申請新的記憶體,通過調用 mmap 申請記憶體返回 _ENOMEM 的時候會拋出 runtime: out of memory
異常,throw 會調用到 exit 導致整個程式退出。
func sysMap(v unsafe.Pointer, n uintptr, sysStat *sysMemStat) {
sysStat.add(int64(n))
p, err := mmap(v, n, _PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_FIXED|_MAP_PRIVATE, -1, 0)
if err == _ENOMEM {
throw("runtime: out of memory")
}
if p != v || err != 0 {
throw("runtime: cannot map pages in arena address space")
}
}
func throw(s string) {
...
fatalthrow()
*(*int)(nil) = 0 // not reached
}
func fatalthrow() {
systemstack(func() {
...
exit(2)
})
}
map 並發讀寫
func main() {
defer errorHandler()
m := map[string]int{}
go func() {
for {
m["x"] = 1
}
}()
for {
_ = m["x"]
}
}
func errorHandler() {
if r := recover(); r != nil {
fmt.Println(r)
}
}
map 由於不是執行緒安全的,所以在遇到並發讀寫的時候會拋出 concurrent map read and map write
異常,從而使程式直接退出。
func mapaccess1_faststr(t *maptype, h *hmap, ky string) unsafe.Pointer {
...
if h.flags&hashWriting != 0 {
throw("concurrent map read and map write")
}
...
}
這裡的 throw 和上面一樣,最終會調用到 exit 執行退出。
這裡其實是很奇怪的,我以前是做 java 的,用 hashmap 遇到並發的竟態問題的時候也只是拋了個異常,並不會導致程式 crash。對於這一點官方是這樣解釋的:
The runtime has added lightweight, best-effort detection of concurrent misuse of maps. As always, if one goroutine is writing to a map, no other goroutine should be reading or writing the map concurrently. If the runtime detects this condition, it prints a diagnosis and crashes the program. The best way to find out more about the problem is to run the program under the race detector, which will more reliably identify the race and give more detail.
棧記憶體耗盡
func main() {
defer errorHandler()
var f func(a [1000]int64)
f = func(a [1000]int64) {
f(a)
}
f([1000]int64{})
}
這個例子中會返回:
runtime: goroutine stack exceeds 1000000000-byte limit
runtime: sp=0xc0200e1be8 stack=[0xc0200e0000, 0xc0400e0000]
fatal error: stack overflow
對於棧不熟悉的同學可以看我這篇文章: 一文教你搞懂 Go 中棧操作 。下面我簡單說一下,棧的基本機制。
在Go中,Goroutines 沒有固定的堆棧大小。相反,它們開始時很小(比如4KB),在需要時增長/縮小,似乎給人一種 “無限 “堆棧的感覺。但是增長總是有限的,但是這個限制並不是來自於調用深度的限制,而是來自於堆棧記憶體的限制,在Linux 64位機器上,它是1GB。
var maxstacksize uintptr = 1 << 20 // enough until runtime.main sets it for real
func newstack() {
...
if newsize > maxstacksize || newsize > maxstackceiling {
throw("stack overflow")
}
...
}
在棧的擴張中,會校驗新的棧大小是否超過閾值 1 << 20
,超過了同樣會調用 throw("stack overflow")
執行 exit 導致整個程式 crash。
嘗試將 nil 函數交給 goroutine 啟動
func main() {
defer errorHandler()
var f func()
go f()
}
這裡也會直接 crash 掉。
所有執行緒都休眠了
正常情況下,程式中不會所有執行緒都休眠,總是會有執行緒在運行處理我們的任務,例如:
func main() {
defer errorHandler()
go func() {
for true {
fmt.Println("alive")
time.Sleep(time.Second*1)
}
}()
<-make(chan int)
}
但是也有些同學搞了一些騷操作,例如沒有很好的處理我們的程式碼邏輯,在邏輯里加入了一些會永久阻塞的程式碼:
func main() {
defer errorHandler()
go func() {
for true {
fmt.Println("alive")
time.Sleep(time.Second*1)
select {}
}
}()
<-make(chan int)
}
例如這裡在 Goroutine 裡面加入了一個 select 這樣就會造成永久阻塞,go 檢測出沒有 goroutine 可以運行了,就會直接將程式 crash 掉:
fatal error: all goroutines are asleep - deadlock!
能夠被捕獲的異常
數組 ( slice ) 下標越界
func foo(){
defer func() {
if r := recover(); r != nil {
fmt.Println(r)
}
}()
var bar = []int{1}
fmt.Println(bar[1])
}
func main(){
foo()
fmt.Println("exit")
}
返回:
runtime error: index out of range [1] with length 1
exit
因為程式碼中用了 recover
,程式得以恢復,輸出 exit
。
空指針異常
func foo(){
defer func() {
if r := recover(); r != nil {
fmt.Println(r)
}
}()
var bar *int
fmt.Println(*bar)
}
func main(){
foo()
fmt.Println("exit")
}
返回:
runtime error: invalid memory address or nil pointer dereference
exit
除了上面這種情況以外,還有一種常見的就是我們的變數是初始化了,但是卻被置空了,但是 Receiver 是一個指針:
type Shark struct {
Name string
}
func (s *Shark) SayHello() {
fmt.Println("Hi! My name is", s.Name)
}
func main() {
s := &Shark{"Sammy"}
s = nil
s.SayHello()
}
往已經 close 的 chan 中發送數據
func foo(){
defer func() {
if r := recover(); r != nil {
fmt.Println(r)
}
}()
var bar = make(chan int, 1)
close(bar)
bar<-1
}
func main(){
foo()
fmt.Println("exit")
}
返回:
send on closed channel
exit
這個異常我們在 多圖詳解Go中的Channel源碼 這篇文章裡面討論過了:
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
...
//加鎖
lock(&c.lock)
// 是否關閉的判斷
if c.closed != 0 {
unlock(&c.lock)
panic(plainError("send on closed channel"))
}
// 從 recvq 中取出一個接收者
if sg := c.recvq.dequeue(); sg != nil {
// 如果接收者存在,直接向該接收者發送數據,繞過buffer
send(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true
}
...
}
發送的時候會判斷一下 chan 是否已被關閉。
類型斷言
func foo(){
defer func() {
if r := recover(); r != nil {
fmt.Println(r)
}
}()
var i interface{} = "abc"
_ = i.([]string)
}
func main(){
foo()
fmt.Println("exit")
}
返回:
interface conversion: interface {} is string, not []string
exit
所以斷言的時候我們需要使用帶有兩個返回值的斷言:
var i interface{} = "hello"
f, ok := i.(float64) // no runtime panic
fmt.Println(f, ok)
f = i.(float64) // panic
fmt.Println(f)
類似上面的錯誤還是挺多的,具體想要深究的話可以去 stackoverflow 上面看一下://stackoverflow.com/search?q=Runtime+Panic+in+Go
總結
本篇文章從一個例子出發,然後講解了 panic & recover 的源碼。總結了一下實際開發中可能會出現的異常,runtime 包中經常會拋出一些異常,有一些異常是 recover 也捕獲不到的,有一些是正常的 panic 可以被捕獲到的,需要我們開發中時常注意,防止應用 crash。
Reference
//stackoverflow.com/questions/57486620/are-all-runtime-errors-recoverable-in-go
//xiaomi-info.github.io/2020/01/20/go-trample-panic-recover/
//draveness.me/golang/docs/part2-foundation/ch05-keyword/golang-panic-recover/
//zhuanlan.zhihu.com/p/346514343
//stackoverflow.com/questions/39288741/how-to-recover-from-concurrent-map-writes/39289246#39289246
//www.digitalocean.com/community/tutorials/handling-panics-in-go