我认为编写垃圾回收最难的地方就是内存分配,这和阅读 K&R 所写的 malloc 样例难度是相当的。
在开始之前有一些重要的事情需要说明一下:
第一,我们所写的代码是基于Linux Kernel的,注意是Linux Kernel而不是GNU/Linux。
第二,我们的代码是32bit的。
第三,请不要直接使用这些代码。我并不保证这些代码完全正确,可能其中有一些我还未发现的小的bug,但是整体思路仍然是正确的。
好了,让我们开始吧。
1
编写malloc
最开始,我们需要写一个内存分配器(memmory allocator),也可以叫做内存分配函数(malloc function)。最简单的内存分配实现方法就是维护一个由空闲内存块组成的链表,这些空闲内存块在需要的时候被分割或分配。
当用户请求一块内存时,一块合适大小的内存块就会从链表中被移除并分配给用户。
如果链表中没有合适的空闲内存块存在,而且更大的空闲内存块已经被分割成小的内存块了或内核也正在请求更多的内存(译者注:就是链表中的空闲内存块都太小不足以分配给用户的情况)。
那么此时,会释放掉一块内存并把它添加到空闲块链表中。
在链表中的每个空闲内存块都有一个头(header)用来描述内存块的信息。我们的header包含两个部分,第一部分表示内存块的大小,第二部分指向下一个空闲内存块。
将头(header)内嵌进内存块中是唯一明智的做法,而且这样还可以享有字节自动对齐的好处,这很重要。
由于我们需要同时跟踪我们“当前使用过的内存块”和“未使用的内存块”,因此除了维护空闲内存的链表外,我们还需要一条维护当前已用内存块的链表(为了方便,这两条链表后面分别写为“空闲块链表”和“已用块链表”)。
我们从空闲块链表中移除的内存块会被添加到已用块链表中,反之亦然。
现在我们差不多已经做好准备来完成malloc实现的第一步了。但是再那之前,我们需要知道怎样向内核申请内存。
动态分配的内存会驻留在一个叫做堆(heap)的地方,堆是介于栈(stack)和BSS(未初始化的数据段-你所有的全局变量都存放在这里且具有默认值为0)之间的一块内存。
堆(heap)的内存地址起始于(低地址)BSS段的边界,结束于一个分隔地址(这个分隔地址是已建立映射的内存和未建立映射的内存的分隔线)。
为了能够从内核中获取更多的内存,我们只需提高这个分隔地址。为了提高这个分隔地址我们需要调用一个叫作 sbrk 的Unix系统的系统调用,
这个函数可以根据我们提供的参数来提高分隔地址,如果函数执行成功则会返回以前的分隔地址,如果失败将会返回-1。
利用我们现在知道的知识,我们可以创建两个函数:morecore()和add_to_free_list()。
当空闲块链表缺少内存块时,我们调用morecore()函数来申请更多的内存。由于每次向内核申请内存的代价是昂贵的,我们以页(page-size)为单位申请内存。
页的大小在这并不是很重要的知识点,不过这有一个很简单解释:页是虚拟内存映射到物理内存的最小内存单位。
接下来我们就可以使用add_to_list()将申请到的内存块加入空闲块链表。
现在我们有了两个有力的函数,接下来我们就可以直接编写malloc函数了。
我们扫描空闲块链表当遇到第一块满足要求的内存块(内存块比所需内存大即满足要求)时,停止扫描,而不是扫描整个链表来寻找大小最合适的内存块,我们所采用的这种算法思想其实就是首次适应(与最佳适应相对)。
注意:有件事情需要说明一下,内存块头部结构中size这一部分的计数单位是块(Block),而不是Byte。
注意这个函数的成功与否,取决于我们第一次使用时是否使 freep = &base 。这点我们会在初始化函数中进行设置。
尽管我们的代码完全没有考虑到内存碎片,但是它能工作。既然它可以工作,我们就可以开始下一个有趣的部分-垃圾回收!
2
标记与清扫
我们说过垃圾回收器会很简单,因此我们尽可能的使用简单的方法:标记和清除方式。这个算法分为两个部分:首先,我们需要扫描所有可能存在指向堆中数据(heap data)的变量的内存空间并确认这些内存空间中的变量是否指向堆中的数据。
为了做到这点,对于可能内存空间中的每个字长(word-size)的数据块,我们遍历已用块链表中的内存块。
如果数据块所指向的内存是在已用链表块中的某一内存块中,我们对这个内存块进行标记。
第二部分是,当扫描完所有可能的内存空间后,我们遍历已用块链表将所有未被标记的内存块移到空闲块链表中。
现在很多人会开始认为只是靠编写类似于malloc那样的简单函数来实现C的垃圾回收是不可行的,因为在函数中我们无法获得其外面的很多信息。
例如,在C语言中没有函数可以返回分配到堆栈中的所有变量的哈希映射。但是只要我们意识到两个重要的事实,我们就可以绕过这些东西:
第一,在C中,你可以尝试访问任何你想访问的内存地址。因为不可能有一个数据块编译器可以访问但是其地址却不能被表示成一个可以赋值给指针的整数。
如果一块内存在C程序中被使用了,那么它一定可以被这个程序访问。这是一个令不熟悉C的编程者很困惑的概念,因为很多编程语言都会限制程序访问虚拟内存,但是C不会。
第二,所有的变量都存储在内存的某个地方。这意味着如果我们可以知道变量们的通常存储位置,我们可以遍历这些内存位置来寻找每个变量的所有可能值。
另外,因为内存的访问通常是字(word-size)对齐的,因此我们仅需要遍历内存区域中的每个字(word)即可。
局部变量也可以被存储在寄存器中,但是我们并不需要担心这些因为寄存器经常会用于存储局部变量,而且当函数被调用的时候他们通常会被存储在堆栈中。
现在我们有一个标记阶段的策略:遍历一系列的内存区域并查看是否有内存可能指向已用块链表。编写这样的一个函数非常的简洁明了:
为了确保我们只使用头(header)中的两个字长(two words)我们使用一种叫做标记指针(tagged pointer)的技术。
利用header中的next指针指向的地址总是字对齐(word aligned)这一特点,我们可以得出指针低位的几个有效位总会是0。
因此我们将next指针的最低位进行标记来表示当前块是否被标记。
现在,我们可以扫描内存区域了,但是我们应该扫描哪些内存区域呢?我们要扫描的有以下这些:
BBS(未初始化数据段)和初始化数据段。这里包含了程序的全局变量和局部变量。因为他们有可能应用堆(heap)中的一些东西,所以我们需要扫描BSS与初始化数据段,已用的数据块。
当然,如果用户分配一个指针来指向另一个已经被分配的内存块,我们不会想去释放掉那个被指向的内存块。堆栈。因为堆栈中包含所有的局部变量,因此这可以说是最需要扫描的区域了。
我们已经了解了关于堆(heap)的一切,因此编写一个mark_from_heap函数将会非常简单:
幸运的是对于BSS段和已初始化数据段,大部分的现代unix链接器可以导出 etext 和 end 符号。etext符号的地址是初始化数据段的起点(the last address past the text segment,这个段中包含了程序的机器码),end符号是堆(heap)的起点。
因此,BSS和已初始化数据段位于 &etext 与 &end 之间。这个方法足够简单,当不是平台独立的。
堆栈这部分有一点困难。堆栈的栈顶非常容易找到,只需要使用一点内联汇编即可,因为它存储在 sp 这个寄存器中。但是我们将会使用的是 bp 这个寄存器,因为它忽略了一些局部变量。
寻找堆栈的的栈底(堆栈的起点)涉及到一些技巧。出于安全因素的考虑,内核倾向于将堆栈的起点随机化,因此我们很难得到一个地址。
老实说,我在寻找栈底方面并不是专家,但是我有一些点子可以帮你找到一个准确的地址。
一个可能的方法是,你可以扫描调用栈(call stack)来寻找 env 指针,这个指针会被作为一个参数传递给主程序。
另一种方法是从栈顶开始读取每个更大的后续地址并处理inexorible SIGSEGV。
但是我们并不打算采用这两种方法中的任何一种,我们将利用linux会将栈底放入一个字符串并存于proc目录下表示该进程的文件中这一事实。这听起来很愚蠢而且非常间接。
值得庆幸的是,我并不感觉这样做是滑稽的,因为它和Boehm GC中寻找栈底所用的方法完全相同。
现在我们可以编写一个简单的初始化函数。
在函数中,我们打开proc文件并找到栈底。栈底是文件中第28个值,因此我们忽略前27个值。Boehm GC和我们的做法不同的是他仅使用系统调用来读取文件来避免让stdlib库使用堆(heap),但是我们并不在意这些。
现在我们知道了每个我们需要扫描的内存区域的位置,所以我们终于可以编写显示调用的回收函数了:
朋友们,所有的东西都已经在这了,一个用C为C程序编写的垃圾回收器。这些代码自身并不是完整的,它还需要一些微调来使它可以正常工作,但是大部分代码是可以独立工作的。
3
总结
一开始就打算编写完整的程序是很困难的,你编程的唯一算法就是分而治之。先编写内存分配函数,然后编写查询内存的函数,然后是清除内存的函数。最后将它们合在一起。
当你在编程方面克服这个障碍后,就再也没有困难的实践了。你可能有一个算法不太了解,但是任何人只要有足够的时间就肯定可以通过论文或书理解这个算法。
如果有一个项目看起来令人生畏,那么将它分成完全独立的几个部分。
你可能不懂如何编写一个解释器,但你绝对可以编写一个分析器,然后看一下你还有什么需要添加的,添上它。相信自己,终会成功!