STL——模擬實現空間配置器

問題

咱們在平常編寫C++程序時,經常會用到咱們的STL標準庫來幫助咱們解決問題,這當中咱們用得最多估計就是它裏面的vector、list容器了,它們帶來的便利不用多說(畢竟OJ、刷題什麼的,基本全是它們的身影),而在平常學習中咱們對STL中另外一大組件—空間配置器 瞭解可能就相對較少了。不過它也是個有用的東西,之因此這麼說,主要就在於它解決了在內存分配過程當中出現的內存碎片問題,具體就是
git

如上,對於一塊從堆上分配的內存,因爲對該塊內存的釋放一般是不肯定的,由於取決於用戶,對於剛釋放完的那32字節,雖歸還給了os,但因爲中間都是碎片化的內存,因此此時想利用那32字節再從os申請20字節內存便沒法完成。
而在多線程環境下,這種內存碎片問題帶來的影響就更大了,多個線程頻繁的進行內存申請和釋放,同時申請、釋放的內存塊有大有小;程序執行過程中這些碎片的內存就有可能間接形成內存浪費,再一個os要對這樣頻繁的操做管理,勢必會影響到它的效率。github


SGI版本空間配置器—std::alloc

STL中配置器老是隱藏在一切組間(具體地說是container)的背後,默默工做。但站在STL實現角度來看,咱們第一個須要搞清楚的就是空間配置器,由於咱們操做全部STL對象基本都會存放容器當中,而容器必定須要配置空間來置放資料的,不弄清它的原理,一定會影響之後對STL的深刻學習。
而在SGI STL中,std::alloc 爲默認的空間配置器:
例如,vector<int, std::alloc> v
是的,它的寫法好像並非標準的寫法(標準寫法應該是allocator),並且它也不接受參數!但這並不會給咱們帶來困擾,由於它是默認的,不多須要咱們自行指定配置器名稱。(至於爲何不用allocator這個更標準的寫法,這源於它的效率問題。具體能夠參考STL源碼剖析),今天主要來看看alloc版本配置器實現原理,加深咱們關於空間分配的理解。


配置器要完成的其實就是對象構造前的空間配置和對象析構後的空間釋放。參考SGI中作法配置器對此設計要考慮:windows

  • 向系統堆空間獲取空間
  • 考慮多線程狀態
  • 考慮內存不足時的應對措施
  • 考慮過多「小型區塊」 可能帶來的內存碎片問題

基於此,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上的某個位置下面沒有掛上這些小區塊呢?因此,這就是下面RefillchunkAlloc這兩個函數要乾的事情了。

二級配置器相關接口:

#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對應位置下面呢?挨個掛上去還這麼麻煩!每次都直接從內存池返回一塊內存不是更歡快嗎?在這裏固然不用擔憂出現外碎片問題。由於在每次內存釋放時,能夠添加到咱們維護的自由鏈表上,繼續下次分配。

  1. 而在這裏,實際上是考慮了高併發的狀況:這種的併發狀況下,當從內存池取的一塊須要的內存,無疑會有多個線程同時來操做,startfree執行加法返回一塊內存也不是原子操做,因此在此必然就會涉及加鎖解鎖,同時這些線程取得內存塊大小也不統一,全部這麼多的線程必然會由於這裏的鎖而影響執行速度,影響效率。
  2. 一次性取上20塊就能緩解這種情況,當多個線程要取的內存塊不同時,此時便不會鎖住,由於是從不一樣鏈表上取;此時,鎖只會鎖在多個線程從同一個鏈表上取一塊相同大小內存上。
  3. 雖然從內存池取一段內存操做也涉及着加鎖,但因爲調用Refill填充自由鏈表次數相對會少不少,因此上面這樣一次性取20塊作法是能夠提升高併發下程序執行效率。




接下來就是chuncAlloc函數
它表示從內存池那一大塊內存,同時也儘量保證內存池像水池同樣有時刻有「水」。具體它遵循下面幾條方針:

  1. 內存池內存夠多,直接「大方的」返回
  2. 內存池內存有些吃緊了,儘可能返回調用方需求的內存
  3. 內存池「窮得吃土」了,須要求助os來malloc來爲它補充「源頭活水」
  4. os也「吃土」了,內存池「靈機一動」,打上了後面自由鏈表的主意。
  5. 都一無所得,內存池最後一搏,調用一級配置器

到了最後,一級配置器基於它的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中源碼進行更深刻學習。



模擬總體實現:https://github.com/tp16b/project/tree/master/alloc/src
參考:《STL源碼剖析》

相關文章
相關標籤/搜索