自己動手寫GC

有時候事情多得我喘不過氣來的時候,我會出現一種異常反應,好像找點別的事做,就能遠離煩惱了。通常我會寫些自己能完成的獨立的小程序。

有一天早上,我正在寫的書,工作中的事情,還有要爲Strang Loop準備的分享,這些東西讓我感到快崩潰了,突然間我想到,“我要寫一個垃圾回收程序”。

是的,我知道這聽起來有點瘋狂。不過你可以把我這個神經的想法當成是一份編程語言基礎的免費教程。通過百來行普通的C代碼,我實現了一個標記刪除的收集器,你懂的,它確實能回收內存。

在程序開發領域,垃圾回收就像一片鯊魚出沒的水域,不過在本文中,這只是個兒童池,你可以隨意玩耍。(說不定還是會有鯊魚,不過至少水淺多了不是?)

少用,重用,循環用

垃圾回收思想是源於編程語言似乎需要無窮盡的內存。開發人員可以一直一直的分配內存,它就像是魔法一般,永遠不會失敗。

當然了,機器的內存不可能是無限的。所以解決辦法就是,當程序需要分配內存並且意識到內存已經不足了,它開始進行垃圾回收。

在這裏,“垃圾”是指那些已經分配出去但現在不再使用的內存。爲了讓內存看起來是取之不盡的,語言本身對於什麼是“不再使用的”應當十分謹慎。不然的話當你的程序正要訪問那些對象的時候,你卻要回收它們,這可不是鬧着玩的。

爲了能進行垃圾回收,語言本身得確定程序無法再使用這些對象。如果拿不到對象的引用,當然也就無法使用它們了。那麼定義什麼是“在使用中的”就很簡單了:

  1. 如果對象被作用域中的變量引用的話,那麼它就是在使用中的;
  2. 如果對象被在使用中的對象引用的話,那麼它也是在使用中的。

第二條規則是遞歸的。如果對象A被一個變量引用,並且它有個字段引用了對象B,那麼B也是正在使用中的,因爲通過A你能對它進行訪問。

最後就是一張可達對象的圖了——以一個變量爲起點,你能夠遍歷到的所有對象。不在這張可達對象圖裏的對象對程序來說都是沒用的,那麼它佔有的內存就可以回收了。

標記-清除

查找及回收無用對象的方法有很多種,最簡單也是最早的一種方法,叫“標記-清除法”。它是由John McCathy發明的,他同時還發明瞭Lisp和beards,因此你用它來實現的話就像是和遠古大神交流一般,不過希望你可不要被他那套給洗腦了。

這和我們定義可達性的過程簡直是一樣的:

  1. 從根對象開始,遍歷整個對象圖。每訪問一個對象,就把一個標記位設成true。
  2. 一旦完成遍歷,找出所有沒有被標記過的對象,並清除掉它們。

這樣就OK了。你肯定覺得這些你也能想到吧?如果你早點想到,你寫的這個論文可能就被無數人引用了。要知道,想在計算機界混出點名堂,你根本不需要有什麼特別天才的想法,蠢主意也行,只要你是第一個提出來的。

一組對象

在我們開始實現這兩點前,讓我們先做一些準備工作。我們並不是要真正去實現一門語言的解釋器——沒有解析器,字節碼或者任何這些破玩意兒——不過我們確實需要寫一點代碼,生成一些垃圾,這樣我們纔有東西可回收。

假設我們正在寫一門小語言的解釋器。它是動態類型的,有兩種對象:int以及pair。下面是一個定義對象類型的枚舉:

typedef enum {
  OBJ_INT,
  OBJ_PAIR
} ObjectType;

一對(pair)對象可以是任意類型的,比如兩個int,一個int一個pair,什麼都行。有這些就足夠你用的了。由於VM機裏的對象可是是這些中的任意一種,在C裏面典型的實現方式是使用一個標記聯合(tagged union)。

我們來實現一下它:

typedef struct sObject {
  ObjectType type;

  union {
    /* OBJ_INT */
    int value;

    /* OBJ_PAIR */
    struct {
      struct sObject* head;
      struct sObject* tail;
    };
  };
} Object;

Object結構有一個type字段,用來標識它是何種類型的——int或者是pair。然後它還有一個union,用來保存int或者pair的數據。如果你C語言的知識已經生鏽了,那我來提醒你,union是指內存裏面重疊的字段。一個指定的對象要麼是int要麼是pair,沒有理由說在內存裏同時分配三個字段給它們。一個union就搞定了,酷。

一個迷你的虛擬機

現在我們可以把它們封裝到一個小型虛擬機的結構裏了。這個虛擬機在這的作用就是擁有一個棧,它用來存儲當前使用變量。很多語言的虛擬機都要麼是基於棧的(比如JVM和CLR),要麼是基於寄存器的(比如Lua)。不管是哪種結構,實際上它們都得有一個棧。它用來存儲本地變量以及表達式中可能會用到的中間變量。

我們用一種簡單明瞭的方式將它抽象出來,就像這樣:

#define STACK_MAX 256

typedef struct {
  Object* stack[STACK_MAX];
  int stackSize;
} VM;

現在我們需要的基本的數據結構已經就了,我們再來湊幾行代碼,生成一些垃圾對象。首先,先寫一個函數,用來創建並初始化虛擬機:

VM* newVM() {
  VM* vm = malloc(sizeof(VM));
  vm->stackSize = 0;
  return vm;
}

有了虛擬機後,我們需要對它的棧進行操作:

void push(VM* vm, Object* value) {
  assert(vm->stackSize < STACK_MAX, "Stack overflow!");
  vm->stack[vm->stackSize++] = value;
}

Object* pop(VM* vm) {
  assert(vm->stackSize > 0, "Stack underflow!");
  return vm->stack[--vm->stackSize];
}

好了,現在我們可以把東西存到變量裏了,我們需要實際去創建一些對象。這裏是一個輔助函數:

Object* newObject(VM* vm, ObjectType type) {
  Object* object = malloc(sizeof(Object));
  object->type = type;
  return object;
}

它會進行內存分配並且設置類型標記。一會兒我們再來看它。有了它我們就可以寫出用來把各種類型的對象壓到棧裏的函數了:

void pushInt(VM* vm, int intValue) {
  Object* object = newObject(vm, OBJ_INT);
  object->value = intValue;
  push(vm, object);
}

Object* pushPair(VM* vm) {
  Object* object = newObject(vm, OBJ_PAIR);
  object->tail = pop(vm);
  object->head = pop(vm);

  push(vm, object);
  return object;
}

這都是給我們這個迷你的虛擬機準備的。如果我們有個解析器和解釋器來調用這些函數,我們就。並且,如果我們的內存是無限大的話,它簡直就可以運行真實的程序了。不過當然不可能了,所以我們得進行垃圾回收。

Marky mark

(這該怎麼翻譯,這貨難道是作者喜愛的一個演員?不過下面肯定是講標記的)第一個階段是標記階段。我們需要遍歷所有的可達對象,並且設置它們的標記位。需要做的第一件事就是給Object加一個標記位:
typedef struct sObject {
  unsigned char marked;
  /* Previous stuff... */
} Object;
我們得修改下newObject()函數,當我們創建新對象的時候,把這個maked字段初始化成0。爲了標記所有的可達對象,我們得從內存裏的變量先開始,也就是說我們得訪問棧了。代碼就像這樣:
void markAll(VM* vm)
{
  for (int i = 0; i < vm->stackSize; i++) {
    mark(vm->stack[i]);
  }
}
這個函數最後會調用到mark()。我們來分階段實現它。首先:
void mark(Object* object) {
  object->marked = 1;
}
毫不誇張的說,這可是最重要的一個bit位了。我們把這個對象標記成可達的了,不過記住,我們還得處理對象的引用:可達性是遞歸的。如果對象是pair類型的話,它的兩個字段都是可達的。實現這個也簡單:
void mark(Object* object) {
  object->marked = 1;

  if (object->type == OBJ_PAIR) {
    mark(object->head);
    mark(object->tail);
  }
}
不過這裏有個BUG。看到沒?我們遞歸調用了,不過沒有判斷循環引用。如果你有很多pair對象,互相指向對方,引用一個環,會導致棧溢出最後程序崩潰。要解決這個問題,我們得能夠判斷這個對象我們是不是已經處理過了。最終版的mark()函數是這樣的:
void mark(Object* object) {
  /* If already marked, we're done. Check this first
     to avoid recursing on cycles in the object graph. */
  if (object->marked) return;

  object->marked = 1;

  if (object->type == OBJ_PAIR) {
    mark(object->head);
    mark(object->tail);
  }
}
現在我們可以調用markAll了,它能正確的標記內存中所有的可達對象。已經完成一半了!

Sweepy sweep

(凱爾特人的瘋狂支持者,參考http://www.urbandictionary.com/define.php?term=sweep%20sweep,看完就懂了)

下一個階段就是遍歷所有分配的對象,釋放掉那些沒有被標記的了。不過這裏有個問題:那些沒被標記的對象,是不可達的!我們沒法訪問到它們!

虛擬機已經實現了關於對象引用的語義:所以我們只在變量中存儲了對象的指針。一旦某個對象沒有人引用了,我們將會徹底的失去它,並導致了內存泄露。

解決這個的小技巧就是VM可以有屬於自己的對象引用,這個和語義中的引用是不同的,那個引用對開發人員是可見的。也就是說,我們可以自己去記錄這些對象。

最簡單的方法就是爲所有分配地賓對象維護一個鏈表。我們將Object擴展成一個鏈表的節點:

typedef struct sObject {
  /* The next object in the list of all objects. */
  struct sObject* next;

  /* Previous stuff... */
} Object;

虛擬機來記錄這個鏈表的頭節點:

typedef struct {
  /* The first object in the list of all objects. */
  Object* firstObject;

  /* Previous stuff... */
} VM;

我們會在newVM()中,確保firstObject被初始成NULL。當我們要創建對象時,我們把它加到鏈表裏:

Object* newObject(VM* vm, ObjectType type) {
  Object* object = malloc(sizeof(Object));
  object->type = type;
  object->marked = 0;

  /* Insert it into the list of allocated objects. */
  object->next = vm->firstObject;
  vm->firstObject = object;

  return object;
}

這樣的話,即便從語言的角度來看無法找到一個對象,在語言的實現中還是能夠找到的。要掃描並刪除示標記的對象,我們只需要遍歷下這個列表就可以了:

void sweep(VM* vm)
{
  Object** object = &vm->firstObject;
  while (*object) {
    if (!(*object)->marked) {
      /* This object wasn't reached, so remove it from the list
         and free it. */
      Object* unreached = *object;

      *object = unreached->next;
      free(unreached);
    } else {
      /* This object was reached, so unmark it (for the next GC)
         and move on to the next. */
      (*object)->marked = 0;
      object = &(*object)->next;
    }
  }
}

這段代碼讀真來需要點技巧,因爲它用到了指針的指針,不過如果你看明白了,你會發現它其實寫的相當直白。它就是遍歷了一下整個列表。一旦它發現一個未標記的對象,釋放它的內存,把它從列表中移除。完成了這個之後,所有不可達的對象都被我們刪除了。

恭喜!我們終於有了一個垃圾回收器!不過還少了一樣東西:實際去調用它。我們先把兩個階段封裝到一起:

void gc(VM* vm) {
  markAll(vm);
  sweep(vm);
}

不可能有比這更直白的標記-清除的實現了。最有難度的是我們在什麼時候調用這個函數呢?內存緊張是什麼意思,尤其是在幾乎有無限的虛擬內存現代計算機裏?

這其實並沒有標準答案。這取決於你如何使用你的虛擬機並且它運行在什麼樣的硬件上了。爲了讓這個例子簡單點,我們在分配一定數量對象後進行回收。確實有一些語言是這麼實現的,同時這也很容易實現。

我們擴展了一下 VM,跟蹤一下分配 了多少對象:

typedef struct {
  /* The total number of currently allocated objects. */
  int numObjects;

  /* The number of objects required to trigger a GC. */
  int maxObjects;

  /* Previous stuff... */
} VM;

然後初始化它們:

VM* newVM() {
  /* Previous stuff... */

  vm->numObjects = 0;
  vm->maxObjects = INITIAL_GC_THRESHOLD;
  return vm;
}

INITIALGCTHRESHOLD 就是觸發GC時分配的對象個數。保守點的話就設置的小點,希望GC花的時間少點的話就設置大點。看你的需要了。

當創建對象時,我們會增加這個numOjbects值,如果它到達最大值了,就執行一次垃圾回收:

Object* newObject(VM* vm, ObjectType type) {
  if (vm->numObjects == vm->maxObjects) gc(vm);

  /* Create object... */

  vm->numObjects++;
  return object;
}

我們還得調整下sweep函數,每次釋放對象的時候進行減一。最後,我們修改下gc()來更新這個最大值:

void gc(VM* vm) {
  int numObjects = vm->numObjects;

  markAll(vm);
  sweep(vm);

  vm->maxObjects = vm->numObjects * 2;
}

每次回收之後,我們會根據存活對象的數量,更新maxOjbects的值。這裏乘以2是爲了讓我們的堆能隨着存活對象數量的增長而增長。同樣的,如果大量對象被回收之後,堆也會隨着縮小。

麻雀雖小

終於大功告成了!如果你堅持看完了,那麼你現在也掌握一種簡單的垃圾回收的算法了。如果你想查看完整的源代碼,請點擊這裏。我得強調一下,這個回收器麻雀雖小,五臟俱全。

在它上面你可以做很多優化(在GC和編程語言裏,做的90%的事情都是優化),不過這裏的核心代碼就是一個完整的真實的垃圾回收器。它和Ruby和Lua之前的回收器非常相像。你可以在你的產品中隨意使用這些代碼。現在就開始動手吧!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章