go中panic原始碼解讀

Rick.lz發表於2021-04-23

panic原始碼解讀

前言

本文是在go version go1.13.15 darwin/amd64上進行的

panic的作用

  • panic能夠改變程式的控制流,呼叫panic後會立刻停止執行當前函式的剩餘程式碼,並在當前Goroutine中遞迴執行呼叫方的defer

  • recover可以中止panic造成的程式崩潰。它是一個只能在defer中發揮作用的函式,在其他作用域中呼叫不會發揮作用;

舉個例子

package main

import "fmt"

func main() {
	fmt.Println(1)
	func() {
		fmt.Println(2)
		panic("3")
	}()
	fmt.Println(4)
}

輸出

1
2
panic: 3

goroutine 1 [running]:
main.main.func1(...)
        /Users/yj/Go/src/Go-POINT/panic/main.go:9
main.main()
        /Users/yj/Go/src/Go-POINT/panic/main.go:10 +0xee

panic後會立刻停止執行當前函式的剩餘程式碼,所以4沒有列印出來

對於recover

  • panic只會觸發當前Goroutine的defer;

  • recover只有在defer中呼叫才會生效;

  • panic允許在defer中巢狀多次呼叫;

package main

import (
	"fmt"
	"time"
)

func main() {
	fmt.Println(1)

	defer func() {
		if err := recover(); err != nil {
			fmt.Println(err)
		}
	}()

	go func() {
		fmt.Println(2)
		panic("3")
	}()
	time.Sleep(time.Second)
	fmt.Println(4)
}

上面的栗子,因為recoverpanic不在同一個goroutine中,所以不會捕獲到

巢狀的demo

func main() {
	defer fmt.Println("in main")
	defer func() {
		defer func() {
			panic("3 panic again and again")
		}()
		panic("2 panic again")
	}()

	panic("1 panic once")
}

輸出

in main
panic: 1 panic once
        panic: 2 panic again
        panic: 3 panic again and again

goroutine 1 [running]:
...

多次呼叫panic也不會影響defer函式的正常執行,所以使用defer進行收尾工作一般來說都是安全的。

panic使用場景

  • error:可預見的錯誤

  • panic:不可預見的異常

需要注意的是,你應該儘可能地使用error,而不是使用panicrecover。只有當程式不能繼續執行的時候,才應該使用panicrecover機制。

panic有兩個合理的用例。

1、發生了一個不能恢復的錯誤,此時程式不能繼續執行。 一個例子就是 web 伺服器無法繫結所要求的埠。在這種情況下,就應該使用 panic,因為如果不能繫結埠,啥也做不了。

2、發生了一個程式設計上的錯誤。 假如我們有一個接收指標引數的方法,而其他人使用 nil 作為引數呼叫了它。在這種情況下,我們可以使用panic,因為這是一個程式設計錯誤:用 nil 引數呼叫了一個只能接收合法指標的方法。

在一般情況下,我們不應通過呼叫panic函式來報告普通的錯誤,而應該只把它作為報告致命錯誤的一種方式。當某些不應該發生的場景發生時,我們就應該呼叫panic。

總結下panic的使用場景:

  • 1、空指標引用

  • 2、下標越界

  • 3、除數為0

  • 4、不應該出現的分支,比如default

  • 5、輸入不應該引起函式錯誤

看下實現

先來看下_panic的結構

// _panic 儲存了一個活躍的 panic
//
// 這個標記了 go:notinheap 因為 _panic 的值必須位於棧上
//
// argp 和 link 欄位為棧指標,但在棧增長時不需要特殊處理:因為他們是指標型別且
// _panic 值只位於棧上,正常的棧指標調整會處理他們。
//
//go:notinheap
type _panic struct {
	argp      unsafe.Pointer // panic 期間 defer 呼叫引數的指標; 無法移動 - liblink 已知
	arg       interface{}    // panic的引數
	link      *_panic        // link 連結到更早的 panic
	recovered bool           // panic是否結束
	aborted   bool           // panic是否被忽略
}

link指向了儲存在goroutine連結串列中先前的panic連結串列

gopanic

編譯器會將panic裝換成gopanic,來看下執行的流程:

1、建立新的runtime._panic並新增到所在Goroutine的_panic連結串列的最前面;

2、在迴圈中不斷從當前Goroutine 的_defer中連結串列獲取runtime._defer並呼叫runtime.reflectcall執行延遲呼叫函式;

3、呼叫runtime.fatalpanic中止整個程式;

// 預先宣告的函式 panic 的實現
func gopanic(e interface{}) {
	gp := getg()
	// 判斷在系統棧上還是在使用者棧上
	// 如果執行在系統或訊號棧時,getg() 會返回當前 m 的 g0 或 gsignal
	// 因此可以通過 gp.m.curg == gp 來判斷所在棧
	// 系統棧上的 panic 無法恢復
	if gp.m.curg != gp {
		print("panic: ")
		printany(e)
		print("\n")
		throw("panic on system stack")
	}
	// 如果正在進行 malloc 時發生 panic 也無法恢復
	if gp.m.mallocing != 0 {
		print("panic: ")
		printany(e)
		print("\n")
		throw("panic during malloc")
	}
	// 在禁止搶佔時發生 panic 也無法恢復
	if gp.m.preemptoff != "" {
		print("panic: ")
		printany(e)
		print("\n")
		print("preempt off reason: ")
		print(gp.m.preemptoff)
		print("\n")
		throw("panic during preemptoff")
	}
	// 在 g 鎖在 m 上時發生 panic 也無法恢復
	if gp.m.locks != 0 {
		print("panic: ")
		printany(e)
		print("\n")
		throw("panic holding locks")
	}

	// 下面是可以恢復的
	var p _panic
	p.arg = e
	// panic 儲存了對應的訊息,並指向了儲存在 goroutine 連結串列中先前的 panic 連結串列
	p.link = gp._panic
	gp._panic = (*_panic)(noescape(unsafe.Pointer(&p)))

	atomic.Xadd(&runningPanicDefers, 1)

	for {
		// 開始逐個取當前 goroutine 的 defer 呼叫
		d := gp._defer
		// 沒有defer,退出迴圈
		if d == nil {
			break
		}

		// 如果 defer 是由早期的 panic 或 Goexit 開始的(並且,因為我們回到這裡,這引發了新的 panic),
		// 則將 defer 帶離連結串列。更早的 panic 或 Goexit 將無法繼續執行。
		if d.started {
			if d._panic != nil {
				d._panic.aborted = true
			}
			d._panic = nil
			d.fn = nil
			gp._defer = d.link
			freedefer(d)
			continue
		}

		// 將deferred標記為started
		// 如果棧增長或者垃圾回收在 reflectcall 開始執行 d.fn 前發生
		// 標記 defer 已經開始執行,但仍將其儲存在列表中,從而 traceback 可以找到並更新這個 defer 的引數幀

		// 標記defer是否已經執行
		d.started = true

		// 記錄正在執行的延遲的panic。
		// 如果在延遲呼叫期間有新的panic,那麼這個panic
		// 將在列表中找到d,並將標記d._panic(此panic)中止。
		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沒有panic。刪除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
			// 忽略的 panic 會被標記,但仍然保留在 g.panic 列表中
			// 這裡將它們移出列表
			for gp._panic != nil && gp._panic.aborted {
				gp._panic = gp._panic.link
			}
			if gp._panic == nil { // 必須由 signal 完成
				gp.sig = 0
			}
			// 傳遞關於恢復幀的資訊
			gp.sigcode0 = uintptr(sp)
			gp.sigcode1 = pc
			// 呼叫 recover,並重新進入排程迴圈,不再返回
			mcall(recovery)
			// 如果無法重新進入排程迴圈,則無法恢復錯誤
			throw("recovery failed") // mcall should not return
		}
	}

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

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

// reflectcall 使用 arg 指向的 n 個引數位元組的副本呼叫 fn。
// fn 返回後,reflectcall 在返回之前將 n-retoffset 結果位元組複製回 arg+retoffset。
// 如果重新複製結果位元組,則呼叫者應將引數幀型別作為 argtype 傳遞,以便該呼叫可以在複製期間執行適當的寫障礙。
// reflect 包傳遞幀型別。在 runtime 包中,只有一個呼叫將結果複製回來,即 cgocallbackg1,
// 並且它不傳遞幀型別,這意味著沒有呼叫寫障礙。參見該呼叫的頁面瞭解相關理由。
//
// 包 reflect 通過 linkname 訪問此符號
func reflectcall(argtype *_type, fn, arg unsafe.Pointer, argsize uint32, retoffset uint32)

梳理下流程

1、在處理panic期間,會先判斷當前panic的型別,確定panic是否可恢復;

  • 系統棧上的panic無法恢復
  • 如果正在進行malloc時發生panic也無法恢復
  • 在禁止搶佔時發生panic也無法恢復
  • 在g鎖在m上時發生panic也無法恢復

2、可恢復的panicpaniclink指向goroutine連結串列中先前的panic連結串列;

3、迴圈逐個獲取當前goroutinedefer呼叫;

  • 如果defer是由早期panic或Goexit開始的,則將defer帶離連結串列,更早的panic或Goexit將無法繼續執行,也就是將之前的panic終止掉,將aborted設定為true,在下面執行recover時保證goexit不會被取消;

  • recovered會在gorecover中被標記,見下文。當recovered被標記為true時,recovery函式觸發Goroutine的排程,排程之前會準備好 sp、pc 以及函式的返回值;

  • 當延遲函式中recover了一個panic時,就會返回1,當runtime.deferproc函式的返回值是1時,編譯器生成的程式碼會直接跳轉到呼叫方函式返回之前並執行runtime.deferreturn,跳轉到runtime.deferturn函式之後,程式就已經從panic恢復了正常的邏輯。而runtime.gorecover函式也能從runtime._panic結構中取出了呼叫panic時傳入的arg引數並返回給呼叫方。

// 在發生 panic 後 defer 函式呼叫 recover 後展開棧。然後安排繼續執行,
// 就像 defer 函式的呼叫方正常返回一樣。
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")
	}

	// 使 deferproc 為此 d 返回
	// 這時候返回 1。呼叫函式將跳轉到標準的返回尾聲
	gp.sched.sp = sp
	gp.sched.pc = pc
	gp.sched.lr = 0
	gp.sched.ret = 1
	gogo(&gp.sched)
}

recovery函式中,利用g中的兩個狀態碼回溯棧指標sp並恢復程式計數器pc到排程器中,並呼叫gogo重新排程g,將g恢復到呼叫recover函式的位置,goroutine繼續執行,recovery在排程過程中會將函式的返回值設定為1。呼叫函式將跳轉到標準的返回尾聲。

func deferproc(siz int32, fn *funcval) { // arguments of fn follow fn
	...

	// 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()
	// No code can go here - the C return register has
	// been set and must not be clobbered.
}

當延遲函式中recover了一個panic時,就會返回1,當runtime.deferproc函式的返回值是1時,編譯器生成的程式碼會直接跳轉到呼叫方函式返回之前並執行runtime.deferreturn,跳轉到runtime.deferturn函式之後,程式就已經從panic恢復了正常的邏輯。而runtime.gorecover函式也能從runtime._panic結構中取出了呼叫panic時傳入的arg引數並返回給呼叫方。

gorecover

編譯器會將recover裝換成gorecover

如果recover被正確執行了,也就是gorecover,那麼recovered將被標記成true

// go/src/runtime/panic.go
// 執行預先宣告的函式 recover。
// 不允許分段棧,因為它需要可靠地找到其呼叫者的棧段。
//
// TODO(rsc): Once we commit to CopyStackAlways,
// this doesn't need to be nosplit.
//go:nosplit
func gorecover(argp uintptr) interface{} {
	// 必須在 panic 期間作為 defer 呼叫的一部分在函式中執行。
	// 必須從呼叫的最頂層函式( defer 語句中使用的函式)呼叫。
	// p.argp 是最頂層 defer 函式呼叫的引數指標。
	// 比較呼叫方報告的 argp,如果匹配,則呼叫者可以恢復。
	gp := getg()
	p := gp._panic
	if p != nil && !p.recovered && argp == uintptr(p.argp) {
		// 標記recovered
		p.recovered = true
		return p.arg
	}
	return nil
}

在正常情況下,它會修改runtime._panicrecovered欄位,runtime.gorecover函式中並不包含恢復程式的邏輯,程式的恢復是由runtime.gopanic函式負責。

gorecoverrecovered標記為true,然後gopanic就可以通過mcall呼叫recovery並重新進入排程迴圈

fatalpanic

runtime.fatalpanic實現了無法被恢復的程式崩潰,它在中止程式之前會通過runtime.printpanics列印出全部的panic訊息以及呼叫時傳入的引數:

// go/src/runtime/panic.go
// fatalpanic 實現了不可恢復的 panic。類似於 fatalthrow,
// 如果 msgs != nil,則 fatalpanic 仍然能夠列印 panic 的訊息
// 並在 main 在退出時候減少 runningPanicDeferss
//
//go:nosplit
func fatalpanic(msgs *_panic) {
	// 返回程式計數暫存器指標
	pc := getcallerpc()
	// 返回堆疊指標
	sp := getcallersp()
	// 返回當前G
	gp := getg()
	var docrash bool
	// 切換到系統棧來避免棧增長,如果執行時狀態較差則可能導致更糟糕的事情
	systemstack(func() {
		if startpanic_m() && msgs != nil {
			// 有 panic 訊息和 startpanic_m 則可以嘗試列印它們

			// startpanic_m 設定 panic 會從阻止 main 的退出,
			// 因此現在可以開始減少 runningPanicDefers 了
			atomic.Xadd(&runningPanicDefers, -1)

			printpanics(msgs)
		}

		docrash = dopanic_m(gp, pc, sp)
	})

	if docrash {
		// 通過在上述 systemstack 呼叫之外崩潰,偵錯程式在生成回溯時不會混淆。
		// 函式崩潰標記為 nosplit 以避免堆疊增長。
		crash()
	}
	// 從系統推出
	systemstack(func() {
		exit(2)
	})

	*(*int)(nil) = 0 // not reached
}

// 列印出當前活動的panic
func printpanics(p *_panic) {
	if p.link != nil {
		printpanics(p.link)
		print("\t")
	}
	print("panic: ")
	printany(p.arg)
	if p.recovered {
		print(" [recovered]")
	}
	print("\n")
}

總結

引一段來自【panic 和recover】的總結

1、編譯器會負責做轉換關鍵字的工作;

  • 1、將panicrecover分別轉換成runtime.gopanicruntime.gorecover

  • 2、將defer轉換成runtime.deferproc函式;

  • 3、在呼叫defer的函式末尾呼叫runtime.deferreturn函式;

2、在執行過程中遇到runtime.gopanic方法時,會從Goroutine的連結串列依次取出runtime._defer結構體並執行;

3、如果呼叫延遲執行函式時遇到了runtime.gorecover就會將_panic.recovered標記成true並返回panic的引數;

  • 1、在這次呼叫結束之後,runtime.gopanic會從runtime._defer結構體中取出程式計數器pc和棧指標sp並呼叫runtime.recovery函式進行恢復程式;

  • 2、runtime.recovery會根據傳入的pcsp跳轉回runtime.deferproc

  • 3、編譯器自動生成的程式碼會發現runtime.deferproc的返回值不為0,這時會跳回runtime.deferreturn並恢復到正常的執行流程;

4、如果沒有遇到runtime.gorecover就會依次遍歷所有的runtime._defer,並在最後呼叫runtime.fatalpanic中止程式、列印panic的引數並返回錯誤碼2

參考

【panic 和 recover】https://draveness.me/golang/docs/part2-foundation/ch05-keyword/golang-panic-recover/
【恐慌與恢復內建函式】https://golang.design/under-the-hood/zh-cn/part1basic/ch03lang/panic/
【Go語言panic/recover的實現】https://zhuanlan.zhihu.com/p/72779197
【panic and recover】https://eddycjy.gitbook.io/golang/di-6-ke-chang-yong-guan-jian-zi/panic-and-recover#yuan-ma
【翻了原始碼,我把 panic 與 recover 給徹底搞明白了】https://jishuin.proginn.com/p/763bfbd4ed8c

相關文章