.NET的Satori GC如何实现低延时高吞吐自适应的神奇效果?

摘要:GC 的 STW 问题 GC,垃圾回收器,本质上是一种能够自动管理自己分配的内存的生命周期的内存分配器。这种方法被大多数流行编程语言采用,然而当你使用垃圾回收器时,你会失去对应用程序如何管理内存的控制。C# 允许在自动控制内存的基础之上局部
GC 的 STW 问题 GC,垃圾回收器,本质上是一种能够自动管理自己分配的内存的生命周期的内存分配器。这种方法被大多数流行编程语言采用,然而当你使用垃圾回收器时,你会失去对应用程序如何管理内存的控制。C# 允许在自动控制内存的基础之上局部对内存进行手动控制,但是自动控制仍然是主要的场景。 然而 GC 总是需要暂停程序的运行以遍历和识别存活的对象,从而删除无效对象以及进行维护操作(例如通过移动对象到更紧凑的内存区域以减少内存碎片,这个过程也叫做压缩)。GC 暂停整个程序的行为也叫做 STW(Stop-The-World)。这个暂停时间越长,对应用的影响越大。 长期以来,.NET 的 GC 都一直在朝着优化吞吐量性能和内存占用的方向不断优化,这对于 Web 应用以及跑在容器中的服务而言非常适合。而在客户端、游戏和金融领域,开发人员一直都需要格外注意代码中的分配问题,例如使用对象池、值类型以及非托管内存等等,避免产生大量的垃圾和各种 GC 难以处理的反模式,以此来减少 GC 的单次暂停时间。例如在游戏中,要做到 60fps,留给每一帧的时间只有 16ms,这其中如果 GC 单次暂停时间过长,用户就会观察到明显的掉帧。 Workstation GC?Server GC?DATAS GC? .NET 一直以来都有两种 GC 模式 —— Workstation GC 和 Server GC。 Workstation GC 是 .NET 最古老的 GC 模式,其目标之一是最小化内存占用,以适配资源有限的场景。在 Workstation GC 中,它只会利用你一个 CPU 核心,因此哪怕你有多核的计算资源,Workstation GC 也不会去使用它们来优化分配性能,虽然 Workstation GC 同样支持后台回收,但即使开启后台回收,Workstation GC 也之多只会用一个后台线程。这么一来其性能发挥就会受到不小的限制。面对大量分配和大量回收场景时 Workstation GC 则显得力不从心。不过,当你的应用很轻量并且不怎么分配内存的时候,Workstation GC 将是一个很适合的选择。 而之后诞生的 Server GC 则可以有效的利用多核计算资源,根据 CPU 核心数量来控制托管堆数量,大幅度提升了吞吐量。然而 Server GC 的缺点也很明显——内存占用大。另外,Server GC 虽然通过并发 GC 等方式将一部分工作移动到 STW 之外,从而使得 GC 和应用程序可以同时运行,让 STW 得到了不小的改进,然而 Server GC 的暂停时间仍然称不上优秀,虽然在 Web 服务等应用场景下表现得不错,然而在一些极端情况下则可能需要暂停上百毫秒。 为了进一步改善 Server GC 的综合表现,.NET 9 引入了新的 DATAS GC,试图在优化内存占用的同时提升暂停时间表现。这个 GC 通过引入各种启发算法自适应应用场景来最小化内存占用的同时,也改善了暂停时间。测试表明 DATAS GC 相比 Server GC 虽然牺牲了个位数百分比的吞吐量性能,却成功的减少了 70%~90% 的内存占用的同时,暂停时间也缩减到 Server GC 的 1/3。 然而,这仍然不能算是完美的解决方案。开发者们都是抱着既要又要还要的心理,需要的是一个既能做到大吞吐量,暂停时间又短,同时内存占用还小的 GC。 因此,.NET 全新的 GC —— 在 .NET Runtime 核心成员几年的努力下诞生了!这就是接下来我要讲的 Satori GC。 Satori GC 为了让 GC 能够正确追踪对象,在不少语言中,编译器会给存储操作插入一个写屏障。在写屏障中 GC 会更新对象的引用从而确保每一个对象都能够正确被追踪。这么做的好处很明显,相比读操作而言,写操作更少,将屏障分担到每次的写操作里显然是一个更有效率的方法。然而这么做的坏处也很明显:当 GC 需要执行压缩操作时不得不暂停整个程序,避免代码访问到无效的内存地址。 而 JVM 上的一些低延时 GC 则放弃了写屏障,转而使用读屏障,在每次读取内存地址的时候通过插入屏障来确保始终拿到的是最新的内存地址,来避免无效地址访问。然而读操作在应用中非常频繁,这么做虽然能够使得 GC 执行压缩操作时不再需要暂停整个程序,却会不可避免地带来性能的损失。 GC 执行压缩操作虽然开销很大,但相对于释放操作而言只是少数,为了少数的操作能够并发执行拖慢所有的读操作显得有些得不偿失。另外,在 .NET 上,由于 .NET 支持内部指针和固定对象的内存地址,因此读屏障在 .NET 上实现较为困难,并且会带来吞吐量的严重下降,在许多场景下难以接受。
阅读全文