Go 高性能 - 逃逸分析

逃逸分析

Go 語言的編譯器使用 逃逸分析 決定哪些變量分配在棧上,哪些變量分配在堆上

在棧上分配和回收內存很快,只需要 2 個指令: PUSH + POP, 也就是僅需要將數據複製到內存的時間,而堆上分配和回收內存,一個相當大的開銷是 GC

特性

例如對於函數內部的變量來說,不論是否通過 new 函數創建,最後會被分配在  還是 ,是由編譯器使用 逃逸分析 之後決定的。具體來說,當發現變量的作用域沒有超出函數範圍,分配在  上,反之則必須分配在  上,也就是說: 如果函數外部沒有引用,則優先分配在  上, 如果函數外部存在引用,則必須分配在  上。所以,閉包必然會發生逃逸

發生場景

過多的變量逃逸到堆上,會增加 GC 成本,我們可以通過控制變量的分配方式,儘可能地降低 GC 成本,提高性能。

分析命令

`$ 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] 極客兔兔: 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