万字详解C++内存池:提高内存分配效率的利器(下)

简介: 万字详解C++内存池:提高内存分配效率的利器

1)结构:

  • 首先假设内存分配器的最小内存分配单元为mem_unit,需要确定最小分配单元的大小。如果设置太小,将使得内存单元过于琐碎,过大则造成空间浪费。基于这个考虑,设置多个大小类别的mem_unit。申请内存单元时,将分配能够满足该大小的最小内存单元。
  • 由于同一类别的mem_unit是随机申请的,空间不连续,所以采用单向链表结构管理同一类别的mem_unit。
  • 为了能够快速定位某一个类别的men_unit_list, 通过对men_unit的size进行hash建立索引。


基于以上三点考虑,建立的内存分配器结构如下图:

2)流程

  • 申请内存:根据申请内存的大小选择合适的mem_unit, 若相应的mem_unit_list为NULL,则向操作系统申请内存空间,然后构建mem_unit进行分配;若mem_unit_list不为NULL,则从中取出mem_unit进行分配。
  • 释放内存:将men_unit添加到相应的mem_list中。


3)总结

  • 申请内存时间复杂度为O(1),释放内存时间复杂度为O(1);
  • 存在空间浪费,单个mem_unit最大浪费4KB;
  • 只申请内存不释放,因此对于内存波动大的应用空间浪费会比较严重;
  • 多线程应用时,每个mem_unit_list必须是线程安全的。


三、并发内存池


该项目主要模拟实现tcmalloc最核心的框架。高并发内存池主要解决的是内存碎片问题,而内存碎片又分为内碎片和外碎片。在32位平台下以外碎片为例,如下图:


假设已申请3G的内存,现在想再申请950M的内存,虽然合计剩余内存大于950M,但是由于空间不连续,因此无法申请到950M的内存空间,这就是由于外碎片问题导致申请内存失败。


内存池是程序在向操作系统申请内存时,申请过量内存,由进程自己将这些过量内存组织起来,待到需要使用内存的时候,直接分配,而不需要频繁地向操作系统申请内存,频繁向操作系统申请资源也是一种性能消耗。当上层释放内存时,将内存重新在内存池中组织起来,等待下次使用,而不是直接让操作系统回收。


3.1整体结构


首先要讨论一下如何管理内存?

上层将要申请的内存大小无法预测,但又不能直接将整块申请来的过量内存交给上层,因此需要对大块内存进行切分。这些内存块无疑是要被容器管理起来的,并且要保证当我们要申请使用的时候,能快速找到适合的内存块大小,哈希表就十分契合这些需求,将切分后的内存块大小与其对应内存建立 大小-内存指针 的kv映射。


高并发内存池主要由三个模块构成:

注意:以下反复虽然提到哈希的key->value的映射关系,但实际实现的时候,大部分不需要真的借助unordered_map,而是通过其他规律就能达到和key->value映射的效果,为了方便,以下直接使用key->value映射表述


ThreadCache: 线程缓存。直接与上层交互,为上层提供内存并将释放的内存以类似哈希桶的思想组织起来==(kb -> 内存块地址)==,等待下次申请。线程缓是每个线程独有的,因此线程缓存是无锁的。多线程同时使用线程缓存申请内存时互不影响,这也是并发内存池效率高的一个重要原因。


CentralCache: 中心缓存。中心缓存和线程缓存的整体结构是相似的,key值也是kb。只不过内存划分单位不同,中心缓存中的内存是以页为单位构建Span管理页内存的。中心缓存是所有线程所共享的,当线程缓存中没有适合分配给用户的内存块时,就会向中心缓存申请对应大小(mem_size)的内存块。中心缓存从 mem_size 所对应的Span链中提取Span,并将该Span页切割成多个大小为 mem_size 的内存块,然后返回部分内存块给线程缓存。因此中心缓存的kv映射是 kb -> Span。


由于中心缓存是所有线程共享,因此需要加锁。但中心缓存的锁是桶锁,意味着只要不是要申请的内存块大小相同,一般不会有竞争锁的问题,这使得CentralCache虽然有锁,但是效率相比无锁不会下降很多。


PageCache: 页缓存。页缓存中的内存划分也是以页为单位。当中心缓存中对应内存大小上的Span链中没有可用内存了,就要来向


页缓存申请页内存。它和中心缓存的最大区别在于它不切割页,而是直接就按页分配向上交付,也因此它的kv映射是 page -> 页内存。通过算法由申请的内存大小,计算出分配多少页给CentralCache较为合适,然后到页大小key对应的桶链上拿页内存。


如果该桶链上没有页内存,就从当前桶开始,不断往下继续搜寻(类比线性探测法),因为比当前桶key值(页大小)大的桶,如果有页内存,则该内存页大小一定比我们需要的页大小大。


把向下搜寻到的该页内存切成两部分:上层所需页内存大小 + 切割后剩余页内存大小。然后把上层所需页内存大小返回给上层,将剩余页内存大小按照key值插入到对应桶中。


或者往下探测直到最后一个桶:128页大小,都没有找到有一个桶中有页内存,则需要直接进行系统调用,向系统堆申请页内存,然后切割并分配。


规定:

  • ThreadCache:最多一次性只能分配256kb的内存,即最大划分内存块为256kb。若单次申请内存大于256kb,则直接向PageCache申请页
  • CentrolCache:显然,因为结构与ThreadCache基本相同,所以CentralCache也是最多一次性只能分配256kb的内存,意味着Span页切割小块内存最大把每块小块内存切成256kb。
  • PageCache:最多一次性分配128页内存。若单次申请内存大于128页,则需要直接使用系统调用向系统堆申请内存。


3.2ThreadCache模块


如何切分内存?

如果我们以kb为内存划分的最小单位,能否将内存按1kb、2kb、3kb…直到256K这样切分?

这样管理起来,整个数据结构太过庞大,不可取。因此,我们需要设定对齐数,在可接受的范围内,既保证用户申请的内存大小和我们划分的内存块大小尽量接近,又要保证不使整个结构过于庞大。


对齐数设置

参考tcmalloc,我们设置对齐数如下:

整体控制在最多10%左右的内碎片浪费
内存大小          对齐数         桶下标
[1,128]         8byte对齐           freelist[0,16)
[128+1,1024]      16byte对齐          freelist[16,72)
[1024+1,8*1024]     128byte对齐       freelist[72,128)
[8*1024+1,64*1024]    1024byte对齐          freelist[128,184)
[64*1024+1,256*1024]  8*1024byte对齐      freelist[184,208)


以对齐数8为例:


当用户申请内存大小:1、2、3、4、5、6、7、8字节时,满足[1,128]的区间,按8字节对齐,分配 8 * 1的字节,即都是分配8字节


当用户申请内存大小:9、10、11、12、13、14、15字节时,满足[1,128]的区间,按8字节对齐,分配 8 * 2的字节,即都是分配16字节


依此类推


以8字节对齐的内存,假设用户申请1B,分配8B,则浪费7B内存。像这样,因分配的内存大于实际申请的内存,导致部分内存被浪费,


我们称被浪费的这部分内存为内碎片。第一个区间[1,128]内,由于用户申请的内存比较小,容易产生较大的内碎片浪费率,但是因为本身最多也就分配128B,所以第一个区间的浪费率我们忽略不计。


不考虑第一个区间:


以16B对齐,则内碎片最大为 16 − 1 = 15 16 - 1 = 1516−1=15,最小为0(刚好申请内存大小为对齐数的整数倍)


若申请129B,则内碎片浪费率为 15 / 144 ≈ 10 % 15 / 144 ≈ 10\%15/144≈10%。同理,申请145B,内碎片浪费率为 15 / 160 ≈ 10 % 15 / 160 ≈ 10\%15/160≈10%


以128B对齐,则内碎片最大为 128 − 1 = 127 128 - 1 = 127128−1=127,最小为0(刚好申请内存大小为对齐数的整数倍)


若申请1025B,则内碎片浪费率为 127 / 1152 ≈ 11 % 127 / 1152 ≈ 11\%127/1152≈11%。


以1024B对齐,则内碎片最大为 1024 − 1 = 1023 1024 - 1 = 10231024−1=1023,最小为0(刚好申请内存大小为对齐数的整数倍)


若申请(8*1024+1)B,则内碎片浪费率为 1023 / 9216 ≈ 11 % 1023 / 9216 ≈ 11\%1023/9216≈11%。


以8*1024B对齐,则内碎片最大为 8 ∗ 1024 − 1 = 8191 8*1024 - 1 = 81918∗1024−1=8191,最小为0(刚好申请内存大小为对齐数的整数倍)


若申请(64*1024+1)B,则内碎片浪费率为 8191 / 73728 ≈ 11 % 8191 / 73728 ≈ 11\%8191/73728≈11%。(8*1024+1)B,则内碎片浪费率为 1023 / 9216 ≈ 11 % 1023 / 9216 ≈ 11\%1023/9216≈11%。


综上,ThreadCache的结构图如下:

实现过程中的部分注意点:

//向CentralCache申请内存
void* ThreadCache::FetchFromCentralCache(size_t MemSize)
{
  //上层只申请了一块内存,ThreadCache也只向中心缓存要一块来供给上层,合情合理
  //但是ThreadCache也可以多申请几块(补充库存)
  //这样下次上层再向ThreadCache申请内存的时候,就能直接给上层分配,不用频繁向中心缓存申请了。
    //申请策略?慢增长
  size_t requestMemNum = min(_freeList[index].GetMaxUseNum(), SizeClass::RequestWay(MemSize));
}


//申请策略。计算实际向下层申请的内存数量
  //参数:申请的内存大小
  static size_t RequestWay(size_t MemSize)
  {
    assert(MemSize > 0);
    //算法:固定值 / 申请的内存大小
    //分母越大,结果越小
    //申请的内存越小,一次性可以多要一些
    //申请的内存越大,一次性要少点,否则对系统负担大
    size_t ret = MaxMemSize / MemSize;
    if (ret < 2) //下限
    {
      ret = 2;
    }
    if (ret > 512) //上限
    {
      ret = 512;
    }
    return ret;
  }


//释放内存
//ThreadCache桶链太长的话,应当回收一部分给CentralCache
//太长?长短的标准是什么?
//定义使用频数成员变量,当长度 > 使用频数的时候,就可以考虑回收一部分了:回收使用频数个

3.3CentralCache模块

kb -> Span

CentralCache与Thread结构相似,不同点在于kb映射的是Span页,Span页中是一个个切割的小块内存,以及每个桶链中都有桶锁:

为什么CentralCache的桶链中的数据结构是带头双向循环链表,而ThreadCache的桶链结构是单链表?

因为CentralCache中,内存被Span结构体管理着,Span是一个结构体,要形成双向循环链表无非就是在结构体中添加分别指向前驱Span和后继SPan的指针。

ThreadCache中,管理的是原生内存,没有经过任何封装,因此无法像结构体那样增加前驱和后继指针,形成带头双向循环链表。

既然ThreadCache中的内存没有经过任何封装,没有添加指针,那它是怎么将内存链起来的?

内存本身天然就是数据的容器!!! 数据是写在内存上的,换句话说,内存上是可以写数据的!!! 只要我们能把下一段内存的地址写入当前内存中,不就好像把他们链起来了吗?!!

32位平台下指针地址占4Byte,64位平台下指针地址占8Byte。所以我们也可以大胆猜测一下为什么要设置对齐数从8Byt开始,

或许就是为了让内存池无论是在32位平台还是64位平台下,都至少能把地址写进去,都能够按照这套逻辑进行,保证程序的移植性。

如果内存再大一点,比如16Byte,应该就能想办法做到前驱内存地址,比如:前8Byte用来记录后继内存地址,后8Byte用来记录前驱内存地址。只是这样做,代码可能会变得更复杂,而且8Byte内存无法这样做,无法统一逻辑。或许出于种种考虑,因此不这么做了。

如何正确地修改内存,让它保存下一个内存的地址?

假设有一个指针mem指向一段内存:

void* mem;

如果我们要改变mem指针所指向内存的内容,需要访问这段并修改,回顾一下指针类型的意义:

  • 数据是存储在内存中的,指针类型决定了指针如何看待所指内存,看待内存的角度不同,取出来的数据值也不同
  • 指针类型决定了指针访问所指内存的字节数
int main()
{
  //00000001 00001001 00001001 00001001
  //小端: 09 09 09 01 
  unsigned int i = 0x01090909;
  cout << i << endl << endl;
  cout << 0x09 << endl;
  char* p = (char*)&i;
  cout << "*p = " << (int)* p << endl << endl;  //9;
  //这里需要在打印的时候再把值强转成int类型,否则ASCII中,9对应的字符是'\t'
  //char类型把他当'\t'打印了,运行结果显示什么也没有。
  cout << 0x0909 << endl;
  wchar_t* q = (wchar_t*)&i;
  cout << "*q = " << (int)*q << endl << endl;
  unsigned int* r = &i;
  cout << "*r = " << *r << endl;
}

这段代码可以很充分地帮我们验证指针类型的意义。

综上述,如果要修改内存中的内容,只要正确使用指针即可。

能否这样写:

//已知void* m1指向一段内存,void* m2指向另一段内存
*(int*)(m1) = m2;

逐步分析:

  • 强转int*:将m1所指内存当做当做一个存储int数据类型的内存,即将来解引用时,访问4个字节。
  • 解引用并赋值:将m1内存中的前4个字节的内容写入m2(另一段内存的地址)

像以上这样的操作,在32位平台下,是没有问题的,但是在64位平台下,就有问题了。

无论是32位平台还是64位平台,int都是占4个字节!*(int*)表示访问内存的4个字节,并记录另一段内存的地址。

但是在64位平台下,指针(地址)占8个字节,只用4个字节记录,是无法正确记录另一段内存的地址的。 如果上面那样去修改内存内容,在64位平台下会出错,代码的可移植性差。

需要一个类型,在32位平台下是4个字节,64位平台下是8个字节,让我们能够正确强转。 实际上,问题的答案在一开始就有了,指针在32位平台下4个字节,64位平台下8个字节。

但是我们最后是要解引用的,一级指针解引用后就不是指针了,所以强转的指针类型必须是2级指针

*(void**)(m1) = m2;

实现过程中的部分注意点:

//从CentralCache的自由链表中取出一个Span
Span* CentralCache::GetOneSpan(SpanList&list, size_t MemSize)
{
    //....
    //若桶链中没有Span或所有Span都没有剩余内存
    //向PageCache申请Span
  //考虑到ThreadCache向CentralCache要内存并不是需要多少拿多少,而是多拿
  //如果原本CentralCache中有Span可用,有多少给多少,不一定够,尽量满足也就罢了
  //既然调到了这个函数,就说明CentralCache中没有Span,要再找PageCache申请Span
  //那既然要申请,那就理所当然申请一定能够满足ThreadCache的内存数
  //那就去计算这样大小的内存,上层实际会申请会申请多少个,转换成页是多少页?
  //再按照这样的页数报给PageCache去申请
    size_t pageNum = SizeClass::NumberOfPages(MemSize);
  //PageCache使用的是大锁,锁住整个PageCache
  PageCache::GetPageCache()->PageCacheLock();
  Span* newSpan = PageCache::GetPageCache()->GetSpanToUpper(pageNum);
  PageCache::GetPageCache()->PageCacheUnlock();
  assert(newSpan);
  // 为什么申请完Span就解锁了?切割不需要加锁吗?
  // 切割不需要加锁,因为切割是把新申请的Span切割,这个newSpan指针是只属于当前线程的局部变量
  // 也就是说只有当前线程能够看到这个Span。不存在线程安全问题,因此切割的时候不需要加锁。
  // 这样就能提高效率,如果有其他的CentralCache桶中无Span,也需要申请,但是PageCache只有一把大锁
  // 如果切割的时候没用到却没有释放掉,其他线程就只能阻塞了。
}

3.4PageCache模块

Page -> Span

虽然PageCache和CentralCache的桶链结构都是Span,但是他们的区别还是很大的,Central中的Span页内存,终将切割成为一块块与key值对应的小内存块,而PageCache中的Span与它的key值(页大小)直接对应,key值多大,Span中管理的页内存就是多大,不会进行切割。

实现过程中的部分注意点:

//参数:上层要拿k页
Span* PageCache::GetSpanToUpper(size_t k)
{
  assert(k > 0);
  //申请页数 > 128,直接向堆申请
  if (k > NPage - 1)
  {
    //Span* p = (Span*)SystemAlloc(k); //不能这么写
    //栈上的局部变量,出了作用域就销毁了
    //但是我们在释放的时候,还要用Span来释放,所以必须在堆上开Span避免被自动销毁
    void* ptr = SystemAlloc(k);
    //Span* span = new Span; 
    Span* span = _spanPool.New(); 
    span->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
    span->_n = k;
    span->_isUse = true;
    _pageMapSpan[span->_pageId] = span;
    return span;
  }
  else
  {
    //页数 = 桶下标
    //到对应桶中拿Span
    if (!_pclist[k].Empty())
    {
      Span* kSpan = _pclist[k].PopFront();
      kSpan->_isUse = true;
      for (int i = 0; i < kSpan->_n; ++i)
      {
        _pageMapSpan[kSpan->_pageId + i] = kSpan;
      }
      return kSpan;
    }
    //走到这里说明对应页的桶中无Span,往下面的桶里找
    size_t n = k + 1;
    while (n < NPage)
    {
      //往下找,找到了非空页桶,取出其中的span拿来切割成pages页 和 i - pages页
      if (!_pclist[n].Empty())
      {
        Span* nPages = nPages = _pclist[n].PopFront();
        //Span* kPages = new Span;
        Span* kPages = _spanPool.New();
        //切割成两部分
        //前一部分的页号就是原本的页号 不需要改动
        kPages->_pageId = nPages->_pageId;
        kPages->_n = k;
        nPages->_pageId += k; //后一部分的起始页号 = 原本的起始页号 + 前一部分的页数
        nPages->_n -= k; //切割后的页数要改一下
        //建立id和Span的映射,方便查找
        for (PAGE_ID i = 0; i < kPages->_n; ++i)
        {
          _pageMapSpan[kPages->_pageId + i] = kPages;
        }
        //没有向上交付的页,即nPages的首尾页映射一下即可
        //kPage要把Span中的每一页都映射是因为CentralCache中没有页的概念
        //对于CentralCache而言,它拿到的就是一个Span,然后把这个Span不断切割成等量小块内存
        //因此中间页的内存也会被切割,和大Span在逻辑上分离。
        // 例如: 某Span有 100 101 102 三个页
        //假设我们CentralCache层也只映射首尾页,当某个内存需要回收,那它首先需要根据地址找到它所在的页
        //如果它所在的页是101页,当我们拿着这个页去映射它对应的Span时,就会发现映射不到Span
        //因为我们只映射了首尾:100 102
        //但nPages不同,nPages映射的目的是为了在回收内存的时候去合并更大的页,
        //因此就要找某页的前后页,如果它的前后页也没有被使用,那么它们就能合并
        //例如: 页号为200的Span被回收,并尝试合并
        //它会向前找页号199的Span(Span中可能有多页,199是该Span的最后一页), 向后找页号201的Span(201是该Span的第一页),看它们是否被使用
        //但首尾位置是相对的,我们无法预知
        //就像如果199所在的Span有3页:197 198 199
        //对于200来说,它通过且只能通过199页去找所在的它所在Span是否被使用,
        //因为程序无法确定198是不是和199在同一个Span,如果不是,那么200要合并的是199所在的Span
        //而不是198所在的Span,因为200的Span只和199所在的Span连续
        //但对于196来说,他需要的却是197而不是199
        //因此相对位置无法确定,首尾页都必须映射。
        //并且上述例子又再次印证了为什么nPage不需要映射中间页
        //就像200不能够使用198去映射一样,中间页在Span中是没有被切割的,并且在这一层使用中间页映射是不合逻辑的
        _pageMapSpan[nPages->_pageId] = nPages;
        _pageMapSpan[nPages->_pageId + nPages->_n - 1] = nPages; //最后一页的页号 = _pageId + n - 1
        //将 i - page页的Span插入相应的页桶中, 然后将page页的Span交付上层。
        _pclist[nPages->_n].PushFront(nPages);
        //PushVmap();
        return kPages;
      }
      n++;
    }
    //走到这里,说明直到最后一个桶都没找到未分配的Span
    //需要直接向堆申请内存页。直接申请一个最大内存页:128页
    //Span* newSpan = new Span;
    Span* newSpan = _spanPool.New();
    void* ptr = SystemAlloc(NPage - 1);
    newSpan->_pageId = (PAGE_ID)ptr >> 13; // 页号 = 地址 / 8k
    newSpan->_n = NPage - 1;
    //链接到128页对应的桶
    _pclist[newSpan->_n].PushFront(newSpan);
    return GetSpanToUpper(k);  //再调一次该函数,自动完成切割并向上交付
  }
}

回收的时候消耗过大:map查找有消耗 + 锁竞争。 并且map越慢,竞争越激烈

精品文章推荐阅读:

相关文章
|
5天前
|
存储 编译器 Linux
【c++】类和对象(上)(类的定义格式、访问限定符、类域、类的实例化、对象的内存大小、this指针)
本文介绍了C++中的类和对象,包括类的概念、定义格式、访问限定符、类域、对象的创建及内存大小、以及this指针。通过示例代码详细解释了类的定义、成员函数和成员变量的作用,以及如何使用访问限定符控制成员的访问权限。此外,还讨论了对象的内存分配规则和this指针的使用场景,帮助读者深入理解面向对象编程的核心概念。
18 4
|
1月前
|
存储 程序员 编译器
简述 C、C++程序编译的内存分配情况
在C和C++程序编译过程中,内存被划分为几个区域进行分配:代码区存储常量和执行指令;全局/静态变量区存放全局变量及静态变量;栈区管理函数参数、局部变量等;堆区则用于动态分配内存,由程序员控制释放,共同支撑着程序运行时的数据存储与处理需求。
99 21
|
25天前
|
程序员 C++ 容器
在 C++中,realloc 函数返回 NULL 时,需要手动释放原来的内存吗?
在 C++ 中,当 realloc 函数返回 NULL 时,表示内存重新分配失败,但原内存块仍然有效,因此需要手动释放原来的内存,以避免内存泄漏。
|
28天前
|
存储 C语言 C++
【C++打怪之路Lv6】-- 内存管理
【C++打怪之路Lv6】-- 内存管理
35 0
【C++打怪之路Lv6】-- 内存管理
|
1月前
|
存储 C语言 C++
【C/C++内存管理】——我与C++的不解之缘(六)
【C/C++内存管理】——我与C++的不解之缘(六)
|
1月前
|
程序员 C语言 C++
C++入门5——C/C++动态内存管理(new与delete)
C++入门5——C/C++动态内存管理(new与delete)
62 1
|
1月前
|
C++
C/C++内存管理(下)
C/C++内存管理(下)
46 0
|
1月前
|
存储 Linux C语言
C/C++内存管理(上)
C/C++内存管理(上)
35 0
|
1月前
|
Linux C++
Linux c/c++文件虚拟内存映射
这篇文章介绍了在Linux环境下,如何使用虚拟内存映射技术来提高文件读写的速度,并通过C/C++代码示例展示了文件映射的整个流程。
45 0
|
4天前
|
存储 编译器 C++
【c++】类和对象(中)(构造函数、析构函数、拷贝构造、赋值重载)
本文深入探讨了C++类的默认成员函数,包括构造函数、析构函数、拷贝构造函数和赋值重载。构造函数用于对象的初始化,析构函数用于对象销毁时的资源清理,拷贝构造函数用于对象的拷贝,赋值重载用于已存在对象的赋值。文章详细介绍了每个函数的特点、使用方法及注意事项,并提供了代码示例。这些默认成员函数确保了资源的正确管理和对象状态的维护。
21 4