闭包内的外部变量会跟随外部变量变化(类似指针传入)

写在前面

为了在不同的线程之间转移任务,最近项目代码中大量地使用了闭包:在一个 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 指向了一个匿名函数(闭包),在这个匿名函数中,a1a2 均是外部变量。

从上面代码的运行输出可以知道,闭包内的外部变量并不是被“锁死”的,而是会随着外部变量的变化而变化。这个特性应该与函数参数的传值特性进行区分:① Golang 中函数的参数以及返回都是数值的传递,而非引用的传递;也就是说,即使入参是一个指针,在函数运行的时候起作用的也是一个被拷贝出来的指针。② 闭包内的外部变量会跟随外部变量的变化,就好像在闭包内引用的永远是变量的指针(哪怕变量是一个普普通通的数值);比如上面代码中 a1a2 均是 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 函数定义的地方, a1a2 都是地址传递(地址传递)而非数值传递。

从下面的汇编代码还可以看出第二次调用 myfunc 函数与第一次调用的方式不一样,主要考虑是 DX 寄存器的纯粹性,第一次调用 myfuncDX 是满足需求的,第二次就需要专门置位了。

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 (匿名函数)的汇编代码

从下面的汇编代码可以看到,匿名函数在每次调用时,都会 ① 首先根据闭包内的外部变量的地址(a1a2 的地址)获取得到外部变量的值,然后才 ② 利用获取得到的值进行闭包内逻辑的运算。

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 函数了,代码很长很臭,就不贴了

小结

本文就闭包中外部变量的使用进行展开,首先 ① 介绍了闭包内的外部变量会随着外部变量的变化而变化(类比于指针的使用),然后 ② 在汇编语句层面进行了进一步的分析,道明了闭包中外部变量使用的本质。

参考