詳解Go逃逸分析

Go是一門帶有垃圾回收的現(xiàn)代語言,它拋棄了傳統(tǒng)C/C++的開發(fā)者需要手動(dòng)管理內(nèi)存的方式,實(shí)現(xiàn)了內(nèi)存的主動(dòng)申請(qǐng)和釋放的管理。Go的垃圾回收,讓堆和棧的概念對(duì)程序員保持透明,它增加的逃逸分析與GC,使得程序員的雙手真正地得到了解放,給了開發(fā)者更多的精力去關(guān)注軟件設(shè)計(jì)本身。

就像《CPU緩存體系對(duì)Go程序的影響》文章中說過的一樣,“你不一定需要成為一名硬件工程師,但是你確實(shí)需要了解硬件的工作原理”。Go雖然幫我們實(shí)現(xiàn)了內(nèi)存的自動(dòng)管理,我們?nèi)匀恍枰榔鋬?nèi)在原理。內(nèi)存管理主要包括兩個(gè)動(dòng)作:分配與釋放。逃逸分析就是服務(wù)于內(nèi)存分配,為了更好理解逃逸分析,我們先談一下堆棧。

堆和棧

應(yīng)用程序的內(nèi)存載體,我們可以簡(jiǎn)單地將其分為堆和棧。

在Go中,棧的內(nèi)存是由編譯器自動(dòng)進(jìn)行分配和釋放,棧區(qū)往往存儲(chǔ)著函數(shù)參數(shù)、局部變量和調(diào)用函數(shù)幀,它們隨著函數(shù)的創(chuàng)建而分配,函數(shù)的退出而銷毀。一個(gè)goroutine對(duì)應(yīng)一個(gè)棧,棧是調(diào)用棧(call stack)的簡(jiǎn)稱。一個(gè)棧通常又包含了許多棧幀(stack frame),它描述的是函數(shù)之間的調(diào)用關(guān)系,每一幀對(duì)應(yīng)一次尚未返回的函數(shù)調(diào)用,它本身也是以棧形式存放數(shù)據(jù)。

舉例:在一個(gè)goroutine里,函數(shù)A()正在調(diào)用函數(shù)B(),那么這個(gè)調(diào)用棧的內(nèi)存布局示意圖如下。

1.png

與棧不同的是,應(yīng)用程序在運(yùn)行時(shí)只會(huì)存在一個(gè)堆。狹隘地說,內(nèi)存管理只是針對(duì)堆內(nèi)存而言的。程序在運(yùn)行期間可以主動(dòng)從堆上申請(qǐng)內(nèi)存,這些內(nèi)存通過Go的內(nèi)存分配器分配,并由垃圾收集器回收。

棧是每個(gè)goroutine獨(dú)有的,這就意味著棧上的內(nèi)存操作是不需要加鎖的。而堆上的內(nèi)存,有時(shí)需要加鎖防止多線程沖突(為什么要說有時(shí)呢,因?yàn)镚o的內(nèi)存分配策略學(xué)習(xí)了TCMalloc的線程緩存思想,他為每個(gè)處理器P分配了一個(gè)mcache,從mcache分配內(nèi)存也是無鎖的)。

而且,對(duì)于程序堆上的內(nèi)存回收,還需要通過標(biāo)記清除階段,例如Go采用的三色標(biāo)記法。但是,在棧上的內(nèi)存而言,它的分配與釋放非常廉價(jià)。簡(jiǎn)單地說,它只需要兩個(gè)CPU指令:一個(gè)是分配入棧,另外一個(gè)是棧內(nèi)釋放。而這,只需要借助于棧相關(guān)寄存器即可完成。

另外還有一點(diǎn),棧內(nèi)存能更好地利用CPU的緩存策略。因?yàn)樗鼈兿噍^于堆而言是更連續(xù)的。

逃逸分析

那么,我們?cè)趺粗酪粋€(gè)對(duì)象是應(yīng)該放在堆內(nèi)存,還是棧內(nèi)存之上呢?可以官網(wǎng)的FAQ(地址:https://golang.org/doc/faq)中找到答案。

2.png

如果可以,Go編譯器會(huì)盡可能將變量分配到到棧上。但是,當(dāng)編譯器無法證明函數(shù)返回后,該變量沒有被引用,那么編譯器就必須在堆上分配該變量,以此避免懸掛指針(dangling pointer)。另外,如果局部變量非常大,也會(huì)將其分配在堆上。

那么,Go是如何確定的呢?答案就是:逃逸分析。編譯器通過逃逸分析技術(shù)去選擇堆或者棧,逃逸分析的基本思想如下:檢查變量的生命周期是否是完全可知的,如果通過檢查,則可以在棧上分配。否則,就是所謂的逃逸,必須在堆上進(jìn)行分配。

Go語言雖然沒有明確說明逃逸分析規(guī)則,但是有以下幾點(diǎn)準(zhǔn)則,是可以參考的。

  • 逃逸分析是在編譯器完成的,這是不同于jvm的運(yùn)行時(shí)逃逸分析;
  • 如果變量在函數(shù)外部沒有引用,則優(yōu)先放到棧中;
  • 如果變量在函數(shù)外部存在引用,則必定放在堆中;

我們可通過go build -gcflags '-m -l'命令來查看逃逸分析結(jié)果,其中-m 打印逃逸分析信息,-l禁止內(nèi)聯(lián)優(yōu)化。下面,我們通過一些案例,來熟悉一些常見的逃逸情況。

情況一:變量類型不確定

package main

import "fmt"

func main() {
    a := 666
    fmt.Println(a)
}

逃逸分析結(jié)果如下

 $ 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

可以看到,分析結(jié)果告訴我們變量a逃逸到了堆上。但是,我們并沒有外部引用啊,為啥也會(huì)有逃逸呢?為了看到更多細(xì)節(jié),可以在語句中再添加一個(gè)-m參數(shù)。得到信息如下

 $ 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逃逸是因?yàn)樗粋魅肓?code>fmt.Println的參數(shù)中,這個(gè)方法參數(shù)自己發(fā)生了逃逸。

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

因?yàn)?code>fmt.Println的函數(shù)參數(shù)為interface類型,編譯期不能確定其參數(shù)的具體類型,所以將其分配于堆上。

情況二:暴露給外部指針

package main

func foo() *int {
    a := 666
    return &a
}

func main() {
    _ = foo()
}

逃逸分析如下,變量a發(fā)生了逃逸。

 $ 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

這種情況直接滿足我們上述中的原則:變量在函數(shù)外部存在引用。這個(gè)很好理解,因?yàn)楫?dāng)函數(shù)執(zhí)行完畢,對(duì)應(yīng)的棧幀就被銷毀,但是引用已經(jīng)被返回到函數(shù)之外。如果這時(shí)外部從引用地址取值,雖然地址還在,但是這塊內(nèi)存已經(jīng)被釋放回收了,這就是非法內(nèi)存,問題可就大了。所以,很明顯,這種情況必須分配到堆上。

情況三:變量所占內(nèi)存較大

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

func main() {
    foo()
}

逃逸分析結(jié)果

$ 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

可以看到,當(dāng)我們創(chuàng)建了一個(gè)容量為10000的int類型的底層數(shù)組對(duì)象時(shí),由于對(duì)象過大,它也會(huì)被分配到堆上。這里我們不禁要想一個(gè)問題,為啥大對(duì)象需要分配到堆上。

這里需要注意,在上文中沒有說明的是:在Go中,執(zhí)行用戶代碼的goroutine是一種用戶態(tài)線程,其調(diào)用棧內(nèi)存被稱為用戶棧,它其實(shí)也是從堆區(qū)分配的,但是我們?nèi)匀豢梢詫⑵淇醋骱拖到y(tǒng)棧一樣的內(nèi)存空間,它的分配和釋放是通過編譯器完成的。與其相對(duì)應(yīng)的是系統(tǒng)棧,它的分配和釋放是操作系統(tǒng)完成的。在GMP模型中,一個(gè)M對(duì)應(yīng)一個(gè)系統(tǒng)棧(也稱為M的g0棧),M上的多個(gè)goroutine會(huì)共享該系統(tǒng)棧。

不同平臺(tái)上的系統(tǒng)棧最大限制不同。

$ ulimit -s
8192

以x86_64架構(gòu)為例,它的系統(tǒng)棧大小最大可為8Mb。我們常說的goroutine初始大小為2kb,其實(shí)說的是用戶棧,它的最小和最大可以在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

而堆則會(huì)大很多,從1.11之后,Go采用了稀疏的內(nèi)存布局,在Linux的x86-64架構(gòu)上運(yùn)行時(shí),整個(gè)堆區(qū)最大可以管理到256TB的內(nèi)存。所以,為了不造成棧溢出和頻繁的擴(kuò)縮容,大的對(duì)象分配在堆上更加合理。那么,多大的對(duì)象會(huì)被分配到堆上呢。

通過測(cè)試,小菜刀發(fā)現(xiàn)該大小為64KB(這在Go內(nèi)存分配中是屬于大對(duì)象的范圍:>32kb),即s :=make([]int, n, n)中,一旦n達(dá)到8192,就一定會(huì)逃逸。注意,網(wǎng)上有人通過fmt.Println(unsafe.Sizeof(s))得到s的大小為24字節(jié),就誤以為只需分配24個(gè)字節(jié)的內(nèi)存,這是錯(cuò)誤的,因?yàn)閷?shí)際還有底層數(shù)組的內(nèi)存需要分配。

情況四:變量大小不確定

我們將情況三種的示例,簡(jiǎn)單更改一下。

package main

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

func main() {
    foo()
}

得到逃逸分析結(jié)果如下

$ 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

這次,我們?cè)?code>make方法中,沒有直接指定大小,而是填入了變量n,這時(shí)Go逃逸分析也會(huì)將其分配到堆區(qū)去??梢姡瑸榱吮WC內(nèi)存的絕對(duì)安全,Go的編譯器可能會(huì)將一些變量不合時(shí)宜地分配到堆上,但是因?yàn)檫@些對(duì)象最終也會(huì)被垃圾收集器處理,所以也能接受。

總結(jié)

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

既然Go的堆棧分配對(duì)于開發(fā)者來說是透明的,編譯器已經(jīng)通過逃逸分析為對(duì)象選擇好了分配方式。那么我們還可以從中獲益什么?

答案是肯定的,理解逃逸分析一定能幫助我們寫出更好的程序。知道變量分配在棧堆之上的差別,那么我們就要盡量寫出分配在棧上的代碼,堆上的變量變少了,可以減輕內(nèi)存分配的開銷,減小gc的壓力,提高程序的運(yùn)行速度。

所以,你會(huì)發(fā)現(xiàn)有些Go上線項(xiàng)目,它們?cè)诤瘮?shù)傳參的時(shí)候,并沒有傳遞結(jié)構(gòu)體指針,而是直接傳遞的結(jié)構(gòu)體。這個(gè)做法,雖然它需要值拷貝,但是這是在棧上完成的操作,開銷遠(yuǎn)比變量逃逸后動(dòng)態(tài)地在堆上分配內(nèi)存少的多。當(dāng)然該做法不是絕對(duì)的,如果結(jié)構(gòu)體較大,傳遞指針將更合適。

因此,從GC的角度來看,指針傳遞是個(gè)雙刃劍,需要謹(jǐn)慎使用,否則線上調(diào)優(yōu)解決GC延時(shí)可能會(huì)讓你崩潰。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 參考:https://www.cnblogs.com/shijingxiang/articles/12200355...
    碼二哥閱讀 698評(píng)論 0 0
  • 閱讀前請(qǐng)悉知:本文是一篇翻譯文章,出于對(duì)原文的喜愛與敬畏,所以需要強(qiáng)調(diào):如果讀者英文閱讀能力好,請(qǐng)直接移步文末原文...
    wu_sphinx閱讀 2,706評(píng)論 5 5
  • 逃逸對(duì)性能的影響 在(1)中,通過一個(gè)共享在 goroutine 的棧上的值的例子講解了逃逸分析的基礎(chǔ)。還有其他沒...
    GGBond_8488閱讀 467評(píng)論 0 1
  • 引言 內(nèi)存管理的靈活性是讓C/C++程序猿們又愛又恨的東西,比如malloc或new一塊內(nèi)存我可以整個(gè)進(jìn)程使用。但...
    木工007閱讀 4,208評(píng)論 4 7
  • 久違的晴天,家長(zhǎng)會(huì)。 家長(zhǎng)大會(huì)開好到教室時(shí),離放學(xué)已經(jīng)沒多少時(shí)間了。班主任說已經(jīng)安排了三個(gè)家長(zhǎng)分享經(jīng)驗(yàn)。 放學(xué)鈴聲...
    飄雪兒5閱讀 7,789評(píng)論 16 22

友情鏈接更多精彩內(nèi)容