Erlang垃圾回收细节及其重要性

Erlang试图解决的主要问题之一是创建一个可以实现具有高度响应能力的软实时系统的平台。这样的系统需要快速的垃圾回收策略,这样就不会阻止系统以及时的方式进行响应。另外当我们认为Erlang是一门无破坏性修改特性的不可改变语言的时候,垃圾回收变得更重要,因为这样的语言有更高产生垃圾的几率。

内存布局

在我们深入垃圾回收之前,了解Erlang进程的内存布局是很有必要的。Erlang进程的内存布局可以分为三个主要部分:进程控制块,栈和堆。这和Unix的进程布局非常像。

  • 进程控制块:进程控制块持有进程的一些信息,诸如:它在进程表里的标识符(PID),当前状态(运行、等待),它的注册名字,初始化调用和当前调用;另外进程控制块还持有指向到达消息的指针,这些消息是链接列表的成员,它们存储在进程私有堆里。
  • :它是一个向下增长的内存区域,它持有函数的进出参数,返回地址,本地变量以及计算表达式的临时空间。
  • :它是一个向上增长的内存区域,它持有进程邮箱的实际消息,像列表元组这样的复合数据,二进制数据,大于一个机器字的诸如浮点数对象。大于64字节的二进制数据不保存在进程私有堆里,这样的二进制数据叫做Refc Binary(引用计数二进制),它们存储在一个大共享堆,被那些有指向引用计数二进制数据指针的进程访问。那些指针叫做ProcBin并且存储在进程的私有堆里。

垃圾回收细节

为了解释当前默认的Erlang垃圾回收机制,我们可以简单地说,一种是独立运行在每个Erlang进程私有堆内的分代复制垃圾回收,另一种是发生在全局共享堆的引用计数垃圾回收。

私有堆垃圾回收

私有堆的垃圾回收是分代的。分代垃圾回收将堆分成两个段:年轻代和老生代。分代的原理是:如果一个对象在一个垃圾回收周期存活下来,那么它短时间成为垃圾的机会就降低了。所以年轻代给新分配的数据使用,老生代给那些已经执行了指定次数的垃圾回收后还依然幸存下来的数据使用。这种分割为两个段的方式有助于垃圾回收减少在还没有变成垃圾的数据上进行不必要的垃圾回收过程。Erlang的垃圾回有两种策略:分代的(轻量级的)和全扫描(重量级的)。分代的垃圾回收只是回收年轻代堆,而全扫描垃圾回收则回收年轻代和老生代的堆。现在让我们仔细看看一个新启动的Erlang进程在私有堆里的垃圾回收步骤:

场景1:

1
Spawn > No GC > Terminate

一个短时存活的进程没有垃圾回收发生,它用的堆没有超过min_heap_size设置的值然后就终止了。在这个场景下,被进程使用的所有内存都被回收。

场景2:

1
Spawn > Fullsweep > Generational > Terminate

一个新创建的进程,它的数据增长超过min_heap_size设置的值,所以发生了一次全扫描垃圾回收,很明显因为在此之前从来没有发生过垃圾回收,所以就不存在年轻代和老生代两个段。在第一次全扫描垃圾回收后,堆被分割成年轻代和老生代,并且此后垃圾回收策略切换到分代的垃圾回收并且一直维持这种策略直到进程终止。

场景3:

1
Spawn > Fullsweep > Generational > Fullsweep > Generational > ... > Terminate

在进程的生命周期里当垃圾回收策略从分代的垃圾回收再次切换到全扫描的垃圾回收的时候,这会有几种不同的情况。第一种情况是在一定数量的分代的垃圾回收发生后。这个一定的数量可以全局设置或者每个进程用fullsweep_after标志设置。每个进程的分代的垃圾回收次数统计和它切换到全扫描的垃圾回收前的分代的垃圾回收次数上限分别是进程的minor_gcsfullsweep_after属性,同时这两个值可用process_info(PID, garbage_collection)的返回值来获得。第二种情况是分代的垃圾回收不能回收足够的内存的时候。最后一种情况是当garbage_collection(PID)函数被手工调用的时候。在这几种情况后,垃圾回收策略再次从全扫描的垃圾回收切换回分代的垃圾回收并且保持直到上述的情况发生。

场景4:

1
Spawn > Fullsweep > Generational > Fullsweep > Increase Heap > Fullsweep > ... > Terminate

在场景3里,如果第二次全扫描垃圾回收不能回收足够的内存,那么堆被增大,而垃圾回收策略再切换回全扫描垃圾回收,像一个新创建的进程一样。所有这四种场景可以反复发生。

那么现在的问题是,像Erlang这样的自动垃圾回收语言,上述这些知识为什么重要?首先这些知识能帮助你通过调优全局的或某个进程的垃圾回收的发生和策略来使得你的系统更快。其次从它的垃圾回收角度来开,我们可以理解使得Erlang成为一个软实时平台的主要原因之一。这是因为每一个进程都有它自己的私有堆和它自己的垃圾回收,因此每次在一个进程里垃圾回收发生只是让这个正在进行垃圾回收的进程停顿而不会停顿其他任何进程,这是一个软实时系统所需要的。

共享堆垃圾回收

共享堆垃圾回收是引用计数垃圾回收。每一个共享堆里的对象(Refc)都有一个引用它的计数器,这个计数器被其他对象(ProcBin)持有,而这个ProcBin对象存储在Erlang进程的私有堆里。如果一个对象的引用计数器的值变为0,这个对象变成不可访问,并且将被销毁。引用计数器方式的垃圾回收是如此的廉价而且帮助系统避免出现意外的长时间暂停同时促进了系统的响应。但是由于在设计你的参与者模式系统时不太熟悉的一些知名反模式可能会造成内存泄漏的麻烦。

  • 首先是当一个引用计数二进制数据被分割为子二进制数据。为了节省资源,子二进制数据并不是原二进制数据分割部分的新拷贝,而只是对这个分割部分的引用。然而除了原始二进制数据,这个子二进制数据的引用计数是一个新的引用,正如你能理解的,这将导致一个问题,原始二进制数据必须等它的子二进制数据被回收后才能回收。
  • 另一个众所周知的问题是当有一类长期生存的中间件进程,它作为一个请求控制器或消息路由器来控制和传输大的引用计数二进制消息。因为这个进程和所有这些引用计数二进制数据关联,所以它们的计数器值就增加了。所以回收这些引用计数二进制数据依赖于回收所有的ProcBin对象,甚至包括这个中间件进程里的ProcBin对象。非常不幸的是,因为ProcBin只是一个指针,它非常廉价,以至于在这个中间件进程里要花很长时间才能遇到一次回收这个ProcBin对象。造成的结果就是,即使除了中间件进程外其他所有进程里的ProcBin对象都被回收了,引用计数二进制数据还是继续留存在共享堆里。

共享堆很重要,因为它减少了进程间传递大二进制消息的IO开销。另外子二进制数据只是某个二进制数据的指针,所以它的创建是如此快速。但是按一般规律来说,为了更快而使用快捷方式是有代价的,代价就是要以某种方式好好设计你的系统不至于让你陷入到麻烦当中。另外针对引用计数二进制数据泄漏问题有一些著名的架构模式,这些在Fred Hebert的免费电子书 Erlang in Anger 里有详细的解释,我想我是没办法解释的比他更好。所以我强烈建议你读一下这本书。

结论

即使我们现在正在使用的语言,它像Erlang这样自己管理内存,但是也不能阻止我们去理解它是如何分配和回收内存的。不像Go语言内存模型文档所建议的:“如果你必须读本文档剩下部分来理解你自己程序的行为,你就是太聪明了。不过别自作聪明。”,我相信我们必须足够聪明才能够使我们的系统更快更安全,有时候它不会发生除非我们更深地钻研挖进去理解它的本质。

资源

原文链接: https://hamidreza-s.github.io/erlang%20garbage%20collection%20memory%20layout%20soft%20realtime/2015/08/24/erlang-garbage-collection-details-and-why-it-matters.html