Erlang 试图解决的主要问题之一是创建一个用于实现具有高响应能力的软实时系统的平台。这样的系统需要一个快速的垃圾回收机制,以保证系统不会停止及时响应。另一方面,当我们把 Erlang 视为具有非破坏性更新特性的不可变语言时,垃圾回收就变得更加重要,因为在这类语言中垃圾产生率很高。
内存布局
在深入研究 GC 之前,有必要了解 Erlang 进程的内存布局,它可分为三个主要部分: 进程控制块(Process Control Block)、堆栈(Stack)和堆(Heap)。它与 Unix 进程内存布局非常相似。
- PCB:进程控制块(Process Control Block)保存有关进程的一些信息,如进程表中的标识符(PID)、当前状态(运行、等待)、注册名称、初始调用和当前调用,PCB 还保存一些指向传入信息的指针,这些信息是存储在堆中的链接列表的成员。
- 堆栈: 它是一个向下增长的内存区域,用于存放传入和传出参数、返回地址、局部变量和用于评估表达式的临时空间。
- 堆:它是一个向上增长的内存区域,用于存放进程邮箱的物理信息、列表、元组和二进制数等复合项以及浮点数等大于机器字的对象。大于 64 字节的二进制项不存储在进程私有堆中。它们被称为 Refc 二进制(Reference Counted Binary),存储在一个大型共享堆中,所有拥有该 Refc 二进制指针的进程都可以访问该共享堆。该指针称为 ProcBin,存储在进程私有堆中。
GC 细节
为了简明扼要地解释当前默认的 Erlang GC 机制,我们可以这样说:它是在每个 Erlang 进程的私有堆中独立运行的分代复制垃圾收集,同时也对全局共享堆进行引用计数垃圾收集。
私有堆 GC
私有堆的 GC 是分代 GC。分代 GC 将堆分为两部分:年轻一代和年老一代。这种划分是基于这样一个事实:如果一个对象在 GC 循环中存活下来,那么它在短期内成为垃圾的几率就很低。因此,年轻一代用于处理新分配的数据,而年老一代则用于处理已通过特定次数 GC 的数据。这种分离有助于 GC 减少对尚未成为垃圾的数据进行不必要的循环。Erlang 垃圾回收有两种策略:年轻一代(次要)和全扫(主要)。次要 GC 只收集年轻堆,而全扫描则同时收集年轻堆和老堆。现在让我们回顾一下新启动的 Erlang 进程在私有堆中的 GC 步骤:
场景 1:
无 GC 发生在一个短命进程中,该进程不会使用超过 min_heap_size 的堆,然后终止。这样,进程使用的全部内存都会被收集起来。
场景2:
一个新创建的进程,其数据增长超过 min_heap_size 时,会使用 fullsweep GC。在第一次 fullsweep GC 之后,堆被分为年轻和年老两部分,之后 GC 策略切换为 generational GC,并一直持续到进程终止。
场景 3:
在进程生命周期的某些情况下,GC 策略会再次从 generational 切换回 fullsweep。第一种情况是在执行了一定次数的 generational GC 之后。可以用 fullsweep_after 标志为全局或每个进程指定一定的次数。此外,每个进程的 generational GC 计数器及其在 fullsweep GC 之前的上限分别是 minor_gcs 和 fullsweep_after 属性,可以在 process_info(PID, garbage_collection) 的返回值中看到。第二种情况是分代 GC 无法收集到足够的内存,最后一种情况是手动调用 garbage_collect(PID)函数。在这些情况发生后,GC 策略会再次从 fullsweep 还原为 generational GC,并一直保持到上述情况发生为止。
场景 4:
在第 3 种情况下,如果第二次 fullsweep GC 无法收集到足够的内存,那么堆的大小就会增加,GC 策略就会再次切换到 fullsweep,就像新创建的进程一样。
现在的问题是,为什么在 Erlang 这样的自动垃圾回收语言中,这一点很重要。首先,这些知识可以帮助你调整全局或每个进程的 GC 发生率和策略,使系统运行得更快。其次,我们可以从垃圾回收的角度来理解 Erlang 成为软实时平台的主要原因之一。这是因为每个进程都有自己的私有堆和自己的 GC,所以每次 GC 在进程内发生时,它只会停止正在收集垃圾的 Erlang 进程,而不会停止其他进程,这正是软实时系统所需要的。
共享堆 GC
共享堆的 GC 是引用计数。共享堆中的每个对象(Refc)都有一个被其他对象(ProcBin)引用的计数器,这些对象存储在 Erlang 进程的私有堆中。如果对象的引用计数器为零,则表示该对象已不可访问,并将被销毁。引用计数 GC 非常便宜,可以帮助系统避免意外的长时间停顿,提高系统响应速度。但是,如果在设计角色模型系统时没有注意到一些众所周知的反模式,就会在内存泄漏时带来麻烦。
-
首先,当 Refc 被拆分为子二进制时。为了便宜起见,子二进制并不是原始二进制分割部分的新副本,而只是对该部分的引用。然而,除了原始二进制外,这个子二进制也算作一个新的引用,当原始二进制必须等待其子二进制被收集时,就会产生问题。
-
另一个已知的问题是,当有一个长期存在的中间件进程作为请求控制器或消息路由器来控制和传输大型 Refc 二进制消息时。当这个进程接触到每条 Refc 报文时,它们的计数器就会递增。因此,收集这些 Refc 消息取决于收集所有 ProcBin 对象,即使是中间件进程内部的对象。遗憾的是,由于 ProcBin 只是一个指针,因此它们的成本很低,在中间件进程内部进行 GC 可能需要很长时间。因此,即使从所有其他进程(中间件除外)收集了 Refc 消息,它们仍会保留在共享堆上。
共享堆之所以重要,是因为它减少了进程间传递大型二进制信息的 IO。此外,创建子二进制也非常快,因为它们只是指向另一个二进制的指针。但根据经验,使用捷径来提高速度是有代价的,而代价就是要以一种不会陷入困境的方式来架构你的系统。Fred Hebert 在他的免费电子书《Erlang in Anger》中对 Refc 二进制泄漏问题进行了解释。因此,我强烈建议你阅读这本书。
结论
即使我们使用的是像 Erlang 这样自己管理内存的语言,也没有什么能阻止我们了解内存是如何分配和释放的。不像 Go 语言内存模型文档页面建议 “如果你必须阅读本文档的其余部分才能理解你的程序的行为,那你就太聪明了。不要自作聪明。”,我认为我们必须足够聪明,才能让我们的系统更快、更安全。