2.defer

defer

go 里的 defer 可以将函数延迟至返回前再执行。相比手动在 return 前执行函数来说,一是可以使开启和结束的代码放在一起,清晰的同时避免忘写结束代码;另外即便 panic 时函数也可以继续执行,这是手动写做不到的。

1. 基本原理

其基本原理是,编译器会检查 defer 关键字, 把 defer 部分的函数插入到返回之前。

对于多个 defer,则将其串成链表,后进先出

1.1 数据结构

结构体通过 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链表
}

2. 执行机制

中间代码生成阶段的 cmd/compile/internal/gc.state.stmtarrow-up-right 会负责处理程序中的 defer,该函数会根据条件的不同,使用三种不同的机制处理该关键字:

堆分配、栈分配和开放编码是处理 defer 关键字的三种方法,早期的 Go 语言会在堆上分配 runtime._deferarrow-up-right 结构体,不过该实现的性能较差,Go 语言在 1.13 中引入栈上分配的结构体,减少了 30% 的额外开销1,并在 1.14 中引入了基于开放编码的 defer,使得该关键字的额外开销可以忽略不计2。

2.1 堆上分配 (go1.13 之前)

从上面方法可以看出,堆上分配是默认的兜底方案。该方案会调用 cmd/compile/internal/gc.state.callResultarrow-up-rightcmd/compile/internal/gc.state.callarrow-up-right,在 defer 位置生成一个 runtime.deferproc 调用,并在函数退出时生成一个 runtime.deferreturn 调用

  • deferproc 负责构造一个用来保存 函数地址 以及 函数参数_defer 结构体对象,并把该对象入栈,即插入 G 结构体对象的 _defer 链表表头

  • deferreturn 负责从栈里读取函数及参数,并依次执行

编译器不仅将 defer 关键字都转换成 runtime.deferprocarrow-up-right 函数,它还会通过以下三个步骤为所有调用 defer 的函数末尾插入 runtime.deferreturnarrow-up-right 的函数调用:

  1. cmd/compile/internal/gc.walkstmtarrow-up-right 在遇到 ODEFER 节点时会执行 Curfn.Func.SetHasDefer(true) 设置当前函数的 hasdefer 属性;

  2. cmd/compile/internal/gc.buildssaarrow-up-right 会执行 s.hasdefer = fn.Func.HasDefer() 更新 statehasdefer

  3. cmd/compile/internal/gc.state.exitarrow-up-right 会根据 statehasdefer 在函数返回之前插入 runtime.deferreturnarrow-up-right 的函数调用;

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 关键字的方法,它不是在所有的场景下都会开启的,开放编码只会在满足以下的条件时启用:

  1. 函数的 defer 数量 <= 8 个;

  2. 函数的 defer 关键字不能在循环中执行;

  3. 函数的 return 语句与 defer 语句的乘积小于或者等于 15 个;

  4. 其他等条件(略

一旦决定使用开放编码,cmd/compile/internal/gc.buildssaarrow-up-right 会在编译期间在栈上初始化大小为 8 个比特的 deferBits 变量,通过二进制的01标记哪些 defer 关键字在函数中被执行。正是因为 deferBits 的大小仅为 8 比特,所以该优化的启用条件为函数中的 defer 关键字少于 8 个。

源码

deferproc

主要做的事情就是把 defer 后的函数、参数写入 _defer 结构体里,并将其插入到 Gdefer 链表头部

deferreturn

panic

顺带一提,panic 的实现和 defer 息息相关,也写在这里。

先来看 G 的结构。G 的字段里分别有两个链表,储存 panicdefer

编译器将 panic 翻译成 gopanic 函数调用。它会将错误信息打包成 _panic 对象,并挂到 G._panic 链表的头部。然后遍历 G._defer 链表,检查是否有 recover。如被 recover,则终止遍历执行,跳转到正常的 deferreturn 环节;否则终止进程。

正因为 panicG 相关,因此在主协程里的 recover, 是无法捕获子协程的 panic 的。

参考

深入理解defer(下)defer实现机制arrow-up-right

draveness - 5.3 deferarrow-up-right

Last updated