詳解Go逃逸分析

Go是一門帶有垃圾回收的現代語言,它拋棄了傳統C/C++的開發者需要手動管理記憶體的方式,實現了記憶體的主動申請和釋放的管理。Go的垃圾回收,讓堆和棧的概念對程式設計師保持透明,它增加的逃逸分析與GC,使得程式設計師的雙手真正地得到了解放,給了開發者更多的精力去關注軟體設計本身。

就像《CPU快取體系對Go程式的影響》文章中說過的一樣,“你不一定需要成為一名硬體工程師,但是你確實需要了解硬體的工作原理”。Go雖然幫我們實現了記憶體的自動管理,我們仍然需要知道其內在原理。記憶體管理主要包括兩個動作:分配與釋放。逃逸分析就是服務於記憶體分配,為了更好理解逃逸分析,我們先談一下堆疊。

堆和棧

應用程式的記憶體載體,我們可以簡單地將其分為堆和棧。

在Go中,棧的記憶體是由編譯器自動進行分配和釋放,棧區往往儲存著函式引數、區域性變數和呼叫函式幀,它們隨著函式的建立而分配,函式的退出而銷燬。一個goroutine對應一個棧,棧是呼叫棧(call stack)的簡稱。一個棧通常又包含了許多棧幀(stack frame),它描述的是函式之間的呼叫關係,每一幀對應一次尚未返回的函式呼叫,它本身也是以棧形式存放資料。

舉例:在一個goroutine裡,函式A()正在呼叫函式B(),那麼這個呼叫棧的記憶體佈局示意圖如下。

詳解Go逃逸分析

與棧不同的是,應用程式在執行時只會存在一個堆。狹隘地說,記憶體管理只是針對堆記憶體而言的。程式在執行期間可以主動從堆上申請記憶體,這些記憶體透過Go的記憶體分配器分配,並由垃圾收集器回收。

棧是每個goroutine獨有的,這就意味著棧上的記憶體操作是不需要加鎖的。而堆上的記憶體,有時需要加鎖防止多執行緒衝突(為什麼要說有時呢,因為Go的記憶體分配策略學習了TCMalloc的執行緒快取思想,他為每個處理器P分配了一個mcache,從mcache分配記憶體也是無鎖的)。

而且,對於程式堆上的記憶體回收,還需要透過標記清除階段,例如Go採用的三色標記法。但是,在棧上的記憶體而言,它的分配與釋放非常廉價。簡單地說,它只需要兩個CPU指令:一個是分配入棧,另外一個是棧內釋放。而這,只需要藉助於棧相關暫存器即可完成。

另外還有一點,棧記憶體能更好地利用CPU的快取策略。因為它們相較於堆而言是更連續的。

逃逸分析

那麼,我們怎麼知道一個物件是應該放在堆記憶體,還是棧記憶體之上呢?可以官網的FAQ(地址:https://golang。org/doc/faq)中找到答案。

詳解Go逃逸分析

如果可以,Go編譯器會盡可能將變數分配到到棧上。但是,當編譯器無法證明函式返回後,該變數沒有被引用,那麼編譯器就必須在堆上分配該變數,以此避免懸掛指標(dangling pointer)。另外,如果區域性變數非常大,也會將其分配在堆上。

那麼,Go是如何確定的呢?答案就是:逃逸分析。編譯器透過逃逸分析技術去選擇堆或者棧,逃逸分析的基本思想如下:

檢查變數的生命週期是否是完全可知的,如果透過檢查,則可以在棧上分配。否則,就是所謂的逃逸,必須在堆上進行分配。

Go語言雖然沒有明確說明逃逸分析規則,但是有以下幾點準則,是可以參考的。

逃逸分析是在編譯器完成的,這是不同於jvm的執行時逃逸分析;

如果變數在函式外部沒有引用,則優先放到棧中;

如果變數在函式外部存在引用,則必定放在堆中;

我們可透過

go build -gcflags ‘-m -l’

命令來檢視逃逸分析結果,其中-m 列印逃逸分析資訊,-l禁止內聯最佳化。下面,我們透過一些案例,來熟悉一些常見的逃逸情況。

情況一:變數型別不確定

package mainimport “fmt”func main() { a := 666 fmt。Println(a)}

逃逸分析結果如下

$ go build -gcflags ‘-m -l’ main。go# command-line-arguments。/main。go:7:13: 。。。 argument does not escape。/main。go:7:13: a escapes to heap

可以看到,分析結果告訴我們變數

a

逃逸到了堆上。但是,我們並沒有外部引用啊,為啥也會有逃逸呢?為了看到更多細節,可以在語句中再新增一個

-m

引數。得到資訊如下

$ go build -gcflags ‘-m -m -l’ main。go# command-line-arguments。/main。go:7:13: a escapes to heap:。/main。go:7:13: flow: {storage for 。。。 argument} = &{storage for a}:。/main。go:7:13: from a (spill) at 。/main。go:7:13。/main。go:7:13: from 。。。 argument (slice-literal-element) at 。/main。go:7:13。/main。go:7:13: flow: {heap} = {storage for 。。。 argument}:。/main。go:7:13: from 。。。 argument (spill) at 。/main。go:7:13。/main。go:7:13: from fmt。Println(。。。 argument。。。) (call parameter) at 。/main。go:7:13。/main。go:7:13: 。。。 argument does not escape。/main。go:7:13: a escapes to heap

a逃逸是因為它被傳入了

fmt。Println

的引數中,這個方法引數自己發生了逃逸。

func Println(a 。。。interface{}) (n int, err error)

因為

fmt。Println

的函式引數為

interface

型別,編譯期不能確定其引數的具體型別,所以將其分配於堆上。

情況二:暴露給外部指標

package mainfunc foo() *int { a := 666 return &a}func main() { _ = foo()}

逃逸分析如下,變數a發生了逃逸。

$ go build -gcflags ‘-m -m -l’ main。go# command-line-arguments。/main。go:4:2: a escapes to heap:。/main。go:4:2: flow: ~r0 = &a:。/main。go:4:2: from &a (address-of) at 。/main。go:5:9。/main。go:4:2: from return &a (return) at 。/main。go:5:2。/main。go:4:2: moved to heap: a

這種情況直接滿足我們上述中的原則:變數在函式外部存在引用。這個很好理解,因為當函式執行完畢,對應的棧幀就被銷燬,但是引用已經被返回到函式之外。如果這時外部從引用地址取值,雖然地址還在,但是這塊記憶體已經被釋放回收了,這就是非法記憶體,問題可就大了。所以,很明顯,這種情況必須分配到堆上。

情況三:變數所佔記憶體較大

func foo() { s := make([]int, 10000, 10000) for i := 0; i < len(s); i++ { s[i] = i }}func main() { foo()}

逃逸分析結果

$ go build -gcflags ‘-m -m -l’ main。go# command-line-arguments。/main。go:4:11: make([]int, 10000, 10000) escapes to heap:。/main。go:4:11: flow: {heap} = &{storage for make([]int, 10000, 10000)}:。/main。go:4:11: from make([]int, 10000, 10000) (too large for stack) at 。/main。go:4:11。/main。go:4:11: make([]int, 10000, 10000) escapes to heap

可以看到,當我們建立了一個容量為10000的

int

型別的底層陣列物件時,由於物件過大,它也會被分配到堆上。這裡我們不禁要想一個問題,為啥大物件需要分配到堆上。

這裡需要注意,在上文中沒有說明的是:在Go中,執行使用者程式碼的goroutine是一種使用者態執行緒,其呼叫棧記憶體被稱為使用者棧,它其實也是從堆區分配的,但是我們仍然可以將其看作和系統棧一樣的記憶體空間,它的分配和釋放是透過編譯器完成的。與其相對應的是系統棧,它的分配和釋放是作業系統完成的。在GMP模型中,一個M對應一個系統棧(也稱為M的

g0

棧),M上的多個goroutine會共享該系統棧。

不同平臺上的系統棧最大限制不同。

$ ulimit -s8192

以x86_64架構為例,它的系統棧大小最大可為8Mb。我們常說的goroutine初始大小為2kb,其實說的是使用者棧,它的最小和最大可以在

runtime/stack。go

中找到,分別是2KB和1GB。

// The minimum size of stack used by Go code_StackMin = 2048。。。var maxstacksize uintptr = 1 << 20 // enough until runtime。main sets it for real

而堆

則會大很多,從1。11之後,Go採用了稀疏的記憶體佈局,在Linux的x86-64架構上執行時,整個堆區最大可以管理到256TB的記憶體。所以,為了不造成棧溢位和頻繁的擴縮容,大的物件分配在堆上更加合理。那麼,多大的物件會被分配到堆上呢。

透過測試,小菜刀發現該大小為64KB(這在Go記憶體分配中是屬於大物件的範圍:>32kb),即

s :=make([]int, n, n)

中,一旦

n

達到8192,就一定會逃逸。注意,網上有人透過

fmt。Println(unsafe。Sizeof(s))

得到

s

的大小為24位元組,就誤以為只需分配24個位元組的記憶體,這是錯誤的,因為實際還有底層陣列的記憶體需要分配。

情況四:變數大小不確定

我們將情況三種的示例,簡單更改一下。

package mainfunc foo() { n := 1 s := make([]int, n) for i := 0; i < len(s); i++ { s[i] = i }}func main() { foo()}

得到逃逸分析結果如下

$ go build -gcflags ‘-m -m -l’ main。go# command-line-arguments。/main。go:5:11: make([]int, n) escapes to heap:。/main。go:5:11: flow: {heap} = &{storage for make([]int, n)}:。/main。go:5:11: from make([]int, n) (non-constant size) at 。/main。go:5:11。/main。go:5:11: make([]int, n) escapes to heap

這次,我們在

make

方法中,沒有直接指定大小,而是填入了變數

n

,這時Go逃逸分析也會將其分配到堆區去。可見,為了保證記憶體的絕對安全,Go的編譯器可能會將一些變數不合時宜地分配到堆上,但是因為這些物件最終也會被垃圾收集器處理,所以也能接受。

總結

本文只列舉了逃逸分析的部分例子,實際的情況還有很多,理解思想最重要。這裡就不過多列舉了。

既然Go的堆疊分配對於開發者來說是透明的,編譯器已經透過逃逸分析為物件選擇好了分配方式。那麼我們還可以從中獲益什麼?

答案是肯定的,理解逃逸分析一定能幫助我們寫出更好的程式。知道變數分配在棧堆之上的差別,那麼我們就要儘量寫出分配在棧上的程式碼,堆上的變數變少了,可以減輕記憶體分配的開銷,減小gc的壓力,提高程式的執行速度。

所以,你會發現有些Go上線專案,它們在函式傳參的時候,並沒有傳遞結構體指標,而是直接傳遞的結構體。這個做法,雖然它需要值複製,但是這是在棧上完成的操作,開銷遠比變數逃逸後動態地在堆上分配記憶體少

的多

。當然該做法不是絕對的,如果結構體較大,傳遞指標將更合適。

因此,從GC的角度來看,指標傳遞是個雙刃劍,需要謹慎使用,否則線上調優解決GC延時可能會讓你崩潰。