1. 程式人生 > 實用技巧 >GoLang中的逃逸分析簡介

GoLang中的逃逸分析簡介

微信公眾號:[double12gzh]
關注容器技術、關注Kubernetes。問題或建議,請公眾號留言。

本篇文章基於GoLang 1.13.

逃逸分析是GoLang編譯器中的一個階段,它通過分析使用者原始碼,決定哪些變數應該在堆疊上分配,哪些變數應該逃逸到堆中。

靜態分析

Go靜態地定義了在編譯階段應該被堆或棧分配的內容。當編譯(go build)和/或執行(go run)你的程式碼時,可以通過標誌-gcflags="-m "進行分析。下面是一個簡單的例子。

package main

import "fmt"

func main() {
   num := GenerateRandomNum()
   fmt.Println(*num)
}

//go:noinline
func GenerateRandomNum() *int {
   tmp := rand.Intn(500)

   return &tmp
}

執行逃逸分析,具體命令如下:

F:\hello>go build -gcflags="-m" main.go
# command-line-arguments
.\main.go:15:18: inlining call to rand.Intn
.\main.go:10:13: inlining call to fmt.Println
.\main.go:15:2: moved to heap: tmp
.\main.go:10:14: *num escapes to heap
.\main.go:10:13: []interface {} literal does not escape
<autogenerated>:1: .this does not escape
<autogenerated>:1: .this does not escape

從上面的結果.\main.go:15:2: moved to heap: tmp中我們發現tmp逃逸到了堆中。

靜態分析的第一步是生成原始碼的抽象語法樹(具體命令:go build -gcflags="-m -m -m -m -m -W -W" main.go),讓GoLang瞭解在哪裡進行了賦值和分配,以及變數的定址和解引用。

下面是之前程式碼生成的抽象語法樹的一個例子:

關於抽象語法樹請參考: package ast, ast example

為了簡化分析, 下面我給出了一個簡化版的抽象語法樹的結果:

由於該樹暴露了定義的變數(用NAME表示)和對指標的操作(用ADDR

DEREF表示),故它可以向GoLang提供進行逃逸分析所需要的所有資訊。一旦建立了樹,並解析了函式和引數,GoLang現在就可以應用逃逸分析邏輯來檢視哪些應該是堆或棧分配的。

超過堆疊框架的生命週期

在執行逃逸分析並從AST圖中遍歷函式(即: 標記)的同時,Go會尋找那些超過當前棧框架並因此需要進行堆分配的變數。假設沒有堆分配,在這個基礎上,通過前面例子的棧框架來表示,我們先來定義一下outlive的含義。下面是呼叫這兩個函式時,堆疊向下生長的情況。

在這種情況下,變數num不能指向之前堆上分配的變數。在這種情況下,Go必須在上分配變數,確保它的生命週期超過堆疊框架的生命週期。

變數tmp現在包含了分配給堆疊的記憶體地址,可以安全地從一個堆疊框架複製到另一個堆疊框架。然而,並不是只有返回的值才會失效。下面是規則:

  • 任何返回的值都會超過函式的生命週期,因為被呼叫的函式不知道這個值。
  • 在迴圈外宣告的變數在迴圈內的賦值後會失效。如下面的例子:
package main

func main() {
   var l *int
   for i := 0; i < 10; i++ {
      l = new(int)
      *l = i
   }
   println(*l)
}

./main.go:8:10: new(int) escapes to heap

  • 在閉包外宣告的變數在閉包內的賦值後失效。
package main

func main() {
   var l *int
   func() {
      l = new(int)
      *l = 1
   }()
   println(*l)
}

./main.go:10:3: new(int) escapes to heap

逃逸分析的第二部分包括確定它是如何操作指標的,幫助瞭解哪些東西可能會留在堆疊上。

定址和解引用

構建一個表示定址/引用次數的加權圖,可以讓Go優化堆疊分配。讓我們分析一個例子來了解它是如何工作的:

package main

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:12: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 可以安全地在堆上分配它。