chaoz的杂货铺

生命有息、学无止境、折腾不止

0%

2022-Golang-内存

内存分配

golang内存模型

预备知识

tcmalloc

https://studygolang.com/articles/26306?fr=sidebar

tcmalloc是google推出的一种内存分配器,常见的内存分配器还有glibc的ptmalloc和google的jemalloc。相比于ptmalloc,tcmalloc性能更好,特别适用于高并发场景。

逻辑

tcmalloc分配的内存主要来自两个地方:全局缓存堆和进程的私有缓存。对于一些小容量的内存申请试用进程的私有缓存,私有缓存不足的时候可以再从全局缓存申请一部分作为私有缓存。对于大容量的内存申请则需要从全局缓存中进行申请。而大小容量的边界就是32k。缓存的组织方式是一个单链表数组,数组的每个元素是一个单链表,链表中的每个元素具有相同的大小。

Go的内存管理

局部缓存并不是分配给进程或者线程,而是分配给P(这个还需要说一下go的goroutine实现)
go的GC是stop the world,并不是每个进程单独进行GC。(有疑问)
span的管理更有效率

Go内存管理

mcache

per-P cache,可以认为是 local cache。

我们知道每个 Gorontine 的运行都是绑定到一个 P 上面,mcache 是每个 P 的 cache。这么做的好处是分配内存时不需要加锁。

mspan

mspan 在 tcmalloc 中作为一种管理内存的基本单位而存在。
mspan 的大小是 page 的整数倍,结构为链表。

mspan是用于管理arena页的关键数据结构,每个mspan中包含1个或多个连续页,为了满足小对象分配,mspan中的一页会划分更小的粒度,而对于大对象比如超过页大小,则通过多页实现。

mcentral

全局 cache,mcache 不够用的时候向 mcentral 申请。有锁,保证多个p竞争时的安全性。mcentral 包含在 mheap。

cache作为线程的私有资源为单个线程服务,而mcentral则是全局资源,为多个线程服务,当某个线程内存不足时会向mcentral申请,当某个线程释放内存时又会回收进mcentral。

mheap

当 mcentral 也不够用的时候,通过 mheap 向操作系统申请。
mheap_ 是一个全局变量,会在系统初始化的时候初始化(在函数 mallocinit() 中)。

spans

记录 arena 区域页号(page number)和 mspan 的映射关系。

#2.1.1 class

arena

arena 是 Golang 中用于分配内存的连续虚拟地址区域。由 mheap 管理,堆上申请的所有内存都来自 arena。

内存布局

20220226141215

系统初始化

初始化的时候,Golang 向操作系统申请一段连续的地址空间。不同的操作系统上调用不同的系统调用。
操作系统申请完地址之后就是初始化 mheap ,这个过程会有内存对齐,然后初始化arena,bitmap,spans 地址。
per-P mcache 初始化最后进行。

内存分配

整个分配过程可以根据 object size 拆解成三部分:
size < 16 byte
16 byte <= size <= 32 K byte
size > 32 K byte。

  1. object size > 32K,则使用 mheap 直接分配。
  2. object size < 16 byte,使用 mcache 的小对象分配器 tiny 直接分配。 (其实 tiny 就是一个指针,暂且这么说吧。)
  3. object size > 16 byte && size <=32K byte 时,先使用 mcache 中对应的 size class 分配。
  4. 如果 mcache 对应的 size class 的 span 已经没有可用的块,则向 mcentral 请求。
  5. 如果 mcentral 也没有可用的块,则向 mheap 申请,并切分。
  6. 如果 mheap 也没有合适的 span,则想操作系统申请。

20220228155820
https://www.golangroadmap.com/books/goexpert/n1.html#_3-%E5%86%85%E5%AD%98%E5%88%86%E9%85%8D%E8%BF%87%E7%A8%8B

go内存回收

mcache 回收

mcache 回收可以分两部分:
第一部分是将 alloc 中未用完的内存归还给对应的 mcentral。
除此之外将剩下的 mcache (基本是个空壳)归还给 mheap.cachealloc,其实就是把 mcache 插入 free list 表头。

mcentral 回收

当 mspan 没有 free object 的时候,将 mspan 归还给 mheap。

mheap 回收

mheap 并不会定时向操作系统归还,但是会对 span 做一些操作,比如合并相邻的 span。

GC

为了保证程序内内存的连续,Golang会申请一大块内存(有时候,只写一个hello, world可能监控内存可能都会发现占用内存比想象中的大)。当用户的程序申请的内存大于之前预申请的内存时,runtime会进行一次GC,并且将GC的阈值翻倍。也就是说,之前是超过10M时进行GC,那么下一次GC就是超过20M才进行。此外,runtime还支持定时GC。我们内存升高的原因,目前看来就是访问量过大,数据库访问的时候导致GC阈值变大,回收频率变低。而且在回收方面,Golang采用了一种拖延症策略,即使是被释放的内存,runtime也不会立刻把内存还给系统。这就导致了内存降不下来,一种内存泄漏的假象。

Golang在GC的时候会发生Stop the world,整个程序会暂停,然后去标记整个内存里面可以被回收的变量,标记完之后恢复程序执行,最后异步得去回收内存。一般这个过程会达到20ms。标记可回收变量的时间取决于临时变量的个数。临时变量数量越多,扫描时间会越长。

常见的垃圾回收算法:

引用计数:每个对象维护一个引用计数,当被引用对象被创建或被赋值给其他对象时引用计数自动加 +1;如果这个对象被销毁,则计数 -1 ,当计数为 0 时,回收该对象。
优点:对象可以很快被回收,不会出现内存耗尽或到达阀值才回收。
缺点:不能很好的处理循环引用
标记-清除:从根变量开始遍历所有引用的对象,引用的对象标记“被引用”,没有被标记的则进行回收。
优点:解决了引用计数的缺点。
缺点:需要 STW(stop the world),暂时停止程序运行。
分代收集:按照对象生命周期长短划分不同的代空间,生命周期长的放入老年代,短的放入新生代,不同代有不同的回收算法和回收频率。
优点:回收性能好
缺点:算法复杂

标记清除算法

黑色赋值器:已经由回收器扫描过,不会再次对其进行扫描。
灰色赋值器:尚未被回收器扫描过,或尽管已经扫描过但仍需要重新扫描。

20220225101319
第一步,暂停程序业务逻辑, 分类出可达和不可达的对象,然后做上标记。
20220225102813
第二步, 开始标记,程序找出它所有可达的对象,并做上标记。
20220225103041
第三步, 标记完了之后,然后开始清除未标记的对象。此时会发生STW。
第四步, 停止暂停,让程序继续跑。然后循环重复这个过程,直到process程序生命周期结束。

以上便是标记-清除(mark and sweep)回收的算法。

缺点

STW,stop the world;让程序暂停,程序出现卡顿 (重要问题);
标记需要扫描整个heap;
清除数据会产生heap碎片。

根对象到底是什么?

根对象在垃圾回收的术语中又叫做根集合,它是垃圾回收器在标记过程时最先检查的对象,包括:

全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量。
执行栈:每个 goroutine 都包含自己的执行栈,这些执行栈上包含栈上的变量及指向分配的堆内存区块的指针。
寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块。

为什么要扫描整个堆?为什么会出现堆碎片?

怎么找到所有可达对象的?

三色标记法

目的:解决标记清除算法的缺点

第一步 , 每次新创建的对象,默认的颜色都是标记为“白色”,如图所示。
20220225104733
20220225104909

第二步, 每次GC回收开始, 会从根节点开始遍历所有对象,把遍历到的对象从白色集合放入“灰色”集合如图所示。
只遍历一层
20220225104900

第三步, 遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合,如图所示。
也是只遍历一层
20220225105001

第四步, 重复第三步, 直到灰色中无任何对象,如图所示。
20220225105142
20220225105255

第五步: 回收所有的白色标记表的对象. 也就是回收垃圾,如图所示。
20220225105316

那么Go是如何解决标记-清除(mark and sweep)算法中的卡顿(stw,stop the world)问题的呢?

屏障机制

屏障机制

GC回收的过程中可能存在黑色对象又引用了白色对象,导致对象误删。

强弱三色不变式

强三色不变式:(破坏条件1:Dijistra写屏障)
不允许黑色对象去引用白色对象。

弱三色不变式:(破坏条件2:Yuasa写屏障)
黑色对象可以引用白色对象,但是这个白色对象必须存在其他灰色对象对他的引用。
由此带来了屏障机制

屏障机制

插入写屏障

对象被引入情况

就是有新的对象需要添加,插入到了黑色标记的对象上,出于对堆栈上对象特性的考虑,对堆上的对象触发插入屏障机制,将堆上的新对象(黑色对象引用的)强制标记为灰色。对于栈上新引入的对象,不执行屏障机制,而是在GC之前执行STW机制,暂停整个程序,对栈上的对象执行三色标记,直到没有灰色对象,执行GC,删除所有的白色对象。

PS:最后将栈和堆空间 扫描剩余的全部 白色节点清除. 这次STW大约的时间在10~100ms间.

20220225144913

这里有个疑问:STW机制 一定是暂停整个程序还是可以暂停堆或者栈?

删除写屏障

对象被删除

20220225154633

就是存在对象取消引用情况,当白色对象与灰色对象引用断开,白色对象会被回收,但是这个白色对象所连接的白色对象是有用的,会被误删除。这个时候就将断开引用的白色对象置为灰色,继续执行三色标记。

PS:这种方式的回收精度低,一个对象即使被删除了最后一个指向它的指针也依旧可以活过这一轮,在下一轮GC中被清理掉。

这里有个疑问:上图有没有问题?删除屏障是操作的堆对象还是栈对象?

混合写屏障

流程
参考1:
具体操作:
1、GC开始将栈上的对象全部扫描,并将可达对象标记为黑色(之后不再进行第二次重复扫描,无需STW)(三色标记法)。
2、GC期间,任何在栈上创建的新对象,均为黑色。
3、被删除的对象标记为灰色。
4、被添加的对象标记为灰色。

参考2:
混合写屏障继承了插入写屏障的优点,起始无需 STW 打快照,直接并发扫 描垃圾即可;
混合写屏障继承了删除写屏障的优点,赋值器是黑色赋值器,GC 期间,任 何在栈上创建的新对象,均为黑色。扫描过一次就不需要扫描了,这样就 消除了插入写屏障时期最后 STW 的重新扫描栈;
混合写屏障扫描精度继承了删除写屏障,比插入写屏障更低,随着带来的 是 GC 过程全程无 STW;
混合写屏障扫描栈虽然没有 STW,但是扫描某一个具体的栈的时候,还是 要停止这个 goroutine 赋值器的工作(针对一个 goroutine 栈来说,是 暂停扫的,要么全灰,要么全黑哈,原子状态切换)。

​Golang中的混合写屏障满足弱三色不变式,结合了删除写屏障和插入写屏障的优点,只需要在开始时并发扫描各个goroutine的栈,使其变黑并一直保持,这个过程不需要STW,而标记结束后,因为栈在扫描后始终是黑色的,也无需再进行re-scan操作了,减少了STW的时间。

场景:
场景一: 对象被一个堆对象删除引用,成为栈对象的下游
堆触发屏障

场景二: 对象被一个栈对象删除引用,成为另一个栈对象的下游
栈不启动屏障

场景三:对象被一个堆对象删除引用,成为另一个堆对象的下游
触发屏障

场景四:对象从一个栈对象删除引用,成为另一个堆对象的下游
栈空间不触发屏障,

总结:
GoV1.3- 普通标记清除法,整体过程需要启动STW,效率极低。

GoV1.5- 三色标记法, 堆空间启动写屏障,栈空间不启动,全部扫描之后,需要重新扫描一次栈(需要STW),效率普通

GoV1.8-三色标记法,混合写屏障机制, 栈空间不启动,堆空间启动。整个过程几乎不需要STW,效率较高。

STW

https://studygolang.com/articles/28450

触发GC的三种方式

主动调用

通过调用 runtime.GC(),这是阻塞式的。

自动检测

在堆上分配大于 32K byte 对象的时候进行检测此时是否满足垃圾回收条件,如果满足则进行垃圾回收。
堆内存的分配达到控制器计算的触发堆大小,初始大小环境变量GOGC,之后堆内存达到上一次垃圾收集的 2 倍时才会触发GC。

监控触发

Golang 本身还会对运行状态进行监控,如果超过两分钟没有 GC,则触发 GC。监控函数是 sysmon(),在主 goroutine 中启动。

观察GC的四种方式

方式一:GODEBUG=gctrace=1
方式二:go tool trace
方式三:debug.ReadGCStats
方式四: runtime.ReadMemStats

Go 历史各个版本在 GC 方面的改进?

Go 1:串行三色标记清扫
Go 1.3:并行清扫,标记过程需要 STW,停顿时间在约几百毫秒
Go 1.5:并发标记清扫,停顿时间在一百毫秒以内
Go 1.6:使用 bitmap 来记录回收内存的位置,大幅优化垃圾回收器自身消耗的内存,停顿时间在十毫秒以内
Go 1.7:停顿时间控制在两毫秒以内
Go 1.8:混合写屏障,停顿时间在半个毫秒左右
Go 1.9:彻底移除了栈的重扫描过程
Go 1.12:整合了两个阶段的 Mark Termination,但引入了一个严重的 GC Bug 至今未修(见问题 20),尚无该 Bug 对 GC 性能影响的报告
Go 1.13:着手解决向操作系统归还内存的,提出了新的 Scavenger
Go 1.14:替代了仅存活了一个版本的 scavenger,全新的页分配器,优化分配内存过程的速率与现有的扩展性问题,并引入了异步抢占,解决了由于密集循环导致的 STW 时间过长的问题
https://golang.design/go-questions/memgc/history/

补充资料

https://www.kancloud.cn/aceld/golang/1958308

https://golang.design/under-the-hood/zh-cn/part2runtime/ch08gc/basic/

逃逸分析

逃逸分析是一种确定指针动态范围的方法。简单来说就是分析在程序的哪些地方可以访问到该指针。

简单来说,编译器会根据变量是否被外部引用来决定是否逃逸:

1、如果函数外部没有引用,则优先放到栈中;
2、如果函数外部存在引用,则必定放到堆中;
对此你可以理解为,逃逸分析是编译器用于决定变量分配到堆上还是栈上的一种行为。

注意:go 在编译阶段确立逃逸,并不是在运行时。
Go编译器会在编译期对考察变量的作用域,并作一系列检查,如果它的作用域在运行期间对编译器一直是可知的,那么就会分配到栈上。

堆和栈

堆适合不可预知的大小的内存分配。但是为此付出的代价是分配速度较慢,而且会形成内存碎片。

栈内存分配则会非常快,栈分配内存只需要两个CPU指令:“PUSH”和“RELEASE”分配和释放;而堆分配内存首先需要去找到一块大小合适的内存块。之后要通过垃圾回收才能释放。

20220225190102

逃逸

指针逃逸

函数返回值引用情况下

栈空间不足逃逸

当切片长度扩大到10000时就会逃逸。实际上当栈空间不足以存放当前对象时或无法判断当前切片长度时会将对象分配到堆中。

动态类型逃逸

编译期间很难确定其参数的具体类型,也能产生逃逸。比如interface

逃逸分析算法

算法基于的两个不变性:

  1. 指向栈对象的指针不能存储在堆中(pointers to stack objects cannot be stored in the heap);
  2. 指向栈对象的指针不能超过该栈对象的存活期(即指针不能在栈对象被销毁后依旧存活)(pointers to a stack object cannot outlive that object)。

具体还是看源码吧。用到了加权树。

手动强制避免逃逸

分析某个对象不需要逃逸到堆上,可以手动设置阻止它进行逃逸。
那是否有一种方法可以干扰逃逸分析,使逃逸分析认为需要在堆上分配的内存对象而我们确定认为不需要逃逸的对象避免逃逸。
通过uintptr做了一次转换,而这次转换将指针转换成了数值,这“切断”了逃逸分析的数据流跟踪,导致传入的指针避免逃逸。
github.com/bigwhite/experiments/blob/master/go-escape-analysis/go/printf3.go

案例分析

https://github.com/bigwhite/experiments/tree/master/go-escape-analysis/go
$go build -gcflags "-m -l" int.go

总结
1、堆上动态分配内存比栈上静态分配内存,开销大很多。
2、变量分配在栈上需要能在编译期确定它的作用域,否则会分配到堆上。
3、编译器会根据变量是否被外部引用来决定是否逃逸,逃逸分析在编译阶段完成的。
4、对于Go程序员来说,编译器的这些逃逸分析规则不需要掌握,我们只需通过go build -gcflags ‘-m’命令来观察变量逃逸情况就行了。
5、不要盲目使用变量的指针作为函数参数,虽然它会减少复制操作。但其实当参数为变量自身的时候,复制是在栈上完成的操作,开销远比变量逃逸后动态地在堆上分配内存少的多。

资料参考

https://topgoer.cn/blog-145.html
https://topgoer.cn/blog-104.html
http://legendtkl.com/2017/04/02/golang-alloc/

思考题

内存分配

Go内存分配机制与TCMalloc

https://studygolang.com/articles/26306?fr=sidebar
https://www.jianshu.com/p/183724c2f3fc

Go 语言中的变量究竟是分配在栈上、还是分配在堆上?

看有没有作为函数返回值;
看类型是不是接口类型;
看是不是特别大的对象,栈存不下(是存不下还是有限制?)。

go内存操作也要处理IO,是如何处理的?

有mcentral为啥要mcache

Go实现的内存管理采用了tcmalloc这种架构,并配合goroutine和垃圾回收。tcmalloc的基本策略就是将内存分为多个级别。申请对象优先从最小级别的内存管理集合mcache中获取,若mcache无法命中则需要向mcentral申请一批内存块缓存到本地mcache中,若mcentral无空闲的内存块,则向mheap申请来填充mcentral,最后向系统申请。

答了mcentral是服务所有系统线程,mcache为系统线程独享,mcache缺少span时去mcentral->mheap中取

逃逸分析,分析了栈帧,讲五种例子,描述堆栈优缺点

https://topgoer.cn/blog-104.html

结构体内存对齐是怎么回事

https://developer.ibm.com/articles/pa-dalign/

垃圾回收

有了 GC,为什么还会发生内存泄露?

形式1:预期能被快速释放的内存因被根对象引用而没有得到迅速释放
形式2:goroutine 泄漏
https://www.bookstack.cn/read/qcrao-Go-Questions/spilt.7.GC-GC.md

写代码的时候如何减少小对象分配,Go 的 GC 如何调优?

通过 go tool pprof 和 go tool trace 等工具
控制内存分配的速度,限制 Goroutine 的数量,从而提高赋值器对 CPU 的利用率。
减少并复用内存,例如使用 sync.Pool 来复用需要频繁创建临时对象,例 如提前分配足够的内存来降低多余的拷贝。
需要时,增大 GOGC 的值,降低 GC 的运行频率。
https://www.bookstack.cn/read/qcrao-Go-Questions/spilt.14.GC-GC.md

和JAVA垃圾回收机制有啥区别,go的GC和Python的GC

在 Go、Java 和 V8 JavaScript 之间比较 GC 的性能本质上是一个不切实际的问题。如前面所说,垃圾回收器的设计权衡了很多方面的因素,同时还受语言自身设计的影响,因为语言的设计也直接影响了程序员编写代码的形式,也就自然影响了产生垃圾的方式。
https://www.bookstack.cn/read/qcrao-Go-Questions/spilt.19.GC-GC.md

垃圾回收 相比程序员直接 free 和 delete 之类的,有什么优化(内存碎片)

元素值在经过通道传递时会被复制,那么这个复制是浅表复制还是深层复制呢?

只复制长度,未复制容量,浅复制。

深浅复制

浅复制:只复制长度,未复制容量,
深复制:

如果内存分配速度超过了标记清除的速度怎么办?

当存在新的内存分配时,会暂停分配内存过快的那些 goroutine,并将其转去执行一些辅助标记(Mark Assist)的工作,从而达到放缓继续分配、辅助 GC 的标记工作的目的。

Go 的垃圾回收器有哪些相关的 API?其作用分别是什么?

runtime.GC:手动触发 GC
runtime.ReadMemStats:读取内存相关的统计信息,其中包含部分 GC 相关的统计信息
debug.FreeOSMemory:手动将内存归还给操作系统
debug.ReadGCStats:读取关于 GC 的相关统计信息
debug.SetGCPercent:设置 GOGC 调步变量
debug.SetMaxHeap(尚未发布[10]):设置 Go 程序堆的上限值

目前提供 GC 的语言以及不提供 GC 的语言有哪些?GC 和 No GC 各自的优缺点是什么?

https://www.bookstack.cn/read/qcrao-Go-Questions/spilt.18.GC-GC.md

Go 对比 Java、V8 中 JavaScript 的 GC 性能如何?

https://www.bookstack.cn/read/qcrao-Go-Questions/spilt.19.GC-GC.md

Sysmon

Sysmon 也叫监控线程,变动的周期性检查,好处
释放闲置超过 5 分钟的 span 物理内存;
如果超过 2 分钟没有垃圾回收,强制执行;
将长时间未处理的 netpoll 添加到全局队列;
30 向长时间运行的 G 任务发出抢占调度(超过 10ms 的 g,会进行 retake);
收回因 syscall 长时间阻塞的 P;

逃逸分析

函数传递指针真的比传值效率高吗?

我们知道传递指针可以减少底层值的拷贝,可以提高效率,但是如果拷贝的数据量小,由于指针传递会产生逃逸,可能会使用堆,也可能会增加GC的负担,所以传递指针不一定是高效的。

Golang 编译器会将函数的局部变量分配到栈帧(stack frame)上。 然而,如果编译器不能确保变量在函数 return之后不再被引用,编译器就会将变量分配到堆上。而且,如果一个局部变量非常大,那么它也应该被分配到堆上而不是栈上。

当前情况下,如果一个变量被取地址,那么它就有可能被分配到堆上。然而,还要对这些变量做逃逸分析,如果函数return之后,变量不再被引用,则将其分配到栈上。

栈和栈帧

堆栈(stack)又称为栈或堆叠,是计算机科学里最重要且最基础的数据结构之一,它按照FILO(First In Last Out,后进先出)的原则存储数据。

栈的相关概念:

栈顶和栈底:允许元素插入与删除的一端称为栈顶,另一端称为栈底。
压栈:栈的插入操作,叫做进栈,也称压栈、入栈。
弹栈:栈的删除操作,也叫做出栈。

从技术上说,栈就是CPU寄存器里的某个指针所指向的一片内存区域。这里所说的“某个指针”通常位于x86/x64平台的ESP寄存器/RSP寄存器,以及ARM平台的SP寄存器。

操作栈的最常见的指令时PUSH(压栈)和POP(弹栈)。PUSH指令会对ESP/RSP/SP寄存器的值进行减法运算,使之减去4(32位)或8(64位),然后将操作数写到上述寄存器里的指针所指向的内存中。

POP指令是PUSH指令的逆操作:它先从栈指针指向的内存中读取数据,用以备用(通常是写到其他寄存器里),然后再将栈指针的数值加上4或8.

栈在进程中的作用如下:
暂时保存函数内的局部变量。
调用函数时传递参数。
保存函数返回的地址。

逆向的时候这几个指令最常见。

栈帧

栈帧也叫过程活动记录,是编译器用来实现过程/函数调用的一种数据结构。简言之,栈帧就是利用EBP(栈帧指针,请注意不是ESP)寄存器访问局部变量、参数、函数返回地址等的手段。

每一次函数的调用,都会在调用栈(call stack)上维护一个独立的栈帧(stack frame)。每个独立的栈帧一般包括:
函数的返回地址和参数
临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量
函数调用的上下文

参考

https://blog.csdn.net/Casuall/article/details/88783277

逃逸分析的作用是什么呢?

1、逃逸分析的好处是为了减少gc的压力,不逃逸的对象分配在栈上,当函数返回时就回收了资源,不需要gc标记清除。

2、逃逸分析完后可以确定哪些变量可以分配在栈上,栈的分配比堆快,性能好(逃逸的局部变量会在堆上分配 ,而没有发生逃逸的则有编译器在栈上分配)。

3、同步消除,如果你定义的对象的方法上有同步锁,但在运行时,却只有一个线程在访问,此时逃逸分析后的机器码,会去掉同步锁运行。

逃逸分析(escape analysis)要解决的问题

逃逸分析(escape analysis)就是在程序编译阶段根据程序代码中的数据流,对代码中哪些变量需要在栈上分配,哪些变量需要在堆上分配进行静态分析的方法。
Go编译器使用逃逸分析来决定哪些变量应该在goroutine的栈上分配,哪些变量应该在堆上分配。

喜欢这篇文章?打赏一下作者吧!

欢迎关注我的其它发布渠道