1. 程式人生 > >夥伴分配器的一個極簡實現

夥伴分配器的一個極簡實現

在原文的基礎上做了部分修改

buddy system是一種經典的記憶體分配演算法,Linux底層的記憶體管理用的就是它。這裡不探討核心這麼複雜實現,而僅僅是將該演算法抽象提取出來,同時給出一份及其簡潔的原始碼實現,以便定製擴充套件。

夥伴分配的實質就是一種特殊的“分離適配”(可參見《CSAPP》第9章的動態記憶體分配部分的“分離適配”),即將記憶體按2的冪進行劃分,相當於分離出若干個塊大小一致的空閒連結串列,搜尋該連結串列並給出同需求最佳匹配的大小。其優點是快速搜尋合併(O(logN)時間複雜度)以及低外部碎片(最佳適配best-fit);其缺點是內部碎片,因為按2的冪劃分塊,如果碰上66單位大小,那麼必須劃分128單位大小的塊。但若需求本身就按2的冪分配,比如可以先分配若干個記憶體池,在其基礎上進一步細分就很有吸引力了。

可以在維基百科上找到該演算法的描述,大體如是:

分配記憶體:

1.尋找大小合適的記憶體塊(size向上取2的冪次,即大於等於所需大小並且最接近2的冪,比如需要27,實際分配32):

1.1. 如果找到了,分配給應用程式。
1.2. 如果沒找到(這裡是指大於32,如只有128的記憶體塊),分出合適的記憶體塊:

1.2.1. 對半分離出高於所需大小的空閒記憶體塊(這就是buddy)
1.2.2. 如果分到最低限度(即大小為32),分配這個大小。
1.2.3. 否則, 回溯到步驟1(尋找合適大小的塊)
1.2.4. 重複該步驟直到一個合適的塊

釋放記憶體:

1.釋放該記憶體塊

1.尋找相鄰的塊,看其是否釋放了。
2.如果相鄰塊也釋放了,合併這兩個塊,重複上述步驟直到遇上未釋放的相鄰塊,或者達到最高上限(即所有記憶體都釋放了)。

上面這段文字對你來說可能看起來很費勁,沒事,我們看個記憶體分配和釋放的示意圖你就知道了:

上圖中,首先我們假設我們一個記憶體塊有1024K,當我們需要給A分配70K記憶體的時候,

  1. 我們發現1024K的一半大於70K,然後我們就把1024K的記憶體分成兩半,一半512K。
  2. 然後我們發現512K的一半仍然大於70K,於是我們再把512K的記憶體再分成兩半,一半是128K。
  3. 此時,我們發現128K的一半小於70K,於是我們就分配為A分配128K的記憶體,A右邊的128K成為最左邊256K塊的夥伴塊。

後面的,B,C,D都這樣,而釋放記憶體時,則會把相鄰的塊一步一步地合併起來(合併也必需按分裂的逆操作進行合併)。

我們可以看見,這樣的演算法,用二叉樹這個資料結構來實現再合適不過了。

我在網上分別找到cloudwuwuwenbin寫的兩份開源實現和測試用例。實際上後一份是對前一份的精簡和優化,本文打算從後一份入手講解,因為這份實現真正體現了“極簡”二字,追求突破常規的,極致簡單的設計。網友對其評價甚高,甚至可用作教科書標準實現,看完之後回過頭來看cloudwu的程式碼就容易理解了。

分配器的整體思想是,通過一個數組形式的完全二叉樹來監控管理記憶體,二叉樹的節點用於標記相應記憶體塊的使用狀態,高層節點對應大的塊,低層節點對應小的塊,在分配和釋放中我們就通過這些節點的標記屬性來進行塊的分離合並。如圖所示,假設總大小為16單位的記憶體,我們就建立一個深度為5的滿二叉樹,根節點從陣列下標[0]開始,監控大小16的塊;它的左右孩子節點下標[1~2],監控大小8的塊;第三層節點下標[3~6]監控大小4的塊……依此類推。

在分配階段,首先要搜尋大小適配的塊,假設第一次分配3,轉換成2的冪是4,我們先要對整個記憶體進行對半切割,從16切割到4需要兩步,那麼從下標[0]節點開始深度搜索到下標[3]的節點並將其標記為已分配。第二次再分配3那麼就標記下標[4]的節點。第三次分配6,即大小為8,那麼搜尋下標[2]的節點,因為下標[1]所對應的塊被下標[3~4]佔用了。

在釋放階段,我們依次釋放上述第一次和第二次分配的塊,即先釋放[3]再釋放[4],當釋放下標[4]節點後,我們發現之前釋放的[3]是相鄰的,於是我們立馬將這兩個節點進行合併,這樣一來下次分配大小8的時候,我們就可以搜尋到下標[1]適配了。若進一步釋放下標[2],同[1]合併後整個記憶體就回歸到初始狀態。

還是看一下原始碼實現吧,首先是夥伴分配器的資料結構:


struct buddy2 {
  unsigned size;
  unsigned longest[1];
};


這裡的成員size表明管理記憶體的總單元數目(測試用例中是32,32個塊),成員longest就是二叉樹的節點標記,表明所對應的記憶體塊的空閒單位,在下文中會分析這是整個演算法中最精妙的設計。此處陣列大小為1表明這是可以向後擴充套件的(注:在GCC環境下你可以寫成longest[0],不佔用空間,這裡是出於可移植性考慮),我們在分配器初始化的buddy2_new可以看到這種用法。


struct buddy2* buddy2_new( int size ) 
{
  struct buddy2* self;
  unsigned node_size;
  int i;
 
  if(size < 1 || !IS_POWER_OF_2(size))
    return NULL;
 
  self = (struct buddy2*)ALLOC( 2 * size * sizeof(unsigned))
  // 實際上self指向的空間大小為2*size個塊,size欄位需要一個塊,longest陣列(滿二叉樹)欄位需要(2 * size - 1)個塊
 self->size = size;
node_size = size * 2;
// 滿二叉樹的節點數為(2 * size - 1),從longest[0]開始,
// 且self->size的內容和longest[0]的內容相同
 for(i = 0; i < 2 * size - 1; ++i)
{ if(IS_POWER_OF_2(i+1))
node_size /= 2;
self->longest[i] = node_size;
}
return self;
}

整個分配器的大小減去self欄位大小就是滿二叉樹節點數目,即所需管理記憶體單元數目的2倍。一個節點對應4個位元組,longest記錄了節點所對應的的記憶體塊大小。

記憶體分配的alloc中,入參是分配器指標和需要分配的大小,返回值是記憶體塊索引。alloc函式首先將size調整到2的冪大小,並檢查是否超過最大限度。然後進行適配搜尋,深度優先遍歷,當找到對應節點後,將其longest標記為0,即分離適配的塊出來,並轉換為記憶體塊索引offset(即從第幾個塊開始,葉節點都是1個塊,一個塊大小為4位元組)返回,依據二叉樹排列序號,比如記憶體總體大小32塊,我們找到節點下標[8],記憶體塊對應大小是4,則offset = (8+1)*4-32 = 4(這一結論如何得到?),那麼分配記憶體塊就從索引4開始往後4個塊(共16位元組)。


int buddy2_alloc(struct buddy2* self, int size) {
  unsigned index = 0;
  unsigned node_size;
  unsigned offset = 0;
 
  if(self == NULL)
    return -1;
 
  if(size <= 0)
    size = 1;
  else if (!IS_POWER_OF_2(size))
    size = fixsize(size);
 
  if(self->longest[index] < size)
    return -1; // 分配器大小不足size
 
  // 根據longest[0]指示,子節點一定有滿足size的大小,肯定能分配成功
  for(node_size = self->size; node_size != size; node_size >> 1 ) // 原文是除2操作,unsigned邏輯右移,不用擔心負數
  {
    if(self->longest[LEFT_LEAF(index)] >= size)
      index = LEFT_LEAF(index);
    else
      index = RIGHT_LEAF(index);
  }
 
  self->longest[index] = 0; //分配出去,置0
  offset = (index + 1) * node_size - self->size;//葉節點的偏移地址(位元組為單位)
 
  while(index) { // 修改父節點的容量
    index = PARENT(index);
    self->longest[index] =
      MAX(self->longest[LEFT_LEAF(index)], self->longest[RIGHT_LEAF(index)]);
  }
 
  return offset;
}

在函式返回之前需要回溯,因為小塊記憶體被佔用,大塊就不能分配了,比如下標[8]標記為0分離出來,那麼其父節點下標[0]、[1]、[3]也需要相應大小的分離。將它們的longest進行折扣計算,取左右子樹較大值,下標[3]取4,下標[1]取8,下標[0]取16,表明其對應的最大空閒值。

在記憶體釋放的free介面,我們只要傳入之前分配的記憶體地址索引,並確保它是有效值。之後就跟alloc做反向回溯,從最後的節點開始一直往上找到longest為0的節點,即當初分配塊所適配的大小和位置。我們將longest恢復到原來滿狀態的值。繼續向上回溯,檢查是否存在合併的塊,依據就是左右子樹longest的值相加是否等於原空閒塊滿狀態的大小,如果能夠合併,就將父節點longest標記為相加的和(多麼簡單!)。


void buddy2_free(struct buddy2* self, int offset) {
  unsigned node_size, index = 0;
  unsigned left_longest, right_longest;
 
  assert(self && offset >= 0 && offset < size);
 
  node_size = 1; //葉節點的size為1個塊的大小,其父節點為2個塊大小
  index = offset + self->size - 1;
 
  // 找到longe[index]為0的index
  for(; self->longest[index] ; index = PARENT(index)) {
    node_size *= 2;
    if(index == 0)
      return;
  }
 
  self->longest[index] = node_size; // 將longest[index]由0改為node_size
 
  while(index) {
    index = PARENT(index);
    node_size *= 2;
 
    left_longest = self->longest[LEFT_LEAF(index)];
    right_longest = self->longest[RIGHT_LEAF(index)];
 
    if(left_longest + right_longest == node_size)
      self->longest[index] = node_size;
    else
      self->longest[index] = MAX(left_longest, right_longest);
  }
}

上面兩個成對alloc/free介面的時間複雜度都是O(logN),保證了程式執行效能。然而這段程式設計的獨特之處就在於使用加權來標記記憶體空閒狀態,而不是一般的有限狀態機,實際上longest既可以表示權重又可以表示狀態,狀態機就毫無必要了,所謂“少即是多”嘛!反觀cloudwu的實現,將節點標記為UNUSED/USED/SPLIT/FULL四個狀態機,反而會帶來額外的條件判斷和管理實現,而且還不如數值那樣精確。從邏輯流程上看,wuwenbin的實現簡潔明瞭如同教科書一般,特別是左右子樹的走向,記憶體塊的分離合並,塊索引到節點下標的轉換都是一步到位,不像cloudwu充斥了大量二叉樹的深度和長度的間接計算,讓程式碼變得晦澀難讀,這些都是longest的功勞。一個“極簡”的設計往往在於你想不到的突破常規思維的地方。

這份程式碼唯一的缺陷就是longest的大小是4位元組,記憶體消耗大。但cloudwu的部落格上有人提議用logN來儲存值,這樣就能實現uint8_t大小了,看,又是一個“極簡”的設計!

說實話,很難在網上找到比這更簡約更優雅的buddy system實現了——至少在Google上如此。