004 高并发内存池_ThreadCache设计

​🌈个人主页:Fan_558
🔥 系列专栏:项目一
☀ 代码仓库:高并发内存池
🌹关注我💪🏻带你学更多操作系统知识
在这里插入图片描述

前言

本文将会向你介绍ThreadCache的具体实现

文章重点

在此模块需要完成以下任务:

1、对自由链表FreeList这个结构进行封装(以便我们能管理释放回来的内存块)
2、如果每一个字节数都需要对应一个自由链表的话,开销太大,我们需要定制一个对齐映射规则
3、根据对齐映射规则完成申请与释放内存对象(完成Allocate与Deallocate函数)
4、我们说每个线程独有一个ThreadCache线程缓存,因此不需要加锁,那么是如何让每一个线程独有一个ThreadCache呢(完成TLS线程局部存储的设计)
5、如果ThreadCache中没有多余的内存,我们需要向CentralCache进行申请(承上启下,函数实现放在CentralCache中)
在这里插入图片描述

一、设计FreeList自由链表结构

首先我们应先对ThreadCache结构进行代码设计
一、设计自由链表的结构
目前只提供了Push和Pop两个成员函数,分别是将对象插入到自由链表(头插),和从自由链表中获取一个对象(头删),后面还会根据需求对FreeList结构添加新的成员函数

class FreeList
{
public:
	//释放空间
	void Push(void* obj)
	{
		assert(obj);
		//让obj指向的空间与原自由链表链接
		*(void**)obj = _freeList;
		//让_freeList指向头部
		_freeList = obj;
	}
	//分配空间
	void* Pop()
	{
		assert(_freeList);
		void* obj = _freeList;
		//让指向自由链表头部的指针指向下一个
		_freeList = *(void**)_freeList;
		//返回自由链表头部的内存对象
		return obj;
	}

private:
	void* _freeList = nullptr;	//指向自由链表的指针
};

二、定制对齐映射规则

上文说到我们选择做一些平衡的牺牲,也就是将线程所申请的内存对象的大小向上调整,这样我们就不用对每一个字节都建立一个哈希桶,不然光是存储这些自由链表的头指针就需要消耗大量内存呢
因为这些内存块是需要被链接到自由链表上的,因此一开始就应按照8字节对齐。
最起码是要以8Bytes为最小对齐数,因为在64位下,如果你要连接自由链表前后节点,你起码得有存的下指针的空间,但如果所有的字节数都按照8Bytes对齐的话,那么我就需要建立256×1024÷8=32768个桶,数量比较多,因此我们可以让不同范围的字节数按照不同的对齐数进行对齐

在这里插入图片描述

对齐数:要求分配的内存块必须是对齐数的倍数,比如我们申请129个Bytes,此时对齐数是16,是不能够被整除的,因此我们需要将申请内存块的大小向上对齐加上15Bytes,分配144Bytes,这样的话15Bytes就是我们浪费的字节数

虽然对齐产生的内碎片会引起一定程度的空间浪费,但按照上面的对齐规则,我们可以将浪费率控制到百分之十左右。需要说明的是,1~128这个区间我们不做讨论,因为1字节就算是对齐到2字节也有百分之五十的浪费率,这里我们就从第二个区间开始进行计算。
在这里插入图片描述

根据上面的公式,我们要得到某个区间的最大浪费率,就应该让分子取到最大,让分母取到最小。比如129~1024这个区间,该区域的对齐数是16,那么最大浪费的字节数就是15,而最小对齐后的字节数就是这个区间内的前16个数所对齐到的字节数,也就是144,那么该区间的最大浪费率也就是15 ÷ 144 ≈ 10.42 % 同样的道理,后面两个区间的最大浪费率分别是127 ÷ 1152 ≈ 11.02 和1023 ÷ 9216 ≈ 11.10 %

获取对齐后的字节数:
 在获取某一字节数向上对齐后的字节数时,可以先判断该字节数属于哪一个区间,然后再通过调用一个子函数进行进一步处理。
(1)首先对申请的字节数进行划分范围

	//划分对齐范围
	static inline size_t AlignUp(size_t size)
	{
		if (size < 128)
		{
			return _AlignUp(size, 8);
		}
		else if (size <= 1024)
		{
			return _AlignUp(size, 16);
		}
		else if (size <= 64*1024)
		{
			return _AlignUp(size, 128);
		}
		else if (size <= 128*1024)
		{
			return _AlignUp(size, 1024);
		}
		else if (size <= 256*1024)
		{
			return _AlignUp(size, 8*1024);
		}
		else
		{
			assert(false);
			return -1;
		}
	}

(2)调用子函数获取对齐后的字节数

	//获取对齐后的字节数
	static inline size_t _AlignUp(size_t size, size_t AlignNum)
	{
		//对齐后的字节数
		size_t alignSize = 0;
		if (size / AlignNum != 0)
		{
			//向上取整
			alignSize = (size / AlignNum + 1) * AlignNum;
		}
		else
		{
			alignSize = size;
		}
		return alignSize;
	}

(3)另一种子函数写法(运用位运算)

	//我们还可以通过位运算的方式来进行计算,虽然位运算可能并没有上面的写法容易理解,但计算机执行位运算的速度是比执行乘法和除法更快的
	static inline size_t _AlignUp(size_t size, size_t AlignNum)
	{
		return ((size + AlignNum - 1) & ~(AlignNum - 1));
	}

计算所申请的字节数应该在哪一个桶里去申请空间,这里可以再对照ThreadCache的结构,比如所申请的字节数是129Bytes,那么调用_Index(129-128,16),得到的返回值就是1,然后再加上16,就是第17个桶。对照ThreadCache的结构,每8Bytes一个桶,可以验证一下

	//计算在自由链表上的第几个桶
	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, 8);
		}
		else if (Bytes <= 1024) {
			return _Index(Bytes - 128, 16) + group_array[0];
		}
		else if (Bytes <= 8 * 1024) {
			return _Index(Bytes - 1024, 128) + group_array[1] + group_array[0];
		}
		else if (Bytes <= 64 * 1024) {
			return _Index(Bytes - 8 * 1024, 1024) + group_array[2] + group_array[1] + group_array[0];
		}
		else if (Bytes <= 256 * 1024) {
			return _Index(Bytes - 64 * 1024, 8192) + group_array[3] + group_array[2] + group_array[1] + group_array[0];
		}
		else {
			assert(false);
		}
		return -1;
	}

(2)调用子函数

	//对齐后的申请空间的大小, 对齐数
	static inline size_t _Index(size_t bytes, size_t align)
	{
		if (bytes % align == 0)
		{
			return bytes / align - 1;
		}
		else
		{
			return bytes / align;
		}
	}

(3)这里如果采用位运算的算法,需要将Index函数的第二个参数改成:例如,8为2^3,需要传入3,16为 2^4需要传入4依次类推

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

三、完成申请Allocate与释放Deallocate内存函数

在完成了FreeList结构的设计以及对齐映射规则的设计,我们接下来就要对申请与释放内存函数进行定义

ThreadCache类:
Common.h:
//给定最大的内存对象申请大小
static const size_t MAX_BYTES = 256 * 1024;
//自由链表上哈希桶的个数
static const size_t FreeListBucket = 208;
ThreadCache.h:
#pragma once
#include "Common.h"
// thread cache本质是由一个哈希映射的对象自由链表构成
class ThreadCache
{
public:
	// 申请和释放内存对象
	void* Allocate(size_t size);
	void Deallocate(void* ptr, size_t size);
	FreeList _freeLists[FreeListBucket];
};

向ThreadCache中申请内存对象,无非就是根据所申请对象的大小根据对齐映射规则计算出AlignNum与Index,并在ThreadCache结构中(对照本文开头的图来看比较好)找到对应的桶->_freeList找到自由链表,然后再(Pop)取出里面的一个内存对象

在这里插入图片描述

ThreadCache.cpp:
//申请空间
void* ThreadCache::Allocate(size_t size)
{
	assert(size <= MAX_BYTES);		//申请的字节数不能大于了256KB
	size_t AlignNum = AlignmentRules::AlignUp(size);		//计算对齐后的所申请空间大小 
	size_t index = AlignmentRules::Index(size);		//计算在哪个桶
	if (!_freeLists[index].Empty())	//若当前自由链表有资源则优先拿释放掉的资源
		return _freeLists[index].Pop();  //取出该桶中由自由链表管理的头一个空间(头删)
	//自由链表没有就从中心缓存获取空间(下一章实现)
	else return FetchFromCentralCache(index, AlignNum);
	return nullptr;
}

//释放pTLSThreadCache对象(需要告诉释放哪一个桶)
void Deallocate(void* ptr, size_t size)
{
	assert(ptr);
	assert(size < MAX_BYTES);
	//计算出属于哪一个桶
	size_t index = AlignmentRules::Index(size);
	//用自由链表管理释放的内存
	pTLSThreadCache->_freeLists[index].Push(ptr);
}

四、线程局部存储TLS设计

在申请与释放内存对象函数中,我们看到了pTLSThreadCache的字眼,是什么意思呢?开头我们也提到过,每个线程独有一个ThreadCache线程缓存,因此不需要加锁,那么是如何让每一个线程独有一个ThreadCache呢
首先我们不能将这个threadcache创建为全局的,因为全局变量是所有线程共享的,这样就不可避免的需要锁来控制,增加了控制成本和代码复杂度。
要实现每个线程无锁的访问属于自己的thread cache,我们需要用到线程局部存储TLS(Thread Local Storage),这是一种变量的存储方法,使用该存储方法的变量在它所在的线程是全局可访问的,但是不能被其他线程访问到,这样就保持了数据的线程独立性

//TLS - Thread Local Storage
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;

但并不是每个线程被创建时就立马有了属于自己的thread cache,而是当该线程调用相关申请内存的接口时才会创建自己的thread cache,因此在申请内存的函数中会包含以下逻辑。

//通过TLS,每个线程无锁的获取自己专属的ThreadCache对象
if (pTLSThreadCache == nullptr)
{
	pTLSThreadCache = new ThreadCache;
}

基于此,我们在对申请和释放内存的函数作封装,加上TLS

//通过TLS,每个线程无锁地获取自己地专属ThreadCache对象
static void* ConcurrentAlloc(size_t size)
{
	if (pTLSThreadCache == nullptr)
	{
		pTLSThreadCache = new ThreadCache;
	}
	cout << std::this_thread::get_id() << ":" << pTLSThreadCache << endl;
	return pTLSThreadCache->Allocate(size);
}
//释放_freeList
static void* ConcurrentFree(void* ptr, size_t size)
{
	//每一个线程都会有一个pTLSThreadCache对象
	assert(pTLSThreadCache);
	pTLSThreadCache->Deallocate(ptr, size);
}

当然,我们可以对此进行测试,看看到底线程有没有获取自己专属的ThreadCache对象

#include <thread>
void Alloc1()
{
	for (size_t i = 0; i < 5; i++)
	{
		//申请5字节大小的内存对象
		void* ptr = ConcurrentAlloc(5);
	}
}

void Alloc2()
{
	for (size_t i = 0; i < 5; i++)
	{
		//申请6字节大小的内存对象
		void* ptr = ConcurrentAlloc(6);
	}
}

void TLSTest()
{
	//线程一
	std::thread t1(Alloc1);
	//线程二
	std::thread t2(Alloc2);

	t1.join();
	t2.join();
}

我们可以观察到现象:线程一、二都获取到了自己的pTLSThreadCache
在这里插入图片描述

五、承上启下:向CentralCache中申请

当一个线程找ThreadCache申请内存对象时,根据映射关系找到了对应的桶,但是桶里的自由链表为空,那么只能向下一层进行申请内存对象了

//从中心存储中获取
void* ThreadCache::FetchFromCentralCache(size_t index, size_t size)
{
	//请听下回分解...
}

小结

今日的项目分享就到这里啦,下一篇我将会向你介绍如何实现CentralCache,如果本文存在疏漏或错误的地方还请您能够指出,共同进步~

posted @ 2024-03-30 15:22  Fan_558  阅读(14)  评论(0编辑  收藏  举报  来源