文章目录
GoLang之defer底层系列一(v 1.12)
注:本文以Go SDK v1.12进行讲解
1.前言
“ defer如何延迟,因何倒序?”
“ defer函数参数怎么传递?”
“ 加个闭包再多套几层,你还hold住吗?”
“ 都说defer1.12性能有坑,那坑从何来?又该怎么填?”
如何延迟?因何倒序?
2.如何延迟?因何倒序?
2.1如何延迟?
Go语言的defer是一个很方便的机制,能够把某些函数调用推迟到当前函数返回前才实际执行。我们可以很方便的用defer关闭一个打开的文件、释放一个Redis连接,或者解锁一个Mutex。而且Go语言在设计上保证,即使发生panic,所有的defer调用也能够被执行。不过多个defer函数是按照定义顺序倒序执行的。
我们通过一个例子来解释defer函数的延迟与倒序。
func f1() {
defer A()
// code to do something
}
像这样一段代码,在Go1.12中编译后的伪指令是这样的:
func f1() {
r := runtime.deferproc(0, A) // 经过recover返回时r为1,否则为0
if r > 0 {
goto ret
}
// code to do something
runtime.deferreturn()
return
ret:
runtime.deferreturn()
}
其中与defer指令相关的有两个部分。第一部分是deferproc,它负责保存要执行的函数信息,我们称之为defer“注册”。
从函数原型来看,deferproc函数有两个参数,第一个是被注册的defer函数的参数加返回值共占多少字节;第二个参数是一个runtime.funcval结构体的指针,也就是一个Function Value,已介绍过,此不赘述。
func deferproc(siz int32, fn *funcval)
与defer指令相关的第二部分就是deferreturn,它被编译器插入到函数返回以前调用,负责执行已经注册的defer函数。所以defer函数之所以能延迟到函数返回前执行,就是因为先注册,后调用。
2.2因何倒序?
再来看看defer函数为什么会倒序执行。
defer注册信息会保存到defer链表。每个goroutine在运行时都对应一个runtime.g结构体,其中有一个_defer字段,保存的就是defer链表的头指针。
deferproc新注册的defr信息会添加到链表头。deferreturn执行时也从链表头开始,所以defer才会表现为倒序执行。
理解了这些,就可以继续细化,看看defer注册时保存了什么信息,defer链表中每个元素究竟是什么结构了。
3.defer链表项
defer链表链起来的是一个一个_defer结构体。
type _defer struct {
siz int32
started bool
sp uintptr // sp at time of defer
pc uintptr
fn *funcval
_panic *_panic // panic that is running defer
link *_defer
}
1.siz : 由deferproc第一个参数传入,就是defer函数参数加返回值的总大小。
这段空间会直接分配在_defer结构体后面,用于在注册时保存给defer函数传入的参数,并在执行时直接拷贝到defer函数的调用者栈上。
2.started : 标识defer函数是否已经开始执行;
3.sp : 就是注册defer函数的函数栈指针;
4.pc : 是deferproc函数返回后要继续执行的指令地址;
5.fn :由deferproc的第二个参数传入,也就是被注册的defer函数;
6._panic : 是触发defer函数执行的panic指针,正常流程执行defer时它就是nil;
7.link: 自然是链到之前注册的那个_defer结构体。
这一篇我们只关注正常流程下defer函数的执行,不考虑panic或runtime.Goexit()的情况。
4.defer传参机制
func A1(a int) {
fmt.Println(a)
}
func A() {
a, b := 1, 2
defer A1(a)
a = a + b
fmt.Println(a, b)
}
func main() {
A()
/*
3 2
1
*/
}
func A1(a int) {
fmt.Println(a)
}
func A() {
a, b := 1, 2
defer A1(a)
a = a + b
fmt.Println(a, b)
}
这里函数A注册了一个defer函数A1,在A的函数栈帧中,局部变量区域存储a=1,b=2。
到deferproc函数注册defer函数A1时,第一个参数是A1的参数加返回值共占多少字节。A1没有返回值,64位下一个整型参数占用8字节。第二个参数是函数A1。前面我们介绍过,没有捕获列表的Function Value,在编译阶段会做出优化,就是在只读数据段分配一个共用的funcval结构体。如下图中,函数A1的指令入口地址为addr1。在只读数据段分配的指向A1指令入口的funcval结构体地址为addr2,所以deferproc函数第二个参数就是addr2。
额外要注意的是,deferproc函数调用时,编译器会在它自己的两个参数后面,开辟一段空间,用于存放defer函数A1的返回值和参数。这一段空间会在注册defer时,直接拷贝到_defer结构体的后面。A1只有一个参数a=1,放在deferproc函数自己的两个参数之后。注意deferproc函数的返回值空间并没有分配在调用者栈上,而是放到了寄存器中,这和recover有关,且先忽略。
deferproc函数执行,需要堆分配一段空间,用于放_defer结构体以及后面siz大小的参数与返回值。_defer结构体的第一个字段,A1的参数加返回值共占8字节;defer函数尚未执行,所以started=false;sp就是调用者A的栈指针;pc就是deferproc函数的返回地址return addr;被注册的function value为A1;defer结构体后面的8字节用来保存传递给A1的参数。
然后这个_defer结构体就被添加到defer链表头,deferproc注册结束。
注:“频繁的堆分配势必影响性能,所以Go语言会预分配不同规格的deferpool,执行时从空闲_defer中取一个出来用。没有空闲的或者没有大小合适的,再进行堆分配。用完以后,再放回空闲_defer池。这样可以避免频繁的堆分配与回收。”
deferproc结束后,接下来会执行到a=a+b这一步,所以,局部变量a被置为3。接下来会输出:a=3,b=2。
//函数A编译后的伪指令
func A() {
a, b := 1, 2
r := runtime.deferproc(8, A1,1)
if r > 0 {
goto ret
}
a = a + b
fmt.Println(a, b)//3,2
runtime.deferreturn()//执行defer链表
return
ret:
runtime.deferreturn()
}
然后就到deferreturn执行defer链表这里了。从当前goroutine找到链表头上的这个_defer结构体,通过_defer.fn找到defer函数的funcval结构体,进而拿到函数A1的入口地址。接下来就可以调用A1了。
调用A1时,会把_defer后面的参数与返回值整个拷贝到A1的调用者栈上。然后A1开始执行,输出参数值a=1。这个例子的关键,是defer函数的参数在注册时拷贝到堆上,执行时再拷贝到栈上。
5.defer+闭包
既然deferproc注册的是一个Function Value,下面就来看看有捕获列表时是什么情况。
func A() {
a, b := 1, 2
defer func(b int) {
a = a + b
fmt.Println(a, b)
}(b)
a = a + b
fmt.Println(a, b)
}
func main() {
A()
/*
3 2
5 2
*/
}
func A() {
a, b := 1, 2
defer func(b int) {
a = a+b
fmt.Println(a, b)
}(b)
a = a + b
fmt.Println(a, b)
}
这个例子中,defer函数不止要传递局部变量b做参数,还捕获了外层函数的局部变量a,形成闭包。匿名函数会由编译器按照A_func1这样的形式命名。如下图所示,假设这个闭包函数的指令入口地址为addr1。
上图中,由于捕获变量a除了初始化赋值外还被修改过,所以A的局部变量a改为堆分配,栈上只存它的地址。创建闭包对象时,会堆分配一个funcval结构体,funcval.fn指向闭包函数入口addr1,捕获列表中存储a在堆上的地址。而这个funcval结构体本身的地址addr2,就是deferproc执行时,_defer结构体中的fn的值。别忘了,传给defer函数的参数b=2,也要拷贝到_defer结构体后面。
上图所示_defer结构体被添加到defer链表头以后,deferproc注册结束。继续执行后面的逻辑。到a=a+b这里,a被置为3。下一步输出a=3,b=2。
接下来,deferreturn执行注册的defer函数时,要把参数b拷贝到栈上的参数空间。还记得闭包函数执行时怎样找到对应的捕获列表吗?通过寄存器存储的funcval地址加上偏移,找到捕获变量a的地址。
//示例代码编译后的伪指令
func A() {
a := new(int)
*a = 1
b := 2
r := runtime.deferproc(8, A_func1,2)
if r > 0 {
goto ret
}
*a = *a + b
fmt.Println(*a, b)//3,2
runtime.deferreturn()//执行defer链表
return
ret:
runtime.deferreturn()
}
func A_func1(b int){
a := (int *)([DX]+8)
*a = *a + b
fmt.Println(*a,b)
}
要注意捕获变量a在堆上分配,闭包函数执行时,捕获变量a=3,参数b=2。
所以,接下来在defer函数中,捕获变量a被置为5,最终输出a=5,b=2。这个例子中,最关键的是分清defer传参与闭包捕获变量的实现机制。
6.defer( A( B© ) )
func B(a int) int {
a++
return a
}
func A(a int) {
a++
fmt.Println(a)
}
func main() {
a := 1
defer A(B(a))
a++
fmt.Println(a)
/*
2
3
*/
}
这个例子中,main函数注册的defer函数是A,所以,defer链表项中_defer.fn存储的是A的funcval指针。但是deferproc执行时,需要保存A的参数到_defer结构体后面。这就需要在defer注册时拿到B的返回值。
既然B会在defer注册时执行,那么对B(a)求值时a=1。函数B的返回值就是2,也就是defer注册时保存的参数值为2,所以defer函数A执行时就会输出3。
7.defer嵌套
这一次,我们抛开各种细节,只关注defer链表随着defer函数的注册与执行究竟会如何变化。
func A(){
//......
defer A1()
//......
defer A2()
//......
}
func A2(){
//......
defer B1()
defer B2()
//......
}
func A1(){
//......
}
//所有defer函数都正常执行......
这个例子中函数A注册两个defer,我们用函数名标记为A1和A2。
到函数A返回前执行deferreturn时,会判断defer链表头上的defer是不是A注册的。方法就是判断_defer结构体记录的sp是否等于A的栈指针.如果是A注册的,就保存defer函数调用的相关信息,然后把这一项从defer链表中移除,然后调用函数A2,A2执行时又注册两个defer,记为B1和B2。
函数A2返回前同样去执行defer链表,同样判断是否是自己注册的defer函数。所以B2执行,之后B1执行。此时A2仍然不知道自己注册的defer函数已经执行完了,直到下一个_defer.sp不等于A2的栈指针,A2注册的defer执行完,A2就可以结束了。
因为A1是函数A注册的defer函数,所以又回到A的defer执行流程。A1结束后,defer链表为空,函数A结束。
这个例子的关键是defer链表注册时添加链表项,执行时移除链表项的用法。
“理解了defer注册与执行的逻辑,再配合之前介绍过的Function Value、函数调用栈等内容,就很容易理解上面几个例子,也就不用刷那些重复的defer面试题了。”
8.defer1.12,有点儿慢
因为defer真的很方便,所以大家都已经习惯了随手使用它。但是与一般的函数调用比起来,defer1.12的实现方式会在调用时造成较大的额外开销,尤其是在锁释放这种场景。因此经常被一些库设计者所诟病,甚至有些项目的注释中写明了不用defer能节省多少多少纳秒。
defer1.12的性能问题主要缘于两个方面:
1._defer结构体堆分配,即使有预分配的deferpool,也需要去堆上获取与释放。而且defer函数的参数还要在注册时从栈拷贝到堆,执行时又要从堆拷贝到栈。
2.defer信息保存到链表,而链表操作比较慢。
但是,defer作为一个关键的语言特性,怎能如此受人诟病?所以GO语言在1.13和1.14中做出了不同的优化。