你為什么不應該過度關注go語言的逃逸分析
逃逸分析算是go語言的特色之一,編譯器自動分析變量/內存應該分配在棧上還是堆上,程序員不需要主動關心這些事情,保證了內存安全的同時也減輕了程序員的負擔。
然而這個“減輕負擔”的特性現在卻成了程序員的心智負擔。尤其是各路八股文普及之后,逃逸分析相關的問題在面試里出現的頻率越來越高,不會往往意味著和工作機會失之交臂,更有甚者會認為不了解逃逸分析約等于不會go。
我很不喜歡這些現象,不是因為我不會go,而是我知道逃逸分析是個啥情況:分析規則有版本間差異、規則過于保守很多時候把可以在棧上的變量逃逸到堆上、規則繁雜導致有很多corner case等等。更不提有些質量欠佳的八股在逃逸分析的描述上還有誤導了。
所以我建議大部分人回歸逃逸分析的初心——對于程序員來說逃逸分析應該就像是透明的,不要過度關心它。
怎么知道變量是不是逃逸了
我還見過一些比背過時的八股文更過分的情況:一群人圍著一段光禿禿的代碼就變量到底會不會逃逸爭得面紅耳赤。
他們甚至沒有用go編譯器自帶的驗證方法來論證自己的觀點。
那樣的爭論是沒有意義的,你應該用下面的命令來檢查編譯器逃逸分析的結果:
$ go build -gcflags=-m=2 a.go
# command-line-arguments
./a.go:5:6: cannot inline main: function too complex: cost 104 exceeds budget 80
./a.go:12:20: inlining call to fmt.Println
./a.go:12:21: num escapes to heap:
./a.go:12:21: flow: {storage for ... argument} = &{storage for num}:
./a.go:12:21: from num (spill) at ./a.go:12:21
./a.go:12:21: from ... argument (slice-literal-element) at ./a.go:12:20
./a.go:12:21: flow: fmt.a = &{storage for ... argument}:
./a.go:12:21: from ... argument (spill) at ./a.go:12:20
./a.go:12:21: from fmt.a := ... argument (assign-pair) at ./a.go:12:20
./a.go:12:21: flow: {heap} = *fmt.a:
./a.go:12:21: from fmt.Fprintln(os.Stdout, fmt.a...) (call parameter) at ./a.go:12:20
./a.go:7:19: make([]int, 10) does not escape
./a.go:12:20: ... argument does not escape
./a.go:12:21: num escapes to heap
哪些東西逃逸了哪些沒有顯示得一清二楚——escapes to heap表示變量或表達式逃逸了,does not escape則表示沒有發生逃逸。
另外本文討論的是go官方的gc編譯器,像一些第三方編譯器比如tinygo沒義務也沒理由使用和官方完全相同的逃逸規則——這些規則并不是標準的一部分也不適用于某些特殊場景。
本文的go版本是1.23,我也不希望未來某一天有人用1.1x或者1.3x版本的編譯器來問我為啥實驗結果不一樣了。
八股文里的問題
先聲明,對事不對人,愿意分享信息的精神還是值得尊敬的。
不過分享之前至少先做點簡單的驗證,不然那些倒果為因還有胡言亂語的內容就止增笑耳了。
編譯期不知道大小的東西會逃逸
這話其實沒說錯,但很多八股文要么到這里結束了,要么給出一個很多時候其實不逃逸的例子然后做一大通令人捧腹的解釋。
比如:
package main
import "fmt"
type S struct {}
func (*S) String() string { return "hello" }
type Stringer interface {
String() string
}
func getString(s Stringer) string {
if s == nil {
return "<nil>"
}
return s.String()
}
func main() {
s := &S{}
str := getString(s)
fmt.Println(str)
}
一些八股文會說getString的參數s在編譯期很難知道實際類型是什么,所以大小不好確定,所以會導致傳給它的參數逃逸。
這話對嗎?對也不對,因為編譯期這個時間段太寬泛了,一個interface在“編譯期”的前半段時間不知道實際類型,但后半段就有可能知道了。所以關鍵在于逃逸分析在什么時候進行,這直接決定了類型為接口的變量的逃逸分析結果。
我們驗證一下:
# command-line-arguments
...
./b.go:22:18: inlining call to getString
...
./b.go:22:18: devirtualizing s.String to *S
...
./b.go:23:21: str escapes to heap:
./b.go:23:21: flow: {storage for ... argument} = &{storage for str}:
./b.go:23:21: from str (spill) at ./b.go:23:21
./b.go:23:21: from ... argument (slice-literal-element) at ./b.go:23:20
./b.go:23:21: flow: fmt.a = &{storage for ... argument}:
./b.go:23:21: from ... argument (spill) at ./b.go:23:20
./b.go:23:21: from fmt.a := ... argument (assign-pair) at ./b.go:23:20
./b.go:23:21: flow: {heap} = *fmt.a:
./b.go:23:21: from fmt.Fprintln(os.Stdout, fmt.a...) (call parameter) at ./b.go:23:20
./b.go:21:14: &S{} does not escape
./b.go:23:20: ... argument does not escape
./b.go:23:21: str escapes to heap
我只截取了關鍵信息,否則雜音太大。&S{} does not escape這句直接告訴我們getString的參數并沒有逃逸。
為啥?因為getString被內聯了,內聯后編譯器發現參數的實際類型就是S,所以devirtualizing s.String to *S做了去虛擬化,這下接口的實際類型編譯器知道了,所以沒有讓參數逃逸的必要了。
而str逃逸了,str的類型是已知的,內容也是常量字符串,按八股文的理論不是不應該逃逸么?其實上面的信息也告訴你為什么了,因為fmt.Println內部的一些函數沒法內聯,而它們又用any去接受參數,這時候編譯器沒法做去虛擬化,沒法最終確定變量的真實大小,所以str只能逃逸了。記得最開頭我說的嗎,逃逸分析是很保守的,因為內存安全和程序的正確性是第一位的。
如果禁止函數inline,情況就不同了,我們在go里可以手動禁止一個函數被內聯:
+//go:noinline
func getString(s Stringer) string {
if s == nil {
return "<nil>"
}
return s.String()
}
這回再看結果:
# command-line-arguments
./b.go:14:6: cannot inline getString: marked go:noinline
...
./b.go:22:14: &S{} escapes to heap:
./b.go:22:14: flow: s = &{storage for &S{}}:
./b.go:22:14: from &S{} (spill) at ./b.go:22:14
./b.go:22:14: from s := &S{} (assign) at ./b.go:22:11
./b.go:22:14: flow: {heap} = s:
./b.go:22:14: from s (interface-converted) at ./b.go:23:19
./b.go:22:14: from getString(s) (call parameter) at ./b.go:23:18
./b.go:22:14: &S{} escapes to heap
./b.go:24:20: ... argument does not escape
./b.go:24:21: str escapes to heap
getString沒法內聯,所以沒法做去虛擬化,最后無法在逃逸分析前得知變量的大小,所以作為參數的s最后逃逸了。
因此“編譯期”這個表述不太對,正確的應該是“在逃逸分析執行時不能知道確切大小的變量/內存分配會逃逸”。還有一點要注意:內聯和一部分內置函數/語句的改寫發生在逃逸分析之前。內聯是什么大家應該知道,改寫改天有空了再好好介紹。
而且go對于什么能在逃逸分析前計算出來也是比較隨性的:
func main() {
arr := [4]int{}
slice := make([]int, 4)
s1 := make([]int, len(arr)) // not escape
s2 := make([]int, len(slice)) // escape
}
s1不逃逸但s2逃逸,因為len在計算數組的長度時會直接返回一個編譯期常量。而len計算slice的長度時并不能在編譯期完成計算,所以即使我們很清楚slice此時的長度就是4,但go還是會認為s2的大小不能在逃逸分析前就確定。
這也是為什么我告誡大家不要過度關心逃逸分析這東西,很多時候它是反常識的。
編譯期知道大小就不會逃逸嗎
有的八股文基于上一節的現象,得出了下面這樣的結論:make([]T, 常數)不會逃逸。
我覺得一個合格的go或者c/c++/rust程序員應該馬上近乎本能地反駁:不逃逸就會分配在棧上,棧空間通常有限(系統棧通常8-10M,goroutine則是固定的1G),如果這個make需要的內存空間大小超過了棧的上限呢?
很顯然超過了上限就會逃逸到堆上,所以上面那句不太對。go當然有規定一次在棧空間上分配內存的上限,這個上限也遠小于棧大小的上限,但我不會告訴你是多少,因為沒人保證以后不會改,而且我說了,你關心這個并沒有什么用。
還有一種經典的情況,make生成的內容做返回值:
func f1() []int {
return make([]int, 64)
}
逃逸分析會給出這樣的結果:
# command-line-arguments
...
./c.go:6:13: make([]int, 64) escapes to heap:
./c.go:6:13: flow: ~r0 = &{storage for make([]int, 64)}:
./c.go:6:13: from make([]int, 64) (spill) at ./c.go:6:13
./c.go:6:13: from return make([]int, 64) (return) at ./c.go:6:2
./c.go:6:13: make([]int, 64) escapes to heap
這沒什么好意外的,因為返回值要在函數調用結束后繼續被使用,所以它只能在堆上分配。這也是逃逸分析的初衷。
不過因為這個函數太簡單了,所以總是能內聯,一旦內聯,這個make就不再是返回值,所以編譯器有機會不讓它逃逸。你可以用上一節教的//go:noinline試試。
slice的元素數量和是否逃逸關系不大
還有的八股會這么說:“slice里的元素數量太多會導致逃逸”,還有些八股文還會信誓旦旦地說這個數量限制是什么10000、十萬。
那好,我們看個例子:
package main
import "fmt"
func main() {
a := make([]int64, 10001)
b := make([]byte, 10001)
fmt.Println(len(a), len(b))
}
分析結果:
...
./c.go:6:11: make([]int64, 10001) escapes to heap:
./c.go:6:11: flow: {heap} = &{storage for make([]int64, 10001)}:
./c.go:6:11: from make([]int64, 10001) (too large for stack) at ./c.go:6:11
...
./c.go:6:11: make([]int64, 10001) escapes to heap
./c.go:7:11: make([]byte, 10001) does not escape
...
怎么元素數量一樣,一個逃逸了一個沒有?說明了和元素數量就沒關系,只和上一節說的棧上對內存分配大小有限制,超過了才會逃逸,沒超過你分配一億個元素都行。
關鍵是這種無聊的問題出鏡率還不低,我和我朋友都遇到過這種:
make([]int, 10001)
就問你這個東西逃逸不逃逸,面試官估計忘了int長度不是固定的,32位系統上它是4字節,64位上是8字節,所以沒有更多信息之前這個問題沒法回答,你就是把Rob Pike抓來他也只能搖頭。面試遇到了還能和面試官掰扯掰扯,筆試遇到了你怎么辦?
這就是我說的倒果為因,slice和數組會逃逸不是因為元素數量多,而是消耗的內存(元素大小x數量)超過了規定的上限。
new和make在逃逸分析時幾乎沒區別
有的八股文還說new的對象經常逃逸而make不會,所以應該盡量少用new。
這是篇老八股了,現在估計沒人會看,然而就算在當時這句話也是錯的。我想大概是八股作者不經驗證就把Java/c++里的知識嫁接過來了。
我得澄清一下,new和make確實非常不同,但只不同在兩個地方:
new(T)返回*T,而make(T, ...)返回Tnew(T)中T可以是任意類型(但slice呀接口什么的一般不建議),而make(T, ...)的T只能是slice、map或者chan。
就這兩個,另外針對slice之類的東西它們在初始化的具體方式上有一點區別,但這勉強包含在第二點里了。
所以絕不會出現new更容易導致逃逸,new和make一樣,會不會逃逸只受大小限制以及可達性的影響。
看個例子:
package main
import "fmt"
func f(i int) int {
ret := new(int)
*ret = 1
for j := 1; j <= i; j++ {
*ret *= j
}
return *ret
}
func main() {
num := f(5)
fmt.Println(num)
}
結果:
./c.go:5:6: can inline f with cost 20 as: func(int) int { ret := new(int); *ret = 1; for loop; return *ret }
...
./c.go:15:10: inlining call to f
./c.go:16:13: inlining call to fmt.Println
./c.go:6:12: new(int) does not escape
...
./c.go:15:10: new(int) does not escape
./c.go:16:13: ... argument does not escape
./c.go:16:14: num escapes to heap
看到new(int) does not escape了嗎,流言不攻自破。
不過為了防止有人較真,我得稍微介紹一點實現細節:雖然new和make在逃逸分析上差異不大,但當前版本的go對make的大小限制更嚴格,這么看的話那個八股還是錯的,因為make導致逃逸的概率稍大于new。所以該用new就用,不需要在意這些東西。
編譯優化太弱雞拖累逃逸分析
這兩年go語言有兩個讓我對逃逸分析徹底失去興趣的提交,第一個是:7015ed
改動就是給一個局部變量加了別名,這樣編譯器就不會讓這個局部變量錯誤地逃逸了。
為啥編譯器會讓這個變量逃逸?和編譯器實現可達性分析的算法有關,也和編譯器沒做優化導致分析精度降低有關。
如果你碰到了這種問題,你能想出這種修復手段嗎?我反正是不能,因為這個提交這么做是有開發和維護編譯器的大佬深入研究之后才定位問題并提出可選方案的,對普通人來說恐怕都想不明白問題出在哪。
另一個是我在1.24開發周期里遇到的。這個提交為了添加新功能對time.Time做了點小修改,以前的代碼這樣:
func (t Time) MarshalText() ([]byte, error) {
b := make([]byte, 0, len(RFC3339Nano))
b, err := t.appendStrictRFC3339(b)
if err != nil {
return nil, errors.New("Time.MarshalText: " + err.Error())
}
return b, nil
}
新的長這樣:
func (t Time) appendTo(b []byte, errPrefix string) ([]byte, error) {
b, err := t.appendStrictRFC3339(b)
if err != nil {
return nil, errors.New(errPrefix + err.Error())
}
return b, nil
}
func (t Time) MarshalText() ([]byte, error) {
return t.appendTo(make([]byte, 0, len(RFC3339Nano)), "Time.MarshalText: ")
}
其實就是開發者要復用里面的邏輯,所以抽出來單獨做了一個子函數,核心內容都沒變。
然而看起來沒啥本質區別的新代碼,卻顯示MarshalText的性能提升了40%。
怎么回事呢,因為現在MarshalText變簡單了,所以能在很多地方被內聯,而appendTo本身不分配內存,這就導致原先作為返回值的buf因為MarshalText能內聯,編譯器發現它在外部調用它的地方并不需要作為返回值而且大小已知,因此適用第二節里我們說到的情況,buf并不需要逃逸。不逃逸意味著不需要分配堆內存,性能自然就提高了。
這當然得賴go過于孱弱的內聯優化,它創造出了在c++里幾乎不可能出現的優化機會(appendTo就是個包裝,還多了一個參數,正常內聯展開后和原先的代碼幾乎不會有啥區別)。這在別的語言里多少有點反常識,所以一開始我以為提交里的描述有問題,花了大把時間排查加測試,才想到是內聯可能影響了逃逸分析,一個下午都浪費在這上面了。
這類問題太多太多,issue里就有不少,如果你不了解編譯器具體做了什么工作用了什么算法,排查解決這些問題是很困難的。
還記得開頭說的么,逃逸分析是要減輕程序員的負擔的,現在反過來要程序員深入了解編譯器,有點本末倒置了。
這兩個提交最終讓我開始重新思考開發者需要對逃逸分析了解到多深這個問題。
該怎么做
其實還有很多對逃逸分析的民間傳說,我懶得一一證實/證偽了。下面只說在逃逸分析本身就混亂而復雜的情況下,作為開發者該怎么做。
對于大多數開發者:和標題一樣,不要過度關注逃逸分析。逃逸分析應該是提升你效率的翅膀而不是寫代碼時的桎梏。
畢竟光看代碼,你很難分析出個所以然來,編譯期知道大小可能會逃逸,看起來不知道大小的也可能不會逃逸,看起來相似的代碼性能卻天差地別,中間還得穿插可達性分析和一些編譯優化,corner case多到超乎想象。寫代碼的時候想著這些東西,效率肯定高不了。
每當自己要想逃逸分析如何如何的時候,可以用下面的步驟幫助自己擺脫對逃逸分析的依賴:
- 變量的生命周期是否長于創建它的函數?
- 如果是,那么能選用返回“值”代替返回指針嗎,函數能被內聯或者值的尺寸比較小時復制的開銷幾乎是可以忽略不計的;
- 如果不是或者你發現設計可以修改使得變量的生命周期沒有那么長,則往下
- 函數是否是性能熱點?
- 如果不是那么到此為止,否則你需要用memprofile和cpuprofile來確定逃逸帶來了多少損失
- 性能熱點里當然越少逃逸越好,但如果逃逸帶來的損失本身不是很大,那么就不值得繼續往下了
- 復用堆內存往往比避免逃逸更簡單也更直觀,試試
sync.Pool之類的東西而不是想著避免逃逸 - 到了這一步,你不得不用
-gcflags=-m=2看看為什么發生逃逸了,有些原因很明顯,可以被優化 - 對于那些你看不懂為什么逃逸的,要么就別管了要么用go以外的手段(比如匯編)解決。
- 求助他人也是可以的,但前提是他們不是機械式地背背八股文。
總之,遵守一些常見的規定比如在知道slice大小的情況下提前分配內存、設計短小精悍的函數、少用指針等等,你幾乎沒啥研究逃逸分析的必要。
對于編譯器、標準庫、某些性能要求較高的程序的開發者來說,了解逃逸分析是必要的。因為go的性能不是很理想,所以得抓住一切能利用的優化機會提升性能。比如我往標準庫塞新功能的時候就被要求過一些函數得是“零分配”的。當然我沒有上來就研究逃逸,而是先寫了測試并研究了profile,之后才用逃逸分析的結果做了更進一步的優化。
總結
這篇文章其實還有一些東西沒說,比如數組和閉包在逃逸分析的表現。總體上它們的行為沒有和別的變量差太多,在看看文章的標題——所以我不建議過度關注它們的逃逸分析。
所以說,你不應該過度關心逃逸分析。也應該停止背/搬運/編寫有關逃逸分析的八股文。
大部分人關心逃逸分析,除了面試之外就是為了性能,我常說的是性能分析一定要結合profile和benchmark,否則憑空臆斷為了不逃逸而削足適履,不僅浪費時間對性能問題也沒有絲毫幫助。
話說回來,不深入了解逃逸分析和不知道有逃逸分析這東西可是兩回事,后者確實約等于go白學了。


浙公網安備 33010602011771號