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. 版權所有.