STL——模擬實現空間配置器
問題
我們在日常編寫C++程式時,常常會用到我們的STL標準庫來幫助我們解決問題,這當中我們用得最多估計就是它裡面的vector、list容器了,它們帶來的便利不用多說(畢竟OJ、刷題什麼的,基本全是它們的身影),而在日常學習中我們對STL中另一大元件—空間配置器 瞭解可能就相對較少了。不過它也是個有用的東西,之所以這麼說,主要就在於它解決了在記憶體分配過程中出現的記憶體碎片問題,具體就是
如上,對於一塊從堆上分配的記憶體,由於對該塊記憶體的釋放通常是不確定的,因為取決於使用者,對於剛釋放完的那32位元組,雖歸還給了os,但由於中間都是碎片化的記憶體,所以此時想利用那32位元組再從os申請20位元組記憶體便無法完成。
而在多執行緒環境下,這種記憶體碎片問題帶來的影響就更大了,多個執行緒頻繁的進行記憶體申請和釋放,同時申請、釋放的記憶體塊有大有小;程式執行過程當中這些碎片的記憶體就有可能間接造成記憶體浪費,再一個os要對這樣頻繁的操作管理,勢必會影響到它的效率。
SGI版本空間配置器—std::alloc
STL中配置器總是隱藏在一切組間(具體說是,container)的背後,默默工作。但站在STL實現角度看,我們第一個需要搞清楚的就是空間配置器,因為我們操作所有STL物件基本都會存放容器當中,而容器一定需要配置空間來置放資料的,不弄清它的原理,定會影響以後對STL的深入學習。
而在SGI STL中,std::alloc 為預設的空間配置器:vector<int, std::alloc> iv
是的,它的寫法好像並不是標準的寫法(標準寫法應該是allocator),而且它也不接受引數,但這並不會給我們帶來困擾,因為它是預設的,很少需要我們自行指定配置器名稱。(至於為什麼不用allocator這個更標準的寫法,這源於它的效率問題。具體可以參考STL原始碼剖析
配置器要完成的其實就是物件構造前的空間配置和物件析構後的空間釋放。參考SGI中做法配置器對此設計要考慮:
- 向系統堆空間獲取空間
- 考慮多執行緒狀態
- 考慮記憶體不足時的應對措施
- 考慮過多“小型區塊” 可能帶來的記憶體碎片問題
基於此,alloc實現中設計了雙層級配置器模型。一級配置器直接使用malloc和free,二級配置器則視情況採取不同的策略,具體來講就是:當需求的記憶體塊超過128位元組時,就將其視為大塊記憶體需求,便直接呼叫一級配置器來分配;當需要記憶體塊< 128位元組,便交由二級配置器來管理(這當中可能還聯合一級配置器一起使用,具體原因在後面)。
一級空間配置器
首先,一級配置器STL預設名通常是__malloc_alloc_template<0>
.在STL實現中將它typedef為了alloc。然後 要注意的是源於__USE_MALLOC
通常未定義,它在STL中並不是預設的配置器。
一級配置器模擬實現:
#pragma once
#include <iostream>
#include <windows.h>
using namespace std;
//一級空間配置器
typedef void(*HANDLE_FUNC)();
template <int inst> // inst為預留引數,方便以後擴充套件
class __MallocAllocTemplate
{
private:
/*定義函式指標型別成員,方便回撥執行使用者
自定義的記憶體釋放函式,該成員預設設定不執行*/
static HANDLE_FUNC __malloc_alloc_oom_handler;
static void* OOM_Malloc(size_t n){
while (1){
if (0 == __malloc_alloc_oom_handler){
throw bad_alloc();
}else{
__malloc_alloc_oom_handler(); //釋放記憶體
Sleep(200);
void* ret = malloc(n);
if (ret)
return ret;
}
}
}
public:
static void* Allocate(size_t n){
void *result = malloc(n);
//malloc申請失敗,執行OOM_Malloc再請求申請記憶體
if (0 == result)
result = OOM_Malloc(n);
cout<<"申請成功!"<<endl;
return result;
}
static void Deallocate(void *p, size_t /* n */){
free(p);
}
/*設定oom_malloc控制代碼函式,*/
static HANDLE_FUNC SetMallocHandler(HANDLE_FUNC f){
HANDLE_FUNC old = f;
__malloc_alloc_oom_handler = f;
return old;
}
};
template<int inst>
HANDLE_FUNC __MallocAllocTemplate<inst>::__malloc_alloc_oom_handler = 0;
//自定義的記憶體釋放函式
static void FreeMemory(){
cout<<"執行使用者自定義函式,開始釋放記憶體..."<<endl;
}
void Test_Alloc1();
void Test_Alloc2();
關於一級配置器實現中. 注意兩個地方:
當中的記憶體分配Allocate和釋放Dellocate都是簡單封裝malloc和free,同時該類的成員函式中都是用static修飾的靜態成員函式
- 之所以設定為靜態成員函式,就是想在類外部可以直接呼叫,而不用去建立物件。注意配置器面向的單位其實是程序。在一個程序中可能存在不同的容器,它們都會向空間配置器要記憶體,所以將配置器介面置為通用的。但在C++中又注重程式的封裝性,所以便又將它們用class進行了一層包裝。
實現了一個static void* OOM_Malloc(size_t ) 函式 。這通常是在一次malloc呼叫失敗後,再去呼叫它來丟擲bad_alloc異常。但這裡設計考慮它的擴充套件性。
- 一級配置器類中聲明瞭一個函式指標型別成員“**__malloc_alloc_oom_handler” 如果使用者自己有幫助os得到空間加以分配freeMemory方法,就可以通過該成員 ,讓OOM_malloc**中回撥你的freeMemor函式進而幫助os獲得記憶體,使得malloc分配成功。
- 可以通過static HANDLE_FUNC SetMallocHandler(HANDLE_FUNC f)來進行設定該__malloc_alloc_oom_handler成員
- 這一般是自己設計的一種策略。設計這個函式就是一個提升空間配置器效率的一個方法,因為要保證malloc儘可能的成功。這一般是大佬去玩兒的。我們這還是乖乖把控制代碼函式初始化為0,使用預設的方式吧。
實現完了一級配置器,可是我們從前面不難發現,這個單純封裝malloc、free的一級配置器效率並不高,所以STL真正具有設計哲學是下面的二級配置器。
二級空間配置器
首先當呼叫方需求的記憶體小於128位元組時,此時便要利用二級配置器來分配記憶體了,當然不僅僅如此,這個二級配置器還要進行記憶體回收工作。整個空間配置器正是因為它才能達到真正的迅速分配記憶體。至於緣由則還要從它的組成結構開始說起
它的組成結構有兩個:
- 一個記憶體池(一大塊記憶體)
- 一組自由連結串列(freelist)
注意到有兩個指標startFree、endfree,它們就相當於水位線的一種東西,它表示了記憶體池的大小。
自由連結串列中其實是一個大小為16的指標陣列,間隔為8的倍數。各自管理大小分別為8,16,24 . . . 120,128 位元組的小額區塊。在每個下標下掛著一個連結串列,把同樣大小的記憶體塊連結在一起。(這貌似就是雜湊桶吧!)
分配記憶體過程:
首先,當我們的容器向配置器申請<128小塊記憶體時,先就要從對應的連結串列中取得一塊。具體就是:拿著申請記憶體大小進行近似除8的方法算得在這個指標陣列中下標,緊接著就可以從連結串列中取出第一塊記憶體返回。當一塊記憶體用完,使用者釋放時,進行同樣的操作,接著計算對於的下標再將該塊記憶體頭插到對應連結串列中。
(當然實際計算這些對應下標時,採用兩個更準確、高效的函式,見後面,這裡只是簡單分析)
看看連結串列結點結構和連結
二級配置器中有一個這樣結構
union Obj{
union Obj* _freelistlink;
char client_data[1]; /* The client sees this. 用來除錯用的*/
};
- 注意到這是一個聯合體, 這個結構起的作用就是一塊記憶體塊空閒時,就在一個記憶體塊中摳出4個位元組大小來,然後強制這個obj以此來連結到下一個空閒塊,當這個記憶體塊交付給使用者時,它就直接儲存使用者的資料。obj* 是4個位元組那麼大,但是大部分記憶體塊大於4。我們想要做的只是將一塊塊記憶體區塊連結起來,我們不用看到記憶體裡所有的東西,所以我們可以只用強轉為obj*就可以實現大記憶體塊的連結。
- 再一個就是自由連結串列中的不同下標下區塊都是以8為單位往上增的,並且最小得為8位元組 。理由很簡單,因為我們還要考慮在64位機子的環境。因為每一個區塊至少要存下一個obj*,這樣才能把小區塊連線起來。
- 也正是源於上面這樣的原因。若我們僅僅需求5位元組記憶體,就造成3位元組浪費;所以我們的這個二級配置器引入了另一個問題——內碎片問題(前面我們配合自由連結串列解決的只是os分配記憶體外碎片問題)。對於連結起來的小區塊,我們同樣不能對它百分百的利用,畢竟萬事終難全嘛。
好了,我們到這討論的還處在一個大前提上——freelist下面掛有連結起來的小區塊。當freelist上的某個位置下面沒有掛上這些小區塊呢?所以,這就是下面Refill,chunkAlloc這兩個函式要乾的事情了。
二級配置器相關介面:
#pragma once
#include "Allocator.h"
///////////////////////////////////////////////////////////////////////
//二級空間配置器
template <bool threads, int inst>
class __DefaultAllocTemplate
{
public:
// 65 72 -> index=8
// 72 79
static size_t FREELIST_INDEX(size_t n){
return ((n + __ALIGN-1)/__ALIGN - 1);
}
// 65 72 -> 72
// 72 79
static size_t ROUND_UP(size_t bytes) {
return (((bytes) + __ALIGN-1) & ~(__ALIGN - 1));
}
static void* ChunkAlloc(size_t size, size_t& nobjs);//獲取大塊記憶體
static void* Refill(size_t bytes); //填充自由連結串列
static void* Allocate(size_t n); //分配返回小記憶體塊
static void Deallocate(void* p, size_t n); //管理回收記憶體
private:
enum {__ALIGN = 8 };
enum {__MAX_BYTES = 128 };
enum {__NFREELISTS = __MAX_BYTES/__ALIGN };
union Obj{
union Obj* _freelistlink;
char client_data[1]; /* The client sees this. 用來除錯用的*/
};
// 自由連結串列
static Obj* _freelist[__NFREELISTS];
// 記憶體池
static char* _startfree;
static char* _endfree;
static size_t _heapsize;
};
//__DefaultAllocTemplate成員初始化
template <bool threads, int inst>
typename __DefaultAllocTemplate<threads, inst>::Obj*
__DefaultAllocTemplate<threads, inst>::_freelist[__NFREELISTS] = {0};
// 記憶體池
template <bool threads, int inst>
char* __DefaultAllocTemplate<threads, inst>::_startfree = NULL;
template <bool threads, int inst>
char* __DefaultAllocTemplate<threads, inst>::_endfree = NULL;
template <bool threads, int inst>
size_t __DefaultAllocTemplate<threads, inst>::_heapsize = 0;
Refill、chunkAlloc函式
前面說了,當我們需求的記憶體塊在所對自由連結串列的下標處沒掛有記憶體塊時,我們就必須呼叫refill去填充自由連結串列了。申請時一般一次性申請20個記憶體塊大小的記憶體(可參加STL實現原始碼)。
那又從那裡找呢?——當然記憶體池啦!分配這麼大塊記憶體到二級配置器就是現在來用的。可以通過移動startFree指標快速地從記憶體池內給“切割”出來這一段記憶體,然後按照大小切成小塊掛在自由連結串列下面。在這個過程中可以直接將第一小塊記憶體塊返回給使用者,其餘的再掛在自由連結串列下,方便下次分配了。
基於這樣思路就可以將refill實現如下:
void* __DefaultAllocTemplate<threads, inst>::Refill(size_t bytes)
{
size_t nobjs = 20; /*預設從記憶體池取20塊物件,填充*/
//從記憶體池中拿到一大塊記憶體
char* chunk = (char*)ChunkAlloc(bytes, nobjs);
if (nobjs == 1) /*只取到了一塊*/
return chunk;
size_t index = FREELIST_INDEX(bytes);
printf("返回一個物件,將剩餘%u個物件掛到freelist[%u]下面\n", nobjs-1, index);
Obj* cur = (Obj*)(chunk + bytes);
_freelist[index] = cur;
for (size_t i = 0; i < nobjs-2; ++i){
Obj* next = (Obj*)((char*)cur + bytes);
cur->_freelistlink = next;
cur = next;
}
cur->_freelistlink = NULL;
return chunk;
}
注:chunkAlloc向記憶體池索要記憶體
考慮一個問題
到此,我們好像就會有一個疑問。既然簡單移動startfree就可以歡快的從記憶體池取到得一塊記憶體返回,那為什麼又要一次性取20塊,返回一塊,將剩下那19塊掛到freelist對應位置下面呢?挨個掛上去還這麼麻煩!每次都直接從記憶體池返回一塊記憶體不是更歡快嗎?在這裡當然不用擔心出現外碎片問題。因為在每次記憶體釋放時,可以新增到我們維護的自由連結串列上,繼續下次分配。
- 而在這裡,其實是考慮了高併發的情況:這種的併發情況下,當從記憶體池取的一塊需要的記憶體,無疑會有多個執行緒同時來操作,startfree執行加法返回一塊記憶體也不是原子操作,所以在此必然就會涉及加鎖解鎖,同時這些執行緒取得記憶體塊大小也不統一,所有這麼多的執行緒必然會因為這裡的鎖而影響執行速度,影響效率。
- 一次性取上20塊就能緩解這種狀況,當多個執行緒要取的記憶體塊不一樣時,此時便不會鎖住,因為是從不同連結串列上取;此時,鎖只會鎖在多個執行緒從同一個連結串列上取一塊相同大小記憶體上。
- 雖然從記憶體池取一段記憶體操作也涉及著加鎖,但由於呼叫Refill填充自由連結串列次數相對會少很多,所以上面這樣一次性取20塊做法是可以提高高併發下程式執行效率。
接下來就是chuncAlloc函式
它表示從記憶體池那一大塊記憶體,同時也儘可能保證記憶體池像水池一樣有時刻有“水”。具體它遵循4條方針: - 記憶體池記憶體夠多,直接“大方的”返回
- 記憶體池記憶體有些吃緊了,儘量返回呼叫方需求的記憶體
- 記憶體池“窮得吃土”了,需要求助os來malloc來為它補充“源頭活水”
- os也“吃土”了,記憶體池“靈機一動”,打上了後面自由連結串列的主意。
- 都一無所獲,記憶體池最後一搏,呼叫一級配置器
到最後一級配置器基於它的out-of-memory處理機制,或許有機會釋放去其它的記憶體,然後拿來此處使用。如果可以那就成功“幫助”記憶體池,否則便發出bad_alloc異常通知使用者。
基於這樣的思路,便可以模擬實現出ChunkAlloc函式
//function:從記憶體池申請一大塊記憶體
template <bool threads, int inst>
void* __DefaultAllocTemplate<threads, inst>::ChunkAlloc(size_t size, size_t& nobjs)
{
size_t totalbytes = nobjs*size;
size_t leftbytes = _endfree - _startfree;
//a) 記憶體池中有足夠記憶體
if (leftbytes >= totalbytes){
printf("記憶體池有足夠%u個物件的記憶體塊\n", nobjs);
void* ret = _startfree;
_startfree += totalbytes;
return ret;
//b) 記憶體池僅剩部分物件記憶體塊
}else if (leftbytes > size){
nobjs = leftbytes/size; /*儲存能夠使用物件塊數*/
totalbytes = size*nobjs;
printf("記憶體池只有%u個物件的記憶體塊\n", nobjs);
void* ret = _startfree;
_startfree += totalbytes;
return ret;
//c) 記憶體池中剩餘記憶體不足一個物件塊大小
}else{
// 1.先處理掉記憶體池剩餘的小塊記憶體,將其頭插到對應自由連結串列上
if(leftbytes > 0){
size_t index = FREELIST_INDEX(leftbytes);
((Obj*)_startfree)->_freelistlink = _freelist[index];
_freelist[index] = (Obj*)_startfree;
}
// 2.呼叫malloc申請更大的一塊記憶體放入記憶體池
size_t bytesToGet = totalbytes*2 + ROUND_UP(_heapsize>>4);
_startfree = (char*)malloc(bytesToGet);
printf("記憶體池沒有記憶體,到系統申請%ubytes\n", bytesToGet);
if (_startfree == NULL){
//3. malloc申請記憶體失敗,記憶體池沒有記憶體補給,到更大的自由連結串列中找
size_t index = FREELIST_INDEX(size);
for (; index < __NFREELISTS; ++index){
//自由連結串列拿出一塊放到記憶體池
if (_freelist[index]){
_startfree = (char*)_freelist[index]; //BUG ??
Obj* obj = _freelist[index];
_freelist[index] = obj->_freelistlink;
return ChunkAlloc(size, nobjs);
}
}
_endfree = NULL; /*in case of exception. !!保證異常安全*/
//逼上梁山,最後一搏. 若記憶體實在吃緊,則一級配置器看看out-of-memory能否盡點力,不行就拋異常通知使用者
_startfree = (char*)__MallocAllocTemplate<0>::Allocate(bytesToGet);
}
_heapsize += bytesToGet;
_endfree = _startfree + bytesToGet;
//遞迴呼叫自己,為了修正nobjs
return ChunkAlloc(size, nobjs);
}
}
這裡也還要注意一個點:就是_endfree= NULL
這樣一個操作
-
這句話很容易被我們忽略掉。這其實是十分重要的一個操作,這關乎到異常安全問題,在記憶體池窮山盡水之時,它取呼叫了一級配置器,希望一級配置器能否釋放一些記憶體,在chunkAlloc內可以malloc成功,但通常這都是失敗的,所以一級配置器便丟擲了異常,然而異常丟擲並不意味著程式結束,此時的endfree並不為NULL並且可能是較大的數,(endfree保持以前的值)此時的startfree指標是為NULL的。這兩者的差值表示著記憶體池有著大塊的記憶體,然而這已不屬於記憶體池了。
整理一下配置器分配的流程
最後,配置器封裝的simple_alloc介面
無論alloc被定義為第一級或第二級配置器,SGI還為它包裝了一個介面Simple_alloc,使配置器介面符合STL規格:
#ifdef __USE_MALLOC
typedef __MallocAllocTemplate<0> alloc;
#else
typedef __DefaultAllocTemplate<false, 0> alloc;
#endif
template<class T, class Alloc>
class SimpleAlloc
{
public:
static T* Allocate(size_t n){
return 0 == n? 0 : (T*) Alloc::Allocate(n * sizeof (T));
}
static T* Allocate(void){
return (T*) Alloc::Allocate(sizeof (T));
}
static void Deallocate(T *p, size_t n){
if (0 != n)
Alloc::Deallocate(p, n * sizeof (T));
}
static void Deallocate(T *p){
Alloc::Deallocate(p, sizeof (T));
}
};
這裡面內部四個成員函式其實都是單純的轉呼叫,呼叫傳遞給配置器的成員函式,這個介面時配置器的配置單位從bytes轉為了個別元素的大小。SGI STL中容器全部使用simple_alloc介面,例如
template< class T, class Alloc= alloc>
class vector{
protected:
//專屬空間配置器,每次配置一個元素大小
typedef simple_alloc<value_type, Alloc> data_allocator;
void deallocate(){
if(...)
data_allocator::deallocate(start, end_of_storage- start);
}
...
};
為了將問題控制在一定複雜度內,到此以上的這些,僅僅處理了單執行緒的情況。對於併發的情況,它的處理過程會相對更復雜。我們可以檢視STL中空間配置器的原始碼實現來進一步的學習,這當中又會體現出很多優秀的思想,
- 例如,在對chunk_alloc的操作加鎖時,就採用了類似“智慧指標”的機理。因為在多執行緒的情況下,在chunk_alloc分配記憶體時,可能會因為某個執行緒因異常終止而沒有進行解鎖的操作,進而使得其他執行緒阻塞,造成死鎖問題,影響程式的執行。
STL中在這裡加鎖,用的是一個封裝lock類物件,當這個物件出了作用域就會自動析構,實現解鎖操作,保證了執行緒安全問題。 而這就是RAII(資源獲得即初始化)思想的一種具體體現。
STL配置器還有許多其它優秀設計,這裡只是本人對它的部分認識。為了加深理解,我們可以檢視STL中原始碼進行更深入學習。