查看源代码 Erlang 垃圾回收器

Erlang 使用追踪式垃圾回收器管理动态内存。更准确地说,它是一个每个进程的、分代的、半空间复制回收器,使用切尼(Cheney)的复制回收算法,以及一个全局的大对象空间。(参见 参考中的 C. J. Cheney。)

概述

每个 Erlang 进程都有自己的堆栈和堆,它们在同一内存块中分配,并朝向彼此增长。当堆栈和堆相遇时,会触发垃圾回收器并回收内存。如果回收的内存不足,堆会增长。

创建数据

通过计算表达式在堆上创建项。主要有两种类型的项:立即项,不需要堆空间(小整数、原子、进程 ID、端口 ID 等),以及 cons 或 装箱项(元组、大数字、二进制等),需要堆空间。立即项不需要任何堆空间,因为它们嵌入到包含结构中。

让我们看一个返回包含新创建数据的元组的示例。

data(Foo) ->
   Cons = [42|Foo],
   Literal = {text, "hello world!"},
   {tag, Cons, Literal}.

在这个例子中,我们首先创建一个新的 cons 单元,包含一个整数和一个包含一些文本的元组。然后创建一个大小为 3 的元组,用原子标签包装其他值并返回。

在堆上,元组的每个元素以及头部都需要一个字大小的空间。Cons 单元总是需要两个字。把这些加起来,元组需要七个字,cons 单元需要 26 个字。字符串 "hello world!" 是一个 cons 单元列表,因此需要 24 个字。原子 tag 和整数 42 不需要任何额外的堆内存,因为它们是立即项。将所有项加在一起,此示例中所需的堆空间应为 33 个字。

将此代码编译为 beam 汇编代码(erlc -S)可以准确地显示发生了什么。

...
{test_heap,6,1}.
{put_list,{integer,42},{x,0},{x,1}}.
{put_tuple,3,{x,0}}.
{put,{atom,tag}}.
{put,{x,1}}.
{put,{literal,{text,"hello world!"}}}.
return.

查看汇编代码,我们可以看到三件事:此函数中的堆需求实际上只有六个字,如 {test_heap,6,1} 指令所示。所有分配都组合成一个指令。大部分数据 {text, "hello world!"} 是一个字面量。字面量,有时被称为常量,不会在函数中分配,因为它们是模块的一部分,并在加载时分配。

如果堆上没有足够的空间来满足 test_heap 指令的内存请求,则会启动垃圾回收。它可能会立即在 test_heap 指令中发生,或者根据进程的状态延迟到稍后的时间。如果垃圾回收被延迟,则所需的任何内存都将在堆片段中分配。堆片段是年轻堆的一部分额外内存块,但不是在通常放置项的连续区域中分配。有关更多详细信息,请参见年轻堆

回收器

Erlang 具有复制的半空间垃圾回收器。这意味着在进行垃圾回收时,项会从一个称为源空间的独立区域复制到一个新的干净区域,称为目标空间。回收器首先扫描根集(堆栈、寄存器等)。

Garbage collection: initial values

它遵循从根集到堆的所有指针,并将每个项逐字复制到目标空间

在复制头部字后,会在其中破坏性地放置一个移动标记,指向目标空间中的项。任何其他指向已移动项的项都将看到此移动标记并复制引用指针。例如,如果我们有以下 Erlang 代码

foo(Arg) ->
    T = {test, Arg},
    {wrapper, T, T, T}.

堆上只存在一个 T 的副本,并且在垃圾回收期间,只有第一次遇到 T 时才会复制它。

Garbage collection: root set scan

复制根集引用的所有项后,回收器会扫描目标空间,并复制这些项引用的所有项。扫描时,回收器会遍历目标空间上的每个项,并且任何仍然引用源空间的项都会复制到目标空间。某些项包含非项数据(例如,堆上二进制文件的有效负载)。当回收器遇到这些值时,只会简单地跳过它们。

Garbage collection: heap scan

我们可以访问的每个项对象都会被复制到目标空间并存储在扫描停止线的顶部,然后将扫描停止线移动到最后一个对象的末尾。

Garbage collection: heap scan

扫描停止标记赶上 扫描开始标记时,垃圾回收就完成了。此时,我们可以释放整个源空间,从而回收整个年轻堆。

分代垃圾回收

除了上面描述的回收算法外,Erlang 垃圾回收器还提供分代垃圾回收。使用一个额外的堆,称为老堆,其中存储长生命周期的数据。原始堆称为年轻堆,有时也称为分配堆。

考虑到这一点,我们可以再次查看 Erlang 的垃圾回收。在复制阶段,任何应复制到年轻目标空间的内容,如果低于高水位线,则会改为复制到老目标空间

Garbage collection: heap scan

高水位线放置在前一次垃圾回收(在概述中描述)结束的位置,并且我们引入了一个名为老堆的新区域。在进行正常的垃圾回收遍历时,任何位于高水位线以下的项都会被复制到老的目标空间,而不是年轻的。

Garbage collection: heap scan

在下一次垃圾回收中,将忽略任何指向老堆的指针,并且不会扫描它们。这样,垃圾回收器就不必扫描长生命周期的项。

分代垃圾回收旨在以内存为代价提高性能。这是因为在大多数垃圾回收中只考虑年轻的、较小的堆。

分代假设预测大多数项倾向于在年轻时死亡(参见 参考中的 D. Ungar),并且对于像 Erlang 这样的不可变语言,年轻项的死亡速度甚至比其他语言更快。因此,对于大多数使用模式,新堆中的数据将在分配后很快死亡。这很好,因为它限制了复制到老堆的数据量,而且还因为所使用的垃圾回收算法与堆上活动数据的量成正比。

这里需要注意的一个关键问题是,年轻堆上的任何项都可以引用老堆上的项,但老堆上的任何项都不能引用年轻堆上的项。这是由于复制算法的性质。老堆项引用的任何内容都不会包含在引用树、根集及其后续项中,因此不会被复制。如果它被复制了,数据将会丢失,并且将会出现地狱之火吞噬整个地球。幸运的是,这对于 Erlang 来说是很自然的,因为项是不可变的,因此无法修改老堆上的指针以指向年轻堆。

为了从老堆中回收数据,在回收期间会包含年轻堆和老堆,并将其复制到公共的目标空间。然后会释放年轻堆和老堆的源空间,并且该过程将从头开始重新开始。这种类型的垃圾回收称为完全扫描,当高水位线下的区域大小大于老堆的空闲区域大小时,会触发该扫描。也可以通过手动调用 erlang:garbage_collect()来触发,或者通过运行[spawn_opt(fun(),{fullsweep_after, N}])设置的年轻垃圾回收限制来触发,其中 N 是在强制对年轻堆和老堆都进行垃圾回收之前要执行的年轻垃圾回收次数。

年轻堆

年轻堆或分配堆,由概述中描述的堆栈和堆组成。但是,它还包括附加到堆的任何堆片段。所有堆片段都被认为高于高水位线,并且是年轻代的一部分。堆片段包含不适合堆的项,或者是由另一个进程创建然后附加到堆的项。例如,如果 bif binary_to_term/1 创建了一个不适合当前堆的项,而无需进行垃圾回收,它将为该项创建一个堆片段,然后安排稍后进行垃圾回收。此外,如果向进程发送消息,则有效负载可能会放置在堆片段中,并且当在接收子句中匹配消息时,该片段会添加到年轻堆中。

此过程与 Erlang/OTP 19.0 之前的工作方式不同。在 19.0 之前,只有年轻堆和堆栈所在的连续内存块才被认为是年轻堆的一部分。堆片段和消息会立即复制到年轻堆中,然后才能被 Erlang 程序检查。19.0 中引入的行为在许多方面都更优越,最重要的是它减少了必要的复制操作和垃圾回收的根集。

调整堆大小

正如概述中提到的,堆的大小会增长以容纳更多数据。堆分两个阶段增长,首先使用从 233 个字开始的斐波那契数列的变体。然后,在大约 1 兆个字时,堆仅以20% 的增量增长

在两种情况下,年轻堆会增长

  • 如果堆 + 消息和堆片段的总大小超过当前堆大小。
  • 如果在完全扫描后,活动对象的总数大于 75%。

在两种情况下,年轻堆会缩小

  • 如果年轻代垃圾回收后,存活对象的总量少于堆的 25% 并且年轻代堆很大
  • 如果完全垃圾回收后,存活对象的总量少于堆的 25%。

老年代堆的增长阶段始终比年轻代堆快一步。

字面量

在对堆(年轻代或老年代)进行垃圾回收时,所有字面量都保留在原地,不会被复制。为了确定在垃圾回收时是否应该复制一个项,会使用以下伪代码

if (erts_is_literal(ptr) || (on_old_heap(ptr) && !fullsweep)) {
  /* literal or non fullsweep - do not copy */
} else {
  copy(ptr);
}

erts_is_literal 检查在不同的架构和操作系统上的工作方式不同。

在允许映射未保留虚拟内存区域的 64 位系统上(大多数操作系统,Windows 除外),会映射一个 1 GB 大小的区域(默认情况下),然后将所有字面量放置在该区域内。然后,要确定某个事物是否为字面量,只需进行 两次快速的指针检查。该系统依赖于这样一个事实:尚未被触及的内存页不占用任何实际空间。因此,即使映射了 1 GB 的虚拟内存,也只有实际用于字面量的内存才会在 RAM 中分配。字面量区域的大小可通过 +MIscs erts_alloc 选项进行配置。

在 32 位系统上,没有足够的虚拟内存空间来为字面量分配 1 GB,因此会按需创建 256 KB 大小的字面量区域,然后使用整个 32 位内存空间的卡片标记位数组来确定一个项是否为字面量。由于总内存空间只有 32 位,因此卡片标记位数组只有 256 字大小。在 64 位系统上,相同的位数组必须是 1 tera 字大小,因此此技术仅在 32 位系统上可行。与 64 位系统中可以进行的指针检查相比,在数组中查找会稍微昂贵一些,但不会非常昂贵。

在 64 位 Windows 上,erts_alloc 无法进行未保留的虚拟内存映射,因此使用 Erlang 项对象中的 特殊标签来确定某个事物是否为字面量。这非常便宜,但是,该标签仅在 64 位机器上可用,并且将来可以使用此标签进行大量其他不错的优化(例如,更紧凑的列表实现),因此它不会在不需要它的操作系统上使用。

此行为与 Erlang/OTP 19.0 之前的行为不同。在 19.0 之前,字面量检查是通过检查指针是否指向年轻代或老年代堆块来完成的。如果不是,则认为它是字面量。这导致了相当大的开销和奇怪的内存使用场景,因此在 19.0 中将其删除。

二进制堆

二进制堆充当大于 64 字节的二进制项(以下称为堆外二进制)的大对象空间。二进制堆是引用计数的,并且指向堆外二进制的指针存储在进程堆上。为了跟踪何时递减堆外二进制的引用计数器,一个包含 fun 和外部项以及堆外二进制的链表(MSO - 标记和清除对象列表)被编织在堆中。完成垃圾回收后,会扫描 MSO 列表,并且任何在头部字中没有写入移动标记的堆外二进制的引用计数将递减并可能被释放

MSO 列表中的所有项都按其添加到进程堆的时间顺序排列,因此在执行次要垃圾回收时,MSO 清除器只需清除到它遇到老年代堆上的堆外二进制为止。

虚拟二进制堆

每个进程都有一个与其关联的虚拟二进制堆,其大小等于该进程引用的所有当前堆外二进制的大小。虚拟二进制堆也有一个限制,并且会根据进程使用堆外二进制的方式增长和缩小。二进制堆和项堆使用相同的增长和缩小机制,因此首先是类似斐波那契的序列,然后是 20% 的增长。

虚拟二进制堆的存在是为了在潜在存在大量可回收的堆外二进制数据时,更早地触发垃圾回收。此方法不会捕获所有二进制内存释放不够快的问题,但它确实捕获了很多问题。

消息

消息可以在不同的时间成为进程堆的一部分。这取决于进程的配置方式。我们可以使用 process_flag(message_queue_data, off_heap | on_heap) 配置每个进程的行为,或者可以使用 +hmqd 选项在启动时为所有进程设置默认值。

这些不同的配置有什么作用?我们应该何时使用它们?让我们从一个 Erlang 进程向另一个进程发送消息时会发生什么开始。发送进程需要做几件事

  1. 计算要发送的消息的大小
  2. 分配足够的空间以容纳整个消息
  3. 复制消息负载
  4. 分配一个包含一些元数据的消息容器
  5. 消息容器插入接收进程的消息队列

接收进程的进程标志 message_queue_data 控制发送进程在步骤 2 中的消息分配策略,以及垃圾回收器如何处理消息数据。

上述过程与 19.0 之前的过程不同。在 19.0 之前,没有配置选项,行为始终与 19.0 中的 on_heap 选项非常相似。

消息分配策略

如果设置为 on_heap,则发送进程将首先尝试直接在接收进程的年轻代堆块上分配消息空间。这并非总是可行的,因为它需要获取接收进程的主锁。该主锁在进程执行时也会被持有。因此,在密集协作的系统中,发生锁冲突的可能性很高。如果发送进程无法获取主锁,则会为消息创建一个堆片段,并将消息负载复制到该片段上。使用 off_heap 选项,发送进程始终为发送到该进程的消息创建堆片段。

在尝试确定要使用的策略时,会涉及许多不同的权衡。

使用 off_heap 似乎是获得更具可扩展性的系统的好方法,因为在主锁上几乎没有争用,但是,分配堆片段比在接收进程的堆上分配更昂贵。因此,如果发生争用的可能性非常小,则尝试直接在接收进程的堆上分配消息会更有效。

使用 on_heap 将强制所有消息成为年轻代堆的一部分,这将增加垃圾回收器必须移动的数据量。因此,如果在处理大量消息时触发垃圾回收,则这些消息将被复制到年轻代堆。这反过来会导致消息很快被提升到老年代堆,从而增加其大小。这可能是好是坏,具体取决于进程的具体操作。较大的老年代堆意味着年轻代堆也会更大,这反过来意味着在处理消息队列时触发的垃圾回收次数会更少。这将在短期内提高进程的吞吐量,但代价是更多的内存使用量。但是,如果所有消息都被消耗后,进程进入接收到的消息少得多的状态。那么,可能需要很长时间才能发生下一次完全垃圾回收,并且老年代堆上的消息将一直存在直到发生垃圾回收。因此,虽然 on_heap 可能比其他模式更快,但它会在更长的时间内使用更多内存。此模式是旧模式,与 Erlang/OTP 19.0 之前处理消息队列的方式几乎相同。

哪种策略最好很大程度上取决于进程正在做什么以及它如何与其他进程交互。因此,与往常一样,请分析应用程序并查看它在不同选项下的行为。

参考

C. J. Cheney。一种非递归列表压缩算法。Commun。ACM,13(11):677–678, 1970 年 11 月。

D. Ungar。生成回收:一种非中断的高性能存储回收算法。SIGSOFT Softw. Eng. Notes, 9(3):157–167, 1984 年 4 月。