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’ 就可以看到逃逸分析的過程和結果
  • 到處都用指針傳遞並不一定是最好的,要用對。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章