大內高手—記憶體管理器(一) 轉載時請註明出處:http://blog.csdn.net/absurd作為一個C程式員,每天都在和malloc/free/calloc/realloc系列函數打交道。也許和它們混得太熟了,反而忽略了它們的存在,甚至有了三五年的交情,仍然對它們的實現一無所知。相反,一些好奇心未泯的新手,對它們的實現有著濃厚的興趣。當初正是一個新同事的問題,促使我去研究記憶體管理演算法的實現。 記憶體管理演算法多少有些神秘,我們很少想著去實現自己的記憶體管理演算法,這也難怪:有這樣需求的情況並不多。其實,至於記憶體配置演算法的實現,說簡單也簡單,說複雜也複雜。要寫一個簡單的,或許半天時間就可以搞掂,而要寫一個真正實用的,可能要花上你幾周甚至幾個月的時間。 malloc和free是兩個核心函數,而calloc和realloc之所以存在,完全是為了提高效率的緣故。否則完全可以用malloc和free的組合來類比它們。 拿calloc函數的實現來說,在32位機上,記憶體管理器保證記憶體至少是4位元組對齊的,其長度也會擴充到能被4位元組整除,那麼其清零演算法就可以最佳化。可以一次清零4個位元組,這大大提高清零速度。 拿realloc函數的實現來說,如果realloc的指標後面有足夠的空間,記憶體管理器可以直接擴充其大小,而無須拷貝原有內容。當然,新大小比原來還小時,更不拷貝了。相反,通過malloc和free來實現realloc時,兩種情況下都要拷貝,效率自然會低不少。 另外還有兩個非機標準的,但很常用的函數,也涉及到記憶體配置:strdup和strndup。這兩個函數在linux和win32下都支援,非常方便。這完全可以用malloc來類比,而且沒有效能上的損失。 這裡我們主要關注malloc和free兩個函數的實現,並以glibc 2.3.5(32位linux) 為例分析。
記憶體管理器的目標記憶體管理器為什麼難寫?在設計記憶體管理演算法時,要考慮什麼因素?管理記憶體這是記憶體管理器的功能需求。正如設計其它軟體一樣,品質需求一樣佔有重要的地位。分析記憶體管理演算法之前,我們先看看對記憶體管理演算法的品質需求有哪些: l 最大化相容性要實現記憶體管理器時,先要定義出分配器的介面函數。介面函數沒有必要標新立異,而是要遵循現有標準(如POSIX或者Win32),讓使用者可以平滑的過度到新的記憶體管理器上。 l 最大化可移植性通常情況下,記憶體管理器要向OS申請記憶體,然後進行二次分配。所以,在適當的時候要擴充記憶體或釋放多餘的記憶體,這要調用OS提供的函數才行。OS提供的函數則是因平台而異,盡量抽象出平台相關的代碼,保證記憶體管理器的可移植性。 l 浪費最小的空間記憶體管理器要管理記憶體,必然要使用自己一些資料結構,這些資料結構本身也要佔記憶體空間。在使用者眼中,這些記憶體空間毫無疑問是浪費掉了,如果浪費在記憶體管理器身的記憶體太多,顯然是不可以接受的。 記憶體片段也是浪費空間的罪魁禍首,若記憶體管理器中有大量的記憶體片段,它們是一些不連續的小塊記憶體,它們總量可能很大,但無法使用,這也是不可以接受的。 l 最快的速度記憶體配置/釋放是常用的操作。按著2/8原則,常用的操作就是效能熱點,熱點函數的效能對系統的整體效能尤為重要。 l 最大化可調性(以適應於不同的情況)記憶體管理演算法設計的痛點就在於要適應用不同的情況。事實上,如果缺乏應用的上下文,是無法評估記憶體管理演算法的好壞的。可以說在任何情況下,專用演算法都比通用演算法在時/空效能上的表現更優。 為每種情況都寫一套記憶體管理演算法,顯然是不太合適的。我們不需要追求最優演算法,那樣代價太高,能達到次優就行了。設計一套通用記憶體管理演算法,通過一些參數對它進行配置,可以讓它在特定情況也有相當出色的表現,這就是可調性。 l 最大化局部性(Locality)大家都知道,使用cache可以提高程度的速度,但很多人未必知道cache使程式速度提高的真正原因。拿CPU內部的cache和RAM的訪問速度相比,速度可能相差一個數量級。兩者的速度上的差異固然重要,但這並不是提高速度的充分條件,只是必要條件。 另外一個條件是程式訪問記憶體的局部性(Locality)。大多數情況下,程式總訪問一塊記憶體附近的記憶體,把附近的記憶體先加入到cache中,下次訪問cache中的資料,速度就會提高。否則,如果程式一會兒訪問這裡,一會兒訪問另外一塊相隔十萬八千裡的記憶體,這隻會使資料在記憶體與cache之間來回搬運,不但於提高速度無益,反而會大大降低程式的速度。 因此,記憶體管理演算法要考慮這一因素,減少cache miss和page fault。 l 最大化調試功能作為一個C/C++程式員,記憶體錯誤可以說是我們的噩夢,上一次的記憶體錯誤一定還讓你記憶猶新。記憶體管理器提供的調試功能,強大易用,特別對於嵌入式環境來說,記憶體錯誤偵測工具缺乏,記憶體管理器提供的調試功能就更是不可或缺了。 l 最大化適應性前面說了最大化可調性,以便讓記憶體管理器適用於不同的情況。但是,對於不同情況都要去調設定,無疑太麻煩,是非方便使用的。要盡量讓記憶體管理器適用於很廣的情況,只有極少情況下才去調設定。 設計是一個多目標最佳化的過程,有些目標之間存在著競爭。如何平衡這些競爭力是設計的痛點之一。在不同的情況下,這些目標的重要性又不一樣,所以根本不存在一個
最好的記憶體配置演算法。 關於glibc的記憶體 Clerk,我們並打算做代碼級分析,只談談幾點有趣的東西:
1.
Glibc分配演算法概述:l 小於等於64位元組:用pool演算法分配。l 64到512位元組之間:在最佳憑配演算法分配和pool演算法分配中取一種合適的。l 大於等於512位元組:用最佳憑配演算法分配。l 大於等於128K:直接調用OS提供的函數(如mmap)分配。
2.
Glibc擴充記憶體的方式: l int brk(void *end_data_segment);本函數用於擴充堆空間(堆空間的定義可參考記憶體模型一章),用end_data_segment指明堆的結束位址。l void *sbrk(ptrdiff_t increment);本函數用於擴充堆空間(堆空間的定義可參考記憶體模型一章),用increment指定要增加的大小。l void* mmap(void *start, size_t length, int prot , int flags, int fd, off_t offset);本函數用於分配大塊記憶體了,如前面所述大於128K的記憶體。
3.
null 指標和零長度記憶體l free(NULL)會讓程式crash嗎?答案是不會,標準C要求free接受null 指標,然後什麼也不做。l malloc(0)會分配成功嗎?答案是會的,它會返回一塊最小記憶體給你。
4.
對齊與取整l 記憶體管理器會保證分配出來的記憶體位址是對齊的,通常是4或8位元組對齊。l 記憶體管理器會對要求記憶體長度取整,讓記憶體長度能被4或8的整除。5.
已經分配記憶體的結構
如果前面有一塊有效記憶體塊的,則第一個size_t指明前一塊記憶體的大小。第二個size_t指明自己的大小,同時還指明:自己是不是用mmap分配的(M),前面是否有一個效記憶體塊(P)。你可能覺得奇怪,在32位機上,sizeof(size_t)就是32位,怎麼還能留下兩個位來儲存標誌呢?前面我們說了,會對記憶體長度取整,保證最低2或3bits為0,即是閒置。 6.
空閑記憶體的管理
由此可以看出,最小記憶體塊的長度為16位元組:sizeof(size_t) +sizeof(size_t) +sizeof(void*) +sizeof(void*) +0這一招非常管用,第一次看到時,感覺簡直太巧妙了。這使得無需要額外的記憶體來管理空閑塊,利用空閑塊自己,把空閑塊強制轉換成一個雙向鏈表就行了。
Trackback: http://tb.blog.csdn.net/TrackBack.aspx?PostId=842292