1. 程式人生 > 其它 >go 變數逃逸分析

go 變數逃逸分析


0. 前言

小白學標準庫之 reflect 篇中介紹了反射的三大法則以及變數的逃逸分析。對於逃逸分析的介紹不多,大部分都是引自 Go 逃逸分析。不過後來看反射原始碼的過程中發現有一種情況 Go 逃逸分析 沒講透。且當時沒從底層彙編的角度去看,導致有種似懂非懂的感覺。這裡就變數逃逸內容進行介紹。

1. 逃逸分析案例

這裡的案例不同於 Go 逃逸分析,當然所屬情況是其中概括的幾種型別。

1.1 全域性變數和區域性變數

示例程式碼:

var a int

func main() {
	x := 10
	a = x
	println(a, x)
}

程式碼非常簡單,定義全域性變數 a 和區域性變數 x,然後呼叫 println 列印 a 和 x。

使用 go tool compile 檢視編譯情況,注意變數逃逸在編譯階段,而不是執行時確定的。所以這裡用 go tool compile 是能確定變數逃逸情況的:

// -m 檢視變數逃逸情況
$ go tool compile -m escape.go 
escape.go:5:6: can inline main
escape.go:39:6: can inline escapes
escape.go:39:14: leaking param: x

// 使用 -l 關閉函式內聯
$ go tool compile -m -l escape.go 
escape.go:39:14: leaking param: x

列印 leaking param: x 表明 x 程式碼中並未對 x 做任何引用操作,x 是一個洩露引數。不過,對於變數逃逸分析不影響,從結果來看,全域性變數和區域性變數都是在棧上分配的。

進一步思考,為什麼全域性變數會在棧上分配呢?
因為對全域性變數賦值是傳值的,傳值就意味著這個值不是原有值,是值的拷貝。所以原有值不需要逃逸到堆上,只需要在棧上做變數拷貝就行。

改寫示例程式碼如下:

var a *int

func main() {
	x := 10
	a = &x
	println(a, x)
}

將全域性變數改為全域性指標型別變數,指標指向區域性變數 x。檢視變數逃逸情況:

$ go tool compile -l -m escape.go 
escape.go:7:2: moved to heap: x

可以看到,變數 x 被 moved 到堆中。不難理解,全域性變數指向 x,如果 x 不移到堆中,當 x 釋放時,其它函式通過全域性變數 a 找不到 x 了。事實上這是 c/c++ 語言會出現的情況。

通過彙編程式碼也能驗證這點:

$ go tool compile -N -S -l escape.go 
...
CALL    runtime.newobject(SB)

繼續改寫上述程式碼:

func main() {
	x := 10
	a := &x
	println(a, x)
}

這裡用一個區域性指標型別變數 a 指向 x,檢視變數分配情況:

$ go tool compile -l -m escape.go

可以看到,變數 x 和 a 都是在棧上分配的。編譯器檢查到 a 是個指標型別變數並不會被外部作用域引用,可以將 x 放在棧上分配。

1.2 interface{} 型變數逃逸

go 介面學習筆記 中介紹了介面型別的表示。

對於 interface{} 型別的執行時表示為 runtime.eface:

type eface struct {
	_type *_type
	data  unsafe.Pointer
}

這是空介面的執行時表示,對於編譯階段用於反射的空介面表示是 reflect.emptyInterface:

type emptyInterface struct {
	typ  *rtype
	word unsafe.Pointer
}

知道了 interface{} 的反射表示,我們看示例程式碼:

func main() {
	var a int = 10
	var ai interface{} = a
	println(ai)
}

逃逸分析:

$ go tool compile -l -m escape.go

改寫示例程式碼:

func main() {
	var a int = 10
	var ai interface{} = &a
	println(ai)
}

逃逸分析:

$ go tool compile -l -m escape.go

可以看到,對於區域性變數 interface{} 型別變數轉換,不管是賦值還是赴地址都沒有變數逃逸。這裡發生了什麼其實和上一節的區域性變數一樣,就不過多分析了。

值得提的一點是,給 interface{} 傳地址,結構體的 word 將指向地址,而給 interface{} 傳值,結構體的 word 是一個指標,將指向值所在的記憶體地址。這裡由於是區域性變數,這個變數值 a 是在棧上分配的,結構體 word 指向的是棧上值所在的地址。

再改寫示例程式碼 1:

var ai interface{}

func main() {
	var a int = 10
	ai = a
	println(ai)
}

逃逸分析:

$ go tool compile -l -m escape.go 
escape.go:9:5: a escapes to heap

示例程式碼 2:

var ai interface{}

func main() {
	var a int = 10
	ai = &a
	println(ai)
}

逃逸分析:

$ go tool compile -l -m escape.go 
escape.go:8:6: moved to heap: a

可以看到,對於全域性變數 ai 不管是傳值還是傳地址,變數 a 都將逃逸到堆中。為什麼會這樣也好理解:interface{} 反射的結構體表示是指標 data: unsafe.Pointer

通過彙編程式碼看傳值的例子:

$ go tool compile -N -S -l escape.go 
CALL    runtime.convT64(SB)

重點看 runtime.convT64 函式,該函式會在堆上分配記憶體。詳細看這裡,不在展開了。

1.3 反射

示例程式碼如下:

func main() {
	var a int = 10
	var ai interface{} = a
	fmt.Println(ai)
}

逃逸分析:

$ go tool compile -l -m escape.go 
escape.go:11:13: ... argument does not escape
escape.go:11:13: a escapes to heap

這裡程式碼除了 fmt.Println 改動基本和 1.2 節程式碼一樣,為什麼這時候 a 就逃到堆上了呢?

原因肯定在於 fmt.Println 函式,檢視函式我們發現程式碼會走到 escapes(i) 這裡,escapes 的函式實現是:

// Dummy annotation marking that the value x escapes,
// for use in cases where the reflect code is so clever that
// the compiler cannot follow.
func escapes(x interface{}) {
	if dummy.b {
		dummy.x = x
	}
}

var dummy struct {
	b bool
	x interface{}
}

再解釋這段實現之前,先看下為什麼要用 escapes(i) 函式:

// TODO: Maybe allow contents of a Value to live on the stack.
// For now we make the contents always escape to the heap. It
// makes life easier in a few places (see chanrecv/mapassign
// comment below).

comment:
Note: some of the noescape annotations below are technically a lie, 
but safe in the context of this package. Functions like chansend 
and mapassign don't escape the referent, but may escape anything 
the referent points to (they do shallow copies of the referent).
It is safe in this package because the referent may only point 
to something a Value may point to, and that is always in the 
heap (due to the escapes() call in ValueOf).

說白了,不用 escapes() 會讓編譯器很麻煩,這裡涉及到 noescape,詳細瞭解可看這裡

escapes 實際上是一種欺騙行為,欺騙編譯器使得編譯器將變數逃逸到堆中。怎麼欺騙的呢?其實和結合上兩節分析,基本能看出來了。

在變數 escapes(i) 到 escapes(x interface{}) 時發生了型別轉換,將 i 轉換為 interface{} 型別,實際做的就是 1.2 節描述的行為。然後,重點在 dummy.x == x,全域性變數 dummy.x 會引用轉換的介面 x,由於 dummy.x 是一個 interface{} 型別,其實質是一個指標,所以編譯器會將 interface x 中 data 指向的變數 i 分配到堆中。這裡注意 i 可以是值也可以是地址,如果是地址,編譯器會將地址指向的值分配到堆中。

可能描述起來較為複雜,複雜的原因是 interface{} 做了好幾層包裝。我們拆開包裝,用一種簡化方式看程式碼的欺騙行為:

var a *int

func main() {
	x := 10

	var f bool
	if f {
		a = &x
	}
}

逃逸分析:

$ go tool compile -l -m escape.go 
escape.go:33:2: moved to heap: x

可以看到,騙過了編譯器使得變數 x 逃逸到了堆上,雖然 a = &x 不會執行。

1.4 總結

本篇文章通過幾個逃逸分析案例重點分析 escapes 函式是如果做到欺騙編譯器實現變數逃逸的。