逃逸分析,看着一个非常高大上的名词,很多人第一次听到它的感觉会觉得它好厉害的样子,其实说到底它很好理解,并不复杂。之前一直没有写也是有原因的,因为其实在实际中,我真的很难用上它。这次写也是有原因的,因为有人催更了…其实拖了有一段时间了,最近终于忙完了,开始补债了。

image-20210702224531952

栈和堆

在说逃逸分析之前,我们需要有一些前置知识点

我们常说的栈是一种数据结构,当然这里说的栈特指我们在谈论内存分配的时候说的栈。它的作用是在函数调用的过程中保存函数的参数局部变量等数据。而且当函数调用完毕后,它所使用的栈空间将立即释放。所以它“便宜”。

堆的概念我们就应该非常熟悉了,它用来存放很多需要使用的对象,这些对象的生命,在 go 里面是交给 GC 去管理的,当我们再也不使用的时候,GC 会将它们回收。所以它“贵”,因为它需要额外的做功才能将它回收掉。

为什么?

那为什么需要堆?

不用堆不行吗?其实答案显然不行,因为如果所有的变量对象都在栈上,用完了就扔掉,那么其他人想要再使用的时候就无法使用了。

那全部都在堆上不行吗?

答案也很显然不行,因为栈便宜,用完就扔,堆很贵,你不能将所有的东西都扔给 GC,这样它要累死。

那对象到底在哪?

我怎么知道我使用的这个对象应该放在哪里?我再写程序的时候也没有手动去指定要将对象放在哪里鸭!

没错,go 帮我们做了这个事情,它会聪明的去确定,你使用的对象到底应该放在哪里最合适,编译阶段它就会做这个事情。而确定对象在栈上还是堆上的过程就是我们今天的主角 —— 逃逸分析

escape-analysis-wuzui

逃逸分析

定义

其实刚才我们就已经知道了,逃逸分析就是帮我们确定,我们所使用的对象应该放在栈上还是堆上。那么我一开始的想法就很直接了,那不是挺简单的吗,如果这个对象在当前函数外面还在用的时候就应该在堆上,如果只在函数里面用,不就在栈上了吗?但是其实情况远远比我想的要复杂许多….

怎么看?

首先我们必须要有工具来进行逃逸分析,当让 go 提供了这个工具

go build -gcflags '-m -l' main.go

其中的 -m 就是会打印出逃逸分析的优化策略,可以多加 m 来查看更加详细的信息,其中的 -l 是禁用函数内联以防止内联优化导致无法正确观察到逃逸的情况

举个🌰

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

import "fmt"

func main() {
bigBytes()
}

func bigBytes() {
a := make([]int64, 8192)
fmt.Println(len(a))
return
}

这真的是我见过最让人眼前一亮的逃逸分析的第一个案例,乍一看,啊?这也能逃逸?没错它逃了,这里的 a 逃了。让我们用命令一敲便知。

1
2
3
4
5
6
➜ go build -gcflags '-m -l' main.go

# command-line-arguments
./main.go:10:11: make([]int64, 8192) escapes to heap
./main.go:11:13: ... argument does not escape
./main.go:11:17: len(a) escapes to heap

其实原因也很简单,这里的 a slice 太大了,栈放不下了,所以只能放到堆上了

案例说明

案例来源:https://github.com/golang/go/tree/master/test

golang 的单元测试肯定包含了大多数出现逃逸情况,情况数量极多,下面举例其中一些

将内部变量作为指针返回

显然当你作为指针返回后,外部就可能会使用和修改,就必须在堆上,不能随着函数返回就不见了

1
2
3
4
5
func i_escapes(x int) *int {
var i int
i = x
return &i
}

在其他协程访问返回值

这里的 x 在其他协程还在赋值修改它,所以只能在堆上了

1
2
3
4
5
6
func defer1(i int) (x int) {
c := make(chan int)
go func() { x = i; c <- 1 }()
<-c
return
}

在其他函数访问返回值

特别是还有很多一些闭包表达式操作经常会出现逃逸的问题

1
2
3
4
5
6
func foo21() func() int {
x := 42
return func() int { // ERROR "func literal escapes to heap$"
return x
}
}

将函数参数用在别的地方

1
2
3
4
5
6
7
8
9
10
var x *int

func fooleak(xx *int) int { // ERROR "leaking param: xx$"
x = xx
return *x
}

func foo31(x int) int { // ERROR "moved to heap: x$"
return fooleak(&x)
}

将 slice 中的某个元素指针返回

这里 b 中的某个元素在外部被使用了

1
2
3
4
5
6
7
8
9
10
11
var save151 *int

func foo151(x *int) { // ERROR "leaking param: x$"
save151 = x
}

func bar151b() {
var a [10]int // ERROR "moved to heap: a$"
b := a[:]
foo151(&b[4:8][0])
}

大 slice 或不确定的 slice

太大的 slice 无法分配到栈上,而一些无法确定长度的 slice 也无法分配到栈上

1
2
3
4
5
6
7
8
9
10
11
12
13
// BAD: x need not leak.
func doesMakeSlice(x *string, y *string) { // ERROR "leaking param: x" "leaking param: y"
a := make([]*string, 10) // ERROR "make\(\[\]\*string, 10\) does not escape"
a[0] = x
b := make([]*string, 65537) // ERROR "make\(\[\]\*string, 65537\) escapes to heap"
b[0] = y
}

func nonconstArray() {
n := 32
s1 := make([]int, n) // ERROR "make\(\[\]int, n\) escapes to heap"
s2 := make([]int, 0, n) // ERROR "make\(\[\]int, 0, n\) escapes to heap"
}

这里的 s1 也是会分配到堆上的,因为你不知道这个 slice 会扩容成什么大小

1
2
3
4
5
6
7
func nonconstArray() {
s1 := make([]int, 0)
for i := 0; i < 10; i++ {
s1 = append(s1, i)
}
fmt.Println(s1)
}

字符串作为返回值

这里的 t 逃逸了,因为字符串作为返回值出去了

1
2
3
4
func stringEs(s string) string { // ERROR "s does not escape"
t := s + "YYYY" // ERROR "escapes to heap"
return t // ERROR "... argument does not escape"
}

全局变量赋值

这个很直观,你都赋值给全局变量了那肯定逃逸了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var sink interface{}

type X struct {
p1 *int
p2 *int
a [2]*int
}
type Y struct {
x X
}

func field18() {
i := 0 // ERROR "moved to heap: i$"
var x X
// BAD: &i should not escape
x.p1 = &i
var iface interface{} = x // ERROR "x does not escape"
y, _ := iface.(Y) // Put X, but extracted Y. The cast will fail, so y is zero initialized.
sink = y // ERROR "y escapes to heap"
}

atomic 操作

当然将一些内部变量通过 atomic 操作放到全局变量上,肯定也会逃逸的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var ptr unsafe.Pointer

func StorePointer() {
var x int // ERROR "moved to heap: x"
atomic.StorePointer(&ptr, unsafe.Pointer(&x))
}

func SwapPointer() {
var x int // ERROR "moved to heap: x"
atomic.SwapPointer(&ptr, unsafe.Pointer(&x))
}

func CompareAndSwapPointer() {
// BAD: x doesn't need to be heap allocated
var x int // ERROR "moved to heap: x"
var y int // ERROR "moved to heap: y"
atomic.CompareAndSwapPointer(&ptr, unsafe.Pointer(&x), unsafe.Pointer(&y))
}

总结

  1. 其实逃逸的情况非常多,你完全没有必要去死记硬背的,情况能想到的也就那一些,即使有一些特殊情况,只要你敲下命令自然就出来了。总的来说大多数情况都是由于将内部变量作为返回值或者在其他函数中使用,或者是作为全局变量赋值等等….

  2. 我写这个博客的目的,只是说当你写代码的时候有这样的意识,这样优化的时候会有思路,去调整一些参数,减少一些全局变量的设置等等。

  3. 逃逸分析本身并不神奇,神奇的是 go 去实现逃逸分析的代码 cmd/compile/internal/gc/escape. go

  4. 最后重点来了:请你暂时忘记它吧,其实大部分的业务场景都用不到它,因为绝大多数的 OOM 并不会简简单单因为你的变量逃逸而出现问题;大部分的 GC 时间长也并非因为逃逸导致;所以请先分析瓶颈,找到关键瓶颈后再进行优化,不要一上来就逃逸分析半天,结果发现加个索引就好了。