1. 程式人生 > 其它 >探究 Go 原始碼中 panic & recover 有哪些坑?

探究 Go 原始碼中 panic & recover 有哪些坑?

轉載請宣告出處哦~,本篇文章釋出於luozhiyun的部落格: https://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 三個欄位的討論以及程式碼提交可以看看這裡:https://github.com/golang/go/commit/7dcd343ed641d3b70c09153d3b041ca3fe83b25e 以及這個討論 runtime: panic + recover can cancel a call to Goexit

panic 流程

  1. 編譯器會將關鍵字 panic 轉換成 runtime.gopanic 並呼叫,然後在迴圈中不斷從當前 Goroutine 的 defer 連結串列獲取 defer 並執行;
  2. 如果在呼叫的 defer 函式中有 recover ,那麼就會呼叫到 runtime.gorecover,它會修改 runtime._panic 的 recovered 欄位為 true;
  3. 呼叫完 defer 函式之後回到 runtime.gopanic 主邏輯中,檢查 recovered 欄位為 true 會從 runtime._defer 結構體中取出程式計數器pc和棧指標sp並呼叫runtime.recovery函式恢復程式。runtime.recvoery 在排程過程中會將函式的返回值設定成 1;
  4. runtime.deferproc 函式的返回值是 1 時,編譯器生成的程式碼會直接跳轉到呼叫方函式返回之前並執行 runtime.deferreturn,然後程式就已經從 panic 中恢復了並執行正常的邏輯;
  5. 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), &regs) 
		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       
}

我們先來看看這段邏輯:

  1. 首先會獲取當前的 Goroutine ,並建立新的 runtime._panic 並新增到所在 Goroutine 的 _panic 連結串列的最前面;
  2. 接著會進入到迴圈獲取當前 Goroutine 的 defer 連結串列,並呼叫 reflectcall 執行 defer 函式;
  3. 執行完之後會將 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 上面看一下:https://stackoverflow.com/search?q=Runtime+Panic+in+Go

總結

本篇文章從一個例子出發,然後講解了 panic & recover 的原始碼。總結了一下實際開發中可能會出現的異常,runtime 包中經常會丟擲一些異常,有一些異常是 recover 也捕獲不到的,有一些是正常的 panic 可以被捕獲到的,需要我們開發中時常注意,防止應用 crash。

Reference

https://stackoverflow.com/questions/57486620/are-all-runtime-errors-recoverable-in-go

https://xiaomi-info.github.io/2020/01/20/go-trample-panic-recover/

https://draveness.me/golang/docs/part2-foundation/ch05-keyword/golang-panic-recover/

https://zhuanlan.zhihu.com/p/346514343

https://stackoverflow.com/questions/39288741/how-to-recover-from-concurrent-map-writes/39289246#39289246

https://www.digitalocean.com/community/tutorials/handling-panics-in-go