项目--高并发内存池

学习C++也有不少时日了,今天我们来学习一个项目,该项目是借鉴谷歌的tc-malloc,让我们一起来认识一下这个设计极其优秀的项目吧

需求分析

我们该项目利用了设计模式中的池化技术,在传统的内存池上进行了优化,该内存池可以有效地提高内存申请效率以及解决内存碎片化的问题

普通内存池的优缺点

我们先来回顾一下内存池的主要思路,就是预先开辟一大块内存,当我们程序需要使用内存时,直接从该大块内存中拿取一块,可以提高申请释放效率,而不需要再去new/malloc从堆中申请内存

优点:提高效率,解决部分内存碎片问题

缺点:无法处理高并发时申请内存存在的锁竞争问题,该问题会使效率降低

我们的内存池解决的问题就是上面这些问题

主要设计思路

高并发内存池整体框架由以下三部分组成,各部分的功能如下:

线程缓存(thread cache):每个线程独有线程缓存,主要解决多线程下高并发运行场景线程之间的锁竞争问题。线程缓存模块可以为线程提供小于64k内存的分配,并且多个线程并发运行不需要加锁。
中心控制缓存(central control cache):中心控制缓存顾名思义,是高并发内存池的中心结构主要用来控制内存的调度问题。负责大块内存切割分配给线程缓存以及回收线程缓存中多余的内存进行合并归还给页缓存,达到内存分配在多个线程中更均衡的按需调度的目的,它在整个项目中起着承上启下的作用。(注意:这里需要加锁,当多个线程同时向中心控制缓存申请或归还内存时就存在线程安全问题,但是这种情况是极少发生的,并不会对程序的效率产生较大的影响,总体来说利大于弊)
页缓存(page cache):以页为单位申请内存,为中心控制缓存提供大块内存。当中心控制缓存中没有内存对象时,可以从page cache中以页为单位按需获取大块内存,同时page cache还会回收central control cache的内存进行合并缓解内存碎片问题。

那么我们就从0开始对这个项目进行实现吧

thread cache整体设计

Thread cache结构设计

首先,我们思考一下,我们的这个内存池最主要的目的是什么?就是可以同时分配内存给不同的线程,基于这一点,参考我们内存池的设计,我们设计了一个成员是自由链表的哈希桶thread_cache的主要结构

watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5bmzICDnlJ8=,size_20,color_FFFFFF,t_70,g_se,x_16

从我们之前内存池的经验来看,我们将大小为8的内存块都链接到8字节的哈希桶上,大小为16的都链接到16字节的哈希桶上,但如果我们将哈希桶的每个内存块,都按照8字节大小的间隔来分的话,我们的哈希桶就会特别长,占用空间是极大的,所以我们采用分区分间隔的方式对哈希桶中字节大小进行设计,我们将0-126字节按照8字节对其,按照16字节对齐,1025到8*1024按照128字节对齐,当我们对需要1到8字节大小的内存块时,我们都给线程8字节大小的内存块,当我们需要128到144大小的内存块时,我们都给线程144大小的内存块,我们采用这样的策略将内存块分配给线程,虽然会产生一定的内碎片问题,但是浪费率却得到了有效的控制,都保持在了12%以下

NEXT函数的细节处理

在这个过程中有几处细节,首先是我们的自由链表,这个自由链表挂载的是内存块,其实为了方便我们后续内存块大小的不断增加,且为了寻找到下一个内存块,我们将内存块的前4/8字节大小的位置用于存储下一个内存块的地址,这样的设计就不需要我们单独为链表增加一份指针了

inline void*& NextObj(void* obj)
{
	return *((void**)obj);
}

我们将二级指针解引用,这样不管是处在32位机器,还是64位机器得到的一定是一份地址大小,我们就通过访问该结点的前4/8个字节获取下一节点的地址

freelist链表结构

class FreeList
{
public:
	void PushRange(void* start, void* end, int n)//将一个范围内(start到end)的n个结点插入自由链表
	{
		NextObj(end) = _head;//头插start到end进链表
		_head = start;//头变为start
		_size += n;//更新size
	}

	void PopRange(void*& start, void*& end, int n)//归还start到end间隔为n的对象
	{
		start = _head;
		for (int i = 0; i < n; ++i)
		{
			end = _head;
			_head = NextObj(_head);
		}

		NextObj(end) = nullptr;
		_size -= n;
	}

	// 头插
	void Push(void* obj)
	{
		NextObj(obj) = _head;
		_head = obj;
		_size += 1;
	}

	// 头删
	void* Pop()//将一份
	{
		void* obj = _head;
		_head = NextObj(_head);
		_size -= 1;

		return obj;
	}

	bool Empty()
	{
		return _head == nullptr;
	}

	size_t MaxSize()
	{
		return _max_size;
	}

	void SetMaxSize(size_t n)
	{
		_max_size = n;
	}

	size_t Size()
	{
		return _size;
	}

private:
	void* _head = nullptr;
	size_t _max_size = 1;
	size_t _size = 0;
};

映射细节处理

其次我们在将需要的字节映射到我们对应哈希桶的内存大小时,不采用%/的方式,而是将其优化为位运算的方式

static inline size_t _RoundUp(size_t bytes, size_t align)
	{
		return (((bytes)+align - 1) & ~(align - 1));//将不到对齐数的数对其[例,如果需要对齐到8,则将低三位全变为0即可,与16对其则将低4位变为0]
	}

只需要将对应比特位置0即可,比如我们需要9字节时,我们将9(1001)左移一位后将后三位置0,此时就映射到了16(10000)的位置,这样便在运算层面省去了%与/的大量复杂运算,优化了效率,当然,这两个函数我们用的频率极高,将其置为内联函数

static inline size_t RoundUp(size_t bytes)
	{
		//assert(bytes <= MAX_BYTES);

		if (bytes <= 128){
			return _RoundUp(bytes, 8);
		}
		else if (bytes <= 1024){
			return  _RoundUp(bytes, 16);
		}
		else if (bytes <= 8192){
			return  _RoundUp(bytes, 128);
		}
		else if (bytes <= 65536){
			return  _RoundUp(bytes, 1024);
		}
		else
		{
			return _RoundUp(bytes, 1 << PAGE_SHIFT);//一页大小
		}

		return -1;
	}

此时我们便完成了对于其需要freelist大小的映射,我们还需要找到对应哈希桶的下标

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 <= 8192){
			return _Index(bytes - 1024, 7) + group_array[1] + group_array[0];
		}
		else if (bytes <= 65536){
			return _Index(bytes - 8192, 10) + group_array[2] + group_array[1] + group_array[0];
		}

		assert(false);

		return -1;
	}

这里我们采用相同的策略,只不过下标会从0开始,因此计算时先按照1开始计算,而后减去即可

TLS完成线程的无锁访问

当我们完成了Thread的基本结构后,我们还需要一个很重要的东西,那就是将内存分配给线程而不需要加锁的TLS,又称为线程本地存储,对于我们全局变量而言,所有线程去访问都只会是同一份,而某一个线程对其修改就会改变其值,所以就有了我们的TLS,对于每个线程而言都有自己的独一份,这样就完成了对每个线程的访问,所以也是不需要加锁的,因为访问的都是自己线程的那一份TLS,其静态实现原理就是每多创建一个线程就分配一块新的内存块

下面我们梳理一下整个ThreadCache的逻辑

主要功能

申请内存:

1.当内存申请size<=256KB,先获取到线程本地存储的thread cache对象,计算size映射的哈希桶自由链表下标i。
2.如果自由链表_freeLists[i]中有对象,则直接Pop一个内存对象返回。
3.如果_freeLists[i]中没有对象时,则批量从central cache中获取一定数量的对象,插入到自由链表并返回一个对象

释放内存:

1.当释放内存小于256k时将内存释放回thread cache,计算size映射自由链表桶位置i,将对象Push到_freeLists[i]

2.当链表的长度过长,则回收一部分内存对象到central cache

此时我们便明确了其对外部暴露的接口

class ThreadCache//线程缓存类
{
public:
	// 申请和释放内存对象
	void* Allocate(size_t size);//分配size大小的内存
	void Deallocate(void* ptr, size_t size);//回收size大小的内存

	// 从中心缓存获取对象
	void* FetchFromCentralCache(size_t index, size_t size);

	// 释放对象时,链表过长时,回收内存回到中心缓存
	void ListTooLong(FreeList& list, size_t size);
private:
	FreeList _freeLists[NFREELISTS];//自由链表大小为N
};
//初始化一个本地线程缓存变量指针并赋值为nullptr,此处必须声明为static全局变量
static __declspec(thread) ThreadCache* tls_threadcache = nullptr;

下面我们对其相关函数进行实现

函数实现

线程向thread cache申请内存

我们threadcache首先要完成的便是对于线程内存的分配,这里我们采用直接将链表截取给到线程的方式完成,当链表中内存不够时再去向中心缓存获取

void* ThreadCache::Allocate(size_t size)//线程向theadCache申请内存
{
	size_t i = SizeClass::Index(size);//计算size尺寸所对应的哈希桶下标i
	if (!_freeLists[i].Empty())//当对应的自由链表不为空时
	{
		return _freeLists[i].Pop();//从自由链表中Pop出一份内存返回
	}
	else//当自由链表中没有内存结点时,向中心缓存申请size尺寸的内存连到哈希桶i的位置上
	{
		return FetchFromCentralCache(i, size);
	}
}

thread cache向centralcache申请内存

我们的上层Threadcache中的链表总有将自己内存分配完的时候,此时就需要向下层centralcache申请补充,我们在向centralcache补充内存时采用类似于TCP中的慢启动算法,每当Threadcache向centralcache申请,我们centralcache批发给Threadcache一页内存,再申请批发数量增加,每增加一次申请就会增加批发给threadcache的页数,这样的话就减缓了给的太多浪费,给的太少不够的情况,是一种折中的方案

	// 一次从中心缓存获取多少个结点
	static size_t NumMoveSize(size_t size)
	{
		if (size == 0)
			return 0;

		// [2, 512],一次批量移动多少个对象的(慢启动)上限值
		// 小对象一次批量上限高
		// 小对象一次批量上限低
		int num = MAX_BYTES / size;
		if (num < 2)
			num = 2;

		if (num > 512)
			num = 512;

		return num;
	}

不过我们的批发页数也不是无限次增长的,会定格在512页中 

下面是我们中心缓存给Threadcache的代码逻辑,通过慢启动获取对应字节存入Threadcache,将centralcache中非空span,也就是我们所属的页,将span中的内存块截取catchNum个给到threadcache,当span不够时则有多少给多少 

void* ThreadCache::FetchFromCentralCache(size_t i, size_t size)//从中心缓存中拿缓存
{
	// 获取一批对象,数量使用慢启动方式
	// SizeClass::NumMoveSize(size)是上限值
	size_t batchNum = min(SizeClass::NumMoveSize(size), _freeLists[i].MaxSize());

	// 去中心缓存获取batch_num个对象
	void* start = nullptr;
	void* end = nullptr;
	size_t actualNum = CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, SizeClass::RoundUp(size));//到底给了多少个
	assert(actualNum > 0);

	// >1,返回一个,剩下挂到自由链表
	// 如果一次申请多个,剩下挂起来,下次申请就不需要找中心缓存
	// 减少锁竞争,提高效率
	if (actualNum > 1)//当我们要返回的节点>1,我们
	{
		_freeLists[i].PushRange(NextObj(start), end, actualNum - 1);//将start到end结点插入,但是我们还需要返回start,所以插入个数为个数-1
	}

	// 慢启动增长,MaxSize为该自由链表的最大链接数
	if (_freeLists[i].MaxSize() == batchNum)//每当最大连接数和需要从中心缓存拿的数量相同,+1
	{
		_freeLists[i].SetMaxSize(_freeLists[i].MaxSize() + 1);
	}

	return start;
}

 thread cache释放内存到centralcache

当我们给链表中的内存补充过多时,我们将整一份freelist归还,留下多余部分,这里我们理解为,当freelist实际长度大于一次实际要批量申请的内存时就开始释放整个freelist还给central cache,不过这里我们的接口设计程传入的释放个数,方便以后控制修改

void ThreadCache::Deallocate(void* ptr, size_t size)//回收
{
	size_t i = SizeClass::Index(size);//找到size对应的哈希桶下标
	_freeLists[i].Push(ptr);//插入指针

	// List Too Long central cache 去释放
	if (_freeLists[i].Size() >= _freeLists[i].MaxSize())//当自由链表长度大于等于最大长度
	{
		ListTooLong(_freeLists[i], size);
	}
}

// 释放对象时,链表过长时,回收内存回到中心缓存
void ThreadCache::ListTooLong(FreeList& list, size_t size)//还对象
{
	size_t batchNum = list.MaxSize();//还的个数初始化为最大size
	void* start = nullptr;//初始化start与end
	void* end = nullptr;
	list.PopRange(start, end, batchNum);

	CentralCache::GetInstance()->ReleaseListToSpans(start, size);//
}

central cache的整体设计

central cache的结构设计

我们的centralcache为了满足给threadcache补充内存块,我们将其也设计为哈希桶结构,哈希桶的数组部分与threadcache映射都是一样的,不同的是我们的centralcache上挂的是spanlist链表结构,每个span才挂着我们需要给threadcache补充的内存块,而我们的span,是一个以页为单位的大块内存对象,下面挂着一个个可以直接链入threadcache的切成下快的内存块

 接下来就是分析span与spanlist,因为我们的span中会涉及指定对象的删除,我们将其设计为双向带头链表结构方便我们操作

// Span
// 管理一个跨度的大块内存

// 管理以页为单位的大块内存
struct Span
{
	PageID _pageId = 0;   // 页号,目的是合并
	size_t _n = 0;        // 页的数量,目的是计算span大小

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

	void* _list = nullptr;  // 大块内存切小链接起来,这样回收回来的内存也方便链接(若其为空,则代表中心缓存没有货了)
	size_t _usecount = 0;	// 使用计数,==0 说明所有对象都回来了

	size_t _objsize = 0;	// 切出来的单个对象的大小
};

class SpanList
{
public:
	SpanList()
	{
		_head = new Span;
		_head->_next = _head;
		_head->_prev = _head;
	}

	Span* Begin()//设置头
	{
		return _head->_next;
	}

	Span* End()//设置尾
	{
		return _head;
	}

	void PushFront(Span* span)
	{
		Insert(Begin(), span);
	}

	Span* PopFront()
	{
		Span* span = Begin();
		Erase(span);

		return span;
	}

	void Insert(Span* cur, Span* newspan)//带头双向循环,cur前插入
	{
		Span* prev = cur->_prev;//找前一个
		// prev newspan cur
		prev->_next = newspan;//左连前一个
		newspan->_prev = prev;//右连前一个

		newspan->_next = cur;//右连后一个
		cur->_prev = newspan;//左连后一个
	}

	void Erase(Span* cur)//删除
	{
		assert(cur != _head);//头节点断言

		Span* prev = cur->_prev;//找前
		Span* next = cur->_next;//找后

		prev->_next = next;//跨越链接
		next->_prev = prev;
	}

	bool Empty()//判空
	{
		return _head->_next == _head;
	}

	void Lock()
	{
		_mtx.lock();
	}

	void Unlock()
	{
		_mtx.unlock();
	}

private:
	Span* _head;

public:
	std::mutex _mtx;
};

当我们的结构设计完成之后,我们对其实现的功能进行分析

主要功能

申请内存:

1.当thread cache中没有内存时,就会批量向central cache申请一些内存对象,这里的批量获取对象的数量使用了拥塞控制的慢启动算法;central cache也有一个哈希映射的spanlist,spanlist中挂着span,从span中取出对象给thread cache,由于不同线程可能访问central cache的同一个哈希桶。因此这个过程是需要加锁的。
2.central cache映射的spanlist中所有span的都没有内存以后,则需要向page cache申请一个新的span对象,拿到span以后将span管理的内存按大小切好作为自由链表链接到一起。然后从span中取对象给thread cache。
3.central cache的中挂的span中use_count记录分配了多少个对象出去,分配一个对象给thread cache,就++use_count

释放内存

当thread_cache过长或者线程销毁,则会将内存释放回central cache中的,释放回来时–use_count(起到均衡的效果)。当use_count减到0时则表示所有对象都回到了span,则将span释放回page cache, page cache中会对前后相邻的空闲页进行合并(解决外部碎片的问题)

class CentralCache//中心缓存区
{
public:
	static CentralCache* GetInstance()//设定初始值
	{
		return &_sInst;
	}

	// 从中心缓存获取一定数量的对象给thread cache
	size_t FetchRangeObj(void*& start, void*& end, size_t n, size_t byte_size);

	// 从SpanList或者page cache获取一个span
	Span* GetOneSpan(SpanList& list, size_t byte_size);

	// 将一定数量的对象释放到span跨度
	void ReleaseListToSpans(void* start, size_t byte_size);
private:
	SpanList _spanLists[NFREELISTS]; // 按对齐方式映射

private:
	CentralCache()
	{}

	CentralCache(const CentralCache&) = delete;//将centralcache设置为单例模式

	static CentralCache _sInst;
}

注意我们这里centralcache是仅此一份的,所以要设置为单例模式,所以是需要加锁的

函数实现

central cache分配内存给thread cache

这个函数呢,我们首先是找到一份非空的span,在该span对象下取出一定数量的内存块,内存块起始位置与终止位置使用输入输出型参数进行控制,而不够的span则有多少给多少

Span* CentralCache::GetOneSpan(SpanList& list, size_t size)//从获取一份非空span
{
	// 现在spanlist中去找还有内存的span
	Span* it = list.Begin();//找链表头
	while (it != list.End())//开始遍历
	{
		if (it->_list)//当it还有节点
		{
			return it;//链表有节点返回结点
		}

		it = it->_next;
	}
size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t n, size_t size)//从中心缓存中拿n个span给threacache,通过直接引用改变start与end
{
	size_t i = SizeClass::Index(size);//计算需要大小的span在哪一个哈希桶中
	//_spanLists[i].Lock();
	std::lock_guard<std::mutex> lock(_spanLists[i]._mtx);

	Span* span = GetOneSpan(_spanLists[i], size);//获取了一个sapn

	// 找到一个有对象的span,有多少给多少
	size_t j = 1;
	start = span->_list;//start赋给该span的头
	void* cur = start;
	void* prev = start;
	while (j <= n && cur != nullptr)//将cur后移n-1位
	{
		prev = cur;//prev跟cur走
		cur = NextObj(cur);//cur后移
		++j;//size计数
		span->_usecount++;//使用计数
	}

	span->_list = cur;//将cur链到span上(连剩下的链表)
	end = prev;//更新end
	NextObj(prev) = nullptr;

	//_spanLists[i].Unlock();

	return j-1;//返回实际返回的个数
}

central cache没有非空Span向page cache申请内存

当我们的span都被分配完了之后,我们就需要在向下一层pagecache进行申请补充span,我们这里申请的一块span的大小原则上尽量满足threadcache一次批量申请的大小

一页拥有4k,这里我们自己设定的,那么我们申请8字节的单个对象时,所给到的一个span是512(个)*8=4k,16字节的则是256*16=4k,一个页,而256k*2=512/4=128页,当不够一个页时至少给一个页,此时我们也可以发现,当所需内存快越小时,我们一次申请所获得的页也就越多,申请内存快越大,得到的页也就越少

	static size_t NumMoveSize(size_t size)
	{
		if (size == 0)
			return 0;

		// [2, 512],一次批量移动多少个对象的(慢启动)上限值
		// 小对象一次批量上限高
		// 小对象一次批量上限低
		int num = MAX_BYTES / size;
		if (num < 2)
			num = 2;

		if (num > 512)
			num = 512;

		return num;
	}

	// 计算一次向系统获取几个页
	// 单个对象 8byte
	// ...
	// 单个对象 64KB
	static size_t NumMovePage(size_t size)
	{
		size_t num = NumMoveSize(size);//获取使用的节点数
		size_t npage = num*size;//页数=结点数*对象大小(例512*8)

		npage >>= 12;//将页数右移12位(计算页数,除4k)
		if (npage == 0)//至少给1
			npage = 1;

		return npage;//返回页数,对象越大,页数越少,对象越少,页数越大
	}
};

我们在从pagecache获取span时都要进行分割成块,每一块都是哈希桶映射的自由链表固定大小,我们采用尾插的方式进行分割,分割出来链成的一段物理内存仍是连续的,这有利于缓存命中,获得新的span后分割好加入到central对应的哈希桶中

Span* CentralCache::GetOneSpan(SpanList& list, size_t size)//从获取一份非空span
{
	// 现在spanlist中去找还有内存的span
	Span* it = list.Begin();//找链表头
	while (it != list.End())//开始遍历
	{
		if (it->_list)//当it还有节点
		{
			return it;//链表有节点返回结点
		}

		it = it->_next;
	}

	// 走到这里代表着span都没有内存了,只能找pagecache
	Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(size));//要几页的span,获取合适页的span
	// 切分好挂在list中
	char* start = (char*)(span->_pageId << PAGE_SHIFT);//计算span起始地址
	char* end = start + (span->_n << PAGE_SHIFT);//计算span末尾地址
	while (start < end)
	{
		char* next = start + size;//保存next
		// 挨个头插,将span中的结点分出链接起来
		NextObj(start) = span->_list;
		span->_list = start;

		start = next;
	}
	span->_objsize = size;//更新属性

	list.PushFront(span);//头插span到自由链表

	return span;//返回
}

central cache补回Span对象

因为我们Threadcache释放内存顺序是混乱的,因此我们需要确定释放的内存块应该归还到哪个span中,我们可以使用归还的内存地址-页大小获得对应的页号,如果我们直接处理得话时间复杂度会是O(N^2),所以我们另开一个哈希表将其页号与span映射关系储存起来

void CentralCache::ReleaseListToSpans(void* start, size_t byte_size)//将起始点为start的,大小为size的自由链表空的归还给span
{
	size_t i = SizeClass::Index(byte_size);//计算size大小的对象在threadcache哪个哈希桶中
	std::lock_guard<std::mutex> lock(_spanLists[i]._mtx);

	while (start)
	{
		void* next = NextObj(start);//设置next指针

		// 找start内存块属于哪个span
		Span* span = PageCache::GetInstance()->MapObjectToSpan(start);

		// 把对象插入到span管理的list中
		NextObj(start) = span->_list;
		span->_list = start;
		span->_usecount--;
		// _usecount == 0说明这个span中切出去的大块内存
		// 都还回来了
		if (span->_usecount == 0)//当这个span切出去的内存全部还回来了
		{
			_spanLists[i].Erase(span);//解除与哈希桶的连接
			span->_list = nullptr;//链表置空
			PageCache::GetInstance()->ReleaseSpanToPageCache(span);//将整块span还给pagecache
		}

		start = next;//更新结点
	}
}

page cache整体设计

page cache结构设计

我们的pagecache结构也是哈希桶,但是映射关系不同于上两层,pagecache映射的是页数,比如我们申请256k的对象内存,128*4k/256=2,分配最大的内存块可以分配2个span,同样的,我们的pagecache也是全局仅有一个,需要设计为单例模式

 我们可以思考,假设当我们的centralcache需要补充3页内存时,我们的pagecache就会去

3页所对应的链表后寻找有没有span,有的话直接给到certralcache,没有的话则向后面的更大page寻找,找到更大的,假设找到一个5page的,就将这个5page的一页span分割成1个2page的span和一个3page的span,这样我们就有了3page的span,所以我们的结构就是按页分配的哈希表后连着相应大小的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的哈希桶,但是他们是有本质区别的,central cache中哈希桶,是按跟thread cache一样的大小对齐关系映射的,他的spanlist中挂的span中的内存都被按映射关系切好链接成小块内存的自由链表。而page cache 中的spanlist则是按下标桶号映射的,也就是说第i号桶中挂的span都是i页内存

释放内存

如果central cache释放回一个span,则依次寻找span的前后page id的没有在使用的空闲span,看是否可以合并,如果合并继续向前寻找。这样就可以将切小的内存合并收缩成大的span,减少内存碎片。

class PageCache
{
public:
	static PageCache* GetInstance()
	{
		return &_sInst;
	}

	// 向系统申请k页内存挂到自由链表
	void* SystemAllocPage(size_t k);

	Span* NewSpan(size_t k);

	// 获取从对象到span的映射
	Span* MapObjectToSpan(void* obj);

	// 释放空闲span回到Pagecache,并合并相邻的span
	void ReleaseSpanToPageCache(Span* span);
private:
	SpanList _spanList[NPAGES];	// 按页数映射(0位置不用)

	//std::mutex _map_mtx;
	//std::unordered_map<PageID, Span*> _idSpanMap;//span对应页号的映射(通过页*4k算出地址)
	TCMalloc_PageMap2<32-PAGE_SHIFT> _idSpanMap;
	//TCMalloc_PageMap1 _idSizeMap;

	// tcmalloc 基数树  效率更高

	std::recursive_mutex _mtx;


private:
	PageCache()
	{}

	PageCache(const PageCache&) = delete;

	// 单例,将pagecache设置为单例模式
	static PageCache _sInst;
};

函数实现

Page cache中获取span

我们的Pagecache可能会存在当前位置没有span,无法给到centralcache的情况,那么我们怎么获取相应大小的span呢,首先想到的就是向后遍历,找到第一个比当前span页大的span,将其分割,popfront()该对象,一个返回给需要的centralcache,另一个就放到原大小页的span位置,那么当我们pagecache所有的span都为空的时候,此时我们就只能向系统调用malloc等申请内存的函数申请了,注意这里我们span对象的内存起始位置我们都是通过页号来计算的,内存大小通过span对象的块数计算

注意,这里我们需要揭开桶锁,因为当我们如果申请失败时仍然释放内存,就会因为锁住释放不了

向系统申请内存,当申请内存的大小大于我们的最大内存块大小采用系统调用

// 向系统申请k页内存
void* PageCache::SystemAllocPage(size_t k)
{
	return ::SystemAlloc(k);//调用全局函数申请了k页内存
}
Span* PageCache::NewSpan(size_t k)//创建总大小为k的span
{
	std::lock_guard<std::recursive_mutex> lock(_mtx);

	// 针对直接申请大于NPAGES的大块内存,直接找系统要
	if (k >= NPAGES)
	{
		void* ptr = SystemAllocPage(k);//创建大小为k的内存
		Span* span = new Span;//创建新span
		span->_pageId = (ADDRES_INT)ptr >> PAGE_SHIFT;//将span的页号改为该内存对应大小的页号
		span->_n = k;//总大小设为k

		{
			//std::lock_guard<std::mutex> lock(_map_mtx);
			_idSpanMap[span->_pageId] = span;//将该span与页号对应,存入map
		}

		return span;//返回该span
	}

	if (!_spanList[k].Empty())//当前页不为空,有我们想要的大小
	{
		return _spanList[k].PopFront();//直接头删返回一个
	}

	for (size_t i = k+1; i < NPAGES; ++i)//当前页不为空,则从下一页开始找(比如我们要3个,大小为3的没了,我们就从4开始依次往后找)
	{
		// 大页给切小,切成k页的span返回
		// 切出i-k页挂回自由链表
		if (!_spanList[i].Empty())//
		{

			// 头切
			/*Span* span = _spanList[i].Begin();//获取链表头
			_spanList->Erase(span);//删除该头(此时不会将结构体释放,只是将指针后移)

			Span* splitSpan = new Span;
			splitSpan->_pageId = span->_pageId + k;//比如要从100到128,切出4字节,我们的编号就是104
			splitSpan->_n = span->_n - k;//剩余大小n就是128-104=24

			span->_n = k;//该span大小就是24

			_spanList[splitSpan->_n].Insert(_spanList[splitSpan->_n].Begin(), splitSpan);

			return span;*///返回上层

			// 尾切出一个k页span
			Span* span = _spanList[i].PopFront();//将链表头删

			Span* split = new Span;//创建新Span
			split->_pageId = span->_pageId + span->_n - k;//将删除后的链表
			split->_n = k;//将前面的span中的n赋为k,比如128分为100,2以及102,126

			// 改变切出来span的页号和span的映射关系
			{
				//std::lock_guard<std::mutex> lock(_map_mtx);
				for (PageID i = 0; i < k; ++i)
				{
					_idSpanMap[split->_pageId + i] = split;//将k页的对应关系改为新的(id+i)新id
				}
			}

			span->_n -= k;//将新页数更新

			_spanList[span->_n].PushFront(span);//将切出的对应大小的span插入对应的链表的位置

			return split;
		}
	}

	Span* bigSpan = new Span;//当没一个合适的大页时创建(从初始到128都没)
	void* memory = SystemAllocPage(NPAGES - 1);//申请了这个Span大小设置为最大(128字节),
	bigSpan->_pageId = (size_t)memory >> 12;//页号为字节数/4k(可以类比我们地址的编号,页号就是在第几个4k)
	bigSpan->_n = NPAGES - 1;//页数和字节数相同
	// 按页号和span映射关系建立

	{
		//std::lock_guard<std::mutex> lock(_map_mtx);
		for (PageID i = 0; i < bigSpan->_n; ++i)//遍历
		{
			PageID id = bigSpan->_pageId + i;//给id赋值
			_idSpanMap[id] = bigSpan;//将所有从同一个大页分出的span映射到同一id中
		}
	}

	_spanList[NPAGES - 1].Insert(_spanList[NPAGES - 1].Begin(), bigSpan);//将这个申请好的最大的页插入到合适的位置(比如第一次就插入到128)

	return NewSpan(k);//再次递归调用自己(比如要了个4字节,此次就是在128中找到了,分成了4和124,下次再来就是120与4)
}

Page回收合并

PageCahce的锁的上锁和释放都放在central进行使用。
合并的时候要考虑是否不存在,是否有对象在使用以及是否可能超过128page,所以要注意最多合成到128page。
合并过程要把原来cache page中的内存块erase掉,合成新块后进行新块的_use,map的映射关系更新,Insert挂到Page cache中。
 

void PageCache::ReleaseSpanToPageCache(Span* span)//将凑齐的整块span还给pagecache
{
	if (span->_n >= NPAGES)
	{

		{
			//std::lock_guard<std::mutex> lock(_map_mtx);
			//_idSpanMap.erase(span->_pageId);
			_idSpanMap.erase(span->_pageId);
		}
		void* ptr = (void*)(span->_pageId << PAGE_SHIFT);//计算页号将起始页号赋给ptr
		SystemFree(ptr);//释放该span
		delete span;
		return;
	}

	std::lock_guard<std::recursive_mutex> lock(_mtx);

	// 检查前后空闲span页,进行合并,解决内存碎片问题

	// 向前合并
	while (1)
	{
		PageID preId = span->_pageId - 1;//前一页的页号
		//auto ret = _idSpanMap.find(preId);//找到前页页号对应的迭代器
		 如果前一个页的span不存在,未分配,结束向前合并
		//if (ret == _idSpanMap.end())
		//{
		//	break;
		//}
		Span* preSpan = _idSpanMap.get(preId);//找到前页页号所对应的指针
		if (preSpan == nullptr)//当前一页不存在
		{
			break;
		}

		// 如果前一个页的span还在使用中,结束向前合并
		if (preSpan->_usecount != 0)
		{
			break;
		}

		// 开始合并...

		// 超过128页,不需要合并了
		if (preSpan->_n + span->_n >= NPAGES)//已经到了末尾,直接退出
		{
			break;
		}

		// 从对应的span链表中解下来,再合并
		_spanList[preSpan->_n].Erase(preSpan);//将前一页span从哈希桶中解除关系
		//我们这里不需要处理我们当前位置的span,因为span本身就是刚从中心缓存中返回的,没有进到pagecache中

		span->_pageId = preSpan->_pageId;//当前id改为前一id(比如,100,2,与102,25合并,就变成100,128)
		span->_n += preSpan->_n;

		// 更新页之间映射关系
		{
			//std::lock_guard<std::mutex> lock(_map_mtx);
			for (PageID i = 0; i < preSpan->_n; ++i)
			{
				_idSpanMap[preSpan->_pageId + i] = span;//改变n个页的对应关系
			}
		}

		delete preSpan;//释放前页
	}

	// 向后合并
	while (1)
	{
		PageID nextId = span->_pageId + span->_n;
		/*auto ret = _idSpanMap.find(nextId);
		if (ret == _idSpanMap.end())
		{
			break;
		}*/

		Span* nextSpan = _idSpanMap.get(nextId);
		if (nextSpan == nullptr)
		{
			break;
		}

		//Span* nextSpan = ret->second;
		if (nextSpan->_usecount != 0)
		{
			break;
		}

		// 超过128页,不需要合并了
		if (nextSpan->_n + span->_n >= NPAGES)
		{
			break;
		}

		_spanList[nextSpan->_n].Erase(nextSpan);//解除后一个span与span哈希桶的关系

		span->_n += nextSpan->_n;//将后一页的n加到前一页的n上

		{
			//std::lock_guard<std::mutex> lock(_map_mtx);
			for (PageID i = 0; i < nextSpan->_n; ++i)
			{
				_idSpanMap[nextSpan->_pageId + i] = span;
			}
		}

		delete nextSpan;//释放被合并的页
	}

	// 合并出的大span,插入到对应的链表中
	_spanList[span->_n].PushFront(span);
}

单元测试

void BenchmarkMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
	std::vector<std::thread> vthread(nworks);
	size_t malloc_costtime = 0;
	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));
				}
				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);

	printf("%u个线程并发执行%u轮次,每轮次free %u次: 花费:%u ms\n",
		nworks, rounds, ntimes, free_costtime);

	printf("%u个线程并发malloc&free %u次,总计花费:%u ms\n",
		nworks, nworks*rounds*ntimes, malloc_costtime + free_costtime);
}


// 单轮次申请释放次数 线程数 轮次
void BenchmarkConcurrentMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
	std::vector<std::thread> vthread(nworks);
	size_t malloc_costtime = 0;
	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));
				}
				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);

	printf("%u个线程并发执行%u轮次,每轮次concurrent dealloc %u次: 花费:%u ms\n",
		nworks, rounds, ntimes, free_costtime);

	printf("%u个线程并发concurrent alloc&dealloc %u次,总计花费:%u ms\n",
		nworks, nworks*rounds*ntimes, malloc_costtime + free_costtime);
}

int main()
{
	cout << "==========================================================" << endl;
	BenchmarkMalloc(10000, 10, 10);
	cout << endl << endl;

	BenchmarkConcurrentMalloc(10000, 10, 10);
	cout << "==========================================================" << endl;
	system("pause");
	return 0;
}

 可以看到相对于malloc而言,我们的内存池具有较大的性能优化

不足与拓展

1.我们的项目仍未完全脱离malloc,里面仍然有malloc与new生成对象,我们可以通过增加定长内存池来使用sbrk,virtualalloc向系统申请内存,替换malloc与new

2.使用map映射耗时太长效率太低

通过map建立页号和Span的映射,由于通过页号获取Span需要加锁处理,但是这个接口需要频繁的被调用。会导致效率降低的问题。

解决:思想,只能使通过页号查找Span的过程时间更快一点,使线程不会阻塞太久。

不用map来保存页号和Span的映射关系。

        在32位系统下,我们使用直接定址法。

        在32位系统下,一共有2^20个页号,我们直接开辟一个大小为2^20次方大小的数组。数组里面直接保存Span的地址。数组最大占用空间也就4M多。通过页号,直接就可以找到Span。

        在64位系统下,使用直接定址法不适用,会导致数组占用空间太大。

        参考tcmalloc,我们可以使用基数树。思想:多阶哈希。结构如下:

         tcmalloc用的三阶哈希。

        在64位系统下,如果一页位4KB。一共有2^52次方个页号。如果需要保存奖励Span和每个页号的关系,需要建立一个2^52次方大小的数组。占用内存太大。

        于是tcmalloc中,采用三阶哈希。

        用高15位来表示第一层的哈希表,第一层的哈希表位置指向第二层的对应的哈希表,第二层哈希表用中间15位表示。第二层的哈希表的对应位指向第三层对应哈希表。第三层哈希表用最后22位表示,第三次哈希表中保存的是对应Span的地址。

        第一层一个哈希表,个数为2^15次方。第二层2^15次方个哈希表,每个哈希表的大小为2^15次方。第三次2^30个哈希表。每一个哈希表大小为2^22次方。

3.平台及兼容性

 解决:可以使用条件编译。在window操作系统下,使用VirtualAlloc申请内存。在Linux下使用sbrk/brk/mmap来申请内存

问题

如何实现比malloc快的?
       该项目实现比malloc快并不在于使用了内存池。malloc底层使用的也是用来池化技术。

       其实mallco底层思想和本项目的底层思想差不多。该项目比malloc快的情况,是在多线程斌且频繁申请内存的情况。重点是因为thread cache的存在。

        在多线程的情况下,mollac每次申请内存都会需要进行加锁。加锁就会导致效率降低。

        而该项目中,因为有thread cache的存在,每个线程私有thread cache,线程向thread cache申请内存不需要加锁。虽然,当thead cache没有内存时,需要向central cache申请内存,也会需要加锁。但是,由于central cache会使用慢启动的方式该内存块给thread cache。到后面,thread cache并不会频繁的进入central cache。加锁的概率会大大减小。 

能不能将thread cache和central cache合并?
        答案是不能。

        如果合并成thread cache的结构。

在page cache中就会存在切割了的Span和没有切割的Span。
如果用户申请大块内存,就需要在page cache桶中遍历查找,效率降低
如果thread cache需要内存,需要查找切割了的Span,均衡调度会受影响
page cache需要将整个哈希桶加锁,锁的竞争大大增加

  如果合并成central cache结构

用户申请内存需要遍历Span查找是否Span存在内存。
并且还需要加锁


thread cache线程私有,如果线程销毁了,即thread cache销毁了,上面还有内存块怎么办?
       

可以在创建thread cache时,调用接口来注册一个回调函数。当线程销毁,会自动调用回调函数,来讲thread cache的内存块回收到central cache中。

调研资料:

几个内存池库的对比

tcmalloc源码学习

TCMALLOC源码阅读

如何设计内存池? - 码农的荒岛求生的回答 - 知乎

tcmalloc源代码

  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
老师可能会提出以下问题: 1. 什么是内存池?为什么需要内存池? 答:内存池是一种内存管理技术,用于提高内存分配和释放的性能。它通过预先分配一定数量的内存块,并在程序运行期间重复利用这些内存块来避免频繁的内存分配和释放操作,从而提高程序的运行效率。 2. 内存池如何实现高并发? 答:内存池可以通过多线程技术来实现高并发。一般情况下,内存池会将内存块分配给不同的线程进行使用,每个线程都有自己的内存池。当多个线程同时请求内存块时,内存池可以进行加锁操作来保证线程安全。 3. 如何处理内存池中的内存碎片问题? 答:内存池中的内存碎片问题可以通过两种方式来解决。一种是使用内存池的分配算法来减少内存碎片的产生,另一种是定期对内存池进行整理和重组,以消除已有的内存碎片。 4. 如何进行内存池的扩展和收缩? 答:内存池的扩展和收缩可以通过动态调整内存池的大小来实现。当内存池中的内存块被耗尽时,可以重新分配一定数量的内存块,并将它们添加到内存池中。当内存池中的内存块处于空闲状态时,可以将它们从内存池中移除,以释放内存空间。 5. 如何测试内存池的性能? 答:测试内存池的性能可以使用一些基准测试工具,如Google Benchmark等。在测试时,可以比较内存池的分配和释放操作与系统默认的分配和释放操作之间的性能差异。同时,还可以测试内存池高并发环境下的性能表现。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值