36

Golang 闭包内的外部变量

 4 years ago
source link: https://www.tuicool.com/articles/r2mm2eV
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

写在前面

为了在不同的线程之间转移任务,最近项目代码中大量地使用了闭包:在一个 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 函数了,代码很长很臭,就不贴了

小结

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

参考


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK