【譯】Go: 逃逸分析介紹
本文基於 Go 1.13
逃逸分析是 Go 編譯器的一部分。它分析原始碼並確定哪些變數應該分配到棧上、哪些逃逸到堆上。
靜態分析
Go 在編譯階段就定義了什麼應該在堆,什麼應該在棧上。在 go run
或 go build
時加上 -gcflags="-m"
就可以得到分析結果。
這裡有個簡單的例子:
func main() {
num := getRandom()
println(*num)
}
//go:noinline
func getRandom() *int {
tmp := rand.Intn(100)
return &tmp
}
逃逸分析告訴我們 tmp
逃逸到了堆上
./main.go:12:2: moved to heap: tmp
靜態分析的第一步時構建原始碼的抽象語法樹,允許 Go 理解在哪裡進行賦值和分配,以及變數定址和解引用。
下面是上一份程式碼的示例:
然而,為了放便分析,我們去掉 AST 的不相關資訊,可以得到一個簡單的版本
由於數公開了定義的變數(NAME 表示)和對指標的操作(ADDR 或 DEREF 表示),因此它將所有資訊提供給 Go 執行逃逸分析,一旦樹被構建並解析了函式和引數,Go 就可以使用逃逸分析邏輯檢視應該給哪些分配堆和棧。
存活時間超過棧幀
在執行逃逸分析並從 AST 圖中遍歷函式時,Go 會查詢比當前棧幀存活時間更長的變數,因此這些變數會分配到堆上。首先我們定義什麼是 outlive
那麼,當函式 getRandom
返回, 此函式建立的棧失效時,任何在函式棧上建立的變數都無法訪問。
在這種情況下,變數 num 不能指向在前一個棧上分配的變數。在這個例子中, Go 必須將變數分配到堆上,確保它比棧幀活的長:
變數 tmp
包含了分配到棧上的記憶體地址,並且可以安全的從一個棧複製到另一個棧。然而,返回值並不是唯一可以 outlive
的值,它們的規則如下:
-
任何返回值都超過該函式的壽命,因為被呼叫的函式不知道該值
-
在迴圈外宣告的變數比迴圈內活的更久
func main() { var l *int for i := 0; i < 10; i++ { l = new(int) *l = i } println(*l) } ./main.go:6:10: new(int) escapes to heap
-
在閉包外部宣告的變數比在閉包內部賦值活的更久:
func main() { var l *int func() { l = new(int) *l = 1 }() println(*l) } ./main.go:8:3: new(int) escapes to heap
逃逸分析的第二部分包括確定它如何操作指標,幫助理解哪些內容可能留在棧上。
定址和解引用
構建表示定址/解引用計數的加權圖能讓 Go 優化棧的分配。讓我們分析一個例子來理解它是如何工作的:
func main() {
n := getAnyNumber()
println(*n)
}
//go:noinline
func getAnyNumber() *int {
l := new(int)
*l = 42
m := &l
n := &m
o := **n
return o
}
./main.go:10:10: new(int) escapes to heap
這裡是生成的 AST 簡單版本
Go 通過構建加權圖來定義分配。每次解引用(* 或 DEREF 表示) 權重增加 1,每次定址操作(& 或 ADDR 表示)權重減去1。
下面是通過逃逸分析的順序定義:
variable o has a weight of 0, o has an edge to n
variable n has a weight of 2, n has an edge to m
variable m has a weight of 1, m has an edge to l
variable l has a weight of 0, l has an edge to new(int)
variable new(int) has a weight of -1
每個以負數結束的變數如果超過當前棧幀壽命就會逃逸到堆上。返回值的壽命超過其函式的棧幀,並且通過計算得到了負值,就會分配到堆上。
通過構建這個圖,Go可以瞭解哪些變數應該留在棧上,儘管它比棧活的長。
下面是另一個基本的例子:
func main() {
num := func1()
println(*num)
}
//go:noinline
func func1() *int {
n1 := func2()
*n1++
return n1
}
//go:noinline
func func2() *int {
n2 := rand.Intn(99)
return &n2
}
./main.go:20:2: moved to heap: n2
變數 n1 存活的比棧幀長, 但是它的權重不是負數,因為 func1 在任何地方都不指向它的地址,然而, n2 一直存活並被解引用,Go 可以安全的將它分配到堆上。