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

  1. 减少GC压力,栈上的变量,随着函数退出后系统直接回收,不需要GC标记后再清除。
  2. 减少内存碎片的产生。
  3. 减轻分配堆内存的开销,提高程序的运行速度。

逃逸案例

能引起变量逃逸到堆上的典型情况

  • 在方法内把局部变量指针返回 局部变量原本应该在栈中分配,在栈中回收。但是由于返回时被外部引用,因此其生命周期大于栈,则溢出。
  • 发送指针或带有指针的值到 channel 中。 在编译时,是没有办法知道哪个 goroutine 会在 channel 上接收数据。所以编译器没法知道变量什么时候才会被释放。
  • 在一个切片上存储指针或带指针的值。 一个典型的例子就是 []*string 。这会导致切片的内容逃逸。尽管其后面的数组可能是在栈上分配的,但其引用的值一定是在堆上。
  • slice 的背后数组被重新分配了,因为 append 时可能会超出其容量( cap )。 slice 初始化的地方在编译时是可以知道的,它最开始会在栈上分配。如果切片背后的存储要基于运行时的数据进行扩充,就会在堆上分配。
  • 在 interface 类型上调用方法。 在 interface 类型上调用方法都是动态调度的 —— 方法的真正实现只能在运行时知道。想像一个 io.Reader 类型的变量 r , 调用 r.Read(b) 会使得 r 的值和切片b 的背后存储都逃逸掉,所以会在堆上分配。

指针逃逸

指针逃逸应该是最容易理解的一种情况了,即在函数中创建了一个对象,返回了这个对象的指针。这种情况下,函数虽然退出了,但是因为指针的存在,对象的内存不能随着函数结束而回收,因此只能分配在堆上。

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

type UserData struct {
Name string
}

func main() {
var info UserData
info.Name = "WilburXu"
_ = GetUserInfo(info)
}

func GetUserInfo(userInfo UserData) *UserData {
return &userInfo
}

执行 go run -gcflags '-m -l' main.go 后返回以下结果:

1
2
3
# command-line-arguments
.\main.go:14:9: &userInfo escapes to heap
.\main.go:13:18: moved to heap: userInfo

GetUserInfo函数里面的变量 userInfo 逃到堆上了(分配到堆内存空间上了)。

GetUserInfo 函数的返回值为 *UserData 指针类型,然后 将值变量userInfo 的地址返回,此时编译器会判断该值可能会在函数外使用,就将其分配到了堆上,所以变量userInfo就逃逸了。取地址的操作都可能会引起逃逸,包括初始化的new()。

优化方案

1
2
3
4
5
6
7
8
9
func main() {
var info UserData
info.Name = "WilburXu"
_ = GetUserInfo(&info)
}

func GetUserInfo(userInfo *UserData) *UserData {
return userInfo
}
1
2
3
# command-line-arguments
.\main.go:13:18: leaking param: userInfo to result ~r1 level=0
.\main.go:10:18: main &info does not escape

对一个变量取地址,可能会被分配到堆上。但是编译器进行逃逸分析后,如果发现到在函数返回后,此变量不会被引用,那么还是会被分配到栈上。

动态类型逃逸

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

type User struct {
name interface{}
}

func main() {
name := "WilburXu"
MyPrintln(name)
}

func MyPrintln(one interface{}) (n int, err error) {
var userInfo = new(User)
userInfo.name = one // 泛型赋值 逃逸
return
}

执行 go run -gcflags '-m -l' main.go 后返回以下结果:

1
2
# command-line-arguments
./main.go:12:16: leaking param: one./main.go:13:20: MyPrintln new(User) does not escape./main.go:9:11: name escapes to heap

这里可能有同学会好奇,MyPrintln函数内并没有被引用的便利,为什么变了name会被分配到了堆上呢?

上一个案例我们知道了,普通的手法想去”骗取补助”,聪明灵利的编译器是不会“上当受骗的噢”;但是对于interface类型,很遗憾,go 编译器或者链接器不可能在编译的时候计算两者的对应关系,因此只能分配到堆上。

优化方案

将结构体User的成员name的类型、函数MyPringLn参数one的类型改为 string,将得出:

1
2
3
# command-line-arguments
./main.go:12:16: leaking param: one
./main.go:13:20: MyPrintln new(User) does not escape

拓展分析

对于上面例子的分析,还可以通过反编译命令go tool compile -S main.go查看,会发现如果为interface类型,main主函数在编译后会额外多出以下指令:

1
2
3
4
5
6
7
8
9
# main.go:9 -> MyPrintln(name)
0x001d 00029 (main.go:9) PCDATA $2, $1
0x001d 00029 (main.go:9) PCDATA $0, $1
0x001d 00029 (main.go:9) LEAQ go.string."WilburXu"(SB), AX 0x0024 00036 (main.go:9) PCDATA $2, $0
0x0024 00036 (main.go:9) MOVQ AX, ""..autotmp_5+32(SP) 0x0029 00041 (main.go:9) MOVQ $8, ""..autotmp_5+40(SP) 0x0032 00050 (main.go:9) PCDATA $2, $1
0x0032 00050 (main.go:9) LEAQ type.string(SB), AX 0x0039 00057 (main.go:9) PCDATA $2, $0
0x0039 00057 (main.go:9) MOVQ AX, (SP) 0x003d 00061 (main.go:9) PCDATA $2, $1
0x003d 00061 (main.go:9) LEAQ ""..autotmp_5+32(SP), AX 0x0042 00066 (main.go:9) PCDATA $2, $0
0x0042 00066 (main.go:9) MOVQ AX, 8(SP) 0x0047 00071 (main.go:9) CALL runtime.convT2Estring(SB)

可以参考 Golang汇编快速指南

间接赋值(Assignment to indirection escapes)

对某个引用类对象中的引用类成员进行赋值。Go 语言中的引用类数据类型有 funcinterfaceslicemapchan*Type(point)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

type User struct {
name interface{}
age *int
}

func main() {
var (
userOne User
userTwo = new(User)
)
userOne.name = "WilburXuOne" // 不逃逸
userTwo.name = "WilburXuTwo" // 逃逸

userOne.age = new(int) // 不逃逸
userTwo.age = new(int) // 逃逸
}

执行 go run -gcflags '-m -l' main.go 后返回以下结果:

1
2
3
4
5
6
# command-line-arguments
.\main.go:14:17: "WilburXuTwo" escapes to heap
.\main.go:17:19: new(int) escapes to heap
.\main.go:11:16: main new(User) does not escape
.\main.go:13:17: main "WilburXuOne" does not escape
.\main.go:16:19: main new(int) does not escape

为什么这里值类型不会逃逸而引用类型会逃逸呢?这是因为在 userTwo = new(User) 对象的创建时,编译器先是分析userTwo 对象可能分配在堆上,同时成员变量 name 和 age 也为引用类型,为了保证不出现栈回收后,导致对象userTwo的成员值也被回收,所以nameage需要逃逸。

但是,如果nameage为值类型,那么编译器虽然初步分析userTwo会分配在堆上,但由于main主函数结束后,变量都会被回收,也就是说对象没有被其他引用,那么就都会分配在栈上,所以nameage没有发生逃逸。

优化建议

尽量不要将引用对象赋值给引用对象。

栈空间不足

操作系统对内核线程使用的栈空间是有大小限制的,64 位系统上通常是 8 MB。可以使用 ulimit -a 命令查看机器上栈允许占用的内存的大小。

1
2
3
$ ulimit -a
-s: stack size (kbytes) 8192
-n: file descriptors 12800

因为栈空间通常比较小,因此递归函数实现不当时,容易导致栈溢出。

对于 Go 语言来说,运行时(runtime) 尝试在 goroutine 需要的时候动态地分配栈空间,goroutine 的初始栈大小为 2 KB。当 goroutine 被调度时,会绑定内核线程执行,栈空间大小也不会超过操作系统的限制。

对 Go 编译器而言,超过一定大小的局部变量将逃逸到堆上,不同的 Go 版本的大小限制可能不一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func generate8191() {
nums := make([]int, 8191) // < 64KB
for i := 0; i < 8191; i++ {
nums[i] = rand.Int()
}
}

func generate8192() {
nums := make([]int, 8192) // = 64KB
for i := 0; i < 8192; i++ {
nums[i] = rand.Int()
}
}

func generate(n int) {
nums := make([]int, n) // 不确定大小
for i := 0; i < n; i++ {
nums[i] = rand.Int()
}
}

func main() {
generate8191()
generate8192()
generate(1)
}
  • generate8191() 创建了大小为 8191 的 int 型切片,恰好小于 64 KB(64位机器上,int 占 8 字节),不包含切片内部字段占用的内存大小。
  • generate8192() 创建了大小为 8192 的 int 型切片,恰好占用 64 KB。
  • generate(n),切片大小不确定,调用时传入。

编译结果如下:

1
2
3
4
5
$ go build -gcflags=-m main_stack.go
# command-line-arguments
./main_stack.go:9:14: generate8191 make([]int, 8191) does not escape
./main_stack.go:16:14: make([]int, 8192) escapes to heap
./main_stack.go:23:14: make([]int, n) escapes to heap

make([]int, 8191) 没有发生逃逸,make([]int, 8192) 和make([]int, n) 逃逸到堆上,也就是说,当切片占用内存超过一定大小,或无法确定当前切片长度时,对象占用内存将在堆上分配。

闭包

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。

1
2
3
4
5
6
7
8
9
10
11
12
13
func Increase() func() int {
n := 0
return func() int {
n++
return n
}
}

func main() {
in := Increase()
fmt.Println(in()) // 1
fmt.Println(in()) // 2
}

Increase() 返回值是一个闭包函数,该闭包函数访问了外部变量 n,那变量 n 将会一直存在,直到 in 被销毁。很显然,变量 n 占用的内存不能随着函数 Increase() 的退出而回收,因此将会逃逸到堆上。

1
2
3
$ go build -gcflags=-m main_closure.go 
# command-line-arguments
./main_closure.go:6:2: moved to heap: n

如何利用逃逸分析提升性能

传值 VS 传指针

传值会拷贝整个对象,而传指针只会拷贝指针地址,指向的对象是同一个。传指针可以减少值的拷贝,但是会导致内存分配逃逸到堆中,增加垃圾回收(GC)的负担。在对象频繁创建和删除的场景下,传递指针导致的 GC 开销可能会严重影响性能。

一般情况下,对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。对于只读的占用内存较小的结构体,直接传值能够获得更好的性能。