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

forever_you發表於2014-09-23

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

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

如果你看到任何有誤的地方,請郵件聯絡我maplant2@illinois.edu

編寫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 。這點我們會在初始化函式中進行設定。

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

標記和清掃

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

首先,我們需要掃描所有可能存在指向堆中資料(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指標的最低位進行標記來表示當前塊是否被標記。

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

  1. BBS(未初始化資料段)和初始化資料段。這裡包含了程式的全域性變數和區域性變數。因為他們有可能應用堆(heap)中的一些東西,所以我們需要掃描BSS與初始化資料段。
  2. 已用的資料塊。當然,如果使用者分配一個指標來指向另一個已經被分配的記憶體塊,我們不會想去釋放掉那個被指向的記憶體塊。
  3. 堆疊。因為堆疊中包含所有的區域性變數,因此這可以說是最需要掃描的區域了。

我們已經瞭解了關於堆(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程式編寫的垃圾回收器。這些程式碼自身並不是完整的,它還需要一些微調來使它可以正常工作,但是大部分程式碼是可以獨立工作的。

總結

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

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

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

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

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

相關文章