众所周知,Golang是一门自带GC的编程语言。这意味着内存的分配和管理绝大多数情况下不需要开发者去过多干涉。
在编译程序优化理论中,逃逸分析是一种确定指针动态范围的方法——分析在程序的哪些地方可以访问到指针。它涉及到指针分析和形状分析。
当一个变量(或对象)在子程序中被分配时,一个指向变量的指针可能逃逸到其它执行线程中,或是返回到调用者子程序。如果使用尾递归优化(通常在函数编程语言中是需要的),对象也可以看作逃逸到被调用的子程序中。如果一种语言支持第一类型的延续性在Scheme和Standard ML of New Jersey中同样如此),部分调用栈也可能发生逃逸。
如果一个子程序分配一个对象并返回一个该对象的指针,该对象可能在程序中被访问到的地方无法确定——这样指针就成功“逃逸”了。如果指针存储在全局变量或者其它数据结构中,因为全局变量是可以在当前子程序之外访问的,此时指针也发生了逃逸。
逃逸分析确定某个指针可以存储的所有地方,以及确定能否保证指针的生命周期只在当前进程或线程中。引用自 维基百科
简单梳理一下,逃逸分析的讨论范围,主要是值的可达性,保证在需要的时候能够获取到相对应的值。在Golang的具体语境下,逃逸分析一般是讨论编译时决定值是存放在栈上,还是逃逸到堆上。
首先,明确一下堆栈的定义:
- 栈 栈是为每个具体函数分配的内存片段,栈的生存周期等同于函数的生命周期,函数执行完栈空间会被回收
- 堆 堆是运行时动态使用的内存块,堆的生存周期为整个程序的生命周期,甚至更长
那么编译器究竟如何决定值的分配区域呢?简单一句话就是:需要在栈帧之间传递的,分配在堆上;大对象分配在堆上;尽在栈内部使用的,分配在栈上。
下面通过几个具体示例来简单说明,我们可以通过go的gcflags
来把逃逸部分的信息打印出来:
go build -gcflags "-m -l" demo.go
示例1
package main
type People struct {
Name string
}
//go:noinline
func newPeople1() People {
return People{
Name: "test1",
}
}
//go:noinline
func newPeople2() *People {
return &People{
Name: "test2",
}
}
func main() {
p1 := newPeople1()
p2 := newPeople2()
_, _ = p1, p2
}
运行结果:
# command-line-arguments
./demo.go:16:9: &People literal escapes to heap
# 这里运行结果可以看出,只有newPeople2函数的返回值才逃逸到堆上,这是因为newPeople2函数返回的是指针,为了确保这个指针在newPeople2方法执行完之后依旧可以访问,只能将它分配到堆上
示例2
...
// 其他部分代码如*示例1*
func main() {
p1 := newPeople1()
p2 := newPeople2()
fmt.Println(p1, p2)
}
执行结果:
# command-line-arguments
./demo.go:18:9: &People literal escapes to heap
# 这里同示例1的解释
./demo.go:26:13: ... argument does not escape
./demo.go:26:13: p1 escapes to heap
# 因为在Println函数中需要p1的值,所以p1也逃逸到了堆上
示例3
...
// 其他部分代码如*示例1*
//go:noinline
func GetPeople1(p *People) *People {
p1 := p
return p1
}
//go:noinline
func GetPeople2(p *People) *People {
p1 := *p
return &p1
}
func main() {
p := People{Name: "test3"}
_ = GetPeople1(&p)
_ = GetPeople2(&p)
}
运行结果:
# command-line-arguments
./demo.go:16:9: &People literal escapes to heap
./demo.go:22:17: leaking param: p to result ~r1 level=0
./demo.go:28:17: leaking param content: p
./demo.go:29:2: moved to heap: p1
# 因为GetPeople2函数中返回值是栈内数据(p1)的地址(&p1),为了保证这个值能够被安全访问到,所以p1逃逸到堆上。
示例4
package main
type People struct {
Name *string
Age int32
}
func setPeople1(p People, name string) People {
p.Name = &name
return p
}
func setPeople2(p People, age int32) People {
p.Age = age
return p
}
func main() {
p := People{}
_ = setPeople1(p, "Mike")
_ = setPeople2(p, 25)
}
运行结果:
# command-line-arguments
./demo.go:8:17: leaking param: p to result ~r2 level=0
./demo.go:8:27: moved to heap: name
# 在setPeople1函数中,返回值的Name字段的值指向一个栈内的值(name)的地址,所以逃逸到堆上
./demo.go:13:17: leaking param: p to result ~r2 level=0
示例5
package main
func sliceAppend(s []int64) []int64 {
for i := 0; i < 1000; i++ {
s = append(s, int64(i))
}
return s
}
func sliceSlice(s []int64) []int64 {
return s[1:]
}
func mapExtend(m map[int64]int64) map[int64]int64 {
for i := 0; i < 1000; i++ {
m[int64(i)] = int64(2 * i)
}
return m
}
func mapRemove(m map[int64]int64) map[int64]int64 {
for k, v := range m {
if k%2 == 0 || v%2 == 0 {
delete(m, k)
}
}
return m
}
func main() {
s := make([]int64, 32)
m := make(map[int64]int64)
s = sliceAppend(s)
s = sliceSlice(s)
m = mapExtend(m)
m = mapRemove(m)
}
运行结果:
# command-line-arguments
./demo.go:3:18: leaking param: s to result ~r1 level=0
./demo.go:10:17: leaking param: s to result ~r1 level=0
./demo.go:14:16: leaking param: m to result ~r1 level=0
./demo.go:21:16: leaking param: m to result ~r1 level=0
./demo.go:31:11: make([]int64, 32) does not escape
./demo.go:32:11: make(map[int64]int64) does not escape
# 从结果来看,slice和map都是简单的值传递,不涉及任何逃逸行为
总结
逃逸分析对于我们实际编程有什么意义呢?我们上面提到过,分配在栈上的空间会随着函数执行完而被回收,所以我们日常提到的GC主要是指各种对于堆上内存空间的追踪和清理。理解逃逸分析有助于我们写出GC友好的代码(通过减少堆上内存分配来减少内存压力),从另一个角度来看,如果代码不分配在堆上,就意味着有可能会在多个栈中存在多个拷贝,额外空间占用和GC开销之间需要作出权衡。