Golang Escape Analysis
堆和栈
要理解什么是逃逸分析会涉及堆和栈的一些基本知识:
- 堆(Heap):一般来讲是人为手动进行管理,手动申请、分配、释放。堆适合不可预知大小的内存分配,这也意味着为此付出的代价是分配速度较慢,而且会形成内存碎片。
- 栈(Stack):由编译器进行管理,自动申请、分配、释放。一般不会太大,因此栈的分配和回收速度非常快;我们常见的函数参数(不同平台允许存放的数量不同),局部变量等都会存放在栈上。
在栈上分配和回收内存的开销很低,只需要两个CPU指令:PUSH 和 POP,分配和释放;而堆分配内存首先需要去找到一块大小合适的内存块,之后要通过垃圾回收才能释放。
Go 程序会在 2 个地方为变量分配内存,一个是全局的堆(heap)空间用来动态分配内存,另一个是每个 goroutine 的栈(stack)空间。与 Java、Python 等语言类似,Go 语言实现垃圾回收机制,因此Go语言的内存管理是自动的,通常开发者并不需要关心内存分配在栈上,还是堆上。但是从性能的角度出发,在栈上分配内存和在堆上分配内存,性能差异是非常大的。
在函数中申请一个对象,如果分配在栈中,函数执行结束时自动回收,如果分配在堆中,则在函数结束后某个时间点进行垃圾回收。
escape analysis
what is
在编译程序优化理论中,逃逸分析是一种确定指针动态范围的方法,简单来说就是分析在程序的哪些地方可以访问到该指针。编译器决定内存分配位置的方式,就称之为逃逸分析(escape analysis)。逃逸分析由编译器完成,作用于编译阶段。
简单的说,Go是通过在编译器里做逃逸分析来决定一个对象放栈上还是放堆上,不逃逸的对象放栈上,可能逃逸的放堆上;即发现变量在退出函数后没有用了,那么就把丢到栈上,毕竟栈上的内存分配和回收比堆上快很多;反之,函数内的普通变量经过逃逸分析后,发现在函数退出后变量还有在其他地方上引用,那就将变量分配在堆上。
在Go中通过逃逸分析日志来确定变量是否逃逸,开启逃逸分析日志:
1 | go run -gcflags '-m -l' main.go |
-m
会打印出逃逸分析的优化策略,实际上最多总共可以用 4 个-m
,但是信息量较大,一般用 1 个就可以了。-l
会禁用函数内联,在这里禁用掉内联能更好的观察逃逸情况,减少干扰。
why
- 减少GC压力,栈上的变量,随着函数退出后系统直接回收,不需要GC标记后再清除。
- 减少内存碎片的产生。
- 减轻分配堆内存的开销,提高程序的运行速度。
逃逸案例
能引起变量逃逸到堆上的典型情况:
- 在方法内把局部变量指针返回 局部变量原本应该在栈中分配,在栈中回收。但是由于返回时被外部引用,因此其生命周期大于栈,则溢出。
- 发送指针或带有指针的值到 channel 中。 在编译时,是没有办法知道哪个 goroutine 会在 channel 上接收数据。所以编译器没法知道变量什么时候才会被释放。
- 在一个切片上存储指针或带指针的值。 一个典型的例子就是 []*string 。这会导致切片的内容逃逸。尽管其后面的数组可能是在栈上分配的,但其引用的值一定是在堆上。
- slice 的背后数组被重新分配了,因为 append 时可能会超出其容量( cap )。 slice 初始化的地方在编译时是可以知道的,它最开始会在栈上分配。如果切片背后的存储要基于运行时的数据进行扩充,就会在堆上分配。
- 在 interface 类型上调用方法。 在 interface 类型上调用方法都是动态调度的 —— 方法的真正实现只能在运行时知道。想像一个 io.Reader 类型的变量 r , 调用 r.Read(b) 会使得 r 的值和切片b 的背后存储都逃逸掉,所以会在堆上分配。
指针逃逸
指针逃逸应该是最容易理解的一种情况了,即在函数中创建了一个对象,返回了这个对象的指针。这种情况下,函数虽然退出了,但是因为指针的存在,对象的内存不能随着函数结束而回收,因此只能分配在堆上。
1 | package main |
执行 go run -gcflags '-m -l' main.go
后返回以下结果:
1 | command-line-arguments |
GetUserInfo函数里面的变量 userInfo
逃到堆上了(分配到堆内存空间上了)。
GetUserInfo 函数的返回值为 *UserData 指针类型,然后 将值变量userInfo
的地址返回,此时编译器会判断该值可能会在函数外使用,就将其分配到了堆上,所以变量userInfo
就逃逸了。取地址的操作都可能会引起逃逸,包括初始化的new()。
优化方案
1 | func main() { |
1 | command-line-arguments |
对一个变量取地址,可能会被分配到堆上。但是编译器进行逃逸分析后,如果发现到在函数返回后,此变量不会被引用,那么还是会被分配到栈上。
动态类型逃逸
1 | package main |
执行 go run -gcflags '-m -l' main.go
后返回以下结果:
1 | command-line-arguments |
这里可能有同学会好奇,MyPrintln
函数内并没有被引用的便利,为什么变了name
会被分配到了堆上呢?
上一个案例我们知道了,普通的手法想去”骗取补助”,聪明灵利的编译器是不会“上当受骗的噢”;但是对于interface
类型,很遗憾,go 编译器或者链接器不可能在编译的时候计算两者的对应关系,因此只能分配到堆上。
优化方案
将结构体User
的成员name
的类型、函数MyPringLn
参数one
的类型改为 string
,将得出:
1 | command-line-arguments |
拓展分析
对于上面例子的分析,还可以通过反编译命令go tool compile -S main.go
查看,会发现如果为interface
类型,main主函数在编译后会额外多出以下指令:
1 | main.go:9 -> MyPrintln(name) |
可以参考 Golang汇编快速指南
间接赋值(Assignment to indirection escapes)
对某个引用类对象中的引用类成员进行赋值。Go 语言中的引用类数据类型有 func
, interface
, slice
, map
, chan
, *Type(point)
。
1 | package main |
执行 go run -gcflags '-m -l' main.go
后返回以下结果:
1 | command-line-arguments |
为什么这里值类型不会逃逸而引用类型会逃逸呢?这是因为在 userTwo = new(User)
对象的创建时,编译器先是分析userTwo
对象可能分配在堆上,同时成员变量 name
和 age
也为引用类型,为了保证不出现栈回收后,导致对象userTwo
的成员值也被回收,所以name
和age
需要逃逸。
但是,如果name
和age
为值类型,那么编译器虽然初步分析userTwo
会分配在堆上,但由于main
主函数结束后,变量都会被回收,也就是说对象没有被其他引用,那么就都会分配在栈上,所以name
和age
没有发生逃逸。
优化建议
尽量不要将引用对象赋值给引用对象。
栈空间不足
操作系统对内核线程使用的栈空间是有大小限制的,64 位系统上通常是 8 MB。可以使用 ulimit -a
命令查看机器上栈允许占用的内存的大小。
1 | ulimit -a |
因为栈空间通常比较小,因此递归函数实现不当时,容易导致栈溢出。
对于 Go 语言来说,运行时(runtime) 尝试在 goroutine 需要的时候动态地分配栈空间,goroutine 的初始栈大小为 2 KB。当 goroutine 被调度时,会绑定内核线程执行,栈空间大小也不会超过操作系统的限制。
对 Go 编译器而言,超过一定大小的局部变量将逃逸到堆上,不同的 Go 版本的大小限制可能不一样:
1 | func generate8191() { |
generate8191()
创建了大小为 8191 的 int 型切片,恰好小于 64 KB(64位机器上,int 占 8 字节),不包含切片内部字段占用的内存大小。generate8192()
创建了大小为 8192 的 int 型切片,恰好占用 64 KB。generate(n)
,切片大小不确定,调用时传入。
编译结果如下:
1 | go build -gcflags=-m main_stack.go |
make([]int, 8191)
没有发生逃逸,make([]int, 8192)
和make([]int, n)
逃逸到堆上,也就是说,当切片占用内存超过一定大小,或无法确定当前切片长度时,对象占用内存将在堆上分配。
闭包
一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。
1 | func Increase() func() int { |
Increase()
返回值是一个闭包函数,该闭包函数访问了外部变量 n,那变量 n 将会一直存在,直到 in
被销毁。很显然,变量 n 占用的内存不能随着函数 Increase()
的退出而回收,因此将会逃逸到堆上。
1 | go build -gcflags=-m main_closure.go |
如何利用逃逸分析提升性能
传值 VS 传指针
传值会拷贝整个对象,而传指针只会拷贝指针地址,指向的对象是同一个。传指针可以减少值的拷贝,但是会导致内存分配逃逸到堆中,增加垃圾回收(GC)的负担。在对象频繁创建和删除的场景下,传递指针导致的 GC 开销可能会严重影响性能。
一般情况下,对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。对于只读的占用内存较小的结构体,直接传值能够获得更好的性能。