Erlang 的垃圾收集器
Erlang的垃圾收集
Erlang使用跟踪垃圾收集器(tracing grabage collector)来管理动态内存。准确的说,每个进程使用Cheney的分代垃圾收集算法和一个全局的大对象空间。
概述
每个Erlang进程都有自己的栈和堆,这些栈和堆分配在同一块内存中,并相互增长。当栈和堆相遇时,会触发垃圾收集器。如果没有回收到足够的内存,那么需要从新申请新的堆空间。
创建数据
Terms通过计算表达式创建堆空间中。有两种主要的Term类型:立即项immediate terms不存放在堆空间(例如,小整数、原子atom、进程标志pid、端口标识port id等);装箱项boxed terms需要存放在堆空间(例如,元组tuple、big num、二进制binary等)。立即项不需要存放在堆空间,是因为被嵌套到了包结构中。
让我们看一个示例,它返回一个组装的元组。
data(Foo) ->
Cons = [42|Foo],
Literal = {text, "hello world!"},
{tag, Cons, Literal}.
在这个例子中,首先创建了一个新的cons单元列表(列表单元),又创建一个包含文本的元组。然后创建并返回一个用原子tag包装起来的三元组。
在堆上,元组的每一个元素需要1个字以及元组本身也需要1个字,cons单元列表每个元素需要2个字。把这些加在一起,我们得到7个字的元组和26个字的cons单元。字符串"hello world!" 是一个cons单元列表,需要24个字。原子tag和整数42不需要额外的堆内存,因为他们是立即项(immediate).将所有term加起来,需要占用33个字的堆空间。
把此代码通过erlc -S表以为beam汇编,可以准确的看到发生了什么。
...
{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}.所示,该函数最终只要了6个字;所有分配都组合在了一条指令中;大部分数据是{text, "hello world!"},它是字面量(literal,有时称为常量constant),它不在函数中分配,因为它是属于模块的,在模块加载时分配。
如果没有足够的可用空间来满足test_heap指令的需求,则启动垃圾收集。这个过程可能立即发生在test_heap指令中,或者是延迟稍后再执行,这依赖于进程的状态。如果垃圾收集延迟了,所需空间将分配在堆的碎片中(堆碎片是额外的内存块,是young堆的一部分),但是不能分配到term常驻的邻近区域中。更多详情在young heap中。
垃圾收集器
Erlang有一个半空间垃圾收集算法。这意味着,进行垃圾收集时,从一个称为from space空间的distinct区域复制到一个称为to space空间去。收集器从根跟集合开始扫描(堆栈、寄存器等)。
图:
它遵循从根集合到堆的所有指针,将每个term复制到to space空间。
在复制了header word后,会破坏性的在其中放置一个移动标识,指向to space空间的term。指向已移动term的任意其他term,都会看到这个移动标识,并复制引用指针。
如果有下面这样的代码:
foo(Arg) ->
T = {test, Arg},
{wrapper, T, T, T}.
在堆上只存在一个T副本,并且在垃圾收集期间,只有在第一次遇到T时才会复制它。
图:
复制根集合引用的所有term后,收集器扫描to space空间,并复制这些term引用的所有term。扫描时,收集器将逐步遍历to space空间上的每一个term,并将仍然引用from space空间的任意term都复制到to space空间。有些term包含non-term数据(例如,堆上binary的有效负载)。当收集器遇到这样的term时,会直接跳过。
图:
我们可以达到(reach)的每一个term对象被复制到to space空间,并且存储在scan stop行的上面,然后scan stop 被移动到最后一个对象的末尾。
当scan stop标记赶上 scan start标记时,垃圾收集结束。此时,我们可以释放整个from space空间,从而回收(reclaim)整个young heap。
分代垃圾收集器(Generational Garbage Collection)
除了上述的收集算法,Erlang的垃圾收集器还提供了分代GC。另一个称为old heap的堆,用来存储长时间活动的数据。原始堆(original)称为年轻堆(young),有时称为分配堆(allocation)。
考虑到这一点,我们可以再次查看Erlang的垃圾收集。在复制阶段,任何应该复制到young to space空间的内容,如果低于高水位线,则会复制到old to space空间。
图:
高水位线位于上一次垃圾收集结束的地方,并且,我们引入了一个新的区域,称为old heap。在执行常规的垃圾收集过程时,位于高水位线以下的任何term都会被复制到old to space空间,而不是young to space。
图:
在下一次垃圾收集中,任何指向old heap的指针都被忽略,并不会被扫描。这样,垃圾收集器就不必扫描长期活动的term。
分代垃圾收集器,牺牲内存为代价来提高性能,这是因为大多数垃圾收集动作只需考虑年轻的、小的堆。
分代垃圾收集器假设,大多数term会在年轻期消亡,对于像Erlang这样的不可变语言(immutable language),年轻term比其他语言消亡的更快。因此,对于大多数使用模式,新堆中的数据再分配后很快就会死去。这样很好,因为它限制了复制到old heap的数据量,而且垃圾收集算法的使用与堆上存活数据量成正比。
这里需要注意一个严重的问题,young heap上的任意term都能引用old heap上的term,但是old heap上的term不可能引用young heap上的term,这是复制算法的一个特性。old heap上的任意term引用都不包含在引用树、根集合及其跟随者中,因此不会被复制,如果真是这样,数据就会丢失,火和硫磺会升起来覆盖地球。幸运的是,对于Erlang来说是很自然的,因为term时不可变的,因此不能修改old heap上的指针指向young heap。
从old heap中回收(reclaim)数据,收集过程中包含young和old heap,并将其复制到一个公共的to space,然后释放young的from space空间和old heap,过程将重头开始。这类型的垃圾收集称为完全扫描(full sweep),当高水位线下的区域大小大于old heap的空闲区域大小时触发。也可以通过手动调用erlang:garbage_collect()或者运行spawun_opt(fun(), [{fullsweep_after, N}])设置年轻垃圾收集限制来触发,其中N是强制对young和old heap进行垃圾收集前要执行的年轻收集次数。
young heap
年轻堆或者分配堆,是由栈和堆组成,如概述中所述。但是,它还包含附加到堆的任何堆碎片。所有的堆碎片都被认为在高水位之上,并且是年轻代的一部分。堆碎片包含不适合堆的term,或者是由另一个进程创建,然后附加到堆上,例如,binary_to_term在不进行垃圾收集的情况下创建一个不适合当前堆的term,它将为当前堆创建一个堆碎片,然后安排稍后的垃圾收集。此外,如果消息被发送到进程,有效负载可能被放置在堆碎片中,当该消息在接收子句中匹配时,该碎片将会被添加到年轻堆中。
此过程与Erlang/OTP19.0之前的工作方式不同,在19.0之前,只有年轻堆和栈所在的连续内存块被认为是年轻堆的一部分。堆碎片和消息在Erlang程序检测之前被立即复制到年轻堆中。19.0引进的行为在许多方面都是好的,尤其是它减少了必要的复制操作数量和垃圾收集的根集。
调整堆大小
概述中有提到,堆的大小会增长以容纳更多的数据。堆分两个阶段增长,第一个阶段是使用斐波那契额序列的变体,从233字开始,然后在大于一百万字时,堆会以20%的增量进行增长。
年轻堆增长的两个情况:
- 如果堆 + 消息和堆碎片的大小超过当前堆大小。
- 如果完成扫描后, 活动对象的总数大于75%。
年轻堆收缩的两个情况:
- 如果在年轻收集后,活动对象的总数小于堆的25%,并且年轻堆很大时。
- 如果完全扫描后, 活动对象的总数小于堆的25%时。
旧堆在对增长阶段中,总是比年轻堆领先一步。
字面量 Literals
当垃圾收集时(年轻的或旧的)所有的字面量都是保留在原地,不会被复制。想确定在执行垃圾收集时是否应复制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外的大多数系统),映射大小为1GB(默认值)的区域,所有的字面量都放置在这个区域。然后,要确认某个内容是否为字面量,只需要两次快速的指针检测。该系统依赖于这样一个事实,没有触碰的内存也,不能占用任何实际空间。因此,即是映射了1GB的虚拟内存,RAM中只分配了字面量实际需要的内存。字面量区域的大小可以通过+Mlscs erts_alloc选项进行配置。
在32位系统上,虚拟内存空间不足,不能仅为字面量分配1GB的空间,因为会根据需求创建大小为256KB的字面量区域,然后用整个32位内存空间的卡片标记位数组来确定一个term是否为字面量。由于总内存空间只有32位,卡片标记位数组只有256个字大。在64位系统上相同的位数据必须是1Tera字大,
因此该技术只能在32位系统上可行。在数组中查找比在64位系统中只用检测指针贵一些,但不是非常贵。
在64位windows上,erts_alloc无法进行无保留的虚拟内存映射,Erlang term对象中的特殊标记用于确定某个内容是否为字面量。这是非常便宜的,但是,该标记只能在64位机器上可用,并且将来可用使用该标记进行大量其他出色的优化(例如,更紧凑的列表实现),因此它不会在不需要它的操作系统上使用。
这种行为与Erlang/OTP 19.0之前的工作方式不同,19.0之前,字面量的检查是通过检查指针是否指向年轻堆或旧堆块来完成,如果不是,认为它是一个字面量。这会导致相当大的开销和奇怪的内存使用场景,因此在19.0中删除了他。
二进制堆 binary heap
二进制堆是一个大对象空间,用作存放大于64字节的二进制term(当前称为非堆二进制off-heap binaries)。二进制堆是采用引用计数,且在进程堆上的指针指向存储在off-heap上的二进制。为了跟踪off-heap上的二进制何时减少了引用计数,在堆中编织了一个包含了Funs和externals以及off-heap binaries的链表(MSO-mark and sweep object list 标记和扫描对象列表)。在垃圾收集完成之后,MSO列表将被清理,任何未在头字中写入移动标识的off-heap二进制,其引用数将被减少,并且可能被释放。
MSO列表的所有项,都是按照它们加入进程堆时间进行排序的,因此在执行minor GC时,MSO清理程序只需要清理到旧堆上的off-heap二进制处。
虚拟二进制堆 Virtual Binary heap
每一个进程都有一个与其关联的虚拟二进制堆,该堆的大小是当前进程所有引用off-heap binaries的大小。该堆有一个限制,他的增长和缩小依赖于进程如何使用off-heap binaries。二进制堆和term堆使用相同的增长和缩小机制,首先使用斐波那契数列,然后按20%增长。
虚拟二进制堆的存在,是为了在可能有大量可回收的off-heap二进制数据时,能够更早触发垃圾收集。这种方法不能解决二进制内存没有快速释放的所有问题,但是他确实解决了很多问题。
消息 Messages
消息可以在不同时间成为进程堆的一部分,这取决于如何配置进程。我们可以使用process_flag(message_queue_data, off_heap | onheap)配置每个进程的行为,或者我们可以在启动时使用+hmqd选项为所有进程设定默认值。
这些不同的配置有什么作用,我们该在什么时候使用?让我们从一个Erlang进程给另一个进程发送消息时会发生什么。发送过程需要做几件事:
接收方进程的进程标识message_queue_data,在第2步中,控制着消息的分配策略,以及消息是如何被垃圾收集处理的。
上面的过程在19.0之前的工作方式不同,19.0之前,没有配置选项,与19.0中的on_heap选项非常相似。
消息分配策略 Message allocation strategies
如果设定为on_heap,发送方进程首先会尝试直接在接收方进程的年轻堆上为消息分配空间。这并不是总是可能的,因为他需要拿接收方进程的主锁(main lock),当进程在执行中,这个主锁是被保留的。因此在高强度协作的系统中很可能发送锁冲突,如果发送者进程无法获取主锁,则会为该消息创建堆碎片,并将消息的负载复制到该堆碎片上。使用off_heap选项,发送发进程总是为发送到进程的消息创建堆碎片。
当你试图找出你想要使用哪种策略时,有很多不同的权衡。
使用off_heap似乎是一种获得更具可伸缩性的系统的好方法,因为在主锁上机会没有争抢,但是,分配在堆碎片上比接收方进程的堆上分配更加昂贵。所以如果不太可能发送争抢,纳闷直接在接收堆上分配消息会更加有效。
使用on_heap会强制将所有消息成为年轻堆的一部分,这会增加垃圾收集器必须移动的数据总量。所以,如果在处理大量消息时触发垃圾收集,他们家被复制到年轻堆,折返回来会导致消息被快速提升到旧堆,从而增加其大小。这可能是好的,也可能是坏的,这取决于进程具体怎么做。大的旧堆意味着年轻堆会更大,这反而意味着,在处理消息队列时,很少触发垃圾收集。
这会临时增加内存吞吐量,但是会消耗更多的内存占用。但是,所有消息都被消费后,进程进入一种接收到很少消息的状态,然后可能需要很长时间才能进行下一次完全扫描的垃圾收集,并且消息一直在旧堆中,直到发生完全扫描为止。所以,on_heap可能会比其他模式快。但是它使用更多的内存和更长的时间。此模式是传统模式,几乎是Erlang/OTP 19.0之前消息队列的处理方式。
这些策略中哪一个好,很大程度上依赖进程在做什么,以及它如何与其他进程合作。所以,像往常一样,分析应用程序,并查看它在不同选项下的行为。
浙公网安备 33010602011771号