Golang 闭包内的外部变量

写在前面

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

小结

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

参考