如何实现Satori GC,既高吞吐又低延时且内存占用低?

摘要:前言 GC 的设计里一直有一个很难绕开的矛盾:高吞吐、低延时、低内存占用,通常很难同时做到。 传统做法里,想要更短的停顿,往往要把更多工作搬到并发阶段,甚至让平时的对象访问承担更高成本;想要更高的吞吐量,又往往意味着平时路径成本必须足够低,
前言 GC 的设计里一直有一个很难绕开的矛盾:高吞吐、低延时、低内存占用,通常很难同时做到。 传统做法里,想要更短的停顿,往往要把更多工作搬到并发阶段,甚至让平时的对象访问承担更高成本;想要更高的吞吐量,又往往意味着平时路径成本必须足够低,于是更多工作会堆到回收阶段;想要更低的内存占用,则又需要更积极地回收、整理和归还内存。 .NET 的 Satori GC 最有意思的地方不在于它把某个现有方向做得更激进,而在于它在设计上先问了自己:最频繁的那部分回收,真的必须是全局问题吗? GC 在做什么 对于托管语言来说,GC 要做的事情并不复杂: 找出哪些对象仍然存活 回收已经不可达的对象 必要时移动存活对象,减少碎片并维持分配效率 麻烦主要出在第三件事。对象一旦被移动,所有指向它们的引用都必须保持一致。为了保证这一点,GC 在某些阶段需要暂停用户线程。GC 暂停整个程序的行为通常也叫 STW。 分代 GC 之所以不需要每次都扫描整个堆,是因为它利用了一个非常重要的经验事实:大多数对象死得很快。 因此现代 GC 通常都会做分代: Gen 0:最新创建、也最容易很快死掉的对象 Gen 1:活过几轮回收,但还不算特别老的对象 Gen 2:已经证明自己很能活的长寿对象 分代的意义在于把最频繁的回收工作限制在最年轻的那一层。只要大多数短命对象都死在 Gen 0,那么大部分 GC 就不需要碰到更老的对象。 写屏障和卡表 分代 GC 还有一个关键问题:老对象可能引用新对象。 如果下一次只回收 Gen 0,而 GC 不知道某个 Gen 2 对象里正好存着一个指向 Gen 0 对象的引用,就可能把本来还活着的对象误回收。 所以 GC 需要一种增量记录机制。最常见的做法是: 当程序写入对象引用时,顺手执行一个很小的额外动作,这叫写屏障 把对应内存区域标成“这里可能需要重点检查”,这叫卡表 这样 GC 在回收年轻代时,就不必重新扫描整个老年代,而只需要检查被写屏障标脏的那部分区域。 不可能三角 GC 最痛苦的地方在于,高吞吐、低延时、低内存这三个目标经常互相打架。 如果想要低延时,通常就得把更多工作搬到并发阶段,让程序和 GC 尽量同时工作。问题是,并发不是白来的。为了保证并发期间不出错,往往需要更复杂的屏障、更严格的不变量,甚至更多额外空间。 如果想要高吞吐,通常又希望平时路径尽量成本够低。也就是说,分配对象、读取引用、修改引用这些日常动作最好不要成本太高。问题在于,如果日常什么都不做,很多账就得在真正回收时一次性算清,这又容易把停顿做长。 如果想要低内存占用,通常需要更积极地回收、更积极地整理碎片、甚至更积极地把空闲内存还给操作系统。但这些动作本身也要消耗时间和资源。 所以不同 GC,本质上是在回答同一个问题:我到底把成本放在哪里? Satori 的目标 Satori 不是一个完全脱离 .NET 运行时的玩具实验。它直接接在真实运行时接口上,也就是说,它不是纸上谈兵,而是真的在现实约束下重新组织回收方式。 它的目标很明确: 尽量少调参,最好能自动适应工作负载 避免长时间停顿 在现实功能约束下保持完整可用 这里“现实功能约束”很重要。因为一个 GC 如果只是把难题都删掉,那当然容易写得漂亮。但 Satori 并不是这么做的。它仍然要支持内部指针、终结器、弱引用、依赖句柄、可卸载类型、精确根扫描和保守根扫描。 也就是说,Satori 不是回避真实世界场景,而是在真实世界场景下重新组织 GC 的工作。 Page、Region 和代 Satori 的堆组织方式围绕 Page 和 Region 这两个概念展开。 Page:更大的预留单位 Region:Page 内部更小、更适合独立管理的单位 一个大 Page 里可以包含很多个小 Region,而 GC 可以围绕这些 Region 做更细粒度的管理决策。 这里 Region 很重要,因为在 Satori 里,Region 不只是物理切分方式,它还是: 分配单位 线程本地所有权单位 Gen 0 局部回收单位 移动和整理的规划单位 空闲内存归还时的处理单位 很多 GC 虽然也会把堆切成很多小块,但这些小块更多只是方便调度。Satori 不一样,Region 本身就是它的核心抽象。 Satori 在 Page 和 Region 周围维护了不少紧凑的元数据。Page 里会有卡表、Region 映射和更粗粒度的卡组信息;Region 里则会有位图,记录对象的关键状态,例如是否已标记、是否已逃逸、是否被固定。这些元数据决定了 Satori 后面能不能高效地做线程本地回收、逃逸跟踪和局部压缩整理。 把 Gen 0 变成本地问题 Satori 最关键的想法可以用一句话概括:如果一批新对象几乎都只在线程内部短暂存在,那为什么回收它们时一定要把全世界都叫停? Satori 在分配小对象时,仍然有每线程的快速分配上下文,所以正常情况下分配非常直接。但它比传统路径多做了一层非常关键的设计:线程不是随便向全局堆索要空间,而是尽量在自己手里的 Region 里分配。 如果当前 Region 还有空间,事情很简单,继续往里放对象。如果当前 Region 快没空间了,Satori 的第一反应也不是立刻说“这块用完了,交给全局 GC,再去拿一块新的”。它会先判断这个 Region 还适不适合在线程内部自己清理一下。 这背后的前提是现实程序里非常常见的一种模式: 对象是刚创建的 对象生命周期很短 对象主要只在当前线程里流转 如果一块 Region 满足这些条件,那么它的回收就没有必要上升为全局问题。当前线程可以在有限的范围内自行完成这块 Region 的 Gen 0 回收。这就是 Satori 的 thread-local Gen 0。 普通 Gen 0 回收虽然也是在回收新对象,但通常仍然需要站在整个进程角度来想问题:所有线程现在是什么状态,老对象里哪里可能指向新对象,哪些卡表需要扫描,哪些全局结构需要同步。 Satori 的 thread-local Gen 0 则把问题收缩成: 当前线程自己的栈上还拿着哪些对象 当前 Region 里哪些对象已经和外界产生关系 正是因为它把范围限制得很小,局部回收才变得现实。 逃逸跟踪 Satori 用逃逸跟踪来判断一个 Region 还能不能继续保持 thread-local 特性。 一个对象一开始可能只在线程内部使用,但之后完全可能逃逸到别处。例如: 放进全局缓存 挂到别的线程也能访问到的对象上 丢进任务队列,之后由其他线程继续处理 一旦发生这种事,这个对象就不再是纯线程本地的了,它已经和外界建立了联系。这就是逃逸。 线程本地回收成立的前提是这个 Region 里的大部分对象真的主要属于当前线程。但如果对象不断逃逸出去,那这个 Region 的线程局部性就会越来越弱。继续强行按线程本地方式处理,只会越来越不划算。 所以 Satori 的做法不是假装线程本地永远成立,而是显式跟踪逃逸。一旦某个对象逃逸,Satori 不只会记住这个对象本身,还会沿着这个对象在当前 Region 里的引用,把仍然因此对外可达的对象也一并纳入考虑。 但如果只是少量对象逃逸,线程本地回收仍然很值得做,因为整体上这个 Region 依然主要由当前线程使用。真正的问题是逃逸越来越多时怎么办。 Satori 在这里设了一个很现实的阈值:当逃逸量大到一定程度时,就不再把这个 Region 当成 thread-local Gen 0,而是把它转入更全局的代际管理。 这个阈值的意义很明确: 如果只要一发生逃逸就立刻放弃 thread-local Gen 0,那么很多本来仍然很划算的场景也会失去收益 如果无论逃逸多少都坚持 thread-local Gen 0,那么局部回收又会变得越来越不划算 Satori 选择的是在这两者之间取一个平衡点。 线程本地回收 理解了 thread-local Region 和逃逸跟踪以后,就可以看线程本地回收本身了。 它之所以有机会快,不是因为它做的事情更少,而是因为它处理的范围更小、要看的根更少。 1. 判断回收的必要性 Satori 不会一看到空间紧张就立刻做局部回收,它会先判断这次回收值不值得。例如: 离上一次局部回收是不是太近了 逃逸是不是已经太多了 存活对象是不是已经多到不适合本地小扫除 如果这些条件说明回收很可能不划算,那就不做,直接把问题交给更全局的路径。 2. 从更小的根集合开始标记 如果仍然适合做线程本地回收,那么它要看的根集合其实很有限,主要是两类: 当前线程栈上仍然指向该 Region 的对象 已经逃逸出去、因此对外可达的对象以及它们在 Region 内可达的对象 这和全局 Gen 0 回收相比,差别非常大。全局 Gen 0 回收需要考虑整个进程里的线程状态、老年代到年轻代的引用以及各种全局结构;线程本地回收则只需要处理当前线程栈和当前 Region 内已经逃逸出去的那部分对象图。 3. 规划整理方案 标记完活对象之后,还不能立刻宣布结束。因为 Region 里可能已经出现很多空洞。 这时 Satori 会判断: 活对象有哪些 这些活对象搬到哪里会更紧凑 哪些引用之后需要更新 本质上这一步是在把“哪些对象需要留下、它们应该放到哪里、之后哪些引用要被改写”先计算清楚。 4. 更新引用和局部压缩整理 最后才是真正的整理阶段。对象如果被搬到了更紧凑的位置,所有指向这些对象的引用也要同步更新。等引用都更新完,Region 内部就可以重新变得连续,后续分配也更顺畅。 而且这里它整理的不是整个堆,而只是一个小 Region。这就是为什么它有机会把停顿控制得很小。 举个例子 假设一个网络请求在线程 A 上处理。这个请求会创建很多短命对象,例如请求上下文、路由匹配结果、解析数据时的中间结果、若干临时字符串和列表。 在 Satori 里,这些对象很可能先进入线程 A 当前持有的某个 Region。 如果请求结束后,这些对象都没有被放进全局缓存,也没有被交给别的线程,那么当这个 Region 空间变紧时,线程 A 完全可以先做一次局部回收,把这批短命垃圾清掉,然后继续在原 Region 里分配。 如果请求处理中有一部分对象被放进了全局缓存,或者被封装成任务交给线程池里的另一个线程,那这些对象就发生了逃逸。 如果只有少量对象这样做,Satori 仍然可以维持这个 Region 的线程本地特性,因为整体上它依然主要由线程 A 使用。但如果这种共享越来越多,最后逃逸量超过阈值,这个 Region 就不再适合继续走 thread-local Gen 0 的路线。它会退出私有状态,转入更全局的 GC 流程。 这就是 Satori 的基本策略:能在线程本地解决,就尽量在线程本地解决;一旦局部性不再成立,就及时退回全局路径。 全局 GC Satori 当然不只是一个线程本地回收器。它同样有完整的全局 GC 体系,用来处理这些场景: 逃逸已经很多的 Region 更老的对象 全局内存压力 Region 之间的移动与整理 Satori 的思路不是只做局部回收,而是把最频繁、最适合局部化的那部分工作先拿走,剩下真正需要全局处理的事情,再交给全局 GC。 全局 GC 阶段 一次全局 GC 大致还是绕不开几件事: 标记哪些对象还活着 规划哪些 Region 值得整理、哪些 Region 值得移动 更新引用 必要时移动和压缩整理 Satori 的目标不是把这些阶段全部变没,而是让其中尽可能多的部分和应用程序并发进行。它追求的不是永远绝对零停顿,而是尽量不要把与堆大小成比例的大工作放到阻塞阶段里一起做。 可选移动 这里恰好能看出 Satori 和另一类低延时 GC 的哲学差异。 很多极低停顿 GC 之所以能把停顿时间压得非常稳定,是因为它们愿意为对象随时可以并发移动这件事付出更高的日常成本。也就是说,平时每次访问对象时,程序都要遵守更强的规则。 Satori 没有默认走这条路。它承认对象移动和压缩整理很重要,但它不把任何时候都必须无条件并发移动设成铁律。Region 级别的移动是可选能力,而不是必须永远打开的总开关。 这背后的取舍其实很清楚: 如果你坚持让任何对象都能随时并发移动,平时路径通常成本会更高 如果你允许移动变成一种按需使用的能力,平时路径就更有机会降低成本 Satori 选择的是后者。这也是它为什么有机会保住吞吐量。 让应用线程帮忙推进回收 Satori 还有一个非常实用的设计,就是应用线程协助推进回收。 如果程序分配内存的速度非常快,而并发回收推进得不够快,那么垃圾会越积越多。到了最后,就可能不得不用一次很重的阻塞阶段去把进度追回来。 Satori 的做法是:当检测到这种风险时,正在分配内存的线程自己也会顺手做一点回收推进工作。 这样做的好处有两个: 避免分配速度彻底甩开回收速度 避免最后只能靠一次很重的停顿把问题补回来 换句话说,Satori 不是把所有并发成本平均摊到每一次访问上,而是更倾向于在真的快失控的时候才让应用线程多帮一点忙。这也是它兼顾吞吐量和低延时的关键技巧之一。 低内存占用 低延时 GC 往往更吃内存,这并不奇怪。因为想把停顿做短,通常就意味着: 更多并发阶段 更多缓冲空间 更保守的内存保留 更多用于协调正确性的元数据 Satori 之所以有机会把内存占用也压下来,是因为它不是只在“怎么回收”上想办法,而是在三个方向一起发力。 1. 让短命垃圾尽量死在年轻阶段 如果一个对象本来只在线程内部短暂存在,那么最理想的情况就是它还没来得及混进更老的代,就已经在线程本地回收里死掉了。 这会直接带来两个好处: 更老的代不会被大量短命垃圾污染 后续全局 GC 要处理的活对象总量也会变小 thread-local Gen 0 机制本身,就已经在替低内存占用打基础。 2. 显式把空闲内存还给操作系统 Satori 里还有一个专门负责整理空闲 Region 并归还内存的线程。 它不负责主回收逻辑,而是负责做很务实的事情: 看哪些 Region 已经空得足够明显 尝试合并相邻的空闲 Region 把已经不需要保持提交状态的内存还给操作系统 更重要的是,它不是一股脑猛冲,而是限速的。它会控制扫描和归还节奏,避免为了省一点内存反而把系统抖得很厉害。 3. 尽量减少元数据开销 GC 除了要管对象本身,还要维护很多辅助状态。如果这些辅助状态一味膨胀,哪怕对象回收得再好,整体内存占用也可能不好看。 Satori 在这方面有一个很巧妙的做法:它会尽量复用对象头附近在 64 位环境下尚未充分利用的空间,去存放一些临时信息,例如局部整理时需要的链接信息或移动后的转发表信息,而不是动不动就额外开一大堆旁表。 实现不可能三角:同时做到高吞吐、低延时和低内存占用 现在把前面的设计拼起来,就能比较清楚地看到 Satori 的逻辑了。 为什么能做到低延时 因为它把最频繁的年轻对象回收,从全局协调问题变成了线程局部问题。 只要对象大多还停留在线程内部,一次回收只需要处理一个小 Region、当前线程自己的栈以及少量已逃逸对象,而不是让所有托管线程停下来配合。 为什么能做到高吞吐 因为它没有默认选择让每次对象访问成本都变得更高的路线。 Satori 的主要思路不是把强约束铺满所有日常路径,而是: 先把最常见的小垃圾局部化 再用并发全局 GC 和应用线程协助推进,去兜住更大的压力 另外,局部回收还能减少短命垃圾升入更老代的机会,这又进一步减轻了后续全局 GC 的扫描和整理压力,对吞吐量也是加分项。 为什么还能做到低内存占用 因为它不是只会更快回收,还会同时做这些事: 让短命垃圾更早死掉,减少升代污染 显式归还空闲提交内存 控制元数据本身的膨胀 所以 Satori 的低内存占用,不是某个单点技巧带来的,而是一整套设计共同作用的结果。 和其他 GC 的对比 这里把几类常见 GC 放在一起比较,最重要的不是看谁在哪个 benchmark 里赢了,而是看它们各自把成本放在哪里。 Workstation GC 和 Server GC 这两个 GC 在架构上是同一条线上的不同形态,而不是两套完全不同的算法。 它们都采用分代设计,依赖写屏障和卡表来处理老对象指向年轻对象的引用;小对象堆仍然是 Gen 0 / Gen 1 / Gen 2 的分层,老年代回收则会进入更重的标记、规划和整理阶段。 Workstation GC 更适合较轻量、较交互式的场景。它的特点是: 更偏向单堆、较克制的资源使用 Gen 0 / Gen 1 仍然是前台 STW Gen 2 可以做后台回收,但整体并行度有限 它的优点是实现成熟、资源占用相对克制;缺点也很明确,面对高并发和高分配率时,吞吐量上限会比较早暴露出来。 Server GC 则是在同样的基本模型上,把并行能力拉高。它会给每个逻辑处理器准备独立的堆和更强的 GC 线程资源,因此更适合服务器场景。代价通常是: 堆更大 线程更多 资源占用更重 但它们有一个共同点没有变:最频繁的 Gen 0 / Gen 1 回收本质上仍然是全局 STW 的一部分。Satori 和它们最大的不同点,就在于它先动刀的是这条最频繁的路径。 DATAS DATAS 不是一套全新的 GC 结构,而是叠加在现有 Server GC 之上的策略层。 它解决的是:我应该给这个程序多大的堆预算、多少个堆、怎样控制 Gen 0 的增长,以及怎样让堆大小更贴近真正的长寿数据规模? 也就是说,DATAS 改的是策略,不是机制。它让现有 Server GC 更聪明,但并不改变“最频繁的年轻代回收仍然是全局路径的一部分”这件事。 Satori 则是在解决另一个层次的问题:最频繁的年轻对象回收,到底能不能不走全局路径? G1 G1 也是按 Region 管理堆,但它和 Satori 使用 Region 的目的并不一样。 G1 的核心思路是: 把堆切成很多固定大小的 Region 通过跨 Region 引用表记录 Region 之间的引用关系 通过快照式并发标记和写屏障支持并发标记 在回收时把待回收 Region 里的存活对象复制到别处 也就是说,G1 的 Region 主要服务于全局平衡和停顿目标控制。哪些 Region 该进入回收集合、哪些 Region 该做混合回收、哪些 Region 该参与对象复制,都是围绕全局调度在转。 Satori 也使用 Region,但它更进一步:Region 不只是调度单位,还是线程本地所有权、逃逸跟踪和局部回收的边界。这是 Satori 和 G1 在设计哲学上的最大区别。 ZGC 和 Shenandoah 这两类低延时 GC 选择的是另一条路线。 它们的共同点是:愿意在日常路径里承担更强的运行时机制,以换取更稳定的并发移动能力。但它们的具体实现并不一样: ZGC 的核心设计是把一部分 GC 状态直接编码到指针里,再配合读屏障;到了分代 ZGC,又进一步加入了写入时的额外屏障。它的目标非常明确,就是让并发移动成为默认能力,从而尽量让 STW 时间不随着堆大小一起增长。 Shenandoah 的核心设计则更接近对象间接访问 + 并发移动压缩。它会在每个对象上多维持一层间接引用,用来支持并发移动。所以它平时承担的成本形态和 ZGC 不完全一样,但本质上仍然是在用更强的运行时机制换取更强的低停顿能力。 Satori 没有默认采用这条路线。它没有把对象必须随时可以并发移动当成前提,而是把移动设成可选能力,同时把最频繁的 Gen 0 回收局部化。这样一来,它就不需要像 ZGC 那样默认接受每次读对象都要经过额外检查的成本,也不需要像 Shenandoah 那样默认接受每个对象都多一层间接访问的成本。 所以 Satori 和 ZGC、Shenandoah 的差别,不是谁更激进,而是谁把成本放在平时,谁把成本放在回收边界设计上。 总结 Satori 真正有意思的地方,不在于它是一个并发 GC,而是它重新思考了最频繁的那部分回收应该怎么做。 它的核心思路可以浓缩成四句话: 短命对象先尽量在线程本地解决 一旦对象开始广泛共享,就及时退回全局路径 全局 GC 尽量并发推进,但不强迫所有日常路径都为对象移动买单 内存占用不只靠更快回收,还靠主动归还空闲内存和克制的元数据设计 如果这条路最终成熟,它带来的意义可能不只是多了一个实验性 GC,而是给 .NET 提供了一种非常不同的 GC 设计方向。