Go中被閉包捕獲的變量何時(shí)會(huì)被回收
1. Go函數(shù)閉包
Go語言原生提供了對(duì)閉包(closure)的支持。在Go語言中,閉包就是函數(shù)字面值[2]。Go規(guī)范中是這樣詮釋閉包的:
函數(shù)字面值(function literals)是閉包:它們可以引用其包裹函數(shù)(surrounding function)中定義的變量。然后,這些變量在包裹函數(shù)和函數(shù)字面值之間共享,只要它們可以被訪問,它們就會(huì)繼續(xù)存在。
閉包在Go語言中有著廣泛的應(yīng)用,最常見的就是與go關(guān)鍵字一起聯(lián)合使用創(chuàng)建一個(gè)新goroutine,比如下面標(biāo)準(zhǔn)庫中net/http包中的一段代碼:
// $GOROOT/src/net/http/fileTransport.go
00 func (t fileTransport) RoundTrip(req *Request) (resp *Response, err error) {
01 rw, resc := newPopulateResponseWriter()
02 go func() {
03 t.fh.ServeHTTP(rw, req)
04 rw.finish()
05 }()
06 return <-resc, nil
07 }
上面這段代碼中的RoundTrip方法就是使用go關(guān)鍵字結(jié)合閉包創(chuàng)建了一個(gè)新的goroutine,并且在這個(gè)goroutine中運(yùn)行的函數(shù)還引用了本屬于其外部包裹函數(shù)的變量:t、rw和req,或者說兩者共享這些變量。
原本僅在RoundTrip方法內(nèi)部使用的變量一旦被“共享”給了其他函數(shù),那么它就無法在棧上分配了,逃逸到堆上是確定性事件。
那么問題來了!這些被引用或叫被閉包捕獲的分配在堆上的外部變量何時(shí)能被回收呢?也許上面的例子還十分容易理解,當(dāng)新創(chuàng)建的goroutine執(zhí)行完畢后,這些變量就可以回收了。那么下面的閉包函數(shù)呢?
func foo() func(int) int {
i := []int{0: 10, 1: 11, 15: 128}
return func(n int) int {
n+=i[0]
return n
}
}
在這個(gè)foo函數(shù)中,被閉包函數(shù)捕獲的長度為16的切片變量i何時(shí)可以被回收呢?
注:我們定義閉包時(shí),喜歡用引用外部包裹函數(shù)的變量這種說法,但在Go編譯器的實(shí)現(xiàn)代碼[3]中,使用的是capture var,翻譯過來就是“被捕獲的變量”,所以這里也用了“捕獲”一詞來表示那些被閉包共享使用的外部包裹函數(shù)甚至是更外層函數(shù)中的變量。
foo函數(shù)的返回值類型是一個(gè)函數(shù),也就是說foo函數(shù)的本地變量i被foo返回的新創(chuàng)建的閉包函數(shù)所捕獲,i不會(huì)被回收。通常一個(gè)堆上的內(nèi)存對(duì)象有明確的引用它的對(duì)象或指向它的地址的指針,該對(duì)象才會(huì)繼續(xù)存活,當(dāng)其不可達(dá)(unreachable)時(shí),即再?zèng)]有引用它的對(duì)象或指向它的指針時(shí)才會(huì)被GC回收。
那么,變量i究竟是被誰引用了呢?變量i將在何時(shí)被回收呢?
我們先回頭看一個(gè)非閉包的一般函數(shù):
func f1() []int {
i := []int{0: 10, 1: 11, 15: 128}
return i
}
func f2() {
sl := f1()
sl[0] = sl[0] + 10
fmt.Println(sl)
}
func main() {
f2()
}
我們看到f1將自己的局部切片變量i返回后,該變量被f2函數(shù)中的sl所引用,f2函數(shù)執(zhí)行完成后,切片變量i將變成unreachable,GC將回收該變量對(duì)應(yīng)的堆內(nèi)存。
如果換成閉包函數(shù),比如前面的foo函數(shù),我們很大可能是這么來用的:
// https://github.com/bigwhite/experiments/tree/master/closure/closure1.go
1 package main
2
3 import "fmt"
4
5 func foo() func(int) int {
6 i := []int{0: 10, 1: 11, 15: 128}
7 return func(n int) int {
8 n += i[0]
9 return n
10 }
11 }
12
13 func bar() {
14 f := foo()
15 a := f(5)
16 fmt.Println(a)
17 }
18
19 func main() {
20 bar()
21 g := foo()
22 b := g(6)
23 fmt.Println(b)
24 }
在這里例子中,只要閉包函數(shù)中引用了foo函數(shù)的本地變量。這突然讓我想起了“在Go中,函數(shù)也是一等公民的特性[4]”。難道是閉包函數(shù)這一對(duì)象引用了foo函數(shù)的本地變量? 那么閉包函數(shù)在內(nèi)存布局上是如何引用到foo函數(shù)的本地整型切片變量i的呢?閉包函數(shù)在內(nèi)存布局中被映射為什么了呢?
如果一門編程語言對(duì)某種語言元素的創(chuàng)建和使用沒有限制,我們可以像對(duì)待值(value)一樣對(duì)待這種語法元素,那么我們就稱這種語法元素是這門編程語言的“一等公民”。
2. Go閉包函數(shù)對(duì)象
要解答這個(gè)問題,我們只能尋求Go匯編[5]的幫助。我們生成上面的closure1.go的匯編代碼(我們使用go 1.16.5版本Go編譯器):
$go tool compile -S closure1.go > closure1.s
在匯編代碼中,我們找到closure1.go中第7行創(chuàng)建一個(gè)閉包函數(shù)所對(duì)應(yīng)的匯編代碼:
// https://github.com/bigwhite/experiments/tree/master/closure/closure1.s
0x0052 00082 (closure1.go:7) LEAQ type.noalg.struct { F uintptr; "".i []int }(SB), CX
0x0059 00089 (closure1.go:7) MOVQ CX, (SP)
0x005d 00093 (closure1.go:7) PCDATA $1, $1
0x005d 00093 (closure1.go:7) NOP
0x0060 00096 (closure1.go:7) CALL runtime.newobject(SB)
0x0065 00101 (closure1.go:7) MOVQ 8(SP), AX
0x006a 00106 (closure1.go:7) LEAQ "".foo.func1(SB), CX
0x0071 00113 (closure1.go:7) MOVQ CX, (AX)
0x0074 00116 (closure1.go:7) MOVQ $16, 16(AX)
0x007c 00124 (closure1.go:7) MOVQ $16, 24(AX)
0x0084 00132 (closure1.go:7) PCDATA $0, $-2
0x0084 00132 (closure1.go:7) CMPL runtime.writeBarrier(SB), $0
0x008b 00139 (closure1.go:7) JNE 165
0x008d 00141 (closure1.go:7) MOVQ ""..autotmp_7+16(SP), CX
0x0092 00146 (closure1.go:7) MOVQ CX, 8(AX)
0x0096 00150 (closure1.go:7) PCDATA $0, $-1
0x0096 00150 (closure1.go:7) MOVQ AX, "".~r0+40(SP)
0x009b 00155 (closure1.go:7) MOVQ 24(SP), BP
0x00a0 00160 (closure1.go:7) ADDQ $32, SP
0x00a4 00164 (closure1.go:7) RET
0x00a5 00165 (closure1.go:7) PCDATA $0, $-2
0x00a5 00165 (closure1.go:7) LEAQ 8(AX), DI
0x00a9 00169 (closure1.go:7) MOVQ ""..autotmp_7+16(SP), CX
0x00ae 00174 (closure1.go:7) CALL runtime.gcWriteBarrierCX(SB)
0x00b3 00179 (closure1.go:7) JMP 150
0x00b5 00181 (closure1.go:7) NOP
匯編總是晦澀難懂。我們重點(diǎn)看第一行:
0x0052 00082 (closure1.go:7) LEAQ type.noalg.struct { F uintptr; "".i []int }(SB), CX
我們看到對(duì)應(yīng)到Go源碼中創(chuàng)建閉包函數(shù)的第7行,這行匯編代碼大致意思是將一個(gè)結(jié)構(gòu)體對(duì)象的地址放入CX。我們把這個(gè)結(jié)構(gòu)體對(duì)象摘錄出來:
struct {
F uintptr
i []int
}
這個(gè)結(jié)構(gòu)體對(duì)象是哪里來的呢?顯然是Go編譯器根據(jù)閉包函數(shù)的“特征”創(chuàng)建出來的。其中的F就是閉包函數(shù)自身的地址,畢竟是函數(shù),這個(gè)地址與一般函數(shù)的地址應(yīng)該是在一個(gè)內(nèi)存區(qū)域(比如rodata的只讀數(shù)據(jù)區(qū)),那么整型切片變量i呢?難道這就是閉包函數(shù)所捕獲的那個(gè)Foo函數(shù)本地變量i。沒錯(cuò)!正是它。如果不信,我們可以再定義一個(gè)捕獲更多變量的閉包函數(shù)來驗(yàn)證一下。
下面是一個(gè)捕獲3個(gè)整型變量的閉包函數(shù)的生成函數(shù):
// https://github.com/bigwhite/experiments/tree/master/closure/closure2.go
func foo() func(int) int {
var a, b, c int = 11, 12, 13
return func(n int) int {
a += n
b += n
c += n
return a + b + c
}
}
其對(duì)應(yīng)的匯編代碼中那個(gè)閉包函數(shù)結(jié)構(gòu)為:
0x0084 00132 (closure2.go:10) LEAQ type.noalg.struct { F uintptr; "".a *int; "".b *int; "".c *int }(SB), CX
將該結(jié)構(gòu)體提取出來,即:
struct {
F uintptr
a *int
b *int
c *int
}
到這里,我們證實(shí)了引用了包裹函數(shù)本地變量的正是閉包函數(shù)自身,即編譯器為其在內(nèi)存中建立的閉包函數(shù)結(jié)構(gòu)體對(duì)象。通過unsafe包,我們甚至可以輸出這個(gè)閉包函數(shù)對(duì)象。以closure2.go為例,我們來嘗試一下,如下面代碼所示。
// https://github.com/bigwhite/experiments/tree/master/closure/closure2.go
func foo() func(int) int {
var a, b, c int = 11, 12, 13
return func(n int) int {
a += n
b += n
c += n
return a + b + c
}
}
type closure struct {
f uintptr
a *int
b *int
c *int
}
func bar() {
f := foo()
f(5)
pc := *(**closure)(unsafe.Pointer(&f))
fmt.Printf("%#v\n", *pc)
fmt.Printf("a=%d, b=%d,c=%d\n", *pc.a, *pc.b, *pc.c)
f(6)
fmt.Printf("a=%d, b=%d,c=%d\n", *pc.a, *pc.b, *pc.c)
}
在上面代碼中,我們參考匯編的輸出定義了closure這個(gè)結(jié)構(gòu)體來對(duì)應(yīng)內(nèi)存中的閉包函數(shù)對(duì)象(每種閉包對(duì)象都是不同的,一個(gè)技巧就是參考匯編輸出的對(duì)象來定義),通過unsafe的地址轉(zhuǎn)換,我們將內(nèi)存中的閉包對(duì)象映射到closure結(jié)構(gòu)體實(shí)例上。運(yùn)行上面程序,我們可以得到如下輸出:
$go run closure2.go
main.closure{f:0x10a4d80, a:(*int)(0xc000118000), b:(*int)(0xc000118008), c:(*int)(0xc000118010)}
a=16, b=17,c=18
a=22, b=23,c=24
在上面的例子中,閉包函數(shù)捕獲了外部變量a、b和c,這些變量實(shí)質(zhì)上被編譯器創(chuàng)建的閉包內(nèi)存對(duì)象所引用。當(dāng)我們調(diào)用foo函數(shù)時(shí),閉包函數(shù)對(duì)象創(chuàng)建(其地址賦值給變量f)。這樣,f對(duì)象一直引用著變量a、b和c。只有當(dāng)f被回收,a、b和c才會(huì)因unreachable而被回收。
如果我們?cè)陂]包函數(shù)中僅僅是對(duì)捕獲的外部變量進(jìn)行只讀操作,那么閉包函數(shù)對(duì)象不會(huì)存儲(chǔ)這些變量的指針,而僅會(huì)做一份值拷貝。當(dāng)然,如果某個(gè)變量被一個(gè)函數(shù)中創(chuàng)建的多個(gè)閉包所捕獲,并且有的只讀,有的修改,那么閉包函數(shù)對(duì)象還是會(huì)存儲(chǔ)該變量的地址的。
了解了閉包函數(shù)的本質(zhì),我們?cè)賮砜幢疚臉?biāo)題中的問題就容易多了。其答案就是在捕捉變量的閉包函數(shù)對(duì)象被回收后,如果這些被捕捉的變量沒有其他引用,它們將變?yōu)閡nreachable的,后續(xù)就會(huì)被GC回收了。
3. 小結(jié)
我們回顧一下文章開頭引用的Go語言規(guī)范中對(duì)閉包詮釋中提到的一句話:“只要它們可以被訪問,它們就會(huì)繼續(xù)存在”。現(xiàn)在看來,我們可以將其理解為:只要閉包函數(shù)對(duì)象存在,其捕獲的那些變量就會(huì)存在,就不會(huì)被回收。
閉包函數(shù)的這種機(jī)制決定了我們?cè)谌粘J褂眠^程中也要時(shí)刻考慮著閉包函數(shù)所捕獲的變量可能的“延遲回收”。如果某個(gè)場景下,閉包引用的變量占用內(nèi)存較大,且閉包函數(shù)對(duì)象被創(chuàng)建出的數(shù)量很多且因業(yè)務(wù)需要延遲很久才會(huì)被執(zhí)行(比如定時(shí)器場景),這就會(huì)導(dǎo)致堆內(nèi)存可能長期處于高水位,我們要考慮內(nèi)存容量是否能承受這樣的水位,如果不能,則要考慮更換實(shí)現(xiàn)方案了。
本文涉及的所有代碼可以從這里下載[6]:https://github.com/bigwhite/experiments/tree/master/closure
4. 參考資料
深入理解函數(shù)閉包 - https://zhuanlan.zhihu.com/p/56750616 Go語言高級(jí)編程 - https://github.com/chai2010/advanced-go-programming-book/blob/master/ch3-asm/ch3-06-func-again.md#366-閉包函數(shù)
參考資料
本文永久鏈接: https://tonybai.com/2021/08/09/when-variables-captured-by-closures-are-recycled-in-go
[2]函數(shù)字面值: https://tip.golang.org/ref/spec#Function_literals
[3]Go編譯器的實(shí)現(xiàn)代碼: https://github.com/golang/go/tree/master/src/cmd/compile/internal/gc/closure.go
[4]在Go中,函數(shù)也是一等公民的特性: https://www.imooc.com/read/87/article/2420
[5]Go匯編: https://tip.golang.org/doc/asm
[6]這里下載: https://github.com/bigwhite/experiments/tree/master/closure
[7]改善Go語?編程質(zhì)量的50個(gè)有效實(shí)踐: https://www.imooc.com/read/87
[8]Kubernetes實(shí)戰(zhàn):高可用集群搭建、配置、運(yùn)維與應(yīng)用: https://coding.imooc.com/class/284.html
[9]我愛發(fā)短信: https://51smspush.com/
[10]鏈接地址: https://m.do.co/c/bff6eed92687
推薦閱讀
