本文源自实际项目实践。服务端基于 Go 1.21、监控基于 Prometheus client_golang v1.9.0,讨论重负载游戏服务器的 GC 停顿、Mark Assist、监控口径与优化,并与 .NET 10 做横向比较。

TL;DR

  1. Go 1.21 的 GC 大部分工作与业务并发执行,但 GC 阶段切换时仍会短暂暂停所有 goroutine(即 STW,stop-the-world)
  2. 对重负载游戏服务器,Mark Assist 最值得警惕:它不是 STW,却会把 GC 扫描工作直接摊派到正在分配内存的业务 goroutine 上。谁在并发标记期分配得越猛,谁越容易“欠债”,还债方式就是被迫替 GC 扫描对象,由此带来不可预测的延迟。这是本文重点(第三到七部分,六、七为实操)。
  3. go_gc_duration_seconds 既不是整轮 GC 耗时,也不是平均耗时;在本项目里它是最近最多 256 轮 GC 中、单轮所有 STW pause 之和的最大值。Mark Assist 不计入该指标,所以它常常只是冰山一角——曲线平稳,tick p99/p99.9 却可能已恶化。
  4. 对短命对象极多的高分配负载,.NET 10 的分代 Server GC 通常比 Go 1.21(Go 坚持不做分代 GC)更有分配吞吐优势;但最坏 tick 延迟都不能仅凭 GC 算法判断。
  5. 对象的指针密度、内存局部性、数据结构形状和缓存命中率都会显著影响 GC 成本。

一、Go GC 到底会不会阻塞进程

两个背景概念,方便不熟悉 Go 的读者:goroutine 是 Go 的轻量级协程,业务都跑在它上;STW(stop-the-world) 指运行时暂停全部 goroutine、只让 GC 推进的时刻。

Go 的 GC 是并发、非分代、非移动的 mark-sweep。一轮可简化为:

短暂 STW → 并发标记 → 短暂 STW → 并发清扫
          业务 goroutine 可能参与 Mark Assist

STW 期间所有 goroutine 停止执行。官方强调“大部分标记/扫描与应用并发,不会把整堆扫描塞进一次全局 STW”。但“STW 很短”不等于“GC 不影响延迟”——官方列出的延迟来源还包括:阶段切换的短暂 STW、并发标记占用 CPU、高分配触发的 Mark Assist、写屏障增加指针写入成本、goroutine 因根扫描被挂起。在 CPU 高利用率、容器 throttling、宿主机超售或 GOMAXPROCS 不匹配时,GC 与业务对 CPU 的竞争会进一步放大尾延迟。

参考:A Guide to the Go Garbage Collector

二、“GC 耗时 20ms” 的真正统计口径

本项目框架用的是自定义 collector(golang.xxx.com/fx/pkg/prometheus,底层 client_golang v1.9.0),它创建 5 个 PauseQuantiles 槽位并调用 debug.ReadGCStats

stats.PauseQuantiles = make([]time.Duration, 5)
debug.ReadGCStats(&stats)

Go 1.21 会把这 5 个位置填为「最小值、P25、P50、P75、最大值」。因此 go_gc_duration_seconds{quantile="1"}最近最多 256 轮 GC pause 记录中的最大值。这里有三个容易踩的坑:

  • 一条记录不是一段连续停顿。 runtime.MemStats.PauseNs 是长度 256 的环形缓冲,每项对应一轮 GC,但一轮可能含多次 STW,记录的是它们之和。所以 quantile="1"=0.020 只表示“最差一轮 GC 的所有 STW pause 合计约 20ms”,不代表连续卡了 20ms,更不是平均值。
  • 窗口按轮数而非时间。 窗口 = 最近最多 256 轮 GC:每秒 10 次 GC 约覆盖 25.6 秒,每分钟 1 次则可能覆盖约 4.3 小时。同一指标在不同负载/实例上代表的时间跨度完全不同,因此不适合直接当 SLO。
  • Grafana 的 Mean 不是平均 pause。 若面板画的是 quantile="1",右下角 Mean 只是“滚动最大值序列”在时间范围内的均值;若再跨实例 avg(),就更没有统计意义(Summary quantile 不能跨实例平均)。想看最差实例用 max,想看平均 pause 用 _sum/_count

几个更可靠的 PromQL:

# 平均每轮 GC 的 STW 时间
sum(rate(go_gc_duration_seconds_sum[5m])) / sum(rate(go_gc_duration_seconds_count[5m]))
# 单实例处于 GC STW 的墙钟时间比例
100 * rate(go_gc_duration_seconds_sum[5m])
# GC 频率
rate(go_gc_duration_seconds_count[5m])

参考:debug/garbage.gomstats.go

三、Mark Assist:分配器向业务收取的“GC 税”

一句话概括:并发标记期间,如果业务分配可能快到让 GC 在堆触顶前标记不完,Go 就让正在分配的业务 goroutine 帮忙扫描对象。 它本质是一套“分配限速 + 工作量记账 + 反馈控制”。

记账规则。 pacer 要求“业务每分配一部分内存,就要完成相应比例的扫描”,比例动态计算:

assistWorkPerByte := float64(scanWorkRemaining) / float64(heapRemaining)

例如剩余扫描 800 MiB、剩余可分配空间 400 MiB,则 ratio = 2:一个 goroutine 分配 64 KiB,就应贡献约 128 KiB 的扫描工作。注意“扫描工作”是 runtime 内部记账单位(近似可扫描字节),不等价于对象大小或固定 CPU 时间——指针密度、内存局部性、数据结构形状和缓存命中率都会改变真实成本

每个 goroutine 有自己的账户 gcAssistBytes>0 有 credit 可继续分配,<0 则欠债必须偿还。并发标记期间,mallocgc 在真正分配前先 deductAssistCredit(size),因此 assist 的耗时直接落在发起分配的业务调用栈上。偿债顺序是:先花后台 GC 攒下的全局 credit(bgScanCredit)→ 不够就自己扫描灰色对象 → 仍取不到工作可扫且标记未结束,就 park 到 assist 队列,等后台 GC 还债后被唤醒

所以“处于并发标记阶段”不等于“每次分配都 assist”——只有分配速度持续压过 GC、全局 credit 被耗尽时才会亲自下场。三个反直觉的延迟来源:

  • 扫描的不一定是自己的对象。 一个网络 goroutine 因临时 []byte 欠债,可能被迫去扫描毫不相关的玩家对象、房间 entity map、AI 状态树甚至别的 goroutine 栈。一次普通分配可能突然夹带一段不可预测的全局对象图扫描,这正是它对延迟敏感路径最危险的地方。
  • park 比直接扫描更伤 p99.9。 挂起路径的墙钟延迟是 park → 等后台 GC → ready → 调度排队 → 重新拿到 P,比自己扫一遍还长。
  • Over-assist 制造局部尖峰。 runtime 会按最小批次多扫一点、留下正 credit,于是常见“这次分配慢、随后几次很快”的锯齿——利好吞吐,却是微秒到毫秒级的局部尖峰。

参考:mgcpacer.gomalloc.gomgcmark.go

四、虽然不是 STW,却可能让服务“卡住”

Mark Assist 只暂停“正在还债”的那个 goroutine,其余 goroutine 和后台 worker 照常运行,所以它不是全局 STW,也不计入 go_gc_duration_seconds。但在极端高分配率下,大量业务 goroutine 会同时执行 assist、park 等待 credit、与后台 worker 抢 CPU、唤醒后再抢调度——服务因此近似“卡住”,而 pause 指标毫无尖峰。

游戏服的典型路径:Match Tick → 构造临时事件 → slice 扩容 / map 插入 → protobuf/JSON 编码 → interface/closure 逃逸 → runtime.gcAssistAlloc → 本帧超时。这就解释了那个常见现象:go_gc_duration_seconds 很低,match tick p99/p99.9 却明显恶化

五、什么情况最容易触发大量 Mark Assist

  • 瞬时分配率过高:开局/结算、批量刷怪、全量状态同步、大量消息同时解码、某玩法突然创建大量临时容器。
  • 存活对象图大或指针密集:大量 map[]*T、指针链表/树、多层 interface、玩家/房间/AI 交叉引用、大量 goroutine 栈——都抬高扫描成本。
  • 距 heap goal 空间过小scanWorkRemaining 大而 heapRemaining 小时,assistWorkPerByte 飙升,单次分配被收的税更重。
  • GOGC 太低 / GOMEMLIMIT 太紧:前者减少 heap runway、抬高 GC 频率;后者可能把 OOM 风险换成持续高 GC CPU 与尾延迟。
  • CPU 饱和或容器 throttling:后台 worker 拿不到 CPU 就会落后,assist 随之增多。
  • GOMAXPROCS 与实际 quota 不匹配:按宿主机核数设置但容器 quota 很小时,pacer、worker、调度器全部偏离预期。
  • 大量无指针(noscan)对象[]byte、字符串 buffer 内容不用扫,但仍消耗 heap runway,同样会被收 assist debt。

六、如何确认线上存在 Mark Assist

Execution Trace(最适合查尾延迟)。 在开了 pprof 的 canary/压测实例采样:

curl -o trace.out "http://<ip>:<port>/debug/pprof/trace?seconds=10"
go tool trace trace.out

重点看 GC mark assist / GC assist wait 落在哪些 goroutine、是否命中 match tick / 消息处理 / 序列化路径、前后是否伴随大量分配、CPU 是否饱和。

CPU Profile(看总体成本)。 go tool pprof ".../profile?seconds=30",关注 runtime.mallocgcgcAssistAlloc(1)gcDrainNscanobject。官方经验:mallocgc 累计 CPU 超过约 15% 说明分配压力大,gcAssistAlloc 超过约 5% 说明分配正明显压过 GC。profile 看不到 park 后的等待,须与 trace 配合。

Runtime Metrics(长期监控)。 采集 /cpu/classes/gc/mark/{assist,dedicated,idle}:cpu-seconds/cpu/classes/gc/total/cpu/classes/total/gc/heap/{allocs,live,goal}/gc/cycles/total,即可算出 assist 占 Go 可用 CPU、占 GC CPU 的比例(这些是 runtime 估算值,只宜同类相比,别和 OS CPU 秒混用)。本项目自定义 collector 未导出它们,建议升级 collector 或补一个基于 runtime/metrics 的。

临时诊断。 GODEBUG=gctrace=1,gcpacertrace=1 会把 mark/scan CPU 拆成 assist/background/idle 并打印 pacer 状态与 assist ratio;格式是内部接口、随版本变,只适合短期排查。

Allocation Profile(找分配源)。 go tool pprof -sample_index=alloc_space ".../allocs"——降低 GC 成本要看 alloc_space 而非 inuse_space,因为 assist 主要受分配速率而非当前存活量驱动。

参考:Go DiagnosticsRuntime Metrics 描述Runtime 环境变量

七、降低 Mark Assist 的工程方法

  • 先砍 tick 热路径分配:slice 反复扩容、临时 map、fmt.Sprintf/字符串拼接、[]bytestring 转换、interface 装箱、closure 逃逸、protobuf/JSON 临时对象、每 tick 新建 timer/channel。
  • 预分配与复用make([]T, 0, expected)、为房间/match 留 scratch buffer、复用序列化 buffer、固定容量 ring buffer。sync.Pool 可用但要小心——它不是永久池,GC 会清,用错还会造成内存滞留和跨请求污染。
  • 减少对象图中的指针:把 []*Entitymap[uint64]*Player 换成 []Entity + []uint32(索引)这种连续、索引化布局,既降分配又提升 GC 扫描和业务访问的局部性。
  • 有内存余量时适度调高 GOGC:增大 heap runway、降低 GC 频率与 assist 压力,代价是峰值堆更高。别只看 pause,要同时比较 RSS、heap live/goal、分配率、assist CPU、tick p99/max、OOM 风险。
  • 合理设置 GOMEMLIMIT:别直接等同容器上限,要给栈、runtime metadata、mmap、cgo 内存、日志/网络 buffer、profiler 峰值留余量。
  • 核对 CPU quota 与 GOMAXPROCS:确认真实 quota、throttling、run queue、NUMA/亲和性,别让 runtime 以为自己有远超 quota 的 CPU。
  • 把分配搬离 tick goroutine 要谨慎:交给后台 goroutine 能把 assist debt 移出关键路径,但不减少总 GC 工作,还可能引入 channel 排队;仅在所有权、生命周期、执行顺序都允许时才用。

八、Go 1.21 与 .NET 10 Server GC 的对比

维度Go 1.21.NET 10 Server GC
GC 结构非分代、并发、非移动 mark-sweep分代、压缩,后台并发 Gen2,Server GC 并行回收
短命对象高频分配每轮仍受整个存活指针图影响Gen0/Gen1 专门处理短命对象,通常更有优势
大量长期存活世界状态存活指针图越大,扫描成本越高老对象进入 Gen2,不必每次 Gen0 都扫描全部老对象
全局停顿GC 阶段转换时短暂 STWGen0/Gen1 前台 GC 会 STW;后台 Gen2 大部分并发
分配限速Mark Assist 将扫描成本压给分配 goroutine分代 allocation budget,预算耗尽时触发前台回收或分配等待
内存整理非移动,避免搬迁,但可能有碎片和更高 RSS压缩移动,通常内存密度和局部性更好
尾延迟风险STW、Mark Assist、assist park、GC CPU 竞争高频 Gen0/1、Gen2 完整回收、LOH/POH、固定对象和碎片

.NET 后台 GC 只作用于 Gen2,Gen0/Gen1 前台回收仍会暂停所有托管线程;Server GC 侧重吞吐与多核扩展,更适合独占机器。

哪一方更快? 若业务是“高并发 + 高分配 + 大量短命对象”,.NET 10 的分代结构通常在分配吞吐和 GC CPU 效率上占优。但若通过结构化布局、预分配、对象复用和低分配协议把热路径做到接近零分配,两边差距会显著缩小,最终更取决于调度模型、网络栈、序列化、锁竞争、数据布局、JIT/AOT 及分片架构——别凭一张 quantile="1" 图就决定换语言

DATAS。 .NET 10 默认启用并改进了 DATAS(Dynamic Adaptation To Application Sizes),动态调整 heap 数、Gen0 budget 与内存/吞吐权衡,并在高分配场景减少无谓回收、平滑 pause、修正碎片计算。独占机追求吞吐时,值得分别压测「开 DATAS」与「关/调 DATAS」。

参考:.NET GC FundamentalsBackground GCGC ConfigurationPerformance Improvements in .NET 10

九、压测该测什么

Go 1.21 已略过时,不能代表当前 Go GC。Go 1.26 默认启用 Green Tea GC:通过批量扫描小对象改善局部性与多核扩展,官方预计 GC 密集型程序可降约 10%~40% GC overhead,新 amd64(Intel Ice Lake / AMD Zen 4 起)借向量指令再降约 10%——恰好作用在本文关心的标记/扫描阶段。因此至少压测三组:Go 1.21、Go 1.26、.NET 10(Server + Background GC + DATAS)。

变量必须统一:机器/CPU quota/内存/NUMA、房间/玩家/AI/消息速率/世界状态规模、预热时间、分片模型、序列化协议、日志等级、监控与 profiler 开销。核心指标:业务吞吐、Match Tick p50/p95/p99/p99.9/max、Missed Tick 比例、端到端消息延迟、CPU 与 throttling、RSS/Heap Live/Heap Goal、分配率(B/s、objects/s)、GC 频率、STW pause 分布、Mark Assist CPU 与 Assist Wait、GC CPU 占比。别只比某一个运行时自报的 GC 指标(Go 与 .NET 口径不一致),最终标准是业务 SLO、资源成本与稳定性。

总结

Go GC 的低 STW 设计解决的是“别为扫整堆而长时间全局停顿”,但没让成本消失——它被拆到了短暂 STW、后台 GC worker、写屏障、根扫描、分配路径上的 Mark Assist,以及 assist wait 与调度延迟里。对重负载游戏服,Mark Assist 尤其值得重视:它不出现在 go_gc_duration_seconds 曲线里,却可能直接落在 match tick 关键路径上。

Mark Assist 是 Go GC 对分配器施加的动态“GC 税”:谁在并发标记期大量申请堆内存,谁就得扫描一部分全局对象图;暂时还不上就被 park,直到后台 GC 替它还债。

所以真正有效的优化不是一味压低 STW 数字,而是同时控制分配率、存活指针图、CPU 余量、heap runway,以及关键路径上的 GC 工作分布。

更多相关资料