遥不可及|Go 协程的栈内存管理,解密

本文来源:微信公众号:网管叨bi叨
原文地址:
应用程序的内存会分成堆区(Heap)和栈区(Stack)两个部分 , 程序在运行期间可以主动从堆区申请内存空间 , 这些内存由内存分配器分配并由垃圾收集器负责回收 。 栈区的内存由编译器自动进行分配和释放 , 栈区中存储着函数的参数以及局部变量 , 它们会随着函数的创建而创建 , 函数的返回而销毁 。
网管碎碎念:堆和栈都是编程语言里的虚拟概念 , 并不是说在物理内存上有堆和栈之分 , 两者的主要区别是栈是每个线程或者协程独立拥有的 , 从栈上分配内存时不需要加锁 。 而整个程序在运行时只有一个堆 , 从堆中分配内存时需要加锁防止多个线程造成冲突 , 同时回收堆上的内存块时还需要运行可达性分析、引用计数等算法来决定内存块是否能被回收 , 所以从分配和回收内存的方面来看栈内存效率更高 。
在Go应用程序运行时 , 每个goroutine都维护着一个自己的栈区 , 这个栈区只能自己使用不能被其他goroutine使用 。 栈区的初始大小是2KB(比x86_64架构下线程的默认栈2M要小很多) , 在goroutine运行的时候栈区会按照需要增长和收缩 , 占用的内存最大限制的默认值在64位系统上是1GB 。 栈大小的初始值和上限这部分的设置都可以在Go的源码runtime/stack.go里找到:
//rumtime.stack.go//TheminimumsizeofstackusedbyGocode_StackMin=2048varmaxstacksizeuintptr=1<<20//enoughuntilruntime.mainsetsitforreal其实栈内存空间、结构和初始大小在最开始并不是2KB , 也是经过了几个版本的更迭
v1.0~v1.1—最小栈内存空间为4KB;v1.2—将最小栈内存提升到了8KB;v1.3—使用连续栈替换之前版本的分段栈;v1.4—将最小栈内存降低到了2KB;Go1.3版本前使用的栈结构是分段栈 , 随着goroutine调用的函数层级的深入或者局部变量需要的越来越多时 , 运行时会调用runtime.morestack和runtime.newstack创建一个新的栈空间 , 这些栈空间是不连续的 , 但是当前goroutine的多个栈空间会以双向链表的形式串联起来 , 运行时会通过指针找到连续的栈片段:
遥不可及|Go 协程的栈内存管理,解密
文章图片
分段栈虽然能够按需为当前goroutine分配内存并且及时减少内存的占用 , 但是它也存在一个比较大的问题:
如果当前goroutine的栈几乎充满 , 那么任意的函数调用都会触发栈的扩容 , 当函数返回后又会触发栈的收缩 , 如果在一个循环中调用函数 , 栈的分配和释放就会造成巨大的额外开销 , 这被称为热分裂问题(Hotsplit) 。 【遥不可及|Go 协程的栈内存管理,解密】为了解决这个问题 , Go在1.2版本的时候不得不将栈的初始化内存从4KB增大到了8KB 。 后来把采用连续栈结构后 , 又把初始栈大小减小到了2KB 。
连续栈可以解决分段栈中存在的两个问题 , 其核心原理就是每当程序的栈空间不足时 , 初始化一片比旧栈大两倍的新栈并将原栈中的所有值都迁移到新的栈中 , 新的局部变量或者函数调用就有了充足的内存空间 。 使用连续栈机制时 , 栈空间不足导致的扩容会经历以下几个步骤:
调用用runtime.newstack在内存空间中分配更大的栈内存空间;使用runtime.copystack将旧栈中的所有内容复制到新的栈中;将指向旧栈对应变量的指针重新指向新栈;调用runtime.stackfree销毁并回收旧栈的内存空间;
遥不可及|Go 协程的栈内存管理,解密
文章图片
copystack会把旧栈里的所有内容拷贝到新栈里然后调整所有指向旧栈的变量的指针指向到新栈 , 我们可以用下面这个程序验证下 , 栈扩容后同一个变量的内存地址会发生变化 。
packagemainfuncmain(){varx[10]intprintln(&x)a(x)println(&x)}//go:noinlinefunca(x[10]int){println(`funca`)vary[100]intb(y)}//go:noinlinefuncb(x[100]int){println(`funcb`)vary[1000]intc(y)}//go:noinlinefuncc(x[1000]int){println(`funcc`)}程序的输出可以看到在栈扩容前后 , 变量x的内存地址的变化:


推荐阅读