golang 逃逸分析與棧、堆分配分析
我們在寫 golang 代碼時候定義變量,那麼一個很常見的問題,申請的變量保存在哪裏呢?棧?還是堆?會不會有一些特殊例子?這篇文章我們就來探索下具體的case以及如何做分析。
還是從實際使用場景出發:
Question
package main
type User struct {
ID int64
Name string
Avatar string
}
func GetUserInfo() *User {
return &User{
ID: 666666,
Name: "sim lou",
Avatar: "https://www.baidu.com/avatar/666666",
}
}
func main() {
u := GetUserInfo()
println(u.Name)
}
這裏GetUserInfo 函數裏面的 User 對象是存儲在函數棧上還是堆上?
什麼是堆?什麼是棧?
簡單說:
- 堆:一般來講是人爲手動進行管理,手動申請、分配、釋放。一般所涉及的內存大小並不定,一般會存放較大的對象。另外其分配相對慢,涉及到的指令動作也相對多
- 棧:由編譯器進行管理,自動申請、分配、釋放。一般不會太大,我們常見的函數參數(不同平臺允許存放的數量不同),局部變量等等都會存放在棧上
今天我們介紹的 Go 語言,它的堆棧分配是通過 Compiler 進行分析,GC 去管理的,而對其的分析選擇動作就是今天探討的重點
逃逸分析
逃逸分析是一種確定指針動態範圍的方法,簡單來說就是分析在程序的哪些地方可以訪問到該指針。
通俗地講,逃逸分析就是確定一個變量要放堆上還是棧上,規則如下:
- 是否有在其他地方(非局部)被引用。只要有可能被引用了,那麼它一定分配到堆上。否則分配到棧上
- 即使沒有被外部引用,但對象過大,無法存放在棧區上。依然有可能分配到堆上
對此你可以理解爲,逃逸分析是編譯器用於決定變量分配到堆上還是棧上的一種行爲。
在什麼階段確立逃逸
go 在編譯階段確立逃逸,注意並不是在運行時
爲什麼需要逃逸
其實就是爲了儘可能在棧上分配內存,我們可以反過來想,如果變量都分配到堆上了會出現什麼事情?例如:
- 垃圾回收(GC)的壓力不斷增大
- 申請、分配、回收內存的系統開銷增大(相對於棧)
- 動態分配產生一定量的內存碎片
其實總的來說,就是頻繁申請、分配堆內存是有一定 “代價” 的。會影響應用程序運行的效率,間接影響到整體系統。因此 “按需分配” 最大限度的靈活利用資源,纔是正確的治理之道。這就是爲什麼需要逃逸分析的原因,你覺得呢?
go怎麼確定是否逃逸
第一:編譯器命令
可以看到詳細的逃逸分析過程。而指令集 -gcflags 用於將標識參數傳遞給 Go 編譯器,涉及如下:
- -m 會打印出逃逸分析的優化策略,實際上最多總共可以用 4 個 -m,但是信息量較大,一般用 1 個就可以了
- -l 會禁用函數內聯,在這裏禁用掉 inline 能更好的觀察逃逸情況,減少干擾
$ go build -gcflags '-m -l' main.go
第二:反編譯命令查看
$ go tool compile -S main.go
注:可以通過 go tool compile -help 查看所有允許傳遞給編譯器的標識參數
實際案例
1.指針
package main
type User struct {
ID int64
Name string
Avatar string
}
func GetUserInfo() *User {
return &User{
ID: 666666,
Name: "sim lou",
Avatar: "https://www.baidu.com/avatar/666666",
}
}
func main() {
u := GetUserInfo()
println(u.Name)
}
看編譯器命令執行結果:
$go build -gcflags '-m -l' escape_analysis.go
# command-line-arguments
./escape_analysis.go:13:11: &User literal escapes to heap
通過查看分析結果,可得知 &User 逃到了堆裏,也就是分配到堆上了。這是不是有問題啊…再看看彙編代碼確定一下,如下:
$go tool compile -S escape_analysis.go
"".GetUserInfo STEXT size=190 args=0x8 locals=0x18
0x0000 00000 (escape_analysis.go:9) TEXT "".GetUserInfo(SB), ABIInternal, $24-8
......
0x002c 00044 (escape_analysis.go:13) CALL runtime.newobject(SB)
......
0x0045 00069 (escape_analysis.go:12) CMPL runtime.writeBarrier(SB), $0
0x004c 00076 (escape_analysis.go:12) JNE 156
0x004e 00078 (escape_analysis.go:12) LEAQ go.string."sim lou"(SB), CX
......
0x0061 00097 (escape_analysis.go:13) CMPL runtime.writeBarrier(SB), $0
0x0068 00104 (escape_analysis.go:13) JNE 132
0x006a 00106 (escape_analysis.go:13) LEAQ go.string."https://www.baidu.com/avatar/666666"(SB), CX
......
執行了 runtime.newobject 方法,也就是確實是分配到了堆上。這是爲什麼呢?這是因爲 GetUserInfo() 返回的是指針對象,引用被返回到了方法之外了。因此編譯器會把該對象分配到堆上,而不是棧上。否則方法結束之後,局部變量就被回收了,豈不是翻車。所以最終分配到堆上是理所當然的。
那麼所有的指針都在堆上?也不是:
func PrintStr() {
str := new(string)
*str = "hello world"
}
func main() {
PrintStr()
}
看編譯器逃逸分析的結果:
$go build -gcflags '-m -l' escape_analysis3.go
# command-line-arguments
./escape_analysis3.go:4:12: PrintStr new(string) does not escape
看,該對象分配到棧上了。很核心的一點就是它有沒有被作用域之外所引用,而這裏作用域仍然保留在 main 中,因此它沒有發生逃逸。
2. 不確定類型
func main() {
str := new(string)
*str = "hello world"
fmt.Println(*str)
}
執行命令觀察一下,如下:
$go build -gcflags '-m -l' escape_analysis4.go
# command-line-arguments
./escape_analysis4.go:6:12: main new(string) does not escape
./escape_analysis4.go:8:13: main ... argument does not escape
./escape_analysis4.go:8:14: *str escapes to heap
通過查看分析結果,可得知 str 變量逃到了堆上,也就是該對象在堆上分配。但上個案例時它還在棧上,我們也就 fmt 輸出了它而已。這…到底發生了什麼事?
相對案例一,案例二隻加了一行代碼 fmt.Println(str),問題肯定出在它身上。其原型:
func Println(a ...interface{}) (n int, err error)
通過對其分析,可得知當形參爲 interface 類型時,在編譯階段編譯器無法確定其具體的類型。因此會產生逃逸,最終分配到堆上。
如果你有興趣追源碼的話,可以看下內部的 reflect.TypeOf(arg).Kind() 語句,其會造成堆逃逸,而表象就是 interface 類型會導致該對象分配到堆上。
總結
- 靜態分配到棧上,性能一定比動態分配到堆上好
- 底層分配到堆,還是棧。實際上對你來說是透明的,不需要過度關心
- 每個 Go 版本的逃逸分析都會有所不同(會改變,會優化)
- 直接通過 go build -gcflags ‘-m -l’ 就可以看到逃逸分析的過程和結果
- 到處都用指針傳遞並不一定是最好的,要用對。