歡迎光臨
每天分享高質量文章

Golang之變數去哪兒

寫過C/C++的同學都知道,呼叫著名的malloc和new函式可以在堆上分配一塊記憶體,這塊記憶體的使用和銷毀的責任都在程式員。一不小心,就會發生記憶體洩露,搞得膽戰心驚。

切換到Golang後,基本不會擔心記憶體洩露了。雖然也有new函式,但是使用new函式得到的記憶體不一定就在堆上。堆和棧的區別對程式員“模糊化”了,當然這一切都是Go編譯器在背後幫我們完成的。

一個變數是在堆上分配,還是在棧上分配,是經過編譯器的逃逸分析之後得出的結論。

這篇文章,就將帶領大家一起去探索逃逸分析——變數到底去哪兒,堆還是棧?

01
什麼是逃逸分析

以前寫C/C++程式碼時,為了提高效率,常常將pass-by-value(傳值)“升級”成pass-by-reference,企圖避免建構式的執行,並且直接傳回一個指標。

你一定還記得,這裡隱藏了一個很大的坑:在函式內部定義了一個區域性變數,然後傳回這個區域性變數的地址(指標)。這些區域性變數是在棧上分配的(靜態記憶體分配),一旦函式執行完畢,變數佔據的記憶體會被銷毀,任何對這個傳回值作的動作(如解取用),都將擾亂程式的執行,甚至導致程式直接崩潰。比如下麵的這段程式碼:

  1. int *foo ( void )  

  2. {  

  3.    int t = 3;

  4.    return &t;

  5. }

有些同學可能知道上面這個坑,用了個更聰明的做法:在函式內部使用new函式構造一個變數(動態記憶體分配),然後傳回此變數的地址。因為變數是在堆上建立的,所以函式退出時不會被銷毀。但是,這樣就行了嗎?new出來的物件該在何時何地delete呢?呼叫者可能會忘記delete或者直接拿傳回值傳給其他函式,之後就再也不能delete它了,也就是發生了記憶體洩露。關於這個坑,大家可以去看看《Effective C++》條款21,講得非常好!

C++是公認的語法最複雜的語言,據說沒有人可以完全掌握C++的語法。而這一切在Go語言中就大不相同了。像上面示例的C++程式碼放到Go裡,沒有任何問題。

你錶面的光鮮,一定是背後有很多人為你撐起的!Go語言裡就是編譯器的逃逸分析。它是編譯器執行靜態程式碼分析後,對記憶體管理進行的最佳化和簡化。

在編譯原理中,分析指標動態範圍的方法稱之為逃逸分析。通俗來講,當一個物件的指標被多個方法或執行緒取用時,我們稱這個指標發生了逃逸。

更簡單來說,逃逸分析決定一個變數是分配在堆上還是分配在棧上。

02
為什麼要逃逸分析

前面講的C/C++中出現的問題,在Go中作為一個語言特性被大力推崇。真是C/C++之砒霜Go之蜜糖!

C/C++中動態分配的記憶體需要我們手動釋放,導致猿們平時在寫程式時,如履薄冰。這樣做有他的好處:程式員可以完全掌控記憶體。但是缺點也是很多的:經常出現忘記釋放記憶體,導致記憶體洩露。所以,很多現代語言都加上了垃圾回收機制。

Go的垃圾回收,讓堆和棧對程式員保持透明。真正解放了程式員的雙手,讓他們可以專註於業務,“高效”地完成程式碼編寫。把那些記憶體管理的複雜機制交給編譯器,而程式員可以去享受生活。

逃逸分析這種“騷操作”把變數合理地分配到它該去的地方,“找準自己的位置”。即使你是用new申請到的記憶體,如果我發現你竟然在退出函式後沒有用了,那麼就把你丟到棧上,畢竟棧上的記憶體分配比堆上快很多;反之,即使你錶面上只是一個普通的變數,但是經過逃逸分析後發現在退出函式之後還有其他地方在取用,那我就把你分配到堆上。真正地做到“按需分配”,提前實現共產主義!

如果變數都分配到堆上,堆不像棧可以自動清理。它會引起Go頻繁地進行垃圾回收,而垃圾回收會佔用比較大的系統開銷(佔用CPU容量的25%)。

堆和棧相比,堆適合不可預知大小的記憶體分配。但是為此付出的代價是分配速度較慢,而且會形成記憶體碎片。棧記憶體分配則會非常快。棧分配記憶體只需要兩個CPU指令:“PUSH”和“RELEASSE”,分配和釋放;而堆分配記憶體首先需要去找到一塊大小合適的記憶體塊,之後要透過垃圾回收才能釋放。

透過逃逸分析,可以儘量把那些不需要分配到堆上的變數直接分配到棧上,堆上的變數少了,會減輕分配堆記憶體的開銷,同時也會減少gc的壓力,提高程式的執行速度。

03
逃逸分析是怎麼完成的

Go逃逸分析最基本的原則是:如果一個函式傳回對一個變數的取用,那麼它就會發生逃逸。

簡單來說,編譯器會分析程式碼的特徵和程式碼生命週期,Go中的變數只有在編譯器可以證明在函式傳回後不會再被取用的,才分配到棧上,其他情況下都是分配到堆上。

Go語言裡沒有一個關鍵字或者函式可以直接讓變數被編譯器分配到堆上,相反,編譯器透過分析程式碼來決定將變數分配到何處。

對一個變數取地址,可能會被分配到堆上。但是編譯器進行逃逸分析後,如果考察到在函式傳回後,此變數不會被取用,那麼還是會被分配到棧上。套個取址符,就想騙補助?Too young!

簡單來說,編譯器會根據變數是否被外部取用來決定是否逃逸:

  1. 如果函式外部沒有取用,則優先放到棧中;

  2. 如果函式外部存在取用,則必定放到堆中;

針對第一條,可能放到堆上的情形:定義了一個很大的陣列,需要申請的記憶體過大,超過了棧的儲存能力。

04
逃逸分析實體

Go提供了相關的命令,可以檢視變數是否發生逃逸。

還是用上面我們提到的例子:

  1. package main

  2. import "fmt"

  3. func foo() *int {

  4.    t := 3

  5.    return &t;

  6. }

  7. func main() {

  8.    x := foo()

  9.    fmt.Println(*x)

  10. }

foo函式傳回一個區域性變數的指標,main函式裡變數x接收它。執行如下命令:

  1. go build -gcflags '-m -l' main.go

-l是為了不讓foo函式被行內。得到如下輸出:

  1. # command-line-arguments

  2. src/main.go:7:9: &t; escapes to heap

  3. src/main.go:6:7: moved to heap: t

  4. src/main.go:12:14: *x escapes to heap

  5. src/main.go:12:13: main ... argument does not escape

foo函式裡的變數t逃逸了,和我們預想的一致。讓我們不解的是為什麼main函式裡的x也逃逸了?這是因為有些函式引數為interface型別,比如fmt.Println(a …interface{}),編譯期間很難確定其引數的具體型別,也會發生逃逸。

使用反彙編命令也可以看出變數是否發生逃逸。

  1. go tool compile -S main.go

擷取部分結果,圖中標記出來的說明 t是在堆上分配記憶體,發生了逃逸。

05
總結

堆上動態分配記憶體比棧上靜態分配記憶體,開銷大很多。

變數分配在棧上需要能在編譯期確定它的作用域,否則會分配到堆上。

Go編譯器會在編譯期對考察變數的作用域,並作一系列檢查,如果它的作用域在執行期間對編譯器一直是可知的,那麼就會分配到棧上。

簡單來說,編譯器會根據變數是否被外部取用來決定是否逃逸。對於Go程式員來說,編譯器的這些逃逸分析規則不需要掌握,我們只需透過go build-gcflags'-m'命令來觀察變數逃逸情況就行了。

不要盲目使用變數的指標作為函式引數,雖然它會減少複製操作。但其實當引數為變數自身的時候,複製是在棧上完成的操作,開銷遠比變數逃逸後動態地在堆上分配記憶體少的多。

最後,儘量寫出少一些逃逸的程式碼,提升程式的執行效率。

06
參考資料

【逃逸是怎麼發生的】https://www.do1618.com/archives/1328/go-%E5%86%85%E5%AD%98%E9%80%83%E9%80%B8%E8%AF%A6%E7%BB%86%E5%88%86%E6%9E%90/

【Go的變數到底在堆還是棧中分配】https://github.com/developer-learning/night-reading-go/blob/master/content/discuss/2018-07-09-make-new-in-go.md

【Golang堆疊的理解】https://segmentfault.com/a/1190000017498101

【逃逸分析 編寫棧分配記憶體建議】https://segment.com/blog/allocation-efficiency-in-high-performance-go-services/ 【逃逸分析 比較簡潔】https://studygolang.com/articles/17584

【逃逸分析定義】https://cloud.tencent.com/developer/article/1117410

【逃逸分析例子】https://my.oschina.net/renhc/blog/2222104

https://gocn.vip/article/355 【彙編程式碼 傳參】https://github.com/maniafish/aboutgo/blob/master/heapstack.md

【逃逸分析的缺陷】https://studygolang.com/articles/12396

【比較好的逃逸分析的例子】http://www.agardner.me/golang/garbage/collection/gc/escape/analysis/2015/10/18/go-escape-analysis.html


Gopher China 2019 最新資訊

Gopher China 2019 講師專訪 -百度資深研發工程師陳肖楠

Gopher China 2019 講師專訪 -微博資深架構師晁嶽攀

【重磅】Gopher China 2019 大會講師及議題揭曉

重磅!會前一天培訓講師揭曉:Dave&William;

探探Gopher China 2019大會全面啟動

戳下方“ 閱讀原文 ”即可報名