Go中被閉包捕獲的變量何時會被回收

語言: CN / TW / HK

本文永久鏈接 – https://tonybai.com/2021/08/09/when-variables-captured-by-closures-are-recycled-in-go

1. Go函數閉包

Go語言原生提供了對閉包(closure)的支持。在Go語言中,閉包就是 函數字面值 。Go規範中是這樣詮釋閉包的:

函數字面值(function literals)是閉包:它們可以引用其包裹函數(surrounding function)中定義的變量。然後,這些變量在包裹函數和函數字面值之間共享,只要它們可以被訪問,它們就會繼續存在。

閉包在Go語言中有着廣泛的應用,最常見的就是與go關鍵字一起聯合使用創建一個新goroutine,比如下面標準庫中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關鍵字結合閉包創建了一個新的goroutine,並且在這個goroutine中運行的函數還引用了本屬於其外部包裹函數的變量:t、rw和req,或者説兩者共享這些變量。

原本僅在RoundTrip方法內部使用的變量一旦被“共享”給了其他函數,那麼它就無法在棧上分配了, 逃逸到堆上 是確定性事件。

那麼問題來了!這些被引用或叫 被閉包捕獲 的分配在堆上的外部變量何時能被回收呢?也許上面的例子還十分容易理解,當新創建的goroutine執行完畢後,這些變量就可以回收了。那麼下面的閉包函數呢?

func foo() func(int) int {
    i := []int{0: 10, 1: 11, 15: 128}
    return func(n int) int {
        n+=i[0]
        return n
    }
}

在這個foo函數中,被閉包函數捕獲的長度為16的切片變量i何時可以被回收呢?

注:我們定義閉包時,喜歡用引用外部包裹函數的變量這種説法,但在 Go編譯器的實現代碼 中,使用的是capture var,翻譯過來就是“被捕獲的變量”,所以這裏也用了“ 捕獲 ”一詞來表示那些被閉包共享使用的外部包裹函數甚至是更外層函數中的變量。

foo函數的返回值類型是一個函數,也就是説foo函數的本地變量i被foo返回的新創建的閉包函數所捕獲,i不會被回收。通常一個堆上的內存對象有明確的引用它的對象或指向它的地址的指針,該對象才會繼續存活,當其不可達(unreachable)時,即再沒有引用它的對象或指向它的指針時才會被GC回收。

那麼,變量i究竟是被誰引用了呢?變量i將在何時被回收呢?

我們先回頭看一個非閉包的一般函數:

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函數中的sl所引用,f2函數執行完成後,切片變量i將變成unreachable,GC將回收該變量對應的堆內存。

如果換成閉包函數,比如前面的foo函數,我們很大可能是這麼來用的:

// 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 }

在這裏例子中,只要閉包函數中引用了foo函數的本地變量。這突然讓我想起了“ 在Go中,函數也是一等公民的特性 ”。難道是閉包函數這一對象引用了foo函數的本地變量? 那麼閉包函數在內存佈局上是如何引用到foo函數的本地整型切片變量i的呢?閉包函數在內存佈局中被映射為什麼了呢?

如果一門編程語言對某種語言元素的創建和使用沒有限制,我們可以像對待值(value)一樣對待這種語法元素,那麼我們就稱這種語法元素是這門編程語言的“一等公民”。

2. Go閉包函數對象

要解答這個問題,我們只能尋求 Go彙編 的幫助。我們生成上面的closure1.go的彙編代碼(我們使用go 1.16.5版本Go編譯器):

$go tool compile -S closure1.go > closure1.s

在彙編代碼中,我們找到closure1.go中第7行創建一個閉包函數所對應的彙編代碼:

// 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

彙編總是晦澀難懂。我們重點看第一行:

0x0052 00082 (closure1.go:7)    LEAQ    type.noalg.struct { F uintptr; "".i []int }(SB), CX

我們看到對應到Go源碼中創建閉包函數的第7行,這行彙編代碼大致意思是將一個結構體對象的地址放入CX。我們把這個結構體對象摘錄出來:

struct {
    F uintptr
    i []int
}

這個結構體對象是哪裏來的呢?顯然是Go編譯器根據閉包函數的“特徵”創建出來的。其中的F就是閉包函數自身的地址,畢竟是函數,這個地址與一般函數的地址應該是在一個內存區域(比如rodata的只讀數據區),那麼整型切片變量i呢?難道這就是閉包函數所捕獲的那個Foo函數本地變量i。沒錯!正是它。如果不信,我們可以再定義一個捕獲更多變量的閉包函數來驗證一下。

下面是一個捕獲3個整型變量的閉包函數的生成函數:

// 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
    }
}

其對應的彙編代碼中那個閉包函數結構為:

0x0084 00132 (closure2.go:10)   LEAQ    type.noalg.struct { F uintptr; "".a *int; "".b *int; "".c *int }(SB), CX

將該結構體提取出來,即:

struct {
    F uintptr
    a *int
    b *int
    c *int
}

到這裏,我們證實了 引用了包裹函數本地變量的正是閉包函數自身,即編譯器為其在內存中建立的閉包函數結構體對象 。通過unsafe包,我們甚至可以輸出這個閉包函數對象。以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這個結構體來對應內存中的閉包函數對象(每種閉包對象都是不同的,一個技巧就是參考彙編輸出的對象來定義),通過unsafe的地址轉換,我們將內存中的閉包對象映射到closure結構體實例上。運行上面程序,我們可以得到如下輸出:

$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

在上面的例子中,閉包函數捕獲了外部變量a、b和c,這些變量實質上被編譯器創建的閉包內存對象所引用。當我們調用foo函數時,閉包函數對象創建(其地址賦值給變量f)。這樣,f對象一直引用着變量a、b和c。只有當f被回收,a、b和c才會因unreachable而被回收。

如果我們在閉包函數中僅僅是對捕獲的外部變量進行只讀操作,那麼閉包函數對象不會存儲這些變量的指針,而僅會做一份值拷貝。當然,如果某個變量被一個函數中創建的多個閉包所捕獲,並且有的只讀,有的修改,那麼閉包函數對象還是會存儲該變量的地址的。

瞭解了閉包函數的本質,我們再來看本文標題中的問題就容易多了。其答案就是 在捕捉變量的閉包函數對象被回收後,如果這些被捕捉的變量沒有其他引用,它們將變為unreachable的,後續就會被GC回收了

3. 小結

我們回顧一下文章開頭引用的Go語言規範中對閉包詮釋中提到的一句話:“只要它們可以被訪問,它們就會繼續存在”。現在看來,我們可以將其理解為: 只要閉包函數對象存在,其捕獲的那些變量就會存在,就不會被回收

閉包函數的這種機制決定了我們在日常使用過程中也要時刻考慮着閉包函數所捕獲的變量可能的“延遲迴收”。如果某個場景下,閉包引用的變量佔用內存較大,且閉包函數對象被創建出的數量很多且因業務需要延遲很久才會被執行(比如定時器場景),這就會導致堆內存可能長期處於高水位,我們要考慮內存容量是否能承受這樣的水位,如果不能,則要考慮更換實現方案了。

本文涉及的所有代碼可以從 這裏下載 :https://github.com/bigwhite/experiments/tree/master/closure

4. 參考資料

  • 深入理解函數閉包 – https://zhuanlan.zhihu.com/p/56750616
  • Go語言高級編程 – https://github.com/chai2010/advanced-go-programming-book/blob/master/ch3-asm/ch3-06-func-again.md#366-閉包函數

“Gopher部落”知識星球 正式轉正(從試運營星球變成了正式星球)!“gopher部落”旨在打造一個精品Go學習和進階社羣!高品質首發Go技術文章,“三天”首發閲讀權,每年兩期Go語言發展現狀分析,每天提前1小時閲讀到新鮮的Gopher日報,網課、技術專欄、圖書內容前瞻,六小時內必答保證等滿足你關於Go語言生態的所有需求!部落目前雖小,但持續力很強。在2021年上半年,部落將策劃兩個專題系列分享,並且是部落獨享哦:

  • Go技術書籍的書摘和讀書體會系列
  • Go與eBPF系列

歡迎大家加入!

Go技術專欄“ 改善Go語⾔編程質量的50個有效實踐 ”正在慕課網火熱熱銷中!本專欄主要滿足廣大gopher關於Go語言進階的需求,圍繞如何寫出地道且高質量Go代碼給出50條有效實踐建議,上線後收到一致好評!歡迎大家訂

閲!

我的網課“ Kubernetes實戰:高可用集羣搭建、配置、運維與應用 ”在慕課網熱賣中,歡迎小夥伴們訂閲學習!

我愛發短信 :企業級短信平台定製開發專家 https://51smspush.com/。smspush : 可部署在企業內部的定製化短信平台,三網覆蓋,不懼大併發接入,可定製擴展; 短信內容你來定,不再受約束, 接口豐富,支持長短信,簽名可選。2020年4月8日,中國三大電信運營商聯合發佈《5G消息白皮書》,51短信平台也會全新升級到“51商用消息平台”,全面支持5G RCS消息。

著名雲主機服務廠商DigitalOcean發佈最新的主機計劃,入門級Droplet配置升級為:1 core CPU、1G內存、25G高速SSD,價格5$/月。有使用DigitalOcean需求的朋友,可以打開這個 鏈接地址 :https://m.do.co/c/bff6eed92687 開啟你的DO主機之路。

Gopher Daily(Gopher每日新聞)歸檔倉庫 – https://github.com/bigwhite/gopherdaily

我的聯繫方式:

  • 微博:https://weibo.com/bigwhite20xx
  • 微信公眾號:iamtonybai
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • “Gopher部落”知識星球:https://public.zsxq.com/groups/51284458844544

微信讚賞:

商務合作方式:撰稿、出書、培訓、在線課程、合夥創業、諮詢、廣吿合作。

© 2021,bigwhite. 版權所有.