用 C 語言編寫一個簡單的垃圾回收器

http://blog.jobbole.com/77248/


人們似乎認爲編寫垃圾回收機制是很難的,是一種只有少數智者和Hans Boehm(et al)才能理解的高深魔法。我認爲編寫垃圾回收最難的地方就是內存分配,這和閱讀K&R所寫的malloc樣例難度是相當的。

在開始之前有一些重要的事情需要說明一下:第一,我們所寫的代碼是基於Linux Kernel的,注意是Linux Kernel而不是GNU/Linux。第二,我們的代碼是32bit的。第三,請不要直接使用這些代碼。我並不保證這些代碼完全正確,可能其中有一些我還未發現的小的bug,但是整體思路仍然是正確的。好了,讓我們開始吧。

如果你看到任何有誤的地方,請郵件聯繫我[email protected]

編寫malloc

最開始,我們需要寫一個內存分配器(memmory allocator),也可以叫做內存分配函數(malloc function)。最簡單的內存分配實現方法就是維護一個由空閒內存塊組成的鏈表,這些空閒內存塊在需要的時候被分割或分配。當用戶請求一塊內存時,一塊合適大小的內存塊就會從鏈表中被移除並分配給用戶。如果鏈表中沒有合適的空閒內存塊存在,而且更大的空閒內存塊已經被分割成小的內存塊了或內核也正在請求更多的內存(譯者注:就是鏈表中的空閒內存塊都太小不足以分配給用戶的情況)。那麼此時,會釋放掉一塊內存並把它添加到空閒塊鏈表中。

在鏈表中的每個空閒內存塊都有一個頭(header)用來描述內存塊的信息。我們的header包含兩個部分,第一部分表示內存塊的大小,第二部分指向下一個空閒內存塊。

1
2
3
4
typedef struct header{
    unsigned int  size;
    struct block  *next;
} header_t;

將頭(header)內嵌進內存塊中是唯一明智的做法,而且這樣還可以享有字節自動對齊的好處,這很重要。

由於我們需要同時跟蹤我們“當前使用過的內存塊”和“未使用的內存塊”,因此除了維護空閒內存的鏈表外,我們還需要一條維護當前已用內存塊的鏈表(爲了方便,這兩條鏈表後面分別寫爲“空閒塊鏈表”和“已用塊鏈表”)。我們從空閒塊鏈表中移除的內存塊會被添加到已用塊鏈表中,反之亦然。

現在我們差不多已經做好準備來完成malloc實現的第一步了。但是再那之前,我們需要知道怎樣向內核申請內存。

動態分配的內存會駐留在一個叫做堆(heap)的地方,堆是介於棧(stack)和BSS(未初始化的數據段-你所有的全局變量都存放在這裏且具有默認值爲0)之間的一塊內存。堆(heap)的內存地址起始於(低地址)BSS段的邊界,結束於一個分隔地址(這個分隔地址是已建立映射的內存和未建立映射的內存的分隔線)。爲了能夠從內核中獲取更多的內存,我們只需提高這個分隔地址。爲了提高這個分隔地址我們需要調用一個叫作 sbrk 的Unix系統的系統調用,這個函數可以根據我們提供的參數來提高分隔地址,如果函數執行成功則會返回以前的分隔地址,如果失敗將會返回-1。

利用我們現在知道的知識,我們可以創建兩個函數:morecore()和add_to_free_list()。當空閒塊鏈表缺少內存塊時,我們調用morecore()函數來申請更多的內存。由於每次向內核申請內存的代價是昂貴的,我們以頁(page-size)爲單位申請內存。頁的大小在這並不是很重要的知識點,不過這有一個很簡單解釋:頁是虛擬內存映射到物理內存的最小內存單位。接下來我們就可以使用add_to_list()將申請到的內存塊加入空閒塊鏈表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/*
 * Scan the free list and look for a place to put the block. Basically, we're
 * looking for any block 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;
 
    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 4096 /* 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函數了。我們掃描空閒塊鏈表當遇到第一塊滿足要求的內存塊(內存塊比所需內存大即滿足要求)時,停止掃描,而不是掃描整個鏈表來尋找大小最合適的內存塊,我們所採用的這種算法思想其實就是首次適應(與最佳適應相對)。

注意:有件事情需要說明一下,內存塊頭部結構中size這一部分的計數單位是塊(Block),而不是Byte。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
static header_t base; /* Zero sized block to get us started. */
static header_t *usedp, *freep;
 
/*
 * 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 to 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;
        }
    }
}

注意這個函數的成功與否,取決於我們第一次使用時是否使 freep = &base 。這點我們會在初始化函數中進行設置。

儘管我們的代碼完全沒有考慮到內存碎片,但是它能工作。既然它可以工作,我們就可以開始下一個有趣的部分-垃圾回收!

標記和清掃

我們說過垃圾回收器會很簡單,因此我們儘可能的使用簡單的方法:標記和清除方式。這個算法分爲兩個部分:

首先,我們需要掃描所有可能存在指向堆中數據(heap data)的變量的內存空間並確認這些內存空間中的變量是否指向堆中的數據。爲了做到這點,對於可能內存空間中的每個字長(word-size)的數據塊,我們遍歷已用塊鏈表中的內存塊。如果數據塊所指向的內存是在已用鏈表塊中的某一內存塊中,我們對這個內存塊進行標記。

第二部分是,當掃描完所有可能的內存空間後,我們遍歷已用塊鏈表將所有未被標記的內存塊移到空閒塊鏈表中。

現在很多人會開始認爲只是靠編寫類似於malloc那樣的簡單函數來實現C的垃圾回收是不可行的,因爲在函數中我們無法獲得其外面的很多信息。例如,在C語言中沒有函數可以返回分配到堆棧中的所有變量的哈希映射。但是隻要我們意識到兩個重要的事實,我們就可以繞過這些東西:

第一,在C中,你可以嘗試訪問任何你想訪問的內存地址。因爲不可能有一個數據塊編譯器可以訪問但是其地址卻不能被表示成一個可以賦值給指針的整數。如果一塊內存在C程序中被使用了,那麼它一定可以被這個程序訪問。這是一個令不熟悉C的編程者很困惑的概念,因爲很多編程語言都會限制程序訪問虛擬內存,但是C不會。

第二,所有的變量都存儲在內存的某個地方。這意味着如果我們可以知道變量們的通常存儲位置,我們可以遍歷這些內存位置來尋找每個變量的所有可能值。另外,因爲內存的訪問通常是字(word-size)對齊的,因此我們僅需要遍歷內存區域中的每個字(word)即可。

局部變量也可以被存儲在寄存器中,但是我們並不需要擔心這些因爲寄存器經常會用於存儲局部變量,而且當函數被調用的時候他們通常會被存儲在堆棧中。

現在我們有一個標記階段的策略:遍歷一系列的內存區域並查看是否有內存可能指向已用塊鏈表。編寫這樣的一個函數非常的簡潔明瞭:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#define UNTAG(p) (((unsigned int) (p)) & 0xfffffffc)
 
/*
 * Scan a region of memory and mark any items in the used list appropriately.
 * Both arguments should be word aligned.
 */
static void
mark_from_region(unsigned int *sp, unsigned int *end)
{
    header_t *bp;
 
    for (; sp < end; sp++) {
        unsigned int v = *sp;
        bp = usedp;
        do {
            if (bp + 1 <= v &&
                bp + 1 + bp->size > v) {
                    bp->next = ((unsigned int) bp->next) | 1;
                    break;
            }
        } while ((bp = UNTAG(bp->next)) != usedp);
    }
}

爲了確保我們只使用頭(header)中的兩個字長(two words)我們使用一種叫做標記指針(tagged pointer)的技術。利用header中的next指針指向的地址總是字對齊(word aligned)這一特點,我們可以得出指針低位的幾個有效位總會是0。因此我們將next指針的最低位進行標記來表示當前塊是否被標記。

現在,我們可以掃描內存區域了,但是我們應該掃描哪些內存區域呢?我們要掃描的有以下這些:

  1. BBS(未初始化數據段)和初始化數據段。這裏包含了程序的全局變量和局部變量。因爲他們有可能應用堆(heap)中的一些東西,所以我們需要掃描BSS與初始化數據段。
  2. 已用的數據塊。當然,如果用戶分配一個指針來指向另一個已經被分配的內存塊,我們不會想去釋放掉那個被指向的內存塊。
  3. 堆棧。因爲堆棧中包含所有的局部變量,因此這可以說是最需要掃描的區域了。

我們已經瞭解了關於堆(heap)的一切,因此編寫一個mark_from_heap函數將會非常簡單:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/*
 * Scan the marked blocks for references to other unmarked blocks.
 */
static void
mark_from_heap(void)
{
    unsigned int *vp;
    header_t *bp, *up;
 
    for (bp = UNTAG(usedp->next); bp != usedp; bp = UNTAG(bp->next)) {
        if (!((unsigned int)bp->next & 1))
            continue;
        for (vp = (unsigned int *)(bp + 1);
             vp < (bp + bp->size + 1);
             vp++) {
            unsigned int v = *vp;
            up = UNTAG(bp->next);
            do {
                if (up != bp &&
                    up + 1 <= v &&
                    up + 1 + up->size > v) {
                    up->next = ((unsigned int) up->next) | 1;
                    break;
                }
            } while ((up = UNTAG(up->next)) != bp);
        }
    }
}

幸運的是對於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),但是我們並不在意這些。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/*
 * 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;

現在我們知道了每個我們需要掃描的內存區域的位置,所以我們終於可以編寫顯示調用的回收函數了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/*
 * 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 end, etext; /* Provided by the linker. */
 
    if (usedp == NULL)
        return;
     
    /* Scan the BSS and initialized data segments. */
    mark_from_region(&etext, &end);
 
    /* Scan the stack. */
    asm volatile ("movl    %%ebp, %0" : "=r" (stack_top));
    mark_from_region(stack_top, stack_bottom);
 
    /* Mark from the heap. */
    mark_from_heap();
 
    /* And now we collect! */
    for (prevp = usedp, p = UNTAG(usedp->next);; prevp = p, p = UNTAG(p->next)) {
    next_chunk:
        if (!((unsigned int)p->next & 1)) {
            /*
             * The chunk hasn't been marked. Thus, it must be set free.
             */
            tp = p;
            p = UNTAG(p->next);
            add_to_free_list(tp);
 
            if (usedp == tp) {
                usedp = NULL;
                break;
            }
 
            prevp->next = (unsigned int)p | ((unsigned int) prevp->next & 1);
            goto next_chunk;
        }
        p->next = ((unsigned int) p->next) & ~1;
        if (p == usedp)
            break;
    }
}

朋友們,所有的東西都已經在這了,一個用C爲C程序編寫的垃圾回收器。這些代碼自身並不是完整的,它還需要一些微調來使它可以正常工作,但是大部分代碼是可以獨立工作的。

總結

從小學到高中,我一直在學習打鼓。每個星期三的下午4:30左右我都會更一個很棒的老師上打鼓教學課。

每當我在學習一些新的打槽(groove)或節拍時,我的老師總會給我一個相同的告誡:我試圖同時做所有的事情。我看着樂譜,我只是簡單地嘗試用雙手將它全部演奏出來,但是我做不到。原因是因爲我還不知道怎樣打槽,但我卻在學習打槽地時候同時學習其它東西而不是單純地練習打槽。

因此我的老師教導我該如何去學習:不要想着可以同時做所有地事情。先學習用你地右手打架子鼓,當你學會之後,再學習用你的左手打小鼓。用同樣地方式學習貝斯、手鼓和其它部分。當你可以單獨使用每個部分之後,慢慢開始同時練習它們,先兩個同時練習,然後三個,最後你將可以可以同時完成所有部分。

我在打鼓方面從來都不夠優秀,但我在編程時始終記着這門課地教訓。一開始就打算編寫完整的程序是很困難的,你編程的唯一算法就是分而治之。先編寫內存分配函數,然後編寫查詢內存的函數,然後是清除內存的函數。最後將它們合在一起。

當你在編程方面克服這個障礙後,就再也沒有困難的實踐了。你可能有一個算法不太瞭解,但是任何人只要有足夠的時間就肯定可以通過論文或書理解這個算法。如果有一個項目看起來令人生畏,那麼將它分成完全獨立的幾個部分。你可能不懂如何編寫一個解釋器,但你絕對可以編寫一個分析器,然後看一下你還有什麼需要添加的,添上它。


發佈了30 篇原創文章 · 獲贊 1 · 訪問量 11萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章