写在前面
为了在不同的线程之间转移任务,最近项目代码中大量地使用了闭包:在一个 goroutine(协程)中把一段逻辑封装成为匿名函数,然后传入到另一个线程的 channel(通道)变量去排队运行。
在业务逻辑的测试过程中发现了一个怪异的点,查证后发现原来是闭包的使用认知存在问题,这里作为一个知识点总结一下。
Golang 闭包内的外部变量
闭包(匿名函数)
教科书式的定义可以这么理解闭包:
闭包是可以包含自由(未绑定到特定对象)变量的代码块,这些变量不在这个代码块内或者任何全局上下文中定义,而是在定义代码块的环境中定义。要执行的代码块(由于自由变量包含在代码块中,所以这些自由变量以及它们引用的对象没有被释放)为自由变量提供绑定的计算环境(作用域)。(摘自《Go语言编程》)
如果大家对闭包的细节感兴趣希望深入理解其设计,可以自行查阅资料;本文中提到的闭包可以简单地理解为“匿名函数”。
先看一段代码
下面的代码中定义了一个匿名函数并赋值给myfunc变量,同时在代码的后面连续调用了两次myfunc函数。大家可以先考虑一下代码的输出是什么,然后再查看文章后面的内容。
// cat main.go package main import ( "fmt" ) func main() { a1 := 1 a2 := 2 myfunc := func() { sum := a1 + a2 fmt.Printf("a1: %d, a2:%d, sum: %d\n", a1, a2, sum) } myfunc() a1 = 11 a2 = 22 myfunc() }
运行上面的代码,可以看到上面代码的输出为:
# go run main.go a1: 1, a2:2, sum: 3 a1: 11, a2:22, sum: 33
Golang 闭包内的外部变量
在上面的代码中, myfunc 指向了一个匿名函数(闭包),在这个匿名函数中,a1和a2均是外部变量。
从上面代码的运行输出可以知道,闭包内的外部变量并不是被“锁死”的,而是会随着外部变量的变化而变化。这个特性应该与函数参数的传值特性进行区分:① Golang 中函数的参数以及返回都是数值的传递,而非引用的传递;也就是说,即使入参是一个指针,在函数运行的时候起作用的也是一个被拷贝出来的指针。② 闭包内的外部变量会跟随外部变量的变化,就好像在闭包内引用的永远是变量的指针(哪怕变量是一个普普通通的数值);比如上面代码中a1和a2均是int类型的值,但在闭包内的使用就好像是指针。
汇编代码的分析
如果想要进一步分析闭包内外部变量的作用方式,可以在汇编层面进行进一步的探究,研究其本质。
汇编代码的生成
把上面的代码保存到某个目录中,运行下面的指令可以得到相应的汇编文件:
# 下面的指令标明把 main.go 生成 linux 下的 amd64 二进制文件 # 其中 -N 指定编译器不要进行优化,-l 指定编译器不要对函数进行内联处理 # 其中 -o testl 指定输出二进制文件到 testl 中 # -gcflags 的参数可以通过 go tool compile --help 获取 CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build --gcflags "-N -l" -o testl main.go # 可以通过 go tool objdump --help 来查看 objdump 的 -s 用法 # 比如 go tool objdump -s "^main.main$" testl 只返回 main.main 函数的汇编代码 # 下面的指令标明把 上一步生成的 testl 提取汇编代码到 ojbl.S 文件中 go tool objdump -S testl > objl.S
main.main 函数的汇编代码
函数体对应的汇编语言如下,大家可以看里面的注释进行理解。需要重点关注的点是:在 myfunc 函数定义的地方,a1与a2都是地址传递(地址传递)而非数值传递。
从下面的汇编代码还可以看出第二次调用 myfunc 函数与第一次调用的方式不一样,主要考虑是DX寄存器的纯粹性,第一次调用myfunc时DX是满足需求的,第二次就需要专门置位了。
TEXT main.main(SB) /golang/src/jingwei.link/main.go func main() { 0x488300 64488b0c25f8ffffff MOVQ FS:0xfffffff8, CX 0x488309 483b6110 CMPQ 0x10(CX), SP 0x48830d 0f8690000000 JBE 0x4883a3 ; 上面三是对栈进行扩容判定,如果栈不够用了,会进行扩容 0x488313 4883ec40 SUBQ $0x40, SP ; 预留出 0x40 的栈空间供 main 函数使用 0x488317 48896c2438 MOVQ BP, 0x38(SP) 0x48831c 488d6c2438 LEAQ 0x38(SP), BP ; 上面两句待探究,应该是为了保存某个场景为未来恢复某个状态做准备 a1 := 1 0x488321 48c744240801000000 MOVQ $0x1, 0x8(SP) ; 把 1 赋值到 0x8(SP) 的地址,即 a1 a2 := 2 0x48832a 48c7042402000000 MOVQ $0x2, 0(SP) ; 把 2 赋值到 0x8(SP) 的地址,即 a2 myfunc := func() { 0x488332 48c744242000000000 MOVQ $0x0, 0x20(SP) 0x48833b 0f57c0 XORPS X0, X0 0x48833e 0f11442428 MOVUPS X0, 0x28(SP) 0x488343 488d542420 LEAQ 0x20(SP), DX ; 把 0x20(SP) 的地址加载到 DX 中 0x488348 4889542418 MOVQ DX, 0x18(SP) ; 把 DX 的值,即 0x20(SP) 的值,赋值到 0x18(SP) 中; 0x18(SP) 中保存的是 0x20(SP) 的地址 0x48834d 8402 TESTB AL, 0(DX) 0x48834f 488d05ca000000 LEAQ main.main.func1(SB), AX ; 把 func1(我们定义的闭包函数体) 的地址赋值到 AX 0x488356 4889442420 MOVQ AX, 0x20(SP) ; 把 AX 的值,即 func1 的地址,赋值到 0x20(SP) 中; 0x20(SP) 中保存的是 func1 的调用地址 0x48835b 8402 TESTB AL, 0(DX) 0x48835d 488d442408 LEAQ 0x8(SP), AX ; 把 0x8(SP) 的地址,即 a1 的地址(指针)赋值到 AX 0x488362 4889442428 MOVQ AX, 0x28(SP) ; 把 a1 赋值到 0x28(SP) 中;0x28(SP) 中保存的是 a1 的地址 0x488367 8402 TESTB AL, 0(DX) 0x488369 488d0424 LEAQ 0(SP), AX ; 把 0(SP) 的地址,即 a2 的地址(指针)赋值到 AX 0x48836d 4889442430 MOVQ AX, 0x30(SP) ; 把 a2 赋值到 0x30(SP) 中;0x30(SP) 中保存的是 a2 的地址 0x488372 4889542410 MOVQ DX, 0x10(SP) ; 把 DX 的值,即 0x20(SP) 的地址,赋值到 0x10(SP) 中;0x10(SP) 中保存的是 0x20(SP) 的地址 myfunc() 0x488377 488b442420 MOVQ 0x20(SP), AX ; 把 0x20(SP) 中的内容,即 func1 的地址加载到 AX 寄存器 0x48837c ffd0 CALL AX ; 调用 func1 函数 a1 = 11 0x48837e 48c74424080b000000 MOVQ $0xb, 0x8(SP) ; 把 11 赋值到 0x8(SP) 的地址,即更新 a1 a2 = 22 0x488387 48c7042416000000 MOVQ $0x16, 0(SP) ; 把 22 赋值到 0(SP) 的地址,即更新 a2 myfunc() 0x48838f 488b542410 MOVQ 0x10(SP), DX ; 这里把 0x10(SP) 中的值,即 0x20(SP) 的地址加载到 DX 寄存器 0x488394 488b02 MOVQ 0(DX), AX ; 把 0(DX) 中的值,即 func1 的地址加载到 AX 寄存器 0x488397 ffd0 CALL AX ; 调用 func 1 函数。 } 0x488399 488b6c2438 MOVQ 0x38(SP), BP 0x48839e 4883c440 ADDQ $0x40, SP 0x4883a2 c3 RET func main() { 0x4883a3 e83869fcff CALL runtime.morestack_noctxt(SB) ; 申请更多的栈空间的地方,也是 goroutine 抢占的检查点 0x4883a8 e953ffffff JMP main.main(SB)
myfunc (匿名函数)的汇编代码
从下面的汇编代码可以看到,匿名函数在每次调用时,都会 ① 首先根据闭包内的外部变量的地址(a1和a2的地址)获取得到外部变量的值,然后才 ② 利用获取得到的值进行闭包内逻辑的运算。
TEXT main.main.func1(SB) /golang/src/jingwei.link/main.go myfunc := func() { 0x488420 64488b0c25f8ffffff MOVQ FS:0xfffffff8, CX 0x488429 488d4424a8 LEAQ -0x58(SP), AX 0x48842e 483b4110 CMPQ 0x10(CX), AX 0x488432 0f86ab010000 JBE 0x4885e3 ; 上面三是对栈进行扩容判定,如果栈不够用了,会进行扩容 0x488438 4881ecd8000000 SUBQ $0xd8, SP ; 预留出 0xd8 的栈空间供 func1(myfunc) 函数使用 0x48843f 4889ac24d0000000 MOVQ BP, 0xd0(SP) 0x488447 488dac24d0000000 LEAQ 0xd0(SP), BP ; 上面两句待探究,应该是为了保存某个场景为恢复某个状态做准备 ; 下面重点关注 DX 的值,是 main.mian 中 0x20(SP) 的地址(区别于本函数的 SP 地址,本函数的 SP 地址已经由 SUBQ 改变过了) 0x48844f 488b4208 MOVQ 0x8(DX), AX ; 0x8(DX),其实就是 main.main 中的 0x28(SP),即 a1 的地址,把这个地址里的值赋值到 AX 0x488453 4889842480000000 MOVQ AX, 0x80(SP) ; 把 a1 的值赋值到 0x80(SP) 0x48845b 488b4210 MOVQ 0x10(DX), AX ; 0x10(DX),其实就是 main.main 中的 0x30(SP),即 a2 的地址,把这个地址里的值赋值到 AX 0x48845f 4889442478 MOVQ AX, 0x78(SP) ; 把 a2 的值赋值到 0x80(SP) sum := a1 + a2 0x488464 488b8c2480000000 MOVQ 0x80(SP), CX ; 接下来就是很容易理解的加法运算了 0x48846c 488b09 MOVQ 0(CX), CX 0x48846f 480308 ADDQ 0(AX), CX 0x488472 48894c2440 MOVQ CX, 0x40(SP) fmt.Printf("a1: %d, a2:%d, sum: %d\n", a1, a2, sum) ; 再往下就是复杂的 fmt.Printf 函数了,代码很长很臭,就不贴了
小结
本文就闭包中外部变量的使用进行展开,首先 ① 介绍了闭包内的外部变量会随着外部变量的变化而变化(类比于指针的使用),然后 ② 在汇编语句层面进行了进一步的分析,道明了闭包中外部变量使用的本质。