一、一次 GC 周期长什么样?(先搭建骨架)
1.1「cycle」的四个阶段
Go runtime 定义的 GC cycle 包含:
- sweep termination(STW)
- mark(并发)
- mark termination(STW)
- sweep(通常并发)
源码证据:runtime.GC() 的注释对 cycle 定义得非常明确,它通过 work.cycles 等待「第 N 次 mark 完成」:mgc.go。
阶段与 gcphase 的对应关系
runtime 用 gcphase 变量表示当前阶段,但它只有三个值,不是四个:
| gcphase 值 | 含义 | 写屏障 | 对应 cycle 阶段 |
|---|---|---|---|
_GCoff | 未在 GC 中 | 关闭 | sweep termination + sweep |
_GCmark | 并发标记 | 开启 | mark |
_GCmarktermination | 标记终止 | 开启 | mark termination |
源码:mgc.go。
为什么 _GCoff 涵盖两个阶段?因为 sweep termination 只是”上一轮 sweep 的收尾”,本质上还是 sweep 的一部分。在实现上,sweep termination 的核心工作(finishsweep_m)是在 STW 下完成的,但它是 _GCoff 相位下的一个同步点。
阶段切换由 setGCPhase 驱动,它同时更新 gcphase 和 writeBarrier.enabled:
func setGCPhase(x uint32) { atomic.Store(&gcphase, x) writeBarrier.enabled = x != _GCoff}源码:mgc.go。
完整的状态机转换路径:
1.2 流程图:从触发到结束
对应源码主入口:
1.3 两个“计数”别混:work.cycles vs memstats.numgc
work.cycles:cycle 计数(sweep term + mark + mark term + sweep)memstats.numgc:统计口径,在 mark termination 时递增(不是 cycle 末尾)
源码:work.cycles 注释强调它不同于 memstats.numgc:mgc.go,memstats.numgc++ 在 mark termination 后半段执行:mgc.go。
二、何时触发 GC?入口在哪里?触发机制到底长啥样?
先给出结论:Go 的 GC 入口统一走 gcStart(trigger),但触发来源有三大类(外加一些 API 包装)。
2.1 入口(Entrypoints)
A) 分配触发(最常见):mallocgc → gcTriggerHeap → gcStart
在分配尾部检查 heap trigger:
if t := (gcTrigger{kind: gcTriggerHeap}); t.test() { gcStart(t) }
malloc.go
B) 定时触发(保底):sysmon → forcegchelper → gcStart(gcTriggerTime)
sysmon 周期检查 time trigger,满足条件且 forcegc.idle==true 时唤醒 forcegchelper:proc.go。
forcegchelper 被唤醒后直接执行 gcStart(gcTriggerTime):proc.go。
time trigger 使用的上限间隔 forcegcperiod 默认为 2 分钟:proc.go,判断逻辑在 gcTrigger.test 的 time 分支:mgc.go。
C) 用户强制触发:runtime.GC()
runtime.GC() 会执行以下流程:
- 等待当前正在进行的 mark/mark termination 结束
- 强制触发下一轮 cycle(
gcTriggerCycle) - 再等待下一轮 mark 完成,并协助 sweep 直到结束 mgc.go
D) debug.FreeOSMemory()
runtime/debug.FreeOSMemory() 会先 GC(),再做一次全量 scavenging(更积极归还物理内存):mheap.go。
2.2 触发条件(gcTrigger.test):为什么“该触发”但可能“没开始”
触发逻辑统一由 gcTrigger.test() 判断,它有三层关卡:
-
总闸门控:必须启用 GC、不能在 panic 过程中、且相位必须为
_GCoffif !memstats.enablegc || panicking!=0 || gcphase != _GCoff { return false }mgc.go -
触发种类:
-
heap:
heapLive >= trigger(trigger 来自 pacer) mgc.go -
time:
now-last_gc_nanotime > forcegcperiodmgc.go -
cycle:目标 n 尚未开始(相对于
work.cycles) mgc.go3)「真正启动」还需通过
gcStart的上下文安全检查:g0、持锁过多、preemptoff等情况会直接 return,避免在脆弱上下文中启动 STW:mgc.go。
三、Pacer:heapLive/trigger/goal/runway 如何决定 GC 频率?
GC 触发不是简单的 “heap > live*(1+GOGC)”:
- Go 需要在并发标记下保证”启动不要太晚”(否则要靠 mutator assist 扛住内存/延迟)
- 也不能太早(否则频繁 cycle,per-cycle 固定成本上升)
这就需要 pacer:gcController(gcControllerState)负责计算:
- goal:理想上这轮 GC 结束时允许的 live 上界(考虑 GOGC、stacks/globals 等)
- trigger:何时开始这一轮 mark(在各种上下界与 runway 约束下选取)
源码入口:gcController.trigger() 返回 (trigger, goal):mgcpacer.go。
两个最重要的字段:
gcController.heapLive:用于触发判断的 live 计数(保守计数,倾向于更早触发)
mgcpacer.gogcController.runway:给并发 mark 留的”堆增长跑道”,trigger ≈ goal - runway(再被 clamp 到 min/max)
mgcpacer.go
可以将 pacer 直觉化为一句话:
「预计这轮要扫描多少(heap+stack+globals),按历史估计的扫描速度与分配速度,算出需要多少堆增长空间(runway),然后在合规范围内尽量让 GC 在 heapLive 到达 goal 前恰好完成。」
3.1 goal 的计算:GOGC 如何决定堆目标
heap goal 的核心公式(简化版):
goal = heapLive + (heapLive + stacks + globals) * GOGC / 100其中:
heapLive:上一轮 GC 结束时的存活堆大小stacks:所有 goroutine 栈占用的字节数globals:全局变量占用的字节数GOGC:默认 100,即允许堆增长 100%
直觉理解:如果上一轮 GC 结束时存活 100MB,GOGC=100,那么 goal = 100 + (100+stacks+globals)*1 = 200MB+(加上栈和全局变量的额外开销)。GC 的目标是”在堆增长到 goal 之前完成标记”。
当 GOGC=off(设为 -1)时,goal 被设为最大值,GC 不再基于堆大小触发(但 time trigger 和 GOMEMLIMIT 仍然有效)。
源码:gcControllerState.heapGoal 计算:mgcpacer.go。
3.2 trigger 的计算:runway 与上下界约束
trigger 的计算比 goal 更复杂,因为它需要考虑”并发标记期间堆还会继续增长”这个事实。核心逻辑:
trigger = goal - runway然后被 clamp 到 [minTrigger, maxTrigger] 范围内:
- minTrigger:不能低于上一轮 GC 结束时的 live 大小(否则 GC 会连续触发)
- maxTrigger:不能超过 goal(否则 GC 永远无法在 goal 前完成)
runway 的计算基于反馈控制:
- 估计本轮需要扫描的总字节数(heap + stacks + globals)
- 根据历史扫描速度(
scanWork)和分配速度,估算标记需要多长时间 - 在标记期间,mutator 还会继续分配,这些分配量就是 runway
runway = 扫描总字节数 / 扫描速度 * 分配速度
如果上一轮 GC 的扫描速度估计偏慢(实际更快),pacer 会在 endCycle 中修正,让下一轮的 runway 更小(触发更晚),减少不必要的 GC 频率。反之亦然。
源码:gcControllerState.trigger():mgcpacer.go。
3.3 startCycle 与 endCycle:每个 GC cycle 的 pacer 闭环
gcController.startCycle() 在 gcStart 中被调用,初始化本轮的扫描预算和 assist 比率:
assistWorkPerByte:mutator 每分配 1 字节需要做多少扫描工作assistBytesPerWork:反过来,每做 1 单位扫描工作可以分配多少字节dedicatedMarkWorkersNeeded:需要多少个 dedicated workerfractionalUtilizationGoal:fractional worker 的 CPU 占用目标
gcController.endCycle() 在 gcMarkDone 中被调用,根据本轮实际扫描量和堆增长情况,计算下一个 cycle 的 trigger ratio,形成闭环反馈:
func (c *gcControllerState) endCycle() float64 { // 计算实际扫描量与预期扫描量的比率 // 据此调整下一轮的 trigger ratio // 如果本轮扫描比预期快,下一轮可以触发更晚 // 如果本轮扫描比预期慢,下一轮需要触发更早}源码:mgcpacer.go。
四、从 gcStart 到结束:源码级主流程(工程师视角)
这部分建议边看边对照 runtime-gc-flow.md 作为「流程底稿」。这里采用更贴近「读者/排障/调优」的视角串联。
4.1 gcStart:从 _GCoff 进入 _GCmark
触发后进入 gcStart(trigger):
g0/锁/不可抢占则退出。
调用 sweepone() 避免残留 unswept span。
获取 work.startSema,再复核 trigger。
获取 gcsema/worldsema,停止世界。
finishsweep_m() + clearpools()。
work.cycles.Add(1),调用 gcController.startCycle(...)。
setGCPhase(_GCmark) 开写屏障。
gcPrepareMarkRoots() 切 root jobs;gcMarkTinyAllocs();gcBlackenEnabled=1。
startTheWorldWithSema:并发 mark 开始。
源码主干:mgc.go。
4.2 并发 mark 怎么推进?三条”发动机”
并发 mark 的”推进”来自三条通道:
-
root jobs:在 STW 时把根扫描拆成 job,后续由 worker/assist 在 drain 循环中领取执行(不是独立阶段)
gcPrepareMarkRoots:mgcmark.gomarkroot:mgcmark.go
-
后台 mark workers:每个 P 有一个 worker G,平时 park 在 pool,调度器按需唤醒执行 drain
-
mutator assists:分配太快时,分配 goroutine 会”欠债”,必须先帮着做标记工作才能继续分配
- 扣债:
deductAssistCredit:malloc.go - assist:
gcAssistAlloc/gcAssistAlloc1:mgcmark.go
- 扣债:
4.2.1 后台 worker 池:gcBgMarkWorkerPool 与三种工作模式
gcBgMarkStartWorkers 在 GC 启动前为每个 P 创建一个后台 worker G,所有 worker 在启动后立刻 park 到 gcBgMarkWorkerPool(一个无锁栈):
func gcBgMarkWorker() { gp := getg() node := new(gcBgMarkWorkerNode) node.gp.set(gp) node.m.set(acquirem()) notewakeup(&work.bgMarkReady)
for { // park 到 pool,等待调度器或 GC 控制器唤醒 gopark(func(g *g, nodep unsafe.Pointer) bool { node := (*gcBgMarkWorkerNode)(nodep) if mp := node.m.ptr(); mp != nil { releasem(mp) } gcBgMarkWorkerPool.push(&node.node) return true }, unsafe.Pointer(node), waitReasonGCWorkerIdle, traceEvGoBlock, 0)源码:mgc.go。
关键点:worker 通过 gopark 把自己压入 pool,而不是进入全局运行队列。调度器在 findrunnable 或 GC 控制器在 findRunnableGCWorker 中直接从 pool 弹出并唤醒。
每个 worker 被唤醒后,根据 P 上的 gcMarkWorkerMode 决定执行策略,Go 定义了三种模式:
| 模式 | 含义 | 可被抢占? | 触发条件 |
|---|---|---|---|
gcMarkWorkerDedicatedMode | 专职标记,独占 P | 被抢占后把 P 上其他 G 转移到全局队列 | dedicatedMarkWorkersNeeded > 0 |
gcMarkWorkerFractionalMode | 占 P 的部分 CPU 时间执行标记 | 可以 | dedicated 不够用,需要补足 25% CPU 目标 |
gcMarkWorkerIdleMode | P 空闲时顺便做标记 | 可以,有其他 G 就让出 | findrunnable 中 P 无其他 G 可运行 |
dedicated 模式的执行逻辑比较特殊:先调用一次 gcDrain(允许抢占),如果被抢占,就把 P 上其他可运行 G 全部转移到全局队列,然后继续 gcDrain(不再允许抢占),保证 dedicated worker 不被反复打断:
case gcMarkWorkerDedicatedMode: gcDrain(&_p_.gcw, gcDrainUntilPreempt|gcDrainFlushBgCredit) if gp.preempt { lock(&sched.lock) for { gp, _ := runqget(_p_) if gp == nil { break } globrunqput(gp) } unlock(&sched.lock) } gcDrain(&_p_.gcw, gcDrainFlushBgCredit)源码:mgc.go。
fractional 模式用于”核数不能被 4 整除”的场景。比如 3 核机器上,dedicated worker 只需要 0 个,但 GC 目标是 25% CPU,就需要一个 fractional worker 占用某个 P 的一部分时间。控制器通过 gcFractionalMarkTime / (nanotime() - markStartTime) 是否超过 fractionalUtilizationGoal 来决定是否继续执行 fractional worker:
delta := nanotime() - gcController.markStartTimeif delta > 0 && float64(_p_.gcFractionalMarkTime)/float64(delta) > c.fractionalUtilizationGoal { return nil // 已达目标,不启动}_p_.gcMarkWorkerMode = gcMarkWorkerFractionalMode源码:mgcpacer.go 中 findRunnableGCWorker 方法。
idle 模式最简单:调度器在 findrunnable 的末尾检查,如果 P 没有其他 G 可运行且 GC 有待处理 work,就把 idle worker 唤醒:
if gcBlackenEnabled != 0 && _p_.gcBgMarkWorker != 0 && gcMarkWorkAvailable(_p_) { _p_.gcMarkWorkerMode = gcMarkWorkerIdleMode gp := _p_.gcBgMarkWorker.ptr() casgstatus(gp, _Gwaiting, _Grunnable) return gp, false}源码:proc.go 中 findrunnable。
4.3 gcDrainN:并发标记的”消费灰对象”循环
gcDrainN(gcw, scanWork) 是消费灰对象的循环(worker/assist 最终都会走到 drain):
- 按优先级从 P 本地 workbuf、全局 workbuf、写屏障 buffer、root jobs 等获取工作
- 拿到对象就
scanobject,发现新指针就入队为灰对象
源码:mgcmark.go。
其中”取 work 的优先级”很重要(理解 root jobs 为什么不是独立阶段): mgcmark.go
4.3.1 gcWork:P 本地缓冲 + 全局队列的”生产者-消费者”模型
每个 P 持有一个 gcWork,它封装了双缓冲机制(wbuf1/wbuf2),用于缓存待扫描的灰对象指针:
type gcWork struct { wbuf1, wbuf2 *workbuf // 主/备缓冲区 bytesMarked uint64 // 标记字节累计 scanWork int64 // 扫描工作量累计 flushedWork bool // 本次 dispose 是否往全局推送了 work}源码:mgcwork.go。
基本操作逻辑:
gcWork.put(ptr):往wbuf1推入一个灰对象指针。wbuf1满了就交换wbuf1/wbuf2;两个都满了就把wbuf1放入全局满队列(work.full),从全局空队列取一个空 buf 继续gcWork.tryGet():从wbuf1取一个灰对象指针。wbuf1空了就交换;两个都空了就从全局满队列取 buf
balance() 方法会把本地部分 work 放回全局队列,让负载高的 P 分担工作,这是 GC 层面的”work stealing”:
func (w *gcWork) balance() { if w.wbuf1 == nil { return } if w.wbuf2 == nil { w.wbuf2 = trygetfull() } else if w.wbuf1.nobj > 4 { w.wbuf2 = trygetfull() if w.wbuf2 != nil { putfull(w.wbuf1) // 把较满的 wbuf1 放到全局 w.wbuf1 = w.wbuf2 w.wbuf2 = nil } }}源码:mgcwork.go。
4.3.2 drain 循环内的取 work 优先级
gcDrainN 在循环中按以下优先级获取灰色对象:
- root jobs:先检查
work.markrootNext < work.markrootJobs,有则原子递增领取一个 root job 执行markroot - P 本地 gcWork:先
tryGetFast()(无锁路径),失败再tryGet()(可能需要交换缓冲区) - 写屏障缓冲:如果本地 gcWork 为空,调
wbBufFlush(nil, 0)把当前 P 的写屏障缓冲刷入 gcWork,再tryGet() - 全局队列:在步骤 2 之前如果
work.full == 0,调balance()从全局队列获取
// gcDrainN 的核心循环(简化)for work != 0 { if work.full == 0 { gcw.balance() } b := gcw.tryGetFast() if b == 0 { b = gcw.tryGet() if b == 0 { wbBufFlush(nil, 0) b = gcw.tryGet() } } if b == 0 { break // 确实没有 work 了 } scanobject(b, gcw) // 扫描对象,发现的指针通过 greyobject 入队 // ... 累计 scanWork,检查是否该让出}源码:mgcmark.go。
scanobject 是真正”扫描对象内容”的函数:从对象的起始位置开始,按 bitmap 中的指针位图逐槽位检查,遇到指针就调 greyobject 将目标对象标记为灰色并入队。
4.4 什么时候算”标记完成”?gcMarkDone + ragged barrier
并发 mark 何时结束靠 worker/assist 观察到:
incnwait == work.nproc && !gcMarkWorkAvailable(nil) 时触发 gcMarkDone()
mgc.go
gcMarkWorkAvailable 会检查:
- 全局 workbuf/spanq 是否为空
- root jobs 是否还有
mgc.go
gcMarkDone() 里最重要的工程细节是 ragged barrier:
forEachP刷写屏障缓冲wbBufFlush1(pp),并把每个 P 本地pp.gcw.dispose()刷到全局- 如果 barrier 期间发现有新 work(
gcMarkDoneFlushed != 0),就回到 top 重新判定
这是为了避免”本地缓存里还有灰对象”导致误判完成。
源码主干:mgc.go。
4.4.1 ragged barrier 的必要性:为什么不能只看”nwait == nproc”
问题出在”每个 P 的 gcWork 和 wbBuf 都是本地缓存”。当所有 worker 都报告”我没活干了”(nwait == nproc),可能只是因为:
- 某个 P 的
wbBuf里还缓存着写屏障记录的指针对(old/new),还没 flush 到 gcWork - 某个 P 的
gcWork.wbuf1里还有灰对象,但还没 dispose 到全局队列
如果此时直接宣布”标记完成”,这些灰对象就会被遗漏,导致存活对象被错误回收。
forEachP 就是解决这个问题的”同步屏障”:它对每个 P 执行一次回调,强制把本地缓存全部刷到全局。如果刷写过程中发现了新 work(gcMarkDoneFlushed != 0),说明刚才的”完成”判断是误判,需要回到 top 重新走一遍判定流程:
top: if !(gcphase == _GCmark && work.nwait == work.nproc && !gcMarkWorkAvailable(nil)) { return } gcMarkDoneFlushed = 0 systemstack(func() { forEachP(func(_p_ *p) { wbBufFlush1(_p_) // 刷写屏障缓冲 _p_.gcw.dispose() // 把本地 workbuf 推到全局 if _p_.gcw.flushedWork { atomic.Xadd(&gcMarkDoneFlushed, 1) _p_.gcw.flushedWork = false } }) }) if gcMarkDoneFlushed != 0 { goto top // 有新 work,重新判定 }源码:mgc.go。
这个循环可能执行多次,直到一次 forEachP 没有产生任何新 work 为止。此时才能确认”所有灰对象都已处理完毕”,安全地进入 mark termination。
4.5 mark termination:gcMarkTermination → setGCPhase(_GCoff) → sweep
gcMarkTermination(stw) 做最终 STW 收尾:
setGCPhase(_GCmarktermination)(写屏障仍开)systemstack(gcMark(startTime))最终标记与一致性处理- 标记完成后
setGCPhase(_GCoff)关闭写屏障 gcSweep(work.mode)开始 sweep- 更新
memstats并唤醒 waiters
源码:mgc.go。
4.5.1 gcMarkTermination 的完整流程
gcMarkDone 确认标记完成后,会执行以下关键步骤进入 mark termination:
- 关闭
gcBlackenEnabled,唤醒所有 assist goroutine,恢复用户 G 调度 - 调用
gcController.endCycle()计算下一个 cycle 的 trigger ratio - 调用
gcMarkTermination(nextTriggerRatio)进入终止阶段
gcMarkTermination 在 STW 下执行的完整流程:
func gcMarkTermination(nextTriggerRatio float64) { atomic.Store(&gcBlackenEnabled, 0) setGCPhase(_GCmarktermination)
systemstack(func() { gcMark(startTime) // 最终标记:确保所有对象都已处理 })
// 标记完成,切换到 _GCoff systemstack(func() { setGCPhase(_GCoff) // 关闭写屏障 gcSweep(work.mode) // 初始化 sweep 状态 })
// 更新触发比率与统计 gcSetTriggerRatio(nextTriggerRatio) wakeScavenger()
// 更新 memstats systemstack(func() { forEachP(func(_p_ *p) { _p_.mcache.prepareForSweep() }) })
// 恢复世界 startTheWorldWithSema(true)}源码:mgc.go。
其中 gcMark(startTime) 在系统栈上执行最终的标记工作。虽然并发标记已经宣布完成,但此时仍需要做一轮确认性扫描,确保没有遗漏。setGCPhase(_GCoff) 是整个标记阶段的真正终点,此时写屏障关闭,所有新创建的对象回到白色。
4.5.2 sweep 阶段:从惰性清扫到并发清扫
进入 sweep 阶段后,Go 不会立刻扫描所有 span。相反,sweep 是惰性的:当 goroutine 分配内存时,才会清扫对应的 span。这种设计把清扫的 CPU 开销分摊到了分配路径上。
sweepone() 是清扫单个 span 的入口,它在堆上查找 sweepgen == mheap.sweepgen - 2 的 span(即”上一轮 GC 存活但本轮尚未清扫”),然后调用 mspan.sweep(false) 执行清扫:
func sweepone() uintptr { _g_ := getg() _g_.m.locks++ sg := mheap_.sweepgen for { s = mheap_.nextSpanForSweep() if s == nil { break } if s.sweepgen == sg-2 && atomic.Cas(&s.sweepgen, sg-2, sg-1) { break // 找到待清扫 span,CAS 抢占 } } if s != nil { if s.sweep(false) { atomic.Xadduintptr(&mheap_.reclaimCredit, npages) } } _g_.m.locks-- return npages}源码:mgc.go。
mspan.sweep 的核心工作:
- 遍历 span 中所有对象,通过 mark bits 判断哪些对象存活
- 未标记的对象被回收(free),加入 span 的空闲链表
- 如果 span 中所有对象都未被标记(即整个 span 都是垃圾),该 span 被直接归还给 mheap
- 清除 mark bits,为下一轮 GC 做准备
sweepgen 状态机控制 span 的清扫生命周期:
| sweepgen 值 | 含义 |
|---|---|
mheap.sweepgen - 2 | 待清扫(上一轮 GC 标记后尚未清扫) |
mheap.sweepgen - 1 | 正在清扫(被某个 G 抢占) |
mheap.sweepgen | 已清扫,可分配 |
源码:mgcsweep.go。
五、写屏障:插入/删除/混合到底是什么意思?Go 用的是哪个?
这一节是本文最“干货”的部分:不仅讲概念,还把 Go 的实现链路从 compiler 到 runtime 串起来。
5.1 为什么需要写屏障?
并发三色标记中,mutator 在 GC 标记期间仍然会修改对象图。如果不做约束,会破坏三色不变式,导致:
- 把一个“白对象”从灰集合里“漏掉”,最终被错误回收(悬垂引用)
写屏障的本质是:在 mutator 写指针时,额外做一点工作(记录 old/new 或者直接标记某个对象),保证并发标记的正确性。
在 Go runtime 里,写屏障开关由 setGCPhase 控制:
writeBarrier.enabled = (gcphase == _GCmark || gcphase == _GCmarktermination)
mgc.go
5.2 插入写屏障(Insertion barrier)是什么?Go 怎么实现?
定义(Dijkstra insertion barrier 直觉版):
- 当执行
*slot = ptr时,对新写入的指针ptr做 shade/mark(或记录),保证新接入的可达对象不会漏标。
Go 的混合写屏障里确实包含 insertion barrier 的成分,但它是有条件的:只有当“当前 goroutine 栈还可能是灰色”时才对 ptr 做 shade(见 5.4)。
在实现层面:
- 编译器会在需要写指针的位置插入分支:
if writeBarrier.enabled { ... } else { normal store }writeBarrier变量的布局刻意让编译器用 32-bit load 读enabled:mgc.go- SSA 插桩逻辑在
cmd/compile/internal/ssa/writebarrier.go,它会加载&runtime.writeBarrier并判断非零走写屏障分支:writebarrier.go
- 写屏障分支会通过
OpWB(后端 lowering)调用runtime.gcWriteBarrier{1..8}申请一段 buffer,并把 old/new 指针写进去,后续由 runtime 批量处理。
5.3 删除写屏障(Deletion barrier)是什么?Go 怎么实现?
定义(Yuasa deletion barrier 直觉版):
- 当执行
*slot = ptr时,对写入前旧值*slot做 shade/mark(或记录),避免旧的可达路径被 mutator 切断导致漏标。
在 Go 的混合写屏障伪代码里,“shade(*slot)”就是 deletion barrier 的部分(见 5.4)。
实现落点:
- 对于批量内存写(memmove/memclr 等),Go 在真正写入前走
bulkBarrierPreWrite,它会把目标区域里所有“即将被覆盖”的指针槽位的 old(以及需要时的 new)记录到wbBuf:bulkBarrierPreWrite:mbitmap.goif !writeBarrier.enabled { return }:mbitmap.go
5.4 混合写屏障(Hybrid barrier)是什么?Go 具体用的就是它
Go 在 runtime 源码里直接写出了混合写屏障的伪代码与说明,这是最硬的证据:
- 混合屏障说明:mbarrier.go
其伪代码是:
shade(*slot)(deletion barrier:旧值)- 如果当前 goroutine 的栈”仍可能灰”(尚未被扫黑),则
shade(ptr)(insertion barrier:新值)
这套组合解决了并发标记下常见的”黑->白”漏标风险,同时避免了某些纯 insertion 方案对 STW 栈处理的要求。
5.4.1 为什么混合写屏障能保证正确性?
混合写屏障同时维护了弱三色不变性(Yuasa deletion 部分)和强三色不变性(Dijkstra insertion 部分,有条件),两者互补:
Yuasa 部分(shade(*slot))保证弱三色不变性:当 mutator 执行 *slot = ptr 时,旧值 *slot 被 shade 成灰色。这意味着即使旧引用被覆盖,旧值指向的下游对象仍然可以从灰色对象到达,不会被漏标。这满足弱三色不变性:黑色对象指向的白色对象,一定存在一条从灰色对象经过若干白色对象的可达路径。
Dijkstra 部分(条件 shade(ptr))保证栈对象安全:当 mutator 的栈尚未被扫描(仍可能包含灰色区域)时,新写入的 ptr 也被 shade。这防止了”栈上黑对象直接指向白对象”的情况,因为栈在扫描前可能包含未追踪的指针。
两者的组合效果:
- 堆上的指针修改:deletion barrier 保证旧引用链不断,insertion barrier(条件性)保证新引用链被追踪
- 栈上的指针修改:栈不需要写屏障(开销太大),但栈最终会被扫描扫黑,扫描前 insertion barrier 兜底
- 标记阶段新创建的对象:直接标记为黑色(
gcmarknewobject),不需要写屏障介入
5.4.2 混合写屏障 vs 纯 Dijkstra 插入写屏障:为什么 Go 1.8 要切换
Go 1.5-1.7 使用纯 Dijkstra 插入写屏障。问题在于:栈对象也是根,但栈上开写屏障开销太大(每次栈上指针赋值都要走屏障路径),所以 Go 选择不在栈上开写屏障。代价是:标记阶段结束后,必须 STW 重新扫描所有 goroutine 的栈,把栈上新写入的指针指向的对象标记为灰色。
在活跃 goroutine 数量很多的程序中,重新扫描栈可能需要 10-100ms,这是 GC 暂停的主要来源。
混合写屏障通过 shade(*slot)(deletion 部分)消除了重新扫描栈的必要性:即使栈上某个指针被修改,旧值已经被 shade,下游对象不会丢失。同时,标记阶段新创建的对象直接标黑,栈扫描完成后栈上所有对象都是黑色,不需要二次扫描。
这就是 Go 1.8 把 GC 暂停从 ~1ms 降到 ~0.5ms 的关键优化。
5.4.3 标记阶段新对象直接标黑:gcmarknewobject
混合写屏障的正确性还依赖一个关键机制:标记阶段新创建的对象直接标记为黑色。这由 gcmarknewobject 实现,在 mallocgc 中被调用:
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer { ... if gcphase != _GCoff { gcmarknewobject(span, uintptr(x), size, scanSize) } ...}
func gcmarknewobject(span *mspan, obj, size, scanSize uintptr) { objIndex := span.objIndex(obj) span.markBitsForIndex(objIndex).setMarked() // 直接标黑 arena, pageIdx, pageMask := pageIndexOf(span.base()) if arena.pageMarks[pageIdx]&pageMask == 0 { atomic.Or8(&arena.pageMarks[pageIdx], pageMask) } gcw := &getg().m.p.ptr().gcw gcw.bytesMarked += uint64(size) gcw.scanWork += int64(scanSize)}源码:mgcmark.go。
为什么新对象必须标黑?因为新对象在创建时没有任何其他对象指向它(赋值操作还没发生),如果标白,可能在赋值前就被判定为垃圾。标黑确保新对象在本轮 GC 中不会被回收,即使赋值操作触发了写屏障,也不影响正确性。
这个机制与混合写屏障配合,构成了完整的正确性保证:
- 已有对象的指针修改:混合写屏障覆盖
- 新创建的对象:直接标黑
- 栈上的指针:栈扫描时统一处理,不需要写屏障
5.5 “Go 的写屏障”在工程实现上到底怎么实现?
你可以把它分成三层:
5.5.1 编译器插桩:if enabled then OpWB / stores
- SSA 插桩主文件:
cmd/compile/internal/ssa/writebarrier.go- 读取
runtime.writeBarrier并判断非零:writebarrier.go OpWB申请 buffer,随后用OpStore把要记录的指针写进 buffer:writebarrier.go
- 读取
5.5.2 快路径:gcWriteBarrier{1..8} 汇编申请 wbBuf 空间
- amd64 的
gcWriteBarrier与gcWriteBarrier1..8在:asm_amd64.s- 快路径:推进
p.wbBuf.next,有空间就返回 - 慢路径:buffer 满则调用
wbBufFlush后重试
- 快路径:推进
amd64 上 gcWriteBarrier 的核心汇编逻辑(简化):
// DI = slot (写入目标地址)// AX = ptr (要写入的新值)get_tls(BX)MOVQ g(BX), BXMOVQ g_m(BX), BXMOVQ m_p(BX), BXMOVQ (p_wbBuf+wbBuf_next)(BX), CX // 当前 P 的 wbBuf 写指针LEAQ 8(CX), CX // 推进 8 字节(一个指针)MOVQ CX, (p_wbBuf+wbBuf_next)(BX) // 更新写指针CMPL CX, (p_wbBuf+wbBuf_end)(BX) // 是否超出 buffer 末尾?MOVQ AX, -8(CX) // 记录新值 ptrMOVQ (DI), BX // 读取旧值 *slotMOVQ BX, -8+8(CX) // 记录旧值 *slotJEQ flush // buffer 满了,走慢路径ret: MOVQ AX, (DI) // 执行实际写操作 *slot = ptr RETflush: CALL runtime·wbBufFlush(SB) // 刷 buffer JMP ret源码:asm_amd64.s。
关键设计细节:
- 快路径只有约 10 条指令,且没有函数调用,对性能的影响被压缩到最低
- 写屏障把 old/new 指针对都记录下来,后续由
wbBufFlush1统一处理 gcWriteBarrier1..8变体用于不同数量的指针写入(如copy、memmove等批量操作),减少多次调用快路径的开销- 实际写操作
*slot = ptr在屏障记录之后执行,这是语义要求:必须先记录旧值再覆盖
5.5.3 批处理与入队:wbBufFlush1 → putObjBatch → gcWork
wbBuf结构与get1/get2:mwbbuf.gowbBufFlush/wbBufFlush1:flush 时会 shade/mark,并把真正需要追踪的对象批量putObjBatch进 GC work queue:mwbbuf.go
最后,GC worker/assist 在 gcDrainN 中消费这些 work(见 4.3)。
wbBufFlush1 的核心逻辑是:遍历 buffer 中记录的指针对(old, new),对每个指针判断是否需要 shade(标记为灰色并加入工作队列):
func wbBufFlush1(_p_ *p) { start := uintptr(_p_.wbBuf.start) end := uintptr(_p_.wbBuf.next) n := (end - start) / unsafe.Sizeof(uintptr(0)) / 2 // 指针对数量
buf := (*[1 << 30]uintptr)(unsafe.Pointer(start))[:2*n] // 每一对 (old, new) 都需要检查 var pos int for i := 0; i < 2*n; i += 2 { old := buf[i] new := buf[i+1] // 混合写屏障语义:shade(old) 始终执行 // shade(new) 在当前栈未扫描时执行 if old != 0 && isShaded(old) == false { shade(old) buf[pos] = old pos++ } if stackScan && new != 0 && isShaded(new) == false { shade(new) buf[pos] = new pos++ } } // 批量入队到 gcWork if pos > 0 { putObjBatch(&buf[0], pos) } _p_.wbBuf.next = _p_.wbBuf.start // 重置 buffer}源码:mwbbuf.go。
注意 isShaded 检查:只有尚未被 shade 的对象才会被加入队列,避免重复入队导致工作膨胀。这是 wbBuf 批处理的一个优化点,相比逐条处理写屏障记录,批量检查可以减少对 mark bits 的原子操作次数。
六、如何观察 Go 的 GC?(从”能看见”开始)
按“侵入性从低到高”给一组工具箱:
-
GODEBUG=gctrace=1:最直接的运行时日志。
runtime 在 mark termination 末尾打印一行 gctrace(包含 STW/CPU/heap goal 等),代码就在 mgc.go。 -
runtime/metrics:面向监控系统的指标面板(更现代)。
你关心的通常是:/gc/cycles/*/gc/pauses/*/memory/classes/*
-
pprof:
- heap profile:看“谁在堆上分配了大量对象”
- allocs profile:看“分配热点”(小对象多时非常关键)
-
go tool trace(execution tracer):当你怀疑“GC 与调度/锁/网络/系统调用交互”导致尾延迟时,trace 最有用。
七、实战案例:3 类典型服务的 allocs/heap/trace
为了把“GC 压力”从抽象概念变成可定位的函数栈,这里给 3 个可以直接跑的最小案例(都在本文同仓库的 doc/gc-blog-cases 目录下)。
案例目录:gc-blog-cases
通用跑法(在案例目录下执行):
GODEBUG=gctrace=1 go run ./caseX -out out/caseXgo tool pprof -top -sample_index=alloc_space out/caseX/allocs.pprofgo tool pprof -top -sample_index=inuse_space out/caseX/heap.pprofgo tool trace out/caseX/trace.out注意:案例代码把 runtime.MemProfileRate 设为 1 以获得更清晰的 profile;生产环境不要这么做,开销很高。
trace 阅读建议(打开后优先看这几处):
- “GC” 轨道:每次 GC 的 start/mark/mark termination、STW 片段与时长
- “Goroutine analysis”:是否出现大量 runnable 但被 GC assist 拖慢的现象
- “Network/Sync blocking profile”:尾延迟是否来自锁争用/网络阻塞与 GC 叠加
案例 1:JSON 解码/编码导致的“小对象风暴”(典型 Web API)
代码:case1-json-churn/main.go
你会在很多“网关/REST/GraphQL”链路里看到类似形态:
- 收到 JSON(
[]byte) encoding/json.Unmarshal到map[string]any/[]any- 业务处理后
encoding/json.Marshal回写
allocs 结果(节选,alloc_space):
| flat | flat% | sum% | cum | cum% | 函数 |
|---|---|---|---|---|---|
| 152.68MB | 33.88% | 33.88% | 236.11MB | 52.40% | encoding/json.(*decodeState).objectInterface |
| 74.16MB | 16.46% | 50.34% | 179.38MB | 39.81% | encoding/json.Marshal |
| 65.36MB | 14.51% | 64.85% | 65.36MB | 14.51% | encoding/json.unquote (inline) |
| 58.70MB | 13.03% | 77.88% | 104.75MB | 23.25% | encoding/json.mapEncoder.encode |
| 24.54MB | 5.45% | 93.08% | 260.65MB | 57.85% | encoding/json.(*decodeState).arrayInterface |
| 1.64MB | 0.36% | 98.18% | 267.47MB | 59.36% | encoding/json.Unmarshal |
Showing nodes accounting for 442.84MB, 98.28% of 450.59MB total
文件来源:allocs_top.txt(case1)
读者视角的“干货结论”:
map[string]any/[]any是小对象放大器:每个元素/键值的解析都会引入大量短命对象与反射路径。- 这类服务的 GC 压力通常先从“分配速率”爆发(alloc_space 非常高),再通过 pacer/assist 传导到“尾延迟”。
案例 2:无界缓存导致的“可达但无用”(典型内存泄漏形态)
代码:case2-leaky-cache/main.go
这是最常见的“为什么有 GC 还会泄漏”的真实形态:对象一直可达(被 map/全局结构持有),因此 GC 永远不会回收。
heap 结果(节选,inuse_space):
Type: inuse_space
| flat | flat% | sum% | cum | cum% | 函数 |
|---|---|---|---|---|---|
| 48.83MB | 76.35% | 76.35% | 63.89MB | 99.90% | runtime.main |
| 12.96MB | 20.27% | 96.62% | 15.06MB | 23.55% | main.main |
Showing nodes accounting for 63.88MB, 99.88% of 63.96MB total
文件来源:heap_top.txt(case2)
读者视角的“干货结论”:
- 只要引用链不断(比如全局 map / 长生命周期缓存 / goroutine 泄漏持有),GC 就不会回收;这就是“GC 语义上的泄漏”。
- 这类问题优先看 heap inuse(活对象占用),而不是 allocs(分配速率)。
案例 3:高并发 fan-out 管道:sync.Pool 让 allocs 断崖式下降
代码:case3-fanout-pipeline/main.go
对比两次运行(同样 jobs/size,不同 -pool):
不用 pool:大量短命 payload 触发分配洪峰
Type: alloc_space
| flat | flat% | sum% | cum | cum% | 函数 |
|---|---|---|---|---|---|
| 50.75MB | 95.61% | 95.61% | 53MB | 99.85% | main.main |
Showing nodes accounting for 53MB, 99.85% of 53.08MB total
文件来源:allocs_top.txt(case3 不用 pool)
用 pool:把“短命对象”变成“可复用缓冲”,allocs 显著下降
Type: alloc_space
| flat | flat% | sum% | cum | cum% | 函数 |
|---|---|---|---|---|---|
| 2305.13kB | 82.09% | 82.09% | 2305.13kB | 82.09% | runtime/trace.(*traceMultiplexer).startLocked |
| 5.53kB | 0.2% | 99.43% | 2427.94kB | 86.46% | main.main |
Showing nodes accounting for 2792.18kB, 99.43% of 2808.19kB total
文件来源:allocs_top.txt(case3 用 pool)
读者视角的“干货结论”:
- 你真正想消灭的是分配速率与小对象数量;
sync.Pool能把大量短命对象转化为可复用缓存,从根上降低 GC 工作量。 - 但
sync.Pool也会改变内存曲线(高水位、回收时机不确定),一定要用 profile 验证收益与副作用。
八、如何调优 Go 的 GC?(原则 + 可操作手段)
8.1 先定目标:你要省内存,还是要省 CPU,还是要省暂停?
Go 的调优通常是“三者取舍”:
- 更低的内存:更频繁 GC、更多写屏障与扫描 CPU
- 更低的 GC CPU:更高的内存峰值(更大的堆 goal)
- 更低的暂停:通常要减少 root 集合与写屏障 flush 压力,以及减少“大对象/大栈/大量 goroutine”
8.2 Scavenger(内存回收器):如何把空闲内存归还给 OS?
GC 的 sweep 阶段只是把对象标记为”空闲”,但对应的物理内存页并未归还给操作系统,进程的 RSS(Resident Set Size)可能远高于实际需要的堆大小。Scavenger 就是负责把空闲页的物理内存归还给 OS 的机制。
8.2.1 两种 scavenging:后台异步 + 分配时同步
Go 的 scavenging 发生在两条路径上:
- 后台 scavenger goroutine(
bgscavenge):一个独立的后台 goroutine,以不超过 mutator CPU 时间的scavengePercent(1%)为限,持续将空闲页归还给 OS。它通过 PI 控制器(scavengerState.sleepController)动态调整工作/睡眠比例,确保 CPU 开销可控。 - 分配时同步 scavenging:当从 mheap 分配新页时,如果发现内存接近或超过
GOMEMLIMIT,或者堆增长导致 RSS 上升,分配路径会主动 scavenge 一部分空闲页来抵消 RSS 增长。
源码入口:
- 后台 scavenger 主循环:mgcscavenge.go
- scavenger 状态与 PI 控制器:mgcscavenge.go
8.2.2 Scavenge goal:RSS 目标怎么算?
Scavenger 要回答的问题是”RSS 应该维持在多少”?Go 根据是否设置了 GOMEMLIMIT 计算两个独立目标:
-
GOGC 目标(无内存限制时):
goal = (1 + retainExtraPercent/100) × (heapGoal / lastHeapGoal) × lastHeapInUse其中
retainExtraPercent = 10,即保留 10% 的额外缓冲空间,避免分配时频繁触发 page fault。直觉上:RSS 跟随 heap goal 按比例缩放,并留一点余量。 -
GOMEMLIMIT 目标(有内存限制时):
goal = (1 - reduceExtraPercent/100) × memoryLimit其中
reduceExtraPercent = 5,即目标设为 memoryLimit 的 95%。方向相反:接近限制时需要更激进地归还内存,所以目标低于限制值,给 scavenger 更大的紧迫感。
两个目标取”更严格的那个”,只要任一目标未满足,scavenger 就继续工作。
源码:gcPaceScavenger:mgcscavenge.go
8.2.3 密度启发式:避免破坏 Huge Page
Scavenger 不会盲目归还所有空闲页。它遵循一个启发式:只归还至少经历了一个完整 GC cycle 仍未被密集分配的 chunk。原因有二:
- 刚被 sweep 的 span 很可能很快被重新分配(堆在 mark 结束后通常很密集),归还了又要 page fault 拿回来,得不偿失。
- 归还会拆分 Transparent Huge Page(THP),对性能影响显著。因此只对”稀疏”chunk 做 scavenging,对”密集”chunk 反而尝试用
MADV_HUGEPAGE合并成大页。
但在 debug.FreeOSMemory() 或接近 GOMEMLIMIT 时,这些启发式会被忽略(force scavenging),因为归还内存的优先级高于保持 THP 完整性。
8.2.4 sysUnused 的平台实现
归还内存的底层操作是 sysUnused,在 Linux 上通过 madvise 实现:
- 优先尝试
MADV_FREE(Linux 4.5+):惰性释放,内核在内存压力时才回收,page fault 可直接重新使用。 - 回退到
MADV_DONTNEED:立即释放,访问时触发 page fault 重新映射。 - 如果
madvise完全不支持(极少数嵌入式 Linux),回退到mmap(MAP_FIXED)重新映射。
源码:mem_linux.go
8.3 GOMEMLIMIT 与 GOGC 的交互:软内存上限如何影响 GC?
Go 1.19 引入了 GOMEMLIMIT / debug.SetMemoryLimit,它是一个软内存上限(soft memory limit),与 GOGC 协同工作,让容器化场景下的内存控制更可预测。
8.3.1 内存上限的语义
SetMemoryLimit 设置的上限涵盖 Go runtime 管理的所有已映射但未释放的内存,精确表达式为:
MemStats.Sys - MemStats.HeapReleased
或用 runtime/metrics 表达:
/memory/classes/total:bytes - /memory/classes/heap/released:bytes
这不包括 Go 二进制本身占用的内存、cgo 分配的内存、以及通过 syscall.Mmap 直接映射的内存。
8.3.2 GOGC 与 GOMEMLIMIT 的协作规则
重要规则:
-
GOGC 仍然生效:
GOMEMLIMIT不会替代GOGC,而是在GOGC计算的 heap goal 超过内存限制时,自动降低 goal 以满足限制。即effectiveGoal = min(GOGC_goal, memoryLimit_goal)。 -
即使 GOGC=off 也生效:
SetGCPercent(-1)关闭基于 GOGC 的触发,但GOMEMLIMIT仍然有效,当内存接近限制时,GC 依然会被触发。 -
内存目标比率:当
GOMEMLIMIT生效时,pacer 会将 heap goal 压低到不超过memoryLimit - nonHeapMemory(nonHeapMemory 包括栈、全局变量、runtime 内部结构等)。如果 GOGC 计算的 goal 已经在限制内,则 GOGC 正常生效,GOMEMLIMIT 不干预。
8.3.3 GC CPU 限制器(gcCPULimiter)
当 GOMEMLIMIT 生效且 GC 非常频繁时,可能出现 GC CPU 颠簸(thrashing):GC 占用大量 CPU,mutator 几乎无法推进,导致更多分配堆积,又触发更多 GC,死亡螺旋。
Go 通过 GC CPU 限制器(gcCPULimiter)来缓解这个问题:
- 机制是漏桶(leaky bucket):GC CPU 时间”灌入”桶,mutator CPU 时间”排出”桶。
- 当桶满(GC CPU 时间占比过高),限制器启用,允许 mutator 在 GC mark 期间获取更多 CPU 时间。
- 桶容量为
GOMAXPROCS × 1s,即最多容忍 1 秒的 GC CPU 超额。 - 限制器还会扣除 idle P 的时间,避免在低负载时误判。
源码:mgclimit.go
8.3.4 工程建议
- 容器环境务必设置 GOMEMLIMIT:建议设为容器内存限制的 80–90%,留出余量给 cgo/系统开销。Go 1.19+ 的
GOMEMLIMIT环境变量支持单位后缀(如GOMEMLIMIT=1GiB)。 - GOGC + GOMEMLIMIT 组合:GOGC 控制稳态下的 GC 频率,GOMEMLIMIT 兜底峰值。典型配置:
GOGC=50, GOMEMLIMIT=1GiB。 - 避免设得太低:如果
GOMEMLIMIT接近 runtime 自身最低占用,GC 会近乎连续运行(文档明确警告):runtime/debug/garbage.go。
8.4 你能直接调的旋钮
-
GOGC / debug.SetGCPercent:调“目标堆增长比例”,从而影响 goal/trigger。
SetGCPercent的文档说明就在:runtime/debug/garbage.go -
GOMEMLIMIT / debug.SetMemoryLimit:调“软内存上限”,runtime 会在内存压力下调高 GC 频率并更积极归还内存。
见:runtime/debug/garbage.go -
减少堆分配(最有效):
- 减少小对象逃逸(逃逸分析、返回值、interface/closure 捕获等)
- 尽量复用对象(
sync.Pool等),注意这会改变内存曲线与 GC 行为,需配合 profile 验证
-
减少指针密度/扫描成本:
- 用切片/数组装“非指针元素”比链表/树更利于 GC(指针少、局部性好)
- 大量 map[string]*T、[]*T 会显著增加扫描工作量
8.5 当分配速度 > 标记清除速度,会发生什么?
答案是:Go 会通过 mutator assist 把压力转移到分配方,让分配 goroutine 自己做一部分标记工作,从而”强行把分配速率压下来”,防止堆失控增长。
源码链路:
deductAssistCredit扣减g.gcAssistBytes,欠债则gcAssistAlloc:malloc.gogcAssistAlloc计算需要做多少scanWork,并通过gcDrainN实际执行标记:mgcmark.go
工程直觉:
当 GC 跟不上时,runtime 会把你的分配路径变慢,让你用 CPU 换内存上限与正确性。
8.5.1 债务系统的完整链路:gcAssistBytes、bgScanCredit 与 assistQueue
mutator assist 的核心是一个”债务账本”系统,有三个关键角色:
g.gcAssistBytes:每个 goroutine 的本地账本。正数表示”有存款可以分配”,负数表示”欠债需要做标记来还”gcController.bgScanCredit:全局信用池。后台 worker 做的标记工作存入这里,mutator 可以从中”借”来还债work.assistQueue:等待队列。欠债太多且全局信用不足的 goroutine 在此 park,等后台 worker 还债后唤醒
当 goroutine 在 mallocgc 中分配内存时,deductAssistCredit 先扣减 gcAssistBytes:
assistG.gcAssistBytes -= int64(size)if assistG.gcAssistBytes < 0 { gcAssistAlloc(assistG) // 欠债了,必须做标记来还}源码:malloc.go。
gcAssistAlloc 的还债流程:
func gcAssistAlloc(gp *g) {retry: debtBytes := -gp.gcAssistBytes scanWork := int64(gcController.assistWorkPerByte * float64(debtBytes))
// 第一步:尝试从全局信用池 bgScanCredit 借 bgScanCredit := atomic.Loadint64(&gcController.bgScanCredit) stolen := int64(0) if bgScanCredit > 0 { if bgScanCredit < scanWork { stolen = bgScanCredit gp.gcAssistBytes += 1 + int64(gcController.assistBytesPerWork*float64(stolen)) } else { stolen = scanWork gp.gcAssistBytes += debtBytes } atomic.Xaddint64(&gcController.bgScanCredit, -stolen) scanWork -= stolen if scanWork == 0 { return // 借够了,不用自己做 } }
// 第二步:借不够,自己动手做标记 systemstack(func() { gcAssistAlloc1(gp, scanWork) // 内部调用 gcDrainN })
// 第三步:做完还是欠债,park 到 assistQueue 等唤醒 if gp.gcAssistBytes < 0 { if gp.preempt { Gosched() goto retry } if !gcParkAssist() { goto retry } }}源码:mgcmark.go。
反过来,后台 worker 完成标记后通过 gcFlushBgCredit 向全局信用池”存款”,同时检查 assistQueue 中是否有等待的 goroutine 可以被唤醒:
func gcFlushBgCredit(scanWork int64) { if work.assistQueue.q.empty() { // 没人等,直接存入全局信用池 atomic.Xaddint64(&gcController.bgScanCredit, scanWork) return } // 有人等,按债务大小依次唤醒 scanBytes := int64(float64(scanWork) * gcController.assistBytesPerWork) for !work.assistQueue.q.empty() && scanBytes > 0 { gp := work.assistQueue.q.pop() if scanBytes+gp.gcAssistBytes >= 0 { scanBytes += gp.gcAssistBytes gp.gcAssistBytes = 0 ready(gp, 0, false) // 唤醒 } else { gp.gcAssistBytes += scanBytes scanBytes = 0 work.assistQueue.q.pushBack(gp) break } } // 剩余存入全局信用池 if scanBytes > 0 { scanWork = int64(float64(scanBytes) * gcController.assistWorkPerByte) atomic.Xaddint64(&gcController.bgScanCredit, scanWork) }}源码:mgcmark.go。
这套债务系统在宏观上实现了”分配多少内存就要做多少标记”的原则,在微观上通过全局信用池和等待队列平衡了后台 worker 与 mutator 之间的工作量分配。
九、Go GC 的历史演进:有哪些改进?
这一节用“发布可感知的里程碑 + 对工程的影响”的方式,按版本做成表(便于你对着公司技术栈的 Go 版本做迁移收益评估)。
| Go 版本 | GC/运行时相关改进(与 GC 强相关) | 工程影响(你能感知到的变化) | 官方资料 |
|---|---|---|---|
| 1.5 | GC 重构为并发(concurrent),显著降低 STW pause(目标“几乎总是 <10ms”) | Web 服务/交互式程序尾延迟显著改善;GC 与调度/分配器成为长期演进主线 | Go 1.5 Release Notes(Garbage collector):https://go.dev/doc/go1.5 |
| 1.8 | 通过消除 “stop-the-world stack rescanning” 降低 GC 暂停;trace 工具对 GC 展示更清晰 | 暂停进一步缩短;trace 更易分析 GC 行为(GC 单独一行、worker 角色标注) | Go 1.8 Release Notes:https://go.dev/doc/go1.8 |
| 1.14 | goroutine 异步抢占(async preemption):无函数调用的 tight loop 不再显著延迟 GC | 降低“最坏暂停/最坏启动延迟”(以前 GC 可能等 safepoint 等很久);调度更健壮 | Go 1.14 Release Notes(Runtime):https://go.dev/doc/go1.14 |
| 1.19 | 软内存上限(GOMEMLIMIT / SetMemoryLimit),并在接近上限时限制 GC CPU thrash(倾向用内存换进度) | 容器化场景更可控(避免 OOM/过度 GC);可把“内存”作为一等约束来调优 | Go 1.19 Release Notes(Runtime):https://go.dev/doc/go1.19 与 GC Guide:https://go.dev/doc/gc-guide |
| 1.25 | 新实验 GC:Green Tea(GOEXPERIMENT=greenteagc),提升小对象标记/扫描的局部性与可扩展性(部分工作负载 GC CPU 降 10–40%) | GC 重负载服务可能明显省 CPU;但仍需按业务画像验证收益 | Go 1.25 Release Notes(Runtime):https://go.dev/doc/go1.25 与官方博客:https://go.dev/blog/greenteagc |
和源码的对应关系(给你“读源码时该看哪里”):
- 并发 cycle 的骨架(
_GCoff/_GCmark/_GCmarktermination+setGCPhase):mgc.go - 混合写屏障伪代码(Yuasa deletion + Dijkstra insertion):mbarrier.go
- pacer(trigger/goal/runway + assist credit):mgcpacer.go
十、Go GC 演化中有哪些设计没有被采用?为什么?
这里给几个最常被问的方向(“没采用”不等于永远不会,更多是 trade-off 没到那个点):
-
分代 GC(Generational GC)作为默认策略
分代 GC 在很多对象“朝生夕死”时非常高效(尤其吞吐),但需要额外的代际写屏障与 remembered set 维护;对 Go 来说需要在复杂指针形态、unsafe、cgo 交互下保持正确性与低开销,实现与验证成本很高。
Go 目前仍以“非分代”的并发 mark-sweep 为主,更多通过 pacer + assist 控制节奏与内存。 -
移动/压缩(Moving/Compacting)GC 作为默认
压缩能解决碎片与高 RSS,但移动对象意味着指针必须可更新;Go 的unsafe.Pointer、cgo 传指针到 C、以及大量“把地址当身份”的工程用法,会使移动 GC 的可行性与兼容性非常困难。
因此 Go 选择了“基本不移动对象”的设计,代价就是碎片问题需要用其他方式缓解(scavenger、分配器策略等)。 -
引用计数(Reference Counting)作为主要回收机制
RC 的优点是更可预测的暂停(甚至没有传统 STW),但代价是每次指针赋值都要做 inc/dec(写放大),且天然处理不了环(需要额外 cycle collector)。对 Go 这种高并发、指针操作密集的场景通常得不偿失。 -
彻底无 STW 的并发回收
工程上极难。哪怕在现代并发 GC 中,也通常需要非常短的 STW 来做:- 切相位与全局一致性点(比如打开/关闭写屏障)
- 处理根集合快照、终止条件的全局验证
Go 也是这个路线:STW 被压缩,但没有被消灭(见gcStart与gcMarkDone的 stop-the-world 调用)。
十一、目前 Go 的 GC 还存在哪些问题?
把问题表述成“已知 trade-offs”会更工程化:
-
非压缩带来的碎片与 RSS 偏高风险
特别是大对象、生命周期错配、arena/sizeclass 结构导致的内部碎片。 -
指针密集型工作负载扫描成本高
大量map[string]*T、[]*T、链表/树结构会显著增加扫描工作量,进而增加 GC CPU 与尾延迟。 -
root 集合过大(goroutine/stack/global)时,终止阶段压力上升
根扫描与 mark termination 都会受到 goroutine 数、栈大小、全局变量规模影响。 -
内存限制场景下更容易出现“近乎连续 GC”
当你把GOMEMLIMIT设得接近 runtime 自身最低占用时,GC 会非常频繁(文档也提示了这一点):runtime/debug/garbage.go。
十二、Go GC vs Java GC vs V8 GC:性能对比怎么理解?
这类对比必须先明确“性能”指什么:
- 吞吐(Throughput):单位时间做多少有效业务
- 暂停(Latency):单次/尾部暂停有多大
- 内存占用(Memory):峰值/碎片/可控性
工程上更合理的结论是“擅长点不同”:
-
Go GC(并发、非压缩、强调低停顿)
通常暂停较短、行为相对稳定,但在碎片/峰值上不如压缩型 GC;遇到分配过快会用 assist 反压分配路径(可能影响尾延迟)。 -
Java(现代 JVM 多提供多种 GC:分代、压缩、并发/并行多组合)
Java 的生态与 runtime 更允许移动/压缩与分代策略,因此在吞吐与碎片控制上往往更强;但不同 GC(G1/ZGC/Shenandoah/Parallel)行为差异极大,不能一句话定输赢。 -
V8(JS 引擎,强分代 + 多种增量/并发技术)
JS 堆通常相对更小且对象朝生夕死显著,分代 GC 很吃香;但 JS 的执行模型与引擎内部优化(JIT、hidden class 等)使得 GC 与语言特性强耦合,调优方式也不同。
“怎么选”的实用建议:
- 如果你在乎“非常稳定且低的暂停”,Go 与低停顿 JVM GC 都可能适合,但要结合对象图/分配模式。
- 如果你需要“强吞吐 + 可压缩 + 大堆”,Java 的某些 GC 更有优势。
- JS/V8 的 GC 更多是“引擎内优化的一环”,很难直接拿来和服务端 Go/Java 的 GC 策略做一刀切对比。
十三、常见问题
13.1 Go 语言历史版本在 GC 方面有哪些改进?
- 里程碑:并发 tracing mark-sweep、混合写屏障、pacer 演进、内存限制(GOMEMLIMIT/SetMemoryLimit)。
细节见前面历史演进一节;源码侧可直接对照:
- 并发相位骨架:
_GCmark/_GCmarktermination:mgc.go - 混合写屏障伪代码注释:mbarrier.go
- 软内存上限 API 语义:runtime/debug/garbage.go
- 并发相位骨架:
13.2 Go GC 演化过程中有哪些设计没有被采用?为什么?
- 分代、移动/压缩、引用计数、彻底无 STW 等,原因主要是:实现复杂度、与
unsafe/cgo 的兼容性、指针更新成本、以及 Go 对稳定低停顿的工程目标。
详见前面未被采用的设计方向一节。
13.3 目前 Go 语言的 GC 还存在哪些问题?
- 碎片/RSS、指针密集扫描成本、root 集合过大、内存限制下可能近乎连续 GC。
详见前面现存问题一节。
13.4 哪些编程语言提供 GC,哪些不提供?GC 和 No GC 各自的优缺点是什么?
常见“有 GC”的语言(追踪式为主)
- Go、Java、C#/.NET、JavaScript(V8 等引擎)、Python、Ruby、Erlang/Elixir、Julia、Lua 等。
常见“无追踪 GC”的语言
- C、C++(可用库/智能指针/RC,但非语言统一 tracing GC)、Rust(所有权/借用 + 可选 RC/arena)、Zig 等。
优缺点(工程视角)
- GC 优点:开发效率高;更少 use-after-free/double-free;对象生命周期管理简单;在复杂对象图上更易正确。
- GC 缺点:不可避免的运行时开销(写屏障/扫描);暂停与尾延迟风险;峰值内存与碎片;调优成本。
- No GC 优点:更可控的内存与延迟上界(在足够纪律/模型下);通常更低 runtime 开销。
- No GC 缺点:生命周期复杂;更容易写出内存安全 bug;复杂对象图/共享场景管理困难;需要工具与规范兜底。
13.5 Go 语言的 GC 性能相比 Java 和 JS V8 引擎中的 GC 怎么样?
这题没有统一答案,取决于你的“性能指标”(吞吐/暂停/内存)。建议按前面 Go GC vs Java GC vs V8 GC 一节的框架理解:Go 擅长低停顿、Java 擅长多策略覆盖(含压缩/分代)、V8 对 JS 分代/增量优化更深。
13.6 Go 语言中,为什么小对象多了会造成 GC 压力?
因为追踪 GC 的成本与“对象数/指针数”强相关:
- 小对象越多 → 堆上对象数量越多 → 标记时要扫描/访问的对象元数据与指针槽位更多 → GC CPU 与 cache miss 增加
- 同时分配越频繁 → pacer 更容易触发 GC;若仍跟不上 → assist 让分配路径变慢
源码链路:
13.7 Go 语言中两次 GC 周期重叠会引发什么问题,GC 触发机制是什么样的?
先说结论:
- mark 相位不会重叠:因为
gcTrigger.test()明确要求gcphase == _GCoff,只要在_GCmark/_GCmarktermination,新的触发 test 就会失败:mgc.go。 - 但 sweep 可以与下一轮 mark 并行:这正是并发 sweep 的设计(cycle 被分解了)。
为什么 mark 重叠会出大问题?
- 两轮 mark 会同时依赖写屏障语义与 mark bits/队列一致性;相位切换与终止条件会相互干扰,破坏正确性与统计口径。
在实现上,Go 用多把“推进锁”保证相位推进的唯一性:
13.8 什么是 Go 语言的插入写屏障?它又是如何实现的?
- 定义见前面插入写屏障一节(对新值 shade)。
- Go 的实现体现在“混合写屏障”的 insertion 部分,算法说明见:mbarrier.go。
- 工程实现链路:编译器插桩(SSA
writebarrier.go)→ 调用runtime.gcWriteBarrier*(汇编)→ 写入wbBuf→wbBufFlush1批处理入队。
13.9 什么是 Go 语言的删除写屏障?它又是如何实现的?
- 定义见前面删除写屏障一节(对旧值 shade)。
- Go 混合写屏障包含 deletion barrier,算法说明同样在:mbarrier.go。
- 批量写入场景的关键实现是
bulkBarrierPreWrite,它会在写入前把 old/new 指针记录到 buffer:mbitmap.go。
13.10 什么是 Go 语言的写屏障?它又是如何实现的?
- 写屏障是并发标记的正确性装置:mutator 写指针时记录必要信息,保证不会漏标。
- Go 的 write barrier 开关由
setGCPhase控制:mgc.go。 - 实现链路详见前面写屏障工程实现一节(编译器插桩 + gcWriteBarrier 汇编 + wbBufFlush)。
13.11 什么是 Go 语言的混合写屏障?它又是如何实现的?
- 定义与伪代码在 runtime 源码注释里写得非常清楚(Yuasa deletion + Dijkstra insertion):mbarrier.go。
- 实现:插桩写指针写入、以及
bulkBarrierPreWrite覆盖 memmove/memclr 等批量写路径(见前面写屏障一节)。
13.12 Go 语言中 GC 垃圾回收的过程是怎么样的?请介绍工作原理
见前面 cycle 骨架一节与从 gcStart 到 gcMarkTermination 的源码级流程。函数入口:
13.13 什么是 Go 语言中的根对象?
根对象(roots)是 GC 标记遍历的起点集合,典型包括:
- goroutine 栈上的指针
- 全局变量区(data/bss)中的指针
- runtime 自己维护的一些固定根(finalizer、cleanup 队列等)
源码里,gcPrepareMarkRoots() 在 STW 下把 roots 拆成 job,并把要扫描的 goroutine 快照到 work.stackRoots:mgcmark.go。
13.14 常见的 GC 实现方式有哪些?Go 语言使用的是什么 GC 实现?
常见方式:
- 引用计数(RC)
- 追踪式:标记-清扫、标记-整理(压缩)、复制(copying)
- 分代 GC(通常结合复制/整理)
Go runtime 的主实现是:并发追踪式标记-清扫(concurrent mark-sweep),配合写屏障与 pacer、并发 sweep、mutator assist。
13.15 Go 语言中的三色标记法是什么?
三色标记是追踪式标记的一个并发正确性框架:
- 白:未发现(可能垃圾)
- 灰:已发现但未扫描其指针
- 黑:已扫描完成
并发下不允许”黑对象指向白对象”而白对象又不被灰化(否则漏标)。写屏障就是为此服务。
13.16 如何观察 Go 语言的 GC 运行情况?
见前面观察 GC 一节。最直接的是:
GODEBUG=gctrace=1(runtime 打印位置:mgc.go)- runtime/metrics、pprof、trace
13.17 在有 GC 的情况下,为什么 Go 语言中仍会发生内存泄漏?
因为 GC 只回收“不可达”的对象。只要对象仍可达,它就不会被回收;工程上常见的“可达但无用”包括:
- 全局 map 缓存无限增长
- goroutine 泄漏(持有引用链)
- channel/队列里残留引用
sync.Pool/内部缓存导致的高水位(不是泄漏但表现类似)- finalizer/cleanup 或外部资源引用链不释放
- C 侧分配内存(cgo)不在 Go GC 管理范围
13.18 Go 语言中并发标记清除法的难点是什么?
难点主要是:
- 并发修改对象图导致的正确性(需要写屏障、终止条件验证、ragged barrier)
- 低争用的工作队列设计(P 本地 + 全局 workbuf)
- 触发时机(pacer)与极端情况(分配突刺、root 暴涨)
源码落点:
gcMarkDone的 ragged barrier:mgc.gogcDrainN的 work 获取优先级与 root job 领取:mgcmark.go
13.19 Go 语言中 GC 的具体流程是什么?
见前面从 gcStart 到 gcMarkTermination 的源码级流程。如果你要一份「按函数链路走读」,也可以对照 runtime-gc-flow.md。
13.20 Go 语言中触发 GC 的时机是什么?
三类触发器:
- heap:
heapLive >= trigger - time:超过
forcegcperiod - cycle:
runtime.GC()强制开启下一轮
统一判断入口 gcTrigger.test():mgc.go。
入口来源见前面触发入口一节。
13.21 如果内存分配速度超过了标记清除的速度,Go 语言会如何处理?
通过 mutator assist 把分配方变慢,并强制其执行标记工作(前面调优一节的 mutator assist 部分)。
源码链路见:malloc.go 与 mgcmark.go。
13.22 Go 语言的 GC 关注的主要指标有哪些?
工程上通常盯三类:
- 暂停:STW pause(p50/p99/最大)
- CPU:GC CPU fraction、assist/dedicated/idle 时间组成
- 内存:heap live、heap goal、RSS/碎片、scavenger 归还效果
源码里 gctrace 打印的字段非常贴近这三类指标:mgc.go。
13.23 如何对 Go 语言的 GC 进行调优?
见前面调优一节。最通用且最有效的路径是:
- 先用 allocs/heap profile 找分配热点与逃逸
- 通过减少堆分配、降低指针密度、复用对象,把“根因”压下去
- 再用 GOGC/GOMEMLIMIT 做资源取舍调参,并用指标验证
13.24 Go 语言垃圾回收器的相关 API 有哪些?它们的作用分别是什么?
常用 API(按“你真会用到”的频率排序):
runtime.GC():强制触发并等待一轮完整 cycle(见第 2.1C)
mgc.goruntime.ReadMemStats:读取MemStats(用于观测)
对应结构体更新点在 mark termination:mgc.goruntime/debug.SetGCPercent:设置 GOGC(调频)
runtime/debug/garbage.goruntime/debug.SetMemoryLimit/GOMEMLIMIT:设置软内存上限(容器化场景很重要)
runtime/debug/garbage.goruntime/debug.FreeOSMemory():GC()+ 更积极归还内存给 OS
runtime/debug/garbage.goruntime.SetFinalizer:终结器(谨慎使用,会影响对象可回收时机与 GC 行为)
终结器处理在mfinal.go(可进一步深挖)
小结
Go GC 的核心取舍可以凝缩为一句话:用内存换延迟,用 CPU 换正确性。并发标记-清除选择了不移动对象,换来的是 unsafe.Pointer 和 cgo 的兼容性,代价是碎片和高 RSS;GC Assist 让分配者自己扛标记成本,换来的是堆不会失控增长,代价是分配路径可能变慢;pacer 在”触发太早则浪费 CPU”和”触发太晚则堆暴涨”之间走钢丝,用 runway 和 goal 精确调节节奏。调优的起点永远是从根源上降低 GC 的工作量:减少堆分配,预分配、sync.Pool 复用、降低指针密度。GOGC 和 GOMEMLIMIT 是资源约束旋钮,前者控制稳态频率,后者兜底峰值内存,二者配合才能在容器环境下既不 OOM 又不颠簸。什么时候不该调 GC?当你还没有用 allocs/heap profile 定位分配热点时,盲目调参只会掩盖问题。
参考资料
- Garbage collection - 剑桥大学 GC 课程讲义
- On-the-Fly Garbage Collection: An Exercise in Cooperation - Dijkstra 等人的并发 GC 经典论文
- Realtime Garbage Collection on General-purpose Machines - Yuasa 删除写屏障的理论基础
- Go Scavenger 实现 - 内存回收器源码
- Go GC CPU 限制器 - GC CPU 限制实现源码
- Go Linux 内存操作 - Linux 平台 madvise/mmap 实现
- Go SetMemoryLimit 文档 - GOMEMLIMIT API 语义文档
- Go GC 指南 - 官方 GC 调优指南
- Go Runtime: mgc.go - GC 核心实现源码
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






