erlang 垃圾回收
卢卡斯·拉尔森 ( Lukas Larsson)
这是对我们以前的博客文章 Erlang 19.0 Garbage Collector的更新 。 使用Erlang / OTP 20.0,某些事情已经改变,这就是此更新的博客文章的原因。
Erlang通过跟踪垃圾收集器管理动态内存。 更准确地说,是使用Cheney的副本收集算法和全局大型对象空间的按进程生成的半空间副本收集器。
每个Erlang进程都有其自己的堆栈和堆,这些堆栈和堆分配在同一内存块中,并且彼此接近。 当堆栈和堆相遇时 ,将触发垃圾收集器并回收内存。 如果没有回收足够的内存,堆将会增长。
通过评估表达式在堆上创建术语。 术语有两种主要类型:不需要堆空间的立即数(小整数,原子,PID,端口ID等)和需要堆空间的利弊或盒装术语 (元组,大数,二进制等)。 即时术语不需要任何堆空间,因为它们被嵌入到包含结构中。
让我们看一个返回带有新创建的数据的元组的示例。
data(Foo) -> Cons = [42|Foo], Literal = {text, "hello world!"}, {tag, Cons, Literal}.
在此示例中,我们首先创建一个新的con单元格,该单元格带有整数和带有一些文本的元组。 然后,创建并返回一个大小为三的元组,将其他值包裹在一个atom标记上。
在堆上,元组的每个元素和标头都需要一个字长。 缺点单元格总是需要两个词。 将这些内容加在一起,我们得到的元组为7个单词,cons单元为26个单词。 字符串"hello world!"
是con单元格的列表,因此需要24个字。 原子tag
和整数42
不需要任何额外的堆内存,因为它是立即数 。 将所有术语加在一起,此示例中所需的堆空间应为33个字。
将此代码编译为梁装配体( 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}
指令{test_heap,6,1}
此函数中的堆要求仅为6个字。 所有分配都组合成一条指令。 大部分数据{text, "hello world!"}
是一个文字 。 文字,有时也称为常量,由于它们是模块的一部分并在加载时分配,因此未在函数中分配。
如果堆上没有足够的空间来满足test_heap
指令对内存的请求,则将启动垃圾回收。 它可能立即在test_heap
指令中发生,或者可以延迟到以后,具体取决于进程所处的状态。如果延迟了垃圾回收,则所需的任何内存都将分配在堆碎片中。 堆碎片是属于年轻堆的一部分的额外内存块,但未在通常驻留术语的连续区域中分配。 有关更多详细信息,请参见年轻堆 。
Erlang有一个复制的半空间垃圾收集器。 这意味着进行垃圾回收时,术语会从一个不同的区域(称为from space)复制到一个新的干净区域(称为to space) 。 收集器通过扫描根集 (堆栈,寄存器等)开始。
它遵循从根集合到堆的所有指针,并将每个术语逐字复制到to空间 。
复制标题词后, 将破坏性地放置一个移动标记 ,指向to空间中的术语。 指向已经移动的术语的任何其他术语都将看到此移动标记并代替复制引用指针。 例如,如果具有以下Erlang代码:
foo(Arg) -> T = {test, Arg}, {wrapper, T, T, T}.
堆上仅存在T的一个副本,并且在垃圾回收期间,只有第一次遇到T时才会将其复制。
复制了根集引用的所有术语后,收集器将扫描到空格并复制这些术语所引用的所有术语。 扫描时,收集器将遍历to空间上的每个术语,并且仍然引用from空间的任何术语都将复制到to space上 。 一些术语包含非术语数据(例如,堆上二进制文件的有效负载)。 当收集器遇到这些值时,将直接跳过这些值。
我们可以到达的每个术语对象都被复制到to空间并存储在扫描停止行的顶部,然后将扫描停止移动到最后一个对象的末尾。
当扫描停止标记赶上 扫描开始标记时,便完成了垃圾回收。 在这一点上,我们可以从空间中 释放整个堆,从而回收整个年轻堆。
除了上述收集算法之外,Erlang垃圾收集器还提供了分代垃圾收集。 另外一个称为旧堆的堆用于存储长期数据。 原始堆称为年轻堆,有时也称为分配堆。
考虑到这一点,我们可以再次查看Erlang的垃圾回收。 在此期间,应该被复制到年轻的空间复制阶段,任何事情,而不是被复制到旧空间 ,如果它是高水印下方 。
高水位标记放置在先前的垃圾回收( 概述中已描述)结束的位置,并且我们引入了一个称为旧堆的新区域。 在进行正常的垃圾收集过程时,位于高水位线以下的任何术语都会复制到旧的空间而不是年轻的空间 。
在下一个垃圾回收中,所有指向旧堆的指针都将被忽略并且不会被扫描。 这样,垃圾收集器不必扫描长期存在的条件。
分代垃圾回收旨在以牺牲内存为代价来提高性能。 之所以能够实现这一目标,是因为在大多数垃圾回收中只考虑了较小的年轻堆。
世代假设预测大多数术语倾向于年轻而死,对于像Erlang这样的不可变语言,年轻术语的死亡甚至比其他语言更快。 因此,对于大多数使用模式而言,新堆中的数据在分配后很快就会消失。 这很好,因为它限制了复制到旧堆的数据量,并且还因为使用的垃圾回收算法与堆上的活动数据量成正比。
这里要注意的一个关键问题是,新堆上的任何术语都可以引用旧堆上的术语,但是旧堆上的任何术语都不能引用新堆上的术语。 这是由于复制算法的性质。 旧堆术语所引用的任何内容都不会包含在引用树,根集及其跟随者中,因此不会被复制。 如果是这样,数据将丢失,火和硫磺将覆盖整个地球。 幸运的是,这对于Erlang来说是自然而然的,因为这些术语是不可变的,因此在旧堆上不能修改任何指向年轻堆的指针。
为了从旧堆中回收数据,在收集期间会同时包含新堆和旧堆,并将它们复制到space的公共空间 。 然后,重新分配旧堆和旧堆的from空间 ,并且过程将从头开始。 这种类型的垃圾收集称为完全清除,当高水位标记下的区域大小大于旧堆的可用区域的大小时触发。 也可以通过手动调用erlang:garbage_collect()或遇到spawn_opt(fun(),[{fullsweep_after,N}])设置的年轻垃圾收集限制来触发,其中N是年轻垃圾的数量在强制对旧堆和旧堆进行垃圾回收之前要执行的回收。
如概述中所述,新堆或分配堆由堆栈和堆组成。 但是,它也包括附加到堆的所有堆片段。 所有堆碎片都被认为高于高水位线并且是年轻一代的一部分。 堆片段包含的术语要么不适合堆,要么由另一个进程创建,然后附加到堆。 例如,如果bif binary_to_term在不进行垃圾回收的情况下创建了一个不适合当前堆的术语,它将为该术语创建一个堆碎片,然后调度垃圾回收以备后用。 同样,如果将消息发送到进程,则有效负载可能会放在堆碎片中,并且当在接收子句中将消息匹配时,该碎片会添加到年轻堆中。
此过程与Erlang / OTP 19.0之前的工作方式不同。 在19.0之前,仅将年轻堆和堆栈所在的连续内存块视为年轻堆的一部分。 堆碎片和消息会立即复制到年轻的堆中,然后再由Erlang程序检查它们。 19.0中引入的行为在许多方面都具有优越性-最重要的是,它减少了必需的复制操作数量和垃圾回收的根集。
如概述中所述,堆的大小会增加以容纳更多数据。 堆增长分为两个阶段,首先使用233个单词开始的Fibonacci序列变体 。 然后,堆大约以1兆字为单位以20%的增量增长 。
幼堆增长有两种情况:
有两种情况会收缩年轻堆:
在堆增长阶段,老堆总是比年轻堆领先一步。
当垃圾收集堆(无论是旧的还是旧的)时,所有文字都保留在原处而不被复制。 为了确定在进行垃圾回收时是否应复制术语,使用了以下伪代码:
if (erts_is_literal(ptr) || (on_old_heap(ptr) && !fullsweep)) { /* literal or non fullsweep - do not copy */ } else { copy(ptr); }
erts_is_literal
检查在不同的体系结构和操作系统上的工作方式有所不同。
在允许映射未保留的虚拟内存区域(Windows以外的大多数操作系统)的64位系统上,将映射大小为1 GB(默认)的区域,然后将所有文字放在该区域中。 然后,需要做的就是确定两个对象是否为文字,这是两个快速指针检查 。 该系统依赖于以下事实:尚未被触摸的内存页面不会占用任何实际空间。 因此,即使映射了1 GB虚拟内存,也只会在ram中分配字面量实际需要的内存。 文字区域的大小可以通过+ MIscs erts_alloc选项进行配置。
在32位系统上,没有足够的虚拟存储空间来为文字分配1 GB的空间,因此,按需创建了256 KB的小字面区域,然后使用整个32位存储空间的卡标记位阵列来确定术语是否为文字。 由于总存储空间只有32位,因此卡标记位数组只有256个字大。 在64位系统上,相同的位数组必须大1兆个字,因此该技术仅在32位系统上可行。 在数组中进行查找要比仅在64位系统中可以完成的指针检查要昂贵得多,但并非完全如此。
在erts_alloc无法执行未保留的虚拟内存映射的64位窗口上,使用Erlang术语对象中的特殊标记来确定某些内容是否为文字 。 这是非常便宜的,但是该标记仅在64位计算机上可用,并且将来有可能对此标记进行很多其他不错的优化(例如,例如更紧凑的列表实现),因此不会在不需要它的操作系统上使用。
此行为与Erlang / OTP 19.0之前的工作方式不同。 在19.0之前,通过检查指针是否指向旧堆块或旧堆块来进行文字检查。 如果不是,则将其视为文字。 这导致相当大的开销和奇怪的内存使用情况,因此在19.0中已将其删除。
对于大于64个字节的二进制项(从现在起称为堆外二进制文件),二进制堆充当较大的对象空间。 二进制堆被引用计数,并且指向堆外二进制文件的指针存储在进程堆上。 为了跟踪何时减少堆外二进制文件的引用计数器,通过堆编织了一个包含乐趣和外部因素以及堆外二进制文件的链表(MSO-标记和清除对象列表)。 垃圾收集完成后, MSO列表将被清除 ,并且没有在标头单词中写入移动标记的任何堆外二进制文件的引用将减少,并且有可能被释放 。
MSO列表中的所有项目都按它们添加到进程堆的时间排序,因此,在进行次要垃圾回收时,MSO清除程序只需清除,直到遇到旧堆上的堆外二进制文件为止。
每个进程都有一个与之关联的虚拟二进制堆,该虚拟二进制堆具有该进程所引用的所有当前堆外二进制文件的大小。 虚拟二进制堆也有一个限制,并且会根据进程使用堆外二进制文件的方式来增长和缩小。 二进制堆和术语堆使用相同的增长和收缩机制,因此首先是斐波那契数列,然后增长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
将强制所有消息成为年轻堆的一部分,这将增加垃圾收集器必须移动的数据量。 因此,如果在处理大量消息时触发了垃圾回收,它们将被复制到年轻堆中。 反过来,这将导致消息将Swift提升到旧堆,从而增加其大小。 这可能是好事,也可能是坏事,具体取决于该过程执行的操作。 大的旧堆意味着年轻的堆也将更大,这反过来意味着在处理消息队列时将触发较少的垃圾回收。 这将临时增加进程的吞吐量,但需要更多的内存使用量。 但是,如果在使用完所有消息之后,该过程将进入一种状态,在该状态下,接收到的消息要少得多。 然后可能要等很长时间才能进行下一个全扫描垃圾收集,并且旧堆上的消息将一直存在,直到发生这种情况为止。 因此,尽管on_heap
可能比其他模式更快,但它会在更长的时间内使用更多的内存。 此模式是传统模式,几乎是在Erlang / OTP 19.0之前处理消息队列的方式。
这些策略中哪一个最好,在很大程度上取决于该流程在做什么以及与其他流程的交互方式。 因此,与往常一样,对应用程序进行概要分析,并查看其在不同选项下的行为。
[1]:CJ Cheney。 非递归列表压缩算法。 公社 ACM,13(11):677-678,1970年11月。
[2]:D。Ungar。 生成清除:一种无中断的高性能存储回收算法。 SIGSOFT软件。 。 笔记,9(3):157-167,1984年4月。
了解有关我们与 Erlang 和 Elixir 合作的更多信息 。
最初在 www.erlang-solutions.com上 发布 。
翻译自: https://hackernoon.com/erlang-garbage-collector-dfc9ad952130
erlang 垃圾回收