圖解 TCMalloc

ashane1314發表於2020-10-29

前言

TCMalloc 是 Google 開發的記憶體分配器,在不少專案中都有使用,例如在 Golang 中就使用了類似的演算法進行記憶體分配。它具有現代化記憶體分配器的基本特徵:對抗記憶體碎片、在多核處理器能夠 scale。據稱,它的記憶體分配速度是 glibc2.3 中實現的 malloc的數倍。

之所以學習 TCMalloc,是因為在學習 Golang 記憶體管理的時候,發現 Golang 竟然就用了鼎鼎大名的 TCMalloc,而在此之前雖然也對記憶體管理有過一些淺薄的瞭解,但一直沒有機會深入。因此藉此機會再鞏固一下記憶體管理的知識。

在學習 TCMalloc 的過程中看過不少文章,但程式設計師寫出來的文章常常以程式碼分析居多,可讀性不是那麼高。我之前也喜歡寫一些原始碼分析之類的文章,但漸漸發覺從原始碼出發雖然能夠探究實現的細節,但這些東西更適合作為自己的學習筆記,如果要講給別人,還是用一些更加可讀的方式比較好。因此,這篇文章主要以看圖說話為主,是為圖解。

如何分配定長記錄?

 

首先是基本問題,如何分配定長記錄?例如,我們有一個 Page 的記憶體,大小為 4KB,現在要以 N 位元組為單位進行分配。為了簡化問題,就以 16 位元組為單位進行分配。

解法有很多,比如,bitmap。4KB / 16 / 8 = 32, 用 32 位元組做 bitmap即可,實現也相當簡單。

出於最大化記憶體利用率的目的,我們使用另一種經典的方式,freelist。將 4KB 的記憶體劃分為 16 位元組的單元,每個單元的前8個位元組作為節點指標,指向下一個單元。初始化的時候把所有指標指向下一個單元;分配時,從連結串列頭分配一個物件出去;釋放時,插入到連結串列。

由於連結串列指標直接分配在待分配記憶體中,因此不需要額外的記憶體開銷,而且分配速度也是相當快。

如何分配變長記錄?

定長記錄的問題很簡單,但如何分配變長記錄的。對此,我們把問題化歸成對多種定長記錄的分配問題。

 

我們把所有的變長記錄進行“取整”,例如分配7位元組,就分配8位元組,31位元組分配32位元組,得到多種規格的定長記錄。這裡帶來了內部記憶體碎片的問題,即分配出去的空間不會被完全利用,有一定浪費。為了減少內部碎片,分配規則按照 8, 16, 32, 48, 64, 80這樣子來。注意到,這裡並不是簡單地使用2的冪級數,因為按照2的冪級數,記憶體碎片會相當嚴重,分配65位元組,實際會分配128位元組,接近50%的記憶體碎片。而按照這裡的分配規格,只會分配80位元組,一定程度上減輕了問題。

大的物件如何分配?

上面講的是基於 Page,分配小於Page的物件,但是如果分配的物件大於一個 Page,我們就需要用多個 Page 來分配了:

 

這裡提出了 Span 的概念,也就是多個連續的 Page 會組成一個 Span,在 Span 中記錄起始 Page 的編號,以及 Page 數量。

分配物件時,大的物件直接分配 Span,小的物件從 Span 中分配。

Span如何分配?

對於 Span的管理,我們可以如法炮製:

 

還是用多種定長 Page 來實現變長 Page 的分配,初始時只有 128 Page 的 Span,如果要分配 1 個 Page 的 Span,就把這個 Span 分裂成兩個,1 + 127,把127再記錄下來。對於 Span 的回收,需要考慮Span的合併問題,否則在分配回收多次之後,就只剩下很小的 Span 了,也就是帶來了外部碎片 問題。

為此,釋放 Span 時,需要將前後的空閒 Span 進行合併,當然,前提是它們的 Page 要連續。

問題來了,如何知道前後的 Span 在哪裡?

從Page到Span

由於 Span 中記錄了起始 Page,也就是知道了從 Span 到 Page 的對映,那麼我們只要知道從 Page 到 Span 的對映,就可以知道前後的Span 是什麼了。

 

最簡單的一種方式,用一個陣列記錄每個Page所屬的 Span,而陣列索引就是 Page ID。這種方式雖然簡潔明瞭,但是在 Page 比較少的時候會有很大的空間浪費。

為此,我們可以使用 RadixTree 這種資料結構,用較少的空間開銷,和不錯的速度來完成這件事:

乍一看可能有點懵,這個跟 RadixTree 能扯上關係嗎?可以把 RadixTree 理解成壓縮過的字首樹(trie),所謂壓縮,就是在一條路徑上的節點都只有一個子節點,就把這條路徑合併到父節點去,因此內部節點最少會有 Radix 個位元組點。具體的分析可以參考一下 wikipedia 。

實現時,可以通過一定的空間換來時間,也就是減少層數,比如說3層。每層都是一個陣列,用一個地址的前 1/3 的bit 索引陣列,剩下的 bit 對下一層進行定址。實際的定址也可以非常快。

PageHeap

到這裡,我們已經實現了 PageHeap,對所有 Page進行管理:

 

全域性物件分配

既然有了基於 Page 的物件分配,和Page本身的管理,我們把它們串起來就可以得到一個簡單的記憶體分配器了:

 

按照我們之前設計的,每種規格的物件,都從不同的 Span 進行分配;每種規則的物件都有一個獨立的記憶體分配單元:CentralCache。在一個CentralCache 記憶體,我們用連結串列把所有 Span 組織起來,每次需要分配時就找一個 Span 從中分配一個 Object;當沒有空閒的 Span 時,就從 PageHeap 申請 Span。

看起來基本滿足功能,但是這裡有一個嚴重的問題,在多執行緒的場景下,所有執行緒都從CentralCache 分配的話,競爭可能相當激烈。

ThreadCache

到這裡 ThreadCache 便呼之欲出了:

每個執行緒都一個執行緒區域性的 ThreadCache,按照不同的規格,維護了物件的連結串列;如果ThreadCache 的物件不夠了,就從 CentralCache 進行批量分配;如果 CentralCache 依然沒有,就從PageHeap申請Span;如果 PageHeap沒有合適的 Page,就只能從作業系統申請了。

在釋放記憶體的時候,ThreadCache依然遵循批量釋放的策略,物件積累到一定程度就釋放給 CentralCache;CentralCache發現一個 Span的記憶體完全釋放了,就可以把這個 Span 歸還給 PageHeap;PageHeap發現一批連續的Page都釋放了,就可以歸還給作業系統。

至此,TCMalloc 的大體結構便呈現在我們眼前了。

總結

這裡用圖解的方式簡單講述了 TCMalloc 的基本結構,如何減少內部碎片,如何減少外部碎片,如何使用夥伴演算法進行記憶體合併,如何使用單連結串列進行記憶體分配,如何通過執行緒區域性的方式提高擴充套件性。

不過實現一個高效能的記憶體分配器絕非如此簡單,TCMalloc 中有許多策略,許多引數,許多細節的考量,都值得我們深究。一篇文章難以覆蓋,之後的文章再做詳解。

另外,畫圖其實也蠻有意思的,對提高可讀性非常有幫助。這裡用的 draw.io 畫的圖,暫時覺得蠻好用的。

相關文章