Go 函數閉包底層實現

函數閉包對於大多數讀者而言並不是什麼高級詞彙,那什麼是閉包呢?這裏摘自 Wiki 上的定義:

a closure is a record storing a function together with an environment.The environment is a mapping associating each free variable of the function (variables that are used locally, but defined in an enclosing scope) with the value or reference to which the name was bound when the closure was created.

簡而言之,閉包是一個由函數和引用環境而組合的實體。閉包在實現過程中,往往是通過調用外部函數返回其內部函數來實現的。在這其中,引用環境是指的外部函數中自由變量(內部函數使用,但是卻定義於外部函數中)的映射。內部函數通過引入外部的自由變量,使得這些變量即使離開了外部函數的環境也不會被釋放或刪除,在返回的內部函數仍然持有這些信息。

這段話可能不太好理解,我們直接用看例子。

 1package main
 2
 3import "fmt"
 4
 5func outer() func() int {
 6    x := 1
 7    return func() int {
 8        x++
 9        return x
10    }
11}
12
13func main() {
14    closure := outer()
15    fmt.Println(closure())
16    fmt.Println(closure())
17}
18
19// output
202
213

可以看到,Go 中的兩條特性(函數是一等公民,支持匿名函數)使其很容易實現閉包。

在上面的例子中,closure是閉包函數,變量 x 就是引用環境,它們的組合就是閉包實體。x本是outer函數之內,匿名函數之外的局部變量。在正常函數調用結束之後,x就會隨着函數棧的銷燬而銷燬。但是由於匿名函數的引用,outer返回的函數對象會一直持有x變量。這造成了每次調用閉包closurex變量都會得到累加。

這裏和普通的函數調用不一樣:局部變量x並沒有隨着函數的調用結束而消失。那麼,這是爲什麼呢?

實現原理

我們不妨從彙編入手,將上述代碼稍微修改一下

 1package main
 2
 3func outer() func() int {
 4    x := 1
 5    return func() int {
 6        x++
 7        return x
 8    }
 9}
10
11func main() {
12    _ := outer()
13}

得到編譯後的彙編語句如下。

 1$ go tool compile -S -N -l main.go 
 2"".outer STEXT size=181 args=0x8 locals=0x28
 3        0x0000 00000 (main.go:3)        TEXT    "".outer(SB), ABIInternal, $40-8
 4        ...
 5        0x0021 00033 (main.go:3)        MOVQ    $0, "".~r0+48(SP)
 6        0x002a 00042 (main.go:4)        LEAQ    type.int(SB), AX
 7        0x0031 00049 (main.go:4)        MOVQ    AX, (SP)
 8        0x0035 00053 (main.go:4)        PCDATA  $1, $0
 9        0x0035 00053 (main.go:4)        CALL    runtime.newobject(SB)
10        0x003a 00058 (main.go:4)        MOVQ    8(SP), AX
11        0x003f 00063 (main.go:4)        MOVQ    AX, "".&x+24(SP)
12        0x0044 00068 (main.go:4)        MOVQ    $1, (AX)
13        0x004b 00075 (main.go:5)        LEAQ    type.noalg.struct { F uintptr; "".x *int }(SB), AX
14        0x0052 00082 (main.go:5)        MOVQ    AX, (SP)
15        0x0056 00086 (main.go:5)        PCDATA  $1, $1
16        0x0056 00086 (main.go:5)        CALL    runtime.newobject(SB)
17        0x005b 00091 (main.go:5)        MOVQ    8(SP), AX
18        0x0060 00096 (main.go:5)        MOVQ    AX, ""..autotmp_4+16(SP)
19        0x0065 00101 (main.go:5)        LEAQ    "".outer.func1(SB), CX
20        0x006c 00108 (main.go:5)        MOVQ    CX, (AX)
21        ...

首先,我們發現不一樣的是 x:=1 會調用 runtime.newobject 函數(內置new函數的底層函數,它返回數據類型指針)。在正常函數局部變量的定義時,例如

 1package main
 2
 3func add() int {
 4    x := 100
 5    x++
 6    return x
 7}
 8
 9func main() {
10    _ = add()
11}

我們能發現 x:=100 是不會調用 runtime.newobject 函數的,它對應的彙編是如下

1"".add STEXT nosplit size=58 args=0x8 locals=0x10
2        0x0000 00000 (main.go:3)        TEXT    "".add(SB), NOSPLIT|ABIInternal, $16-8
3        ...
4        0x000e 00014 (main.go:3)        MOVQ    $0, "".~r0+24(SP)
5        0x0017 00023 (main.go:4)        MOVQ    $100, "".x(SP)  // x:=100
6        0x001f 00031 (main.go:5)        MOVQ    $101, "".x(SP)
7        0x0027 00039 (main.go:6)        MOVQ    $101, "".~r0+24(SP)
8        ...

留着疑問,繼續往下看。我們發現有以下語句

1        0x004b 00075 (main.go:5)        LEAQ    type.noalg.struct { F uintptr; "".x *int }(SB), AX
2        0x0052 00082 (main.go:5)        MOVQ    AX, (SP)
3        0x0056 00086 (main.go:5)        PCDATA  $1, $1
4        0x0056 00086 (main.go:5)        CALL    runtime.newobject(SB)

我們看到 type.noalg.struct { F uintptr; "".x *int }(SB),這其實就是定義的一個閉包數據類型,它的結構表示如下

1type closure struct {
2    F uintptr   // 函數指針,代表着內部匿名函數
3    x *int      // 自由變量x,代表着對外部環境的引用
4}

之後,在通過 runtime.newobject 函數創建了閉包對象。而且由於 LEAQ xxx yyy代表的是將 xxx 指針,傳遞給 yyy,因此 outer 函數最終的返回,其實是閉包結構體對象指針。在《詳解逃逸分析》一文中,我們詳細地描述了 Go 編譯器的逃逸分析機制,對於這種函數返回暴露給外部指針的情況,很明顯,閉包對象會被分配至堆上,變量 x 也會隨着對象逃逸至堆。這就很好地解釋了爲什麼x變量沒有隨着函數棧的銷燬而消亡。

我們可以通過逃逸分析來驗證我們的結論

 1$  go build -gcflags '-m -m -l' main.go
 2# command-line-arguments
 3./main.go:6:3: outer.func1 capturing by ref: x (addr=true assign=true width=8)
 4./main.go:5:9: func literal escapes to heap:
 5./main.go:5:9:   flow: ~r0 = &{storage for func literal}:
 6./main.go:5:9:     from func literal (spill) at ./main.go:5:9
 7./main.go:5:9:     from return func literal (return) at ./main.go:5:2
 8./main.go:4:2: x escapes to heap:
 9./main.go:4:2:   flow: {storage for func literal} = &x:
10./main.go:4:2:     from func literal (captured by a closure) at ./main.go:5:9
11./main.go:4:2:     from x (reference) at ./main.go:6:3
12./main.go:4:2: moved to heap: x                   // 變量逃逸
13./main.go:5:9: func literal escapes to heap       // 函數逃逸

至此,我相信讀者已經明白爲什麼閉包能持續持有外部變量的原因了。那麼,我們來思考上文中留下的疑問,爲什麼在x:=1 時會調用 runtime.newobject 函數。

我們將上文中的例子改爲如下,即刪掉 x++ 代碼

 1package main
 2
 3func outer() func() int {
 4    x := 1
 5    return func() int {
 6        return x
 7    }
 8}
 9
10func main() {
11    _ = outer()
12}

此時,x:=1處的彙編代碼,將不再調用 runtime.newobject 函數,通過逃逸分析也會發現將x不再逃逸,生成的閉包對象中的x的將是值類型int

1type closure struct {
2    F uintptr 
3    x int      
4}

這其實就是 Go 編譯器做得精妙的地方:當閉包內沒有對外部變量造成修改時,Go 編譯器會將自由變量的引用傳遞優化爲直接值傳遞,避免變量逃逸。

總結

函數閉包一點也不神祕,它就是函數和引用環境而組合的實體。在 Go 中,閉包在底層是一個結構體對象,它包含了函數指針與自由變量。

Go 編譯器的逃逸分析機制,會將閉包對象分配至堆中,這樣自由變量就不會隨着函數棧的銷燬而消失,它能依附着閉包實體而一直存在。因此,閉包使用的優缺點是很明顯的:閉包能夠避免使用全局變量,轉而維持自由變量長期存儲在內存之中;但是,這種隱式地持有自由變量,在使用不當時,會很容易造成內存浪費與泄露。

在實際的項目中,閉包的使用場景並不多。當然,如果你的代碼中寫了閉包,例如你寫的某回調函數形成了閉包,那就需要謹慎一些,否則內存的使用問題也許會給你帶來麻煩。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/-kNqvvdcRYKBdBsJwvEIuw