Go記憶體管理逃逸分析

2023-03-15 12:01:44

1. 前言

所謂的逃逸分析(Escape analysis)是指由編譯器決定記憶體分配的位置嗎不需要程式設計師指定。


函數中申請一個新的物件

  • 如果分配在棧中, 則函數執行結束後可自動將記憶體回收
  • 如果分配在堆中, 則函數執行借宿可交給GC(垃圾回收)處理

有了逃逸分析,返回函數區域性變數將變得可能,除此之外,逃逸分析還跟閉包息息相關,瞭解哪些場景下物件會逃逸至關重要。


2. 逃逸策略

每當函數中申請新的物件,編譯器會根據該物件是否被函數外部參照來決定是否逃逸:

  1. 如果函數外部沒有參照,則優先放到棧中;
  2. 如果函數外部存在參照,則必定放到堆中;

注意,對於函數外部沒有參照的物件,也有可能放到堆中,比如記憶體過大超過棧的儲存能力。


3. 逃逸場景

3.1 指標逃逸

我們知道Go可以返回區域性變數指標,這其實是一個典型的變數逃逸案例,範例程式碼如下:

package main

type Student struct {
    Name string
    Age  int
}

func StudentRegister(name string, age int) *Student {
    s := new(Student) //區域性變數s逃逸到堆

    s.Name = name
    s.Age = age

    return s
}

func main() {
    StudentRegister("Jim", 18)
}

函數StudentRegister()內部s為區域性變數,其值通過函數返回值返回,s本身為一指標,其指向的記憶體地址不會是棧而是堆,這就是典型的逃逸案例。

通過編譯引數-gcflag=-m可以檢視編譯過程中的逃逸分析:

D:\SourceCode\GoExpert\src>go build -gcflags=-m
# _/D_/SourceCode/GoExpert/src
.\main.go:8: can inline StudentRegister
.\main.go:17: can inline main
.\main.go:18: inlining call to StudentRegister
.\main.go:8: leaking param: name
.\main.go:9: new(Student) escapes to heap
.\main.go:18: main new(Student) does not escape

可見在StudentRegister()函數中,也即程式碼第9行顯示」escapes to heap」,代表該行記憶體分配發生了逃逸現象。

3.2 棧空間不足逃逸

看下面的程式碼,是否會產生逃逸呢?

package main

func Slice() {
    s := make([]int, 1000, 1000)

    for index, _ := range s {
        s[index] = index
    }
}

func main() {
    Slice()
}

上面程式碼Slice()函數中分配了一個1000個長度的切片,是否逃逸取決於棧空間是否足夠大。
直接檢視編譯提示,如下:

D:\SourceCode\GoExpert\src>go build -gcflags=-m
# _/D_/SourceCode/GoExpert/src
.\main.go:4: Slice make([]int, 1000, 1000) does not escape

我們發現此處並沒有發生逃逸。那麼把切片長度擴大10倍即10000會如何呢?

D:\SourceCode\GoExpert\src>go build -gcflags=-m
# _/D_/SourceCode/GoExpert/src
.\main.go:4: make([]int, 10000, 10000) escapes to heap

我們發現當切片長度擴大到10000時就會逃逸。

實際上當棧空間不足以存放當前物件時或無法判斷當前切片長度時會將物件分配到堆中。

3.3 動態型別逃逸

很多函數引數為interface型別,比如fmt.Println(a …interface{}),編譯期間很難確定其引數的具體型別,也會產生逃逸。
如下程式碼所示:

package main

import "fmt"

func main() {
    s := "Escape"
    fmt.Println(s)
}

上述程式碼s變數只是一個string型別變數,呼叫fmt.Println()時會產生逃逸:

D:\SourceCode\GoExpert\src>go build -gcflags=-m
# _/D_/SourceCode/GoExpert/src
.\main.go:7: s escapes to heap
.\main.go:7: main ... argument does not escape

3.4 閉包參照物件逃逸

某著名的開源框架實現了某個返回Fibonacci數列的函數:

func Fibonacci() func() int {
    a, b := 0, 1
    return func() int {
        a, b = b, a+b
        return a
    }
}

該函數返回一個閉包,閉包參照了函數的區域性變數a和b,使用時通過該函數獲取該閉包,然後每次執行閉包都會依次輸出Fibonacci數列。
完整的範例程式如下所示:

package main

import "fmt"

func Fibonacci() func() int {
    a, b := 0, 1
    return func() int {
        a, b = b, a+b
        return a
    }
}

func main() {
    f := Fibonacci()

    for i := 0; i < 10; i++ {
        fmt.Printf("Fibonacci: %d\n", f())
    }
}

上述程式碼通過Fibonacci()獲取一個閉包,每次執行閉包就會列印一個Fibonacci數值。輸出如下所示:

D:\SourceCode\GoExpert\src>src.exe
Fibonacci: 1
Fibonacci: 1
Fibonacci: 2
Fibonacci: 3
Fibonacci: 5
Fibonacci: 8
Fibonacci: 13
Fibonacci: 21
Fibonacci: 34
Fibonacci: 55

Fibonacci()函數中原本屬於區域性變數的a和b由於閉包的參照,不得不將二者放到堆上,以致產生逃逸:

D:\SourceCode\GoExpert\src>go build -gcflags=-m
# _/D_/SourceCode/GoExpert/src
.\main.go:7: can inline Fibonacci.func1
.\main.go:7: func literal escapes to heap
.\main.go:7: func literal escapes to heap
.\main.go:8: &a escapes to heap
.\main.go:6: moved to heap: a
.\main.go:8: &b escapes to heap
.\main.go:6: moved to heap: b
.\main.go:17: f() escapes to heap
.\main.go:17: main ... argument does not escape

4 逃逸總結

  • 棧上分配記憶體比在堆中分配記憶體有更高的效率
  • 棧上分配的記憶體不需要GC處理
  • 堆上分配的記憶體使用完畢會交給GC處理
  • 逃逸分析目的是決定內分配地址是棧還是堆
  • 逃逸分析在編譯階段完成

5. 注意事項

思考一下這個問題:函數傳遞指標真的比傳值效率高嗎?
我們知道傳遞指標可以減少底層值的拷貝,可以提高效率,但是如果拷貝的資料量小,由於指標傳遞會產生逃逸,可能會使用堆,也可能會增加GC的負擔,所以傳遞指標不一定是高效的。