GO 运行时内部原理
defer / panic / recover 内部原理
defer 看起来是"函数返回前执行"这么简单一句话,但只要涉及嵌套 defer、跨函数的 panic、以及 recover 到底能不能接住,直觉就很容易出错。这篇文章配了两个逐步播放且和真实 go run 输出逐行比对过的调用栈模拟场景,以及若干条直接引用自 Go 运行时源码的片段。
01 · 一个反直觉的例子
defer 到底属于谁
先看一段代码,不运行,自己猜输出顺序:
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 f(),运行时就把一个 _defer 结构体插到这个 goroutine 的链表头部——包括要调用的函数、注册时的栈指针 sp,和指向下一个 _defer 的 link。函数返回时沿着链表逐个弹出、逐个执行,天然就是 LIFO。
panic 不知道"该找谁处理",它只会不断问:当前帧还有没有没跑完的 defer?有就跑;没有就翻到调用者继续问;一直问到某个 defer 里调用了 recover(),或者问到栈底还没人接住。
recover() 不是"抓住任何地方抛出的异常",它只在被 panic 帧直接 defer 的那个函数内部、当场调用时才生效——多包一层函数调用,或者写成 defer recover(),都不行。
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]
}
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 的值会发生什么。
04 · panic 的核心循环
gopanic 在问什么
抛开各种前置的安全检查(比如不能在系统栈上 panic、持锁时不能 panic),gopanic 的核心逻辑短得惊人——就是一个"要 defer 就跑,跑完了再要"的循环:
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(),就轮到程序崩溃:
// 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 了,翻到调用者的帧继续问:
// 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"的调用栈画了出来:
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 1 → recover 函数 → a defer 2,LIFO 弹出顺序反过来,recover 发生在中间那一个。recover 只是拦住了"继续往上层传播"这件事,不会打断当前帧自己 defer 链的执行——所以 recover 成功之后,a defer 1 依然会正常执行,才轮到 a() 真正返回。真实输出(已用 go run 验证)里 a defer 1 排在 a: recovered: boom 后面就是这个原因。
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 触发后,运行时会算出 a 里 deferreturn 这个"退出点"的地址,直接跳过去继续正常执行——而不是简单地从 gopanic 那个循环里 return。deferreturn 是编译器在每个函数末尾插入的"跑完剩下的 defer 再真正返回"代码,场景一里 a defer 1 正是被这一段跑掉的。
08 · 再 panic 一次会怎样
后一个 panic 会盖掉前一个
如果一个 deferred 函数在处理某次 panic 的过程中自己又 panic 了,会发生什么?模拟器场景二验证了这件事——c() 先 panic("first panic"),它的一个 defer 又紧接着 panic("second panic"),另一个 defer 里调用 recover()。真实运行结果是:
c: about to repanic
c: recovered: second panic
main: after c()
main defer
recover() 拿到的是 "second panic","first panic" 就这样悄悄消失了——只有在完全不 recover、任由程序崩溃的情况下,才能在最终的崩溃信息里看到两个 panic 被串起来:
panic: first panic
panic: second panic
这就对应上面 _panic.link 那个链表:第二次 panic 时,新的 _panic 被插到链表最前面,gp._panic 永远指向"最新这一个"。一次成功的 recover 会把从当前帧往下(startSP 小于恢复点的那些)整条链一起清空,而不是一次只清一层——所以场景二里一次 recover 就够了,不需要对 "first panic" 再 recover 一次。
09 · 两个经典的坑
顺手提两个常见错误
- defer 参数在
defer这一行就已经被求值了,不是等到真正执行时才求值。 模拟器场景一里b()的defer fmt.Println("...", x)在x还是 10 的时候就把它复制进了_defer结构体,即使紧接着把x改成 20,最终打印出来的还是 10。想在执行时才读取最新值,得用defer func(){ fmt.Println(x) }()把x包进闭包里,让它在真正调用的那一刻才读。 - 不要在循环里堆 defer。 下面这种写法在函数返回之前,文件描述符一个都不会被释放——所有 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 · 参考与说明
引用与这个模拟器做了哪些简化
- 本文所有源码引用均来自 golang/go,commit
72aa6db7943024b48c4d41c1fbc32b57b9fa036e,文件src/runtime/panic.go与src/runtime/runtime2.go,遵循 Go 项目的 BSD-3-Clause 协议(© The Go Authors)。每段引用都标了具体文件、行号,并附了指向该 commit 的永久链接。 - 两个模拟场景用到的 Go 代码都是真实跑过的(
go run,Go 1.26),模拟器里每一条打印输出都和真实终端输出逐行断言比对过,不是手写的猜测顺序。 - 模拟器略去了 open-coded defer 这条优化路径——Go 1.14 之后,如果一个函数里的 defer 数量不超过 8 个且不在循环里,编译器会把它们直接展开成内联代码(用一个 bitmask 记录哪些需要执行),完全绕开
_defer堆分配,比链表快不少。这篇文章为了把"defer 到底在干什么"讲清楚,统一按未开放编码(heap-allocated_defer)的路径来演示——两条路径的可观察行为完全一致,只是内部实现和性能不同。 gorecover引用的那段省略了后半部分真正做栈回溯(unwinder)的代码,只保留了判定规则的文字说明和入口检查——回溯部分是纯粹的实现细节,不影响理解"什么情况下能 recover"这条规则本身。