# go 逃逸分析

先看代码

package main
func foo(arg_val int)(*int) {
    var foo_val int = 11;
    return &foo_val;
}
func main() {
    main_val := foo(666)
    println(*main_val)
}

编译运行

$ go run pro_1.go 
11

没有报错。正常 C/C++ 都会报错,因为外部函数使用了子函数的局部变量,局部变量超过生命周期就会被销毁。

# Golang 编译器得逃逸分析

go 语言编译器会自动决定把一个变量放在栈还是放在堆,编译器会做逃逸分析 (escape analysis)当发现变量的作用域没有跑出函数范围,就可以在栈上,反之则必须分配在堆

go 语言声称这样可以释放程序员关于内存的使用限制,更多的让程序员关注于程序功能逻辑本身。

再看代码

package main
func foo(arg_val int) (*int) {
    var foo_val1 int = 11;
    var foo_val2 int = 12;
    var foo_val3 int = 13;
    var foo_val4 int = 14;
    var foo_val5 int = 15;
    // 此处循环是防止 go 编译器将 foo 优化成 inline (内联函数)
    // 如果是内联函数,main 调用 foo 将是原地展开,所以 foo_val1-5 相当于 main 作用域的变量
    // 即使 foo_val3 发生逃逸,地址与其他也是连续的
    for i := 0; i < 5; i++ {
        println(&arg_val, &foo_val1, &foo_val2, &foo_val3, &foo_val4, &foo_val5)
    }
    // 返回 foo_val3 给 main 函数
    return &foo_val3;
}
func main() {
    main_val := foo(666)
    println(*main_val, main_val)
}
$ go run pro_2.go 
0xc000030758 0xc000030738 0xc000030730 0xc000082000 0xc000030728 0xc000030720
0xc000030758 0xc000030738 0xc000030730 0xc000082000 0xc000030728 0xc000030720
0xc000030758 0xc000030738 0xc000030730 0xc000082000 0xc000030728 0xc000030720
0xc000030758 0xc000030738 0xc000030730 0xc000082000 0xc000030728 0xc000030720
0xc000030758 0xc000030738 0xc000030730 0xc000082000 0xc000030728 0xc000030720
13 0xc000082000

我们能看到 foo_val3 是返回给 main 的局部变量,其中他的地址应该是 0xc000082000 , 很明显与其他的 foo_val1、2、3、4 不是连续的.

go tool compile 测试一下

$ go tool compile -m pro_2.go
pro_2.go:24:6: can inline main
pro_2.go:7:9: moved to heap: foo_val3

果然,在编译的时候, foo_val3 具有被编译器判定为逃逸变量,将 foo_val3 放在堆中开辟.

我们在用汇编证实一下:

$ go tool compile -S pro_2.go > pro_2.S

打开 pro_2.S 文件,搜索 runtime.newobject 关键字

...
 16     0x0021 00033 (pro_2.go:5)   PCDATA  $0, $0
 17     0x0021 00033 (pro_2.go:5)   PCDATA  $1, $0
 18     0x0021 00033 (pro_2.go:5)   MOVQ    $11, "".foo_val1+48(SP)
 19     0x002a 00042 (pro_2.go:6)   MOVQ    $12, "".foo_val2+40(SP)
 20     0x0033 00051 (pro_2.go:7)   PCDATA  $0, $1
 21     0x0033 00051 (pro_2.go:7)   LEAQ    type.int(SB), AX
 22     0x003a 00058 (pro_2.go:7)   PCDATA  $0, $0
 23     0x003a 00058 (pro_2.go:7)   MOVQ    AX, (SP)
 24     0x003e 00062 (pro_2.go:7)   CALL    runtime.newobject(SB)  //foo_val3是被new出来的
 25     0x0043 00067 (pro_2.go:7)   PCDATA  $0, $1
 26     0x0043 00067 (pro_2.go:7)   MOVQ    8(SP), AX
 27     0x0048 00072 (pro_2.go:7)   PCDATA  $1, $1
 28     0x0048 00072 (pro_2.go:7)   MOVQ    AX, "".&foo_val3+56(SP)
 29     0x004d 00077 (pro_2.go:7)   MOVQ    $13, (AX)
 30     0x0054 00084 (pro_2.go:8)   MOVQ    $14, "".foo_val4+32(SP)
 31     0x005d 00093 (pro_2.go:9)   MOVQ    $15, "".foo_val5+24(SP)
 32     0x0066 00102 (pro_2.go:9)   XORL    CX, CX
 33     0x0068 00104 (pro_2.go:15)  JMP 252
 ...

看出来,foo_val3 是被 runtime.newobject() 在堆空间开辟的,而不是像其他几个是基于地址偏移的开辟的栈空间.

# new 的变量在哪?

那么对于 new 出来的变量,是一定在 heap 中开辟的吗,我们来看看

package main
func foo(arg_val int) (*int) {
    var foo_val1 * int = new(int);
    var foo_val2 * int = new(int);
    var foo_val3 * int = new(int);
    var foo_val4 * int = new(int);
    var foo_val5 * int = new(int);
    // 此处循环是防止 go 编译器将 foo 优化成 inline (内联函数)
    // 如果是内联函数,main 调用 foo 将是原地展开,所以 foo_val1-5 相当于 main 作用域的变量
    // 即使 foo_val3 发生逃逸,地址与其他也是连续的
    for i := 0; i < 5; i++ {
        println(arg_val, foo_val1, foo_val2, foo_val3, foo_val4, foo_val5)
    }
    // 返回 foo_val3 给 main 函数
    return foo_val3;
}
func main() {
    main_val := foo(666)
    println(*main_val, main_val)
}

我们将 foo_val1-5 全部用 new 的方式来开辟,编译运行看结果

$ go run pro_3.go 
666 0xc000030728 0xc000030720 0xc00001a0e0 0xc000030738 0xc000030730
666 0xc000030728 0xc000030720 0xc00001a0e0 0xc000030738 0xc000030730
666 0xc000030728 0xc000030720 0xc00001a0e0 0xc000030738 0xc000030730
666 0xc000030728 0xc000030720 0xc00001a0e0 0xc000030738 0xc000030730
666 0xc000030728 0xc000030720 0xc00001a0e0 0xc000030738 0xc000030730
0 0xc00001a0e0

很明显, foo_val3 的地址 0xc00001a0e0 依然与其他的不是连续的。依然具备逃逸行为.

# 逃逸规则

我们其实都知道一个普遍的规则,就是如果变量需要使用堆空间,那么他就应该进行逃逸。但是实际上 Golang 并不仅仅把逃逸的规则如此泛泛。Golang 会有很多场景具备出现逃逸的现象。

一般我们给一个引用类对象中的引用类成员进行赋值,可能出现逃逸现象。可以理解为访问一个引用对象实际上底层就是通过一个指针来间接的访问了,但如果再访问里面的引用成员就会有第二次间接访问,这样操作这部分对象的话,极大可能会出现逃逸的现象。

Go 语言中的引用类型有 func(函数类型),interface(接口类型),slice(切片类型),map(字典类型),channel(管道类型),*(指针类型)等。

那么我们下面的一些操作场景是产生逃逸的。

# 指针逃逸

指针逃逸应该是最容易理解的一种情况了,即在函数中创建了一个对象,返回了这个对象的指针。这种情况下,函数虽然退出了,但是因为指针的存在,对象的内存不能随着函数结束而回收,因此只能分配在堆上。

// main_pointer.go
package main
import "fmt"
type Demo struct {
	name string
}
func createDemo(name string) *Demo {
	d := new(Demo) // 局部变量 d 逃逸到堆
	d.name = name
	return d
}
func main() {
	demo := createDemo("demo")
	fmt.Println(demo)
}

这个例子中,函数 createDemo 的局部变量 d 发生了逃逸。d 作为返回值,在 main 函数中继续使用,因此 d 指向的内存不能够分配在栈上,随着函数结束而回收,只能分配在堆上。

# interface {} 动态类型逃逸

在 Go 语言中,空接口即 interface{} 可以表示任意的类型,如果函数参数为 interface{} ,编译期间很难确定其参数的具体类型,也会发生逃逸。

例如上面例子中的局部变量 demo

func main() {
	demo := createDemo("demo")
	fmt.Println(demo)
}
./main_pointer.go:18:13: demo escapes to heap

demo 是 main 函数中的一个局部变量,该变量作为实参传递给 fmt.Println() ,但是因为 fmt.Println() 的参数类型定义为 interface{} ,因此也发生了逃逸。

fmt 包中的 Println 函数的定义如下:

func Println(a ...interface{}) (n int, err error) {
	return Fprintln(os.Stdout, a...)
}

如果我们将上面的例子修改为:

func test(demo *Demo) {
	fmt.Println(demo.name)
}
func main() {
	demo := createDemo("demo")
	test(demo)
}

这种情况下,局部变量 demo 不会发生逃逸,但是 demo.name 仍旧会逃逸。

# 栈空间不足

操作系统对内核线程使用的栈空间是有大小限制的,64 位系统上通常是 8 MB。可以使用 ulimit -a 命令查看机器上栈允许占用的内存的大小。

$ ulimit -a
-s: stack size (kbytes)             8192
-n: file descriptors                12800
...

因为栈空间通常比较小,因此递归函数实现不当时,容易导致栈溢出。

对于 Go 语言来说,运行时 (runtime) 尝试在 goroutine 需要的时候动态地分配栈空间,goroutine 的初始栈大小为 2 KB。当 goroutine 被调度时,会绑定内核线程执行,栈空间大小也不会超过操作系统的限制。

对 Go 编译器而言,超过一定大小的局部变量将逃逸到堆上,不同的 Go 版本的大小限制可能不一样。我们来做一个实验:

func generate8191() {
	nums := make([]int, 8191) // < 64KB
	for i := 0; i < 8191; i++ {
		nums[i] = rand.Int()
	}
}
func generate8192() {
	nums := make([]int, 8192) // = 64KB
	for i := 0; i < 8192; 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() {
	generate8191()
    generate8192()
    generate(1)
}
  • generate8191() 创建了大小为 8191 的 int 型切片,恰好小于 64 KB (64 位机器上,int 占 8 字节),不包含切片内部字段占用的内存大小。
  • generate8192() 创建了大小为 8192 的 int 型切片,恰好占用 64 KB。
  • generate(n) ,切片大小不确定,调用时传入。

编译结果如下:

$ go build -gcflags=-m main_stack.go
# command-line-arguments
./main_stack.go:9:14: generate8191 make([]int, 8191) does not escape
./main_stack.go:16:14: make([]int, 8192) escapes to heap
./main_stack.go:23:14: make([]int, n) escapes to heap

make([]int, 8191) 没有发生逃逸, make([]int, 8192)make([]int, n) 逃逸到堆上,也就是说,当切片占用内存超过一定大小,或无法确定当前切片长度时,对象占用内存将在堆上分配。

# 闭包

例如:

func Increase() func() int {
	n := 0
	return func() int {
		n++
		return n
	}
}
func main() {
	in := Increase()
	fmt.Println(in()) // 1
	fmt.Println(in()) // 2
}

Increase() 返回值是一个闭包函数,该闭包函数访问了外部变量 n,那变量 n 将会一直存在,直到 in 被销毁。很显然,变量 n 占用的内存不能随着函数 Increase() 的退出而回收,因此将会逃逸到堆上。

$ go build -gcflags=-m main_closure.go 
# command-line-arguments
./main_closure.go:6:2: moved to heap: n

# 逃逸范例

逃逸范例一

[]interface{} 数据类型,通过 [] 赋值必定会出现逃逸。

package main
func main() {
    data := []interface{}{100, 200}
    data[0] = 100
}
$ go tool compile -m 1.go
1.go:3:6: can inline main
1.go:4:23: []interface {}{...} does not escape
1.go:4:24: 100 does not escape
1.go:4:29: 200 does not escape
1.go:6:10: 100 escapes to heap

我们能看到, data[0] = 100 发生了逃逸现象。

逃逸范例二

map[string]interface{} 类型尝试通过赋值,必定会出现逃逸。

package main
func main() {
    data := make(map[string]interface{})
    data["key"] = 200
}
$ go tool compile -m 2.go
2.go:3:6: can inline main
2.go:4:14: make(map[string]interface {}) does not escape
2.go:6:14: 200 escapes to heap

我们能看到, data["key"] = 200 发生了逃逸。

逃逸范例三

map[interface{}]interface{} 类型尝试通过赋值,会导致 key 和 value 的赋值,出现逃逸。

package main
func main() {
    data := make(map[interface{}]interface{})
    data[100] = 200
}
$ go tool compile -m 3.go
3.go:3:6: can inline main
3.go:4:14: make(map[interface {}]interface {}) does not escape
3.go:6:6: 100 escapes to heap
3.go:6:12: 200 escapes to heap

我们能看到, data[100] = 200 中,100 和 200 均发生了逃逸。

逃逸范例四

map[string][]string 数据类型,赋值会发生 []string 发生逃逸。

package main
func main() {
    data := make(map[string][]string)
    data["key"] = []string{"value"}
}
$ go tool compile -m 4.go
4.go:3:6: can inline main
4.go:4:14: make(map[string][]string) does not escape
4.go:6:24: []string{...} escapes to heap

我们能看到, []string{...} 切片发生了逃逸。

逃逸范例五

[]*int 数据类型,赋值的右值会发生逃逸现象。

package main
func main() {
    a := 10
    data := []*int{nil}
    data[0] = &a
}
$ go tool compile -m 5.go
5.go:3:6: can inline main
5.go:4:2: moved to heap: a
5.go:6:16: []*int{...} does not escape

其中 moved to heap: a ,最终将变量 a 移动到了堆上。

逃逸范例六

func(*int) 函数类型,进行函数赋值,会使传递的形参出现逃逸现象。

package main
import "fmt"
func foo(a *int) {
    return
}
func main() {
    data := 10
    f := foo
    f(&data)
    fmt.Println(data)
}
$ go tool compile -m 6.go
6.go:5:6: can inline foo
6.go:12:3: inlining call to foo
6.go:14:13: inlining call to fmt.Println
6.go:5:10: a does not escape
6.go:14:13: data escapes to heap
6.go:14:13: []interface {}{...} does not escape

我们会看到 data 已经被逃逸到堆上。

逃逸范例七

func([]string) : 函数类型,进行 []string{"value"} 赋值,会使传递的参数出现逃逸现象。

package main
import "fmt"
func foo(a []string) {
    return
}
func main() {
    s := []string{"aceld"}
    foo(s)
    fmt.Println(s)
}
$ go tool compile -m 7.go
7.go:5:6: can inline foo
7.go:11:5: inlining call to foo
7.go:13:13: inlining call to fmt.Println
7.go:5:10: a does not escape
7.go:10:15: []string{...} escapes to heap
7.go:13:13: s escapes to heap
7.go:13:13: []interface {}{...} does not escape

我们看到 s escapes to heap ,s 被逃逸到堆上。

逃逸范例八

chan []string 数据类型,想当前 channel 中传输 []string{"value"} 会发生逃逸现象。

package main
func main() {
    ch := make(chan []string)
    s := []string{"aceld"}
    go func() {
        ch <- s
    }()
}
$ go tool compile -m 8.go
8.go:8:5: can inline main.func1
8.go:6:15: []string{...} escapes to heap
8.go:8:5: func literal escapes to heap

我们看到 []string{...} escapes to heap , s 被逃逸到堆上。

# 结论

Golang 中一个函数内局部变量,不管是不是动态 new 出来的,它会被分配在堆还是栈,是由编译器做逃逸分析之后做出的决定。

-->