本章主要討論關於內存空閑空間管理的一些問題。如果需要管理的內存空間被划分為固定大小的單元,空閑空間管理就很容易。在這種情況下,只需要維護這些大小固定的單元的列表,如果有內存分配請求,就返回列表中的第一項。但是,如果空閑空間由大小不同的單元構成,管理就變得比較困難。這種情況出現在用戶級的內存分配庫(如malloc()
和free()
),或者操作系統用分段的方式實現虛擬內存。在這兩種情況下,出現了外部碎片的問題,即空閑空間被分割成不同大小的碎片,后續的請求可能失敗,因為沒有一塊足夠大的連續空閑空間,即使這時總的空閑空間超出了請求的大小。所以,本章需要解決的問題是,要滿足變長內存分配請求,應該使用什么策略管理空閑空間?
假設
我們假定基本的接口就像malloc()
和free()
提供的那樣。具體來說,void* malloc(siz_t size)
需要一個參數size,它是應用程序請求的字節數。函數返回一個void類型的指針,指向這樣大小(或較大一點)的一塊空間。對應的函數void free(void *ptr)
函數接受一個指針,釋放對應的內存塊。注意在釋放空間時,用戶不需告知庫這塊空間的大小。因此,在只傳入一個指針的情況下,庫必須能夠弄清楚這塊內存的大小。該庫管理的空間由於歷史原因被稱為堆,在堆上管理空閑空間的數據結構通常稱為空閑列表,它包含了管理內存區域中所有空閑塊的引用。當然,這種數據結構不一定真的是列表,而只是某種可以追蹤空閑空間的數據結構。
進一步假設我們主要關心外部碎片問題。當然,分配程序也可能有內部碎片的問題。如果分配程序給出的內存塊超出請求的大小,在這種塊中超出請求而未使用的空間就是內部碎片(因為浪費發生在已分配單元的內部),這是另一種形式的空間浪費。簡單起見,這里主要討論外部碎片。
我們還假設,內存一旦被分配給客戶,就不可以被重定位到其他位置。例如,一個程序調用malloc()
,並獲得一個指向堆中一塊空間的指針,這塊區域就“屬於”這個程序了,庫不再能夠移動,直到程序調用相應的free()
函數將它歸還。這意味着操作系統不會為了減少內存碎片而進行緊湊空閑空間的操作。但是,操作系統層在實現分段時,卻可以通過緊湊來減少碎片(正如第16章討論的那樣)。
最后我們假設,分配程序所管理的是連續的一塊字節區域。某些情況下,分配程序可以要求這塊區域增長。例如,一個用戶級的內存分配庫在空間快用完時,可以向內核申請增加堆空間(通過sbrk這樣的系統調用)。但是,簡單起見,我們假設這塊區域在其整個生命周期內大小固定。
底層機制
在深入策略細節之前,我們先來介紹大多數分配程序采用的通用機制。首先,探討空間分割與合並的基本知識。其次,看看如何快速並相對輕松地追蹤已分配的空間。最后,討論如何利用空閑區域的內部空間維護一個簡單的列表,來追蹤空閑和已分配的空間。
分割與合並
空閑列表包含一組元素,記錄了堆中的哪些空間還沒有分配。假設有下面的30字節的堆:
這個堆對應的空閑列表會有兩個元素,一個描述第一個10字節的空閑區域(字節0~9),一個描述另一個空閑區域(字節20~29):
通過上面的介紹可以看出,由於沒有足夠的連續可用空間,任何大於10字節的分配請求都會失敗。而恰好10字節的需求可以由兩個空閑塊中的任何一個滿足。如果申請小於10字節空間,分配程序會執行所謂的分割操作:它找到一塊可以滿足請求的空閑空間,將其分割,第一塊返回給用戶,第二塊留在空閑列表中。假設遇到申請一個字節的請求,分配程序選擇對第二塊空閑空間進行分割,對malloc()
的調用會返回20(1字節分配區域的地址),空閑列表會變成這樣:
從上面可以看出,空閑列表基本沒有變化,只是第二個空閑區域的起始位置由20變成21,長度由10變為9了。因此,如果請求的空間大小小於某塊空閑塊,分配程序通常會進行分割。
對於這個堆,如果應用程序調用free(10)
歸還堆中間的空間,會發生什么?如果只是簡單地將這塊空閑空間加入空閑列表,可能得到如下的結果:
現在問題來了,盡管整個堆現在完全空閑,但它似乎被分割成了3個10字節的區域。如果用戶此時請求20字節的空間,簡單遍歷空閑列表會找不到這樣的空閑塊,因此返回失敗。為了避免這個問題,分配程序會在釋放一塊內存時合並可用空間。在用戶歸還一塊空閑內存時,分配程序仔細查看要歸還的內存塊的地址以及鄰近的空閑空間塊。如果新歸還的空間與原有空閑塊相鄰,就將它們合並為一個較大的空閑塊。通過合並,最后空閑列表應該像這樣:
實際上,這就是堆的空閑列表最初的樣子。通過合並,分配程序可以更好地確保大塊的空閑空間能提供給應用程序。
追蹤已分配空間的大小
在使用free(void *ptr)
釋放已申請的內存空間時,我們會發現該接口沒有塊大小的參數。因此它假定對於給定的指針,內存分配庫可以很快確定要釋放空間的大小,從而將它放回空閑列表。要完成這個任務,大多數分配程序都會在頭塊(存在於返回的內存塊之前)中保存一些額外的信息。在下面這個例子中,我們調用int *ptr = malloc(20)
申請20字節的空間,並將結果保存在ptr
中:
該頭塊中至少包含所分配空間的大小,也可能包含一些額外的指針來加速空間釋放,包含一個幻數來提供完整性檢查,以及其他的一些信息。我們假定一個簡單的頭塊包含了分配空間的大小和一個幻數:
typedef struct header_t {
int size;
int magic;
} header_t;
用戶調用free(ptr)
時,庫會通過簡單的指針運算得到頭塊的位置:
void free(void *ptr) {
header_t *hptr = (header_t *)ptr - 1;
assert(hptr->magic == 1234567);
}
獲得頭塊的指針后,庫可以很容易地確定幻數是否符合預期的值,並簡單計算要釋放的空間大小(即頭塊的大小加區域長度)。注意,內存分配庫實際釋放的是頭塊大小加上分配給用戶的空間的大小。因此,如果用戶請求N字節的內存,庫會尋找N加上頭塊大小的空閑塊。
嵌入空閑列表
到目前為止,這個簡單的空閑列表還只是一個概念上的存在,它就是一個列表,描述了堆中的空閑內存塊。但如何在空閑內存自己內部建立這樣一個列表呢?在更典型的列表中,如果要分配新節點,你會調用malloc()
來獲取該節點所需的空間。遺憾的是,在內存分配庫內我們無法這么做,我們的目的是在空閑空間本身中建立空閑空間列表。
假設我們需要管理一個4096字節的內存塊(即堆是4KB)。為了將它作為一個空閑空間列表來管理,首先要初始化這個列表。初始時列表中只有一個條目,記錄了大小為4096的空間(減去頭塊的大小)。下面是該列表中一個節點描述:
typedef struct node_t {
int size;
struct node_t *next;
} node_t;
現在,有一些代碼用來初始化堆,並將空閑列表的第一個元素放在該空間中。假設堆構建在某塊空閑空間上,這塊空間通過系統調用mmap()
獲得。這不是構建這種堆的唯一選擇,但在這個例子中很合適。
// mmap() returns a pointer to a chunk of free space
node_t *head = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_ANON | MAP_PRIVATE, -1, 0);
head->size = 4096 - sizeof(node_t);
head->next = NULL;
執行這段代碼之后,列表的狀態是它只有一個條目,記錄大小為4088。head指針指向這塊區域的起始地址,這里假設是16KB。堆看起來如下所示。
現在,假設有一個100字節的內存請求。為了滿足這個請求,庫首先要找到一個足夠大小的塊。因為只有一個4088字節的塊,所以選中這個塊。然后,這個塊被分割為兩塊,一塊足夠滿足請求(以及頭塊,如前所述),一塊是剩余的空閑塊。假設頭塊大小為8個字節(兩個整數,分別記錄大小和幻數),那么現在堆中的空間如圖所示。
至此,對於100字節的請求,庫從原有的一個空閑塊中分配了108字節,返回指向它的一個指針(在上圖中用ptr表示),並在其之前連續的8字節中記錄頭塊信息,以供未來的free()
函數使用。同時將列表中的空閑節點縮小為3980字節。現在再來看該堆,其中有3個已分配區域,每個100(加上頭塊是108)字節。
可以看出,堆的前324字節已經分配,因此該空間中有3個頭塊,以及3個100字節的用戶使用空間。空閑列表只有一個由head指向的節點,但在3次分割后,大小只有3764字節。但如果用戶程序通過free()
歸還一些內存,會發生什么?在這個例子中,應用程序調用free(16500),歸還了中間的一塊已分配空間。內存庫會計算出這塊要釋放空間的大小,並將空閑塊加回空閑列表。假設我們將它插入到空閑列表的頭位置,那么整個空間就如下所示。
現在,空閑空間被分割成兩段,空閑列表包括一個100字節的小空閑塊和一個3764字節的大空閑塊。假設剩余的兩塊已分配的空間也被釋放。如果沒有合並,那么空閑列表將非常破碎。雖然整個內存空間是空閑的,但卻被分成了多個小段,因此形成了碎片化的內存空間。解決方案非常簡單:遍歷列表,然后合並相鄰塊。完成之后,堆又成了一個整體。
讓堆增長
如果堆中的內存空間耗盡,應該怎么辦?最簡單的方式就是返回失敗。在某些情況下這也是唯一的選擇。大多數傳統的分配程序會從很小的堆開始,當空間耗盡時,再向操作系統申請更大的空間。通常,這意味着它們進行了某種系統調用(例如,大多數UNIX系統中的sbrk)讓堆增長。操作系統在執行sbrk系統調用時,會找到空閑的物理內存頁,將它們映射到請求進程的地址空間中去,並返回新的堆的末尾地址。這時,就有了更大的堆,請求就可以成功滿足。
基本策略
既然有了這些底層機制,讓我們來看看管理空閑空間的一些基本策略。理想的分配程序可以同時保證快速和碎片最小化。遺憾的是,由於分配及釋放的請求序列是任意的,任何特定的策略在某組不匹配的輸入下都會變得非常差。所以我們不會描述“最好”的策略,而是介紹一些基本的選擇,並討論它們的優缺點。
最優匹配
最優匹配策略首先遍歷整個空閑列表,找到和請求大小一樣或更大的空閑塊,然后返回這組候選者中最小的一塊。只需要遍歷一次空閑列表,就足以找到正確的塊並返回。最優匹配選擇最接近用戶請求大小的塊,從而盡量避免空間浪費。然而,簡單的實現在遍歷查找正確的空閑塊時,要付出較高的性能代價。
最差匹配
最差匹配方法與最優匹配相反,它嘗試找最大的空閑塊,分割並滿足用戶需求后,將剩余的塊(很大)加入空閑列表。最差匹配嘗試在空閑列表中保留較大的塊,而不是向最優匹配那樣可能剩下很多難以利用的小塊。但是,最差匹配同樣需要遍歷整個空閑列表。更糟糕的是,大多數研究表明它的表現非常差,會導致過量的碎片,同時還有很高的開銷。
首次匹配
首次匹配策略就是找到第一個足夠大的塊,將請求的空間返回給用戶。同樣,剩余的空閑空間留給后續請求。首次匹配有速度優勢,但有時會讓空閑列表開頭的部分有很多小塊。因此,分配程序如何管理空閑列表的順序就變得很重要。一種方式是基於地址排序,通過保持空閑塊按內存地址有序,合並操作會很容易,從而減少了內存碎片。
下次匹配
不同於首次匹配每次都從列表的開始查找,下次匹配算法多維護一個指針,指向上一次查找結束的位置。其想法是將對空閑空間的查找操作擴散到整個列表中去,避免對列表開頭頻繁的分割。這種策略的性能與首次匹配很接近,同樣避免了遍歷查找。
一些改進策略
分離空閑列表
有種很有趣的方式叫作分離空閑列表。如果某個應用程序經常申請一種(或幾種)大小的內存空間,那就用一個獨立的列表,只管理這樣大小的對象。其他大小的請求都交給更通用的內存分配程序。這種方法的好處顯而易見。通過拿出一部分內存專門滿足某種大小的請求,碎片就不再是問題了。而且,由於沒有復雜的列表查找過程,這種特定大小的內存分配和釋放都很快。
然而,這種方式為系統引入了新的復雜性。例如,應該拿出多少內存來專門為某種大小的請求服務,而將剩余的用來滿足一般請求?Solaris系統內核中的厚塊分配程序(slab allocator)優雅地處理了這個問題。在內核啟動時,它為可能頻繁請求的內核對象創建一些對象緩存,如鎖和文件系統inode等。這些對象緩存每個分離了特定大小的空閑列表,因此能夠很快地響應內存請求和釋放。如果某個緩存中的空閑空間快耗盡時,它就向通用內存分配程序申請一些內存厚塊(總量是頁大小和對象大小的公倍數)。相反,如果給定厚塊中對象的引用計數變為0,通用的內存分配程序可以從專門的分配程序中回收這些空間,這通常發生在虛擬內存系統需要更多的空間的時候。
厚塊分配程序比大多數分離空閑列表做得更多,它將列表中的空閑對象保持在預初始化的狀態。通過將空閑對象保持在初始化狀態,厚塊分配程序避免了頻繁的初始化和銷毀,從而顯著降低了開銷。
伙伴系統
因為合並對分配程序很關鍵,所以一些研究致力於讓合並變得簡單,一個例子就是二分伙伴分配程序。在這種系統中,空閑空間首先從概念上被看成大小為\(2^N\)的大空間。當有一個內存分配請求時,空閑空間被遞歸地一分為二,直到剛好可以滿足請求的大小(再一分為二就無法滿足)。這時,請求的塊被返回給用戶。在下面的例子中,一個64KB大小的空閑空間被切分,以便提供7KB的塊:
在這個例子中,最左邊的8KB塊被分配給用戶(如上圖中深灰色部分所示)。請注意,這種分配策略只允許分配2的整數次冪大小的空閑塊,因此會有內部碎片的麻煩。伙伴系統的漂亮之處在於塊被釋放時。如果將這個8KB的塊歸還給空閑列表,分配程序會檢查“伙伴”8KB是否空閑。如果是,就合二為一,變成16KB的塊。然后會檢查這個16KB塊的伙伴是否空閑,如果是,就合並這兩塊。這個遞歸合並過程繼續上溯,直到合並整個內存區域,或者某一個塊的伙伴還沒有被釋放。伙伴系統運轉良好的原因,在於很容易確定某個塊的伙伴。每對互為伙伴的塊只有一位不同,這一位就決定了它們在整個伙伴樹中的層次。