GO 运行时内部原理

defer / panic / recover 内部原理

defer 看起来是"函数返回前执行"这么简单一句话,但只要涉及嵌套 defer、跨函数的 panic、以及 recover 到底能不能接住,直觉就很容易出错。这篇文章配了两个逐步播放且和真实 go run 输出逐行比对过的调用栈模拟场景,以及若干条直接引用自 Go 运行时源码的片段。

· 引用 Go 源码 commit 72aa6db7,文件 src/runtime/panic.goruntime2.go · 源码遵循 BSD-3-Clause 协议,版权归 The Go Authors 所有,下文均为简短引用并附原文链接
defer · 每个函数调用自己的一条 LIFO 链 调用栈 · panic 沿着它一帧一帧往上问 panic → recover · 只在直接 defer 里当场有效

01 · 一个反直觉的例子

defer 到底属于谁

先看一段代码,不运行,自己猜输出顺序:

nested-defer.go猜猜输出顺序
func f() {
    defer fmt.Println("D")
    defer func() {
        fmt.Println("A")
        defer fmt.Println("B")
        fmt.Println("C")
    }()
}

直觉上很多人会猜 A D B 或者 A B C D,但真实运行(go run 验证过)的结果是 A C B D。原因是 defer 语句永远注册到"当前正在执行的这个函数"自己的 defer 链上——外层匿名函数被 f 的 defer 触发执行后,它内部新出现的 defer fmt.Println("B") 注册的是匿名函数自己的链,不是 f 的;这个匿名函数必须先把自己的 defer(B)跑完、真正返回,f 的 defer 循环才会继续弹出下一个(D)。defer 本质上是"每次函数调用自己的一个 LIFO 栈",嵌套 defer 只是让这个"栈的栈"结构递归了一层。

02 · 三个核心概念

defer 链 / 调用栈 / panic-recover

_defer

每次执行 defer f(),运行时就把一个 _defer 结构体插到这个 goroutine 的链表头部——包括要调用的函数、注册时的栈指针 sp,和指向下一个 _deferlink。函数返回时沿着链表逐个弹出、逐个执行,天然就是 LIFO。

调用栈

panic 不知道"该找谁处理",它只会不断问:当前帧还有没有没跑完的 defer?有就跑;没有就翻到调用者继续问;一直问到某个 defer 里调用了 recover(),或者问到栈底还没人接住。

panic → recover

recover() 不是"抓住任何地方抛出的异常",它只在被 panic 帧直接 defer 的那个函数内部、当场调用时才生效——多包一层函数调用,或者写成 defer recover(),都不行。

runtime2.go · L1154-L1165在 GitHub 上查看 ↗
type _defer struct {
	heap      bool
	rangefunc bool    // true for rangefunc list
	sp        uintptr // sp at time of defer
	pc        uintptr // pc at time of defer
	fn        func()  // can be nil for open-coded defers
	link      *_defer // next defer on G; can point to either heap or stack!

	// If rangefunc is true, *head is the head of the atomic linked list
	// during a range-over-func execution.
	head *atomic.Pointer[_defer]
}
runtime2.go · L1175-L1201(节选)在 GitHub 上查看 ↗
type _panic struct {
	arg  any     // argument to panic
	link *_panic // link to earlier panic

	// startPC and startSP track where _panic.start was called.
	// (These are the SP and PC of the gopanic frame itself.)
	startPC uintptr
	startSP unsafe.Pointer

	// The current stack frame that we're running deferred calls for.
	pc uintptr
	sp unsafe.Pointer
	fp unsafe.Pointer

	// retpc stores the PC where the panic should jump back to, if the
	// function last returned by _panic.nextDefer() recovers the panic.
	retpc uintptr

	// Extra state for handling open-coded defers.
	deferBitsPtr *uint8
	slotsPtr     unsafe.Pointer

	recovered   bool // whether this panic has been recovered
	repanicked  bool // whether this panic repanicked
	goexit      bool
	deferreturn bool
}

注意 _panic 也有一个 link 字段——这意味着 panic 本身也能连成链表。如果一个 deferred 函数在处理上一个 panic 的过程中自己又 panic 了,新的 _panic 会挂在旧的前面,后面第 07 节会专门验证这件事。

03 · 现场直播

两个真实验证过的调用栈场景

下面两个场景都是先用 go run 跑出真实输出,再逐行比对生成的轨迹——不是凭感觉画的动画。场景一是"跨三层函数调用的 panic → recover",顺带验证 defer 参数的求值时机;场景二是"defer 里再次 panic",验证前一个 panic 的值会发生什么。

点击"下一步"或"播放"开始。
0 / 0
正常帧 正在展开 panic 已 recover,准备正常返回 下一个要弹出执行的 defer

04 · panic 的核心循环

gopanic 在问什么

抛开各种前置的安全检查(比如不能在系统栈上 panic、持锁时不能 panic),gopanic 的核心逻辑短得惊人——就是一个"要 defer 就跑,跑完了再要"的循环:

panic.go · L848-L860在 GitHub 上查看 ↗
	var p _panic
	p.arg = e

	runningPanicDefers.Add(1)

	p.start(sys.GetCallerPC(), unsafe.Pointer(sys.GetCallerSP()))
	for {
		fn, ok := p.nextDefer()
		if !ok {
			break
		}
		fn()
	}

p.nextDefer() 每次返回"下一个该跑的 defer 函数",这个函数可能在当前帧,也可能在翻过好几层调用者之后的某一帧——具体怎么翻,下一节细说。如果这个循环把整个 goroutine 里所有的 defer 都跑完了,还是没人调用 recover(),就轮到程序崩溃:

panic.go · L871-L880在 GitHub 上查看 ↗

	// ran out of deferred calls - old-school panic now
	// Because it is unsafe to call arbitrary user code after freezing
	// the world, we call preprintpanics to invoke all necessary Error
	// and String methods to prepare the panic strings before startpanic.
	preprintpanics(&p)

	fatalpanic(&p)   // should not return
	*(*int)(nil) = 0 // not reached
}

这也是为什么"未处理的 panic"会打印出完整的调用栈——fatalpanic 就是那个最终打印 panic: xxx 加 goroutine 堆栈然后退出进程的地方。

05 · defer 链是怎么被消费的

沿着调用栈一帧一帧问下去

nextDefer(略去开放编码 defer 那部分优化路径)做的事情很直接:看这个 goroutine 头上的 _defer 是不是属于"当前正在处理的这一帧"(靠比较 sp),是就摘下来执行;不是,说明这一帧已经没有 defer 了,翻到调用者的帧继续问:

panic.go · L926-L929, L971-L987(节选)在 GitHub 上查看 ↗
// nextDefer returns the next deferred function to invoke, if any.
//
// Note: The "ok bool" result is necessary to correctly handle when
// the deferred function itself was nil (e.g., "defer (func())(nil)").
	...
	Recheck:
		if d := gp._defer; d != nil && d.sp == uintptr(p.sp) {
			if d.rangefunc {
				deferconvert(d)
				popDefer(gp)
				goto Recheck
			}

			fn := d.fn

			p.retpc = d.pc

			// Unlink and free.
			popDefer(gp)

			return fn, true
		}

模拟器场景一里,b() panic 之后,先跑完 b 自己剩下的两个 defer,发现 b 没有更多 defer 了,才"翻页"翻到调用者 a 继续跑 a 的 defer——这一步在模拟器里对应"unwind"这一条日志。

06 · recover 为什么必须"当场、直接"调用

gorecover 的三张图

这是整个 panic.go 里注释写得最用心的一段,直接把"能 recover"和"不能 recover"的调用栈画了出来:

panic.go · L1083-L1133在 GitHub 上查看 ↗
func gorecover() any {
	gp := getg()
	p := gp._panic
	if p == nil || p.goexit || p.recovered {
		return nil
	}

	// Check to see if the function that called recover() was
	// deferred directly from the panicking function.
	// For code like:
	//     func foo() {
	//         defer bar()
	//         panic("panic")
	//     }
	//     func bar() {
	//         recover()
	//     }
	// Normally the stack would look like this:
	//     foo
	//     runtime.gopanic
	//     bar
	//     runtime.gorecover
	//
	// However, if the function we deferred requires a wrapper
	// of some sort, we need to ignore the wrapper. In that case,
	// the stack looks like:
	//     foo
	//     runtime.gopanic
	//     wrapper
	//     bar
	//     runtime.gorecover
	// And we should also successfully recover.
	//
	// Finally, in the weird case "defer recover()", the stack looks like:
	//     foo
	//     runtime.gopanic
	//     wrapper
	//     runtime.gorecover
	// And we should not recover in that case.
	//
	// So our criteria is, there must be exactly one non-wrapper
	// frame between gopanic and gorecover.
	//
	// We don't recover this:
	//     defer func() { func() { recover() }() }()
	// because there are 2 non-wrapper frames.
	//
	// We don't recover this:
	//     defer recover()
	// because there are 0 non-wrapper frames.
	canRecover := false

翻译一下判定标准:gopanic 和 gorecover 之间必须恰好隔着一个"非包装"帧。正常的 defer bar()bar 直接调用 recover(),中间只隔着 bar 自己这一帧,能recover;如果写成 defer func(){ func(){ recover() }() }(),中间隔了两层,不行;如果写成 defer recover(),压根没有额外的帧,也不行。这就是为什么教程里反复强调"recover 必须写在 defer 的函数体里直接调用",而不能包在另一层函数或者放进 if 判断之外的辅助函数里。

模拟器里对应的位置:场景一的 a 里那个 defer 是 func(){ ...; recover(); ... }()——recover() 前面只隔着这一层匿名函数,满足"恰好一个非包装帧",所以能成功接住。

07 · recover 之后,当前帧剩下的 defer 还跑不跑

会,而且必须跑完

模拟器场景一里一个容易被忽略的细节:a 里的 defer 注册顺序是 a defer 1recover 函数a defer 2,LIFO 弹出顺序反过来,recover 发生在中间那一个。recover 只是拦住了"继续往上层传播"这件事,不会打断当前帧自己 defer 链的执行——所以 recover 成功之后,a defer 1 依然会正常执行,才轮到 a() 真正返回。真实输出(已用 go run 验证)里 a defer 1 排在 a: recovered: boom 后面就是这个原因。

panic.go · L1310-L1326(recovery 节选)在 GitHub 上查看 ↗
func recovery(gp *g) {
	p := gp._panic
	pc, sp, fp := p.retpc, uintptr(p.sp), uintptr(p.fp)
	p0, saveOpenDeferState := p, p.deferBitsPtr != nil && *p.deferBitsPtr != 0

	// The linker records the f-relative address of a call to deferreturn in f's funcInfo.
	// Assuming a "normal" call to recover() inside one of f's deferred functions
	// invoked for a panic, that is the desired PC for exiting f.
	f := findfunc(pc)
	if f.deferreturn == 0 {
		throw("no deferreturn")
	}
	gotoPc := f.entry() + uintptr(f.deferreturn)

	// Unwind the panic stack.
	for ; p != nil && uintptr(p.startSP) < sp; p = p.link {
		// Don't allow jumping past a pending Goexit.

recover 触发后,运行时会算出 adeferreturn 这个"退出点"的地址,直接跳过去继续正常执行——而不是简单地从 gopanic 那个循环里 return。deferreturn 是编译器在每个函数末尾插入的"跑完剩下的 defer 再真正返回"代码,场景一里 a defer 1 正是被这一段跑掉的。

08 · 再 panic 一次会怎样

后一个 panic 会盖掉前一个

如果一个 deferred 函数在处理某次 panic 的过程中自己又 panic 了,会发生什么?模拟器场景二验证了这件事——c()panic("first panic"),它的一个 defer 又紧接着 panic("second panic"),另一个 defer 里调用 recover()。真实运行结果是:

go run 真实输出已验证
c: about to repanic
c: recovered: second panic
main: after c()
main defer

recover() 拿到的是 "second panic","first panic" 就这样悄悄消失了——只有在完全不 recover、任由程序崩溃的情况下,才能在最终的崩溃信息里看到两个 panic 被串起来:

go run 真实输出(去掉 recover 之后)已验证
panic: first panic
	panic: second panic

这就对应上面 _panic.link 那个链表:第二次 panic 时,新的 _panic 被插到链表最前面,gp._panic 永远指向"最新这一个"。一次成功的 recover 会把从当前帧往下(startSP 小于恢复点的那些)整条链一起清空,而不是一次只清一层——所以场景二里一次 recover 就够了,不需要对 "first panic" 再 recover 一次。

09 · 两个经典的坑

顺手提两个常见错误

循环里的 defer:资源会攒到函数返回才释放
func processAll(paths []string) error {
    for _, p := range paths {
        f, err := os.Open(p)
        if err != nil {
            return err
        }
        defer f.Close() // 攒着,不会在这次循环结束时关闭
    }
    // ... 处理完全部文件之后才会集中 Close
    return nil
}

正确做法:把每次循环体单独包成一个函数(或者立即执行的闭包),让 defer 跟着这个更小的函数帧一起提前返回、提前释放,而不是攒到最外层函数结束。

10 · 参考与说明

引用与这个模拟器做了哪些简化

☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电