Go 的垃圾回收 (一) 基础

221 阅读4分钟

开篇

Go中的垃圾回收简要描述就是:负责跟踪堆栈的内存,将不用的释放出去,保留仍在用的内存。

一般来讲,开发者并不需要了解实现的具体细节,因为这些底层逻辑并不影响日常开发,并且随着发行GO的新版本,回收机制也会变化。所以对开发者而言,更重要的是理解垃圾回收的流程和方式。在这篇文章中,将会重点介绍收集器的流程。(了解更多内容,可以读 garbage collectors 系列文章

收集器的三步曲

收集器的流程有三个步骤,其中有两个阶段创建了STW延迟 (StopTheWorld),另一个还降低了程序的吞吐量。这三个步骤为:

  1. 设计标记 -- STW
  2. 做标记 --并发
  3. 标记终止 -- STW

阶段一:设计标记

开始回收时,先开启写屏障。这么做的目的是在收集器和应用程序同时运行的时候,收集器能保护堆栈中的数据完整。打开写屏障时,所有的goroutines都要停止,而这么短的时间平均只用 10-30微秒。(如果想更深入地理解,可以读 Go Scheduler 的相关文章)



这张图展示了四个应用进程的goroutine,他们在正常地运行着。但在收集器开始之前,他们都会停止。这是如何实现的呢? 收集器会对它们进行监听,等待着它们在安全的时候调用终止函数。

问题一:如果其中一个进程正在执行某些复杂的运算和循环,没法调用终止函数呢?



比如上图中P4的goroutine可能因为不合理的循环而无法终止,导致无法启动垃圾回收。而且其他的P也无法进行新的goroutine任务。(这也是Go团队想在1.14版本在调度进程中添加抢先技术解决的问题)

做标记--并发

开启写屏障的时候,垃圾收集器就开始做标记了。首先它需要占用可用CPU容量的25% ,在下面的例子中,是用一整个P来进行垃圾回收 (如下图所示)。



现在P1 的goroutine来做垃圾回收,下面开始进行“标记”阶段。

首先,标记出堆内存中仍然在用的值。我们检查正在运行的goroutines堆栈,找到所有指向堆栈内存的根指针,然后从根指针遍历出堆栈内存图。这也意味着垃圾回收的影响可以达到最小化,只占用25%的内存,我们可以一边在 P1 上进行垃圾回收的标记,一边在P2\P3\P4并发运行应用程序。

可是,事情也不会永远这么简单,总有意外出现。

场景一:

P1的标记还没有打完,然而堆栈内存已经满了;

场景二:

在P2\P3\P4里面有一个goroutine导致无法及时结束垃圾回收。

遇到这些情况怎么办呢?就需要减慢内存的分配速度(尤其是有问题的那个goroutine)。回收的进程变慢了,会召集其他的goroutine一起来打标记。这个过程称为“标记辅助”。goroutine在“标记辅助”中所用的时间一般与堆栈内存中所存的数据量成正比。“标记辅助”能加快回收的速度。


上图就展示了P3运行的goroutine作为“标记辅助”帮收集器进行标记的状态。这样其他的goroutine就不需要参与回收工作,尽量保证在运行很复杂的进程时,只有少量的goroutine在参与“标记辅助”。收集器会有意的限制“标记辅助”的调用数量。如果有个收集器在结束的时候还在请求很多的“标记辅助”来帮忙,收集器会尽早进行下一轮垃圾回收,以减少下轮回收中请求“标记辅助”的数量。

标记终止 -- STW

“标记终止”阶段意味着关闭了写屏障并开始执行各种清理工作,准备开始下一轮回收任务。如果发现Goroutines在标记阶段陷入很深的循环嵌套,就会触发“标记终止”延长SWT的等待时间。下图展示了“标记终止”结束时所有的goroutine都结束的场景。这一阶段平均耗时60~90毫秒。


在这阶段可以不用SWT,因为用它虽然代码更简单,但带来的效果微乎其微。



当回收结束的时候,每个 P 又可以重新调用它们的goroutine,应用又回到了最大的吞吐量的状态(上图所示)。


本文节选翻译自 www.ardanlabs.com/blog/2018/1…

未完待续...