004 高并发内存池_ThreadCache设计
文章目录
前言
本文将会向你介绍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,如果本文存在疏漏或错误的地方还请您能够指出,共同进步~