Go语言中的闭包详解

标签:Go语言首次发布:2024-01-27最近修改:2024-04-02

基本概念

Go 语言中闭包是引用了自由变量的函数,被引用的自由变量和函数一同存在,即使已经离开了自由变量的环境也不会被释放或者删除,在闭包中可以继续使用这个自由变量。闭包也可以说是一个函数和与其相关的引用环境组合而成的实体。简单来说,闭包=函数+引用环境。如下所示就是闭包的示意图:

image-20240125161017458

前面的概念可能还是不太好理解,下面是关于闭包的汇编分析。

汇编分析

例子 1

使用闭包写一个非常简单的测试代码:

go
func main() {    a := 1    func() int {        a = 3        return a    }()}

使用go tool compile -S -N -l main.go命令生成的核心汇编代码如下:

text
main.main STEXT size=56 args=0x0 locals=0x28 funcid=0x0 align=0x0        0x0000 00000 (main.go:3)        TEXT    main.main(SB), ABIInternal, $40-0        // 栈溢出检查        0x0000 00000 (main.go:3)        CMPQ    SP, 16(R14)        0x0004 00004 (main.go:3)        PCDATA  $0, $-2        0x0004 00004 (main.go:3)        JLS     49        0x0006 00006 (main.go:3)        PCDATA  $0, $-1        // 栈内存分配、保存旧栈的基地址和建立新栈帧        0x0006 00006 (main.go:3)        SUBQ    $40, SP        0x000a 00010 (main.go:3)        MOVQ    BP, 32(SP)        0x000f 00015 (main.go:3)        LEAQ    32(SP), BP        // gc垃圾处理        0x0014 00020 (main.go:3)        FUNCDATA        $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)        0x0014 00020 (main.go:3)        FUNCDATA        $1, gclocals·EaPwxsZ75yY1hHMVZLmk6g==(SB)        // a=1        0x0014 00020 (main.go:4)        MOVQ    $1, main.a+8(SP)        // 把变量a的地址存储到AX寄存器        0x001d 00029 (main.go:8)        LEAQ    main.a+8(SP), AX        0x0022 00034 (main.go:8)        PCDATA  $1, $0        // 调用匿名函数        0x0022 00034 (main.go:8)        CALL    main.main.func1(SB)        // BP地址恢复和栈空间回收        0x0027 00039 (main.go:9)        MOVQ    32(SP), BP        0x002c 00044 (main.go:9)        ADDQ    $40, SP        // 函数返回        0x0030 00048 (main.go:9)        RET        0x0031 00049 (main.go:9)        NOP        0x0031 00049 (main.go:3)        PCDATA  $1, $-1        0x0031 00049 (main.go:3)        PCDATA  $0, $-2        0x0031 00049 (main.go:3)        CALL    runtime.morestack_noctxt(SB)        0x0036 00054 (main.go:3)        PCDATA  $0, $-1        0x0036 00054 (main.go:3)        JMP     0main.main.func1 STEXT nosplit size=61 args=0x8 locals=0x10 funcid=0x0 align=0x0        0x0000 00000 (main.go:5)        TEXT    main.main.func1(SB), NOSPLIT|ABIInternal, $16-8        // 栈内存分配、保存旧栈的基地址和建立新栈帧        0x0000 00000 (main.go:5)        SUBQ    $16, SP        0x0004 00004 (main.go:5)        MOVQ    BP, 8(SP)        0x0009 00009 (main.go:5)        LEAQ    8(SP), BP        // gc垃圾处理        0x000e 00014 (main.go:5)        FUNCDATA        $0, gclocals·wgcWObbY2HYnK2SU/U22lA==(SB)        0x000e 00014 (main.go:5)        FUNCDATA        $1, gclocals·J5F+7Qw7O7ve2QcWC7DpeQ==(SB)        0x000e 00014 (main.go:5)        FUNCDATA        $5, main.main.func1.arginfo1(SB)        // 把AX寄存器的值(main函数a变量的地址)存储到栈上的某个位置---24(SP)        0x000e 00014 (main.go:5)        MOVQ    AX, main.&a+24(SP)        // 初始化匿名函数的返回值        0x0013 00019 (main.go:5)        MOVQ    $0, main.~r0(SP)        // 把a变量的地址存储到CX寄存器        0x001b 00027 (main.go:6)        MOVQ    main.&a+24(SP), CX        // 将立即数3拷贝到CX寄存器指向的内存地址(a变量的内存地址)        0x0020 00032 (main.go:6)        MOVQ    $3, (CX)        // 把a变量的地址存储到CX寄存器        0x0027 00039 (main.go:7)        MOVQ    main.&a+24(SP), CX        // 把CX寄存器指向的内存地址中存储的值(变量a的内存地址)拷贝到AX寄存器        0x002c 00044 (main.go:7)        MOVQ    (CX), AX        // 将AX寄存器中的值存储到函数返回值的位置        0x002f 00047 (main.go:7)        MOVQ    AX, main.~r0(SP)        // BP地址恢复和栈空间回收        0x0033 00051 (main.go:7)        MOVQ    8(SP), BP        0x0038 00056 (main.go:7)        ADDQ    $16, SP        // 函数返回        0x003c 00060 (main.go:7)        RET

从汇编中可以看出,匿名函数可以实现对变量 a 的获取和修改是因为在调用匿名函数之前,main 函数将变量 a 的地址保存到了 AX 寄存器中。也就是说,现在 main 函数的栈中除了保存了变量 a 的值,还有对于寄存器 AX 的引用。那么对于匿名函数,只需要获取寄存器 AX 的值就能知道变量 a 的地址,进而可以对变量 a 进行修改。

所以,匿名函数本质上是通过寄存器 AX 来捕捉到 main 函数栈中的变量 a,那么这种引用(通过 AX 寄存器来获得变量 a)和匿名函数的堆栈内存空间就构成了一个闭包。

在这个例子 1 之前,我尝试过在匿名函数内部只是获取变量 a,但不修改变量 a,结果就是 AX 寄存器会直接存储变量 a 的值,而不是地址。

例子 2

测试代码如下:

go
func test(x int) func() int {    return func() int {        x = 2        return x    }}func main() {    f := test(1)    f()}

使用go tool compile -S -N -l main.go命令生成的核心汇编代码如下(由于篇幅限制,在此省略掉部分汇编代码,比如栈空间操作、gc 垃圾处理和函数返回):

text
main.main STEXT size=60 args=0x0 locals=0x18 funcid=0x0 align=0x0        0x0000 00000 (main.go:10)       TEXT    main.main(SB), ABIInternal, $24-0        ······        // 传入参数1        0x0014 00020 (main.go:11)       MOVL    $1, AX        0x0019 00025 (main.go:11)       PCDATA  $1, $0        // 调用test()函数,该函数返回的是闭包对象的地址并保存到AX寄存器        0x0019 00025 (main.go:11)       CALL    main.test(SB)        // 将闭包对象的地址保存到栈上        0x001e 00030 (main.go:11)       MOVQ    AX, main.f+8(SP)        // 将闭包对象的地址中里面的数据加载到CX寄存器        // 这个数据是一个函数指针,该指针指向与闭包关联的函数        0x0023 00035 (main.go:12)       MOVQ    (AX), CX        // 将闭包对象的地址保存到DX寄存器        0x0026 00038 (main.go:12)       MOVQ    AX, DX        // 调用与闭包关联的函数        0x0029 00041 (main.go:12)       CALL    CX        ······main.test STEXT size=175 args=0x8 locals=0x30 funcid=0x0 align=0x0        0x0000 00000 (main.go:3)        TEXT    main.test(SB), ABIInternal, $48-8        ······        // 参数传递,得到x为1        0x0018 00024 (main.go:3)        MOVQ    AX, main.x+56(SP)        // 返回值初始化        0x001d 00029 (main.go:3)        MOVQ    $0, main.~r0+16(SP)        // 获取type.int的地址,作为runtime.newobject()的参数        0x0026 00038 (main.go:3)        LEAQ    type.int(SB), AX        0x002d 00045 (main.go:3)        PCDATA  $1, $0        // 调用runtime.newobject()表示要在堆上分配内存,分配一个int类型的对象        // 调用完成后返回这块内存的地址保存在AX寄存器中        0x002d 00045 (main.go:3)        CALL    runtime.newobject(SB)        // 将AX寄存器中的值复制到栈指针SP加32字节的位置        // 前面的main.&x表示这个内存位置的值(AX寄存器中的值)将作为变量x的地址        0x0032 00050 (main.go:3)        MOVQ    AX, main.&x+32(SP)        // 把变量x的值(相对于栈指针SP加56字节的位置)加载到寄存器CX中        0x0037 00055 (main.go:3)        MOVQ    main.x+56(SP), CX        // 把CX寄存器中的值(变量x的值)复制到AX寄存器指向的内存地址        // 通过上面的CALL指令和这三条MOVQ指令实现了:将x变量从栈区拷贝到了堆区(内存逃逸)        0x003c 00060 (main.go:3)        MOVQ    CX, (AX)        // 获取闭包结构体的地址        0x003f 00063 (main.go:4)        LEAQ    type.noalg.struct { F uintptr; main.x *int }(SB), AX        0x0046 00070 (main.go:4)        PCDATA  $1, $1        // 调用runtime.newobject为闭包本身分配内存,分配的内存地址保存到AX寄存器中        0x0046 00070 (main.go:4)        CALL    runtime.newobject(SB)        // 将AX寄存器中闭包对象的地址复制到栈上SP+24字节的内存位置        0x004b 00075 (main.go:4)        MOVQ    AX, main..autotmp_2+24(SP)        // 将匿名函数的地址复制到CX寄存器        0x0050 00080 (main.go:4)        LEAQ    main.test.func1(SB), CX        // 将匿名函数地址存入AX寄存器指向的堆地址(设置了闭包中的函数指针)        0x0057 00087 (main.go:4)        MOVQ    CX, (AX)        // .autotmp_2通常表示一个自动分配的临时变量,这个变量其实是用来存储闭包对象的地址        // 将这个变量的值(在SP栈指针向上偏移24字节的位置)复制到DI寄存器        0x005a 00090 (main.go:4)        MOVQ    main..autotmp_2+24(SP), DI        // 应该是设置某个条件码寄存器的标志位(暂时不用管)        0x005f 00095 (main.go:4)        TESTB   AL, (DI)        // 将变量x的地址(在SP栈指针向上偏移32字节的位置)复制给CX寄存器        0x0061 00097 (main.go:4)        MOVQ    main.&x+32(SP), CX        // DX寄存器用来存储闭包的环境指针        0x0066 00102 (main.go:4)        LEAQ    8(DI), DX        0x006a 00106 (main.go:4)        PCDATA  $0, $-2        // 这段是内存写屏障相关的汇编,有点看不懂        0x006a 00106 (main.go:4)        CMPL    runtime.writeBarrier(SB), $0        0x0071 00113 (main.go:4)        JEQ     117        0x0073 00115 (main.go:4)        JMP     123        // 设置闭包的环境指针(CX寄存器存储的是x的地址)        0x0075 00117 (main.go:4)        MOVQ    CX, 8(DI)        0x0079 00121 (main.go:4)        JMP     135        0x007b 00123 (main.go:4)        MOVQ    DX, DI        0x007e 00126 (main.go:4)        NOP        0x0080 00128 (main.go:4)        CALL    runtime.gcWriteBarrierCX(SB)        0x0085 00133 (main.go:4)        JMP     135        0x0087 00135 (main.go:4)        PCDATA  $0, $-1        // test()最终返回的是闭包对象的地址        0x0087 00135 (main.go:4)        MOVQ    main..autotmp_2+24(SP), AX        0x008c 00140 (main.go:4)        MOVQ    AX, main.~r0+16(SP)        ······main.test.func1 STEXT size=214 args=0x0 locals=0x68 funcid=0x0 align=0x0        0x0000 00000 (main.go:6)        TEXT    main.test.func1(SB), NEEDCTXT|ABIInternal, $104-0        ······        // DX寄存器存储的是闭包对象的起始地址        // 从闭包内存空间的第8个字节的位置读取变量x的地址到CX寄存器        0x000e 00014 (main.go:4)        MOVQ    8(DX), CX        // 将变量x的地址拷贝到栈内存中(SP指针向上偏移8字节的位置)        0x0012 00018 (main.go:4)        MOVQ    CX, main.&x+8(SP)        // 返回值初始化        0x0017 00023 (main.go:4)        MOVQ    $0, main.~r0(SP)        // 把变量x的地址放到CX寄存器中(获取变量x的地址,为写入x做准备)        0x001f 00031 (main.go:5)        MOVQ    main.&x+8(SP), CX        // 将立即数2写入到变量x所在的内存地址(该位置在堆内存中)        0x0024 00036 (main.go:5)        MOVQ    $2, (CX)        // 再次获取变量x的地址,准备读取x的值        0x002b 00043 (main.go:6)        MOVQ    main.&x+8(SP), CX        // CX寄存器指向的内存地址(变量x的地址)中的值(立即数2)复制给AX寄存器        0x0030 00048 (main.go:6)        MOVQ    (CX), AX        // 把AX寄存器的值(变量x)复制到函数返回寄存器(r0)中        0x0033 00051 (main.go:6)        MOVQ    AX, main.~r0(SP)        ······

上面分析完了大部分的汇编代码。汇编代码晦涩难懂,既要懂得相关语法,又要把握好程序执行基本脉络。

从汇编中可以看出,闭包其实就是一个函数指针和一个环境指针。环境指针指向变量 x 的地址,由于变量 x 最初分配在栈上,当 test()调用完成后变量 x 就会被销毁。于是编译器为了能让环境指针一直指向变量 x,就将变量 x 从栈内存拷贝到了堆内存,以此来延长变量 x 的生命周期。除此之外,一个指向匿名函数的指针也被赋值给了闭包的函数指针,这样只要调用闭包,相对应的函数就能执行。

整体逻辑虽然并不算复杂,但是其中的细节确实非常多,想要完全弄明白也不容易(堆栈内存分配和写屏障问题)。

闭包中的“延迟求值”特性

只要从汇编角度理解了闭包,其实闭包的“延迟求值”特性也不难理解。主要是以前我不太理解这个延迟求值的本质是什么。下面看测试代码:

go
func test() []func() {    var s []func()//定义一个切片s,s的类型为func()    for i := 0; i < 2; i++ {        s = append(s, func() {            fmt.Println(i)        })    }    return s}func main() {    for _, value := range test() {//遍历test()的返回值        value()    }}

这段代码的执行结果我最初以为是 0 和 1,但是实际的执行结果是输出了两个 2。

现在结合闭包的底层原理很快就能知道为什么是两个 2。切片 s 为 func()类型的切片,使用 append 将匿名函数放在切片 s 中。由于匿名函数引用了外部变量 i,因此这个匿名函数和变量 i 构成了一个闭包。所以这个匿名函数并不会立即执行,只有在调用闭包的时候,匿名函数才会执行。

现在看 main 函数中的执行逻辑:首先执行 test()函数,得到一个 func()类型的切片,该切片中包含两个匿名函数,接下来就是遍历切片。在第一次迭代中执行闭包的时候,需要打印变量 i 的值,尽管 test()函数已经执行完毕(test 函数的栈帧已经被销毁),但是由于变量 i 和匿名函数构成了闭包,所以编译器将变量 i 从栈内存拷贝到了堆内存,那现在 i 的值是多少呢?就是 test 函数栈在销毁前最终的 i 值,也就是 2。因此两个匿名函数都使用了堆内存中的同一个 i,并将其打印出来。

那么如果我想让执行结果为 0 和 1,该如何修改代码呢?其实也很简单,问题的根源在于 test()函数执行完成后变量 i 从栈内存拷贝到了堆内存,所以堆中只是记录了 i 的最终状态,那么如果我们能够把 test()函数中 for 循环中的每个变量 i 都记录下来,那么问题也就解决了。所以解决办法是在匿名函数外部,for 循环内部定义一个变量,将 i 值记录到这个变量中。这样每次 for 循环都会创建一个新的变量,每个变量记录当前的 i 值,最终这些变量都会逃逸到堆区,最后被匿名函数引用。代码如下:

go
func test() []func() {    var s []func()    for i := 0; i < 2; i++ {        //虽然都是x,但是每个x的地址都不同,最后所有的x都会逃逸到堆区        x := i        s = append(s, func() {            println(x)        })    }    return s}func main() {    result := test()    for _, value := range result {      value()    }}

下面是使用命令go tool compile -N -l -m=2 main.go进行逃逸分析:

bash
[deep@binary test]$ go tool compile -N -l -m=2 main.gomain.go:9:17: func literal escapes to heap:main.go:9:17:   flow: {heap} = &{storage for func literal}:main.go:9:17:     from func literal (spill) at main.go:9:17main.go:9:17:     from append(s, func literal) (call parameter) at main.go:9:13main.go:8:3: test capturing by value: x (addr=false assign=false width=8)main.go:10:15: x escapes to heap:main.go:10:15:   flow: {storage for ... argument} = &{storage for x}:main.go:10:15:     from x (spill) at main.go:10:15main.go:10:15:     from ... argument (slice-literal-element) at main.go:10:15main.go:10:15:   flow: {heap} = {storage for ... argument}:main.go:10:15:     from ... argument (spill) at main.go:10:15main.go:10:15:     from fmt.Println(... argument...) (call parameter) at main.go:10:15main.go:9:17: func literal escapes to heapmain.go:10:15: ... argument does not escapemain.go:10:15: x escapes to heap

可以看到变量 x 和闭包函数(func literal)逃逸到了堆上(x escapes to heapfunc literal escapes to heap),gc 会负责管理这些在堆上分配的内存。