go 里的 defer 可以将函数延迟至返回前再执行。相比手动在 return 前执行函数来说,一是可以使开启和结束的代码放在一起,清晰的同时避免忘写结束代码;另外即便 panic 时函数也可以继续执行,这是手动写做不到的。
其基本原理是,编译器会检查 defer 关键字, 把 defer 部分的函数插入到返回之前。
对于多个 defer,则将其串成链表,后进先出
结构体通过 link 字段串联成链表:
// src/runtime/runtime2.go
type _defer struct {
siz int32 // 参数和结果的大小
heap bool // 是否是堆上分配
openDefer bool // 是否经过开放编码的优化
sp uintptr // 栈指针
pc uintptr // 调用方的程序计数器 program counter
fn *funcval // 传入的函数
_panic *_panic // panic that is running defer
link *_defer // defer链表
}
中间代码生成阶段的 cmd/compile/internal/gc.state.stmt 会负责处理程序中的 defer,该函数会根据条件的不同,使用三种不同的机制处理该关键字:
堆分配、栈分配和开放编码是处理 defer 关键字的三种方法,早期的 Go 语言会在堆上分配 runtime._defer 结构体,不过该实现的性能较差,Go 语言在 1.13 中引入栈上分配的结构体,减少了 30% 的额外开销1,并在 1.14 中引入了基于开放编码的 defer,使得该关键字的额外开销可以忽略不计2。
2.1 堆上分配 (go1.13 之前)
从上面方法可以看出,堆上分配是默认的兜底方案。该方案会调用 cmd/compile/internal/gc.state.callResult 和 cmd/compile/internal/gc.state.call,在 defer 位置生成一个 runtime.deferproc 调用,并在函数退出时生成一个 runtime.deferreturn 调用
deferproc 负责构造一个用来保存 函数地址 以及 函数参数 的 _defer 结构体对象,并把该对象入栈,即插入 G 结构体对象的 _defer 链表表头
deferreturn 负责从栈里读取函数及参数,并依次执行
编译器不仅将 defer 关键字都转换成 runtime.deferproc 函数,它还会通过以下三个步骤为所有调用 defer 的函数末尾插入 runtime.deferreturn 的函数调用:
2.2 栈上分配 (go1.13)
在 go1.13 之前所有 defer 都是在堆上分配。go1.13 版本新加入 deferprocStack 实现了在栈上分配 defer。
相比堆上分配,栈上分配在函数返回后 _defer 便得到释放,省去了内存分配时产生的性能开销,只需适当维护 _defer 的链表即可。按官方文档的说法,这样做提升了约 30% 左右的性能。
除了分配位置的不同,栈上分配和堆上分配并没有本质的不同。
另外,1.13 版本中并不是所有 defer 都能够在栈上分配。循环中的 defer,无论是显示的 for 循环,还是 goto 形成的隐式循环,都只能使用堆上分配。
2.3 开放编码 (go1.14)
go 1.14 版本加入了开放编码(open coded),该机制会把 defer 调用的代码直接内联到返回之前,省去了 runtime 的操作。
还拿上面那个例子举例,编译器将生成如下的代码:
然而开放编码作为一种优化 defer 关键字的方法,它不是在所有的场景下都会开启的,开放编码只会在满足以下的条件时启用:
函数的 return 语句与 defer 语句的乘积小于或者等于 15 个;
一旦决定使用开放编码,cmd/compile/internal/gc.buildssa 会在编译期间在栈上初始化大小为 8 个比特的 deferBits 变量,通过二进制的01标记哪些 defer 关键字在函数中被执行。正是因为 deferBits 的大小仅为 8 比特,所以该优化的启用条件为函数中的 defer 关键字少于 8 个。
deferproc
主要做的事情就是把 defer 后的函数、参数写入 _defer 结构体里,并将其插入到 G 的 defer 链表头部
deferreturn
顺带一提,panic 的实现和 defer 息息相关,也写在这里。
先来看 G 的结构。G 的字段里分别有两个链表,储存 panic 和 defer
编译器将 panic 翻译成 gopanic 函数调用。它会将错误信息打包成 _panic 对象,并挂到 G._panic 链表的头部。然后遍历 G._defer 链表,检查是否有 recover。如被 recover,则终止遍历执行,跳转到正常的 deferreturn 环节;否则终止进程。
正因为 panic 和 G 相关,因此在主协程里的 recover, 是无法捕获子协程的 panic 的。
参考
深入理解defer(下)defer实现机制
draveness - 5.3 defer