mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
14006 字
40 分钟
Go GC 机制深度解析
2023-06-29

一、一次 GC 周期长什么样?(先搭建骨架)#

1.1「cycle」的四个阶段#

Go runtime 定义的 GC cycle 包含:

  1. sweep termination(STW)
  2. mark(并发)
  3. mark termination(STW)
  4. 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 驱动,它同时更新 gcphasewriteBarrier.enabled

func setGCPhase(x uint32) {
atomic.Store(&gcphase, x)
writeBarrier.enabled = x != _GCoff
}

源码:mgc.go

完整的状态机转换路径:

stateDiagram-v2 [*] --> _GCoff : 程序启动 _GCoff --> _GCmark : gcStart\n(sweep termination → mark) _GCmark --> _GCmarktermination : gcMarkDone\n(mark 完成) _GCmarktermination --> _GCoff : gcMarkTermination\n(终止 → sweep) _GCoff --> _GCoff : sweep 并发执行\n(sweepone 惰性清扫)

1.2 流程图:从触发到结束#

flowchart TD A["分配 / 定时 / 用户调用"] --> B{"是否触发 GC?\nenablegc 且非 panicking\n 且 gcphase == _GCoff"} B -- 否 --> A B -- 是 --> C["gcStart(trigger)"] C --> D{"是否安全上下文?\n 非 g0 / 锁少 / 可抢占"} D -- 否 --> A D -- 是 --> E["并发补扫 sweepone()\n 直到 trigger 失效或 sweep 完成"] E --> F["semacquire(work.startSema)\n 复核 trigger"] F --> G["STW\nstopTheWorldWithSema(stwGCSweepTerm)"] G --> H["finishsweep_m()\nclearpools()\nwork.cycles++\ngcController.startCycle()"] H --> I["setGCPhase(_GCmark)\nwriteBarrier.enabled = true"] I --> J["gcPrepareMarkRoots()\ngcMarkTinyAllocs()\ngcBlackenEnabled = 1"] J --> K["startTheWorldWithSema()\n 进入并发 mark"] K --> L["并发标记推进\n 后台 worker / mutator assist / root jobs"] L --> M{"并发 mark 是否完成?\nwork.nwait == work.nproc\n 且无可用 mark work"} M -- 否 --> L M -- 是 --> N["gcMarkDone()\nragged barrier\nwbBufFlush1 + gcw.dispose"] N --> O["STW\nstopTheWorldWithSema(stwGCMarkTerm)"] O --> P["gcMarkTermination(stw)\nsetGCPhase(_GCmarktermination)"] P --> Q["systemstack(gcMark)\n 最终标记 / 收尾"] Q --> R["setGCPhase(_GCoff)\nwriteBarrier.enabled = false"] R --> S["gcSweep(work.mode)\n 更新 memstats / pacer / 唤醒 waiters"] S --> T["本轮 GC 结束\n 等待下一次触发"]

对应源码主入口:

  • 触发判断:gcTrigger.testmgc.go
  • 分配触发:mallocgc 末尾 heap trigger:malloc.go
  • 启动与相位推进:gcStartmgc.go
  • 完成点与 ragged barrier:gcMarkDonemgc.go
  • mark termination 与 sweep:gcMarkTerminationmgc.go

1.3 两个“计数”别混:work.cycles vs memstats.numgc#

  • work.cycles:cycle 计数(sweep term + mark + mark term + sweep)
  • memstats.numgc:统计口径,在 mark termination 时递增(不是 cycle 末尾)

源码:work.cycles 注释强调它不同于 memstats.numgcmgc.gomemstats.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 时唤醒 forcegchelperproc.goforcegchelper 被唤醒后直接执行 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() 判断,它有三层关卡:

  1. 总闸门控:必须启用 GC、不能在 panic 过程中、且相位必须为 _GCoff if !memstats.enablegc || panicking!=0 || gcphase != _GCoff { return false } mgc.go

  2. 触发种类:

  • heap:heapLive >= trigger(trigger 来自 pacer) mgc.go

  • time:now-last_gc_nanotime > forcegcperiod mgc.go

  • cycle:目标 n 尚未开始(相对于 work.cyclesmgc.go

    3)「真正启动」还需通过 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:gcControllergcControllerState)负责计算:

  • goal:理想上这轮 GC 结束时允许的 live 上界(考虑 GOGC、stacks/globals 等)
  • trigger:何时开始这一轮 mark(在各种上下界与 runway 约束下选取)

源码入口:gcController.trigger() 返回 (trigger, goal)mgcpacer.go

两个最重要的字段:

  • gcController.heapLive:用于触发判断的 live 计数(保守计数,倾向于更早触发)
    mgcpacer.go
  • gcController.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 的计算基于反馈控制:

  1. 估计本轮需要扫描的总字节数(heap + stacks + globals)
  2. 根据历史扫描速度(scanWork)和分配速度,估算标记需要多长时间
  3. 在标记期间,mutator 还会继续分配,这些分配量就是 runway
  4. 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 worker
  • fractionalUtilizationGoal: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)

1
安全检查

g0/锁/不可抢占则退出。

2
并发补扫

调用 sweepone() 避免残留 unswept span。

3
抢占相位推进权

获取 work.startSema,再复核 trigger。

4
STW 进入 sweep termination

获取 gcsema/worldsema,停止世界。

5
完成残留清扫

finishsweep_m() + clearpools()

6
初始化 pacer

work.cycles.Add(1),调用 gcController.startCycle(...)

7
开启写屏障

setGCPhase(_GCmark) 开写屏障。

8
准备标记根

gcPrepareMarkRoots() 切 root jobs;gcMarkTinyAllocs()gcBlackenEnabled=1

9
恢复世界

startTheWorldWithSema:并发 mark 开始。

源码主干:mgc.go

4.2 并发 mark 怎么推进?三条”发动机”#

并发 mark 的”推进”来自三条通道:

  1. root jobs:在 STW 时把根扫描拆成 job,后续由 worker/assist 在 drain 循环中领取执行(不是独立阶段)

  2. 后台 mark workers:每个 P 有一个 worker G,平时 park 在 pool,调度器按需唤醒执行 drain

  3. mutator assists:分配太快时,分配 goroutine 会”欠债”,必须先帮着做标记工作才能继续分配

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 目标
gcMarkWorkerIdleModeP 空闲时顺便做标记可以,有其他 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.markStartTime
if delta > 0 && float64(_p_.gcFractionalMarkTime)/float64(delta) > c.fractionalUtilizationGoal {
return nil // 已达目标,不启动
}
_p_.gcMarkWorkerMode = gcMarkWorkerFractionalMode

源码:mgcpacer.gofindRunnableGCWorker 方法。

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.gofindrunnable

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 在循环中按以下优先级获取灰色对象:

  1. root jobs:先检查 work.markrootNext < work.markrootJobs,有则原子递增领取一个 root job 执行 markroot
  2. P 本地 gcWork:先 tryGetFast()(无锁路径),失败再 tryGet()(可能需要交换缓冲区)
  3. 写屏障缓冲:如果本地 gcWork 为空,调 wbBufFlush(nil, 0) 把当前 P 的写屏障缓冲刷入 gcWork,再 tryGet()
  4. 全局队列:在步骤 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:

  1. 关闭 gcBlackenEnabled,唤醒所有 assist goroutine,恢复用户 G 调度
  2. 调用 gcController.endCycle() 计算下一个 cycle 的 trigger ratio
  3. 调用 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 的核心工作:

  1. 遍历 span 中所有对象,通过 mark bits 判断哪些对象存活
  2. 未标记的对象被回收(free),加入 span 的空闲链表
  3. 如果 span 中所有对象都未被标记(即整个 span 都是垃圾),该 span 被直接归还给 mheap
  4. 清除 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 读 enabledmgc.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

5.4 混合写屏障(Hybrid barrier)是什么?Go 具体用的就是它#

Go 在 runtime 源码里直接写出了混合写屏障的伪代码与说明,这是最硬的证据:

其伪代码是:

  • 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。这防止了”栈上黑对象直接指向白对象”的情况,因为栈在扫描前可能包含未追踪的指针。

两者的组合效果:

  1. 堆上的指针修改:deletion barrier 保证旧引用链不断,insertion barrier(条件性)保证新引用链被追踪
  2. 栈上的指针修改:栈不需要写屏障(开销太大),但栈最终会被扫描扫黑,扫描前 insertion barrier 兜底
  3. 标记阶段新创建的对象:直接标记为黑色(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 中不会被回收,即使赋值操作触发了写屏障,也不影响正确性。

这个机制与混合写屏障配合,构成了完整的正确性保证:

  1. 已有对象的指针修改:混合写屏障覆盖
  2. 新创建的对象:直接标黑
  3. 栈上的指针:栈扫描时统一处理,不需要写屏障

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 的 gcWriteBarriergcWriteBarrier1..8 在:asm_amd64.s
    • 快路径:推进 p.wbBuf.next,有空间就返回
    • 慢路径:buffer 满则调用 wbBufFlush 后重试

amd64 上 gcWriteBarrier 的核心汇编逻辑(简化):

// DI = slot (写入目标地址)
// AX = ptr (要写入的新值)
get_tls(BX)
MOVQ g(BX), BX
MOVQ g_m(BX), BX
MOVQ m_p(BX), BX
MOVQ (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) // 记录新值 ptr
MOVQ (DI), BX // 读取旧值 *slot
MOVQ BX, -8+8(CX) // 记录旧值 *slot
JEQ flush // buffer 满了,走慢路径
ret:
MOVQ AX, (DI) // 执行实际写操作 *slot = ptr
RET
flush:
CALL runtime·wbBufFlush(SB) // 刷 buffer
JMP ret

源码:asm_amd64.s

关键设计细节:

  • 快路径只有约 10 条指令,且没有函数调用,对性能的影响被压缩到最低
  • 写屏障把 old/new 指针对都记录下来,后续由 wbBufFlush1 统一处理
  • gcWriteBarrier1..8 变体用于不同数量的指针写入(如 copymemmove 等批量操作),减少多次调用快路径的开销
  • 实际写操作 *slot = ptr 在屏障记录之后执行,这是语义要求:必须先记录旧值再覆盖

5.5.3 批处理与入队:wbBufFlush1 → putObjBatch → gcWork#

  • wbBuf 结构与 get1/get2 mwbbuf.go
  • wbBufFlush/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?(从”能看见”开始)#

按“侵入性从低到高”给一组工具箱:

  1. GODEBUG=gctrace=1:最直接的运行时日志。
    runtime 在 mark termination 末尾打印一行 gctrace(包含 STW/CPU/heap goal 等),代码就在 mgc.go

  2. runtime/metrics:面向监控系统的指标面板(更现代)。
    你关心的通常是:

    • /gc/cycles/*
    • /gc/pauses/*
    • /memory/classes/*
  3. pprof

    • heap profile:看“谁在堆上分配了大量对象”
    • allocs profile:看“分配热点”(小对象多时非常关键)
  4. 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/caseX
go tool pprof -top -sample_index=alloc_space out/caseX/allocs.pprof
go tool pprof -top -sample_index=inuse_space out/caseX/heap.pprof
go 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.Unmarshalmap[string]any / []any
  • 业务处理后 encoding/json.Marshal 回写

allocs 结果(节选,alloc_space):

flatflat%sum%cumcum%函数
152.68MB33.88%33.88%236.11MB52.40%encoding/json.(*decodeState).objectInterface
74.16MB16.46%50.34%179.38MB39.81%encoding/json.Marshal
65.36MB14.51%64.85%65.36MB14.51%encoding/json.unquote (inline)
58.70MB13.03%77.88%104.75MB23.25%encoding/json.mapEncoder.encode
24.54MB5.45%93.08%260.65MB57.85%encoding/json.(*decodeState).arrayInterface
1.64MB0.36%98.18%267.47MB59.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

flatflat%sum%cumcum%函数
48.83MB76.35%76.35%63.89MB99.90%runtime.main
12.96MB20.27%96.62%15.06MB23.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

flatflat%sum%cumcum%函数
50.75MB95.61%95.61%53MB99.85%main.main

Showing nodes accounting for 53MB, 99.85% of 53.08MB total

文件来源:allocs_top.txt(case3 不用 pool)

用 pool:把“短命对象”变成“可复用缓冲”,allocs 显著下降#

Type: alloc_space

flatflat%sum%cumcum%函数
2305.13kB82.09%82.09%2305.13kB82.09%runtime/trace.(*traceMultiplexer).startLocked
5.53kB0.2%99.43%2427.94kB86.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 发生在两条路径上:

  1. 后台 scavenger goroutinebgscavenge):一个独立的后台 goroutine,以不超过 mutator CPU 时间的 scavengePercent(1%)为限,持续将空闲页归还给 OS。它通过 PI 控制器(scavengerState.sleepController)动态调整工作/睡眠比例,确保 CPU 开销可控。
  2. 分配时同步 scavenging:当从 mheap 分配新页时,如果发现内存接近或超过 GOMEMLIMIT,或者堆增长导致 RSS 上升,分配路径会主动 scavenge 一部分空闲页来抵消 RSS 增长。

源码入口:

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 就继续工作。

源码:gcPaceScavengermgcscavenge.go

8.2.3 密度启发式:避免破坏 Huge Page#

Scavenger 不会盲目归还所有空闲页。它遵循一个启发式:只归还至少经历了一个完整 GC cycle 仍未被密集分配的 chunk。原因有二:

  1. 刚被 sweep 的 span 很可能很快被重新分配(堆在 mark 结束后通常很密集),归还了又要 page fault 拿回来,得不偿失。
  2. 归还会拆分 Transparent Huge Page(THP),对性能影响显著。因此只对”稀疏”chunk 做 scavenging,对”密集”chunk 反而尝试用 MADV_HUGEPAGE 合并成大页。

但在 debug.FreeOSMemory() 或接近 GOMEMLIMIT 时,这些启发式会被忽略(force scavenging),因为归还内存的优先级高于保持 THP 完整性。

flowchart TD A["GC sweep 完成\n 空闲页可用"] --> B{"后台 scavenger\nRSS > goal?"} B -- 是 --> C["scavengeQuantum = 64KB\n 批量归还空闲页"] C --> D["sysUnused → madvise(MADV_FREE/DONTNEED)\n 物理页归还给 OS"] D --> E["PI 控制器调整\nsleepRatio"] E --> B B -- 否 --> F["scavenger park\n 等待下次唤醒"] G["分配新页\n(mheap.allocSpan)"] --> H{"接近 GOMEMLIMIT\n 或堆增长?"} H -- 是 --> I["同步 scavenge\n 抵消 RSS 增长"] H -- 否 --> J["正常分配"] K["debug.FreeOSMemory()"] --> L["强制全量 scavenge\n 忽略密度启发式"]

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

Note

这不包括 Go 二进制本身占用的内存、cgo 分配的内存、以及通过 syscall.Mmap 直接映射的内存。

源码文档:runtime/debug/garbage.go

8.3.2 GOGC 与 GOMEMLIMIT 的协作规则#

重要规则:

  1. GOGC 仍然生效GOMEMLIMIT 不会替代 GOGC,而是在 GOGC 计算的 heap goal 超过内存限制时,自动降低 goal 以满足限制。即 effectiveGoal = min(GOGC_goal, memoryLimit_goal)

  2. 即使 GOGC=off 也生效SetGCPercent(-1) 关闭基于 GOGC 的触发,但 GOMEMLIMIT 仍然有效,当内存接近限制时,GC 依然会被触发。

  3. 内存目标比率:当 GOMEMLIMIT 生效时,pacer 会将 heap goal 压低到不超过 memoryLimit - nonHeapMemory(nonHeapMemory 包括栈、全局变量、runtime 内部结构等)。如果 GOGC 计算的 goal 已经在限制内,则 GOGC 正常生效,GOMEMLIMIT 不干预。

flowchart TD A["GC cycle 结束\n 计算下一个 goal"] --> B{"GOGC goal\n≤ memoryLimit?"} B -- 是 --> C["使用 GOGC goal\nGOMEMLIMIT 不干预"] B -- 否 --> D["使用 memoryLimit goal\nGOGC 被自动压低"] C --> E["正常 GC 频率"] D --> F["更频繁 GC\n+ 更积极 scavenging"] G["GOGC = off (-1)"] --> H{"内存接近\nGOMEMLIMIT?"} H -- 是 --> I["仍触发 GC\n+ scavenger 更积极"] H -- 否 --> J["不触发 GC\n(GOGC=off 生效)"]

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 你能直接调的旋钮#

  1. GOGC / debug.SetGCPercent:调“目标堆增长比例”,从而影响 goal/trigger。
    SetGCPercent 的文档说明就在:runtime/debug/garbage.go

  2. GOMEMLIMIT / debug.SetMemoryLimit:调“软内存上限”,runtime 会在内存压力下调高 GC 频率并更积极归还内存。
    见:runtime/debug/garbage.go

  3. 减少堆分配(最有效)

    • 减少小对象逃逸(逃逸分析、返回值、interface/closure 捕获等)
    • 尽量复用对象(sync.Pool 等),注意这会改变内存曲线与 GC 行为,需配合 profile 验证
  4. 减少指针密度/扫描成本

    • 用切片/数组装“非指针元素”比链表/树更利于 GC(指针少、局部性好)
    • 大量 map[string]*T、[]*T 会显著增加扫描工作量

8.5 当分配速度 > 标记清除速度,会发生什么?#

答案是:Go 会通过 mutator assist 把压力转移到分配方,让分配 goroutine 自己做一部分标记工作,从而”强行把分配速率压下来”,防止堆失控增长。

源码链路:

  • deductAssistCredit 扣减 g.gcAssistBytes,欠债则 gcAssistAllocmalloc.go
  • gcAssistAlloc 计算需要做多少 scanWork,并通过 gcDrainN 实际执行标记:mgcmark.go

工程直觉:

当 GC 跟不上时,runtime 会把你的分配路径变慢,让你用 CPU 换内存上限与正确性。

8.5.1 债务系统的完整链路:gcAssistBytes、bgScanCredit 与 assistQueue#

mutator assist 的核心是一个”债务账本”系统,有三个关键角色:

  1. g.gcAssistBytes:每个 goroutine 的本地账本。正数表示”有存款可以分配”,负数表示”欠债需要做标记来还”
  2. gcController.bgScanCredit:全局信用池。后台 worker 做的标记工作存入这里,mutator 可以从中”借”来还债
  3. 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.5GC 重构为并发(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.14goroutine 异步抢占(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 没到那个点):

  1. 分代 GC(Generational GC)作为默认策略
    分代 GC 在很多对象“朝生夕死”时非常高效(尤其吞吐),但需要额外的代际写屏障与 remembered set 维护;对 Go 来说需要在复杂指针形态、unsafe、cgo 交互下保持正确性与低开销,实现与验证成本很高。
    Go 目前仍以“非分代”的并发 mark-sweep 为主,更多通过 pacer + assist 控制节奏与内存。

  2. 移动/压缩(Moving/Compacting)GC 作为默认
    压缩能解决碎片与高 RSS,但移动对象意味着指针必须可更新;Go 的 unsafe.Pointer、cgo 传指针到 C、以及大量“把地址当身份”的工程用法,会使移动 GC 的可行性与兼容性非常困难。
    因此 Go 选择了“基本不移动对象”的设计,代价就是碎片问题需要用其他方式缓解(scavenger、分配器策略等)。

  3. 引用计数(Reference Counting)作为主要回收机制
    RC 的优点是更可预测的暂停(甚至没有传统 STW),但代价是每次指针赋值都要做 inc/dec(写放大),且天然处理不了环(需要额外 cycle collector)。对 Go 这种高并发、指针操作密集的场景通常得不偿失。

  4. 彻底无 STW 的并发回收
    工程上极难。哪怕在现代并发 GC 中,也通常需要非常短的 STW 来做:

    • 切相位与全局一致性点(比如打开/关闭写屏障)
    • 处理根集合快照、终止条件的全局验证
      Go 也是这个路线:STW 被压缩,但没有被消灭(见 gcStartgcMarkDone 的 stop-the-world 调用)。

十一、目前 Go 的 GC 还存在哪些问题?#

把问题表述成“已知 trade-offs”会更工程化:

  1. 非压缩带来的碎片与 RSS 偏高风险
    特别是大对象、生命周期错配、arena/sizeclass 结构导致的内部碎片。

  2. 指针密集型工作负载扫描成本高
    大量 map[string]*T[]*T、链表/树结构会显著增加扫描工作量,进而增加 GC CPU 与尾延迟。

  3. root 集合过大(goroutine/stack/global)时,终止阶段压力上升
    根扫描与 mark termination 都会受到 goroutine 数、栈大小、全局变量规模影响。

  4. 内存限制场景下更容易出现“近乎连续 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)。 细节见前面历史演进一节;源码侧可直接对照:

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 让分配路径变慢

源码链路:

  • 分配触发 GC:mallocgc 末尾触发检查:malloc.go
  • 分配过快触发 assist:deductAssistCreditgcAssistAllocmalloc.go

13.7 Go 语言中两次 GC 周期重叠会引发什么问题,GC 触发机制是什么样的?#

先说结论:

  • mark 相位不会重叠:因为 gcTrigger.test() 明确要求 gcphase == _GCoff,只要在 _GCmark/_GCmarktermination,新的触发 test 就会失败:mgc.go
  • sweep 可以与下一轮 mark 并行:这正是并发 sweep 的设计(cycle 被分解了)。

为什么 mark 重叠会出大问题?

  • 两轮 mark 会同时依赖写屏障语义与 mark bits/队列一致性;相位切换与终止条件会相互干扰,破坏正确性与统计口径。

在实现上,Go 用多把“推进锁”保证相位推进的唯一性:

  • work.startSema 保护 _GCoff → _GCmark 的推进:mgc.go
  • work.markDoneSema 保护 _GCmark → _GCmarktermination 的推进:mgc.go

13.8 什么是 Go 语言的插入写屏障?它又是如何实现的?#

  • 定义见前面插入写屏障一节(对新值 shade)。
  • Go 的实现体现在“混合写屏障”的 insertion 部分,算法说明见:mbarrier.go
  • 工程实现链路:编译器插桩(SSA writebarrier.go)→ 调用 runtime.gcWriteBarrier*(汇编)→ 写入 wbBufwbBufFlush1 批处理入队。

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 骨架一节与从 gcStartgcMarkTermination 的源码级流程。函数入口:

13.13 什么是 Go 语言中的根对象?#

根对象(roots)是 GC 标记遍历的起点集合,典型包括:

  • goroutine 栈上的指针
  • 全局变量区(data/bss)中的指针
  • runtime 自己维护的一些固定根(finalizer、cleanup 队列等)

源码里,gcPrepareMarkRoots() 在 STW 下把 roots 拆成 job,并把要扫描的 goroutine 快照到 work.stackRootsmgcmark.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.go
  • gcDrainN 的 work 获取优先级与 root job 领取:mgcmark.go

13.19 Go 语言中 GC 的具体流程是什么?#

见前面从 gcStartgcMarkTermination 的源码级流程。如果你要一份「按函数链路走读」,也可以对照 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.gomgcmark.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.go
  • runtime.ReadMemStats:读取 MemStats(用于观测)
    对应结构体更新点在 mark termination:mgc.go
  • runtime/debug.SetGCPercent:设置 GOGC(调频)
    runtime/debug/garbage.go
  • runtime/debug.SetMemoryLimit / GOMEMLIMIT:设置软内存上限(容器化场景很重要)
    runtime/debug/garbage.go
  • runtime/debug.FreeOSMemory()GC() + 更积极归还内存给 OS
    runtime/debug/garbage.go
  • runtime.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 定位分配热点时,盲目调参只会掩盖问题。

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Go GC 机制深度解析
https://blog.souloss.cn/posts/golang/go-gc/
作者
Souloss
发布于
2023-06-29
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时