9

goroutine究竟占用多少内存?

 4 years ago
source link: https://studygolang.com/articles/25393
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

相信接触过 Go 语言的同学,都应该有听说过 Go 协程,也就是 goroutine 的概念,对于 goroutine 的介绍,大部分文章中提到的都是,相较于线程,goroutine 十分轻量,相同大小的内存,可以运行更多的 goroutine。但是很少有文章解释 goroutine 是如何做到占用更少资源的,单个 goroutine 究竟占用多少内存?本文将针对这些问题进行解释。

一些基本结论

  • goroutine 所占用的内存,均在栈中进行管理
  • goroutine 所占用的栈空间大小,由 runtime 按需进行分配
  • 以 64位环境的 JVM 为例,会默认固定为每个线程分配 1MB 栈空间,如果大小分配不当,便会出现栈溢出的问题

聪明的你应该不难从上面这些结论中看出,goroutine 相较于线程更加轻量,关键点就在于栈空间的动态分配,这样便可以最大限度的利用内存资源。既然是动态分配,那脱离实际情况而单纯说单个 goroutine 占用多大内存,就有点吹毛求疵了。所以接下来,我们就先来看看,goroutine 是如何做到栈空间动态分配的。

分段栈

在 Go 的早期版本中,使用分段栈的方式进行内存管理,当一个goroutine被创建时, runtime 会为协程分配 8KB 的内存区域。那么问题来了,8KB 空间不够了怎么办?

为了解决这个问题,Go 会在每个函数的入口处都插入一小段前置代码,它能够检查栈空间是否被消耗殆尽,如果用完了,便会调用 morestack() 函数来扩展空间。

morestack()函数机理,即分段栈扩张机理:为栈空间分配一块新的内存区域。然后在这个新栈的底部的结构体中填充关于该栈的各种数据,包括刚刚来自的旧栈的地址。当得到了一个新的栈分段之后,通过重新执行,导致栈被用完的函数,来重启goroutine。这就被称为栈的分裂

+---------------+
  |               |
  |   unused      |
  |   stack       |
  |   space       |
  +---------------+
  |    test       |
  |               |
  +---------------+
  |               |
  |  lessstack    |
  +---------------+
  | Stack info    |
  |               |-----+
  +---------------+     |
                        |
                        |
  +---------------+     |
  |    test       |     |
  |               | <---+
  +---------------+
  | rest of stack |
  |               |
复制代码

分段栈回溯机理:如上图所示,新栈会为lessstack()插入一个栈条目。这个函数并不实际显式调用。它会在耗尽旧栈的那个函数返回的时候被设置,例如图中的test(),当test()运行完毕返回时,会返回到lessstack()中,它会查询栈底部的结构体信息,并调整栈指针(SP),以便能够回溯到上一个栈分段。然后,就可以释放新栈段空间了。

分段栈存在的问题

分段栈机制使得栈可以按需扩张收缩。而程序员不需要在意栈的大小。

但是分段栈也有瑕疵。收缩栈是一个相对昂贵的操作。如果是在一个循环中分裂栈情况更明显。函数会增长栈,分裂栈,返回栈,并且释放栈分段。如果是在循环里面做这些操作,那么将会付出很大的开销。例如循环一次经历了这些过程,当下一次循环时栈又被耗尽,又得重新分配栈分段,然后又被释放掉,周而复始,循环往复,开销就会巨大。

这就是熟知的 hot split problem (热点分裂问题)。这是Golang开发组切换到新的栈管理方式的主要原因,新方式称为栈拷贝。

连续栈

从GO1.4之后,开始正式使用了连续栈机制。

栈拷贝开始很像分段栈。协程运行,使用栈空间,当栈将要耗尽时,触发相同的栈溢出检测。

但是,不像分段栈里有一个回溯链接,栈拷贝的方式则是创建了一个新的分段,它是旧栈的两倍大小,并且把旧栈完全拷贝进来。 这样当栈收缩为旧栈大小时,runtime不会做任何事情。收缩变成了一个no op免费操作。此外,当栈再次增长时,runtime也不需要做任何事情,重新使用刚才扩容的空间即可。

不像听起来那么容易,其实拷贝栈是一项艰巨的任务。由于栈中的变量在Golang中能够获取其地址,因此最终会出现指向栈的指针。而如果轻易拷贝移动栈,任何指向旧栈的指针都会失效。

而Golang的内存安全机制规定,任何能够指向栈的指针都必须存在于栈中。

所以可以通过垃圾收集器协助栈拷贝,因为垃圾收集器需要知道哪些指针可以进行回收,所以可以查到栈上的哪些部分是指针,当进行栈拷贝时,会更新指针信息指向新目标,以及它相关的所有指针。

但是,runtime中大量核心调度函数和GC核心都是用C语言写的,这些函数都获取不到指针信息,那么它们就无法复制。这种都会在一个特殊的栈中执行,并且由runtime开发者分别定义栈尺寸。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK