GO基础学习(6)函数和堆

写在开头

非原创,知识搬运工,本节介绍了GO的函数栈,函数传值和返回值
demo代码地址
b站看动画学函数栈

目录

链接地址

带着问题去阅读

1.程序在内存中主要的存在形式
2.栈和栈帧是什么关系
3.GO的函数栈帧是怎么样的
4.GO函数的参数是什么传递
5.为什么值传递不能影响本地变量
6.为什么传指针能影响
7.defer如何改变返回值

1.GO函数栈

详细内容在这里
进程在内存中的布局主要分为4个部分:代码区、数据区、堆和栈

866058907eb2b3be326d3228a676a212.png

  • 代码区。包括能被CPU执行的机器码(指令,我们的程序最后都会被编译器转为一堆机器能读懂的机器指令)和只读数据(如字符串常量),当操作系统把程序加载进内存后,代码区大小不会再变化
  • 数据区。包括程序的全局变量和静态变量(C有,GO没有静态变量),和代码区一样,加载完毕后大小不变
  • 堆。程序运行过程中动态分配的内存都在堆中,堆大小随程序运行而改变。当向堆分请求分配内存,若堆内存不足,则向操作系统申请向高地址方向扩展堆大小,反之释放内存归还与堆时,若发现空闲内存太多,则向操作系统请求向低地址收缩堆大小。也就是说从堆上分配的内存用完之后必须还给堆,否则堆不断向高地址扩展,导致内存越用越多,最后内存不足,也就是内存泄露
  • 栈。在程序运行过程中,会涉及函数的调用,函数返回后继续执行本函数剩下的代码,程序就是靠栈来组织这一个调用过程。

栈(操作系统):由操作系统自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
堆(操作系统): 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收,分配方式倒是类似于链表。

1.1.栈与栈帧

栈stack是程序(进程)执行单元(线程或协程)在执行过程中记录程序上下文的数据结构,用于保存函数调用过程中的各种数据(如本地变量、参数、返回值)。每一个程序执行单元(线程或协程)都有一个独立的栈,伴随其整个生命周期,这样保证了执行单元能够并行执行
栈stack由栈帧stack frame组成,每一个函数对应一个栈帧(栈是分配给执行单元的空间,栈帧是分配给函数的空间)

SP/BP是CPU的寄存器用于存放栈相关数据。
sp是栈顶指针,每次指向栈顶,说人话就是栈顶
bp是基址寄存器,一般用于保存进入函数时的sp栈顶基址,说人话就是栈数据结构的栈基、栈底
C语言中是bp+sp的方式移动扩大栈的(空间大小逐步扩张),但GO不相同
要注意GO在编译阶段就能确认函数栈的大小(空间大小一开始就被确定),因此一开始函数栈是直接分配好的,后续是SP+数字的方式移动的
GO的这种设计是为了防止栈越界访问,对于内存消耗较大的函数,go在其头部插入检测代码,如果发现需要栈增长,就会另外分配一段栈空间,并拷贝原来栈上的数据(原来空间被释放)

image.png

栈空间从高地址向低地址生长,当调用函数时则从当前栈顶(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生成一个新协程(也可以看作是主协程)调用的,因此我们可以绘出上述代码栈图
image.png

上图还不够完整,还应该有runtime.main()(这是后续GMP调度的内容,理解不了就先跳过),每个函数的上下文空间,即他们自己的栈空间称为栈帧

1.1.1栈帧

GO1.18栈帧如下
image.png

主要6个部分组成:

  1. caller BP:保存SP于BP,用于函数返回后获得调用函数的栈帧基地址(函数执行完收缩一个栈帧)
  2. local var:保存函数内部本地变量
  3. temporarily unused space:保存在函数运行过程中产生的临时变量。
  4. return temp var:保存函数返回值临时变量。
  5. callee arg:保存被调用函数的参数。
  6. return address:保存被调用函数返回后的程序地址,即本函数调用被调用函数的下一条指令地址。

调用一个函数,对应机器指令为Call,程序执行到这条指令就跳到函数入口执行,每个函数最后都有RET指令,负责函数结束后跳回调用处继续执行后续代码
return address不是在创建函数栈时生成,是在调用Call函数时生成,函数RET时释放。

image.png

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个字节)


image.png

2.保存myfunction的参数于sp+0->sp+16这16个字节中(因为例子中没有局部变量,所以不开辟该空间)

 MOVQ    $66, (SP)
 MOVQ    $77, 8(SP)

我们可以看到参数的压栈顺序是从右到左的,先77再66


image.png

3.调用myfunction函数

CALL    "".myFunction(SB)

4.剩余16个字节sp+16->sp+32保存返回的两个值,这里就是上文提到的callee arg
image.png

这里可能会好奇,之前一直说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存入栈顶中,


image.png

然后改变栈帧指针SP执行myfunction汇编指令,我们继续看该汇编
5.将callee arg部分上(main上预留的返回值)的值清空,对应main的SP+16->SP+32

$0, "".~r2+24(SP)
 $0, "".~r3+32(SP)

6.然后就是函数代码部分的计算,根据栈的相对位置获取参数执行加减,并将值存回栈中(这里算产生的局部变量对应上文local var),栈帧输入如下,一行表示8字节
image.png

同时写入main栈帧的空着的空间


image.png
  1. myfunction返回后main函数通过以下指令恢复栈基址并销毁(收缩)这40个字节内存
MOVQ    32(SP), BP
ADDQ    $40, SP
 RET

小结

1.我们同时也从栈帧结构上得知了GO函数如何支持多个返回值(开辟多个返回值空间)
2.函数的参数入栈顺序是从右到左
3.返回值入栈也是从右到左


image.png

当然例子中没有涉及局部变量,我们可以另外再试试

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的函数栈帧


image.png
  • 先函数内部本地变量local var压栈a=1,b=2
  • swap函数没有返回值则不需要压栈
  • 参数b=2,a=1按顺序压栈
  • 最后 return address压入栈

我们注意到局部变量和参数的空间排序是前后关系没有关联,在压入局部变量后,又对局部变量进行了一次拷贝,所以说GO是值传递
接着是swap函数栈帧,当函数执行交换代码a,b=b,a时,参数相对地址我们可以从上图找到,但是我们交换的是参数的a和b,并没有
修改局部变量的内存地址,swap结束后这个形参区域就没有意义了,所以失败了。
我的理解就是值传递影响的是栈帧中的参数区域,并没有影响局部变量区域,这两个区域并没有关系

那如何让参数区域和局部变量产生关系呢?,使用地址&a,&b

image.png

那不就可以从参数区域上找到局部变量了吗?这也是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

image.png

这是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影响的都是参数区域或临时变量,不能影响返回值

  1. 具名返回值时,defer可以影响到返回值区域
  2. defer的实现有三种 栈、堆和开放编码,当defer小于8时在栈上实现,大于8在堆上实现

4.逃逸分析

通过我们上文对GO函数栈的分析,我们知道了函数使用的变量是分配到栈空间上的,但这也是理想情况,也有以下情况,变量是被分配到堆上(即逃逸到堆上面创建变量):

  1. 栈帧回收后,需要继续使用的变量(典型如函数返回了一个指针),称为指针逃逸
  2. 太大的变量,一般在64位上超过64kb的变量,称为大变量逃逸
  3. 空接口逃逸,即形参为interface,实参可能会逃逸(interface函数往往使用反射,如fmt.Print,反射要求变量在堆上分配)

5.堆的内存结构

上文介绍了栈的内存结构,现在说堆的内存结构。堆是低地址向高地址扩展是数据结构,不是连续的内存区域(系统用链表的方式来存储空闲的内存地址,所以不是连续的)。在堆上创建变量就意味着需要向操作系统申请堆的内存单元heapArena:

  1. GO以heapArena为单位,向操作系统申请虚拟内存单元,大小为64MB(释放也是64MB)
  2. 最多可以申请个2^20个虚拟内存单元heapArena
  3. 所有的heapArena组成了GO的堆内存mheap
    image.png

    heapArean在runtime的结构在这,这一个结构体描述了64MB的内存单元数据信息
type heapArena struct {
    spans [pagesPerArena]*mspan

mheap由heapArena组成

type mheap struct {
    arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena

该字段记录了所有的heapArena
往堆上创建变量就是往64mb中填数据,那么有几种填法:

  1. 线性分配


    image.png

    如图所示,申请一个就往后面填一个,但上面是理想情况,实际上因为变量大小的不同会产生很多内存碎片
    image.png
  2. 链表分配
    image.png

    链表记录内存碎片位置,但任然避免不了内存被回收后的内存碎片问题

  3. 分级分配,即事先给heapArena划分不同区域,根据变量大小不同往对应区域里面放,放的原则遵循 变量大小<区域最小大小
    image.png

    没填满的空间怎么办?就不管了。
    分级分配就是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

image.png

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种mspan
image.png

mcache中每个级别的mspan只有一个,当mspan满了之后,会从mcentral中换取一个新的(清空,数据拷贝)

GO的堆内存申请实际是模仿了TCmalloc(c++的堆内存方案,GO的很多设计理念都模仿c++),建立自己的堆内存架构

除了mspan分级,要放入heapArena的对象也要分级,对象分级同时由mallocgc分配

  1. Tiny微对象(0,16B)无指针,分配至普通mspan,但是微对象太小,为了节约内存,会将多个微对象合并成一个16Byte存入(16B正好是一个2级mspan的小单元)mspan
    image.png
//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中找一个空的
            }
  1. small小对象[16B,32KB]
  2. large 大对象(32KB,~),量身定制mspan
// func mallocgc()
span = c.allocLarge(size, noscan) //使用大对象创建mspan

// func allocLarge
spc := makeSpanClass(0, noscan) //分配0级,表示是定制

参考

1.GO函数调用
2.GO函数调用栈
3.GO传参
4.GO栈和栈帧

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,099评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,828评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,540评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,848评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,971评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,132评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,193评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,934评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,376评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,687评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,846评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,537评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,175评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,887评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,134评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,674评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,741评论 2 351

推荐阅读更多精彩内容