你们可能听说了 Innokenty Mihailov’s great Enumerable::Lazy feature 是如何被收入Ruby的基础代码.但是你可能没有听说在一月被整合进ruby2.0的另一个更重要的变化:一个叫做“Bitmap Maiking”的GC新算法。这个精致且富于创新的变化的幕后开发者,Narihiro Nakamura, 至少从08年就开始了对此的研究,并且实现了已经收录进1.9.3版本的 “Lazy Sweep”GC算法. 新的Bitmap Marking GC算法承诺,所有运行在WebServei上的Ruby进程的总体内存占用将会被大幅减少!
“bitmap marking”究竟是什么意思呢? 他又为什么可以减少内存占用呢? 如果你会日语,可以直接阅读Narihiro Nakamura和Yukihiro (“Matz”) Matsumoto的文章a detailed academic paper published in 2008 . 我对此非常感兴趣,所以周末我花了一些时间自己研究了MRI Ruby的GC源码,这篇文章将会总结我所学习到的东西。在这篇文章里你不会得到关于Ruby编程的指导, 但我希望你能对Ruby GC的内部工作模式有一个更深入的理解, 了解为什么Ruby2.0值得期待,以及Ruby核心开发者们是多么的富有革新精神。
标记与回收
就像我一月份的文章中所说的, Never create Ruby strings longer than 23 characters, 所有的Ruby String 值都被MRI内部保存在一个叫做RString的C结构体中,也就是 “Ruby String.” 的缩写。每个RString结构体都被分成这样的两部分:
下半部分是这个字符串自身的值, 上半部分我用Flags来表示,它代表各种被Ruby持续追踪的内部元数据值。实际上所有Ruby程序所使用的值都被保存在差不多的结构中, 例如RArray, RHash,RFile等等. 他们都具有相同的基本结构:一些数据,以及相同的一组flag. 这种被所有内部对象所共用的结构体,叫做RValue,也就是Ruby Value。
Ruby 把这些RValue结构体们组成一组叫做“堆(heap)”的队列。 下面是一个Ruby堆队列的概念图,它包含了三个字符串值以及一些其他的RValue:
当你的Ruby程序运行,无论何时你创建了一个新的变量或者一些类型的值,Ruby解释器都会在堆内找到相应的RValue结构体用于保存新的值。当然,你完全不用单行这个过程,这些都会被漂亮地自动完成。
当RValue结构体用完而你的程序又需要保存新的值时,Ruby就会运行垃圾收集(GC)。 垃圾收集器的任务就是找到这些RValues中不会再被程序使用,并可以回收重利用的那些。下面就是GC在较高层面上的工作原理….
首先, GC “标记” 所有的活跃的RValue结构体。它会遍历所有的变量和其他活跃的对RValue的引用, 然后利用那些内部flag中的一个来为每一个RValue做上标记, 这个内部flag叫做FL_MARK。
这是Ruby “标记与回收” GC算法的第一部分. 那些被标记的结构体正在被程序使用着,不能被释放和回收。
当所有活跃的结构体都被标记完毕,剩下的RValue结构体就会被清理到一个有Next指针连接的链表中。下图中, 我用M来表示堆队列中被标记FL_MARK的结构体。在下方你可以看到那些没有被标记的RValue结构体,称作“空闲列表”。
但过了一会,堆中可能就再也没有未被标记的结构体剩下了,所有的RValue都在使用中,这种情况下,Ruby就会申请一个全新的堆空间来获得更多的RValue结构体。(实际上它会一次申请10个堆空间) 一个传统的Ruby程序最终可能会有很多不同的堆队列。
写时拷贝: Unix怎样在不同子进程中共享内存
在我们谈 “Bitmap Marking”及其重要性之前, 我们首先需要学习Linux和其他Unix类OS的一个关于内存管理和内存分配的特性:写时拷贝最优化。在这些操作系统中,当一个进程调用fork来创建一个和父进程完全相同的子进程时,新的子进程会和父进程共享所有的内存,包括所有的数据、变量等父进程之前创建的一切内容。 因为避免了不必要的内存的拷贝,使得子进程的调用要快很多,同时也减少了内存占用。
它被叫做“写时拷贝”技术是因为,当一个子进程将要更改内存数据的时候,一份将要变更的数据的拷贝将会被生成供子进程编辑。这与Ruby解释器在处理RString的时候所做的事情是差不多的,具体的内容可以看我一月份的文章:Seeing double: how Ruby shares string values.
为了更好的理解,我们来看一张Ruby进程的概念图:
例子中的Ruby程序有两个堆队列。假设这个程序运行在一个Web Server中,比如一个Rails应用,这时另外一个用户发来了另一个HTTP请求。
现在我们有了两个运行的Ruby进程。有可能这个Web Server是Appach结合了Passanger之类的东西来fork一个单独的Ruby进程以处理多个HTTP请求。
Linux的写时拷贝优化的好处是,这两个Ruby进程可以共享堆中绝大多数的RValue结构体,因为他们经常是相同的。 这可能让人难以一下子认同; 这两个Ruby进程里的变量数据怎么会是相同的呢?但是其实在web server上,我们实际上运行着多个相同代码的实例, 也在一遍又一遍的创建着相同的变量. 并且,很多这些RValue结构体其实是属于被解析的Ruby程序自身 – 抽象语法树 (“Abstract Syntax Tree”-AST). 由于所有进程都在运行相同的代码,所有这些节点都有着相同的值并且不会被改变. 当然,其中一些数据会不同的并未会被每个进程单独保存 – 例如在网页上输入并提交的用户数据,SQL请求的不同结果等等。
但是, 虽然听起来很不错, 这在Ruby里却没有真正起作用!
为什么呢? 因为只要Ruby运行了GC,所有AST节点和很多其他的使用中的RValue都会被做上标记。为了设置FL_MARK它们都会被修改,这时系统的写时拷贝技术就会把这些东西全都复制一份了。 所以,这才是在一个典型的Ruby的web应用中将会发生的事:
就是那小小的FL_MARK在作祟! 要不是它,程序将可以大大的减少内存占用。
一点重要的提示: Hongli Lai fromPhusion, 连接了Apache和基于Rack的Ruby引用的流行的中间件Passenger的作者,修改了Ruby1.8并创建了一个新的版本Ruby Enterprise Edition ,解决了这个问题并且在其他方面提升了性能。所以,那些使用了REE的Ruby1.8应用已经享受写时拷贝技术带来的福利好几年了。但是写时拷贝技术还是无法在标准MRI Ruby 1.8或1.9中发挥作用。
Ruby 2.0 GC: Bitmap Marking
以下是 Narihiro Nakamura’s 为 Ruby 2.0 做出的变更! 不同于以前使用 FL_MARK来标记使用中无法释放的RValue, Ruby 2.0 把这些信息保存在一个叫做‘bitmap’的东西中。 不… 此 “bitmap” 与位图无关; “bitmap” 在这里是指一组以每个bit映射标记RValue结构体的二进制数据。
Narihiro 通过在每个堆队列的头部加上一个头结构体以指向对应的bitmap来实现了这项技术. 这代表着Ruby2.0中,GC的标记阶段将不用再去修改那些RValue结构体自身了。这使得Unix能够让不同的Ruby进程共享内存!而bitmaps自身会频繁的被编辑,但是由于它使用一个连续的比特流所以自身并不会太大,可以被每个进程单独保存而不占用太多内存。
一个有趣且重要的细节是,堆内存的分配现在必须是“对齐的”。意思是说,在分配堆内存时,Ruby C代码将会调用posix_memalign而不是malloc,前者在Linux 或Unix系统上将会返回一个符合2的幂的地址边界。(?)
这到底是啥意思? 如果你对C编程或是按位运算很熟悉的话, 它使得Ruby C代码可以快速的通过给出的RValue内存地址计算出“头结构体”的位置。我们再来看一下Ruby2.0的堆:
假设Ruby2.0 GC需要标记堆中的第五个RValue,也就是途中ptr所使用的值。 内存对齐让 Ruby 2.0可以使用ptr 的值快速算出堆的”头结构体“的地址。Ruby 2.0要做的是过滤掉最后几位用于表示RValue地址的比特位,本例中是指16进制位移 “68”,来找到”头结构体“的地址,本例中是指 “membase” 或 0x80FFC000(32bit).、
总结
一般人认为GC并不是Ruby中最迷人和有意思的部分, 但如果你仔细看看就会发现其中有很多很多有趣的创新。实际上, Bitmap Marking将会帮助MRI Ruby 2.0产品在web server下工作的更好,大幅减少内存开销。但是比起考虑Bitmap Marking将会帮我的Rails应用更好的运行,我更倾向于把它当做一个对复杂问题的令人兴奋的创造性的解决方案。学习Ruby2.0的GC工作方式非常的有意思,我也希望大家藉此感受到Ruby团队中那些开发者们的努力!
原文 Why You Should Be Excited About Garbage Collection in Ruby 2.0 Pat Shaughnessy