如何設計一個記憶體分配器?

發表於2015-07-30

通常工程裡不推薦自己寫記憶體分配器,因為你費力寫一個出來99%可能性沒有內建的好,且記憶體出bug難除錯
不過看書之餘,你也可以動手自己試試,當個玩具寫寫玩玩。

 

1. 實現教科書上的記憶體分配器:

 
做一個連結串列指向空閒記憶體,分配就是取出一塊來,改寫連結串列,返回,釋放就是放回到連結串列裡面,並做好歸併。注意做好標記和保護,避免二次釋放,還可以花點力氣在如何查詢最適合大小的記憶體快的搜尋上,減少記憶體碎片,有空你了還可以把連結串列換成夥伴演算法,寫著玩嘛。

 

2. 實現固定記憶體分配器:

 
即實現一個 FreeList,每個 FreeList 用於分配固定大小的記憶體塊,比如用於分配 32位元組物件的固定記憶體分配器,之類的。每個固定記憶體分配器裡面有兩個連結串列,OpenList 用於儲存未分配的空閒物件,CloseList用於儲存已分配的記憶體物件,那麼所謂的分配就是從 OpenList 中取出一個物件放到 CloseList 裡並且返回給使用者,釋放又是從 CloseList 移回到 OpenList。分配時如果不夠,那麼就需要增長 OpenList:申請一個大一點的記憶體塊,切割成比如 64 個相同大小的物件新增到 OpenList中。這個固定記憶體分配器回收的時候,統一把先前向系統申請的記憶體塊全部還給系統。

 

3. 實現 FreeList 池:

 
在你實現了 FreeList的基礎上,按照不同物件大小(8位元組,16位元組,32,64,128,256,512,1K。。。64K),構造十多個固定記憶體分配器,分配記憶體時根據記憶體大小查表,決定到底由哪個分配器負責,分配後要在頭部的 header 處(ptr[-sizeof(char*)]處)寫上 cookie,表示又哪個分配器分配的,這樣釋放時候你才能正確歸還。如果大於64K,則直接用系統的 malloc作為分配,如此以浪費記憶體為代價你得到了一個分配時間近似O(1)的記憶體分配器,差不多實現了一個 memcached 的 slab 記憶體管理器了,但是先別得意。此 slab 非彼 slab(sunos/solaris/linux kernel 的 slab)。這說白了還是一個弱智的 freelist 無法歸還記憶體給作業系統,某個 FreeList 如果高峰期佔用了大量記憶體即使後面不用,也無法支援到其他記憶體不夠的 FreeList,所以我們做的這個和 memcached 類似的分配器其實是比較殘缺的,你還需要往下繼續優化。

 

4. 實現正統的 slab (非memcached的偽 slab)代替 FreeList:

 
這時候你需要閱讀一下 http://citeseer.ist.psu.edu/bonwick94slab.html 這篇論文了,現代記憶體分配技術的基礎,如何管理 slab 上的物件,如何進行地址管理,如何管理不同 slab 的生命週期,如何將記憶體回收給系統。然後開始實現一個類似的東西,文章上傳統的 slab 的各種基礎概念雖然今天沒有改變,但是所用到的資料結構和控制方法其實已經有很多更好的方法了,你可以邊實現邊思考下,實在不行還可以參考 kernel 原始碼嘛。但是有很多事情應用程式做不了,有很多實現你是不能照搬的,比如頁面提供器,可以提供連續線性地址的頁面,再比如說 kernel 本身記錄著每個頁面對應的 slab,你查詢 slab 時,系統其實是根據線性地址移位得到頁面編號,然後查表得到的,而你應用程式不可能這麼幹,你還得做一些額外的體系來解決這些問題,還需要寫一些額外的 cookie 來做標記。做好記憶體收縮工作,記憶體不夠時先收縮所有分配器的 slab,再嘗試重新分配。再做好記憶體回收工作,多餘的記憶體,一段時間不使用可以還給作業系統。

 

5. 實現混合分配策略:

 
你實現了上面很多常見的演算法後,該具體閱讀各種記憶體分配器的程式碼了,這些都是經過實踐檢驗的,比如 libc 的記憶體分配器,或者參考有自帶記憶體管理的各種開源專案,比如 python 原始碼,做點實驗對比他們的優劣,然後根據分配物件的大小採用不同的分配策略,區別對待各種情況。試驗的差不多了就得引入多執行緒支援了,將你的鎖改小。注意很多系統層的執行緒安全策略你是沒法弄的,比如作業系統可以關中斷,短時間內禁止本cpu發生任務切換,這點應用程式就很麻煩了,還得用更小的鎖來代替。當鎖已經小到不能再小,也可以選擇引入 STM 來代替各種連結串列的鎖。

 

6. 實現 Per-CPU Cache:

 
現代記憶體分配器,在多核下的一個重要優化就是給多核增加 cache,為了進一步避免多執行緒鎖競爭,需要引入 Per-CPU Cache 了。分配記憶體先找到對應執行緒所在的cpu,從該cpu上對應的 cache 裡分配,cache 不夠了就一次性從你底層的記憶體分配器裡多分配幾個物件進來填充 cache,釋放時也是先放回 cache,cache裡面如果物件太多,就做一次收縮,把記憶體換個底層分配器,讓其他 cpu 的cache有機會利用。這樣針對很多短生命週期的頻繁的分配、釋放,其實都是在 cache 裡完成的,沒有鎖競爭,同時cache分配邏輯簡單,速度更快。作業系統裡面的程式碼經常是直接讀取當前的cpu是哪個,而應用層實現你可以用 thread local storage 來代替,目前這些東西在 crt的 malloc 裡還暫時支援不到位(不排除未來版本會增加),可以更多參考 tc/jemalloc。

 

7. 實現地址著色:

 
現代記憶體分配器必須多考慮匯流排壓力,在很多機型上,如果記憶體訪問集中在某條 cache line相同的偏移上,會給匯流排帶來額外的負擔和壓力。比如你經常要分配一個 FILE 物件,而每個 FILE物件使用時會比較集中的訪問 int FILE::flag; 這個成員變數,如果你的頁面提供器提供的頁面地址是按照 4K對齊的,那麼很可能多個 FILE物件的 flag 成員所處的 cache line 偏移地址是相同的,大量訪問這些相同的偏移地址會給匯流排帶來很大負擔,這時候你需要給每個物件額外增加一些偏移,讓他們能夠均勻的分佈線上性地址對應的cache line 偏移上,消減匯流排衝突的開銷。

 

8. 優化快取競爭:

 
多核時代,很多單核時代的程式碼都需要針對性的優化改寫,最基本的一條就是 cache 競爭,這是比前面鎖競爭更惡劣的情況:如果兩個cpu同時訪問相同的 cache-line 或者物理頁面,那麼 cpu 之間為了保證記憶體一致性會做很多的通訊工作,比如那個cpu0需要用到這段記憶體,發現cpu1也在用,那麼需要通知cpu1,將cpu1 L1-L2快取裡面的資料寫回該實體記憶體,並且釋放控制權,這時cpu0取得了控制權才能繼續操作,期間cpu0-cpu1之間的通訊協議是比較複雜的,代價也是比較大的,cache競爭比鎖競爭惡劣不少。為了避免 cache 競爭,需要比先前Per-CPU cache 更徹底的 Per-CPU Page 機制來解決,直接讓不同的cpu使用不同的頁面進行二次分配,徹底避免 cache 競爭。具體應用層的做法也是利用線性地址來判斷所屬頁面(因為物理頁面對映到程式地址也是4k對齊的),同時繼續使用 thread local storage 或者用系統提供的 api 讀取當前屬於哪個 cpu 來實現。為了避免核太多每個核佔據大量的頁面帶來的不必要的浪費,你可以參考下 Linux 最新的 slub 記憶體分配演算法,但是 slub 也有未盡之處,好幾個 linux 發行版在實踐中發現 slub 還是存在一些問題的(非bug,而是機制),所以大部分發行版預設都是關閉 slub 的,雖然,你還是可以借鑑測試一下。

 

9. 除錯和折騰:

 
繼續參考各種現代記憶體分配器,取長補短,然後給你的分配器新增一些便於除錯的機制,方便診斷各種問題。在你借鑑了很多開源專案,自己也做了一些所謂的優化,折騰了那麼久以後,你或許以為你的分配器可以同各種開源分配器一戰了,測試效果好像也挺好的,先別急,繼續觀察記憶體利用率,向作業系統申請/歸還記憶體的頻率等一系列容易被人忽視的指標是否相同。同時更換你的測試用例,看看更多的情況下,是否結果還和先前一樣?這些都差不多的時候,你發現沒有個一兩年的大規模持續使用,你很難發現一些潛在的隱患和bug,可能你覺得沒問題的程式碼,跑了兩年後都會繼續報bug,這很正常,多點耐心,興許第三年以後就比較穩定了呢?

 

有卯用呢?

 
十多年前 libc 還不成熟的情況下,為了程式長時間執行的穩定性,大部分程式設計師都必須針對自己的應用來實現針對特定情況的記憶體分配器。當年如果不自己管理記憶體,很多客戶端,如果計算密集頻繁分配,才開始可能沒什麼區別,但跑個幾個小時效能立馬就下降下來了;伺服器程式持續執行個10多天不重啟,速度也會越來越慢,碎片多了嘛。如今 libc 的 malloc 也進步了很多,這樣的情況比較少了,那你再做一個記憶體池的意義何在呢?

在你的玩具比較穩定的情況下,終於可以產生一些價值了,因為一些效能指標你無法兼得,標準的分配器往往提供了一個類似保守和中庸的做法,來針對大部分的情況,你可以做的第一步,就是打破這樣的平衡,讓你的分配器傾向於某些情況比如:

1. 現代計算機記憶體都很大,你是不是可以犧牲記憶體利用率為代價換取更高的記憶體歸還/重用的效率?同時換取更快的分配速度?或許你會發現,你可以比 libc 的 malloc 平均浪費 30%記憶體的代價換來兩倍以上的效能提升,在一些記憶體分配成為瓶頸的應用中起到積極的作用。

2. 比如你可以調整大小記憶體的比值,libc如果認為 8K以下是小記憶體,那麼你可以不那麼認為。

3. 比如如果你的系統就是一個單執行緒的東西,那麼你是否能提供開關,完全以單執行緒的模式進行運作,完全繞過各種鎖和針對多核進行的各種冗餘操作呢?

4. 比如你的機器記憶體有限,你應用需要耗費大量的記憶體,那麼你可以引入其他機制,以犧牲少量效能為代價,換取更好的記憶體回收效果和記憶體利用率。

5. 最近分配的記憶體線上性地址上能否儘量連續?能否在地址上儘量規避缺頁中斷?

6. 比如你程式裡面某些物件需要被跟蹤,你能否直接在分配器上實現物件跟蹤機制,跟蹤各種洩漏,越界問題?

7. 每個記憶體分配都在尋求最佳的公平,你在乎的公平是什麼?

相關文章