本文基于 Go 1.13
逃逸分析是 Go 编译器的一部分。它分析源代码并确定哪些变量应该分配到栈上、哪些逃逸到堆上。
静态分析
Go 在编译阶段就定义了什么应该在堆,什么应该在栈上。在 go run
或 go build
时加上 -gcflags="-m"
就可以得到分析结果。
这里有个简单的例子:
func main() {
num := getRandom()
println(*num)
}
//go:noinline
func getRandom() *int {
tmp := rand.Intn(100)
return &tmp
}
逃逸分析告诉我们 tmp
逃逸到了堆上
./main.go:12:2: moved to heap: tmp
静态分析的第一步时构建源代码的抽象语法树,允许 Go 理解在哪里进行赋值和分配,以及变量寻址和解引用。
下面是上一份代码的示例:
然而,为了放便分析,我们去掉 AST 的不相关信息,可以得到一个简单的版本
由于数公开了定义的变量(NAME 表示)和对指针的操作(ADDR 或 DEREF 表示),因此它将所有信息提供给 Go 执行逃逸分析,一旦树被构建并解析了函数和参数,Go 就可以使用逃逸分析逻辑查看应该给哪些分配堆和栈。
存活时间超过栈帧
在运行逃逸分析并从 AST 图中遍历函数时,Go 会查找比当前栈帧存活时间更长的变量,因此这些变量会分配到堆上。首先我们定义什么是 outlive
,假如上个例子的栈帧中没有堆分配。下面是两个函数被调用时栈向下增长的情况:
那么,当函数 getRandom
返回, 此函数创建的栈失效时,任何在函数栈上创建的变量都无法访问。
在这种情况下,变量 num 不能指向在前一个栈上分配的变量。在这个例子中, Go 必须将变量分配到堆上,确保它比栈帧活的长:
变量 tmp
包含了分配到栈上的内存地址,并且可以安全的从一个栈复制到另一个栈。然而,返回值并不是唯一可以 outlive
的值,它们的规则如下:
-
任何返回值都超过该函数的寿命,因为被调用的函数不知道该值
-
在循环外声明的变量比循环内活的更久
func main() { var l *int for i := 0; i < 10; i++ { l = new(int) *l = i } println(*l) } ./main.go:6:10: new(int) escapes to heap
-
在闭包外部声明的变量比在闭包内部赋值活的更久:
func main() { var l *int func() { l = new(int) *l = 1 }() println(*l) } ./main.go:8:3: new(int) escapes to heap
逃逸分析的第二部分包括确定它如何操作指针,帮助理解哪些内容可能留在栈上。
寻址和解引用
构建表示寻址/解引用计数的加权图能让 Go 优化栈的分配。让我们分析一个例子来理解它是如何工作的:
func main() {
n := getAnyNumber()
println(*n)
}
//go:noinline
func getAnyNumber() *int {
l := new(int)
*l = 42
m := &l
n := &m
o := **n
return o
}
./main.go:10:10: new(int) escapes to heap
这里是生成的 AST 简单版本
Go 通过构建加权图来定义分配。每次解引用(* 或 DEREF 表示) 权重增加 1,每次寻址操作(& 或 ADDR 表示)权重减去1。
下面是通过逃逸分析的顺序定义:
variable o has a weight of 0, o has an edge to n
variable n has a weight of 2, n has an edge to m
variable m has a weight of 1, m has an edge to l
variable l has a weight of 0, l has an edge to new(int)
variable new(int) has a weight of -1
每个以负数结束的变量如果超过当前栈帧寿命就会逃逸到堆上。返回值的寿命超过其函数的栈帧,并且通过计算得到了负值,就会分配到堆上。
通过构建这个图,Go可以了解哪些变量应该留在栈上,尽管它比栈活的长。
下面是另一个基本的例子:
func main() {
num := func1()
println(*num)
}
//go:noinline
func func1() *int {
n1 := func2()
*n1++
return n1
}
//go:noinline
func func2() *int {
n2 := rand.Intn(99)
return &n2
}
./main.go:20:2: moved to heap: n2
变量 n1 存活的比栈帧长, 但是它的权重不是负数,因为 func1 在任何地方都不指向它的地址,然而, n2 一直存活并被解引用,Go 可以安全的将它分配到堆上。