👉 上一篇文章:深入理解 Go defer(上):基本使用与行为解析
引言:为什么defer的“行为”必须回到源码解释
- 在上篇中关于defer的一些特点原理将会在这一篇章中得到解释
- 为什么参数会立即求值
- 为什么是按照LIFO的顺序执行defer后面的函数
- 为什么recover只能够在defer中执行
以下所有的源码都来源于Go1.25.5
1.defer在编译器阶段做了什么
defer在编译期会被识别、分析、分类、降级或优化,最终被编译器决定走open-coded/stack/heap 三条路径之一,runtime只负责执行
1.1defer在编译器中的生命周期
从源码到机器码,defer主要是经历了这几个阶段
1.2核心阶段解释
- AST/IR阶段
- 所有defer在IR中都统一表示为:
ir.ODEFER → *ir.GoDeferStmt
源码位置:/src/cmd/compile/internal/ir/stmt.go 这个结构里面包含了相关的一些信息包括:
| 字段 | 含义 |
|---|---|
| Call | defer调用的函数表达式 |
| DeferAt | 是否延迟到指定点 |
| Esc() | 逃逸分析结果 |
| Pos() | 源码位置 |
- walk阶段:函数级别的处理
- 在IR walk阶段,编译器会对每一个defer进行扫描,并在函数级别统计defer的数量、逃逸属性和执行位置。一旦发现任何一个defer不满足open-coded defer的严格约束,编译器就会彻底禁止该函数使用open-coded defer,退回到传统的runtime defer机制,下面是相关的一些流程解读(/src/cmd/compile/internal/walk/stmt.go):
case ir.ODEFER:
// 将ir.ODEFER转换为*ir.GoDeferStmt,这是defer在ir中的真实形态
n := n.(*ir.GoDeferStmt)
// 设置当前函数中存在defer,并且defer数量+1
ir.CurFunc.SetHasDefer(true)
ir.CurFunc.NumDefers++
// 判断当前的函数中的defer数量是否超过了8个或者当前defer的执行时机已经被固定了
// 则这个函数中的所有defer都不会再走open-code路径了
if ir.CurFunc.NumDefers > maxOpenDefers || n.DeferAt != nil {
ir.CurFunc.SetOpenCodedDeferDisallowed(true)
}
// 当前函数逃逸分析发现可能会发生逃逸,则这个函数中的所有defer都不会再走open-code路径了
if n.Esc() != ir.EscNever {
ir.CurFunc.SetOpenCodedDeferDisallowed(true)
}
fallthrough
- SSA 阶段:最终路线选择
- 简化后的源码解析,核心说明(/src/cmd/compile/internal/ssagen/ssa.go)
hasOpenDefers→ 函数是否允许open-coded deferEsc == EscNever && DeferAt == nil→ 栈上分配defer- 其他情况 → 堆上分配 defer
- 简化后的源码解析,核心说明(/src/cmd/compile/internal/ssagen/ssa.go)
if hasOpenDefers {
// 函数可以使用open-coded defer
openDeferRecord(callExpr)
} else {
if Esc == EscNever && DeferAt == nil {
// 栈上分配defer
callDeferStack(callExpr)
} else {
// 堆上分配/runtime defer
callDefer(callExpr)
}
}
2.defer的数据结构
2.1defer核心数据结构
源码位置:/src/runtime/runtime2.go 在runtime中,栈上和堆上defer都用一个_defer结构体来描述:
type _defer struct {
heap bool // 是否在堆上分配
rangefunc bool // true 表示 range-over-func 内部使用
sp uintptr // defer 注册时的栈指针
pc uintptr // defer 注册时的程序计数器
fn func() // defer 的函数指针,open-coded defer 可为 nil
link *_defer // 链表指针,指向下一个 defer(可在堆或栈上)
// rangefunc为true时,head指向atomic链表的头
head *atomic.Pointer[_defer]
}
常见的字段解析:
- heap:当前的defer分配在堆上还是栈上
- rangefunc:true表示range-over-func 内部使用,在下面这种语义复杂场景下面可能才会用到
for range f() {
defer g()
}
- sp:用于标识defer所属的函数栈帧边界,指向了所属函数的栈帧
- pc:用于标识defer语句在源码和指令流中的位置
- fn:defer后面要执行的函数
- link:指向下一个_defer,形成LIFO链表
3.defer是如何被注册的
3.1deferpro
deferproc是runtime中用于注册普通(非 open-coded)defer的核心函数 编译器会将defer f()转换为对deferproc或其变体(如deferprocStack)的调用
强调两点:
这是runtime注册阶段不是defer执行阶段(执行在deferreturn / gopanic)
源码位置:/src/runtime/panic.go
func deferproc(fn func()) {
// 获取当前正在执行的goroutine
gp := getg()
// 判断当前defer只能够被注册到goroutine的用户栈上,不能是system stack上
if gp.m.curg != gp {
throw("defer on system stack")
}
// 创建一个新的defer结构体
d := newdefer()
// 头插法,将当前defer挂到link列表上,也就是说明了为什么执行顺序是LIFO了
d.link = gp._defer
gp._defer = d
// 绑定要执行的func、pc、fn
d.fn = fn
d.pc = sys.GetCallerPC()
d.sp = sys.GetCallerSP()
}
接下来我们来看一下newdefer这个函数中是如何获取一个defer实例的
func newdefer() *_defer {
var d *_defer
// 获取当前的m和m绑定的p
mp := acquirem()
pp := mp.p.ptr()
// 如果p的本地deferpool为空并且全局deferpool不为空则会去全局池里面拿defer
if len(pp.deferpool) == 0 && sched.deferpool != nil {
lock(&sched.deferlock)
// 这里最多本地deferpool会拿全局deferpool的一半
for len(pp.deferpool) < cap(pp.deferpool)/2 && sched.deferpool != nil {
d := sched.deferpool
sched.deferpool = d.link
d.link = nil
pp.deferpool = append(pp.deferpool, d)
}
unlock(&sched.deferlock)
}
// 如果本地deferpool不为空则从本地取defer
if n := len(pp.deferpool); n > 0 {
d = pp.deferpool[n-1]
pp.deferpool[n-1] = nil
pp.deferpool = pp.deferpool[:n-1]
}
releasem(mp)
mp, pp = nil, nil
// 只有本地deferpool和全局的deferpool都没有defer才会在堆上创建一个defer
if d == nil {
d = new(_defer)
}
d.heap = true
return d
}
总结一下整体流程:
- 获取当前M和P
mp := acquirem()获取当前os线程也就是当前的mpp := mp.p.ptr()获取当前m绑定的p,用于访问deferpool
- 检查本地deferpool是否有可用defer
- 如果本地deferpool非空,直接弹出最后一个
_defer使用
- 如果本地deferpool非空,直接弹出最后一个
- 如果本地deferpool空,则从全局deferpool填充
- 使用锁
sched.deferlock保护全局pool - 将全局pool的defer拿一部分(cap/2)放入本地deferpool
- 释放锁
- 使用锁
- 再次尝试从本地deferpool弹出defer
- 如果成功,直接使用
- 堆分配新defer
d = new(_defer)在堆上创建一个新的_defer对象- 设置
d.heap = true
3.2deferprocStack
deferprocStack是注册栈defer的核心函数,它把编译器在栈上分配好的defer结构体安全地挂入goroutine的defer链表
栈defer的核心思想是:编译器提前分配defer,runtime只负责补全信息并挂链,避免堆分配
func deferprocStack(d *_defer) {
// 获取当前正在执行的goroutine
gp := getg()
// 判断当前defer只能够被注册到goroutine的用户栈上,不能是system stack上
if gp.m.curg != gp {
throw("defer on system stack")
}
// 给sp pc 等defer相关的属性赋值
d.heap = false
d.rangefunc = false
d.sp = sys.GetCallerSP()
d.pc = sys.GetCallerPC()
// 将当前的defer挂入defer链表(头插法)
*(*uintptr)(unsafe.Pointer(&d.link)) = uintptr(unsafe.Pointer(gp._defer))
*(*uintptr)(unsafe.Pointer(&d.head)) = 0
*(*uintptr)(unsafe.Pointer(&gp._defer)) = uintptr(unsafe.Pointer(d))
}
整体来说这段代码比较简单的就是给在编译阶段分配好的defer赋值,但是在这里有几个点需要注意一下为什么使用unsafe.Pointer
- 栈defer _defer部分字段尚未完全初始化
- 普通赋值可能触发写屏障,GC看到未初始化指针会出问题
- unsafe写入确保原子性 + 避免写屏障
4.defer是如何被执行的
在Go中,defer的执行是由runtime完整管理的,包括函数返回(return)和panic两种场景。核心源码主要涉及以下函数:
deferreturn_panic.start_panic.nextDefer_panic.nextFrame下面我们按执行流程逐一解析关键的源码(源码位置:/src/runtime/panic.go)
4.1deferreturn:defer的统一入口
func deferreturn() {
// 创建_panic结构,表示这个是return场景
var p _panic
p.deferreturn = true
// 初始化执行的上下文,设置当前函数的栈帧信息
p.start(sys.GetCallerPC(), unsafe.Pointer(sys.GetCallerSP()))
for {
// 获取当前栈帧中下一个需要执行的defer
fn, ok := p.nextDefer()
// 如果当前函数中的defer已经执行完了,则退出循环
if !ok {
break
}
// 执行defer函数
fn()
}
}
- 初始化
_panic结构- 标记这是
deferreturn场景(return而不是panic) - 记录当前函数的栈指针和返回地址
- 标记这是
- 循环执行defer
- 调用
nextDefer()获取下一个待执行 defer - 立即执行,直到当前函数栈帧的defer全部执行完
- 调用
本质:deferreturn是每个defer 的函数自动插入的入口,它保证当前函数的defer被LIFO执行
4.2_panic.start:初始化执行上下文
func (p *_panic) start(pc uintptr, sp unsafe.Pointer) {
// 获取当前goroutine
gp := getg()
// 记录调用者的pc和sp,用于后续判断defer是否已经被恢复
p.startPC = sys.GetCallerPC()
p.startSP = unsafe.Pointer(sys.GetCallerSP())
// return场景:只处理当前函数,不需要跨帧
if p.deferreturn {
// 保存当前栈帧sp,供nextDefer使用
p.sp = sp
return
}
// panic场景:将当前panic链接到goroutine的panic链表
p.link = gp._panic
gp._panic = p
// 记录当前帧的返回地址和帧指针
p.lr, p.fp = pc, sp
// 查找当前panic所在栈帧及上层栈帧中是否有defer
p.nextFrame()
}
- 记录当前执行帧信息
- 用于后续 defer 调用或 recover
- 区分return/panic
- return:只需绑定当前函数栈帧
- panic:需要向上回溯栈帧,调用
nextFrame查找上层 defer
本质:初始化_panic结构,确定本次defer/panic的执行范围
4.3_panic.nextDefer:获取并执行当前帧的defer
func (p *_panic) nextDefer() (func(), bool) {
for {
// 如果当前帧还有未执行的open-coded defer,则返回
for p.deferBitsPtr != nil {
return *(*func())(add(p.slotsPtr, i*goarch.PtrSize)), true
}
// 处理普通defer链表_defer
if d := gp._defer; d != nil && d.sp == uintptr(p.sp) {
// 获取defer函数
fn := d.fn
// 从链表中移除
popDefer(gp)
return fn, true
}
// panic场景则查找上层栈帧
if !p.nextFrame() {
return nil, false
}
}
}
- 优先处理当前帧defer
- 包括普通defer(链表
_defer)和open-coded defer(栈上 bits/slots)
- 包括普通defer(链表
- 循环获取下一个defer
- 如果当前帧还有defer → 返回并执行
- 当前帧没有defer → 调用
nextFrame查找下一帧(仅 panic 场景)
本质:nextDefer是defer执行的调度器,保证LIFO顺序,处理完当前函数才考虑跨帧
4.4_panic.nextFrame:跨函数栈展开
func (p *_panic) nextFrame() (ok bool) {
// 如果lr为0,说明栈已经回溯到顶层,没有更多栈帧
if p.lr == 0 {
return false
}
// 在系统栈上面执行,保证栈安全
systemstack(func() {
var limit uintptr
// 获取当前goroutine的defer链表,用于定位栈帧
if d := gp._defer; d != nil {
limit = d.sp
}
// 初始化unwinder,用于遍历调用栈
var u unwinder
u.initAt(p.lr, uintptr(p.fp), 0, gp, 0)
for {
// 如果当前帧无效,说明栈到底了,直接返回
if !u.valid() {
p.lr = 0
return
}
// 找到当前帧对应的普通defer链表
if u.frame.sp == limit {
break
}
// 查找当前帧是否有open-coded defer
if p.initOpenCodedDefers(u.frame.fn, unsafe.Pointer(u.frame.varp)) {
break
}
// 移动到上层帧
u.next()
}
// 更新_panic
p.lr = u.frame.lr
p.sp = unsafe.Pointer(u.frame.sp)
p.fp = unsafe.Pointer(u.frame.fp)
ok = true
})
return
}
- 逐帧向上回溯调用栈
- 使用
unwinder查找上层函数
- 使用
- 查找包含defer的函数
- 检查普通defer链表
_defer - 检查open-coded defer元数据
- 检查普通defer链表
- 切换
_panic上下文到找到的栈帧- 下一次
nextDefer就会在该帧执行 defer
- 下一次
本质:nextFrame只在panic场景触发,实现跨帧defer展开
5.总结
核心原理总结:
- 注册阶段
- defer会被记录在当前函数对应的栈帧或者goroutine的链表里
- 后注册的defer放在前面 → 保证LIFO执行
- 执行阶段
- 函数return → 执行当前函数栈帧的所有defer
- panic → 从当前栈帧向上查找所有defer,直到panic被recover或goroutine退出
- 参数与recover
- defer的参数在注册时就求值
- recover只能在defer中调用,用于捕获panic
- 优化机制
- 栈上open-coded defer减少堆分配和链表操作
- defer pool减少重复分配,提高性能