逃逸分析

什么是逃逸分析

CC++这类需要手动管理内存的编程语言中,对象或结构体具体分配到堆还是栈上是由工程师自主决定的,如果能够精准地为每一个变量分配合理的空间,那么程序的运行效率和内存使用效率一定是最高的,但是手动分配会导致以下两个问题:

  • 不需要分配到堆上的对象分配到堆上 —— 浪费内存空间、且可能忘记释放导致内存泄漏
  • 需要分配到堆上的对象分配到栈上 —— 悬挂指针、影响内存安全

悬挂指针

C语言中,栈上的变量被函数作为返回值返回给调用方是一个常见的错误

如以下代码

1
2
3
4
5
int *foo ( void )   
{
int t = 3;
return &t;
}

foo函数返回后,它的本地变量会被编译器回收,调用方获取的是一个悬挂指针,不确定当前指针指向的值是否还能正常使用(可能都已经被回收了)

什么是逃逸分析?

在编译器优化中,逃逸分析是用来决定指针动态作用域的方法,是一种静态分析下方法

Go语言的编译器使用逃逸分析决定变量应该在栈上分配还是在堆上分配

Go语言中的逃逸分析是编译器执行静态代码分析后,对内存管理进行的优化和简化,它可以决定一个变量是分配到栈上还是堆上

逃逸策略

如果一个函数返回一个变量的引用,那么它就会发生逃逸

编译器根据变量是否被外部引用来决定是否逃逸

  • 如果函数外部没有引用,则优先放到栈中(不是一定放在栈中)
  • 如果函数外部存在引用,则必定放在堆中

解读

  • 函数外部没有引用,则优先放在栈中

    栈空间

    优点:栈内存的分配非常的快,只需要两个CPU指令——PUSHRELEASE,对应分配和释放,栈会在函数调用结束后自动回收

    缺点:栈空间有限,不像堆一样空间充足,这就是为什么在不存在外部引用的情况下也只是优先栈,而不是绝对分配在栈上,对于那些不可预知大小的变量,依然要分配在堆上

    堆空间

    优点:堆空间充足(相较于栈空间而言)

    缺点:堆不像栈那样可以自动清理,在CC++这类没有GC机制的语言中就必须手动释放内存,否则容易发生内存泄漏

  • 如果函数外部存在引用,则必定放在堆中

    当外部存在引用时,将变量分配到栈上势必会出现悬挂指针,所以这类变量必须分配到堆中

通过逃逸分析,从代码层面分析后尽量将那些不需要分配到堆上的变量直接分配到栈上,堆上的变量少了,会减轻分配堆内存的开销,同时也减少GC的压力,提高程序的运行速度

如何查看逃逸分析结果

比如以下代码栗子🌰

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func foo() *int {
t := 3
return &t;
}

func main() {
x := foo()
fmt.Println(*x)
}

查看编译时的输出结果

使用以下命令编译可以知道逃逸分析的结果

1
go build -gcflags '-m -l' main.go

-gcflags:是Go编译器选项,具体有哪些选项及含义可以使用以下命令查看

1
go tool compile -h

命令输出

1
2
3
4
5
usage: compile [options] file.go...
...
-l disable inlining
-m print optimization decisions
...

-l:关闭内联优化

-m:打印优化决策结果

1711871015189

查看汇编代码

也可以通过汇编代码知道逃逸分析的结果

1
go tool compile -S escape_analysis.go

1711871320525

这里说明t是通过runtime.newobject()在堆上分配的对象

常见的逃逸场景

指针逃逸

最典型的一种场景,当函数外部存在对函数内变量的引用的时候,就会发生逃逸,将变量分配到堆上(不然不就出现悬挂指针了嘛)

看一段平时最常见的指针逃逸代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

type Student struct {
Name string
Age int
}

func NewStudent(name string, age int) *Student {
return &Student{
Name: name,
Age: age,
}
}

func main() {
NewStudent("Crayon", 24)
}

这段代码的意义很简单,就是提供了一个NewStudent的创建者函数,有时候为了避免大结构体作为函数出入参时的值拷贝的性能开销,会使用指针类型的结构体

NewStudent函数内部创建了一个Student结构体,并将该结构体指针返回给外部,Go编译器认为这是一种指针逃逸行为,需要将变量分配到堆上以避免悬挂指针的问题的产生,尽管函数外部(main函数)中目前还没有使用到NewStudent函数返回的结构体指针

栈空间不足逃逸

栈的空间是有限的,所以当变量所需要的内存大小超过栈所能分配的空间的时候,就会将变量分配到堆上

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

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

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

func main() {
Slice()
}

查看编译的结果,此时并不会发生逃逸

1711876383880

但是我们加大切片的容量大小,就会发生逃逸

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

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

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

func main() {
Slice()
}

将切片容量扩大到10000,此时再执行编译,可以看到发生了逃逸

1711876465962

容量未知也会导致导致

当切片的容量为变量(即编译期还是未知的)的时候,也会发生逃逸

1
2
3
4
5
6
7
8
9
10
11
12
package main

import "math/rand"

func Slice() {
n := rand.Intn(10)
_ = make([]int, n)
}

func main() {
Slice()
}

1712202550858

闭包引用对象逃逸

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

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++ {
f()
}
}

1711877883619

可见函数内部ab都发生了逃逸

动态类型逃逸?

这块应该是有一定的误区,至少我在1.17版本测试时,空接口类型的参数不会发生逃逸

1
2
3
4
5
6
7
8
9
10
package main

func test(v interface{}) interface{} {
return v
}

func main() {
s := "Crayon"
test(s)
}

这里test函数的入参是空接口(动态类型),但是并没有发生逃逸

但是使用fmt.Println函数时就会发生逃逸

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func test(v interface{}) interface{} {
return v
}

func main() {
s := "Crayon"
// test(s)
fmt.Println(s)
}

使用fmt.Println打印变量就发生了逃逸

1711879475952

分析fmt.Println的源码,参考其他文章发现一个有意思的点

那就是reflect系列的方法,如reflect.ValueOf,调用该类方法会触发逃逸

1
2
3
4
5
6
7
8
9
10
11
package main

import "reflect"

func main() {
s := "Crayon"
reflect.TypeOf(s)
reflect.TypeOf(s).Kind()
reflect.ValueOf(s)
reflect.ValueOf(s).Kind()
}

1712207034963

reflect.ValueOf导致逃逸?

这里先看下reflect.ValueOf函数,点进源码就能直接看到为什么逃逸了

1712207129918

显示的调用escapes函数让变量逃逸,这里是有意为之,具体为什么有待深入研究下,这里就不往下展开了

reflect.TypeOf导致逃逸?

从实验的结果来看,只是调用reflect.TypeOf函数并不会触发逃逸,而是在接着调用Type接口(reflect.TypeOf函数的返回值)的方法的时候才会发生逃逸

1712207287284

查看TypeOf的源码,简单画了一下涉及的几个结构体/接口的类图

1712208272189

Type是一个顶层的接口,rtypeType接口的一个实现,emptyInterfacertype则是组合的关系,存在一个typ的成员变量引用rtype

1712208451934

toType的实现很简单,就是将具体实现转化成更抽象的接口类型

场景复现

分别模拟了reflect.TypeOf函数的源码行为以及结构体抽象成接口的方法调用的场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package main

import "unsafe"

type MyInterface interface {
Do()
}

type Pointer struct {
}

func (s *Pointer) Do() {

}

func Pointer2MyInterface(s *Pointer) MyInterface {
return s
}

type emptyInterface struct {
typ *Pointer
word unsafe.Pointer
}

func TypeOf(i interface{}) MyInterface {
eface := (*emptyInterface)(unsafe.Pointer(&i))
return Pointer2MyInterface(eface.typ)
}

func TypeOf0(i interface{}) *Pointer {
eface := (*emptyInterface)(unsafe.Pointer(&i))
return eface.typ
}

func TypeOf1(i interface{}) Pointer {
eface := (*emptyInterface)(unsafe.Pointer(&i))
return *eface.typ
}

func main() {
p1 := Pointer{}
TypeOf(p1)
TypeOf(p1).(*Pointer).Do()
TypeOf(p1).Do()

TypeOf0(p1)
TypeOf0(p1).Do()

TypeOf1(p1)
pp1 := TypeOf1(p1)
pp1.Do()

p2 := &Pointer{}
Pointer2MyInterface(p2)

p3 := &Pointer{}
Pointer2MyInterface(p3).Do()

p4 := &Pointer{}
Pointer2MyInterface(p4).(*Pointer).Do()
}

逃逸分析结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ go tool compile -m -l escape_analysis.go
escape_analysis.go:12:7: s does not escape
escape_analysis.go:16:26: leaking param: s to result ~r1 level=0
escape_analysis.go:25:13: leaking param: i to result ~r1 level=0
escape_analysis.go:30:14: leaking param: i to result ~r1 level=0
escape_analysis.go:35:14: i does not escape
escape_analysis.go:42:11: p1 does not escape
escape_analysis.go:43:11: p1 does not escape
escape_analysis.go:44:11: p1 escapes to heap
escape_analysis.go:46:12: p1 does not escape
escape_analysis.go:47:12: p1 does not escape
escape_analysis.go:49:12: p1 does not escape
escape_analysis.go:50:19: p1 does not escape
escape_analysis.go:53:11: &Pointer{} does not escape
escape_analysis.go:56:11: &Pointer{} escapes to heap
escape_analysis.go:59:11: &Pointer{} does not escape
<autogenerated>:1: leaking param: .this
结论

从逃逸分析的结果可以说明,接口方法的调用会导致逃逸

大概的原因可能是接口属于动态的类型,只要实现了接口的结构体都可能是结构体的实现,而编译期间我们并不能知道调用方法的变量属于哪类实现,为避免指针引用以及栈空间大小可能不足的问题,就直接将变量逃逸分配到堆上了

slice、map、chan元素为指针类型,元素逃逸

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package main

func main() {
s1 := make([]*string, 0, 10)
str1 := "str1"
s1 = append(s1, &str1)

s2 := make([]string, 0, 10)
str2 := "str2"
s2 = append(s2, str2)

m1 := make(map[*string]string, 8)
mkey1 := "mkey1"
mvalue1 := "mvalue1"
m1[&mkey1] = mvalue1

m2 := make(map[string]*string, 8)
mkey2 := "mkey2"
mvalue2 := "mvalue2"
m2[mkey2] = &mvalue2

m3 := make(map[*string]*string, 8)
mkey3 := "mkey3"
mvalue3 := "mvalue3"
m3[&mkey3] = &mvalue3

m4 := make(map[string]string, 8)
mkey4 := "mkey4"
mvalue4 := "mvalue4"
m4[mkey4] = mvalue4

ch1 := make(chan *string, 10)
chStr1 := "chStr1"
ch1 <- &chStr1

ch2 := make(chan string, 10)
chStr2 := "chStr2"
ch2 <- chStr2
}

逃逸分析结果

1
2
3
4
5
6
7
8
9
10
11
12
13
$ go tool compile -m -l escape_analysis.go
escape_analysis.go:5:5: moved to heap: str1
escape_analysis.go:13:5: moved to heap: mkey1
escape_analysis.go:19:5: moved to heap: mvalue2
escape_analysis.go:23:5: moved to heap: mkey3
escape_analysis.go:24:5: moved to heap: mvalue3
escape_analysis.go:33:5: moved to heap: chStr1
escape_analysis.go:4:15: make([]*string, 0, 10) does not escape
escape_analysis.go:8:15: make([]string, 0, 10) does not escape
escape_analysis.go:12:15: make(map[*string]string, 8) does not escape
escape_analysis.go:17:15: make(map[string]*string, 8) does not escape
escape_analysis.go:22:15: make(map[*string]*string, 8) does not escape
escape_analysis.go:27:15: make(map[string]string, 8) does not escape

结论:指针类型元素会发生逃逸

案例分析

案例一

1
2
3
4
5
6
7
8
9
10
11
package main
type S struct {}

func main() {
var x S
_ = identity(x)
}

func identity(x S) S {
return x
}

没有发生逃逸

由于Go语言中参数是值传递,输入输出都是值拷贝,所以此处不会发生逃逸,变量x还是分配在栈上

案例二

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

type S struct {}

func main() {
var x S
y := &x
_ = identity(y)
}

func identity(z *S) *S {
return z
}

没有发生逃逸

变量x被变量y引用,但是都没有超出main函数的作用域,所以没有发生逃逸

identity函数输入直接作为返回值返回,没有将变量z的引用返回到函数外部,所以变量z没有发生逃逸

案例三

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

type S struct {
M *int
}

func main() {
var i int
refStruct(i)
}

func refStruct(y int) (z S) {
z.M = &y
return z
}

发生逃逸

变量y属于refStruct函数的局部变量(形参),将y的引用赋给z.M这个结构体内的变量再返回给函数外部,函数外部想要访问M(获取M引用地址对应的值),y就不能分配在栈上,必须逃逸分配到堆上

总结

  • 栈上分配内存比在堆中分配内存有更高的效率
  • 栈上分配的内存不需要GC处理,回收的效率高(用完即回收)
  • 堆上分配的内存使用完毕会交给GC处理
  • 逃逸分析的目的是决定内存分配在栈还是堆上,正确的内存方式能够提高分配效率和回收效率,提升程序性能
  • Go语言逃逸分析在编译阶段完成

参考资料

Go 语言的栈内存和逃逸分析 | Go 语言设计与实现 (draveness.me)

逃逸分析是怎么进行的 | Go 程序员面试笔试宝典 (golang.design)

golang的fmt包引发的变量逃逸到堆的问题_golang fmt.sprintf out of memory-CSDN博客

golang变量逃逸分析小探 - 声zzz (reusee.github.io)

通过实例理解Go逃逸分析 - 知乎 (zhihu.com)

通过实例理解Go逃逸分析 | Tony Bai