不用写 free 真爽,但 Go 的 GC 到底在背后干了什么
从 C 转到 Go,最直接的解放是:再也不用写 free 了。上一篇里那些让我半夜 debug 的 use-after-free、内存泄漏,好像一夜之间都不存在了。
爽过之后,恐惧接踵而至:我不 free,那垃圾是谁清的?这个”谁”在什么时候、用什么方式,把我不要的内存收走?更要命的是,它会不会趁我程序跑得正欢的时候,突然按下暂停键,让所有请求卡在那儿?
这篇就来拆开 Go 的垃圾回收器,看它怎么做到”神不知鬼不觉”,以及它到底会不会让你卡顿。
垃圾的定义:能不能从根走到你
GC 要回答的第一个问题是:凭什么说一块内存是”垃圾”?
Go 的答案是可达性。它有一组根(roots):全局变量、每个 goroutine 的栈、寄存器里的指针。从这些根出发,顺着指针一路能走到的对象,都算活着;一个都走不到的,就是垃圾。
这和引用计数是两条路。Python、Swift 那类语言给每个对象记一个”被引用次数”,归零就回收,好处是及时,坏处是处理不了循环引用(两个对象互相指着,计数永远不归零),而且每次指针赋值都要改计数。Go 不记数,它直接从根去追,天然不怕循环引用。
三色标记:GC 怎么一步步”走完”整个堆
Go 用的是并发标记-清扫。标记阶段的核心,是一个叫三色标记的抽象:
- 白色:还没被访问到,候选垃圾
- 灰色:已经被访问到,但它引用的对象还没扫完,正排在待处理队列里
- 黑色:自己被访问过,它引用的对象也都扫完了
算法只有三步:
- 一开始所有对象都是白的,把根直接引用的那批对象染成灰色
- 从灰色集合里取一个对象,把它引用的所有白对象染灰,然后把它自己染黑
- 重复第 2 步,直到再没有灰色对象
初始:根把 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 染成黑色(以为它扫完了、不会再看),这时你的代码做了两件事:
- 让黑色的 A 指向一个还是白色的对象 C
- 把原本指向 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:
GODEBUG=gctrace=1 go run main.go输出大概长这样:
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 MB | GC 开始时堆 4MB,结束时 4MB,存活 1MB |
5 MB goal | 下次触发的目标堆大小 |
8 P | 8 个逻辑处理器参与 |
两段 STW 加起来不到 20 微秒,而并发标记的 0.82 毫秒里,你的程序是照跑的。想在代码里读这些数,用 runtime.ReadMemStats,里面有 NumGC、PauseNs 这些字段。
写在最后
搞清楚这套机制之后,我对”不用 free”这件事的心态变了。它不是编译器施了魔法,而是运行时用可达性分析、三色标记、写屏障、并发调度,替我做了一件我在 C 里手动做、还经常做错的事。
从”手动 free 老出错”,到”敢信任 GC 不卡顿”,中间隔着的正是这些原理。信任的前提,是我自己能讲清楚它在背后干了什么。
再往后我才慢慢意识到,这种”用一套确定性的机制,去包住一个复杂到难以手动掌控的过程”的思路,会在我后来做的很多系统里反复出现。不过那是后话了。