RPython 中的垃圾回收

简介

我们的垃圾回收策略和框架的概述和描述可以在 关于此主题的欧盟报告 中找到。请参阅该文件以获取旧的,但或多或少准确的描述。本文档描述了我们在框架中编写的特定垃圾回收器。

当前为 GC 框架编写的垃圾回收器

提醒:要选择要在翻译后的 RPython 程序中包含哪个 GC,请使用 --gc=NAME 选项 translate.py。有关更多详细信息,请参阅 翻译命令行选项概述

以下概述按时间顺序编写,因此“最佳”GC(在翻译时为默认值)是下面的最后一个。

标记清除

经典的标记清除收集器。还包含许多实验性和半维护的功能。已删除。

半空间复制收集器

两个大小相等的空间,只有一个空间正在使用并填充新对象。当空间已满时,使用 Cheney 算法将存活的对象复制到另一个空间。然后清除旧空间。参见 rpython/memory/gc/semispace.py

在 Unix 上,清除是通过将 /dev/zero 读取到空间中来完成的,这至少在 Linux 上非常节省内存:它允许内核释放旧空间使用的 RAM 并将其全部替换为按需分配的内存。

每个半空间的大小从 8MB 开始,但随着存活对象数量的增长而按需增长。

分代 GC

这是一个两代 GC。参见 rpython/memory/gc/generation.py

它作为半空间复制收集器的子类实现。它添加了一个苗圃,它是当前半空间的一部分。其大小被计算为 CPU 二级缓存大小的一半。分配填充苗圃,当它已满时,它会被收集,并且仍然存活的对象会被移动到当前半空间的其余部分。

其思想是,对象在创建后不久死亡是很常见的。分代 GC 在这种情况下有很大帮助,特别是如果程序实际操作的存活对象数量适合二级缓存。此外,半空间的填充速度会慢得多,从而减少了完整收集的频率。

混合 GC

这是一个三代 GC。

它作为分代 GC 的子类实现。混合 GC 可以处理位于半空间内部和外部的对象(“外部”)。外部对象不会移动并以标记清除方式收集。大型对象被分配为外部对象以避免代价高昂的移动。存活时间足够长(多次半空间收集)的小对象也会被设为外部对象,以便它们停止移动。

这与将对象分成三代相结合。每一代的收集频率都远低于前一代。代的划分比苗圃/半空间/外部稍微复杂一些;请参阅源代码开头的图表,位于 rpython/memory/gc/hybrid.py

标记压缩 GC

在主干中已删除。以下文档仅供历史参考。

至少部分地受到 Squeak 的垃圾回收器的启发,这是一个单空间 GC,其中收集会原位压缩对象。此 GC 的主要目的是尽可能节省内存(不比半空间差),但不会在收集期间出现双倍内存使用量的峰值。

与半空间 GC 不同,收集需要多次遍历数据。这使得收集速度相当慢。未来的改进可以向标记压缩添加苗圃以缓解此问题。

在收集期间,如果空间仍然足够大,我们会原位重用空间。如果不是,我们需要分配一个新的、更大的空间,并将对象移动到那里;但是,此移动是分块完成的,并且在块移动后立即清除(即返回到操作系统)。这意味着(从操作系统的角度来看)收集永远不会导致总内存使用量的重大临时增长。

更准确地说,当空间包含超过 N*M 字节时会触发收集,其中 N 是上次收集后存活的字节数,M 是一个常数因子,默认为 1.5。这保证了程序的总内存使用量永远不会超过其存活对象总大小的 1.5 倍。

对象本身非常紧凑:它们在堆中彼此相邻分配,由仅一个字(在 32 位平台上为 4 字节)的 GC 头隔开,并且可能后跟最多 3 个字节的填充以用于非字大小的对象(例如字符串)。在收集期间会有一些额外的内存使用量:需要一个数组,其中包含每个存活对象的 2 个字节,以备份(一半)存活对象的头部,以便收集器在常规头部中存储临时关系信息。

Minimark GC

这是对混合 GC 中想法的简化和重写。它对年轻对象使用苗圃,对旧对象使用标记清除。这是一个移动 GC,但对象只能移动一次(从苗圃到旧阶段)。

与混合 GC 的主要区别在于,标记清除对象(“旧阶段”)由 GC 的自定义分配器直接处理,而不是由 malloc() 调用处理。好处是,然后可以在主要收集期间遍历所有旧代对象,而无需存储指向它们的指针列表。因此,作为第一个近似值,与混合 GC 相比,Minimark GC 每个旧对象节省了一个字的内存。

一些环境变量 可以调整以影响 GC。(它们默认值对于大多数用法应该没问题。)

更详细地

  • 新分配的小对象在苗圃中分配(情况 1)。苗圃中所有对象都是“年轻”的。
  • 大对象始终由系统 malloc() 直接处理。但是,即使新分配的大对象不在苗圃中,它们在分配时也仍然是“年轻”的(情况 2)。
  • 当苗圃已满时,我们会进行次要收集,即我们找到哪些“年轻”对象仍然存活(来自情况 1 和 2)。然后删除“年轻”标志。存活的案例 1 对象会被移动到旧阶段。正在消亡的案例 2 对象会立即被释放。
  • 旧阶段是包含旧(小)对象的内存区域。它由 rpython/memory/gc/minimarkpage.py 处理。它被组织成 256KB 或 512KB 的“区域”,细分为 4KB 或 8KB 的“页面”。每个页面可以是空闲的,或者包含所有相同大小的小对象。此外,在任何时间点,每个对象位置都可以是已分配的或已释放的。基本设计来自 CPython 中的 obmalloc.c(它本身来自与 Linux 系统 malloc() 相同的来源)。
  • 在每次次要收集时,新的对象都会被添加到旧阶段。在次要收集后立即,当我们达到某个阈值时,我们会触发主要收集。这是标记清除步骤。它遍历所有对象(标记),然后释放其中的一部分(清除)。这意味着我们想要释放对象的唯一时间是在遍历所有对象时;我们从不请求仅根据其地址释放对象。与 obmalloc.c 相比,这允许一些简化和内存节省。
  • 与所有分代收集器一样,此 GC 需要写屏障来记录哪些旧对象对年轻对象的引用。
  • 此外,我们发现专门处理大型数组的情况很有用:当我们分配一个大型数组(使用系统 malloc())时,我们在之前预留少量字节。当数组变旧时,我们使用额外的字节作为一组位。每个位表示数组中的 128 个条目。每当调用写屏障以记录从数组的第 N 个条目到某个年轻对象的引用时,我们将位号 (N/128) 设置为 1。这可以大大加快次要收集的速度,因为我们只需要扫描数组的 128 个条目而不是所有条目。
  • 像往常一样,我们需要特别注意弱引用和带有终结器的对象。弱引用在苗圃中分配,如果它们存活,它们会像所有对象一样移动到旧阶段;区别在于它们包含的引用必须跟随对象,或者如果对象死亡则设置为 NULL。并且带有终结器的对象,被认为足够罕见,会被立即分配为旧以简化设计。特别是它们的 __del__ 方法只能在主要收集后立即调用。

  • 对象只移动一次,因此我们可以使用一个技巧来实现 id() 和 hash()。如果对象不在苗圃中,它将不再移动,因此其 id() 和 hash() 就是对象的地址,转换为整数。如果对象在苗圃中,并且我们请求其 id() 或 hash(),那么我们会在旧阶段预留一个位置,并返回该位置的地址。如果对象在下次次要回收中幸存下来,我们将其移动到那里,因此其 id() 和 hash() 会被保留。如果对象死亡,则预留的位置将变成空闲垃圾,在下次主要回收时被回收。

此 GC 的确切名称为 minimarkincminimark。后者是增量执行主要回收的版本(即,一个主要回收被分成若干次次要回收,而不是在特定次要回收之后一次性完成)。默认值为 incminimark,因为它似乎对性能和内存使用影响极小,并且可以避免 minimark 的长时间停顿。