Go 运行时 01:defer 原理

从语言语义、编译器改写和 panic 展开三个层面理解 Go defer 的实现原理

defer 用来把一次函数调用推迟到当前函数退出时执行。它常用于释放锁、关闭文件、记录耗时,以及配合 recover 处理 panic。

看起来简单的一条语句,背后却同时涉及语言规范、编译器优化和 runtime。本篇先明确它的行为,再分析当前 Go 编译器采用的三种实现方式。

本文的实现细节以 Go 1.26 源码为参考。defer 的语言语义是稳定的,但编译器选择哪种实现属于内部细节,可能随 Go 版本变化。

先掌握四条语义

1. 延迟调用按后进先出执行

同一个函数中,后注册的 defer 先执行:

1
2
3
4
5
6
7
8
9
func main() {
	defer fmt.Println("A")
	defer fmt.Println("B")
	defer fmt.Println("C")
}

// C
// B
// A

可以把它理解为一个栈:

defer 注册与执行顺序

2. 函数值和参数在执行 defer 语句时求值

被推迟的是“调用”,不是参数求值:

1
2
3
4
5
6
7
func main() {
	x := 1
	defer fmt.Println(x)
	x = 2
}

// 1

执行到 defer fmt.Println(x) 时,参数 x 的值已经保存下来,因此最终输出 1

闭包捕获变量则不同。闭包保存的是对变量的引用,执行时才读取变量:

1
2
3
4
5
6
7
8
9
func main() {
	x := 1
	defer func() {
		fmt.Println(x)
	}()
	x = 2
}

// 2

3. defer 在返回值确定后、函数真正返回前执行

无论返回值是否命名,Go 都会先计算并保存返回值,再执行 defer,最后完成函数返回:

defer 与函数返回流程

二者的关键区别是:命名返回值是函数作用域内可访问的变量,匿名返回值则没有可以被 defer 直接引用的名字。

对比项匿名返回值命名返回值
声明形式func f() intfunc f() (n int)
defer 能否直接读取返回变量不能
defer 能否替换最终返回值不能直接替换可以赋值或修改
是否支持裸 return不支持支持,但不宜滥用

匿名返回值

匿名返回值没有标识符。return n 会先把 n 的当前值复制到返回值槽位,随后 defer 修改的是局部变量 n,不会影响已经保存的返回值:

1
2
3
4
5
6
7
8
9
func anonymous() int {
	n := 41
	defer func() {
		n++
	}()
	return n
}

// anonymous() == 41

它的执行过程可以近似理解为:

1
2
3
result := n // result = 41
n++        // defer 修改局部变量,n = 42
return result

命名返回值

命名返回值在进入函数时就已经声明,并初始化为对应类型的零值。defer 闭包可以直接引用它,因此能够改变调用者最终收到的值:

1
2
3
4
5
6
7
8
9
func named() (n int) {
	n = 41
	defer func() {
		n++
	}()
	return n
}

// named() == 42

这里的 return n 会先把返回表达式赋给命名返回变量 n,然后执行 defer。由于 defer 修改的正是返回值变量,所以结果变成 42

命名返回值还允许裸返回:

1
2
3
4
5
6
7
8
9
func namedBareReturn() (n int) {
	n = 41
	defer func() {
		n++
	}()
	return
}

// namedBareReturn() == 42

return 不会改变 defer 的执行规则,只是省略了返回表达式。函数较长时,显式写出返回值通常更容易阅读。

返回引用类型时的区别

“匿名返回值不能被 defer 修改”指的是不能直接替换已经保存的返回值。如果返回值包含指针,defer 仍然可以修改它指向的数据:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func anonymousPointer() *int {
	n := 41
	defer func() {
		n++
	}()
	return &n
}

func main() {
	p := anonymousPointer()
	fmt.Println(*p) // 42
}

return &n 保存的是指向 n 的指针。defer 没有替换这个指针,但在函数返回前把指针指向的值改成了 42。切片、map 和包含指针的结构体也需要区分“返回值本身”和“它引用的底层数据”。

实际使用建议

命名返回值与 defer 常用于统一包装错误:

1
2
3
4
5
6
7
8
9
func load() (err error) {
	defer func() {
		if err != nil {
			err = fmt.Errorf("load config: %w", err)
		}
	}()

	return readConfig()
}

这种写法适合集中增加上下文、记录日志或指标,但应避免在多个 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 链
堆上 _deferruntime defer 池或堆循环中的动态 defer 等支持动态数量,但开销更高

这里的“栈上”和“堆上”指 _defer 记录存放在哪里,不是被延迟调用的函数在哪执行。延迟函数仍由当前 goroutine 执行。

传统实现:_defer

runtime 用 _defer 描述一次传统延迟调用。省略部分内部字段后,可以近似看成:

1
2
3
4
5
6
7
type _defer struct {
	heap bool
	sp   uintptr
	pc   uintptr
	fn   func()
	link *_defer
}

goroutine 的 g._defer 指向链表头。每注册一个 defer,runtime 就把新记录插到头部:

传统 defer 链表结构

头插法天然满足 LIFO。函数退出时,runtime 从链表头逐个取出并调用,于是执行顺序是 C -> B -> A

堆上 defer

编译器会把这类语句改写为对 runtime.deferproc 的调用。deferproc 通过 newdefer 获取 _defer:优先复用每个 P 的 defer 池,没有可复用记录时再分配,然后将其挂到 g._defer

循环中的 defer 数量只有运行时才能确定,通常需要走这条路径:

1
2
3
4
5
func closeAll(files []*os.File) {
	for _, f := range files {
		defer f.Close()
	}
}

这里所有文件都要等 closeAll 返回才关闭。文件很多时,不仅 defer 记录会累积,文件描述符也会长时间占用。更合适的写法是把单次迭代提取成函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func process(f *os.File) {
	defer f.Close()
	// 使用 f
}

func processAll(files []*os.File) {
	for _, f := range files {
		process(f)
	}
}

栈上 defer

如果传统 defer 记录的生命周期可以确定不超过当前栈帧,编译器可以直接在栈帧中预留 _defer,再调用 runtime.deferprocStack 将它挂到同一条 g._defer 链上。

它省去了堆分配和 defer 池操作,但注册、遍历链表的过程仍然存在。

正常返回时,编译器插入的 runtime.deferreturn 会依次找到属于当前栈帧的 defer 并执行。

主要优化:open-coded defer

从 Go 1.14 开始,常见的 defer 可以由编译器直接展开。它不为每条 defer 创建 _defer,而是在当前函数的栈帧中保存:

  • 延迟函数或闭包;
  • 已求值的参数;
  • 一个记录哪些 defer 已激活的 deferBits 位图。

例如:

1
2
3
4
5
6
func f(ok bool) {
	defer A()
	if ok {
		defer B()
	}
}

可以粗略理解为下面的伪代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func f(ok bool) {
	var deferBits uint8

	a := A
	deferBits |= 1 << 0

	var b func()
	if ok {
		b = B
		deferBits |= 1 << 1
	}

	if deferBits&(1<<1) != 0 {
		deferBits &^= 1 << 1
		b()
	}
	if deferBits&(1<<0) != 0 {
		deferBits &^= 1 << 0
		a()
	}
}

有两个细节值得注意:

  1. 参数和闭包先保存成功,再设置对应 bit。如果求值本身发生 panic,这条 defer 不能算已经注册。
  2. 调用前先清除 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:

panic 展开、defer 执行与 recover 流程

runtime 需要同时处理传统 _defer 链和 open-coded defer 元数据,但对 Go 代码暴露的顺序完全相同。

recover 为什么必须直接写在延迟函数里

recover 只有在正在展开 panic,并且由延迟函数直接调用时才有效:

1
2
3
4
5
6
7
8
9
func safeRun(fn func()) {
	defer func() {
		if value := recover(); value != nil {
			log.Printf("recovered: %v", value)
		}
	}()

	fn()
}

recover 放进延迟函数再次调用的普通辅助函数中,或在没有 panic 时调用,都会得到 nil

恢复 panic 只会停止继续展开,不会让程序回到发生 panic 的那一行。包含该 defer 的函数会结束,控制流返回它的调用者。

如何观察编译器选择

可以打开编译器的 defer 诊断信息,观察它为每条语句选择的实现方式:

1
go build -gcflags='-d=defer' ./...

输出中可能看到:

1
2
3
open-coded defer
stack-allocated defer
heap-allocated defer

也可以查看汇编:

1
go build -gcflags='-S' .

传统路径中通常能看到对 runtime.deferprocruntime.deferprocStackruntime.deferreturn 的引用;open-coded 路径则主要表现为函数退出前展开的条件判断和调用。

这些输出依赖 Go 版本和优化条件,适合学习与性能排查,不适合写进程序逻辑。

性能与使用建议

open-coded defer 已经让普通 defer 的成本很低。大多数场景应优先保证资源释放逻辑紧挨资源获取位置:

1
2
mu.Lock()
defer mu.Unlock()

需要特别关注的是:

  • 不要为了省一次 defer 而牺牲异常路径的正确性;
  • 避免在长循环中累计 defer,必要时提取小函数;
  • 延迟调用的参数可能持有大对象,应留意其生命周期;
  • 性能敏感路径先 benchmark,再决定是否手动改写;
  • 不要依赖栈上、堆上或 open-coded 等内部实现保证业务行为。

总结

理解 defer 可以分成三个层次:

  1. 语言语义:参数立即求值,调用延迟执行,多个 defer 按 LIFO 运行。
  2. 编译器实现:优先使用 open-coded defer,必要时退回栈上或堆上的 _defer
  3. 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 的逃逸分析
Licensed under CC BY-NC-SA 4.0
最后更新于 Aug 24, 2026 00:00 UTC
使用 Hugo 构建
主题 StackJimmy 设计