Go语言垃圾回收机制:一篇看懂原理与实践

154 Views
深入剖析Go语言垃圾回收器的演进、工作原理及调优技巧,助你编写内存更高效的Go程序。

Go语言垃圾回收机制:一篇看懂原理与实践

在现代编程语言中,垃圾回收(Garbage Collection,简称GC)算得上是一个明星特性。它把我们从繁琐的手动内存管理中解放出来,让我们能更专注于业务逻辑,同时还避免了许多因内存操作不当引发的棘手问题。Go语言作为一门追求简洁高效的现代语言,自然也内置了自动垃圾回收机制。

那么,Go的GC是如何工作的?它经历了怎样的发展?我们又该如何与它更好地"相处",写出内存使用更高效的程序呢?这篇分享,我们就来一起深入探究Go语言GC的这些方面。

Go垃圾回收:一路走来的演进

Go的垃圾回收器并非一蹴而就,它也是从一个相对简单的版本,一步步迭代优化,才进化到我们今天看到的这个高性能并发回收器的。

早期岁月(Go 1.0 - Go 1.4)

最早期的Go垃圾回收器,采用的是比较传统的标记-清除(Mark and Sweep)策略,它有几个显著的特点:

  • 串行执行:这意味着GC工作时,整个程序都得暂停下来,也就是我们常说的"Stop The World"(STW)。
  • 标记-清除:基本思路就是先标记出所有还在使用的对象,然后把那些没被标记的(也就是垃圾)给清理掉。
  • 性能瓶颈:对于那些内存占用比较大的程序,这种方式很容易导致较长时间的暂停,影响程序响应。

可以想象,那时的Gopher们在享受Go带来的开发便利的同时,可能也为GC的暂停时间捏了一把汗。

中期革新(Go 1.5 - Go 1.7)

转折点出现在Go 1.5版本,这个版本引入了三色标记法和并发回收的概念,GC性能得到了质的飞跃:

  • 三色标记算法:这是一种更精细的标记方法,把对象分成了白、灰、黑三种状态,方便并发处理。
  • 并发标记:核心改进之一,标记过程可以和用户程序并发执行了,大大减少了STW的时间。
  • 写屏障技术:为了解决并发标记时,用户程序可能修改对象引用关系导致标记出错的问题,引入了写屏障。
  • STW时间显著缩短: благодаря этим улучшениям (thanks to these improvements), STW时间从之前的几百毫秒级别,一下子降到了几十毫秒。

这个阶段的改进,让Go的GC真正迈向了现代化。

现代GC(Go 1.8至今)

从Go 1.8开始,我们迎来了更加成熟和高效的垃圾回收器。它是一个高度并发的三色标记-清除收集器,并且还在持续优化:

  • 混合写屏障:Go 1.8引入,进一步优化了写屏障的效率和STW时间。
  • 并行标记与清除:能够更好地利用多核CPU,让标记和清除过程更快。
  • 极低的延迟:如今,STW时间通常能控制在1毫秒以下,对于大多数应用来说几乎无感。
  • 增量回收:通过巧妙的调度,GC工作可以分步进行,减少单次暂停的压力。
  • GC频率自适应:GC不再是固定频率触发,而是会根据堆内存的大小和分配速率动态调整。

可以说,现代Go GC在性能和延迟方面已经做得相当出色了。

Go垃圾回收是怎么工作的?

了解了历史,我们再来看看Go GC的核心工作机制,尤其是它依赖的三色标记-清除算法。

三色标记-清除算法揭秘

这个算法听起来有点玄乎,其实就是把内存中的对象按照状态分成三种颜色:

白色:代表"可能是垃圾"。在GC开始时,所有对象都是白色的。如果一轮GC结束后,一个对象还是白色的,那它就要被回收了。
灰色:代表"自己是活的,但我引用的对象还没检查完"。GC会从根对象(比如全局变量、栈上的对象)开始,先把它们标记成灰色。
黑色:代表"自己是活的,而且我引用的所有对象也都检查过了"。当一个灰色对象的所有引用都被扫描后,它就变成了黑色。

整个垃圾回收的流程大致如下:

  1. 初始标记:GC开始,先把所有对象都"刷"成白色。
  2. 根扫描与标记
    • 从程序的根对象(如全局变量、当前活跃的goroutine栈上的变量等)出发,把这些根对象标记为灰色,并放入一个待处理的灰色对象集合中。
    • 不断从灰色对象集合里拿出一个对象,把它标记为黑色。
    • 然后,扫描这个黑色对象直接引用的所有其他对象:
      • 如果引用的是白色对象,就把那个白色对象也标记为灰色,加入灰色对象集合。
      • 如果引用的是灰色或黑色对象,说明它们已经被访问过或即将被访问,不用重复处理。
    • 这个过程一直持续,直到灰色对象集合变空。
  3. 清除阶段:当没有灰色对象了,所有剩下的白色对象就是真正的垃圾了,GC会把它们占用的内存回收掉。

并发标记的那些事儿

上面说的是理想情况。如果GC在标记的时候,我们的应用程序(Go里叫Mutator)也在同时运行和修改对象的引用关系,事情就复杂了。比如:

// 初始状态:对象A是黑色(已扫描),它引用对象B(白色)。还有一个对象C(白色)。
// Mutator执行了两个操作:
// 1. A不再引用B,而是引用了C (A.ref = C)
// 2. B不再被任何其他扫描过的对象引用 (比如某个指向B的引用被删除了)

如果GC标记的顺序不当,或者没有特殊机制,可能会发生:

  • A已经是黑色,GC不会再扫描它了。它新引用的C对象,如果之前没被其他路径标记为灰色,就可能被漏掉,最后被当成垃圾误删。
  • B对象虽然可能已经没有存活的引用了,但如果它在被A断开引用前已经被标记成了灰色甚至黑色(比如通过其他路径),它可能就不会被回收。

这就是所谓的"对象丢失"或"对象错误存活"问题。

写屏障:并发标记的守护神

为了解决这些并发场景下的麻烦,Go引入了"写屏障"(Write Barrier)技术。你可以把它理解为一段特殊代码,当应用程序修改对象指针(即写入指针)的时候,这段代码会被触发,它会通知GC:"嘿,这里有个引用关系变了,你注意一下!"

Go 1.8之后主要使用的是混合写屏障(Hybrid Write Barrier),它的核心思想可以简化为几条规则(实际实现更复杂):

  1. GC开始时,栈上所有可达对象都被认为是根,直接标记为灰色。这是为了防止栈上的对象在标记过程中被修改导致问题,算是一种保守但安全的策略。
  2. 堆上新分配的对象,一出生就是黑色。这意味着新创建的对象默认是存活的,本轮GC不会回收它们。这是为了简化写屏障的逻辑,因为新对象没有入引用,只有出引用,标记为黑色后,它引用的对象会通过写屏障变灰。
  3. 核心规则:当一个指针从一个对象(源对象)指向另一个对象(目标对象)时,如果源对象是黑色,那么目标对象必须被标记为灰色。 这条规则是防止黑色对象指向一个白色对象后,白色对象被遗漏的关键。

伪代码大概是这样:

// 简化版混合写屏障伪代码
func writePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    // 在执行真正的指针赋值前,先处理一下
    // 如果当前GC处于标记阶段,并且满足特定条件(比如slot所在的源对象是黑色)
    // 那么,ptr指向的对象(目标对象)就需要被标记为灰色,以防它被漏掉
    shade(ptr) // shade函数会把ptr标记为灰色
 
    // 执行真正的指针赋值
    *slot = ptr
}

混合写屏障的设计目标是在保证正确性的前提下,尽量减少写屏障带来的性能开销。

GC什么时候会被触发?

Go的GC并不是随心所欲地运行,它有明确的触发时机:

  1. 内存分配达到阈值:这是最主要的触发条件。当程序累计分配的堆内存达到一个阈值时,GC就会启动。这个阈值是动态计算的,通常是上次GC结束后存活堆内存大小的一倍。这个比例可以通过环境变量GOGC来控制(默认是100,代表100%的增长,即翻倍)。
  2. 定时触发:即使内存分配没那么猛,如果距离上次GC的时间超过了一个固定阈值(目前是2分钟),也会触发一次GC。这是为了防止内存长时间不回收,即便增长缓慢。
  3. 手动触发:开发者也可以在代码中调用runtime.GC()来强制启动一次GC。不过这一般不推荐,除非你非常清楚自己在做什么,比如在某些特定的测试或基准场景下。

垃圾回收全过程拆解

一次完整的Go GC周期,大致可以分为以下几个阶段:

1. 标记准备阶段(Sweep Termination / Mark Setup)

这是一个非常短暂的STW阶段,主要做两件事:

  • 完成上一轮GC的清扫工作(如果还没做完的话)。
  • 开启写屏障:这是关键,确保后续并发标记的正确性。
  • 进行一些准备工作,比如设置辅助GC(Pacing Assist)的状态,帮助控制标记速度。
  • 扫描所有goroutine的栈,将栈上所有可达对象标记为灰色(这是混合写屏障的一部分)。

这个STW非常短,目标是尽可能快地完成并进入并发标记。

2. 标记阶段(Marking)

这是GC的主要工作阶段,与用户程序(Mutator)并发执行

  • GC的后台工作线程(称为mark worker)会从灰色对象集合中不断取出对象,扫描它们引用的其他对象。
  • 如果扫描到白色对象,就把它标记为灰色,加入待处理集合。
  • 这个过程中,写屏障一直在工作,确保用户程序对对象图的修改能被GC感知到。
  • 辅助GC(Pacing Assist)也会参与进来。如果用户goroutine分配内存太快,超过了GC标记的速度,分配内存的goroutine自己也会被拉来做一部分标记工作,以帮助GC"跟上进度"。

这个阶段耗时最长,但因为是并发的,所以对用户程序的影响相对较小。

3. 标记终止阶段(Mark Termination)

当标记工作基本完成时,会进入第二个短暂的STW阶段。主要任务:

  • 关闭写屏障
  • 对在并发标记期间可能被修改过的goroutine栈和全局变量进行一次快速的重新扫描(Re-scan),确保没有遗漏。这是为了处理一些在并发标记结束前最后发生的修改。
  • 完成所有标记工作,确定所有存活对象。
  • 计算下一次GC的目标堆大小。

这个STW通常也非常短,目标是毫秒级甚至亚毫秒级。

4. 清除阶段(Sweeping)

标记完成后,就知道哪些内存是可以回收的了(那些仍然是白色的对象)。清除阶段就是回收这些内存。

  • 这个阶段也是与用户程序并发执行的。
  • 清扫工作不是一次性完成的,而是按需进行的。当用户程序需要分配新内存时,如果分配器发现某个内存块(span)是脏的(即包含上一轮GC识别出的垃圾),它会先清扫这个span,然后再分配。
  • 同时,后台也有专门的清扫goroutine在逐步清扫所有待回收的内存。

这种并发和按需清扫的方式,避免了因清扫产生长时间的STW。

总结

本文介绍了Go语言的垃圾回收机制,包括原理、工具和常见问题。希望这篇文章能帮助你更好地理解GC,写出更高效的Go程序。

154 Views