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
,该函数会根据条件的不同,使用三种不同的机制处理该关键字:
func (s *state) stmt(n *Node) {
...
switch n.Op {
case ODEFER:
if s.hasOpenDefers {
s.openDeferRecord(n.Left) // openDeferRecord:开放编码
} else {
d := callDefer // callDefer:堆分配,默认兜底方案
if n.Esc == EscNever { // defer 不处于 for循环 或 goto语句中
d = callDeferStack // callDeferStack:栈分配
}
s.callResult(n.Left, d)
}
}
}
堆分配、栈分配和开放编码是处理 defer
关键字的三种方法,早期的 Go 语言会在堆上分配 runtime._defer
结构体,不过该实现的性能较差,Go 语言在 1.13 中引入栈上分配的结构体,减少了 30% 的额外开销1,并在 1.14 中引入了基于开放编码的 defer
,使得该关键字的额外开销可以忽略不计2。
从上面方法可以看出,堆上分配是默认的兜底方案。该方案会调用 cmd/compile/internal/gc.state.callResult
和 cmd/compile/internal/gc.state.call
,在 defer
位置生成一个 runtime.deferproc
调用,并在函数退出时生成一个 runtime.deferreturn
调用
deferproc
负责构造一个用来保存 函数地址 以及 函数参数 的_defer
结构体对象,并把该对象入栈,即插入G
结构体对象的_defer
链表表头deferreturn
负责从栈里读取函数及参数,并依次执行
// 生成 runtime.deferproc 调用
func (s *state) call(n *Node, k callKind, returnResultAddr bool) *ssa.Value {
...
var call *ssa.Value
if k == callDeferStack {
// 在栈上初始化 defer 结构体
...
} else {
...
switch {
case k == callDefer:
aux := ssa.StaticAuxCall(deferproc, ACArgs, ACResults) // 这里调用 deferproc。个函数接收了参数的大小和闭包所在的地址两个参数。
call = s.newValue1A(ssa.OpStaticCall, types.TypeMem, aux, s.mem())
...
}
call.AuxInt = stksize
}
s.vars[&memVar] = call
...
}
编译器不仅将 defer
关键字都转换成 runtime.deferproc
函数,它还会通过以下三个步骤为所有调用 defer
的函数末尾插入 runtime.deferreturn
的函数调用:
cmd/compile/internal/gc.walkstmt
在遇到ODEFER
节点时会执行Curfn.Func.SetHasDefer(true)
设置当前函数的hasdefer
属性;cmd/compile/internal/gc.buildssa
会执行s.hasdefer = fn.Func.HasDefer()
更新state
的hasdefer
;cmd/compile/internal/gc.state.exit
会根据state
的hasdefer
在函数返回之前插入runtime.deferreturn
的函数调用;
// 生成 Deferreturn 调用
func (s *state) exit() *ssa.Block {
if s.hasdefer {
...
s.rtcall(Deferreturn, true, nil) // 这里调用 Deferreturn
}
...
}
在 go1.13
之前所有 defer
都是在堆上分配。go1.13
版本新加入 deferprocStack
实现了在栈上分配 defer
。
相比堆上分配,栈上分配在函数返回后 _defer
便得到释放,省去了内存分配时产生的性能开销,只需适当维护 _defer
的链表即可。按官方文档的说法,这样做提升了约 30% 左右的性能。
除了分配位置的不同,栈上分配和堆上分配并没有本质的不同。
另外,1.13 版本中并不是所有
defer
都能够在栈上分配。循环中的defer
,无论是显示的for
循环,还是goto
形成的隐式循环,都只能使用堆上分配。
go 1.14
版本加入了开放编码(open coded),该机制会把 defer
调用的代码直接内联到返回之前,省去了 runtime
的操作。
还拿上面那个例子举例,编译器将生成如下的代码:
fmt.Println("hello")
bar() // generated for line 5
foo() // generated for line 5
然而开放编码作为一种优化 defer
关键字的方法,它不是在所有的场景下都会开启的,开放编码只会在满足以下的条件时启用:
- 函数的
defer
数量 <= 8 个; - 函数的
defer
关键字不能在循环中执行; - 函数的
return
语句与defer
语句的乘积小于或者等于 15 个; - 其他等条件(略
// cmd/compile/internal/gc.walkstmt
const maxOpenDefers = 8
func walkstmt(n *Node) *Node {
switch n.Op {
case ODEFER:
Curfn.Func.SetHasDefer(true)
Curfn.Func.numDefers++
if Curfn.Func.numDefers > maxOpenDefers { // defer 大于 8 个
Curfn.Func.SetOpenCodedDeferDisallowed(true) // 禁用开放编码优化
}
if n.Esc != EscNever { // defer 在循环中
Curfn.Func.SetOpenCodedDeferDisallowed(true)
}
fallthrough
...
}
}
// cmd/compile/internal/gc.buildssa
func buildssa(fn *Node, worker int) *ssa.Func {
...
s.hasOpenDefers = s.hasdefer && !s.curfn.Func.OpenCodedDeferDisallowed()
...
if s.hasOpenDefers &&
s.curfn.Func.numReturns*s.curfn.Func.numDefers > 15 { // defer 与 return 语句数量的乘积 > 15
s.hasOpenDefers = false
}
...
if s.hasOpenDefers {
deferBitsTemp := tempAt(src.NoXPos, s.curfn, types.Types[TUINT8]) // 初始化延迟比特,大小为8 bit,因此才限制 defer 数量最多8个
s.deferBitsTemp = deferBitsTemp
startDeferBits := s.entryNewValue0(ssa.OpConst8, types.Types[TUINT8])
s.vars[&deferBitsVar] = startDeferBits
s.deferBitsAddr = s.addr(deferBitsTemp)
s.store(types.Types[TUINT8], s.deferBitsAddr, startDeferBits)
s.vars[&memVar] = s.newValue1Apos(ssa.OpVarLive, types.TypeMem, deferBitsTemp, s.mem(), false)
}
}
一旦决定使用开放编码,cmd/compile/internal/gc.buildssa
会在编译期间在栈上初始化大小为 8 个比特的 deferBits
变量,通过二进制的01标记哪些 defer
关键字在函数中被执行。正是因为 deferBits
的大小仅为 8 比特,所以该优化的启用条件为函数中的 defer
关键字少于 8 个。
主要做的事情就是把 defer
后的函数、参数写入 _defer
结构体里,并将其插入到 G
的 defer
链表头部
// src/runtime/panic.go
func deferproc(siz int32, fn *funcval) {
sp := getcallersp() // 获取栈指针
argp := uintptr(unsafe.Pointer(&fn)) + unsafe.Sizeof(fn) // fn 函数后紧跟的就是参数列表
callerpc := getcallerpc()
d := newdefer(siz) // 新建一个 _defer(会优先使用空闲缓存,没有空闲的才新建
d.link = gp._defer // 新 defer 做头元素,原链表挂在新 defer 后面。因此 defer 的执行顺序是后进先出
gp._defer = d // 把链表写入G里
d.fn = fn
d.pc = callerpc
d.sp = sp
// 进行参数拷贝
switch siz {
// 如果defered函数的参数只有指针大小则直接通过赋值来拷贝参数
case sys.PtrSize:
// 将 argp 所对应的值 写入到 deferArgs 返回的地址中
*(*uintptr)(deferArgs(d)) = *(*uintptr)(unsafe.Pointer(argp))
default:
// 如果参数大小不是指针大小,那么进行数据拷贝
memmove(deferArgs(d), unsafe.Pointer(argp), uintptr(siz))
}
return0()
}
// src/runtime/panic.go
func deferreturn(arg0 uintptr) {
gp := getg()
d := gp._defer
sp := getcallersp() // 确定 defer 的调用方是不是当前 deferreturn 的调用方
if d.sp != sp {
return
}
switch d.siz {
case sys.PtrSize:
// 将 defer 保存的参数复制出来
// arg0 实际上是 caller SP 栈顶地址值,所以这里实际上是将参数复制到 caller SP 栈顶地址值
*(*uintptr)(unsafe.Pointer(&arg0)) = *(*uintptr)(deferArgs(d))
default:
// 如果参数大小不是 sys.PtrSize,那么进行数据拷贝
memmove(unsafe.Pointer(&arg0), deferArgs(d), uintptr(d.siz))
}
gp._defer = d.link // 将 defer 从链表移出
freedefer(d) // 将 defer 对象放入到 defer 池中,后面可以复用
fn := d.fn
_ = fn.fn
// 这里是实际执行函数的部分,jumpdefer 是汇编代码
jmpdefer(fn, uintptr(unsafe.Pointer(&arg0))) // 传入需要执行的函数和参数
}
顺带一提,panic
的实现和 defer
息息相关,也写在这里。
先来看 G
的结构。G
的字段里分别有两个链表,储存 panic
和 defer
type g struct {
_panic *_panic // panic链表
_defer *_defer // defer链表
}
编译器将 panic
翻译成 gopanic
函数调用。它会将错误信息打包成 _panic
对象,并挂到 G._panic
链表的头部。然后遍历 G._defer
链表,检查是否有 recover
。如被 recover
,则终止遍历执行,跳转到正常的 deferreturn
环节;否则终止进程。
正因为 panic
和 G
相关,因此在主协程里的 recover
, 是无法捕获子协程的 panic
的。