目录
1.前言
前言
本篇文章将着重对内存池的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;
}