写在开头
非原创,知识搬运工,本节介绍了GO的函数栈,函数传值和返回值
demo代码地址
b站看动画学函数栈
目录
带着问题去阅读
1.程序在内存中主要的存在形式
2.栈和栈帧是什么关系
3.GO的函数栈帧是怎么样的
4.GO函数的参数是什么传递
5.为什么值传递不能影响本地变量
6.为什么传指针能影响
7.defer如何改变返回值
1.GO函数栈
详细内容在这里
进程在内存中的布局主要分为4个部分:代码区、数据区、堆和栈
- 代码区。包括能被CPU执行的机器码(指令,我们的程序最后都会被编译器转为一堆机器能读懂的机器指令)和只读数据(如字符串常量),当操作系统把程序加载进内存后,代码区大小不会再变化
- 数据区。包括程序的全局变量和静态变量(C有,GO没有静态变量),和代码区一样,加载完毕后大小不变
- 堆。程序运行过程中动态分配的内存都在堆中,堆大小随程序运行而改变。当向堆分请求分配内存,若堆内存不足,则向操作系统申请向高地址方向扩展堆大小,反之释放内存归还与堆时,若发现空闲内存太多,则向操作系统请求向低地址收缩堆大小。也就是说从堆上分配的内存用完之后必须还给堆,否则堆不断向高地址扩展,导致内存越用越多,最后内存不足,也就是内存泄露
- 栈。在程序运行过程中,会涉及函数的调用,函数返回后继续执行本函数剩下的代码,程序就是靠栈来组织这一个调用过程。
栈(操作系统):由操作系统自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
堆(操作系统): 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收,分配方式倒是类似于链表。
1.1.栈与栈帧
栈stack是程序(进程)执行单元(线程或协程)在执行过程中记录程序上下文的数据结构,用于保存函数调用过程中的各种数据(如本地变量、参数、返回值)。每一个程序执行单元(线程或协程)都有一个独立的栈,伴随其整个生命周期,这样保证了执行单元能够并行执行。
栈stack由栈帧stack frame组成,每一个函数对应一个栈帧(栈是分配给执行单元的空间,栈帧是分配给函数的空间)
SP/BP是CPU的寄存器用于存放栈相关数据。
sp是栈顶指针,每次指向栈顶,说人话就是栈顶
bp是基址寄存器,一般用于保存进入函数时的sp栈顶基址,说人话就是栈数据结构的栈基、栈底
C语言中是bp+sp的方式移动扩大栈的(空间大小逐步扩张),但GO不相同
要注意GO在编译阶段就能确认函数栈的大小(空间大小一开始就被确定),因此一开始函数栈是直接分配好的,后续是SP+数字的方式移动的
GO的这种设计是为了防止栈越界访问,对于内存消耗较大的函数,go在其头部插入检测代码,如果发现需要栈增长,就会另外分配一段栈空间,并拷贝原来栈上的数据(原来空间被释放)
栈空间从高地址向低地址生长,当调用函数时则从当前栈顶(SP)往下扩一个栈帧(BP记录SP),函数执行完毕返回,则往上缩一个栈帧(返回SP)
如demo
package main
func myFunction(a, b int) (int, int) {
return a + b, a - b
}
func main() {
myFunction(66, 77)
}
需要注意GO的main函数是被runtime.main生成一个新协程(也可以看作是主协程)调用的,因此我们可以绘出上述代码栈图上图还不够完整,还应该有runtime.main()(这是后续GMP调度的内容,理解不了就先跳过),每个函数的上下文空间,即他们自己的栈空间称为栈帧
1.1.1栈帧
GO1.18栈帧如下主要6个部分组成:
- caller BP:保存SP于BP,用于函数返回后获得调用函数的栈帧基地址(函数执行完收缩一个栈帧)
- local var:保存函数内部本地变量
- temporarily unused space:保存在函数运行过程中产生的临时变量。
- return temp var:保存函数返回值临时变量。
- callee arg:保存被调用函数的参数。
- return address:保存被调用函数返回后的程序地址,即本函数调用被调用函数的下一条指令地址。
调用一个函数,对应机器指令为Call,程序执行到这条指令就跳到函数入口执行,每个函数最后都有RET指令,负责函数结束后跳回调用处继续执行后续代码
return address不是在创建函数栈时生成,是在调用Call函数时生成,函数RET时释放。
1.1.2从汇编角度查看栈帧
package main
func myFunction(a, b int) (int, int) {
return a + b, a - b
}
func main() {
myFunction(66, 77) // a1
}
go tool compile -S -N -l main.go
"".main STEXT size=68 args=0x0 locals=0x28
0x000f 00015 (main.go:7) SUBQ $40, SP // 分配 40 字节栈空间
0x0013 00019 (main.go:7) MOVQ BP, 32(SP) // 将基址指针存储到栈上
0x0018 00024 (main.go:7) LEAQ 32(SP), BP
0x001d 00029 (main.go:8) MOVQ $66, (SP) // 第一个参数
0x0025 00037 (main.go:8) MOVQ $77, 8(SP) // 第二个参数
0x002e 00046 (main.go:8) CALL "".myFunction(SB)
0x0033 00051 (main.go:9) MOVQ 32(SP), BP
0x0038 00056 (main.go:9) ADDQ $40, SP
0x003c 00060 (main.go:9) RET
这是main函数的汇编代码,我们可以分析出调用myfunction之前的栈sp+0为栈顶,sp+40=bp为栈底(证实上面编译阶段就能确认栈空间大小的说法)
1.保存main函数的基栈地址,从该栈址上由由高地址向低地址开辟40个字节的栈帧长度用于执行main函数,这里的main是由runtime.main调用,所以先入栈的栈帧基址caller BP=runtime.main的栈基址,sp+40为高地址,栈底,sp+0为低地址,栈顶
SUBQ $40, SP
MOVQ BP, 32(SP)
LEAQ 32(SP), BP
其中SP+32->BP(SP+40)这8个字节保存main函数栈基址指针(2个int即16个字节)
2.保存myfunction的参数于sp+0->sp+16这16个字节中(因为例子中没有局部变量,所以不开辟该空间)
MOVQ $66, (SP)
MOVQ $77, 8(SP)
我们可以看到参数的压栈顺序是从右到左的,先77再66
3.调用myfunction函数
CALL "".myFunction(SB)
4.剩余16个字节sp+16->sp+32保存返回的两个值,这里就是上文提到的callee arg这里可能会好奇,之前一直说main.main是runtime.main调用,为啥栈顶不是一个返回地址return address呢?学了GMP就知道了实际上每个协程最后都是一个goexit函数的调用,返回地址是被我们用户给替换了的,回到正文,myfunction的栈帧在main的下方被开辟
"".myFunction STEXT nosplit size=49 args=0x20 locals=0x0
0x0000 00000 (main.go:3) MOVQ $0, "".~r2+24(SP) // 初始化第一个返回值
0x0009 00009 (main.go:3) MOVQ $0, "".~r3+32(SP) // 初始化第二个返回值
0x0012 00018 (main.go:4) MOVQ "".a+8(SP), AX // AX = 66
0x0017 00023 (main.go:4) ADDQ "".b+16(SP), AX // AX = AX + 77 = 143
0x001c 00028 (main.go:4) MOVQ AX, "".~r2+24(SP) // (24)SP = AX = 143
0x0021 00033 (main.go:4) MOVQ "".a+8(SP), AX // AX = 66
0x0026 00038 (main.go:4) SUBQ "".b+16(SP), AX // AX = AX - 77 = -11
0x002b 00043 (main.go:4) MOVQ AX, "".~r3+32(SP) // (32)SP = AX = -11
0x0030 00048 (main.go:4) RET
这是myfunction的函数栈帧,实际上main中的call指令会首先将main的返回地址return address存入栈顶中,
然后改变栈帧指针SP执行myfunction汇编指令,我们继续看该汇编
5.将callee arg部分上(main上预留的返回值)的值清空,对应main的SP+16->SP+32
$0, "".~r2+24(SP)
$0, "".~r3+32(SP)
6.然后就是函数代码部分的计算,根据栈的相对位置获取参数执行加减,并将值存回栈中(这里算产生的局部变量对应上文local var),栈帧输入如下,一行表示8字节同时写入main栈帧的空着的空间
- myfunction返回后main函数通过以下指令恢复栈基址并销毁(收缩)这40个字节内存
MOVQ 32(SP), BP
ADDQ $40, SP
RET
小结
1.我们同时也从栈帧结构上得知了GO函数如何支持多个返回值(开辟多个返回值空间)
2.函数的参数入栈顺序是从右到左
3.返回值入栈也是从右到左
当然例子中没有涉及局部变量,我们可以另外再试试
2.传参
GO的传参是只有值传递,无论是传递基本类型、结构还是指针,都会对参数进行拷贝
值传递-函数调用这会对参数进行拷贝,被调用方和调用方两者持有不相关的两份数据
引用传递-函数调用时会传递参数的指针,被调用方和调用方持有相同数据
demo
func TestParam(t *testing.T) {
a := 30
b := &a
fmt.Printf("in main a address:%d=%p,b address:%d,%p\n", a, &a, *b, &b)
myfunction(a, b)
}
func myfunction(i int, j *int) {
fmt.Printf("in myfunction i address:%d=%p,j address:%d,%p\n", i, &i, *j, &j)
}
in main a address:30=0xc00009f1e8,b address:30,0xc0000c4088
in myfunction i address:30=0xc00009f1f0,j address:30,0xc0000c4090
即使是传递了一个指针,两个指针地址也不相同(c4088和c4090),也证明了GO函数是值传递,那为什么值传递指针也能修改 指针中的数据,因为指针所保存的是原始数据在虚拟内存中的地址,指针的地址不同没关系,指针中保存的地址数据相同就能定位到具体内存地址进行修改。
2.1为什么值传递不能修改数据
这里指的不是传入指针,我们用一个例子说明
func swap(a,b int) {
a,b = b,a
}
func main() {
a,b := 1,2
swap(a,b)
println(a,b) //1,2
}
首先分析main的函数栈帧
- 先函数内部本地变量local var压栈a=1,b=2
- swap函数没有返回值则不需要压栈
- 参数b=2,a=1按顺序压栈
- 最后 return address压入栈
我们注意到局部变量和参数的空间排序是前后关系没有关联,在压入局部变量后,又对局部变量进行了一次拷贝,所以说GO是值传递
接着是swap函数栈帧,当函数执行交换代码a,b=b,a时,参数相对地址我们可以从上图找到,但是我们交换的是参数的a和b,并没有
修改局部变量的内存地址,swap结束后这个形参区域就没有意义了,所以失败了。
我的理解就是值传递影响的是栈帧中的参数区域,并没有影响局部变量区域,这两个区域并没有关系
那如何让参数区域和局部变量产生关系呢?,使用地址&a,&b
那不就可以从参数区域上找到局部变量了吗?这也是GO中指针能改变值的原理
因此我们不建议函数参数传入结构体(拷贝空间开销大),而是使用指针
3.返回值
从第一节知识点中我们获取到的信息是GO是通过在栈上分配多个返回值的空间来做到多返回值的,返回值也是从右到左传递的
那函数中有关键词defer呢,栈帧结构如何
demo
func incr(a int) int {
var b int
defer func(){
a++
b++
}()
a++
b = a
return b
}
func main(){
var a,b int
b = incr(a)
println(a,b) //0,1
}
到底是先return返回值 b,还是先执行defer函数b++呢,实际上执行一下程序就可以知道b=1,先返回b
这是main函数栈帧,incr函数执行后,先将b=1赋给返回值区域,再执行defer,因为defer只影响了参数区域的a=0和incr的临时变量b=0,并没有影响返回值区域,结果输出b=1
当不是匿名返回值是具名返回值时呢
demo
func incr(a int) (b int) {
defer func(){
a++
b++
}()
a++
return a
}
func main(){
var a,b int
b = incr(a)
println(a,b) //0,2
}
那么defer不就能影响返回值区域的b了吗,结果输出b=2
小结一下和补充defer
1.匿名返回值时,defer影响的都是参数区域或临时变量,不能影响返回值
- 具名返回值时,defer可以影响到返回值区域
- defer的实现有三种 栈、堆和开放编码,当defer小于8时在栈上实现,大于8在堆上实现
4.逃逸分析
通过我们上文对GO函数栈的分析,我们知道了函数使用的变量是分配到栈空间上的,但这也是理想情况,也有以下情况,变量是被分配到堆上(即逃逸到堆上面创建变量):
- 栈帧回收后,需要继续使用的变量(典型如函数返回了一个指针),称为指针逃逸
- 太大的变量,一般在64位上超过64kb的变量,称为大变量逃逸
- 空接口逃逸,即形参为interface,实参可能会逃逸(interface函数往往使用反射,如fmt.Print,反射要求变量在堆上分配)
5.堆的内存结构
上文介绍了栈的内存结构,现在说堆的内存结构。堆是低地址向高地址扩展是数据结构,不是连续的内存区域(系统用链表的方式来存储空闲的内存地址,所以不是连续的)。在堆上创建变量就意味着需要向操作系统申请堆的内存单元heapArena:
- GO以heapArena为单位,向操作系统申请虚拟内存单元,大小为64MB(释放也是64MB)
- 最多可以申请个2^20个虚拟内存单元heapArena
- 所有的heapArena组成了GO的堆内存mheap
heapArean在runtime的结构在这,这一个结构体描述了64MB的内存单元数据信息
type heapArena struct {
spans [pagesPerArena]*mspan
type mheap struct {
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
该字段记录了所有的heapArena
往堆上创建变量就是往64mb中填数据,那么有几种填法:
-
线性分配
如图所示,申请一个就往后面填一个,但上面是理想情况,实际上因为变量大小的不同会产生很多内存碎片 -
链表分配
链表记录内存碎片位置,但任然避免不了内存被回收后的内存碎片问题
- 分级分配,即事先给heapArena划分不同区域,根据变量大小不同往对应区域里面放,放的原则遵循 变量大小<区域最小大小
没填满的空间怎么办?就不管了。
分级分配就是GO使用的数据填充办法,每一个区域被称为内存管理单元mspan
type mspan struct {
_ sys.NotInHeap
next *mspan // next span in list, or nil if none
prev *mspan // previous span in list, or nil if none
list *mSpanList // For debugging. TODO: Remove.
我们看到mspan也是一个链表结构,上文heapArena的spans成员存放的就是mspan链表
5.1 mspan
有一个问题,每个mspan都不确定,如何快速定位到所需要的mspan?
go使用了一个中心索引,即mcentral
heapArena中有136个mcentral结构体,其中68个组需要GC扫描的mspan,另外67个不需要GC扫描,根据分配需求,由mcentral查找合适的msapn
type mheap struct {
central [numSpanClasses]struct {
mcentral mcentral
pad [(cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize) % cpu.CacheLinePadSize]byte
}
缺点是mcentral使用互斥锁保护,高并发情况下锁冲突问题严重。因此后面参考了GMP模型,增加线程本地缓存(p的本地队列,后续会讲),每个P拥有一个mcache,一个mcache有136种mspanmcache中每个级别的mspan只有一个,当mspan满了之后,会从mcentral中换取一个新的(清空,数据拷贝)
GO的堆内存申请实际是模仿了TCmalloc(c++的堆内存方案,GO的很多设计理念都模仿c++),建立自己的堆内存架构
除了mspan分级,要放入heapArena的对象也要分级,对象分级同时由mallocgc分配:
-
Tiny微对象(0,16B)无指针,分配至普通mspan,但是微对象太小,为了节约内存,会将多个微对象合并成一个16Byte存入(16B正好是一个2级mspan的小单元)mspan
//func mallocgc
if size <= maxSmallSize { //小对象逻辑分支
if noscan && size < maxTinySize { //微对象逻辑分支
span = c.alloc[tinySpanClass]
v := nextFreeFast(span)
if v == 0 {
v, span, shouldhelpgc = c.nextFree(tinySpanClass)//从二级mspan中找一个空的
}
- small小对象[16B,32KB]
- large 大对象(32KB,~),量身定制mspan
// func mallocgc()
span = c.allocLarge(size, noscan) //使用大对象创建mspan
// func allocLarge
spc := makeSpanClass(0, noscan) //分配0级,表示是定制