聊聊 go 中的逃逸分析

1. 從一個例子開始

下面是一段 c 代碼,函數 getStr 生成了 a-z 的串,我們分別在函數內部和 main 中對字串進行了輸出。

//例1.1
#include <stdio.h>

//返回字串
char* getStr(){
    //char數組 函數棧上分配內存
    char buf[27];
    int i;

    //產生a-z的串
    for (i=0; i<sizeof(buf)-1; i++){
        buf[i] = i + 'a';
    }
    buf[i] = '\0';

    printf("%s\n", buf);
    return buf;
}

int main(){
    char *p;
    p = getStr();
    printf("%s\n", *p);

    return 0;
}

運行結果如下:

abcdefghijklmnopqrstuvwxyz
m

如果你有一些 c 的編程經驗,那麼你一定知道產生這個結果是因爲 buf[27] 的內存是在函數棧上分配的,這段內存在函數結束後會被自動回收,所以在 main 函數中想再次輸出這個字串,就會產生一個未知的結果。我們在對上面代碼進行編譯時,編譯器也會給出 告警:

In function ‘getStr’:
warning: function returns address of local variable [-Wreturn-local-addr]

解決這個問題的方法之一 (只是一種方法,並非好的實踐) 是在函數內部使用 malloc 申請一段內存,因爲 malloc 的內存是在堆上分配的,函數返回後不會自動回收因此可以得到預期結果。代碼如下:

//例1.2
#include <stdio.h>
#include <stdlib.h>

char* getStr(){
    char *buf;
    int len = 27;
    //堆上分配內存,不會在函數結束後被自動回收
    buf = (char *) malloc(len);

    int i;
    for (i=0; i<len-1; i++){
        buf[i] = i + 'a';
    }

    buf[i] = '\0';

    printf("%s\n", buf);
    return buf;
}

int main(){
    char *p;
    p = getStr();
    printf("%s\n", p);

    //手動將堆上內存釋放
    free(p);
    return 0;
}

類似的功能,我們用 go 語言實現,可以是這樣的:

//例1.3
package main

import "fmt"

func getStr() *[26] byte{
    buf := [26]byte{}
    for i:=0; i<len(buf); i++{
        buf[i] = byte('a' + i)
    }

    return &buf
}

func main(){
    var p *[26] byte
    p = getStr();

    fmt.Printf("%s\n", *p)
}

運行結果如下:

abcdefghijklmnopqrstuvwxyz

這段程序中,我們並沒有在 getStr 中指出 buf 是要分配在堆上的,但是程序爲什麼能正確運行呢?正是因爲 go 中有逃逸分析機制。

2. 什麼是逃逸分析

函數中的一個變量,其內存是分配在堆上,還是分配在棧上?在 go 語言中,這一點是由編譯器決定的,這就是所謂的逃逸分析。例 1.3 中,go 編譯器發現 buf 對應的內存在函數返回後仍然被使用,於是自動將其分配到堆上,從而保證了程序可以正常運行。而且逃逸至堆上的內存,其回收也是由 go 的垃圾回收機制自動完成,yyds!

3. 查看逃逸的方法

假如我們的代碼是 escape.go,可以使用如下命令查看實際的逃逸情況。

//逃逸概要情況
go build -gcflags "-m" escape.go
//詳情
go build -gcflags "-m -m" escape.go

對於例 1.3 中的代碼,執行go build -gcflags "-m",得到結果如下:

# command-line-arguments
./c.go:20:15: inlining call to fmt.Printf
./c.go:6:5: moved to heap: buf
./c.go:20:24: *p escapes to heap
./c.go:20:15: []interface {} literal does not escape
<autogenerated>:1: .this does not escape

可見 buf 的確逃逸到了堆上。

4. 產生逃逸的情況

哪些情況 go 會將函數棧上的內存分配至堆上呢?官方的 FAQ(How do I know whether a variable is allocated on the heap or the stack?[1]) 裏給出了答案

When possible, the Go compilers will allocate variables that are local to a function in that function's stack frame. However, if the compiler cannot prove that the variable is not referenced after the function returns, then the compiler must allocate the variable on the garbage-collected heap to avoid dangling pointer errors. Also, if a local variable is very large, it might make more sense to store it on the heap rather than the stack.

可見逃逸的情形主要有兩大類:

4.1 函數返回後變量仍被使用的情況

  1. 由於閉包,導致函數返回後函數內變量仍被外部使用。
package main

func main() {
    f := fibonacci()
    f()
}

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

查看逃逸情況如下:

go build -gcflags "-m -l" escape.go
# command-line-arguments
./escape.go:9:5: moved to heap: a
./escape.go:9:8: moved to heap: b
./escape.go:10:12: func literal escapes to heap
  1. 返回指針
package main

type User struct {
    name string
    age int8
}

//返回指向User的指針
func NewUser() *User{
    u := User{
        name: "ball",
        age: 99,
    }

    //u對應的內存可能在外部被使用,放到堆上
    return &u
}

func main() {
}

查看逃逸情況如下:

go build -gcflags "-m  -l" escape.go
# command-line-arguments
./escape.go:9:2: moved to heap: u
  1. 返回接口
package main

type Man interface{
    Show()
}

type User struct {
    name string
    age int8
}

func (u User)Show(){
}

func NewMan() (m Man){
    u:= User{
        name: "ball",
        age: 99,
    }

    m = u

    return
}

func main() {
}

查看逃逸情況如下:

go build -gcflags "-m -l" escape.go
# command-line-arguments
./escape.go:12:7: u does not escape
./escape.go:21:4: u escapes to heap
<autogenerated>:1: .this does not escape
<autogenerated>:1: leaking param: .this

Newman 中有一個 u 到接口 m 的轉換。go 同的接口由動態值和動態類型兩部分構成,m 中的動態值指針,指向了 u(更準備的說應該是 u 的拷貝) 對應的內存,這部分是可能在函數返回後會用到的,所以只能分配在堆上。

4.2 變量過大被分配在堆上的情況

//escape.go
package main
func Slice(){
    s := make([]int64, 8192, 8192)
    s[0] = 1
}

func main() {
    Slice()
}

查看逃逸情況如下:

go build -gcflags "-m -m -l" escape.go
# command-line-arguments
./escape.go:3:11: make([]int64, 8192, 8192) escapes to heap:
./escape.go:3:11:   flow: {heap} = &{storage for make([]int64, 8192, 8192)}:
./escape.go:3:11:     from make([]int64, 8192, 8192) (too large for stack) at ./escape.go:3:11
./escape.go:3:11: make([]int64, 8192, 8192) escapes to heap

這裏由於切片長度過大(too large for stack),被分配到了棧上。如果你的好奇心比較強,可能會有如下疑問:

關於這些問題,準備後面寫一篇函數棧內存分配的文章專門說明。這裏你只要記住結論就可以。

s := make([]int64, 8191, 8191)

5. 逃逸分析可能帶來的問題

5.1 go 中內存分配在堆與棧上的不同

5.2 可能的問題

由 5.1 可知,如果過多的產生逃逸,會使更多的內存分配在堆上,其後果是 GC 的壓力比較大,這同樣可能影響代碼運行的效率。實際項目中需要進行權衡,以決定是否要避免逃逸。

我們看下面一個比較極端的例子:

benchmark.go
package gotest

func Slice(n int64){
    s := make([]int64, 8191, 8191)
    s[0] = 1
}

對應的壓測文件

//benchmark_test.go
package gotest_test

import (
    "testing"
    "gotest"
)

func BenchmarkSlice(b *testing.B){
    for i :=0; i<b.N; i++{
        gotest.Slice()
    }
}

Slice 中我們設置切片容量 8191,此時內存分配在棧上,未發生逃逸。

壓測結果

go test -bench=.

goos: linux
goarch: amd64
pkg: gotest
BenchmarkSlice-4        1000000000               0.510 ns/op
PASS
ok      gotest  0.570s

接下來,我們將切片大小改爲 8192,剛好產生逃逸,內存分配在堆上

s := make([]int64, 8192, 8192)
go test -bench=.
goos: linux
goarch: amd64
pkg: gotest
BenchmarkSlice-4           80602             13799 ns/op
PASS
ok      gotest  1.275s

可見,本例中,棧上分配內存帶了來巨大的優勢。

6. 更多逃逸的情況

第 4 節中所概括的逃逸情況只是主要場景,還有很多逃逸的情形。

6.1 變量大小不定帶來的逃逸

package main

func s(){
    n := 10
    s := make([]int32, n)
    s[0] = 1
}

func main() {
}

查看逃逸情況如下

go build -gcflags "-m -m -l" escape.go
# command-line-arguments
./escape.go:5:11: make([]int32, n) escapes to heap:
./escape.go:5:11:   flow: {heap} = &{storage for make([]int32, n)}:
./escape.go:5:11:     from make([]int32, n) (non-constant size) at ./escape.go:5:11
./escape.go:5:11: make([]int32, n) escapes to heap

編譯器給出解釋爲 non-constant size。這也可以理解,大小不定就有可能很大,爲了確保棧內存分配的高效,防禦性的把它分配到堆上,說得過去。

6.2 那些神奇的逃逸

package main

type X struct {
    p *int
}

func main() {
    var i1 int
    x1 := &X{
        p: &i1, // GOOD: i1 does not escape
    }
    *x1.p++

    var i2 int
    x2 := &X{}
    x2.p = &i2 // BAD: Cause of i2 escape
    *x2.p++
}

對 x1 的 x2 兩個的賦值,同樣的功能,只因爲寫法的不同,就造成其中一個產生了逃逸!我能說什麼呢...

go build -gcflags "-m -l" escape.go
# command-line-arguments
./escape.go:15:6: moved to heap: i2
./escape.go:9:8: &X literal does not escape
./escape.go:16:8: &X literal does not escape

對兩種方法使用 benchmark 測試,性能相差近 50 倍!所以,大家應該知道 struct 中有指針成員該怎麼進行賦值效率最高了吧。

更多匪夷所思的逃逸情況可以參看:Escape-Analysis Flaws[2]。不想啃英文的同學可以去這裏 Go 逃逸分析的缺陷 [3]

參考

[1] How do I know whether a variable is allocated on the heap or the stack?: https://golang.org/doc/faq#stack_or_heap

[2] Escape-Analysis Flaws: https://www.ardanlabs.com/blog/2018/01/escape-analysis-flaws.html

[3] Go 逃逸分析的缺陷: https://studygolang.com/articles/12396

[4] 《Go 專家編程》Go 逃逸分析: https://my.oschina.net/renhc/blog/2222104

[5] 深入理解 Go - 逃逸分析: https://segmentfault.com/a/1190000020086727

[6] Go 語言機制之內存剖析: https://studygolang.com/articles/12445

[7] 淺談接口實現原理: https://www.cnblogs.com/DongDon/p/12586212.html

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/AbJt0qgcvop43eqPDbz0Zw