ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

03-05-架构篇-.NET Core GC架构解析

03-05-架构篇-.NET Core GC架构解析

.NET Core GC 架构解析

篇章:03-架构篇 |阅读时间:约 50 分钟 |前置知识:了解 Server/Workstation GC


一、引言

.NET 的垃圾回收器是工业界迭代时间最长的 GC 之一。从 .NET Framework 1.0 时代单一的 Workstation 模式,到今天支持 Server 多堆并行、后台并发标记、LOH 手动压缩、POH 固定堆的完整体系,它前后演进了近 20 年。对后端开发者来说,这套体系意味着大部分性能问题只需要调几个开关;对 Unity 开发者来说,摸清 .NET GC 的天花板在哪,才能准确判断 Mono SGen 和 Unity 增量式 GC 还差在哪一层。

它和本系列前面讨论过的两个对象定位很不一样。Boehm 是保守式、不分代、不移动的回收器,用最低的集成成本换来了碎片化和吞吐上限;SGen 是精确分代、移动式的回收器,但只有 Nursery 和 Major 两代,停在 Stop-The-World 一种执行模式上,也没有多核协同。.NET Core GC 把分代、移动压缩、并发标记、多堆并行全放进了一个实现里,代价是架构复杂度陡增:段、代、卡表、分配上下文、后台线程、分配预算,每一层都要看懂,才能解释清楚"为什么这个服务的 GC 停顿是 20ms 而不是 2ms"。

本文按"堆结构、运行模式、并发、配置、诊断"的顺序展开。先把堆在内存里的真实形状画出来,再看 Workstation 和 Server 两种模式怎么使用这个堆,然后拆 SOH / LOH / POH 三个子堆与分配路径,接着讲 Background GC 和触发机制,最后落到配置最佳实践与线上诊断手段。读完你应该能回答三个问题:对象到底落在哪里,GC 什么时候被叫醒,出了问题从哪里拿证据。


二、堆的物理结构:段与代

2.1 段:虚拟内存的分配单元

.NET GC 不会逐个对象向操作系统要内存。它先预留一大块虚拟地址空间,切成若干段(Segment),之后所有分配都在段内部完成。段采用两阶段管理:先 Reserve 占住地址范围,真正写入时才 Commit 物理页;回收后先 Decommit 归还物理页、保留地址空间,内存紧张时才整段 Release。运行时把回收掉的段留在缓存里复用,这就是段缓存,它省下了大量系统调用。

Workstation 模式的段通常只有数 MB,Server 模式下每个堆的段可以达到几十 MB 甚至 GB 量级,具体随核数变化。同一代的对象可能分布在多个段里:

托管堆的一个堆(Server 模式下每个逻辑处理器各有一份) Gen0/Gen1 短暂段(ephemeral segment) ┌────────────────────────────────────────────────┐ │ 对象A │ 对象B │ 已分配 → │ 空闲 │ └────────────────────────────────────────────────┘ Gen2 段 ┌────────────────────────────────────────────────┐ │ 长寿对象 │ 长寿对象 │ 碎片 │ 空闲 │ └────────────────────────────────────────────────┘ LOH 段 ┌────────────────────────────────────────────────┐ │ 大数组 128KB │ 空闲 │ 大字符串 96KB │ 空闲 │ └────────────────────────────────────────────────┘ POH 段(.NET 5+) ┌────────────────────────────────────────────────┐ │ pinned 缓冲区 1MB │ 空闲 │ └────────────────────────────────────────────────┘

2.2 对象按代分布

Gen0 和 Gen1 共用短暂段。新对象一律往短暂段里 bump 分配,一次 Gen0 / Gen1 回收会把幸存对象压缩到段的一端,腾出来的空间继续给新分配使用。Gen2 对象住在自己的段里,只有 Gen2 回收才允许移动它们。LOH 与 POH 各自持有独立的段,完全不参与 SOH 的代际晋升。

对象的典型轨迹: new Foo() → Gen0(短暂段) │ 存活过一次 Gen0 GC ▼ Gen1 ── 存活过一次 Gen1 GC ──→ Gen2(长寿,只在 Gen2 回收时移动) ≥ 85,000 字节的对象 ─────────→ LOH(不晋升,只随 Gen2 回收) pinned: true 分配 ─────────→ POH(不分代,地址永久固定)

这个设计的收益很直接:绝大多数对象活不过一次 Gen0 回收,GC 只需要盯着一小块短暂段,不用为"长寿对象可能引用了新生对象"去遍历整个老年代。

2.3 卡表与分配上下文

分代回收要解决的第一个问题是"老年代里有没有引用指向新生代"。卡表(Card Table)把堆划成固定大小的卡,每张卡对应一个记录字节;当应用把引用从老对象写向新对象时,写屏障(write barrier)顺手把对应的卡标脏。Gen0 回收只扫根集合和脏卡,不必遍历整个 Gen2,这就是分代 GC 的效率来源,也是写屏障给每次引用赋值增加的那点开销换来的。

每个托管线程还持有自己的分配上下文(allocation context),本质是alloc_ptr和alloc_limit两个指针,圈出该线程当前独占的一段分配窗口。线程在窗口内分配对象时只碰自己的数据,完全不经过全局锁。第五节会把这条路径逐行拆开。

关于实现细节要留一个口子:不同版本的运行时一直在调整堆的物理布局,例如把"按代划分的段"进一步做成 region 粒度的管理单元。这类调整属于持续演进中的实现细节,不构成对外契约。本文统一按"段 + 代 + 短暂段"的主线讲解,你读源码时看到不一样的命名不必意外。


三、Server GC 与 Workstation GC

3.1 Workstation GC

  • 单堆单线程:一个托管堆配一个 GC 线程
  • 两种执行方式可选:阻塞式(回收期间应用全停)和并发式(标记与应用同时跑)
  • 内存占用小:一套堆元数据、一张卡表
  • 暂停时长与堆大小正相关,堆越大单次回收越慢

它是默认模式,适合桌面应用、低核容器,以及任何"内存比吞吐更贵"的场景。

3.2 Server GC

  • 多堆多线程:默认每个逻辑处理器一个独立堆,配一个专属 GC 线程
  • GC 线程与处理器绑定(affinitize),避免跨核迁移造成的缓存失效
  • 吞吐更高、单次暂停更短:每个堆更小,各堆还能并行回收
  • 内存占用成比例上升,碎片风险同步上升

触发一次 GC 时,各核的 GC 线程并行清理本核的堆,堆与堆之间几乎没有同步点。16 核机器上,标记与清扫的工作量被摊到 16 条线程,理论并行度就是 16。这里给的是量级说明而不是基准测试:真实加速比受串行的根扫描阶段、内存带宽、各堆大小不均的影响,一般到不了 16 倍,但"吞吐随核数近似线性扩展"确实是 Server GC 的设计目标。

3.3 配置示例

{ "runtimeOptions": { "configProperties": { "System.GC.Server": true, "System.GC.Concurrent": true, "System.GC.HeapCount": 8 } } }

同一组开关还有两种等价写法:项目文件里的<Server>true</Server>,以及环境变量DOTNET_GCServer=1。容器部署几乎都用后者,因为它不改镜像。

3.4 性能特征

Server 模式下每个核各有一套堆,元数据(卡表、段结构、分代统计)也按堆复制一份,通常占堆总大小的 3% 到 5%。工作集 4GB 时这部分就是 120 到 200MB。"Server GC 吞吐高但更吃内存"这句话的账全在这里,第八节的选择指南也是从这条推出来的。


四、堆结构:SOH / LOH / POH

4.1 三种堆总览

堆大小阈值分代压缩策略主要用途
SOH< 85,000 字节三代(Gen0/1/2)每次回收都压缩绝大多数日常对象
LOH≥ 85,000 字节视作 Gen2不自动压缩(可手动触发)大数组、大字符串
POH任意大小不分代不压缩,地址永久固定(.NET 5+)需要固定地址的缓冲区

4.2 SOH:三代与压缩

SOH 承接了 99% 以上的日常分配,内部再分三代,各自分工明确。Gen0 只装最新分配的对象,回收最频繁也最便宜;Gen1 是缓冲区,接住 Gen0 里活下来但还没证明自己长寿的对象,避免它们过早挤进 Gen2;Gen2 收留真正的长寿对象,回收昂贵但频率低。

每一代被回收时都会压缩,把幸存对象挪到一起,因此 SOH 内部基本不产生外部碎片,对象布局始终紧凑。代价是对象地址会变,任何缓存了托管指针的代码都必须依赖句柄或固定机制,而不能长期持有裸地址。这条规则贯穿 .NET 的整个互操作设计。

4.3 LOH:大对象与碎片

85,000 字节这个阈值来自对象对齐与段管理的成本权衡,不是随手挑的整数。达到它的对象直接进 LOH,跳过 Gen0 和 Gen1 的复制晋升,因为每次 Gen2 回收都可能去动它们,没有必要再让它们在年轻代里反复搬运。

LOH 的关键规则是不自动压缩:搬动一个几 MB 的大数组,CPU 和内存带宽的代价都很高,收益却不一定有。副作用是外部碎片:大对象频繁分配释放后,LOH 会留下大量尺寸不匹配的空洞,后续大分配可能因为找不到连续空间而提前触发 Gen2 回收,哪怕总空闲量足够。缓解手段有三种。

第一是复用缓冲区,而不是反复 new。第二是显式请求一次 LOH 压缩:

// LOH 压缩是 opt-in 的,默认关闭 GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce; GC.Collect(2, GCCollectionMode.Forced, blocking: true);

第三是选对时机:LOH 压缩会移动对象,如果有原生代码正持有其中某个数组的指针(P/Invoke 缓冲区、第三方库句柄),压缩会立刻制造悬垂指针。安全时机是"进程内没有外借的 LOH 指针",例如请求间隙、批处理任务的阶段切换处。若这类缓冲区长期存在,正确做法是改用 POH,而不是反复压 LOH。

还有一条容易忘的规则:LOH 上的对象只有 Gen2 回收时才会被处理,这点和 SOH 的 Gen2 一致。所以 LOH 膨胀会直接拖长 Gen2 回收的间隔与单次成本。

4.4 POH:固定对象堆

POH 是 .NET 5 引入的第三类堆,专门存放地址必须稳定的大块内存。它的规则简单:不分代、不压缩、不移动,分配后地址永久有效,回收只在 Gen2 回收时发生。

POH 解决的是 pin 的老问题。传统做法里,把一个托管数组交给原生代码,必须用 GCHandle 把它钉住,否则下一次压缩回收就会把它搬走。钉住的对象会拖慢它所在堆的压缩,因为压缩算法要绕开它;而且句柄本身需要手动释放。POH 把这类对象单独隔离出来:它们本来就不参与压缩,自然不需要钉,也不会占用压缩算法的注意力。

代价同样要说清楚。POH 的对象只能整块等待 Gen2 回收,没有"部分回收"这回事,所以它适合生命周期本身就长的缓冲区;把大量短命对象往 POH 上塞,等于把它们全变成要等 Gen2 才能释放的垃圾。POH 不是万能的分配池,它是固定地址这个需求的专用解。


五、分配路径

5.1 快速路径:bump pointer 分配

new 一个对象时,JIT 生成的代码进入分配入口,第一步只做一次边界检查:

线程的分配上下文 [ 已分配区 | alloc_ptr → 空闲窗口 … alloc_limit ] 快速路径: ptr = ctx.alloc_ptr if ptr + size <= ctx.alloc_limit: // 边界检查 ctx.alloc_ptr = ptr + size // 指针前移 初始化对象头(方法表、同步块索引) return ptr else: 进入慢速路径

核心就是加法、比较、一次写回,指令数在个位数。没有空闲链表遍历,没有按尺寸分桶查找,没有锁。因为窗口由线程独占,连原子操作都不需要。这是 .NET 分配吞吐高的直接原因。

5.2 慢速路径:新窗口、新段、触发回收

上下文用尽时进入慢速路径,按顺序尝试四件事:

  1. 向当前段申请一个新的分配窗口,段归堆所有,这一步可能需要和 GC 协调
  2. 当前段不够,向运行时申请一个新段
  3. 新段也拿不到,说明分配预算耗尽,触发 GC 后重试
  4. GC 回收后仍然放不下,说明堆确实现货不足,只能抛OutOfMemoryException

慢路径是分配开销的主要来源,也是 GC 被实际叫醒的地方。健康运行的服务里,慢路径命中率应该极低;如果它频繁出现,要么分配速率失控,要么堆的预算配置不合理。

5.3 大对象路径

请求达到 85,000 字节时直接走 LOH 分配器,绕开分配上下文:先在 LOH 的空闲区里找一块足够大的连续空间,找不到就触发 Gen2 回收。POH 的路径类似,只是目标堆换成了 POH。这条路径没有 bump pointer 的运气成分,每次都要真正搜索空间,所以大对象分配本身就比小对象贵一个量级。频繁分配大对象的服务,问题往往出在这条路径上,而不是 GC 本身。

5.4 为什么 .NET 的分配吞吐这么高

对照本系列 03-02 讲过的 Boehm:保守式回收器大多用空闲链表管理内存,分配时要按尺寸找合适的块,容易受碎片影响,还得周期性整理。SGen 用 TLAB(线程本地分配缓冲)解决并发分配,思路上和 .NET 的分配上下文属于同一类设计,都是把堆切一块给线程独占,让绝大多数分配退化成指针加法。

.NET 的差别在于整条路径的完成度:分代让慢路径很少被触发,段缓存让向操作系统要内存的成本极低,Server 模式下每个核还有独立的堆,连跨线程的窗口竞争都省了。几个因素叠加,才有了"分配几乎免费"的观感。任何一条腿短了,这个观感都会消失。


六、Background GC(后台并发回收)

6.1 阻塞式回收的问题

最朴素的 GC 流程是全程 Stop-The-World:挂起所有托管线程,标记、清扫、压缩一次做完,再恢复。堆越大、存活对象越多,暂停越长。对 Web 服务来说,一次 5 到 50ms 的阻塞式 Gen2 回收(典型范围)足以让一批请求的尾延迟明显抖动。Background GC 就是为压掉这部分停顿而生的。

6.2 四个阶段

后台回收把最耗时的标记阶段挪到后台线程,和应用同时运行:

时间轴 ──────────────────────────────────────────────────► 应用线程 正常运行 ▥停█ 正常运行(并发标记期间) ▥停█ 正常运行… │ GC 线程 ├─ 1. 初始标记(STW,极短) ├─ 2. 并发标记(与应用并行,遍历对象图) ├─ 3. 重标记(STW,处理并发期的引用变更) └─ 4. 并发清扫(与应用并行,归还空闲空间)
  1. 初始标记(STW):暂停所有线程,只扫描根集合和卡表脏项,确定一批必然存活的起点。这一步必须独占视图,否则根会在扫描过程中被改掉。
  2. 并发标记:GC 线程从起点出发遍历对象图,应用照常运行。期间应用修改引用,由写屏障记录下来,留给下一步处理。
  3. 重标记(STW):再次暂停,消化并发期间积累的引用变更,把标记补完整。这一步同样很短。
  4. 并发清扫:把未标记的内存归还或整理,应用继续运行。

两次 STW 的耗时都与根集合规模相关,而不是与堆大小相关,所以堆涨上去之后,单次停顿基本不动。这是后台回收最值钱的性质。

6.3 与 Gen0/Gen1 短回收的配合

后台回收只负责 Gen2。Gen0 和 Gen1 回收依然是短阻塞式,因为它们要处理的存活对象少、区间小,暂停本来就在亚毫秒到几毫秒量级。并发标记跑到一半时 Gen0 预算用完,运行时会插入一次前台的 Gen0 或 Gen1 回收,处理紧急的分配需求后立刻把线程还给并发标记。两条流水线并行不悖,这正是这套设计的精巧之处。

一个必须记住的约束:分配速率过快时并发标记可能来不及跑完,Gen2 空间先耗尽了,此时只能退化成一次完整的阻塞式 Gen2 回收,暂停反而比平常更长。监控里看到"本该后台跑的回收变成了 blocking",通常意味着分配速率或堆规模需要介入。

6.4 延迟量级与开关

典型范围:阻塞式 Gen2 回收 5 到 50ms,Background GC 下真正的 STW 切片通常在 1ms 以下。这两个数字是量级参考而不是承诺,实际取决于堆大小、根集合规模和硬件。

后台回收默认开启,也可以显式关闭:

{ "runtimeOptions": { "configProperties": { "System.GC.Concurrent": false } } }

关掉之后回到全程阻塞模式,总 CPU 可能略降(省掉写屏障和并发协调的开销),代价是暂停回归 5 到 50ms 量级。批处理任务可以这么换,交互式服务别动。


七、GC 触发机制

7.1 预算触发

GC 不是被定时器叫醒的,是被分配预算花光叫醒的。运行时给每个代记账:Gen0 有一份分配预算,初始值随模式与核数变化,每次分配往上累加,用尽就触发一次 Gen0 回收;Gen1 的预算在 Gen0 回收后重新结算;Gen2 的预算跨度最大,通常由存活集大小和压缩收益共同决定。

这种按分配量计费的设计让 GC 频率自动正比于分配速率:分配少就回收少,分配猛就回收猛,不需要人工调周期。预算还会根据历史存活率动态修正,回收后存活比例高就调低预算,让下次回收来得更早。

7.2 分配失败与代际递进

慢路径拿不到内存是另一类触发器,而且是逐级升格的:先试 Gen0 回收,不够再 Gen1,再不够 Gen2,最后 Full GC。每升一级,单次暂停时间和能回收的量都上一个台阶。线上如果频繁看到高代回收,通常意味着长寿对象在增长,或者分配速率已经超出了预算触发的处理能力。

7.3 显式触发:GC.Collect 的正确用法

GC.Collect(); // 等价于 Collect(2, Forced, blocking: true) GC.Collect(0, GCCollectionMode.Forced); // 只回收 Gen0 GC.Collect(2, GCCollectionMode.Optimized); // 交给运行时判断值不值得 GC.Collect(2, GCCollectionMode.Forced, blocking: false); // 非阻塞,允许后台完成

第一个参数是代掩码,决定回收到哪一代;blocking决定是否停到底,设为 false 时运行时可以把它拆成后台执行。生产代码里手写GC.Collect往往是个信号,说明分配模式有问题。少数合理场景:性能基准测试的起点对齐、测试用例需要确定性、批处理任务在阶段切换时主动归还内存。

7.4 内存压力通知

宿主环境和操作系统会推送内存压力事件,比如容器接近限额、系统整体吃紧。运行时收到后会收紧回收预算、提前触发高代回收,尽量避免进程被 OOM 杀掉。托管代码也能主动感知:监听内存使用率回调,在压力升高时自己先降级,丢弃大缓冲区、清掉缓存,把命运掌握在自己手里,而不是等内核出手。

7.5 LatencyMode:四种语义

GCSettings.LatencyMode = GCLatencyMode.Interactive; // 默认值
模式行为适用场景
Interactive(默认)后台回收开启,Gen2 回收被拆成短暂停Web API、桌面交互应用
Batch关闭后台回收,追求总吞吐,不关心单次暂停批处理、离线计算、基准测试
LowLatency压制 Gen2 回收,尽量只跑 Gen0/Gen1有明确时间边界的延迟敏感窗口
SustainedLowLatency长期压制 Gen2 回收,可常驻持续低延迟要求的服务

LowLatency 和 SustainedLowLatency 本质是借未来的债:Gen2 回收被推迟,堆只涨不缩,用久了最终还是要还一次更大的回收。它们适合"接下来 30 秒不能卡"这种有边界的场景,不适合常开。判断标准很简单,如果压了两分钟堆还在涨而你开始担心内存,就该切回 Interactive 让 GC 正常工作。


八、配置最佳实践

8.1 配置键总览

{ "runtimeOptions": { "configProperties": { "System.GC.Server": true, "System.GC.Concurrent": true, "System.GC.HeapCount": 8, "System.GC.NoAffinitize": false, "System.GC.RetainVM": false } } }
配置键作用
System.GC.Server开关 Server GC,与项目文件的<Server>和环境变量DOTNET_GCServer等价
System.GC.Concurrent开关 Background GC,默认为 true
System.GC.HeapCount显式指定堆数量,默认按逻辑处理器数推导
System.GC.NoAffinitize关闭 GC 线程与处理器的绑定,改由操作系统调度
System.GC.RetainVM控制回收后的段是否把虚拟地址空间也归还操作系统,此处列出作为可选项

配置改完必须验证,第九节的两行代码就是干这个的。见过太多案例:调了半天 Workstation 参数,最后发现进程是 Server GC 起的,所有调整都没落在生效的配置上。

8.2 容器化部署

容器里最常见的坑是 GC 看到的 CPU 数与容器配额对不上。Kubernetes 的 pod 限了 2 核,运行时却按宿主机 64 核初始化了 64 个堆,既浪费内存又互相抢内存带宽。做法有两条:

  1. 显式设置System.GC.HeapCount,数值与容器的 CPU limit 对齐,limit 是 2 就设 2,不要沿用默认值。
  2. 限核较小时同时考虑System.GC.NoAffinitize。容器内的处理器本来就可能被宿主机调走,硬绑定反而制造停顿。

System.GC.RetainVM在容器里通常保持默认,除非进程内存曲线频繁大幅波动,才需要放开它让虚拟地址空间的归还更积极。

8.3 什么时候不要用 Server GC

场景推荐原因
ASP.NET Web API(多核)Server GC多堆并行,吞吐与暂停双赢
WPF / WinForms 桌面应用Workstation GC单机内存有限,多堆元数据是纯浪费
微服务容器(1 到 2 核)Workstation GC堆数太少,并行收益覆盖不了内存开销
批处理、离线计算Server GC + Batch 模式极致吞吐,不关心单次暂停
交易、音视频管线Workstation GC + SustainedLowLatency内存可控,暂停可压制

判断标准压缩成一句话:核数少、内存紧、对单次暂停敏感,就用 Workstation;核多、吞吐优先、内存有富余,才上 Server。1 到 2 核的容器强行开 Server GC,是实践中最典型的配置错误之一。


九、诊断与可观测性

9.1 GC 事件:ETW 与 LTTng

GC 的每个阶段都会发事件。Windows 上走 ETW(Event Tracing for Windows),Linux 上走 LTTng,事件名两边保持一致,因此工具链可以跨平台复用:

  • GCStart / GCEnd:一次回收的起止,带代数和是否 blocking,两条配对就是单次暂停时长
  • GCHeapStats:回收前后的各代存活量与分配量,用来判断晋升速率
  • GCPerHeapHistory:Server 模式下每个堆的回收明细,用来定位"哪个核的堆特别大"
  • GCGenerationRange:各代地址范围的变化,观察段的扩张与收缩

拿到这些事件之后,"暂停长"就不再是一个感觉,而是一条可以精确到毫秒的时间线。

9.2 工具链

工具用途
PerfViewWindows 上最全的 ETW 分析器,GC 日志、分配速率、时间线一把抓
dotnet-trace跨平台采样与 GC 事件采集,线上排障首选
dotnet-counters实时计数器,盯 GC 堆大小与回收次数的斜率
dotnet-gcdump抓托管堆快照,对比两次抓取定位泄漏对象

dotnet-counters的典型用法是一条命令挂上去看趋势:

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

关注GC Heap Size和GC Count两条曲线:前者单调上涨说明有泄漏或者预算失衡,后者斜率突变说明分配速率变了。排查节奏建议按"趋势、事件、对象"推进,先用 dotnet-counters 看趋势,再用 dotnet-trace 抓事件确认是哪一代、是不是 blocking,最后才上 gcdump 找具体对象。反过来做,成本会高一个量级。

9.3 自省 API

不借助外部工具也能拿到关键信息:

var info = GC.GetGCMemoryInfo(); long heapSize = info.HeapSizeBytes; // 当前堆大小 long committed = info.TotalCommittedBytes; // 已提交内存 long fragmented = info.FragmentedBytes; // 碎片量,LOH 问题的直接证据 int gen0 = GC.CollectionCount(0); // 各代回收次数 int gen2 = GC.CollectionCount(2);

GC.GetGCMemoryInfo()在高代回收之后数据最准,适合打进周期性日志。CollectionCount要看差值而不是绝对值,两个时间点相减就能算出区间内的回收频率。

9.4 运行时确认当前模式

配置到底有没有生效,运行时一查便知:

Console.WriteLine($"Server GC : {GCSettings.IsServerGC}"); Console.WriteLine($"LatencyMode: {GCSettings.LatencyMode}");

这两行应该出现在服务的启动日志里。它排掉的是一整类误判:模式判断错、配置键拼错、环境变量没注入容器,全都会在这里现形。


十、POH 实战

10.1 固定缓冲区的两种做法

// 方式一(.NET 5+):直接分配在 POH byte[] pinnedBuffer = GC.AllocateArray<byte>(1024 * 1024, pinned: true); // 地址永久固定,压缩回收永远绕开它,不需要任何句柄 // 原生代码可以长期安全地持有它的指针 // 方式二(传统):普通堆 + 手动钉住 byte[] buffer = new byte[1024 * 1024]; GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned); try { IntPtr ptr = Marshal.AddrOfPinnedObject(buffer); // 交给原生代码使用… } finally { handle.Free(); // 漏掉这一行就是资源泄漏 }

差别不只是代码行数。方式二里,被钉住的对象会让它所在堆的压缩变慢,因为算法必须绕开它;GCHandle 本身还是需要释放的资源。方式一把这类对象彻底隔离到 POH,SOH 的压缩不再受干扰,"忘了 Free"这个错误类别也从根上消失。字符串同理,GC.AllocateString可以直接分配到 POH。

10.2 GCHandle 泄漏这一类 bug

传统 pin 模式下的典型事故有三种:正常路径写了Free()而异常路径忘了;两个方法各自Alloc同一个缓冲区却只Free一次;以及循环里反复Alloc但只在循环外释放。后果不只是内存泄漏,被钉住的对象会持续拖累后续所有压缩回收,线上表现往往是"堆不算大,GC 却特别慢",查起来非常隐蔽。

POH 把这类问题消解掉了,因为根本没有句柄需要管理。除了互操作缓冲区,POH 还适合另一类场景:长期复用的网络收发缓冲、序列化中间结果。它们的生命周期本来就是整个请求乃至整个进程,交给 Gen2 回收完全合理,还能省掉反复 pin 和 unpin 的开销。


十一、与 Mono SGen / Boehm 的架构对比

维度BoehmMono SGen.NET Core GC
分代无2 代(Nursery + Major)3 代(Gen0/1/2)
是否移动否,保守标记不搬对象是,Nursery 复制,Major 可压缩是,SOH 全压缩,LOH 默认不压
并发能力无有限并发Background GC(并发标记 + 并发清扫)
Server 模式无无有,每核一堆 + 专属 GC 线程
大对象处理无独立堆,同样走空闲链表≥8KB 直接进 Major独立 LOH(≥85,000 字节)
固定对象本来就不移动GCHandle 钉住POH 专用堆(.NET 5+)
线程模型STW + safepointSTW + safepointSTW 切片 + 后台线程并发
GC 接口嵌入点Mono 的 GC 接口层,实现可热插拔同左,与 Boehm 实现同一组接口CLR 的 EE 层 GCHeap 接口
配置方式无MONO_GC_PARAMS 环境变量runtimeconfig / 项目文件 / 环境变量

三者的差异不是功能清单的长短,而是设计目标不同:Boehm 追求零依赖的嵌入成本,SGen 追求在 Unity 这种受限环境里给出可预期的分代回收,.NET Core GC 追求在多核服务器上同时拿到吞吐和低延迟。理解这条光谱,也就理解了 Unity 为什么做增量式 GC,以及为什么"把 .NET 的后台标记思路搬进游戏运行时"会成为方向。


总结

.NET Core GC 的架构可以用三句话概括。堆被切成段和代,短命对象在短暂段里快速 bump 分配,长寿对象和大对象各有专属区域;模式上 Workstation 求省内存、Server 求并行吞吐,差别只在几个配置键;并发上 Background GC 把最贵的 Gen2 标记拆到后台,只留下与根集合成正比的两次短暂停。

落到工程实践,最重要的三个结论是:第一,先确认自己跑在什么模式上,IsServerGC和LatencyMode两行代码就能排掉一半的误判;第二,配置要和部署环境对齐,容器里HeapCount不匹配 CPU limit 是最常见的隐性浪费;第三,诊断要按"趋势、事件、对象"的顺序推进,dotnet-counters 起步,dotnet-trace 定位,gcdump 收尾。

把这条线对照回本系列前面的 Mono 章节,会看到一条清晰的演进路径:从 Boehm 的保守不分代,到 SGen 的两代 STW,再到 .NET 的三堆、并发与多核并行。GC 架构的历史,本质就是在吞吐、暂停、内存三者之间不断重新切分代价的历史,而 .NET Core GC 目前把这道题答得最完整。


代码示例:运行时 GC 状态 dump

using System; using System.Runtime; class GCStatusDump { static void Main() { Console.WriteLine($"IsServerGC : {GCSettings.IsServerGC}"); Console.WriteLine($"LatencyMode : {GCSettings.LatencyMode}"); Console.WriteLine($"LOH compaction: {GCSettings.LargeObjectHeapCompactionMode}"); for (int gen = 0; gen <= 2; gen++) Console.WriteLine($"Gen{gen} collections: {GC.CollectionCount(gen)}"); var info = GC.GetGCMemoryInfo(); Console.WriteLine($"Heap size : {info.HeapSizeBytes / (1024.0 * 1024):F1} MB"); Console.WriteLine($"Committed : {info.TotalCommittedBytes / (1024.0 * 1024):F1} MB"); Console.WriteLine($"Fragmented : {info.FragmentedBytes / (1024.0 * 1024):F1} MB"); Console.WriteLine($"Available : {info.TotalAvailableMemoryBytes / (1024.0 * 1024):F1} MB"); } }

跑一次就能确认当前是 Server 还是 Workstation、LatencyMode 落在哪一档、各代回收了几次、堆上堆了多少碎片。上线前后各打一次,配置是否生效、内存是否恶化,一眼就能对上。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进