前言
关于defer不同版本直接差异的简要概述请看专栏这一篇博文
浅谈Golang 不同版本的defer
Go1.12
关于defer,我们知道它会在函数返回之前倒叙执行,像这样的代码,编译后的伪指令是这样的
defer指令对应到两部分内容,deferproc负责把要执行的函数保存起来,我们称之为defer注册,deferproc函数会返回0,和panic recover有关,先忽略,对应要跳转的也先忽略。defer注册完成后程序会继续执行后面的逻辑,直到返回之前通过deferreturn执行注册的defer函数。正是因为先注册后调用,才实现了defer延迟执行的效果。
defer信息会注册到一个链表,而当前执行的goroutine持有这个链表的头指针,每个goroutine在运行时都有一个对应的结构体g,其中有一个字段指向defer链表头,defer链表,链起来的是一个个_defer结构体,新注册的defer会添加到链表头,执行时也是从头开始,所以defer才会表现为倒序执行
这里函数A注册了一个defer函数A1,A的栈帧首先是两个局部变量,然后就要注册defer函数A1了。deferproc函数原型只有两个参数,第一个是defer函数A1的参数加返回值共占用多大空间,A1没有返回值,只有一个int参数,所以第一个参数为8,第二个参数是一个function value,之前说过,没有捕获列表的function value 在编译阶段会做出优化,就是在只读数据段分配一个共用的funcval结构体,所以deferproc的第二个参数就是它的地址。
我们先把_defer结构体展开来看一下,siz记录defer参数与返回值共占多少字节,这段空间会直接分配在_defer结构体后面,用于在注册时保存参数,并在执行时拷贝到调用者参数与返回值空间。
deferproc函数调用时,编译器会在它自己的两个参数后面,开辟一段空间,用于存放dfefer函数的返回值和参数,这一段空间会被直接拷贝到_defer结构体的后面。
deferproc函数执行时,需要堆分配一段空间,用于存放_defer结构体,以及后面siz大小的参数与返回值,然后这个结构体就被添加到defer链表头,deferproc注册结束
实际上go语言会预分配不同规格的defer池,执行时从空闲_defer中取一个来用,没有空闲或者没有大小合适的再进行堆分配,用完以后再放回空闲_defer池,这样可以避免频繁的堆分配和回收
等到deferreturn执行defer链表,从当前goroutine拿到链表头上的这个_defer结构体,通过fn找到funcval,拿到函数入口地址,调用A1时会把_defer后面的参数与返回值,整个拷贝到A1的调用者栈上,然后A1开始执行。这里的关键是defer函数的参数,再注册时拷贝到堆上,执行时又拷贝到栈上。
既然deferproc注册的是一个function value,那就来看看有捕获列表时是什么情况,这个例子中defer函数不止要传递局部变量b做参数,还捕获了外层函数的局部变量a,形成闭包
deferproc执行时,defer结构体中的fn,保存的就是这个funcval结构体的起始地址,除此之外还要拷贝参数b的值到后面,然后把这个defer结构体添加到defer链表头。deferproc结束。
执行到deferreturn时,从defer链表头拿到这个defer结构体。执行注册的defer函数时,把参数b拷贝到栈上的参数空间。这里最关键的是分清defer传参与闭包捕获变量的实现机制。
现在就能解释形如defer A(B(a))这样的问题了。这里defer注册的函数是A,defer链表存储的也是A的funcval指针。因为注册时需要保存A的参数,就要拿到B的返回值,所以B(a)会在defer注册时执行。注册时a=1,defer注册保存的参数值就是2,所以defer执行时函数A会输出3。
最后来看一个defer嵌套的例子,这一个抛开所有细节。只看defer链表随着A的执行会怎样变化,首先函数A注册两个defer,到A返回前执行deferreturn时,会判断defer链表头上的defer是不是A注册的,方法就是判断defer结构体记录的sp是否等于A的栈指针。
保存函数调用的相关信息后,把它从defer链表中移除,然后执行函数A2,又注册两个defer,A2返回前,同样去执行defer链表,同样判断是否是自己注册的defer函数,然后B2执行,同样的流程B1执行。
此时A2仍然不知道自己注册的defer已经执行完了,直到下一个_defer.SP不等于自己的栈指针,然后A2就可以结束了。
再次回到A的defer执行流程,执行A1,A1结束后defer链表为空,函数A结束。这里的关键是了解defer链表注册时添加链表项,执行时移除链表项的用法
Go1.12版本的defer基本设计思路就算梳理完了,这一版本的defer比较明显的问题就是慢。
第一个原因是_defer结构体堆分配,即使又预分配的deferpool,也需要去堆上获取与释放,而且参数还要再堆栈上来回拷贝。
第二个原因是使用链表注册defer信息,而链表本身操作比较慢。所以Go1.13和1.14中分别做了不同的优化
Go1.13
在一个函数A中,有一个函数B。编译后的伪指令是这样的。
我们暂且只关注正常执行流程,所以先忽略掉这些和panic,recover相关的部分下面就来看看1.13做出了怎样的优化。
1.12中通过deferproc注册defer函数信息,_defer结构体分配在堆上,在1.13中,通过在编译阶段,增加这样的局部变量,把defer信息保存到当前函数栈的局部变量区域,再通过deferprocstack把栈上这个_defer结构体注册到defer链表中。
defer1.13的优化点,主要在减少defer信息的堆分配。之所以说减少,是因为像第一种显示循环或者这样的隐式循环中的defer,依然需要使用1.12版本的处理方式,在堆上分配。
为此_defer结构体中增加了一个字段,用于表示是否为堆分配。所以这里的局部变量d,64位下参数加返回值共占8字节。heap为false,其他与1.12相同。到defer执行时,依然是通过deferreturn实现的。也同要在defer函数执行时拷贝参数,不过不是在堆栈之间,而是从栈上的局部变量空间,拷贝到参数空间。
1.13的defer,官方提供的性能提升是30%
Go1.14
1.14版本,有什么不一样的优化策略呢?这一次一部分一部分的看。这里是函数A编译后的伪指令,我们略去一部分recover相关的内容。函数A有两个defer,我们先看deferA1,这里把函数A1需要的参数定义为局部变量,然后在函数返回前直接调用defer函数A1,用这样的方式,省去了构造defer链表项,并注册到链表的过程,也同样实现了defer函数延迟执行的效果。
不过A2就不能这样简单处理了,它要到执行阶段才能确定是否需要被调用。Go语言用一个表示变量df来解决这个问题 。df里每一位对应标识一个defer函数时是否要被执行。例如这里第一个对应defer函数A1,A1需要执行所以通过或运算把df第一位置为1,defer函数调用这里,也要修改一个,先判断defer标识为是否是1,执行前,还要把df对应标识位置为0.避免重复执行。然后直接调用A1就好 。
同样的方式到defer A2这里,到程序执行阶段,就会根据具体条件判断df第二个标识位是否要被置为1,对应的函数返回前也要根据第二个标识位来决定是否要调用函数A2。
Go1.14的defer就是通过在编译阶段插入代码,把defer函数执行逻辑展开在所属函数内。从而免于创建_defer结构体,而且不需要注册到defer链表。Go语言称这种方式为open coded defer
但是同1.13一样,它依然不适用与循环中的defer,所以在这两个版本中,1.12版本的处理方式是一直保留的。通过性能测试三个版本的表现如上,1.14版本的性能几乎提升了一个数量级。
但是这并非没有代价,我们一直在梳理的,都是程序正常执行的流程,如果发生panic或者调用runtime.Goexit()函数, 后面这些代码根本执行不到,就要去执行defer链表了
而这些open coded方式实现的defer,并没有注册到链表,需要额外通过栈扫描的方式来发现,所以1.14版本中的_defer结构体,在1.13版本的基础上,又增加了几个字段,借助这些信息,可以找到未注册到链表的defer函数。并按照正确的顺序执行 。
所以实际上1.14版本中,defer的确变快了,但panic变得更慢了,但是Go语言做出这样的优化,一定是综合考量了整体性能,毕竟panic发生的几率要比defer低