深入理解 Go defer(下):编译器与runtime视角的实现原理

👉 上一篇文章:深入理解 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中都统一表示为:
go 复制代码
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):
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 defer
      • Esc == EscNever && DeferAt == nil → 栈上分配defer
      • 其他情况 → 堆上分配 defer
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结构体来描述:

go 复制代码
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]
}

常见的字段解析:

  1. heap:当前的defer分配在堆上还是栈上
  2. rangefunc:true表示range-over-func 内部使用,在下面这种语义复杂场景下面可能才会用到
go 复制代码
for range f() {
    defer g()
}
  1. sp:用于标识defer所属的函数栈帧边界,指向了所属函数的栈帧
  2. pc:用于标识defer语句在源码和指令流中的位置
  3. fn:defer后面要执行的函数
  4. link:指向下一个_defer,形成LIFO链表

3.defer是如何被注册的

3.1deferpro

deferproc是runtime中用于注册普通(非 open-coded)defer 的核心函数 编译器会将defer f()转换为对deferproc或其变体(如 deferprocStack)的调用

强调两点: 这是runtime注册阶段 不是defer执行阶段(执行在deferreturn / gopanic) 源码位置:/src/runtime/panic.go

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实例的

go 复制代码
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
}

总结一下整体流程:

  1. 获取当前M和P
    • mp := acquirem() 获取当前os线程也就是当前的m
    • pp := mp.p.ptr() 获取当前m绑定的p,用于访问deferpool
  2. 检查本地deferpool是否有可用defer
    • 如果本地deferpool非空,直接弹出最后一个_defer使用
  3. 如果本地deferpool空,则从全局deferpool填充
    • 使用锁sched.deferlock保护全局pool
    • 将全局pool的defer拿一部分(cap/2)放入本地deferpool
    • 释放锁
  4. 再次尝试从本地deferpool弹出defer
    • 如果成功,直接使用
  5. 堆分配新defer
    • d = new(_defer)在堆上创建一个新的_defer对象
    • 设置 d.heap = true

3.2deferprocStack

deferprocStack 是注册栈defer的核心函数,它把编译器在栈上分配好的defer结构体安全地挂入goroutine的defer链表

栈defer的核心思想是:编译器提前分配defer,runtime只负责补全信息并挂链,避免堆分配

go 复制代码
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的统一入口

go 复制代码
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()
    }
}
  1. 初始化_panic结构
    • 标记这是deferreturn场景(return而不是panic)
    • 记录当前函数的栈指针和返回地址
  2. 循环执行defer
    • 调用nextDefer()获取下一个待执行 defer
    • 立即执行,直到当前函数栈帧的defer全部执行完

本质:deferreturn是每个defer 的函数自动插入的入口,它保证当前函数的defer被LIFO执行

4.2_panic.start:初始化执行上下文

go 复制代码
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()
}
  1. 记录当前执行帧信息
    • 用于后续 defer 调用或 recover
  2. 区分return/panic
    • return:只需绑定当前函数栈帧
    • panic:需要向上回溯栈帧,调用nextFrame查找上层 defer

本质:初始化_panic结构,确定本次defer/panic的执行范围

4.3_panic.nextDefer:获取并执行当前帧的defer

go 复制代码
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
       }
    }
}
  1. 优先处理当前帧defer
    • 包括普通defer(链表 _defer)和open-coded defer(栈上 bits/slots)
  2. 循环获取下一个defer
    • 如果当前帧还有defer → 返回并执行
    • 当前帧没有defer → 调用nextFrame查找下一帧(仅 panic 场景)

本质:nextDefer是defer执行的调度器,保证LIFO顺序,处理完当前函数才考虑跨帧

4.4_panic.nextFrame:跨函数栈展开

go 复制代码
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
}
  1. 逐帧向上回溯调用栈
    • 使用unwinder查找上层函数
  2. 查找包含defer的函数
    • 检查普通defer链表_defer
    • 检查open-coded defer元数据
  3. 切换_panic上下文到找到的栈帧
    • 下一次nextDefer就会在该帧执行 defer

本质:nextFrame只在panic场景触发,实现跨帧defer展开

5.总结

核心原理总结:

  1. 注册阶段
    • defer会被记录在当前函数对应的栈帧或者goroutine的链表里
    • 后注册的defer放在前面 → 保证LIFO执行
  2. 执行阶段
    • 函数return → 执行当前函数栈帧的所有defer
    • panic → 从当前栈帧向上查找所有defer,直到panic被recover或goroutine退出
  3. 参数与recover
    • defer的参数在注册时就求值
    • recover只能在defer中调用,用于捕获panic
  4. 优化机制
    • 栈上open-coded defer减少堆分配和链表操作
    • defer pool减少重复分配,提高性能
相关推荐
工边页字1 小时前
为什么 RAG系统里,Embedding成本往往远低于 LLM成本,但很多公司仍然疯狂优化 Embedding?
前端·人工智能·后端
952361 小时前
初识多线程
java·开发语言·jvm·后端·学习·多线程
二哈赛车手1 小时前
新人笔记---责任链模式
后端
Darren2451 小时前
Junit到Springboot单元测试
后端
张涛酱1074562 小时前
「实战」Spring Boot 4.1.0-M3 新特性速览:gRPC、OpenTelemetry全面升级
后端
龙码精神2 小时前
ClickHouse 容灾技术方案(两方案对比+落地细节)
后端·架构
bugcome_com2 小时前
WPF 命令 ICommand 从原理到实战
后端·wpf·icommand
若水不如远方2 小时前
分布式一致性(七):架构角度 —— 分布式共识系统的选型指南
分布式·后端
shark_chili2 小时前
Spring 核心知识点全面解析
后端