高并发内存池
项目介绍
当前项目是实现一个高并发的内存池,原型是google的一个开源项目tcmalloc,tcmalloc全称Thread-Caching Malloc,即线程缓存的malloc,实现了高效的多线程内存管理,用于替代系统的内存分配相关的函数(malloc、free)。
什么是内存池
1.池化技术
所谓“池化技术”,就是程序先向系统申请过量的资源,然后自己管理,以备不时之需。之所以要申请过量的资源,是因为每次申请该资源都有较大的开销,不如提前申请好了,这样使用时就会变得非常快捷,大大提高程序运行效率。
2.内存池
内存池是指程序预先从操作系统申请一块足够大内存,此后,当程序中需要申请内存的时候,不是直接向操作系统申请,而是直接从内存池中获取;同理,当程序释放内存的时候,并不真正将内存返回给操作系统,而是返回内存池。当程序退出(或者特定时间)时,内存池才将之前申请的内存真正释放。
3.内存池主要解决的问题
这一点是我们最关心的,也是为什么会有这个东西的原因。
内存池主要解决的是效率的问题。其次站在系统的内存分配器的角度,还需要解决一下内存碎
片的问题。那什么是内存碎片?
内存碎片分为外碎片和内碎片。上面是外碎片问题。外部碎片是一些空闲的连续内存区域太小,这些内存空间不连续,以至于合计的内存足够,但是不能满足一些的内存分配申请需求。内部碎片是由于一些对齐的需求,导致分配出去的空间中一些内存无法被利用。内碎片问题,在后面项目中就会看到。
4.malloc
C/C++中我们要动态申请内存都是通过malloc去申请内存,但是我们要知道,实际我们不是直接去堆获取内存的,而malloc就是一个内存池。malloc() 相当于向操作系统“批发”了一块较大的内存空间,然后“零售”给程序用。当全部“售完”或程序有大量的内存需求时,再根据实际需求向操作系统“进货”。
设计一个自己的定长内存池(自己开超市)
作为程序员(C/C++)我们知道申请内存使用的是malloc,malloc其实就是一个通用的大众货,什么场景下都可以用,但是什么场景下都可以用就意味着什么场景下都不会有很高的性能。
先熟悉一下简单内存池是如何控制的,第二他会作为我们后面内存池的一个基础组件。
定长内存池说明:当需要一块内存空间的时候,我们先到定长内存池中取内存对象(开始freelist为空)
1、先向系统申请一块很大的内存空间,这里我们申请的是128k,
2、从定长内存池中取走一块T类型大小的内存对象
3、释放的时候,直接归还给自由链表,下一次取内存对象的话先从自由链表freelist中取,如果freelist为空,在去定长内存池中取。
需要注意的是,自由链表中指向下一个结点的地址,这里取类型的转换,32位下是没有问题的,能取到4个字节,但是64位下,就存在问题了。
所以我们这里这里可以将 int* 改为 void**,32位和64位都是void*(指针,改成int**一样的)大小,指针会变化,这样64位下也可以取到下一个结点地址。
具体代码如下:
template<class T>
class ObjectPool
{
public:
//开辟一块内存池
T* New()
{
T* obj = nullptr;
//如果自由链表上面有内存块,则先从自由链表上面取
if (_freeList)
{
obj = (T*)_freeList;
_freeList = *((void**)_freeList);//指向下一个内存块
//_freeList = *((int*)_freeList);
}
else//从定长内存池中取一块内存块
{
//如果定长内存池中剩余字节连一个类型都无法满足,此时需要重新向系统申请一块内存
if (_leftBytes < sizeof(T))
{
_leftBytes = 128 * 1024;
// _memory = (char*)malloc(_leftBytes);
_memory = (char*)SystemAlloc(_leftBytes >> 13);
//有可能开辟失败
if (_memory == nullptr)
{
throw std::bad_alloc();
}
}
//取一块内存
obj = (T*)_memory;
//_memory向后移动一个T类型字节,确保能取到下一个结点地址
size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
_memory += objSize;//向后移动一个内存块
_leftBytes -= objSize;//内存池减少一个内存块
}
new(obj) T;//使用定位new调用T的构造函数初始化
return obj;
}
//不需要的内存给自由链表
void Delete(T* obj)
{
obj->~T();//显示调用T类型的析构函数进行资源清理
//obj指向第一个结点,头指针freelist指向obj
*((void**)obj) = _freeList;
//*((int*)obj) = _freeList;
_freeList = obj;
}
private:
char* _memory = nullptr;//指向内存块的指针
int _leftBytes = 0;//内存块中剩余字节
void* _freeList = nullptr;//自由链表
};
高并发内存池框架整体设计
现代很多的开发环境都是多核多线程,在申请内存的场景下,必然存在激烈的锁竞争问题。malloc本身其实已经很优秀,那么我们项目的原型tcmalloc就是在多线程高并发的场景下更胜一筹,所以这次我们实现的内存池需要考虑以下几方面的问题。
- 性能问题。
- 多线程环境下,锁竞争问题。
- 内存碎片问题。
concurrent memory pool主要由以下3个部分构成:
- thread cache:线程缓存是每个线程独有的,用于小于256KB的内存的分配,线程从这里申请内存不需要加锁,每个线程独享一个cache,这也就是这个并发线程池高效的地方。
- central cache:中心缓存是所有线程所共享,thread cache是按需从central cache中获取的对象。central cache合适的时机回收thread cache中的对象,避免一个线程占用了太多的内存,而其他线程的内存吃紧,达到内存分配在多个线程中更均衡的按需调度的目的。central cache是存在竞争的,所以从这里取内存对象是需要加锁,首先这里用的是桶锁,其次只有thread cache的没有内存对象时才会找central cache,所以这里竞争不会很激烈。
- page cache:页缓存是在central cache缓存上面的一层缓存,存储的内存是以页为单位存储及分配的,central cache没有内存对象时,从page cache分配出一定数量的page,并切割成定长大小的小块内存,分配给central cache。当一个span的几个跨度页的对象都回收以后,page cache会回收central cache满足条件的span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。
高并发内存池–Thread Cache(第一层)
thread cache是哈希桶结构,每个桶是一个按桶位置映射大小的内存块对象的自由链表。每个线程都会有一个thread cache对象,这样每个线程在这里获取对象和释放对象时是无锁的。
申请内存:
- 当内存申请size<=256KB,先获取到线程本地存储的thread cache对象,计算size映射的哈希桶自由链表下标index。
- 如果自由链表_freeLists[index]中有对象,则直接Pop一个内存对象返回。
- 如果_freeLists[index]中没有对象时,则批量从central cache中获取一定数量的对象,插入到自由链表并返回一个对象。
我们先处理自由链表,将映射关系建立好,自由链表的哈希桶跟对象大小的映射关系:
哈希桶
上面我们可以看到Thread Cache 从8bit、16bit…256KB增长的,这样的话我们就需要32768个桶,32768个桶是可以申请的,但是我们没有必要申请这么多,这样产生的自由链表太多,减少浪费。
我们来看看大佬的设计
内存对齐:
整体控制在最多10%左右的内碎片浪费 129 + 15 15 / 144 ~ 10%
为什么不8个位去对齐?这样产生的自由链表太多,减少浪费
[1,128] 8byte对齐 freelist[0,16)
[128+1,1024] 16byte对齐 freelist[16,72) (1024-128+1)/16,增加56个链表,区间[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)
static inline size_t _RoundUp(size_t bytes, size_t alignNum)
{
第一种写法
size_t alignSize = 0;
if (bytes % alignNum != 0)
{
alignSize = (bytes / alignNum + 1) / alignNum;//+1向上提升,不足8个字节,需要对齐
}
else
{
alignSize = bytes;
}
return alignSize;
//7 + 8 - 1
//&
//7
//1110
//1000
//==8
第二种写法
return ((bytes + alignNum - 1) & ~(alignNum - 1));
}
//对齐数划分区间
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);
}//256kb到thread cache的头
else if (size <= 256 * 1024)
{
return _RoundUp(size, 8 * 1024);
}
else
{
return _RoundUp(size, 1 << PAGE_SHIFT);
//return -1;//超过,返回-1
}
}
对象大小:
哈希映射位置,计算映射的那个一个自由链表桶
1 + 7 8
2 9
...
8 15
static inline size_t _Index(size_t bytes, size_t align_shift)
{
第一种写法
if (bytes % align_shift == 0)
{
return bytes / align_shift - 1;//16/8 - 1 = 1
}
else
{
return bytes / align_shift;//15 / 8 = 1
}
//16 + 1<<3 -1 = 23 >> 3 -1 = 2 - 1 = 1
第二种写法
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];//按照与算,需要-128,在加上一个区间有多少个自由链表。
}
else if (bytes <= 8 * 1024)
{
return _Index(bytes - 1024, 7) + group_array[0] + group_array[1];
}
else if (bytes <= 64 * 1024)
{
return _Index(bytes - 8 * 1024, 10) + group_array[0] + group_array[1] + group_array[2];
}
else if (bytes <= 256 * 1024)
{
return _Index(bytes - 64 * 1024, 13) + group_array[0] + group_array[1] + group_array[2] + group_array[3];
}
else
{
assert(false);
return -1;
}
}
ThreadCache中的自由链表(FreeList)
FreeList这里是一个单链表,维护着申请和释放内存对象的关系。
//管理每个小对象的自由链表
class FreeList
{
public:
//头插
void Push(void* obj)
{
assert(obj);
NextObj(obj) = _freelist;
_freelist = obj;
++_size;
}
//插入一段内存
void PushRange(void* start, void* end, size_t n)
{
NextObj(end) = _freelist;//指向第一个结点
_freelist = start;//指向新插入的内存块的第一个结点
_size += n;//链表长度
}
//释放
void PopRange(void* start, void* end, size_t n)
{
assert(n >= _size);
start = _freelist;
end = start;
//释放n个内存块到Central Cache中
for (size_t i = 0; i < n - 1; ++i)
{
end = NextObj(end);
}
_freelist = NextObj(end);
NextObj(end) = nullptr;
_size -= n;//链表长度-n
}
//借走一块内存块
void* Pop()
{
assert(_freelist);//确保链表不为空
void* obj = _freelist;
_freelist = NextObj(obj);
--_size;
return obj;
}
bool Empty()
{
return _freelist == nullptr;
}
//需要修改,引用
size_t& MaxSize()
{
return _maxsize;
}
//链表长度
size_t Size()
{
return _size;
}
private:
void* _freelist = nullptr;//头指针
size_t _maxsize = 1;//慢增长的大小
size_t _size = 0;//链表的长度
};
其中的nextObj函数是取的指针obj的四个字节,指向一个结点,我们这里以头插举例:
//下一个结点地址
static void*& NextObj(void* obj)
{
return *((void**)obj);
}
ThreadCache框架
- thread cache本质是由一个哈希映射的对象自由链表构成。
//封装了一层
class ThreadCache
{
public:
void* Allocate(size_t size);//申请对象
void Deallocate(void* ptr, size_t size);//释放对象
// 从中心缓存获取对象
void* FetchFromCentralCache(size_t index, size_t size);
// 释放对象时,链表过长时,回收内存回到中心缓存
void ListTooLong(FreeList& list, size_t size);
private:
//自由链表
FreeList _freeList[FREELISTNUM];//开辟208个
};
// TLS thread local storage
线程局部存储(TLS),是一种变量的存储方法,这个变量在它所在的线程内是全局可访问的,但是不能被其他线程访问到,这样就保持了数据的线程独立性。
而熟知的全局变量,是所有线程都可以访问的,这样就不可避免需要锁来控制,增加了控制成本和代码复杂度。
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;
线程局部存储说明(TLS):
- 线程局部存储(TLS),是一种变量的存储方法,这个变量在它所在的线程内是全局可访问的,但是不能被其他线程访问到,这样就保持了数据的线程独立性。所以threadcache中是不需要加锁的。
- 而熟知的全局变量,是所有线程都可以访问的,这样就不可避免需要锁来控制,增加了控制成本和代码复杂度。
threadcache中申请的是小于256kb的内存对象,且有208个桶,我们这里定义出来
static const size_t MAX_BYTES = 256 * 1024;//256kb thread cache这一层,每个线程独享
static const size_t FREELISTNUM = 208;//freelist的表大小
申请内存空间
void* ThreadCache::Allocate(size_t size)
{
assert(size <= MAX_BYTES);//小于最大位数
size_t alignSize = SizeClass::RoundUp(size);//获取对齐数
size_t index = SizeClass::Index(size);//获取映射位置
//如果自由链表不为空,直接弹出一个对象
if (!_freeList[index].Empty())
{
return _freeList[index].Pop();
}
else
{
//没有对象,从central cache层获取一定数量对象,插入到自由链表中并返回一个对象。
return FetchFromCentralCache(index, alignSize);//这里的size注意是对齐后的
}
}
从中心缓存获取内存对象
慢增长方法,同网络的拥塞控制,开始需要1个给1个,后面1个给2个,2个给3个,慢慢增长,避免浪费内存。
void* ThreadCache::FetchFromCentralCache(size_t index, size_t size)
{
//1、最开始不会向central catch要太多对象,要多了用不完,比如需要8个字节,结果给了500个对象,但实际只用了50个,就存在浪费的情况
//2、如果不需要size这个大小的内存需求,batchNum就会一直增大
//3、size越小,获取到的batchNum越大
//4、size越大,获取到的batchNum越小
size_t batchNum = min(_freeList[index].MaxSize(), SizeClass::NumMoveSize(size));//取最小值
//保证最小取到一个对象 要1个,给2个,1个给3个,依次递增,同拥塞控制
if (_freeList[index].MaxSize() == batchNum)
{
_freeList[index].MaxSize() += 1;
}
//去central catch中获取batchNum个对象,有多少取多少
void* start = nullptr;
void* end = nullptr;
//获取对象 不要忘记这里使用的是单例模式,注意调用方法
size_t actualNum = CentralCatch::GetInstance()->FetchRangeObj(start, end, batchNum, size);
assert(actualNum > 0);//确保申请成功
if (actualNum == 1)
{
assert(start == end);//一个的时候这两个相等
return start;
}
else
{
_freeList[index].PushRange(NextObj(start), end, actualNum - 1);//第一个对象返回,后面的链接回thread cache中
return start;
}
}
将内存对象控制在[2,512]这个区间,一次thread cache从中心缓存获取多少个对象
static size_t NumMoveSize(size_t size)
{
assert(size > 0);//大于0
//[2, 512] ,一次批量移动多少个对象的(慢启动)上限值
// 小对象一次批量上限高
// 大对象一次批量上限低
int num = MAX_BYTES / size;
if (num < 2)
num = 2;
if (num > 512)
num = 512;
//最小为2,最大为512,控制在这个区间
return num;
}
计算一次向系统获取几个页,后面PageCache中申请页的函数
// 计算一次向系统获取几个页
// 单个对象 8byte
// ...
// 单个对象 256KB
static size_t NumMovePage(size_t size)
{
size_t num = NumMoveSize(size);
size_t npage = num * size;//32 * 8 获取8个字节 1页,
npage >>= PAGE_SHIFT;
if (npage == 0)
npage = 1;
return npage;
}
高并发内存池–Central Cache(第二层)
central cache也是一个哈希桶结构,他的哈希桶的映射关系跟thread cache是一样的。不同的是他的每个哈希桶位置挂是SpanList链表结构,不过每个映射桶下面的span中的大内存块被按映射关系切成了一个个小内存块对象挂在span的自由链表中。
申请内存:
- 当thread cache中没有内存时,就会批量向central cache申请一些内存对象,这里的批量获取对象的数量使用了类似网络tcp协议拥塞控制的慢开始算法;central cache也有一个哈希映射的spanlist,spanlist中挂着span,从span中取出对象给thread cache,这个过程是需要加锁的,不过这里使用的是一个桶锁,尽可能提高效率。
- central cache映射的spanlist中所有span的都没有内存以后,则需要向page cache申请一个新的span对象,拿到span以后将span管理的内存按大小切好作为自由链表链接到一起。然后从span中取对象给thread cache。
- central cache的中挂的span中use_count记录分配了多少个对象出去,分配一个对象给thread cache,就++use_count
CentralCache中的双向链表(SpanList)
SpanList是一个双向链表,每个结点又是一个span结构体,span结构体中又包含个多个成员变量,其中包含一个自由链表freelist。
struct Span
{
PAGE_ID _pageId = 0; // 大块内存起始页的页号 表示每个桶起始位置
size_t _n = 0; // 页的数量
Span* _next = nullptr; // 双向链表的结构
Span* _prev = nullptr;
size_t _objSize = 0;//切好的小块对象的大小
size_t _useCount = 0; // 切好小块内存,被分配给thread cache的计数
bool _isUse = false;//此块内存是否在被使用
void* _freeList = nullptr; // 切好的小块内存的自由链表
};
//central catch的spanlist自由链表,双向链表,好方便查找翻页
class SpanList
{
public:
//构造初始化
SpanList()
{
_head = new Span;
_head->_next = _head;
_head->_prev = _head;
}
Span* Begin()
{
return _head->_next;
}
Span* End()
{
return _head;//就是head
}
bool Empty()
{
return _head->_next == _head;//空,老毛病,注意== 和 =
}
Span* PopFront()
{
Span* front = _head->_next;//第一个结点
Erase(front);
return front;
}
void PushFront(Span* span)
{
Insert(Begin(), span);
}
//在pos之前插入newspan,跟顺序表插入逻辑一样
void Insert(Span* pos, Span* newSpan)
{
//首先检查断言
assert(pos);
assert(newSpan);
Span* prev = pos->_prev;
prev->_next = newSpan;
newSpan->_prev = prev;
newSpan->_next = pos;
pos->_prev = newSpan;
}
void Erase(Span* pos)
{
assert(pos);
assert(pos != _head);//头指针
Span* next = pos->_next;
Span* prev = pos->_prev;
prev->_next = next;
next->_prev = prev;//这里为什么没有delete,不用了就归还给自由链表,需要从自由链表取即可,objectpool同理的
}
private:
Span* _head;//spanlist自由链表
public:
std::mutex _mtx;//桶锁,防止一个进程在获取时,例外一个进程也进程竞争资源
};
CentralCache框架
//单例模式,只能存在一个central catch对象
class CentralCatch
{
public:
//只能生成一个对象,饿汉模式,开始就创建对象
static CentralCatch* 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);
// 将一定数量的对象释放到span跨度
void ReleaseListToSpans(void* start, size_t byte_size);
private:
CentralCatch() {};
CentralCatch(const CentralCatch&) = delete;//禁止生成
static CentralCatch _sInst;//静态对象
private:
SpanList _spanLists[FREELISTNUM];//映射规则跟thread catch一样
};
从中心缓存获取一定数量的对象给Thread Cache
1、先根据获取对应的映射位置
2、开启桶锁,保护临界资源,只让一个线程进行访问
3、从central cache中获取对应的span,需要几个batchNum就拿几个内存对象,如果不够batchNum有多少个拿多少个,对应SpanList中的usecount也要记录借了多少个内存对象出去
4、对拿到的batchNum个对象链接成自由链表,返回给threadcache
5、解锁,防止死锁
对拿到的span我们进行一个切割
// 从中心缓存获取一定数量的对象给thread cache
size_t CentralCatch::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size)
{
size_t index = SizeClass::Index(size);
_spanLists[index]._mtx.lock();//锁住
//获取一个span
Span* span = GetOneSpan(_spanLists[index], size);
assert(span);
assert(span->_freeList);//span要存在,并且下面挂有小块内存的自由链表
// 从span中获取batchNum个对象
// 如果不够batchNum个,有多少拿多少
start = span->_freeList;//头
end = start;//初始都指向头结点
//然后往后走取batchNum个对象
size_t i = 0;
size_t actualNum = 1;
//这里要保证取到这么多,并且下一个结点不为空,才能继续往下走
while (i < batchNum - 1 && NextObj(end) != nullptr)
{
end = NextObj(end);
++i;
++actualNum;
}
span->_freeList = NextObj(end);
NextObj(end) = nullptr;//end的下一个结点就指向空 end->next = nullptr
span->_useCount += actualNum;//记录分配了多少个对象出去
_spanLists[index]._mtx.unlock();//解锁,该线程不使用时候解锁,避免死锁
return actualNum;
}
获取一个非空的span
1、先判断自己的SpanList中有没有一个span中还有内存对象,有就先返回
2、对应上一个FetchRangeObj函数,这里需要解锁,防止其他线程内存对象释放回来,存在阻塞的情况
3、到这一步,我们只能去向Page Cache中获取一个非空的span,拿到对应的起始页号
4、将大块内存span切割成自由链表,放入Central Cache中挂起(写入这时需要加锁)
将从Page Cache中拿到的span大块内存,进行一个切割放Central Cache中挂起
Span* CentralCatch::GetOneSpan(SpanList& list, size_t byte_size)
{
//1、查看CentralCache中有没有内存对象,有就先取
Span* it = list.Begin();
while (it != list.End())
{
//当前it的小块内存自由链表中不为空,直接返回
if (it->_freeList != nullptr)
{
return it;
}
else
{
it = it->_next;
}
}
list._mtx.unlock();//解锁原因,如果其他线程释放内存对象回来,就不会存在阻塞了
//2、走到这里,central catch中内存也空的,继续向page catch中去找内存
PageCache::GetInstance()->_pageMtx.lock();//先要锁住,防止其他线程进入
Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(byte_size));//获取几个页
span->_isUse = true;//正在使用
span->_objSize = byte_size;
PageCache::GetInstance()->_pageMtx.unlock();
// 对获取span进行切分,不需要加锁,因为这会其他线程访问不到这个span
// 对获取到的span进行切分
//起始地址和内存块大小
char* start = (char*)(span->_pageId << PAGE_SHIFT);//计算该span的起始页号
size_t bytes = span->_n << PAGE_SHIFT;//计算该span的字节大小
char* end = start + bytes;//末尾地址
//将大块内存span切割成自由链表,放入central catch中挂起
//取个头结点下来 方便进行尾插
span->_freeList = start;
start += byte_size;
void* tail = span->_freeList;
while (start < end)
{
NextObj(tail) = start;
tail = NextObj(tail);//tail = start
start += byte_size;
}
//tail记得置空
NextObj(tail) = nullptr;
list._mtx.lock();//span挂到桶里面的时候,需要加锁
list.PushFront(span);
return span;
}
高并发内存池–Page Cache(第三层)
申请内存:
- 当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页内存。
PageCache代码框架
1、page cache是一个以页为单位的span自由链表。
2、为了保证全局只有唯一的page cache,这个类被设计成了单例模式。
//单例模式,同central catch一样
class PageCache
{
public:
static PageCache* GetInstance()
{
return &_sInst;
}
// 获取一个K页的span
Span* NewSpan(size_t k);
// 获取从对象到span的映射
Span* MapObjectToSpan(void* obj);
// 释放空闲span回到Pagecache,并合并相邻的span
void ReleaseSpanToPageCache(Span* span);
std::mutex _pageMtx;
private:
SpanList _spanLists[NPAGES];
ObjectPool<Span> _spanPool;//用定长内存池替代new、delete
//std::unordered_map<PAGE_ID, Span*> _isSpanMap;//内存对象跟span的映射关系
std::map<PAGE_ID, Span*> _isSpanMap;//map更易观察
//TCMalloc_PageMap1<32 - PAGE_SHIFT> _idSpanMap;//使用基数树优化
PageCache()
{}
PageCache(const PageCache&) = delete;
static PageCache _sInst;//静态对象
};
页号跟span的映射关系(方便释放内存对象回来查找对应位置)
1、使用map建立页号跟span的映射关系
// 获取从对象到span的映射
//start - end 都是span
Span* PageCache::MapObjectToSpan(void* obj)
{
PAGE_ID id = ((PAGE_ID)obj >> PAGE_SHIFT);//右移13位,找到对应的id
std::unique_lock<std::mutex> lock(_pageMtx); //加锁,RAII,出了作用域,自己解锁
auto ret = _isSpanMap.find(id);//查找对应的span
//去查找对应的span
if (ret != _isSpanMap.end())
{
//找到返回,没找到返回nullptr
return ret->second;
}
else
{
assert(false);
return nullptr;
}
}
获取一个K页的span
1、先检查对应映射位置的桶里面是否还有span,有则弹出span、并建立页号跟span的映射关系。
2、如果没有则继续往后面的桶里面寻找span,若有则切割该span。
3、如果Page Cache中都没有span,则向堆去申请,然后递归调用自己重复1、2步骤。
这里作图说明:
Span* PageCache::NewSpan(size_t k)
{
assert(k > 0);//检查
//检查第k个桶里面有没有span,有就弹出
if (!_spanLists[k].Empty())
{
Span* kSpan = _spanLists[k].PopFront();//切割第一个span返回
for (PAGE_ID i = 0; i < kSpan->_n; ++i)
{
_isSpanMap[kSpan->_pageId + i] = kSpan;//建立映射关系,方便central cache回收内存时进行查找。
}
return kSpan;
}
//该页,没有span,则继续往后面的页进行寻找
size_t i = k + 1;
for (; i < NPAGES; ++i)
{
//如果此页不为空,进行切割,比如需要4页的,但是10页才有,切割成一个6页的和一个4页的
if (!_spanLists[i].Empty())
{
Span* nSpan = _spanLists[i].PopFront();//弹出第一个span
1、使用定长内存池的new
Span* kSpan = new Span;
//在nspan的头部切下k页下来
kSpan->_pageId = nSpan->_pageId;
kSpan->_n = k;//页数为k
nSpan->_pageId += k;//往后走k页,起始地址往后移动
nSpan->_n -= k;//减去k页
_spanLists[nSpan->_n].PushFront(nSpan);//npages插入n号桶中
// 存储nSpan的首位页号跟nSpan映射,方便page cache回收内存时
// 进行的合并查找
//start 到 end 都是span
_isSpanMap[nSpan->_pageId] = nSpan;
_isSpanMap[nSpan->_pageId + nSpan->_n - 1] = nSpan;
// 建立id和span的映射,方便central cache回收小块内存时,查找对应的span
for (PAGE_ID i = 0; i < kSpan->_n; ++i)
{
_isSpanMap[kSpan->_pageId + i] = kSpan;
}
return kSpan;//返回central catch中使用
}
}
//到这里,说明page catch中也没用多余的span了
//这时就要去向堆中申请一个128页的span
Span* bigSpan = new Span;
void* ptr = SystemAlloc(NPAGES - 1);
bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;//8 * 1024右移算页号,右移算id 8kb
bigSpan->_n = NPAGES - 1;
_spanLists[bigSpan->_n].PushFront(bigSpan);
递归重复找
return NewSpan(k);//可以迭代下去,递归更好,不存在效率问题,现在计算机很快
}
windows和Linux下如何直接向堆申请和释放页为单位的大块内存
首先这里需要注意一下自己是在32位还是64位
选择机器位
#ifdef _WIN64
typedef unsigned long long PAGE_ID;
#elif _WIN32
typedef size_t PAGE_ID;
#else
// linux
#endif
向系统申请内存空间
// 匹配系统 向堆申请空间
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等
小于128k,_brk是将数据段(.data)的最高地址指针_edata往高地址推;
大于128k,mmap是在进程的虚拟地址空间中(堆和栈中间,称为文件映射区域的地方)找一块空闲的虚拟内存。
详细可参考对应链接:https://www.cnblogs.com/vinozly/p/5489138.html
#endif
if (ptr == nullptr)
throw std::bad_alloc();
return ptr;
}
释放内存空间
inline static void SystemFree(void* ptr)
{
#ifdef _WIN32
VirtualFree(ptr, 0, MEM_RELEASE);
#else
// sbrk unmmap等
#endif
}
释放内存整体框架
Threadcache释放内存
1、当释放内存小于256k时将内存释放回thread cache,计算size映射自由链表桶位置i,将对象Push到_freeLists[i]。
2、当链表的长度过长,则回收一部分内存对象到central cache。
对第二条单独说明一下:
- 每次申请完的内存对象释放回threadcache中,如果链表长度大于申请的内存时,说明此时用不到这么多内存对象,我们就需要还一部分给中心缓存centralcache
//1. 当释放内存小于256k时将内存释放回thread cache,计算size映射自由链表桶位置i,将对象Push到_freeLists[i]。
//2. 当链表的长度过长,则回收一部分内存对象到central cache。
void ThreadCache::Deallocate(void* ptr, size_t size)
{
assert(ptr);//需要判断是否有效
assert(size <= MAX_BYTES);
size_t index = SizeClass::Index(size);//获取对应索引位置
_freeList[index].Push(ptr);//释放h
//每次申请,用了又反给central cache 直到链表长度大于一次批量申请的内存
//比如申请100个,用了1个,还99个,申请100个,用了50个,还50个,申请50个,此时链表长度51大于50个,就应该回收一部分内存对象给central cache
if (_freeList[index].Size() >= _freeList[index].MaxSize())
{
ListTooLong(_freeList[index], size);
}
}
链表过长,回收内存到中心缓存
//链表过长,回收内存到中心缓存
void ThreadCache::ListTooLong(FreeList& list, size_t size)
{
void* start = nullptr;
void* end = nullptr;
//取下内存对象,准备放回中心缓存
list.PopRange(start, end, list.MaxSize());
//释放回span中
CentralCatch::GetInstance()->ReleaseListToSpans(start, size);
}
Centralcache释放内存
- 当thread_cache过长或者线程销毁,则会将内存释放回central cache中的,释放回来时–use_count。当use_count减到0时则表示所有对象都回到了span,则将span释放回page cache,page cache中会对前后相邻的空闲页进行合并。
// 将一定数量的对象释放到span跨度
void CentralCatch::ReleaseListToSpans(void* start, size_t byte_size)
{
size_t index = SizeClass::Index(byte_size);//获取哈希桶对应位置
_spanLists[index]._mtx.lock();//归还内存对象时,禁止其他线程进入,加锁
while (start)
{
void* next = NextObj(start);//准备进行递归
//根据页号和span的映射关系找到对应的span
Span* span = PageCache::GetInstance()->MapObjectToSpan(start);
//头插回去
NextObj(start) = span->_freeList;
span->_freeList = start;
span->_useCount--;//归还一个-1,直到为0,归还给central cache
//如果为0,则所有的小块内存都回来了
//此时这个span就回收给page cache
if (span->_useCount == 0)
{
centralcache中的SpanList对应的位置要清空该span,并将指针指向修改为nullptr
_spanLists[index].Erase(span);
span->_freeList = nullptr;
span->_next = nullptr;
span->_prev = nullptr;//都清空
//释放给Page cache,需要使用page cache锁,解除spanlist的桶锁
_spanLists[index]._mtx.unlock();
PageCache::GetInstance()->_pageMtx.lock();
PageCache::GetInstance()->ReleaseSpanToPageCache(span);//归还给page cache,一层套一层
PageCache::GetInstance()->_pageMtx.unlock();
//完了之后重新加上
_spanLists[index]._mtx.lock();
}
start = next;
}
_spanLists[index]._mtx.unlock();//解锁
}
Pagecache释放内存
- 如果central cache释放回一个span,则依次寻找span的前后page id的没有在使用的空闲span,看是否可以合并,如果合并继续向前寻找。这样就可以将切小的内存合并收缩成大的span,减少内存碎片。
三种不合并情况:
1、没找到不合并
2、找到了,但是正在被其他线程所使用,不合并
3、前后页的span的页数加起来超过128页,也不合并,因为无法管理
void PageCache::ReleaseSpanToPageCache(Span* span)
{
//向前合并
while (1)
{
PAGE_ID prevId = span->_pageId - 1;//前一个span的地址
auto ret = _isSpanMap.find(prevId);//查找前一个span存不存在
//前面没有页,不合并
if (ret == _isSpanMap.end())
{
break;
}
//前面相邻的span在使用,不合并
Span* prevSpan = ret.second;
if (prevSpan->_isUse == true)
{
break;
}
//前后页的span合并 超过128页,也不合并(无法管理)
if (prevSpan->_n + span->_n > NPAGES - 1)
{
break;
}
//这里就要进行合并了
span->_pageId = prevSpan->_pageId;
span->_n += prevSpan->_n;
//并将前面的span删掉
_spanLists[prevSpan->_n].Erase(prevSpan);
//delete prevSpan;
_spanPool.Delete(prevSpan);
//一直循环,就能合并所有的外碎片,减少内存碎片
}
//向后合并
while (1)
{
PAGE_ID nextId = span->_n + span->_pageId;//+上自己的页数就是下一个span的起始地址
auto ret = _isSpanMap.find(nextId);
//后面没有相邻的span
if (ret == _isSpanMap.end())
{
break;
}
//找到
Span* nextSpan = ret.second;
if (nextSpan->_isUse == true)
{
break;
}
if (nextSpan->_n + span->_n > NPAGES - 1)
{
break;
}
//这里页数相加即可
span->_n += nextSpan->_n;
//删除后面的span
_spanLists[nextSpan->_n].Erase(nextSpan);
//delete nextSpan;
_spanPool.Delete(nextSpan);
}
//插入进去
_spanLists[span->_n].PushFront(span);
span->_isUse = false;
_isSpanMap[span->_pageId] = span;//建立映射关系
_isSpanMap[span->_pageId + span->_n - 1] = span;
}
大于256KB的大块内存申请问题
1、<=256KB找三层缓存
2、大于256KB
- a、32 * 8k < size <= 128 * 8k 找pagecache
- b、size > 128 * 8k找系统堆
修改对应代码:
pagecache中的NewSpan函数,加在最前面
if (k > NPAGES - 1)
{
void* ptr = SystemAlloc(k);
//Span* span = new Span;//创建一个新的span
Span* span = _spanPool.New();
span->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;//起始地址
span->_n = k;
//_isSpanMap[span->_pageId] = span;//建立映射关系
_idSpanMap.set(span->_pageId, span);
return span;
}
pagecache中的ReleaseSpanToPageCache函数,如果超过128页直接释放回堆
//超过128页直接返给堆
if (span->_n > NPAGES - 1)
{
//拿到内存地址
void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
SystemFree(ptr);
//delete span;//释放span
_spanPool.Delete(span);
return;
}
释放对象时优化为不传对象大小
直接给span增加一个成员变量 _objSize用来表示申请的内存块对象的大小
struct Span
{
PAGE_ID _pageId = 0; // 大块内存起始页的页号 表示每个桶起始位置
size_t _n = 0; // 页的数量
Span* _next = nullptr; // 双向链表的结构
Span* _prev = nullptr;
size_t _objSize = 0;//切好的小块对象的大小
size_t _useCount = 0; // 切好小块内存,被分配给thread cache的计数
bool _isUse = false;//此块内存是否在被使用
void* _freeList = nullptr; // 切好的小块内存的自由链表
};
申请内存和释放内存的总函数接口
申请内存的接口:
static void* ConcurrentAlloc(size_t size)
{
//如果申请的大小大于256kb,直接向page cache中去要
if (size > MAX_BYTES)
{
size_t alignSize = SizeClass::RoundUp(size);//对齐数
size_t kPage = alignSize >> PAGE_SHIFT;//id
//去申请内存块
PageCache::GetInstance()->_pageMtx.lock();//锁住,防止其他进程进入,保证原子性
Span* span = PageCache::GetInstance()->NewSpan(kPage);
span->_objSize = size;
PageCache::GetInstance()->_pageMtx.unlock();
void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
return ptr;
}
else
{
// 通过TLS 每个线程无锁的获取自己的专属的ThreadCache对象
if (pTLSThreadCache == nullptr)
{
static ObjectPool<ThreadCache> tcPool;
pTLSThreadCache = tcPool.New();//去自己写的定长内存池进行申请
}
return pTLSThreadCache->Allocate(size);
}
}
释放内存的接口:
static void ConcurrentFree(void* ptr)
{
Span* span = PageCache::GetInstance()->MapObjectToSpan(ptr);//找到对应的span
size_t size = span->_objSize;
//大于32*8k直接返给pagecache
if (size > MAX_BYTES)
{
PageCache::GetInstance()->_pageMtx.lock();
PageCache::GetInstance()->ReleaseSpanToPageCache(span);
PageCache::GetInstance()->_pageMtx.unlock();
}
else
{
assert(pTLSThreadCache);
pTLSThreadCache->Deallocate(ptr, size);
}
}
性能瓶颈分析
通过查看下面的运行结果,我们可以看到自己写的性能还是比较差的。
但是我们不知道具体的时间慢在哪里,接下来我们用VS的性能诊断工具查看一下具体的消耗时间在哪里?
查看步骤一:点击调试 ->性能探查器
查看步骤二:开始->点击项目->确定
查看步骤三:时间浪费最多的地方会显示出来,自己点击查看
使用tcmalloc源码中实现基数树进行优化
基数树实际上就是一个分层的哈希表,根据所分层数不同可分为单层基数树、二层基数树、三层基数树等。
单层基数树实际采用的就是直接定址法,每一个页号对应span的地址就存储数组中在以该页号为下标的位置。
单层基数树:
// Single-level array
template <int BITS>
class TCMalloc_PageMap1 {
private:
static const int LENGTH = 1 << BITS;
void** array_;
public:
typedef uintptr_t Number;
explicit TCMalloc_PageMap1(void* (*allocator)(size_t)) {
array_ = reinterpret_cast<void**>((*allocator)(sizeof(void*) << BITS));
memset(array_, 0, sizeof(void*) << BITS);
}
// Return the current value for KEY. Returns NULL if not yet set,
// or if k is out of range.
void* get(Number k) const {
if ((k >> BITS) > 0) {
return NULL;
}
return array_[k];
}
// REQUIRES "k" is in range "[0,2^BITS-1]". k的范围在[0,2^BITS-1]
// REQUIRES "k" has been ensured before.
//
// 建立起始页号和span的映射关系
void set(Number k, void* v) {
array_[k] = v;
}
};
比如32位平台下,以一页大小为8K为例,此时页的数目就是2^32 / 2^13 = 2^19 ,因此存储页号最多需要19个比特位,此时传入模板参数传来的值是32 − 13 = 19 。由于32位平台下指针的大小是4字节,因此该数组的大小就是2^19 * 4 = 2^21 =2M,内存消耗不大,是可行的。但如果是在64位平台下,此时该数组的大小是2^51 × 8 = 2^54 = 2^24 G ,这显然是不可行的,实际上对于64位的平台,我们需要使用三层基数树。
二层基数树:
这里还是以32位平台下,一页的大小为8K为例来说明,此时存储页号最多需要19个比特位。而二层基数树实际上就是把这19个比特位分为两次进行映射。
比如用前5个比特位在基数树的第一层进行映射,映射后得到对应的第二层,然后用剩下的比特位在基数树的第二层进行映射,映射后最终得到该页号对应的span指针。
// Two-level radix tree
template <int BITS>
class TCMalloc_PageMap2 {
private:
// Put 32 entries in the root and (2^BITS)/32 entries in each leaf.
static const int ROOT_BITS = 5; //第一层对应页号的前5个比特位
static const int ROOT_LENGTH = 1 << ROOT_BITS; //第一层存储元素的个数
static const int LEAF_BITS = BITS - ROOT_BITS; //第二层对应页号的其余比特位
static const int LEAF_LENGTH = 1 << LEAF_BITS; //第二层存储元素的个数
// Leaf node
struct Leaf {
void* values[LEAF_LENGTH];
};
Leaf* root_[ROOT_LENGTH]; // Pointers to 32 child nodes
void* (*allocator_)(size_t); // Memory allocator
public:
typedef uintptr_t Number;
explicit TCMalloc_PageMap2() {
//allocator_ = allocator;
memset(root_, 0, sizeof(root_));
PreallocateMoreMemory();
}
void* get(Number k) const {
const Number i1 = k >> LEAF_BITS;
const Number i2 = k & (LEAF_LENGTH - 1);
if ((k >> BITS) > 0 || root_[i1] == NULL) {
return NULL;
}
return root_[i1]->values[i2];
}
void set(Number k, void* v) {
const Number i1 = k >> LEAF_BITS;
const Number i2 = k & (LEAF_LENGTH - 1);
ASSERT(i1 < ROOT_LENGTH);
root_[i1]->values[i2] = v;
}
确保映射[start,start_n-1]页号的空间是开辟好了的
bool Ensure(Number start, size_t n) {
for (Number key = start; key <= start + n - 1;) {
const Number i1 = key >> LEAF_BITS;
// 栈溢出
if (i1 >= ROOT_LENGTH)
return false;
// 第一层i1下标指向的空间未开辟
if (root_[i1] == NULL) {
static ObjectPool<Leaf> leafPool;
Leaf* leaf = (Leaf*)leafPool.New();
memset(leaf, 0, sizeof(*leaf));
root_[i1] = leaf;
}
key = ((key >> LEAF_BITS) + 1) << LEAF_BITS;
}
return true;
}
void PreallocateMoreMemory() {
分配足够的资源以跟踪所有可能的页面
Ensure(0, 1 << BITS);
}
};
三层基数树:
三层基数树一般在64位下使用,三层基数树与二层基数树类似,三层基数树实际上就是把存储页号的若干比特位分为三次进行映射。当我们要建立某一页号的映射关系时,需要先确保存储该页映射的数组空间是开辟好了的,也就是调用代码中的Ensure函数,如果对应数组空间未开辟则会立马开辟对应的空间(同第二层)。
// Three-level radix tree
template <int BITS>
class TCMalloc_PageMap3 {
private:
// How many bits should we consume at each interior level
static const int INTERIOR_BITS = (BITS + 2) / 3; // Round-up
static const int INTERIOR_LENGTH = 1 << INTERIOR_BITS;
// How many bits should we consume at leaf level
static const int LEAF_BITS = BITS - 2 * INTERIOR_BITS;
static const int LEAF_LENGTH = 1 << LEAF_BITS;
// Interior node
struct Node {
Node* ptrs[INTERIOR_LENGTH];
};
// Leaf node
struct Leaf {
void* values[LEAF_LENGTH];
};
Node* root_; // Root of radix tree
void* (*allocator_)(size_t); // Memory allocator
Node* NewNode() {
Node* result = reinterpret_cast<Node*>((*allocator_)(sizeof(Node)));
if (result != NULL) {
memset(result, 0, sizeof(*result));
}
return result;
}
public:
typedef uintptr_t Number;
explicit TCMalloc_PageMap3(void* (*allocator)(size_t)) {
allocator_ = allocator;
root_ = NewNode();
}
void* get(Number k) const {
const Number i1 = k >> (LEAF_BITS + INTERIOR_BITS);
const Number i2 = (k >> LEAF_BITS) & (INTERIOR_LENGTH - 1);
const Number i3 = k & (LEAF_LENGTH - 1);
if ((k >> BITS) > 0 ||
root_->ptrs[i1] == NULL || root_->ptrs[i1]->ptrs[i2] == NULL) {
return NULL;
}
return reinterpret_cast<Leaf*>(root_->ptrs[i1]->ptrs[i2])->values[i3];
}
void set(Number k, void* v) {
ASSERT(k >> BITS == 0);
const Number i1 = k >> (LEAF_BITS + INTERIOR_BITS);
const Number i2 = (k >> LEAF_BITS) & (INTERIOR_LENGTH - 1);
const Number i3 = k & (LEAF_LENGTH - 1);
reinterpret_cast<Leaf*>(root_->ptrs[i1]->ptrs[i2])->values[i3] = v;
}
bool Ensure(Number start, size_t n) {
for (Number key = start; key <= start + n - 1;) {
const Number i1 = key >> (LEAF_BITS + INTERIOR_BITS);
const Number i2 = (key >> LEAF_BITS) & (INTERIOR_LENGTH - 1);
// Check for overflow
if (i1 >= INTERIOR_LENGTH || i2 >= INTERIOR_LENGTH)
return false;
// Make 2nd level node if necessary
if (root_->ptrs[i1] == NULL) {
Node* n = NewNode();
if (n == NULL) return false;
root_->ptrs[i1] = n;
}
// Make leaf node if necessary
if (root_->ptrs[i1]->ptrs[i2] == NULL) {
Leaf* leaf = reinterpret_cast<Leaf*>((*allocator_)(sizeof(Leaf)));
if (leaf == NULL) return false;
memset(leaf, 0, sizeof(*leaf));
root_->ptrs[i1]->ptrs[i2] = reinterpret_cast<Node*>(leaf);
}
// Advance key past whatever is covered by this leaf node
key = ((key >> LEAF_BITS) + 1) << LEAF_BITS;
}
return true;
}
void PreallocateMoreMemory() {
}
};
优化代码实现
pagecache.h头文件
//单例模式,同central catch一样
class PageCache
{
private:
//std::map<PAGE_ID, Span*> _isSpanMap;//map更易观察
TCMalloc_PageMap1<32 - PAGE_SHIFT> _idSpanMap;//使用基数树优化
};
起始页号跟span映射关系修改
//_isSpanMap[kSpan->_pageId + i] = kSpan;
_idSpanMap.set(kSpan->_pageId + i, kSpan);
查找起始页号对应的span
auto ret = (Span*)_idSpanMap.get(prevId);
此时,我们在来观察一下运行结果,可以看到自己写的比系统的快了2倍左右,
为什么基数树不需要加锁?
在前面,我们需要加锁,是因为底层我们用到了map对应红黑树,unordered_map对应哈希表,我们在插入删除的时候,其结构会发生变化,所以需要加锁保证其线程安全。
对于基数树:
1、我们只在pagecache中的这两个函数中建立id和span的映射关系。
2、在基数树中,写之前又会提取开辟好空间,在我们写的过程中,是不会去改变结果的。
3、我们不会对同一个位置进行读写,线程1在对一个位置进行读写的时候,例外一个线程是被禁止访问这个区域的,这一块区域也就是线程1此时独享,线程2只能例外找一个位置进行读写。
扩展学习及当前项目实现的不足
实际中我们测试了,当前实现的并发内存池比malloc/free是更加高效的,那么我们能否替换到系统调用malloc呢?实际上是可以的。
- 不同平台替换方式不同。 基于unix的系统上的glibc,使用了weak alias的方式替换。具体来说是因为这些入口函数都被定义成了weak symbols,再加上gcc支持 alias attribute,所以替换就变成了这种通用形式:
void* malloc(size_t size) THROW attribute__ ((alias (tc_malloc)))
因此所有malloc的调用都跳转到了tc_malloc的实现 。
源码:https://gitee.com/deng_yuniubi/high-concurrency-memory-pool