学习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的主要结构
从我们之前内存池的经验来看,我们将大小为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);
//