前两天看到 Satori GC 已经被封装成 MSBuild SDK 挂上了 NuGet,在项目文件里加一行就能启用。折腾了这么多年的实验性 GC,终于到了普通人也能随手玩玩的程度,觉得值得写一篇聊聊。
先说清楚 Satori 是什么。它是 .NET Runtime 核心团队成员 VSadov 在业余时间搞的一个实验性 GC,仓库挂在 GitHub 上(VSadov/Satori),简介写得很随意:"Once in a while I have an idea and I want to give it a try."——但做出来的东西一点也不随意。它不是玩具,是直接接在真实运行时接口上的完整 GC,内部指针、终结器、弱引用、依赖句柄、可卸载类型、精确和保守根扫描,这些脏活累活全都支持。
老问题:GC 的不可能三角
写托管代码的人都绕不开 GC 的 STW(Stop-The-World)。GC 要遍历对象图、回收垃圾、必要时还得移动对象做压缩,移动对象的时候所有引用都得跟着改,所以只能把程序停下来。停得越久,应用越卡。
问题在于,GC 设计里有三个目标一直互相打架:
-
低延时:想要停顿短,就得把更多工作搬到并发阶段,代价是更复杂的屏障、更严格的不变量,日常路径成本变高; -
高吞吐:想要平时跑得快,分配、读写引用的成本就得压到最低,代价是很多账攒到回收时一次算清,停顿容易做长; -
低占用:想要省内存,就得更积极地回收、整理碎片、把空闲内存还给操作系统,这些动作本身也烧资源。
不同 GC 本质上都是在回答同一个问题:成本到底放在哪里?
.NET 现有的几张牌
在 Satori 出现之前,.NET 手里主要是三张牌。
Workstation GC 是最老的,单堆单线程回收,内存占用克制,但多核资源基本不利用,遇到高分配场景就力不从心。Server GC 按核心数开堆,吞吐拉满,代价是内存占用大,而且极端情况下单次暂停上百毫秒并不稀奇。.NET 9 引入的 DATAS 则是在 Server GC 上叠了一层自适应策略,牺牲个位数百分比的吞吐,换来内存占用减少 70%~90%、暂停缩短到三分之一。
进步很大,但注意一点:这三位有个共同的底层事实没变——最频繁的 Gen 0 / Gen 1 回收,本质上仍然是全局 STW 的一部分。而 Satori 动刀的地方,恰恰就是这里。
Satori 的核心想法:最频繁的回收,凭什么要惊动全世界
Satori 的堆组织围绕两个概念:Page 是大的预留单位,Region 是 Page 内部更小的管理单位。Region 在 Satori 里不只是物理切分,它同时是分配单位、线程本地所有权单位、Gen 0 局部回收单位、压缩整理的规划单位和内存归还的处理单位。
有了这个基础,Satori 最关键的想法就可以用一句话说清:如果一批新对象几乎只在一个线程内部短暂存在,回收它们的时候为什么要把全世界都叫停?
现实程序里这种场景太多了——一个请求进来,线程创建一堆上下文对象、中间结果、临时字符串,请求处理完它们就全死了。传统 GC 回收这批对象时要从整个进程的视角出发:所有线程什么状态、老对象哪里指向新对象、卡表扫哪些。Satori 不一样,线程尽量在自己持有的 Region 里分配,Region 快满的时候先判断:这玩意儿能不能我自己就地扫了?这就是 thread-local Gen 0。局部回收要看的根集合很小——当前线程自己的栈,加上已经逃逸出去的少量对象——跟全局 Gen 0 完全不是一个量级。
当然,线程本地不是永远成立的。对象会逃逸:被塞进全局缓存、挂到共享对象上、丢进任务队列给别的线程处理。Satori 不假装逃逸不存在,而是显式跟踪它。少量逃逸无所谓,Region 整体还是当前线程的地盘,局部回收照旧划算;逃逸量超过阈值,就体面退出,把这个 Region 交给全局代际管理。这个阈值设得很现实——一发生逃逸就放弃,会丢掉大量本该吃到的收益;死扛到底,局部回收又会越来越不值。取平衡点。
全局 GC 和压缩:可选,而不是铁律
Satori 当然也有完整的全局 GC:处理逃逸严重的 Region、老对象、全局内存压力、跨 Region 的整理。思路是把最频繁、最适合局部化的工作先拿走,剩下真正需要全局处理的再走全局路径,而且与堆大小成比例的主要阶段都尽量和应用程序并发跑。
这里能看出 Satori 和 JVM 系低延时 GC 的哲学差异。ZGC 把 GC 状态编码进指针、配合读屏障,Shenandoah 给每个对象加一层间接引用,它们愿意为"任何对象随时可以并发移动"付日常成本——每次读对象都要过额外检查。Satori 没走这条路。它仍然用 Dijkstra 风格的写屏障,压缩整理是可选能力而不是必须常开的总开关。毕竟 C++/Rust 的分配器不压缩也活得好好的,Go 的 GC 也不压缩。
更妙的是压缩开关可以在运行时动态切。GCSettings.LatencyMode = GCLatencyMode.LowLatency 一行代码进入低延时模式,直接关掉压缩,用少量内存换更低的停顿。很多 Web 场景里大量对象只活一次请求,反正马上全变垃圾,压缩它们图什么呢?有些情况下关掉压缩内存占用反而更小。另外 Gen 0 也可以关(DOTNET_gcGen0=0),用不上 Gen 0 的场景就不必为写屏障里的额外操作买单。
还有两个很务实的设计值得提。一是应用线程协助推进:分配速度眼看要甩开回收速度时,正在分配的线程自己顺手做一点回收推进,避免最后靠一次重停顿把账补上——成本不是平均摊到每次访问,而是快失控时才让你帮一把。二是内存归还:有个专门的线程负责限速地扫描空闲 Region、合并相邻空闲块、把不用的内存还给操作系统;元数据方面则尽量复用 64 位下对象头附近没充分利用的空间存临时信息,而不是动不动开旁表。短命垃圾尽量死在线程本地、不升代污染老年代,本身也是在给低占用打地基。
疗效如何?上数据
Unity 风格的帧分配测试
模拟游戏每帧分配大量数据但不渲染,用单帧时间反映 GC 暂停:
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Server GC 的吞吐有了,最大暂停却只有它的零头。
Gen 0 压力测试
制造大量 Gen 2 → Gen 0 反向引用让 GC 忙起来,再猛分配短命对象触发 Gen 0 回收:
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
亚毫秒级暂停——可以说这个测试里 Satori 压根没让应用停下来过,同时内存占用是全场最低。
BinaryTree 压测(最新数据)
这是最新的二叉树压力测试结果,Satori 两种模式对 Workstation 和 Server(DATAS):
|
|
|
|
|
|
|---|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
吞吐最高的同时,延时和占用还都是最低。LowLatency 模式把最大暂停压进了 2ms。GC 后内存只剩 27MB 这个数字尤其夸张——归还内存的机制是实打实生效的。
社区贡献的 GCBurn 测试(模拟 Cache Server / Stateless Server / Worker Server 三种场景)里,Satori 的分配速率在极端 Cache Server 场景略逊于 Server GC——但现实中没有一秒分配两千万个对象的应用,这算不上瓶颈;而暂停时间上 Satori 稳定做到了亚毫秒级,峰值内存在 Stateless 场景也是全场最低。
一行代码上手
重点来了。现在不用自己去编译 runtime 了,Satori 已经被封装成 MSBuild SDK 传到了 NuGet(PublishWithSatoriGC),项目文件里加一行就行。
.NET 10:
<Sdk Name="PublishWithSatoriGC" Version="10.0.13-satori.38707290103.1" />
.NET 11:
<Sdk Name="PublishWithSatoriGC" Version="11.0.0-satori.38707303131.1" />
版本跟着上游开发滚动更新,记得定期检查新版。之后 dotnet publish 走起,注意只有自包含发布才生效,所以要带 --self-contained。NativeAOT、ReadyToRun、裁剪、单文件都支持:
dotnet publish -c Release -r win-x64 --self-contained /p:PublishSingleFile=true /p:PublishTrimmed=truedotnet publish -c Release -r win-x64 /p:PublishAot=true
平台覆盖 Windows、Linux、macOS,.NET 11 还支持 Android 和 iOS。发布完可以用 GC.GetConfigurationVariables().ContainsKey("SatoriGC") 验证是否启用。两个模式里 Interactive(默认)偏重吞吐和资源占用,LowLatency 偏重延时保证,运行时用 GCSettings.LatencyMode 动态切。
写在最后
Satori 真正有意思的地方,不在于它是个并发 GC——并发 GC 多了去了——而在于它重新问了一个根本问题:最频繁的那部分回收,真的必须是全局问题吗?
它的答案概括起来就四句话:短命对象尽量在线程本地解决;广泛共享了就及时退回全局路径;全局 GC 尽量并发推进,但不强迫日常路径为对象移动买单;内存占用靠主动归还和克制的元数据设计,而不只靠回收快。
当然也要泼点冷水:这目前仍是个人实验项目,不是官方 runtime 的一部分,生产环境慎用。但不管它最终能不能进主线,这个方向本身已经足够有价值——.NET 的 GC 版图上,终于有人认真尝试了一条和 ZGC、Shenandoah 都不一样的路。

