Skip to content

gc 技术专项

所有源代码以go1.23.12为例展示。

GC Pacer

src/runtime/mgcpacer.go里面

https://gemini.google.com/u/1/app/c03c21fce21c7247?pageId=none

省流,gc pacer在heap usage达到trigger point后开始background scan, 这个过程中每个时间片(TODO:多少时间?)基于扫描进度和分配进度会更新一次assitRatio,然后根据assitRatio决定CPU utilization, 决定alloc需要进行多少的assist work,确保到达target heap时按时完成。

依据单元测试,GOGC计算出的target heap通常允许+-5%的浮动,允许超过也允许偏低,但不允许过度。 但设置GOMEMLIMIT时必须高于heap usage peak。

  • TODO: scavenging
  • TODO: 25%最低utilization
  • TODO: write barrier
  • TODO; gomemlimit的突破??有这个么 TODO: 添加更多细节

GC Sweep

src/runtime/mgcsweep.go里面

gemini ref

gc sweep本身逻辑简单, 但由于引入了大量的额外逻辑,极大的提高了复杂度,列出了一些内容。

  • 位运算
  • arena逻辑
  • special(profile&finalizer)处理逻辑
  • trace逻辑设置;
  • 内部debug逻辑检查;
  • zombie检查(同样和内部逻辑相关)

sweep严重依赖scan和mark的阶段,mark本质上就是在对span的扫描做位运算,给后面的sweep用。 这种bitflip完成了O1的复杂度,没有任何清理(因为清理实际上lazy发生在分配阶段)。

  • s.allocBits = s.gcmarkBits
  • s.gcmarkBits = newMarkBits(uintptr(s.nelems))
  • atomic.Store(&s.sweepgen, sweepgen)

重置发生的位运算是全局的规则告诉runtime哪些mspan还在用哪些已经free了,但更重要的是这些已经被修改好状态的mspan需要被放回到内存分配的池子里。

分为两部分,小对象和大对象,通过if spc.sizeclass() != 0来判断,然后又有统计和归还两部分逻辑:

  • 小对象:统计阶段额外判定如果mspan内部有被释放的,标记s.needzero = 1, 分配时候再由cpu reset,更新runtime 内部memstats的数据; 归还阶段:

    • 检查如果mspan里面内容全挂了,就还给全局堆: mheap_.freeSpan(s)
    • 全都活着就放在fullSwept队列: mheap_.central[spc].mcentral.fullSwept(sweepgen).push(s)
    • 如果部分还在就partialSwept队列: mheap_.central[spc].mcentral.partialSwept(sweepgen).push(s)
  • 大对象(>32KB):因为一个span只放一个大对象

    • 挂了直接还回去: mheap_.freeSpan(s)
    • 没挂就放在fullSwept队列: mheap_.central[spc].mcentral.fullSwept(sweepgen).push(s)

所以sweep阶段量很小,设置位运算并且还回heap去,所以说sweep阶段是非常快的。 注:这里mspan清理后会被push到heap的队列里去,而pop发生在分配阶段,分配器会从合适的队列里抓一个mspan出来用 ,在下一轮gc的时候会扫描并检查它再把它放回到合适的地方。

TODO: 一些针对性的问题

  • TODO: Sweep Termination(清扫终止)引发的 STW
  • TODO: Lazy Sweep 对分配延迟的影响
  • TODO: Sweep Assist(清扫辅助)与 Pacing
  • TODO: 手动 GC 与 Sweep 的交互

GC Mark

src/runtime/mgcmark.go里面

  • 两次STW
  • 如何具体scan

STW before GC Scan

程序入口在函数gcStart

  • 先STW, stopTheWorldWithSema(stwGCSweepTerm)
  • finishsweep_m(), STW后确保全部完成清扫堆是干净的,所以这里暗示如果有什么问题STW可能会很久。
  • 分配bg mark go routine,有特别要求,但这里不太重要可以忽略
  • clearpools, 把sync.Pool给清理了
  • if mode != gcBackgroundMode TODO: 这个是啥玩意?runtime.GC会有行为不同,需要检查
  • startTheWorldWithSema(0, stw): 结束stw,开始scan

GC worker

在gc mark之前开启background mark。

  • STW内,setGCPhase(_GCmark) 开启写屏障
  • STW内,gcMarkRootPrepare 推进队列,主要是global和stack
  • STW内,gcMarkTinyAllocs把小对象标黑 TODO:不理解,这都是什么玩意
  • STW内,atomic.Store(&gcBlackenEnabled, 1) 开启辅助标记 TODO 不理解,这都是什么玩意

由于是异步的,所以gcStart里主要设置状态和启动开始画,然后后台另外的go routine在扫描,主要是两种扫描来源,后台的标记worker和分配时候强制的assist(分配的assistRatio由gc pacer计算)。

第一部分逻辑来自于gcBgMarkStartWorkers,background扫描是per-P的,所以会根据gomaxprocs来设置, 每个p都有自己的gcBgMarkWorker执行,这个goroutine通过channel等待进入mark phase。

创建node节点把goroutine(G)和machine(M)绑定,注意这里禁止抢占TODO:我不理解为什么🤡 然后进入了循环,不工作的时候直接gopark挂起,不占用任何cpu, TODO: gopark怎么工作的我不知道。 直到调度器通过调用findRunnableGCWorker把它从池子里捞出来,gopark结束。

干活的逻辑包在systemstack里,todo:why? 然后改了下状态,可以让别人扫描他,TODO 同样不太理解 casGToWaitingForSuspendG(gp, _Grunning, waitReasonGCWorkerActive).

然后有三种模式, 包在switch里:

  • gcMarkWorkerDedicatedMode -> gcDrainMarkWorkerDedicated
  • gcMarkWorkerFractionalMode -> gcDrainMarkWorkerFractional
  • gcMarkWorkerIdleMode -> gcDrainMarkWorkerIdle

最后是通过nwait检查状态和调用gcMarkDone,最后一个gc worker会结束gc mark阶段。

gcDrain: markroot

所有的具体逻辑都在gcDrain里面,这是扫描的最核心的逻辑,drain的意思就是把所有的灰色都变成黑色的。 先说下颜色,白色是潜在的垃圾,gc开始时所有的对象都是白色的;灰色是本身可以达到的,但是他引用的子对象还没有扫描。黑色就是以及完成扫描的存活对象。

另外注意,标记并不是把所有的对象标记上存活or死亡,也就是死掉不是一种独立的状态,而是标记缺失的结果。

所以扫描开始从gc root(global和stack)出发,这个时候所有的对象(前面的small alloc似乎是黑色的)都是白色的,把他们标记上(意味着存活),markroot(gcw, job, flushBgCredit),

  • 全局变量在数据段BSS
  • finalizer也要去扫描?? TODO着我不知道了
  • scan go routine stacks

markroot的扫描基本单位是一个block,gc并不知道block里面有什么,不过编译器会生成ptrmask让gogc拿着参考使用,来检查谁是指针。

具体的扫描逻辑在scanblock, 里面有个染色逻辑greyobject, 染色之后通过gcw.putFastorgcw.put把他加入到gc worker的队列里。

注意,黑和灰在位图里表示都是一样的,区分它们只是他们在队列里不,甚至gcDrain只清控队列,它不关心是不是黑色的,也没有检查队列检查存在与否的。检查过程中有一些逻辑可以忽略掉:

  • 如何检查当前指针拿到所有的子引用
  • 如何给位图标记

具体移除队列的操作是先把当前对象pop出来,然后把所有子对象加进去,于是自己就自然而然变黑了。

gcDrain: drain heap

无限循环直到所有(可达)对象都扫过了。

循环里尝试拿到一个object,fast path, slow path最后尝试把用wbBufFlush把在write barrier里 mutator里堆积的对象给记录了下,如果都拿不到说明干完了就退出了。

这里注意,在返回所有mutator里的数据后,理论上仍然可能存在新的灰色对象在mutator里,这里的wbBufFlush并不保证保证当前mutator不会有遗漏的内容。后续所有worker都干完活决定gc mark done和gc mark termination会检查所有的mutator保证不会有遗漏对象。具体可以看mutator的实现TODO:在下面写即可。

TODO: wgBufFlush在systemstack, 我不知道这个是什么

拿到对象的实现是把当前object从队列里拿出来,这个动作就把object从灰色染成黑色了。

拿到object后就去扫描,注意在markroot里是以block为单位扫描的,而这里是以object为单位扫描。 大对象有额外的拆分扫描逻辑,大部分逻辑都是在找子对象,但这里反而可以忽略, 找到所有子指针然后再加进队列里去。

markDone -> mark termination

所有reachable对象都标记后,状态变化 gc mark -> mark termination

只有一个worker可以执行gcMarkDone, 然后初步检查下是不是可以结束gc mark了。 这里需要理解的是在什么情况下走到了gcMarkDone会出现不满足的状况,因为这往往对应着一个corner case和一个bug。

清理所有P的本地缓存,把里面潜在的灰色对象抓出来,然后跳到top再检查下,如果有东西就打回去继续干活。 这里就正好对应了前文的疑惑。

如果发现已经干净了,开始STW,因为这个时候该收尾了。STW完再检查下因为清理完所有的P和STW之间mutator可能收到了新的内容,没有了的话就结束了,而有的话就可能继续。所以说经典情况整个mark阶段会STW 2次,但如果运气差STW可能会有很多次。

这里gc mark的策略是短暂STW把写屏障打开,于是就做到了大部分mspan的mark不需要STW,最后通过第二次STW专门处理写屏障buffer里记录的对象。 这样大幅度提高了程序的可用性。

除此之外,mark termination还做了些收尾的工作,例如:

  • 更新stack size,给gc pacer用
  • 关闭gc assist,因为这里刚扫描完,后面该清理了,heap usage很快就会降下去,没有理由触发assist
  • gcWakeAllAssists:?
  • gcWakeAllStrongFromWeak:?

调用gcController.endCycle(now, int(gomaxprocs), work.userForced)告诉gc pacer这轮扫描的所有细节。

之后在gcMarkTermination更新一大堆状态,比如memstats,增加gc generation计数器,重启世界等等等 于是mark阶段就结束了。

gc会把堆的全局sweepgen计数器+2, 代表着下一轮sweep要开始了。 不过gc自己并不进行清扫,而是让bgWorker或者allocator自己去清理,这个+2操作就是让allocator和bgWorker可以同时并发进行, 一个CAS操作。这里的好处是lazy sweep, 按需清扫。

另外这里强调下mark和sweep的顺序,这里以mark N, sweep N, mark N+1, sweep N=1为例。

mark N总是先于sweep N,这很好理解,因为sweep完全依赖mark里的各种位图结果。 sweep N严格先于mark N+1,这个等待发生在N+1 gc cycle,通过在gcStart开头调用finishsweep_m()来完成,也就是说新一轮的gc cycle会等待前一轮的sweep完成。在大多数情况下,sweep很快而gc间隔很长,在极端情况下,由于mark会直接覆盖过去的状态,所以说必须要等待上一轮gc sweep的完成。

QA Section

Q1 STW 的边界与代价

“你提到 gcStart 开头有 STW(Sweep Term),结尾 gcMarkDone 也有 STW(Mark Term)。 既然 Go 说是并发 GC,为什么不能完全消除这两个 STW? 如果我的服务日志里出现了长达 50ms 的 STW,请根据你刚才的理解,分析在 Sweep Term 和 Mark Term 这两个阶段,分别可能是什么原因导致的?”

STW1 write barrier, STW2 flush mutator and finish mark => this ensures go can mark most of the spans without STW, only objects recorded inside mutators are necessary to sweep under STW.

if we want to elinminate the 2 STW, the only possible way is STW and sweep all, which is worse because it takes longer time and at that timespan application is total unavailable.

50ms STW: - mark start -> last sweep hasn't done, means the allocations surges a lot. - mark termination STW(flush mutator buff) -> frequent pointer/assign operations of that application. the app is write-heavy need to check QPS and allocation. GOMAXPROCS, OS noise(CPU throttling).

TODO: go tool trace

Q2 辅助标记 (Mutator Assist) 的触发

“我们在 gcStart 里看到了 atomic.Store(&gcBlackenEnabled, 1) 开启了辅助标记。 假设我的 Go 服务平时 CPU 占用率只有 10%,突然有一波流量进来,但我发现 CPU 瞬间飙升到 100%,而且接口延迟从 20ms 涨到了 200ms。我看 Trace 发现很多 goroutine 都在执行 gcAssistAlloc。 请问,GC 是如何判定一个 G 需要‘偿还债务’(去辅助标记)的?这个‘债务’是怎么算出来的?如果不开启这个机制,会有什么后果?”

Q3 写屏障 (Write Barrier) 的本质

“你提到了 wbBufFlush 用来处理写屏障缓冲区。 现在的 Go 使用的是混合写屏障(Hybrid Write Barrier)。 请问,为什么我们需要‘写屏障’?如果在 Mark 阶段我关闭写屏障(为了性能),具体会发生什么逻辑错误?请举一个具体的指针赋值的例子,演示一下对象是如何‘丢失’的。”

写屏障的核心目标是完成大部分对象的扫描不需要STW,如果关闭了为了性能关闭写屏障会导致在并发mark阶段无法识别从扫描过程中的新修改。 有2个影响,按照严重程度:1) 需要的对象被释放了,严重的程序runtime崩溃,轻一点的ub 2)需要释放的没有释放,内存泄露了,不过会被下一轮gc发现

Q4 扫描密度与性能 (Scan Object)

“你刚才分析了 gcDrain 和 scanobject。 现在有两个服务:

服务 A:缓存了 10GB 数据,全是 map[int]*User,User 结构体里有很多指针。

服务 B:缓存了 10GB 数据,全是 map[int][]byte(比如图片数据),或者是大数组。 在堆大小相同的情况下,哪个服务的 GC Mark 阶段会更长?为什么?这对我们写高性能 Go 代码有什么指导意义?”

Q5 终止检测的极限 (The Ragged Barrier)

“你详细描述了 gcMarkDone 的逻辑:先 Flush 所有 P,如果发现有剩余工作就 goto top。 假设有一个极端场景:P1 刚 Flush 完空了,P2 还在运行并且通过写屏障产生了一个新对象放入了 buffer。此时 P1 会认为 GC 结束了吗?Go 到底是如何保证在‘分布式’环境下,所有人都达成‘没活干了’的一致共识的?”

GC Mutator

在mark阶段已经接触到了mutator的相关处理,在第一次STW后写屏障打开,然后第二次STW flush mutator buf然后去mark这些对象。 注意,读屏障会极大拖累性能,通过写屏障避免了读屏障的需求:todo 如何避免的我要如何理解。

注意,mutator说的其实是用户代码,比如assignment之类的操作。 那么本节内容关注runtime如何追踪mutator的实现,重点关注内存屏障是如何工作的,以及flush mutator是如何完成的。由于flush之后mark部分的代码是一致的所以我们不在讨论如何mark。

src/runtime/mbarrier.go里面。

核心两种技术

  • Yuasa 删除屏障(Deletion Barrier)
  • Dijkstra 插入屏障(Insertion Barrier

TODO: 具体的算法之后再看,对我的项目来说影响不大。

go tool nm myapp | grep gcWriteBarrier
go tool objdump -s "runtime\.gcWriteBarrier" myapp

核心是在gc打开write barrier之后,用户的mutator会执行编译时插入的汇编代码, 编译器可能会有多个write barrier的函数,是优化产物。

go tool nm app  | grep gcWriteBarrier
  4943c0 t gcWriteBarrier
 6a44b40 d github.com/bytedance/sonic/internal/decoder/jitdec._F_gcWriteBarrier2
 6a43e00 d github.com/bytedance/sonic/internal/encoder/x86._F_gcWriteBarrier2
 6a3b918 d github.com/twitchyliquid64/golang-asm/obj/wasm.gcWriteBarrier
  496c80 t runtime.gcWriteBarrier1
  496ca0 t runtime.gcWriteBarrier2
  496cc0 t runtime.gcWriteBarrier3
  496ce0 t runtime.gcWriteBarrier4
  496d00 t runtime.gcWriteBarrier5
  496d20 t runtime.gcWriteBarrier6
  496d40 t runtime.gcWriteBarrier7
  496d60 t runtime.gcWriteBarrier8

gcWriteBarrier本身是汇编写的,比如go1.23.12/src/runtime/asm_amd64.s

做了寄存器保护,然后把新旧指针放到当前P的wbBuf里面,还有一些额外检查。

TEXT gcWriteBarrier<>(SB),NOSPLIT,$112

在gc没有打开write barrier时候,成本很低,真正逻辑里寄存器的保存和恢复成本较高(20-50ns)。 扫描对象由于是异步由mark部分完成,所以不被认为是写屏障的成本。

最慢的是当gc开启,wbBuf满了的时候,gcWriteBarrier必须把这些指针flush到gc worker queue里面去。 (200-1000ns). L1缓存污染,这些周期可以被执行其他运算etc。TODO:理解这个成本,我现在理解的不好。

这里是一个权衡,因为这个操作避免了更昂贵的STW操作。

  • TODO: mutator assist, 在malloc时候强制执行,不是gc write barrier的事情。

  • TODO: 这里也会被高频问到,主要回答write barrier本身逻辑以及和stw相比的设置。

TODO: 采用Zettelkasten + MECE重新整理思路 Zettelkasten 强迫做“重构”: 它要求每一张卡片只讲一个独立的知识点。MECE 的核心贡献:维度化,从哪些维度去切分这个问题,确保不重不漏。工程化的MECE模版:

What (定义/定位): 它是什么?解决了什么核心痛点?(如:解决 STW rescan 问题); How (核心机制): 关键数据结构是什么?关键算法流程是什么?(如:wbBuf, Yuasa+Dijkstra); Cost/Trade-off (代价/权衡): 引入它带来了什么损耗?(如:写操作变慢,缓存污染); Edge Case (边界/异常): 极端情况下会怎样?(如:Buf 满了 flush,或者内存分配过快触发 Assist)

Allocation and GC

分配是需要GC的原因,而且分配最直到当前的分配状况,所以理所应当的分配和GC紧密相连。这里重点关注四个要点:

触发 GC

alloc在分配时候会检查是否需要触发新的gc周期,核心代码非常简单

    if shouldhelpgc {
        if t := (gcTrigger{kind: gcTriggerHeap}); t.test() {
            gcStart(t)
        }
    }

shouldhelpgc 这个flag和分配活动高度相关,如果本地缓存命中(tiny 对象命中/小对象命中缓存/大对象不需要refill),小对象只有从mcentral重新填充的时候才检查,而大对象每次都检查。这里的检查说的是“分配的比较大所以要检查下超没超过“,需要检查之后还需要再额外检查是不是超过了trigger point才会开始。

TODO:要看下分配逻辑这部分还挺重要

然后调用t.test()去检查当前的heap usage是否到了gc的触发条件,如果达到了就开始gc。

Mark Assist

前文提到了gc pacer控制了assistRatio算法,用来确保gc的mark和allocs速度保持一致。 alloc这里负责对gc pacer数据的更新(比如heap usage,分配速度等)。除此之外,alloc也从gc pacer中拿到assistRatio等数据来根据情况触发assist mark。 这里需要提到gc pacer会至少分配25%的cpu utilization给bgGcWorker做扫描使用,但提前结束就不会用那么多CPU。

assistG := deductAssistCredit(size)

在分配内存时候,直接回把当前分配size的大小从信用分里减掉了, 如果这个时候发现G欠了一笔债(负数),就需要根据assistRatio为基准强制协助gcAssistAlloc.

assist发生在真正allocation之前,assist完毕之前不会有新的allocation发生,也就意味着程序的heap usage并不会上涨(Strict Backpressure)。

assist的额度是从gc controller里拿到的然后乘上分配的字节数目,这里有个摊平操作,确保小的分配也会清扫一定的量,然后积累一定的信用,下一轮不用真正的去扫描。 Mutator Assist 只有在后台 GC 算力(那 25%)不足以支撑当前分配速率时才会发生。如果后台有盈余,Mutator 会优先进行 Credit Stealing,从而避免长尾延迟。”

至于真正的gc assist,就还是调用gcDrain类似的方法去mark,具体思路和前文是一样的。

publication barrier

(并发安全)

内存分配通过publicationBarrier确保分配x在heap上称为type-safe 内存时不被gc发现。

这为了解决一个问题,如果cpu重拍指令,在内存分配没有初始化的情况下gc就去读垃圾数据,从而导致内存corruption 这里注意的是新分配的内存全是黑的,并不需要担心gc把新分配但没引用的对象给释放了。 屏障的作用就是确保内存分配和指针相关操作完全完全完成后才暴露给gc。

大对象的delayedZeroing

当分配的对象比较大时,除了shouldhelpgc会被设置为true,delayedZeroing同样会被设置为true。 在delayedZeroing被设置后,他会分块(256KB)来清理,清理每个块的时候禁止抢占, 但每个块清理之间都可以抢占。256KB来自go的benchmark,他们在一些数据中选取了这个。

这里需要解释下内存清理需要禁止抢占的原因,因为memclr是汇编写的,内部不含任何调度器的代码,于是只要启动调度器没有任何办法只能等它执行完毕才可以在scheduler级别上做抢占。属于无能为力,而前文malloc是逻辑上的必须抢占,每个P都有自己的缓存,保证只有当前的G才可以访问P上的内存,而允许抢占意味着可能有多个G访问同一个P的内存。

分块清理解决了大malloc导致长尾 STW。如果一次清理一大块内存(2GB), 在这个过程中拒绝抢占,一个P卡住了,其他的P全都需要等待这个P结束,因为STW要求所有P都停下来,结果导致了超长的STW。

本质上这是把长耗时操作移除临界区的操作。

TODO Scavenging

Formula:

  • without GOMEMLIMIT: (retainExtraPercent+100) / 100 * (heapGoal / lastHeapGoal) * lastHeapInUse
  • with GOMEMLIMIT: (100-reduceExtraPercent) / 100 * memoryLimit

reduceExtraPercent = 10;

对于没有memlimit的来说,scavenge是宽松的比heap usage更大,而GOMEMLIMIT有着更激进的回收要求。

另外一个需要理解的地方就是上面的公式是按照已有的fragmentation来估算未来的内存使用, 这里不使用heapinuse的具体值是因为和os交互的基本单位是page,用heapinuse很容易低估导致过度归还rss,导致频繁向os申请内存使用。 请注意,这里的fragmentation说的不是一个page里面有零星的span,剩余的空间叫做fragmentation. 而是内存管理里面一个个的空闲页。举个例子,空闲链表里面可能会有空闲的连续½/3个page。

每轮gc cycle会更新 scavenger需要维护的目标水位。

scavenger有两种,异步的就像sweep一样,而主动的scavenger出现在alloc部分, 如果分配速度过快,要超过GOMEMLIMIT了, go runtime会尝试还给OS一些脏页,这样新的需求就可以分配到一个新页了,不然马上就OOM了。 如果没有GOMEMLIMIT,当分配过快的时候scavenger会强制回收一部分页再去分配, 具体逻辑发生在mheap.go种的allocSpan函数,举个例子,现在有一堆小的页面(连续½/3个),现在需要分配4个连续的page,这个时候scavenger会同步先还回去几个小的,然后再分配一个大的。内存分配强调空间的连续。

需要强调的是,由于GOGC是不移动的,所以fragmentation是不可避免的。

在go1.23中,runtime采用MADV_DONTNEED来让os回收内存,统一的入口在func sysUnusedOS(v unsafe.Pointer, n uintptr),

特点是让os立刻回收这部分内存,由于go本身scavenge的机制,在这个级别上他就尽可能保证了内存页是够用的。

TODO: STW to scan stack

  • stack扩容操作
  • stack scan的特殊性

当栈扩容(2KB -> 4KB)时,Go 会创建一个新栈,把旧栈数据拷贝过去。栈上的变量地址变了!所以栈上的指针(指向堆的,或者指向栈内其他变量的)必须被 Runtime 修正。这解释了为什么 Stack Scan 必须 STW(或者使用极其复杂的栈屏障)。