当前项目是实现一个高并发的内存池,他的原型是google的一个开源项目tcmalloc,tcmalloc全称Thread-Caching Malloc,即线程缓存的malloc,实现了高效的多线程内存管理,用于替代系统的内存分配相关的函数(malloc,free)
我只是把tcmalloc最核心的框架简化拿出来,模拟实现了一个自己的高并发内存池。
所谓“池化技术”,就是程序先向系统申请过量的资源,然后自己管理,以备不时之需,之所以要申请过量的资源,是因为每次申请该资源都有较大的开销,不如提前申好了,这样使用时就会变得非常快捷,大大提高程序的运行效率
在计算机中,有很多使用“池”这种技术的地方,除了内存池,还有连接池,线程池,对象池等。以上服务器上的线程池为例,它的主要思想是:先启动若干数量的线程,让它们处于睡眠状态,当接收到客户端的请求时,唤醒池中某个睡眠的线程,让它来处理客户端的请求,当处理完这个请求,线程又处于睡眠状态。
内存池是指程序预先从操作系统申请一大块足够大内存,此后,当程序中需要申请内存的时候,不是直接向操作系统申请,而是直接从内存池中获取;同理,当程序释放内存的时候,并不真正将内存返回给操作系统,而是返回内存池。当程序退出(或者特定时间)时,内存池才将之前申请的内存真正释放。
内存池主要解决的当然是效率的问题,其次如果作为系统的内存分配器的角度,还需要解决一下内存碎片的问题。
还需要补充说明一点:
内存碎片分为内碎片和外碎片
外碎片是一些连续空闲的连续内存区太小,以至于虽然合计的内存空间足够,但是因为其不连续,不能满足一些内存分配申请需求(就如上图,虽然有384byte的空间,但是却没有大于256byte的连续空间,以至于我们申请超过256byte的连续空间则申请不出来)
内碎片我们后面具体遇到再讲解
下面我们先来设计一个定长内存池,设计该定长内存池主要有以下两个作用:
1.熟悉一下内存池是如何实现的
2.其会作为我们后面高并发内存池的一个基础组件
由此图可以发现,在不同的场景下,我们需要使用不同的定长内存池,
这正是因为定长内存池就如它的名字一样,只能分配定长的内存空间,
对于不同的内存大小需求,我们就需要定义分配不同内存大小的定长内存池
1.当程序第一次申请空间时,先申请一大块内存空间,用_memory记录其初始位置
2.当程序需要使用size大小的内存时,将大块内存切分成size的小块内存(实际就是将_memory给程序)。然后_memory的指向向后移size大小
3.当程序后面申请空间时,如果_freeList(自由链表)上挂的有内存块,则从自由链表上将内存块分配给程序。
4.当程序释放内存时,不是将内存释放给系统,而是将内存块归还给内存池,内存池将释放的空间挂入自由链表_freeList,可供下次申请使用。
注:_freeList不是构建了一个链表结构,用_next去指向下一个内存块。
而是用前一个空间的前4个字节(32位)或者前8个字节(64位)记录下一个内存块的起始地址进行抽象链接的
具体代码实现如下:
#pragma once
//直接去堆上按页申请空间
inline static void* SystemAlloc(size_t kpage) {
#ifdef _WIN32
void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else
#endif
if (ptr == nullptr) {
throw std::bad_alloc();
}
return ptr;
}
//定长内存池
//template
//class ObjectPoll{
//};
template<class T>
class ObjectPool {
private:
//C++11特性,用默认构造函数即可将其初始化
char* _memory = nullptr; //指向一大块内存空间的指针
void* _freeList = nullptr; //自由指针,指向还回来空间组成的链表
size_t _remainBytes = 0; //_memory中还剩余的字节数
public:
T* New(){
T* obj = nullptr;
//当_freeList对象不为空时,去重新利用该部分空间
if (_freeList) {
//去前面指向下一个节点的空间
void* next = *(void**)_freeList;
obj = (T*)_freeList;
_freeList = next;
}
else {
//剩余内存不够时,重新去申请一个大空间
if (_remainBytes < sizeof(T)) {
_remainBytes = 128 * 1024;
_memory = (char*)SystemAlloc(_remainBytes >> 13);
//_memory = (char*)malloc(_remainBytes);
if (_memory == nullptr) {
throw std::bad_alloc();
}
}
obj = (T*)_memory;
//如果T对象的大小小于指针大小,那么无法在_freeList中存储下一个节点的地址
//或者存储nullptr
//所以在T对象的大小小于指针大小时,让其大小为指针大小
size_t objsize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
_memory += objsize;
_remainBytes -= objsize;
}
//定位new,显示调用构造函数初始化对象
new(obj)T;
return obj;
}
void Delete(T* obj) {
//将释放的空间挂接在自由链表下
*(void**)obj = _freeList;
_freeList = obj;
//显示的调用析构函数清理对象
obj->~T();
}
};
对于定长内存池的实现,我们得掌握以下两个知识点:
1.因为我们实现了一个自己的内存池,去帮助程序申请空间,释放空间,
以此用于取代malloc,所以我们内存池申请内存不再调用malloc,而是
直接用windows下的VirtualAlloc直接向堆申请内存空间。
2.实现自由链表的抽象链接
具体实现为:将内存块指针obj强转为(void**)类型,再进行解引用,此时其类型还应该为void*类型,在32位下位四字节,在64位下为八字节,我们再给其赋值下一个内存块的起始地址。
现在的很多开发环境都是多核多线程,在申请内存的场景下,必然存在激烈的锁竞争问题,malloc本身其实已经很优秀了,但我们的项目tcmalloc之所以称为高并发,就是因为其在多线程高并发的场景下更胜一筹,所以我们实现时就要考虑以下的问题:
为什么要提及这些问题呢,就是因为tcmalloc解决了这些问题,可以比malloc更快更稳定的在高并发场景下运行。
下面我们来看看其的整体框架吧
该内存池主要由三部分组成,分别为thread cache,central cache,page cache。
我先分别阐述一下三个部分:
线程缓存是每个线程独有的(运用了线程局部缓存TLS技术),用于小于256KB的内存的分配(注意:不是thread cache只有256KB的内存空间哦,而是小于等于256KB的内存申请都是去找thread cache申请),**线程从这里申请内存不需要加锁,因为每个线程独享一个cache,这也就是这个并发线程池高效的地方。
中心缓存是所有线程共享,thread cache按需从thread cache获取的对象,而Central Cache在合适的时机回收thread cache中的对象,避免一个线程占用了太多的内存,而其他线程的内存吃紧。让内存分配在多个线程中更均衡的按需调度central cache中是存在锁的竞争的,因为每个thread cache都会去找同一个central cache要内存,在central cache用的是桶锁(后面具体讲central cache的结构时会进行说明。因为只有在thread cache中没有内存对象时才会找central cache要内存,所以这里的竞争不会很激烈
页缓存是在central cache缓存上面的一层缓存,存储的内存是以页为单位存储及分配的,central cache没有内存对象时,会从page cache分配出一定数量的page,并切割成定长大小的小块内存,分配给page cache。(怎么切割后面具体讲解page cache时会讲到),当一个span 的几个跨度页的对象都回收以后,page cache会回收central cache满足条件的span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。
在定长内存池时,我们是开辟了一大块内存空间,然后用自由链表接收释放回来的内存块,在这里,如果我们对每一个空间大小都创建一个自由链表,则需要256*1024个自由链表(因为小于等于256KB的内存都向thread cache申请)。这样设计的话需要的自由链表太多了。
基于上面的问题,我们可以设计出一个thread cache哈希桶结构,每个桶是一个按桶位置映射大小的内存块对象的自由链表。每个线程都会有一个thread cache对象,这样每个线程在这里获取对象和释放对象是无锁的
由上图的映射规则可知,我们的前128byte是按8byte对齐的,什么意思呢?
就是其中有以8byte大小的自由链表,有以16byte大小的自由链表。也就是说其前128byte是按8的倍数定义自由链表,一共有16个自由链表
在129到1024byte,是按16byte对齐的,什么意思呢?
跟前128byte类似,也就是按16的倍速+128去定义自由链表,一共有56个自由链表
后面的分配跟前面规则类似。
最后将这些自由链表封装成一个哈希桶结构就完成了对thread cache基础结构的构建
而哈希桶结构我们可用一个自由链表的数组来实现
这样我们不仅可以用208个自由链表完成对thread cache结构的建立,整体还控制在最多只有10%的内存碎片。
注:这个时候的内存碎片是内碎片,那内碎片是什么意思呢?
比如我们需要申请5字节的内存空间,但我们哈希桶中最小空间的自由链表为8byte,此时我们还要3字节的空间都用不了了,这些没用的空间相当于碎片化了,叫内碎片
既然我们thread cache的基础结构构建好了,那我们怎么去申请和释放内存空间呢?
1.申请空间
我们在项目构建时,会建立一个Common.h类,在其中我们会定义一个名为SizeClass的类,在类中会定义两个函数Roundup和Index分别去计算对齐数(申请多大的空间)和去哪个桶中申请。
Roundup函数如下:
static inline size_t _RoundUp(size_t bytes, size_t alignNum) {
return ((bytes + alignNum - 1) & ~(alignNum - 1));
}
//计算其对齐数,总共要给他多少空间
static size_t RoundUp(size_t size) {
assert(size <= MAX_BYTES);
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 {
//1 << PAGE_SHIFT为对齐数
return _RoundUp(size, 1 << PAGE_SHIFT);
}
}
Index函数如下:
static inline size_t _Index(size_t bytes, size_t align_shift) {
return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1;
}
static size_t Index(size_t size) {
assert(size <= MAX_BYTES);
static int group_array[4] = { 16,56,56,56 };
if (size <= 128) {
//8为2^3,传入次方数
return _Index(size, 3);
}
else if (size <= 1024) {
//16为2^4
//得加上前128byte所占的16个桶
return _Index(size-128, 4) + group_array[0];
}
else if (size <= 8 * 1024) {
//128为2^7
//类似的加上之前
return _Index(size - 1024, 7) + group_array[0] + group_array[1];
}
else if (size <= 64 * 1024) {
//1024为2^10
return _Index(size - 8 * 1024, 10) + group_array[0] + group_array[1]+ group_array[2];
}
else if (size <= 256 * 1024) {
//8*1024为2^13
return _Index(size - 64 * 1024, 13) + group_array[0] + group_array[1] + group_array[2] + group_array[3];
}
else {
assert(false);
}
return -1;
}
两个函数的构建也是按照上面图片的映射规则构建的,对上面函数不理解的,可以看看注释和对上面映射规则的讲解。
有些人还会问为什么上面的计算规则要用内联函数,因为我们申请和释放内存时会频繁调用这两个函数。
2.线程局部存储TLS
在创建线程时,怎么让其与thread cache对应联系起来呢?并且怎么创建其才是每个线程独有的呢?
为了让thread cache 为每个线程独有,我们得用到TLS(线程局部存储)
线程局部存储(TLS),是一种变量的存储方法,这个变量在它所在的线程内是全局可访问的,但是不能被其他的线程访问到,这样就保证了数据的线程独立性
这样的好处就是,每个线程独一份thread cache,小于256KB的内存申请不用再加锁申请,大大提高了效率
那怎么定义TLS呢
定义TLS特别简单,只用在ThreadCache结构中加一段这样的代码
//TLS(thread local storage)
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;
这样只要我们的线程创建好,就会有这样的指针,对线程内是全局的,其他线程无法访问。(第一次访问时,做个判断,先创建一个ThreadCache即可)
因为申请内存时,不可能让线程自己来获取自己的pTLSThreadCache对象,所以我们在Thread Cache结构之上在封装一个ConcurrentAlloc来管理内存分配即可。这样后面大于256KB的内存申请,也可以直接经过ConcurrentAlloc的结构去申请了。
3.申请和释放
申请和释放就没多少说的,大家看一下代码应该就能理解。
部分解释放在了注释里
唯一需要说明的点,就是Deallocate释放内存连同上层封装的ConcurrentAlloc释放内存还需要传入内存大小,这个后面会进行优化
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 {
//该函数作用是去向Central Cache申请内存
return FetchFromCentralCache(index, alignSize);
}
}
void ThreadCache::Deallocate(void* ptr, size_t size) {
assert(ptr);
assert(size <= MAX_BYTES);
//找到对应空间的桶位置,将其放入
size_t index = SizeClass::Index(size);
_freeLists[index].Push(ptr);
//大于一次批量的,即开始向central cache归还内存
if (_freeLists[index].Size() >= _freeLists[index].MaxSize()) {
ListTooLong(_freeLists[index], size);
}
}
4.与central cache联系
当我们程序申请内存空间时,先去计算其要申请哪个桶,如果该桶的自由链表下挂的有空间,就取下来分配给程序
如果没有,则去向Central cache要空间
central cache也是一个哈希桶结构,他的哈希桶的映射关系跟thread cache是一样的。不同的是他的哈希桶位置挂的是SpanList链表结构,不过每个映射桶下面的span中的大内存块被按映射切成了一个个小内存块对象挂在span的自由链表中。
1.thread cache是每个线程独享一个,而central cache是所有线程没有内存都会找它,所以其需要给每个桶加锁
正是因为是桶锁,所以两个线程去申请不同桶的空间时,不会存在竞争
怎样实现所有线程都去找同一个thread cache呢?
将thread cache类定义为单例模式即可
2.thread cache去要一个X空间,central cache不一定给它一个,而是给它几个,因为线程再需要X空间时,直接去找thread cache要了,而不是再来找central cache(这里具体给多少有个类似于网络tcp协议拥塞控制的慢开始算法,后面会讲解)
3.每个span的页数,在不同大小的内存桶下不一样,桶中的内存块大小越大,可能span页数越多越大
看了上面的,突然冒出了span和SpanList那又是什么东西啊
1.span --管理多个连续页大块内存的跨度结构
我们先来看一下span的代码
//定义在Common.h,因为其不仅要给central cache用,还要给page cache用
class Span {
public:
PAGE_ID _pageId = 0; //页号
size_t _n = 0; //页的数量
Span* _prev = nullptr; //指向前一个节点
Span* _next = nullptr; //指向后一个节点
size_t _useCount = 0; //计数,记录分配了多少个对象出去,
void* _freeList = nullptr; //自由指针
bool _isUse = false;
size_t _objSize = 0; //去记录该span一个小对象的大小,方便释放对象时可以不用传入对象大小
};
//双向带头循环链表
class SpanList {
private:
Span* _head; //头节点
//设置为公有,不然不好设置锁去拿取资源
public:
std::mutex _mtx; //桶锁
public:
SpanList() {
_head = new Span;
_head->_prev = _head;
_head->_next = _head;
}
Span* Begin() {
return _head->_next;
}
Span* End() {
return _head;
}
bool Empty() {
return _head->_next == _head;
}
void PushFront(Span* newspan) {
Insert(_head->_next, newspan);
}
Span* PopFront() {
Span* front = _head->_next;
Erase(front);
return front;
}
void Insert(Span* pos, Span* newSpan) {
assert(pos);
assert(newSpan);
Span* pre = pos->_prev;
pre->_next = newSpan;
newSpan->_prev = pre;
newSpan->_next = pos;
pos->_prev = newSpan;
}
void Erase(Span* pos) {
assert(pos);
assert(pos != _head);
//条件断点+查看栈帧
/*if (pos != _head) {
int x = 0;
}*/
Span* pre = pos->_prev;
pre->_next = pos->_next;
pos->_next->_prev = pre;
}
};
从上面的代码结构我们发现,span是一个双向链表结构,其中还有一些东西我们一一解释
1.PAGE_ID _pageId; 页号,其算是代表该页在内存中的位置,
2.size_t n; 表示页的数量,就是该span有几页的内存空间,相当于表示该span的内存大小。
对于后面页的合并,切分也有作用
3.size_t _usecount=0; 计数,分配一个内存对象出去则++,
还回来一个内存对象则--,
当span减到0表示所有对象都回到了span,
则将span释放回page cache,page cache会对前后相邻页进行合并
4._freeList表示span这个大内存空间切分成一个个小的内存块对象挂在该自由链表上
5._isUse 表示该span是否使用,用来区分,刚从page cache 获取的span和因为归还
空间_usecount减为0的span,此时该span因为_usecount减为0,将要释放给page cache
而刚才page cache获取的span对象其_usecount也为0
所以_isUse是为了区分他们
对page_id的理解
page_id相当于就是一个内存的起始地址除以页数
也就是说,有了内存的起始地址,那么用一个循环对地址进行对齐数大小的整数加减,就可以切割内存了;
例如:假设一页的大小为1KB,那么page_id和地址与数量之间的关系就如下图:
假设我的span只有中间的阴影部分的页内存,那么我的span结构中重要的数据为:
struct Span{
PAGE_ID _pageId = 2; //大块内存起始页号
size_t _n = 2; //页的数量
}
所以其内存大小就为页的数量一页的大小,该内存块的起始地址就为页号一页的大小
对于SpanList的一些解释
1.在central cache的哈希桶中,一个桶中可能有多个span,所以在我们去找
内存对象时,得再spanList中去遍历,
一个span用完了才能用下一个span
2.存在多个span都有一部分被申请出去的情况,因为thread cache可能还回
来再用完的span中,因为该span之前用完了,所以我们用了下一个span
3.为什么SpanList要设计为双向链表结构,因为如果有一个span全部回来了,
我们要将该span归还给page cache,如果单向链表不方便找到那个span,
也不方便进行删除。
所以我们运用双向链表,即简单又高效,插入删除为O(1);
2.页号
现在我们继续对页号进行细节处理
假设一页为2的13次方也就是8KB
那么在32位程序下,2^32 / 2 ^ 13=2 ^ 19个页,也就是大概五十万个页
在64位程序下,2 ^ 64 / 2 ^ 13 = 2 ^ 51个页,如果我们继续用size_t来记录页号,那么在32位下,size_t显然够用,但在64位下,size_t显然就不够用了
所以我们用条件编译那处理此情况:
#ifdef _WIN64
typedef unsigned long long PAGE_ID;
#elif _WIN32
typedef size_t PAGE_ID;
#else
#endif
这里有个小细节,就是我们得先对_WIN64进行判断,再对_WIN32进行判断
因为在_WIN32配置下,_WIN32有定义,_WIN64没有定义
在x64配置下,_WIN32和_WIN64都有定义
3.慢开始反馈调节
thread cache来要空间时,具体给多少?
我们采用慢开始反馈调节算法
具体实现如下:
//thread cache 去 central cache中去拿对象的规则
static size_t NumMoveSize(size_t size) {
assert(size > 0);
//[2,512]一次批量移动多少个对象的上下限
//小对象一次批量上限高
//大对象一次批量上限低
//batch ...批量
size_t batchNum = MAX_BYTES / size;
//限制下限
if (batchNum < 2) {
batchNum = 2;
}
//限制上限
if (batchNum > 512) {
batchNum = 512;
}
return batchNum;
}
在该函数实现中,其定义了一个上限和下限,
上限主要是限制小空间,在去拿空间时,不要超过512
下限主要是要求大空间至少都得拿两个走
那有人问了,这里哪里体现了慢开始反馈调节,因为这个代码还有一部分在这:
该代码是thread cache去向central cache要空间的函数实现
void* ThreadCache::FetchFromCentralCache(size_t index, size_t size) {
//慢开始反馈调节算法
//最开始不会向central cache一次批量太多,因为太多用不完
//如果你不断有这个size大小内存需求,那么batchNum就不会增长,直到上限
//size越大,一次向central cache要的batchNum就越小
//size越小,一次central cache要的batchNum就越大
size_t batchNum = min(_freeLists[index].MaxSize(),SizeClass::NumMoveSize(size));
if (batchNum == _freeLists[index].MaxSize()) {
//如果觉得1增长的太慢,用2,3也可以
_freeLists[index].MaxSize() += 1;
}
void* start = nullptr;
void* end = nullptr;
//实际获取的数量
int actualNum = CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, size);
assert(actualNum > 0);
if (actualNum == 1) {
assert(start == end);
return start;
}
else {
//start要返回,给线程运用
//start之后的挂在thread cache中
_freeLists[index].PushRange(NextObj(start), end,actualNum-1);
return start;
}
}
下面代码是_freeList中的一部分代码,这三部分协调作用,共同完成了慢开始反馈调节
class FreeList {
private:
void* _freeList = nullptr;
//限制向central cache要空间的数量
size_t _maxSize = 1;
size_t _size = 0;
public:
size_t& MaxSize() {
return _maxSize;
}
};
总的来说,就是先开始取的空间数量为1,然后为2,慢慢的,一直增长,直到与SizeClass中的NumMoveSize(size)一样大,将以其为上限,取的数量不会再增大
所以:
1.最开始不会向central cache一次批量要太多,因为要太多了用不完
2.如果你不断有这个size大小的内存需求,那么batchNum就不会断增长,直到上限
3.size越小,一次向central cache要的batchNum就越大
4.size越大,一次向central cache要的batchNum就越小
4.central cache中的一些函数接口简单介绍
就简单说明一下一些接口函数,方便后续总表理解整个过程
1.FetchRangeObj() 从中心缓存获取一定数量的对象给thread cache
2.GetOneSpan() 先判断central cache中的桶中有没有,有的话就直接取出,没有的话调用page cache中的NewSpan()获取一个span
3.RealeaseListToSpans() 将thread cache中的一部分空间对象归还给central cache中的span,如果central cache中的span中的_usecount==0,将该span归还给page cache
page cache结构
可看出page cache虽然也是哈希桶结构,但明显与central cache和thread cache明显不一样了,因为他是以每页为为单位划分一个桶,一共有128个桶
在每个桶下面还是挂的span,每个span的内存大小与桶划分的大小对于。其也是一个SpanList结构。只不过每个span下面没有划分了小空间的自由链表。
申请内存
1.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。
2.如果找到_spanList[128]都没有合适的span,则向系统使用mmap、brk、或者是VirtualAlloc等方式申请128页page span挂在自由链表中,再重复1的过程
3.需要注意central cache和page cache的核心结构都是SpanList _spanLists的哈希桶,但他们有本质区别,central cache中哈希桶,是按跟thread cache一样的大小对齐关系映射的,他的_spanLists中挂的span中的内存都按映射关系切好链接成小块内存的自由链表,而page cache中的_spanLists则是按下标桶号映射的,也就是说第i号桶中挂的span都是i页内存。
释放内存
如果central cache释放回一个span,则依次寻找span的前后page id的没有在使用的空闲span,看是否可以合并,如果合并继续向前寻找,这样就可以将切小的内存合并收缩成大的span,减少内存碎片。
为什么这里要用128页为最大页呢
没有为什么,根据自己需求去选择,如果用256页为最大页也可以
在PageCache.h中构建PageCache结构并且定义会使用的接口函数
在PageCache.cpp中实现那些接口函数
//单例模式
class PageCache {
public:
static PageCache* GetInstance() {
return &_sInstance;
}
Span* NewSpan(size_t k);
//返回一个该PAGE_ID对应的Span*
Span* MapObjectToSpan(void* obj);
//释放空闲span回到PageCache,并合并相邻的Span
void ReleaseSpanToPageCache(Span* span);
public:
//全局锁,避免在一个位置没有找到span,继续向后找span时,用桶锁会频繁加锁解锁
std::mutex _pageMtx;
private:
PageCache(){}
PageCache(const PageCache&) = delete;
private:
//与CentralCache中的_spanLists不一样,
//CentralCache中的_spanLists是按照对象大小划分桶,每个桶下链接一个span链表,
// 每个span有划分为对象空间的自由链表
//PageCache中的_spanLists是按照页数划分为桶,代表span的大小,同时span下没有
// 划分的自由链表
SpanList _spanLists[NPAGES];
ObjectPool<Span> _spanPool;
//声明
static PageCache _sInstance;
//PAGE_ID与Span*的映射关系
std::unordered_map<PAGE_ID, Span*> _idSpanMap;
};
PageCache类的设计成单例模式,因为central cache去访问page cache也应该访问的是同一个page caceh。
page cache的锁还是桶锁吗
page cache中对哈希桶的锁不能再是桶锁了,得是全局锁**std::mutex _pageMtx;
所以在去PageCache要空间时,可以将CentralCache的桶锁解掉,用全局锁加锁,这样其他去访问CentralCache的即可访问,再拿到空间返回时,我们再重新申请锁即可
因为如果申请4页的span,而4页的桶下已经没有剩余的span了。那么我们会继续向更大页的桶中去寻找span,
所以如果是桶锁,我们去向后找,会不断加锁解锁,消耗很大,效率降低
总:
所以对于PageCache它会在一个范围进行索引访问,并且多个线程访问同一个桶的概率大大提升,
使用桶锁就会导致频繁加锁解锁,导致效率降低,相反,使用整体锁,每个线程就只需加解一次
对于CentralCache,不同线程访问的大概率不是一个位置的桶,所以这个时候加桶锁就非常合适
如果加整体锁,反而会造成大量线程等待,降低效率
这里就不贴源代码了,讲一下实现,
过程
获取K页的span,但在PageCache中的_spanLists中没有,那么去找大一点span,去分成小的span
如果到128page还没有可用的span,那么就会去找堆要。
注意
1.分成的小页span在后面CentraCache释放空间时会进行合并为大页span,以缓解内存碎片问题
2.找堆要时,堆也不会给小的span,而是给一块128page的空间,去切成各种空间的span,后面把这些切小的span可以合并。
如:需要2页的span,就把该128页的span分为2页的span和126页的span,2页的span返回给CentralCache用,126页的span挂在PageCache的第126号桶下
切分过程
因为我们PageCache将span给CentralCache时,还需要将span切分成小对象,挂在自由链表下面,因为CentralCache的哈希桶下的span下面存在切分好的小对象
计算起始地址
之前讲过页号的作用,起始地址=页号*页的大小,页的大小我们定义为2^13
所以 起始地址=页号 << 13
所占字节数=页的数量*一页的大小
所以 所占字节数=页的数量 << 13
知道span的起始地址了,知道所占字节数了
就可以将其拆分为小对象了
大块内存的链接
跟之前的自由链表链接方法一样,用前一个内存块的前四个字节或前八个字节指向下一个内存块即可
该函数将CentralCache的span回收回PageCache。
在回收CentralCache的内存时,注意要看其前后相邻也是否空闲,如果空闲与前后相邻页进行合并,以此减少内存碎片
合并过程
由图可知,假如此时回收回来的span页号为100,页的数量为x。
我们合并时,得找前后相邻页。
先找前面相邻页99是否空闲,通过前面在span中新增的_isUse去判断其是否被使用,如果没有被使用,则合并,如果被使用了,即结束向前合并。同时如果在合并时合并后的span总页数会大于128或者等于128则结束合并
再向后寻找100+x页是否空闲,如果空闲继续往后寻找。
所以那我们怎么实现去查找前后相邻页是否空闲呢?
可构建一个unordered
然后在PageCache给CentralCache内存时,将那些span,全部加 _idSpanMap,并且将PageCache空闲的Span全部加入 _idSpanMap结构,这样可以快速判断回收回来的页前后是否空闲
我们加入_idSpanMap建立映射关系时,只用加这个span的前后页号与其建立关系即可,因为我们合并时,也只是找一个Span的前后页号(前页号为span中的页号,后页号为span中的页号+页的数量-1)
合并后需要把其合并前页的映射从_idSpanMap中删去,然后建立合并后span与页号的映射
前面讲ThreadCache与CentralCache时并没有讲其怎么回收内存,而讲PageCache时,但讲解了回收内存与合并前后页减少内存碎片
threadcache回收内存非常简单
1.某个线程这块内存不用了,调Dealloc将内存回收释放给threadcache,threadcache算好该内存
映射的哪个桶,将该内存插入到对应的桶里去
2.如果对应的桶太长了,会向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);
//大于一次批量的,即开始向central cache归还内存
if (_freeLists[index].Size() >= _freeLists[index].MaxSize()) {
ListTooLong(_freeLists[index], size);
}
}
void ThreadCache::ListTooLong(FreeList& list, size_t size) {
void* start = nullptr;
void* end = nullptr;
//取出需要释放回central cache的内存对象
list.PopRange(start, end, list.MaxSize());
//调用CentralCache的接口去将该对象释放回Span
CentralCache::GetInstance()->RealeaseListToSpans(start, size);
}
注:ListTooLong()函数将内存对象释放回centralcache
该函数将从threadcache回收的内存挂回span
页号 = 内存地址数 / 2^13
所以在x到x+2^13这个内存范围内的值除以2^13次方还是等于2000
找到页号后,根据_idSpanMap结构即可找到与页号对应的Span
但是我们span只有前后页在_idSpanMap有映射关系。所以我们在切分小对象时,传入Span中每一页的页号与Span的映射关系
所以我只要用对象的内存首地址除以2^13次方即可算出其是哪个span下分出去的对象
总的来说整个回收过程就是:
如果CentralCache中的span _usecount等于0,则说明切分给threadcache的小块内存全都回来了,则CentralCache把这个span还给PageCache,PageCache通过页号,查看前后的相邻页是否空闲,是的话就合并,合并出更大的页,解决内存碎片问题
1.<=256KB
通过三层缓存去申请内存,通过threadcache->centralcache->pagecache
2.>256KB
分为两种情况
当32*8k < size <= 128 * 8K则去找pagecache要页空间
当size>128*8K时,直接去找系统要空间
当32*8k < size <= 128 * 8K则将空间归还给pagecache
当size>128*8K时,则直接将内存空间归还给系统
在后续继续进行了一些优化
有:
1. 定长内存池去配合脱离new的使用,因为我们之前申请内存还在用malloc,
当然不用定长内存池也行,直接调用系统调用接口申请内存,但是定长内存池
相当于也减少了反复找系统要内存的开销
2. 释放对象时不传入内存大小
加入一个unordered_map<PAGE_ID,size_t>去存储页号和大小即可解决
3. 因为锁的消耗和unordered_map查找的消耗非常大,所以可以引入基数树来
替代unordered_map,以此来优化unordered_map内的查找消耗和锁的竞争
消耗
以上的优化跟我们学习tcmalloc的基本架构没有太大关系,优化了代码和效率提升
我实现tcmalloc的核心代码思路主要是要了解ThreadCache、CentralCache
、PageCache的内存申请和释放,了解它是如何做到在多线程高并发场景下比malloc更胜一筹
在项目中,构建了一个Benchmark.cpp去比较与malloc的效率
#define _CRT_SECURE_NO_WARNINGS
#include"ConcurrentAlloc.h"
// ntimes 一轮申请和释放内存的次数
// rounds 轮次
void BenchmarkMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
std::vector<std::thread> vthread(nworks);
std::atomic<size_t> malloc_costtime = 0;
std::atomic<size_t> free_costtime = 0;
for (size_t k = 0; k < nworks; ++k)
{
vthread[k] = std::thread([&, k]() {
std::vector<void*> v;
v.reserve(ntimes);
for (size_t j = 0; j < rounds; ++j)
{
size_t begin1 = clock();
for (size_t i = 0; i < ntimes; i++)
{
//v.push_back(malloc(16));
v.push_back(malloc((16 + i) % 8192 + 1));
}
size_t end1 = clock();
size_t begin2 = clock();
for (size_t i = 0; i < ntimes; i++)
{
free(v[i]);
}
size_t end2 = clock();
v.clear();
malloc_costtime += (end1 - begin1);
free_costtime += (end2 - begin2);
}
});
}
for (auto& t : vthread)
{
t.join();
}
printf("%u个线程并发执行%u轮次,每轮次malloc %u次: 花费:%u ms\n",
nworks, rounds, ntimes, malloc_costtime.load());
printf("%u个线程并发执行%u轮次,每轮次free %u次: 花费:%u ms\n",
nworks, rounds, ntimes, free_costtime.load());
printf("%u个线程并发malloc&free %u次,总计花费:%u ms\n",
nworks, nworks * rounds * ntimes, malloc_costtime.load() + free_costtime.load());
}
// 单轮次申请释放次数 线程数 轮次
void BenchmarkConcurrentMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
std::vector<std::thread> vthread(nworks);
std::atomic<size_t> malloc_costtime = 0;
std::atomic<size_t> free_costtime = 0;
for (size_t k = 0; k < nworks; ++k)
{
vthread[k] = std::thread([&]() {
std::vector<void*> v;
v.reserve(ntimes);
for (size_t j = 0; j < rounds; ++j)
{
size_t begin1 = clock();
for (size_t i = 0; i < ntimes; i++)
{
//v.push_back(ConcurrentAlloc(16));
v.push_back(ConcurrentAlloc((16 + i) % 8192 + 1));
}
size_t end1 = clock();
size_t begin2 = clock();
for (size_t i = 0; i < ntimes; i++)
{
ConcurrentFree(v[i]);
}
size_t end2 = clock();
v.clear();
malloc_costtime += (end1 - begin1);
free_costtime += (end2 - begin2);
}
});
}
for (auto& t : vthread)
{
t.join();
}
printf("%u个线程并发执行%u轮次,每轮次concurrent alloc %u次: 花费:%u ms\n",
nworks, rounds, ntimes, malloc_costtime.load());
printf("%u个线程并发执行%u轮次,每轮次concurrent dealloc %u次: 花费:%u ms\n",
nworks, rounds, ntimes, free_costtime.load());
printf("%u个线程并发concurrent alloc&dealloc %u次,总计花费:%u ms\n",
nworks, nworks * rounds * ntimes, malloc_costtime.load() + free_costtime.load());
}
int main()
{
size_t n = 10000;
std::cout << "==========================================================" << std::endl;
BenchmarkConcurrentMalloc(n, 4, 10);
std::cout << std::endl << std::endl;
BenchmarkMalloc(n, 4, 10);
std::cout << "==========================================================" << std::endl;
return 0;
}
**这是在最大申请空间为8K时的对比
这是在最大申请为256KB的内存申请对比
可见当申请内存空间小于256KB时,tcmalloc的效率明显优于malloc