高并发内存池项目
项目源码地址:高并发内存池
什么是内存池
1.池化技术
所谓池化技术,就是程序先向系统申请过量的资源,然后自己进行管理,以备不时之需
之所以要申请过量的资源,是因为每次申请该资源都有较大的开销。一次性IO可以提高程序运行的效率
2.内存池
内存池是指程序预先从OS中申请一块足够大内存。此后程序申请内存时,直接从内存池中获取
当程序释放内存的时候,并不真正将内存返回给OS,而是返回内存池。只有程序退出时,内存池才将之前申请的内存真正释放
3.内存池主要解决的问题
内存池提高了效率,也解决了内存碎片问题
如上图所示,当对象销毁后有384Byte的空间,但是如果要申请超过256Byte的空间却申请不出来,因为这两块空间碎片化了,是不连续的
内存碎片分为内碎片和外碎片。上图所示的为外碎片问题
内碎片(Internal Fragmentation):指在分配内存时,由于分配单位的大小大于实际需要的大小,导致分配出的内存块中有部分空间无法被有效利用而浪费的现象。内碎片是由内部因素造成的,例如内存分配策略导致的空间浪费
外碎片(External Fragmentation):指在动态内存分配过程中,由于已分配的内存块不连续或者存在空闲区域但大小无法满足新的内存请求时,导致系统中出现大量无法利用的零散空闲区域的现象。外碎片是由外部因素造成的,例如内存释放的顺序或者大小不当导致的空间浪费
4.malloc
void* malloc(size_t size);
malloc函数用于在程序运行时从堆(heap)中分配指定大小的字节数,并返回一个指向分配内存的指针。如果分配成功,则返回指针;如果分配失败,则返回NULL
其中,size_t是一个无符号整数类型,用于表示要分配的字节数。malloc函数接受一个size参数,该参数指定要分配的字节数
C/C++中动态申请内存是通过malloc进行的,但实际上并不是直接去堆区获取内存的
malloc就是一个内存池。malloc()相当于向OS申请了较大的内存空间,然后分块给程序使用。当需要内存不足使再重新向OS申请
定长内存池
其中char* memory是向系统申请的大块内存空间,使用char因为char是一个字节长,便于内存划分
void* _freeList用于链接各种类型的内存块,用每个内存块的前4个字节保存下一个内存块的地址,从而进行链接
当对象申请的空间释放返回时,将有自由链表_freelist进行回收链接
空间申请
T* New()//申请一个小内存块,返回首地址
{
if (_memory == nullptr)
{
_lastBytes = 128 * 1024;
_memory = (char*)malloc(128*1024);
if (_memory == nullptr)
{
throw std::bad_alloc();
}
}
T* obj = (T*)_memory;
_memory += sizeof(T);
return obj;
private:
char* _memory=nullptr;//一次向系统申请的大块内存,类型为char是因为char是一个字节方便进行分割
void* _freeList = nullptr;//被释放的小内存块链接后的链表头指针
}
如上所示的代码开辟了128KB的空间,并可以给不同类型的对象开辟空间,通过+=操作实现了内存块地址的后移
但存在如下问题,当整块内存分配完毕时,_memory并不指向空而指向下一个内存的地址。此时如果向后访问会导致越界
因此需要增加一个属性size_t last,用于标识剩余空间的大小
public:
T* New()//申请一个小内存块,返回首地址
{
//if (_lastBytes == 0)最后的剩余空间不一定刚好分配完毕128KB
if (_lastBytes < sizeof(T))
{
_lastBytes = 128 * 1024;
_memory = (char*)malloc(128*1024);
if (_memory == nullptr)
{
throw std::bad_alloc();
}
}
T* obj = (T*)_memory;
_memory += sizeof(T);
_lastBytes -= sizeof(T);
return obj;
}
private:
char* _memory=nullptr;//一次向系统申请的大块内存,类型为char是因为char是一个字节方便进行分割
void* _freeList = nullptr;
size_t _lastBytes = 0;
在空间释放之后,大内存块会重新拥有可使用的内存。所以要优先使用归还的空间,也就是_freeList单链表的头删
T* New()//申请一个小内存块,返回首地址
{
T* obj = nullptr;
//优先使用已经返还的空间
if (_freeList)
{
void* next = *((void**)_freeList);//下一次使用的空间为第一个节点的前sizeof(void*)空间内
obj = (T*)_freeList;//obj指向第一个节点
_freeList = next;
return obj;
}
else
{
//if (_lastBytes == 0)最后的剩余空间不一定刚好分配完毕128KB
if (_lastBytes < sizeof(T))
{
_lastBytes = 128 * 1024;
_memory = (char*)malloc(128 * 1024);
if (_memory == nullptr)
{
throw std::bad_alloc();
}
}
obj = (T*)_memory;
_memory += sizeof(T);
_lastBytes -= sizeof(T);
return obj;
}
}
如上已经完成了空间的申请使用和释放后空间的重新使用。但还有一些细小的问题需要改进
在_freeList中使用节点的前void*大小的空间用来存放下一个节点空间的地址,但前面使用的
_memory += sizeof(T);
_lastBytes -= sizeof(T);
可能存在类型T的大小小于void*的问题,比如T为char只有一个字节
因此改动如下
size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
_memory += objSize;
_lastBytes -= objSize;
T可能是自定义类型,因此使用定位new手动调用构造函数进行构造
空间申请代码最终如下
T* New()//申请一个小内存块,返回首地址
{
T* obj = nullptr;
//优先使用返还的空间
if (_freeList)
{
void* next = *((void**)_freeList);//下一次使用的空间为第一个节点的前sizeof(void*)空间内
obj = (T*)_freeList;//obj指向第一个节点
_freeList = next;
}
else
{
//if (_lastBytes == 0)最后的剩余空间不一定刚好分配完毕128KB
if (_lastBytes < sizeof(T))
{
_lastBytes = 128 * 1024;
_memory = (char*)malloc(128 * 1024);
if (_memory == nullptr)
{
throw std::bad_alloc();
}
}
obj = (T*)_memory;
//可能存在类型T的大小小于void*的问题
//_memory += sizeof(T);
//_lastBytes -= sizeof(T);
size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
_memory += objSize;
_lastBytes -= objSize;
}
//对于自定义类型,要调用构造函数进行初始化。定位new
new(obj)T;
return obj;
}
空间释放
空间释放的逻辑就是将对象释放的空间连接到_freeList上,并且用该空间的前4个字节存储下一个对象释放空间的地址
void Delete(T* obj)
{
if (_freeList == nullptr)
{
_freeList = obj;
*(int*)obj=nullptr;//将obj转换成int*类型,并解引用获取头四个字节,将其置空用于指向下一个对象释放的空间
}
}
如果是32位系统环境下,上面的程序是没有问题的。但如果是64位,就变成8个字节了,程序出现问题
void Delete(T* obj)
{
if (_freeList == nullptr)
{
_freeList = obj;
*(void**)obj = nullptr;
//将obj转换为void**类型,即obj指向空间存储void*数据,而void*32位下是4,64位下是8
}
}
因此将程序更改为如上形式,从而完成了第一次的插入
由于单链表进行尾插的时间复杂度是O(n),所以采用头插的方式进行多个节点的链接。由于使用头插,所以是否有节点并不影响头插的实现
void Delete(T* obj)
{
*(void**)obj = nullptr;
_freeList = obj;
}
T可能是自定义类型,因此手动调用析构函数进行析构
最终空间释放代码如下:
void Delete(T* obj)
{
//对于自定义类型,显示调用析构函数清理对象
obj->~T();
//*(int*)obj=nullptr;
//将obj转换成int*类型,并解引用获取前四个字节,将其置空用于指向下一个对象释放的空间
//但只适用于32位空间
*(void**)obj = nullptr;
_freeList = obj;
//将obj转换为void**类型,即obj指向空间存储void*数据,而void*32位下是4,64位下是8
}
测试结果
高并发内存池整体框架设计
内存池需要考虑如下几个问题:①性能问题②多线程环境下,锁竞争问题③内存碎片问题
concurrent memory pool主要有以下三部分构成:
- thread cache:线程缓存是每个线程所有的,用于小于256KB内存的分配,线程从这里申请内存不需要加锁,每个线程独享一个cache。因此并发线程池十分高效
- central cache:中心缓存是所有线程共享的。thread cache是按需从central cache中获取对象。central cache在合适的时机回收thread cache中的对象,避免出现一个线程占用太多内存而其他线程内存紧张
central cache的内部实现是一个哈希桶,是存在竞争的,因此从这里取内存的对象需要加锁 - page cache:页缓存是在central cache缓冲的上一层缓存,存储的内存是以页为单位存储以及分配的。当central cache没有内存对象时,从page cache分配出一定数量的page,并切割成定长大小的小块内存分配给central cache
page cache会回收central cache满足条件的span对象,并且合并相邻的页,组成更大的页,以解决内存外碎片问题
Trhead Cache
之前我们实现定长内存池的时候是一个永远申请一个固定字节的大小,现在我们想要申请随机空间的大小,那么这个时候我们可以采取一个类似于哈希桶的结构,当我们想要开辟某个大小空间的时候,我们就从对应的下标的桶中取出一个对象大小
每个哈希桶是一个按桶位置映射大小的内存块对象的自由链表,每个线程都会有一个thread cache对象
如上图所示,最小的分配内存大小为8Byte,即使申请3B、5B的空间也在thread cache中分配8Byte的空间
这样方便了对分配空间进行管理,因为如果对每一种大小都建立像定长数组中的自由链表,那么thread cache管理的256KB内存分配将要256*1024=262144个链表
但这样定性的规定内存大小,也同样导致了内存碎片问题,这种就是内碎片
此时就需要做一个平衡,如果给的空间与实际需求空间差距较小,那么需要开辟更大的哈希桶;如果给的空间与实际需求空间差距较大,就会造成空间的浪费
计算对齐数
因此引入内存对齐机制,如果都按照8字节对其的话,就需要128*1024/8=32768,所以引入如下规则
根据上面的公式,我们要得到某个区间的最大浪费率,就应该让分子取到最大,让分母取到最小。比如129~1024这个区间,该区域的对齐数是16,那么最大浪费的字节数就是15,而最小对齐后的字节数就是这个区间内的前16个数所对齐到的字节数,也就是144,那么该区间的最大浪费率也就是15÷144≈10.42%左右
size_t _RoundUp(size_t size, size_t alignNum)//alignNUm为对齐数
{
size_t alignSize;
if (size % alignNum != 0)
{
alignSize = (size / alignNum + 1) * alignNum;//计算实际分配的内存大小
}
else
{
alignSize = size;
}
}
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 _RoundUp(size_t bytes, size_t alignNum)
{
return ((bytes + alignNum - 1) & ~(alignNum - 1));
}
计算哈希桶中下标
按照对齐规则进行计算,也就是[1,128]字节大小的数据的桶号对应[0,16),以此类推
size_t _Index(size_t bytes, size_t alignNum)
{
if (bytes % alignNum == 0)
{
return bytes / alignNum - 1;
}
else
{
return bytes / alignNum;
}
}
内存申请与释放
在Allocate函数中,首先根据所需内存大小计算对齐后的大小和对应的内存块索引,然后检查对应的自由链表是否有空闲内存块,如果有则直接返回,否则调用FetchFromCentralCache从中心缓存获取内存
申请内存:
- 当内存申请size<=256KB,先获取到线程本地存储的thread cache对象,计算size映射的哈希桶自由链表下标i
- 如果自由链表_freeLists[i]中有对象,则直接Pop一个内存对象返回
- 如果_freeLists[i]中没有对象时,则批量从central cache中获取一定数量的对象,插入到自由链表并返回一个对象
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);
}
}
释放内存:
- 当释放内存小于256k时将内存释放回thread cache,计算size映射自由链表桶位置i,将对象Push到_freeLists[i]
- 当链表的长度过长,则回收一部分内存对象到central cache
void ThreadCache::Deallocate(void* ptr, size_t size)//释放空间
{
assert(ptr);
assert(size <= MAX_BYTES);
//找到桶中下标并插入对象
size_t index = SizeClass::Index(size);
_freeLists[index].Push(ptr);
}
TLS线程局部存储
TLS全称为Thread Local Storage,是一种变量的存储方法,这个变量在他所在的线程内是全局可访问的。这是本项目的入口
但是不能被其他线程访问到,这样就保持了数据的线程独立性
在POSIX的pthread.h库中提供了一组API来实现该功能
int pthread_key_create(pthread_key_t *key, void (*destructor)(void*));
int pthread_key_delete(pthread_key_t key);
void *pthread_getspecific(pthread_key_t key);
int pthread_setspecific(pthread_key_t key, const void *value);
在本项目中采用TLS静态初始化 static __declspec(thread) ThreadCache* TLS_ThreadCache = nullptr;
__declspec(thread)的前缀是Microsoft添加给Visual C++编译器的一个修改符。它告诉编译器,对应的变量应该放入可执行文件或DLL文件中它的自己的节中
__declspec(thread)后面的变量必须声明为函数中(或函数外)的一个全局变量或静态变量
不能用于局部变量,因为局部变量是与特定线程的函数调用相关联的,每次函数调用结束,局部变量的生命周期也会结束,所以将局部变量声明为__declspec(thread)是没有意义的
#pragma once
//通过TLS对ThreadCache封装
#include"common.h"
#include"ThreadCache.h"
#include<thread>
static void* ConcurrentAlloc(size_t size)//
{
//通过TLS每个线程无锁的获取自己的专属ThreadCache对象
if (TLS_ThreadCache == nullptr)
{
TLS_ThreadCache = new ThreadCache;
}
//测试打印
cout << std::this_thread::get_id() << ":" << TLS_ThreadCache << endl;
return TLS_ThreadCache->Allocate(size);
}
static void ConcurrentFree(void* ptr, size_t size)
{
assert(TLS_ThreadCache);
TLS_ThreadCache->Deallocate(ptr, size);//将申请的空间还回哈希桶
}
通过上述的TLS封装,每个线程可以拥有自己的ThreadCache对象,在多线程环境下能够实现无锁的内存分配和释放操作,有效的提高了并发性能
从CentralCache中申请内存
- 采用慢开始反馈调节算法,每次ThreadCache申请就给批量的内存,小对象多给,大对象少给
- 最开始不会一次向CentralCache申请太多,可能用不完
- 如果不断申请则每次申请的空间逐渐变大,直至上限NumMoveSize(size)
void* ThreadCache::FetchFromCentralCache(size_t index, size_t size)//从中心缓存获取内存
{
size_t batchNum = std::min(_freeLists->MaxSize(), SizeClass::NumMoveSize(size));//计算给内存的数量
if (_freeLists[index].MaxSize() == batchNum)
{
_freeLists[index].MaxSize() += 1;
}
return nullptr;
}
//一次ThreaCache从CentralCache获取多少个对象
static SizeClass::size_t NumMoveSize(size_t size)
{
assert(size > 0);
int num = MAX_BYTES / size;//size大则num小,size小则num大。即小的多给,大的少给
if (num < 2)
{
num = 2;
}
if (num > 512)//最多512个避免太多
{
num = 512;
}
return num;
}
在ThreadCache::FetchFromCentralCache中计算出ThreadCache向CentralCache申请对象的数量后,还需要知道CentralCache实际能分配的对象数量
CentralCache不一定有足够的数量给ThreadCache,因此其有多少给多少。如果多给了则将多给的保存在ThreadCache的自由链表中,以便下次使用
void* ThreadCache::FetchFromCentralCache(size_t index, size_t size)//从中心缓存获取内存
//采用慢开始反馈调节算法:每次ThreadCache申请就给批量的内存,小对象多给,大对象少给
{
//最开始不会一次向CentralCache申请太多,可能用不完
//如果不断申请则每次申请的空间逐渐变大,直至上限NumMoveSize(size)
size_t batchNum = std::min(_freeLists->MaxSize(), SizeClass::NumMoveSize(size));//计算给内存的数量
if (_freeLists[index].MaxSize() == batchNum)
{
_freeLists[index].MaxSize() += 1;
}
void* start = nullptr;
void* end = nullptr;
size_t actualNum = CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, size);
//actualNum是实际CentralCache分配给ThreadCache的数量,因为不一定有足够的数量给ThreadCache
assert(actualNum > 1);
if (actualNum == 1)//如果CentralCache只能分配给ThreadCache一个就直接给
{
assert(start == end);
return start;
}
else//如果能多分配,则直接将一个给线程,剩余的保存到ThreadCache中
{
_freeLists[index].PushRange(NextObj(start), end);//将第二个到end处的节点放入自由链表
return start;//返回第一个分配的内存
}
return nullptr;
}
CentralCache
CentralCache与ThreadCache类似,也是哈希桶结构,它的映射规则与ThreadCache相同
不同之处在于,ThreadCache每个线程都有,而CentralCache是所有线程共享的,因此是需要加锁的
CentralCache使用的是桶锁,桶锁将数据划分为多个桶(或分区),每个桶拥有一个独立的锁。当线程需要访问某个桶中的数据时,它只需获取该桶对应的锁,而不需要锁住整个数据集
同时,ThreadCache每个位置下面挂的都是自由链表,而CentralCache下面挂的是SpanList链表结构,其下管理的是以页为单位的span块,每一个span都按照对应桶号的字节对齐大小成为一个又一个小对象
这些小对象是在一个span中切分得到的,目的是为了将空间返回时便于整合小对象
比如下图中的256KB,一个span就至少需要32页;而8Byte就由一个span分割而来
这里一定要明确,span可大可小,最大有32页,最小只有1页。每个span下面自由链表连接的对象大小是不一样的。ThreadCache申请多大的,就从对应大小的span下面拿
可以看出,CentralCache中的SpanList是一个双向链表,便于Span的删除返还给PageCache
Span
Span 是一个在页面缓存管理中表示一块内存区域的数据结构。每个 Span 对象代表了一块连续的内存页,并记录了该内存块的起始页号 _pageId 和包含的页数 _n。通过这样的方式,可以更有效地管理大块内存的分配和释放
当需要分配内存时,Span 可以根据需要进行切分,从而提供适当大小的内存块。当释放内存时,Span 也可以合并相邻的空闲内存块,以便将多个小块合并为一个大块
这个结构体主要用于管理大块内存切分为小块,并记录这些小块的状态信息
假设一页大小为8KB,即213 (本项目中一个span的大小就为8KB)
在32位程序下:232/213=219,即需要52万个页
在64位程序下:264/213=251,即需要千万亿个页,这远超了size_t的范围
因此需要使用条件编译,根据不同的操作系统定义一个PAGE_ID的类别别名。在64位Windows系统上,PAGE_ID被定义为unsigned long long类型;而在32位Windows系统上,PAGE_ID被定义为size_t类型
#ifdef _WIN64
typedef unsigned long long PAGE_ID;
#elif _WIN32
typedef size_t PAGE_ID;
#endif
//CentralCache和PageCache都需要使用
//Span管理以页为单位的大块内存,是SpanList中的节点
struct Span
{
PAGE_ID _pageID=0;//页号,即第几个页
size_t _n=0;//包含的页的数量
Span* _next=nullptr;//双向链表的结构
Span* _prev=nullptr;
size_t _objSize = 0;
size_t _useCount=0;//切好的小块内存,被分配给ThreadCache的数量
void* _freeList=nullptr;//切好的小块内存的自由链表
bool _isUse=false;//判断该span是否在被使用
};
单例模式
从前面的内存池结构可以看出,每个线程都有各自独立的ThreadCache,但它们都有相同的一个CentraCache,因此一个进程中只存在一个CentralCache,非常适合使用单例模式
//采用单例模式,饿汉
class CentralCache
{
public:
static CentralCache* GetInstance()
{
return &_sInst;
}
// 获取一个非空的span
Span* GetOneSpan(SpanList& list, size_t byte_size);
// 从中心缓存获取一定数量的对象给thread cache
size_t FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size);
private:
SpanList _spanLists[NFREELIST];//CentralCache桶的数量与ThreadCache一样
private:
static CentralCache _sInst;//整个进程只有一个CentralCahche
CentralCache()
{}
CentralCache(const CentralCache&) = delete;
CentralCache& operator=(const CentralCache&) = delete;
};
注意:静态成员变量_sInst不能在头文件中定义,因为如果在头文件中定义静态成员变量,当多个源文件引用该头文件时,就会导致多次定义静态成员变量的情况,从而引发链接错误
其中FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size);函数用于计算CentralCache能给ThreadCache对象的数量
内存申请与释放
申请内存:
- 当thread cache中没有内存时,就会批量向central cache申请一些内存对象,这里的批量获取对象的数量使用了类似网络tcp协议拥塞控制的慢开始算法;
central cache也有一个哈希映射的spanlist,spanlist中挂着span,从span中取出对象给thread cache,这个过程是需要加锁的,不过这里使用的是一个桶锁,尽可能提高效率
// 从中心缓存获取一定数量的对象给thread cache
size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size)
{
size_t index = SizeClass::Index(size);
_spanLists[index]._mutex.lock();//多线程向CentralCache申请,因此需要加锁
Span* span = GetOneSpan(_spanLists[index], size);//从桶中找一个非空的span
assert(span);
assert(span->_freeList);
//从span中获取batchNum个对象,如果不够有多少给多少
start = span->_freeList;
end = start;
size_t i = 0;
size_t actualNum = 0;
while (i < batchNum - 1 && NextObj(end) != nullptr)
{
end = NextObj(end);
i++;
actualNum++;
}
span->_freeList = NextObj(end);
NextObj(end) = nullptr;
_spanLists[index]._mutex.unlock();
return actualNum;
}
误区:
这个时候不要担心一次性获取不到足够的对象,然后再去找pageache中获取新的span再拿剩余没获取完的对象,没有必要。
因为我们每次申请空间实际上只需要弹出一个对象就够了(因为那1个对象的大小就是经过计算、对齐之后的本次申请的大小,这个大小必然是大于等于我们的申请空间)
所以说只要span不为空,只要还有一个对象,相当于就满足了这次的申请空间的需求,而下次再申请的时候,如果对应spanlist中一个对象都没有的话,Getonespan函数就会到对应的pageache获取
所以这里不够时,就多少拿多少就行。
- central cache映射的spanlist中所有span的都没有内存以后,则需要向page cache申请一个新的span对象,拿到span以后将span管理的内存按大小切好作为自由链表链接到一起。然后从span中取对象给thread cache
- central cache的中挂的span中use_count记录分配了多少个对象出去,分配一个对象给threadcache,就++use_count
释放内存:
- 当thread_cache过长或者线程销毁,则会将内存释放回central cache中的,释放回来时–use_count。当use_count减到0时则表示所有对象都回到了span,则将span释放回page cache,page cache中会对前后相邻的空闲页进行合并
void CentralCache::ReleaseListToSpans(void* start, size_t size)
{
size_t index = SizeClass::Index(size);
_spanLists[index]._mutex.lock();
while (start)
{
void* next = NextObj(start);
Span* span = PageCache::GetInstance()->MapObjectToSpan(start);
NextObj(start) = span->_freeList;//将span加入表中
span->_freeList = start;
span->_useCount--;
//所以span切分出去的小块内存都回收了,CentralCache返回给PageCache
if (span->_useCount == 0)
{
_spanLists[index].Erase(span);
span->_freeList = nullptr;
span->_next = nullptr;
span->_prev = nullptr;
//释放span给PageCache,使用PageCache的锁,将桶锁解除
_spanLists[index]._mutex.unlock();
PageCache::GetInstance()->_pageMutex.lock();
PageCache::GetInstance()->ReleaseSpanToPageCache(span);
PageCache::GetInstance()->_pageMutex.unlock();
_spanLists[index]._mutex.lock();
}
}
_spanLists[index]._mutex.unlock();
}
为了找到ThreadCache自由链表返回给CentralCache中spanlist的映射关系,在PageCache中定义一个映射表,用来记录页号PAGE_ID与span的映射关系
在PageCache中定义的原因是:CentralCache的哈希桶中的span也会释放给PageCache的spanlist,也需要映射
CentralCache从PageCache获取一个新的Span
对应上面申请内存的第二点,获取一个非空的span
注意span并不是像obj一样分发完了就不存在了,它的结构是一个固定的结构,所以我们每次都要遍历一遍在这个桶的spanlist的链表,看是否有空的span
如果没有的话我们就应该往下一层pageache中去获取span,获取完成以后,把span的使用设置成正在被使用的状态,此时问题是获取一个几页的span比较合适呢?
// 计算一次向系统获取几个页
// CentralCache的单个对象大小从8byte->256KB
static size_t NumMovePage(size_t size)
{
size_t num = NumMoveSize(size);//ThreaCache一次从CentralCache获取的最大对象数量,是上限值
size_t npage = num * size;//总共字节大小
npage >>= PAGE_SHIFT;//PAGE_SHIFT=13,一页是8KB也就是13位。右移13位相当于/8k
if (npage == 0)
npage = 1;//至少给1页
return npage;
}
首先计算ThreaCache一次从CentralCache获取的最大对象数量,然后得到ThreadCache一次向CentralCache最多获取的字节数,再计算出需要申请多少页的span
// 从SpanList或者page cache获取一个span
Span* CentralCache::GetOneSpan(SpanList& list, size_t size)
{
Span* it = list.Begin();
while (it != list.End())
{
if (it->_freeList != nullptr)//span下面的自由链表不为空就返回
{
return it;
}
else
{
it = it->_next;
}
}
list._mutex.unlock();//先把central cache的桶锁解掉,这样如果其他线程释放内存对象回来,不会阻塞
//如果CentralCache的list中没有非空span,只能向下层PageCache申请
size_t page_num = SizeClass::NumMovePage(size);//需要的页数
Span* span = PageCache::GetInstance()->NewSpan(page_num);//向CentralCache申请一个page_num个页的内存块
//计算从PageCache申请的大块内存span的起始地址和大小(字节)
char* start=(char*)(span->_pageID << PAGE_SHIFT);//序号*每页大小
size_t bytes = span->_n << PAGE_SHIFT;//页的数量*每页大小
char* end = start + bytes;
//把大块内存切割成自由链表
//1.先切一块下来作为链表头节点,节点大小为size,便于尾插
span->_freeList = start;
start += size;
void* tail = span->_freeList;
while (start < end)
{
NextObj(tail) = start;
tail = start;
start += size;
}
list._mutex.lock();// 切好span以后,需要把span挂到桶里面去的时候,再加锁
list.PushFront(span);//把切分好的span插入到CentralCache中的list中
return span;
}
此时需要思考,当CentralCache向PageCache申请时,是否需要CentralCache的桶锁呢?
关于锁:这里的加锁是以桶为单位的,只有两个线程同时访问到同一个桶时,才需要加锁
当CentralCache对象不足时,需要从PageCache中拿去时可以将桶锁解锁。此时是去PageCache中操作,在操作过程中允许其他线程返还一部分对象,所以这里打开桶锁
而我们从PageCache拿到一个span后,可以把大锁放开,因为这个span是我们新申请的,其他的线程就算是再去PageCache中申请,也不可能拿到当前已经申请完成的span,所以这里可以把大锁放开,再进行span的切分操作
当span被切分好了对象并插入到对应的桶的时候,需要在对应的桶里进行插入操作,有可能会造成一个线程在插入,另一个线程在获取,两个线程就可能对同一个桶同时进行操作,线程就不安全了,所以需要加锁
PageCache
PageCache的映射规则与CentralCache不同,且PageCache内部的大块内存不切小,它是为CentralCache提供服务的
与threadache和centralache不同的是,Pageache的基本单位是span,而其哈希桶下标所对应的下标是表示这个哈希桶挂着的是一个几页的span,这也是方便了centralache向pageache中获取的时候直接以n页的span为目的获取
内存申请与释放
申请内存:
- 当central cache向page cache申请内存时,page cache先检查对应位置有没有span,如果没有则向更大页寻找一个span,如果找到则分裂成两个
比如:申请的是4页page,4页page后面没有挂span,则向后面寻找更大的span,假设在10页page位置找到一个span,则将10页page span分裂为一个4页page span和一个6页page span - 如果找到_spanList[128]都没有合适的span,则向系统使用mmap、brk或者是VirtualAlloc等方式申请128页page span挂在自由链表中,再重复1中的过程
- 需要注意的是central cache和page cache 的核心结构都是spanlist的哈希桶,但是他们是有本质区别的,central cache中哈希桶,是按跟thread cache一样的大小对齐关系映射的
他的spanlist中挂的span中的内存都被按映射关系切好链接成小块内存的自由链表。而page cache 中的spanlist则是按下标桶号映射的,也就是说第i号桶中挂的span都是i页内存
Span* PageCache::NewSpan(size_t k)
{
assert(k > 0 && k < NPAGES);
//如果有k页的span则直接头删返回
if (_spanlists[k].Empty())
{
return _spanlists[k].PopFront();
}
//检查后面的桶是否有span,也就是找更大的span切分
for (size_t i = k+1; i < NPAGES; i++)
{
if (!_spanlists[i].Empty())//不为空则切分
{
Span* nspan = _spanlists[i].PopFront();//获取大span
Span* kspan = new Span;//需要的k页大小的span
//从nspan头部切一个k页的span
kspan->_pageID = nspan->_pageID;
kspan->_n = k;
nspan->_pageID += k;
nspan->_n -= k;
_spanlists[nspan->_n].PushFront(nspan);
return kspan;
}
}
//没有超过k页大小的span了,从堆申请一个128页的span
Span* bigSpan = new Span;
void* ptr = SystemAlloc(NPAGES - 1);
bigSpan->_pageID = (PAGE_ID)ptr >> PAGE_SHIFT;//计算起始地址
bigSpan->_n = NPAGES-1;
_spanlists[bigSpan->_n].PushFront(bigSpan);
return NewSpan(k);
}
由于PageCache也同样只有一个实例,在多线程环境下需要考虑加锁
不能直接在上面的代码中加锁,因此函数是递归的使用普通互斥锁当递归返回时会造成死锁。可以使用recursive_mutex递归互斥锁解决该问题
其中从堆中申请内存同样需要考虑系统的问题,因此也使用条件变量
// 直接去堆上按页申请空间
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32
void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else
// linux下brk mmap等
#endif
if (ptr == nullptr)
throw std::bad_alloc();
return ptr;
}
释放内存:
- 如果central cache释放回一个span,则依次寻找span的前后的没有在使用的空闲span,看是否可以合并,如果合并继续向前寻找。这样就可以将切小的内存合并成大的span,减少内存碎片
- 如果central cache中的span的usecount等于0,说明切分给threadcache的小块内存都回收了,则centralcche把这个span还给page cache。pagecache通过页号查看前后的相邻页是否空闲,如果空闲则合并出更大的页
申请内内存结果展示
从上图可以看出,每个地址的差值刚好为8,且是同一个进程的不同线程
大于256KB大块内存申请问题
- 小于等于256KB时,256KB也就是32页,可以使用三级缓存结构
- 大于256KB时。如果在128页以下,可以从PageCache申请,如果大于128页从系统堆申请
static void* ConcurrentAlloc(size_t size)//
{
if (size > MAX_BYTES)
{
size_t alignSize = SizeClass::RoundUp(size);//对齐
size_t kpage = alignSize >> PAGE_SHIFT;//计算要多少页
PageCache::GetInstance()->_pageMutex.lock();
Span* span = PageCache::GetInstance()->NewSpan(kpage);
span->_objSize = size;
PageCache::GetInstance()->_pageMutex.unlock();
void* ptr = (void*)(span->_pageID << PAGE_SHIFT);//页号*8k=地址
return ptr;
}
else
{
//通过TLS每个线程无锁的获取自己的专属ThreadCache对象
if (TLS_ThreadCache == nullptr)
{
TLS_ThreadCache = new ThreadCache;
}
//测试打印
cout << std::this_thread::get_id() << ":" << TLS_ThreadCache << endl;
return TLS_ThreadCache->Allocate(size);
}
}
static void ConcurrentFree(void* ptr)
{
Span* span = PageCache::GetInstance()->MapObjectToSpan(ptr);
size_t size = span->_objSize;
if (size > MAX_BYTES)
{
PageCache::GetInstance()->_pageMutex.lock();
PageCache::GetInstance()->ReleaseSpanToPageCache(span);
PageCache::GetInstance()->_pageMutex.unlock();
}
else
{
assert(TLS_ThreadCache);
TLS_ThreadCache->Deallocate(ptr, size);//将申请的空间还回哈希桶
}
}
使用定长内存池替换new
new的本质其实就是malloc,既然已经实现了定长内存池和我们自己的高并发内存池。就可以使用自己的内存池来提高效率
测试结果与性能对比
将实现的高并发内存池与系统的malloc进行对比,可以看出高并发内存池是有一定的优势的
项目存在的问题
有一定的内存泄漏问题,在ThreadCache中自由链表的释放只有过长时释放,或者超过MaxSize()时出错。解决方法是在ThreadCache中增加析构函数,并且使用动态TLS使得每个线程在退出时将空间释放