動機
TCMalloc是一個非常快速的內存管理庫,它比glibc 2.3的malloc以及其他的一些內存管理庫都要更高效。ptmalloc2在2.8GHz P4機器上執行一次malloc/free(分配釋放小的內存塊)大約耗時300納秒。相同的執行操作,TCMalloc的實現只需要大約50納秒。
TCMalloc同時也爲多線程編程減少了鎖的競爭,對於小塊內存分配,TCMalloc實際上沒有鎖開銷。對於大塊內存,TCMalloc使用非常高效的自旋鎖。Ptmalloc2通過給每個線程一個內存池來實現減少鎖競爭,但是ptmalloc2在使用每線程一個內存池時存在一個較大的問題。它的各個線程的內存池分配的內存不可以互相移動。這導致巨大的空間浪費
如何使用
TCMalloc使用起來非常簡單,只要在編譯鏈接程序是添加-ltcmalloc鏈接選項即可。
TCMalloc編譯後同時生產動態鏈接庫和靜態鏈接庫,應用程序默認是鏈接動態鏈接庫的,個人不喜歡動態鏈接,我將編譯後的動態鏈接庫移到另一個目錄,這樣應用程序鏈接時就鏈接靜態庫了。
TCMalloc同時包含了heap checker和heap profiler工具。
如果應用程序不想包含這兩個庫,那麼只鏈接-ltcmalloc_minimal即可。
TCMalloc總覽
TCMalloc的英文全稱是:Thead-Caching Malloc。顧名思義,TCMalloc給每個線程都分配一個局部緩存,採用線程局部數據技術。小塊內存的分配從線程局部緩存分配即可滿足。內存對象根據應用程序需要從中心堆棧移到線程局部緩存中,同時週期性的將線程緩存中過多的內存回收到中心堆棧。
TCMalloc對<=32K的內存與大內存塊的處理不同。TCMalloc使用頁級別(內存頁以4k字節對齊)的內存分配器直接從中心堆分配。即,大塊內存對象是頁對齊的,通常佔用整數倍個頁面。
一系列的內存頁可以被分割爲很多大小相同的小內存對象,例如,一個4k內存頁面可以被分割成32個128字節小內存對象。、
小內存對象的分配
每一個小內存對象都可映射在大約60個可分配的固定大小內存對象池中的一個。例如,所有分配833到1024字節大小的內存都將映射到1024。對象池根據大小按照8字節,16字節,32字節,等進行對齊。假設一個應用程序需要分配一個N+1個字節大小的內存對象,而N剛好是一個對齊對象的大小,那麼N+1個字節就要被映射到下一個對齊對象N+k,那麼k-1個字節就被浪費了。爲了防止這種過多的內存被浪費,最大的對齊間隔是被控制的。
每一個線程局部緩存對保存了每一個空閒對齊對象的單鏈表,如下圖:
小內存對象分配過程:
1) 將要分配的大小映射到對應的對齊對象。
2) 在當前線程的局部緩存中查找該對齊對象鏈表。
3) 如果該鏈表不爲空,刪除鏈表第一個節點並返回給調用者。
通過上述快速的過程,TCMalloc沒有請求任何鎖。無鎖機制顯著的提高了內存分配速度,因爲一對lock、unlock操作在2.8GHz的Xeon上大概會耗時100納秒。
如果空閒鏈表是空的:
1) 從中心堆鏈表取出一大串該對齊對象,中心堆棧被所有線程共享。
2) 把這些對象添加到線程局部緩存的鏈表中。
3) 返回一個新取到的對象。
如果中心堆棧鏈表也是空的:
1) 從中心頁面分配器中分配一系列頁面。
2) 將這些頁分割成該對齊對象的大小。
3) 將分割後的對齊對象添加到中心堆棧鏈表。
4) 如前所述,從中心堆棧的鏈表移動一些到線程局部緩存鏈表中去。
調整線程局部緩存鏈表的大小
調整線程局部緩存鏈表到合適的大小是非常重要的。如果鏈表太小,那麼就需要經常去中心共享的鏈表中取對象。如果局部緩存的鏈表太大,又浪費了太多的空閒內存。
需要注意的是線程局部緩存對釋放對象也同分配對象一樣重要。如果沒有線程局部緩存,每一次釋放內存對需要將內存對象移動到中心共享鏈表中去。同時一些線程的分配和釋放並不是對稱的(例如生產者和消費者線程),因此調整局部緩存鏈表的到一個合適的大小變得更加的複雜了。
爲了合適的調整局部緩存空閒鏈表的大小,TCMalloc使用了慢速啓動算法來決定各個線程的空閒鏈表最大長度。因爲空閒鏈表被頻繁的使用,它的最大長度就增大。然而,如果空閒鏈表被內存釋放使用多於內存分配使用,它的最大長度將只增長到一個點,在這個點的時候整個鏈表可以高效的被一次移動到中心共享鏈表中去。
下面這段僞代碼將說明慢速啓動算法。需要注意的是num_objects_to_move針對每一種對齊的內存對象。通過使用共識的長度移動空閒鏈表,中心共享緩存可以高效在這些線程緩存直接傳遞鏈表。如果一個線程緩存想要獲得比num_objects_to_move少的內存快個數,中心緩存上的操作只有線性複雜度。經常使用 num_objects_to_move作爲中心緩存鏈表傳入或傳出對象的個數在這些並不需要這麼多內存的線程中就會浪費內存。
Start each freelist max_length at 1. Allocation if freelist empty { fetch min(max_length, num_objects_to_move) from central list; if max_length < num_objects_to_move { // slow-start max_length++; } else { max_length += num_objects_to_move; } } Deallocation if length > max_length { // Don't try to release num_objects_to_move if we don't have that many. release min(max_length, num_objects_to_move) objects to central list if max_length < num_objects_to_move { // Slow-start up to num_objects_to_move. max_length++; } else if max_length > num_objects_to_move { // If we consistently go over max_length, shrink max_length. overages++; if overages > kMaxOverages { max_length -= num_objects_to_move; overages = 0; } } } |
參加垃圾回收章節描述它如何影響max_length。
大內存對象的分配
大對象(>32K)的大小被規整到頁面(4K)對齊並且被一箇中心頁面堆所處理。中心頁面堆是一個空閒列表數組。對於i<256,第k項就是一個由包含K個頁面的系列組成的空閒鏈表。第256項是由包含超過256個頁面系列組成的空閒列表。
分配k個頁面只需要在第k個空閒鏈表中查找即可滿足。如果該鏈表爲空,再下一個鏈表中查找,以此類推。如果前面都查找失敗,那麼就最後的鏈表中查找。如果依然失敗,我們就從系統中獲取內存(使用sbrk, mmap, 或者映射部分/dev/mem)
如果從長度大於k個連續頁面中分配k個頁面,那麼剩餘的系列頁面將被重新插入到中心堆棧中合適的空閒鏈表中去。
Spans
TCMalloc的堆管理是由一系列頁面組成。一系列連續的頁面被稱作爲一個Spans對象。一個Spans可以被分配了的也可以是空閒的。如果是空閒的,該Spans是堆棧鏈表中的某一項。如果是被分配的,它是一個已經被移交給應用程序的大對象,或者是已經被切割成一組小對象的系列頁面。如果被分割成小對象,這些小對象的大小會被記錄在spans中。
由頁面號索引的中心數組可以被用來查找一個頁面所屬的spans。例如,span a佔據2個頁面,span b佔據一個頁面,span c佔據5個頁面和span d佔據3個頁面。
在32位地址空間中,中心數組用一個2層的基數樹來表示,樹的根節點包含32項,每個葉子節點包含2^15項(一個32位的地址空間包含了2^20 個4k頁面,所以這裏的樹的第一層用2^5整除2^20個頁面)。這導致中心數組一開始就要使用128K內存(2^15*4 bytes),這看起來還可以接受。
在64位機器上就要使用三層的基數樹了。
內存釋放
當一個內存被釋放時,我們計算它的頁號並在中心數組中查找其對應的Span對象。該Span告訴我們該對象是不是小對象,如果是小對象還告訴我們它的對齊對象的尺寸。如果是小對象,我們就把它插入到當前線程對應的線程局部緩存中去。如果該線程局部緩存現在超過了預設置的大小(默認2MB),我們就執行垃圾回收將不使用的內存從線程局部緩存移到中心堆棧鏈表中去。
如果是大對象,span會告訴我們該對象覆蓋頁面的範圍。假設該範圍是[p,q]。同時我們也去查找page p-1和page q+1的span。如果這些相鄰的span也是空閒的,我們將他們和[p,q] span合併。合併的結果插入到中心堆棧合適的鏈表中去。
小對象的中心空閒鏈表
如前所述,我們爲每一種尺寸的對齊對象保存一箇中心空閒鏈表。每一箇中心空閒鏈表由一個兩層的數據結構組成:一個Spans集合,每個span有一個鏈表。
從中心空閒鏈表分配數據時直接返回一些span的鏈表第一個節點。(如果中心鏈表的所有spans均爲空的,那麼就從中心頁堆中分配合適大小的span)
當中心空閒鏈表回收對象時,把該對象添加到它對應的span的鏈表中去。如果此刻鏈表的長度等於span中所有小對象的個數,那麼該span就是完全空閒的了,它將被中心頁堆回收。
線程局部緩存的垃圾回收
從線程局部緩存進行對象的垃圾回收保證了緩存的大小可控並返回這些對象到中心空閒鏈表。一些線程需要很大的緩存,而另一些線程可能需要很小或者不需要緩存。當一個線程局部緩存的大小超過了它的max_size,垃圾回收開始執行,同時一些線程與另一些線程競爭更大的緩存。
垃圾回收只發生在內存釋放時。我們遍歷緩存中的空閒鏈表,並且移動一些對象到其對應的中心空閒鏈表。
從緩存的空閒鏈表移除的對象個數取決於每個鏈表的低閾值L。L記錄了從上次垃圾回收以來鏈表最小的長度。需要注意的是,我們可能在上一次垃圾回收中只是把空閒鏈表縮短了L個對象,而沒有對象中心空閒鏈表進行任何額外的訪問。我們使用過去的歷史來預測未來的訪問,並且將L/2個對象從局部緩存鏈表移動到其對應的中心空閒鏈表。該算法有一個非常好的特性,即當某個線程不再使用某一種大小的對齊對象時,該緩存中所有該對象將被迅速的移到中心空閒鏈表,這樣就可以被其他線程所使用。
如果一個線程連續釋放某大小的對齊對象的速度超過該對象分配的速度,這種L/2的行爲將導致始終有L/2個對象在空閒鏈表中。爲避免這種內存浪費,我們收縮鏈表的最大長度向num_objects_to_move集中靠攏。(參靠 調整線程局部緩存鏈表大小)
Garbage Collection if (L != 0 && max_length > num_objects_to_move) { max_length = max(max_length - num_objects_to_move, num_objects_to_move) } |
事實上如果線程局部緩存向超過它的max_size就表示該線程將要需要更大的緩存。簡單的增加max_size將使擁有大量活動線程的程序過度使用大量的內存。開發者可以使用flag --tcmalloc_max_total_thread_cache_bytes來限制內存。
每一個線程的起始max_size非常小(64k),因爲空閒線程不需要預分配內存,因爲他們不需要。每次緩存執行垃圾回收,它就會嘗試增大max_size。如果線程緩存中所有對齊對象的大小之和小於tcmalloc_max_total_thread_cache_bytes,max_size增長得很容易。如果不是,線程緩存1將通過減少線程緩存2的max_size來從緩存2偷取(循環獲取)。通過這種方法,比較活躍的線程往往比竊取自己內存更加頻繁的竊取其他線程的內存。通常空閒的線程止於小緩存,活躍的線程止於大緩存。需要注意的是,這種竊取將導致所有線程的緩存的大小大於--tcmalloc_max_total_thread_cache_bytes 直到線程緩存2釋放一些內存來觸發垃圾回收。