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