defer 用来把一次函数调用推迟到当前函数退出时执行。它常用于释放锁、关闭文件、记录耗时,以及配合 recover 处理 panic。
看起来简单的一条语句,背后却同时涉及语言规范、编译器优化和 runtime。本篇先明确它的行为,再分析当前 Go 编译器采用的三种实现方式。
本文的实现细节以 Go 1.26 源码为参考。
defer的语言语义是稳定的,但编译器选择哪种实现属于内部细节,可能随 Go 版本变化。
先掌握四条语义
1. 延迟调用按后进先出执行
同一个函数中,后注册的 defer 先执行:
| |
可以把它理解为一个栈:
2. 函数值和参数在执行 defer 语句时求值
被推迟的是“调用”,不是参数求值:
| |
执行到 defer fmt.Println(x) 时,参数 x 的值已经保存下来,因此最终输出 1。
闭包捕获变量则不同。闭包保存的是对变量的引用,执行时才读取变量:
| |
3. defer 在返回值确定后、函数真正返回前执行
无论返回值是否命名,Go 都会先计算并保存返回值,再执行 defer,最后完成函数返回:
二者的关键区别是:命名返回值是函数作用域内可访问的变量,匿名返回值则没有可以被 defer 直接引用的名字。
| 对比项 | 匿名返回值 | 命名返回值 |
|---|---|---|
| 声明形式 | func f() int | func f() (n int) |
| defer 能否直接读取返回变量 | 不能 | 能 |
| defer 能否替换最终返回值 | 不能直接替换 | 可以赋值或修改 |
是否支持裸 return | 不支持 | 支持,但不宜滥用 |
匿名返回值
匿名返回值没有标识符。return n 会先把 n 的当前值复制到返回值槽位,随后 defer 修改的是局部变量 n,不会影响已经保存的返回值:
| |
它的执行过程可以近似理解为:
| |
命名返回值
命名返回值在进入函数时就已经声明,并初始化为对应类型的零值。defer 闭包可以直接引用它,因此能够改变调用者最终收到的值:
| |
这里的 return n 会先把返回表达式赋给命名返回变量 n,然后执行 defer。由于 defer 修改的正是返回值变量,所以结果变成 42。
命名返回值还允许裸返回:
| |
裸 return 不会改变 defer 的执行规则,只是省略了返回表达式。函数较长时,显式写出返回值通常更容易阅读。
返回引用类型时的区别
“匿名返回值不能被 defer 修改”指的是不能直接替换已经保存的返回值。如果返回值包含指针,defer 仍然可以修改它指向的数据:
| |
return &n 保存的是指向 n 的指针。defer 没有替换这个指针,但在函数返回前把指针指向的值改成了 42。切片、map 和包含指针的结构体也需要区分“返回值本身”和“它引用的底层数据”。
实际使用建议
命名返回值与 defer 常用于统一包装错误:
| |
这种写法适合集中增加上下文、记录日志或指标,但应避免在多个 defer 中反复修改返回值,否则最终结果会依赖 LIFO 顺序,代码很难推理。
4. 正常返回、panic 和 Goexit 都会执行 defer
以下情况会执行当前 goroutine 栈上的延迟调用:
- 函数执行到
return; - 函数末尾隐式返回;
- panic 展开调用栈;
runtime.Goexit终止当前 goroutine。
os.Exit 会直接终止进程,不会执行任何 defer。致命 runtime 错误也不能依赖 defer 做清理。
编译器看到 defer 后做了什么
现代 Go 并不是只有一种 defer 实现。编译器会根据控制流、逃逸分析和优化条件,在下面三条路径中选择:
| 实现 | 记录位置 | 典型场景 | 主要特点 |
|---|---|---|---|
| open-coded defer | 当前函数栈帧中的槽位和位图 | 少量、非循环内的 defer | 正常返回路径开销最低 |
栈上 _defer | 当前函数栈帧 | 无法 open-code,但记录不逃逸 | 需要挂到 goroutine 的 defer 链 |
堆上 _defer | runtime defer 池或堆 | 循环中的动态 defer 等 | 支持动态数量,但开销更高 |
这里的“栈上”和“堆上”指 _defer 记录存放在哪里,不是被延迟调用的函数在哪执行。延迟函数仍由当前 goroutine 执行。
传统实现:_defer 链
runtime 用 _defer 描述一次传统延迟调用。省略部分内部字段后,可以近似看成:
| |
goroutine 的 g._defer 指向链表头。每注册一个 defer,runtime 就把新记录插到头部:
头插法天然满足 LIFO。函数退出时,runtime 从链表头逐个取出并调用,于是执行顺序是 C -> B -> A。
堆上 defer
编译器会把这类语句改写为对 runtime.deferproc 的调用。deferproc 通过 newdefer 获取 _defer:优先复用每个 P 的 defer 池,没有可复用记录时再分配,然后将其挂到 g._defer。
循环中的 defer 数量只有运行时才能确定,通常需要走这条路径:
| |
这里所有文件都要等 closeAll 返回才关闭。文件很多时,不仅 defer 记录会累积,文件描述符也会长时间占用。更合适的写法是把单次迭代提取成函数:
| |
栈上 defer
如果传统 defer 记录的生命周期可以确定不超过当前栈帧,编译器可以直接在栈帧中预留 _defer,再调用 runtime.deferprocStack 将它挂到同一条 g._defer 链上。
它省去了堆分配和 defer 池操作,但注册、遍历链表的过程仍然存在。
正常返回时,编译器插入的 runtime.deferreturn 会依次找到属于当前栈帧的 defer 并执行。
主要优化:open-coded defer
从 Go 1.14 开始,常见的 defer 可以由编译器直接展开。它不为每条 defer 创建 _defer,而是在当前函数的栈帧中保存:
- 延迟函数或闭包;
- 已求值的参数;
- 一个记录哪些 defer 已激活的
deferBits位图。
例如:
| |
可以粗略理解为下面的伪代码:
| |
有两个细节值得注意:
- 参数和闭包先保存成功,再设置对应 bit。如果求值本身发生 panic,这条 defer 不能算已经注册。
- 调用前先清除 bit。如果某个延迟函数再次 panic,runtime 不会重复执行它。
正常返回时,编译器生成的退出代码按 bit 从高到低直接调用延迟函数,避免了创建和遍历 _defer 链。
为什么还要生成元数据
直接展开只覆盖正常返回路径。若函数中途 panic,程序不一定能走到编译器生成的函数尾部。
因此编译器还会生成 FUNCDATA_OpenCodedDeferInfo,记录 deferBits 和各个函数槽位在栈帧中的位置。panic 展开时,runtime 根据这些元数据找到仍处于激活状态的 defer,并按逆序执行。
这使 open-coded defer 同时满足两个目标:
- 正常返回时走便宜的内联路径;
- panic 时仍保持与语言规范一致的行为。
哪些情况不能 open-code
具体规则属于编译器实现,不能作为业务代码依赖。以当前编译器为例,常见限制包括:
- defer 位于循环中;
- 一个函数中 defer 数量超过位图容量,目前上限是 8;
- 返回点数量与 defer 数量的乘积过大,展开会明显增加代码体积;
- 使用
-N关闭编译器优化; - 某些插桩、架构或返回值逃逸场景。
未满足条件不影响正确性,只是退回传统实现。
panic 时如何执行 defer
发生 panic 后,runtime 创建并初始化 _panic 状态,然后从当前栈帧开始寻找待执行的 defer:
runtime 需要同时处理传统 _defer 链和 open-coded defer 元数据,但对 Go 代码暴露的顺序完全相同。
recover 为什么必须直接写在延迟函数里
recover 只有在正在展开 panic,并且由延迟函数直接调用时才有效:
| |
把 recover 放进延迟函数再次调用的普通辅助函数中,或在没有 panic 时调用,都会得到 nil。
恢复 panic 只会停止继续展开,不会让程序回到发生 panic 的那一行。包含该 defer 的函数会结束,控制流返回它的调用者。
如何观察编译器选择
可以打开编译器的 defer 诊断信息,观察它为每条语句选择的实现方式:
| |
输出中可能看到:
| |
也可以查看汇编:
| |
传统路径中通常能看到对 runtime.deferproc、runtime.deferprocStack 或 runtime.deferreturn 的引用;open-coded 路径则主要表现为函数退出前展开的条件判断和调用。
这些输出依赖 Go 版本和优化条件,适合学习与性能排查,不适合写进程序逻辑。
性能与使用建议
open-coded defer 已经让普通 defer 的成本很低。大多数场景应优先保证资源释放逻辑紧挨资源获取位置:
| |
需要特别关注的是:
- 不要为了省一次 defer 而牺牲异常路径的正确性;
- 避免在长循环中累计 defer,必要时提取小函数;
- 延迟调用的参数可能持有大对象,应留意其生命周期;
- 性能敏感路径先 benchmark,再决定是否手动改写;
- 不要依赖栈上、堆上或 open-coded 等内部实现保证业务行为。
总结
理解 defer 可以分成三个层次:
- 语言语义:参数立即求值,调用延迟执行,多个 defer 按 LIFO 运行。
- 编译器实现:优先使用 open-coded defer,必要时退回栈上或堆上的
_defer。 - runtime 协作:正常返回通过展开代码或
deferreturn执行;panic 则结合 defer 链与函数元数据展开调用栈。
所以,“defer 就是挂在 goroutine 上的一条链表”只描述了传统实现。对于现代 Go 的常见代码,更准确的说法是:defer 的语义像栈,具体实现由编译器按场景选择。
参考源码
src/runtime/panic.go:defer 注册、返回和 panic 展开src/runtime/runtime2.go:_defer、_panic数据结构src/cmd/compile/internal/ssagen/ssa.go:open-coded defer 代码生成src/cmd/compile/internal/walk/stmt.go:open-coded defer 的部分选择条件src/cmd/compile/internal/escape/call.go:defer 的逃逸分析