探究 Go 语言 defer 语句的三种机制

Golang 的 1.13 版本 与 1.14 版本对 defer 进行了两次优化,使得 defer 的性能开销在大部分场景下都得到大幅降低,其中到底经历了什么原理?

这是因为这两个版本对 defer 各加入了一项新的机制,使得 defer 语句在编译时,编译器会根据不同版本与情况,对每个 defer 选择不同的机制,以更轻量的方式运行调用。

堆上分配

在 Golang 1.13 之前的版本中,所有 defer 都是在堆上分配,该机制在编译时会进行两个步骤:

  1. defer 语句的位置插入 runtime.deferproc,当被执行时,延迟调用会被保存为一个 _defer 记录,并将被延迟调用的入口地址及其参数复制保存,存入 Goroutine 的调用链表中。
  2. 在函数返回之前的位置插入 runtime.deferreturn,当被执行时,会将延迟调用从 Goroutine 链表中取出并执行,多个延迟调用则以 jmpdefer 尾递归调用方式连续执行。

这种机制的主要性能问题存在于每个 defer 语句产生记录时的内存分配,以及记录参数和完成调用时参数移动的系统调用开销。

栈上分配

Go 1.13 版本新加入 deferprocStack 实现了在栈上分配的形式来取代 deferproc,相比后者,栈上分配在函数返回后 _defer 便得到释放,省去了内存分配时产生的性能开销,只需适当维护 _defer 的链表即可。

编译器有自己的逻辑去选择使用 deferproc 还是 deferprocStack,大部分情况下都会使用后者,性能会提升约 30%。不过在 defer 语句出现在了循环语句里,或者无法执行更高阶的编译器优化时,亦或者同一个函数中使用了过多的 defer 时,依然会使用 deferproc

开放编码

Go 1.14 版本继续加入了开发编码(open coded),该机制会将延迟调用直接插入函数返回之前,省去了运行时的 deferprocdeferprocStack 操作,在运行时的 deferreturn 也不会进行尾递归调用,而是直接在一个循环中遍历所有延迟函数执行。

这种机制使得 defer开销几乎可以忽略,唯一的运行时成本就是存储参与延迟调用的相关信息,不过使用此机制需要一些条件:

  1. 没有禁用编译器优化,即没有设置 -gcflags "-N"
  2. 函数内 defer 的数量不超过 8 个,且返回语句与延迟语句个数的乘积不超过 15;
  3. defer 不是在循环语句中。

该机制还引入了一种元素 —— 延迟比特(defer bit),用于运行时记录每个 defer 是否被执行(尤其是在条件判断分支中的 defer),从而便于判断最后的延迟调用该执行哪些函数。

延迟比特的原理:
同一个函数内每出现一个 defer 都会为其分配 1 个比特,如果被执行到则设为 1,否则设为 0,当到达函数返回之前需要判断延迟调用时,则用掩码判断每个位置的比特,若为 1 则调用延迟函数,否则跳过。

为了轻量,官方将延迟比特限制为 1 个字节,即 8 个比特,这就是为什么不能超过 8 个 defer 的原因,若超过依然会选择堆栈分配,但显然大部分情况不会超过 8 个。

用代码演示如下:

deferBits = 0  // 延迟比特初始值 00000000

deferBits |= 1<<0  // 执行第一个 defer,设置为 00000001
_f1 = f1  // 延迟函数
_a1 = a1  // 延迟函数的参数
if cond {
    // 如果第二个 defer 被执行,则设置为 00000011,否则依然为 00000001
    deferBits |= 1<<1
    _f2 = f2
    _a2 = a2
}
...
exit:
// 函数返回之前,倒序检查延迟比特,通过掩码逐位进行与运算,来判断是否调用函数

// 假如 deferBits 为 00000011,则 00000011 & 00000010 != 0,因此调用 f2
// 否则 00000001 & 00000010 == 0,不调用 f2
if deferBits & 1<<1 != 0 {
    deferBits &^= 1<<1  // 移位为下次判断准备
    _f2(_a2)
}
// 同理,由于 00000001 & 00000001 != 0,调用 f1
if deferBits && 1<<0 != 0 {
    deferBits &^= 1<<0
    _f1(_a1)
}

总结

以往 Golang defer 语句的性能问题一直饱受诟病,最近正式发布的 1.14 版本终于为这个争议画上了阶段性的句号。如果不是在特殊情况下,我们不需要再计较 defer 的性能开销。

参考资料

[1] Ou Changkun - Go 语言原本:
https://changkun.de/golang/zh-cn/part2runtime/ch09lang/defer/

[2] 峰云就她了 - go1.14实现defer性能大幅度提升原理:
http://xiaorui.cc/archives/6579

[3] 34481-opencoded-defers:
https://github.com/golang/proposal/blob/master/design/34481-opencoded-defers.md



本文属于原创,首发于微信公众号「面向人生编程」,如需转载请后台留言。

关注后回复以下信息获取更多资源
回复【资料】获取 Python / Java 等学习资源
回复【插件】获取爬虫常用的 Chrome 插件
回复【知乎】获取最新知乎模拟登录

原文地址:https://www.cnblogs.com/zkqiang/p/12389420.html

时间: 2024-08-01 22:46:40

探究 Go 语言 defer 语句的三种机制的相关文章

PDO中执行SQL语句的三种方法

在PDO中,我们可以使用三种方式来执行SQL语句,分别是 exec()方法,query方法,以及预处理语句prepare()和execute()方法~大理石构件来图加工 在上一篇文章<使用PDO构造函数连接数据库及DSN详解>中,我们介绍了如何使用构造函数连接数据库和DSN的详解,那么我们这篇文章跟大家介绍在PDO中执行SQL语句的三种方式,下面我们将一一介绍! 第一种方法:exec()方法 exec()方法返回执行SQL 语句后受影响的行数,其语法格式如下: 1 int PDO::exec(

while 语句的三种控制/结束循环方式

while语句若一直满足条件,则会不断的重复下去.但有时,我们需要停止循环,则可以用下面的三种方式: 1.在while语句中设定条件语句,条件不满足,则循环自动停止:ie: 只输出3的倍数的循环:范围:0到20. current_number = 0 while current_number < 20: current_number += 1 if current_number % 3 != 0: continue print(current_number) 敲黑板,敲黑板,重点在这里: 先将起

CSS布局的三种机制

浮动元素之间没有缝隙,这和行内块还是不一样的,有点区别的! 2) 浮动元素与兄弟盒子之间的关系 注意:解决浮动的四种办法,后三种都是针对浮动元素的父元素的. 原文地址:https://www.cnblogs.com/python-machine/p/12228890.html

Shell条件测试语句及三种if语句

7 - Shell_条件操作测试及if语句 要使Shell脚本程序具备一定的"智能",面临的第一个问题就是如何区分不同的情况以确定执行何种操作.例如,当磁盘使用率超过95%发送警告:当备份目录不存在时能够自动创建:当源码编译程序的配置失败则不再继续安装等. Shell环境根据命令执行后的返回状态值($?)来判断是否执行成功,当返回值为0,表示成功,值为其他时,表示失败. 使用专门的测试工具-test命令,可以对特定条件进行测试,并根据返回值来判断条件是否成立(返回值0为成立) 使用te

C语言 函数指针定义三种方式

//函数指针 #include<stdio.h> #include<stdlib.h> #include<string.h> //函数指针类型跟数组类型非常相似 //函数名就是函数的地址,函数的指针,对函数名进行&取地址操作,还是函数名本身,这是C语言编译器的特殊处理 void test(int a){ printf("a=%d\n",a); } void ProtectA(){ //定义函数类型 typedef void(FunType)(

java break语句的三种用法

1.用于switch语句当中,用于终止语句 2.用于跳出循环,此为不带标签的break语句,相当与goto的作用 e.g 1 while(i<j&&h<k){ 2 if(h<k) 3 { 4 .... 5 } 6 } 7 8 while(i<j){ 9 if(h>k) break; 10 } 在第一种写法中,对条件h<k进行了两次检测,而第二种写法,通过使用break跳出循环,减少了对同一条件的判别次数,避免了重复检测. 注意,在一系列循环嵌套中,bre

C语言:strlen的三种实现方法

方法一:指针的方式 #include <stdio.h> #include <stdlib.h> int my_strlen(const char *str) { char *tmp = str;        //用tmp保存原指针的地址 while (*str)            //str++直到'\0' { str++; } return str - tmp;      //两个指针相减,得到字符长度 } int main() { char *p = "bit

C语言创建符号常量的三种方法;printf()和scanf()函数

1.#indefine pi 3.1415926形式的宏定义 2.const int MONTHS=12:这使得MONTHS成为一个只读值. 3.enum 枚举类型 4.printf()和scanf()函数使我们能够与程序通信,他们被称为输入/输出函数,(I/O函数) 5.字符串.浮点数输出的类型及介绍:实例程序: #include<stdio.h> #define BLURB "Authentic imitation!"//宏定义方式 int main(void) { c

C语言main函数的三种形式

在C语言中,main()函数有三种形式. 1.无参数 #include <stdio.h> int main(void) { printf("Hello World!\n"); return 0; } 2.有两个参数 习惯上第一个参数是整型argc,保存了外部调用命令的参数个数,第二个参数是指针数组或二级指针argv,以字符串形式保存了与argc对应的参数,如下例子: #include <stdio.h> int main(int argc, char* arg