Go語言-逃逸分析

對於手動管理內存的語言,比如 C/C++,調用著名的malloc和new函數可以在堆上分配一塊內存,這塊內存的使用和銷燬的責任都在程序員。一不小心,就會發生內存洩露,搞得膽戰心驚。

但是 Golang 並不是這樣,雖然 Golang 語言裡面也有 new。Golang 編譯器決定變量應該分配到什麼地方時會進行逃逸分析。使用new函數得到的內存不一定就在堆上。堆和棧的區別對程序員“模糊化”了,當然這一切都是Go編譯器在背後幫我們完成的。一個變量是在堆上分配,還是在棧上分配,是經過編譯器的逃逸分析之後得出的結論。

一、 逃逸分析是什麼

wiki定義

In compiler optimization, escape analysis is a method for determining the dynamic scope of pointers - where in the program a pointer can be accessed. It is related to pointer analysis and shape analysis.

When a variable (or an object) is allocated in a subroutine, a pointer to the variable can escape to other threads of execution, or to calling subroutines. If an implementation uses tail call optimization (usually required for functional languages), objects may also be seen as escaping to called subroutines. If a language supports first-class continuations (as do Scheme and Standard ML of New Jersey), portions of the call stack may also escape.

If a subroutine allocates an object and returns a pointer to it, the object can be accessed from undetermined places in the program — the pointer has "escaped". Pointers can also escape if they are stored in global variables or other data structures that, in turn, escape the current procedure.

Escape analysis determines all the places where a pointer can be stored and whether the lifetime of the pointer can be proven to be restricted only to the current procedure and/or threa.

C/C++中,有時為了提高效率,常常將pass-by-value(傳值)“升級”成pass-by-reference,企圖避免構造函數的運行,並且直接返回一個指針。然而這裡隱藏了一個很大的坑:在函數內部定義了一個局部變量,然後返回這個局部變量的地址(指針)。這些局部變量是在棧上分配的(靜態內存分配),一旦函數執行完畢,變量佔據的內存會被銷燬,任何對這個返回值作的動作(如解引用),都將擾亂程序的運行,甚至導致程序直接崩潰。例如:

<code>int *foo ( void )    

{
int t = 3;
return &t;
}/<code>

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

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

你表面的光鮮,一定是背後有很多人為你撐起的!Go語言裡就是編譯器的逃逸分析。它是編譯器執行靜態代碼分析後,對內存管理進行的優化和簡化。

在編譯原理中,分析指針動態範圍的方法稱之為逃逸分析。通俗來講,當一個對象的指針被多個方法或線程引用時,我們稱這個指針發生了逃逸。

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

二、 為什麼要逃逸分析

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

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

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

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

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

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

通過逃逸分析,可以儘量把那些不需要分配到堆上的變量直接分配到棧上,堆上的變量少了,會減輕分配堆內存的開銷,同時也會減少gc的壓力,提高程序的運行速度。

三、 逃逸分析如何完成

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

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

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

對一個變量取地址,可能會被分配到堆上。但是編譯器進行逃逸分析後,如果考察到在函數返回後,此變量不會被引用,那麼還是會被分配到棧上。

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

1)如果函數外部沒有引用,則優先放到棧中;

2) 如果函數外部存在引用,則必定放到堆中;

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

四、 逃逸分析實例

下面是一個簡單的例子。

<code>package main

import ()

func foo() *int {
var x int
return &x
}

func bar() int {
x := new(int)
*x = 1
return *x
}

func main() {}/<code>

開啟逃逸分析日誌很簡單,只要在編譯的時候加上-gcflags '-m',但是我們為了不讓編譯時自動內連函數,一般會加-l參數,最終為-gcflags '-m -l',執行如下命令:

<code>$ go build -gcflags '-m -l' main.go
# command-line-arguments
./main.go:5:9: &x escapes to heap
./main.go:4:6: moved to heap: x
./main.go:9:10: bar new(int) does not escape/<code>

上面代碼中foo() 中的 x 最後在堆上分配,而 bar() 中的 x 最後分配在了棧上。

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

<code>$ go tool compile -S main.go/<code>

截取部分結果,圖中標記出來的說明foo中x是在堆上分配內存,發生了逃逸。

Go語言-逃逸分析

反彙編命令結果

什麼時候逃逸呢? golang.org FAQ 上有一個關於變量分配的問題如下:

Q: How do I know whether a variable is allocated on the heap or the stack?

A: From a correctness standpoint, you don't need to know. Each variable in Go exists as long as there are references to it. The storage location chosen by the implementation is irrelevant to the semantics of the language.

The storage location does have an effect on writing efficient programs. When possible, the Go compilers will allocate variables that are local to a function in that function's stack frame. However, if the compiler cannot prove that the variable is not referenced after the function returns, then the compiler must allocate the variable on the garbage-collected heap to avoid dangling pointer errors. Also, if a local variable is very large, it might make more sense to store it on the heap rather than the stack.

In the current compilers, if a variable has its address taken, that variable is a candidate for allocation on the heap. However, a basic escape analysis recognizes some cases when such variables will not live past the return from the function and can reside on the stack.

關於什麼時候逃逸,什麼時候不逃逸,我們接下來再看幾個小例子。

1)Example1

<code>package main
type S struct{}
func main() {
var x S
y := &x
_ = *identity(y)
}
func identity(z *S) *S {
return z
}/<code>

結果如下:

<code># command-line-arguments
./main.go:8:22: leaking param: z to result ~r1 level=0
./main.go:5:7: main &x does not escape/<code>

這裡的第一行表示z變量是“流式”,因為identity這個函數僅僅輸入一個變量,又將這個變量作為返回輸出,但identity並沒有引用z,所以這個變量沒有逃逸,而x沒有被引用,且生命週期也在mian裡,x沒有逃逸,分配在棧上。

2)Example2

<code>package main
type S struct{}
func main() {
var x S
_ = *ref(x)
}
func ref(z S) *S {
return &z
}/<code>

結果如下:

<code># command-line-arguments
./main.go:8:9: &z escapes to heap
./main.go:7:16: moved to heap: z/<code>

這裡的z是逃逸了,原因很簡單,go都是值傳遞,ref函數copy了x的值,傳給z,返回z的指針,然後在函數外被引用,說明z這個變量在函數內聲明,可能會被函數外的其他程序訪問。所以z逃逸了,分配在堆上

3)Example3

<code>package main
type S struct {
M *int
}
func main() {
var i int
refStruct(i)
}
func refStruct(y int) (z S) {
z.M = &y
return z
}/<code>

結果如下:

<code># command-line-arguments
./main.go:10:8: &y escapes to heap
./main.go:9:26: moved to heap: y/<code>

看日誌的輸出,這裡的y是逃逸了,看來在struct裡好像並沒有區別,有可能被函數外的程序訪問就會逃逸

4)Example4

<code>package main
type S struct {
M *int
}
func main() {
var i int
refStruct(&i)
}
func refStruct(y *int) (z S) {
z.M = y
return z
}/<code>

結果如下:

<code># command-line-arguments
./main.go:9:27: leaking param: y to result z level=0
./main.go:7:12: main &i does not escape/<code>

這裡的y沒有逃逸,分配在棧上,原因和Example1是一樣的。

5)Example5

<code>package main
type S struct {
M *int
}
func main() {
var x S
var i int
ref(&i, &x)
}
func ref(y *int, z *S) {
z.M = y
}/<code>

結果如下:

<code># command-line-arguments
./main.go:10:21: leaking param: y
./main.go:10:21: ref z does not escape
./main.go:8:6: &i escapes to heap
./main.go:7:6: moved to heap: i
./main.go:8:10: main &x does not escape/<code>

這裡的z沒有逃逸,而i卻逃逸了,這是因為go的逃逸分析不知道z和i的關係,逃逸分析不知道參數y是z的一個成員,所以只能把它分配給堆。


分享到:


相關文章: