Go 高性能 - 逃逸分析
逃逸分析
Go 語言的編譯器使用 逃逸分析
決定哪些變量分配在棧上,哪些變量分配在堆上。
在棧上分配和回收內存很快,只需要 2 個指令: PUSH
+ POP
, 也就是僅需要將數據複製到內存的時間,而堆上分配和回收內存,一個相當大的開銷是 GC
。
特性
-
• 指向
棧
對象的指針不能分配堆
上 (避免懸掛指針) -
• 指向
棧
對象的指針在對象銷燬時必須被同時銷燬 (避免懸掛指針和內存泄露)
例如對於函數內部的變量來說,不論是否通過 new
函數創建,最後會被分配在 堆
還是 棧
,是由編譯器使用 逃逸分析
之後決定的。具體來說,當發現變量的作用域沒有超出函數範圍,分配在 棧
上,反之則必須分配在 堆
上,也就是說: 如果函數外部沒有引用,則優先分配在 棧
上, 如果函數外部存在引用,則必須分配在 堆
上。所以,閉包必然會發生逃逸。
發生場景
-
• 變量佔用內存過大 (如大的結構體)
-
• 變量佔用內存不確定 (如 鏈表, slice 導致的擴容)
-
• 變量類型不確定 (interface{})
-
• 指針類型
-
• 函數返回變量地址 (如一個結構體地址)
-
• 閉包
-
• interface
過多的變量逃逸到堆上,會增加 GC
成本,我們可以通過控制變量的分配方式,儘可能地降低 GC
成本,提高性能。
分析命令
- • 使用 go 命令
`$ go tool compile -m main.go
或者
$ go build -gcflags='-m -l' main.go`
- • 反彙編
$ go tool compile -S main.go
示例
確定的數據類型和 interface{}
如果變量類型是 interface
,那麼將會 逃逸
到堆上。函數返回值應儘量使用確定的數據類型,避免使用 interface{}
。
package main
func main() {
data := []interface{}{100, 200}
data[0] = 100
}
$ go tool compile -m main.go
# 輸出如下
main.go:3:6: can inline main
main.go:4:23: []interface {}{...} does not escape
main.go:4:24: 100 does not escape // 未發生逃逸
main.go:4:29: 200 does not escape // 未發生逃逸
main.go:5:2: 100 escapes to heap // 發生逃逸
基準測試
這裏以使用 interface{}
觸發逃逸作爲例子進行分析。
使用 interface{}
package performance
import "testing"
const size = 1024
func genSeqNumbers() interface{} {
var res [size]int
for i := 0; i < len(res); i++ {
if i <= 1 {
res[i] = 1
continue
}
res[i] = res[i-1] + res[i-2]
}
return res
}
func Benchmark_Compare(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = genSeqNumbers()
}
}
運行測試,並將基準測試結果寫入文件:
# 運行 100000 次, 統計內存分配
$ go test -run='^$' -bench=. -count=1 -benchtime=100000x -benchmem > slow.txt
使用確定的數據類型
package performance
import "testing"
const size = 1024
func genSeqNumbers() [1024]int {
var res [size]int
for i := 0; i < len(res); i++ {
if i <= 1 {
res[i] = 1
continue
}
res[i] = res[i-1] + res[i-2]
}
return res
}
func Benchmark_Compare(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = genSeqNumbers()
}
}
運行測試,並將基準測試結果寫入文件:
# 運行 100000 次, 統計內存分配
$ go test -run='^$' -bench=. -count=1 -benchtime=100000x -benchmem > fast.txt
使用 benchstat 比較差異
$ benchstat -alpha=100 fast.txt slow.txt
# 輸出如下
name old time/op new time/op delta
_Compare-8 1.44µs ± 0% 3.22µs ± 0% +123.40% (p=1.000 n=1+1)
name old alloc/op new alloc/op delta
_Compare-8 0.00B 8192.00B ± 0% +Inf% (p=1.000 n=1+1)
name old allocs/op new allocs/op delta
_Compare-8 0.00 1.00 ± 0% +Inf% (p=1.000 n=1+1)
從輸出的結果中可以看到,通過使用確定的數據類型,運行時間提升了 1 倍多
, 內存分配量和內存分配次數降爲 0
。
高性能 Tips: 在
hot path
上要儘可能返回具體的數據類型。
其他逃逸場景
接下來介紹幾種常見的逃逸場景。
切片
當切片佔用內存超過一定大小,或無法確定切片長度時,對象將分配在 堆
上。
逃逸場景
package main
func main() {
weekdays := 7
data := make(map[string][]int)
data["weeks"] = make([]int, weekdays)
data["weeks"] = append(data["weeks"], []int{0, 1, 2, 3, 4, 5, 6}...)
}
$ go tool compile -m main.go
# 輸出如下 (發生逃逸)
main.go:3:6: can inline main
main.go:5:14: make(map[string][]int) does not escape
main.go:6:22: make([]int, weekdays) escapes to heap
main.go:7:45: []int{...} does not escape
避免逃逸方案
如果切片容量較小,可以改爲使用數組,避免發生逃逸,詳情見 [切片和數組性能差異]。
package main
func main() {
data := make(map[string][7]int)
data["weeks"] = [...]int{0, 1, 2, 3, 4, 5, 6}
}
$ go tool compile -m main.go
# 輸出如下 (沒有發生逃逸)
main.go:3:6: can inline main
main.go:4:14: make(map[string][7]int) does not escape
指針
逃逸場景
package main
func main() {
n := 10
data := make([]*int, 1)
data[0] = &n
}
$ go tool compile -m main.go
# 輸出如下 (發生逃逸)
main.go:3:6: can inline main
main.go:4:2: moved to heap: n
main.go:5:14: make([]*int, 1) does not escape
interface{}
逃逸場景
package main
func main() {
data := make(map[interface{}]interface{})
data[100] = 200
}
$ go tool compile -m main.go
# 輸出如下 (發生逃逸)
main.go:3:6: can inline main
main.go:4:14: make(map[interface {}]interface {}) does not escape
main.go:5:2: 100 escapes to heap
main.go:5:2: 200 escapes to heap
函數返回值
這應該是比較常見的一種情況,在函數中創建了一個對象,返回了這個對象的指針。這種情況下,函數雖然退出了,但是因爲指針的存在, 對象的內存不能隨着函數結束而回收,因此只能分配在 堆
上。
逃逸場景
package main
import "math/rand"
func foo(argVal int) *int {
var fooVal1 = 11
var fooVal2 = 12
var fooVal3 = 13
var fooVal4 = 14
var fooVal5 = 15
// 循環是防止編譯器將 foo 函數優化爲 inline
// 如果不用隨機數指定循環次數,也可能被編譯器優化爲 inline
// 如果是內聯函數,main 調用 foo 將是原地展開
// 那麼 fooVal1 ... fooVal5 相當於 main 作用域的變量
// 即使 fooVal3 發生逃逸,地址與其他幾個變量也是連續的
n := rand.Intn(5) + 1
for i := 0; i < n; i++ {
println(&argVal, &fooVal1, &fooVal2, &fooVal3, &fooVal4, &fooVal5)
}
return &fooVal3
}
func main() {
mainVal := foo(1)
println(*mainVal, mainVal)
}
運行代碼
$ go run main.go
# 輸出如下 (發生逃逸)
0xc000114f58 0xc000114f38 0xc000114f30 0xc000120000 0xc000114f28 0xc000114f20
0xc000114f58 0xc000114f38 0xc000114f30 0xc000120000 0xc000114f28 0xc000114f20
13 0xc000120000
通過輸出的結果可以看到,變量 fooVal3
的地址明顯與其他變量地址不是連續的。
結果分析
$ go tool compile -m main.go
# 輸出如下 (發生逃逸)
main.go:16:16: inlining call to rand.Intn
main.go:24:6: can inline main
main.go:8:6: **moved to heap: fooVal3**
# 查看逃逸分析詳情
$ go build -gcflags='-m -l' main.go
# 輸出如下 (發生逃逸)
./main.go:5:6: cannot inline foo: function too complex: cost 124 exceeds budget 80
...
./main.go:8:6: fooVal3 escapes to heap:
...
./main.go:8:6: **moved to heap: fooVal3**
# 或者
$ go tool compile -S main.go | grep "runtime.newobject"
# 輸出如下
0x0036 00054 (**main.go:8**) CALL runtime.newobject(SB)
rel 55+4 t=7 runtime.newobject+0
# main.go 第 8 行正好是 var fooVal3 = 13
通道
逃逸場景
package main
func main() {
ch := make(chan string)
s := "hello world"
go func() {
ch <- s
}()
<-ch
}
$ go tool compile -m main.go
# 輸出如下 (發生逃逸)
main.go:19:5: can inline main.func1
main.go:19:5: func literal escapes to heap
閉包
一個函數和對其周圍狀態(lexical environment,詞法環境)的引用捆綁在一起(或者說函數被引用包圍),這樣的組合就是閉包(closure)。
簡單來說,閉包可以在一個函數內部訪問到其外部函數的作用域。
逃逸場景
package main
func inc() func() int {
n := 0
return func() int {
n++
return n
}
}
func main() {
in := inc()
println(in()) // 1
println(in()) // 2
}
$ go tool compile -m main.go
# 輸出如下 (發生逃逸)
main.go:3:6: can inline inc
main.go:5:9: can inline inc.func1
main.go:12:11: inlining call to inc
main.go:5:9: can inline main.func1
main.go:13:12: inlining call to main.func1
main.go:14:12: inlining call to main.func1
main.go:4:2: **moved to heap: n**
main.go:5:9: func literal escapes to heap
main.go:12:11: func literal does not escape
inc()
函數返回一個閉包函數,閉包函數內部訪問了外部變量 n
, 形成了引用關係,那麼外部變量 n
將會一直存在,直到 inc()
函數被銷燬, 所以最終外部變量 n
被分配到了 堆
上。
佔用過大空間
操作系統對內核線程使用的棧空間是有大小限制的,64 位系統上通常是 8 MB。
# 查看系統棧內存大小
$ ulimit -s
# 8192
因爲棧空間通常比較小,因此遞歸函數實現不當時,容易導致棧溢出。對於 Go 語言來說,運行時 (runtime) 嘗試在 goroutine 需要的時候動態地分配棧空間, goroutine 的初始棧大小爲 2 KB。當 goroutine 被調度時,會綁定內核線程執行,棧空間大小也不會超過操作系統的限制。對 Go 編譯器而言, 超過一定大小的局部變量將逃逸到 堆
上,不同的 Go 版本的大小限制可能不一樣。
逃逸場景
package main
import "math/rand"
func generate8192() {
nums := make([]int, 8192) // = 64KB
for i := 0; i < 8192; i++ {
nums[i] = rand.Int()
}
}
func generate8193() {
nums := make([]int, 8193) // < 64KB
for i := 0; i < 8193; i++ {
nums[i] = rand.Int()
}
}
func generate(n int) {
nums := make([]int, n) // 不確定大小
for i := 0; i < n; i++ {
nums[i] = rand.Int()
}
}
func main() {
generate8192()
generate8193()
generate(1)
}
$ go tool compile -m main.go
# 輸出如下 (發生逃逸)
main.go:6:14: make([]int, 8192) does not escape
main.go:13:14: make([]int, 8193) escapes to heap
main.go:20:14: make([]int, n) escapes to heap
從輸出的結果中可以看到,make([]int, 8192)
沒有發生逃逸,make([]int, 8193)
和 make([]int, n)
逃逸到 堆
上, 說明當切片佔用內存超過一定大小,或無法確定當前切片長度時,對象將分配到 堆
上。
擴展閱讀
返回值和返回指針
值傳遞
會拷貝整個對象,而 指針傳遞
只會拷貝地址,指向的對象是同一個。返回指針可以減少值的拷貝,但是會導致內存分配逃逸到堆中, 增加 GC
的負擔。在對象頻繁創建和刪除的場景下,指針傳遞導致的 GC
開銷可能會嚴重影響性能。
一個通用的實踐是: 對於需要修改原對象值,或佔用內存比較大的結構體,返回指針,其他情況直接返回值。
避免逃逸
在瞭解了 逃逸
發生的具體規則和場景後,我們可以通過對應的規則來避免逃逸,此外,也可以參考標準庫中避免逃逸的方法。
標準庫源碼
// Go 1.19 $GOROOT/src/strings/builder.go:27
// noescape hides a pointer from escape analysis. It is the identity function
// but escape analysis doesn't think the output depends on the input.
// noescape is inlined and currently compiles down to zero instructions.
// USE CAREFULLY!
// This was copied from the runtime; see issues 23382 and 7921.
//
//go:nocheckptr
func noescape(p unsafe.Pointer) unsafe.Pointer {
x := uintptr(p)
return unsafe.Pointer(x ^ 0)
}
noescape
通過一個無用的位運算,解除了參數與返回值的關聯,切斷了 逃逸分析
對指針的追蹤,類似代碼標準庫中有很多,但是正如註釋中所寫, 該方法要謹慎使用,除非明確了逃逸是性能瓶頸。
# 查看標準庫切斷逃逸分析相關代碼
$ grep -nr "func noescape" "$(dirname $(which go))/../src"
小結
本文介紹了幾種常見的逃逸場景,並且針對其中的常見的逃逸發生場景做了詳細的分析,感興趣的讀者可以採用本文提供的分析方法,看看自己的項目中有哪些對象逃逸場景。
在大多數情況下,關於變量應該分配到堆上還是棧上,Go 編譯器已經優化的足夠好,無需開發者刻意優化。爲了保證絕對的內存安全, 編譯器可能會將一些變量錯誤地分配到堆上,但是最終 GC
會避免內存泄露以及懸掛指針等安全問題,降低開發者心智負擔,提高開發效率。
Reference
-
• 極客兔兔 [1]
-
• escape.go[2]
-
• akutz/lem[3]
-
• Go: Introduction to the Escape Analysis[4]
引用鏈接
[1]
極客兔兔: https://geektutu.com/post/hpg-escape-analysis.html
[2]
escape.go: https://tip.golang.org/src/cmd/compile/internal/escape/escape.go
[3]
akutz/lem: https://github.com/akutz/lem
[4]
Go: Introduction to the Escape Analysis: https://medium.com/a-journey-with-go/go-introduction-to-the-escape-analysis-f7610174e890
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/q_F-9NQWycRLvQpACUl7gQ