【C++项目设计】4.高并发内存池 -- thread cache 的整体设计及代码实现

博客主题:thread cache 的整体设计及代码实现
个人主页:https://blog.csdn.net/sobercq
CSDN专栏:https://blog.csdn.net/sobercq/category_12884309.html
Gitee链接:https://gitee.com/yunshan-ruo/high-concurrency-memory-pool



前言

在上上一篇我们实现了定长内存池的代码,我们也清楚的知道了定长内存池如何分配内存,回收内存,分配对象和释放对象,并且使用自由链表来管理切好使用的小块内存,本期我们将从定长内存池代码的基础上进一步扩展代码,从而实现thread cache的设计。

thread cache的整体设计

为什么是哈希桶结构

我们可以回顾一下定长内存池,首先定长内存池只支持固定大小内存块的申请释放,因此定长内存池中只需要一个自由链表管理释放回来的内存块。
在这里插入图片描述
但每块内存都是固定的,也就是说,如果你需要1字节,在定长内存池中,我们只能给你分配8字节的内存块,那这样就会造成内碎片,内碎片就是由于一些对齐的需求,导致分配出去的空间中一些内存无法被利用。
所以我们为了节省资源,我们可以考虑从1B到256KB不同大小内存的分配,而不同的内存大小就需要有不同字节大小的自由链表来管理释放回来的内存块,因此我们可以把thread cache设计成一个哈希桶结构,用一个数组来存储各种字节大小的自由链表的头,以用来满足我们不同字节数的内存大小分配。

如何分配内存块大小

如果真的按照1B到256KB,那我们就要分配262,144个自由链表,光是存储这些自由链表的头指针就需要消耗大量内存。
这时我们可以选择做一些平衡的牺牲,让这些字节数按照某种规则进行对齐,例如我们让这些字节数都按照8字节进行向上对齐,那么thread cache的结构就是下面这样的,此时当线程申请1-8字节的内存时会直接给出8字节,而当线程申请9~16字节的内存时会直接给出16字节,以此类推。
这里会存在一定的空间浪费,
在这里插入图片描述
我们可以大概抽象成这样,这就是我们的一个哈希桶结构,具体的对应规则我们等会就会详细介绍,这里的类型就是我们的Freelist。

自由链表

因为我们会很经常用到自由链表,所以我们可以封装一下自由链表。

//返回前4/8字节指针
void*& NextObj(void* Obj)
{
	assert(Obj);
	return *(void**)Obj;
}

//管理切分好的小对象的自由链表
class FreeList
{
public:
	void Push(void* obj)
	{
		assert(obj);
		//头插
		NextObj(obj) = _freeList;
		_freeList = obj;
	}

	void* Pop()
	{
		assert(_freeList);
		//头删
		void* obj = _freeList;
		_freeList = NextObj(obj);
		return obj;
	}

private:
	void* _freeList = nullptr;
};

我们提供一个函数用于获取下一个内存块的地址,其次我们的自由链表要插入和删除,对应的我们也要检查一下是否合规。

因此不难理解,threadcache实际只是一个数组,用来存放不同字节大小的自由链表,至于这个数组中到底存储了多少个自由链表,就需要看我们的字节对齐规则,也就是哈希桶映射对齐规则了。

哈希桶映射对齐规则

按多少字节对齐?

自由链表处理完后,我们要来处理一下对应的thread cache对齐,首先要保证我们的字节数是在256KB之间的,其次我们是像哈希桶那样,一段区间对应一个桶,所以我们要建立好一个规则。

首先我们得按8字节对齐。为什么?因为32位平台下的指针大小是4字节,而64就是8字节了,那如果是64位平台而我们采用4字节就会导致指针分配不上,因此一开始肯定是按8字节进行对齐是最合适的。

如果我们都按8字节对齐,那所需要的自由链表桶数量是32768(256*1024 / 8),这个数量还是比较多的,所以我们可以让不同范围的字节数按照不同的对齐数进行对齐,这样能提高我们的空间利用率。

字节范围对齐字节数自由链表桶下标
1 B – 128 B8 B[0, 16)
129 B – 1 KB16 B[16, 72)
1.001 KB – 8 KB128 B[72, 128)
8.001 KB – 64 KB1 KB[128, 184)
64.001 KB – 256 KB8 KB[184, 208)

空间浪费率

我们知道规则后,虽然按照对齐会存在一定的内碎片的浪费,内部碎片是由于对齐或块大小固定导致的浪费,但按照上面的对齐规则,我们可以将浪费率控制到百分之十左右。

需要说明的是,1~128这个区间我们不做讨论,因为1字节就算是对齐到2字节也有百分之五十的浪费率,这里我们就从第二个区间开始进行计算。我们求最大浪费率即可。

浪费率 = 浪费字节数 ÷ 对齐后的字节数

请求大小 (B)对齐后大小 (B)浪费字节数浪费率
1291441510.42%
13614485.56%
14314410.69%
14414400%

比如:129~1024这个区间,该区域的对齐数是16,那么最大浪费的字节数就是15,而对齐后的字节数就是这个区间内的前16个数所对齐到的字节数,也就是144。那么该区间的最大浪费率也就是15 ÷ 144 ≈ 10.42 % 。同理,后一个1.001 KB – 8 KB区间的最大浪费率是,(128 - 1)÷(1024+128) = 127 ÷ 1152 = 11.02%。

对齐和映射

内存字节的对齐

对齐规则的实现,在获取我们的字节数时,可以先判断该字节数属于哪一个区间,然后再通过调用一个子函数进行进一步处理,所以我们设计两个函数,一个是RoundUp,一个是子函数_RoundUp进行具体的计算。

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);
	}
	else if (size <= 256*1024)
	{
		return _RoundUp(size, 8 * 1024);
	}
	else
	{
		assert(false);
		return -1;
	}
}

子函数_RoundUp的实现,一般的做法是通过判断余数是否为0来决定是否需要进位,如果size不是alignNum的倍数,就计算下一个倍数,否则保持不变。例如,当size是15,alignNum是8时,15除以8得到1余7,所以需要进位到(1+1)*8=16。

static inline size_t _RoundUp(size_t size,size_t alignNum)
{
	size_t alignSize;
	if (size % alignNum != 0)
	{
		alignSize = (size / alignNum + 1) * alignNum;
	}
	else
	{
		alignSize = size;
	}

	return alignSize;
}

大佬的写法,而大佬的写法是(size + alignNum -1) & ~(alignNum -1)
我们假设alignNum是2的幂,比如8,那么alignNum-1就是7,二进制是0111。取反之后就是…11111000(32位)。这样,当我们将size加上alignNum-1,也就是7,然后和~7进行按位与,相当于把低三位清零,也就是向上取整到8的倍数,15+7=22,二进制是10110,和~7(即111…1000)按位与的结果是16,即10110 & 11111000 = 10000。

static inline size_t _RoundUp(size_t size, size_t alignNum)
{
	return ((size + alignNum - 1) &~ (alignNum - 1));
}

但是注意,对齐数必须为2的幂,并且我们可以使用静态和内联来提高我们使用这两个函数的效率。

自由链表桶下标的映射

我们知道了对齐的字节数后,我们还需要知道它具体对应的桶的下标位置。这里我们还是跟计算字节数对齐的设计一样,Index来分类,子函数_Index进行具体的计算。Index根据请求的内存字节数 bytes,确定其应映射到哪个自由链表桶中。

// 计算映射的哪一个自由链表桶
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 <= 8 * 1024)
	{
		return _Index(bytes - 1024, 7) + group_array[1] + group_array[0];
	}
	else if (bytes <= 64 * 1024)
	{
		return _Index(bytes - 8 * 1024, 10) + group_array[2] + group_array[1]
			+ group_array[0];
	}
	else if (bytes <= 256 * 1024)
	{
		return _Index(bytes - 64 * 1024, 13) + group_array[3] +
			group_array[2] + group_array[1] + group_array[0];
	}
	else
	{
		assert(false);
		return -1;
	}
}

我们既然将其分类出来了,那还需要一个子函数来具体计算对应的下标。一般的做法是我们直接将对齐字节数和请求字节数传入,根据 bytes 是否能被 alignNum 整除,直接计算索引。若 bytes 是 alignNum 的整数倍,索引为 (bytes / alignNum) - 1。否则,索引为 bytes / alignNum。

_Index(128, 3)
= 128 % 8 == 0 → true
→ return 128 / 8 - 1
= 16 - 1
= 15

static inline size_t _Index(size_t bytes, size_t alignNum)
{
	if (bytes % alignNum == 0)
	{
		return bytes / alignNum - 1;
	}
	else
	{
		return bytes / alignNum;
	}
}

位运算,首先我们得清楚左移1相当于乘2,右移1相当于除2.

当bytes = 128,alignNum = 3时
_Index = ((128 + (1 << 3) - 1) >> 3) - 1
= ((128 + 8 - 1) >> 3) - 1
= (135 >> 3) - 1
= 16 - 1
= 15

static inline size_t _Index(size_t bytes, size_t align_shift)
{
	return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1;
}

ThreadCache类

知道对齐和映射后的关系,我们就可以继续写ThreadCache类了。按照上述的对齐规则,thread cache中桶的个数,也就是自由链表的个数是208,以及thread cache允许申请的最大内存大小256KB,我们可以将这些数据按照如下方式进行定义。

common.h

//ThreadCache申请的最大字节数
static const size_t MAX_BYTES = 256 * 1024;
//自由链表桶的总数量
static const size_t NFREELISTS = 208;

现在对ThreadCache类进行定义,thread cache就是一个存储208个自由链表的数组,目前thread cache就先提供一个Allocate函数用于申请对象,以及一个从中心缓存获取对象的FetchFromCentralCache函数。

ThreadCache.h

class ThreadCache
{
public:
	// 申请内存对象
	void* Allocate(size_t size);

	//从中心缓存获取对象
	void* FetchFromCentralCache(size_t index, size_t size);
private:
	FreeList _freeLists[NFREELISTS];
};

如何申请对象?

首先我们得保证这个申请的字节数在最大字节数范围内,然后我们要知道对齐的字节数和映射后的桶下标。

这些都知道了后,我们就可以看自由链表中是否有空余的内存块,如果有就弹出来,使用自由链表的内存块,如果没有我们就向CentralCache中心缓存获取内存。

ThreadCache.cpp

// 申请内存对象
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);
	}
}

threadcacheTLS无锁访问

在我们的高并发内存池中,我们是每个线程都有一个独享的cache。cache里面我们可以看到是一个哈希桶,每个桶里面挂的就是一个小对象,切好的小对象的自由列表,有了一个值以后,就需要有一个申请的大小,然后直接计算在哪个桶,然后再去对应的桶里面,如果有对象取一个对象,没有对象就需要再找下一层。

如果对象都有的情况下效率是非常高的,但这个时候还差一层,就是当前线程如何获取到Threadcache?

而我们的一个进程里面可能有多个线程,多个线程共享整个进程地址的空间,每个线程有独立的栈,寄存器等等,但有些东西是共享的,比如说我们的全局数据段和代码段等等这些东西是共享的。

所以我们不能将这个thread cache创建为全局的,因为全局变量是所有线程共享的,这样就不可避免的需要锁来控制,增加了控制成本和代码复杂度。

如果我们要实现每个线程无锁的访问属于自己的thread cache,我们需要用到线程局部存储TLS(Thread Local Storage).

Thread Local Storage(线程局部存储)TLS

线程局部存储(TLS),是一种变量的存储方法,这个变量在它所在的线程内是全局可访问的,但是不能被其他线程访问到,这样就保持了数据的线程独立性。而熟知的全局变量,是所有线程都可以访问的,这样就不可避免需要锁来控制,增加了控制成本和代码复杂度。

文章的讲解主要是在Linux下使用TLS,那我们想在Windows下使用的话,我们这里可以使用静态的TLS(参考文章)。

// TLS thread local storage
static _declspec(thread) ThreadCache* pTLSThreadcache = nullptr;

写上这样一句话,我们就为本程序中的每一个线程创建了一个独立的pTLSThreadcache数据,这里我们可以加上static,这样它只在当前文件内使用,避免后续链接问题。

申请对象

因为每个线程都会有一个独立的pTLSThreadcache,所以我们只要判断,pTLSThreadcache是否为空,不为空我们就直接返回ThreadCache对象,为空我们就new一个出来。

static void* ConcurrentAlloc(size_t size)
{
	//通过TLS,每个线程无锁的获取自己专属的ThreadCache对象
	if (pTLSThreadcache == nullptr)
	{
		pTLSThreadcache = new ThreadCache;
	}

	return  pTLSThreadcache->Allocate(size);
}

回收对象

我们每个线程通过调用ConcurrentFree来进行释放,那最主要还是通过ThreadCache本身提供的Deallocate来进行回收到我们的自由链表,实现如下,这里就不多写啦。

static void* ConcurrentFree(void* ptr, size_t size)
{
	assert(pTLSThreadcache);

	pTLSThreadcache->Deallocate(ptr,size);
}

void ThreadCache::Deallocate(void* ptr, size_t size)
{
	assert(ptr);
	assert(size <= MAX_BYTES);

	//获取桶下标
	size_t index = SizeClass::Index(size);

	_freeLists[index].Push(ptr);
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值