news 2026/10/26 10:24:46

07-05-源码-.NET Core GC后台GC实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
07-05-源码-.NET Core GC后台GC实现

.NET Core GC 后台 GC 实现

篇章:07-源码深度-.NET Core GC |阅读时间:约 40 分钟


一、引言

后台 GC(Background GC,以下简称 BGC)是 .NET Core GC 并发能力的核心:GC 线程在后台与应用线程同时运行,把 STW 暂停从毫秒级压到微秒级,几乎是服务器应用的标准配置。对延迟敏感的 Web API、微服务、桌面交互应用来说,BGC 是压住 P99 尾延迟的头号手段;对吞吐优先的批处理任务,它反而可能成为应该关掉的开销。

但"后台 GC 把暂停降到 1ms 以下"这句话本身是不完整的。它没有回答:标记到一半时应用又在改引用怎么办?并发标记期间 Gen0 预算用完了怎么办?后台清扫和用户分配同时抢一段内存怎么办?分配速率高到后台标记追不上,运行时又会做什么?这些问题的答案构成了一台状态机,理解这台状态机,才算真正理解后台 GC。

本文按"动机、生命周期、交互、清扫、触发与回退、模式差异、源码走读、配置、代价、监控、误区、对比"的顺序展开。读完你应该能回答三个问题:BGC 的每一次暂停分别在等什么,前台 GC 什么时候会插进来,后台标记追不上分配时系统会退化成什么样。

二、为什么 Gen2 需要一条后台路径

2.1 阻塞式 Gen2 回收的账

最朴素的回收流程是全程 Stop-The-World:挂起所有托管线程,标记、清扫、压缩一次做完,再恢复。Gen0 和 Gen1 用这套流程没问题,因为它们要处理的存活对象少、区间小,暂停本来就在亚毫秒到几毫秒量级。Gen2 不一样:它面对的是整个老年代加 LOH,单次阻塞式 Gen2 回收的典型范围是 5 到 50ms(典型估算,视堆规模、存活率与硬件而定,不是承诺值)。对 60FPS 的客户端这是几帧的冻结,对 Web 服务这是一批请求的尾延迟一起抖动。

问题的本质是暂停时长与什么成正比。阻塞式回收的标记阶段要遍历整个存活集,暂停时间因此近似与堆大小成正比,堆只涨不缩的服务停顿只会越来越长。而一次回收真正"不得不暂停"的部分只有根扫描:线程栈、静态字段、寄存器里的引用,应用线程随时在改,GC 必须独占一致视图才能扫。至于从根出发遍历对象图,应用线程在旁边跑是可行的——只要把它们改过的引用记录下来,最后补扫一遍。

后台 GC 就是按这笔账设计的:把"与堆大小成正比"的那部分挪到后台线程与应用并发执行,只把"与根集合规模成正比"的部分留在 STW 里。堆涨十倍,根集合涨不了多少,所以单次停顿基本不动——这是 BGC 最值钱的性质,也是"暂停降到微秒到亚毫秒级"这句话成立的前提条件。

2.2 并发不是免费的

BGC 用三样东西换低延迟:写屏障与卡表在并发期间持续记录引用变更的 CPU 开销;标记栈、脏卡缓冲等额外内存;以及并发协调本身的复杂度——后台线程与前台线程要在正确的时刻互相等待,任何一处同步出错都是堆损坏。常见的量化口径是:总 GC 时间增加约 30%,写屏障的 CPU 开销约 5% 到 10%(两组均为典型估算量级,不是实测常数,实际取决于分配与引用写入的密度)。这笔账和 Unity 增量 GC 的账是同一笔:用吞吐换延迟。

还有个隐性成本:并发标记期间堆会继续膨胀,瞬时占用高于稳态,配额紧张的容器里这段膨胀有时会顶到限额,选型时要算进去(详见第十节)。

2.3 常见误解先纠正

第一,后台 GC 不是让 GC 整体变快,它让暂停变短、让总耗时变长。第二,后台 GC 只负责 Gen2;Gen0/Gen1 从来就是短暂停的前台回收,BGC 开不开它们都在。第三,后台 GC 不是"完全无暂停",它有两次(有时是三次)短暂 STW 切片,只是这些切片的长度跟根集合走、跟堆大小无关。第四,BGC 期间照样可能退化成阻塞式 Gen2 回收,而且那次的暂停比平常更长——这是第六节的主题。

三、后台 GC 的完整生命周期与状态机

3.1 四段式结构

一轮后台 GC 的骨架是"初始标记(短 STW)→ 并发标记 → 最终标记(短 STW)→ 并发清扫"。它和前台阻塞式回收的差别只在执行方式:阻塞式把四步串在一个暂停里做完,后台模式把中间两步放到与应用并发的时间窗里。

一轮后台 GC 的时序(简化示意,暂停时长为典型量级,非实测常数) 时间轴 ────────────────────────────────────────────────────────────► 应用线程 [运行] ▓停▓ [运行:并发分配] ▓停▓ [运行:并发分配] ▓停▓ [运行] … │ │ │ 初始标记 最终标记 清扫收尾 根 + 脏卡快照 补扫并发期的脏卡 短暂同步点 (与堆大小无关) (与堆大小无关) 后台GC线程 ├────── 并发标记 gen2 ──────┤────── 并发清扫 ──────┤ 完成 (BGC thread) │ 遍历对象图,写屏障/卡表 │ 标记为死的对象与段 │ │ 记录应用改过的引用 │ 一边归还一边供分配复用 │ 前台GC (并发标记期间任意时刻) (foreground) Gen0 / Gen1 照常触发:短 STW → 压缩短暂段 → 立即还线程 └ 两者互不替代,可理解为两条并行的流水线 失败分支(第六节) 并发标记追不上分配 ──► 中止 BGC ──► 退化为阻塞式 Gen2 回收 (比平常更长的一次 STW)

把这张图读成状态机,BGC 的主干状态就是:空闲 → 初始标记(暂停)→ 并发标记 → 最终标记(暂停)→ 并发清扫 → 收尾(暂停)→ 空闲,外加一条从"并发标记"指向"阻塞式 Gen2"的失败转移。运行时源码里后台标记与后台清扫各自对应独立的阶段函数(如background_mark_phase/background_sweep_phase这一族命名,具体签名随运行时版本演进,此处按公开设计意图做示意标注,非逐字引用),状态切换由 GC 自己的同步原语控制,而不是简单的函数调用顺序。

3.2 三次暂停分别在等什么

暂停点触发位置独占视图要保护什么时长量级(典型估算)
初始标记BGC 启动瞬间扫描线程栈与静态根,建立标记起点;给卡表拍起始快照亚毫秒,随根集合与核数变化
最终标记并发标记接近完成消化并发期间积累的脏卡与引用变更,把标记收口闭合亚毫秒到毫秒级
清扫收尾并发清扫结束后台线程与前台分配线程对段状态做最后同步极短

关键在于三次暂停的长度都只跟根集合、脏卡数量、线程数相关,不跟堆大小相关。所以 4GB 的堆和 400MB 的堆,BGC 的单次停顿差别不大——这正是阻塞式回收做不到的。脏卡数量理论上可以随堆增大而增多,但实践里它取决于"并发期间被改写的老年代对象数量",而不是堆总量,所以增长是温和的、有界的。

3.3 暂停与恢复是怎么发生的

GC 挂起托管线程用的是协作式挂起:运行时在安全点(safepoint)检查挂起请求,线程到达安全点后主动交出控制权,迟迟不到的线程由 GC 线程"劫持"(hijack)其托管帧强制停下,恢复时反向操作。这意味着两件事:暂停时长包含"等最慢线程到达安全点"的成分,线程长时间执行非托管代码或长循环里没有安全点都会拉长 STW;暂停的进出又是全局同步操作,核多的机器上每多一次暂停就多付一次同步开销,这也是 BGC 不把暂停切得更碎的原因。

实用推论:BGC 停顿异常偏长时,先别怀疑标记算法,先查有没有线程卡在非托管调用里不出来。这类问题在 Profiler 里表现为"GC 开始时间与线程恢复时间之间有一段空隙",而不是标记本身很慢。

四、后台标记与前台 gen0/gen1 回收的交互

4.1 两条流水线并行不悖

后台标记只盯 gen2。Gen0 和 Gen1 的分配照常进行,gen0 预算用完时,运行时插入一次前台的 Gen0 或 Gen1 回收:短暂 STW,把短暂段里活下来的对象压缩到一端,然后立刻把线程还给应用和后台标记。两条流水线互不替代——这就是"后台 GC 期间 gen0/gen1 短暂停回收照常发生"的准确含义。

对应用来说,BGC 期间观察到的暂停分布是"一串很短的 gen0/gen1 回收 + 两次极短的 BGC 切片",而不是"完全没有暂停",评估效果要看这个混合分布的尾部。两者在资源上也会互相牵制:都要扫根、都要动卡表,短暂段的压缩还会改变 gen1 晋升的落点,所以运行时在阶段切换处有明确的先后顺序(按公开设计意图做示意推断)。

4.2 并发期间的分配去哪了

并发标记跑着,应用还在分配 gen0/gen1 对象,从 gen1 晋升进 gen2 的对象会落在哪?运行时给后台标记期间预留了专门的 gen2 分配窗口(gen2 allocation context 一类的机制):晋升对象先从这个预留窗口里取地,而不是去抢后台标记正在处理的区域。这样并发标记看到的 gen2 布局不会被实时分配打乱,标记进度也不会被频繁重置。

预留窗口不是无限的,它是一块固定量级的缓冲区,分配速率高、标记进度慢时会被耗尽——耗尽那一刻就是 BGC 的失败转移点,详见第六节。可以把它理解成信用额度:标记每推进一段,额度就续上一些,标记停滞则只出不进,归零即违约。它也解释了"后台标记期间堆暂时变高"属于正常现象:晋升的活对象先堆在窗口里,等标记完成后再由后续回收统一整理。

4.3 引用变更如何不被漏标

并发标记期间应用改引用,是所有并发 GC 的核心难题。语义上 BGC 接近 SATB(Snapshot At The Beginning,原始快照式):并发开始时的存活状态被当作快照,期间改写出来的引用都要在结束前被看到。但 .NET 并不照搬 G1 那套独立 SATB 队列,它的记录载体是卡表加写屏障:引用写进老年代对象时屏障顺手标脏对应卡,后台标记遇到脏卡就重扫,最终标记那次 STW 把剩余脏卡一次消化干净。部分版本的屏障还会在标记期对新写入引用做更积极的补录,具体策略随版本演进,以上按公开设计意图做示意推断。

这套机制的成本落点很明确:引用写入密集的老年代改写会推高脏卡率,脏卡率高则最终标记那段 STW 变长,同时后台标记要反复回扫同一片区域,进度被拖慢。这直接连到第六节的失败回退——写屏障记录得再全,标记追不上分配一样要出事。反过来,写屏障本身也有收益:它在前台 gen0/gen1 回收里承担老到新的记录职责,同一套屏障两处复用,这是分代 GC 摊薄写屏障成本的标准做法。

五、并发清扫:与用户分配共处

5.1 清扫为什么也能并发

阻塞式回收里,清扫要跑完才恢复应用。BGC 把这一步也放到后台:标记已确定哪些对象是死的,清扫只需按段处理这些已知死对象,不必独占整个堆。应用在清扫期间分配,走分配上下文与段的空闲区;后台清扫线程一边回收死对象空间,一边把不再使用的段解提交(decommit,归还物理页保留虚拟地址)甚至整段释放。两边操作的区域由段状态协调,互不踩踏。

标记解决"谁是垃圾",清扫解决"垃圾占的空间什么时候还",两者都发生在应用运行的同时。缺了并发清扫,标记省下的暂停会在清扫那一步全吐回去;缺了并发标记,清扫根本无从下手。真正被留在 STW 里的只有收口动作。

这里有个容易误解的点:SOH 上的压缩(移动对象)并不是每轮 BGC 都在后台完成。并发清扫主要是把空间收回来,而把幸存对象搬在一起的压缩动作与前台回收的分工,随运行时版本与回收配置变化(按公开设计意图做示意推断)。可以确定的边界是:BGC 保证标记并发、清扫并发,而涉及全局对象移动的重活仍然存在短暂同步点——这也是为什么 LOH 压缩这类操作必须显式触发,参见本专栏 07-04 对清扫与压缩时机的拆解。

5.2 清扫阶段的分配友好性

清扫期间的分配还有个收益:后台把死对象空间陆续交还,分配慢路径向堆要新窗口的频率随之下降,应用不必等一次完整的回收结束才有地方分配。反过来,如果清扫追不上分配(典型的高分配速率场景),堆的已提交内存会持续上行,直到触发更激进的回收动作。所以观察到"BGC 期间堆水位单调上涨"不一定是泄漏,先确认清扫是否被分配速率压制——区分手段是看GC Heap Size曲线在一轮 background 结束后有没有回落,有回落是节奏问题,无回落才是泄漏嫌疑。

清扫还会顺手做段的归还:默认物理页 decommit 而虚拟地址保留,System.GC.RetainVM打开后连地址空间一起归还,代价是下次扩张要重新要地址。它的收益点恰好落在后台清扫这一段——清扫越积极,归还越频繁,工作集越贴近真实需求。

六、触发条件、失败与回退

6.1 什么时候会进入后台模式

BGC 的触发和普通 GC 一样由分配预算驱动:Gen2 预算耗尽或慢路径拿不到空间时触发 Gen2 回收,只要延迟模式允许且开关未关(System.GC.Concurrent默认 true),这次回收就以后台方式执行。没有定时器在背后周期性跑 BGC——它是被分配叫醒的,分配少就跑得少。显式调用GC.Collect(2, GCCollectionMode.Forced, blocking: false)同样走后台路径,blocking: true则强制阻塞,测试要确定性复现某类暂停时靠这个参数切换。

另外两个会主动压制或关闭 BGC 的开关:GCLatencyMode.Batch关闭后台回收换取吞吐;LowLatency与SustainedLowLatency反过来压制 Gen2 回收本身,让 BGC 更少被触发(代价是借未来的债,堆只涨不缩)。四种延迟模式的完整语义在 03-05 第七节有表格,本文不重复。

6.2 追不上了:退化为阻塞式 Gen2

这是本文最重要的失败模型。并发标记期间,晋升与分配持续消耗 gen2 预留窗口;只要后台标记的推进速度低于消耗速度,窗口终会耗尽。运行时的处理是中止当前后台 GC,改为发起一次完整的阻塞式 Gen2 回收:所有线程停下,标记清扫一次做完,然后再恢复正常运行。

这次退化惩罚是双重的。第一,这一轮同时付了已经跑掉的并发标记开销(白付)和一次完整阻塞暂停(照付),是标准的最差情况。第二,退化时的堆通常正处于分配最快、存活最多的时刻,阻塞暂停比稳态下的 Gen2 回收更长——量级上是 5 到 15ms(典型估算,非实测常数,实际随堆规模上浮)。识别信号在 GC 日志里很清楚:本该成对出现的 background 起止事件被一次 blocking Gen2 打断,或者回收计数在涨而 background 完成数不同步。

什么样的负载会把 BGC 逼到退化?两类行为叠加最危险:高分配速率(每秒几十 MB 量级的临时对象,示例量级)和高脏卡率(频繁改写老年代里的引用,让标记反复回扫),典型场景是每帧重建大对象集合的游戏逻辑、无池化的序列化流水线。应对方向和 Unity 增量 GC 的回退应对是同一条:先把分配速率压下来(池化、复用、把大对象的重建挪到低峰期),让后台标记有完成一轮的喘息空间。指望调 BGC 参数解决分配过多,是把因果关系搞反了。

6.3 失败回退的判定口径

判断是否频繁退化建议用两个差值:单位时间内 blocking Gen2 的次数,以及它的单次时长分位数。前者高说明分配压力大,后者高说明退化代价重。只看% Time in GC会把两者混在一起——它超过 5%(经验阈值,示例口径)时,先分清是"后台跑得多"还是"退化跑得多",前者通常良性,后者才要动手。一句话版的现场口径就是:监控 GC 触发频率,前台触发过多即后台负载过重;落到工具上是第十一节的两条曲线。

七、Server 与 Workstation 下的差异

Workstation 模式下只有一个堆、一个 GC 线程,后台标记的所有活都压在这一个线程上,与应用线程抢核。Server 模式下每个逻辑处理器各有一个堆和对应的 GC 线程,Gen2 回收时各堆并行推进,后台标记的吞吐随核数上升,单个堆的规模也更小,同样的分配速率下后台标记更容易跑赢。

维度Workstation + BGCServer + BGC
堆与 GC 线程1 堆 1 GC 线程每逻辑处理器 1 堆 1 GC 线程(可用DOTNET_GCHeapCount覆盖)
后台标记的并行度单线程推进,与应用抢核多堆并行,各堆独立推进
线程绑核无绑核需求默认按掩码绑核(DOTNET_GCHeapAffinitizeMask、DOTNET_GCNoAffinitize可控)
退化风险单核推进慢,高分配速率下更易退化单堆更小推进更快,但多堆要协调全局阶段切换
内存开销一套堆元数据元数据按堆复制,通常占堆总量 3%~5%(典型估算)
适用场景桌面、低核容器、内存紧张多核服务器、吞吐与延迟都要

还有一个差异是全局阶段同步:Server 模式下所有堆必须一起进入和退出某个阶段(比如一起完成初始标记),所以最快的堆要等最慢的堆。堆之间负载不均时,这个等待会出现在暂停时长里。配置上把DOTNET_GCHeapCount设得比实际核数少(容器里很常见),既能减少内存开销,也能减少阶段同步的等待者数量——03-05 第八节讲容器配置时给的建议就是从这条来的。

反方向也要提一句:Workstation 也能开并发标记,两种模式跑的是同一套状态机,差别只在"谁来跑、跑多快、跟谁抢"。所以别把"我要低延迟"直接翻译成"开 Server GC"——1 到 2 核的容器里堆数太少,并行收益覆盖不了内存开销,反而让后台标记和应用在仅有的核上互踩。

八、代码级走读:后台线程与标记循环

先看后台线程的骨架。下面这段是简化伪代码,按公开的线程模型整理,非逐字源码:

// 简化伪代码 / 示意:后台 GC 线程主循环,结构对应运行时的后台回收线程模型 DWORD BackgroundGCThread(LPVOID param) { while (true) { WaitForGCRequest(); // 等待 GC 触发信号 // 后台标记(与应用并发) BackgroundMark(); // 后台清扫(与应用并发) BackgroundSweep(); } }

逐段解读:WaitForGCRequest()说明后台线程平时是挂起的,它不轮询、不空转,只有 Gen2 回收被触发且被判定为可后台执行时才被唤醒——这对应第六节"没有定时器,BGC 被分配叫醒"的结论。BackgroundMark()与BackgroundSweep()前后相接,两者内部各自完成"暂停—恢复"的往返:进入时短暂 STW 拿一致视图,拿到后立刻 ResumeAllThreads 放应用继续跑。循环结构本身解释了为什么 BGC 是一轮一轮的——每轮从请求开始、到清扫结束,两轮之间应用完全不受影响。真实实现里这个循环还要处理退化分支:BackgroundMark()内部发现预留窗口耗尽时不会走到BackgroundSweep(),而是直接转入阻塞式 Gen2,这一点伪代码没有画出来,读源码时要特别留意。

再看标记循环本身,同样是简化伪代码 / 示意:

void BackgroundMark() { // 1. 初始标记(短暂 STW,扫描 Roots) SuspendAllThreads(); ScanRoots(); ResumeAllThreads(); // 2. 并发标记(后台进行) while (HasWork()) { MarkStep(); // 标记一批对象 // 应用线程在此期间可以运行 } // 3. 最终标记(短暂 STW,处理积压) SuspendAllThreads(); ProcessSATBBuffer(); ResumeAllThreads(); }

逐段解读:第一段的SuspendAllThreads / ScanRoots / ResumeAllThreads就是第三节表格里的初始标记,它必须包住根扫描,因为根随时在变,没有独占视图的根扫描结果不可信。第二段的while (HasWork())是并发标记的主体,注意循环体里没有暂停——MarkStep()每次推进一小批对象就返回,把 CPU 让回给应用,进度由标记栈和游标持久保存,被前台 gen0/gen1 回收打断后能精确续上,这正是第四节两条流水线并行的实现基础。第三段的ProcessSATBBuffer对应并发期间积累的引用变更:在这个简化模型里把它画成独立缓冲,实际运行时的载体以脏卡表为主、辅以屏障的补录策略,语义都是把并发期间改出来的引用收口。整个函数的结构印证了核心结论:两次 Suspend 之间隔着的,正是与堆大小成正比的那部分工作,而 Suspend 本身只承担与根集合成正比的开销。

还有一处留白:HasWork()的判定不只看标记栈空不空,还要看脏卡处理进度,否则会出现"对象图扫完了但卡还没清"的假完成。读源码时建议顺着background_mark_phase的入口把每个 return 条件抄在纸上,它们就是这台状态机的全部出口。

九、配置与开关

9.1 延迟模式与后台开关

// 启用后台 GC(默认启用) GCSettings.LatencyMode = GCLatencyMode.Batch; // 关闭后台 GC(高吞吐量模式) GCSettings.LatencyMode = GCLatencyMode.Interactive; // 启用后台 GC(默认)

这段代码的读法要注意:LatencyMode不只是后台 GC 的开关,它是延迟策略的总闸。Interactive默认开启后台回收、把 Gen2 拆成短暂停;Batch关闭后台回收换总吞吐;LowLatency与SustainedLowLatency走反方向,压制 Gen2 回收本身的频率。运行时另有独立于 LatencyMode 的显式开关:配置键System.GC.Concurrent、环境变量DOTNET_GCConcurrent,关掉它 Gen2 回收一律阻塞,与 LatencyMode 设成什么无关。生产环境建议启动日志里同时打印GCSettings.IsServerGC与LatencyMode,否则配置键拼错、环境变量没注入容器这类事故会一直潜伏——监控触发频率的第一步,就是先确认开关落没落上。

9.2 配置键速查

配置键 / 环境变量作用与 BGC 的关系
System.GC.Concurrent/DOTNET_GCConcurrent后台 GC 总开关,默认 true关闭即回到全程阻塞 Gen2
System.GC.Server/DOTNET_GCServerServer GC 开关决定后台标记是单线程还是多堆并行
DOTNET_GCHeapCount显式指定堆数影响后台标记并行度与阶段同步等待
DOTNET_GCHeapAffinitizeMaskGC 线程绑核掩码决定后台线程落在哪些核上
DOTNET_GCNoAffinitize关闭绑核容器内核会被迁移时改用
GCSettings.LatencyMode延迟策略总闸Interactive 默认允许 BGC,Batch 关闭

配置建议可以归到这张表里:默认开启,追求吞吐才关(Batch 或System.GC.Concurrent=false),高分配速率场景要盯着前台触发频率,触发过多即后台负载过重。四条建议分别落在"开不开"和"看什么"两端,中间缺的正是第六节的失败模型——关不关只是入口,判断跟不跟得上才是运维期的常态问题。

9.3 Unity 语境下的位置

放到 Unity 语境里再补一层背景:传统 Unity 的 IL2CPP 与 Mono 栈不使用 .NET Core GC,这条后台 GC 链路对它们不可用;Mono 自带的 SGen 有有限度的并发标记支持,语义上更接近"并发"而非完整后台状态机;Unity Incremental GC 是游戏场景下最接近"后台 GC"的替代方案——它同样做并发标记加短 STW 收口,但底座是不分代不压缩的 Boehm。Unity 6 引入的 CoreCLR 后端是唯一会真正跑上 .NET 后台 GC 的 Unity 路径,迁移前后的评估对象完全不同,边界结论见 03-04 第八节。

十、代价与适用场景

10.1 后台 GC 不是免费的

三条代价各自的作用机理如下:

  1. 总 GC 时间增加约 30%(并发标记的协调开销)——典型估算,非实测常数。来源是写屏障的持续开销、标记进度的跨线程维护、以及并发期堆膨胀导致的额外标记量。
  2. 写屏障的 CPU 开销约 5% 到 10%(每次赋值都检查)——典型估算,非实测常数。落点在引用写入密集的热循环,老年代改写越频繁,卡表越脏,实际开销越靠上限。
  3. 额外的内存开销(标记栈、脏卡缓冲,以及并发期的堆膨胀)——量级随堆规模与核数变化,Server 模式下按堆复制,再叠上 03-05 说过的 3% 到 5% 堆元数据。

把总账放在一起说:后台 GC 将暂停时间从 5 到 50ms 降到 1ms 以下,代价是约 30% 的总 GC 时间增加(两处均为典型估算量级,非实测常数);对于延迟敏感的 Server 应用,这是值得的权衡。三条代价给出选型分界线:后台 GC 适合延迟敏感型应用(Web API、交互式应用),不适合批处理型应用(数据处理、离线计算)——批处理的停顿不面向用户,拆碎暂停只换来更高的总 CPU,不如一次阻塞收干净。

10.2 场景清单与归因

场景结论归因
Web API(ASP.NET)默认开启,暂停 <1ms请求尾延迟直接受单次 STW 影响;<1ms 为典型量级,非承诺值
微服务推荐开启,延迟敏感对外 RPC 的 P99 就是 SLA,暂停抖动会传导到调用方
WPF 桌面推荐开启,UI 流畅渲染线程被 GC 冻结会直接掉帧,桌面场景对尖峰同样敏感
批处理关闭以获得更高吞吐无人等待延迟,写屏障与协调是纯损耗
计算密集减少后台线程的 CPU 竞争后台标记与业务线程抢核,算力已打满时并发反成负担
分配速率极高后台跟不上时触发前台 GC 反而更差直接命中第六节的退化路径,付双份钱

最后一行最有价值:BGC 有明确的能力边界,负载超过边界后不是"效果变差",而是"比不开还差"。判断边界靠的不是感觉,是第十一节的监控数据。

十一、后台 GC 的监控与诊断

监控入口是性能计数器与 dotnet-counters:% Time in GC应小于 5%(经验阈值,示例口径),超过即 GC 压力过大;实时监控命令一行就够——

dotnet-counters monitor --process-id <pid> System.Runtime

配套的观察方法再往前推一步。盯三条曲线:GC Heap Size看堆水位是否在一轮 background 后回落,GC Count看回收频率的斜率,二者一起能把"后台跑得多"和"退化跑得多"分开。再加一条信号:监控 GC 日志里的background关键字。落到事件层就是 GCStart/GCEnd 事件里带的代数与 blocking 标记:一次 Gen2 回收如果标记为 background,成对出现说明完整跑完;如果中间插进一次 blocking Gen2,就是退化现场。

诊断顺序压成三步:先用 dotnet-counters 看趋势(水位、频率),再用 dotnet-trace 或 PerfView 抓事件,确认每轮 Gen2 是 background 还是 blocking、单次时长分位数多少,最后才谈调参或改分配。跳过前两步直接调DOTNET_GCHeapCount,最常见的结局是调了半天发现进程根本没跑在预期模式上。

十二、常见误区与反模式

误区一:开了 BGC 就不会再有长暂停。
错误做法是把延迟预算全押在开关上。为什么错:BGC 有三次短暂 STW,还可能退化成阻塞式 Gen2,线程卡在非托管调用里也会拉长任意一次暂停。正确做法是验收看分布尾部(P99 单次 STW、blocking Gen2 次数),出现长暂停先分类:BGC 切片变长、退化、还是挂起等待,三者解法完全不同。

误区二:后台 GC 能解决分配太多的问题。
错误做法是把分配速率失控归咎于"GC 不够快",反复开关后台模式。为什么错:BGC 只重排暂停,它不减少任何分配,反而因为并发期堆膨胀让内存曲线更难看。正确做法是回到分配本身——池化、复用、缓存结果、把大对象重建挪出关键路径,让 gen2 预留窗口的消耗速度低于标记推进速度,退化自然消失。

误区三:批处理也开后台 GC 更安全。
错误做法是低延迟开关无脑全开。为什么错:批处理没有延迟收益,写屏障与并发协调换不来任何东西,只有总 CPU 上升。正确做法是显式设Batch,把这 30% 量级的总开销(典型估算)拿回来,同时保留 dotnet-counters 监控防止内存失控。

误区四:% Time in GC高就一定说明后台 GC 失效。
错误做法是看到超过 5% 立刻动配置。为什么错:它把良性并发开销和恶性退化暂停混在一起。正确做法是拆开看:background 轮次的总 CPU 高但单次 STW 短,是良性的;blocking Gen2 次数与单次时长分位数在涨,才是需要干预的信号。

十三、横向对比与在全专栏中的位置

维度Unity 增量 GC(Boehm)Mono SGen 并发.NET 后台 GC
并发做什么分片标记,跨帧推进可选并发标记并发标记 + 并发清扫(完整状态机)
暂停结构两次短 STW 加一次性清扫STW 为主,有限并发三次短 STW(初始/最终/收尾)
分代否两代三代 + LOH + POH
失败退化回退为全量 STW 标记并发中途退化为阻塞预留窗口耗尽退化为阻塞 Gen2
多核并行无无Server 模式多堆并行
关闭开关GarbageCollector.GCModeMONO_GC_PARAMSSystem.GC.Concurrent/LatencyMode

这张表的关键在第三列到第四列的跨越:SGen 的并发是"可以开",.NET 的后台 GC 是一台带失败转移的完整状态机——两者都做并发标记,但只有后者把并发清扫、预留窗口、退化路径、多堆同步这些工程配套一并做完。它也建立在本章前四篇的地基上:不理解段与分配上下文,就无法理解并发标记期间的分配去哪了;不理解清扫与压缩的分工,就无法理解后台清扫能并发到什么程度。07-06 从另一个方向接续:状态机保证"能并发",优化技术决定"并发得有多便宜"。

本章总结

  1. 后台 GC 的本质是把与堆大小成正比的标记工作挪出 STW,只留下与根集合成正比的暂停;三次暂停各司其职,长度基本不随堆增长。
  2. 生命周期是"初始标记(STW)→ 并发标记 → 最终标记(STW)→ 并发清扫 → 收尾(STW)"的循环,外加一条指向阻塞式 Gen2 的失败转移。
  3. 并发标记与前台 gen0/gen1 回收是两条并行流水线:前台回收照常发生,晋升对象走 gen2 预留窗口,写屏障加卡表保证引用变更不被漏标。
  4. BGC 的能力边界由分配速率决定:预留窗口耗尽即退化,退化轮付出双份开销,解药在分配侧而不在配置侧。
  5. Server 与 Workstation 共享同一套状态机,差别在并行度、绑核与阶段同步;容器里DOTNET_GCHeapCount必须与配额对齐。
  6. 配置默认可用:System.GC.Concurrent与LatencyMode.Interactive已开,批处理才显式关;验收看 P99 单次 STW 与 blocking Gen2 次数,不看平均值。

一句话记忆点:后台 GC 不消灭暂停,它把暂停从"随堆增长"改成"随根集合走",并用一条退化路径为分配速率失控兜底——守住分配速率,这条路径就永远不会被走到。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 10:24:07

《Java数据结构》第一讲:顺序表

一、顺序表&#xff08;ArrayList&#xff09; 顺序表是用一段 物理地址连续 的存储单元依次存储数据元素的线性结构&#xff0c;一般情况下采用 数组 存储。 1. List 1.1 List的定义 List 是一个接口2. 顺序表的结构 1.1 顺序表是一个类&#xff0c;实现了 List 接口1.2 java…

作者头像 李华
网站建设 2026/10/26 10:23:44

Java 项目实战: 交友项目 - 圈子功能实现:总结

概述 圈子这一章把「发一条动态」背后的一整套存储设计摊开了&#xff1a;MongoDB 存详情、时间线表做收件箱、Redis 存推荐列表&#xff0c;三套存储各管一段。这里做全章复盘&#xff0c;知识地图、五个可复用的架构套路、十二个实测踩过的坑、自检清单和四个动手练习&#…

作者头像 李华
网站建设 2026/10/26 10:23:08

初识C语言——C语言常见概念

提示&#xff1a;文章写完后&#xff0c;目录可以自动生成&#xff0c;如何生成可参考右边的帮助文档 文章目录一、在VS2022上写代码1.创建项目2.运行程序二、main函数三、printf和库函数四、关键字五、字符和ASCII编码1.需要掌握的&#xff1a;2.打印一个字符的方法&#xff1…

作者头像 李华
网站建设 2026/10/26 10:22:50

SKILL全详解:从SKILL.md到scripts与references的Agent能力拆解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/26 10:22:33

给园区系统接问数平台,我们复盘了 6 个真实坑

园区日常跑着一套智慧园区系统&#xff0c;招商、合同、账单、工单都在上面发生&#xff0c;数据自然沉淀下来。后来我们接了数智安全问数平台&#xff0c;让它用大白话问这些已经存在的经营数据&#xff0c;返回结论、图表、明细和来源。这两套东西分工清楚。智慧园区系统管业…

作者头像 李华
网站建设 2026/10/26 10:21:39

Jev 决策模型

摘要 Jev 是 TypeSafe AI 于 2026 年 9 月发布的首个「System One 模型」。它刻意不做大语言模型&#xff08;LLM&#xff09;做的事——不生成自然语言文本&#xff0c;而是接收一份程序状态和若干预定义的问题&#xff0c;返回带校准概率的类型化决策&#xff0c;让软件可以…

作者头像 李华