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源码剖析》

相关文章
相关标签/搜索