Go函數(shù)閉包底層實現(xiàn)
函數(shù)閉包對于大多數(shù)讀者而言并不是什么高級詞匯,那什么是閉包呢?這里摘自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.
簡而言之,閉包是一個由函數(shù)和引用環(huán)境而組合的實體。閉包在實現(xiàn)過程中,往往是通過調(diào)用外部函數(shù)返回其內(nèi)部函數(shù)來實現(xiàn)的。在這其中,引用環(huán)境是指的外部函數(shù)中自由變量(內(nèi)部函數(shù)使用,但是卻定義于外部函數(shù)中)的映射。內(nèi)部函數(shù)通過引入外部的自由變量,使得這些變量即使離開了外部函數(shù)的環(huán)境也不會被釋放或刪除,在返回的內(nèi)部函數(shù)仍然持有這些信息。

這段話可能不太好理解,我們直接用看例子。
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中的兩條特性(函數(shù)是一等公民,支持匿名函數(shù))使其很容易實現(xiàn)閉包。
在上面的例子中,closure是閉包函數(shù),變量x就是引用環(huán)境,它們的組合就是閉包實體。x本是outer函數(shù)之內(nèi),匿名函數(shù)之外的局部變量。在正常函數(shù)調(diào)用結(jié)束之后,x就會隨著函數(shù)棧的銷毀而銷毀。但是由于匿名函數(shù)的引用,outer返回的函數(shù)對象會一直持有x變量。這造成了每次調(diào)用閉包closure,x變量都會得到累加。
這里和普通的函數(shù)調(diào)用不一樣:局部變量x并沒有隨著函數(shù)的調(diào)用結(jié)束而消失。那么,這是為什么呢?
實現(xiàn)原理
我們不妨從匯編入手,將上述代碼稍微修改一下
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 ...
首先,我們發(fā)現(xiàn)不一樣的是 x:=1 會調(diào)用 runtime.newobject 函數(shù)(內(nèi)置new函數(shù)的底層函數(shù),它返回數(shù)據(jù)類型指針)。在正常函數(shù)局部變量的定義時,例如
1package main
2
3func add() int {
4 x := 100
5 x++
6 return x
7}
8
9func main() {
10 _ = add()
11}
我們能發(fā)現(xiàn) x:=100 是不會調(diào)用 runtime.newobject 函數(shù)的,它對應(yīng)的匯編是如下
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 ...
留著疑問,繼續(xù)往下看。我們發(fā)現(xiàn)有以下語句
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),這其實就是定義的一個閉包數(shù)據(jù)類型,它的結(jié)構(gòu)表示如下
1type closure struct {
2 F uintptr // 函數(shù)指針,代表著內(nèi)部匿名函數(shù)
3 x *int // 自由變量x,代表著對外部環(huán)境的引用
4}
之后,在通過 runtime.newobject 函數(shù)創(chuàng)建了閉包對象。而且由于 LEAQ xxx yyy代表的是將 xxx 指針,傳遞給 yyy,因此 outer 函數(shù)最終的返回,其實是閉包結(jié)構(gòu)體對象指針。在《詳解逃逸分析》一文中,我們詳細(xì)地描述了Go編譯器的逃逸分析機制,對于這種函數(shù)返回暴露給外部指針的情況,很明顯,閉包對象會被分配至堆上,變量x也會隨著對象逃逸至堆。這就很好地解釋了為什么x變量沒有隨著函數(shù)棧的銷毀而消亡。
我們可以通過逃逸分析來驗證我們的結(jié)論
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 // 函數(shù)逃逸
至此,我相信讀者已經(jīng)明白為什么閉包能持續(xù)持有外部變量的原因了。那么,我們來思考上文中留下的疑問,為什么在x:=1 時會調(diào)用 runtime.newobject 函數(shù)。
我們將上文中的例子改為如下,即刪掉 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處的匯編代碼,將不再調(diào)用 runtime.newobject 函數(shù),通過逃逸分析也會發(fā)現(xiàn)將x不再逃逸,生成的閉包對象中的x的將是值類型int
1type closure struct {
2 F uintptr
3 x int
4}
這其實就是Go編譯器做得精妙的地方:當(dāng)閉包內(nèi)沒有對外部變量造成修改時,Go 編譯器會將自由變量的引用傳遞優(yōu)化為直接值傳遞,避免變量逃逸。
總結(jié)
函數(shù)閉包一點也不神秘,它就是函數(shù)和引用環(huán)境而組合的實體。在Go中,閉包在底層是一個結(jié)構(gòu)體對象,它包含了函數(shù)指針與自由變量。
Go編譯器的逃逸分析機制,會將閉包對象分配至堆中,這樣自由變量就不會隨著函數(shù)棧的銷毀而消失,它能依附著閉包實體而一直存在。因此,閉包使用的優(yōu)缺點是很明顯的:閉包能夠避免使用全局變量,轉(zhuǎn)而維持自由變量長期存儲在內(nèi)存之中;但是,這種隱式地持有自由變量,在使用不當(dāng)時,會很容易造成內(nèi)存浪費與泄露。
在實際的項目中,閉包的使用場景并不多。當(dāng)然,如果你的代碼中寫了閉包,例如你寫的某回調(diào)函數(shù)形成了閉包,那就需要謹(jǐn)慎一些,否則內(nèi)存的使用問題也許會給你帶來麻煩。
------------------- End -------------------
往期精彩文章推薦:

歡迎大家點贊,留言,轉(zhuǎn)發(fā),轉(zhuǎn)載,感謝大家的相伴與支持
想加入Go學(xué)習(xí)群請在后臺回復(fù)【入群】
萬水千山總是情,點個【在看】行不行
