如何確定一個 Go 變量會被分配在哪里?
本文作者記錄了 Go 變量分配位置的觀測技巧和 Go 語言設(shè)計(jì)思路。
一、由 iter 包引發(fā)的疑問
最近在 GitHub 上偶然發(fā)現(xiàn)了 Brad Fitzpatrick 的 iter 包,整個包只有 一個函數(shù)(一行代碼):
但其中的一行注釋令人費(fèi)解:
It does not cause any allocations.
1. 空結(jié)構(gòu)體
我們知道,struct{} 是空結(jié)構(gòu)體(empty struct)。關(guān)于空結(jié)構(gòu)體,Dave Cheney 在 The empty struct 中作了很好地闡述:
-
空結(jié)構(gòu)體不占用空間(The empty struct consumes no storage)。 -
空結(jié)構(gòu)體的切片只占用切片頭的空間(Slices of struct{}s consume only the space for their slice header)。
2. Go 切片
按照官方博客 Go Slices: usage and internals 的說法:
A slice is a descriptor of an array segment. It consists of a pointer to the array, the length of the segment, and its capacity (the maximum length of the segment).
因?yàn)榍衅偸侵赶蛞粋€底層數(shù)組的,所以所謂的 “切片頭” 其實(shí)就是切片本身。一個切片包括:指向數(shù)組片段的指針、數(shù)組片段的長度和最大長度,總共 3 個字長(在 64 位機(jī)器上,就是 24 個字節(jié))。
3. 疑問
按照上面的分析,在 64 位機(jī)器上,不管 n 是多少,make([]struct{}, n)
?得到的切片一定會占用 24 個字節(jié),reddit 上的討論 也證實(shí)了我們的分析。
那為什么 Brad Fitzpatrick 聲稱函數(shù) N 不會引發(fā)分配呢?
為了解決這個疑惑,我們需要先弄清楚兩個問題:
-
一個 Go 變量可能會被分配在哪里? -
如何確定一個 Go 變量最終會被分配在哪里?
二、Go 變量可能的分配位置
1. 進(jìn)程的內(nèi)存布局
在 Linux/x86-32 系統(tǒng)中,一個進(jìn)程的典型的內(nèi)存布局如下圖所示(圖片來自 The Linux Programming Interface 圖 6-1):
結(jié)合維基百科對 Data segment 的描述,我們得知:
-
初始化的全局變量或靜態(tài)變量,會被分配在 Data 段。 -
未初始化的全局變量或靜態(tài)變量,會被分配在 BSS 段。 -
在函數(shù)中定義的局部變量,會被分配在堆(Heap 段)或棧(Stack 段)。 -
實(shí)際上,如果考慮到 編譯器優(yōu)化,局部變量還可能會被 分配在寄存器,或者直接被 優(yōu)化去掉。
-
2. Go 內(nèi)存分配
對于 Go 而言,有兩個地方可以用于分配:
-
堆(heap) -
由 GC 負(fù)責(zé)回收。 -
對應(yīng)于進(jìn)程地址空間的堆。
-
-
棧(stack) -
不涉及 GC 操作。 -
每個 goroutine 都有自己的棧,初始時(shí)被分配在進(jìn)程地址空間的棧上,擴(kuò)容時(shí)被分配在進(jìn)程地址空間的堆上。
-
Go 變量主要分為兩種:
-
全局變量 -
會被 Go 編譯器標(biāo)記為一些特殊的 符號類型,分配在堆上還是棧上目前尚不清楚,不過不是本文討論的重點(diǎn)。
-
-
局部變量
所以綜上,對于在函數(shù)中定義的?Go 局部變量:要么被分配在堆上,要么被分配在棧上。
三、確定 Go 變量最終的分配位置
至此,我們還剩下一個問題:對于一個 Go 局部變量,如何確定它被分配在堆上還是棧上?
按照官方 FAQ How do I know whether a variable is allocated on the heap or the stack? 的解釋:
-
Go 編譯器會盡可能將變量分配在棧上 -
以下兩種情況,Go 編譯器會將變量分配在堆上 -
如果一個變量被取地址(has its address taken),并且被逃逸分析(escape analysis)識別為 “逃逸到堆”(escapes to heap) -
如果一個變量很大(very large)
-
1. 逃逸分析
以使用 iter 包的這段代碼為例:
下列演示中,我將使用 Go 1.11.4:
下面我們對這段代碼作逃逸分析:
按照前面的分析,從 “make([]struct {}, iter.n) escapes to heap” 的信息,我們推斷:make([]struct {}, iter.n)
?會被分配在堆上。
到這里,我們最初的疑惑似乎已經(jīng)有了答案:make([]struct {}, iter.n)
?一定會引發(fā)堆分配,那是 Brad Fitzpatrick 的注釋寫錯了嗎?
2. 內(nèi)存分配器追蹤
除了逃逸分析,Go 還提供了一種叫內(nèi)存分配器追蹤(Memory Allocator Trace)的方法,用于細(xì)粒度地分析由程序引發(fā)的所有堆分配(和釋放)操作:
因?yàn)檫M(jìn)行內(nèi)存分配器追蹤時(shí),很多由 runtime 引發(fā)的分配信息也會被打印出來,所以我們用 grep 進(jìn)行過濾,只顯示由用戶代碼(user code)引發(fā)的分配信息。然而這里的輸出結(jié)果為空,表明?make([]struct {}, iter.n)
?沒有引發(fā)任何堆分配。
內(nèi)存分配器追蹤的結(jié)論與逃逸分析的結(jié)論截然相反!那到底哪個結(jié)論是對的呢?
3. 匯編分析
黔驢技窮之際,Go’s Memory Allocator - Overview 這篇文章給了我提示:
So, we know that i is going to be allocated on the heap. But how does the runtime set that up? With the compiler’s help!?We can get an idea from reading the generated assembly.
關(guān)于 Go 匯編(assembly),推薦大家閱讀 Go internals, Chapter 1: Go assembly。
下面我們來看看示例代碼對應(yīng)的匯編:
可以看到,其中有一處對?runtime.makeslice(SB)
?的調(diào)用,顯然是由?make([]struct{}, n)
?引發(fā)的。
查看 runtime.makeslice 的源碼:
其中,mallocgc 的源碼如下:
slice 對應(yīng)的結(jié)構(gòu)體如下:
結(jié)合上述幾段源碼,我們可以看出:
-
makeslice 函數(shù)中:slice 結(jié)構(gòu)體正是我們在第一節(jié)提到的 Go 切片 —— array 是指向數(shù)組片段的指針,len 是數(shù)組片段的長度,cap 是數(shù)組片段的最大長度。 -
makeslice 函數(shù)中:array 的值來自 p,而 p 則是一個指針,它指向由 mallocgc 分配得到的底層數(shù)組。 -
mallocgc 函數(shù)中:因?yàn)榭战Y(jié)構(gòu)體的 size 為 0,所以 mallocgc 并沒有實(shí)際進(jìn)行堆分配;由于沒有執(zhí)行到 tracealloc 的地方,所以進(jìn)行內(nèi)存分配器追蹤時(shí),不會采集到相關(guān)的分配信息。 -
makeslice 函數(shù)中:切片 slice 本身是以結(jié)構(gòu)體的形式返回的,所以只會被分配在棧上。
四、總結(jié)
經(jīng)過一系列的探索和分析,至此,我們可以得出以下結(jié)論:
-
make([]struct{}, n)
?只會被分配在棧上,而不會被分配在堆上。 -
Brad Fitzpatrick 的注釋是對的,并且他的意思是 “不會引發(fā)堆分配”。 -
逃逸分析識別出 escapes to heap,并不一定就是堆分配,也可能是棧分配。 -
進(jìn)行內(nèi)存分配器追蹤時(shí),如果采集不到堆分配信息,那一定只有棧分配。
最后,我們來解答文章標(biāo)題提出的疑問 —— 如何確定一個 Go 變量會被分配在哪里?對此,我們的答案是:
-
先對代碼作逃逸分析 -
如果該變量被識別為 escapes to heap,那么它十有八九是被分配在堆上。 -
如果該變量被識別為 does not escape,或者沒有與之相關(guān)的分析結(jié)果,那么它一定是被分配在棧上。
-
-
如果對 escapes to heap 心存疑惑,就對代碼作內(nèi)存分配器追蹤 -
如果有采集到與該變量相關(guān)的分配信息,那么它一定是被分配在堆上。 -
否則,該變量一定是被分配在棧上。
-
-
此外,如果想知道 Go 編譯器是如何將變量分配在堆上或者棧上的,可以去分析 Go 匯編(以及 runtime 源碼)。
五、思考題
-
如果換成? make([]int, n)
,結(jié)果還會是棧分配嗎? -
如果換成? make([]int, 4)
?呢? -
除了空結(jié)構(gòu)體? make([]struct{}, n)
?的特例,還有哪些 “被逃逸分析識別為 escapes to heap,但其實(shí)是棧分配” 的案例? -
Go 支持閉包(closure),那么閉包中的變量,又是分配在哪里的?(Where are variables in a closure stored - stack or heap? 說是分配在棧上,對于 Go 也是成立的嗎?)
轉(zhuǎn)自:russellluo.com/2019/07/how-to-confirm-where-a-go-variable-will-be-allocated.html