一 摘要
本文主要介绍下 GC 的基本概念、常见的 GC 算法、go 的 GC 过程,最后介绍一些 GC 优化的方法来提升服务性能。通过本文,读者可以对 go 的 GC 机制有一个大致的了解,针对 GC 问题有一定的解决思路。在业务开发中,能够及时发现由于 GC 导致的性能问题,并对其进行调优。
二 什么是 GC
GC:Garbage Collector「垃圾回收」,是一种自动的内存管理机制。
根据内存管理方式的不同,可以将编程语言分为手动内存管理「C、C++」和自动内存管理「Java、Python、Go」。在具有自动内存管理的语言中,开发者无需关注程序运行过程中的内存申请与回收,只需关注业务逻辑的实现即可。为了让程序实现自动的内存分配和回收,不同的语言都有一套属于自己的 GC 机制。
三 GC 算法
垃圾回收的主要目标是自动回收程序不再使用的内存空间,主要分为一下两种形式:
3.1 引用计数
跟踪每个对象被引用的次数,当一个对象的引用计数变为 0 时,表示这个对象不再被任何其他对象所引用,也就意味着程序将来不会再使用这个对象,因此这个对象就可以被当做垃圾回收了。
具体操作如下:
引用计数式 GC 的优点是实现简单,垃圾回收实时性强,不会造成程序暂停。但缺点也明显,它需要维护引用计数,存在一定的时间和空间开销,而且无法处理循环引用的问题,即两个或多个对象互相引用,但实际上已经不可能再被访问到。
3.2 追踪式
追踪式垃圾回收(Tracing Garbage Collection)是一类基于"可达性"分析来进行内存回收的算法。这类算法的基本思想是:从一组根对象(通常是全局变量和当前执行的函数的局部变量)开始,通过跟踪对象引用关系,逐步找出所有能够被访问到的对象,这些对象被认为是"存活"的,而其他未被访问到的对象则被认为是垃圾,可以被回收。
追踪式垃圾回收的主要代表算法有:
四 GC 流程
在 go 中使用的是无分代「不会进行分代管理」、不整理「不会对产生的碎片空间进行移动和整理」、并发「gc 程序与用户代码并发执行」的三色标记清楚算法。go 采取这种算法主要是出于以下考虑:
- 在 go 中,基于 tcmalloc(Thread-Caching Malloc)进行内存管理,讲内存分为不同固定大小的块,分配内存时会优先寻找符合大小的块分配,从而减少了内存碎片的产生。
- 在 go 中,使用的是值传递。这就意味着程序中频繁创建的非指针对象直接就会分配到栈中,函数执行完直接就回收栈空间,从而避免在堆上产生大量生命周期较短的小对象。此外,go 通过逃逸分析判断是否将对象分配到栈上,从而保证只有生命周期相对较长的对象才会分配到堆中。因此,无需对对象进行分代处理,只会增加相应的复杂度。
- go 垃圾回收器的目标则是优化时延,尽可能保证与用户代码并发执行,从而减低 GC 带来的时延。
4.1 三色标记算法
在 go 中,垃圾回收采用的并发的标记清楚算法,又叫三色标记法。在三色标记法中将对象分为三类,并用不同的颜色相称:
- 白色对象「可能死亡」:未被垃圾回收器访问到的对象。在回收未开始时,所有的对象均为白色。回收结束后,白色对象为不可达对象。
- 灰色对象:已经被回收器访问到的对象,但回收器需要对其中的指针进行扫描,因为可能指向白色对象。
- 黑色对象: 已经被回收器访问到的对象,且其中的所有字段都已经被扫描,黑色对象中的任何一个指针都不可能指向白色对象。
标记过程如下:
根对象又叫根集合,是垃圾回收中最先检查的对象,主要包括:
- 全局变量:程序在编译器就能确定的那些存在于整个程序生命周期的变量
- 执行栈:每个 goroutine 都包含自己的执行栈,根对象为执行栈上包含的变量以及指向堆内存区块的指针
- 寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块
4.2 屏障机制
在垃圾回收机制中,为了保证其准确性,通常需要一段时间暂停全部的用户代码,等待垃圾回收相关操作完成,将其称为 STW「Stop-The-Word」。在 go 中,为了将少 STW 的时间,采用了并发标记的方式,即标记与用户代码并发执行。这也引入一个新的问题,即并发标记过程中,用户代码更改对象引用的问题。如下图所示,当垃圾回收器对 B 进行扫描时,用户代码更新了对 C 的引用。最终标记完成后,C 为白色对象,需要被错误的清理。
可以证明,当以下两个条件同时被满足时会破坏垃圾回收器的正确性, 即用户代码导致白色对象无法被垃圾回收器扫描:
上面的 case 则同时满足了这两个条件「a: 将 A 指向 C,b: 从 B 到 C 的访问路径被破坏」,导致垃圾回收器错误的回收对象 C.
对于上面的两个条件,只要避免其中的一条,就可以保证算法的正确性。为保证标记结果的正确性,需要通过赋值器屏障技术来保证指针读写的一致性。本质上就是在赋值器更新引用时,能够“通知”回收器,让回收器根据引用关系的变更来更新对象颜色。
插入屏障
核心思想是破坏条件 a,即避免黑色对象直接引用白色对象。当插入的对象时白色时,就将其标记为灰色。它保证了所有可疑对象都是灰色的,且避免黑色对象直接引用白色对象。如下图所示,当更新 A -> C 的引用时,则将 C 标记为灰色,最终标记为黑色。
插入屏障的优点是可以保证标记算法和用户代码的并发执行。缺点是需要回收的对象可能会被标记会黑色「如下图流程中,当 A->C 的引用被用户代码删除,这是 C 已经被标记为黑色,即不可回收」,需要下一次 GC 才能进行回收。另一方面,每次的插入操作都会引入写屏障,增加了性能开销。为了减少性能开销,Go 在具体实现时,并没有对针对栈上的指针写操作开启写屏障。因此,在并发标记结束时,需要执行 STW,重新对栈空间进行三色标记。
删除屏障
核心思想是破环条件 b,即保证灰色对象对白色对象路径的完整性。当发生引用删除时,如果该对象是白色,则把它标记为灰色。如下图所示,当 B->C 的引用被删除时,将 C 标记为灰色。
删除屏障的优势在于无需对栈对象重新进行扫描,结束之后可以直接对白色对象进行回收;缺点在于会产生冗余的扫描。如下图,当删除 C 时,仍会对 C 进行扫描,且下次 GC 才能将 C 清除。
混合屏障
插入屏障和删除屏障进行混合,尽可能减少 STW 的时间。具体操作如下,详细流程参考:
4.3 整体流程
清理终止阶段
A. STW。所有的 goroutine 进入安全点「计算机程序中的一个特定位置,通常是一些指令之后,这个位置被认为是"安全的",在此点上,垃圾回收器可以安全地停止该线程,并进行垃圾回收或其他的系统级操作。」
B. 如果是强制进入的 GC「显性掉用 GC 方法」,则需要先清理未被处理的内存管理单元。
标记阶段
A. 将状态切换至 _GCmark,开启写屏障、用户程序协助、根对象如队列。
B. 恢复程序执行。标记程序和用于协助的用户程序会开始并发标记内存中的对象,写屏障会将被覆盖的指针和新指针都标记成灰色,而所有新创建的对象都会被直接标记成黑色。
C. 开始根节点标记工作。扫描所有的栈空间,全局变量,堆外运行「不属于 go 垃圾收集器管理的内存」时数据结构中的任何堆指针。每扫描一个 goroutine 栈时,都会先停止此 goroutine,扫描完成后重新启动 goroutine。
D. 依次处理灰色队列中的对象,将对象标记成黑色,并将它们指向的对象标记成灰色,放回待处理队列
E. 使用分布式终止算法检查所有的标记工作是否已经完成,进入标记终止阶段。
标记终止阶段
A. STW.
B. 将状态切换至 _GCmarktermination
并关闭辅助标记的用户程序
C. 清理处理器上的线程缓存
清理阶段
A. 将状态设置为 _GCoff,初始化清理状态并关闭写屏障
B. 恢复用户程序,所有新创建的对象标记为白色
C. 后台使用较少的 CPU 并发清理内存单元,当 Goroutine 申请新的内存时也会触发清理
当分配了大量的内存,开始进入步骤 1
4.4 GC 触发时机
GOGC
GOGC 是一个环境变量,用于控制 GC 频率。GOGC 的值越大,GC 频率越小,反之亦然。当然,也可以通过代码 debug.SetGCPercent(100)
,显性的控制 GOGC 的值。根据 GOGC 的值来计算下次触发 GC 的时机,其计算公式如下。即当程序的内存达到 Target heap memory 时会触发下次 GC。
举一个具体的例子,当前 GOGC 的值为 100「默认值」,全局变量有 1MB,程序内存达到 4MB「默认值」 时开始第一次 GC;GC 结束后存活的内存还有 3MB「Live heap」,goroutine 栈有 1MB;当程序内存到达 8 MB 「3MB + (3MB + 1MB + 1MB) * 100 / 100」时,触发第二次 GC。
Memory Limit
在上面的例子中,假设操作系统只给当前程序分配了 7MB 内存「触发 GC 需要达到 8MB」,那将会导致内存空间不足的问题。因此,在 go 1.19 中,支持在程序运行时设置内存限制,也通过 GOMEMLIMIT 环境变量进行控制,或者使用代码 debug.SetMemoryLimit(1024 * 1024 * 7)
,它规定了程序可以使用的总内存。当程序使用的内存达到设置的内存限制则会开始触发 GC。
可以通过调整 GOGC 和 Mem Limit 的值,来保证一个合理的 GC 频率。当内存使用没有超出系统分配的阈值时,通过 GOGC 参数来控制 GC 的步长;当申请阈值到达 Mem Limit 时,直接触发 GC,避免无法从操作系统中申请到内存。
为了避免开发者将 Mem Limit 设置的过小、或者程序有一些峰值导致的频繁到达 Mem Limit 阈值而导致的频繁 GC。垃圾回收器并不会严格的按照 Mem Limit 触发 GC,在具体实现时,程序本身会考虑垃圾回收器使用的 CPU 时间,如果垃圾回收器占用的 CPU 时间超过 50%,程序仍然会继续分配内存,降低 GC 对程序的影响。
手动触发
go 也给开发者提供了显性触发 GC 的接口,当掉用 GC 方法runtime.GC()
时,会阻塞当前任务,直到 GC 工作完成。其实,大多数场景下开发者均不需要显性的触发 GC,直接交给垃圾回收器即可。然后,对于一些内存密集型操作,由于内存资源有限,需要及时释放内存来完成后续的工作。这时可以手动触发 GC。
五 GC 调优
在程序世界中,没有银弹。开发者体验垃圾回收机制便利的同时,也需要忍受垃圾回收带来的副作用。那就是内存不能及时回收导致内存占用过高,以及垃圾回收时带来的性能影响。因此,需要通过一些手段让程序能够在有限的资源内发挥最大的性能。常见的手段包括增加硬件设置「cpu、mem」、合理设置 GOGC 和 Mem Limit、减少程序的内存分配等。
5.1 识别 GC 的开销
在开始 GC 调优之前,需要先确定 GC 是否已经给业务程序带来了显著的影响。否则,就会陷入过度优化的泥沼。可以通过一下几种方式来判断 GC 是否已经占用了过多资源,影响到了业务程序。
CPU 火焰图
通过 CPU 火焰图可以直观的直到 CPU 时间都花费在什么地方。查看火焰图时,如果有下面的函数占用 CPU 时间过高,则意味着 GC 耗费了太多的 CPU。
runtime.gcBgMarkWorker
: 标记工作的入口点。花费的时间取决于 GC 的频率和对象图的规模。它代表了程序在标记扫描阶段花费时间的基准。runtime.mallocgc
: 堆内存分配的入口点。如果函数占用过多的 cpu 时间「>15%」,则意味着程序大量的分配内存,需要看下是否能够进行优化。runtime.gcAssitAlloc
: 当部分 goroutine 协助 GC 进行扫描和标记时会执行此函数。大量的 cpu 时间「>5%」花费在这个函数则意味着 GC 回收的速度已经跟不上内存分配的速度,因此需要其他 goroutine 协助进行标记扫描。此函数被 runtime.mallocgc 的调用树所包含,从而导致runtime.mallogc
占用 cpu 过高。
执行跟踪
CPU 火焰图只是抽样聚合了不同函数占用的 CPU 时间,无法进行深入观察性能消耗。例如,堆内存增长情况、GC 的次数、持续时长等信息、goroutine 的创建、阻塞等信息。执行跟踪的具体用法参考:execution trace。
GC 跟踪
如果 CPU 火焰图和执行跟踪均无法发现问题,go 提供一些特定的跟踪用于深入观察 GC 的行为。这些追踪可以将 GC 的详细信息直接输入到 STDERR 中。它们主要用于调试 GC 机制本身,因此需要对 GC 算法的实现细节有一定了解。淡然,也可以用于了解程序本身的 GC 行为。
通过设置环境变量 GODEBUG=gctrace=1。可以输出 GC 对应的详细信息,具体文档参考:environment_variables。更深入的信息可以通过设置 GODEBUG=gcpacertrace=1
查看,这里就需要对 GC 实现细节有深入的了解:gc pacer。
5.2 减少堆内存分配
GC 的本质是内存资源有限,需要回收内存保证程序的执行。因此可以尽量避免堆内存的分配,让程序使用较少的内存,从而降低 GC 的次数。
堆内存火焰图
使用堆内存分析,可以详细看到哪里进行了内存分配,从而进行相关的优化。每个 profile 文件可以按照一下四种方式分析:
inuse_objects
: 活动对象数量
inuse_space
: 活动对象使用的内存「byte」
alloc_objecs
: 程序开始执行以来分配的对象总数
alloc_space
: 程序开始执行以来分配的内存总量
为了降低 GC 开销,则需要重点关注 alloc_space
,它表示了内存分配率。可以通过优化此指标来降低 GC 执行的频率。
逃逸分析
通过堆内存的 profile 可以看到内存上分配的对象,那如何避免分配到堆上呢?可以通过 Go 编译器进行逃逸分析,使用更有效的方式分配内存,例如分配到 goroutine 栈上。通过逃逸分析,可以知道为什么对应的对象会分配到堆上。有了这些信息之后,就可以重新组织源代码避免分配到堆上「超出本文范围」。
通过下面的命令,可以直接将逃逸分析的结果进行输出:$ go build -gcflags=-m=3 [package]
5.3 动态 GC
在上面我们了解到,通过设置 GOGC 和 Memory Limit 来调整 GC 触发的时机。如果相关参数设置的不合理,会导致频繁触发 GC,造成不必要的性能开销。在生产环境,一般会使用 docker, 为其分配 4GB 内存,在默认情况下触发 GC 的频率可能是 4MB => 8MB => 16MB,但完全可以到 3GB 时触发 GC。因此,可以使用动态设置 GOGC 的方式,调整 GC 触发时机。核心原则:使用内存未达到 threshold 时,尽可能大的设置 GCPercent;超过时,尽可能小的设置 GCPercent。
具体使用可以参考:gctuner。
5.4 基于 GC 实现的优化
除了优化内存分配,降低 GC 执行次数之外。还可以基于 GC 的实现,通过优化结构体,降低 GC 执行的时间。在 go 的 GC 实现中,可以通过一下方式进行优化「一下优化可能会降低代码的可读性,并且随着 go 版本的更新可能会失效。因此,只有在对 GC 开销要求极高的地方使用」:
- 无指针值和其他值分开
在程序中,对于一些结构体,如果非必要可以尽可能的使用值而不是指针,可以减少 GC 扫描的压力。当然,这种优化只有在对象图复杂、垃圾回收器在标记和扫描上花费大量的情况下收益比较明显。
- GC 将在值的最后一个指针处停止扫描
可以将结构体中的指针子弹分组在值的开头,这样就无需扫描结构体的全部字段。 「理论上,编译器可以自动执行此操作,但尚未实现,并且结构字段的排列方式与源代码中所写的相同。」
此外,GC 必须与它所看到的几乎每个指针交互。因此,使用切片中的索引而不是指针,也可以帮助降低GC成本。
六 参考资料
tip.golang.org/doc/gc-guid…
golang.design/go-question…