GO 垃圾回收 再解

119 阅读12分钟

0. 前提引入

在各种流传甚广的 C 语言宝典里一般都有这么一条神秘的规则,不能返回局部变量:

int * func(void) {
    int num = 1234;
    return #
}

函数返回后,函数的栈帧即被销毁,引用了被销毁位置的内存轻则数据错乱,重则 segmentation fault。

经过了八十一难,终于成为了 C 语言绝世高手,还是逃不过复杂的堆上对象引用关系导致的 dangling pointer: 图片 当 B 被 free 掉之后,应用程序依然可能会使用指向 B 的指针,这是比较典型的 dangling pointer 问题。

依赖人去处理这些问题是不科学,不合理的。C 和 C++ 程序员已经被折磨了数十年,不应该再重蹈覆辙了。

本章阅读前建议先阅读Go 内存分配,本章涉及一些内存分配的问题

1. 垃圾回收概述

在传统编程语言中我们需要关注对象的分配位置,要自己去选择对象分配在堆还是栈上,但在 Go 这门有 GC 的语言中,集成了逃逸分析功能,帮助我们自动判断对象应该在堆上还是栈上,可以使用 go build -gcflags="-m" 来观察逃逸分析的结果:

package main

func main() {
    var m = make([]int10240)
    println(m[0])
}

较大的对象也会被放在堆上 图片 可以看到发生了逃逸

若对象被分配在栈上,其管理成本较低,通过挪动栈顶寄存器就可以实现对象的分配和释放。若分配在堆上,则要经历层层的内存申请过程。但这些流程对用户都是透明的,编写代码时我们并不需要在意它。需要优化时,才需要研究具体的逃逸分析规则。

逃逸分析与垃圾回收结合在一起,极大地解放了程序员们的心智,在编写代码时,似乎再也没必要去担心内存的分配和释放问题了。

然而一切抽象皆有成本,这个成本要么花在编译期,要么花在运行期。

GC 这种方案是选择在运行期来解决问题,不过在极端场景下 GC 本身引起的问题依然是令人难以忽视的: 图片 GC 使用了 90% 以上的 CPU 资源

上图的场景是在内存中缓存了上亿的 kv,这时 GC 使用的 CPU 甚至占到了总 CPU 占用的 90% 以上。简单粗暴地在内存中缓存对象,到头来发现 GC 成为了 CPU 杀手。吃掉了大量的服务器资源,这显然不是我们期望的结果。

2. 内存管理的三个参与者

  • mutator 即我们的应用,我们将堆上的对象看作一个图,跳出应用来看的话,应用的代码就是在不停地修改这张堆对象图里的指向关系。下面的图可以帮我们理解 mutator 对堆上的对象的影响: 图片
  • allocator 内存分配器,应用需要内存时要向 allocator 申请。allocator 要维护好内存分配的数据结构,多线程分配器要考虑高并发场景下锁的影响,并针对性地进行设计以降低锁冲突。
  • collector 垃圾回收器。死掉的堆对象,不用的堆内存由 collector 回收,最终归还给OS。扫描内存中存活的堆对象,扫描完成后,未被扫描到的对象就是无法访问的堆上垃圾,需要将其占用内存回收掉。

三者的交互过程可以用下图来表示: 图片

3. 分配内存

应用程序使用 mmap 向 OS 申请内存,操作系统提供的接口较为简单,mmap 返回的结果是连续的内存区域。

mutator 申请内存是以应用视角来看问题,我需要的是某一个 struct,某一个 slice 对应的内存,这与从操作系统中获取内存的接口之间还有一个鸿沟。需要由 allocator 进行映射与转换,将以“块”来看待的内存与以“对象”来看待的内存进行映射:

图片

应用代码中的对象与内存间怎么做映射?

详情可见:Go 内存分配

4. 垃圾回收

Go 语言使用了并发标记与清扫算法作为其 GC 实现,并发标记清扫算法无法解决内存碎片问题,而 tcmalloc 恰好一定程度上缓解了内存碎片问题,两者配合使用相得益彰。

这并不是说 tcmalloc 完全没有内存碎片,不信你在代码里搜搜 max waste

4.1 垃圾分类

图片

语义垃圾,有些场景也被称为内存泄露

语义垃圾指从语法上可达(可以通过局部、全局变量被引用)的对象,但从语义上来讲他们是垃圾,垃圾回收器对此无能为力。

图片

我们初始化一个 slice,元素均为指针,每个指针都指向了堆上 10MB 大小的一个对象。

图片

当这个 slice 缩容时,底层数组的后两个元素已经无法再访问了,但其关联的堆上内存依然是无法释放的。

碰到类似的场景,需要在缩容前先将数组元素置为 nil

语法垃圾

从语法上无法到达的对象,这些才是垃圾收集器主要的收集目标。

图片

在 allocOnHeap 返回后,堆上的 a 无法访问,便成为了语法垃圾。

4.2 GC 流程

Go 的每一轮迭代几乎都会对 GC 做优化。

经过多次优化后,较新的 GC 流程如下图: 图片

在并发标记前/终止时,有两个短暂的 stw,该 stw 可以使用 pprof 的 pauseNs 来观测,也可以直接采集到监控系统中: 图片 pauseNs 就是每次 stw 的时长

尽管官方声称 Go 的 stw 已经是亚毫秒级了,我们在高压力的系统中仍然能够看到毫秒级的 stw。

4.3 标记流程

Go 语言使用三色抽象作为其并发标记的实现,首先要理解三种颜色抽象:

  • 黑:已经扫描完毕,子节点扫描完毕。(gcmarkbits = 1,且在队列外)
  • 灰:已经扫描完毕,子节点未扫描完毕。(gcmarkbits = 1, 在队列内)
  • 白:未扫描,collector 不知道任何相关信息。

三色抽象主要是为了能让垃圾回收流程与应用流程并发执行,这样将对象扫描过程拆分为多个阶段,而不需要一次性完成整个扫描流程。 图片 GC 线程与应用线程大部分情况下是并发执行的

GC 扫描的起点是根对象,常见的根对象可以参见下图: 图片 所以在 Go 语言中,从根开始扫描的含义是从 .bss 段.data 段以及 goroutine 的栈开始扫描,最终遍历整个堆上的对象树。

标记过程是一个广度优先的遍历过程,扫描节点,将节点的子节点推到任务队列中,然后递归扫描子节点的子节点,直到所有工作队列都被排空为止。 图片 两张内容比较多的图截出来了: image.png image.png 后台标记 worker 的工作过程

mark 阶段会将白色对象标记,并推进队列中变成灰色对象。我们可以看看 scanobject 的具体过程: 图片 在后台的 mark worker 执行对象扫描,并将 堆上的指针 push 到工作队列(gcw)

在标记过程中,gc mark worker 会一边从gcw中弹出对象,并将其子对象 push 到gcw中,如果工作队列满了,则要将一部分元素向全局队列转移。

我们知道堆上对象本质上是图,会存储引用关系互相交叉的时候,在标记过程中也有简单的剪枝逻辑: 图片 如果两个后台 mark worker 分别从 A、B 这两个根开始标记,他们会重复标记 D 吗?

D 是 A 和 B 的共同子节点,在标记过程中自然会减枝,防止重复标记浪费计算资源: 图片

4.4 协助标记

当应用分配内存过快时,后台的 mark worker 无法及时完成标记工作,这时应用本身需要进行堆内存分配时,会判断是否需要适当协助 GC 的标记过程,防止应用因为分配过快发生 OOM。

碰到这种情况时,我们会在火焰图中看到对应的协助标记的调用栈: 图片 协助标记会对应用的响应延迟产生影响,可以尝试降低应用的对象分配数量进行优化。

4.5 对象丢失问题

前面提到了 GC 协程与应用协程是并发执行的,在 GC 标记worker 工作期间,应用还会不断地修改堆上对象的引用关系,下面是一个典型的应用与 GC 同时执行时,由于应用对指针的变更导致对象漏标记,从而被 GC 误回收的情况。

如图所示,在 GC 标记过程中,应用动态地修改了 A 和 C 的指针,让 A 对象的内部指针指向了 B,C 的内部指针指向了 D,如果标记过程无法感知到这种变化,最终 B 对象在标记完成后是白色,会被错误地认作内存垃圾被回收。 图片 为了解决漏标,错标的问题,我们先需要定义三色不变性,如果我们的堆上对象的引用关系不管怎么修改,都能满足三色不变性,那么也不会发生对象丢失问题。

强三色不变性:禁止黑色对象指向白色对象。 图片 弱三色不变性:黑色对象可以指向白色对象,但指向的白色对象,必须有能从灰色对象可达的路径。 图片 无论应用在与 GC 并发执行期间如何修改堆上对象的关系,只要修改之后,堆上对象能满足任意一种不变性,就不会发生对象的丢失问题。

而实现强/弱三色不变性均需要引入屏障技术。在 Go 语言中,使用写屏障,即 write barrier 来解决上述问题。

4.6 write barrier

barrier 本质: 在指针前插入代码片段进行修改。

Go 语言的 GC 只有 write barrier,没有 read barrier。

在应用进入 GC 标记阶段前的 stw 阶段,会将全局变量 runtime.writeBarrier.enabled 修改为 true,这时所有的堆上指针修改操作在修改之前便会额外调用 runtime.gcWriteBarrier

图片 在指针修改时被插入的 write barrier 函数调用

常见的 write barrier 有两种:

  • Dijistra 插入屏障:指针修改时,指向的新对象要标灰 图片
  • Yuasa 删除屏障:指针修改时,修改前指向的对象要标灰 图片

Go 的写屏障混合了上述两种屏障:

图片 如果 Go 语言的所有对象都在堆上分配,理论上我们只要选择 Dijistra 或者 Yuasa 中的任意一种,就可以实现强/弱三色不变性了,为什么要做这么复杂呢?

因为在 Go 语言中,由于栈上对象操作过于频繁,即使在标记执行阶段,栈上对象也是不开启写屏障的。如果我们只使用 Dijistra 或者只使用 Yuasa Barrier,都会有对象丢失的问题:

  • Dijistra 插入屏障 的对象丢失问题 图片
  • Yuasa 删除屏障 的对象丢失问题 图片
    早期 Go 只使用了 Dijistra 屏障,但因为有上述对象丢失问题,需要在第二个 stw 进行栈重扫。当 goroutine 数量较多时,会造成 stw 时间较长。

想要消除栈重扫,单独使用任意一种 barrier 都没法满足 Go 的要求,所以最新版本中 Go 使用混合屏障。

4.7 回收流程

进程启动时会有两个特殊 goroutine:

  • sweep.g:负责清扫死对象,合并相关的空闲页
  • scvg.g:负责向操作系统归还内存
(dlv) goroutines
* Goroutine 1 - User: ./int.go:22 main.main (0x10572a6) (thread 5247606)
  Goroutine 2 - User/usr/local/go/src/runtime/proc.go:367 runtime.gopark (0x102e596) [force gc (idle) 455634h24m29.787802783s]
  Goroutine 3 - User/usr/local/go/src/runtime/proc.go:367 runtime.gopark (0x102e596) [GC sweep wait]
  Goroutine 4 - User/usr/local/go/src/runtime/proc.go:367 runtime.gopark (0x102e596) [GC scavenge wait]

这里的 GC sweep wait 和 GC scavenge wait, 就是这两个 goroutine

标记流程结束后,sweep goroutine 就会被唤醒,进行清扫工作。针对每个 mspan,sweep.g 的工作是将标记期间生成的 bitmap 替换掉分配时使用的 bitmap: 图片 然后根据 mspan 中的槽位情况决定该 mspan 的去向:

  • 如果 mspan 中存活对象数 = 0,即所有 element 都变成了内存垃圾,那执行 freeSpan:归还组成该 mspan 所使用的页,并更新全局的页分配器摘要信息
  • 如果 mspan 中没有空槽,说明所有对象都是存活的,将其放入 fullSwept 队列中
  • 如果 mspan 中有空槽,说明这个 mspan 还可以拿来做内存分配,将其放入 partialSweep 队列中

之后清道夫被唤醒,执行线性流程,一路运行到将页内存归还给操作系统: 最终用 madvise 将内存归还给操作系统

小结

Go 语言垃圾回收的关键点:

  • 无分代
  • 与应用执行并发
  • 协助标记流程
  • 并发执行时开启 write barrier

因为无分代,当我们遇到一些需要在内存中保留几千万 kv map 的场景时,就需要想办法降低 GC 扫描成本。

因为有协助标记,当应用的 GC 占用的 CPU 超过 25% 时,会触发大量的协助标记,影响应用的延迟,这时也要对 GC 进行优化。

简单的业务使用 sync.Pool 就可以带来较好的优化效果,若碰到一些复杂的业务场景,还要考虑 offheap 之类的欺骗 GC 的方案