C语言实现简易内存回收
前言
写这篇文章的起因是看到了Matthew Plant的一篇博文Writing a Simple Garbage Collector in C,在文中Matthew Plant将一个简易的内存回收拆分成几个部分,并用干练和诙谐的语言介绍了每个部分的实现。对于具有一定基础C语言程序员来说是篇非常好的学习教材,不仅可以从中了解到内存回收的实现思想,而且可以加深对Linux内存管理,链接器等知识的理解。虽然文章中实现的内存回收代码有很多缺陷没法在实际生产中应用,但是仍然具有学习意义。本文在保留了原文核心思想的前提下,结合笔者在复现代码过程中所遇到的问题,希望能给读者一点启发。
1. 简介
对于初级程序员来说,内存回收机制(GC,garbage collector)似乎是遥不可及的东西,是一种只有少数专家才会涉及的领域。然而事实并非如此,本文所介绍的内存回收代码设计思想直白,而且易于实现。其中,最复杂的部分莫过于内存分配机制的实现了,但是实际难度与《C程序设计语言 (K&R)》书中所写的malloc例程相差无几。
在正式开始之前有几点需要注意:
- 本文代码基于Linux内核实现,不是GNU/Linux而是Linux内核,内核版本为:Linux ubuntu 5.15.0-1018-raspi
- 本文代码在ARM64环境下编写和编译
- 请不要在实际工作中使用这些代码,因为会有一些潜在的bug,但是代码本身的实现思想是正确的。
现在让我们开始吧!
2. 内存分配
首先,我们要实现一个自己的malloc函数用作内存分配。一种直接的做法是:维护一个free list,在用户需要内存时,可以从中返回一块大小合适的内存。如果free list中找不到大小正好合适的内存,要么从更大的内存块上切分出大小合适的内存,要么从内核申请更多的内存。当我们释放内存时,只需要将用完的内存放回free list即可。
我们为free list中的每个内存块都分配一个header,用来记录内存块的相关信息。header中包含两个位域:size和next。一个用于表示内存块的大小,另一个用来指向下一个空闲内存。
typedef struct header{
unsigned int size;
struct header *next;
} header_t;
对于malloc函数来说,在其分配的内存中嵌入header是个明智的做法。这样做不仅有助于内存管理,而且能让内存块按word大小对齐,这一点后面会详细介绍。
因为我们需要持续追踪内存块当前是否有在使用,所以在free list之外我们还需要准备一个used list。当内存块从free list移出来后添加会加到used list中,反之亦然。
至此,我们已经完成了所有的准备工作,可以开始写我们的malloc函数了。在此之前,我们需要对如何从linux内核申请内存有一个基本了解。
程序运行中动态申请的内存都位于堆(heap)上,这段内存区域位于栈(stack)和BSS之间。BSS用来存放程序中未初始化的或者初始化为0的全局变量和静态变量。堆的生长方向是从地址往高地址生长,始于BSS的上边界,止于program break。如果试图访问program break和栈顶之间的区域会导致“非法访问”的错误。我们为了从内核申请更多的内存,只需简单的扩展program break即可,这样我们可访问的内存空间会增加。Unix系统调用函数sbrk提供了这种功能,sbrk根据输入参数来扩展program break。如果调用成功,则返回一个指针指向调用前的program break。如果失败,则返回 (void *) -1。
基于上述内容,我们创建两个辅助函数:morecore()和add_to_free_list()。如果free list中出现内存用尽的情况,可以调用morecore()申请更多内存。由于从内核申请内存的成本很高,所以我们每次申请的最小单位是page。page可以简单的理解为虚拟内存映射到物理内存的最小单位。add_to_free_list()的功能是将空闲内存块插入free list中。
static header_t base; //zero size block to get us started
static header_t *freep = &base; //points to first free block of memory
static header_t *usedp; //points to first used block of memory
/*
* Scan the free list and look for a place to put the block. Basically, we're
* looking for any the the to-be-freed block might have been partitioned from.
*/
static void add_to_free_list(header_t *bp)
{
header_t *p;
for(p=freep; !(bp > p && bp < p->next); p = p->next)
if(p >= p->next && (bp > p || bp < p->next))
break;
// insert free block
if(bp + bp->size == p->next){
bp->size += p->next->size;
bp->next = p->next->next;
}else
bp->next = p->next;
if(p+p->size == bp){
p->size += bp->size;
p->next = bp->next;
}else
p->next = bp;
freep = p;
}
#define MIN_ALLOC_SIZE 8192 //we allocate blocks in page sized chunks
/*
* request more memory from the kernel
*/
static header_t *morecore(size_t num_units)
{
void *vp;
header_t *up;
if(num_units > MIN_ALLOC_SIZE)
num_units = MIN_ALLOC_SIZE / sizeof(header_t);
if((vp = sbrk(num_units*sizeof(header_t))) == (void*)-1)
return NULL;
up = (header_t*)vp;
up->size = num_units;
add_to_free_list(up);
return freep;
}
有了上面两个辅助函数,接下来写我们的malloc函数就非常简单了。遍历free list,并从中找出首个与我们所需大小一样的内存块。由于我们找的是首个大小一样的内存块而不是“最合适”的内存块,所以这个算法叫做首次匹配算法。
在这里需要澄清的一点是:header中size字段是以header大小为单位来计算的,而不是字节为单位计算,即每个内存块的size都是header size的整数倍。这样可以保证分配出来的内存是按word对齐的。
/*
* Find a chunk from the free list and put it in the used list
*/
void *GC_malloc(size_t alloc_size)
{
size_t num_units;
header_t *p, *prevp;
num_units = (alloc_size+sizeof(header_t)-1)/sizeof(header_t)+1;
prevp = freep;
for(p = prevp->next; ; prevp = p, p = p->next){
if(p->size >= num_units){ //big enough
if(p->size == num_units) //exact size
prevp->next = p->next;
else{
p->size -= num_units;
p += p->size;
p->size = num_units;
}
freep = prevp;
//add p to the used list
if(usedp == NULL)
usedp = p->next = p;
else{
p->next = usedp->next;
usedp->next = p;
}
return (void *)(p+1);
}
if(p == freep){ //not enough memory
p = morecore(num_units);
if(p == NULL) //request for more memory failed
return NULL;
}
}
}
虽然我们的malloc函数无法处理内存碎片,但是这仍不妨碍它正常工作。接下来的内容是本文的重头戏——内存回收!
3. 内存回收
Mark and Sweep是一种最简单最直接的内存回收算法,该算法分为两个部分:
- Mark:扫描所有可能引用了heap内存的区域,并找出其中正在被使用的heap内存。对于当前正在被扫描的区域,从起始地址开始检查每个word大小的内存块。同时,遍历整个used list。如果被检查区域内存中的值在used list中某个item的地址范围内,则标记该used list item。
- Sweep:在扫描过所有可能的内存后,遍历used list并将其中没有被标记的item放回free list。
很多人会认为C语言无法实现内存回收,仅通过malloc这样一个简单的函数,无法知道内存整体发生了什么变化。由于在C语言中不存在这样一个函数,能够返回一个hash map其中包含了栈上所有已分配变量的信息。因此我们无法通过调用函数来了解当前内存的情况。但是,下面要介绍的内容或许可以解答这个疑虑:
首先,在C语言中你可以尝试访问任何你想访问的虚拟内存,即使有可能会触发非法访问的错误。因而不会存在某个内存块能够被编译器访问,但是该内存块的地址无法表示为一个整形变量,并且无法转换为指针。也就是说,如果在C语言程序中使用了某块内存,那么这块内存一定可以被该程序访问。这个概念对于不熟悉C语言的人来说会有点困扰,因为许多编程语言都严格限制对虚拟内存地址的访问,但是C语言没有。
其次,所有的变量都按某种规则保存在内存的固定区域。如果我们知道变量通常会保存在哪,我们可以直接查找那段内存区域,并找出所有变量可能的值。此外,由于内存通常按word对齐,我们以word为单位扫描每段内存区域即可。
虽然局部变量也可能保存在寄存器中,但是为此我们也不用太过担心。因为寄存器只会用于保存局部变量,到我们的函数被调用时,这些局部变量可能已经保存到栈上了。
对于内存回收流程的Mark阶段,我们扫描一段内存区域,查看其中是否有内存引用了used list中某个item。
#define UNTAG(p) (((unsigned long)(p)) & 0xfffffffffffffffc)
/*
* Scan a region of memory and mark any items in the used list appropriately
* Both arguments should be word algined.
*/
static void scan_region(unsigned long *sp, unsigned long *end)
{
header_t *bp;
for(; sp < end; sp++){
unsigned long v = *sp;
bp = usedp;
do{
if(v >= bp+1 && v < bp+1+bp->size){
bp->next = ((unsigned long) bp->next) | 1;
break;
}
}while((bp = UNTAG(bp->next)) != usedp);
}
}
为了保证每个header只占两个word的大小,我们在这用了一个叫标记指针的技术。由于next指针按word大小对齐,低两比特总是0。因此,我们使用next指针的最低比特来指示当前item(不是next指针所指向的下个item)已被标记。
现在我们已经具备扫描内存区域的能力了,但是我们要扫描哪些内存区域呢?可以从下面几个区域入手:
- BSS和已初始化数据段:这些区域包含程序中所有全局变量和静态变量,它们是有可能引用heap中的某些数据的。
- 堆中已经使用的内存:如果用户分配了一个指针指向heap中某个已分配的内存,那我们不能释放这些内存。
- 栈:栈上保存着所有局部变量,可以说这是最重要的地方。
i. 如何扫描堆
堆跟其他内存区域不一样,它是不连续的。这好像跟我们的代码有些矛盾,为了申请更多内存,我们使用sbrk()函数扩展了program break的结尾。因此我们似乎可以像扫描其他区域一样扫描整个堆。
这是不对的,因为现代存储技术可以按page分配内存,也可以不连续分配。按page分配通常通过mmap()系统调用实现。
事实上我也不清楚sbrk和mmap之间是否会相互影响的,也不确定内核是否留出了足够的内存区域来保证你在调用GC_malloc的时候,sbrk不会在mmap已经分配区域中进行内存映射。所以除非我们自己想进行更深入的探索,当用户使用GC_malloc时,我们必须警告他们不能再使用系统提供的malloc函数。
即使我们可以连续扫描堆,最好也不要这么做。内核回收内存时通常不会导致读取释放后的内存出现页面错误的问题,所以连续访问堆上的地址也不会报错。但是,仅通过地址我们无法有效地确认一个内存块是否被映射到页表(Page Table)上。
虽然无法连续扫描堆有些遗憾,但是这样会让我们的代码更加简单和快速。我们需要扫描的对象就是used list中的那些items。
/*
* Sacn the marked blocks for references to other unmarked blocks.
*/
static void scan_heap(void)
{
unsigned long *vp;
header_t *bp, *up;
for(bp = UNTAG(usedp->next); bp != usedp; bp = UNTAG(bp->next)){
if(!((unsigned long)bp->next & 1))
continue;
for(vp = (unsigned long *)(bp+1); vp < (bp+bp->size+1); vp++){
unsigned long v = *vp;
up = UNTAG(bp->next);
do{
if(up != bp && v >= up+1 && v < up+1+up->size){
up->next = ((unsigned long) up->next) | 1;
break;
}
}while((up = UNTAG(up->next)) != bp);
}
}
}
ii. 扫描连续内存区域
与堆不同,BSS、已初始化数据段和栈都是连续的,为了扫描这些区域我们需要分别知道他们的最低有效地址和最高有效地址。
a. 查找数据段的位置
在我们讨论如何查找数据段的位置之前,让我们先回顾一下内存区域的分布。
Address | Segment |
---|---|
Low address | Text segment |
… | Initialized Data |
BSS | |
Heap(grows low to high) | |
… | |
High address | Stack(grow high to low) |
大多数Unix链接器会导出几个用户可访问的symbol,这些symbol的地址就是某些内存区域的起始或结束地址。原文中作者介绍到etext symbol的地址是text段的结束地址,也是initialized data段的起始地址。end symbol的地址是heap的起始地址,也可以说是BSS段的结束地址。但是我使用的链接器版本是:GNU ld (GNU Binutils for Ubuntu) 2.38 ,从它提供的信息中可以看出etext并不是text段的结尾,.fifn才是。同时,etext也不是initialized data段的起始地址。
所以我们要往后寻找data段的起始地址。可以看见链接器在data段的开头输出了一个_data_start symbol,_data_start的地址可以在代码中作为data段的起始地址。 因为在BSS段和data段之间没有其他segment,可以把他们当成一个整体,直接扫描&_data_start到&end之间的区域。
b. 查找调用栈的底部
栈处理起来会有点棘手。使用少量的内联汇编代码可以很容易地找到栈顶,因为它存储在SP寄存器中。但是,要找到栈底(堆栈开始的地方)则需要一些技巧。
出于安全考虑,内核会随机化栈的起始地址,因此我们无法把栈底地址硬编码到程序中。对于寻找栈底我没有什么经验,但是我能给你提供两条思路帮助你能准确地找到栈底。
- 扫描调用栈,找到env指针后当做参数传给main函数;
- 从栈顶开始依次往后读取更大的地址,同时要处理可能会发生的SIGSEGV;
我们并不会使用上述两种方法,Linux会把栈底地址放在proc目录下的某个文件中,我们可以利用这一点找出栈底地址。这个方法听上去一点都不专业,甚至有点蠢。但是我不这么认为,因为Boehm GC也是这样找到栈底的!
c. 合并上述所有功能
现在我们开始写初始化函数,初始化free list和used list之外更重要的是找到栈底。我们打开/proc/self/stat文件,其中第28个值就是栈底地址。与Boehm GC不同的是,Boehm GC中使用系统调用来读取文件,从而避免了stdlib.h的文件操作函数使用堆上内存。但是我们并不关心这些。
static unsigned long stack_bottom;
/*
* Find the absolute bottom of the stack and set stuff up.
*/
void GC_init(void)
{
static int initted;
FILE *statfp;
if(initted)
return;
initted = 1;
statfp = fopen("/proc/self/stat", "r");
assert(statfp != NULL);
fscanf(statfp,
"%*d %*s %*c %*d %*d %*d %*d %*d %*u "
"%*lu %*lu %*lu %*lu %*lu %*lu %*ld %*ld "
"%*ld %*ld %*ld %*ld %*llu %*lu %*ld "
"%*lu %*lu %*lu %lu", &stack_bottom);
fclose(statfp);
usedp = NULL;
base.next = freep = &base;
base.size = 0;
}
在知道所有待扫描内存区域的位置后,可以开始写回收函数了。
/*
* Mark blocks of memory in use and free the ones not in use.
*/
void GC_collect(void)
{
header_t *p, *prevp, *tp;
unsigned long stack_top;
extern char __data_start, _end; //provided by the linker.
if(usedp == NULL)
return;
//scan the BSS and initialized data segments
scan_region(&__data_start, &_end);
//scan the stack
asm volatile("mov %0, sp" : "=r" (stack_top));
scan_region(stack_top, stack_bottom);
//mark from the heap
scan_heap();
//collect memory garbage
for(prevp = usedp, p = UNTAG(usedp->next); ; prevp = p, p = UNTAG(p->next)){
next_chunk:
if(!((unsigned long)p->next & 1)){
//the chunk hasn't been marked. Thus, it must be free
tp = p;
p = UNTAG(p->next);
add_to_free_list(tp);
if(usedp == tp){
usedp = NULL;
break;
}
prevp->next = (unsigned long)p | ((unsigned long)prevp->next & 1);
goto next_chunk;
}
p->next = ((unsigned long)p->next) & ~1;
if(p == usedp)
break;
}
}
以上就是C语言简易内存回收机制的全部代码了。最后运行的时候记得加上下面这些头文件。
#include <stdio.h>
#include <stddef.h>
#include <assert.h>
#include <unistd.h>
4. 反思
正如我之前所说,这个简易的内存回收代码并不完美。与产品级的内存回收相比我们的代码少了什么呢?下面我给出几点原因:
i. 回收机制不够精确
内存回收根据其准确性可以分为模糊回收和精确回收。模糊回收无法保证回收所有垃圾内存,但是精确回收可以做到。
在C语言中我们无法判断一块内存是用作指针,还是仅用来保存整形或浮点型数据。通常来说,编程语言不能提供足够的反射信息来确定栈上分配变量的类型。然而,即使C语言能提供这些信息也于事无补,因为C语言中地址和数值类型可以相互转换,导致我们无法准确判断变量的类型。Boehm GC也有同样的问题。如果栈和堆上保存着大量堆上的数据,这种情况很常见,按照我们代码的逻辑由于无法区分哪些是指针哪些是数值,因此不会回收这些内存,而是越攒越多。
因此内存回收大多都内置在编程语言支持的运行时中。运行时提供的信息让内存回收更加精确。例如,Go语言会将数据类型的信息也压入栈中,同时禁止指针和数据类型之间的转化,以此来实现精确回收。
ii. 回收代码不能并行执行
a. 函数的可重入性
如果一个函数在执行的任意时刻中断它,转而执行另一段代码,返回执行该函数后不会出现错误,那么这个函数就具有可重入性。具有可重入性的函数可用于多任务环境。标准C语言内存分配例程虽然是串行实现,但是允许并行调用。我们的GC_malloc函数并不是可重入的,因为sbrk本身就不可重入。实现malloc函数可重入性的更好选择是通过mmap实现匿名页面映射,同时这样也能保证页面对齐。为了实现并行编程而优化这些函数超出了本文的范围。退一步说,即使将sbrk替换为mmap,但是在GC_malloc中我们还是会修改全局变量的值,无法做到可重入。
b. Stop the world vs. Parallel collection
除了简单的将函数改成可重入的之外,还可以通过其他方式提高程序并行性。当前我们的代码有个问题,每当执行内存回收代码时,需要暂停当前线程上所有运行的程序,这就是我们所说的“Stop the World”。为了减少程序暂停执行的影响,或者分摊暂停执行的成本,可以在另一个线程上单独执行内存回收代码。
iii. 缺少寄存器检查
除了我们之前讨论的那些内存区域,寄存器也很有可能会指向动态分配的内存。一个简单的解决办法是,在扫描栈之前将函数调用方保存在寄存器中的变量压入栈中即可。
原文链接:Writing a Simple Garbage Collector in C
参考链接: https://stackoverflow.com/questions/1765969/where-are-the-symbols-etext-edata-and-end-defined