GO语言变量逃逸分析
空格键_11aa · 2019-07-09 21:32:41 · 274 次点击 · 预计阅读时间 1 分钟 · 大约1分钟之前 开始浏览
这是一个创建于 2019-07-09 21:32:41 的文章,其中的信息可能已经有所发展或是发生改变。
引言
内存管理的灵活性是让C/C++程序猿们又爱又恨的东西,比如malloc或new一块内存我可以整个进程使用。但是,如果这块内存在某个函数中new了,但是暂时不能释放那就是悲剧开始了。鬼知道何时释放合适及是不是我还记得我new过它。所以后来很多语言都限制了内存管理或者优化了内存管理机制,添加gc机制来“辅助”程序猿们编程。变量分配在堆上还是栈上不是由是否new/malloc决定,而是通过编译器的“逃逸分析”来决定。
什么是逃逸分析
在编译程序优化理论中,逃逸分析是一种确定指针动态范围的方法——分析在程序的哪些地方可以访问到指针。也是就是说逃逸分析是解决指针作用范围的编译优化方法。编程中常见的两种逃逸情景:
1,函数中局部对象指针被返回(不确定被谁访问)
2,对象指针被多个子程序(如线程 协程)共享使用
为什么要做逃逸分析
开始我们提到go语言中对象内存的分配不是由语言运算符或函数决定,而是通过逃逸分析来决定。为什么要这么干呢?其实说到底还是为了优化程序。函数中生成一个新对象:
1,如果分配到栈上,待函数返回资源就被回收了
2,如果分配到堆上,函数返回后交给gc来管理该对象资源
栈资源的分配及回收速度比堆要快,所以逃逸分析最大的好处应该是减少了GC的压力。
逃逸分析原理
逃逸分析的场景
指针逃逸
典型的逃逸case,函数返回局部变量的指针。
运行:go build -gcflags "-m -l" escap01.go
-m 可以用多个来打印更详细的信息,-l去掉inline信息。局部变量a被分配到堆上。
栈空间不足逃逸
当对象大小超过的栈帧大小时(详见go内存分配),变量对象发生逃逸被分配到堆上。
当s的容量足够大时,s逃逸到堆上。t容量较小分配到栈上
闭包引用逃逸
Fibonacci()函数返回一个函数变量赋值给f,f就成了一个闭包。闭包f保存了a b的地址引用,所以每次调用f()后ab的值发生变化。ab发生逃逸。
但如果直接调用Fibonacci(),则ab都是独立的局部变量。
动态类型逃逸
当对象不确定大小或者被作为不确定大小的参数时发生逃逸。
t的大小是个变量所以会逃逸到堆上。size作为interface{}参数逃逸到堆上。
切片或map赋值
在给切片或者map赋值对象指针(与对象共享内存地址时),对象会逃逸到堆上。但赋值对象值或者返回对象值切片是不会发生逃逸的。
变量逃逸情况还有很多,暂时学习整理这些。程序性能优化是一个很重要的方向,对于现在还在完善的go编译器,我们需要不断总结现有缺陷,尽量在编码时注意潜在的问题,不要把优化都留给编译器(也不可能都留给它,因为我也不知道要优化什么 0-0 )。
逃逸策略
- 如果编译器不能证明某个变量在函数返回后不再被引用,则分配在堆上
- 如果一个变量过大,则有可能分配在堆上
分析目的
- 不逃逸的对象分配在栈上,则变量在用完后就会被编译器回收,从而减少GC的压力
- 栈上的分配要比堆上的分配更加高效
- 同步消除,如果定义的对象上有同步锁,但是栈在运行时只有一个线程访问,逃逸分析后如果在栈上则会将同步锁去除
逃逸场景
指针逃逸
在 build 的时候,通过添加 -gcflags "-m" 编译参数就可以查看编译过程中的逃逸分析
在有些时候,因为变量太大等原因,我们会选择返回变量的指针,而非变量,这里其实就是逃逸的一个经典现象
func main() {
test()
}
func test() *int {
i := 1
return &i
}
逃逸分析结果:
# command-line-arguments
./main.go:7:6: can inline test
./main.go:3:6: can inline main
./main.go:4:6: inlining call to test
./main.go:4:6: main &i does not escape
./main.go:9:9: &i escapes to heap
./main.go:8:2: moved to heap: i
可以看到最后两行指出,变量 i
逃逸到了 heap
上
栈空间不足逃逸
首先,我们尝试创建一个 长度较小的 slice
func main() {
stack()
}
func stack() {
s := make([]int, 10, 10)
s[0] = 1
}
逃逸分析结果:
./main.go:12:6: can inline stack
./main.go:3:6: can inline main
./main.go:4:7: inlining call to stack
./main.go:4:7: main make([]int, 10, 10) does not escape
./main.go:13:11: stack make([]int, 10, 10) does not escape
结果显示未逃逸
然后,我们创建一个超大的slice
func main() {
stack()
}
func stack() {
s := make([]int, 100000, 100000)
s[0] = 1
}
逃逸分析结果:
./main.go:12:6: can inline stack
./main.go:3:6: can inline main
./main.go:4:7: inlining call to stack
./main.go:4:7: make([]int, 100000, 100000) escapes to heap
./main.go:13:11: make([]int, 100000, 100000) escapes to heap
这时候就逃逸到了堆上了
动态类型逃逸
func main() {
dynamic()
}
func dynamic() interface{} {
i := 0
return i
}
逃逸分析结果:
./main.go:18:6: can inline dynamic
./main.go:3:6: can inline main
./main.go:5:9: inlining call to dynamic
./main.go:5:9: main i does not escape
./main.go:20:2: i escapes to heap
这里的动态类型逃逸,其实在理解了interface{}
的内部结构后,还是可以归并到 指针逃逸 这一类的,有兴趣的同学可以看一下 《深入理解Go的interface》
闭包引用逃逸
func main() {
f := fibonacci()
for i := 0; i < 10; i++ {
f()
}
}
func fibonacci() func() int {
a, b := 0, 1
return func() int {
a, b = b, a+b
return a
}
}
逃逸分析结果:
./main.go:11:9: can inline fibonacci.func1
./main.go:11:9: func literal escapes to heap
./main.go:11:9: func literal escapes to heap
./main.go:12:10: &b escapes to heap
./main.go:10:5: moved to heap: b
./main.go:12:13: &a escapes to heap
./main.go:10:2: moved to heap: a
逃逸的其他情况
1.被已经逃逸的变量引用的指针,肯定会引发逃逸
type A struct{
data *int
}
func newA() *A{
return &A{}
}
func main(){
a := newA() //返回值是A结构体指针
b := 2
a.data = &b //此时a的指针必然发生逃逸
}
下图可以看到A是一个已经逃逸的指针,引用了b的指针,所以b的指针也发生了逃逸
我们再看上面备注中的代码例子:
func main() {
a := make([]*int,1)
b := 12
a[0] = &b
}
结果:
➜ testProj go run -gcflags "-m -l" main.go
# command-line-arguments
./main.go:7:2: moved to heap: b
./main.go:6:11: main make([]*int, 1) does not escape
sliace a并没有发生逃逸,但是被a引用的b依然逃逸了。类似的情况同样发生在map和chan中:
func main() {
a := make([]*int,1)
b := 12
a[0] = &b
c := make(map[string]*int)
d := 14
c["aaa"]=&d
e := make(chan *int,1)
f := 15
e <- &f
}
结果:
➜ testProj go run -gcflags "-m -l" main.go
# command-line-arguments
./main.go:7:2: moved to heap: b
./main.go:11:2: moved to heap: d
./main.go:15:2: moved to heap: f
./main.go:6:11: main make([]*int, 1) does not escape
./main.go:10:11: main make(map[string]*int) does not escape
被chan,map,slice引用的指针必然会发生逃逸
由此我们可以得出结论:
被指针类型的slice、map和chan引用的指针一定发生逃逸
备注:stack overflow上有人提问为什么使用指针的chan比使用值的chan慢30%,答案就在这里:使用指针的chan发生逃逸,gc拖慢了速度。问题链接https://stackoverflow.com/questions/41178729/why-passing-pointers-to-channel-is-slower
总结
我们得出了指针必然发生逃逸的三种情况(go version go1.13.4 darwin/amd64):
- 在某个函数中new或字面量创建出的变量,将其指针作为函数返回值,则该变量一定发生逃逸(构造函数返回的指针变量一定逃逸);
- 被已经逃逸的变量引用的指针,一定发生逃逸;
- 被指针类型的slice、map和chan引用的指针,一定发生逃逸;
同时我们也得出一些必然不会逃逸的情况:
- 指针被未发生逃逸的变量引用;
- 仅仅在函数内对变量做取址操作,而未将指针传出;
**有一些情况****可能发生逃逸,也可能不会发生逃逸:
- 将指针作为入参传给别的函数;这里还是要看指针在被传入的函数中的处理过程,如果发生了上边的三种情况,则会逃逸;否则不会逃逸;