妥善地决定堆与栈

逃逸分析是服务于Go的内存分配过程的,编译器会在编译阶段决定,哪些变量会被分配到堆上,哪些变量会被分配到栈上

这个方式和C语言是由很大区别的,C语言的程序员可以决定将变量分配到堆上(malloc),或者依然驻留在函数的栈中。良好地分配内存可以编写出高效的程序,而不恰当的内存分配会有两种糟糕的后果:

  • 不需要分配到堆上的内存分配到了堆上
    • 会增加GC的压力,从而影响效率(所以,Go专家编程指出,通过传递指针来减少拷贝提高效率的措施,也许会增加GC负担,并不一定是高效的)
    • 浪费堆空间
  • 需要分配到堆上的对象驻留在了栈中
    • 可能导致悬空指针,影响内存安全

所以Go语言将内存管理做的更加自动化,采用了逃逸分析的机制,由编译器来判断内存分配到堆或者栈上。逃逸的基本原则是(具体参见下文官方QA):

  • 如果不能保证在return之后是否有引用,则分配到堆上
  • 栈空间不足/对象过大的时候,将分配到堆上

    关于逃逸的原则,几本书的说法略有不同,这里选取了官方的QA:

    正确性的角度来看,你不需要知道。只要有对它的引用,Go中的每个变量就存在。实现所选择的存储位置与语言的语义无关。

    存储位置确实对编写高效程序有影响。在可能的情况下,Go编译器会在一个函数的栈中分配属于该函数的局部变量。然而,如果编译器不能证明该变量在函数返回后没有被引用,那么编译器必须在GC所管理的堆上分配该变量,以避免悬空指针错误。另外,如果一个局部变量非常大,把它存储在堆上而不是栈上可能更有意义。

    在目前的编译器中,如果一个变量的地址被占用,那么这个变量就是在堆上分配的候选变量。然而,一个基本的转义分析认识到,在某些情况下,这种不会活过函数return的变量,可以驻留在栈上。

由于逃逸分析是在编译阶段进行的,并不能准确地预测运行时某个函数中的对象是否在return之后还会被引用,所以并不能保证逃逸到堆上的变量都一定会被再次引用(起码2021/10/13的时候还不能)。所以,在寻找效率瓶颈的时候,可以做一做逃逸分析,说不定就遇到了什么奇怪的逃逸现象影响了效率呢。

什么时候会发生逃逸

可以使用go build -gcflags=-m查看编译过程中的逃逸分析 go build -gcflags="-m -m"查看详情

下面是一些常见情况

1 指针逃逸

  1. // 典型逃逸案例
  2. package main
  3. type Student struct {
  4. Name string
  5. Age int
  6. }
  7. func StudentRegister(name string, age int) *Student {
  8. s := new(Student) //局部变量s逃逸到堆
  9. s.Name = name
  10. s.Age = age
  11. return s
  12. }

首先我们自己分析一下,按照前文所述的原则,func StudentRegister中的指针变量s,在函数return之后仍然存在引用,所以将发生逃逸,s所指向的地址将是堆而不是栈。我们使用go build来验证一下:

  1. D:\learning>go build -gcflags=-m
  2. # _/D_/learning
  3. .\main.go:8:6: can inline StudentRegister
  4. .\main.go:17:6: can inline main
  5. .\main.go:8:22: leaking param: name
  6. .\main.go:9:10: new(Student) escapes to heap // 发生逃逸!

2 栈空间不足/变量过大

  1. func Slice() {
  2. s := make([]int, 1000, 10000)
  3. for index, _ := range s {
  4. s[index] = index
  5. }
  6. }
  1. D:\learning>go build -gcflags=-m
  2. # _/D_/learning
  3. .\main.go:11:6: can inline main
  4. .\main.go:4:11: make([]int, 1000, 10000) escapes to heap

这里挺有意思的,具体多大才会逃逸?函数栈的大小应该不可能只有这么大,所以分配到堆上的理由应该是栈内部分配与回收这块空间的成本比较高。目前只需要了解,在做性能分析的时候,也许过大的局部变量是导致你的程序GC负担过重的原因。具体的函数栈相关的问题,以后再了解。

3 动态类型逃逸

在涉及到接口的时候,是很容易发生逃逸的,甚至一条简单的fmt.Println()语句都会带来逃逸(突然想说一句,用Println()倒不会发生逃逸)。这是因为编译期间难以确定类型导致的。

  1. func try() {
  2. s := `dddd`
  3. println(s)
  4. b := `qqqq`
  5. fmt.Println(b)
  6. }
  1. D:\learning>go build -gcflags=-m
  2. # _/D_/learning
  3. .\main.go:10:13: inlining call to fmt.Println
  4. .\main.go:13:6: can inline main
  5. .\main.go:10:13: b escapes to heap
  6. .\main.go:10:13: []interface {} literal does not escape
  7. <autogenerated>:1: .this does not escape
  8. <autogenerated>:1: .this does not escape

4 闭包引用对象逃逸

  1. func Fibonacci() func() int {
  2. a, b := 0, 1
  3. return func() int {
  4. a, b = b, a+b
  5. return a
  6. }
  7. }
  8. func main() {
  9. f := Fibonacci()
  10. for i := 0; i < 10; i++ {
  11. fmt.Printf("Fibonacci: %d\n", f())
  12. }
  13. }
  1. D:\learning>go build -gcflags=-m
  2. # _/D_/learning
  3. .\main.go:7:9: can inline Fibonacci.func1
  4. .\main.go:17:13: inlining call to fmt.Printf
  5. .\main.go:6:2: moved to heap: a
  6. .\main.go:6:5: moved to heap: b
  7. .\main.go:7:9: func literal escapes to heap
  8. .\main.go:17:34: f() escapes to heap
  9. .\main.go:17:13: []interface {} literal does not escape
  10. <autogenerated>:1: .this does not escape
  11. <autogenerated>:1: .this does not escape

关于逃逸分析中的其他情况

在Link中,引用了别人关于逃逸分析缺陷的文章,举例说明了一些在少见OR奇怪情况下发生的逃逸分析,内容已经足够精炼,同时也算拓展阅读,所以不再进行总结。其中的一些例子或许对于我们代码规范有一定的帮助(比如间接赋值)。可以自行查看。

Links