3

[翻译]在GC上加入DPAD

 3 years ago
source link: https://www.cnblogs.com/InCerry/p/put-a-dpad-on-that-gc.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

[翻译]在GC上加入DPAD

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

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

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

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


这是在说什么?是的,我们有一个在区域【原文叫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,另外也有大佬实现了一个Zero GC link,你只需要实现几个接口,就可以自定义GC。
  3. 以后.NET上GC重大功能的发布都会遵循这样一个步骤:功能开发 => 单独发布到clrgc.dll => 公开测试修复bug => 正式发布到coreclr.dll

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

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

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

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

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

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

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

当然,每一代的区域数量可能有很大的不同。但它们都由这些小的内存单元组成。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)。基本上,降级意味着一个对象不会像正常情况下那样得到提升。对于段来说,降级意味着我们将暂存段的一个范围设置为 "降级范围",这个范围只能从暂存段的中间一点到该段的末端。换句话说,我们永远不会把短暂段中间的一个范围设置为降级范围。这正是因为对于段,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,也避免了频繁扩容空间。

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

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

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

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

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

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

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

因此,该区域被并入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,相信很快就能和我们见面,不过看了maoni大佬提交的代码,发现个有趣的东西。

  • Region只支持64位操作系统。从下图中的提交来看,限定了只有64位操作系统才能使用,让我不禁想到ZGC的染色指针,通过染色指针来减少写屏障的使用,进一步降低STW时间。如果支持了染色指针那么标记可能也会采用三色标记,主流的GC算法也趋于一致了。

img

  • 和Hex大佬讨论了一下,后面觉得除了azul的C4算法以外.NET GC也有可能会采用CoCo算法来实现,CoCo也是一种低延时的算法,具体可以看看这篇论文,而且已经有人在.NET上实现了这个。

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

  • 《.NET Core底层入门》:由国内精通C++ 汇编的大佬从2017年阅读CLR源码后编写,写的十分详细并且具有极大的参考意义,主要是介绍CoreCLR的,中间GC的部分也写的很清楚。

image-20210627144554442

  • 《.NET 内存管理宝典》:由国外研究.NET GC的大佬编写,主要围绕着.NET的内存分配、GC执行流程、问题诊断进行介绍,是一本不可多得的好书。

image-20210627144625071

其它文章


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK