golang defer原理探究

555 阅读8分钟

1、代码运行表象

demo代码使用的go官方库为go 1.13.3,所要分析的代码片段如下:

1 package main
2
3 import "fmt"
4
5 func main() {
6    defer func() {
7       fmt.Println("1")
8     }()
9    defer func() {
10      fmt.Println("2")
11   }()
12   fmt.Println("3")
13}

问题一:为什么输出结果是3 2 1 了?
这段代码很简单,用过go的"人肉"执行就可以得到结果:

3  
2  
1  

问题二:为什么调用堆栈一样?
在代码第7行和第10行设置断点,观察此时的堆栈信息
断点第7行的堆栈如下:

0  0x00000000004bb194 in main.main.func1
  at F:/gotest/src/defer/defer.go:7
1  0x00000000004bb11c in main.main
  at F:/gotest/src/defer/defer.go:13
2  0x0000000000432d56 in runtime.main
  at D:/go1.13.3/src/runtime/proc.go:203
3  0x000000000045b351 in runtime.goexit
  at D:/go1.13.3/src/runtime/asm_amd64.s:1357

断点第10行的堆栈如下:

0  0x00000000004bb234 in main.main.func2
   at F:/gotest/src/defer/defer.go:10
1  0x00000000004bb11c in main.main
   at F:/gotest/src/defer/defer.go:13
2  0x0000000000432d56 in runtime.main
   at D:/go1.13.3/src/runtime/proc.go:203
3  0x000000000045b351 in runtime.goexit
   at D:/go1.13.3/src/runtime/asm_amd64.s:1357

这两个defer的闭包函数,上一层调用位置都在main.main函数的第13行,但第13行是函数的结束,编译器难道在这里插入了什么代码??

2、代码原理探究

表象上的简洁语法,通常都是语法糖做了包裹,底层实际包含一堆逻辑。GO语言也不例外,要看到defer的实际调用,需要反汇编一下。这里需要用到plan9的汇编知识,plan9汇编的详细知识可以参考这篇博文plan9 汇编入门,作者的其他博文也很精彩。

plan9的堆栈,将函数参数和返回值放在caller的栈上,详细如下(引用自plan9 汇编入门):

 栈底:高地址端       
                   ----------------- 
                   current func arg0                                           
                   ----------------- <----------- FP(pseudo FP)                
                    caller ret addr                                            
                   +---------------+                                           
                   | caller BP(*)  |                                           
                   ----------------- <----------- SP(pseudo SP,实际上是当前栈帧的 BP 位置)
                   |   Local Var0  |                                           
                   -----------------                                           
                   |   Local Var1  |                                           
                   -----------------                                           
                   |   Local Var2  |                                           
                   -----------------                -                          
                   |   ........    |                                           
                   -----------------                                           
                   |   Local VarN  |                                           
                   -----------------                                           
                   |               |                                           
                   |               |                                           
                   |  temporarily  |                                           
                   |  unused space |                                           
                   |               |                                           
                   |               |                                           
                   -----------------                                           
                   |  call retn    |                                           
                   -----------------                                           
                   |  call ret(n-1)|                                           
                   -----------------                                           
                   |  ..........   |                                           
                   -----------------                                           
                   |  call ret1    |                                           
                   -----------------                                           
                   |  call argn    |                                           
                   -----------------                                           
                   |   .....       |                                           
                   -----------------                                           
                   |  call arg3    |                                           
                   -----------------                                           
                   |  call arg2    |                                           
                   |---------------|                                           
                   |  call arg1    |                                           
                   -----------------   <------------  hardware SP 位置           
                   | return addr   |                                           
                   +---------------+        

将刚才的代码反汇编一下:

        defer func() {
  0x49a806              c744246800000000        MOVL $0x0, 0x68(SP)
  0x49a80e              488d05a3fc0300          LEAQ go.func.*+132(SB), AX
  0x49a815              4889842480000000        MOVQ AX, 0x80(SP)
  0x49a81d              488d442468              LEAQ 0x68(SP), AX
  0x49a822              48890424                MOVQ AX, 0(SP)
  0x49a826              e86515f9ff              CALL runtime.deferprocStack(SB)
  0x49a82b              85c0                    TESTL AX, AX
  0x49a82d              0f85d4000000            JNE 0x49a907
  0x49a833              eb00                    JMP 0x49a835
        defer func() {
  0x49a835              c744243000000000        MOVL $0x0, 0x30(SP)
  0x49a83d              488d057cfc0300          LEAQ go.func.*+140(SB), AX
  0x49a844              4889442448              MOVQ AX, 0x48(SP)
  0x49a849              488d442430              LEAQ 0x30(SP), AX
  0x49a84e              48890424                MOVQ AX, 0(SP)
  0x49a852              e83915f9ff              CALL runtime.deferprocStack(SB)
  0x49a857              85c0                    TESTL AX, AX
  0x49a859              0f8592000000            JNE 0x49a8f1
  0x49a85f              eb00                    JMP 0x49a861

defer被编译器转译成函数deferprocstack,在1.13之前,defer的实现函数为deferproc,从1.13开始,为了提高性能(官方文档:对于大量使用defer的应用,性能可以提高30%),将每次defer都从堆上分配_defer结构改为从栈上分配。只在defer存在于循环结构中时,从堆上分配,这里的循环结构包括两种:

  • 1、显式循环,有for结构
  • 2、隐式循环,有goto这种语句

2.1. deferprocStack/deferproc干的好事

一个defer在go里面的表示结构为_defer(定义在runtime2.go),而 deferprocStack和deferproc的作用是一样的,只是分配_defer的位置不一样,一个在调用栈上,一个在堆上,这里以deferprocStack的处理进行分析。
这两个函数主要完成两样工作:
1、初始化必要的参数
下面两个参数由调用者完成初始化,对应于上面的汇编代码段:0x49a806-0x49a815或 0x49a835-0x49a844

d.siz   //记录参数大小,在执行defer时需要将参数拷贝到执行栈上 
d.fn    //记录执行函数

以下参数由deferprocStack完成初始化

d.started = false       //标记此defer是否已处理,在gopanic和Goexit用到  
d.heap = false          //标记_defer结构的来源:堆或栈,freedefer要用到  
d.sp = getcallersp()    //保存调用者的栈顶sp,在panic/recover后用到,以及执行当前栈上defer进行栈比较
d.pc = getcallerpc()    //保存调用者下一条执行指令地址,在panic/recover后用到

2、将_defer串接起来
_defer是一个个的单链表节点,通过link连接,最后挂到当前g的头结点上

*(*uintptr)(unsafe.Pointer(&d.link)) = uintptr(unsafe.Pointer(gp._defer))
*(*uintptr)(unsafe.Pointer(&gp._defer)) = uintptr(unsafe.Pointer(d))

可以看到,这里的链接方式,采用的是链表"头插法",后定义的defer会排在链表前面,因此,这里构成了类似栈(LIFO)一样的结构。

有趣的是,getcallerpc()在调用者中的下一条执行指令永远是:

TESTL AX, AX
JNE xxxxxxx

这条指令的目的是测试返回值(这里会把返回值放在ax寄存器中),如果为0,就正常顺序执行代码,如果非0,就进行跳转。
为0的情况:
在deferproc和deferprocStack中执行完后, 都会执行一个return0函数,函数实现很简单,就是给AX寄存器赋值0

TEXT runtime·return0(SB), NOSPLIT, $0
	MOVL	$0, AX
	RET

为1的情况:
如果有panic,并且在defer中进行了recover,最后根据_defer中的sp,pc恢复堆栈时,会将ax的值赋值1(runtime/panic.go:recovery)

gp.sched.sp = sp
gp.sched.pc = pc
gp.sched.lr = 0
gp.sched.ret = 1        //这里的1会给到 ax中

通过AX的返回值不同,实现了类似if的功能,执行不同分支。JNE xxxxx的目的是在ax不为0时进行指令地址跳转,在demo的反汇编中,JNE 0x49a907JNE 0x49a8f1分别是跳转到地址0x49a9070x49a8f1,这两处代码分别如下:

0x49a907              90                      NOPL
0x49a908              e8d31af9ff              CALL runtime.deferreturn(SB)
0x49a90d              488bac24d0000000        MOVQ 0xd0(SP), BP
0x49a915              4881c4d8000000          ADDQ $0xd8, SP
0x49a91c              c3                      RET

0x49a8f1              90                      NOPL
0x49a8f2              e8e91af9ff              CALL runtime.deferreturn(SB)
0x49a8f7              488bac24d0000000        MOVQ 0xd0(SP), BP
0x49a8ff              4881c4d8000000          ADDQ $0xd8, SP
0x49a906              c3                      RET

NOPL指令无需关心,其中deferreturn在有defer存在的函数中,最后都会生成。无论AX=0还是AX=1,调用者代码在结束退出(恢复堆栈)之前,都会执行deferreturn。

2.2. deferreturn的魔法

deferreturn主要完成三个部分工作:

1、栈顶比较
将_defer中保存的sp,与当前调用者(main.main)的栈顶进行比较。不一致,说明当前defer不是在当前调用函数里生成,有可能是上一层函数里的,这种情况下,deferreturn正常退出回到调用栈(main.main)里,然后main.main恢复堆栈退出。

d := gp._defer
if d == nil {
	return
}
sp := getcallersp()
if d.sp != sp {
	return
}

这里每次取的都是当前g的_defer头结点,根据前文的描述,defer按照LIFO的顺序进入,因此,在main.main中后执行的defer函数,在这里会先被拿出使用,这就是问题一产生的原因。

2、参数挪移
根据_defer中的siz,将参数拷贝到当前调用者(main.main)的栈上,arg0是deferreturn的参数,根据plan9汇编的调用规范,被调者的参数从右往左,依次放入调用者的栈中。因此&arg0的地址就是main.main当前的栈顶,与getcallersp相等的。

switch d.siz {
case 0:
	// Do nothing.
case sys.PtrSize:
	*(*uintptr)(unsafe.Pointer(&arg0)) = *(*uintptr)(deferArgs(d))
default:
	memmove(unsafe.Pointer(&arg0), deferArgs(d), uintptr(d.siz))
}

3、隐式循环
deferreturn的最后一个函数是jmpdefer,这个函数实现了一些"魔法"功能,从汇编级修改跳转地址,完成for循环,而循环的终止条件是d.sp != getcallersp()或当前g没有可以执行的defer。

// 1. pop the caller
// 2. sub 5 bytes from the callers return
// 3. jmp to the argument
TEXT runtime·jmpdefer(SB), NOSPLIT, $0-16
	MOVQ	fv+0(FP), DX	// fn
	MOVQ	argp+8(FP), BX	// caller sp
	LEAQ	-8(BX), SP	// caller sp after CALL
	MOVQ	-8(SP), BP	// restore BP as if deferreturn returned (harmless if framepointers not in use)
	SUBQ	$5, (SP)	// return to CALL again,(减5是因为一个call有5个字节机器码)
	MOVQ	0(DX), BX
	JMP	BX	// but first run the deferred function

这个代码段完成了for循环的功能:
LEAQ -8(BX), SP 调整当前的栈顶寄存器,BX是调用者main.main函数的栈顶,也就是上文的&args0,BX-8赋值给SP
MOVQ -8(SP), BP 恢复调用者main.main的栈基址
SUBQ $5, (SP)    修改返回地址

以上面的demo为例,正常情况下,(SP)里面的地址存放的是call deferreturn的下一条指令地址,也就是地址0x49a8f70x49a90d,这里(sp)减5操作后,(sp)里的地址变成0x49a8f2/0x49a908,对应的指令为call deferreturn,也就是调用返回后,会继续进入到deferreturn执行,deferreturn会在当前g的defer耗尽或者栈顶不匹配时,正常退出。

这也是问题二出现的原因:在每个defer函数执行时,它的上一层栈,会被调整为定义defer的那个函数