堆上分配
Golang 1.13 之前的版本中,所有 defer 都是在堆上分配,该机制在编译时会进行两个步骤:
在 defer 语句的位置插入
runtime.deferproc
,当被执行时,延迟调用会被保存为一个_defer
记录,并将被延迟调用的入口地址及其参数复制保存,存入 Goroutine 的调用链表中。在函数返回之前的位置插入
runtime.deferreturn
,当被执行时,会将延迟调用从 Goroutine 链表中取出并执行,多个延迟调用则以jmpdefer
尾递归调用方式连续执行。
这种机制的主要性能问题存在于每个 defer 语句产生记录时的内存分配,以及记录参数和完成调用时参数移动的系统调用开销。
栈上分配
Go 1.13 版本新加入 deferprocStack
实现了在栈上分配的形式来取代 deferproc,相比堆上分配,栈上分配在函数返回后 _defer 便得到释放,省去了内存分配时产生的性能开销,只需适当维护 _defer 的链表即可。
不过在 defer 语句出现在了循环语句里,或者无法执行更高阶的编译器优化时,亦或者同一个函数中使用了过多的 defer 时,依然会使用 deferproc。
开放编码
Go 1.14 版本继续加入了 开发编码(open coded)
,该机制会将延迟调用直接插入函数返回之前,省去了运行时的 deferproc 或 deferprocStack 操作,在运行时的 deferreturn 也不会进行尾递归调用,而是直接 在一个循环中遍历所有延迟函数执行
。
限制条件:
- 没有禁用编译器优化,即没有设置 -gcflags “-N”;
- 函数内 defer 的数量不超过 8 个,且返回语句与延迟语句个数的乘积不超过 15;
- defer 不是在循环语句中。
该机制还引入了一种元素 —— 延迟比特(defer bit)
,用于运行时记录每个 defer 是否被执行(尤其是在条件判断分支中的 defer),从而便于判断最后的延迟调用该执行哪些函数。
延迟比特的原理: 同一个函数内每出现一个 defer 都会为其分配 1 个比特,如果被执行到则设为 1,否则设为 0,当到达函数返回之前需要判断延迟调用时,则用掩码判断每个位置的比特,若为 1 则调用延迟函数,否则跳过。
为了轻量,官方将延迟比特限制为 1 个字节,即 8 个比特,这就是为什么不能超过 8 个 defer 的原因,若超过依然会选择堆栈分配,但显然大部分情况不会超过 8 个。
代码演示: