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 不进行压缩(Compaction)。
- 可能会发生内存碎片化。
- 但是,对于 32KB 以下的对象,Go 利用内存分配器(Allocator)的 per-P 缓存来将碎片化降到最低。
- Non-Generational
- Go 的 GC 不按代(Generation)管理对象。
- 所有对象都属于同一代。
- 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 最快速的解决方案可以适用的领域来看,团队似乎认为是内存局部性(Memory Locality)。也就是说,让对象彼此靠近分布,从而将内存跳转降到最低。然而,无法强制编写代码的程序员遵循某种模式,而且对象分配也会因工作流程而无法预测。
或许正因如此,Golang 团队选择的方法是内存跨度(Memory Span)。
什么是内存跨度?
内存跨度是指分配一块相对较大的内存区域,并在其中分配小对象的空间。而名为 GreenTea GC 的新 GC 正是对这些内存跨度执行垃圾回收。具体的运作方式与现有的 Tri-Color Mark and Sweep 几乎相同。
GreenTea GC 的运作方式
首先,GreenTea GC 会分配内存跨度。其大小如前所述,为具有一定规模的 8KiB。在其中最多可以分配 512 bytes 大小的对象。这大致是前面例子中树或图的节点大小,或者是一般会逃逸到堆上的结构体大小难以超过的范围。512 bytes 以下的对象每次逃逸到堆上时都会被累积到该内存跨度中,当该内存跨度被填满时,则会分配新的内存跨度。
此后,当 GC 发生时,GreenTea GC 会将这些内存跨度排入队列并依次检查。在此过程中,GreenTea GC 使用了与现有 GMP 模型几乎相似的调度方式。工作窃取等机制也已实现。总之,从队列中取出内存跨度的 worker 会检查分配给自己的内存跨度内部的对象。在此过程中,同样使用 Tri-Color Mark and Sweep。
有什么好处?
在这一过程中,与现有 GC 的差异大体上只有一点,那就是一次执行 GC 的单位从对象变成了内存跨度。由此,GC 具有以下优点。
- 内存局部性:由于对象聚集在内存跨度中,遍历对象时内存跳转被降到最低。也就是说,可以最大限度地利用 CPU 缓存。
- GC 性能提升:通过以内存跨度为单位执行 GC,GC 在多核环境中能够更高效地运行。
- 内存分配优化:由于内存跨度以固定大小分配,分配小对象时的分配开销减少,内存碎片化的可能性也随之降低。这提高了内存释放的效率。
简而言之,从现在起,我们可以更频繁、更轻松地分配 512 bytes 以下的对象。
不过,这里也存在一些较为有利的场景。
- 树/图结构:扇出(fan-out)较高且很少发生变更的情况
- 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 环境变量来启用。由于该功能仍处于实验阶段,在生产环境中使用之前需要进行充分的测试。