Satori GC 封装成 MSBuild SDK:一行代码体验实验性 GC
夜 1小时前

Satori GC 封装成 MSBuild SDK:一行代码体验实验性 GC

前两天看到 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 暂停:

GC 最大帧时间 平均帧时间 峰值内存
Server GC 323 ms 0.049 ms 5071.9 MB
DATAS GC 139 ms 0.146 ms 1959.3 MB
Workstation GC 23 ms 0.563 ms 563.4 MB
Satori GC 26 ms 0.061 ms 1449.6 MB
Satori(低延时) 8 ms 0.050 ms 1540.9 MB
Satori(低延时 + 关 Gen 0) 3 ms 0.042 ms 1566.8 MB

Server GC 的吞吐有了,最大暂停却只有它的零头。

Gen 0 压力测试

制造大量 Gen 2 → Gen 0 反向引用让 GC 忙起来,再猛分配短命对象触发 Gen 0 回收:

GC 单次暂停 吞吐量 峰值内存
Server GC 59 ms 7.485 iter/s 1286.9 MB
DATAS GC 60 ms 6.362 iter/s 859.7 MB
Workstation GC 1081 ms 0.804 iter/s 805.5 MB
Satori GC 0 ms 4.448 iter/s 801.4 MB
Satori(低延时) 0 ms 4.480 iter/s 804.8 MB

亚毫秒级暂停——可以说这个测试里 Satori 压根没让应用停下来过,同时内存占用是全场最低。

BinaryTree 压测(最新数据)

这是最新的二叉树压力测试结果,Satori 两种模式对 Workstation 和 Server(DATAS):

指标 Satori (Interactive) Satori (LowLatency) Workstation Server (DATAS)
总耗时 (ms) 12,611 15,383 35,290 16,112
最大暂停 (ms) 8.360 1.998 30.062 159.651
平均暂停 (ms) 0.381 0.233 3.366 2.850
P99.9 暂停 (ms) 8.224 1.867 26.852 150.798
P99 暂停 (ms) 1.759 1.126 24.465 41.442
峰值内存 (MB) 1,258 1,461 1,420 2,583
GC 后内存 (MB) 27.3 51.2 230.8 373.7

吞吐最高的同时,延时和占用还都是最低。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=true      dotnet 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 都不一样的路。

有兴趣的话,仓库地址是 github.com/VSadov/Satori,NuGet 搜 PublishWithSatoriGC,一行代码就能自己跑跑看。

最新回复 (0)
返回