今天偶然看到Golang关于内存的文章,其中涉及了一点逃逸分析,由于去年之前都是专研C++,Golang也是去年11月才开始学习的,学完就马上进入项目了,没有深究底层,准备这段时间边改论文边开始仔细学一下Golang。
测试环境:
首先是逃逸分析的介绍
C/C++和Golang的变量内存分配方式不一样,前者是程序员来决定,比如可以使用malloc/new来将对象存在堆上,而Golang是编译器根据变量决定内存分配位置的方式,分配的方法叫做逃逸分析(escape analysis),这个阶段在编译时期完成,与JVM不一样,JVM是运行时期进行逃逸分析。
C++例子
其次在看一个C++下的内存空间的例子,C++不允许函数返回在函数体内初始化变量的指针:
#include <stdio.h>
int *escape_test(int arg_val) {
int foo_val = 11;
return &foo_val;
}
int main()
{
int *main_val = escape_test(666);
printf("%d\n", *main_val);
}
编译发生warning【返回了局部变量的地址】,随即运行,发生了core dump
本地没有core文件,忘记打开了core限制,使用命令:
ulimit -c unlimited
//开启 core dump 功能
然后重新编译并开启gdb调试可以看到错误现场:
gdb ./app core
错误原因和warning原因一致:是由于函数返回了局部变量的地址。学过C++的都知道普通函数局部变量是在栈上实现内存分配的,随着普通函数的结束,栈上的变量将会被自动释放,如果这时候返回变量的地址将导致不确定的结果。
Golang逃逸
而将上述代码使用Golang的语法进行翻译:
package main
func escape_test(arg_val int)(*int) {
var foo_val int = 11;
return &foo_val;
}
func main() {
main_val := escape_test(45)
println(*main_val)
}
运行顺利,并得到输出:
ubuntu@VM-4-8-ubuntu:~/go/src/go_code/escape_01$ go run main.go
11
这就是Go的逃逸分析所做的事情,通过编译器来决定对将变量分配在哪,当发现变量的作用域没有跑出函数范围,则可以分配在栈上,反之则必须分配在堆。
例子:
package main
// golang逃逸
func escape_test(arg int)( *int){
var param_1 int =1;
var param_2 int =2;
var param_3 int =3;
var param_4 int =4;
var param_5 int =5;
for i := 0; i < 5; i++ {
println(&arg,¶m_1,¶m_2,¶m_3,¶m_4,¶m_5)
}
return ¶m_3
}
func main() {
main_val := escape_test(666)
println(*main_val, main_val)
}
这个example是在一个博客上看到的,然后自己重新写的时候遇到了两个坑,主要是编译器优化导致的等下可能会细说这两个坑。运行上述代码,会得到结果:
可以看出param_3和其他变量不是连续分配在一块的,而其他四个变量是存储在一起的,使用go内嵌的编译命令可以发现,param_3逃逸到堆上了。
ubuntu@VM-4-8-ubuntu:~/go/src/go_code/escape_01$ go tool compile -m main.go
main.go:18:6: can inline main
main.go:15:9: ¶m_3 escapes to heap
main.go:8:6: moved to heap: param_3
main.go:13:11: escape_test &arg does not escape
main.go:13:16: escape_test ¶m_1 does not escape
main.go:13:25: escape_test ¶m_2 does not escape
main.go:13:34: escape_test ¶m_3 does not escape
main.go:13:43: escape_test ¶m_4 does not escape
main.go:13:52: escape_test ¶m_5 does not escape
通过tool compile工具得到汇编语句,可以看出,param_3变量是被分配在堆上的:
接下来,将五个局部变量都通过new的方式进行创建:
package main
// golang逃逸
func escape_test(arg int)( *int){
var param_1 *int =new(int);
var param_2 *int =new(int);
var param_3 *int =new(int);
var param_4 *int =new(int);
var param_5 *int =new(int);
for i := 0; i < 5; i++ {
println(&arg,param_1,param_2,param_3,param_4,param_5)
}
return param_3
}
func main() {
main_val := escape_test(666)
println(*main_val, main_val)
}
运行结果:
ubuntu@VM-4-8-ubuntu:~/go/src/go_code/escape_01$ go run main.go
0xc00003a768 0xc00003a738 0xc00003a730 0xc000018070 0xc00003a748 0xc00003a740
0xc00003a768 0xc00003a738 0xc00003a730 0xc000018070 0xc00003a748 0xc00003a740
0xc00003a768 0xc00003a738 0xc00003a730 0xc000018070 0xc00003a748 0xc00003a740
0xc00003a768 0xc00003a738 0xc00003a730 0xc000018070 0xc00003a748 0xc00003a740
0xc00003a768 0xc00003a738 0xc00003a730 0xc000018070 0xc00003a748 0xc00003a740
0 0xc000018070
可以发现param1、 param2、param4、param5与param3的地址依旧不连续,尽管都分配在堆上,但还是发生了逃逸。
逃逸场景
- 指针逃逸:如果一个函数内部创建了一个对象(局部变量),但是在函数返回时是返回该对象的指针,那么该变量的生命周期就变了,即使当前函数执行结束了,但是变量的指针还在,并不是随着函数结束就被回收的,那么这个局部变量就会被分配在堆上,这就产生了指针逃逸。
- 栈空间不足逃逸:栈空间不足以存放当前对象或者无法判断当前切片长度时会将对象分配到堆中
- 动态类型逃逸:函数的参数为interface类型,编译期间很难确定参数的具体类型,也会产生逃逸
- 变量大小不确定:
- 闭包引用对象逃逸:由于闭包的引用,不得不将引用对象其放到堆中,以至于产生逃逸
-
引用类成员的引用:一般我们给一个引用类对象中的引用类成员进行赋值,可能出现逃逸现象。可以理解为访问一个引用对象实际上底层就是通过一个指针来间接的访问了,但如果再访问里面的引用成员就会有第二次间接访问,这样操作这部分对象的话,极大可能会出现逃逸的现象。Go语言中的引用类型有func(函数类型),interface(接口类型),slice(切片类型),map(字典类型),channel(管道类型),*(指针类型)等。
第六个场景的例子有:[]interface{}
数据类型、map[string]interface{}、map[interface{}]interface{}、map[string][]string、[]*int、func(*int)、chan []string等,对这些数据类型赋值的时候会对传递的值进行逃逸
Q1:传值还是传指针?
传值会拷贝整个对象,而传指针只会拷贝指针地址,指向的对象是同一个。传指针可以减少值的拷贝,但是会导致内存分配逃逸到堆中,增加垃圾回收(GC)的负担。在对象频繁创建和删除的场景下,传递指针导致的 GC 开销可能会严重影响性能。
一般情况下,对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。对于只读的占用内存较小的结构体,直接传值能够获得更好的性能。
坑:
我在测试golang逃逸的时候出现了一个坑,那就是go编译器将函数escape_test优化成inline(内联函数),如果是内联函数,main调用escape_test将是原地展开,所以param1-5相当于main作用域的变量,这就不会发生逃逸
package main
// golang逃逸
func escape_test(arg int)( *int){
var param_1 int =1;
var param_2 int =2;
var param_3 int =3;
var param_4 int =4;
var param_5 int =5;
println(&arg,¶m_1,¶m_2,¶m_3,¶m_4,¶m_5)
return ¶m_3
}
func main() {
main_val := escape_test(666)
println(*main_val, main_val)
}
而导致的结果是:
即加了循环就会防止escape_test成为内联函数,理由:
函数调用是存在一些固定开销的,例如维护帧指针寄存器BP、栈溢出检测等。因此,对于一些代码行比较少的函数,编译器倾向于将它们在编译期展开从而消除函数调用,这种行为就是内联。【其他关于内联的可以看扩展知识】
扩展知识:
1、go tool compile常见操作
go tool compile [flags] file...-N Disable optimizations. -S Print assembly listing to standard output (code only). -l Disable inlining. -m Print optimization decisions. Higher values or repetition produce more detail. -pack Write a package (archive) file rather than an object file -race Compile with race detector enabled. -s Warn about composite literals that can be simplified.
2、
go build -gcflags="-m -m" main.go
命令 查看编译器的优化策略。
函数由于存在循环语句并不能被内联:cannot inline iter: unhandled op FOR;
实际上,除了for
循环,还有一些情况不会被内联,例如闭包,select
,for
,defer
,go
关键字所开启的新goroutine等。
Go程序编译时,默认将进行内联优化。我们可通过-gcflags="-l"
选项全局禁用内联,与一个-l
禁用内联相反,如果传递两个或两个以上的-l
则会打开内联,并启用更激进的内联策略。如果不想全局范围内禁止优化,则可以在函数定义时添加 //go:noinline
编译指令来阻止编译器内联函数。