6.8 逃逸分析:变量在哪儿
我们在排查问题或写代码的时候,有时候会在想这个变量到底分配到哪里了,这时候可能会有人说,在栈上,在堆上,信我准没错,某语言上就是这样子的,就是这样改。
但从结果上来讲你还是一知半解,这可万万不行,因为其它语言和 Go 语言不一定一样,这个版本 Go 语言的逃逸分析,又不一定和前几个版本一样了。因此今天我们一起来了解 Go 语言在逃逸分析这块的内容,自己动手丰衣足食,希望你能够掌握基本的分析技巧。
6.8.1 思考
type User struct {
ID int64
Name string
Avatar string
}
func GetUserInfo() *User {
return &User{ID: 13746731, Name: "eddycjy", Avatar: "https://avatars0.githubusercontent.com/u/13746731"}
}
func main() {
_ = GetUserInfo()
}
请问 main 调用 GetUserInfo
后返回的 &User{...}
。这个变量是分配到栈上了呢,还是分配到堆上了,建议你写下答案和你的考量的原因。
6.8.2 什么是堆/栈
- 堆(Heap):一般来讲是人为手动进行管理,手动申请、分配、释放。一般所涉及的内存大小并不定,一般会存放较大的对象。另外其分配相对慢,涉及到的指令动作也相对多
- 栈(Stack):由编译器进行管理,自动申请、分配、释放。一般不会太大,我们常见的函数参数(不同平台允许存放的数量不同),局部变量等等都会存放在栈上
今天我们介绍的 Go 语言,它的堆栈分配是通过 Compiler 进行分析,GC 去管理的,而对其的分析选择动作就是今天探讨的重点。
6.8.3 什么是逃逸分析
在编译程序优化理论中,逃逸分析是一种确定指针动态范围的方法,简单来说就是分析在程序的哪些地方可以访问到该指针,通俗地讲,逃逸分析就是确定一个变量要放堆上还是栈上,规则如下:
- 是否有在其他地方(非局部)被引用。只要有可能被引用了,那么它一定分配到堆上。否则分配到栈上
- 即使没有被外部引用,但对象过大,无法存放在栈区上。依然有可能分配到堆上
对此你可以理解为,逃逸分析是编译器用于决定变量分配到堆上还是栈上的一种行为。
6.8.4 在什么阶段确立逃逸
Go 语言是在编译阶段确立逃逸,注意并不是在运行时。
6.8.5 为什么需要逃逸
这个问题我们可以反过来想,如果变量都分配到堆上了会出现什么事情?例如:
- 垃圾回收(GC)的压力不断增大
- 申请、分配、回收内存的系统开销增大(相对于栈)
- 动态分配产生一定量的内存碎片
其实总的来说,就是频繁申请、分配堆内存是有一定 “代价” 的。会影响应用程序运行的效率,间接影响到整体系统。因此 “按需分配” 最大限度的灵活利用资源,才是正确的治理之道,这就是为什么需要逃逸分析的原因之一。
6.8.6 怎么确定是否逃逸
第一种,我们可以通过编译器提供的指令 -gcflags
就可以看到详细的逃逸分析过程,命令如下:
$ go build -gcflags '-m -l' main.go
其指令涉及的参数如下:
-m
:打印出逃逸分析的优化策略,实际上最多总共可以用 4 个-m
,但是这样子调试的信息量较大,一般用一个就足够了。-l
:禁用函数内联,在这里禁用掉 inline 能更好的观察逃逸情况,减少干扰。
第二种,通过反编译命令查看,如下:
$ go tool compile -S main.go
注:可以通过 go tool compile -help
查看所有允许传递给编译器的标识参数
6.8.7 逃逸案例
6.8.7.1 案例一:指针
首先我们看到一开始抛出的问题,代码如下:
type User struct {
ID int64
Name string
Avatar string
}
func GetUserInfo() *User {
return &User{ID: 13746731, Name: "eddycjy", Avatar: "https://avatars0.githubusercontent.com/u/13746731"}
}
func main() {
_ = GetUserInfo()
}
执行命令观察一下,如下:
$ go build -gcflags '-m -l' main.go
# command-line-arguments
./main.go:10:54: &User literal escapes to heap
通过查看分析结果,可得知 &User
逃到了堆里,也就是分配到堆上了,这是为什么呢,这是不是有问题…我们再看看汇编代码确定一下,如下:
$ go tool compile -S main.go
"".GetUserInfo STEXT size=190 args=0x8 locals=0x18
0x0000 00000 (main.go:9) TEXT "".GetUserInfo(SB), $24-8
...
0x0028 00040 (main.go:10) MOVQ AX, (SP)
0x002c 00044 (main.go:10) CALL runtime.newobject(SB)
0x0031 00049 (main.go:10) PCDATA $2, $1
0x0031 00049 (main.go:10) MOVQ 8(SP), AX
0x0036 00054 (main.go:10) MOVQ $13746731, (AX)
0x003d 00061 (main.go:10) MOVQ $7, 16(AX)
0x0045 00069 (main.go:10) PCDATA $2, $-2
0x0045 00069 (main.go:10) PCDATA $0, $-2
0x0045 00069 (main.go:10) CMPL runtime.writeBarrier(SB), $0
0x004c 00076 (main.go:10) JNE 156
0x004e 00078 (main.go:10) LEAQ go.string."eddycjy"(SB), CX
...
我们将目光集中到 CALL 指令,发现其执行了 runtime.newobject
方法,也就是确实是分配到了堆上。
6.8.7.1.1 分析结果
为什么呢,实际上是因为 GetUserInfo()
返回的是指针对象,引用被返回到了方法之外了。因此编译器会把该对象分配到堆上,而不是栈上,否则方法结束之后,局部变量就被回收了,那么就会出现问题。因此最终分配到堆上是相对合理的。
6.8.7.1.2 再想想
你可能误理解为所有的指针对象,都应该会被分配到在堆上,但其实不是这样的,代码如下:
func main() {
str := new(string)
*str = "eddycjy"
}
你想想这个对象会分配到哪里?如下:
$ go build -gcflags '-m -l' main.go
# command-line-arguments
./main.go:4:12: main new(string) does not escape
显然,该对象分配到栈上了。很核心的一点就是它有没有被作用域之外所引用,而这里作用域仍然保留在 main
中,因此它没有发生逃逸,因此与作用域也是决定是否逃逸的重要原因之一。
6.8.7.2 案例二:未确定类型
func main() {
str := new(string)
*str = "eddycjy"
fmt.Println(str)
}
执行命令观察一下,如下:
$ go build -gcflags '-m -l' main.go
# command-line-arguments
./main.go:6:12: new(string) escapes to heap
./main.go:9:13: main ... argument does not escape
./main.go:9:13: str escapes to heap
通过查看分析结果,可得知 str
变量逃到了堆上,也就是该对象在堆上分配。但上个案例时它还在栈上,我们也就 fmt
输出了它而已,这到底是为什么?
6.8.7.2.1 分析结果
相对上个案例,本案例只加了一行代码 fmt.Println(str)
,却居然直接造成了从栈到堆的分配,我们考虑问题肯定出在它身上,我们一起看看其方法原型:
func Println(a ...interface{}) (n int, err error)
通过对其分析,你可以发现去形参是 interface{}
,它就是造成这一问题的原因,我们可以得知当形参为 interface
类型时,在编译阶段编译器因为无法确定其具体的类型,因此会造成逃逸,最终将该变量分配到堆上。
从源码来讲的话,实际上是该方法内部的 reflect.TypeOf(arg).Kind()
语句造成逃逸,因此表象就是 interface
类型会导致该对象分配到堆上。
6.8.7.3 案例三、泄露参数
type User struct {
ID int64
Name string
Avatar string
}
func GetUserInfo(u *User) *User {
return u
}
func main() {
_ = GetUserInfo(&User{ID: 13746731, Name: "eddycjy", Avatar: "https://avatars0.githubusercontent.com/u/13746731"})
}
6.8.7.3.1 分析结果
$ go build -gcflags '-m -l' main.go
# command-line-arguments
./main.go:9:18: leaking param: u to result ~r1 level=0
./main.go:14:63: main &User literal does not escape
我们注意到 leaking param
的表述,它说明了变量 u
是一个泄露参数。那泄露参数是什么意思呢?
我们结合代码可以得到答案,在代码中其传给 GetUserInfo
方法后,没有做任何引用之类的涉及变量的动作,直接就把这个变量返回出去了。因此这个变量实际上并没有逃逸,它的作用域还在 main()
之中,所以最终分配在栈上。
6.8.7.3.2 再想想
那你再想想怎么样才能让它分配到堆上,结合案例一,我们可以举一反三,修改代码如下:
type User struct {
ID int64
Name string
Avatar string
}
func GetUserInfo(u User) *User {
return &u
}
func main() {
_ = GetUserInfo(User{ID: 13746731, Name: "eddycjy", Avatar: "https://avatars0.githubusercontent.com/u/13746731"})
}
执行命令观察一下,如下:
$ go build -gcflags '-m -l' main.go
# command-line-arguments
./main.go:10:9: &u escapes to heap
./main.go:9:18: moved to heap: u
只要一小改,它就考虑会被外部所引用,因此妥妥的分配到堆上了。
6.8.8 小结
在本章节我给你介绍了逃逸分析的概念和规则,并列举了一些例子加深理解。但实际肯定远远不止这些案例,你需要做到的是掌握逃逸分析的方法,当你正在遇到或考虑是这类瓶颈时,再进行分析,然后针对优化就好了。
除此之外你还需要加深以下几点的认知:
- 静态分配到栈上,性能一般会比动态分配到堆上好。
- 底层分配到堆,还是栈,实际上对你来说是透明的,不需要过度关心、纠结。
- 每个 Go 版本的逃逸分析都会有所不同(会改变,会优化)。
- 到处都用指针传递并不一定是最好的,要合理的用对。
- 遇到怀疑,直接通过
go build -gcflags '-m -l'
可以看到逃逸分析的过程和结果,胜过道听途说。