用 C 語言編寫一個簡單的記憶體回收行程

來源:互聯網
上載者:User

標籤:http   io   os   使用   ar   for   檔案   資料   div   

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

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

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

編寫malloc

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

在鏈表中的每個空閑記憶體塊都有一個頭(header)用來描述記憶體塊的資訊。我們的header包含兩個部分,第一部分表示記憶體塊的大小,第二部分指向下一個空閑記憶體塊。

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()將申請到的記憶體 塊加入空閑塊鏈表。

/* * 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 voidadd_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。

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)即可。

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

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

#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 voidmark_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函數將會非常簡單:

/* * Scan the marked blocks for references to other unmarked blocks. */static voidmark_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),但是我們並不在意這些。

/* * Find the absolute bottom of the stack and set stuff up. */voidGC_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. */voidGC_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)或節拍時,我的老師總會給我一個相同的告誡:我試圖同時做所有的事情。我看著樂譜,我只是簡單地嘗試用雙 手將它全部演奏出來,但是我做不到。原因是因為我還不知道怎樣打槽,但我卻在學習打槽地時候同時學習其它東西而不是單純地練習打槽。

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

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

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

用 C 語言編寫一個簡單的記憶體回收行程

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.