红宝石垃圾收集深潜水:压实

2021-05-08 22:57:59

到目前为止,在这个系列中,我们已经讨论了GC :: Internal_Constants,三色标记和扫描算法,世代GC和增量GC。我们将建立在本帖子中学到的关于Ruby的GC的最新补充的内容:压缩。

在我们潜入压实之前,我们需要了解碎片。碎片是我们在非连续分配时描述存储器的术语。这意味着在我们存储有意义的信息之间的内存之间存在差距,以及我们不存储有意义的信息。

更具体地说,在Ruby堆中,碎片是在分配的插槽(带rvalues)之间有自由的,未分配的插槽(没有rvalues)。该图向我们展示了碎片的一个极端示例,在那里我们可以看到,在我们熟悉的rvalues中实际上有许多有意义的信息,有许多未分配的插槽,我们熟悉的rvalues:

压实是碎片的解决方案。好吧,但碎片有什么问题?什么是压实求解?

最明显的问题是碎片堆将占用比压缩堆更多的整体记忆。如果我们再次从上面看看我们的极端示例,那么所有这些rvalues都可以融入一个页面,而不是五个。与大多数事情一样,内存可以是一个有限的资源,显然它有效地利用它是有益的。

但事实证明还有其他理由要紧凑。让我们讨论其中一些!

操作系统通常将小块的内存加载到高速缓存中,而不是比其余内存更快地访问。在缓存中返回rvalue将花费更少的时间,而不是访问不在缓存中的rvalue。

因此,在每条内存中,我们有利于我们在每块内存中都可以将CPU加载到缓存中。通过这种方式,我们有更高的可能性,我们想要访问的rvalue已经在缓存中使用。但是,如果我们的堆是碎片化的,那么对于CPU加载到其缓存中的每个块,都有一堆免费插槽,并没有让我们更有效地访问任何真正的rvalue。但是,如果我们紧凑的堆,那么CPU缓存中的每个块都会被rvalues打包,因此我们更有可能向我们提供一个我们希望访问的rvalue。

压实的另一个动机是提高写入友好性的副本效率。在Ruby中,当进程被分叉时,子进程'存储点到父进程的内存。

但是,如果孩子想要注入内存,它就无法指向父进程的内存。相反,子进程想要写入的内存被复制到子进程的内存本身。然而,事实证明,CPU在块中复制内存,这些内存是OS页面的大小,比子进程可能想要写入的小的内存更大。

在碎片的堆中,这可能意味着大多数复制的内存实际上已经分配,​​或者已经包含rvalues。在这些情况下,操作系统需要每次想要编写时都能继续复制更多内存。

但是在压实的堆中,大多数(如果不是全部)在此块中复制的内存将被解置,免费插槽。这意味着如果子进程继续写入内存,则操作系统将不需要继续复制更多块,使副本更高效地写入友好。

因此,激励压缩的最后一个原因并没有与Ruby(尚未......)主动相关。如果你愿意,可以随意跳过它。

通常用于压缩的另一个原因是有效的内存使用情况。正如我们所知道的,在Ruby中,每个rvalue都是40字节。但是,在一些其他内存系统中,对象并非所有完全相同的大小。

在这些系统中,我们可以想象出可能存在可用的总内存对于新对象的情况绰绰有余,但它已以简单没有空间的方式构造。

这通常是要压实的主要原因。除了我注意到,它不适用于Ruby的GC,因为每个RValue都是完全相同的大小。但是...... Ruby GC的未来有一个不同尺寸(🤯)的rvalues,这意味着压缩也会有助于更有效的内存使用情况。

无论如何,足够了解为什么要紧凑。让我们讨论Ruby Compaction的工作原理。算法Ruby用于压缩的用途称为双指压缩算法。我知道,我知道,三种颜色,双手......我也很高兴看到未来的一个前缀的STRINEY RUBY的GC雇用了!

该算法实际上是在1964年在“编程语言LISP:其操作和应用程序”中编写的第一个算法。这是1964年的丹尼尔G. Bobrow。这是算法的算法今天(57年后!),所以我认为分享他们写的一些原文:

将两个指针设置为一个到自由存储的顶部,一个到底部。顶尖指针扫描言语,向下推进,寻找一个未标记的。找到一个时,底部指针扫描单词,向上推进,寻找标记的单词。当找到一个时,它被移入由另一个指针标识的位置。指针留在位置从中移动的位置,指向是否被移动到。然后,顶尖指针如前所述。当两个指针相遇时,该过程终止。

好的,现在解读这篇文章。此代码段实际上都讨论了双指压实的第一部分,这是移动物体。

两个指针是算法中的两个“手指”。一个人在堆的开头开始,另一端开始。该策略的主旨是,这两个指针将汇聚,从结束开始递增,递减,从结束时隙中的开头填充自由槽。

left = 0右=堆。尺寸 - 左侧1时<右,如果堆[左]。空的? && !!堆[右]。空的?交换(堆[left],堆[右])结束左+ = 1次!堆[左]。空的?右 - = 1,而堆[右]。空的?结尾

但是,如果您在本系列中一直在关注,您可能会记得一些rvalues可以参考其他rvalues。一个简单的例子是数组。阵列本身将占据一个rvalue,它可以引用其他是其元素的其他rvalues。

因此,如果我们通过我们的移动对象阶段将其中一个元素移动到数组中,我们需要确保原始数组了解这个新位置。代替移动的rvalue,Ruby的GC将留下转发地址。转发地址表示rvalue的新位置。

这将我们带到了我们的双指压实算法的第二阶段:更新引用。一旦Ruby的GC移动了RValues和左转发地址,它需要实际更新引用并清除转发地址。如果它没有更新引用,那么那些转发地址将需要保持永久性,使得新空的插槽无用。

这个过程非常简单。 Ruby的GC迭代堆中的每个插槽。如果插槽为空,则移动。如果插槽被rvalue占用,它会看出来自该rvalue的任何引用。对于每个参考,它看起来有关当前地址的内存中的内存,如果有转发地址,它将更新引用以指向转发地址。

参考更新步骤中有一个大警告。它假设Ruby的GC知道如何阅读Rvalue的引用。对于内置的Ruby对象,这相当简单。但是,Ruby还允许通过C扩展来定义对象。 (彼得·朱旁,彼得朱镕基正在写一系列关于C扩展:Rubyist沿着C侧散步。如果您在这里更多地学习,它肯定值得一读。)

其中一些C扩展指示对Ruby它们如何存储其引用,因此可以更新。但其他人没有。在这些情况下,rvalues无法在压实中移动。相反,它们“固定”到位而不是移动。这是一个小的物体百分比,因此不会急剧干扰前面定义的目标。

重要的是要注意,由于Ruby 3.0,压缩仍在开发中,实际上目前在大多数应用程序上降低性能。 因此,它不会自动打开。 仍然可以使用gc.auto_compact =(true)手动将其转动,但作为文档说明,“启用压缩将降低主要集合的性能”。 这就是现在的一切! 在本系列中的下一个(迷你)帖子中,我将通过对象ID的压缩来行走。