写在前面
为了在不同的线程之间转移任务,最近项目代码中大量地使用了闭包:在一个 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 函数了,代码很长很臭,就不贴了
小结
本文就闭包中外部变量的使用进行展开,首先 ① 介绍了闭包内的外部变量会随着外部变量的变化而变化(类比于指针的使用),然后 ② 在汇编语句层面进行了进一步的分析,道明了闭包中外部变量使用的本质。
参考
- Go语言中的闭包 介绍了 Golang 中的闭包
- A Manual for the Plan 9 assembler 神奇的汇编器,还没来得及研究,放这里作为后面的参考
- Plan9 的 asm.PDF PDF 文档,可以作为理解 Golang 汇编语句的参考
- golang 汇编 — 源代码 很不错的讲汇编的内容
有疑问加站长微信联系(非本文作者)