Erlang垃圾回收机制详解
Erlang尝试去解决的一个主要问题是创建一个高响应的软实时系统,这样的系统需要一个快速的垃圾回收机制支持,当启动垃圾回收的时候不会造成系统响应时间的延迟. 另一方面, 考虑到Erlang作为一个Immutable语言,没有destructive操作,有比较高的垃圾生产率, 所以好的垃圾回收机制就显得尤为重要.
Memory Layout 内存分布
在我们深入垃圾回收机制之前,我们先来看看Erlang进程的内存布局. 一个Erlang进程的内存布局通常分为是三个部分(有人认为是四个部分, 把mailbox作为单独的一个部分), 进程控制块, 堆和栈,和普通的Linux进程的内存布局非常类似.
Shared Heap Erlang Process Memory Layout
+----------------------------------+ +----------------------------------+
| | | |
| | | PID / Status / Registered Name | Process
| | | | Control
| | | Initial Call / Current Call +----> Block
| | | | (PCB)
| | | Mailbox Pointers |
| | | |
| | +----------------------------------+
| | | |
| | | Function Parameters |
| | | | Process
| | | Return Addresses +----> Stack
| | | |
| +--------------+ | | Local Variables |
| | | | | |
| | +------------+--+ | +-------------------------------+--+
| | | | | | | |
| | | +-------------+--+ | | ^ v +----> Free
| | | | | | | | | Space
| | | | +--------------+-+ | +--+-------------------------------+
| +-+ | | | | | |
| +-+ | Refc Binary | | | Mailbox Messages (Linked List) |
| +-+ | | | |
| +------^---------+ | | Compound Terms (List, Tuples) | Process
| | | | +----> Private
| | | | Terms Larger than a word | Heap
| | | | |
| +--+ ProcBin +-------------+ Pointers to Large Binaries |
| | | |
+----------------------------------+ +----------------------------------+
-
进程控制块: 进程控制块持有关于进程的一些信息, 比如PID, 进程状态(running, waitting), 进程注册名, 初始和当前调用,指向进程mailbox的指针
-
栈: 栈是向下增长的, 栈持有函数调用参数,函数返回地址,本地变量以及一些临时空间用来计算表达式.
-
堆: 堆是向上增长的, 堆持有进程的mailbox, 复合terms(Lists, Tuples, Binaries),以及大于一个机器字的对象(比如浮点数对象). 大于64个字节的二进制terms,被称为Reference Counted Binary, 他们不是存在进程私有堆里面,他们是存在一个大的共享堆里,所有进程都可以通过指向RefC Binary的指针来访问该共享堆,RefC Binary指针本身是存在进程私有堆里面的.
GC Details
为了更准确的解释默认的Erlang垃圾回收机制, 实际上运行在每个独立Erlang进程内部的是分代拷贝垃圾回收机制, 还有一个引用计数的垃圾回收运行在共享堆上.
Private Heap GC 私有堆垃圾回收
私有堆的垃圾回收是分代的. 分代机制把进程的堆内存分为两个部分,年轻代和年老代. 区分是基于这样一个考虑, 如果一个对象在运行一次垃圾回收之后没有被回收,那么这个对象短期内被回收的可能性就很低. 所以, 年轻代就用来存储新分配的数据,年老代就用来存放运行一定次数的垃圾回收之后依然幸存的数据. 这样的区分可以帮助GC减少对那些很可能还不是垃圾的数据不必要的扫描. 对应于此, Erlang的GC扫描有两个策略, Generational(Minor) 和 Fullsweep(Major). Generational GC只回收年轻代的区域, 而Fullsweep则同时回收年轻代和年老代.
下面我们一起来review一下一个新创建的Erlang进程触发GC的步骤, 假设以下不同的场景:
场景 1:
Spawn > No GC > Terminate
假设一个生存期较短的进程, 在存活期间使用的堆内存也没有超过 min_heap_size,那么在进程结束是全部内存即被回收.
场景 2:
Spawn > Fullsweep > Generational > Terminate
假设一个新创建的进程,当进程的数据增长超过了min_heap_size时, fullsweep GC即被触发, 因为在此之前还没有任何GC被触发,所以堆区还没有被分成年轻代和年老代. 在第一次fullsweep GC结束以后, 堆区就会被分为年轻代和年老代了, 从这个时候起, GC的策略就被切换为 generational GC了, 直到进程结束.
场景 3:
Spawn > Fullsweep > Generational > Fullsweep > Generational > ... > Terminate
在某些情景下, GC策略会从generation再切换回fullsweep. 一种情景是, 在运行了一定次数(fullsweep_after)的genereration GC之后,系统会再次切换回fullsweep. 这个参数fullsweep_after可以是全局的也可以是单进程的. 全局的值可以通过函数erlang:system_info(fullsweep_after)获取, 进程的可以通过函数erlang:process_info(self(),garbage_collection)来获取. 另外一种情景是, 当generation GC(minor GC)不能够收集到足够的内存空间时. 最后一种情况是, 当手动调用函数garbage_collector(PID)时. 在运行fullsweep之后, GC策略再次切换回generation GC直到以上的任意一个情景再次出现.
场景 4:
Spawn > Fullsweep > Generational > Fullsweep > Increase Heap > Fullsweep > ... > Terminate
假设在场景3里面,第二个fullsweep GC依然没有回收到足够的内存, 那么系统就会为进程增加堆内存, 然后该进程就回到第一个场景,像刚创建的进程一样首先开始一个fullsweep,然后循环往复.
那么对Erlang来说, 既然这些垃圾回收机制都是自动完成的, 为什么我们需要花时间去了解学习呢? 首先, 通过调整GC的策略可以使你的系统运行的更快. 其次, 了解GC可以帮助我们从GC的角度来理解为什么Erlang是一个软实时的系统平台. 因为每个进程有自己的私有内存空间和私有GC,所以每次GC发生的时候只在进程内部进行,只stop本进程, 不会stop其他进程,这正是一个软实时系统所需要的.
Shared Heap GC 共享堆垃圾回收
共享堆的GC是通过引用计数来实现的. 共享堆里面的每个对象都有一个引用计数,这个计数就是表示该对象被多少个Erlang进程持有(对象的指针存在进程的私有堆里). 如果一个对象的引入计数变成0的时候就表示该对象不可访问可以被回收了.
#以下几个英文段落描述了共享堆潜在的问题,我还没有完全理解,等我阅读了更多这方面的资料以后再翻译这一段.
But being unaware of some well-known anti-patterns in designing your actor model system could make troubles in case of memory leak.
-
First when a Refc is splitted into a Sub-Binary. In order to be cheap; a sub-binary is not a new copy of splitted part of original binary, but just a reference into that part. However this sub-binary counts as a new reference in addition to the original binary, and you know, it can cause problem when the original binary must hang around for its sub-binary to be collected.
-
The other known problem is when there is a sort of long-lived middleware process acting as a request controller or message router for controlling and transferring large Refc binary messages. As this process touches each Refc message, the counter of them increments. So collecting those Refc messages depends on collecting all ProcBin objects even ones that are inside the middleware process. Unfortunately because ProcBins are just a pointer hence they are so cheap and it could take so long to happen a GC inside the middleware process. As a result the Refc messages remain on shared heap even if they have been collected from all other processes, except the middleware.
Shared heap matters because it reduces the IO of passing large binary messages between processes. Also creating sub-binaries are so fast because they are just pointers to another binary. But as a rule of thumb using shortcuts for being faster has cost, and its cost is well architecting your system in a way that doesn’t become trapped in bad conditions. Also there are some well-known architectural patterns for Refc binary leak issues which Fred Hebert explains them in his free ebook; Erlang in Anger, and I think that I cannot explain it better than him. So I strongly recommend you to read it.
结论
即使我们使用一种语言,比如Erlang,不需要开发者来管理内存,但是这个并不妨碍我们去学习理解语言本身是如何管理内存空间的, 分配,释放内存. 我相信有了对内存分配和GC的理解可以帮助我们使我们的系统运行的更快更好.
原文
##博客仅作个人记录##