golang的逃逸分析

翻译自:http://www.agardner.me/golang/garbage/collection/gc/escape/analysis/2015/10/18/go-escape-analysis.html

Golang逃逸分析
2015-10-18

垃圾回收是Go的一个很方便的特性--其自动的内存管理使代码更整洁,同时减少内存泄漏的可能性。但是,由于垃圾回收需要周期性的停止程序从而去收集不用的对象,不可避免的会增加额外开销。Go编译器是智能的,它会自动决定一个变量是应该分配在堆上从而在将来便于回收,还是直接分配到函数的栈空间。对于分配到栈上的变量,其与分配到堆上的变量不同之处在于:随着函数的返回,栈空间会被销毁,从而栈上的变量被直接销毁,不需要额外的垃圾回收开销。

Go的逃逸分析相对于Java虚拟机的HotSpot来说更为基础。基本规则就是,如果一个变量的引用从声明它的函数中返出去了,则发生“逃逸”,因为它有可能在函数外被别的内容使用,所以必须分配到堆上。如下几种情况会比较复杂:

  • 函数调用其他函数
  • 引用作为结构体的成员变量
  • 切片和映射
  • Cgo指向变量的指针

为了实现逃逸分析,Go会在编译阶段构造函数调用关系图,同时跟踪入参和返回值的流程。一个函数如果只是引用一个参数,但这个引用并没有返出函数的话,这个变量也不会逃逸。如果一个函数返回了一个引用,但是这个引用被栈中的其他函数解除或者没有返回此引用,则也不会逃逸。为了论证几个例子,可以在编译时加-gcflags '-m'参数,这个参数会打印逃逸分析的详细信息:

package main

type S struct {}

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

func identity(x S) S {
    return x
}

你可以执行go run -gcflags '-m -l'(注:原文中略了go代码文件名)来编译这个代码,-l参数是防止函数identity被内联(换个时间再讨论内联这个话题)。你将会看到没有任何输出!Go使用值传递,所以main函数中的x这个变量总是会被拷贝到函数identity的栈空间。通常情况下没有使用引用的代码都是通过栈空间来分配内存。所以不涉及逃逸分析。下面试下困难一点的:

package main

type S struct {}

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

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

其对应的输出是:

./escape.go:11: leaking param: z to result ~r1
./escape.go:7: main &x does not escape

第一行显示了变量z的“流经”:入参直接作为返回值返回了。但是函数identity没有取走z这个引用,所以没有发生变量逃逸。在main函数返回后没有任何对x的引用存在,所以x这个变量可以在main函数的栈空间进行内存分配。
第三次实验:

package main

type S struct {}

func main() {
  var x S
  _ = *ref(x)
}

func ref(z S) *S {
  return &z
}

其输出为:

./escape.go:10: moved to heap: z
./escape.go:11: &z escapes to heap

现在有了逃逸发生。记住Go是值传递的,所以z是对变量x的一个拷贝。函数ref返回一个对z的引用,所以z不能在栈中分配,否则当函数ref返回时,引用会指向何处呢?于是它逃逸到了堆中。其实执行完ref返回到main函数中后,main函数丢弃了这个引用而不是解除引用,但是Go的逃逸分析还不够机智去识别这种情况。
值得注意的是,在这种情况下,如果我们不停止引用,编译器将内联ref
如果结构体成员定义的是引用又会怎样呢?

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
}

其输出为:

./escape.go:12: moved to heap: y
./escape.go:13: &y escapes to heap

在这种情况下,尽管引用是结构体的成员,但Go仍然会跟踪引用的流向。由于函数refStruct接受引用并将其返回,因此y必须逃逸。对比如下这个例子:

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
}

其输出为:

./escape.go:12: leaking param: y to result z
./escape.go:9: main &i does not escape

尽管在main函数中对i变量做了引用操作,并传递到了函数refStruct中,但是这个引用的范围没有超出其声明它的栈空间。这和之前的那个程序语义上有细微的差别,这个会更高效:在上一个程序中,变量i必须分配在main函数的栈中,然后作为参数拷贝到函数refStruct中,并将拷贝的这一份分配在堆上。而在这个例子中,i仅被分配一次,然后将引用到处传递。

再来看一个有点弯弯绕的例子:

package main

type S struct {
  M *int
}

func main() {
  var x S
  var i int
  ref(&i, &x)
}

func ref(y *int, z *S) {
  z.M = y
}

其输出为:

./escape.go:13: leaking param: y
./escape.go:13: ref z does not escape
./escape.go:9: moved to heap: i
./escape.go:10: &i escapes to heap
./escape.go:10: main &x does not escape

问题在于,y被赋值给了一个入参结构体的成员。Go并不能追溯这种关系(go只能追溯输入直接流向输出),所以逃逸分析失败了,所以变量只能分配到堆上。由于Go的逃逸分析的局限性,许多变量会被分配到堆上,请参考此链接,这里面记录了许多案例(从Go1.5开始)。

最后,来看下映射和切片是怎样的呢?请记住,切片和映射实际上只是具有指向堆内存的指针的Go结构:slice结构是暴露在reflect包中(SliceHeader
)。map结构就更隐蔽了:存在于hmap。如果这些结构体不逃逸,将会被分配到栈上,但是其底层的数组或者哈希桶中的实际数据会被分配到堆上去。避免这种情况的唯一方法是分配一个固定大小的数组(例如[10000]int)。

如果你剖析过你的程序堆使用情况(https://blog.golang.org/pprof
),并且想减少垃圾回收的消耗,可以将频繁分配到堆上的变量移到栈上,可能会有较好的效果。进一步研究HotSpot JVM是如何进行逃逸分析的会是一个不错的话题,可以参考这个链接,这个里面主要讲解了栈分配,以及有关何时可以消除同步的检测。

阅读 359

推荐阅读