Go 运行时抢走的绿茶闲暇,GreenTea GC
为什么又推出新的 GC?
现有 GC 的不足
目前 Go 语言的 GC 可以用以下描述来概括:
- concurrent tri-color mark and sweep(并发三色标记清除)
- 使用三种颜色(白色、灰色、黑色)来追踪对象的存活状态。
- 白色:尚未访问的对象。
- 灰色:已访问但其所有子对象尚未访问完毕的对象。
- 黑色:所有子对象均已访问完毕的对象。
- 遍历结束后,所有白色对象都将被回收。
- 使用 Write Barrier(写屏障)来保护 GC 期间新创建的内存。
- Write Barrier 的开启/关闭时间是通常所说的 Stop-The-World(以下简称 STW)的大部分耗时。
- 此过程是并发执行的。
- 使用三种颜色(白色、灰色、黑色)来追踪对象的存活状态。
- No Compaction(无内存整理)
- Go 的 GC 不进行内存整理。
- 可能导致内存碎片化。
- 但对于小于 32KB 的对象,Go 语言的内存分配器会利用 per-P 缓存来最大程度地减少碎片化。
- Non-Generational(非分代)
- Go 的 GC 不按代管理对象。
- 所有对象都属于同一代。
- Escape Analysis(逃逸分析)
- Go 通过 Escape Analysis 决定对象是在堆上分配还是在栈上分配。
- 大致而言,如果使用悬空指针或接口,则可以认为对象会被分配到堆上。
关键点在于
Go 的 GC 需要从根对象开始遍历所有对象,并执行三色标记。用一句话概括这个过程,可以称之为“拓扑排序图并发遍历算法”。然而,各个对象很可能存在于不同的内存区域。简单来说,
- 假设存在两个相距约 32MB 的内存区域。
- 这两个内存区域各分配了 5 个对象。
- 对象 A、B、C 位于区域 1。
- 对象 D、E 位于区域 2。
- 对象的引用顺序是
A -> D -> B -> E -> C
。 - 当 GC 开始时,它会依次访问 A、D、B、E、C。
- 此时,由于 A 和 D 位于不同的内存区域,在访问 A 之后访问 D 的过程中,需要进行内存区域的切换。
- 这个过程会产生所谓的内存跳转开销,例如内存区域切换和填充新的缓存行。应用程序的 CPU 时间很大一部分可能会分配给这些 GC 工作。
- 这种开销在 GC 执行期间会持续发生。
那么,问题出在哪里?
这种 GC 行为在以下情况中表现不佳:
- 核心数量多,内存容量大时
- Go 的 GC 基本上是并发执行的。
- 但是,如果内存区域广阔且对象分散,多个核心将同时在不同的内存空间中探索对象。
- 在此过程中,如果 CPU 和内存之间的总线带宽不足,内存带宽可能会成为瓶颈。
- 此外,由于物理距离的存在,每一次探索都可能带来相对较大的延迟。
- 细碎对象较多时
- 如果您在 Go 中操作深度较深或子节点较多的树或图结构,GC 必须遍历所有这些对象。
- 在此过程中,如果对象分散在不同的内存区域,则会因第一个原因产生延迟。
- 此外,如果细碎对象很多,GC 必须遍历所有这些对象,因此在 GC 执行期间,CPU 核心将把相当多的资源分配给 GC 并进行工作。
为了解决这些问题,Golang 团队发布了 GreenTea GC。
您已没有余力再品茗绿茶
是何夺走了绿茶?
现有 GC 能够应用的最快解决方案,似乎被认为是内存局部性。即,通过使对象彼此靠近,最小化内存跳转。然而,我们无法强制编写代码的程序员遵循特定模式,并且对象分配也无法根据工作流进行预测。
正因如此,Golang 团队选择的方法是 Memory Span(内存跨度)。
什么是 Memory Span?
Memory Span 是指分配一块相对较大的内存区域,用于分配小型对象。而名为 GreenTea GC 的新 GC 则对这些 Memory Span 执行垃圾回收。其详细操作与现有 Tri-Color Mark and Sweep 几乎相同。
GreenTea GC 的运作
首先,GreenTea GC 会分配 Memory Span。正如之前所述,其大小为 8KiB,具有一定的规模。并且,在此区域内,可以分配最大 512 字节的对象。这个大小恰好是之前举例的树或图的节点大小,或者说,通常情况下,结构体逃逸到堆上的大小很难超过这个限度。每当小于 512 字节的对象逃逸到堆上时,它们就会堆积到这个 Memory Span 中,当这个 Memory Span 满了之后,就会分配一个新的 Memory Span。
现在,当 GC 发生时,GreenTea GC 会将这些 Memory Span 加入队列并按顺序检查。在此过程中,GreenTea GC 采用了与现有 GMP 模型几乎相同的调度方式。工作窃取等领域也已实现。总之,从队列中取出 Memory Span 的工作线程会检查分配给自己的 Memory Span 内部的对象。在此过程中,Tri-Color Mark and Sweep 仍然以相同的方式被使用。
这有什么好处?
在此过程中,与现有 GC 的主要区别只有一个:GC 执行的单位从对象变成了内存跨度。因此,GC 具有以下优势:
- 内存局部性:由于对象聚集在内存跨度中,探索对象时内存跳转最小化。这意味着可以最大限度地利用 CPU 缓存。
- GC 性能提升:通过以内存跨度为单位执行 GC,GC 在多核环境中能够更高效地运作。
- 内存分配优化:内存跨度以固定大小分配,因此在分配小型对象时,内存分配的开销减少,碎片化发生的可能性降低。这提高了内存分配和释放的效率。
简而言之,从现在开始,我们可以更频繁、更轻松地分配小于 512 字节的对象。
然而,这里也存在一定的有利区间。
- 树/图结构:扇出率高且不常发生变化的情况。
- B+ 树、Trie、AST (Abstract Syntax Tree)
- 缓存友好的数据结构
- 批量处理:大量小型数据分配的工作流。
- JSON 解析生成的大量小型对象
- 数据库结果集的 DAO 对象
在这些情况下,GreenTea GC 比现有 GC 能够提供更好的性能。但它并非适用于所有情况,特别是当对象分散在不同内存区域时,仍然难以期待显著的性能提升。
总结
Golang 团队似乎有意长期持续改进 GC。本次 GreenTea GC 可视为一个小型领域的次要变更,而非取代现有 GC。然而,GreenTea GC 却是一个有趣的案例,从中可以窥见 Golang 团队面临或预期的挑战。尽管问题复杂,但其尝试通过相对简单的概念增补来解决问题的做法也令人印象深刻。我个人认为这是一个有趣的案例。
GreenTea GC 是从 Go 1.25 开始引入的实验性功能。可以通过设置 GOEXPERIMENT=greenteagc
环境变量来启用和使用。此功能仍处于实验阶段,因此在生产环境中使用前需要进行充分测试。