go中panic源碼解讀

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

Tags: