高并发内存池(仿谷歌tcmalloc)

前言


做这个项目是为了学习前人的研究成果,只取其中核心部分做讲解


前置知识


池化技术:将程序中需要经常使用的核心资源先申请出来,放到一个池内,由程序自己管理,这样可以提高资源的使用效率,也可以保证本程序占有的资源数量


本篇要讲的内存池就是先向系统申请一大块空间,后续使用空间的时候直接到已经申请过的这一大块空间中去取,而不是再向系统申请资源

这个项目会使用到C++、多线程、互斥锁、链表和哈希桶、操作系统内存管理、单例模式等知识,本篇对这些基础知识不会详细叙述,需自行去了解

项目介绍

本项目是实现一个高并发的内存池,原型是google的一个开源项目tcmalloc,tcmalloc全称Thread-Caching Malloc,即线程缓存的malloc,实现了高效的多线程内存管理,用于替代系统的内存分配相关的malloc、free函数。

本篇所讲的项目是把tcmalloc最核心的框架简化后模拟实现,实现出一个化简版的高并发内存池,目的就是学习tcamlloc的精华,而不是完全的实现tcmalloc。

项目解决的问题

1.申请效率问题

频繁申请内存的场景下,每次需要内存都需向系统申请,这样效率必然有影响。

而使用内存池,预先保存一大块空间,每次需要空间时就去内存池拿,这样就高效很多

2.内存碎片问题

申请空间时在堆上申请,由低地址到高地址申请,但是释放的顺序是不确定的
假设系统依次分配了16byte、8byte、16byte、4byte,还剩余8byte未分配。操作系统先回收了两个16byte,这时要分配一个24byte的空间,总的剩余空间有40byte,但是却不能分配出一个连续24byte的空间,这就是内存碎片问题。

内存池需要解决内存碎片问题(这里主要是外碎片),如何解决在页缓存层那里讲解

上述两个问题malloc也考虑解决了,我们的tcmalloc还考虑了多线程下锁竞争问题

整体设计

分为3层:线程缓存(Threadache)、中心缓存(centralCache)、页缓存(PageCache)

重点:线程缓存向中心缓存申请空间或释放空间的逻辑

中心缓存向页缓存申请空间或释放空间的逻辑

线程缓存

一个线程享有一个ThreadCache,线程从这里申请内存不需要加锁,每个线程独享一个cache,这也就是这个并发线程池高效的地方。

中心缓存

中心缓存是所有线程所共享,thread cache是按需从central cache中获取的对象。central cache周期性的回收thread cache中的对象,避免一个线程占用了太多的内存,而其他线程的内存吃紧。达到内存分配在多个线程中更均衡的按需调度的目的。central cache是存在竞争的,所以从这里取内存对象是需要加锁,不过一般情况下在这里取内存对象的效率非常高,所以这里竞争不会很激烈。

页缓存

页缓存是在central cache缓存上面的一层缓存,存储的内存是以页为单位存储及分配的,central cache没有内存对象时,从page cache分配出一定数量的page,并割成定长大小的小块内存,分配给central cache。page cache会回收central cache满足条件的span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题

用到的全局变量以及通用结构

分别有FreeList类、Span类、SpanList类,每个类作用是什么、管理什么后面讲

static const size_t MAX_BYTES = 256 * 1024;//最大字节数为256KB
static const size_t NFREE_LIST = 208;//总共208个哈希桶
static const size_t NPAGES = 129;//总共129页
static const size_t PAGE_SHIFT = 13;//2^13=8192字节为一页大小,即8KB

//管理切分好的小内存的自由链表
class FreeList
{
public:
	//头插1个小内存块进自由链表
	void Push(void* obj);
	
	//在自由链表头部插一批(n个)结点
	void PushRange(void* start, void* end, size_t n);


	//从自由链表中推出(删除)一个小内存块(返回给调用者),头删
	void* Pop();
	

	//从自由链表中推出(删除)n个小内存块
	void PopRange(void*& start, void*& end, size_t n);


	bool Empty();

	size_t& MaxSize();

	size_t Size();
private:
	void* _freeList = nullptr;//自由链表
	size_t _maxSize = 1;//表征自由链表的最大长度,初始值为1
	size_t _size = 0;//链表中结点个数
};

//管理单个连续页的大块内存跨度结构
struct Span
{
	PAGE_ID _pageId = 0;//每个大块内存起始页的页号,初始值为0
	size_t _n = 0;//页数

	Span* _next = nullptr;
	Span* _prev = nullptr;

	size_t _objSize = 0;//切好的小对象的大小
	size_t _useCount = 0;//切好的小块内存,被分配给threadCache的数量(计数用)

	//每个span结点又带有一个自由链表结点
	void* _freeList = nullptr;

	bool _isUse = false;//是否正在使用该span
};


//管理span的带头双向循环链表
class SpanList
{
public:
	SpanList()
	{
		_head = new Span;
		_head->_next = _head;
		_head->_prev = _head;
	}

	Span* Begin();

	Span* End();

	bool Empty();

	//头插
	void PushFront(Span* span);

	//头删
	Span* PopFront();

	//在pos的前一个位置插入newSpan
	void Insert(Span* pos, Span* newSpan);

	//删除pos位置的span大块内存块以及它下面的链表(把它还给上一层)
	void Erase(Span* pos);

private:
	Span* _head;
public:
	std::mutex _mtx;//每个spanlist的桶锁
};

1.线程缓存设计

为了方便空间分配,需要先建立对齐映射规则

申请字节范围                        对齐规则                      哈希桶范围
[1,128]字节                           按8byte对齐                 freelist[0,16)  
[129,1024]字节                     按16byte对齐               freelist[16,72)  
[1025,8*1024]字节               按128byte对齐              freelist[72,128)
[8*1024+1,64*1024]字节      按1024byte对齐           freelist[128,184)
[64*1024,256*1024]字节      按8*1024byte对齐        freelist[184,208)

按照这种对齐规则,空间浪费率在11%左右,计算方法:假设申请129字节,给多个16字节,按16字节对齐
129字节的申请要求,给的字节数是8*16+16=144,浪费了144-129=15字节,浪费率在15/144=11%

根据申请字节数对齐后的字节数与哈希桶下标的映射关系如下图

每个哈希桶的具体数据结构形式

以后线程向线程缓存申请空间或释放空间,动的就是void*_freeList下挂载的一个个内存块(上图橙色长方形)

问题又来了,一个一个的内存块是没有具体的数据结构表示的,怎么把它们像链表一样前后连接起来?

1.1无数据结构表示的内存块们如何前后连接起来?

以32位平台为例,方法为:取每个内存块的前4个字节(匹配32位下指针类型的大小)存放下一个内存块的地址,把这个方法命名为NextObj

64位平台呢,取前8个字节(匹配64位下指针类型的大小)存放下一个内存块的地址,如何判断是取前4位还是取前8位,需要对不同平台做一一处理吗?

不需要,请查看以下代码

//取每个小内存块的前4/8个字节(根据操作系统变化)的空间
static void*& NextObj(void* obj)
{
	return *(void**)obj;
}

void**是2级指针,解引用*(void**)的结果是void*,而void*是一级指针,在32位下void*就是4字节,64位下void*就是8字节,我们要的就是用void*统筹处理取前4字节还是8字节存放下一个内存块地址

这样就把一个一个的内存块以链表的形式管理起来,我们这个链表称为自由链表

这个自由链表不是真正意义上的链表,而是将一个一个的内存块用取前4个字节或8个字节存放地址的形式前后连接保存起来的形式链表,看起来像是一串链表

对于怎么把1个内存块/多个内存块从自由链表中Pop或者push,代码逻辑这里不详细叙述了,在项目里自行查阅

1.2线程缓存接口设计

class ThreadCache
{
public:
	//申请内存对象,申请size大小的空间,返回给调用者
	void* Allocate(size_t size);

	//释放内存对象,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 _freeLists[NFREE_LIST];//哈希表结构,总共208个哈希桶
};

// 这句告诉给编译器此ThreadCache*类型的变量为线程内部使用,每个线程都会copy一份给本地用
static _declspec (thread)  ThreadCache* pTLSThreadCache = nullptr;

ThreadCache类的成员变量FreeList _freeLists[NFREE_LIST]是个数组,数组上存放一个个的FreeList类型数据,我们用FreeList类里面的void* _freeLists成员变量来表征一个哈希桶,我们用它和上文提到的NextObj函数组合来管理这些一个一个无法用数据结构表示的内存块

下面代码是简洁表示的FreeList类,没有写出成员函数

//管理切分好的小内存的自由链表
class FreeList
{
private:
	void* _freeList = nullptr;//自由链表
	size_t _maxSize = 1;//表征自由链表的最大长度,初始值为1
	size_t _size = 0;//链表中结点个数
};

线程缓存的哈希桶结构示意图在上面发过了,这里就不发了

1.3线程缓存向中心缓存申请空间

中心缓存怎给予线程缓存内存块?中心缓存如果还没有空闲的内存块呢?这些后面讲,最后会总体的梳理一下3层之间,每一层向下一层申请空间和释放空间的逻辑,这里先不做解答

线程TLS:

为了保证效率,我们使用thread local storage(线程本地存储)保存每个线程本地的ThreadCache的指针,这样大部分情况下申请释放内存是不需要锁的。

根据以下代码

// 这句告诉给编译器此ThreadCache*类型的变量为线程内部使用,每个线程都会copy一份给本地用
static _declspec (thread)  ThreadCache* pTLSThreadCache = nullptr;

每个线程享有一个ThreadCache类对象,线程向线程缓存申请空间时就不需要加锁。

当ThreadCache的某个哈希桶下保存有空闲的内存块时,将该块内存返回给线程使用;

如果没有,意味着该哈希桶下一个空闲的内存块也没有,则向中心缓存对应的哈希桶申请批量的内存块,申请完成将内存块给线程使用

从中心缓存拿到内存对象后,怎么纳入线程缓存,过程如下图

//申请内存块返回给调用者,参数size是需求占用的空间大小,不是对齐后的大小
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);//传入的第二个参数是内存对齐后的字节数 
	}
}

接上段代码的FetchFromCentralCache函数核心代码

返回的start就是指向内存块的void*类型地址,这个start会被线程缓存接收,再交给线程,线程就拿到了一块空间

1.4线程缓存向中心缓存释放批量的空间

 span是什么?怎么查找这批内存块属于哪个span?这些问题在中心缓存层讲

线程向线程缓存申请了能承载一定字节数的空间,那么也会向线程缓存换回来这部分空间(意思是把内存块重新挂载进线程缓存对应的哈希桶),在还回来的同时,线程缓存会顺带检查此时是否是一个合理的时机,可以把一部分空间还给中心缓存

什么才算是合理的时机?如果一个哈希桶下挂载的内存块数量大于等于这个哈希桶最大可挂载内存块数量,那么这时就算是一个合理的时机,向中心缓存归还。哈希桶最大可挂载内存块数量也是用一定计算方法算出来的,这里碍于篇幅就不详细叙述了,详情都在代码里,结尾我会放出这个项目链接

void ThreadCache::Deallocate(void* ptr, size_t size)
{
	assert(ptr);//ptr不为空为真
	assert(size <= MAX_BYTES);

	size_t index = SizeClass::Index(size);//计算在哪个哈希桶
	_freeLists[index].Push(ptr);//插入线程缓存的自由链表

	//如果线程缓存存储的内存对象太多,需要将批量的内存对象还回中心缓存
	//当链表使用长度大于一次批量申请的内存个数时就开始还一段list给中心缓存(有控制线程缓存每个哈希桶长度的作用)
	if (_freeLists[index].Size() >= _freeLists[index].MaxSize())
	{
		ListTooLong(_freeLists[index], size);//这里传入的freelist是线程缓存的自由链表,不是中心缓存的
	}
}

ListTooLong函数核心代码

线程缓存哪个哈希桶挂载的空闲内存块超过一定数量了,就把该桶的前n-1个内存块还给中心缓存,剩下最后1个内存块继续挂载在线程缓存的哈希桶

2中心缓存设计

一个进程只允许有一个中心缓存层,所以将该层设计为单例模式

//一个进程只能有一个centralCache,所以将其设置成单例模式(饿汉)
class CentralCache
{
public:
	//在实例化类之前,就已经存在该函数
	static CentralCache* GetInstance()
	{
		return &_sInst;
	}

	//从本层(中心缓存)的spanlist获取一个非空的span
	Span* GetOneSpan(SpanList& list, size_t byte_size);

	//从中心缓存获取一定数量的内存对象给threadcache层,start和end是输出型参数
	size_t FetchRangeOBJ(void*& start, void*& end, size_t n, size_t size);

	//将线程缓存还回的内存对象释放到对应的span中(计算还回中心缓存哪个哈希桶)
	void ReleaseListToSpans(void* start, size_t size);


private:
	SpanList _spanLists[NFREE_LIST];//哈希表结构

private:
	//构造函数私有,禁止拷贝
	CentralCache() {}
	CentralCache(const CentralCache&) = delete;

	static CentralCache _sInst;
};

CentralCache类里的SpanList _spanLists[NFREE_LIST]成员变量是一个数组,数组上存放一个个的SpanList类型,每个SpanList表征一个哈希桶

SpanList类型是什么?

为了简洁叙述,下面只列出了SpanList的成员变量

//管理span的带头双向循环链表
class SpanList
{
private:
	Span* _head;
public:
	std::mutex _mtx;//每个spanlist的桶锁
};

每个SpanList都带有一个锁和一个代表头节点的Span*地址,Span又是什么类型?

//管理单个连续页的大块内存跨度结构
struct Span
{
	PAGE_ID _pageId = 0;//每个大块内存起始页的页号,初始值为0
	size_t _n = 0;//页数

	Span* _next = nullptr;
	Span* _prev = nullptr;

	size_t _objSize = 0;//切好的小对象的大小
	size_t _useCount = 0;//切好的小块内存,被分配给threadCache的数量(计数用)

	//每个span结点又带有一个自由链表结点
	void* _freeList = nullptr;

	bool _isUse = false;//是否正在使用该span
};

可以看到,span里面有表征页号、页数的属性,也有指向前一个span和后一个span的地址,这说明span可以当作一个链表中的结点来看,更有一个最重要的void*_freeList,

_freeList是干嘛的?和线程缓存的void* _freeList起的作用相同,都是用来挂载一个一个的内存块

回到SpanList,SpanList中带有表示span头节点的_head,而span中又有表示前后span的_prev和_next,所以中心缓存的整个结构如下图所示

(由于是多个线程竞争向一个中心缓存申请空间,所以每个哈希桶都需要有一个桶锁,如果线程对该哈希桶进行修改操作,就上锁,不许别的线程同时也对该哈希桶执行修改操作)

2.1中心缓存给予线程缓存内存空间

线程缓存不足,向中心缓存要空间时,中心缓存先查看自己对应的哈希桶下的span是否有空闲的空间,如果有,把该span下的前n-1个空闲内存块给线程缓存,剩下最后1个内存块继续挂载在span中(中心缓存层上锁和解锁的逻辑后面讲,因为需要和页缓存层联合起来才能梳理清楚)

//从中心缓存获取一定数量的内存对象(用链表形式连接起来的批量内存块),把它们给threadcache层
size_t CentralCache::FetchRangeOBJ(void*& start, void*& end, size_t batchNum, size_t size)
{
	size_t index = SizeClass::Index(size);//在哪个桶

	_spanLists[index]._mtx.lock();//在中心缓存层上桶锁

	Span* span = GetOneSpan(_spanLists[index], size);//获取一个span,GetOneSpan函数走完,_spanLists[index]的桶锁还是上锁状态
	assert(span);
	assert(span->_freeList);

	//更新输出型参数start和end
	start = span->_freeList;//地址
	end = start;
	size_t i = 0;

	//起始数量是1是因为当end指向起始位置时,已经算拿了一个
	size_t actualNum = 1;//实际获取的内存对象个数

	//end走batchNum-1步(即end走完,end是指向最后一个结点的前一个结点)且不能走到空
	while (i < batchNum - 1 && NextObj(end) != nullptr)
	{
		end = NextObj(end);
		++i;
		++actualNum;
	}

	span->_freeList = NextObj(end);//更新被拿走内存对象的哈希桶,该桶剩最后一个内存对象还在挂载
	NextObj(end) = nullptr;
	span->_useCount += actualNum;//记录正在使用的内存对象
	_spanLists[index]._mtx.unlock();//在中心缓存层解桶锁

	return actualNum;
}

GetOneSpan函数处理了中心缓存本身有空闲的内存空间和没有空闲的内存空间情况

中心缓存有空闲的空间

有空闲的内存空间时逻辑处理的核心代码

中心缓存没有空闲的空间

如果没有空闲的内存空间,向页缓存申请空间,申请多大的空间?

回答这个问题之前,我建议先去页缓存层看看它的设计结构,页缓存挂载在哈希桶的不是一个一个无数据结构的内存块了,而是一个一个的span类型,而且这个span类型下面没有像中心缓存那样挂载内存块(页缓存挂载的span为什么没有挂载内存块,这个留在页缓存讲)

答案是申请以1页为单位的多页大小的空间,1页大小我定义为8K,申请多少页需要根据申请的字节数,用一定的计算规则转换成多少页

计算方法

先定义一个最大字节数256KB,这个最大字节数是线程缓存最后一个下标的哈希桶所能挂载的内存块大小,假设需要size字节,如果申256KB/size的结果过大(>512个),说明单个对象估计占用的字节数(即size)很小,那么应给予的内存块(以下称之为内存对象)个数就给512个;如果256KB/size的结果过小(<2个),说明单个对象估计占用的字节数(即size)很大,那么应给予的内存对象个数就给2个

知道了应给予多少个内存块,计算这几个内存块合起来,总共有多少字节数,即size_t npage = num * size这行代码

总字节数÷1页大小的结果,就是页数,1页大小我定义为8K

获知了页数,就能知道应该向页缓存哪个哈希桶申请空间了

	static size_t NumMoveSize(size_t size)//传入的size是计算完占用空间后的内存对齐数
	{
		assert(size > 0);

		//[2,512]区间字节,一次批量移动多少个内存对象的(慢启动)上限值
		//小内存对象一次批量上限高
		//大内存对象一次批量上限低
		//其余情况就直接按num的个数来取

		int num = MAX_BYTES / size;
		if (num < 2)                //如果num较小,说明size(单个对象占用空间大小)很大
			num = 2;                //给与2个内存对象
		if (num > 512)              //num较大,说明单个对象很小
			num = 512;              //给予多个(也就是512个)内存对象
		
		return num;
	}

	//计算pagecache一次向OS堆申请几页
	static size_t NumMovePage(size_t size)//传入的size是计算完单个对象占用空间后的内存对齐数
	{
		size_t num = NumMoveSize(size);

		//总字节数=内存对象个数*单个对象大小
		size_t npage = num * size;

		//页数等于总字节数/单页大小,单页大小设置为2^13,即8KB
		npage >>= PAGE_SHIFT;//总字节数右移13位相当于除2^13,即除等8K(缩小8K,一页设置为8K)

		if (npage == 0)//除等的结果为0,说明总字节数不够一页大小,页数一页都没有
		{
			npage = 1;
		}
		return npage;
	}

下面是中心缓存从页缓存拿到空间后的处理逻辑,简单来说就是把一段大长方形(大内存空间)切出一个一个等大的小长方形(小内存空间),切的时候把每个小长方形的地址连接到上一个小长方形,这样就形成了span->内存块->内存块的链表形式,从span产生了一个一个的内存空间,中心缓存就能把这段链表形式的空间挂载到对应的哈希桶下了

接上图

继续接上图

start直到移动到end停止,不再向后移动。有人可能发现了,新切出的内存块首地址,与上一个内存块的地址有间隔连续性,即内存块首地址+size=下一个内存块首地址,这个特性可以很好的利用起来,在中心缓存向页缓存归还空间那里讲怎么利用

在切的过程中,需要把这个内存块是属于哪个span的记录下来,即把内存块的地址和span映射起来,使用1层基数树记录

下面代码是对上面长方形切成小长方形示意图的具体实现

2.2中心缓存接收线程缓存还回来的内存

线程缓存把批量的内存空间还给中心缓存后,中心缓存也需要接收这批空间,把它们挂入中心缓存对应的哈希桶里

以下代码是中心缓存接收从线程缓存还回来的内存空间逻辑(就是把一段自由链表插入到某个span下),其中包含了中心缓存向页缓存归还空间的逻辑,怎么归还?归还多少?在2.4讲


//将从线程缓存接收到的内存对象纳入中心缓存对应的哈希桶中
void CentralCache::ReleaseListToSpans(void* start, size_t size)
{
	size_t index = SizeClass::Index(size);
	_spanLists[index]._mtx.lock();//对应span先上锁
	while (start)
	{
		void* next = NextObj(start);//记录start的下一个结点地址

		//在一个个内存对象中,根据void*地址获知该(start)内存对象原本属于哪个span,即得知这个小内存对象的span*地址
		//后续根据小内存的起始地址,得到的映射span地址可能属于同一个span(毕竟原本就是从一大块span切割成链表形式的内存对象的)
		Span* span = PageCache::GetInstance()->MapObjectToSpan(start);

		//将这块内存对象头插入同一个span中
		NextObj(start) = span->_freeList;//第二个结点开始,挂载到_freelist后面,头插
		span->_freeList = start;
		span->_useCount--;//每插入该span一块内存对象,意味着使用该span的用户减1

		//说明从这个span中切分出去的所有小块内存都回来了,可以回收该span给pagecache了,pagecache再做前后页的合并
		if (span->_useCount == 0)
		{
			_spanLists[index].Erase(span);//摘除spanlist中的该span自由链表列,可以把该span链表解锁了
			//该span下的所有结点的逻辑地址原本就是连续的,只不过被pagecache分配的时候连续的逻辑地址被切割成一个个的结点,用链表结构管理起来
			//所以可以直接置空
			span->_freeList = nullptr;
			span->_next = nullptr;
			span->_prev = nullptr;

			_spanLists[index]._mtx.unlock();//先解锁该spanlist,让其他线程能申请或释放该spanlist的其他span里的内存成功

			PageCache::GetInstance()->_pageMtx.lock();//上pagecache的桶锁
			//将变成大长方形的span还回下一层pagecache 
			PageCache::GetInstance()->ReleaseSpanToPageCache(span);
			PageCache::GetInstance()->_pageMtx.unlock();

			_spanLists[index]._mtx.lock();//该spanlist继续上桶锁
		}
		start = next;//移动start
	}
	_spanLists[index]._mtx.unlock();
}

2.3中心缓存向页缓存申请内存

如果中心缓存某个哈希桶下所有的span都没有挂载空闲的内存块,中心缓存就向页缓存申请空间

页缓存怎么给予中心缓存空间?在页缓存讲。给予多大?在2.1的“中心缓存没有空闲的空间”讲过了。这里对说中心缓存拿到页缓存给予的span后,该怎么纳入到本层做一个复习

2.4中心缓存向页缓存归还空间

同样的,中心缓存也需要向页缓存归还空间,怎么判断需要归还?归还多大的空间给页缓存?

还记得在2.1讲的“同一个span切出来的内存块与上一个内存块的地址有间隔连续性,即内存块首地址+size=下一个内存块首地址”,所以,我们可以将span的_freeList、_prev、_next置空,就认为span把切出去的内存块们都合并了,这个span就成一大块空间

判断方法,从上图可以看出,span下挂载的是一个一个内存块,在span类里,我们只需设置一个_useCount属性用来记录这个span原本在切割出一个一个内存块时,切出了多少个内存块,每向中心缓存还回一个该span下的内存块,该_useCount就减1,等于0的时候即可认为该曾经从该span下切割出去的内存块都回来了,可以将该span的部分属性置空(如span类里的_freeList),还给页缓存,详细代码已经在2.1贴出来。

有同学可能想,切出去的一个一个内存块从中心缓存放到线程缓存后,如果线程缓存某个哈希桶下挂载的内存块超过一定额度了需要归还给中心缓存,线程缓存怎么知道这批内存块属于哪个span切出来的这批内存块应放到中心缓存一个spanlist中的哪个span?

其实答案在2.1的“中心缓存没有空闲空间”讲了。我们从span切出一个一个内存块时,会同时记录这个内存块属于哪个span的,具体代码请看2.1,所以这批内存块就可以根据这个记录知道我原本属于哪个span

将一个一个的小内存块归还给span后,此时的span是一个大长方形(大内存块),根据span类里记录的属性——页数,我们就能得知这个span应该归还给页缓存的哪个哈希桶

页缓存设计

//设计成单例模式
class PageCache
{
public:
	static PageCache* GetInstance()
	{
		return &_sInstan;
	}
	
	//获取内存对象地址到span的映射(获取该块内存是属于哪个span的)
	Span* MapObjectToSpan(void* obj);

	//释放空闲span回到pagecache层,并合并相邻的span
	void ReleaseSpanToPageCache(Span* span);

	//为中心缓存分配内存对象
	Span* NewSpan(size_t k);

	std::mutex _pageMtx;//锁,线程互斥向pageCache申请内存

private:
	SpanList _spanLists[NPAGES];//页的哈希表
	ObjectPool<Span> _spanPool;//主要是为了调用里面的New方法

	//stl里面还是用了malloc
	//std::unordered_map<PAGE_ID, Span*> _idSpanMap;//在pagecache记录页号和内存对象地址的映射

	//使用基数树完全脱离malloc
	TCMalloc_PageMap1<32 - PAGE_SHIFT> _idSpanMap;
	//防拷贝
	PageCache() {}
	PageCache(const PageCache&) = delete;

	static PageCache _sInstan;
};

在页缓存层中,每个哈希桶没有桶锁,我们的设计是给予整个锁,进入页缓存申请内存,我就给页缓存上整个锁,不管你申请的哈希桶是同一个还是不是同一个,我只允许一个线程进入页缓存层来操作

页缓存层中,以页数分类,该span的字节数是2页大小的,就挂在2页的哈希桶处,是128页的就挂在128页的哈希桶处

页缓存给予中心缓存空间

中心缓存向页缓存申请K页的空间时,页缓存先查看第K页的哈希桶有没有空闲的span,有就给予中心缓存,没有则向大于K页的哈希桶寻找

如果在大于K页的哈希桶中找到一个n页的span,先将该span切成2部分,分别是k页的span和(n-k)页的span,两个span,将K页的span给中心缓存,剩下的n-k页span挂载到n-k页处的哈希桶位置

如果在后面的所有大于K页的哈希桶中还没找到一个>K页的span,则页缓存向系统堆申请一块128页大小的内存空间,对这块大空间用一定的逻辑转为span,然后挂载到128页处的哈希桶位置

NewSpan函数完成了上述页缓存给予中心缓存空间的逻辑,下面代码是NewSpan函数的核心代码

如果页缓存有能满足中心缓存申请的空间需求,直接从页缓存拿出这块空间给中心缓存

下面代码依旧是NewSpan函数的核心代码,完成的功能:在第k页的哈希桶找不到空闲的span,往后面大于k页的哈希桶寻找

	//走到这里说明第k个桶后面没有span,检查后面的桶有没有span,有就将它切分
	for (size_t i = k + 1; i < NPAGES; ++i)
	{
		if (!_spanLists[i].Empty())//有
		{
			Span* nSpan = _spanLists[i].PopFront();//拿取该哈希桶的头一个span(这个span一定大于k)
			Span* kSpan = _spanPool.New();//新创建span,用来承接nSpan切分后的k页span

			//在nSpan大块内存中,在头部切一个k页给kSpan
			//k页span返回,nSpan再挂到对应的映射位置
			kSpan->_pageId = nSpan->_pageId;//更新kSpan的ID
			kSpan->_n = k;//页数赋值为k

			nSpan->_pageId += k;//nspan(剩余的span)的页号向后移动k个
			nSpan->_n -= k;//页数也减去已经分配给kSpan的页数k

			//该哈希桶剩下的nspan内存根据更新完的页数_n,从原来的哈希桶中移动到_spanLists[nSpan->_n]哈希桶中
			_spanLists[nSpan->_n].PushFront(nSpan);

			//存储nSpan的首位页号,并与nSpan映射,方便page cache回收内存
			//进行合并查找
			//_idSpanMap[nSpan->_pageId] = nSpan;//nSpan的起始页号映射地址
			_idSpanMap.set(nSpan->_pageId, nSpan);

			//比如起始页是1000,有5页,分别是1000、1001、1002、1003、1004
			// 那么最后一页是1004,所以需要n-1
			//_idSpanMap[nSpan->_pageId + nSpan->_n - 1] = nSpan;//nSpan的最后一个页号映射地址
			_idSpanMap.set(nSpan->_pageId + nSpan->_n - 1, nSpan);


			//将K页的大span分配内存时先建立映射,方便中心缓存还回内存时根据页号还回pagecache对应的哈希桶
			for (PAGE_ID i = 0; i < kSpan->_n; ++i)
			{
				//_idSpanMap[kSpan->_pageId + i] = kSpan;//从k页页号开始,将k+_n页以内的页号关联的内存对象地址都设置为kSpan
				_idSpanMap.set(kSpan->_pageId + i, kSpan);

			}
			return kSpan;
		}
	}

下图的代码(还是在NewSpan函数)意思是如果在后面的所有大于K页的哈希桶中还没找到一个>K页的span,则页缓存向系统堆申请一块128页大小的内存空间

页缓存合并设计

解决内存外碎片问题

当中心缓存还给页缓存一个span时,页缓存需要判断该span是否可以和前span、后span合并成一大块span,然后根据更新的页数挂载到新页数的哈希桶中

3层的申请空间联动

在线程缓存层:

从最底层的线程缓存开始,线程向线程缓存申请空间

如果线程缓存层挂载的空间充足,则从线程缓存对应的哈希桶处给予线程一批空间,这里“对应的哈希桶”指的是根据线程申请的字节数,计算出来线程应该到线程缓存的哪个哈希桶拿空间给自己。

如果线程缓存层对应的哈希桶下挂载的内存块不足,则线程缓存向中心缓存对应的哈希桶申请空间,这里“中心缓存对应的哈希桶”指的是和上面线程缓存“对应的哈希桶”同一个下标的哈希桶,因为中心缓存和线程缓存的哈希桶映射规则是一样的,根据申请的字节数映射到哪个哈希桶。

在中心缓存层:

线程缓存向中心缓存申请空间后,中心缓存要给予线程缓存空间。中心缓存到对应的哈希桶寻找有没有挂载着空闲内存块的span

如果有,假设该span下挂载着n个内存块,那就把该span下的前n-1个内存块给线程缓存,留剩下的最后一个内存块继续挂载在该span下

如果没有,则中心缓存向页缓存一定页数量的span,这里的“一定页数量的span”指的是申请多少字节数,给予你扩大一定字节数的空间,然后把这块空间的大小根据某个计算规则转成页数,中心缓存就拿着这个页数去向页缓存申请空间

在页缓存层:

中心缓存根据页数(假设为k页)向页缓存申请span,页缓存先查看自己对应页数的哈希桶是否有span,

如果有,则将该span给中心缓存(把这个span怎么切出一个一个的内存空间挂载到中心缓存在中心缓存层讲了)

如果没有,继续向大于K页的哈希桶寻找span,如果继续在N页的哈希桶找到了span(N>K),把这个span切成两部分,分别为K页的span和N-K页的span,K页的span交给中心缓存,N-K页的span挂载到页缓存N-K页的哈希桶处

3层的释放空间联动

……

  • 28
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值