本节讨论golang声明变量时,是放在栈上还是堆上的问题

引言 我:“golang 函数传参是不是应该跟 c 一样,尽量不要直接传结构体,而要传结构体指针?“leader:“不对,咱们项目很多都是直接传结构体的。“我:“那样不会造成不必要的内存 copy 开销吗?”leader:“确实会有,但这样可以减小 gc 压力,因为传值会在栈上分配,而一旦传指针,结构体就会逃逸到堆上。“我:“有道理。。。“

下面看一段代码

package mainfunc foo(arg_val int)(*int) { var foo_val int = 11; return &foo_val;}func main() { main_val := foo(666) println(*main_val)}

编译运行

$ go run pro_1.go 11

这段代码在golang中是可以正常运行的,但了解C/C++的同学都知道,这种情况是一定不被允许的。因为外部函数使用了子函数的局部变量, 理论来说,子函数的foo_val 的声明周期早就销毁了,导致无法运行。

但是golang却没有任何问题,这是为什么呢?
因为foo_val变量被分配在了堆上

堆和栈

应用程序的内存载体,我们可以简单地将其分为堆和栈。

在Go中,栈的内存是由编译器自动进行分配和释放,栈区往往存储着函数参数、局部变量和调用函数帧,它们随着函数的创建而分配,函数的退出而销毁。一个goroutine对应一个栈,栈是调用栈(call stack)的简称。一个栈通常又包含了许多栈帧(stack frame),它描述的是函数之间的调用关系,每一帧对应一次尚未返回的函数调用,它本身也是以栈形式存放数据。

举例:在一个goroutine里,函数A()正在调用函数B(),那么这个调用栈的内存布局示意图如下。


与栈不同的是,应用程序在运行时只会存在一个堆。狭隘地说,内存管理只是针对堆内存而言的。程序在运行期间可以主动从堆上申请内存,这些内存通过Go的内存分配器分配,并由垃圾收集器回收。

栈是每个goroutine独有的,这就意味着栈上的内存操作是不需要加锁的。而堆上的内存,有时需要加锁防止多线程冲突(为什么要说有时呢,因为Go的内存分配策略学习了TCMalloc的线程缓存思想,他为每个处理器P分配了一个mcache,从mcache分配内存也是无锁的)。

而且,对于程序堆上的内存回收,还需要通过标记清除阶段,例如Go采用的三色标记法。但是,在栈上的内存而言,它的分配与释放非常廉价。简单地说,它只需要两个CPU指令:一个是分配入栈,另外一个是栈内释放。而这,只需要借助于栈相关寄存器即可完成。

另外还有一点,栈内存能更好地利用CPU的缓存策略。因为它们相较于堆而言是更连续的。

Golang编译器的逃逸分析

在Go官网的FAQ 上有一个关于变量分配的问题如下:

​ go语言编译器会自动决定把一个变量放在栈还是放在堆,编译器会做逃逸分析(escape analysis),当发现变量的作用域没有跑出函数范围,就可以在栈上(但不是绝对会被分配在栈上),反之则必须分配在堆。另外,如果局部变量非常大,也会将其分配在堆上。

看如下代码:

package mainfunc foo(arg_val int) (*int) { var foo_val1 int = 11; var foo_val2 int = 12; var foo_val3 int = 13; var foo_val4 int = 14; var foo_val5 int = 15; //此处循环是防止go编译器将foo优化成inline(内联函数) //如果是内联函数,main调用foo将是原地展开,所以foo_val1-5相当于main作用域的变量 //即使foo_val3发生逃逸,地址与其他也是连续的 for i := 0; i < 5; i++ { println(&arg_val, &foo_val1, &foo_val2, &foo_val3, &foo_val4, &foo_val5) } //返回foo_val3给main函数 return &foo_val3;}func main() { main_val := foo(666) println(*main_val, main_val)}

运行结果:

$ go run pro_2.go 0xc000030758 0xc000030738 0xc000030730 0xc000082000 0xc000030728 0xc0000307200xc000030758 0xc000030738 0xc000030730 0xc000082000 0xc000030728 0xc0000307200xc000030758 0xc000030738 0xc000030730 0xc000082000 0xc000030728 0xc0000307200xc000030758 0xc000030738 0xc000030730 0xc000082000 0xc000030728 0xc0000307200xc000030758 0xc000030738 0xc000030730 0xc000082000 0xc000030728 0xc00003072013 0xc000082000

我们能看到foo_val3是返回给main的局部变量, 其中他的地址是0xc000082000,很明显与其他的foo_val1、2、3、4不是连续的.

我们用go tool compile测试一下

$ go tool compile -m pro_2.gopro_2.go:24:6: can inline mainpro_2.go:7:9: moved to heap: foo_val3

果然,在编译的时候, foo_val3具有被编译器判定为逃逸变量, 将foo_val3放在堆中开辟.

new的变量在栈还是堆?

对于new出来的变量,是一定在heap中开辟的吗,我们来看看

package mainfunc foo(arg_val int) (*int) { var foo_val1 * int = new(int); var foo_val2 * int = new(int); var foo_val3 * int = new(int); var foo_val4 * int = new(int); var foo_val5 * int = new(int); //此处循环是防止go编译器将foo优化成inline(内联函数) //如果是内联函数,main调用foo将是原地展开,所以foo_val1-5相当于main作用域的变量 //即使foo_val3发生逃逸,地址与其他也是连续的 for i := 0; i < 5; i++ { println(arg_val, foo_val1, foo_val2, foo_val3, foo_val4, foo_val5) } //返回foo_val3给main函数 return foo_val3;}func main() { main_val := foo(666) println(*main_val, main_val)}

运行结果

$ go run pro_3.go 666 0xc000030728 0xc000030720 0xc00001a0e0 0xc000030738 0xc000030730666 0xc000030728 0xc000030720 0xc00001a0e0 0xc000030738 0xc000030730666 0xc000030728 0xc000030720 0xc00001a0e0 0xc000030738 0xc000030730666 0xc000030728 0xc000030720 0xc00001a0e0 0xc000030738 0xc000030730666 0xc000030728 0xc000030720 0xc00001a0e0 0xc000030738 0xc0000307300 0xc00001a0e0

很明显, foo_val3的地址0xc00001a0e0依然与其他的不是连续的. 依然具备逃逸行为.

即:Golang中一个函数内局部变量,不管是不是动态new出来的,它会被分配在堆还是栈,是由编译器做逃逸分析之后做出的决定。

发生逃逸现象举例

逃逸分析。编译器通过逃逸分析技术去选择堆或者栈,逃逸分析的基本思想如下:检查变量的生命周期是否是完全可知的,如果通过检查,则可以在栈上分配。否则,就是所谓的逃逸,必须在堆上进行分配。

Go语言虽然没有明确说明逃逸分析规则,但是有以下几点准则,是可以参考的。

逃逸分析是在编译器完成的,这是不同于jvm的运行时逃逸分析;如果变量在函数外部没有引用,则优先放到栈中;如果变量在函数外部存在引用,则必定放在堆中;

我们可通过go build -gcflags '-m -l'命令来查看逃逸分析结果,其中-m 打印逃逸分析信息,-l禁止内联优化。

变量类型不确定暴露给外部指针变量所占内存较大变量大小不确定

以上四种发生逃逸现象的具体实例展示详见:详解 Go 逃逸分析的后半部分

总结

理解逃逸分析能帮助我们写出更好的程序。知道变量分配在栈堆之上的差别,那么我们就要尽量写出分配在栈上的代码,堆上的变量变少了,可以减轻内存分配的开销,减小gc的压力,提高程序的运行速度

所以,你会发现有些Go上线项目,它们在函数传参的时候,并没有传递结构体指针,而是直接传递的结构体。这个做法,虽然它需要值拷贝,但是这是在栈上完成的操作,开销远比变量逃逸后动态地在堆上分配内存少的多。当然该做法不是绝对的,如果结构体较大,传递指针将更合适。

因此,从GC的角度来看,指针传递是个双刃剑,需要谨慎使用,否则线上调优解决GC延时可能会让你崩溃。

参考文档:
大气的奇异果讲golang的逃逸分析
Go内存管理之代码的逃逸分析
详解 Go 逃逸分析