.NET GC - 我们为GC加上了DPAD功能

本文90%通过机器翻译,另外10%译者按照自己的理解进行翻译,和原文相比有所删减,可能与原文并不是一一对应,但是意思基本一致。

译者水平有限,如果错漏欢迎批评指正

译者@Bing Translator、@InCerry,另外感谢@Hex、@晓青、@贾佬、@黑洞百忙之中抽出时间帮忙review和检查错误。

原文链接:https://devblogs.microsoft.com/dotnet/put-a-dpad-on-that-gc/.

.NET GC - 我们为GC加上了DPAD功能

这是在说什么?是的,我们有一个在区域【原文叫region】上叫做DPAD的新功能。区域是我们目前在.NET 6中用于替换段【原文叫segment】的新东西。在这篇博文中,我将首先对区域做一些介绍,然后谈谈DPAD功能。请注意,我们不太可能在.NET 6.0结束时正式支持区域,因为这涉及到很多工作--我们目前的计划是在clrgc.dll中把它作为一个实验性的功能,你可以通过配置来打开。事实上,这就是我希望从现在开始的大型GC功能的发布方式,我们首先将它们与独立的GC一起发布(即在clrgc.dll中),这样人们就可以尝试它们,然后我们在coreclr.dll中正式开启它们,这样它们就默认开启了。

译者注:

原本.NET的GC是分段式GC,也就是说GC管理内存的单位是段,而现在改了,改成区域了,另外这一段中Maoni大佬其实透露三个重要的信息:

  1. 段内存分配的方式结束了,将使用区域的方式来替代段内存分配。
  2. .NET 6.0中大概率不会支持区域,但是会通过clrgc.dll的方式独立提供,你可以通过配置的方法打开,大家要注意这个独立提供,因为从.NET Core 2.1开始我们就可以自定义GC了,也就是说你开心的话,可以自己写一个GC,然后替换掉.NET自带的GC;使用的环境变量是这个link[7],另外也有大佬实现了一个Zero GC link[8],你只需要实现几个接口,就可以自定义GC。
  3. 以后.NET上GC重大功能的发布都会遵循这样一个步骤:功能开发 => 单独发布到clrgc.dll => 公开测试修复bug => 正式发布到coreclr.dll

到目前为止,如你所知,我们一直在段上运作。段多年来为我们提供了很好的服务,但我开始注意到它的局限性,因为人们把更多种类的工作负载放在我们的框架上。段是我们内存管理的基础,所以从段转换成区域是件大事。当我们接近.NET 6发布时,我决定是时候摆脱段式了,所以这是我们的团队最近花费大量时间的地方。那么,段和区域之间的主要区别是什么?段是大的内存单位--在Server GC 64-bit上,如果段的大小是1GB、2GB或4GB(在工作站模式下更小-256MB),而区域是小得多的单位,它们默认为每个4MB。所以你可能会问,"所以它们更小,为什么有意义?"。要回答这个问题,首先让我们回顾一下段是如何工作的。

如果您看不明白上面的这一段文字,那么建议您先补一下基础的知识,微软的官方文档[9]。里面详细的介绍了.NET GC的基础知识,包括什么是分代、垃圾回收的过程、服务器GC与工作站GC、并发GC、后台GC等等。

目前,当我们只有一个段时,SOH在堆上是这样的:

.NET GC - 我们为GC加上了DPAD功能

当我们有多个段时,它可以看起来像这样

.NET GC - 我们为GC加上了DPAD功能

或这样

.NET GC - 我们为GC加上了DPAD功能

蓝色和黄色的空间是一个段上所有已提交【已提交:是指由操作系统分配给应用程序使用的内存】的内存(关于Gen【代】开始的解释,请看这个视频[10],)。每个段都会记录该段上已提交的内容,以便我们知道是否需要提交更多。而该段上的所有空闲空间也是已提交的内存。当我们使用空闲空间来容纳对象时,这很有效,因为我们可以立即使用内存--它已经被提交。但是想象一下这样的场景:我们在某一代有空闲空间,比如说gen0,因为有一些异步IO正在进行,导致我们在gen0中降级了一堆pin对象,但我们实际上并没有使用(这可能是由于没有等待这么长时间来做下一次GC,或者我们已经积累了太多的活着的对象,这意味着GC暂停会太长)。如果我们能将这些空闲空间用于其他代,如果他们需要的话,那不是很好吗?gen2和LOH中的空闲空间也是一样的--你可能在gen2中有一些空闲空间,如果能用它们来分配一些大的对象就好了。我们在段上做撤销提交【uncommit:已提交的反向操作】,但只是在段的末端,也就是在该段上最后一个活对象之后(由每个段末端的浅灰色空间表示)。而如果你有pin对象,就阻止了GC收回段的末端,那么只能形成自由空间,而自由空间里是已提交的内存。当然,你可能会问,"为什么不直接把有大量自由空间的段的中间部分取消提交?"。但这需要记录,以记住段中间的哪些部分被解密,所以当我们想用它们来分配对象时,我们需要重新提交它们。而现在,我们已经进入了区域的概念,也就是让更小的内存量被GC单独操作。

如果您看不懂上面这段文字,那么说明您需要翻阅一下下面这些资料,来了解已提交内存、pin对象、固定对象堆等等

  • https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/keywords/fixed-statement
  • https://zhuanlan.zhihu.com/p/376419012

有了区域,各代人看起来是统一的,我们不再有这种 "短暂的片段 "概念。我们有gen0和gen1区域,就像我们有gen2区域一样。

.NET GC - 我们为GC加上了DPAD功能

当然,每一代的区域数量可能有很大的不同。但它们都由这些小的内存单元组成。LOH的区域确实更大(LOH是SOH区域大小的8倍,所以每个32MB)。当我们释放一个区域时,我们将其返回到自由区域池中,该池中的区域可以被任何一代抓取,甚至在需要时被任何其他堆抓取。因此,你不会再看到这样的情况:你在gen2或LOH中有一些巨大的空闲空间,但它们很长时间都没有被使用(如果你的应用程序的行为经历了一些阶段,其中一个阶段可能比另一个阶段生存更多的内存,而GC认为没有必要做一个完整的压缩GC,这种情况就可能发生)。

在GC工作中,我们总是要做出权衡。有了区域,我们确实获得了很多灵活性。但我们也不得不放弃一些东西。有一件事使段非常有吸引力,那就是我们确实有一个连续的短暂范围,因为gen0和gen1总是生活在短暂的段上,而且总是紧挨着。当我们在写 屏障中设置卡片时【在GC有一个card tables,用来记录对象之间的跨代引用,另外就是实现写屏障,详细可以翻阅《.NET Core底层入门》P289】,我们利用了这个优势。如果你做obj0.f = obj1,并且我们检测到obj1不在短暂的范围内。我们不需要设置卡片,因为我们不需要它(只有当obj1比obj0处于更年轻的一代时才需要设置卡片,如果obj1不在短暂的范围内,这意味着它要么在gen2,要么在LOH/POH,这些都被逻辑上认为是第二代的一部分(但内部被追踪为gen3和gen4,我在这篇文章中互换使用LOH和gen3)。而这意味着它要么与obj0处于同一代,要么处于比obj0更早的一代)。) 但是我们只对工作站GC做了这个优化,因为服务器GC有多个短暂的范围,我们不想在写屏障代码时要和所有的范围进行比较。在区域中,我们要么无条件地设置卡片(这将使Workstation GC的暂停倒退一些,但对Server GC保持相同的性能),要么在写屏障中检查obj1的区域,这将比在最优化的写屏障类型中检查短暂范围更昂贵。不过区域带来的好处应该比这更有说服力。

现在我们可以谈一谈DPAD功能。DPAD是动态升级和降级的意思。严格来说,降级已经是动态的了,因为它只根据Pin对象的情况动态发生。如果你读过我的备忘录,那里解释了降级(如果你没读过,我强烈建议你读mem-doc[11])。基本上,降级意味着一个对象不会像正常情况下那样得到提升。对于段来说,降级意味着我们将暂存段的一个范围设置为 "降级范围",这个范围只能从暂存段的中间一点到该段的末端。换句话说,我们永远不会把短暂段中间的一个范围设置为降级范围。这正是因为对于段,gen1必须在短暂段的gen0之前(在同一个堆上)。所以我们不能有一个gen1的部分,接着是gen0的部分,然后再接着是gen1的部分。

升级是GC中一个常见的概念--它意味着如果一个对象存活了一代,它现在被认为是上一代的一部分。因此,如果你在SOH上有一个长期生存的小对象,它最终会被提升到gen2。但这意味着这需要2次GC才能实现。我正计划提供一个API,让用户可以选择告诉GC将一个新的对象直接分配到某一代,所以你可以将你知道会存活到gen2的对象直接分配到gen2中(到目前为止我还没有实现这个API,因为有区域的支持也会更容易,所以我正计划在我们转换到区域时实现它)。但这并不包括所有的情况,因为有时用户很难知道一个对象是否会 "很可能存活到gen2"。而且你可能正在使用一个库,对这些对象的分配没有控制。一个非常明显的情况是,这种情况会发生在数据基础设施的大小调整上。比方说,你或你使用的库分配了一个List,它需要增加容量。所以它分配了一个新的T[]对象,可以容纳两倍于旧对象的元素数量。现在它为第二部分创建了一堆子元素。现在,如果新的数组足够大,可以上LOH,而且新的子元素都是小对象,所以它们在gen0 -

通过上文的描述,Maoni大佬的团队计划实现一个GC的API,可以让用户指定你的对象分配到某一代中(默认都是从G0开始)。

比如我们经常会有这样一些场景,我们在程序启动的时候会去读一些数据,将它们缓存到内存中,这些缓存直到程序关闭才会释放,也就是说开发者能知道最终它会到gen2;如果没有这个API,那么你缓存的对象将从gen0开始,经过两次GC才到gen2,一般缓存的数据都比较大,导致GC在标记和整理过程中会花更多的实际,而且可能由于可用内存不足,会频繁的去申请空间;如果有了这个API,开发者就能将对象直接分配到gen2,避免了gen0和gen1的GC,也避免了频繁扩容空间。

.NET GC - 我们为GC加上了DPAD功能

(为了说明问题,我只展示了一个8元素的数组和4个新的孩子,如果这是一个对象[],显然它需要更多的元素才能进入LOH)

在片段的情况下,我们会看到这样的情况:

.NET GC - 我们为GC加上了DPAD功能

由于新的数组被认为是gen2的一部分,这意味着所有在gen0中创建的新元素都将存活到gen2中(除非gen2的GC很快发生,并发现父数组已经死亡,这有可能发生,但可能性不大;如果真的发生,那就非常不幸了,因为你花了这么大代价创建一个大对象,却马上把它抛弃)。但要做到这一点,它至少需要经过两次GC。我们很有可能首先观察到一个gen0或gen1的GC,这个GC会让这些孩子生存到gen1。

.NET GC - 我们为GC加上了DPAD功能

然后下一个gen1的GC会发现他们都还活着,因为他们被LOH中的那个阵列保持着活力。现在它把它们都提升到Gen2

.NET GC - 我们为GC加上了DPAD功能

在这种情况下,我们更愿意直接将它们分配到gen2。但是这对段来说是很难做到的。我们可以跟踪哪些对象由这些对象组成,或者主要由这些对象组成,但是当我们做标记时,我们不知道哪些对象会一起形成插头【Plug,被翻译成插头,详情可以看《.NET内存管理宝典》P371和《.NET Core底层入门》P323】。而当我们在形成插头时,我们已经失去了这些信息。我们可以在更大的颗粒度上跟踪这些信息。但你猜怎么着,这基本上就像区域一样!因为我们想把这些信息划分到不同的区域。因为我们想把一个区段划分成更小的单位来跟踪这些信息。所以对于区域来说,这是很容易的。当我们做标记时,我们确切地知道每个区域上有多少存活下来的东西--当我们标记每个对象时,我们跟踪我们需要把存活下来的字节归于哪个区域。所以我们知道有多少存活是由卡片标记完成的。

对于区域,当我们遇到一个主要由对象组成的区域时,如这些因卡片标记而被保留的子对象,我们有一个选择:

.NET GC - 我们为GC加上了DPAD功能

我们可以选择将这个区域直接分配到gen2 :

.NET GC - 我们为GC加上了DPAD功能

因此,该区域被并入gen2。属于gen0的另一个区域的幸存者被压缩到gen1区域,gen0得到一个新的区域用于分配。

在目前的实现中,我只对那些主要被像这样的对象填满的区域做了这个工作。由于区域很小,很可能有些区域被这些东西填满,然后我们有另一个区域部分被这些东西填满,部分被一些真正的临时对象填满。把它们分开的复杂性是不值得的(你可以把它看作是我们回到了这个特定区域的片段情况)。

当我们这样做时,会有一些复杂的情况(对于GC来说,几乎总是有一些复杂的情况......)。一个例子是,由于我们现在只是让gen0的对象在gen2中生存,我们需要确保如果它们指向任何不是gen2的代,就需要为这些对象设置卡片。当我们在重新定位阶段通过活着的对象时,我们会这样做(因为无论如何我们已经必须通过每个对象)。

所以双关语(部分)的意思是,这个DPAD功能有点像D-pad......你可以告诉一个区域它需要去哪个方向--向上或向下(在GC术语中是指年长或年轻)。有很多情况下,我们想动态地提升或降低一个区域,我上面举的例子只是其中之一。重点是,有了区域,我们可以动态地指定一个我们希望一个区域最终处于的代数,因为代数不再是连续的,而且没有特定的顺序,代数必须是相对的(当然,正如你在上面看到的,有一些实施细节需要为不同的场景所关注)。这比我们以前用分段做的有限的降级要灵活得多。而当我们在GC结束时对区域进行线程化处理时,我们只需要将它们线程化到它们所分配的区域。随着我对DPAD的初步检查,我已经实现了3个场景,我们将动态地促进或降级区域。在未来,我们会实现更多。

译者注

从maoni大佬的这篇文章我们可以看到主流的GC设计都越来越趋于一致了,第一眼看到region的时候我就想到了JVM上的ZGC(多占用一些内存和牺牲一定的吞吐量来达到亚毫秒级的STW时间),而目前看来.NET也在做类似的事情,不过我也不敢肯定,那么region能为我们带来什么呢,有得也有失:

  • 通常情况下会有更少的内存占用,特殊情况下更多的内存占用。因为region的一般来说只有4MB大小,而segment会有1GB~4GB大小,另外对于pin住的对象,segment也不能很好的进行处理,从而造成了内存碎片,会占用跟多的空间;region还有的有点就是释放后会返回到一个池中,哪个代需要使用就可以分配给哪个代,这比segment模式更加灵活,更能复用已申请的内存。为什么会说通常情况下,那是因为同样使用1GB内存region的数量肯定比segment要多,所以需要有额外的空间来记录region的引用,当堆很大(比如TB级别以上),可能会占用更多的内存。
  • 更少的STW时间。region很小,所以进行"标记-整理"中整理的步骤时,可以将整个region升代,加快了整理的的速度。
  • 吞吐量的下降。由于region上gen0和gen1不会在连续的地址空间上,所以内存屏障付出的代价会更大,从而造成吞吐量的下降,在此之前.NET的GC都是为吞吐量和P99延时优化的。

现在关于DPAD的代码已经合并到main分支中了,详情可以看这个PR[12],相信很快就能和我们见面,不过看了maoni大佬提交的代码,发现个有趣的东西。

  • Region只支持64位操作系统。从下图中的提交来看,限定了只有64位操作系统才能使用,让我不禁想到ZGC的染色指针,通过染色指针来减少写屏障的使用,进一步降低STW时间。如果支持了染色指针那么标记可能也会采用三色标记,主流的GC算法也趋于一致了。
.NET GC - 我们为GC加上了DPAD功能
img
  • 和Hex大佬讨论了一下,后面觉得除了azul的C4算法以外.NET GC也有可能会采用CoCo算法来实现,CoCo也是一种低延时的算法,具体可以看看这篇论文[13],而且已经有人在.NET上实现了这个。

如果您想更详细的了解.NET的GC和整个实现的原理,您可以看.NET Runtime部分的源码[14]和.NET GC架构师Maoni大佬[15]的博客,另外也有两本不错的书推荐。

  • 《.NET Core底层入门》:由国内精通C++ 汇编的大佬从2017年阅读CLR源码后编写,写的十分详细并且具有极大的参考意义,主要是介绍CoreCLR的,中间GC的部分也写的很清楚。
.NET GC - 我们为GC加上了DPAD功能
image-20210627144554442
  • 《.NET 内存管理宝典》:由国外研究.NET GC的大佬编写,主要围绕着.NET的内存分配、GC执行流程、问题诊断进行介绍,是一本不可多得的好书。
.NET GC - 我们为GC加上了DPAD功能
image-20210627144625071