Go defer(二):从汇编的角度理解延迟调用的实现
Go的延迟调用机制会在当前函数返回前执行传入的函数,它会经常被用于关闭文件描述符、关闭数据库连接以及解锁资源。之前的文章( Go defer(一):延迟调用的使用及其底层实现原理详解 )详细介绍了defer的使用以及其底层实现原理,本文则以Go 1.16版本以及AMD 64架构为实验环境,从汇编语言的角度去分析defer的实现原理,主要涉及defer参数传递方式、闭包、返回值修改、内存分配方式等内容。
1 使用特性
1.1 函数传参
在使用defer实现延迟调用的时候,需要特别注意的是注册函数的参数传参方式,如果直接使用的常规的函数传参,那么参数值在函数注册到defer链表上时就已经被确定了,这时候延迟的只是函数调用的时机,参数值早在一开始构建_defer结构体的时候就已经确定了(如果是引用语义的类型,则需要特别注意,这里仅以int类型作为参数进行分析)。以下面的sum函数为例,分析其汇编代码不难发现:
- _defer结构体的内存布局会将注册函数所需的参数、返回值所需的内存空间紧挨着_defer结构体。这里需要特别注意下,在_defer结构体的内存布局中,前8个字节包含siz(4字节)、started(1字节)、heap(1字节)、openDefer(1字节)。关于Go结构体内存布局,感兴趣的可以参考下Go struct:结构体使用基础以及内存布局。
- 按照函数传参的方式进行defer注册,编译器直接将sum函数所需要的参数值赋值到了_defer结构体存储func参数的栈空间(值拷贝)
- 由于函数传参的方式是以值拷贝实现的,因此后续代码执行的变量自增操作不会对之前延迟注册函数所需的参数产生影响。(注意:Go的引用类型,slice、map、chan、interface,这些类型由于底层包含指针,虽然是值拷贝,但受指针影响,后续的修改可能会对延迟调用产生影响)
func sum(a, b int) int {return a + b
}func main() {a, b := 1, 2// 通过函数传参的方式注册的延迟调用,在注册时已经将函数的形参值确定了defer sum(a, b) a++
}"".main STEXT size=197 args=0x0 locals=0x90 funcid=0x00x0000 00000 (main.go:20) TEXT "".main(SB), ABIInternal, $144-0...0x002f 00047 (main.go:22) MOVQ $1, "".a+32(SP) // 变量a0x0038 00056 (main.go:22) MOVQ $2, "".b+24(SP) // 变量b0x0041 00065 (main.go:23) MOVL $24, ""..autotmp_2+40(SP) // SP+40~SP+112的栈空间存储的是 _defer结构体0x0049 00073 (main.go:23) LEAQ "".sum·f(SB), AX0x0050 00080 (main.go:23) MOVQ AX, ""..autotmp_2+64(SP)0x0055 00085 (main.go:23) MOVQ "".a+32(SP), AX0x005a 00090 (main.go:23) MOVQ AX, ""..autotmp_2+112(SP)0x005f 00095 (main.go:23) MOVQ "".b+24(SP), AX0x0064 00100 (main.go:23) MOVQ AX, ""..autotmp_2+120(SP)0x0069 00105 (main.go:23) LEAQ ""..autotmp_2+40(SP), AX0x006e 00110 (main.go:23) MOVQ AX, (SP)0x0072 00114 (main.go:23) PCDATA $1, $00x0072 00114 (main.go:23) CALL runtime.deferprocStack(SB)0x0077 00119 (main.go:23) TESTL AX, AX // 测试返回值(AX寄存器),如果deferprocStack调用成功,return0()会将AX设置为00x0079 00121 (main.go:23) JNE 161 // 如果返回值不为0,则跳转到161,不再执行main函数中的后续代码0x007b 00123 (main.go:23) JMP 1250x007d 00125 (main.go:27) MOVQ "".a+32(SP), AX0x0082 00130 (main.go:27) INCQ AX // a++0x0085 00133 (main.go:27) MOVQ AX, "".a+32(SP)0x008a 00138 (main.go:28) XCHGL AX, AX0x008b 00139 (main.go:28) CALL runtime.deferreturn(SB) // 执行延迟调用0x0090 00144 (main.go:28) MOVQ 136(SP), BP // 恢复调用者的BP0x0098 00152 (main.go:28) ADDQ $144, SP // 清理栈空间0x009f 00159 (main.go:28) NOP0x00a0 00160 (main.go:28) RET0x00a1 00161 (main.go:23) XCHGL AX, AX0x00a2 00162 (main.go:23) CALL runtime.deferreturn(SB)0x00a7 00167 (main.go:23) MOVQ 136(SP), BP0x00af 00175 (main.go:23) ADDQ $144, SP0x00b6 00182 (main.go:23) RET0x00b7 00183 (main.go:23) NOP...
1.2 闭包
对于传统值拷贝的类型,想要在defer执行函数时,实时获取最新的参数值,则可以借助Go语言里面的闭包特性进行实现。闭包特性简单来说就是使用指针取代具体值进行传递,这样在后续需要使用该变量时,通过对地址的解引用来得到实时的值,从而实现变量最新值的获取。
闭包=函数地址 + 引用变量的地址
当函数引用外部作用域的变量时,我们称之为闭包。在底层实现上,闭包由函数地址和引用到的变量的地址组成,并存储在一个结构体里,在闭包被传递时,实际是该结构体的地址被传递。
type Closure struct {F func()() // 函数地址 uintptri *int // 引用变量的地址 }
还是以sum函数为例,其中两个参数a,b,采用闭包的方式构建匿名函数注册延迟调用,分析其汇编语言发现,主要的流程大致与函数传参的方式一致,区别在于:
- 此时defer注册的是匿名函数func1,不再是sum函数,新函数仅需要两个变量,而没有返回值
- func1函数所需的参数依旧保存在_defer结构体之后,只是编译器通过分析代码,对不同处境的变量做了不同的处理:
- 对于后续不会再被改变的变量,编译器直接进行了值拷贝,例如此例中的变量b
- 如果后续还会对变量进行修改,则编译器将其内存地址保存到了_defer结构体之后,例如此例中的变量a
- 由于参数a保存的是其地址,那么在执行func1时,对其进行解引用拿到的值就是执行了自增之后的值,既实际sum函数执行时,两个参数的值a=2,b=2。
func sum(a, b int) int {return a + b
}func main() {a, b := 1, 2defer func() {sum(a, b)}()a++
}"".main STEXT size=170 args=0x0 locals=0x80 funcid=0x00x0000 00000 (main.go:20) TEXT "".main(SB), ABIInternal, $128-0...0x0021 00033 (main.go:22) MOVQ $1, "".a+24(SP)0x002a 00042 (main.go:22) MOVQ $2, "".b+16(SP)0x0033 00051 (main.go:23) MOVL $16, ""..autotmp_2+32(SP) // 此时注册的延迟调用函数为func匿名函数,仅有sum函数的两个变量,没有返回值0x003b 00059 (main.go:23) LEAQ "".main.func1·f(SB), AX0x0042 00066 (main.go:23) MOVQ AX, ""..autotmp_2+56(SP)0x0047 00071 (main.go:23) LEAQ "".a+24(SP), AX // 由于后面的代码还会对a进行修改,所以此处保存的是a的内存地址(闭包)0x004c 00076 (main.go:23) MOVQ AX, ""..autotmp_2+104(SP)0x0051 00081 (main.go:23) MOVQ "".b+16(SP), AX // b在后续不会再被修改,所以编译器进行了优化直接存储b的值0x0056 00086 (main.go:23) MOVQ AX, ""..autotmp_2+112(SP)0x005b 00091 (main.go:23) LEAQ ""..autotmp_2+32(SP), AX0x0060 00096 (main.go:23) MOVQ AX, (SP)0x0064 00100 (main.go:23) PCDATA $1, $00x0064 00100 (main.go:23) CALL runtime.deferprocStack(SB)0x0069 00105 (main.go:23) TESTL AX, AX // 判断deferprocStack函数是否正常执行return0(),如果没有正常执行直接跳转至143进行栈的清理工作0x006b 00107 (main.go:23) JNE 1430x006d 00109 (main.go:23) JMP 1110x006f 00111 (main.go:26) MOVQ "".a+24(SP), AX0x0074 00116 (main.go:26) INCQ AX0x0077 00119 (main.go:26) MOVQ AX, "".a+24(SP)0x007c 00124 (main.go:27) XCHGL AX, AX0x007d 00125 (main.go:27) NOP0x0080 00128 (main.go:27) CALL runtime.deferreturn(SB)0x0085 00133 (main.go:27) MOVQ 120(SP), BP0x008a 00138 (main.go:27) SUBQ $-128, SP0x008e 00142 (main.go:27) RET0x008f 00143 (main.go:23) XCHGL AX, AX0x0090 00144 (main.go:23) CALL runtime.deferreturn(SB)0x0095 00149 (main.go:23) MOVQ 120(SP), BP0x009a 00154 (main.go:23) SUBQ $-128, SP0x009e 00158 (main.go:23) RET...
1.3 返回值修改
defer注册的函数是在主流程结束,函数返回之前被调用,那么如果在defer延迟调用的函数中对返回值进行修改,又会有怎么样的现象呢?从汇编的角度来看,return分为两步:先是对返回值进行赋值,最后函数结束时执行一个空的返回操作,而defer的执行时机则穿插在这两步之间。具体的执行流程如下:
-
返回值 = xxx
-
调用defer注册的函数
-
空的return
根据上述的流程不难发现,可以在defer注册的延迟调用函数内部对返回值进行修改或赋值,下面从返回值重新赋值、直接修改返回值两种情况来分析下defer的延迟调用对返回值的影响。
1.3.1 返回值重新赋值
编写如下的代码,主要流程为定义一个变量t,通过闭包的形式对其在defer匿名函数中进行修改,并将该变量作为返回值进行返回。此函数有一个有名返回值r,既最后返回的值是一个内部的新变量,并不是直接定义的返回值变量r。通过汇编语言来分析下变量定义以及defer注册之后,其函数栈的具体情况:
- 首先,Go的函数栈分布情况为:调用者函数栈帧会为被调用者预留参数、返回值所需的内存空间。如下图所示,main函数栈帧中会预留f函数的返回值空间。
- 随后,进入f函数内部,有一个局部变量t,以及在栈上分配的_defer结构体,该_defer的延迟调用函数为匿名f.func1,所需参数为t(由于闭包,存储的是t的内存地址)
// 该函数返回值为5
func f() (r int){t := 5defer func(){t = t + 5}()return t
}"".f STEXT size=155 args=0x8 locals=0x68 funcid=0x0...0x0021 00033 (main.go:4) MOVQ $0, "".r+112(SP) // 调用者的函数栈空间,用于存储f函数的返回值0x002a 00042 (main.go:5) MOVQ $5, "".t+8(SP)0x0033 00051 (main.go:6) MOVL $8, ""..autotmp_2+16(SP)0x003b 00059 (main.go:6) LEAQ "".f.func1·f(SB), AX0x0042 00066 (main.go:6) MOVQ AX, ""..autotmp_2+40(SP)0x0047 00071 (main.go:6) LEAQ "".t+8(SP), AX0x004c 00076 (main.go:6) MOVQ AX, ""..autotmp_2+88(SP)0x0051 00081 (main.go:6) LEAQ ""..autotmp_2+16(SP), AX0x0056 00086 (main.go:6) MOVQ AX, (SP)0x005a 00090 (main.go:6) PCDATA $1, $00x005a 00090 (main.go:6) CALL runtime.deferprocStack(SB)0x005f 00095 (main.go:6) NOP
随后,代码开始执行return语句:
- 将t值赋值给r作为返回值,从汇编语言的角度看就是将t的值拷贝到r的内存空间(调用者预留的空间内)。
- 开始执行延迟调用函数,既f.func1函数,将t的变量值➕5,此处操作与变量r完全没有关系。
- 最后执行一个空的返回操作。
0x0060 00096 (main.go:6) TESTL AX, AX0x0062 00098 (main.go:6) JNE 1290x0064 00100 (main.go:6) JMP 1020x0066 00102 (main.go:9) MOVQ "".t+8(SP), AX 0x006b 00107 (main.go:9) MOVQ AX, "".r+112(SP) // 将t的值赋值给r,既将其存储到调用者的f函数返回值栈空间内0x0070 00112 (main.go:9) XCHGL AX, AX0x0071 00113 (main.go:9) CALL runtime.deferreturn(SB) // 执行延迟调用,将t的值➕50x0076 00118 (main.go:9) MOVQ 96(SP), BP0x007b 00123 (main.go:9) ADDQ $104, SP0x007f 00127 (main.go:9) NOP0x0080 00128 (main.go:9) RET // 相当于执行空的return操作..."".f.func1 STEXT nosplit size=21 args=0x8 locals=0x0 funcid=0x00x0000 00000 (main.go:6) TEXT "".f.func1(SB), NOSPLIT|ABIInternal, $0-80x0000 00000 (main.go:6) FUNCDATA $0, gclocals·1a65e721a2ccc325b382662e7ffee780(SB)0x0000 00000 (main.go:6) FUNCDATA $1, gclocals·69c1753bd5f81501d95132d08af04464(SB)0x0000 00000 (main.go:7) MOVQ "".&t+8(SP), AX // AX = 捕获变量 t 的地址0x0005 00005 (main.go:7) MOVQ "".&t+8(SP), CX // CX = 捕获变量 t 的地址0x000a 00010 (main.go:7) MOVQ (CX), CX // CX = *CX (解引用获取 t 的值)0x000d 00013 (main.go:7) ADDQ $5, CX // CX = t + 50x0011 00017 (main.go:7) MOVQ CX, (AX) // *AX = CX (将结果写回 t)0x0014 00020 (main.go:8) RET
1.3.2 直接修改返回值
本例在之前的代码基础上进行修改,将返回值的名称修改为t,既函数内部不再重新定义一个新的变量,而是直接对返回值进行操作,此时编译器做出如下动作:
- 首先,main函数栈帧中会预留f函数的返回值t,f函数对t的操作都直接会反应到返回值上。
- 随后,在栈上分配的_defer结构体,该_defer的延迟调用函数为匿名f.func1,所需参数为t(由于闭包,存储的是t的内存地址,既返回值的地址)
- 最后,deferreturn函数执行延迟调用时,执行匿名f.func1函数,会通过内存地址解引用找到t,对其进行➕5操作,这里的修改直接反映在f.func1函数的返回值上,因此最终f函数会返回10。
2 实现方式
2.1 堆分配
随着Go语言的不断优化发展,_defer结构体内存分配方式也不再仅有最原始的堆分配,新增了栈分配方式以及开发编码。虽然栈分配相比堆分配,性能占有,但其适用范围有效,而堆分配则适用于所用的情况,下面以循环内部注册defer触发编译时期无法确定具体defer数量为例,分析下此时函数栈、堆的具体情况:
- 首先,由于_defer结构体所需的内存是在堆上进行分配的,那么函数栈帧中仅需存储调用deferproc所需的参数值、defer延迟调用函数所需的参数以及返回值空间。
- 随后,调用deferproc函数,通过newdefer函数在堆上分配一块_defer结构体内存,将参数拷贝到对应位置,同时在defer结构体常规成员变量之后。紧接着拷贝注册的延迟调用函数所需参数以及返回值,并将该_defer结构体加入到Goroutine到_defer链表头部。
func sum(a, b int) int {return a + b
}func main() {for i := 0; i < 3; i++ {// 循环内的 defer 导致无法在编译时确定数量defer sum(i, i+1)}
}"".main STEXT size=170 args=0x0 locals=0x38 funcid=0x0...0x0021 00033 (main.go:9) MOVQ $0, "".i+40(SP) // 变量i初始化0x002a 00042 (main.go:9) JMP 440x002c 00044 (main.go:9) CMPQ "".i+40(SP), $3 // i与3进行比较0x0032 00050 (main.go:9) JLT 540x0034 00052 (main.go:9) JMP 1430x0036 00054 (main.go:11) MOVQ "".i+40(SP), AX0x003b 00059 (main.go:11) MOVQ "".i+40(SP), CX0x0040 00064 (main.go:11) MOVL $24, (SP) // deferproc 函数的第一个参数siz0x0047 00071 (main.go:11) LEAQ "".sum·f(SB), DX0x004e 00078 (main.go:11) MOVQ DX, 8(SP) // deferproc 函数的第二个参数fn0x0053 00083 (main.go:11) MOVQ AX, 16(SP) // "".sum·f 函数的第一个参数0x0058 00088 (main.go:11) LEAQ 1(CX), AX // AX = i+10x005c 00092 (main.go:11) MOVQ AX, 24(SP) // "".sum·f 函数的第二个参数0x0061 00097 (main.go:11) PCDATA $1, $00x0061 00097 (main.go:11) CALL runtime.deferproc(SB) // 调用 deferproc函数从堆上分配_defer所需的内存0x0066 00102 (main.go:11) TESTL AX, AX // return0()函数是否执行成功0x0068 00104 (main.go:11) JNE 1250x006a 00106 (main.go:11) JMP 1080x006c 00108 (main.go:9) PCDATA $1, $-10x006c 00108 (main.go:9) JMP 1100x006e 00110 (main.go:9) MOVQ "".i+40(SP), AX0x0073 00115 (main.go:9) INCQ AX0x0076 00118 (main.go:9) MOVQ AX, "".i+40(SP)0x007b 00123 (main.go:9) JMP 440x007d 00125 (main.go:11) PCDATA $1, $00x007d 00125 (main.go:11) XCHGL AX, AX0x007e 00126 (main.go:11) NOP0x0080 00128 (main.go:11) CALL runtime.deferreturn(SB)0x0085 00133 (main.go:11) MOVQ 48(SP), BP0x008a 00138 (main.go:11) ADDQ $56, SP0x008e 00142 (main.go:11) RET...
2.2 栈分配
栈分配适用的条件没有堆分配那么多,仅适用于函数不逃逸且defer
数量确定的场景,由于此时_defer结构体是在函数栈帧上分配的,那么只需移动SP的值就可以完成_defer结构体内存的回收,执行效率很高。对比堆分配,栈分配只是将内存空间放在了栈上,_defer内存布局、延迟调用函数参数及返回值存储位置与堆分配完全一致。
func sum(a, b int) int {return a + b
}func main() {defer sum(1, 2)
}"".main STEXT size=126 args=0x0 locals=0x80 funcid=0x0...0x001d 00029 (main.go:8) MOVL $24, ""..autotmp_0+24(SP)0x0025 00037 (main.go:8) LEAQ "".sum·f(SB), AX0x002c 00044 (main.go:8) MOVQ AX, ""..autotmp_0+48(SP)0x0031 00049 (main.go:8) MOVQ $1, ""..autotmp_0+96(SP)0x003a 00058 (main.go:8) MOVQ $2, ""..autotmp_0+104(SP)0x0043 00067 (main.go:8) LEAQ ""..autotmp_0+24(SP), AX0x0048 00072 (main.go:8) MOVQ AX, (SP)0x004c 00076 (main.go:8) PCDATA $1, $00x004c 00076 (main.go:8) CALL runtime.deferprocStack(SB)...
2.3 开放编码
使用go tool compile -S -l main.go
命令查看了下述简单代码在开放编码下的汇编语言,可以发现编译器没有再调用deferprocStack、deferproc去为每个defer生成一个defer结构体,而是直接编译成函数调用的方式,相当于把延迟调用改写成了在函数返回之前需要进行的正常函数调用。
func sum(a, b int) int {return a + b
}func main() {defer sum(1, 2)
}$ go tool compile -S -l main.go "".main STEXT size=140 args=0x0 locals=0x40 funcid=0x0...0x0029 00041 (main.go:11) FUNCDATA $4, "".main.opendefer(SB) // 标记使用了开放编码0x0029 00041 (main.go:11) MOVB $0, ""..autotmp_0+31(SP)0x002e 00046 (main.go:12) LEAQ "".sum·f(SB), AX0x0035 00053 (main.go:12) MOVQ AX, ""..autotmp_1+48(SP)0x003a 00058 (main.go:12) MOVQ $1, ""..autotmp_2+40(SP)0x0043 00067 (main.go:12) MOVQ $2, ""..autotmp_3+32(SP)0x004c 00076 (main.go:13) MOVB $0, ""..autotmp_0+31(SP)0x0051 00081 (main.go:13) MOVQ ""..autotmp_2+40(SP), AX0x0056 00086 (main.go:13) MOVQ ""..autotmp_3+32(SP), CX0x005b 00091 (main.go:13) MOVQ AX, (SP)0x005f 00095 (main.go:13) MOVQ CX, 8(SP)0x0064 00100 (main.go:13) PCDATA $1, $10x0064 00100 (main.go:13) CALL "".sum(SB) // 直接进行了函数调用0x0069 00105 (main.go:13) MOVQ 56(SP), BP0x006e 00110 (main.go:13) ADDQ $64, SP0x0072 00114 (main.go:13) RET0x0073 00115 (main.go:13) CALL runtime.deferreturn(SB)0x0078 00120 (main.go:13) MOVQ 56(SP), BP0x007d 00125 (main.go:13) ADDQ $64, SP0x0081 00129 (main.go:13) RET...