0%

[译]Golang逃逸分析

前言

本文翻译自 Alysha Gardner 的一篇博文Golang escape analysis

由于原博客创作时间较早,文中的一些编译显示结果可能存在出入,请参照最新的Go版本编译结果。

正文

垃圾回收是Go语言的一项很方便的功能-自动管理内存让代码变得更干净并且内存泄漏更少。然而,GC同样增加了开销,因为程序需要阶段性地停止并且回收没用的对象。Go编译器足够聪明地自动决定一个变量是否应该分配到需要垃圾回收的堆上,或者是否能够分配到声明该变量的函数的栈结构中。栈变量不像堆变量,栈变量不会带来任何的GC开销,因为它们和剩余的栈数据一起在函数返回时被销毁。

Go的逃逸分析比HotSpot JVM的更简单。最基础的规则是如果一个变量的引用被声明它的函数返回了,那么它就”逃逸”了-它可以在函数返回后被引用,所以它必须分配在堆中。这是复杂的, 通过下面几点体现:

  • 函数调用其他函数
  • 引用被赋值给了结构体成员
  • 切片和映射
  • 使用指向变量的指针的cgo

为了演示逃逸分析,在编译期间,Go构建了一副函数调用的图用于追踪输入参数和返回值的流程。一个函数可能引用它的一个参数,但是如果该引用没有被返回,那么该变量不会逃逸。一个函数同样可能返回一个引用,但是在声明该变量的函数返回前,该引用可能被另一个栈中的函数引用或者没有被返回。为了阐述几个例子,我们使用-gcflags '-m'参数来运行编译器,该参数将会打印冗长的逃逸分析信息:

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

type S struct{}

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

func identity(x S) S {
return x
}

你将会使用go run -gcflags '-m -l'编译这个程序--l标识阻止函数identity的内联。该程序将什么也不输出。Go使用值传递语义,所以main中的变量x将总是被拷贝到identity所处的栈中。通常不带引用的代码总是使用栈内存分配,不会有逃逸分析。让我们尝试更难的事情:

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
}

输出为:

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

第一行显示了变量的流向: 输入的变量作为输出被返回了。但是identity()没有引用z, 所以变量没有逃逸。在main返回的的经过中,没有x的引用幸存,所以x可以作为main栈结构的的一部分被分配。

第三个试验:

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

type S struct{}

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

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

输出:

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

现在有逃逸发生,记住Go是值传递,所以z是来自main中的变量x的一份拷贝,ref返回z的一份引用,所以z不能成为ref栈的一部分-那么当ref返回时该引用指向哪里呢?取而代之,它逃逸到了堆上,即使main在没有重复引用它前立即抛出该引用,Go的逃逸分析也没有熟练到能够识别出这种情况,它仅仅只是查看输入流和返回的变量,在这种情况下值得注意的是ref将会被编译器内联如果我们不停止它。

如果一个引用被赋值给了一个结构体成员会怎样呢?

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
}

输出:

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

在这种情况下Go仍然可以追踪引用的流程,即使该引用是一个结构体的成员。因为refStruct产生了引用并且返回了它,y必须逃逸,与这种情形比较:

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
}

输出:

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

因为main发生了引用并且将其传递给了refStruct,该引用不会比声明它的栈存活得更长。这个和先前的程序有略微不同的语义,但是如果第二个程序是足够完整的话,它将会更高效:在第一个例子中i必须分配在main对应的栈中, 然后重新分配在堆中并且作为参数被拷贝到refStruct中。在第二个例子中,i只被分配一次,并且引用被传递。

一个稍微更隐秘一点的例子:

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 x S
var i int
ref(&i, &x)
}

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

输出:

1
2
3
4
5
./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逃逸分析的局限性,有很多文档记载的,非常规的情形(Go 1.5中)中变量必须被分配在堆上——参考链接

最后,关于映射(map)和切片如何呢?记住切片和映射实际上是带有指向分配在堆上的内存的指针的Go结构体:切片结构体在reflect包中的SliceHeader, 映射的结构体要稍微难找一点,但是它在hmap, 如果这些结构体没有逃逸那么它们将被分配在栈空间,但是底层数组或者hash桶中的数据每次都会被分配在堆中,避免这种情况的唯一办法是分配一个固定大小的数组(比如[10000]int)。

如果你已经分析了你的程序的堆使用情况并且需要减少GC时间,将频繁分配的变量从堆中移出可能会好一些。这里同样有一个吸引人的主题: 了解更多关于HotSpot JVM如何处理逃逸分析,查阅这片文章, 这篇文章讲解栈分配,并且还涉及辨别何时同步可以被省略。