多線程的內存分配器mt_alloc

A fixed-size, multi-thread optimized allocator

原文URLhttp://list.cs.brown.edu/people/jwicks/libstdc++/html/ext/mt_allocator.html

簡介

mt allocator是一個固定大小(2的冪)內存的分配器,最初是爲多線程應用程序(以下簡稱爲MT程序)設計的。經過多年的改進,現在它在單線程應用程序(以下簡稱爲ST程序)裏也有出色的表現了。

本文的目的是從應用程序的角度描述mt allocator的“內幕”。  

總體設計

mt allocator3個組成部分:描述內存池特徵的參數,把內存池關聯到通用或專用方案的policy類,和從policy類繼承來的實際的內存分配器類。

描述內存池特徵的參數是:

template<bool _Thread>

     class __pool

這個類表示是否支持線程,然後對多線程(bool==true)和單線程(bool==false)情況進行顯式特化。可以用定製的參數來替代這個類。

對於policy類,至少有2種不同的風格,每種都可以和上面不同的內存池參數單獨搭配:第一個策略,__common_pool_policy,實現了一個通用內存池,即使分配的對象類型不同,比如charlong,也使用同一個的內存池。這是默認的策略。

template<bool _Thread>
    
struct __common_pool_policy

  template
<typename _Tp, bool _Thread>
    
struct __per_type_pool_policy

 第二個策略,__per_type_pool_policy,對每個對象類型都實現了一個單獨的內存池,於是charlong會使用不同的內存池。這樣可以對某些類型進行單獨調整。

把上面這些放到一起,我們就得到了實際的內存分配器:

template<typename _Tp, typename _Poolp = __default_policy>
    
class __mt_alloc : public __mt_alloc_base<_Tp>,  _Poolp

這個類有標準庫要求的接口,比如allocatedeallocate函數等。 

可調參數

有些配置參數可以修改,或調整。有一個嵌套類:包含了所有可調的參數,即:

struct __pool_base::_Tune

 

l  字節對齊

l  多少字節以上的內存直接用new分配

l  可分配的最小的內存塊大小

l  每次從OS申請的內存塊的大小

l  可支持的最多線程數目

l  單個線程能保存的空閒塊的百分比(超過的空閒塊會歸還給全局空閒鏈表)

l  是否直接使用newdelete

對這些參數的調整必須在任何內存分配動作之前,即內存分配器初始化的時候,比如: 

#include <ext/mt_allocator.h>

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(
1651203251202010false);
  tune_type t_single(
165120325120110false);

  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;
}

初始化

靜態變量(內存鏈表的指針,控制參數等)的初始化爲默認的值,比如: 

template<typename _Tp> size_t
__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_NEWSTMT程序都會:

1)計算bin的個數。bin是指2的指數字節的內存集合。默認情況下,mt allocator只處理128字節以內的小內存分配(或者通過在_S_init()裏設置_S_max_bytes來更改這個值),這樣就有如下幾個字節大小的bin1248163264128

2)創建_S_binmap數組。所有的內存申請都上調到2 的指數大小,所以29字節的內存申請會交給32字節的bin處理。_S_binmap數組的作用就是快速定位到合適的bin,比如數值29被定位到5bin 5 = 32字節)。

3)創建_S_bin數組。這個數組由bin_record組成,數組的長度就是前面計算的bin的個數,比如,當_S_max_bytes = 128時長度爲8

4)初始化每個bin_recordfirst<block_record *>數組,程序可以有多少個線程,這個數組就有多長(ST程序只有1個線程,MT程序最多允許_S_max_threads個線程)。first裏保存的是這個bin裏每個線程第一個空閒塊的地址,比如,我們要找線程332字節的空閒塊,只需調用:_S_bin[ 5 ].first[ 3 ]。開始的時候first數組元素全是NULL

對於MT程序,還要進行下面的工作:

5)創建一個空閒線程ID1_S_max_threads間的一個數值)的列表,列表的入口是_S_thread_freelist_first。由於__gthread_self()函數返回的不是我們需要的1_S_max_threads之間的數值,而是類似於進程ID的隨機數,所以我們需要創建一個thread_record鏈表,長度爲_S_max_threads,每個thread_record元素的thread_id字段依次初始化成123,直到_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=1thread_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->freesize_t 的數組,記錄每個線程空閒塊的個數。bin_record->used也是size_t 的數組,記錄每個線程正在使用的塊的個數。這些數組的元素初始值都是0

7)初始化每個bin_record的鎖。bin_record->mutex用來保護全局的空閒塊鏈表,每當有內存塊加入或拿出某個bin時,都要進行加鎖。這種情況只出現在線程需要從全局空閒鏈表裏獲取內存,或者把一些內存歸還給全局鏈表的時候。 

單線程模型(簡化的多線程模型)

我們從空閒塊鏈表的內存佈局開始。下面是bin 3裏線程號爲3的空閒鏈表的頭2個塊:

 ST程序裏,所有的操作都在全局內存池裏——即thread_id0MT程序裏任何線程都不會分配到這個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變量,確保freeused計數器的正確。這是很重要的,因爲它們決定了是否需要把內存歸還給全局內存池。

當程序申請內存(調用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 ]能知道是否有空閒的內存塊。如果有,則移出第一個塊,返回給用戶,別忘了更新usedfree計數器。

如果沒有,我們先從全局鏈表(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不一致的情況下該如何處理)。隨後freeused計數器就要發揮作用了,即空閒鏈表的長度(free)和當前線程正在使用的塊的個數(used)。

讓我們回想前面一個線程專門負責分配內存的程序模型。假設開始時每個線程使用了51232字節的塊,那麼他們的used計數器此時都是516。負責分配內存的線程接着又得到了100032字節的塊,那麼此時它的used計數器是1516

如果某個線程釋放了500個塊,每次釋放操作都會導致used計數器遞減,和該線程的空閒鏈表(free)越來越長。不過deallocate()會把free控制在used_S_freelist_headroom%以內(默認是10%),於是當free超過52516 / 10)時,釋放的空閒塊會歸還給全局空閒鏈表,從而負載分配的線程就能重用它們。

爲了減少鎖競爭(這種歸還操作需要對bin進行加鎖),歸還操作是以block_count個塊爲單位進行的(和從全局空閒鏈表裏獲得塊一樣)。這個“規則”還可以改進,減少某些塊“來回轉移”的機率。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章