你為什麼不應該過度關注go語言的逃逸分析

apocelipes發表於2024-10-21

逃逸分析算是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確實非常不同,但只不同在兩個地方:

  1. new(T)返回*T,而make(T, ...)返回T
  2. new(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多到超乎想象。寫程式碼的時候想著這些東西,效率肯定高不了。

每當自己要想逃逸分析如何如何的時候,可以用下面的步驟幫助自己擺脫對逃逸分析的依賴:

  1. 變數的生命週期是否長於建立它的函式?
  2. 如果是,那麼能選用返回“值”代替返回指標嗎,函式能被內聯或者值的尺寸比較小時複製的開銷幾乎是可以忽略不計的;
  3. 如果不是或者你發現設計可以修改使得變數的生命週期沒有那麼長,則往下
  4. 函式是否是效能熱點?
  5. 如果不是那麼到此為止,否則你需要用memprofile和cpuprofile來確定逃逸帶來了多少損失
  6. 效能熱點裡當然越少逃逸越好,但如果逃逸帶來的損失本身不是很大,那麼就不值得繼續往下了
  7. 複用堆記憶體往往比避免逃逸更簡單也更直觀,試試sync.Pool之類的東西而不是想著避免逃逸
  8. 到了這一步,你不得不用-gcflags=-m=2看看為什麼發生逃逸了,有些原因很明顯,可以被最佳化
  9. 對於那些你看不懂為什麼逃逸的,要麼就別管了要麼用go以外的手段(比如彙編)解決。
  10. 求助他人也是可以的,但前提是他們不是機械式地背背八股文。

總之,遵守一些常見的規定比如在知道slice大小的情況下提前分配記憶體、設計短小精悍的函式、少用指標等等,你幾乎沒啥研究逃逸分析的必要。

對於編譯器、標準庫、某些效能要求較高的程式的開發者來說,瞭解逃逸分析是必要的。因為go的效能不是很理想,所以得抓住一切能利用的最佳化機會提升效能。比如我往標準庫塞新功能的時候就被要求過一些函式得是“零分配”的。當然我沒有上來就研究逃逸,而是先寫了測試並研究了profile,之後才用逃逸分析的結果做了更進一步的最佳化。

總結

這篇文章其實還有一些東西沒說,比如陣列和閉包在逃逸分析的表現。總體上它們的行為沒有和別的變數差太多,在看看文章的標題——所以我不建議過度關注它們的逃逸分析。

所以說,你不應該過度關心逃逸分析。也應該停止背/搬運/編寫有關逃逸分析的八股文。

大部分人關心逃逸分析,除了面試之外就是為了效能,我常說的是效能分析一定要結合profile和benchmark,否則憑空臆斷為了不逃逸而削足適履,不僅浪費時間對效能問題也沒有絲毫幫助。

話說回來,不深入瞭解逃逸分析和不知道有逃逸分析這東西可是兩回事,後者確實約等於go白學了。

相關文章