这是对上一篇博文 Erlang 19.0 垃圾回收的更新。随着 Erlang/OTP 20.0 的发布,一些事情发生了变化,这也是我们更新这篇博文的原因。
Erlang 使用跟踪式垃圾回收器管理动态内存。更确切地说,是使用 Cheney 的复制收集算法和全局大对象空间的每个进程分代半区复制收集器。
概述
每个 Erlang 进程都有自己的堆栈和堆,它们分配在同一个内存块中,并相互增长。当堆栈和堆相遇时,垃圾回收器被触发,内存被回收。如果回收的内存不足,堆就会增长。
创建数据
terms 是通过求值表达式在堆上创建的。terms 有两大类:不需要堆空间的 immediate terms(小整数、原子、pids、端口 id 等)和需要堆空间的cons or boxed terms (元组、大数值、二进制等)。Immediate terms 不需要堆空间,因为它们被嵌入到包含结构中。
我们来看一个例子,它返回一个包含新创建数据的元组。
data(Foo) -> Cons = [42|Foo], Literal = {text, "hello world!"}, {tag, Cons, Literal}.
在本例中,我们首先创建一个新的 Cons 单元,其中包含一个整数和一个包含一些文本的元组。然后创建并返回一个大小为 3 的元组,该元组将其他值用原子标记包裹起来。
在堆上,元组的每个元素和首部都需要一个字的大小。Cons 单元总是需要两个字。将这些加在一起,我们可以得到 7 个字的元组和 26 个字的 Cons 单元。字符串 "hello world!"是一个 cons 单元列表,因此需要 24 个字。原子标签和整数 42 不需要任何额外的堆内存,因为它是一个立即内存。将所有 terms 加在一起,本例所需的堆空间应为 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 指令中立即发生,也可能会根据进程所处的状态推迟到稍后进行。如果垃圾回收被延迟,所需的内存将以堆碎片的形式分配。堆碎片是额外的内存块,是年轻堆的一部分,但并不分配在 terms 通常所在的争议区域。更多详情,请参阅 “年轻堆”。
回收器
Erlang 有一个复制半区垃圾回收器。这意味着在进行垃圾回收时,terms 会从一个不同的区域(称为 from 空间)复制到一个新的干净区域(称为 to 空间)。回收器首先扫描根集(堆栈、寄存器等)。
它跟踪从根集合到堆的所有指针,并将每个 term 逐字复制到 to 空间。
在复制完头字后,会在其中放置一个移动标记,指向 to 空间中的 term。任何指向已移动过的 term 的其他 term 都会看到这个移动标记,并复制引用指针。例如,如果 Erlang 代码如下:
foo(Arg) -> T = {test, Arg}, {wrapper, T, T, T}.
堆上只存在一个 T 的副本,在垃圾回收过程中,只有第一次遇到 T 时才会复制它。
复制完根集引用的所有 term 后,回收器会扫描 to 空间,并复制这些 term 引用的所有 term。扫描时,回收器会逐一检查 to 空间中的每个 term,并将仍然引用 from 空间的 term 复制到 to 空间。有些 term 包含non-term 数据(例如堆上二进制的有效载荷)。当回收器遇到这些值时,会直接跳过。
我们所能接触到的每一个 term 对象都会被复制到 to 空间,并存储在扫描停止行的顶端,然后扫描停止会被移动到最后一个对象的末端。
当扫描停止标记赶上扫描开始标记时,垃圾回收就完成了。此时,我们可以释放整个空间,从而回收整个年轻堆。
分代垃圾回收
除上述回收算法外,Erlang 垃圾回收器还提供分代垃圾回收。一个被称为旧堆(old heap)的额外堆被用于存储长期存活数据。原来的堆称为年轻堆,有时也称为分配堆。
考虑到这一点,我们可以再看看 Erlang 的垃圾回收。在复制阶段,任何本应复制到年轻堆空间的数据,如果低于高水位线,就会被复制到老堆空间。
高水位标记位于上一次垃圾回收(在概述中描述)结束的地方,我们引入了一个新的区域,称为旧堆。在进行正常的垃圾回收时,任何位于高水位标记以下的 term 都会被复制到旧堆空间,而不是新堆空间。
在下一次垃圾回收时,任何指向旧堆的指针都将被忽略,不会被扫描。这样,垃圾回收器就不必扫描长期存在的 term 了。
分代垃圾回收的目的是在牺牲内存的情况下提高性能。之所以能做到这一点,是因为大多数垃圾回收都只考虑年轻的、较小的堆。
根据分代假设的预测,大多数 term 都会在年轻时消亡,而对于 Erlang 这种不可变语言来说,年轻 term 的消亡速度甚至比其他语言更快。因此,对于大多数使用模式,新堆中的数据在分配后很快就会消亡。这是件好事,因为它限制了复制到旧堆的数据量,还因为所使用的垃圾回收算法与堆上的实时数据量成正比。
这里需要注意的一个关键问题是,新堆上的任何 term 都可以引用旧堆上的 term,但旧堆上的任何 term 都不能引用新堆上的 term。这是复制算法的本质决定的。被旧堆 term 引用的任何内容都不包括在引用树、根集及其跟随者中,因此不会被复制。如果复制了,数据就会丢失,火焰和硫磺就会升起覆盖大地。幸运的是,这对 Erlang 来说是自然而然的事,因为term 是不可变的,因此在旧堆上不可能修改指针来指向新堆。
为了从旧堆中回收数据,年轻堆和旧堆都会在收集过程中被包括进来,并复制到一个共同的 to 空间。然后,新堆和旧堆的 from 空间 都会被释放,程序将从头开始。这种类型的垃圾回收称为完全清扫,当高水位线下区域的大小大于旧堆空闲区域的大小时就会触发。也可以通过手动调用 erlang:garbage_collect(),或通过 spawn_opt(fun(),[{fullsweep_after, N}]) 设置的年轻堆垃圾回收限制来触发,其中 N 是在强制对年轻堆和旧堆进行垃圾回收之前进行年轻堆垃圾回收的次数。
年轻堆
新堆或分配堆,如概述中所述,由堆栈和堆组成。不过,它还包括附加到堆上的任何堆片段。所有堆片段都被认为高于高水位线,是年轻堆的一部分。堆碎片包含的 term 要么不适合堆,要么由其他进程创建,然后附加到堆上。例如,如果 bif binary_too_term 在未进行垃圾回收的情况下创建了一个不适合当前堆的 term,它就会为该 term 创建一个堆片段,然后安排稍后进行垃圾回收。此外,如果有消息发送到进程,其有效载荷可能会被放在一个堆片段中,当消息在接收子句中被匹配时,该片段就会被添加到年轻堆中。
这一程序与 Erlang/OTP 19.0 之前的工作方式不同。在 19.0 之前,只有新堆和堆栈所在的连续内存块才被视为新堆的一部分。堆片段和消息会被立即复制到新堆中,然后才能被 Erlang 程序检查。19.0 中引入的行为在许多方面都更胜一筹–最重要的是,它减少了必要的复制操作和垃圾回收根集的数量。
确定堆的大小
正如概述中提到的,堆的大小会随着容纳更多数据而增长。堆的增长分为两个阶段,首先从 233 字开始使用斐波那契数列的变体。然后,在大约 100 万字时,堆仅以 20% 的增量增长。
年轻堆的增长有两种情况:
- 如果堆 + 消息和堆碎片的总大小超过了当前堆的大小。
- 如果经过一次满扫后,实时对象的总量大于 75%。
在两种情况下,年轻堆会缩小:
- 如果经过年轻堆回收后,实时对象的总量小于堆的 25%,且年轻堆为 "大 "堆。
- 如果经过一次全扫后,存活对象的总量少于堆的 25%。
在堆的成长阶段,旧堆总是比新堆领先一步。
字面量
垃圾回收堆(新堆或旧堆)时,所有字面量都会保留在原处,不会被复制。在进行垃圾回收时,要确定是否要复制一个 term,需要使用以下伪代码:
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 的虚拟内存,也只会在内存中分配字面量实际需要的内存。字面区域的大小可以通过 +MIscs erts_alloc 选项进行配置。
在 32 位系统中,没有足够的虚拟内存空间为字面量分配 1 GB 的空间,因此只能按需创建 256 KB 大小的小字面量区域,然后使用整个 32 位内存空间的卡标记位数组来确定一个 term 是否为字面量。由于总内存空间只有 32 位,因此卡标记位数组只有 256 个字符大。在 64 位系统中,同样的位数组需要 1 太字,因此这种技术只适用于 32 位系统。在数组中进行查找比在 64 位系统中进行指针检查要昂贵一些,但也不是非常昂贵。
在 64 位 Windows 系统中,erts_alloc 无法进行无保留虚拟内存映射,因此 Erlang term 对象中的一个特殊标记被用来确定某项内容是否为字面量。这样做很节省,不过,该标记仅在 64 位机器上可用,而且将来有可能对该标记进行大量其他优化(例如更紧凑的列表实现),因此在不需要它的操作系统上不使用该标记。
这种行为与 Erlang/OTP 19.0 之前的工作方式不同。在 19.0 之前,字面量检查是通过检查指针是否指向新的或旧的堆块来完成的。如果不是,则视为字面量。这导致了相当大的开销和奇怪的内存使用情况,因此在 19.0 中被删除。
二进制堆
二进制堆是一个大型对象空间,用于存放大于 64 字节的二进制 term(从现在起称为 “堆外二进制”)。二进制堆采用引用计数,堆外二进制的指针存储在进程堆中。为了跟踪何时递减堆外二进制的引用计数器,我们在堆中编织了一个链表(MSO–标记和清扫对象列表),其中包含函数、外部数据和堆外二进制。垃圾回收完成后,MSO 列表会被清扫,任何未在header words 中写入移动标记的堆外二进制都会被递减引用,并有可能被释放。
MSO 列表中的所有项都是按照它们被添加到进程堆的时间排序的,因此在进行小规模垃圾回收时,MSO 清扫器只需清扫到遇到旧堆上的堆外二进制为止。
虚拟二进制堆
每个进程都有一个与之关联的虚拟二进制堆,其大小与进程引用的所有当前堆外二进制相当。虚拟二进制堆也有一个限制,会根据进程使用堆外二进制的情况而增大或缩小。二进制堆和 term 堆使用相同的增长和收缩机制,因此首先是类似斐波那契数列的增长,然后是 20% 的增长。
虚拟二进制堆的存在是为了在可能有大量堆外二进制数据需要回收时提前触发垃圾回收。这种方法并不能解决二进制内存释放不够快的所有问题,但可以解决很多问题。
消息
消息可以在不同时间成为进程堆的一部分。这取决于进程的配置方式。我们可以使用 process_flag(message_queue_data,off_heap | on_heap)配置每个进程的行为,也可以使用选项 +hmqd 在启动时为所有进程设置默认值。
这些不同的配置有什么作用,我们应该在什么时候使用它们?让我们先来看看一个 Erlang 进程向另一个进程发送消息时会发生什么。发送进程需要做几件事:
接收进程的进程标志 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 之前处理消息队列的方式。
这些策略中哪一种最好,在很大程度上取决于进程正在做什么以及它与其他进程的交互方式。因此,请一如既往地对应用程序进行剖析,看看它在使用不同选项时的表现如何。
原文链接:Erlang Garbage Collector
也可以在otp内部文档中找到:Erlang Garbage Collector