C++高并发内存池:ThreadCache层

目录

        1.前言

        2.内存池分配内存的流程

        3.内存池释放内存的流程

        4.ThreadCache层

        5.TLS 线程本地存储


前言

        本篇文章将着重对内存池的ThreadCache层进行讲解,而在了解ThreadCache层之前我会对向内存池申请分配内存以及释放内存的流程进行讲解,直观的感觉到在处理申请和释放内存的请求时内存池的操作,再对其ThreadCache层进行讲解


内存池分配内存的流程

        针对内存池的分配流程,将参照下图中的步骤进行详细讲解:

图1.内存池分配内存流程图

        步骤1:每一个线程对应一个ThreadCache,该ThreadCache是一对一关系,不需要考虑互斥的问题。当线程需要申请内存时,首先向对应的ThreadCache申请

        步骤2:当线程对应的ThreadCache存在多余内存时,则分配给对应线程

        步骤3:若线程对应的ThreadCache没有多余内存,则向CentralCache层申请内存。CentralCache层对申请的命令进行哈希桶的分配,并对调用的哈希桶进行上锁,防止死锁

        步骤4:当CentralCache层分配的哈希桶存在多余内存时,则返回内存至ThreadCache层,返回的内存再由ThreadCache进行分配

        步骤5:当CentralCache层分配的哈希桶没有多余内存时,则向PageCache层申请内存。PageCache层对申请的命令进行哈希桶的随机分配,并调用锁,使其同一时间段内只存在一个由CentralCache向PageCache申请内存的命令

        步骤6:当PageCache层分配的哈希桶存在多余内存时,则返回内存至CentralCache层,返回的内存再由CentralCache层进行分配

        步骤7:若PageCache分配的哈希桶没有多余内存时,则向OS申请内存

        步骤8:由OS分配内存至PageCache层,再由PageCache层进行分配

        步骤9:当线程一次性申请的空间过大(大于256KB)时,则会向PageCache层申请内存,若PageCache层没有多余内存则向OS申请后,把申请的内存挂到PageCache层的哈希桶上再返回给线程(释放内存参考下一小节)

        PS:在PageCache层以及CentralCache层中,若随机分配的哈希桶没有内存,则先在该层中寻找其他存在多余内存的哈希桶,只有所有的哈希桶都没有空闲内存时,才向下一层申请内存。当分配更大的哈希桶时,则会将该哈希桶划分为更小的哈希桶,并存放到同一层的管理哈希桶链表中


内存池释放内存的流程

        针对内存池的释放流程,将参照下图中的步骤进行详细讲解:

图2.内存池释放内存流程图

        步骤1:当线程释放内存时,把内存释放至ThreadCache层中

        步骤2:若ThreadCache层存在多余空间,不能管理释放的内存,则归还至CentralCache层,并将释放的内存挂在对应的哈希桶中,此步骤允许多个ThreadCache层同时归还内存

        步骤3:若CentralCache层存在多余空间,不能管理释放的内存,则归还至PageCache层,若归还的内存和当前空闲的内存地址相邻,则合并后再挂到哈希桶上进行统一管理

        步骤4:若PageCache层存在多余空间,不能管理释放的内存,则归还至OS层


ThreadCache层

        在使用内存池的项目中,每一个项目中存在的单独线程都指向一个唯一的ThreadCache,使用ThreadCahe,可以方便我们申请和释放不同大小的内存,而上一篇文章中所说的定长内存池并不能实现这一功能。为了方便我们申请和释放不同大小的内存,其ThreadCache层的设计结构如下图:

图1.ThreadCache层结构图

        由图可看出,ThreadCache存在一个链表,该链表的每一个元素的指向一个节点(取名为FreeList),该节点存在两个成员类型,一是指向下一个节点的指针,二是指向对应内存大小的指针,故ThreadCache也属于一种哈希桶的结构。针对以上分析,我们需要为ThreadCache类型定义一个数组,数组中的每一个元素都指向一个节点,而且为了实现ThreadCache的功能,即为线程分配内存,我们还需要对数组存储的链表进行一个出栈和入栈操作,当ThreadCache层没有内存时,我们还需要向CentralCache层申请内存,所以设计的ThreacCache类型和子节点(FreeList)类型如下:

static const size_t NFREELIST = 208;    //哈希桶的总数
static const size_t MAX_BYTES = 256 * 1024; //ThreadCache最大内存数

class ThreadCache{
public:
	void* Allocate(size_t size);    //分配内存
	void Deallocate(void* ptr, size_t size);    //释放内存
    void* FetchFromCentralCache(size_t index, size_t size); //向CentralCache申请内存
private:
	FreeList _freeLists[NFREELIST];  //哈希桶,FreeList节点后续会讲解
};

//哈希桶子节点的类型
class FreeList{
public:
  void Push(void* obj){    //使用头插法插入链表
    NextObj(obj) = _freeList;
    _freeList = obj;
  }
  void* Pop(){    //使用头删法取出链表
    void* obj = _freeList;
    _freeList = NextObj(obj);
    return obj;
  }
  bool Empty(){    //判断哈希桶节点是否为空
    return _freeList == nullptr;
  }
private:
  void* _freeList = nullptr;    //指向下一个节点
};

static void*& NextObj(void* obj)  //获取下一个节点的地址
{
  return *(void**)obj;    //转换为二级指针后再解引用
}

        为什么哈希桶的大小为208?而不是520?也不是250?这是由于ThreadCache管理的哈希桶总大小为256Byte,而如果每一个节点都指向一个内存,则指向共256KB的大小的指针就需要262144个指针(定义第一个指针指向1Byte,定义第二个指针指向2Byte,再定义第二个指针指向3Byte...以此类推则需要256个指针),这么做太浪费内存。所以我们采用了一种映射方式,减少指向内存的指针,这种映射方式如下图:

图2.ThreadCache层内存映射规则

        根据以上映射规则,当线程申请分配内存时,ThreadCache层会先对申请的内存进行计算,找出映射的哈希桶元素的下标,然后再出哈希桶中取出下标锁指向的指针,如果下标内没有指针,则从CentralCache层申请分配内存。在此基础上我们还需要考虑内存的释放,当ThreadCache不存在多余的内存时,会向CentralCache申请内存,释放流程为将ThreadCache管理的多余内存返回至CentralCache层中的哈希桶中,以下为ThreadCache类所含的函数实现:

//申请内存
void* ThreadCache::Allocate(size_t size)
{
  assert(size <= MAX_BYTES);
  size_t alignSize = SizeClass::RoundUp(size);
  size_t index = SizeClass::Index(size);
  if (!_freeLists[index].Empty())    //分配的哈希桶不为空
  {
    return _freeLists[index].Pop();  //返回分配的内存
  }
  else{    //分配的哈希桶为空
    return FetchFromCentralCache(index, alignSize);    //向CentralCache申请内存
  }
}
  
//释放内存
void ThreadCache::Deallocate(void* ptr, size_t size)
{
	assert(ptr);
	assert(size <= MAX_BYTES);
	size_t index = SizeClass::Index(size);//找到释放内存的对应的哈希桶下标
	_freeLists[index].Push(ptr);    //插入链表
 
	//当链表长度大于一次批量申请的内存时就开始还一段内存给CentralCache
	if (_freeLists[index].Size() > _freeLists[index].MaxSize())
	{
		ListTooLong(_freeLists[index], size);    //归还内存
	}
}
 
//归还内存至CentralCache层
void ThreadCache::ListTooLong(FreeList& list, size_t size)
{
	void* start = nullptr;
	void* end = nullptr;
	SizeClass::PopRange(start, end, list.MaxSize());
    //通过映射规则,找到内存映射后的大小。后续代码将实现PopRange()函数
	CentralCache::GetInstance()->ReleaseListToSpans(start, size);
    //对于CentralCache层的设计将在后续章节介绍    
}

        代码中所包含的assert宏,用于测试程序中的条件是否为真。如果条件为真,程序继续执行;如果条件为假(即断言失败),程序将打印一条消息到标准错误输出,并调用 abort() 函数终止程序执行。对此,我们已经实现了TreadCache层的分配与释放,还有子节点的插入和取出操作,接下来我们要实现的是上文所描述的映射规则,其代码实现如下:

//计算对象大小的对齐映射规则
class SizeClass{
public:
  static inline size_t _RoundUp(size_t bytes, size_t alignNum){  //计算桶的大小
    return ((bytes + alignNum - 1) & ~(alignNum - 1));    //对8的倍数进行向上取整
  }
  static inline size_t RoundUp(size_t size){    //计算内存映射后的大小
    //确定每个区间的对齐数
    if (size <= 128){
      return _RoundUp(size, 8);
    }
    else if (size <= 1024){
      return _RoundUp(size, 16);
    }
    else if (size <= 8 * 1024){
      return _RoundUp(size, 128);
    }
    else if (size <= 64 * 1024){
      return _RoundUp(size, 1024);
    }
    else if (size <= 256 * 1024){
      return _RoundUp(size, 8 * 1024);
    }
    else{
      assert(false);    //找不到对齐数
      return -1;
    }
  }

  static inline size_t _Index(size_t bytes, size_t align_shift){
    return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1; //计算桶下标
  }
  static inline size_t Index(size_t bytes) //计算映射的哪一个自由链表桶
  {
    assert(bytes <= MAX_BYTES);
    static int group_array[4] = { 16, 56, 56, 56 };    //每个区间链的个数
    if (bytes <= 128){
      return _Index(bytes, 3);
    }
    else if (bytes <= 1024){
      return _Index(bytes - 128, 4) + group_array[0];
    }
    else if (bytes <= 8 * 1024){
      return _Index(bytes - 1024, 7) + group_array[1] + group_array[0];
    }
    else if (bytes <= 64 * 1024){
      return _Index(bytes - 8 * 1024, 10) + group_array[2] + group_array[1] + group_array[0];
    }
    else if (bytes <= 256 * 1024){
      return _Index(bytes - 64 * 1024, 13) + group_array[3] + group_array[2] + group_array[1] + group_array[0];
    }
    else{
      assert(false);
    }
    return -1;
  }
};

        实现了映射规则的算法,ThreadCache层还剩下一个函数FetchFromCentralCache(),用于当哈希桶内的内存不足时,向CentralCache层申请分配内存。在设计该函数时我们需要考虑到以下几点:

        1.假如向central chche申请过多空间,后续一直不用就会产生浪费

        2.当申请的空间多的时候,不能只申请固定的空间,从而导致小块的内存资源多,而大块的内存资源少

        对此在设计FetchFromCentralCache()函数之前,我们还需要设计一个用于实现以上两点的函数,其代码实现如下:

static size_t NumMoveSize(size_t size){
		assert(size > 0);    //判断申请的内存是否大于0
		if (num < 2) num = 2;    //size越大,返回的值越小
 		if (num > 512) num = 512;//size越小,返回对值越大
		return num;
}

        上述代码中,主要是对申请的内存实现了慢增长的方式(指的是在在资源分配过程中,采取逐渐增加申请内存大小的方式,而不是一次性大幅度增加内存申请的大小。这样做可以避免申请的空间过大而导致的浪费),在了解慢增长的方式后,我们需要对FetchFromCentralCache()函数进行实现,其代码如下:

void* ThreadCache::FetchFromCentralCache(size_t index, size_t size)
{ 
    //申请的批量资源对象的个数
	size_t batchNum = min(NumMoveSize(size), _freeLists[index].MaxSize());  
	if (_freeLists[index].MaxSize() == batchNum){
		_freeLists[index].MaxSize() += 1;
	}
    void* end = nullptr;  //定义尾节点指针
    void* start = nullptr;//定义头节点指针
 
    //实际申请到的对象个数
	size_t actulNum = CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, size);
	assert(actulNum >= 1);    //保证获取的内存个数最少为一个
	if (actulNum == 1){
		assert(start == end);
		return start;
	}
	else{
		_freeLists[index].PushRange(NextObj(start), end, actulNum - 1);
		return start;
    }
}

        在增加了对CentralCache申请内存时采用的慢增长方式,我们还需要对节点类型FreeList进行完善,目的是将向CentralCache申请的内存插入到ThreadCache层的哈希桶中,其需要增加的FreeList的类型代码如下:

class FreeList
{
public:
	void PushRange(void* start, void* end,size_t n)
	{
		NextObj(end) = _freeList;
		_freeList = start;    //将当前节点的尾指针指向申请的内存
		_size += n;    //记录可用内存的个数
	}
private:
	size_t _maxSize = 1;  //用于慢增长部分
};

TLS 线程本地存储

        TLS全称线程本地存储,是一种编程技术,用于在线程级别上创建和管理局部数据。允许每个线程拥有自己独立的存储空间,以避免数据共享带来的竞争条件和数据混乱问题。以下为TLS的优缺点:

        优点:

                1.数据隔离:每个线程都有自己的存储空间,不会干扰其他线程的数据。
                2.线程安全:避免了竞争条件,减少了线程同步的需求。
                3.简化多线程编程:提供了一种更容易管理数据的方式,特别是对于线程局部状态。
        缺点:

                1.内存开销:为每个线程维护独立的存储空间可能会增加内存开销。
                2.复杂性:需要小心管理TLS变量,否则可能引入难以调试的问题。

        如何实现TLS?只需要调用_declspec(thread) ,_declspec(thread) 是 Microsoft Visual C++ 编译器的一个扩展特性,它用于指定一个变量为线程局部存储。当一个变量被声明为_declspec(thread) ,这意味着每个线程都会有这个变量的一个独立副本,而不是在所有线程间共享同一个副本

__declspec(thread) ThreadCache* pTLSThreadCache = nullptr;
 
//在调用ThreadCache层对象时判断一下即可
if (pTLSThreadCache == nullptr){
	pTLSThreadCache = new ThreadCache;
}
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Wild_Pointer.

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值