内存分配(4)–LOKI的小物件分配器

来源:互联网 发布:八大处靳小雷预约知乎 编辑:程序博客网 时间:2024/05/01 11:08

 

今天我们来看看Loki的内存分配.有一本书叫做<Mordern C++ Design>,个人认为是一本非常不错的书,值得一读。Loki的内存分配目的很明确,loki的设计者认为当前的C运行库的内存分配函数(malloc/realloc/free)并没有针对小内存分配做过优化,导致在有大量小内存频繁分配释放的环境下性能很差,所以他要解决这个问题,这一点倒是和C++ STL很相似,只不过loki的设计者更露骨,他在给分配器起名字的时候也充分强调了loki的内存分配是针对小物件做了大量工作的。
对比一下SGI STL,有一个类,维护了一个单向链表,该链表的每个节点指向负责固定大小内存的空闲链表。loki的思想也是这样的,看一下这个类(为了解释起来方便,我做了一点简化)
   class SmallObjAllocator
    {
    public:
        SmallObjAllocator(std::size_t maxObjectSize);
   
        void* Allocate(std::size_t numBytes);
        void  Deallocate(void* p, std::size_t size);
   
    private:
               
        std::vector<FixedAllocator> pool_;
        std::size_t maxObjectSize_;
    };
其实SmallObjAllocator管理的东西和STL的__default_alloc_template管理的东西是一样的,这里的vector<FixedAllocator>就相当于STL里面的m_free_list[16]。FixedAllocator就负责某一固定大小的内存的分配释放。STL中如果内存大小大于128字节,就交给第一级分配器处理,loki中给用户一点自由,你指定一个大小吧,如果将来申请的内存大小大于 maxObjectSize,就直接交给C运行库的malloc/free处理.
还有一点很大的差别,SGI STL的设计者人为的把小内存分成16个类别,以8个字节为单位,要浪费就稍微浪费一点点。loki的设计者连这点都不愿意,那最好就是把在内部有 maxObjectSize个FixedAllocator罗,这样给定一个大小,就可以在常数时间pool_[i](i是内存大小)找到负责的那个FixedAllocator,问题是如果,maxObjectSize是128,但是用户只申请大小为4和64字节的内存,那有126个FixedAllocator就浪费了。所以loki的设计者就认为只有在第一次有用户申请大小为n的内存的时候,才创建负责大小n的FixedAllocator,然后把它加到pool_中去。听上去不错哦,可是这样一来,接着来一个大小为m的申请,我怎么知道大小为m的FixedAllocator已经存在了呢,如果存在,我怎么能以最快的时间在pool_里面找到它呢。那这样好了,我每次创建一个新的FixedAllocator往pool_加的时候都按照他们所管理的内存大小从小到大排号序,来请求的时候,我做一个二分查找,虽然达不到常数时间,但是也不会很差的。
作为一个库的设计者,因该力求效率高,loki的设计者不满足于简单的二分查找的效率,他们要更快,怎么办呢,大家还记得访问局部性的假设吗?loki的设计者觉得阿,如果你上次分配了一个大小为n的内存,你很有可能接下来也是分配大小为n的内存,在释放内存的时候也是有同样的希望。于是再加两个成员变量:
private:
     FixedAllocator* pLastAlloc_;      // 指向上次用于分配内存的那个FixedAllocator
     FixedAllocator* pLastDealloc_;    // 指向上次用于释放内存的那个FixedAllocator
现在来看看分配内存的源代码:
void* SmallObjAllocator::Allocate(std::size_t numBytes)
{
    if (numBytes > maxObjectSize_) return operator new(numBytes);
   
    //如果上次缓存的那个FixedAllocator能行就最好了
    if (pLastAlloc_ && pLastAlloc_->BlockSize() == numBytes)
    {
        return pLastAlloc_->Allocate();
    }
    //如果上次缓存的FixedAllocator不行,只能用二分查找法找
    Pool::iterator i = std::lower_bound(pool_.begin(), pool_.end(), numBytes);
    if (i == pool_.end() || i->BlockSize() != numBytes) //如果没有找到符合要求的
    {
        i = pool_.insert(i, FixedAllocator(numBytes));
        pLastDealloc_ = &*pool_.begin(); // STL的insert操作有可能造成pLastDealloc失效,重新赋值一把
    }
    pLastAlloc_ = &*i; //更新pLastAlloc
    return pLastAlloc_->Allocate();
}
这个缓存基于访问局部性的假设所做的改进具有普遍意义的,这里扯开一会儿,我们来看看linux中内存管理中的一个细节。linux中有一个结构叫做vm_area_struct,标记了一段从vm_start到vm_end的一段虚拟内存。给定一个地址的时候,通常我们需要先找到这个地址所对应的vm_area_struct结构指针,我们来看看这个函数:
/* Look up the first VMA which satisfies  addr < vm_end,  NULL if none. */
struct vm_area_struct* find_vma(struct mm_struct * mm, unsigned long addr)
{
    struct vm_area_struct *vma = NULL;
    if (mm){
             //Check the cache first. (Cache hit rate is typically around 35%.)
             vma = mm->mmap_cache;//这个就是上次访问的vma! 和loki中的pLastAlloc是一个思路
            if (!(vma && vma->vm_end > addr && vma->vm_start <= addr)) { //如果上次缓存的vma不符合条件
                 if(!mm->mmap_avl){ // 如果没有为现有的vma建立平衡二叉树,只能线性查找了
                    
                      // go through the linear list
                           …
                   }else{ // 如果建立了平衡二叉树,搜索效率就高很多了
                       // Then go through the AVL tree quickly
                       …
                    }
             }
              if(vma){ // 如果找到了符合条件的
                   mm->mmap_cache = vma; //别忘了更新这个cache
              }
 }
 return vma;
}
好,现在我们回来讨论用于分配某一固定内存的FixedAllocator。
首先,FixedAllocator内部一定有一个变量来记录自己负责的内存分配是多大的(blockSize)。还记得STL是怎么干的吗,向系统取一个大小是n的内存时,总是试图拿20个。loki是取numBlocks_个,numBlocks_ = 4096/blockSize。管理这numBlocks个大小为n(总内存n*numBlocks_)的结构叫做Chunk。FixedAllocator维护了一个Chunk的池,为了效率,也添加了两个cache指针allocChunk和deallocChunk。
class FixedAllocator
{
private:
 // Data
        std::size_t blockSize_;
        unsigned char numBlocks_;
        std::vector<Chunk> chunks_;
        Chunk* allocChunk_;
         Chunk* deallocChunk_;

};
看看分配算法:
void* FixedAllocator::Allocate()
{
    if (allocChunk_ == 0 || allocChunk_->blocksAvailable_ == 0) //如果缓存的allocChunk_不满足要求
    {
        //因为频繁的申请和释放,以前分配的Chunk中维护的内存很有可能已经被归还了。
        //所以在问系统要内存(分配一个新的Chunk之前先看看能不能废物利用)
        Chunks::iterator i = chunks_.begin();
        for (;; ++i)
        {
            if (i == chunks_.end()) //没有任何废物可以被重利用,问系统要吧,STL里面还有一个后备池,loki中没有
            {
                // Initialize
                chunks_.reserve(chunks_.size() + 1);
                Chunk newChunk;
                newChunk.Init(blockSize_, numBlocks_); // 会失败吗,系统没有资源可供使用了?
                chunks_.push_back(newChunk);
                allocChunk_ = &chunks_.back();   // 缓存指针
                deallocChunk_ = &chunks_.front();
                break;
            }
            if (i->blocksAvailable_ > 0) //找到满足要求的Chunk
            {
                allocChunk_ = &*i; // 更新用于分配的缓存指针
                break;
            }
        }
    }
    assert(allocChunk_ != 0);
    assert(allocChunk_->blocksAvailable_ > 0);
   
    return allocChunk_->Allocate(blockSize_);
}
个人觉得Loki用Chunk来封装的代价就是,搜索的代价!STL中虽然有个概念上的Chunk,但是每次分配和归还都发生的单向链表的头部,在分配和归还时都没有搜索的代价。看看FixedAllocator的内存释放吧,麻烦大了。最主要的任务是给定一个需要被释放的指针,我得找到当初用来分配给他内存的那个Chunk。怎么找呢,loki的设计者又开始yy了,当然是先检查deallocChunk罗!本着访问局部性的假设,FixedAllocator不是从开头开始寻找,而是从deallocChunk的两侧开始向前向后寻找~~ 
deallocChunk = VicinityFind(p);
Chunk* FixedAllocator::VicinityFind(void* p)
{
    const std::size_t chunkLength = numBlocks_ * blockSize_;
    Chunk* lo = deallocChunk_;
    Chunk* hi = deallocChunk_ + 1;
    Chunk* loBound = &chunks_.front();
    Chunk* hiBound = &chunks_.back() + 1;
    for (;;)
    {
        if (lo) //从deallocChunk_开始往前找(包括deallocChunk_)
        {
            if (p >= lo->pData_ && p < lo->pData_ + chunkLength) //检查p的值是不是落在这个Chunk所管辖的范围内
            {
                return lo;
            }
            if (lo == loBound) lo = 0; //没有找到
            else –lo;
        }
       
        if (hi) //从deallocChunk_开始往后找(不包括deallocChunk_)
        {
            if (p >= hi->pData_ && p < hi->pData_ + chunkLength)
            {
                return hi;
            }
            if (++hi == hiBound) hi = 0;//没有找到
        }
    }
    assert(false);
    return 0;
}
接着,FixedAllocator调用 DoDeallocate进行真正的释放操作,本来我以为很简单的,不就是调用deallocChunk->Deallocate吗!看了代码发现不对,这里体现了loki的内存释放和STL的内存释放的一个重大区别!!STL对于系统来说是个无赖,要了钱从来不还,而loki的设计者知道:"出来混,早晚要还的!"好得,很自然的想到,调完deallocChunk->Deallocate后检查一下deallocChunk所指的Chunk是不是都空了,如果是就真正释放他所管理的内存。那我就要问了,难道STL的设计者就素质这么低吗?后来我才发现,是有原因的,写代码一定要注意边缘情况(borderline conditions)。Loki为他的绅士是付出了代价的。考虑这样一个情况:
现在FixAllocator里面所有的Chunk的内存都满了,程序执行这样的客户代码:
for(…)
{
 SmallObjectInstance* p = new SmallObjectInstance;
       … //do something
       delete p;
}
嘿嘿,有趣了,每次迭代,FixedAllocator都会向系统申请内存交给一个新的Chunk管理,然后真正的释放这块刚申请的内存,反反复复,代价太大了!loki的策略是:
1. 如果deallocChunk是唯一的一个为空的Chunk,暂时不要物理释放,怎么知道是最后一个为空的Chunk呢,这里有一个约定:检查一下最后的那个Chunk是否为空(chunks_.back()),如果为空,就物理释放最后那个Chunk,如果不为空,就把当前deallocChunk和最后那个Chunk交换。
2. 如果正好碰到deallocChunk所指的Chunk正好是最后一个Chunk,那就要检查倒数第二个Chunk。为什么是检查倒数第二个呢?说句老实话,我还没有想通,等我想出来了,我会贴到回帖里面!(给自己一个TO DO: )
void FixedAllocator::DoDeallocate(void* p)
{
    // call into the chunk, will adjust the inner list but won’t release memory
    deallocChunk_->Deallocate(p, blockSize_);
    // 区别在这里,有借有还,再借不难
    if (deallocChunk_->blocksAvailable_ == numBlocks_) // 如果Chunk里面的内存都被“释放”了
    {
        // deallocChunk_ is completely free, should we release it?
       
        Chunk& lastChunk = chunks_.back();
       
        if (&lastChunk == deallocChunk_) //如果准备删除的deallocChunk_就是最后一个Chunk
        {
            // check if we have two last chunks empty
            if (chunks_.size() > 1 && deallocChunk_[-1].blocksAvailable_ == numBlocks_)
            {
                // Two free chunks, discard the last one
                lastChunk.Release(); //真正释放内存归还系统
                chunks_.pop_back();
                allocChunk_ = deallocChunk_ = &chunks_.front(); // 重设缓存指针
            }
            return;
        }
       
        if (lastChunk.blocksAvailable_ == numBlocks_) //如果deallocChunk不是最后一个Chunk,并且最后一个Chunk为空
        {
            // Two free blocks, discard one
            lastChunk.Release(); //释放最后那个Chunk
            chunks_.pop_back();
            allocChunk_ = deallocChunk_;
        }
        else // 如果只有一个Chunk为空,把它交换到最后的位置,方便以后判断
        {
            // move the empty chunk to the end
            std::swap(*deallocChunk_, lastChunk);
            allocChunk_ = &chunks_.back();
        }
    }
}
下面我们来看看处在最低层的工人,Chunk!Chunk维护着真正的物理内存,所以他必须有一个指针(unsigned char* pData)指向那块他维护的内存。Chunk是给FixedAllocator提供服务的,所以会把pData所指向的内存切成大小相等的N块,块的大小是由FixedAllocator决定的。那怎么维护这一堆大小相同的物理内存块呢? 空闲链表阿,内嵌指针阿。Chunk就是这么干的:
struct Chunk
{
    void Init(std::size_t blockSize, unsigned char blocks); //初始化Chunk,申请物理内存,并且初始化空闲链表(这是个逻辑上的链表)
    void* Allocate(std::size_t blockSize); //从空闲链表中分配一块内存
    void Deallocate(void* p, std::size_t blockSize);//归还一块物理内存给空闲链表
    void Release();  //真正的释放这块物理内存
    unsigned char* pData_;   //指向物理内存的指针
    unsigned char
                firstAvailableBlock_,   //链表的表头指针(逻辑上的)
                blocksAvailable_;       //在这个Chunk中剩余的内存块数
};
上王道:
void Chunk::Init(std::size_t blockSize, unsigned char blocks)
{
    // Overflow check
    assert((blockSize * blocks) / blockSize == blocks);
   
    pData_ = new unsigned char[blockSize * blocks];
    firstAvailableBlock_ = 0;  //指向下一个可用的空闲块,开始的时候指向第0块内存
    blocksAvailable_ = blocks; //开始的时候一共有blocks块可用内存
    //完全是内嵌指针的思路,这里用一个byte的索引代替指针,把每一块内存的第一个字节用来存储下一块可用内存的索引。、
    //让firstAvailableBlock_指向0(逻辑上就是表头指针),第0块的首字节指向1,第1块的首字节指向2,……   
    unsigned char* p = pData_;
    for (unsigned char i = 0;; i != blocks; p += blockSize)
    {
        *p = ++i;
    }
}
void* Chunk::Allocate(std::size_t blockSize)
{
    if (!blocksAvailable_) return 0; //如果没有可用内存了,返回0表示失败
   
    unsigned char* pResult = pData_ + (firstAvailableBlock_ * blockSize); //计算下一块可用内存的地址
    firstAvailableBlock_ = *pResult; //修改“表头指针”指向下一块可用内存块的索引
    –blocksAvailable_;              //用去一块空闲内存后,空闲内存块的总数就少1了
   
    return pResult;
}
void Chunk::Deallocate(void* p, std::size_t blockSize)
{
    assert(p >= pData_);//标准的指针检查
    unsigned char* toRelease = static_cast<unsigned char*>(p);
    // Alignment check
    assert((toRelease – pData_) % blockSize == 0);
   //接下来的两条语句在逻辑上看就是把待归还的空闲块插到空闲链表的头部
    *toRelease = firstAvailableBlock_; //待归还的区块的下一块空闲块就是原来的表头所指的区块
    firstAvailableBlock_ = static_cast<unsigned char>((toRelease – pData_) / blockSize);//修改表头“指针”指向刚归还块的索引
    // Truncation check
    assert(firstAvailableBlock_ == (toRelease – pData_) / blockSize);
    ++blocksAvailable_;
}
整个分配器的架构差不多就是这个样子,其实还有一层,叫做SmallObject,客户代码使用loki的小物件分配器通常都是和SmallObject打交道,而不是直接和SmallObjectAllocator或者FixedAllocator打交道,这个我们以后说到Singleton的时候再讨论吧~~
原创粉丝点击