A Demo Allocator——實現一個簡單的自定義顯式分配器
前言
在本篇部落格中,我們擬用C語言實現簡單的一個顯式分配器,它模擬實現了C標準庫中的動態記憶體分配的過程。我們給出了其詳細的設計方案與具體實現,也在文章的最後給出了現實應用中,分配器所採用的一些常見設計。
背景知識
首先介紹一下關於動態記憶體分配的背景知識。
關於分配器
雖然可以使用低階的
mmap
和munmap
函式來建立和刪除虛擬記憶體的區域,但是C程式設計師還是會覺得當執行時需要額外虛擬記憶體時,用動態記憶體分配器 (dynamic memory allocator) 更方便,也有更好的可移植性。
動態記憶體分配器維護者一個程序的虛擬記憶體區域,稱為堆 (heap)
brk
,它指向堆的頂部。
分配器有兩種基本風格,兩種風格都要求應用顯式地分配塊,它們的不同之處在於由哪個實體來負責釋放已分配的塊。
顯式分配器 (explicit allocator) ,要求應用顯式地釋放任何已分配的塊。例如,C標準庫提供一種叫做
malloc
程式包的顯式分配器,並通過呼叫free
函式來釋放一個塊。C++中的new
和delete
操作符和C中的malloc
和free
相當。隱式分配器 (implicit allocator) ,另一方面,要求分配器檢測一個已分配塊何時不再被程式所使用,那麼就釋放這個塊。隱式分配器也叫做垃圾收集器 (garbage collector)
,而自動釋放為使用的已分配的塊的過程叫做垃圾收集 (garbage collecion) 。例如,諸如Lisp、ML以及Java之類的高階語言就依賴垃圾收集來釋放已分配的塊。
在本篇部落格中,我們擬用C語言實現簡單的一個顯式分配器,它模擬的正是C標準庫中的分配與釋放記憶體的過程。
關於虛擬記憶體
在實現分配器之前,我們需要知道一些關於Linux系統記憶體管理的基本知識。
為了簡單,現代作業系統在處理記憶體地址時,普遍採用虛擬記憶體地址技術。即在彙編程式(或機器語言)層面,當涉及記憶體地址時,都是使用虛擬記憶體地址。採用這種技術時,每個程序彷彿自己獨享一片
2^N
位元組的記憶體,其中N
是機器位數。例如在64位CPU和64位作業系統下,每個程序的虛擬地址空間為2^64
Byte。
這種虛擬地址空間的作用主要是簡化程式的編寫,及方便作業系統對程序間記憶體的隔離管理,真實中的程序不太可能(也用不到)如此大的記憶體空間,實際能用到的記憶體取決於實體記憶體大小。
由於在機器語言層面都是採用虛擬地址,當實際的機器碼程式涉及到記憶體操作時,需要根據當前程序執行的實際上下文將虛擬地址轉換為實體記憶體地址,才能實現對真實記憶體資料的操作。這個轉換一般由一個叫MMU (Memory Management Unit) 的硬體完成。
那麼,對於一個程序來說,核心又是如何維護它的記憶體分配呢?
我們以64位的Linux系統為例,假設實際用到的記憶體地址為空間為0x0000000000000000
~0x00007FFFFFFFFFFF
和0xFFFF800000000000
~ 0xFFFFFFFFFFFFFFFF
,其中前面為使用者空間 (User Space) ,後者為核心空間 (Kernel Space) 。圖示如下:
對使用者來說,主要關注的空間是User Space。將User Space放大後,可以看到裡面主要分為如下幾段:
Code
:這是整個使用者空間的最低地址部分,存放的是指令(也就是程式所編譯成的可執行機器碼)Data
:這裡存放的是初始化過的全域性變數BSS
:這裡存放的是未初始化的全域性變數Heap
:堆,這是我們本文重點關注的地方,堆自低地址向高地址增長,後面要講到的brk
相關的系統呼叫就是從這裡分配記憶體Mapping Area
:這裡是與mmap
系統呼叫相關的區域。大多數實際的malloc
實現會考慮通過mmap
分配較大塊的記憶體區域,本文不討論這種情況。這個區域自高地址向低地址增長Stack
:這是棧區域,自高地址向低地址增長
一般來說,malloc
所申請的記憶體主要從Heap
區域分配(本文不考慮通過mmap
申請大塊記憶體的情況)。
堆與系統級呼叫
堆
在上文中我們也提到了,Linux維護一個break
指標,這個指標指向堆空間的某個地址。
如下圖所示,從堆起始地址到break
之間的地址空間為對映 (mapped region) 好的,可以供程序訪問;而從break
往上,是未對映 (unmapped region) 的地址空間,如果訪問這段空間則程式會報錯。
brk
與sbrk
我們希望通過直接呼叫系統級函式來實現分配器的功能,因此就需要在分配和釋放記憶體時,改變brk
指標的位置。
Linux通過brk
和sbrk
系統呼叫操作break
指標。兩個系統呼叫的原型如下:
int brk(void *addr);
void *sbrk(intptr_t increment);
brk
將break
指標直接設定為某個地址,而sbrk
將break
從當前位置移動incremen
t所指定的增量。
brk
在執行成功時返回0
,否則返回-1
,並設定errno
為ENOMEM
;sbrk
成功時返回break
移動之前所指向的地址,否則返回(void *)-1
。
一個小技巧是,如果將increment
設定為0
,則可以獲得當前break
的地址。
這兩個系統級函式應如何使用呢?我們先編寫一個最簡單的malloc
函式:
#include <sys/types.h>
#include <unistd.h>
void *malloc(size_t size)
{
void *p;
p = sbrk(0);
if (sbrk(size) == (void *)-1)
return NULL;
return p;
}
這個malloc
每次都在當前break
的基礎上增加size
所指定的位元組數,並將之前break
的地址返回。
當然,這個malloc
由於對所分配的記憶體缺乏記錄,不便於記憶體釋放,所以無法用於真實場景。下面我們就來考慮一個比較完整的分配器設計方案。
設計方案
實現目標
首先我們必須明確的是,一個可用的分配器需要達到哪些要求:
處理任意請求序列:分配器不可以假設分配和釋放請求的順序,即:一個應用可以有任意的分配請求和釋放請求序列,只要滿足響應的約束條件。
注:約束條件指的是——每個釋放請求必須對應於一個當前已分配塊,這個塊是由一個以前的分配請求獲得的。對於不滿足約束條件的請求,會引起記憶體管理的錯誤。現有的C標準庫沒有對此類錯誤進行相應的出錯預警,我們可以為其新增一些錯誤處理功能,詳見筆者的另一篇部落格:An Enhanced Allocator——為C語言的動態記憶體分配添加出錯預警.
立即響應請求:不允許分配器為了提高效能而重新排列或者緩衝請求。
只使用堆:為了使分配器是可擴充套件的,分配器使用的任何非標量資料結構都必須儲存在堆裡。
對齊塊(對齊要求):分配器必須對齊塊,使得它們可以儲存任何型別的資料物件。
不修改已分配的塊:分配器只能操作或者改變空閒塊。
一個實際的分配器要在吞吐率和利用率之間把握好平衡,就必須考慮以下幾個問題:
空閒塊組織:我們如何記錄空閒塊?
放置(適配):我們如何選擇一個合適的空閒塊來放置(適配)一個新分配的塊?
分割:在將一個新分配的塊放置到某空閒塊之後,我們如何處理這個空閒塊中的剩餘部分?
合併:我們如何處理一個剛剛被釋放的塊?
下面我們將分別討論這些問題的實現方案。
資料抽象——隱式空閒連結串列
我們首先介紹一種實現分配器比較簡單的資料結構:隱式空閒連結串列 (implicit free lists) ,它將區分塊邊界、區別已分配塊和空閒塊的資訊,嵌入塊本身,在32位作業系統下,其結構如下圖所示:
一個塊由一個字的頭部、有效載荷,以及可能的一些額外的填充組成。頭部編碼了這個塊的大小(整個連續的記憶體片,包括頭部、有效載荷和所有的填充),以及這個塊是已分配的還是空閒的。
那麼,塊的頭部究竟蘊含著哪些資訊呢?
我們先為記憶體器強加一個雙字的對齊約束條件,在32位作業系統下,這個塊大小就總是8的倍數,因此塊大小的最低3位就一定是0
。所以,我們通過頭部這個字的前29位就可以獲知塊大小,後面的3位就可用來標記這個塊是否為空閒。
例如:我們用1
來標記已分配的塊,用0
來標記空閒塊,那麼如果檢測到頭部資訊為0x00000019
的塊,將其轉化為二進位制,即為0000 0000 0000 0000 0000 0000 0001 1001
,因此其塊大小為11000
,即24個位元組,其最後的三位001
標記了這個塊為已分配的塊。在這個塊首之後的24個位元組地址處,我們就可以找到下一個塊的頭部。
因此,我們可以通過所有塊的頭部,就可以將堆中所有的塊隱含地連線起來。
適配策略
當一個應用請求一個k
位元組當塊時,分配器搜尋空閒連結串列,查詢一個足夠大可以放置所請求塊的空閒塊。分配器執行這種搜尋方式的常見策略有如下三種:
首次適配 (first fit):從頭開始搜尋,選擇第一個合適的空閒塊。
下一次適配 (next fit):從上一次查詢結束的地方開始,選擇第一個合適的空閒塊。
最佳適配 (best fit): 檢查每個空閒塊,選擇適合所需請求大小的最小空閒塊。
合併策略
當分配器釋放一個已分配塊時,可能有其他空閒塊與這個新釋放的空閒塊相鄰,這些臨界的空閒塊可能引起一種現象,叫做假碎片 (fault fragmentation) ,就是有許多空閒塊被切割成小的,無法使用的空閒塊。如下圖所示:
合併 (coalescing) 正是為了解決這一問題,常見的合併策略有如下兩種:
立即合併 (immediate coalescing) :在每次一個塊被釋放時,就合併所有的相鄰塊。
推遲合併 (deferred coalescing) :等到每個稍晚的時候再合併,如:直到某個分配請求失敗時,再掃描整個堆,合併所有的空閒塊。
具體實現
資料結構
首先我們要確定所採用的資料結構。一個簡單可行方案是將堆記憶體空間以塊(Block)的形式組織起來,每個塊由meta
區和資料區組成,meta
區記錄資料塊的元資訊(資料區大小、空閒標誌位、指標等等),資料區是真實分配的記憶體區域,並且資料區的第一個位元組地址即為malloc
返回的地址。
typedef struct s_block *t_block;
struct s_block {
size_t size; /* 資料區大小 */
t_block next; /* 指向下個塊的指標 */
int free; /* 是否是空閒塊 */
int padding; /* 填充4位元組,保證meta塊長度為8的倍數 */
char data[1] /* 這是一個虛擬欄位,表示資料塊的第一個位元組,長度不應計入meta */
};
由於我們只考慮64位機器,為了方便,我們在結構體最後填充一個int,使得結構體本身的長度為8的倍數,以便記憶體對齊。示意圖如下:
適配
我們採用了首次適配的方案:
/* First fit */
t_block find_block(t_block *last, size_t size) {
t_block b = first_block;
while(b && !(b->free && b->size >= size)) {
*last = b;
b = b->next;
}
return b;
}
開闢額外的堆記憶體
如果現有block
都不能滿足size
的要求,則需要在連結串列最後開闢一個新的block
。這裡關鍵是如何只使用sbrk
建立一個struct
:
/* 由於存在虛擬的data欄位,sizeof不能正確計算meta長度,這裡手工設定 */
#define BLOCK_SIZE 24
t_block extend_heap(t_block last, size_t s) {
t_block b;
b = sbrk(0);
if(sbrk(BLOCK_SIZE + s) == (void *)-1)
return NULL;
b->size = s;
b->next = NULL;
if(last)
last->next = b;
b->free = 0;
return b;
}
分割空閒塊
First fit有一個比較致命的缺點,就是可能會讓很小的size
佔據很大的一塊block
,此時,為了提高payload,應該在剩餘資料區足夠大的情況下,將其分裂為一個新的block
,示意如下:
void split_block(t_block b, size_t s) {
t_block new;
new = b->data + s;
new->size = b->size - s - BLOCK_SIZE ;
new->next = b->next;
new->free = 1;
b->size = s;
b->next = new;
}
malloc
的實現
有了上面的程式碼,我們可以利用它們整合成一個簡單但初步可用的malloc
。注意首先我們要定義個bloc
k連結串列的頭first_block
,初始化為NULL
;另外,我們需要剩餘空間至少有BLOCK_SIZE + 8
才執行分裂操作。
由於我們希望malloc
分配的資料區是按8位元組對齊,所以在size
不為8的倍數時,我們需要將size
調整為大於size
的最小的8的倍數:
size_t align8(size_t s) {
if(s & 0x7 == 0)
return s;
return ((s >> 3) + 1) << 3;
}
#define BLOCK_SIZE 24
void *first_block=NULL;
void *malloc(size_t size) {
t_block b, last;
size_t s;
/* 對齊地址 */
s = align8(size);
if(first_block) {
/* 查詢合適的block */
last = first_block;
b = find_block(&last, s);
if(b) {
/* 如果可以,則分裂 */
if ((b->size - s) >= ( BLOCK_SIZE + 8))
split_block(b, s);
b->free = 0;
} else {
/* 沒有合適的block,開闢一個新的 */
b = extend_heap(last, s);
if(!b)
return NULL;
}
} else {
b = extend_heap(NULL, s);
if(!b)
return NULL;
first_block = b;
}
return b->data;
}
calloc
的實現
有了malloc
,實現calloc
只要兩步:(1)malloc
一段記憶體.(2)將資料區內容置為0
.
由於我們的資料區是按8位元組對齊的,所以為了提高效率,我們可以每8位元組一組置0
,而不是一個一個位元組設定。我們可以通過新建一個size_t
指標,將記憶體區域強制看做size_t
型別來實現。
void *calloc(size_t number, size_t size) {
size_t *new;
size_t s8, i;
new = malloc(number * size);
if(new) {
s8 = align8(number * size) >> 3;
for(i = 0; i < s8; i++)
new[i] = 0;
}
return new;
}
free
的實現
free
的實現並不像看上去那麼簡單,這裡我們要解決兩個關鍵問題:
- 如何驗證所傳入的地址是有效地址,即確實是通過
malloc
方式分配的資料區首地址. - 如何解決碎片問題
首先我們要保證傳入free
的地址是有效的,這個有效包括兩方面:
- 地址應該在之前malloc
所分配的區域內,即在first_block
和當前break
指標範圍內.
- 這個地址確實是之前通過我們自己的malloc分配的.
第一個問題比較好解決,只要進行地址比較就可以了,關鍵是第二個問題。這裡有兩種解決方案:一是在結構體內埋一個magic number
欄位,free
之前通過相對偏移檢查特定位置的值是否為我們設定的magic number
,另一種方法是在結構體內增加一個magic pointer
,這個指標指向資料區的第一個位元組(也就是在合法時free
時傳入的地址),我們在free
前檢查magic pointer
是否指向引數所指地址。這裡我們採用第二種方案。
首先我們在結構體中增加magic pointer
(同時要修改BLOCK_SIZE
):
typedef struct s_block *t_block;
struct s_block {
size_t size; /* 資料區大小 */
t_block next; /* 指向下個塊的指標 */
int free; /* 是否是空閒塊 */
int padding; /* 填充4位元組,保證meta塊長度為8的倍數 */
void *ptr; /* Magic pointer,指向data */
char data[1] /* 這是一個虛擬欄位,表示資料塊的第一個位元組,長度不應計入meta */
};
然後我們定義檢查地址合法性的函式:
t_block get_block(void *p) {
char *tmp;
tmp = p;
return (p = tmp -= BLOCK_SIZE);
}
int valid_addr(void *p) {
if(first_block) {
if(p > first_block && p < sbrk(0)) {
return p == (get_block(p))->ptr;
}
}
return 0;
}
當多次malloc
和free
後,整個記憶體池可能會產生很多碎片block
,這些block
很小,經常無法使用,甚至出現許多碎片連在一起,雖然總體能滿足某此malloc
要求,但是由於分割成了多個小block
而無法fit
,這就是碎片問題。
一個簡單的解決方式時當free
某個block
時,如果發現它相鄰的block
也是free
的,則將block
和相鄰block
合併。為了滿足這個實現,需要將s_block
改為雙向連結串列。修改後的block
結構如下:
typedef struct s_block *t_block;
struct s_block {
size_t size; /* 資料區大小 */
t_block prev; /* 指向上個塊的指標 */
t_block next; /* 指向下個塊的指標 */
int free; /* 是否是空閒塊 */
int padding; /* 填充4位元組,保證meta塊長度為8的倍數 */
void *ptr; /* Magic pointer,指向data */
char data[1] /* 這是一個虛擬欄位,表示資料塊的第一個位元組,長度不應計入meta */
};
合併方法如下:
t_block fusion(t_block b) {
if (b->next && b->next->free) {
b->size += BLOCK_SIZE + b->next->size;
b->next = b->next->next;
if(b->next)
b->next->prev = b;
}
return b;
}
free
的實現:
void free(void *p) {
t_block b;
if(valid_addr(p)) {
b = get_block(p);
b->free = 1;
if(b->prev && b->prev->free)
b = fusion(b->prev);
if(b->next)
fusion(b);
else {
if(b->prev)
b->prev->prev = NULL;
else
first_block = NULL;
brk(b);
}
}
}
realloc
的實現
為了實現realloc
,我們首先要實現一個記憶體複製方法。如同calloc
一樣,為了效率,我們以8位元組為單位進行復制:
void copy_block(t_block src, t_block dst) {
size_t *sdata, *ddata;
size_t i;
sdata = src->ptr;
ddata = dst->ptr;
for(i = 0; (i * 8) < src->size && (i * 8) < dst->size; i++)
ddata[i] = sdata[i];
}
下面是realloc
的實現:
void *realloc(void *p, size_t size) {
size_t s;
t_block b, new;
void *newp;
if (!p)
/* 根據標準庫文件,當p傳入NULL時,相當於呼叫malloc */
return malloc(size);
if(valid_addr(p)) {
s = align8(size);
b = get_block(p);
if(b->size >= s) {
if(b->size - s >= (BLOCK_SIZE + 8))
split_block(b,s);
} else {
/* 看是否可進行合併 */
if(b->next && b->next->free
&& (b->size + BLOCK_SIZE + b->next->size) >= s) {
fusion(b);
if(b->size - s >= (BLOCK_SIZE + 8))
split_block(b, s);
} else {
/* 新malloc */
newp = malloc (s);
if (!newp)
return NULL;
new = get_block(newp);
copy_block(b, new);
free(p);
return(newp);
}
}
return (p);
}
return NULL;
}
現實應用
至此,我們通過實現一個簡單的顯式分配器,學習了動態記憶體分配背後的機制。當然與現有C的標準庫實現(例如glibc
)相比,我們實現的malloc
並不是特別高效,但是這個實現比目前真實的malloc
實現要簡單很多,因此易於理解。重要的是,這個實現和真實實現在基本原理上是一致的。
關於真實世界中malloc的實現,可以查閱 glibc 給出的原始碼。除此之外,我們接下來將從其他的方面,再簡要分析一下現實應用中的分配器實現特點。
適配策略
在上文中我們提到了首次適配、下一次適配和最佳適配這三種策略,我們先權衡一下它們的優劣:
策略 | 優勢 | 劣勢 |
---|---|---|
首次適配 | 將大的空閒塊保留在連結串列的後面 | 靠近連結串列起始處的碎片多 |
下一次適配 | 比首次適配執行起來快一些 | 記憶體利用率比首次適配低得多 |
最佳適配 | 記憶體利用率最高 | 需要對堆進行徹底的搜尋,耗時最長 |
在現實應用中,有一些非常精細複雜的分離式空閒連結串列組織,它接近於最佳適配策略,不需要進行徹底地堆搜尋,從而在記憶體利用率、搜尋時間都有較好的應用效果。
合併策略
除了適配策略外,我們還提到了立即合併與推遲合併這兩種合併策略。
我們的Allocator使用了立即合併的策略,它簡單明瞭,可以在常數時間內完成,但是對於某些請求模式,這種方式會產生一種形式的抖動,塊會反覆地合併,然後馬上分割。
如下圖所示:如果反覆地分配和釋放一個3個字的塊,將產生大量不必要的分割和合並。
因此,在現實應用中,快速的分配器通常會選擇某種形式的推遲合併。
顯式空閒連結串列
對於顯示分配器來說,除了我們的Allocator中使用的隱式空閒連結串列(儘管我們最終的資料結構使用了雙向連結串列的形式,而不是一個簡單的頭部,但這依然是把所有的已分配塊與空閒塊連線在一起,因此依然是隱式的),它為我們提供了一種介紹基本分配器概念的簡單方法,然而,因為塊分配與堆塊的總數呈線性關係,所以對於通用的分配器,隱式空閒連結串列是不適合的。
我們下面將分別討論顯式空閒連結串列和分離的空閒連結串列,它們都對空閒塊進行了不同於隱式空閒連結串列的組織方法。
上圖中的顯式空閒連結串列,把分配塊和空閒塊用不同的資料結構進行組織,每個空閒塊中,包含一個前驅和後繼指標,所有的空閒塊形成了一個雙向空閒連結串列,如果我們依然採取首次適配的方式,那麼分配時間就可以從塊總數的線性時間減少到了空閒塊數量的線性時間。
分離的空閒連結串列
通過顯式空閒連結串列,我們將分配時間就從塊總數的線性時間減少到了空閒塊數量的線性時間。採用分離儲存 (segregated storage) 的方式,可以進一步減少分配的時間。
分離儲存,就是維護多個空閒連結串列,其中每個連結串列中的塊有大致相等的大小。一般的思路是將所有可能的塊大小分成一些等價類,也叫做大小類 (size class)。
我們只簡要介紹兩種最基本的方法:簡單分離儲存 (simple segregated storage) 和分離適配 (segregated fit) 。
簡單分離儲存
每個大小類的空閒連結串列包含大小型等的塊,每個塊的大小就是這個大小類中最大元素的大小。
優點:分配和釋放塊都是很快的常數時間操作。
缺點:很容易造成內部和外部碎片。分離適配
分配器維護這一個空閒連結串列的陣列,每個空閒連結串列適合一個大小類相關聯的,並且被組織成某種型別的顯式或隱式連結串列。
這種方法既快速,對記憶體的使用也很有效率。C標準庫中提供的GNUmalloc
包就是採用這種方法。
參考資料
[1] 《C和指標》. [美] Kenneth A.reek 著.
[2]《深入理解計算機系統》(第3版). Randal E. Bryant, David R.O’Hallaron 著.
[5] 真實世界的malloc實現——glibc.