A fixed-size, multi-thread optimized allocator
原文URL:http://list.cs.brown.edu/people/jwicks/libstdc++/html/ext/mt_allocator.html
簡介
mt allocator是一個固定大小(2的冪)內存的分配器,最初是爲多線程應用程序(以下簡稱爲MT程序)設計的。經過多年的改進,現在它在單線程應用程序(以下簡稱爲ST程序)裏也有出色的表現了。
本文的目的是從應用程序的角度描述mt allocator的“內幕”。
總體設計
mt allocator有3個組成部分:描述內存池特徵的參數,把內存池關聯到通用或專用方案的policy類,和從policy類繼承來的實際的內存分配器類。
描述內存池特徵的參數是:
template<bool _Thread>
class __pool
這個類表示是否支持線程,然後對多線程(bool==true)和單線程(bool==false)情況進行顯式特化。可以用定製的參數來替代這個類。
對於policy類,至少有2種不同的風格,每種都可以和上面不同的內存池參數單獨搭配:第一個策略,__common_pool_policy,實現了一個通用內存池,即使分配的對象類型不同,比如char和long,也使用同一個的內存池。這是默認的策略。
struct __common_pool_policy
template<typename _Tp, bool _Thread>
struct __per_type_pool_policy
第二個策略,__per_type_pool_policy,對每個對象類型都實現了一個單獨的內存池,於是char和long會使用不同的內存池。這樣可以對某些類型進行單獨調整。
把上面這些放到一起,我們就得到了實際的內存分配器:
class __mt_alloc : public __mt_alloc_base<_Tp>, _Poolp
這個類有標準庫要求的接口,比如allocate和deallocate函數等。
可調參數
有些配置參數可以修改,或調整。有一個嵌套類:包含了所有可調的參數,即:
l 字節對齊
l 多少字節以上的內存直接用new分配
l 可分配的最小的內存塊大小
l 每次從OS申請的內存塊的大小
l 可支持的最多線程數目
l 單個線程能保存的空閒塊的百分比(超過的空閒塊會歸還給全局空閒鏈表)
l 是否直接使用new和delete
對這些參數的調整必須在任何內存分配動作之前,即內存分配器初始化的時候,比如:
struct pod
{
int i;
int j;
};
int main()
{
typedef pod value_type;
typedef __gnu_cxx::__mt_alloc<value_type> allocator_type;
typedef __gnu_cxx::__pool_base::_Tune tune_type;
tune_type t_default;
tune_type t_opt(16, 5120, 32, 5120, 20, 10, false);
tune_type t_single(16, 5120, 32, 5120, 1, 10, false);
tune_type t;
t = allocator_type::_M_get_options();
allocator_type::_M_set_options(t_opt);
t = allocator_type::_M_get_options();
allocator_type a;
allocator_type::pointer p1 = a.allocate(128);
allocator_type::pointer p2 = a.allocate(5128);
a.deallocate(p1, 128);
a.deallocate(p2, 5128);
return 0;
}
初始化
靜態變量(內存鏈表的指針,控制參數等)的初始化爲默認的值,比如:
__mt_alloc<_Tp>::_S_freelist_headroom = 10;
首次調用allocate()時,也會調用_S_init()函數。爲了保證在MT程序裏它只被調用一次,我們使用了__gthread_once(參數是_S_once_mt和_S_init)函數;在ST程序裏則檢查靜態bool變量_S_initialized。
_S_init()函數:如果設置了GLIBCXX_FORCE_NEW環境變量,它會把_S_force_new設置成true,這樣allocate()就直接用new來申請內存,deallocate()用delete來釋放內存。
如果沒有設置GLIBCXX_FORCE_NEW,ST和MT程序都會:
1)計算bin的個數。bin是指2的指數字節的內存集合。默認情況下,mt allocator只處理128字節以內的小內存分配(或者通過在_S_init()裏設置_S_max_bytes來更改這個值),這樣就有如下幾個字節大小的bin:1,2,4,8,16,32,64,128。
2)創建_S_binmap數組。所有的內存申請都上調到2 的指數大小,所以29字節的內存申請會交給32字節的bin處理。_S_binmap數組的作用就是快速定位到合適的bin,比如數值29被定位到5(bin 5 = 32字節)。
3)創建_S_bin數組。這個數組由bin_record組成,數組的長度就是前面計算的bin的個數,比如,當_S_max_bytes = 128時長度爲8。
4)初始化每個bin_record:first是<block_record *>數組,程序可以有多少個線程,這個數組就有多長(ST程序只有1個線程,MT程序最多允許_S_max_threads個線程)。first裏保存的是這個bin裏每個線程第一個空閒塊的地址,比如,我們要找線程3裏32字節的空閒塊,只需調用:_S_bin[ 5 ].first[ 3 ]。開始的時候first數組元素全是NULL。
對於MT程序,還要進行下面的工作:
5)創建一個空閒線程ID(1到_S_max_threads間的一個數值)的列表,列表的入口是_S_thread_freelist_first。由於__gthread_self()函數返回的不是我們需要的1到_S_max_threads之間的數值,而是類似於進程ID的隨機數,所以我們需要創建一個thread_record鏈表,長度爲_S_max_threads,每個thread_record元素的thread_id字段依次初始化成1,2,3,直到_S_max_threads,作爲4)步中first的索引。當一個線程調用allocate()或deallocate()時,我們會調用_S_get_thread_id(),檢查線程本地存儲的變量_S_thread_key的值。如果是NULL則表示是新創建的線程,那麼從_S_thread_freelist_first列表裏拿出一個元素給該線程。下次調用_S_get_thread_id()時就會找到這個對象,並且找到thread_id字段和對應的bin位置。所以,首先調用allocate()的線程會分配到thread_id=1的thread_record,於是它的bin索引就是1,我們可以用_S_bin[ 5 ].first[ 1 ]來爲它獲取32字節的空閒內存。當創建_S_thread_key時我們定製了析構函數,這樣當線程退出後,它的thread_record會歸還給_S_thread_freelist_first,以便重複使用。_S_thread_freelist_first鏈表有鎖保護,在增、刪元素的時候加鎖。
6)初始化每個bin_record 的空閒和使用的塊數計數器。bin_record->free是size_t 的數組,記錄每個線程空閒塊的個數。bin_record->used也是size_t 的數組,記錄每個線程正在使用的塊的個數。這些數組的元素初始值都是0。
7)初始化每個bin_record的鎖。bin_record->mutex用來保護全局的空閒塊鏈表,每當有內存塊加入或拿出某個bin時,都要進行加鎖。這種情況只出現在線程需要從全局空閒鏈表裏獲取內存,或者把一些內存歸還給全局鏈表的時候。
單線程模型(簡化的多線程模型)
我們從空閒塊鏈表的內存佈局開始。下面是bin 3裏線程號爲3的空閒鏈表的頭2個塊:
在ST程序裏,所有的操作都在全局內存池裏——即thread_id爲0(MT程序裏任何線程都不會分配到這個id)。
當程序申請內存(調用allocate()),我們首先看申請的內存大小是否大於_S_max_bytes,如果是則直接用new。
否則通過_S_binmap找出合適的bin。查看一下_S_bin[ bin ].first[ 0 ]就能知道是否有空閒的塊。如果有,那麼直接把塊移出_S_bin[ bin ].first[ 0 ],返回數據的地址。
如果沒有空閒塊,就需要從系統申請內存,然後建立空閒塊鏈表。已知block_record的大小和當前bin管理的塊的大小,我們算出申請的內存能分出多少個塊,然後建立鏈表,並把第一個塊的數據返回給用戶。
內存釋放的過程同樣簡單。先把指針轉換回block_record指針,根據內存大小找到合適的bin,然後把塊加到空閒列表的前面。
通過一系列的性能測試,我們發現“加到空閒列表前面”比加到後面有10%的性能提升。
多線程模型
在ST程序裏從來用不到thread_id變量,那麼現在我們從它的作用開始介紹。
向共享容器申請或釋放內存的MT程序有“所有權”的概念,但有一個問題就是線程只把空閒內存返回到自己的空閒塊鏈表裏。(比如一個線程專門進行內存的申請,然後轉交給其他線程使用,那麼其他線程的空閒鏈表會越來越長,最終導致內存用盡)
每當一個塊從全局鏈表(沒有所有權)移到某個線程的空閒鏈表時,都會設置thread_id。其他需要設置thread_id的情況還包括直接從空白內存上建立某個線程的空閒塊鏈表,和釋放某個塊刪除時,發現申請塊的線程id和執行釋放操作的線程id不同的時候。
那麼到底thread_id有什麼用呢?當釋放塊時,我們比較塊的thread_id和當前線程的thread_id是否一致,然後遞減生成這個塊的線程的used變量,確保free和used計數器的正確。這是很重要的,因爲它們決定了是否需要把內存歸還給全局內存池。
當程序申請內存(調用allocate()),我們首先看申請的內存大小是否大於_S_max_bytes,如果是則直接用new。
否則通過_S_binmap找出合適的bin。_S_get_thread_id()返回當前線程的thread_id,如果這是第一次調用allocate(),線程會得到一個新的thread_id,保存在_S_thread_key裏。
查看_S_bin[ bin ].first[ thread_id ]能知道是否有空閒的內存塊。如果有,則移出第一個塊,返回給用戶,別忘了更新used和free計數器。
如果沒有,我們先從全局鏈表(freelist (0))裏尋找。如果找到了,那麼把當前bin鎖住,然後從全局空閒鏈表裏移出最多block_count(從OS申請的一個內存塊能生成多少個當前bin的塊)個塊到當前線程的空閒鏈表裏,改變它們的所有權,更新計數器和指針。接着把bin解鎖,把_S_bin[ bin ].first[ thread_id ]裏第一個塊返回給用戶。
最多隻移動block_count個塊的原因是,降低後續釋放塊請求可能導致的歸還操作(通過_S_freelist_headroom來計算,後面詳述)。
如果在全局鏈表裏也沒有空閒塊了,那麼我們需要從OS申請內存。這和ST程序的做法一樣,只有一點注意區別:從新申請的內存塊(大小爲_S_chunk_size字節)上建立起來的空閒鏈表直接交給當前進程,而不是加入全局空閒鏈表。
釋放內存塊的基本操作很簡單:把內存塊直接加到當前線程的空閒鏈表裏,更新計數器和指針(前面說過如果當前線程的id和塊的thread id不一致的情況下該如何處理)。隨後free和used計數器就要發揮作用了,即空閒鏈表的長度(free)和當前線程正在使用的塊的個數(used)。
讓我們回想前面一個線程專門負責分配內存的程序模型。假設開始時每個線程使用了512個32字節的塊,那麼他們的used計數器此時都是516。負責分配內存的線程接着又得到了1000個32字節的塊,那麼此時它的used計數器是1516。
如果某個線程釋放了500個塊,每次釋放操作都會導致used計數器遞減,和該線程的空閒鏈表(free)越來越長。不過deallocate()會把free控制在used的_S_freelist_headroom%以內(默認是10%),於是當free超過52(516 / 10)時,釋放的空閒塊會歸還給全局空閒鏈表,從而負載分配的線程就能重用它們。
爲了減少鎖競爭(這種歸還操作需要對bin進行加鎖),歸還操作是以block_count個塊爲單位進行的(和從全局空閒鏈表裏獲得塊一樣)。這個“規則”還可以改進,減少某些塊“來回轉移”的機率。