golang panic和recover 實現原理


這篇文章是系列文章中的第二篇,系列文章主要包括:

  1. golang defer的原理
  2. golang panic和recover()函數的原理(包括golang對於錯誤處理方式)
  3. defer性能損耗的討論以及最重要的應用場景
  4. defer在golang 1.13 上的性能

panic 能中斷一個程序的執行,同時也能在一定情況下進行恢復(recover)。我們就來看一看 panic 和 recover 這對關鍵字的實現機制。根據我們對 Go 的實踐,可以預見的是,他們的實現跟runtime調度器和 defer 關鍵字也緊密相關。

思考

1.爲什麼go 進程會終止

func main() {
	panic("sim lou.")
}

輸出結果是:

panic: sim lou.

goroutine 1 [running]:
main.main()
	/Users/ytlou/Desktop/golang/golang_study/study/basic/panic/panic_test1.go:4 +0x39
Process finished with exit code 2

這裏思考一下,爲什麼執行 panic 後會導致應用程序運行中止?或者說執行panic爲什麼導致進程終止了?

2. 爲什麼不會中止運行

func main() {
	defer func() {
		if err := recover(); err != nil {
			log.Printf("recover: %v", err)
		}
	}()
	
	panic("sim lou.")
}

輸出結果是:

2019/10/26 22:19:33 recover: sim lou.

Process finished with exit code 0

思考一下爲什麼加上 defer + recover 組合就可以保護應用程序不會退出。

3.不設置 defer 行不

上面問題二是 defer + recover 組合,那我去掉 defer 是不是也可以呢?如下:

func main() {
	if err := recover(); err != nil {
		log.Printf("recover: %v", err)
	}
	panic("sim lou.")
}

運行結果:

panic: sim lou.

goroutine 1 [running]:
main.main()
	/Users/ytlou/Desktop/golang/golang_study/study/basic/panic/panic_test3.go:9 +0xa1

Process finished with exit code 2

不行!!!我們常說 defer + recover 組合 “萬能” 捕獲。但是爲什麼呢。去掉 defer 後爲什麼就無法捕獲了?

思考一下,爲什麼需要設置 defer 後 recover 才能起作用?

同時你還需要仔細想想,我們設置 defer + recover 組合後就能無憂無慮了嗎,各種 “亂” 寫了嗎?

4. 爲什麼起個 goroutine 就不行

func main() {
	go func() {
		defer func() {
			if err := recover(); err != nil {
				log.Printf("recover: %v", err)
			}
		}()
	}()
	panic("qwertyuiop.")
}

輸出結果:

panic: qwertyuiop.

goroutine 1 [running]:
main.main()
	/Users/ytlou/Desktop/golang/golang_study/study/basic/panic/panic_test4.go:13 +0x51

請思考一下,爲什麼新起了一個 Goroutine 就無法捕獲到異常了?到底發生了什麼事…

但是我們改一下:

func main() {
	go func() {
		defer func() {
			if err := recover(); err != nil {
				log.Printf("recover: %v", err)
			}
		}()
		panic("aim lou 2.")
	}()
	time.Sleep(1* time.Second)
}

輸出是:

2019/10/26 22:27:50 recover: aim lou 2.

爲什麼我們把panic放到自定義的協程裏面recover又可以work了呢?

基於前面的四個問題,我們閱讀源碼,從源碼找到root cause.

數據結構

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
	recovered bool           // whether this panic is over
	aborted   bool           // the panic was aborted
}

在 panic 中是使用 _panic 作爲其基礎單元的,每執行一次 panic 語句,都會創建一個 _panic 對象。它包含了一些基礎的字段用於存儲當前的 panic 調用情況,涉及的字段如下

  • argp:指向 defer 延遲調用的參數的指針
  • arg:panic 的原因,也就是調用 panic 時傳入的參數
  • link:指向上一個調用的 _panic,這裏說明panic也是一個鏈表
  • recovered:panic 是否已經被處理過,也就是是否被 recover
  • aborted:panic 是否被中止

通過查看 link 字段,可得知其是一個鏈表的數據結構,如下圖:

+-----------+          +-----------+          +-----------+
|  _panic   |     +---->  _panic   |    +----->  _panic   |
+-----------+     |    +-----------+    |     +-----------+
|  ......   |     |    |  ......   |    |     |  ......   |
+-----------+     |    +-----------+    |     +-----------+
|   link    |-----+    |   link    +----+     |   link    |
+-----------+          +-----------+          +-----------+
|  ......   |          |  ......   |          |  ......   |
+-----------+          +-----------+          +-----------+

panic

我們先看看panic生成的彙編代碼:

func main() {
	panic("sim lou.")
}

彙編代碼:

"".main STEXT size=65 args=0x0 locals=0x18
	0x0000 00000 (panic_test1.go:3)	TEXT	"".main(SB), ABIInternal, $24-0
......
	0x002f 00047 (panic_test1.go:4)	PCDATA	$0, $0
	0x002f 00047 (panic_test1.go:4)	MOVQ	AX, 8(SP)
	0x0034 00052 (panic_test1.go:4)	CALL	runtime.gopanic(SB)
.......

可以看到 panic 翻譯成彙編代碼主要是調用了 runtime.gopanic,我們一起來看看這個方法做了什麼事,如下(省略部分)

func gopanic(e interface{}) {
	gp := getg()
	......
	var p _panic
	p.arg = e
	// 頭插法
	p.link = gp._panic
	gp._panic = (*_panic)(noescape(unsafe.Pointer(&p)))

	for {
		d := gp._defer
		if d == nil {
			break
		}

		// If defer was started by earlier panic or Goexit (and, since we're back here, that triggered a new panic),
		// take defer off list. The earlier panic or Goexit will not continue running.
		if d.started {
			if d._panic != nil {
				d._panic.aborted = true
			}
			d._panic = nil
			d.fn = nil
			gp._defer = d.link
			freedefer(d)
			continue
		}

		// Mark defer as started, but keep on list, so that traceback
		// can find and update the defer's argument frame if stack growth
		// or a garbage collection happens before reflectcall starts executing d.fn.
		d.started = true

		// Record the panic that is running the defer.
		// If there is a new panic during the deferred call, that panic
		// will find d in the list and will mark d._panic (this panic) aborted.
		d._panic = (*_panic)(noescape(unsafe.Pointer(&p)))

		p.argp = unsafe.Pointer(getargp(0))
		reflectcall(nil, unsafe.Pointer(d.fn), deferArgs(d), uint32(d.siz), uint32(d.siz))
		p.argp = nil

		// reflectcall did not panic. Remove d.
		if gp._defer != d {
			throw("bad defer entry in panic")
		}
		d._panic = nil
		d.fn = nil
		gp._defer = d.link

		// trigger shrinkage to test stack copy. See stack_test.go:TestStackPanic
		//GC()

		pc := d.pc
		sp := unsafe.Pointer(d.sp) // must be pointer so it gets adjusted during stack copy
		freedefer(d)
		if p.recovered {
			atomic.Xadd(&runningPanicDefers, -1)

			gp._panic = p.link
			// Aborted panics are marked but remain on the g.panic list.
			// Remove them from the list.
			for gp._panic != nil && gp._panic.aborted {
				gp._panic = gp._panic.link
			}
			if gp._panic == nil { // must be done with signal
				gp.sig = 0
			}
			// Pass information about recovering frame to recovery.
			gp.sigcode0 = uintptr(sp)
			gp.sigcode1 = pc
			mcall(recovery)
			throw("recovery failed") // mcall should not return
		}
	}

	preprintpanics(gp._panic)

	fatalpanic(gp._panic) // should not return
	*(*int)(nil) = 0      // not reached
}
  • 獲取指向當前 Goroutine 的指針
  • 初始化一個 panic 的基本單位 _panic,並將這個panic頭插入當前goroutine的panic鏈表中。
  • 獲取當前 Goroutine 上掛載的 _defer(數據結構也是鏈表)
  • 若當前存在 defer 調用,則調用 reflectcall 方法去執行先前 defer 中延遲執行的代碼。reflectcall方法若在執行過程中需要運行 recover 將會調用 gorecover 方法。
  • 結束前,使用 preprintpanics 方法打印出所涉及的 panic 消息
  • 最後調用 fatalpanic 中止應用程序,實際是執行 exit(2) 進行最終退出行爲的。

通過對上述代碼的執行分析,可得知 panic 方法實際上就是處理當前 Goroutine(g) 上所掛載的 ._panic 鏈表(所以無法對其他 Goroutine 的異常事件響應),然後對其所屬的 defer 鏈表和 recover 進行檢測並處理,最後調用退出命令中止應用程序。

恢復 recover panic

func main() {
	defer func() {
		if err := recover(); err != nil {
			log.Printf("recover: %v", err)
		}
	}()
	
	panic("sim lou.")
}

輸出結果:

2019/10/27 12:39:30 recover: sim lou.

Process finished with exit code 0

我們看彙編代碼,panic是怎麼被recover的:

"".main STEXT size=118 args=0x0 locals=0x50
	......
	0x003a 00058 (panic_test2.go:6)	CALL	runtime.deferprocStack(SB)
	......
	0x005a 00090 (panic_test2.go:12)	CALL	runtime.gopanic(SB)
	......
	0x0060 00096 (panic_test2.go:6)	CALL	runtime.deferreturn(SB)
	......
"".main.func1 STEXT size=151 args=0x0 locals=0x40
	0x0000 00000 (panic_test2.go:6)	TEXT	"".main.func1(SB), ABIInternal, $64-0
	......
	0x0026 00038 (panic_test2.go:7)	CALL	runtime.gorecover(SB)
	......
	0x0092 00146 (panic_test2.go:6)	JMP	0

通過分析底層調用,可得知主要是如下幾個方法:

  • runtime.deferprocStack
  • runtime.gopanic
  • runtime.deferreturn
  • runtime.gorecover

前面我們說了簡單的流程,gopanic 方法會遍歷調用當前 Goroutine 下的 defer 鏈表,若 reflectcall 執行中遇到 recover 就會調用 gorecover 進行處理,該方法代碼如下:

func gorecover(argp uintptr) interface{} {
	// Must be in a function running as part of a deferred call during the panic.
	// Must be called from the topmost function of the call
	// (the function used in the defer statement).
	// p.argp is the argument pointer of that topmost deferred function call.
	// Compare against argp reported by caller.
	// If they match, the caller is the one who can recover.
	gp := getg()
	p := gp._panic
	if p != nil && !p.recovered && argp == uintptr(p.argp) {
		p.recovered = true
		return p.arg
	}
	return nil
}

這代碼,看上去挺簡單的,核心就是修改 recovered 字段。該字段是用於標識當前 panic 是否已經被 recover 處理。但是這和我們想象的並不一樣啊,程序是怎麼從 panic 流轉回去的呢?是不是在覈心方法裏處理了呢?我們再看看 gopanic 的代碼,如下:

func gopanic(e interface{}) {
    ...
    for {
        // defer...
        ...
        pc := d.pc
        sp := unsafe.Pointer(d.sp) // must be pointer so it gets adjusted during stack copy
        freedefer(d)

        // recover...
        if p.recovered {
            atomic.Xadd(&runningPanicDefers, -1)

            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") 
        }
    }
    ...
}

我們回到 gopanic 方法中再仔細看看,發現實際上是包含對 recover 流轉的處理代碼的。恢復流程如下:

  • 判斷當前 _panic 中的 recover 是否已標註爲處理
  • 從 _panic 鏈表中刪除已標註中止的 panic 事件,也就是刪除已經被恢復的 panic 事件
  • 將相關需要恢復的棧幀信息傳遞給 recovery 方法的 gp 參數(每個棧幀對應着一個未運行完的函數。棧幀中保存了該函數的返回地址和局部變量)
  • 執行 recovery 進行恢復動作
  • 從流程來看,最核心的是 recovery 方法。它承擔了異常流轉控制的職責。代碼如下:
func recovery(gp *g) {
	// Info about defer passed in G struct.
	sp := gp.sigcode0
	pc := gp.sigcode1

	// d's arguments need to be in the stack.
	if sp != 0 && (sp < gp.stack.lo || gp.stack.hi < sp) {
		print("recover: ", hex(sp), " not in [", hex(gp.stack.lo), ", ", hex(gp.stack.hi), "]\n")
		throw("bad recovery")
	}

	// Make the deferproc for this d return again,
	// this time returning 1.  The calling function will
	// jump to the standard return epilogue.
	gp.sched.sp = sp
	gp.sched.pc = pc
	gp.sched.lr = 0
	gp.sched.ret = 1
	gogo(&gp.sched)
}

粗略一看,似乎就是很簡單的設置了一些值?但實際上設置的是編譯器中僞寄存器的值,常常被用於維護上下文等。在這裏我們需要結合 gopanic 方法一同觀察 recovery 方法。它所使用的棧指針 sp 和程序計數器 pc 是由當前 defer 在調用流程中的 deferproc 傳遞下來的,因此實際上最後是通過 gogo 方法跳回了 deferproc 方法。另外我們注意到:

gp.sched.ret = 1

在底層中程序將 gp.sched.ret 設置爲了 1,也就是沒有實際調用 deferproc 方法,直接修改了其返回值。意味着默認它已經處理完成。直接轉移到 deferproc 方法的下一條指令去。至此爲止,異常狀態的流轉控制就已經結束了。接下來就是繼續走 defer 的流程了.

panic 拋出

當然如果所有的 defer 都沒有指明顯式的 recover,那麼這時候則直接在運行時拋出 panic 信息:

// 消耗完所有的 defer 調用,保守地進行 panic
// 因爲在凍結之後調用任意用戶代碼是不安全的,所以我們調用 preprintpanics 來調用
// 所有必要的 Error 和 String 方法來在 startpanic 之前準備 panic 字符串。
preprintpanics(gp._panic)

fatalpanic(gp._panic) // 不應該返回
*(*int)(nil) = 0      // 無法觸及

總結:

從 panic 和 recover 這對關鍵字的實現上可以看出,可恢復的 panic 必須要 recover 的配合。 而且,這個 recover 必須位於同一 goroutine 的直接調用鏈上(例如,如果 A 依次調用了 B 和 C,而 B 包含了 recover,而 C 發生了 panic,則這時 B 的 panic 無法恢復 C 的 panic; 又例如 A 調用了 B 而 B 又調用了 C,那麼 C 發生 panic 時,如果 A 要求了 recover 則仍然可以恢復), 否則無法對 panic 進行恢復。

當一個 panic 被恢復後,調度並因此中斷,會重新進入調度循環,進而繼續執行 recover 後面的代碼, 包括比 recover 更早的 defer(因爲已經執行過得 defer 已經被釋放,而尚未執行的 defer 仍在 goroutine 的 defer 鏈表中), 或者 recover 所在函數的調用方。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章