編寫你的第一個垃圾收集器

JeOam發表於2019-05-09

每當我倍感壓力以及有很多事情要做的時候,我總是有這樣一種反常的反應,那就是希望做一些其他的事情來擺脫這種狀況。通常情況下,這些事情都是些我能夠編寫並實現的獨立的小程式。

一天早上,我幾乎要被一堆事情給整瘋了——我得看一本書、處理一些工作上的事情、還要準備一場Strange Loop的演講,然後這時我突然想到:“我該寫一個垃圾收集器了”。

是的,我知道那一刻讓我看上去有多瘋狂。不過我的神經故障卻是你實現一段基礎的程式語言設計的免費教程!在100行左右毫無新意的c程式碼中,我設法實現一個基本的標記和掃描模組。

垃圾收集被認為是有更多程式設計牛人出沒的水域之一,但在這裡,我會給你一個漂亮的兒童游泳池去玩耍。可能這裡面仍然會有一些能手,但至少這會是一個淺水區。

精簡、複用、再複用

垃圾收集背後有這樣一個基本的觀念:程式語言(大多數的)似乎總能訪問無限的記憶體。而開發者可以一直分配、分配再分配——像魔法一樣,取之不盡用之不竭。

當然,我們從來都沒有無限的記憶體。所以計算機實現收集的方式就是當機器需要分配一些記憶體,而記憶體又不足時,讓它收集垃圾。

“垃圾(Garbage)”在這裡表示那些事先分配過但後來不再被使用的記憶體。而基於對無限記憶體的幻想,我們需要確保“不再被使用”對於程式語言來說是非常安全的。要知道在你的程式試圖訪問一些隨機的物件時它們卻剛好正在得到回收,這可不是一件好玩的事情。

為了實現收集,程式語言需要確保程式不再使用那個物件。如果該程式不能得到一個物件的引用,那麼顯然它也不會再去使用它。所以關於”in use”的定義事實上非常簡單:

任何被一個變數引用的物件,仍然在作用域內,就屬於”in use”狀態。
任何被另一個物件引用的物件,仍在使用中,就是”in use”狀態。
如果物件A被一個變數引用,而它又有一些地方引用了物件B,那麼B就是在使用中(“in use”),因為你能夠通過A來訪問到它。

這樣到最後的結果就是得到一張可訪問的物件圖——以一個變數為起點並能夠遍歷到的所有物件。任何不在圖中的物件對於程式來說都是死的,而它的記憶體也是時候被回收了。

標記並清理

有很多不同的方法可以實現關於查詢和回收所有未被使用的物件的操作,但是最簡單也是第一個被提出的演算法就是”標記-清除”演算法。它由John McCarthy——Lisp(列表處理語言)的發明者提出,所以你現在做的事情就像是與一個古老的神在交流,但希望你別用一些洛夫克拉夫特式的方法——最後以你的大腦和視網膜的完全枯萎而結束。

該演算法的工作原理幾乎與我們對”可訪問性(reachability)”的定義完全一樣:
1. 從根節點開始,依次遍歷整個物件圖。每當你訪問到一個物件,在上面設定一個”標記(mark)”位,置為true。
2. 一旦搞定,找出所有標記位為”not”的物件集,然後刪除它們。
對,就是這樣。我猜你可能已經想到了,對吧?如果是,那你可能就成為了一位被引用了數百次的文章的作者。所以這件事情的教訓就是,想要在CS(電腦科學)領域中出名,你不必開始就搞出一個很牛的東西,你只需要第一個整出來即可,哪怕這玩意看上去很搓。

物件對

在我們落實這兩個步驟之前,讓我們先做些不相關的準備工作。我們不會為一種語言真正實現一個直譯器——沒有分析器,位元組碼、或任何這種愚蠢的東西。但我們確實需要一些少量的程式碼來建立一些垃圾去收集。

讓我們假裝我們正在為一種簡單的語言編寫一個直譯器。它是動態型別,並且有兩種型別的變數:int 和 pair。 下面是用列舉來標示一個物件的型別:

typedef enum {
  OBJ_INT,
  OBJ_PAIR
} ObjectType;

其中,pair可以是任何一對東西,兩個int、一個int和另一個pair,什麼都可以。隨你怎麼想都行。因為一個物件在虛擬機器中可以是這兩個當中的任意一種型別,所以在c中實現物件的典型方法是時用一個標記聯合體(tagged union)

typedef struct sObject {
  ObjectType type;

  union {
    <span style="color: #999999;">/* OBJ_INT */</span>
    int value;

   <span style="color: #999999;"> /* OBJ_PAIR */</span>
    struct {
      struct sObject* head;
      struct sObject* tail;
    };
  };
} Object;

這個Object結構擁有一個type欄位表示它是哪種型別的值——要麼是int要麼是pair。接下來用一個union來持有這個int或是pair的資料。如果你對c語言很生疏,一個union就是一個結構體,它將欄位重疊在記憶體中。由於一個給定的物件只能是int或是pair,我們沒有任何理在一個單獨的物件中同時為所有這3個欄位分配記憶體。一個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;
}

這就是我們的小小虛擬機器。如果我們有呼叫這些方法的解析器和直譯器,那我們手上就有了一種對上帝都誠實的語言。而且,如果我們有無限的記憶體,它甚至能夠執行真正的程式。可惜我們們沒有,所以讓我們來收集些垃圾吧。

標記

第一個階段就是標記(marking)。我們需要掃遍所有可以訪問到的物件,並設定其標誌位。現在我們需要做的第一件事就是為物件新增一個標誌位(mark bit):

typedef struct sObject {
  unsigned char marked;
  <span style="color: #999999;">/* Previous stuff... */</span>
} Object;

一旦我們建立了一個新的物件,我們將修改newObject()方法初始化marked為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;
}

毫無疑問,這是最重要的一點。我們標記了這個物件自身是可訪問的,但記住,我們還需要處理物件中的引用:可訪問性是遞迴的。如果該物件是一個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()方法了,它會準確的標記記憶體中所有可訪問的物件。我們已經成功一半了!

清理

下一個階段就是清理一遍所有我們已經分配過(記憶體)的物件並釋放那些沒有被標記過的(物件)。但這裡有一個問題:所有未被標記的物件——我們所定義的——都不可達!我們都不能訪問到它們!

虛擬機器已經實現了物件引用的語義:所以我們只在變數和pair元素中儲存指向物件的指標。當一個物件不再被任何指標指向時,那我們就完全失去它了,而這也實際上造成了記憶體洩露。

解決這個問題的訣竅是:虛擬機器可以有它自己的物件引用,而這不同於對語言使用者可讀的那種語義。換句話說,我們自己可以保留它們的痕跡。

這麼做最簡單的方法是僅維持一張由所有分配過(記憶體)的物件(組成)的連結串列。我們在這個連結串列中將物件自身擴充套件為一個節點:

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;
    }
  }
}

這段程式碼讀起來有點棘手,因為那個指標(指object)指向的是一個指標,但是通過它的工作你會發現它還是非常簡單的。它只是掃遍了整張連結串列。只要它碰到了一個未被標記的物件,它就會釋放該物件的記憶體並將其從連結串列中移除。最後,我們將會刪除所有不可訪問的物件。

祝賀你!我們已經有了一個垃圾收集器!現在只剩下一點工作了:實際呼叫它!首先我們將這兩個階段整合在一起:

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

沒有比這更明顯的”標記-清除”演算法了。現在最棘手的是搞清楚什麼時候來實際呼叫它。”記憶體不足(low on memory)”是個什麼意思?尤其是對於現在的計算機,它們幾乎擁有無限的虛擬記憶體!

事實證明,我們沒有完全正確或錯誤的答案。這真的取決於你使用虛擬機器的目的以及讓它執行在什麼樣的硬體上。為了讓這個例子看上去很簡單,我們僅在進行了一定數量的記憶體分配之後開始收集。事實上一些語言的實現就是這麼做的,而這也很容易。

我們將邀請虛擬機器來追蹤我們到底建立了多少(物件):

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;
}

其中,INITIAL_GC_THRESHOLD為你啟動第一個GC(垃圾收集器)的物件數量。較小的值會更節省記憶體,而較大的值則更省時。自己看著辦吧。

每當我們建立一個物件,我們增加numObjects,如果它達到最大值就啟動一次收集:

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

  /* Create object... */

  vm->numObjects++;
  return object;
}

我不會費心的顯示它(指numObjects),但是我們也會稍微調整sweep()方法,每釋放一次就遞減numObjects。最後,我們修改了gc()方法來更新最大值:

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

  markAll(vm);
  sweep(vm);

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

每次收集之後,我們更新maxObjects——以進行收集後仍在活動的物件為基準。乘法器讓我們的堆隨著活動中的物件數量的增加而增加。同樣,也會隨著一些物件最終被釋放掉而自動減少。

最後

你成功了!如果你全部照做了,那你現在已經得到了一個簡單的垃圾收集演算法的控制程式碼。如果你想看完整的程式碼,在這裡。我再強調一點,儘管這個收集器很簡單,但它可不是一個玩具。

你可以在這上面做一大堆的優化(像在GC和程式設計語言這些事情中,90%的努力都在優化上),但它的核心程式碼可是真正的GC。它與目前Ruby和Lua中的收集器非常的相似。你可以使用一些類似的程式碼到你的專案中。去做些很酷的事情吧!


原文連結:Baby`s First Garbage Collector
轉載自:伯樂線上deathmonkey

相關文章