应用程序的内存会分成堆区(Heap)和栈区(Stack)两个部分,程序在运行期间可以主动从堆区申请内存空间,这些内存由内存分配器分配并由垃圾收集器负责回收栈区的内存由编译器自动进行分配和释放,栈区中存储着函数的参数以及局部变量,它们会随着函数的创建而创建,函数的返回而销毁

网管碎碎念:堆和栈都是编程语言里的虚拟概念,并不是说在物理内存上有堆和栈之分,两者的主要区别是栈是每个线程或者协程独立拥有的,从栈上分配内存时不需要加锁。而整个程序在运行时只有一个堆,从堆中分配内存时需要加锁防止多个线程造成冲突,同时回收堆上的内存块时还需要运行可达性分析、引用计数等算法来决定内存块是否能被回收,所以从分配和回收内存的方面来看栈内存效率更高。

Go应用程序运行时,每个goroutine都维护着一个自己的栈区,这个栈区只能自己使用不能被其他goroutine使用。栈区的初始大小是2KB(比x86_64架构下线程的默认栈2M要小很多),在goroutine运行的时候栈区会按照需要增长和收缩,占用的内存最大限制的默认值在64位系统上是1GB。栈大小的初始值和上限这部分的设置都可以在Go的源码runtime/stack.go里找到:

// rumtime.stack.go
// The minimum size of stack used by Go code
_StackMin = 2048var maxstacksize uintptr = 1 << 20 // enough until runtime.main sets it for real

其实栈内存空间、结构和初始大小在最开始并不是2KB,也是经过了几个版本的更迭

  • v1.0 ~ v1.1 — 最小栈内存空间为 4KB;

  • v1.2 — 将最小栈内存提升到了 8KB;

  • v1.3 — 使用连续栈替换之前版本的分段栈;

  • v1.4 — 将最小栈内存降低到了 2KB;

分段栈和连续栈

分段栈

Go 1.3 版本前使用的栈结构是分段栈,随着goroutine 调用的函数层级的深入或者局部变量需要的越来越多时,运行时会调用 runtime.morestackruntime.newstack创建一个新的栈空间,这些栈空间是不连续的,但是当前 goroutine 的多个栈空间会以双向链表的形式串联起来,运行时会通过指针找到连续的栈片段:

分段栈虽然能够按需为当前 goroutine 分配内存并且及时减少内存的占用,但是它也存在一个比较大的问题:

  • 如果当前 goroutine 的栈几乎充满,那么任意的函数调用都会触发栈的扩容,当函数返回后又会触发栈的收缩,如果在一个循环中调用函数,栈的分配和释放就会造成巨大的额外开销,这被称为热分裂问题(Hot split)。

为了解决这个问题,Go在1.2版本的时候不得不将栈的初始化内存从4KB增大到了8KB。后来把采用连续栈结构后,又把初始栈大小减小到了2KB。

连续栈

连续栈可以解决分段栈中存在的两个问题,其核心原理就是每当程序的栈空间不足时,初始化一片比旧栈大两倍的新栈并将原栈中的所有值都迁移到新的栈中,新的局部变量或者函数调用就有了充足的内存空间。使用连续栈机制时,栈空间不足导致的扩容会经历以下几个步骤:

  1. 调用用runtime.newstack在内存空间中分配更大的栈内存空间;

  2. 使用runtime.copystack将旧栈中的所有内容复制到新的栈中;

  3. 将指向旧栈对应变量的指针重新指向新栈

  4. 调用runtime.stackfree销毁并回收旧栈的内存空间;

copystack会把旧栈里的所有内容拷贝到新栈里然后调整所有指向旧栈的变量的指针指向到新栈, 我们可以用下面这个程序验证下,栈扩容后同一个变量的内存地址会发生变化。

package mainfunc main() {var x [10]intprintln(&x)a(x)println(&x)
}//go:noinline
func a(x [10]int) {println(`func a`)var y [100]intb(y)
}//go:noinline
func b(x [100]int) {println(`func b`)var y [1000]intc(y)
}//go:noinline
func c(x [1000]int) {println(`func c`)
}

程序的输出可以看到在栈扩容前后,变量x的内存地址的变化:

0xc000030738
...
...
0xc000081f38

栈区的内存管理

前面说了每个goroutine都维护着自己的栈区,栈结构是连续栈,是一块连续的内存,在goroutine的类型定义的源码里我们可以找到标记着栈区边界的stack信息,stack里记录着栈区边界的高位内存地址和低位内存地址:

type g struct {stack       stack...
}type stack struct {lo uintptrhi uintptr
}

全局栈缓存

栈空间在运行时中包含两个重要的全局变量,分别是 runtime.stackpoolruntime.stackLarge,这两个变量分别表示全局的栈缓存和大栈缓存,前者可以分配小于 32KB 的内存,后者用来分配大于 32KB 的栈空间:

 // Number of orders that get caching. Order 0 is FixedStack// and each successive order is twice as large.// We want to cache 2KB, 4KB, 8KB, and 16KB stacks. Larger stacks// will be allocated directly.// Since FixedStack is different on different systems, we// must vary NumStackOrders to keep the same maximum cached size.//   OS               | FixedStack | NumStackOrders//   -----------------+------------+---------------//   linux/darwin/bsd | 2KB        | 4//   windows/32       | 4KB        | 3//   windows/64       | 8KB        | 2//   plan9            | 4KB        | 3
_NumStackOrders = 4 - sys.PtrSize/4*sys.GoosWindows - 1*sys.GoosPlan9var stackpool [_NumStackOrders]mSpanListtype stackpoolItem struct {mu   mutexspan mSpanList
}var stackLarge struct {lock mutexfree [heapAddrBits - pageShift]mSpanList
}//go:notinheap
type mSpanList struct {first *mspan // first span in list, or nil if nonelast  *mspan // last span in list, or nil if none
}

可以看到这两个用于分配空间的全局变量都与内存管理单元 runtime.mspan 有关,所以我们栈内容的申请也是跟前面文章里的一样,先去当前线程的对应尺寸的mcache里去申请,不够的时候mache会从全局的mcental里取内存等等,想了解这部分具体细节的同学可以参考前面的文章图解Go内存管理器的内存分配策略

其实从调度器和内存分配的角度来看,如果运行时只使用全局变量来分配内存的话,势必会造成线程之间的锁竞争进而影响程序的执行效率,栈内存由于与线程关系比较密切,所以在每一个线程缓存 runtime.mcache 中都加入了栈缓存减少锁竞争影响。

type mcache struct {...alloc [numSpanClasses]*mspanstackcache [_NumStackOrders]stackfreelist...
}type stackfreelist struct {list gclinkptrsize uintptr
}

栈扩容

编译器会为函数调用插入运行时检查runtime.morestack,它会在几乎所有的函数调用之前检查当前goroutine 的栈内存是否充足,如果当前栈需要扩容,会调用runtime.newstack 创建新的栈:

func newstack() {......// Allocate a bigger segment and move the stack.oldsize := gp.stack.hi - gp.stack.lonewsize := oldsize * 2if newsize > maxstacksize {print("runtime: goroutine stack exceeds ", maxstacksize, "-byte limit\n")throw("stack overflow")}// The goroutine must be executing in order to call newstack,// so it must be Grunning (or Gscanrunning).casgstatus(gp, _Grunning, _Gcopystack)// The concurrent GC will not scan the stack while we are doing the copy since// the gp is in a Gcopystack status.copystack(gp, newsize, true)if stackDebug >= 1 {print("stack grow done\n")}casgstatus(gp, _Gcopystack, _Grunning)
}

旧栈的大小是通过我们上面说的保存在goroutine中的stack信息里记录的栈区内存边界计算出来的,然后用旧栈两倍的大小创建新栈,创建前会检查是新栈的大小是否超过了单个栈的内存上限。

   oldsize := gp.stack.hi - gp.stack.lonewsize := oldsize * 2if newsize > maxstacksize {print("runtime: goroutine stack exceeds ", maxstacksize, "-byte limit\n")throw("stack overflow")}

如果目标栈的大小没有超出程序的限制,会将 goroutine 切换至 _Gcopystack 状态并调用 runtime.copystack 开始栈的拷贝,在拷贝栈的内存之前,运行时会先通过runtime.stackalloc 函数分配新的栈空间:

func copystack(gp *g, newsize uintptr) {old := gp.stackused := old.hi - gp.sched.sp// 创建新栈new := stackalloc(uint32(newsize))...// 把旧栈的内容拷贝至新栈memmove(unsafe.Pointer(new.hi-ncopy), unsafe.Pointer(old.hi-ncopy), ncopy)...// 调整指针adjustctxt(gp, &adjinfo)// groutine里记录新栈的边界gp.stack = new...// 释放旧栈stackfree(old)
}

新栈的初始化和数据的复制是一个比较简单的过程,整个过程中最复杂的地方是将指向源栈中内存的指针调整为指向新的栈,这一步完成后就会释放掉旧栈的内存空间了。

我们可以通过修改一下源码文件runtime.stack.go,把常量stackDebug的值修改为1,使用命令go build -gcflags -S main.go 运行文章最开始的那个例子,观察栈的初始化和扩容过程:

stackalloc 2048
stackcacherefill order=0allocated 0xc000030000
...
copystack gp=0xc000000180 [0xc000030000 0xc0000306e0 0xc000030800] -> [0xc00005c000 0xc00005cee0 0xc00005d000]/4096
stackfree 0xc000030000 2048
stack grow done
...
copystack gp=0xc000000180 [0xc00005c000 0xc00005c890 0xc00005d000] -> [0xc000064000 0xc000065890 0xc000066000]/8192
stackfree 0xc00005c000 4096
stack grow done
...
copystack gp=0xc000000180 [0xc000064000 0xc000065890 0xc000066000] -> [0xc00006c000 0xc00006f890 0xc000070000]/16384
stackfree 0xc000064000 8192
stack grow done
...
copystack gp=0xc000000180 [0xc00006c000 0xc00006f890 0xc000070000] -> [0xc000070000 0xc000077890 0xc000078000]/32768
stackfree 0xc00006c000 16384
stack grow done

栈缩容

goroutine运行的过程中,如果栈区的空间使用率不超过1/4,那么在垃圾回收的时候使用runtime.shrinkstack进行栈缩容,当然进行缩容前会执行一堆前置检查,都通过了才会进行缩容

func shrinkstack(gp *g) {...oldsize := gp.stack.hi - gp.stack.lonewsize := oldsize / 2if newsize < _FixedStack {return}avail := gp.stack.hi - gp.stack.loif used := gp.stack.hi - gp.sched.sp + _StackLimit; used >= avail/4 {return}copystack(gp, newsize)
}

如果要触发栈的缩容,新栈的大小会是原始栈的一半,不过如果新栈的大小低于程序的最低限制 2KB,那么缩容的过程就会停止。缩容也会调用扩容时使用的 runtime.copystack 函数开辟新的栈空间,将旧栈的数据拷贝到新栈以及调整原来指针的指向。

在我们上面的那个例子里,当main函数里的其他函数执行完后,只有main函数还在栈区的空间里,如果这个时候系统进行垃圾回收就会对这个goroutine的栈区进行缩容。在这里我们可以在程序里通过调用runtime.GC,强制系统进行垃圾回收,来试验看一下栈缩容的过程和效果:

func main() {var x [10]intprintln(&x)a(x)runtime.GC()println(&x)
}

执行命令go build -gcflags -S main.go后会看到类似下面的输出。

...
shrinking stack 32768->16384
stackalloc 16384allocated 0xc000076000
copystack gp=0xc000000180 [0xc00007a000 0xc000081e60 0xc000082000] -> [0xc000076000 0xc000079e60 0xc00007a000]/16384
...

总结

栈内存是应用程序中重要的内存空间,它能够支持本地的局部变量和函数调用,栈空间中的变量会与栈一同创建和销毁,这部分内存空间不需要工程师过多的干预和管理,现代的编程语言通过逃逸分析减少了我们的工作量,理解栈内存空间的分配对于理解 Go 语言的运行时有很大的帮助。

看到这里了,如果喜欢我的文章可以帮我点个赞和在看把分享给更多小伙伴,我会每周通过技术文章分享我的所学所见,感谢你的支持。微信搜索关注公众号「网管叨bi叨」第一时间获取我的文章推送。

推荐阅读

- END -

关注公众号,获取更多精选技术原创文章

解密Go协程的栈内存管理相关推荐

  1. 解密Kotlin协程的suspend修饰符

    Kotlin 协程中引入了 suspend 修饰符和挂起函数的概念,Kotlin 编译器将会为每个挂起函数创建一个状态机,这个状态机将为我们管理协程的操作. 协程 协程简化了 Android 平台的异 ...

  2. 20 张图揭开内存管理的迷雾,瞬间豁然开朗

    每日英语,每天进步一点点 前言 之前有不少读者跟我反馈,能不能写图解操作系统? 既然那么多读者想看,我最近就在疯狂的复习操作系统的知识. 操作系统确实是比较难啃的一门课,至少我认为比计算机网络难太多了 ...

  3. 微信终端自研 C++协程框架的设计与实现

    作者:peterfan,腾讯 WXG 客户端开发工程师 背景 基于跨平台考虑,微信终端很多基础组件使用 C++ 编写,随着业务越来越复杂,传统异步编程模型已经无法满足业务需要.Modern C++ 虽 ...

  4. 微信 libco 协程库原理剖析

    作者:alexzmzheng 同 Go 语言一样,libco 也是提供了同步风格编程模式,同时还能保证系统的高并发能力,本文主要剖析 libco 中的协程原理. 简介 libco 是微信后台大规模使用 ...

  5. C++ 开源协程库 libco——原理及应用

    1 导论 使用 C++ 来编写高性能的网络服务器程序,从来都不是件很容易的事情.在没有应用任何网络框架,从 epoll/kqueue 直接码起的时候尤其如此.即便使用 libevent, libev这 ...

  6. 从无栈协程到C++异步框架

    导语 在前面的文章中我们尝试从 C++17 和 C++20 的角度分别探讨过其中无栈协程的包装机制和使用, 但其中的设计由来, 原理, 剥析的并不多. 这也导致对相关特性不太熟悉的读者要理解相关内容存 ...

  7. 从无栈协程到 C++异步框架

    作者:fangshen,腾讯 IEG 游戏客户端开发工程师 导语 本文我们将尝试对整个 C++的协程做深入浅出的剥析, 方便大家的理解. 再结合上层的封装, 最终给出一个 C++异步框架实际业务使用的 ...

  8. arm64入栈出栈_使用 ARM64 汇编实现共享栈式协程

    # 简述 大约在半年以前,我曾经了解过协程的相关实现,也看过腾讯后台开源的协程库`libco`,对其中实现协程相关的汇编有很深的印象(`libco`适配的是 x86 平台).接受了这样的思想,我在自己 ...

  9. Golang —— goroutine(协程)和channel(管道)

    协程(goroutine) 协程(goroutine)是Go中应用程序并发处理的部分,它可以进行高效的并发运算. 协程是轻量的,比线程更廉价.使用4K的栈内存就可以在内存中创建. 能够对栈进行分割,动 ...

最新文章

  1. 学习《Flask Web开发:基于Python的Web应用开发实战》分享
  2. CentOS最小化系统,怎么安装图形界面
  3. 利用 FFmpeg palettegen paletteuse 生成接近全色的 gif 动画
  4. 收好这份 Vue 升级图,假期偷偷上个钻
  5. 向一个数组中插入元素
  6. 【python基础知识】写入文件指定编码格式,例如utf-8
  7. 什么是向量中断,什么是中断向量?
  8. Python 获取剪切板
  9. 检查radio/checkbox是否至少选择一项
  10. 数据结构------图(一)
  11. 轻量级小型网站导航条
  12. java拦截器_Java拦截器
  13. 14. AsciiArt礼节/15. cterm的拷贝TCP包功能
  14. Web应用实例:音频可视化
  15. 如何根据视频的宽屏与竖屏来排序?
  16. 键盘输入,键盘输入结束符。
  17. D. Masquerade strikes back(思维)
  18. linux samba 服务端口号,Linux系统学习 二十、SAMBA服务—介绍、安装、端口
  19. 从JSP跳转到Servlet的两种方式
  20. Android单元测试一:单元测试入门

热门文章

  1. Yii2 源码分析 - 入口文件执行流程
  2. putty的基本使用
  3. 微信成为开发者C#代码
  4. 4.云计算和大数据时代网络揭秘-安全的网络通道-网络加密
  5. 初识webpack——webpack四个基础概念
  6. Pocket Gem OA: Path Finder
  7. string replaceAll
  8. 国外值得关注的网站系列之二-社交化推荐网站GetGlue
  9. 让局域网内部挺高网速
  10. anime studio的本质特性