- C#
- 低延时:想要停顿短,就得把更多工作搬到并发阶段,代价是更复杂的屏障、更严格的不变量,日常路径成本变高;
- 高吞吐:想要平时跑得快,分配、读写引用的成本就得压到最低,代价是很多账攒到回收时一次算清,停顿容易做长;
- 低占用:想要省内存,就得更积极地回收、整理碎片、把空闲内存还给操作系统,这些动作本身也烧资源。
- 神龙合击 2020-6-30
- 全国被吐槽最狠的三家快递公司,服务态度差,投诉都没用! 2023-7-21
- 宝宝叛变 2021-11-12
- 阿拉德 -- 源码+端 2022-10-7
- 666
- 双十一礼包
- 语音合成
- 揽胜版本
- 渲染层级
- 快递公司
- racelmg
- iPhone
- 启航正式版
- CentOS
- 白猪插件
- 挂机管理
- 三国OL
- 装备属性
- TeamViewer
- v162
- GM命令
- 排骨面
- 封号提醒
- race代码
- 输赢率
- 客户端
- 图片
- 检测
- Win11激活
- 抖音精简版
- 资源分享
- 系统更新
- 修复版本
- 翎风
- 学习资料
- 登录器闪退
- 问剑之巅
- 天天快递
- 举报处理
- Agent
- V8引擎
- 脚本修改
- 直升高中
- 热血传奇
- Boss
- 发布站劫持
- 情怀
- 黑神话
- 黑群晖
- Steam
- NewFullMailEx
- 幻想
- 长期熬夜
- framejson
- data目录
- 地图贴图
- 盘古
- 舅舅回应
- 勾芡
- SDK
- 林诗栋
- 礼包激活码
- itemstylejson
- 工程文件
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=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 都不一样的路。
有兴趣的话,仓库地址是 github.com/VSadov/Satori,NuGet 搜 PublishWithSatoriGC,一行代码就能自己跑跑看。