不用写 free 真爽,但 Go 的 GC 到底在背后干了什么

#Go#垃圾回收#内存管理 共 2,115 字 约 7 分钟

从 C 转到 Go,最直接的解放是:再也不用写 free 了。上一篇里那些让我半夜 debug 的 use-after-free、内存泄漏,好像一夜之间都不存在了。

爽过之后,恐惧接踵而至:我不 free,那垃圾是谁清的?这个”谁”在什么时候、用什么方式,把我不要的内存收走?更要命的是,它会不会趁我程序跑得正欢的时候,突然按下暂停键,让所有请求卡在那儿?

这篇就来拆开 Go 的垃圾回收器,看它怎么做到”神不知鬼不觉”,以及它到底会不会让你卡顿。

垃圾的定义:能不能从根走到你

GC 要回答的第一个问题是:凭什么说一块内存是”垃圾”?

Go 的答案是可达性。它有一组根(roots):全局变量、每个 goroutine 的栈、寄存器里的指针。从这些根出发,顺着指针一路能走到的对象,都算活着;一个都走不到的,就是垃圾。

这和引用计数是两条路。Python、Swift 那类语言给每个对象记一个”被引用次数”,归零就回收,好处是及时,坏处是处理不了循环引用(两个对象互相指着,计数永远不归零),而且每次指针赋值都要改计数。Go 不记数,它直接从根去追,天然不怕循环引用。

三色标记:GC 怎么一步步”走完”整个堆

Go 用的是并发标记-清扫。标记阶段的核心,是一个叫三色标记的抽象:

  • 白色:还没被访问到,候选垃圾
  • 灰色:已经被访问到,但它引用的对象还没扫完,正排在待处理队列里
  • 黑色:自己被访问过,它引用的对象也都扫完了

算法只有三步:

  1. 一开始所有对象都是白的,把根直接引用的那批对象染成灰色
  2. 从灰色集合里取一个对象,把它引用的所有白对象染灰,然后把它自己染黑
  3. 重复第 2 步,直到再没有灰色对象
Text
UTF-8|12 Lines|
初始:根把 A 染灰,其余全白
   根 ──► [A 灰] ──► [B 白] ──► [C 白]
                     [D 白]  (没有任何人引用它)

扫描 A:把 A 引用的 B 染灰,A 自己转黑
   根 ──► [A 黑] ──► [B 灰] ──► [C 白]
                     [D 白]

扫描 B:把 C 染灰,B 转黑 …… 直到没有灰色
   [A 黑] [B 黑] [C 黑]      [D 白]  ← 始终是白

清扫:回收所有还是白色的对象(D)

这个算法漂亮在:它把”扫描整张对象图”这件事,变成了一个可以随时暂停、随时恢复的过程。那个灰色集合,就是它的进度条。

“神不知鬼不觉”的代价:并发标记与写屏障

如果标记时把整个程序停住,GC 会既简单又安全,但那样就会卡。Go 的选择是让 GC 和你的代码同时跑,而”同时跑”带来一个致命难题。

假设 GC 已经把对象 A 染成黑色(以为它扫完了、不会再看),这时你的代码做了两件事:

  1. 让黑色的 A 指向一个还是白色的对象 C
  2. 把原本指向 C 的那个引用删掉

现在 C 只被黑色的 A 引用着。可 GC 认定黑对象不必复查,于是 C 会被当成垃圾错误回收,你的程序下一秒就会读到一块已经被收走的内存。

Go 的解法是写屏障(write barrier):在你修改指针的那一瞬间,插入一小段 GC 代码,把相关对象重新染灰,保证它不会被漏掉。

写屏障是并发 GC 正确性的地基

Go 1.8 之后用的是混合写屏障(hybrid write barrier)。它的一个直接好处是:标记快结束时,不必再停下整个世界去重新扫描每个 goroutine 的栈。写屏障多花的这点开销,换来的是 GC 敢和你的程序并肩跑而不出错。

那到底会不会卡顿:STW 与 Go 的取舍

会,但很短。

Go 的 GC 不是零停顿,它在一轮回收里保留了两小段 Stop The World(STW):一次在标记开始前做准备,一次在标记结束时收尾。除这两下之外的标记和清扫,都和你的程序并发进行。

这两段 STW 有多短?现代 Go(1.8 以后)通常把它压到亚毫秒级,很多场景是几十到几百微秒。对比 Go 1.5 之前动辄上百毫秒的停顿,这是数量级的改进。

代价当然存在:

  • 并发标记要和你的程序抢 CPU,Go 的设计目标是拿大约 25% 的 CPU 去做后台标记
  • 本质上是拿吞吐量换低延迟:总计算量比”停下来一次清干净”更多,但把停顿摊薄成了几乎察觉不到的小碎片

一个常见误解

的 GC 不是分代的

很多人下意识以为 Go 抄了 Java 那套分代 GC,其实没有。Go 的 GC 既非分代、也不移动对象(不做堆压缩)。选择不移动,是为了让对象地址保持稳定,方便取地址、方便和 C 互操作。理解这一点,你才不会用 Java 的 GC 直觉去套 Go 的行为。

你手里有个能调的旋钮是 GOGC(默认 100):它表示当堆相对上一次的存活量增长到 100%(也就是翻一倍)时,触发下一轮 GC。调大它,GC 更少、更省 CPU,但更吃内存;调小则反过来。Go 1.19 之后还多了 GOMEMLIMIT,给你设一个软内存上限。

亲眼看见 GC 在跑

这些都不用猜,打开 gctrace 就能看到每一轮 GC:

Bash
UTF-8|1 Line|
GODEBUG=gctrace=1 go run main.go

输出大概长这样:

Text
UTF-8|1 Line|
gc 1 @0.021s 2%: 0.015+0.82+0.003 ms clock, 0.12+0.35/0.75/0+0.028 ms cpu, 4->4->1 MB, 5 MB goal, 8 P

挑几个关键字段读:

字段含义
gc 1第 1 轮 GC
0.015+0.82+0.003 ms clock三段耗时,两头的 0.015 和 0.003 就是那两段 STW,中间 0.82 是并发标记
4->4->1 MBGC 开始时堆 4MB,结束时 4MB,存活 1MB
5 MB goal下次触发的目标堆大小
8 P8 个逻辑处理器参与

两段 STW 加起来不到 20 微秒,而并发标记的 0.82 毫秒里,你的程序是照跑的。想在代码里读这些数,用 runtime.ReadMemStats,里面有 NumGCPauseNs 这些字段。

写在最后

搞清楚这套机制之后,我对”不用 free”这件事的心态变了。它不是编译器施了魔法,而是运行时用可达性分析、三色标记、写屏障、并发调度,替我做了一件我在 C 里手动做、还经常做错的事。

从”手动 free 老出错”,到”敢信任 GC 不卡顿”,中间隔着的正是这些原理。信任的前提,是我自己能讲清楚它在背后干了什么。

再往后我才慢慢意识到,这种”用一套确定性的机制,去包住一个复杂到难以手动掌控的过程”的思路,会在我后来做的很多系统里反复出现。不过那是后话了。