defer 实现:栈帧 + 闭包的源码硬核细节(第71篇)
大家好,我是 kofer X,又到了我们 Go 源码学习系列的第 71 篇。
今天我们来聊一个 Go 程序员几乎每天都会用的关键字——defer。很多人知道 defer 是“延迟执行”,在函数返回前按 LIFO 顺序执行,但它的底层到底是怎么实现的?为什么能完美配合闭包?为什么需要记录栈帧的 sp?
我们直接上 Go 源码(基于 master 分支,runtime 最新实现),一步步拆解 栈帧 + 闭包 的硬核细节。
1. 核心数据结构:_defer 和 goroutine 的 defer 链表
先看运行时最核心的结构体,位于 src/runtime/runtime2.go:
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() // canbenilforopen-codeddefers
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]
}
关键字段解释:
-
sp:这就是栈帧的核心!记录执行defer语句时的栈指针(GetCallerSP())。 -
pc:记录defer语句所在位置的程序计数器。 -
fn func():这就是闭包的载体!defer最终执行的就是这个无参函数。 -
link:形成链表,挂在 goroutine 上。
再看 goroutine 结构体(type g)里的 defer 字段:
type g struct {
...
_defer *_defer // innermost defer
...
}
每个 goroutine 维护一条 _defer 链表,头节点就是最近的 defer。注意:链表是 LIFO 的,新 defer 总是插到头部。
2. deferproc:注册 defer 时发生了什么?
编译器会把 defer f() 翻译成对 runtime.deferproc 的调用(src/runtime/panic.go):
// The compiler turns a defer statement into a call to this.
funcdeferproc(fn func()) {
gp := getg()
if gp.m.curg != gp {
throw("defer on system stack")
}
d := newdefer()
d.link = gp._defer
gp._defer = d
d.fn = fn
d.pc = sys.GetCallerPC()
// We must not be preempted between calling GetCallerSP and
// storing it to d.sp because GetCallerSP's result is a
// uintptr stack pointer.
d.sp = sys.GetCallerSP()
}
重点来了:
-
newdefer()从 P 的 deferpool(或 central pool)分配一个_defer(可能是 heap,也可能 stack)。 -
关键: d.sp = sys.GetCallerSP()—— 把当前函数的栈帧指针永久记录下来。 -
d.fn = fn—— 把闭包函数值存进去。
这里 fn 就是闭包!如果你写的是:
defer fmt.Println(x) // x 是局部变量
编译器实际会生成一个闭包:
deferfunc() {
fmt.Println(x) // x 被捕获
}()
这个 func() 的函数值(本质是一个结构体:代码指针 + 捕获变量的指针)被塞进 d.fn。而捕获的 x 就在当前栈帧里(sp 指向的帧)。这就是为什么 defer 能正确捕获变量——栈帧没被销毁前,闭包数据一直是有效的。
3. deferreturn:函数返回时如何精准执行“属于本帧”的 defer?
编译器会在每个可能有 defer 的函数结尾插入 deferreturn() 调用。
// deferreturn runs deferred functions for the caller's frame.
// The compiler inserts a call to this at the end of any
// function which calls defer.
funcdeferreturn() {
var p _panic
p.deferreturn = true
p.start(sys.GetCallerPC(), unsafe.Pointer(sys.GetCallerSP()))
for {
fn, ok := p.nextDefer()
if !ok {
break
}
fn()
}
}
注意它复用了 _panic 结构体(deferreturn 模式),核心逻辑在 (*_panic).nextDefer():
func(p *_panic)nextDefer()(func(), bool) {
gp := getg()
for {
// 1. 先处理 open-coded defers(优化路径)
for p.deferBitsPtr != nil {
// 通过位图 + 栈槽直接调用
...
}
Recheck:
// 2. 处理传统 _defer 链表
if d := gp._defer; d != nil && d.sp == uintptr(p.sp) {
// 关键匹配:只有 sp 匹配当前栈帧的 defer 才执行!
if d.rangefunc { ... }
fn := d.fn
popDefer(gp) // 从链表摘除
return fn, true
}
// 3. 栈帧展开,找下一个有 defer 的帧
if !p.nextFrame() {
returnnil, false
}
}
}
硬核细节在这里:
-
d.sp == uintptr(p.sp)—— 用栈帧指针精确匹配当前正在返回的函数帧。 -
只有匹配的 defer 才会被执行并弹出。 -
然后 nextFrame()用unwinder沿着调用栈向上走(读取 fp、pc),找到上一个有 defer 的帧。 -
这就是为什么嵌套函数的 defer 不会乱序:每个 defer 都“记住”了自己属于哪个栈帧。
4. 闭包 + 栈帧的完美配合
当你写 defer func() { use localVar }() 时:
-
编译器生成闭包函数值( funcvalue),里面包含指向localVar的指针(栈上或逃逸到堆)。 -
deferproc把这个闭包塞进_defer.fn,同时记录当前sp。 -
函数返回时, nextDefer通过sp匹配到本帧,执行fn(),此时栈帧还在(defer 执行完才真正返回),闭包能安全访问变量。
如果闭包逃逸(被 go 或 defer 本身导致),编译器会把捕获变量分配到堆,fn 里指针指向堆对象,保证安全。
5. 性能优化:open-coded defer(Go 1.13+)
传统方式每次 defer 都要分配 _defer 结构体,太重。
如果函数里没有循环里的 defer,编译器会走 open-coded 路径:
-
在函数栈帧上直接预留几个槽位(slots)和一个 deferBits位图。 -
defer语句直接把fn写进栈槽,置位。 -
函数返回时, deferreturn直接读栈上的位图和槽位调用(initOpenCodedDefers+deferBitsPtr/slotsPtr)。 -
完全不用 _defer结构体,零分配!
源码里 nextDefer 先处理 open-coded,再处理传统链表,正是这个原因。
总结:defer 的本质
-
栈帧(sp):让 defer “知道”自己属于哪个函数帧,实现精准 LIFO 执行。 -
闭包(fn func()):把“带参数的延迟调用”统一成无参函数,捕获变量通过闭包机制绑定到栈帧(或堆)。 -
链表 + 栈展开: g._defer+unwinder实现跨帧 defer 管理。 -
pool + open-coded:性能优化,避免每次 defer 都 malloc。
这就是为什么 Go 的 defer 既优雅又高效——源码里每一行都体现着对栈、闭包、GC 的极致权衡。
欢迎点赞、在看、转发,你的支持是我继续硬核拆源码的最大动力!
—— 第71篇完 ——
kofer X | Go 源码学习系列
(本文所有代码均来自 Go 官方 master 分支 runtime 源码,如有版本差异以官方为准)
夜雨聆风