文章目录
从零实现一个高并发的内存池
-
项目介绍
项目原型是google的开源项目tcmalloc,即线程缓存的malloc,针对其核心框架做的简易版,实现了高效的多线程内存管理,用于替代系统的内存分配相关的函数malloc和free,主要目的是为了学习tcmalloc的精华。
-
所需技术:
C/C++、数据结构(链表、哈希桶)、操作系统内存管理、单例模式、多线程、互斥锁等,开发环境为windows下VS2013,项目中对Linux环境用法也有介绍
-
什么是内存池
内存池指的是程序预先向操作系统申请足够大的一块内存空间;此后,程序中需要申请内存时,不需要直接向操作系统申请,而是直接从内存池中获取;同理,程序释放内存时,也不是将内存直接还给操作系统,而是将内存归还给内存池。
当程序退出(或者特定时间)时,内存池才将之前申请的内存真正释放。
-
调用系统接口申请内存/为什么需要内存池
在C语言中申请内存用的是malloc函数,当频繁的malloc会有出现以下问题:
1.碎片问题:
例如有10个G,A拿走前3个,B拿走中间3个,C拿走第7、8个,现在空闲9、10。现在B把3个都还了,之后D申请4个,但是B还的3个与空闲的2个不连续,就导致申请失败,这就是外碎片问题
除了外碎片还会有内碎片问题,一般是是由于一些对齐的需求,导致分配出去的空间中一些内存无法被利用;例如struct字节对齐数
2.申请效率的问题:
每次申请都会占用CPU的时间,申请地越多越频繁,消耗就越大,效率就越低
例如上学家里给生活费,假设一学期的生活费是6000块。
方式1:开学时6000块直接给你,自己保管,自己分配如何花。
方式2:每次要花钱时,联系父母,父母转钱。
同样是6000块钱,第一种方式的效率肯定更高,因为第二种方式跟父母的沟通交互成本太高了。 同样的道理,程序就像是上学的童鞋,操作系统就像父母,频繁申请内存的场景下,每次需要内存,都向系统申请效率必然有影响。
而内存池可以减少频繁调用malloc而消耗操作系统,尽可能的降低内存碎片问题。
定长内存池
在介绍高并发内存池之前,先用定长内存池来对内存池有一个初步的认识,并且可用于后续在高并发内存池中的一个组件
在 C、C++中申请内存使用的都是malloc,什么场景下都可以使用,但也就表示在任何场景下的性能都是中庸的;那么可以针对特定的场景设计一个定长的内存池
例如,设计一个分配32字节对象的固定内存分配器来对比malloc(32)
定长内存池设计
如图,下面将围绕图中这三部分来设计,具体细节再代码中体现
申请内存池
这里没有什么难度,就是第一次申请内存池时,先判断记录内存池的指针为不为空,为空就表示这是第一次
T* New()
{
if (_memory == nullptr)
{
_memory = (char*)malloc(128 * 1024);
if (_memory == nullptr)
{
throw bad_alloc();
}
}
}
//成员变量
private:
char* _memory = nullptr; //记录内存块位置的指针
void* _freeList = nullptr; //链接释放后的内存块的,单链表头指针
选用char*做为指针的类型,因为是char是以1字节为单位的,可以切割任意大小的字节,用户申请时只需要加上强转即可
用户申请
创建好内存池后,用户就可以去内存池中申请了,每申请一块,_memory
就往后移一块
T* New()
{
if (_freeList == nullptr)
{
_memory = (char*)malloc(128 * 1024);
if (_memory == nullptr)
{
throw bad_alloc();
}
}
T* obj = _memory; //用户申请
_memory += sizeof(T); //申请后更新指针位置
new(obj)T; //定位new显式调用T类型构造函数,给用户初始化
return obj;
}
当此内存池申请满了后,再有用户申请时,_memory
就不能往后移了,需要再去向系统申请定长的内存。
但是申请内存池处的判断依据是_memory
为空,内存池满了后_memory
的位置不为空,因为这只是我们申请下来的定长内存,后面还有内存,只是不属于我们。
所以这里的解决办法是增加一个成员变量,用于记录剩余字节数量,之后再更改申请内存池的判断依据
T* New()
{
if (_remainedBytes < sizeof(T)) //假如剩余的内存比要申请的小,则也需重新申请,剩余的就丢弃
{ //但一般再申请时会根据要申请的定长,设置出能整除的大小
_remainedBytes = 128 * 1024;
_memory = (char*)malloc(128 * 1024);
if (_memory == nullptr)
{
throw bad_alloc();
}
}
T* obj = (T*)_memory;
_memory += sizeof(T);
_remainedBytes -= sizeof(T);
new(obj)T; //定位new显式调用T类型构造函数,给用户初始化
return obj;
}
private:
char* _memory = nullptr; //记录内存块位置的指针
void* _freeList = nullptr; //链接用户释放后的内存块 链表的头指针
size_t _remainedBytes = 0; //记录剩余字节数
用户归还(释放)
这里的策略是,将用户释放后的内存块,用链表链接采用头插策略,每个内存块的前4或8个字节用来记录下一个内存块的地址
获取前4/8字节的方法如下:
T* obj//用户释放的内存块
*(void**)obj = _freeList
_freeList = obj;
// 指针的大小是根据32、64位来定的,使用指针就可以自动根据系统位数使用了
// 但假如直接强转为一级指针记录地址,等于是更改指向内存块的首地址;所以用二级指针,解引用后才是更改内容,让下一个内存块地址做为内存块的内容
用户释放内存代码如下:
void Delete(T* obj)
{
*(void**)obj = _freeList; //先让obj指向的内存块的内容为下一块的地址
_freeList = obj; //再让更新头指针
}
同时要注意:
- 因为申请的是定长大小,所以回收用户释放的内存块是可以再次使用的;
- 这里使用的是模板,T可以代表任何类型大小,包括int、char等等;这些不够4/8字节的,只能在申请时补齐到8字节
综上,对申请内存的代码改动如下:
T* New()
{
T* obj = nullptr;
if (_freeList) //查看释放回来的内存块队列是否为NULL
{
obj = (T*)_freeList;
void* next = *(void**)_freeList;
_freeList = next;
}
else
{
if (_remainedBytes < sizeof(T)) //假如剩余的内存比要申请的小,则也需重新申请,剩余的就丢弃
{ //但一般再申请时会根据要申请的定长,设置出能整除的大小
_remainedBytes = 128 * 1024;
_memory = (char*)malloc(128 * 1024);
if (_memory == nullptr)
{
throw bad_alloc();
}
obj = (T*)_memory;
//如果对象的字节大小小于指针的字节大小,就给他指针大小的内存块,否则就该多大给多大
size_t objSize = sizeof(T) < sizeof(T*) ? sizeof(T*):sizeof(T);//所以根据这里在使用时最好算算能不能整除
_memory += objSize;
_remainedBytes -= objSize;
}
}
new(obj)T; //定位new显式调用T类型构造函数,给用户初始化
return obj;
}
总体代码
#include <iostream>
#include <vector>
#include <time.h>
using std::cout;
using std::endl;
using std::bad_alloc;
template<class T>
class ObjectPool
{
public:
T* New()
{
T* obj = nullptr;
if (_freeList) //查看释放回来的内存块队列是否为NULL
{
obj = (T*)_freeList;
void* next = *(void**)_freeList;
_freeList = next;
}
else
{
if (_remainedBytes < sizeof(T)) //假如剩余的内存比要申请的小,则也需重新申请,剩余的就丢弃
{ //但一般再申请时会根据要申请的定长,设置出能整除的大小
_remainedBytes = 128 * 1024;
_memory = (char*)malloc(128 * 1024);
if (_memory == nullptr)
{
throw bad_alloc();
}
}
obj = (T*)_memory;
//如果对象的字节大小小于指针的字节大小,就给他指针大小的内存块,否则就该多大给多大
size_t objSize = sizeof(T) < sizeof(T*) ? sizeof(T*) : sizeof(T);//所以根据这里在使用时最好算算能不能整除
_memory += objSize;
_remainedBytes -= objSize;
}
new(obj)T; //定位new显式调用T类型构造函数,给用户初始化
return obj;
}
void Delete(T* obj)
{
*(void**)obj = _freeList; //先让obj指向的内存块的内容为下一块的地址
_freeList = obj; //再让更新头指针
}
private:
char* _memory = nullptr;
void* _freeList = nullptr;
size_t _remainedBytes = 0;
};
测试对比(malloc)
下面使用malloc与上述定长内存池进行测试对比
struct TreeNode
{
int _val;
TreeNode* _left;
TreeNode* _right;
TreeNode()
:_val(0)
, _left(nullptr)
, _right(nullptr)
{}
};
void TestObjectPool()
{
// 申请释放的轮次
const size_t Rounds = 5;
// 每轮申请释放多少次
const size_t N = 100000;
std::vector<TreeNode*> v1;
v1.reserve(N);
size_t begin1 = clock();
for (size_t j = 0; j < Rounds; ++j)
{
for (int i = 0; i < N; ++i)
{
v1.push_back(new TreeNode);
}
for (int i = 0; i < N; ++i)
{
delete v1[i];
}
v1.clear();
}
size_t end1 = clock();
std::vector<TreeNode*> v2;
v2.reserve(N);
ObjectPool<TreeNode> TNPool;
size_t begin2 = clock();
for (size_t j = 0; j < Rounds; ++j)
{
for (int i = 0; i < N; ++i)
{
v2.push_back(TNPool.New());
}
for (int i = 0; i < N; ++i)
{
TNPool.Delete(v2[i]);
}
v2.clear();
}
size_t end2 = clock();
cout << "new cost time:" << end1 - begin1 << endl;
cout << "object pool cost time:" << end2 - begin2 << endl;
}
/********************************************************************************/
#include "ObjectPool.h"
int main()
{
TestObjectPool();
return 0;
}
内存池是不释放的,因为用户归还的内存块是不连续的,只要进程正常退出,系统是自动回收的
(也可以在申请内存池时记录每个内存池的首地址)
- 拓展(绕过malloc,直接系统调用)
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32
void* ptr = VirtualAlloc(0, kpage<<13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else
// linux下brk mmap等
#endif
if (ptr == nullptr)
throw std::bad_alloc();
return ptr;
}
//申请内存的 T* new()
if (_remainBytes < sizeof(T))
{
_remainBytes = 128 * 1024;
//_memory = (char*)malloc(_remainBytes);
_memory = (char*)SystemAlloc(_remainBytes >> 13);
if (_memory == nullptr)
{
throw std::bad_alloc();
}
}
高并发内存池框架简介
concurrent memory poll整体分为三个模块
-
thread cache:
这部分是给每个线程都配有一个cache,用于小于256KB的内存(申请、释放),都直接从这里分配,并且因为是每个线程都有一个cache,所以这里不需要锁,这也是并发内存池高效的地方
-
central cache:
本层是所有线程都会共享的中心缓存,当Thread Cache中的缓存不能满足用户的需求,会按需向本层申请,本层根据thread Cache的需求分配给它想要大小的内存块,同时还会周期性的回收内存块,避免个别线程占用内存过多达到均衡分配按需调度的目的。
但central cache是存在锁竞争问题的,因为是多个线程共享central cache;这里采用(哈希桶)桶锁,并且是在thread cache没内存时才会找central cache,所以锁的竞争不会很紧张。
-
page cache:
本层是页缓存,分配是按页为单位的,负责给第二层提供内存,并会根据第二层的内存使用情况进行回收再与其他页合并,之后再分配时又会以整页为单位分配给第二层,可以减少内存碎片问题。
这三层底层的数据结构都会用到哈希桶与链表,具体细节在后面分别介绍
thread cache的设计
thread cache是哈希桶结构,每个桶是一个根据桶位置映射的挂接内存块的自由链表,每个线程都会有一个thread cache对象,这样就可以保证线程在申请和释放对象时是无锁访问的
申请部分
thread cache的申请流程:
- 刚开始启动时的哈希桶没有挂载任何内存的,当用户第一次申请内存时—>
- 会先向第二人层申请,第二层会给第一层想要大小的内存块—>
- 但是一开始是给一块,后面是慢慢增加的
thread cache的底层是哈希桶结构,内存块是以自由链表的形式挂载的(桶),并且thread cache层最大可处理内存为256KB,也就是说256kb映射最大的桶
thread cache内存的映射与分配:
-
因为是自由链表的形式,所以需要用头部的4或8字节(这里直接定义为8字节)去记录下一块内存的地址,所以最小为8字节的桶
static void*& NextObj(void* obj) { return *(void**)obj; }
-
但是从8字节往后例如9、10、11字节一直到256K字节的数字非常大,会划分出大量的哈希桶,还可能会导致较大的内存开销,所以不可能每个数都映射一个桶,具体分配规则如下:
整体控制在最多10%左右的内碎片浪费 [1,128] 8byte对齐 freelist[0,16) ------16个桶 [128+1,1024] 16byte对齐 freelist[16,72) ------56个桶 [1024+1,8*1024] 128byte对齐 freelist[72,128) ------56个桶 [8*1024+1,64*1024] 1024byte对齐 freelist[128,184) ------56个桶 [64*1024+1,256*1024] 8*1024byte对齐 freelist[184,208) ------24个桶 例如129对齐是144,浪费15个字节,15/144 = 0.104... 前1-56浪费率会高,但是前几个对齐的字节数都为8,最大浪费也就7字节,可以忽略
例如 [1,128]有16个桶:
所以这里不仅要根据用户的申请的字节数计算对齐数,还要计算映射的桶号
-
对象的框架搭建
/**************************************Common.h*************************************/ /******************************************自由链表(桶)******************************************/ static const size_t MAX_BYTES = 256 * 1024; //最大字节数 static const size_t NFREELIST = 208; //桶的数量 class FreeList { public: //头删,用于用户申请 void* Pop() { assert(_freeList); void* obj = _freeList; _freeList = NextObj(_freeList); _size -= 1; return obj; } //判断是否为NULL bool Empty() { return _freeList == nullptr; } //用于向上申请需要的内存块数,是递增的,具体用法在后面代码中介绍 size_t& MaxSize() { return _maxSize; } //批量头插,用于向第二层申请批量内存块 void PushRange(void* start,void* end,size_t n) { NextObj(end) = _freeList; _freeList = start; _size += n; } //获取内存块个数 size_t Size() { return _size; } private: void* _freeList = nullptr; //头指针 size_t _maxSize = 1; //用于慢启动,具体用法在申请函数中介绍 size_t _size = 0; //记录每个链表上的内存块个数 }; /******************************************2.thread cache.h****************************************/ #pragma once #include "Common.h" class ThreadCache { public: //申请 void* Allocate(size_t size); //从第二层,中心缓存获取 void* FetchFromCentralCache(size_t index, size_t size); private: FreeList _freeList[NFREELIST];//数组长208,也就是有208个桶,即挂载208个自由链表 }; static _declspec(thread) ThreadCache* pTLS_threadCache = nullptr; /*每个线程各自持有一个 线程局部存储(TLS),是一种变量存储的方法,这个变量在它所在线程内是全局可访问的,但不能被其他线程访问到 */
-
字节对齐数与桶号的映射算法
class SizeClass { //计算对齐数,例如9字节,对齐数就是16 /*方法1: inline static size_t _RoundUp(size_t size,size_t alignNum) { size_t alignSize; if (size % alignNum != 0) { alignSize = (size / alignNum + 1) * alignNum;// 9 % 8=1 + 1 =2 * 8 =16 } else { alignSize = alignNum; } return alignSize; }*/ //方法2: inline static size_t _RoundUp(size_t bytes, size_t alignNum) { return ((bytes + alignNum - 1) & ~(alignNum - 1)); } //计算哈希桶号,也就是哈希映射数组的下标 /*方法1: size_t _Index(size_t bytes, size_t alignNum) { if (bytes % alignNum == 0) { return bytes / alignNum - 1; } else { return bytes / alignNum; } }*/ //方法2: static inline size_t _Index(size_t bytes, size_t align_shift) { return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1; } //9,3 //9 + (1 << 3) - 1 = 8 - 1 + 9 = 16 //16 >> 3 - 1 = 1 public: //计算thred_cache用户申请字节的对齐数 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 { //return _RoundUp(size, 1 << PAGE_SHIFT); //超过256KB处理方式,具体处理方式在后面介绍 return -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) { //bytes - 128:当前区间的字节数 - 上一区间的总字节数 return _Index(bytes - 128, 4) + group_array[0]; //group_array[0]:1~128区间的总桶数量 } else if (bytes <= 8 * 1024) { return _Index(bytes - 1024, 7) + group_array[1] + group_array[0]; //group_array[1] + group_array[0] = 1~1024间的总桶数 } 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; } } // 每一次thread cache要从中心缓存获取多少个内存块 static size_t NumMoveSize(size_t size) { assert(size > 0); // [2, 512],一次批量移动多少个对象的(慢启动)上限值 // 小对象一次批量上限高 // 小对象一次批量上限低 int num = MAX_BYTES / size; if (num < 2) num = 2; if (num > 512) num = 512; return num; } private: };
-
具体执行部分
//用户申请 void* ThreadCache::Allocate(size_t size) { assert(size <= MAX_BYTES); //1.计算用户申请的字节对应的对齐数 size_t allignSize = SizeClass::RoundUp(size); //2.计算用户要到哪个桶取内存块(数组映射的下标中挂载的链表取内存块) size_t index = SizeClass::Index(size); //判断index下标的FreeList中的头指针是否为NULL if (!_freeList[index].Empty()) { return _freeList[index].Pop(); //不为NULL就可以Pop给用户了 } else { return FetchFromCentralCache(index,allignSize);//为NULL就需要向中心缓存申请 } } //当用户申请时thread没有,则向第二层申请 void* ThreadCache::FetchFromCentralCache(size_t index, size_t size) { //thread申请只会每次申请1个,但是central可以每次多给一些,就不用频繁申请了,所以这里采用慢启动调节分配个数 //1.慢启动调节 //(1)最开始不会一次向central 批给cache太多,可能会用不完 //(2)会从1个内存块开始递增给它(batchNum),每申请一次下次就会多一个,直到与算法算的数值持平,再改用算法给出的数值 //(3)相关算法给出的数值:要的字节数越大,给cache的内存块就越少,要的字节数越小,给cache批的内存块就越多 size_t batchNum = min(_freeList[index].MaxSize(), SizeClass::NumMoveSize(size)); if (batchNum == _freeList[index].MaxSize()) { _freeList[index].MaxSize() += 1; } //2.向第二层申请 //(1)参数设置:输出型参数,去第二层获取内存块组成链表的头和尾的地址 void* start = nullptr; void* end = nullptr; //(2)向第二层的哈希桶内获取批量内存块(参数:头部位置,结尾位置,申请的个数,申请的字节大小)(第二层函数接口) size_t actualNum = CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, size); //(3)根据返回值判断获取的个数,给的返回值(actualNum)不一定要多少就能拿到多少,例如要4个但只剩2个,就只能返还2个 assert(actualNum > 0); if (actualNum == 1) //假如就剩1个,直接给用户 { assert(start == end); return start; } else //超过一个则留下头一个,其他的批量头插到thread的哈希桶的自由链表里 { _freeList[index].PushRange(NextObj(start), end, actualNum - 1); return start; } }
示例,如图下图所示:
-
当用户申请了10字节的内存会发生什么?
(1)会先经过相关算法算出字节对齐数为16字节,并且算出桶号为1号桶
(2)会去1号桶拿内存,1号桶没有内存,则向第二层central cache申请,申请的个数会根据慢启动的算法算出本次向上申请多少个
(3)第二层返回第一层的个数只有一个则直发给用户,若返回多个,则把头一个发给用户,剩下的直接批量挂载到1号桶上
注意:向第二层申请的个数,不要一定全都能拿到,这里的申请只是期望申请这么多,但最少必须返回一个给用户
申请部分需要注意的是:
- 对齐数的算法与映射的算法必须要准确
- 拿到第二层给的内存块自由链表后,更新头指针的指向与末尾内存块的指向
释放部分
thread cache的释放流程:
- 用户释放后先算出释放的字节数对应的桶号,之后直接头插到对应的桶上
- 之后会检查该桶上挂载的内存个数
- 假如这个字节数的桶挂载的个数,大于向第二层批量申请的个数,则批量返回一部分,返回的个数就是批量申请的个数
- 释放部分在
class FreeList
的框架中需要添加两个函数
void Push(void* obj)
{
assert(obj);
//头插,用户释放后插入到thread cache对应的桶中
NextObj(obj) = _freeList;
_freeList = obj;
_size += 1;
}
//批量头删,用于还给第二层 central cache
void PopRange(void*& start,void*& end,size_t n)
{
start = _freeList;
end = start;
for (size_t i = 0; i < n - 1; i++)
end = NextObj(end);
_freeList = NextObj(end);
NextObj(end) = nullptr;
_size -= n;
}
- 具体执行部分,在
class ThreadCache
增加以下两个函数的声明
//用户释放
void ThreadCache::Deallocate(void* ptr, size_t size)
{
assert(ptr);
assert(size <= MAX_BYTES);
//1.计算释放回来的字节数对应的桶,并头插
size_t index = SizeClass::Index(size);
_freeList[index].Push(ptr);
//2.判断该桶挂载的内存块个数,是否达到归还第二层的个数标准
if (_freeList[index].Size() > _freeList[index].MaxSize())
{
ListTooLong(_freeList[index], size);
}
}
//释放给第二层
void ThreadCache::ListTooLong(FreeList& list, size_t size)
{
//1.参数设置:输出型参数
void* start = nullptr;
void* end = nullptr;
//2.批量头删:取MaxSize()个,并拿到取到的 整个自由链表的 头地址和尾地址
list.PopRange(start, end, list.MaxSize());
//3.释放给第二层 central cache(第二层的函数接口)
CentralCache::GetInstance()->ReleaseListToSpans(start, size);
}
释放部分需要注意的是,批量删除时:
- 设置好末尾内存块下一个指向为NULL
- 更新头指针的指向
第一层的测试
- 首先需要提供给用户的接口
/*********************ConcurrentAllocl.h*********************/
#pragma once
#include "Common.h" //存放FreeList框架的头文件
#include "ThreadCache.h" //存放ThreadCache的头文件
static void* ConcurrentAlloc(size_t size)
{
// 通过TLS 每个线程无锁的获取自己的专属的ThreadCache对象(只能获取一个,且是唯一的)
if (pTLSThreadCache == nullptr)
{
pTLSThreadCache = new ThreadCache;
}
cout << std::this_thread::get_id() << ":"<<pTLSThreadCache <<endl;
return pTLSThreadCache->Allocate(size);
}
static void ConcurrentFree(void* ptr, size_t size)
{
assert(pTLSThreadCache);
pTLSThreadCache->Deallocate(ptr, size);
}
- 将涉及到第二层的接口暂时写个空客将返回值设置为nullptr
void Alloc1()
{
//线程1,获取5次6字节
for (size_t i = 0; i < 5; i++)
{
void* ptr = ConcurrentAlloc(6);
}
}
void Alloc2()
{
//线程2,获取5次7字节
for (size_t i = 0; i < 5; i++)
{
void* ptr = ConcurrentAlloc(7);
}
}
void TLSTest()
{
std::thread t1(Alloc1);
t1.join();
cout << endl;
std::thread t2(Alloc2);
t2.join();
}
int main()
{
TLSTest();
return 0;
}
如图所示:所有线程都调用同一个申请函数ConcurrentAlloc()
,并且没有加锁,而且都申请了一个thread cache对象
所以这部分可以分解决争锁问题(释放暂时无法测试)
central cache的设计
这层的整体结构与第一层类似,也是哈希桶,并且映射规则和第一层是一样的,不过这层的桶的类型不是FreeList,而是SpanList,是个双链表,链表上每个结构是一个Span对象,Span中挂载的是内存块(单链表)
虽然这里是共享资源,但是可以采用桶锁,也就是只锁具体的一个桶,可以减少锁的竞争
申请部分
central cache的申请流程:
thread向central申请X个内存块,会先根据申请字节数算法算出桶号,之后会在这个桶里找一个非空的Span:
(1)找到一个非空Span,有足够的内存块,可以给thread
(2)找到一个非空Span,不够X个,会把剩余的全给用户(至少有一个够给用户的)
(3)没有不空的Span
-
前两种情况,Span会数够x个给thread层或者不够x个把剩余的给thread层
-
第三种情况:
-
central会向Page层申请一个Span,此时的Span至少带有一页大小的内存,页的首地址会以特殊的方式存在Span中
-
central拿到这个Span后会对整个页进行切成若干个内存块,内存块的大小是thread申请字节数的大小
-
最后在分给thread想要的个数
-
-
记录整页的地址一般是除以 一个页大小的字节数
为了效率起见,将线性地址空间分成若干大小相等的片,称为页( Page )。常见的页面大小为 4KB,每一页都有 4K 字节长,每一页的起始地址都能被 4K 整除。
我的开发环境一页大小是8k,一般系统在分配一页内存时,每页的起始地址都可以被8K整除,并且在这个页内的所有的地址除8K取整都是同一个数;
这样在回收内存块时,就可以找到对应的Span了(具体释放细节在后面介绍,这里只先了解记法)
-
central cache的哈希桶与thread cache用的是同一套映射规则
-
我的开发环境一页是8K,而central cache在向上层申请Span时,当申请很大的内存时例如256KB,那么一页肯定不够用。所以在申请时,需要计算出申请几页的内存
// 计算一次向系统获取几个页 //参数是由用户要申请的字节数传给thread后计算的对齐数,不清楚可以翻看thread申请的函数FetchFromCentralCache static const size_t PAGE_SHIFT = 13; //用于计算页号的,具体用法后面会介绍到 static size_t NumMovePage(size_t size) { size_t num = NumMoveSize(size); size_t npage = num*size; npage >>= PAGE_SHIFT; if (npage == 0) npage = 1; return npage; } //会返回计算出要申请页的个数
-
对象的框架搭建
/*********************Common.h*********************/ //根据系统位数不同来调整页号的数据类型 #ifdef _WIN64 typedef unsigned long long PAGE_ID; #elif _WIN32 typedef size_t PAGE_ID; #endif // 管理多个连续页大块内存跨度的结构(即管理一页或多个页的对象) struct Span { PAGE_ID _pageId = 0; // 大块内存起始页的页号,也就是页的首地址除以8K后的数字 size_t _n = 0; // 页的数量 Span* _next = nullptr; // 双向链表的结构——>尾指针 Span* _prev = nullptr; // 双向链表的结构——>头指针 size_t _useCount = 0; // 切好小块内存,被分配给thread cache的计数,每分走一个就+1 void* _freeList = nullptr; // 切好的小块内存的自由链表 }; class SpanList { public: SpanList() { _head = new Span; _head->_prev = _head; _head->_next = _head; } //指定位置的头插一个Span void Insert(Span* pos, Span* newSpan) { assert(pos && newSpan); Span* newSpan_prev = pos->_prev; newSpan->_next = pos; newSpan->_prev = newSpan_prev; newSpan_prev->_next = newSpan; pos->_prev = newSpan; } //指定位置的头删一个Spaan void Earse(Span* pos) { assert(pos); assert(pos != _head); Span* pos_next = pos->_next; Span* pos_prev = pos->_prev; pos_next->_prev = pos_prev; pos_prev->_next = pos_next; } //SpanList的第一个Span Span* Begin() { return _head->_next; } //SpanList的最后一个Span Span* End() { return _head; } //判断SpanList是否为 NULL bool Empty() { return _head == _head->_next; } //头插 void PushFront(Span* span) { Insert(Begin(),span); } //头删 Span* PopFront() { Span* Front = _head->_next; Earse(Front); return Front; } std::mutex _mtx; // 桶锁 private: Span* _head; //头指针 };
-
central cache框架搭建
central cache是所有线程共享的,所以只能有一个,这里就要用到单例模式
/*********************CentralCache.h*********************/ //单例模式——饿汉模式 class CentralCache { CentralCache(){} CentralCache(const CentralCache& cc) = delete; CentralCache& operator=(CentralCache cc) = delete; public: //提供获取_sInst的唯一接口 static CentralCache* GetInstance() { return &_sInst; } //期望批量从span获取内存块给thread size_t FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size); //获取一个非空的Span Span* GetOneSpan(SpanList& list,size_t byte_size); // 将一定数量的对象释放到span跨度 void ReleaseListToSpans(void* start, size_t byte_size); private: SpanList _spanList[NFREELIST]; static CentralCache _sInst; //初始化放在 CentralCache.cpp中,程序运行即初始化 };
central属于临界资源,需要加锁,但这里加锁加的是桶锁:不同的线程访问不同的桶,就对不同的桶加锁,这样可以减少锁的竞争,只有多个线程在同一个桶申请资源时才会争锁
-
具体执行部分
CentralCache CentralCache::_sInst; //从central获取内存块给thread //这里参数中的start end就是thread中调用FetchFromCentralCache函数时的start end size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size) { //1.算一下在central中的哈希桶号(数组下标) size_t index = SizeClass::Index(size); //2.指定index号桶 加锁 _spanList[index]._mtx.lock(); //3.获取index号桶中的非空span Span* span = GetOneSpan(_spanList[index],size); assert(span); assert(span->_freeList); //4.遍历这个span,数出需要多少个内存块 start = span->_freeList; end = start; //start此时指向的就是第一个,actualNum = 1 size_t actualNum = 1; //span中的内存块也可能达不到预期的个数,所以碰到nullptr也要终止遍历 while (actualNum < batchNum && NextObj(end) != nullptr) { end = NextObj(end); actualNum++; } //5.更新相关的属性 //(1)更新头指针的指向 //例如要2个,那么end的位置正好就在第二个位置,所以头指针指向的应该是end的下一个 span->_freeList = NextObj(end); //(2)更新end的next指向 NextObj(end) = nullptr; //(3)更新拿走多少个小块内存 span->_useCount += actualNum; //到这里,这个span已经删掉了actualNum个,采用的是在指定位置尾删 //6.解除index号桶的锁 _spanList[index]._mtx.unlock(); //返回给thread的个数 return actualNum; } //获取一个非空的Span Span* CentralCache::GetOneSpan(SpanList& list, size_t byte_size) { //1.先找到非空的Span //(1)从第一个开始判断,中途有非空的直接返回Span的地址,没有则继续往下找,直到全都没有 Span* it = list.Begin(); while (it != list.End()) { if (it->_freeList != nullptr) return it; else it = it->_next; } //(2)全都没有则向page申请,此时本线程转到page中,但是其他线程可能会释放需要进入这个桶,所以这里先解锁 list._mtx.unlock(); //2.循环结束没有找到,则向Page索要 //(1)page也属于共享资源所以也要加它的锁,page部分具体在后面介绍 PageCache::GetInstance()->_mtx.lock(); //(2)先根据这个字节数计算需要多少页,之后再申请 Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(byte_size)); //(3)解page的锁 PageCache::GetInstance()->_mtx.unlock(); //3.拿到Page给的Span,开始按申请的字节数(byte_size)切分 //(1)将此Span记录的页号乘8K,恢复到16位首地址的表示方式,之后强转为char*,方便切分 char* start = (char*)(span->_pageId << PAGE_SHIFT); //(2)将页的个数乘8k,计算一共有多少个8k,也就是所有页加一起的总字节数 size_t bytes = span->_n << PAGE_SHIFT; //(3)记录这个Span携带所有页总和的末尾地址 char* end = start + bytes; //(4)开始切分,先更新头指针指向 span->_freeList = start; start += byte_size; //(5)开始遍历,start经过byte_size个字节跳转到下一位置,tail的next指向start的位置,完成一次切分 void* tail = span->_freeList; //采用的是尾插法,这样可以让内存尽量连续,提高缓存利用率 while (start < end) { NextObj(tail) = start; tail = start;//tail = NextObj(tail); start += byte_size; } //(6)切分完成后,设置末尾的内存块的next指向NULL NextObj(tail) = nullptr; //4.向page申请内存的部分结束,重新去争锁,因为要访问桶,把刚获得的Span挂载到central的桶上 list._mtx.lock(); //5.把刚切完的内存块挂载到这个Span的头指针上 list.PushFront(span); //6.返回刚获得并切完的Span return span; }
当thread向central获取一些24B大小的内存块,central先去2号桶找一个非空的Span,没找到后向Page申请,申请流程示例如下:
申请部分需要注意的是:
- central是所有thread共享的资源,需要加锁,但是加的是桶锁,但是在向page申请Span并切分时可以暂时解锁,之后在加上
- central是所有thread共享的,只能有一个,所以需要使用单例模式
- 在处理内存块链表时,容易遗漏头指针、尾部指向等的更新
释放部分
central cache释放流程:
- thread中某一个桶所持有的内存块个数超过了批量申请的个数后,会以批量申请的数字 为释放数字 ,批量释放内存块给central
- central拿到批量的内存块后会先找对应的桶,找到对应的桶后再找对应的Span挂载到对应Span上
- 并让 useCount 减去相应的数量,当_useCount减为0时,将所这个Span释放回Page
-
根据返回的内存块查找对应的Span
内存号是页的首地址整除8K得到的,这一点在介绍central cache申请部分提到过:每一页的起始地址都能被8K整除,并且这一页内任意一个地址除8K取整的数字都是同一个数字(页号)
所以只要Span记录了页号和也的个数,碎内存块就能找到对应的Span,而Page在分给central一个Span之前不仅让它记住页号,还建立一个记录页号与Span的映射关系。这样就可以把central拿到的批量内存块,挂载到对应Span上了
- 不用关心内存碎块的顺序问题
只要这个Span内所有的内存都返回了(useCount=0),就证明不会有任何人正在使用Span内的内存,并且已经是用户释放了的
假如经Page回收,后面需求大了在向page申请时,又申请到这个页的内存,直接从首地址按地址顺序切分就行了,所以不用管内存块是否需要按序排序;
所以Span内,页号(首地址)、页的个数、页的大小等这些属性信息是很重要的
- 代码部分
void CentralCache::ReleaseListToSpans(void* start, size_t byte_size)
{
//1.算桶号
size_t index = SizeClass::Index(byte_size);
//2.加锁,只要访问到共享资源就需要加锁,而释放就是在Span中插入内存块,所以是访问共享资源,需要加锁
_spanList[index]._mtx.lock();
//3.因为返回的批量内存块,不可能都属于同一个Span,所以对每个内存块都查找一次
while (start)
{
//(1)记录next的位置,方便后续查找
void* next = NextObj(start);
//(2)对每个内存块,都查找对应的Span,此函数接口是page中的(根据地址获取对应的Span)
Span* span = PageCache::GetInstance()->MapObjectToSpan(start);
//(3)找到对应的Span后,头插
NextObj(start) = span->_freeList;
span->_freeList = start;
//(4)更新这个Span记录分走内存块的个数
span->_useCount--;
//(5)当一个span所有的内存块都回来后,将这个Span释放给Page
if (span->_useCount == 0)
{
_spanList[index].Earse(span); //删除这个Span
span->_next = nullptr; //清除前后指针记录的位置,防止发生越界访问等错误
span->_prev = nullptr; //......
span->_freeList = nullptr; //既然这个页的内存块都已回收,就可以不用头指针记录了,有页号就够了
//前面都已经清除这个Span在桶中的关联了,再向page释放Span时就可以暂时解锁了
_spanList[index]._mtx.unlock();
//page也是只有一个也是共享的,central在向page释放page时,是在访问page这个共享资源,所以需要加page相关的锁
PageCache::GetInstance()->_mtx.lock();
//page的函数接口,功能是把centraal释放的span插入到page相关的结构中
PageCache::GetInstance()->ReleaseSpanToPageCache(span);
//解page的锁,争central的桶锁
PageCache::GetInstance()->_mtx.unlock();
_spanList[index]._mtx.lock();
}
//4.当前内存块找完,准备找下一个
start = next;
}
//解锁
_spanList[index]._mtx.unlock();
}
释放部分需要注意的就是对锁的操控,其次就是不要遗漏更新Span的相关信息
Page Cache的设计
Page的哈希桶和前两层不一样,它只有128个桶,挂载的都是Span,桶号表示Span所携带的页数;每个Span都是一样大的对象,但每个Span中记录的属性是不一样的,例如SpanA中的页数为2,共16K内存,SpanB中的页数为1,共8K内存。
所以central申请的是一个Span,Span会把它携带的页切成central要的小块挂载在自己的链表上
申请部分
Page Cache的申请流程:
-
假如central向page申请2页的Span
-
page根据申请的页数,先去[1]号桶找Span可能会出现以下3种情况:
(1)[1]号桶中有Span可用,则记录页号与Span的映射后,将Span发送给central
(2)[1]号桶为NULL,则往后继续找不为NULL的桶,例如[2]号桶不为NULL,则把[2]号桶头一个Span切成,1页的Span和2页的Span,之后记录好他们的页号与Span的映射关系,1页的Span挂到[0]号桶上,2页的Span发给central
(3)一直到end都没有非空的桶,则向系统申请一个128页的Span,切分出2页Span和126页的Span,记录好它们各自的页号与Span的映射后,2页的Span给central,126页的Span挂载对应的桶上
如下图演示:
-
向系统申请的函数
static const size_t NPAGES = 129; static const size_t PAGE_SHIFT = 13; #ifdef _WIN32 #include <windows.h> #else // ... #endif inline static void* SystemAlloc(size_t kpage) { #ifdef _WIN32 void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); #else // linux下brk mmap等 #endif if (ptr == nullptr) throw std::bad_alloc(); return ptr; }
-
Page的对象框架
Page也属于共享资源,只有一份,所以也是单例模式
#pragma once #include "Common.h" class PageCache { PageCache(){} PageCache(const PageCache& pc) = delete; PageCache& operator=(PageCache pc) = delete; public: static PageCache* GetInstance() { return &_sInst; } //获取一个K页的span Span* NewSpan(size_t k); //获取小块内存与span的映射 Span* MapObjectToSpan(void* obj); // 释放空闲span回到Pagecache,并合并相邻的span void ReleaseSpanToPageCache(Span* span); std::mutex _mtx; private: static PageCache _sInst; SpanList _spanLists[NPAGES]; std::unordered_map<PAGE_ID, Span*> _idSpanMap; //记录页号与Span的映射关系 }; //static PageCache _sInst;的初始化放在Page.cpp
-
执行部分
#include "PaageCache.h" PageCache PageCache::_sInst; //获取一个 新span Span* PageCache::NewSpan(size_t k) { assert(k > 0); assert( k < NPAGES); //1.先判断申请页的所在的桶是否为NULL(在定义_spanList[]时是定义了129长的数组,所以K不用-1,直接对应桶号即可) if (!_spanLists[k].Empty()) { //(1)不为NULL,从桶里取一个(头删)Span Span* kSpan = _spanLists[k].PopFront(); //(2)假如这个Span携带3页,则循环三次,把每页的页号(每页的首地址)都与这个Span建立映射 //这里可以自行验证下,将地址换成页号后+1再乘8K,就跳过了一页的地址,对于页号的处理方法在central释放部分有介绍 for (PAGE_ID i = 0; i < kSpan->_n; i++) { _idSpanMap[kSpan->_pageId + i] = kSpan; } return kSpan; } //2.当对应的桶里没有Span则继续向后面的桶找 for (size_t i = k + 1; i < NPAGES; i++) { //查看第[i]个桶里是否非NULL if (!_spanLists[i].Empty()) { //(1)拿到[i]桶里的Span Span* nSpan = _spanLists[i].PopFront(); //(2)new一个新的空Span为切分做准备 Span* kSpan = new Span; //(3)给这个KSpan填写页号和页数(central想要的大小页) kSpan->_pageId = nSpan->_pageId; kSpan->_n = k; //(4)更新对nSpan切完后的页号、页数 nSpan->_pageId += k; nSpan->_n -= k; //(5)将切完后的Span插入到对应的桶中 _spanLists[nSpan->_n].PushFront(nSpan); //(6)记录切完后Span与页号的映射位置,这里不使用的Span只记录开头和结尾页号即可,方便后续合并 _idSpanMap[nSpan->_pageId] = nSpan; _idSpanMap[nSpan->_pageId + nSpan->_n - 1] = nSpan; //(7)将要发送给central的Span,建立好它携带页与Span的映射关系(这里和开头部分是一样的) for (PAGE_ID i = 0; i < kSpan->_n; i++) { _idSpanMap[kSpan->_pageId + i] = kSpan; } return kSpan; } } //3.当所有桶都没找到Span后,就向系统申请128页的内存 //(1)new一个空Span,用来接收这个128页的内存 Span* newSpan = new Span; //(2)向系统申请,NPAGES是129,要减一 void* ptr = SystemAlloc(NPAGES - 1); //(3)用首地址算出页号 newSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT; // ptr/8k //(4)记录页数 newSpan->_n = NPAGES - 1; //(5)插入到128页的桶中 _spanLists[newSpan->_n].PushFront(newSpan); //(6)递归再调用一次自己,需要把这128页的内存切出central想要的页 return NewSpan(k); } //通过内存块,找到对应的page_id,其实本质给central释放回Page时用的 Span* PageCache::MapObjectToSpan(void* obj) { //1.根据地址算出页号,一页内的任一地址除8K,CPU只取整,所以都会得到统一的页号 PAGE_ID id = (PAGE_ID)obj >> PAGE_SHIFT; //2.加锁,这里的锁是不管是if还是else都先加锁,当作用域退出后自动释放,可以减少冗余代码 //加锁的原因:这个unordered_map底层是哈希,别的线程在插入、删除时有可能会导致改变结构,所以要加锁 std::unique_lock<std::mutex> lock(_mtx); //3.查找对应的Span auto ret = _idSpanMap.find(id); //若找到了则返回这个Span的地址 if (ret != _idSpanMap.end()) { return ret->second; } else { //一般来说不可能找不到,因为在page发给central时就已经把span记录在map里了 assert(false); return nullptr; } }
申请流程如page申请部分开头图示那样所示
-
这里需要注意:
注意加锁事项,一般在central调用到page的函数时会加page的锁,也可以不在central调用时加 而是写在page申请函数的开头结尾
不要遗漏记录Span与页号的映射
page也是单例模式
释放部分
page释放的流程:
central释放函数中,每向page释放一次Span,page拿到Span后都会进行与其他Span的合并检查,本质主要是页合并,最终的会合并到超过128页后还给系统
-
先检查这个Span携带页的前面的页是否可以合并,若是可以合并,最多可以合并多少页,合并完前面的在去查后面的,然后进行合并
-
合并完后 再将这个合并完的Span插入到对应的桶中
-
当合并的页数超过128,则还给系统
//所以对未使用的Span只记录页开头和结尾映射的Span就够了:因为在分配页时是按序分配的,头删或尾删,没有从中间切页的,所以都是按序的整页,对未使用的页只记录开头和结尾的映射就行了
_idSpanMap[nSpan->_pageId] = nSpan;
_idSpanMap[nSpan->_pageId + nSpan->_n - 1] = nSpan;
-
注意事项:需要在Span添加一个记录使用状态的遍历
有可能线程A刚分出去一个Span,central刚拿到还未切分,线程B将这个Span给合并了,所以需要增加一个记录使用状态的信息
class Span { PAGE_ID _pageId = 0; // 大块内存起始页的页号,也就是页的首地址除以8K后的数字 size_t _n = 0; ...... bool _isUse = false; //记录使用状态,已分配出去为true }
同时在central申请Span代码中,加上更改使用状态的代码
//获取非空Span Span* CentralCache::GetOneSpan(SpanList& list, size_t byte_size) { ..... list._mtx.unlock(); PageCache::GetInstance()->_mtx.lock(); Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(byte_size)); span->_isUse = true; //在这里加上 ...... }
-
向系统释放的函数接口,一定是达到128页以后,才可以释放,因为向系统申请时是每次申请128页的
/***********common.h**************************/ inline static void SystemFree(void* ptr) { #ifdef _WIN32 VirtualFree(ptr, 0, MEM_RELEASE); #else // sbrk unmmap等 #endif }
-
代码部分
//合并 void PageCache::ReleaseSpanToPageCache(Span* span) { //1.还回来的Span带的页数超过128页则还给系统 if (span->_n > NPAGES - 1) { //(1)先将页号恢复成地址 void* ptr = (void*)(span->_pageId << PAGE_SHIFT); //(2)释放 SystemFree(ptr); //(3)删除这个span delete span; return; } //2.还回来的页数没超过128页,则先向前合并 while (true) { //(1)先获取前面一页的页号 PAGE_ID prevID = span->_pageId - 1; //通过页号查找span,判断这个Span是否存在 auto ret = _idSpanMap.find(prevID); if (ret == _idSpanMap.end()) break; //再判断是否是未使用状态 Span* prevspan = ret->second; if (prevspan->_isUse == true) break; //最后判断这两个Span携带的页数加一起是否超过128页 if (span->_n + prevspan->_n > NPAGES - 1) break; //经过前面的检查没问题就可以合并 //(2)更新首页页号 span->_pageId = prevspan->_pageId; //(3)更新页数 span->_n += prevspan->_n; //(4)删除被合并的Span以及在桶中的位置 _spanLists[prevspan->_n].Earse(prevspan); delete prevspan; } //3.向前合并完后向后合并,基本步骤都一样 while (true) { //向后合并先要找Span携带最后一页后面的页 PAGE_ID nextID = span->_pageId + span->_n; auto ret = _idSpanMap.find(nextID); if (ret == _idSpanMap.end()) break; Span* nextspan = ret->second; if (nextspan->_isUse == true) break; if (span->_n + nextspan->_n > NPAGES - 1) break; //因为是向后合并,Span的页号不需要变,只改变页数 span->_n += nextspan->_n; _spanLists[nextspan->_n].Earse(nextspan); delete nextspan; } //4.前后都合并完后,将Span插入到对应的桶中 _spanLists[span->_n].PushFront(span); //更新使用状态 span->_isUse = false; //重新记录映射关系 _idSpanMap[span->_pageId] = span; _idSpanMap[span->_pageId + span->_n - 1] = span; }
page的合并细节比较多:
(1)检查是否达到还给系统的标准
(2)内存池里的所有Span都有与页建立映射,要检查被合并的页是否存在这个内存池里
(3)检查被和合并页的Span,携带的页数,是否超过最大值
(4)合并完后要根据 向前还是向后合并,更新页号与页数,删除被合并页所在的Span,并去除被合并Span在桶中的位置
(5)对新合并出来的Span重新建立映射关系,在插入到对应桶中
这里的锁加在central调用这个函数的前后,也可以在函数里面加解
细节优化
大于256KB的申请
thread cache最大只能分配给用户256KB的内存,那么大于256的则直接向Page申请,再由Page向系统申请
- 对用户申请接口代码优化
static void* ConcurrentAlloc(size_t size)
{
if (size > MAX_BYTES)
{
//1.算对齐数(超出256KB的部分在RoundUp()最后的else中)
size_t alignSize = SizeClass::RoundUp(size);
//2.算页数
size_t kpage = alignSize >> PAGE_SHIFT;
//3.向page申请,由page new一个span再挂载内存
PageCache::GetInstance()->_mtx.lock();
Span* span = PageCache::GetInstance()->NewSpan(kpage);
PageCache::GetInstance()->_mtx.unlock();
//4.发给用户
void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
return ptr;
}
else//小于256KB的 申请
{
if (pTLS_threadCache == nullptr)
{
pTLS_threadCache = new ThreadCache;
}
return pTLS_threadCache->Allocate(size);
}
}
-
对page的newSpan函数更改
Span* PageCache::NewSpan(size_t k) { assert(k > 0); if (k > NPAGES - 1) { //1.向系统申请 void* ptr = SystemAlloc(k); //2.new一个空Span Span* span = new Span; //3.填写页号与页数 span->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT; span->_n = k; //4.记录好映射后发给用户 _idSpanMap[span->_pageId] = span; return span; } assert( k < NPAGES); if (!_spanLists[k].Empty()) ...... }
大于256KB的申请只是由Page代申请,并记录一下映射方便释放时查找,不会影响小于256KB部分的合并
-
释放部分
原来释放的接口需要用户传入对应的对齐数,太麻烦了,这里把 这个接口优化掉。
具体做法:在
class Span
中多加一个参数,用来记录对齐数,之后在分配给用户之前只要填上这个对齐数即可-
class Span部分
struct Span { PAGE_ID _pageId = 0; // 大块内存起始页的页号 size_t _n = 0; // 页的数量 ...... size_t _objsize = 0; };
-
添加对齐数,一共两处需要更改,(在central向page获取Span的函数中,用户申请超256KB处)
Span* CentralCache::GetOneSpan(SpanList& list, size_t byte_size) { ..... list._mtx.unlock(); PageCache::GetInstance()->_mtx.lock(); Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(byte_size)); span->_isUse = true; span->_objsize = byte_size; //在这里加上,byte_size就是thread算好的对齐数传上来的 ...... } static void* ConcurrentAlloc(size_t size) { if (size > MAX_BYTES) { ...... PageCache::GetInstance()->_mtx.lock(); Span* span = PageCache::GetInstance()->NewSpan(kpage); span->_objsize = alignSize;//在这里添加 ...... }
在用户释放时就可以像free一样只传首地址即可
static void ConcurrentFree(void* ptr) { //1.找到ptr所属Span Span* span = PageCache::GetInstance()->MapObjectToSpan(ptr); //2.获取它的对齐数 size_t size = span->_objsize; //大于256KB处理的部分 if (size > MAX_BYTES) { //传给Page,由Page直接还给系统 PageCache::GetInstance()->_mtx.lock(); PageCache::GetInstance()->ReleaseSpanToPageCache(span); PageCache::GetInstance()->_mtx.unlock(); } else//小于256KB处理的部分 { assert(pTLS_threadCache); pTLS_threadCache->Deallocate(ptr, size); } }
-
使用定长内存池代替new Span
在这个内存池中,需要频繁的new Span 、deltet Span,所以对于这部分可以使用定长内存池替代
具体步骤:
-
在Page的框架中添加定长内存池的成员变量
class PageCache { public: ...... private: static PageCache _sInst; SpanList _spanLists[NPAGES]; std::unordered_map<PAGE_ID, Span*> _idSpanMap; ObjectPool<Span> _spanPool;//定长内存池成员变量 };
-
替换所有new、delete的部分(基本集中在page部分)
Span* PageCache::NewSpan(size_t k)函数中有三处new、 void PageCache::ReleaseSpanToPageCache(Span* span)函数中有三处delete
-
在每个用户申请thread时,也会用到new,所以还要添加一个 thread的定长内存池
else { // 通过TLS 每个线程无锁的获取自己的专属的ThreadCache对象 if (pTLSThreadCache == nullptr) { static ObjectPool<ThreadCache> tcPool; //设置为静态,保证每个线程只申请一次 //pTLSThreadCache = new ThreadCache; pTLSThreadCache = tcPool.New(); } return pTLSThreadCache->Allocate(size); } }
整体代码
- 用户接口( ConcurrentAllocl.h)
#pragma once
#include "Common.h"
#include "thread_cache.h"
#include "PaageCache.h"
#include "ObjectPool.h"
static void* ConcurrentAlloc(size_t size)
{
if (size > MAX_BYTES)
{
//1.算对齐数(超出256KB的部分在RoundUp()最后的else中)
size_t alignSize = SizeClass::RoundUp(size);
//2.算页数
size_t kpage = alignSize >> PAGE_SHIFT;
//3.向page申请,由page new一个span再挂载内存
PageCache::GetInstance()->_mtx.lock();
Span* span = PageCache::GetInstance()->NewSpan(kpage);
PageCache::GetInstance()->_mtx.unlock();
//4.发给用户
void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
return ptr;
}
else//小于256KB的 申请
{
if (pTLS_threadCache == nullptr)
{
static ObjectPool<ThreadCache> tcPool; //设置为静态,保证每个线程只申请一次
//pTLS_threadCache = new ThreadCache;
pTLS_threadCache = tcPool.New();
}
return pTLS_threadCache->Allocate(size);
}
}
static void ConcurrentFree(void* ptr)
{
//1.找到ptr所属Span
Span* span = PageCache::GetInstance()->MapObjectToSpan(ptr);
//2.获取它的对齐数
size_t size = span->_objsize;
//大于256KB处理的部分
if (size > MAX_BYTES)
{
//传给Page,由Page直接还给系统
PageCache::GetInstance()->_mtx.lock();
PageCache::GetInstance()->ReleaseSpanToPageCache(span);
PageCache::GetInstance()->_mtx.unlock();
}
else//小于256KB处理的部分
{
assert(pTLS_threadCache);
pTLS_threadCache->Deallocate(ptr, size);
}
}
-
Common.h
/*********************Common.h*********************/ #pragma once #include <iostream> #include <vector> #include <time.h> #include <assert.h> #include <thread> #include <algorithm> #include <mutex> #include <unordered_map> #include <atomic> #ifdef _WIN64 typedef unsigned long long PAGE_ID; #elif _WIN32 typedef size_t PAGE_ID; #endif using std::cout; using std::endl; using std::bad_alloc; static const size_t MAX_BYTES = 256 * 1024; static const size_t NFREELIST = 208; static const size_t NPAGES = 129; static const size_t PAGE_SHIFT = 13; #ifdef _WIN32 #include <windows.h> #else // ... #endif inline static void* SystemAlloc(size_t kpage) { #ifdef _WIN32 void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); #else // linux下brk mmap等 #endif if (ptr == nullptr) throw std::bad_alloc(); return ptr; } inline static void SystemFree(void* ptr) { #ifdef _WIN32 VirtualFree(ptr, 0, MEM_RELEASE); #else // sbrk unmmap等 #endif } static void*& NextObj(void* obj) { return *(void**)obj; } //管理thread_ache中切分好的小块对象(内存),方便挂载到哈希桶 class FreeList { public: void Push(void* obj) { assert(obj); //头插 NextObj(obj) = _freeList; _freeList = obj; _size += 1; } void* Pop() { assert(_freeList); //头删 void* obj = _freeList; _freeList = NextObj(_freeList); _size -= 1; return obj; } bool Empty() { return _freeList == nullptr; } size_t& MaxSize() { return _maxSize; } void PushRange(void* start,void* end,size_t n) { NextObj(end) = _freeList; _freeList = start; _size += n; } void PopRange(void*& start,void*& end,size_t n) { start = _freeList; end = start; for (size_t i = 0; i < n - 1; i++) end = NextObj(end); _freeList = NextObj(end); NextObj(end) = nullptr; _size -= n; } size_t Size() { return _size; } private: void* _freeList = nullptr;//头指针 size_t _maxSize = 1; size_t _size = 0; //记录每个链表上的内存块个数,用于释放判断 }; // 管理多个连续页大块内存跨度结构 struct Span { PAGE_ID _pageId = 0; // 大块内存起始页的页号 size_t _n = 0; // 页的数量 Span* _next = nullptr; // 双向链表的结构——>尾指针 Span* _prev = nullptr; // 双向链表的结构——>头指针 size_t _useCount = 0; // 切好小块内存,被分配给thread cache的计数,每分走一个就+1 void* _freeList = nullptr; // 切好的小块内存的自由链表 bool _isUse = false; //记录使用状态,用于合并 size_t _objsize = 0; //记录对齐数,方便用户释放接口 }; class SpanList { public: SpanList() { _head = new Span; _head->_prev = _head; _head->_next = _head; } //指定位置的头插一个Span void Insert(Span* pos, Span* newSpan) { assert(pos && newSpan); Span* newSpan_prev = pos->_prev; newSpan->_next = pos; newSpan->_prev = newSpan_prev; newSpan_prev->_next = newSpan; pos->_prev = newSpan; } //指定位置的头删一个Spaan void Earse(Span* pos) { assert(pos); assert(pos != _head); Span* pos_next = pos->_next; Span* pos_prev = pos->_prev; pos_next->_prev = pos_prev; pos_prev->_next = pos_next; } //SpanList的第一个Span Span* Begin() { return _head->_next; } //SpanList的最后一个Span Span* End() { return _head; } //判断SpanList是否为 NULL bool Empty() { return _head == _head->_next; } //头插 void PushFront(Span* span) { Insert(Begin(),span); } //头删 Span* PopFront() { Span* Front = _head->_next; Earse(Front); return Front; } std::mutex _mtx; // 桶锁 private: Span* _head; //头指针 }; //计算对齐数、下标、thread向central获取的内存块、centraal向page获取的页数 class SizeClass { // 整体控制在最多10%左右的内碎片浪费 // [1,128] 8byte对齐 freelist[0,16) ------16个桶 // [128+1,1024] 16byte对齐 freelist[16,72) ------56个桶 // [1024+1,8*1024] 128byte对齐 freelist[72,128) ------56个桶 // [8*1024+1,64*1024] 1024byte对齐 freelist[128,184) ------56个桶 // [64*1024+1,256*1024] 8*1024byte对齐 freelist[184,208) ------24个桶 //例如129对齐是144,浪费15个字节,15/144 = 0.104... //前1-56浪费率会高,但是前几个对齐的字节数小,可以忽略 //计算对齐数,例如9字节,对齐数就是16 /*方法1: inline static size_t _RoundUp(size_t size,size_t alignNum) { size_t alignSize; if (size % alignNum != 0) { alignSize = (size / alignNum + 1) * alignNum;// 9 % 8=1 + 1 =2 * 8 =16 } else { alignSize = alignNum; } return alignSize; }*/ //方法2: inline static size_t _RoundUp(size_t bytes, size_t alignNum) { return ((bytes + alignNum - 1) & ~(alignNum - 1)); } //计算哈希桶号,也就是哈希映射数组的下标 /*方法1: size_t _Index(size_t bytes, size_t alignNum) { if (bytes % alignNum == 0) { return bytes / alignNum - 1; } else { return bytes / alignNum; } }*/ //方法2: static inline size_t _Index(size_t bytes, size_t align_shift) //9,3 { return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1; //9 + (1 << 3) - 1 = 8 - 1 + 9 = 16 } //16 >> 3 - 1 = 1 public: //计算thred_cache用户申请字节的对齐数 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 { return _RoundUp(size, 1 << PAGE_SHIFT); } } //计算哈希桶的哪一个桶(映射的数组下标所挂载的自由链表) 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) { //bytes - 128:当前区间的字节数 - 上一区间的总字节数 return _Index(bytes - 128, 4) + group_array[0]; //group_array[0]:1~128区间的总桶数量 } else if (bytes <= 8 * 1024) { return _Index(bytes - 1024, 7) + group_array[1] + group_array[0]; //group_array[1] + group_array[0] = 1~1024间的总桶数 } 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; } } // 每一次thread cache要从中心缓存获取多少个内存块 static size_t NumMoveSize(size_t size) { assert(size > 0); // [2, 512],一次批量移动多少个对象的(慢启动)上限值 // 小对象一次批量上限高 // 小对象一次批量上限低 int num = MAX_BYTES / size; if (num < 2) num = 2; if (num > 512) num = 512; return num; } // 计算一次向系统获取几个页 // 单个对象 8byte // ... // 单个对象 256KB static size_t NumMovePage(size_t size) { size_t num = NumMoveSize(size); size_t npage = num*size; npage >>= PAGE_SHIFT; if (npage == 0) npage = 1; return npage; } private: };
-
thread cache .h/.cpp
#pragma once #include "Common.h" class ThreadCache { public: //申请 void* Allocate(size_t size); //释放 void Deallocate(void* ptr , size_t size); //从第二层,中心缓存获取 void* FetchFromCentralCache(size_t index, size_t size); //用户释放时,单个桶过长(释放的超过批量申请的),则释放一些给central_cache void ListTooLong(FreeList& list, size_t size); private: FreeList _freeList[NFREELIST];//数组长208,也就是有208个桶,即挂载208个自由链表 }; //每个线程各自持有一个 static _declspec(thread) ThreadCache* pTLS_threadCache = nullptr; /* 线程局部存储(TLS),是一种变量存储的方法,这个变量在它所在线程内是全局可访问的,但不能被其他线程访问到 */ /*************************thread.cpp*****************/ #include "thread_cache.h" #include "CentraalCache.h" //用户申请 void* ThreadCache::Allocate(size_t size) { assert(size <= MAX_BYTES); //1.计算用户申请的字节对应的对齐数 size_t allignSize = SizeClass::RoundUp(size); //2.计算用户要到哪个桶取内存块(数组映射的下标中挂载的链表取内存块) size_t index = SizeClass::Index(size); //判断index下标的FreeList中的头指针是否为NULL if (!_freeList[index].Empty()) { return _freeList[index].Pop(); //不为NULL就可以Pop给用户了 } else { return FetchFromCentralCache(index,allignSize);//为NULL就需要向中心缓存申请 } } //当用户申请时thread没有,则向第二层申请 void* ThreadCache::FetchFromCentralCache(size_t index, size_t size) { //thread申请只会每次申请1个,但是central可以每次多给一些,就不用频繁申请了,所以这里采用慢启动调节分配个数 //1.慢启动调节 //(1)最开始不会一次向central 批给cache太多,可能会用不完 //(2)会从1个内存块开始递增给它(batchNum),每申请一次下次就会多一个,直到与算法算的数值持平,再改用算法给出的数值 //(3)相关算法给出的数值:要的字节数越大,给cache的内存块就越少,要的字节数越小,给cache批的内存块就越多 size_t batchNum = min(_freeList[index].MaxSize(), SizeClass::NumMoveSize(size)); if (batchNum == _freeList[index].MaxSize()) { _freeList[index].MaxSize() += 1; } //2.向第二层申请 //(1)参数设置:输出型参数,去第二层获取内存块组成链表的头和尾的地址 void* start = nullptr; void* end = nullptr; //(2)向第二层的哈希桶内获取批量内存块(参数:头部位置,结尾位置,申请的个数,申请的字节大小)(第二层函数接口) size_t actualNum = CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, size); //(3)根据返回值判断获取的个数,给的返回值(actualNum)不一定要多少就能拿到多少,例如要4个但只剩2个,就只能返还2个 assert(actualNum > 0); if (actualNum == 1) //假如就剩1个,直接给用户 { assert(start == end); return start; } else //超过一个则留下头一个,其他的批量头插到thread的哈希桶的自由链表里 { _freeList[index].PushRange(NextObj(start), end, actualNum - 1); return start; } } //用户释放 void ThreadCache::Deallocate(void* ptr, size_t size) { assert(ptr); assert(size <= MAX_BYTES); //1.计算释放回来的字节数对应的桶,并头插 size_t index = SizeClass::Index(size); _freeList[index].Push(ptr); //2.判断该桶挂载的内存块个数,是否达到归还第二层的个数标准 if (_freeList[index].Size() > _freeList[index].MaxSize()) { ListTooLong(_freeList[index], size); } } //释放给第二层 void ThreadCache::ListTooLong(FreeList& list, size_t size) { //1.参数设置:输出型参数 void* start = nullptr; void* end = nullptr; //2.批量头删:取MaxSize()个,并拿到取到的 整个自由链表的 头地址和尾地址 list.PopRange(start, end, list.MaxSize()); //3.释放给第二层 central cache(第二层的函数接口) CentralCache::GetInstance()->ReleaseListToSpans(start, size); }
-
central cache.h/.cpp
#pragma once #include "Common.h" //单例模式——饿汉模式 class CentralCache { CentralCache(){} CentralCache(const CentralCache& cc) = delete; CentralCache& operator=(CentralCache cc) = delete; public: static CentralCache* GetInstance() { return &_sInst; } //期望批量从span获取内存块给thread size_t FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size); //获取一个非空的Span Span* GetOneSpan(SpanList& list,size_t byte_size); // 将一定数量的对象释放到span跨度 void ReleaseListToSpans(void* start, size_t byte_size); private: SpanList _spanList[NFREELIST]; static CentralCache _sInst; }; /*************************central.cpp*****************/ #include "CentraalCache.h" #include "Common.h" #include "PaageCache.h" CentralCache CentralCache::_sInst; //从central获取内存块给thread //这里参数中的start end就是thread中调用FetchFromCentralCache函数时的start end size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size) { //1.算一下在central中的哈希桶号(数组下标) size_t index = SizeClass::Index(size); //2.指定index号桶 加锁 _spanList[index]._mtx.lock(); //3.获取index号桶中的非空span Span* span = GetOneSpan(_spanList[index],size); assert(span); assert(span->_freeList); //4.遍历这个span,数出需要多少个内存块 start = span->_freeList; end = start; //start此时指向的就是第一个,actualNum = 1 size_t actualNum = 1; //span中的内存块也可能达不到预期的个数,所以碰到nullptr也要终止遍历 while (actualNum < batchNum && NextObj(end) != nullptr) { end = NextObj(end); actualNum++; } //5.更新相关的属性 //(1)更新头指针的指向 //例如要2个,那么end的位置正好就在第二个位置,所以头指针指向的应该是end的下一个 span->_freeList = NextObj(end); //(2)更新end的next指向 NextObj(end) = nullptr; //(3)更新拿走多少个小块内存 span->_useCount += actualNum; //到这里,这个span已经删掉了actualNum个,采用的是在指定位置尾删 //6.解除index号桶的锁 _spanList[index]._mtx.unlock(); //返回给thread的个数 return actualNum; } //获取一个非空的Span Span* CentralCache::GetOneSpan(SpanList& list, size_t byte_size) { //1.先找到非空的Span //(1)从第一个开始判断,中途有非空的直接返回Span的地址,没有则继续往下找,直到全都没有 Span* it = list.Begin(); while (it != list.End()) { if (it->_freeList != nullptr) return it; else it = it->_next; } //(2)全都没有则向page申请,此时本线程转到page中,但是其他线程可能会释放需要进入这个桶,所以这里先解锁 list._mtx.unlock(); //2.循环结束没有找到,则向Page索要 //(1)page也属于共享资源所以也要加它的锁,page部分具体在后面介绍 PageCache::GetInstance()->_mtx.lock(); //(2)先根据这个字节数计算需要多少页,之后再申请 Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(byte_size)); span->_isUse = true; //已使用状态 span->_objsize = byte_size;//此Span要切分内存块的的对齐数 //(3)解page的锁 PageCache::GetInstance()->_mtx.unlock(); //3.拿到Page给的Span,开始按申请的字节数(byte_size)切分 //(1)将此Span记录的页号乘8K,恢复到16位首地址的表示方式,之后强转为char*,方便切分 char* start = (char*)(span->_pageId << PAGE_SHIFT); //(2)将页的个数乘8k,计算一共有多少个8k,也就是所有页加一起的总字节数 size_t bytes = span->_n << PAGE_SHIFT; //(3)记录这个Span携带所有页总和的末尾地址 char* end = start + bytes; //(4)开始切分,先更新头指针指向 span->_freeList = start; start += byte_size; //(5)开始遍历,start经过byte_size个字节跳转到下一位置,tail的next指向start的位置,完成一次切分 void* tail = span->_freeList; //采用的是尾插法,这样可以让内存尽量连续,提高缓存利用率 while (start < end) { NextObj(tail) = start; tail = start;//tail = NextObj(tail); start += byte_size; } //(6)切分完成后,设置末尾的内存块的next指向NULL NextObj(tail) = nullptr; //4.向page申请内存的部分结束,重新去争锁,因为要访问桶,把刚获得的Span挂载到central的桶上 list._mtx.lock(); //5.把刚切完的内存块挂载到这个Span的头指针上 list.PushFront(span); //6.返回刚获得并切完的Span return span; } void CentralCache::ReleaseListToSpans(void* start, size_t byte_size) { //1.算桶号 size_t index = SizeClass::Index(byte_size); //2.加锁,只要访问到共享资源就需要加锁,而释放就是在Span中插入内存块,所以是访问共享资源,需要加锁 _spanList[index]._mtx.lock(); //3.因为返回的批量内存块,不可能都属于同一个Span,所以对每个内存块都查找一次 while (start) { //(1)记录next的位置,方便后续查找 void* next = NextObj(start); //(2)对每个内存块,都查找对应的Span,此函数接口是page中的(根据地址获取对应的Span) Span* span = PageCache::GetInstance()->MapObjectToSpan(start); //(3)找到对应的Span后,头插 NextObj(start) = span->_freeList; span->_freeList = start; //(4)更新这个Span记录分走内存块的个数 span->_useCount--; //(5)当一个span所有的内存块都回来后,将这个Span释放给Page if (span->_useCount == 0) { _spanList[index].Earse(span); //删除这个Span span->_next = nullptr; //清除前后指针记录的位置,防止发生越界访问等错误 span->_prev = nullptr; //...... span->_freeList = nullptr; //既然这个页的内存块都已回收,就可以不用头指针记录了,有页号就够了 //前面都已经清除这个Span在桶中的关联了,再向page释放Span时就可以暂时解锁了 _spanList[index]._mtx.unlock(); //page也是只有一个也是共享的,central在向page释放page时,是在访问page这个共享资源,所以需要加page相关的锁 PageCache::GetInstance()->_mtx.lock(); //page的函数接口,功能是把centraal释放的span插入到page相关的结构中 PageCache::GetInstance()->ReleaseSpanToPageCache(span); //解page的锁,争central的桶锁 PageCache::GetInstance()->_mtx.unlock(); _spanList[index]._mtx.lock(); } //4.当前内存块找完,准备找下一个 start = next; } //解锁 _spanList[index]._mtx.unlock(); }
-
page cache .h/.cpp
#pragma once #include "Common.h" #include "ObjectPool.h" class PageCache { PageCache(){} PageCache(const PageCache& pc) = delete; PageCache& operator=(PageCache pc) = delete; public: static PageCache* GetInstance() { return &_sInst; } //获取一个K页的span Span* NewSpan(size_t k); //获取小块内存与span的映射 Span* MapObjectToSpan(void* obj); // 释放空闲span回到Pagecache,并合并相邻的span void ReleaseSpanToPageCache(Span* span); std::mutex _mtx; private: static PageCache _sInst; SpanList _spanLists[NPAGES]; ObjectPool<Span> _spanPool; std::unordered_map<PAGE_ID, Span*> _idSpanMap; }; /************************page.cpp************************/ #include "PageCache.h" PageCache PageCache::_sInst; //获取一个 新span Span* PageCache::NewSpan(size_t k) { assert(k > 0); if (k > NPAGES - 1) { //1.向系统申请 void* ptr = SystemAlloc(k); //2.new一个空Span //Span* span = new Span; Span* span = _spanPool.New(); //3.填写页号与页数 span->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT; span->_n = k; //4.记录好映射后发给用户 _idSpanMap[span->_pageId] = span; return span; } assert( k < NPAGES); //1.先判断申请页的所在的桶是否为NULL(在定义_spanList[]时是定义了129长的数组,所以K不用-1,直接对应桶号即可) if (!_spanLists[k].Empty()) { //(1)不为NULL,从桶里取一个(头删)Span Span* kSpan = _spanLists[k].PopFront(); //(2)假如这个Span携带3页,则循环三次,把每页的页号(每页的首地址)都与这个Span建立映射 //这里可以自行验证下,将地址换成页号后+1再乘8K,就跳过了一页的地址,对于页号的处理方法在central释放部分有介绍 for (PAGE_ID i = 0; i < kSpan->_n; i++) { _idSpanMap[kSpan->_pageId + i] = kSpan; } return kSpan; } //2.当对应的桶里没有Span则继续向后面的桶找 for (size_t i = k + 1; i < NPAGES; i++) { //查看第[i]个桶里是否非NULL if (!_spanLists[i].Empty()) { //(1)拿到[i]桶里的Span Span* nSpan = _spanLists[i].PopFront(); //(2)new一个新的空Span为切分做准备 //Span* kSpan = new Span; Span* KSpan = _spanPool.New(); //(3)给这个KSpan填写页号和页数(central想要的大小页) kSpan->_pageId = nSpan->_pageId; kSpan->_n = k; //(4)更新对nSpan切完后的页号、页数 nSpan->_pageId += k; nSpan->_n -= k; //(5)将切完后的Span插入到对应的桶中 _spanLists[nSpan->_n].PushFront(nSpan); //(6)记录切完后Span与页号的映射位置,这里不使用的Span只记录开头和结尾页号即可,方便后续合并 _idSpanMap[nSpan->_pageId] = nSpan; _idSpanMap[nSpan->_pageId + nSpan->_n - 1] = nSpan; //(7)将要发送给central的Span,建立好它携带页与Span的映射关系(这里和开头部分是一样的) for (PAGE_ID i = 0; i < kSpan->_n; i++) { _idSpanMap[kSpan->_pageId + i] = kSpan; } return kSpan; } } //3.当所有桶都没找到Span后,就向系统申请128页的内存 //(1)new一个空Span,用来接收这个128页的内存 //Span* newSpan = new Span; Span* newSpan = _spanPool.New(); //(2)向系统申请,NPAGES是129,要减一 void* ptr = SystemAlloc(NPAGES - 1); //(3)用首地址算出页号 newSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT; // ptr/8k //(4)记录页数 newSpan->_n = NPAGES - 1; //(5)插入到128页的桶中 _spanLists[newSpan->_n].PushFront(newSpan); //(6)递归再调用一次自己,需要把这128页的内存切出central想要的页 return NewSpan(k); } //通过内存块,找到对应的page_id,其实本质给central释放回Page时用的 Span* PageCache::MapObjectToSpan(void* obj) { //1.根据地址算出页号,一页内的任一地址除8K,CPU只取整,所以都会得到统一的页号 PAGE_ID id = (PAGE_ID)obj >> PAGE_SHIFT; //2.加锁,这里的锁是不管是if还是else都先加锁,当作用域退出后自动释放,可以减少冗余代码 //加锁的原因:这个unordered_map底层是哈希,别的线程在插入、删除时有可能会导致改变结构,所以要加锁 std::unique_lock<std::mutex> lock(_mtx); //3.查找对应的Span auto ret = _idSpanMap.find(id); //若找到了则返回这个Span的地址 if (ret != _idSpanMap.end()) { return ret->second; } else { //一般来说不可能找不到,因为在page发给central时就已经把span记录在map里了 assert(false); return nullptr; } } //合并 void PageCache::ReleaseSpanToPageCache(Span* span) { //1.还回来的Span带的页数超过128页则还给系统 if (span->_n > NPAGES - 1) { //(1)先将页号恢复成地址 void* ptr = (void*)(span->_pageId << PAGE_SHIFT); //(2)释放 SystemFree(ptr); //(3)删除这个span //delete span; _spanPool.Delete(span); return; } //2.还回来的页数没超过128页,则先向前合并 while (true) { //(1)先获取前面一页的页号 PAGE_ID prevID = span->_pageId - 1; //通过页号查找span,判断这个Span是否存在 auto ret = _idSpanMap.find(prevID); if (ret == _idSpanMap.end()) break; //再判断是否是未使用状态 Span* prevspan = ret->second; if (prevspan->_isUse == true) break; //最后判断这两个Span携带的页数加一起是否超过128页 if (span->_n + prevspan->_n > NPAGES - 1) break; //经过前面的检查没问题就可以合并 //(2)更新首页页号 span->_pageId = prevspan->_pageId; //(3)更新页数 span->_n += prevspan->_n; //(4)删除被合并的Span以及在桶中的位置 _spanLists[prevspan->_n].Earse(prevspan); //delete prevspan; _spanPool.Delete(prevspan); } //3.向前合并完后向后合并,基本步骤都一样 while (true) { //向后合并先要找Span携带最后一页后面的页 PAGE_ID nextID = span->_pageId + span->_n; auto ret = _idSpanMap.find(nextID); if (ret == _idSpanMap.end()) break; Span* nextspan = ret->second; if (nextspan->_isUse == true) break; if (span->_n + nextspan->_n > NPAGES - 1) break; //因为是向后合并,Span的页号不需要变,只改变页数 span->_n += nextspan->_n; _spanLists[nextspan->_n].Earse(nextspan); //delete nextspan; _spanPool.Delete(nextspan); } //4.前后都合并完后,将Span插入到对应的桶中 _spanLists[span->_n].PushFront(span); //更新使用状态 span->_isUse = false; //重新记录映射关系 _idSpanMap[span->_pageId] = span; _idSpanMap[span->_pageId + span->_n - 1] = span; }
-
定长内存池(ObjectPool.h)
//合并 void PageCache::ReleaseSpanToPageCache(Span* span) { //1.还回来的Span带的页数超过128页则还给系统 if (span->_n > NPAGES - 1) { //(1)先将页号恢复成地址 void* ptr = (void*)(span->_pageId << PAGE_SHIFT); //(2)释放 SystemFree(ptr); //(3)删除这个span delete span; return; } //2.还回来的页数没超过128页,则先向前合并 while (true) { //(1)先获取前面一页的页号 PAGE_ID prevID = span->_pageId - 1; //通过页号查找span,判断这个Span是否存在 auto ret = _idSpanMap.find(prevID); if (ret == _idSpanMap.end()) break; //再判断是否是未使用状态 Span* prevspan = ret->second; if (prevspan->_isUse == true) break; //最后判断这两个Span携带的页数加一起是否超过128页 if (span->_n + prevspan->_n > NPAGES - 1) break; //经过前面的检查没问题就可以合并 //(2)更新首页页号 span->_pageId = prevspan->_pageId; //(3)更新页数 span->_n += prevspan->_n; //(4)删除被合并的Span以及在桶中的位置 _spanLists[prevspan->_n].Earse(prevspan); delete prevspan; } //3.向前合并完后向后合并,基本步骤都一样 while (true) { //向后合并先要找Span携带最后一页后面的页 PAGE_ID nextID = span->_pageId + span->_n; auto ret = _idSpanMap.find(nextID); if (ret == _idSpanMap.end()) break; Span* nextspan = ret->second; if (nextspan->_isUse == true) break; if (span->_n + nextspan->_n > NPAGES - 1) break; //因为是向后合并,Span的页号不需要变,只改变页数 span->_n += nextspan->_n; _spanLists[nextspan->_n].Earse(nextspan); delete nextspan; } //4.前后都合并完后,将Span插入到对应的桶中 _spanLists[span->_n].PushFront(span); //更新使用状态 span->_isUse = false; //重新记录映射关系 _idSpanMap[span->_pageId] = span; _idSpanMap[span->_pageId + span->_n - 1] = span; }
性能测试及优化
性能测试
测试代码如下:
分别对内存池与malloc测试
#include"ConcurrentAllocl.h"
// ntimes 一轮申请和释放内存的次数
// rounds 轮次
void BenchmarkMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
std::vector<std::thread> vthread(nworks);
std::atomic<size_t> malloc_costtime = 0;
std::atomic<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));
//v.push_back(malloc((16 + i) % 8192 + 1));
}
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);
std::atomic<size_t> malloc_costtime = 0;
std::atomic<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));
v.push_back(ConcurrentAlloc((16 + i) % 8192 + 1));
}
size_t end1 = clock();
size_t begin2 = clock();
for (size_t i = 0; i < ntimes; i++)
{
ConcurrentFree(v[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轮次,每轮次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()
{
size_t n = 1000;
cout << "==========================================================" << endl;
BenchmarkConcurrentMalloc(n, 4, 10);
cout << endl << endl;
BenchmarkMalloc(n, 4, 10);
cout << "==========================================================" << endl;
return 0;
}
测试结果对比发现,内存池实际效率比malloc慢很多,通过vs自带的性能检测工具查看:
由上图可知,对于锁的竞争才是最消耗性能的,且基本都是page的锁
优化
由于 unordered_map的底层是哈希,当达到负载因子后会变换结构,所以可以 用其他结构替换掉它;
因此,这里参考tcmalloc提供的基数树来进行性能的优化。32位下可以采用一层基数树或两层基数树,64位下必须用三层,下面以32位为例介绍:
-
基于一层的基数树,是直接定值法,32位的地址空间可分配的总页数是:2^32 / 2^13 = 2^19 (524288)个页,所以定义一个长为2^19的数组,数组的类型是指针,用来存Span。
template <int BITS>//BITS表示需要多少bit位,32位下需要19位 class TCMalloc_PageMap1 { private: static const int LENGTH = 1 << BITS;//页的数目,数组长度 void** array_;//存储映射关系的数组
本质还是哈希,只不过是直接定值,做了最坏的打算,映射所有页号与Span,总大小:2^19 * 4B = 2M
-
基于两层的基数树,分两次映射,可以理解为高为2的多差树
第一层一共需要32个槽位,也就是2^5,每一个槽位都映射着一个第二层的指针数组,说明第二层一共有32个数组
第二层最大有32个数组,每个数组是一个2^14的指针数组,分配规则如下图:
本质来说,第一层是个索引,用来找到第二层的数组;页号的14~18位是用来找到Span在第二层的哪个数组的钥匙,页号的0 ~ 13位是找到第二层数组映射Span的钥匙
两层比一层的优势:
- 一层基数树必须上来就开好所有内存,而两层基数树,是分批建立映射的,当第二层的数组记满后才会开辟下一个
- 当二层最坏的情况与一层一样大,都是2M大小,(一层2^5 * 2^14 = 2^19,二层是指针数组,32位占4字节,最后还是2M)
template <int BITS> class TCMalloc_PageMap2 { private: // Put 32 entries in the root and (2^BITS)/32 entries in each leaf. static const int ROOT_BITS = 5; // 第一层对应页号的前5个比特位 static const int ROOT_LENGTH = 1 << ROOT_BITS; // 第一层存储元素的个数 (32) static const int LEAF_BITS = BITS - ROOT_BITS;// 第二层对应页号的其余比特位 static const int LEAF_LENGTH = 1 << LEAF_BITS;// 第二层存储元素的个数
-
代码部分
- 增加一个头文件,存放基数树的对象(这里主要用到get与set两个函数,就不对函数相关功能介绍了)
#pragma once
#include"Common.h"
/**************************一层树*****************************/
// Single-level array
template <int BITS>
class TCMalloc_PageMap1 {
private:
static const int LENGTH = 1 << BITS;
void** array_;
public:
typedef uintptr_t Number;
//explicit TCMalloc_PageMap1(void* (*allocator)(size_t)) {
explicit TCMalloc_PageMap1() {
//array_ = reinterpret_cast<void**>((*allocator)(sizeof(void*) << BITS));
size_t size = sizeof(void*) << BITS;
size_t alignSize = SizeClass::_RoundUp(size, 1<<PAGE_SHIFT);
array_ = (void**)SystemAlloc(alignSize>>PAGE_SHIFT);
memset(array_, 0, sizeof(void*) << BITS);
}
// Return the current value for KEY. Returns NULL if not yet set,
// or if k is out of range.
void* get(Number k) const {
if ((k >> BITS) > 0) {
return NULL;
}
return array_[k];
}
// REQUIRES "k" is in range "[0,2^BITS-1]".
// REQUIRES "k" has been ensured before.
//
// Sets the value 'v' for key 'k'.
void set(Number k, void* v) {
array_[k] = v;
}
};
/**************************二层树*****************************/
// Two-level radix tree
template <int BITS>
class TCMalloc_PageMap2 {
private:
// Put 32 entries in the root and (2^BITS)/32 entries in each leaf.
static const int ROOT_BITS = 5;
static const int ROOT_LENGTH = 1 << ROOT_BITS;
static const int LEAF_BITS = BITS - ROOT_BITS;
static const int LEAF_LENGTH = 1 << LEAF_BITS;
// Leaf node
struct Leaf {
void* values[LEAF_LENGTH];
};
Leaf* root_[ROOT_LENGTH]; // Pointers to 32 child nodes
void* (*allocator_)(size_t); // Memory allocator
public:
typedef uintptr_t Number;
//explicit TCMalloc_PageMap2(void* (*allocator)(size_t)) {
explicit TCMalloc_PageMap2() {
//allocator_ = allocator;
memset(root_, 0, sizeof(root_));
PreallocateMoreMemory();
}
void* get(Number k) const {
const Number i1 = k >> LEAF_BITS;
const Number i2 = k & (LEAF_LENGTH - 1);
if ((k >> BITS) > 0 || root_[i1] == NULL) {
return NULL;
}
return root_[i1]->values[i2];
}
void set(Number k, void* v) {
const Number i1 = k >> LEAF_BITS;
const Number i2 = k & (LEAF_LENGTH - 1);
ASSERT(i1 < ROOT_LENGTH);
root_[i1]->values[i2] = v;
}
bool Ensure(Number start, size_t n) {
for (Number key = start; key <= start + n - 1;) {
const Number i1 = key >> LEAF_BITS;
// Check for overflow
if (i1 >= ROOT_LENGTH)
return false;
// Make 2nd level node if necessary
if (root_[i1] == NULL) {
//Leaf* leaf = reinterpret_cast<Leaf*>((*allocator_)(sizeof(Leaf)));
//if (leaf == NULL) return false;
static ObjectPool<Leaf> leafPool;
Leaf* leaf = (Leaf*)leafPool.New();
memset(leaf, 0, sizeof(*leaf));
root_[i1] = leaf;
}
// Advance key past whatever is covered by this leaf node
key = ((key >> LEAF_BITS) + 1) << LEAF_BITS;
}
return true;
}
void PreallocateMoreMemory() {
// Allocate enough to keep track of all possible pages
Ensure(0, 1 << BITS);
}
};
/**************************三层树*****************************/
// Three-level radix tree
template <int BITS>
class TCMalloc_PageMap3 {
private:
// How many bits should we consume at each interior level
static const int INTERIOR_BITS = (BITS + 2) / 3; // Round-up
static const int INTERIOR_LENGTH = 1 << INTERIOR_BITS;
// How many bits should we consume at leaf level
static const int LEAF_BITS = BITS - 2 * INTERIOR_BITS;
static const int LEAF_LENGTH = 1 << LEAF_BITS;
// Interior node
struct Node {
Node* ptrs[INTERIOR_LENGTH];
};
// Leaf node
struct Leaf {
void* values[LEAF_LENGTH];
};
Node* root_; // Root of radix tree
void* (*allocator_)(size_t); // Memory allocator
Node* NewNode() {
Node* result = reinterpret_cast<Node*>((*allocator_)(sizeof(Node)));
if (result != NULL) {
memset(result, 0, sizeof(*result));
}
return result;
}
public:
typedef uintptr_t Number;
explicit TCMalloc_PageMap3(void* (*allocator)(size_t)) {
allocator_ = allocator;
root_ = NewNode();
}
void* get(Number k) const {
const Number i1 = k >> (LEAF_BITS + INTERIOR_BITS);
const Number i2 = (k >> LEAF_BITS) & (INTERIOR_LENGTH - 1);
const Number i3 = k & (LEAF_LENGTH - 1);
if ((k >> BITS) > 0 ||
root_->ptrs[i1] == NULL || root_->ptrs[i1]->ptrs[i2] == NULL) {
return NULL;
}
return reinterpret_cast<Leaf*>(root_->ptrs[i1]->ptrs[i2])->values[i3];
}
void set(Number k, void* v) {
ASSERT(k >> BITS == 0);
const Number i1 = k >> (LEAF_BITS + INTERIOR_BITS);
const Number i2 = (k >> LEAF_BITS) & (INTERIOR_LENGTH - 1);
const Number i3 = k & (LEAF_LENGTH - 1);
reinterpret_cast<Leaf*>(root_->ptrs[i1]->ptrs[i2])->values[i3] = v;
}
bool Ensure(Number start, size_t n) {
for (Number key = start; key <= start + n - 1;) {
const Number i1 = key >> (LEAF_BITS + INTERIOR_BITS);
const Number i2 = (key >> LEAF_BITS) & (INTERIOR_LENGTH - 1);
// Check for overflow
if (i1 >= INTERIOR_LENGTH || i2 >= INTERIOR_LENGTH)
return false;
// Make 2nd level node if necessary
if (root_->ptrs[i1] == NULL) {
Node* n = NewNode();
if (n == NULL) return false;
root_->ptrs[i1] = n;
}
// Make leaf node if necessary
if (root_->ptrs[i1]->ptrs[i2] == NULL) {
Leaf* leaf = reinterpret_cast<Leaf*>((*allocator_)(sizeof(Leaf)));
if (leaf == NULL) return false;
memset(leaf, 0, sizeof(*leaf));
root_->ptrs[i1]->ptrs[i2] = reinterpret_cast<Node*>(leaf);
}
// Advance key past whatever is covered by this leaf node
key = ((key >> LEAF_BITS) + 1) << LEAF_BITS;
}
return true;
}
void PreallocateMoreMemory() {
}
};
-
在page头文件中,创建基数树的成员变量,替换掉原来的map
//std::unordered_map<PAGE_ID, Span*> _idSpanMap; TCMalloc_PageMap1<32 - PAGE_SHIFT> _idSpanMap;
-
更改page.cpp中的函数
-
-
Span* PageCache::NewSpan(size_t k)
把原先的[]替换为get //_idSpanMap[span->_pageId] = span; _idSpanMap.set(span->_pageId, span);
-
-
-
Span* PageCache::MapObjectToSpan(void obj)
Span* PageCache::MapObjectToSpan(void* obj) { PAGE_ID id = ((PAGE_ID)obj >> PAGE_SHIFT); auto ret = (Span*)_idSpanMap.get(id); assert(ret != nullptr); return ret; }
这里不用在加锁了,因为基数树的结构它是固定的,而unordered_map在插入时可能会超过负载因子,导致结构变化所以要加锁
-
-
-
void** PageCache::ReleaseSpanToPageCache(Span* span)
//auto ret = _idSpanMap.find(prevId); //if (ret == _idSpanMap.end()) // break; auto ret = (Span*)_idSpanMap.get(prevId); if (ret == nullptr) break; //Span* prevSpan = ret->second; Span* prevSpan = ret; if (prevSpan->_isUse == true) //_idSpanMap[span->_pageId] = span; //_idSpanMap[span->_pageId+span->_n-1] = span; _idSpanMap.set(span->_pageId, span); _idSpanMap.set(span->_pageId + span->_n - 1, span);
-
-
优化后的运行结果如下:
thread cache使用TLS解决多线程下争锁的问题,且对每个线程都提供了一个缓存(内存池),这也是内存池高效的地方
central cache可以调节单个线程的thread cache占用过多内存,使所有线程都能均衡使用内存,到达“负载均衡”;并且它是用于碎内存回收的桥梁,跨度在page与thread之间,由thread的碎内存合并到central中,再由central释放给page就快很多了。
page cache 哈希桶中挂载的Span,记录了内存(页)的属性,方便内存回收合并工作,碎块内存可以通过Span中的属性合并成完整的页,而页又可以用于分配,有效缓解内存碎片问题。
本项目主要用于学习,有误地方还请大佬们指点