高并发内存池 - page cache 回收内存

高并发内存池 - page cache 回收内存

项目 gitee 链接: 高并发内存池项目

项目 github 链接: 高并发内存池项目

在 Central Cache 层归还 Span 给 Page Cache 后,Page Cache 要做的工作是什么呢?

Page Cache 需要把小的 Span 尽量合并成大的 Span,减少内存外碎片,避免明明内存池中有很多空间,但却都是 1 ~ 2 页的小空间,一旦申请超过 2 页以上的空间就歇菜。

所以 Page Cache 层尽量要合并出大的 Span,以供分配或者切割都可以。

在 Page Cache 层回收合并的 Span 首先必须为闲置的,没有被使用的,那如何确定他们是未被使用的呢

首先想到之前定义的 _useCount,乍一想感觉可以,但仔细分析会发现不行。将 _useCount 为 0 的与回收的 Span 进行合并这一步是可以的,但是在 Central Cache 层中,向 Page Cache 申请 Span 进行切割时,这个 Span 的 _useCount 也为 0,有可能 Page Cache 将要合并的为这个 Span ,造成 Central Cache 刚将 Span 从 Page Cache 中取出,Page Cache 却又将它合并了,这会导致线程安全的问题,所以不能使用 _useCount。

在 Span 类中定义一个 bool 类型,用于判断此 Span 是否被正在使用,可以完美解决这个问题。

更改后的 Span 类:

cpp 复制代码
struct Span
{
    PAGE_ID _pageId = 0;  //大块内存起始页的页号
    size_t _n = 0;        //页的数量
    
    Span* _prev = nullptr;      //双向链表的结构
    Span* _next = nullptr;

    size_t _useCount = 0;        //切好的小块内存,分配给 threadcache 的数量
    void* _freelist = nullptr;   //切好的小块内存的自由链表
    bool _isUse = false;         //是否在被使用
};

基于新添加的 _isUse,Central Cache 向 Page Cache 申请 Span 的函数也要进行 Span 状态的更新:

cpp 复制代码
Span* CentralCache::GetOneSpan(SpanList& list, size_t size)
{
    Span* cur = list.Begin();
    while (cur != list.End())
    {
        if (cur->_freelist != nullptr) //如果一个 Span 中仍有空间可以分配,那自由链表一定不为空
            return cur;      
        cur = cur->_next;
    }
    
    //先将 Central Cache 的桶锁解开,其他线程有归还空间的需求
    list._mtu.unlock();
    //此时 Spanlist 已经为空,需要向 PageCache 申请
    PageCache::GetInstance()->_pageMtx.lock();
    Span* newSpan = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(size));
    newSpan->_isUse = true;            //改变 Span 的状态
    PageCache::GetInstance()->_pageMtx.unlock();
    //从 PageCache 中获得的是一整个 Span,需要分割成小块塞入自由链表中
    
    char* start = (char*)(newSpan->_pageId << PAGE_SHIFT);  //使用页号去计算 Span 的起始地址
    size_t bytes = newSpan->_n << PAGE_SHIFT;
    char* end = start + bytes;

    //将第一块先切出,链入自由链表中
    newSpan->_freelist = start;
    start += size;
    void* tail = newSpan->_freelist;

    while (start < end)
    {
        ObjNext(tail) = start;
        tail = ObjNext(tail);
        start += size;
    }

    ObjNext(tail) = nullptr; //没有置空,踩坑

    list._mtu.lock();
    list.PushFront(newSpan);

    return newSpan;
}

此时思考下一个问题:和哪些 Span 合并呢

不能看见一个空闲的 Span 就上去合并吧,可能这个 Span 的地址和另一个 Span 的地址相差了十万八千里,这有悖局部性原理,显然不是效率最佳的做法。

解决方法是尝试与上一个页号的 Span 和下一页号的 Span 去合并,回收的 Span 中存储了页号 _pageId 和它占的页大小 n,_pageId - 1 即为上一 Span 中最末尾的页号,_pageId + n 即为下一个 Span 中开头的页号。

那现在的问题转变为:上哪里去找 Span 对应的地址呢

还记得我们在 Page Cache 中设计了一个哈希表,用于存储分配给 Central Cache 层中 Span 地址与页号的映射关系。但我们想要的 Span 地址显然不在这里,因为可以合并的 Span 必须属于 Page Cache 层,而哈希表只存储了分配给 Central Cache 层中的 Span 地址,怎么办呢?

我们看一下 Page Cache 将 Span 分配给 Central Cache 层的函数实现:

cpp 复制代码
Span* PageCache::NewSpan(size_t k)
{
    assert(k > 0 && k < NPAGES);
    //先检查第 k 个桶中是否还有 Span
    if (!_spanLists[k].Empty())
        return _spanLists[k].PopFront();  //猜的第三个坑,没有加上 [k]
    
    //走到这里说明桶中为空,要遍历 Page Cache 是否有大页 Span 可分割
    for (int i = k; i < NPAGES; i++)
    {
        if (!_spanLists[i].Empty())    
        {
            Span* spanN = _spanLists[i].PopFront();
            Span* spanK = new Span;                 //踩的第一个坑,没有申请空间,野指针直接使用

            spanK->_pageId = spanN->_pageId;
            spanK->_n = k;
            
            spanN->_pageId += k;
            spanN->_n -= k;
            
            _spanLists[spanN->_n].PushFront(spanN);  //踩的第三个坑,把 spanK 挂上去了

            //将要分配给 Central Cache 的 Span 建立页号和地址的映射,方便归还时寻找对应地址
            for (int i = 0; i < spanK->_n; i++)
            {
                _idSpanMap[spanK->_pageId + i] = spanK;
            }
            return spanK;
        }
    }

    //走到这里说明也没有大页 Span,所以此时需要向堆申请空间
    Span* bigSpan = new Span;
    void* ptr = SystemAlloc(NPAGES - 1);      //申请一个最大页数的 Span
    bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;  //踩的第二个坑,左移右移逻辑踩坑
    bigSpan->_n = NPAGES - 1;

    _spanLists[bigSpan->_n].PushFront(bigSpan);
    
    return NewSpan(k);  //递归调用自己,此时一定有大页 Span 可供分割
}

spanK 最后是要被分配的,而 spanN 最后会被留在 Page Cache 层中,只需要将 spanN 页号与地址的映射页存入哈希表中,那么我们在 Span 合并时,就可以通过哈希表查找到空闲的 Span。

为什么说这里一定会查找到呢 ?这里要明白,内存池一开始是没有空间资源的 ,当第一次 Thread Cache 申请资源是,它为空,跳到 Central Cache 也为空,跳到 Page Cache 也为空,Page Cache 此时就会向堆申请空间,所以一切的空间资源,都是由 Page Cache 先申请出来的,然后 Page Cache 对 Span 进行切割,将空间给 Central Cache,所以理论上所有的资源都经过了 Page Cache 层,是它将资源释放给下一层的,如果 Page Cache 在分割时对分配出去的 Span 做记录,也对留在 Page Cache 的资源做记录,那所有的 Span 它都会记录下来,因为所有 Span 都经过 Page Cache 层。
只有一种情况 Page Cache 层不会记录 Span,那就是直接向系统申请页数为 128 的 Span,128 大小的 Span 并不需要进行合并,因为它已经最大了,所以不用记录在哈希表中。

那在哈希表记录映射时,用将每个页号都与地址建立映射么?

不用,分配给 Central Cache 层的 Span 需要是因为使用的空间地址可能来源于任何一个页 ,哈希表要确保任何一个页都能映射 Span,而空闲在 Page Cache 层中的 Span,我们只会使用末页号和首页号来查询,所以只需映射首末页号和地址的关系即可。

修改后的函数:

cpp 复制代码
Span* PageCache::NewSpan(size_t k)
{
    assert(k > 0 && k < NPAGES);
    //先检查第 k 个桶中是否还有 Span
    if (!_spanLists[k].Empty())
        return _spanLists[k].PopFront();  //猜的第三个坑,没有加上 [k]
    
    //走到这里说明桶中为空,要遍历 Page Cache 是否有大页 Span 可分割
    for (int i = k; i < NPAGES; i++)
    {
        if (!_spanLists[i].Empty())    
        {
            Span* spanN = _spanLists[i].PopFront();
            Span* spanK = new Span;                 //踩的第一个坑,没有申请空间,野指针直接使用

            spanK->_pageId = spanN->_pageId;
            spanK->_n = k;
            
            spanN->_pageId += k;
            spanN->_n -= k;
            
            _spanLists[spanN->_n].PushFront(spanN);  //踩的第三个坑,把 spanK 挂上去了
            
            //将 SpanN 的首页号和末页号存入哈希表中,方便归还合并
            _idSpanMap[spanN->_pageId] = spanN;                //这里有踩坑,有把 spanK 挂上去了
            _idSpanMap[spanN->_pageId + spanN->_n - 1] = spanN;

            //将要分配给 Thread Cache 的 Span 建立页号和地址的映射,方便归还时寻找对应地址
            for (int i = 0; i < spanK->_n; i++)
            {
                _idSpanMap[spanK->_pageId + i] = spanK;
            }
            return spanK;
        }
    }

    //走到这里说明也没有大页 Span,所以此时需要向堆申请空间
    Span* bigSpan = new Span;
    void* ptr = SystemAlloc(NPAGES - 1);      //申请一个最大页数的 Span
    bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;  //踩的第二个坑,左移右移逻辑踩坑
    bigSpan->_n = NPAGES - 1;

    _spanLists[bigSpan->_n].PushFront(bigSpan);
    
    return NewSpan(k);  //递归调用自己,此时一定有大页 Span 可供分割
}

现在怎么查找前后闲置 Span(使用首末页号),上哪里查(哈希表)的问题都已解决,思考下一个问题:Span 的合并在何时应该被终止呢?

首先 Span 的合并不是只合并一次,合并 Span 的目的是为了合成尽可能大的 Span,目标为合成最大的 128 页大小的 Span,所以合并会在循环体中被不断执行,会不断尝试合并前后的 SPan

  • 如果页号在哈希表中不存在,跳出循环
  • 如果对应页号的 Span 在被使用,跳出循环
  • 如果合并后的页数大小大于 128,跳出循环

一旦满足上述三个条件中的一个,那么合并的循环就应该跳出,如果没有跳出循环,说明合并成功了一个 Span,对合并的 Span 进行处理:

  • 是否要更改页号(根据合并的是前后哪边的 Span 决定),改变页数 _n,将被合并的 Span 从 Page Cache 桶中删除,避免被申请。

如果合并前后 Span 的两个循环都跳出,此时 Span 的合并已经结束,将它重新插入 Page Cache 的桶中 ,并非最后合并出的 Span 一定都为 128 页,所以插入哈希表中,以便后续的 Span 继续合并

代码实现:

cpp 复制代码
void PageCache::RealeaseSpanToPageCache(Span* span)
{
    while (1) //不断尝试合并
    {
        //对 Span 前后的页尝试合并,解决外碎片问题
        PAGE_ID prevId = span->_pageId - 1;    //前一个 Span 的末尾页号
        auto ret = _idSpanMap.find(prevId);
        
        if (ret == _idSpanMap.end()) //不存在于哈希表中
        {
            break;
        }

        Span* prevSpan = ret->second;
        if (prevSpan->_isUse == true) //Span 仍在被使用无法合并
        {
            break;
        }

        if (prevSpan->_n + span->_n > NPAGES - 1)
        {
            break;
        }
        
        span->_pageId = prevSpan->_pageId;
        span->_n += prevSpan->_n;

        _spanLists[prevSpan->_n].Erase(prevSpan);  //将其从 PageCache 的桶中删去,以免被申请

        delete prevSpan;  //将此对象的空间释放,而不是释放掉它所指向的空间
        //此时代码会不断向前合并,因为是死循环,知道不满足条件跳出
    }
    
    while (1)
    {
        PAGE_ID nextPage = span->_pageId + span->_n;  //这里踩坑,多加了一个 1
        auto ret = _idSpanMap.find(nextPage);

        if (ret == _idSpanMap.end())
        {
            break;
        }
    
        Span* nextSpan = ret->second;
        if (nextSpan->_isUse == true)
        {
            break;
        }
        
        if (span->_n + nextSpan->_n > NPAGES - 1)
        {
            break;
        }
    
        span->_n += nextSpan->_n;

        _spanLists[nextSpan->_n].Erase(nextSpan);

        delete nextSpan;
    }

    span->_isUse = false;
    _spanLists[span->_n].PushFront(span);
    _idSpanMap[span->_pageId] = span;
    _idSpanMap[span->_pageId + span->_n - 1] = span;
    
}

到此我们也将回收内存流程的函数都讲解完毕,下一篇文章我们将对回收内存部分进行调试,尝试找出 bug 并修复,我们下期再见👋。

相关推荐
微露清风5 小时前
高并发内存池 - 释放内存过程联调
项目学习·内存池
天空'之城3 天前
C 语言工业级通用组件手写 06:固定块内存池
c语言·嵌入式开发·内存池·底层优化
天空'之城6 天前
C 语言工业级通用组件 02:通用内存池
c语言·嵌入式·内存管理·内存池
笨蛋少年派15 天前
信用卡风控分析平台实践项目
项目
南部余额1 个月前
Maven Archetype 项目模板
java·maven·项目·archetype
UrSpecial2 个月前
从零实现Nginx风格内存池
nginx·内存池
君鼎2 个月前
内存池完整实现——C++20版
c++20·内存池
不吃土豆的马铃薯2 个月前
5.SGI STL 二级空间配置器 _S_chunk_alloc核心函数解析
开发语言·c++·vscode·c·内存池
不吃土豆的马铃薯2 个月前
4.SGI STL 二级空间配置器 allocate 与_S_refill 源码解析
c语言·开发语言·c++·dreamweaver·内存池