高并发内存池
一、项目介绍
当前项目旨在实现一个高并发内存池,其原型是 Google 的开源项目 TCMalloc。TCMalloc,全称 Thread-Caching Malloc,是一个高效的多线程内存管理库,用于替代系统的内存分配相关函数(malloc、free)。
我们这个项目的目标是简化并模拟实现 TCMalloc 的核心框架,以学习 TCMalloc 的精华。这种学习方式类似于之前学习 STL 容器的方式。然而,相较于 STL 容器部分,TCMalloc 的代码量和复杂度都大大增加了。
TCMalloc 是由 Google 开源的,可以认为是当时顶尖的 C++ 程序员编写的,具有很高的知名度,许多公司都在使用它。Go 语言直接使用了 TCMalloc 作为其内存分配器。因此,熟悉 TCMalloc 对程序员来说是非常有益。
学习这个项目既有好处也有坏处,通过实现和理解 TCMalloc,可以深入学习高效的多线程内存管理技术,由于 TCMalloc 在许多公司和项目中都有应用,掌握了它,可以在实际工作中派上用场,坏处就是,TCMalloc 的代码量和复杂度较大,理解和掌握需要投入大量时间和精力,如果没有学扎实会容易碰钉子。
总体而言,通过简化并实现 TCMalloc 的核心框架,既能深入学习高效的多线程内存管理技术,又能在面试中展示对复杂项目的掌握,从而获得面试官的认可。这不仅有助于提升自身的编程能力和知识水平,也能在职业发展中带来实际的好处。
项目所需要的知识储备
这个项目会用到C/C++、数据结构(链表、哈希桶)、操作系统内存管理、单例模式、多线程、互斥锁等等方面的知识。
tcmalloc源代码
二、什么是内存池
1.池化技术
所谓“池化技术”,就是程序先向系统申请过量的资源,然后自己管理,以备不时之需。之所以要申请过量的资源,是因为每次申请该资源都有较大的开销,不如提前申请好了,这样使用时就会变得非常快捷,大大提高程序运行效率。
在计算机中,有很多使用“池”这种技术的地方,除了内存池,还有连接池、线程池、对象池等。以服务器上的线程池为例,它的主要思想是:先启动若干数量的线程,让它们处于睡眠状态,当接收到客户端的请求时,唤醒池中某个睡眠的线程,让它来处理客户端的请求,当处理完这个请求,线程又进入睡眠状态。
2.内存池
内存池是指程序预先从操作系统申请一块足够大内存,此后,当程序中需要申请内存的时候,不是直接向操作系统申请,而是直接从内存池中获取;同理,当程序释放内存的时候,并不真正将内存返回给操作系统,而是返回内存池。当程序退出(或者特定时间)时,内存池才将之前申请的内存真正释放。
3.内存池主要解决的问题
内存池主要解决的当然是效率的问题,其次如果作为系统的内存分配器的角度,还需要解决一下内存碎片的问题。那么什么是内存碎片呢?
一般内存碎片分为两种一种是内碎片,一种则是外碎片
3.1内碎片
定义:内碎片是指在分配内存块时,分配的内存块大小大于所需内存大小,导致未使用的部分内存块浪费。
产生原因:通常是由于内存分配单位(如页或块)的大小固定,而申请的内存大小不一定正好等于分配单位的大小。
示例:如果每次分配的内存块大小为4KB,而程序只需要3KB,那么剩余的1KB就是内碎片。
3.2外碎片
定义:外碎片是指在内存分配和释放过程中,虽然总的空闲内存足够,但由于空闲内存块不连续,无法满足新的内存分配请求。
产生原因:通常是由于内存分配和释放不连续,导致空闲内存块分散在内存的各个地方。
示例:如果内存中有多个小的空闲块,但没有一个连续的空闲块能够满足一个较大的内存分配请求,那么这些分散的空闲块就是外碎片。
3.3内存池的解决方案
内存池是一种内存管理技术,通过预先分配一大块内存并将其划分为多个小块,用于满足程序的内存分配请求。内存池主要通过以下方式解决内存碎片问题:
- 固定大小的内存块:内存池通常将内存划分为固定大小的块,这样可以减少内碎片,因为每次分配的内存块大小都是相同的,适合那些需要频繁分配和释放的小块内存的应用.
- 内存块的复用: 内存池通过复用已经分配的内存块,减少了内存分配和释放的频率,从而减少了外碎片的产生。
- 集中管理: 内存池集中管理内存分配和释放,可以更好地控制内存的使用,减少内存碎片的产生。
4.malloc
C/C++中我们要动态申请内存都是通过malloc去申请内存,但是我们要知道,实际我们不是直接去堆获取内存的,而malloc就是一个内存池。malloc() 相当于向操作系统“批发”了一块较大的内存空间,然后“零售”给程序用。当全部“售完”或程序有大量的内存需求时,再根据实际需求向操作系统“进货”。malloc的实现方式有很多种,一般不同编译器平台用的都是不同的。比如windows的vs系列用的微软自己写的一套,linux gcc用的glibc中的ptmalloc。下面有几篇关于这块的文章,大概可以去简单看看了解一下。
一文了解,Linux内存管理,malloc、free 实现原理
malloc()背后的实现原理——内存池
malloc的底层实现(ptmalloc)
三、定长内存池
学习定长内存池目的有两层:
- 了解简单内存池如何控制内存对象
- 第二作为后面内存池的一个基础组件
1.定长内存池设计
首先我们要明白在设计定长内存池时的一整个存取流程:
在我们需要内存的存取时,先向操作系统申请一个大块内存,同时我们会创建一个freelist,便于我们高效化内存的释放和存取,当我们需要内存时,我们首先会向freelist中去查找,是否有内存块,如果没有我们会在大块内存中去查找,如果也没有我们会向操作系统去申请
在设计时有两种方案可以选取:
//"ObjectPool.h"
template <size_t size>
class object_pool
{};
采用非类型的模板参数表示定长内存池,即每次申请和释放内存时,都是一个 size 大小的内存对象。
//"ObjectPool.h"
template <class T>
class object_pool
{};
采用类型的模板参数表示定长内存池,即每次申请和释放内存时,都是一个 sizeof(T) 大小的内存对象。
我们在这里采用方案二,方案二使用类型模板参数表示定长内存池,主要有以下优点:
类型安全:每次分配和释放内存都是特定类型的对象,防止类型错误。可读性和易用性:通过类型直接描述对象池中存储的对象,更直观。自动计算大小:无需手动指定大小,使用sizeof(T)自动计算。易于维护:如果对象类型改变,只需修改模板参数,而不是手动调整大小。总体来说,方案二更适合用于需要特定类型对象的场景,提供了更高的安全性和灵活性。
2.成员属性
1.接下来我们来定义内存池的成员属性,从整个流程来看我们首先需要定义大块内存的位置
//"ObjectPool.h"
template <class T>
class object_pool
{
private:
// 大块内存的起始位置
char* _memory= nullptr;
};
为什么这里使用char*而不是用其他类型的指针呢???
因为内存池中的内存是会随着内存存取变化的,所以_memory也是会随之变化的,而 char* 的指针变量 ± pos 正好移动 pos 个字节,更方便移动。
我们可以发现在程序申请内存时,内存池的处理非常简单,只需要进行指针的移动即可,但是当我们申请内存时就会出现一个问题,就比如A、B、C、D四个程序按顺序申请内存,但是当A要释放内存时,_memory指针的移动就会出现问题。
2.从这个问题上我们就引出了下一个成员属性 _freeList,在我们归还内存时,先将内存给 _freeList,让 _freeList把这些内存先组织起来,让每一个内存对象的前4/8字节的内容为下一个内存对象的起始地址,这个结构我们称之为自由链表,它不存储任何额外的数据,只需要维持这些内存对象的关系即可
//"ObjectPool.h"
template <class T>
class object_pool
{
private:
// 大块内存的起始位置
char* _memory= nullptr;
// 将归还回来的内存对象, 通过这个 _freeList 组织起来
void* _freeList= nullptr;
};
3.当我们_freelist中没有内存,我们会去向大块内存中申请,如果剩余内存不够一个对象大小时,则重新开大块空间如果剩余内存大小够一个对象大小,如果类型T 的大小小于指针的大小(例如,T 是一个小的基本数据类型),则需要确保内存块至少能够存储一个指针。这里我们就引入了另一个成员属性 _remainBytes用于表示剩余的可用字节数。
//"ObjectPool.h"
template <class T>
class object_pool
{
private:
// 大块内存的起始位置
char* _memory=nullptr;
// 将归还回来的内存对象, 通过这个 _freeList 组织起来
void* _freeList= nullptr;
// 大块内存在切分过程中剩余字节数
size_t _remainBytes = 0;
};
3.析构和构造
我们可以摆脱malloc和free的束缚,采用系统调用实现,并对其进行封装
//"ObjectPool.h"
// 直接去堆上按页申请空间
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32
void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); // Windows 下使用 VirtualAlloc 分配内存
#else
void* ptr = mmap(nullptr, kpage << 13, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); // Linux 下使用 brk或mmap 分配内存
#endif
#ifdef _WIN32
if (ptr == nullptr) // 检查指针是否为空
#else
if (ptr == MAP_FAILED) // 检查指针是否映射失败
#endif
throw std::bad_alloc(); // 分配失败,抛出 bad_alloc 异常
return ptr; // 返回分配的内存指针
}
//手动调用来释放之前用 SystemAlloc 分配的内存,否则会导致内存泄漏
inline static void SystemFree(void* ptr)
{
#ifdef _WIN32
VirtualFree(ptr, 0, MEM_RELEASE);
#else
munmap(ptr, size); // Linux下, sbrk、unmmap等
#endif
}
然后实现构造函数和析构函数
//"ObjectPool.h"
ObjectPool() :_memory(nullptr), _freeList(nullptr), _remainBytes(0) {}
//"ObjectPool.h"
~ObjectPool() {
// 释放所有剩余的分配内存
while (_memory) {
SystemFree(_memory);
_memory = nullptr; // 如果有多个分配,需要更新逻辑
}
}
4.New和Delete
接下来有了上面的准备进行对New和Delete的接口
调用New接口时, 程序向内存池申请空间,内存池返回一个内存对象,存在两种情况:
- 首先当自由链表中有内存块时优先使用自由链表中组织的内存块
第一种情况就是将自由链表中的第一块内存块弹出来,相当于对单链表进行pop_front操作
1.保存下一个内存对象,也就是 B 对象的地址;
2.获得第一个内存对象,即A对象,也就是_freeList;
3.更新_freeList, 将B对象的值给 _freeList;
4.返回A对象的地址。
为了获取下一个内存对象的地址,我们需要访问当前内存对象的前4/8字节,因为这些字节存储了下一个内存对象的地址。由于 _freeList
是 void*
类型,不能直接进行解引用,因此需要进行类型转换。可以使用以下代码来实现这一操作:
//"ObjectPool.h"
void* next = *((void**)_freeList);
//类型转换:
//- 使用void* next = *((void**)_freeList);将void*类型的_freeList转换为void**类型,以便可以解引用访问前4/8字节。
//解引用操作:
//- 解引用void**类型的指针,获取存储在前4/8字节中的下一个内存对象的地址。
//这种方法确保了可以安全地访问和操作内存对象链表中的下一个对象地址。
- 如果自由链表中没有内存块,则向大块内存中申请内存,如果大小够,则进行切分,并更新相关字段,如果不够则向操作系统申请,并更新相关字段。
//"ObjectPool.h"
//New代码实现
T* New()
{
// 需要返回的目标内存单元
T* obj = nullptr;
// 优先把还回来内存块对象,再次重复利用
if (_freeList)
{
// 从空闲链表中获取下一个空闲对象的地址
void* next = *((void**)_freeList);
// 将当前的空闲对象指针赋值给 obj
obj = (T*)_freeList;
// 更新空闲链表头指针为下一个空闲对象
_freeList = next;
}
else
{
// 如果空闲链表为空,则需要从系统中申请新的内存块
if (_remainBytes < sizeof(T))
{
// 剩余内存不足以分配一个新的对象,因此申请新的大块内存
_remainBytes = 128 * 1024;// 设置新内存块的大小为 128KB
//_memory = (char*)malloc(_remainBytes);
// 使用系统调用申请内存
_memory = (char*)SystemAlloc(_remainBytes >> 13);
// 检查内存分配是否成功
if (_memory == nullptr)
{
throw std::bad_alloc();// 分配失败,抛出异常
}
}
// 分配内存中的一个对象
obj = (T*)_memory;
// 计算对象的大小,保证大于或等于指针大小
size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
// 大块内存向后移动一个内存单元的大小
_memory += objSize;
// 更新大块内存的剩余字节数
_remainBytes -= objSize;
}
// 定位new,显示调用T的构造函数初始化
new(obj)T;
return obj;
}
在我们调用Delete接口时,当程序使用完内存时,要归还内存对象,即将内存对象添加到内存池中。前面提到,为了便利,内存池需要通过 _freeList 将归还的内存对象组织起来,当一个内存对象归还给内存池时,就是一个 push 的动作。出于效率角度,我们选择 push_front,比如:
此时D就是要归还的内存对象,那么如何归还呢?
- 首先显式调用这个类型的析构函数,对资源进行释放;
- 将 D 对象的前4/8字节的内容存储为A对象;
- 更新_freeList,即将D对象赋值给 _freeList;
如下:
//"ObjectPool.h"
//Delete代码实现
void Delete(T* obj)
{
// 显示调用析构函数清理对象
obj->~T();
// 头插
*(void**)obj = _freeList;
_freeList = obj;
}
5.性能测试
使用以下代码:
//"ObjectPool.h"
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;
}
可以看出来,此时的定长内存池在申请/释放内存对象时比调用 malloc/free 时更快一点。
四、高并发内存池整体框架设计
现代很多的开发环境都是多核多线程,在申请内存的场景下,必然存在激烈的锁竞争问题。malloc本身其实已经很优秀,那么我们项目的原型tcmalloc就是在多线程高并发的场景下更胜一筹,所以这次我们实现的内存池需要考虑以下几方面的问题。
- 性能问题。
- 多线程环境下,锁竞争问题。
- 内存碎片问题。
高并发内存池主要由以下3个部分构成:
- Thread Cache
每个线程都有自己的Thread Cache (TLS - 线程本地存储),用于处理小于等于 256KB 的内存分配和释放。因为Thread
Cache是线程私有的,不存在竞争,也无需加锁,这提高了并发能力。但Thread Cache不能满足所有内存请求:
- 大内存申请:对于大于 256KB 的请求,需要访问Page Cache或通过系统调用获取内存。
- 缓存耗尽:如果Thread Cache没有内存,会访问Central Cache补充。
- Central Cache
Central Cache是多个线程共享的资源。当线程的Thread Cache为空时,它从Central
Cache获取一批内存对象,并存储在自己的缓存中备用。为了防止单个Thread Cache占用过多未使用的内存,Central Cache定期回收这些内存,确保多线程间的内存分配更加均衡。
由于Central Cache是共享的,需要使用锁来管理并发访问。通过桶式哈希结构进行细粒度加锁,减少竞争,保持性能。
- Page Cache
如果Central Cache也耗尽,线程会访问Page Cache。Page Cache提供由连续内存页组成的 Span
大块。这些块被分割后通过Central Cache传递给Thread Cache。当一个完整的 Span 返回到Central Cache后,它会交还给Page Cache,进行页合并以减少碎片化。
同样,Page Cache需要处理并发访问,因此也需要加锁保护。具体的锁机制取决于实现需求。
高并发内存池内存池的整体框架:
五、申请内存设计
1.Thread Cache的结构设计
Thread Cache 用于处理小于等于 256KB 的内存申请,为满足不同的内存需求(如 8byte、16byte 等),采用哈希桶结构。每个桶存储不同大小的内存对象,通过自由链表维护。如下:
1.1Thread Cache中FreeList设计
Thread Cache 的哈希桶中,每个桶都是一个自由链表,用于管理相同大小的内存对象。我们可以创建一个类型来标识这个自由链表结构。自由链表的主要操作有两种:push
(插入内存对象)和 pop
(弹出内存对象),框架如下:
//Common.h
class FreeList
{
public:
// push: 将一个内存单元对象链入到链表中
void push(void* obj) {}
// pop: 从自由链表中弹出一个内存单元对象
void* pop() {}
private:
void* _freeList=nullptr; // 自由链表的起始地址
};
为了方便 pop
和 push
操作读取和写入内存单元的前 4/8 字节,我们实现了一个 NextObj
接口。由于该接口可能在多个源文件中使用,为了避免链接问题,我们将其定义为静态static
内部符号,并声明为 inline
函数,以提高性能并减少函数栈帧的创建和销毁。
//Common.h
static inline void*& NextObj(void* obj)
{
return *(void**)obj;
}
push接口:
//Common.h class FreeList
//push:将一个内存单元对象链入到链表中
void push(void* obj)
{
// 要链入的内存单元对象不能为空
assert(obj);
// 将内存单元对象头插到链表中
NextObj(obj) = _freeList;
// 更新 _freeList
_freeList = obj;
}
pop接口:
//Common.h class FreeList
//pop:从自由链表中弹出一个内存单元对象
void* pop()
{
// 自由链表不能为空
assert(_freeList);
// 保存要返回的内存单元对象
void* obj = _freeList;
// 更新自由链表的起始地址
_freeList = NextObj(_freeList);
// 将要返回的前4/8字节的内容置为空
NextObj(obj) = nullptr;
// 返回这个内存单元对象
return obj;
}
empty接口:
//Common.h class FreeList
// 当前自由链表是否为空
bool empty()
{
return _freeList == nullptr;
}
1.2Thread Cache 哈希桶设计及其对齐规则
Thread Cache 是一个哈希桶结构,每个桶是一个自由链表。它提供三个接口:Allocate
和 Deallocate
、FetchFromCentralCache
,前两个用于申请和释放内存对象,第三个用于当前桶没有内存对象,那么就需要向Central Cache获取内存对象,并且有一个成员属性 _freeLists[NFREELIST]
来表示桶的数量。
//ThreadCache.h
class ThreadCache
{
public:
//申请一个内存对象
void* Allocate(size_t size);
//回收一个内存对象
void Deallocate(void* ptr, size_t size);
// 从CentralCache获取对象
void* FetchFromCentralCache(size_t index, size_t size);
private:
FreeList _freeLists[NFREELIST];
};
那么 NFREELIST
应该是多大呢? 在高并发内存池整体框架设计中我们提到Thread Cache ,用于处理小于等于 256KB 的内存分配和释放,那难道说我们要把每一个字节都设置一个自己的桶吗?我们可以看一组数据:
这样的话就需要262144个自由链表来管理内存,为了减少自由链表的数量,Thread Cache 采用对齐规则,将特定范围内的内存申请统一返回固定字节数的内存对象。例如,申请 3byte、4byte、5byte、6byte 或 7byte 的内存时,Thread Cache 统一返回 8byte 的对象。这种方法大大减少了桶的数量,但也引入了内碎片问题,例如申请 3byte 内存时实际得到 8byte,会有 5byte 的未使用部分。虽然内碎片无法完全避免,但可以控制在一定范围内。
1.2.1Thread Cache 哈希桶的对齐规则
内存申请的范围 | 向上的对齐数 | 当前范围内桶的范围 | 当前范围内桶的个数 |
---|---|---|---|
[1 byte, 128 byte] | 8 byte | [0, 16) | 16 |
[128 + 1 byte, 1024 byte] | 16 byte | [16, 72) | 56 |
[1024 + 1 byte, 8 * 1024 byte] | 128 byte | [72, 128) | 56 |
[8 * 1024 + 1 byte, 64 * 1024 byte] | 1024 byte | [128, 184) | 56 |
[64 * 1024 + 1 byte, 256 * 1024 byte] | 8 * 1024 byte | [184, 208) | 24 |
在实现定长内存池时我们提到了,在实现定长内存池时,FreeList 中的内存对象必须能存储一个指针。由于 32 位和 64 位程序中的指针大小不同(分别为 4byte 和 8byte),我们在 [1, 128] 区间内统一选择 8byte 对齐,通过选择 8byte 对齐,可以在不同系统架构下提供一致的行为和性能,同时避免了由于指针大小不同而导致的潜在问题。
此时对齐映射规则将桶的数量大大降低,此时一共会有 208 个桶。同时,这套规则的内碎片浪费会控制在 10% 左右, 第一个区间额外讨论,因为如果你要申请 1byte,它会返回 8byte,此时的浪费率就很高,但是,浪费的字节数确是很少的,此时才浪费 7个byte,这是可以接受的。而对于其他的区间,比如[1024 + 1 byte, 8 * 1024 byte],如果此时要申请 1025byte,那么按照 128byte 向上对齐,那么 Thread Cache 会返回一个 1152byte 的内存对象,此时的浪费率:
//Common.h
static const size_t NFREELIST = 208;
为了实现对齐规则,需要提供两个接口 RoundUp
和 Index
,以便根据用户申请的字节数,返回对齐后的字节数和对应桶的下标。下面是这两个接口的设计和实现思路:
//Common.h
// 计算对象大小的对齐映射规则
class SizeClass
{
public:
//计算并返回对齐后的字节数
static inline size_t RoundUp(size_t size)
// 计算映射的哪一个自由链表桶
static inline size_t Index(size_t bytes)
};
1.2.2 RoundUp 接口
功能:根据对齐规则,计算并返回对齐后的字节数。
参数:用户申请的字节数。
返回值:对齐后的字节数。
示例实现:
//Common.h class SizeClass
static inline size_t _RoundUp(size_t bytes, size_t alignNum)
{
return ((bytes + alignNum - 1) & ~(alignNum - 1));
}
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方法时,采用了高手常用的实现高效内存对齐的技术,下面详细讲解一下:
_RoundUp
函数用于计算将给定字节数向上对齐到指定对齐数的结果。下面是详细分析:
函数定义
//Common.h class SizeClass
static inline size_t _RoundUp(size_t bytes, size_t alignNum)
{
return ((bytes + alignNum - 1) & ~(alignNum - 1));
}
分析解释:
bytes + alignNum - 1
:> - 目的: 这一步是为了确保在向下取整之前,bytes
的值至少增加到alignNum
的倍数。
- 解释: 如果
bytes
已经是对齐数的倍数,那么这个表达式会刚好是对齐数的倍数。如果bytes
不是对齐数的倍数,这个表达式会把它增加到下一个对齐数的倍数。例如,假设alignNum
是 8,bytes
是 5,那么
bytes + alignNum - 1
是 12,这样可以确保最后的对齐结果是 8 的倍数(8)。
~(alignNum - 1)
:
- 目的: 生成一个掩码,用于将字节数向下对齐到最近的对齐边界。
- 解释:
alignNum - 1
计算出对齐数的掩码。例如,如果alignNum
是 8,那么alignNum - 1
是 7(即 00000111 二进制),其取反~(alignNum - 1)
得到掩码 11111000(二进制),这样可以将字节数的低位清零,实现对齐。
(bytes + alignNum - 1) & ~(alignNum - 1)
:
- 目的: 将字节数向上对齐到指定对齐数的倍数。
- 解释: 通过
&
操作,清除低于对齐数的位,得到向上对齐的字节数。例如,假设bytes
是 5,alignNum
是 8。bytes + alignNum - 1
得到 12。掩码~(alignNum - 1)
是
11111000(二进制),与 12 做&
操作得到 8(二进制:00001000),即 8 的倍数。
示例:
假设我们需要将 13 字节向上对齐到 8 字节的倍数:
- 计算
bytes + alignNum - 1
:
13 + 8 - 1 = 20
- 计算掩码
~(alignNum - 1)
:
alignNum - 1 = 7
,其二进制表示为 00000111- 取反得到掩码 11111000
- 执行
20 & 11111000
:
- 20 的二进制是 00010100
- 执行按位与操作:00010100 & 11111000 = 00010000,即 16
因此,
_RoundUp(13, 8)
的结果是 16,即 13 字节向上对齐到 8 字节的倍数得到 16 字节。
总结:
_RoundUp
函数通过加上alignNum - 1
来确保字节数至少增加到对齐数的倍数,然后使用掩码将其向下对齐到最接近的对齐边界,从而实现对齐操作。这个方法是实现高效内存对齐的常见技术。
1.2.3 Index 接口
功能:根据对齐后的字节数,返回对应桶的下标。
参数:对齐后的字节数。
返回值:桶的下标。
示例实现:
//Common.h class SizeClass
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 <= 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;
}
_Index
函数的主要任务是将给定的字节数映射到一个桶的索引。这个函数的设计目的是将不同大小的内存请求映射到相应的自由链表桶,以优化内存管理。让我们详细分析这个函数。
函数定义
//Common.h class SizeClass
static inline size_t _Index(size_t bytes, size_t align_shift)
{
return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1;
}
分析解释:
1 << align_shift
:
- 作用: 计算对齐的大小。
align_shift
是对齐的位移量。例如,align_shift
为 3 时,1 << 3
计算出对齐大小为 8 字节。- 解释:
align_shift
表示对齐的位数(例如 3 位表示 8 字节对齐),1 << align_shift
计算出对齐的字节数。通过位移操作可以高效地计算出对齐大小。
bytes + (1 << align_shift) - 1
:
- 作用: 确保字节数向上对齐到对齐的大小。
- 解释:
bytes + (1 << align_shift) - 1
计算了一个新的字节数,使其向上对齐到align_size
的倍数。如果bytes
已经是对齐的倍数,这个计算不会改变字节数;如果不是,它会将字节数增加到下一个对齐的倍数。例如,bytes = 5
,align_shift = 3
(对齐 8 字节),那么1 << 3 = 8
,计算为5 + 8 - 1 = 12
。
>> align_shift
:
- 作用: 将字节数转换为对齐后的桶索引。
- 解释: 通过右移位操作
>> align_shift
将字节数转换为桶的索引。例如,对于align_shift = 3
(8 字节对齐),12 >> 3
得到 1。右移操作将对齐大小的影响从字节数中移除,得到桶的索引。
- 1
:
- 作用: 调整桶的索引,使其从 0 开始。
- 解释: 索引是从 0 开始的,因此在计算桶的下标时,需要减去 1。这样可以确保桶的下标从 0 开始而不是 1。例如,右移后得到的结果是 1,减去 1 得到 0,这表示第一个桶。
示例:
假设我们有bytes = 20
和align_shift = 4
(16 字节对齐):
- 计算对齐大小:
1 << 4 = 16
- 计算
bytes + (1 << align_shift) - 1
:
20 + 16 - 1 = 35
- 右移对齐的位移量:
35 >> 4 = 2
- 调整桶的索引:
2 - 1 = 1
因此,
_Index(20, 4)
的结果是 1。
总结:
_Index
函数的目的是将给定字节数映射到一个桶的索引。它通过以下步骤实现:
- 将字节数增加到对齐的倍数;
- 右移位运算以得到对齐后的桶索引;
- 减去 1 以调整桶的下标。
这个方法有效地将不同大小的内存请求映射到对应的桶中,从而优化了内存管理,特别是在内存池中。
// 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);
}
}
1.3Thread Cache TLS 无锁访问
为了确保每个线程拥有独立的 Thread Cache,我们使用了线程局部存储(TLS)。TLS 允许每个线程拥有独立的数据副本,避免了与其他线程的数据冲突和竞争。因此,线程的 Thread Cache 是私有的,线程间不会互相干扰,这种设计可以提高并发性能和效率。
// ThreadCache.h
// TLS thread local storage
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;
// static __thread ThreadCache* TLSThreadCacheObject = nullptr;//Linux
高并发内存池需要对外提供两个接口,这两个接口用于内存的申请和释放,如下:
// ConcurrentAlloc.h
// 提供给上层的申请空间的接口
static void* ConcurrentAlloc(size_t size);
// 提供给上层的释放空间的接口
static void ConcurrentFree(void* ptr, size_t size);
这段代码演示了如何通过线程局部存储(TLS)实现线程安全的内存分配和释放。下面是对每个部分的详细解释:
1.3.1函数 ConcurrentAlloc
// ConcurrentAlloc.h
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);
}
pTLSThreadCache
检查和初始化
if (pTLSThreadCache == nullptr)
{
pTLSThreadCache = new ThreadCache;
}
- 目的: 确保每个线程拥有一个
ThreadCache
实例。- 解释:
pTLSThreadCache
是一个指向ThreadCache
对象的指针。初次调用时,如果它为nullptr
,即表示当前线程还没有初始化自己的ThreadCache
,则为其分配一个新的ThreadCache
对象。此处假设pTLSThreadCache
是线程局部存储(TLS)中定义的变量,实际代码中需要使用 TLS API
来确保每个线程都有自己的pTLSThreadCache
。
- 打印线程 ID 和
ThreadCache
地址
cout << std::this_thread::get_id() << ":" << pTLSThreadCache << endl;
- 目的: 调试和验证每个线程是否有自己的
ThreadCache
实例。- 解释:
std::this_thread::get_id()
获取当前线程的 ID,pTLSThreadCache
打印当前线程的ThreadCache
对象的地址。这有助于确认每个线程都使用了不同的ThreadCache
实例。
- 分配内存
return pTLSThreadCache->Allocate(size);
- 目的: 通过当前线程的
ThreadCache
分配内存。- 解释: 调用
ThreadCache
的Allocate
方法来为指定大小的内存分配空间,并返回内存的指针。由于ThreadCache
是线程私有的,这样的操作是线程安全的。
1.3.2函数 ConcurrentFree
// ConcurrentAlloc.h
static void ConcurrentFree(void* ptr, size_t size)
{
assert(pTLSThreadCache);
pTLSThreadCache->Deallocate(ptr, size);
}
- 检查
pTLSThreadCache
assert(pTLSThreadCache);
- 目的: 确保
pTLSThreadCache
已经被初始化。- 解释: 使用
assert
确保在调用Deallocate
时,pTLSThreadCache
不为nullptr
。这是一种调试手段,确保在释放内存时ThreadCache
已经存在。
- 释放内存
pTLSThreadCache->Deallocate(ptr, size);
- 目的: 通过当前线程的
ThreadCache
释放内存。- 解释: 调用
ThreadCache
的Deallocate
方法来释放指定大小的内存块。由于ThreadCache
是线程私有的,这样的操作也是线程安全的。
总结:
ConcurrentAlloc
和ConcurrentFree
函数利用线程局部存储(TLS)来管理每个线程的私有ThreadCache
实例,从而避免线程间的内存管理竞争。ConcurrentAlloc
确保每个线程拥有自己的ThreadCache
实例,并通过它分配内存。ConcurrentFree
在释放内存时验证ThreadCache
的有效性,并通过它释放内存。- 打印线程 ID 和
ThreadCache
地址用于调试和验证每个线程是否正确地使用了自己的ThreadCache
实例。
为了验证 TLS 是否符合预期,我们可以检查每个线程是否有其独立的
Thread Cache
。验证的示例如下:
// UnitTest.cpp
void Alloc1()
{
for (size_t i = 0; i < 5; ++i)
{
void* ptr = ConcurrentAlloc(6);
}
}
void Alloc2()
{
for (size_t i = 0; i < 5; ++i)
{
void* ptr = ConcurrentAlloc(7);
}
}
void TLSTest()
{
std::thread t1(Alloc1);
t1.join();
std::thread t2(Alloc2);
t2.join();
}
int main()
{
TLSTest();
return 0;
}
调试监视结果:
由监视结果可以看出每个执行流都有独属于自己的 Thread Cache,因此可以做到无锁访问,故访问效率高。
2.Central Cache的结构设计
Central Cache也是一个哈希桶结构,他的哈希桶的映射关系跟thread cache是一样的。不同的是他的每个哈希桶位置挂是SpanList链表结构,不过每个映射桶下面的span中的大内存块被按映射关系切成了一个个小内存块对象挂在span的自由链表中。Thread Cache 的每个桶是一个自由链表,管理不同大小的内存对象(如 8 字节、16 字节)。而 Central Cache 的每个桶是一个带头的双向循环链表,管理 Span 对象。Span 是由多个连续页面组成的内存块,这些Span 被切割成更小的内存对象,并通过自由链表组织。Central Cache 和 Thread Cache 的对齐规则相同,但Central Cache 在 Span 级别上管理内存。 哈希桶的每个位置下面挂的都是Span对象链接的链表,不同的是:
- 8Byte映射位置下面挂的span中的页被切成8Byte大小的对象的自由链表。
- 256KB位置的span中的页被切成256KB大小对象的自由链表
当 Thread Cache 中某个特定桶没有内存对象时,它会向 Central Cache 申请更多。Central Cache 采用与Thread Cache 相似的哈希桶结构,并批量返回多个内存对象。在多线程环境中,Central Cache是一个临界资源,因此需要加锁来确保数据一致性。锁是桶锁,仅在不同线程同时访问同一个桶时才会产生竞争,从而减少串行化,提高性能。
当 Thread Cache 需要内存时,它会批量从 Central Cache 请求对象,采用类似 TCP拥塞控制的慢启动策略。Central Cache 使用 SpanList 哈希映射,从 Span 中提取对象并提供给 ThreadCache,这个过程需要加锁以保证线程安全。如果 Central Cache 中所有 Span 都没有内存,它会向 Page Cache申请新的 Span,将其切分成自由链表供将来使用。每个 Span 会记录已分配的对象数量(use_count),随着对象分配给 ThreadCache 而增加。
接下来我们先实现CentralCache的span:
当然可以!以下是进一步优化的描述:
2.1Span 结构
- 页数:表示 Span 包含的连续页面数量。
- 起始页号:用于判断是否能与相邻 Span 合并,减少碎片。
- 自由链表:管理切分的内存对象。
- 链表指针:前驱和后继指针用于在 Central Cache 中连接 Span。
- 使用计数:跟踪正在使用的内存对象数量,决定是否将 Span 归还给 Page Cache。
// Common.h
#ifdef _WIN64
typedef unsigned long long PAGE_ID;
#elif _WIN32
typedef size_t PAGE_ID;
#else
// linux
#endif
// Common.h
// 管理多个连续页大块内存跨度结构
struct Span
{
PAGE_ID _pageId = 0; // 大块内存起始页的页号
size_t _n = 0; // 页的数量
Span* _next = nullptr; // 双向链表的结构
Span* _prev = nullptr;
size_t _useCount = 0; // 切好小块内存,被分配给thread cache的计数
void* _freeList = nullptr; // 切好的小块内存的自由链表
};
在描述页号时,固定使用
size_t
可能不合适,因为页的数量在 32 位和 64 位程序中差异很大:
- 在 32 位程序中,页的总数为 (2^{19})。
- 在 64 位程序中,页的总数为 (2^{51})
为了解决这个问题,我们可以使用条件编译。具体来说:
- 32 位程序: 使用
_WIN32
宏(操作系统自带)- 64 位程序: 使用
_WIN32
和_WIN64
宏通过条件编译,可以根据不同的程序架构定义合适的页号类型。
Central Cache 中的 Span 对象由带头的双向循环链表维护。为了管理这些 Span 对象,我们实现了 SpanList
,即 Central Cache 的每个桶。当前,我们只需实现两个操作:Insert
和 Erase
,由于在CentralCache中会出现多执行流场景,所以为了解决锁竞争需要加一把锁。
// Common.h
// 带头双向循环链表类
class SpanList
{
public:
// 构造函数
SpanList()
{
// 创建链表头结点
_head = new Span;
// 头结点的前驱和后继都指向自己,形成一个空链表
_head->_next = _head;
_head->_prev = _head;
}
// 在指定位置插入新的 Span 对象
void Insert(Span* pos, Span* newSpan)
{
assert(pos); // 确保插入位置有效
assert(newSpan); // 确保新 Span 对象有效
Span* prev = pos->_prev; // 获取要插入位置的前一个节点
// 将 newSpan 插入到 pos 前面
prev->_next = newSpan; // 前一个节点的后继指向 newSpan
newSpan->_prev = prev; // newSpan 的前驱指向前一个节点
newSpan->_next = pos; // newSpan 的后继指向 pos
pos->_prev = newSpan; // pos 的前驱指向 newSpan
}
// 删除指定的 Span 对象,**加粗样式**不释放
void Erase(Span* pos)
{
assert(pos); // 确保要删除的 Span 对象有效
assert(pos != _head); // 确保不能删除头结点
Span* prev = pos->_prev; // 获取要删除节点的前一个节点
Span* next = pos->_next; // 获取要删除节点的后一个节点
// 更新前一个节点的后继和后一个节点的前驱,以跳过 pos 节点
prev->_next = next;
next->_prev = prev;
}
private:
Span* _head; // 链表的头结点
public:
std::mutex _mtx; // 用于保护链表的桶锁
};
2.2CentralCache结构
Central Cache 需要在多个线程间共享,因此,我们希望它是全局唯一的。为此,我们可以使用单例模式。单例模式分为饿汉和懒汉两种,我们在这里采用懒汉模式来实现,使得 Central Cache 在首次需要时才初始化。
饿汉模式:
- 将构造函数私有
- 将构造函数删除,防止拷贝
- 将单例对象作为一个静态成员变量放在类中,它会在程序启动时自动创建。
在实践中,应避免使用全局变量。当多个代码部分需要访问同一对象时,单例模式确保访问的是同一实例,并在对象较大时只创建一次,节省资源。
// CentralCache.h
// 单例模式
class CentralCache
{
public:
// 获取 CentralCache 的单例实例
static CentralCache* GetInstance()
{
return &_sInst;
}
// 获取一个非空的 Span,满足指定的字节大小
Span* GetOneSpan(SpanList& list, size_t byte_size);
// 从 Central Cache 获取一定数量的对象给 Thread Cache
size_t FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size);
// 将一定数量的对象释放到 Span 跨度
void ReleaseListToSpans(void* start, size_t byte_size);
private:
// SpanList 数组,用于存储不同大小的 Span
SpanList _spanLists[NFREELIST];
private:
// 私有构造函数,防止外部实例化
CentralCache() {}
// 删除拷贝构造函数,防止复制实例
CentralCache(const CentralCache&) = delete;
// 静态实例,保证单例模式
static CentralCache _sInst;
};
在 C++中,静态成员属于类而不属于任意实例,规定静态对象在类外定义。所以在ThreadCache.cpp中要创建一个全局静态对象。
// // CentralCache.cpp
CentralCache CentralCache::_sInst;
2.3慢启动反馈调节
ThreadCache 向 CentralCache 申请内存对象时,使用类似 TCP 慢启动的算法来管理请求数量。这种机制动态调整每次分配的对象数量,优化内存使用并减少对 CentralCache 的访问。
初始请求数量较小。如果 ThreadCache 经常耗尽缓存,它会逐渐增加请求量,减少频繁请求,提高效率。相反,如果缓存不经常用完,它会减少请求量,避免占用过多内存。
在这个项目中,ThreadCache 向 CentralCache 申请对象的数量限制在 [2, 512] 之间。当 ThreadCache 为空时,根据需要调整请求数量。
// Common.h
class SizeClass
{
public:
// 返回 ThreadCache 向 CentralCache 获取对象的个数
// objSize:单个对象的大小
static size_t NumMoveSize(size_t objSize)
{
assert(objSize); // 确保对象大小有效
// 计算 ThreadCache 能获取的最大对象个数
int num = TC_MAX_BYTES / objSize;
// 确保获取的对象数量在合理范围内
if (num < 2) // 如果对象大,至少获取两个
{
num = 2;
}
else if (num > 512) // 如果对象小,最多获取 512 个
{
num = 512;
}
return num;
}
};
这种方法有局限性,尤其是对象很小时,num
设为 512 可能过多。为避免申请过多小对象,可以在自由链表 FreeList
中增加一个计数器 _maxSize
(初始为 1),表示 ThreadCache
中自由链表的最大对象数。随着新对象的加入,_maxSize
会递增,以优化对象申请数量。
// Common.h class FreeList
size_t& MaxSize()
{
return _maxSize;
}
public:
size_t _maxSize = 1; // Object 的最大个数
};
返回引用的原因是后面在向 CentralCache 申请新 Object 时需要更新_maxSize。
当
ThreadCache
首次向CentralCache
申请对象时,初始申请只能获取一个对象。之后的每次申请对象数量会逐渐增加,线性增长的过程被称为“慢增长”:
- 初始申请:第一次申请时,获取一个对象。
- 慢增长:下一次申请时,增加的数量为
_maxSize + 1
,即两个对象。每次增加一个,直到_maxSize
达到NumMoveSize(size)
。这种增长方式是为了避免频繁的内存分配操作。为了提高效率,可以将每次增长的对象数量调整为多个而非单个,以加快增长速度。
控制逻辑:在 _maxSize达到
NumMoveSize(size)
之前,使用min(_maxSize, NumMoveSize(size))
控制申请的对象数量。这样,直到_maxSize
达到NumMoveSize(size)
,申请的对象数量保持为_maxSize
,之后再改为NumMoveSize(size)
。
CentralCache::FetchRangeObj()
函数用于从CentralCache
中取出一定数量的对象,并提供给
ThreadCache
。它的主要逻辑如下:
计算和返回结果:
- 函数需要返回两个值:
- 实际分配的对象数量:这是
ThreadCache
需要用来判断是否获得了足够数量的对象。- 对象的起始和终止地址:这通过输出型参数传递给调用者,确保
ThreadCache
知道分配对象的内存范围。哈希桶和
GetOneSpan()
:
- 在分配对象之前,函数需要根据
ThreadCache
需要的字节数bytes
计算哈希桶的下标index
。- 使用计算得到的
index
,通过GetOneSpan()
从相应的桶中取出一个非空的Span
。- 将
Span
的首尾地址返回,以便为ThreadCache
提供所需的对象。锁的处理:
- 在访问哈希桶之前,需要加锁以确保线程安全。
GetOneSpan()
的实现将涉及到与PageCache
的交互,这部分逻辑稍后会详细讨论。
// CentralCache.cpp
// 从中心缓存获取一定数量的对象给thread cache
size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size)
{
size_t index = SizeClass::Index(size);
_spanLists[index]._mtx.lock();
Span* span = GetOneSpan(_spanLists[index], size);
assert(span);
assert(span->_freeList);
// 从span中获取batchNum个对象
// 如果不够batchNum个,有多少拿多少
start = span->_freeList;
end = start;
size_t i = 0;
size_t actualNum = 1;
while (i < batchNum - 1 && NextObj(end) != nullptr)
{
end = NextObj(end);
++i;
++actualNum;
}
span->_freeList = NextObj(end);
NextObj(end) = nullptr;
span->_useCount += actualNum;
_spanLists[index]._mtx.unlock();
return actualNum;
}
在
CentralCache::FetchRangeObj()
函数中,链表迭代的逻辑至关重要。以下是对这一逻辑的优化说明:
链表迭代的步数:
- 标准情况:如果需要从链表中获取
n
个对象,通常需要让end
指针向后移动n
步。这样可以确保end
指针在链表中前进了足够的距离,以获取所需的对象。- 子链的特殊处理:由于
ThreadCache
使用的子链需要对最后一个对象的next
指针进行处理,因此end
指针仅需要移动n-1
步。这样做的原因是为了确保子链中的最后一个对象的next
指针被置空,使得这部分链表的末尾正确地标记为nullptr
,避免错误的链表连接。链表终止条件:
- 如果在
Span
中没有足够的对象(即小于n
个),则需要取出所有可用的对象。在这种情况下,end
指针的移动直到NextObj(end) == nullptr
,即直到end
指针遇到链表的末尾。这确保了即使链表中的对象数量不足以满足请求,函数也能正确处理链表终止的条件。总结:
- 在获取对象的过程中,需要合理处理链表的末尾,以确保子链的正确性。
- 对于链表长度不足的情况,迭代的停止条件应当以链表的实际结束位置为准,确保所有可用对象都被取出。
这个逻辑帮助确保了在
CentralCache
中高效、安全地管理和分配内存,同时满足了ThreadCache
的需求。
在
ThreadCache::FetchFromCentralCache()
函数中,实现 ThreadCache 向CentralCache 申请 Object 的逻辑如下:逻辑概述
慢开始反馈调节:
- 初始化时,
batchNum
用于记录每次申请的对象数量。- 在慢增长阶段(即
maxSize
还没有达到NumMoveSize(size)
),每次申请后,batchNum
会递增,从而实现线性增长。这样可以避免一次性申请过多的对象,控制申请速率。申请内存并管理对象:
- 使用
start
和end
两个指针来维护申请到的内存段。这两个指针用于跟踪从 CentralCache 中取出的内存范围。- 如果从 CentralCache 申请的内存段只包含一个对象(即
start
等于end
),则直接返回这个对象。- 如果申请的内存段包含多个对象,则需要调用自由链表的
PushRange
接口,将这一段对象范围插入到 ThreadCache 的自由链表中,以便后续使用。返回内存地址:
FetchFromCentralCache
函数需要返回内存地址,因为当 ThreadCache 的某个 SizeClass 的对象用尽时,ThreadCache 需要向 CentralCache 申请新的对象。- 返回的内存地址是 ThreadCache 向 CentralCache 请求的对象的起始位置。
关键点说明
- 慢开始调节:在慢增长阶段,
batchNum
每次递增,控制了申请的对象数量。这样可以避免一次性申请过多对象,导致内存的浪费或不必要的压力。- 对象范围管理:
start
和end
指针用于标记从 CentralCache 获取的对象范围,如果只有一个对象,直接返回;如果有多个对象,则将这段对象范围插入到自由链表中。- 返回内存地址:由于线程的线程缓存可能会空,因此
FetchFromCentralCache
需要返回实际的内存地址,以供 ThreadCache 使用。这种设计确保了线程缓存高效地从 CentralCache 中获取对象,同时保持了内存使用的灵活性和可控性。
首先实现自由链表的 PushRange()和PopRange():
// Common.h class FreeList
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)
{
assert(n <= _size);
start = _freeList;
end = start;
for (size_t i = 0; i < n - 1; ++i)
{
end = NextObj(end);
}
_freeList = NextObj(end);
NextObj(end) = nullptr;
_size -= n;
}
ThreadCache::FetchFromCentralCache()的实现:
// ThreadCache.cpp
// ThreadCache 从 CentralCache 中获取 Object
void* ThreadCache::FetchFromCentralCache(size_t index, size_t size)
{
// 慢开始反馈调节算法
// 1、最开始不会一次向central cache一次批量要太多,因为要太多了可能用不完
// 2、如果你不要这个size大小内存需求,那么batchNum就会不断增长,直到上限
// 3、size越大,一次向central cache要的batchNum就越小
// 4、size越小,一次向central cache要的batchNum就越大
size_t batchNum = min(_freeLists[index].MaxSize(), SizeClass::NumMoveSize(size));
if (_freeLists[index].MaxSize() == batchNum)
{
_freeLists[index].MaxSize() += 1;
}
void* start = nullptr;
void* end = nullptr;
size_t actualNum = CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, size);
assert(actualNum > 0);
if (actualNum == 1)
{
assert(start == end);
return start;
}
else
{
_freeLists[index].PushRange(NextObj(start), end, actualNum - 1);
return start;
}
}
重点:
ThreadCache::FetchFromCentralCache()
返回的是一个对象的地址。这是从 CentralCache 中获取的一段内存中的第一个对象的地址。这个对象会通过线程调用 ThreadCache::Allocate()
被实际分配给线程。与此同时,除了这个分配出去的对象,新申请的其他对象会被添加到 ThreadCache
对应 SizeClass 规格的自由链表中,以便于后续快速分配。
3.Page Cache的结构设计
Page Cache 也是一个哈希桶结构,每个桶包含一个
SpanList
,挂载若干个Span
对象。与 Central
Cache 的区别在于:
- 对齐规则:Central Cache 和 Thread Cache 的对齐规则一致,而 Page Cache 的不同。
- 内存分配:Central Cache 的
Span
对象会被切分为多个内存对象挂载到_freeList
,而 Page Cache 的Span
不会被切分。
Page Cache 的对齐规则
Page Cache 中的
Span
是由多个页组成。桶的下标映射不同数量页的Span
对象:
- 下标为 1 的桶映射到 1 页的
Span
。- 下标为 2 的桶映射到 2 页的
Span
。- 依次类推,最后一个桶映射到 128 页的
Span
。0 号下标为空,以简化映射关系。
如图所示:
Central Cache 与 Page Cache 的交互
当 Central Cache 需要
Span
时,会请求 Page Cache 提供指定页数的Span
对象。例如,需要 1
页的Span
,Page Cache 会检查对应桶是否有可用Span
,如有则直接返回;否则,会向系统申请新的Span
。为了减少频繁申请内存带来的外碎片问题,Page Cache 每次向系统请求 128 页的连续内存。这样可以在需要时将其分割为不同大小的
Span
,例如:
- 1 页请求:分割为 1 页和 127 页的
Span
。- 2 页请求:分割为 2 页和 126 页的
Span
。这种方法允许在
Span
归还时,合并未使用的连续页,从而缓解外碎片问题。
多线程环境中的线程安全
在多线程环境中,多个执行流可能同时访问 Page Cache。为保证线程安全,Page Cache 使用一把全局锁,而不是桶锁。这是因为每次操作可能涉及多个桶,使用全局锁可以减少频繁的锁操作,提升性能。
单例模式设计
和 Central Cache 类似,Page Cache 也被设计为单例模式,并采用懒汉模式确保只有一个实例存在。
// PageCache.h
class PageCache
{
public:
static PageCache* GetInstance()
{
return &_sInst;
}
// 获取从对象到span的映射
Span* MapObjectToSpan(void* obj);
// 释放空闲span回到Pagecache,并合并相邻的span
void ReleaseSpanToPageCache(Span* span);
// 获取一个K页的span
Span* NewSpan(size_t k);
std::mutex _pageMtx;
private:
SpanList _spanLists[NPAGES];
std::unordered_map<PAGE_ID, Span*> _idSpanMap;
PageCache()
{}
PageCache(const PageCache&) = delete;
static PageCache _sInst;
};
3.1实现 CentralCache::GetOneSpan
CentralCache 使用 PageCache 的哈希桶结构来获取 Span
。如果某个 SizeClass
的 SpanList
中没有可用 Span
,需要从 PageCache 获取新的 Span
。在此之前,会遍历自己的 SpanList
,因此需要实现 SpanList
的迭代器 begin
和 end
。
内存申请逻辑
当遍历完 SpanList
,CentralCache 向 PageCache 申请内存。申请的大小基于对象大小,因为它必须满足 ThreadCache 的需求。CentralCache 一次性申请尽可能大的 Span
来减少申请次数。
步骤:
计算需要的页数:
- 根据 ThreadCache 一次申请的最大对象数
- 乘以对象大小
- 除以页大小(8KB,即 (2^13) 字节)
这样确保每次申请的内存至少满足 ThreadCache 的需求,从而减少多次申请带来的性能开销。
class SizeClass
{
public:
// ...
// 返回 CentralCache 向 PageCache 申请的页数
// bytes: ThreadCache 向 CentralCache 申请 Object 的字节数
static size_t NumMovePage(size_t objBytes)
{
size_t num = NumMoveSize(objBytes); // Object 个数
size_t size = num * objBytes; // 一次性申请的最大字节数
size_t nPage = size >> PAGE_SHIFT;
if (nPage == 0)
{
nPage = 1; // 至少给 1 页
}
return nPage;
}
};
从 PageCache 申请内存并切割 Span
一旦确定了需要申请的页数,就可以找到对应的哈希桶。切割
Span
的过程如下:
计算起始地址:
- 用
Span
的起始页号乘以页大小,得到起始地址。计算内存跨度:
- 用
Span
的页数乘以页大小,得到内存的跨度。计算终止地址:
- 将起始地址加上跨度,得到终止地址。
通过这种方式,可以切割出合适的
Span
,以满足 CentralCache 的需求。
3.2切割 Span 并构建自由链表
为了将一个大的 Span
切割成由自由链表组织的对象,按照以下步骤操作:
设置地址范围:
- 使用
start
和end
指针确定Span
的内存范围。初始化指针:
tail
指针最初指向start
,表示已分配内存的尾部。构建链表:
- 通过循环,将
start
赋值给tail
的next
指针。start
向后移动SizeClass
字节,新建一个对象并挂到链表上。- 更新
tail
的位置到新的start
。终止链表:
- 当
start
到达end
时,将最后一个对象的next
指针设为nullptr
,防止越界访问。
通过这些步骤,可以有效地将内存切割成多个对象,并形成一个正确的链表结构。
一个内存块中的 FreeList 能够让一个 Span 中的 Object 在物理上是连续的。线程在使用连续内存时,可以提高 CPU 的高速缓存命中率。
3.3将切割好的 Span 插入 SpanList
切割完成后,将所需的部分挂到对应的 SizeClass
的 SpanList
上。选择头插法的原因是为了便于快速访问非空的 Span
,避免遍历。具体步骤如下:
头插法插入:
- 将新
Span
插入到SpanList
的开头。- 更新新
Span
的前驱和后继指针。头删法删除:
- 直接从
SpanList
的头部取出Span
,方便快速访问。
通过这种方式,可以提高 CentralCache
的效率,使其更快找到可用的 Span
。
// Common.h
class SpanList
{
public:
void PushFront(Span* span)
{
Insert(Begin(), span);
}
Span* PopFront()
{
Span* front = _head->_next;
Erase(front);
return front;
}
};
// CentralCache.h
// 获取一个非空的span
Span* CentralCache::GetOneSpan(SpanList& list, size_t size)
{
// 查看当前的spanlist中是否有还有未分配对象的span
Span* it = list.Begin();
while (it != list.End())
{
if (it->_freeList != nullptr)
{
return it;
}
else
{
it = it->_next;
}
}
// 先把central cache的桶锁解掉,这样如果其他线程释放内存对象回来,不会阻塞
list._mtx.unlock();
// 走到这里说没有空闲span了,只能找page cache要
PageCache::GetInstance()->_pageMtx.lock();
Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(size));
span->_isUse = true;
PageCache::GetInstance()->_pageMtx.unlock();
// 对获取span进行切分,不需要加锁,因为这会其他线程访问不到这个span
// 计算span的大块内存的起始地址和大块内存的大小(字节数)
char* start = (char*)(span->_pageId << PAGE_SHIFT);
size_t bytes = span->_n << PAGE_SHIFT;
char* end = start + bytes;
// 把大块内存切成自由链表链接起来
// 1、先切一块下来去做头,方便尾插
span->_freeList = start;
start += size;
void* tail = span->_freeList;
int i = 1;
while (start < end)
{
++i;
NextObj(tail) = start;
tail = NextObj(tail); // tail = start;
start += size;
}
// 切好span以后,需要把span挂到桶里面去的时候,再加锁
list._mtx.lock();
list.PushFront(span);
return span;
}
CentralCache::GetOneSpan()
用于从CentralCache
获取一个非空的Span
,由CentralCache::FetchRangeObj()
调用,返回Span
的地址以提取特定数量的对象。页号计算:
- 通过地址除以 (2^13) 得到页号。
- 指针值是无符号整数,页号乘以 (2^{13}) 后需转换为
char*
,以正确访问内存地址。这样可以确保正确的内存访问和高效的对象检索。
3.4PageCache::NewSpan
PageCache::NewSpan()
为 CentralCache
提供新的 Span
,用于从 PageCache
中获取指定页数的 Span
。
关键点:
获取 Span
:
- 当某个
SizeClass
的桶为空时,调用PageCache::NewSpan()
。 - 找到对应页数的
Span
,如果没有则顺延寻找更大的Span
,切割后将剩余部分挂回桶中。
系统内存申请:
- 若所有桶无可用
Span
,则调用SystemAlloc
向系统申请。 - 将内存起始地址除以 (2^13) 转换为页号,以页为单位管理内存。
这样可以高效管理内存并减少系统调用次数。
// PageCache.cpp
// 获取一个K页的span
Span* PageCache::NewSpan(size_t k)
{
assert(k > 0 && k < NPAGES);
// 先检查第k个桶里面有没有span
if (!_spanLists[k].Empty())
{
return _spanLists->PopFront();
}
// 检查一下后面的桶里面有没有span,如果有可以把他它进行切分
for (size_t i = k + 1; i < NPAGES; ++i)
{
if (!_spanLists[i].Empty())
{
Span* nSpan = _spanLists[i].PopFront();
Span* kSpan = new Span;
// 在nSpan的头部切一个k页下来
// k页span返回
// nSpan再挂到对应映射的位置
kSpan->_pageId = nSpan->_pageId;
kSpan->_n = k;
nSpan->_pageId += k;
nSpan->_n -= k;
_spanLists[nSpan->_n].PushFront(nSpan);
// 存储nSpan的首位页号跟nSpan映射,方便page cache回收内存时
// 进行的合并查找
_idSpanMap[nSpan->_pageId] = nSpan;
_idSpanMap[nSpan->_pageId + nSpan->_n - 1] = nSpan;
// 建立id和span的映射,方便central cache回收小块内存时,查找对应的span
for (PAGE_ID i = 0; i < kSpan->_n; ++i)
{
_idSpanMap[kSpan->_pageId + i] = kSpan;
}
return kSpan;
}
}
// 走到这个位置就说明后面没有大页的span了
// 这时就去找堆要一个128页的span
Span* bigSpan = new Span;
void* ptr = SystemAlloc(NPAGES - 1);
bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
bigSpan->_n = NPAGES - 1;
_spanLists[bigSpan->_n].PushFront(bigSpan);
return NewSpan(k);
}
PageCache::NewSpan()
使用递归处理Span
的获取和分配,解决了两种情况:
初始情况:
- 如果
PageCache
中没有Span
,则递归到最后会向系统申请一个 128 页的Span
。- 如果请求的页数
k
不等于 128,递归过程中会将 128 页的Span
切分为k
页的Span
并返回,剩余部分挂回到 128-k 页的桶中。其他情况:
- 如果
k
桶为空,则递归查找更大的n
桶,直到找到可用的Span
。这种递归方法确保了内存管理的高效性和灵活性。递归可以用另外的逻辑代替,但是这里权衡递归的成本和代码复用的收益后,后者更加重要,因为递归最多一层。
3.5PageCache 的锁问题
CentralCache 向 PageCache 申请 Span 加 Page 锁
在设计 PageCache 类的最后讨论了:由于分割和合并 Span 的需要,只用桶锁对代码实现的要求很高,而通过 CentralCache 在向 PageCache 申请内存时对一整个 PageCache 加锁,保证并发安全问题。TCMalloc 在 CentralCache 向 PageCache 申请内存的前后加锁。
CentralCache 向 PageCache 申请 Span 解桶锁
当 CentralCache 已经到了要向 PageCache 申请 Span 的地步时,桶内已经没有 Span 了。而 CentralCache 是持有锁才能访问这个桶的,这可以保证在并发情况下多个线程取出 Object 不出现错误。
然而 ThreadCache 也需要释放一部分 Object 到 CentralCache 中,因此在 CentralCache 向 PageCache 申请 Span 之前(这意味着代码跳到其他地方),把桶锁解开,这样当其他 ThreadCache 想归还 Object 给 CentralCache 的这个桶时就不会被阻塞了。
当 CentralCache 访问完 PageCache(申请 Span)后,不应该立即加上桶锁,因为 CentralCache 拿到新申请的 Span 后,还要对它进行切分。这个划分过程只涉及该线程本地的操作,不需要加锁。所以加桶锁的逻辑应该放在“挂到桶上”之前。
注意将 Span 挂到桶上是访问桶的操作,所以要加锁,保证原子性。
加解锁的位置不要想当然,要从资源竞争的角度理解(想象一个线程在访问临界资源的时候其他线程有没有可能访问,是读还是写,会不会影响线程的行为)。加锁意味着要访问临界资源;解锁意味着从访问资源出来。
六、释放内存设计
1.ThreadCache::Deallocate
当线程使用后的对象被 ThreadCache
回收时,ThreadCache
是线程私有的。当 ThreadCache
中积累了过多的对象时,需要将部分对象返回给 CentralCache
,以便其他线程使用。
解决方案:
限定自由链表的长度,这个长度设定为:ThreadCache
一次性向 CentralCache
申请的对象个数。
为什么不直接设置为一个固定值?
不同的应用程序和工作负载有不同的内存使用模式,因此不同线程需要的对象个数不同。将对象最大个数与 ThreadCache
的申请行为相关联,可以确保自由链表的长度既不会太小也不会太大,从而优化内存使用。
此外,ThreadCache
和 CentralCache
之间的交互涉及同步操作,这在多线程环境中可能是一个高成本过程。通过将自由链表的长度与 ThreadCache
的请求行为匹配,可以减少线程之间为了内存分配而进行的同步次数,从而提高性能。
在 TCMalloc
的实现中,还考虑了限制单个线程的内存上限,即 ThreadCache
整体占用的内存不能超过某个值。
//TreadCache.cpp
// 回收内存
void ThreadCache::Deallocate(void* ptr, size_t bytes)
{
assert(ptr);
assert(bytes <= TC_MAX_BYTES);
size_t index = SizeClass::Index(bytes); // 定位到哈希桶
_freeLists[index].Push(ptr); // 插入到桶中
// 当自由链表长度大于等于一次向 CentralCache 申请的个数,再归还这部分
if (_freeLists[index].Size() >= _freeLists[index].MaxSize())
{
ListTooLong(_freeLists[index], bytes);
}
}
void ThreadCache::ListTooLong(FreeList& list, size_t bytes)
{
void* start = nullptr;
void* end = nullptr;
list.PopRange(start, end, list.MaxSize());
CentralCache::GetInstance()->ReleaseListToSpans(start, bytes);
}
补充链表 PopRange 接口,用于头删一段 Object。
// Common.h
class FreeList
{
public:
// 从自由链表获取一段范围的 Object
// start/end:输出型参数
void PopRange(void*& start, void*& end, size_t n)
{
assert(n <= _size);
// 头删
start = _freeList_ptr;
end = start;
for (size_t i = 0; i < n - 1; i++)
{
end = NextObj(end);
}
_freeList_ptr = NextObj(end);
NextObj(end) = nullptr;
_size -= n;
}
size_t Size()
{
return _size;
}
public:
void* _freeList_ptr = nullptr; // 自由链表的起始地址
size_t _size = 0; // 自由链表的结点个数
};
注意,这里删除到倒数第一个 Object 时就要停下来,和之前取 Object 时一样,将最后一个 Object 的 next 指针置空。两个输出型参数用于返回给 CentralCache,只有拿到地址才能操作。
2.CentralCache::ReleaseListToSpans
CentralCache
回收来自ThreadCache
的一段由若干Object
组成的自由链表。这段链表的起始和终止地址由参数start
和end
返回。CentralCache
会遍历这些Object
,然后将它们加入对应SizeClass
的Span
中,并将Span
的_usedCount
减少,表示ThreadCache
归还了这些Object
。如果某个
Span
的_usedCount
变为 0,则说明该Span
中的所有Object
都被归还,这时可以将该Span
还给PageCache
。
重要注意点: 归还的
Objects
可以通过bytes
确定它属于CentralCache
中index
对应的桶,还需要通过Object
的地址除以 (2^13) 得到页号,找到对应的Span
才能插入。这是因为ThreadCache
在调用ThreadCache::Deallocate()
时,归还Object
的个数和时机都是不确定的。
如果
ThreadCache
归还了n
个Object
,而CentralCache
对应的SpanList
上有m
个Span
,那么在插入之前需要逐个比对页号,时间复杂度是 (O(n \cdot m))。为此,可以尝试在CentralCache
调用PageCache::NewSpan()
分配Span
时,建立每个Span
中的页号和Span
首地址之间的映射关系,通过哈希表来提高效率。
注意:这里不建立每个 Page 的地址和 Span 地址之间的映射关系,因为 PageCache 按页管理 Span,页号对应哈希表,我们可以认为地址就相当于页号,这在之前是讨论过了的。
这样以后再要插入 Object 到 CentralCache 对应的 SpanList 上,只需要用 Object 的地址除以 2^13 得到的页号查询哈希表,就能直接找到 Span 的首地址,进行头插。
为 PageCache 增加哈希表:
//PageCache.h class PageCache
std::unordered_map<PAGE_ID, Span*> _idSpanMap;
用一个函数作为地址和 Span 地址的转换:
// 返回从 Object 地址到 Span 首地址的映射
Span* PageCache::MapObjectToSpan(void* obj)
{
PAGE_ID id = ((PAGE_ID)obj >> PAGE_SHIFT);
auto ret = _idSpanMap.find(id);
if (ret != _idSpanMap.end())
{
return ret->second;
}
else
{
assert(false);
return nullptr;
}
}
assert() 的参数为 false,它将生效,用于定位错误。
需要在 PageCache::NewSpan() 的不同分支中增加映射的逻辑:
CentralCache 要将一段 FreeList 归还,那么首先要加桶锁。然后从 FreeList 的起始地址开始遍历,通过PageCache::MapObjectToSpan() 获得每一个 Object 的页号,通过页号查哈希表,得到 Span 的地址,然后将它头插到 Span 中。
// CentralCache.cpp
// ThreadCache 释放若干 Object 到 CentralCache 的某个 Span 中
// start: ThreadCache 返回的一段内存的地址 bytes: 返回内存的字节数
void CentralCache::ReleaseListToSpans(void* start, size_t bytes)
{
size_t index = SizeClass::Index(bytes);
_spanLists[index]._mtx.lock(); // 加桶锁
// 遍历还回来的 Object
void* obj = start;
while (obj)
{
void* next = NextObj(obj);
// 通过 Object 首地址得到映射的 Span 的地址
Span* span = PageCache::GetInstance()->MapObjectToSpan(obj);
// 将 Object 头插到 Span 上
NextObj(obj) = span->_objectsList;
span->_objectsList = obj;
// 更新 Span 记录被分配 Object 的计数器
span->_usedCount--;
// 这个 Span 当初管理的所有 Object 都被还了回来
if (span->_usedCount == 0)
{
// 将 Span 从 CentralCache 的哈希桶取出,并还原信息
_spanLists[index].Erase(span);
span->_prev = nullptr;
span->_next = nullptr;
span->_objectsList = nullptr;
// 解 CentralCache 的桶锁
_spanLists[index]._mtx.unlock();
// 交给 PageCache 管理
PageCache::GetInstance()->_pageMtx.lock(); // 加 PageCache 大锁
PageCache::GetInstance()->ReleaseSpanToPageCache(span);
PageCache::GetInstance()->_pageMtx.unlock(); // 解 PageCache 大锁
// 加 CentralCache 的桶锁
_spanLists[index]._mtx.lock();
}
obj = next;
}
_spanLists[index]._mtx.unlock(); // 解桶锁
}
当 span->_usedCount == 0
时,说明该 Span
中的所有 Object
都已被归还,意味着这部分内存暂时没有被需要。将其返回给 PageCache
可以使这部分内存重新整合,满足其他大小的内存请求。如果将其保留在 CentralCache
中,虽然能更快响应相同大小的内存请求,但可能导致内存利用不足,特别是在内存需求动态变化的情况下。此外,CentralCache
主要处理频繁的、大小固定的内存请求。如果还要保留大量未使用的内存,可能会影响其处理效率和响应速度。
关键步骤:
将
Span
返回给PageCache
之前:
- 需要将其从
CentralCache
的桶中取出。- 持有桶锁,确保操作期间线程安全。
- 清空
Span
作为节点的信息,但保留页号和页数,以便PageCache
后续合并Span
。对
PageCache
的访问:
- 在访问
PageCache
之前和之后,需要加大锁以保证线程安全。通过这些步骤,可以有效地管理内存,优化
CentralCache
的处理效率和响应速度,同时确保内存的充分利用和安全性。
3.PageCache::ReleaseSpanToPageCache
随着 CentralCache
不断申请内存,大多数桶都会挂上 Span
。但是,有一种极端情况是 CentralCache
不断申请相同页数的 Span
,或者剩余的 Span
总是被挂到同一个桶,这会导致某些桶很长,而另一些桶很短。由于切分操作,大页面的 Span
不会太多,而小页面的 Span
会很多。因为切分时选择离 k 页最近的 n 页,导致 n-k 通常较小。CentralCache
从头遍历 SpanList
获取新 Span
,可能导致后面的许多小页面 Span
未被使用,形成外部碎片问题。
为了平衡 CentralCache
各桶中 Span
的数量,PageCache::ReleaseSpanToPageCache()
专门负责回收 CentralCache
归还的 Span
,并尝试合并 Span
。重点在于 Span
的合并,只要合并后的 Span
超过 128 页,就返回给操作系统。
在申请 Span
时,PageCache
首先申请 128 页的 Span
然后进行切割。合并后的 Span
也需要保证每个 Page
在地址上是连续的。因此,Span
的页号和页数在此发挥重要作用。从地址分布上,一个 n 页的 Span
可以向前或向后合并。
关键点:
- 均衡桶中的
Span
数量:
- 通过
PageCache::ReleaseSpanToPageCache()
回收并合并Span
,确保CentralCache
各桶中Span
数量差距不要太大。
- 合并后的
Span
管理:
- 合并后的
Span
超过 128 页则返回给操作系统。- 确保合并后的
Span
在地址上是连续的,以便有效管理内存。
- 解决外部碎片问题:
- 合并
Span
时,充分利用Span
的页号和页数,从地址分布上进行合理合并。通过这些措施,可以有效减少外部碎片问题,优化
CentralCache
的内存管理效率。
向前合并是将后面的 Span
加到前面,然后更新前面的页数。关键是要确保地址的连续性,也就是判断后面的页号是否等于前面的页号加页数。例如,如果前面的页号加页数为 4,且后面的页号为 4,则说明它们在被 PageCache
切割时是连续的。向后合并的逻辑类似。可以在一个循环中不断合并,直到遇到不符合相邻条件的情况为止。
现在的问题是,span->_usedCount == 0
还有另一种情况,即调用 PageCache::NewSpan()
时新分配的 Span
也是一个 Span
都没有被分配出去。为了避免合并和切分操作的冲突,可以使用一个布尔类型的变量 _isUsed
来标记 Span
是否已经被 CentralCache
使用,并将其作为 Span
的成员变量。
// Common.cpp
struct Span
{
// ...
bool _isUsed = false; // 标记 Span 是否正在被线程访问
};
在 CentralCache::GetOneSpan() 获取新 Span 后,立即将它改为 true,注意要在桶锁中进行。
CentralCache 在调用
PageCache::ReleaseSpanToPageCache()
向两边合并 Span时,PageCache 可能会访问 CentralCache 桶中的任何一个 Span,包括 CentralCache 归还的和 PageCache 刚分配给 CentralCache 的 Span。
为方便合并,在
PageCache::NewSpan()
切分 n 页的 Span 时,不需要像分配出去的 k 页 Span 那样建立Span 地址和其所有 Page 页号之间的映射关系。只需要为未分配的 n-k 页 Span 建立地址和其首尾 Page页号之间的映射关系。原因如下:
对于已分配的页面,不需要再跟踪其具体位置,因为这部分内存已经在使用中。只记录未分配部分的首尾地址,可以使合并操作更加简单和直接。
这个过程是可能的,因为它们在被 PageCache 分配之前属于一个 Span,内存是连续的,那么只要 Span 之间是相邻的,那么 SpanA2 的头和 SpanA1 的尾是可以合并的。当原先被使用的 Page 被还回来时仍然会这么合并。
举个例子,现在有两个相邻的两个 Span:
Span A1:空闲页面 [1, 40],映射关系:<1, A1>,<40, A1> Span A2:空闲页面 [41,
60],映射关系:<41, A2>,<60, A2> 现在,Span A1 的页面 [1, 30] 被分配出去,Span A2 的页面
[41, 50] 被分配出去。在以上策略下:
Span A1:空闲页面 [31, 40] Span A2:空闲页面 [51, 60] 现在,如果有一个需要 20页的内存请求,PageCache 可以快速检查这些空闲 Span 并认识到 Span A1 的后半部分和 Span A2的前半部分可以合并来满足这个请求。PageCache 不需要检查每个单独的页面是否被分配;它只需要查看这些 Span的空闲部分的记录。因此,它可以迅速定位到页面 [31, 40] 和页面 [51, 60] 可以合并成一个新的 Span,满足连续 20页的需求。
如果我们必须跟踪每个 Span的每一页,那么合并操作就需要检查每一页,确认哪些是空闲的,然后才能执行合并。这明显比只关注空闲部分的首尾地址更复杂,也更耗时。
了解了 PageCache 分割分配和合并回收 Span 的流程后,可以理解哈希表两者中发挥着不同的作用(见上图注释)。
页面合并的逻辑如下:
边界问题 PageCache 只能合并那些相邻且被还回来的 Page。这通过
_isused
标志来保证。如果某个Page(地址除以 2^13)不在哈希表中有记录,那么说明从它开始往后的内存都没有被 PageCache 分配。合并逻辑
- 相邻页面判断:
- 页面是相邻的,可以一直合并下去(循环)。
- 在合并后,及时更新页号和页数信息。
- 将被合并的 Span 从它所在的桶中删除,并释放 Span 的空间(管理 Object 的 Span 对象是通过
new
来创建的)。
- 挂载到桶上:
- 合并为更大的 Span 后,将其挂到 PageCache 对应的桶上。
- 由于后续也可能会合并,所以需要建立首尾页号和 Span 地址的映射关系。
- 超大 Span 处理:
- 如果合并后的 Span 超过了 PageCache 管理的最大 Span 规格(128 个 Page),则直接将其归还给操作系统,同时记得释放 Span 的空间。
合并操作
- 向前合并:
- 将后面的 Span 加到前面,更新前面的页数。
- 保证地址连续:后面的页号应等于前面的页号 + 页数。例如,前面的页号 + 页数是 4,刚好等于后面的页号 4,说明它们在被 PageCache 切割时是连续的。
- 向后合并:
- 逻辑与向前合并类似,将前面的 Span 加到后面,更新后的 Span 的信息。
总结 页面合并的核心是保持内存的连续性和高效利用。通过
_isused
标志保证合并的 Page 是已被还回的,并通过哈希表记录未分配部分的首尾地址,以简化合并操作。合并后的 Span 挂载到对应的桶上,并建立映射关系,超大 Span则归还给操作系统。向前和向后的合并逻辑相似,确保页面合并高效且正确。
首先补充向操作系统释放内存的函数:
// Common.h
// 直接将内存还给堆
inline static void SystemFree(void* ptr)
{
#ifdef _WIN32
VirtualFree(ptr, 0, MEM_RELEASE);
#else
munmap(ptr, size); // Linux下, sbrk、unmmap等
#endif
}
// PageCache.cpp
// PageCache 回收空闲的 Span,合并相邻的 Span
void PageCache::ReleaseSpanToPageCache(Span* span)
{
// 大于 128 页还给系统并释放空间
if (span->_nPage > PH_MAX_PAGES - 1)
{
void* ptr = (void*)(span->_pageId << PAGE_SHIFT); // 页号转换为地址
SystemFree(ptr);
delete span;
return;
}
// ...
}
注意向前或向后合并是以当前 Span 为基准的,所以向后合并不需要更新 Span 的页号,只需要更新它的页数。
在 PageCache::NewSpan() 中增加哈希映射: 注意向前或向后合并是以当前 Span 为基准的,所以向后合并不需要更新
Span 的页号,只需要更新它的页数。
在 PageCache::NewSpan() 中增加哈希映射:
下面是 PageCache::ReleaseSpanToPageCache() 的实现,逻辑还是比较清晰的:
// PageCache.cpp
// PageCache 回收空闲的 Span,合并相邻的 Span
void PageCache::ReleaseSpanToPageCache(Span* span)
{
// 大于 128 页还给系统并释放空间
if (span->_nPage > PH_MAX_PAGES - 1)
{
void* ptr = (void*)(span->_pageId << PAGE_SHIFT); // 页号转换为地址
SystemFree(ptr);
delete span;
return;
}
// 向前合并
while (true)
{
PAGE_ID preId = span->_pageId - 1; // Span 左边的页号
std::unordered_map<PAGE_ID, Span*>::iterator it = _idSpanMap.find(preId ); // 查表
if (it == _idSpanMap.end()) // 前面没有相邻的 Span
{
break;
}
Span* preSpan = it->second;
if (preSpan->_isUsed == true) // 正在被 CentralCache 使用
{
break;
}
if (preSpan->_nPage + span->_nPage > PH_MAX_PAGES - 1) // 合并后大于 128 页
{
break;
}
// 向前合并,更新信息
span->_nPage += preSpan->_nPage;
span->_pageId = preSpan->_pageId;
// 从桶中删除 preSpan 并其释放空间
_spanLists[preSpan->_nPage].Erase(preSpan);
delete preSpan;
}
// 向后合并
while (true)
{
PAGE_ID nextId = span->_pageId + span->_nPage; // Span 右边的页号
std::unordered_map<PAGE_ID, Span*>::iterator it = _idSpanMap.find(nextId); // 查表
if (it == _idSpanMap.end()) // 后面没有相邻的 Span
{
break;
}
Span* nextSpan = it->second;
if (nextSpan->_isUsed == true) // 正在被 CentralCache 使用
{
break;
}
if (nextSpan->_nPage + span->_nPage > PH_MAX_PAGES - 1) // 合并后大于 128 页
{
break;
}
// 向后直接合并,只更新页数
span->_nPage += nextSpan->_nPage;
// 从桶中删除 nextSpan 并其释放空间
_spanLists[nextSpan->_nPage].Erase(nextSpan);
delete nextSpan;
}
// 将合并后的新 Span 挂到桶上,并标记为空闲
_spanLists[span->_nPage].PushFront(span);
span->_isUsed = false;
// 建立新 Span 地址和首尾页号的映射关系,以方便它后续被合并
_idSpanMap[span->_pageId] = span;
_idSpanMap[span->_pageId + span->_nPage - 1] = span;
}
七、项目完善
1.大内存的申请和释放
1.1申请
以上项目我们只讨论了小于256KB的内存申请逻辑,下面对其进行补充。
我们规定 PageCache 的最大规格 Span 是 128 页(1MB),ThreadCache 的最大规格 Object 是 256KB(32 页)。因此,内存请求的分配策略如下:
- 小于 256KB(32 页)的内存请求由 ThreadCache 负责。
- 大于 256KB(32 页)且小于 1MB(128 页)的内存请求由 PageCache 负责。
- 大于 1MB(128 页)的内存请求交给操作系统处理。
为提高内存申请的效率,内存请求不应仅限于线程需要的精确数量,而应预留一些富余,以便快速处理后续的内存申请。256KB 的内存申请量较大,通常由 PageCache 处理。PageCache 以页(8KB)为单位向操作系统申请内存。因此,在之前实现的 RoundUp()
函数中,对于大于 256KB 的内存申请,按页(8KB)进行对齐。
// Common.cpp class SizeClass
// 获取向上对齐后的字节数
static inline size_t RoundUp(size_t bytes)
{
// ...
else if (bytes <= 256 * 1024)
{
return _RoundUp(bytes, 8 * 1024);
}
else // 大于 256KB 的按页 (8KB) 对齐
{
return _RoundUp(bytes, 1 << PAGE_SHIFT);
}
}
例如 258KB 的内存等于 32 页(256KB)+8KB,这 2KB 不足一页算作一页,最终对齐到 33 页,向 PageCache 申请
33*8KB=264KB,这多余的 6KB 就是内存碎片。
那么现在可以完善线程池的内存分配函数:
// ConcurrentAlloc.h
static void* ConcurrentAlloc(size_t bytes)
{
if (bytes > TC_MAX_BYTES) // 大于 256KB 的内存申请
{
size_t alignSize = SizeClass::RoundUp(bytes); // 按页对齐
size_t k = alignSize >> PAGE_SHIFT; // 对齐后的页数
PageCache::GetInstance()->_pageMtx.lock(); // 访问 PageCache 的 Span,加锁
Span* span = PageCache::GetInstance()->NewSpan(k); // 由 PageCache 分配
span->_objSize = bytes; // 统计大于 256KB 的页
PageCache::GetInstance()->_pageMtx.unlock(); // 解锁
return (void*)(span->_pageId << PAGE_SHIFT); // 返回内存地址
}
else
{
// ...
}
值得注意的是,内存是由线程调用线程池开放的接口 ConcurrentAlloc() 申请的,所以这个函数可以决定从哪里申请内存。超过 256KB 的内存不通过 ThreadCache 而直接访问 PageCache。
1.2释放
内存释放策略如下:小于 256KB(32 页)的内存释放给 ThreadCache;大于 256KB(32 页)且小于 1MB(128 页)的内存释放给 PageCache;大于 1MB(128 页)的内存释放给操作系统的堆区。与大内存的申请类似,大于 1MB 的内存直接归还给 PageCache。
那么现在可以完善线程池的内存回收函数:
// ConcurrentAlloc.h
static void ConcurrentFree(void* ptr)
{
assert(ptr);
// 查表找到内存属于哪个 Span
Span* span = PageCache::GetInstance()->MapObjectToSpan(ptr);
size_t size = span->_objSize; // Span 管理的字节数
if (size > TC_MAX_BYTES) // 大于 256KB,直接还给 PageCache
{
PageCache::GetInstance()->_pageMtx.lock();
PageCache::GetInstance()->ReleaseSpanToPageCache(span);
PageCache::GetInstance()->_pageMtx.unlock();
}
else // 小于 256KB,还给 ThreadCache
{
assert(TLSThreadCache_ptr);
return TLSThreadCache_ptr->Deallocate(ptr, size);
}
}
两个测试用例,大家可以先自己尝试一下
//大内存申请和释放测试
void BigAllocTest()
{
// 找 PageCache 申请
void* ptr1 = ConcurrentAlloc(257 * 1024); // 257KB
ConcurrentFree(ptr1);
// 找堆申请
void* ptr2 = ConcurrentAlloc(129 * 8 * 1024); // 129 页
ConcurrentFree(ptr2);
}
2.使用定长内存池代替 new 和 delete
在目前的实现中,Span 哨兵位头结点以及 ThreadCache 实例都是用 new 和 delete 申请和释放的,为了彻底脱离使用 malloc/free 函数,分别为它们增加一个内存池,用于分配 Span 和线程创建 ThreadCache 实例,单例模式可以不用定长内存池,在这里举个例子即可,比如,申请 Span 对象时,采用定长内存池的 New,释放 Span 对象时,采用定长内存池的 Delete,将定长内存池当作组件加入进来。
// PageCache.h
class PageCache
{
// ...
// span 对象池
object_pool<Span> _spanPool;
};
在申请和释放Span对象时,采用如下方案:
// 申请 Span 对象
Span* span = _spanPool.New();
// 释放 Span 对象
_spanPool.Delete(span);
3.线程查表加锁/ObjectPool 加锁
3.1线程查表加锁
PageCache 向系统申请内存,并在内存归还给操作系统之前负责管理这些内存。因此,需要建立 Span 和页号之间的映射关系,以便在 Object 回收和 Span 合并时能够快速查找和管理这些内存块。将哈希表交由 PageCache 维护是合理的选择。
由于哈希表属于 PageCache,线程在查表之前需要持有 PageCache 的互斥锁,以避免其他线程同时访问或修改这张表。这里使用 std::unique_lock 作为互斥锁,效果与之前使用的互斥锁相同,只是为了方便使用 std::unique_lock 提供的功能。
更具体地说:
- Span 和页号的映射关系:PageCache 需要管理从操作系统申请的内存。因此,需要一个数据结构来记录每个 Span 所对应的页号。这样,在回收 Object 或合并 Span 时,能够快速查找到对应的 Span。
- 互斥锁的使用:由于多个线程可能同时访问或修改哈希表,必须使用互斥锁来保护这些操作。使用 std::unique_lock 是为了更方便地管理锁的生命周期和异常安全。
- 合理性:将哈希表交由 PageCache 维护,是为了集中管理内存映射关系,便于高效地回收和合并内存。通过互斥锁来保护哈希表的访问,可以保证在多线程环境下的线程安全性。
- std::unique_lock 的优势:std::unique_lock 提供了灵活的锁管理机制,包括延迟锁定、尝试锁定、递归锁等功能,使得编写线程安全代码更加简洁和可靠。
3.2ObjectPool 加锁
我们知道 ThreadCache 的内存空间来自同一个 objectsPool,如果按上面这样写,在多线程情况下会出现问题。
初始情况下,如果线程 1 正好在第一个红框切换,线程 2从头开始执行,此时_remainBytes是上一个线程修改后的值,那么它不会进入if (objSize >_remainBytes)分支,那么_memory_ptr此时为nullptr,这会使定位 new 访问空指针。
解决办法是 ThreadCache 在使用New()的前后加互斥锁。在 ObjectPool 中增加互斥锁成员变量:
// ObjectPool.h
template<class T>
class ObjectPool
{
public:
std::mutex _poolMtx; // 防止 ThreadCache 申请时申请到空指针
};
加锁:
八、性能瓶颈分析
上面我们已经完成了高并发内存池的核心框架,接下来我们通过一个测试代码来测试程序是否理想:
#include "ConcurrentAlloc.h"
#include <vector>
#include <atomic>
#include <thread>
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.load());
printf("%u个线程并发执行%u轮次,每轮次free %u次: 花费:%u ms\n",
nworks, rounds, ntimes, free_costtime.load());
printf("%u个线程并发malloc&free %u次,总计花费:%u ms\n",
nworks, nworks * rounds * ntimes, malloc_costtime.load() + free_costtime.load());
}
// 单轮次申请释放次数 线程数 轮次
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]);
}
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.load());
printf("%u个线程并发执行%u轮次,每轮次concurrent dealloc %u次: 花费:%u ms\n",
nworks, rounds, ntimes, free_costtime.load());
printf("%u个线程并发concurrent alloc&dealloc %u次,总计花费:%u ms\n",
nworks, nworks * rounds * ntimes, malloc_costtime.load() + free_costtime.load());
}
int main()
{
size_t n = 10000;
std::cout << "==========================================================" << std::endl;
BenchmarkConcurrentMalloc(n, 4, 5);
std::cout << std::endl << std::endl;
BenchmarkMalloc(n, 4, 5);
std::cout << "==========================================================" << std::endl;
return 0;
}
代码的思路:创建若干个线程让它们分别通过内存池或者 malloc/free 来申请和释放内存,并分别查看二者所花费的时间,结果如下:
第一次结果:
第二次结果:
第三次结果:
我们可以清楚地看到,目前内存池的效率比 malloc/free 低得多。一个次要原因是我们申请和释放的内存对象大小相同,因此频繁访问 Central Cache 中的某个桶,导致该桶的锁带来了高串行化程度。为了更好地测试性能,我们将尝试申请不同大小的内存对象,更改如下:
// 将内存池和 malloc 申请对象更改为如下:
//v.push_back(malloc(16));
v.push_back(malloc((16 + i) % 8192 + 1));
//v.push_back(ConcurrentAlloc(16));
v.push_back(ConcurrentAlloc((16 + i) % 8192 + 1));
第一次结果:
第二次结果:
第三次结果:
我们从结果可以看到,此时二者的效率几乎是差不多的,但是,在多线程场景下,高并发内存池这样的效率是不尽人意的,所以我们要分析一下是什么原因导致高并发内存池的效率这么低的呢?
我们在此采用 vs 中的性能与诊断工具 ,注释掉malloc的部分,因为我们只需要看内存池的性能瓶颈在哪,分析结果如下:
可以看到,这个两个函数耗费的时间最长,而这也是 ConcurrentFree 调用的
由上图数据可知在我们目前所写的内存池中,锁的消耗是巨大的,高手在解决这一问题时是用基数树来解决的,下面我们就来看看如何优化这个问题。
九、基数树
1.基数树的了解
基数树(Radix Tree)是一种分段映射的数据结构,类似于内核中的页表设计。它通过分层次的映射关系来维护内存页号到 Span 指针的映射,以提高空间利用率和查找效率。
2.基数树的实现
我们在这里实现了三种基数树,分别适用于不同的程序位数和内存管理需求。具体实现如下:
2.1一层基数树
适用于 32 位程序。一层基数树使用直接定址法实现页号到 Span 指针的映射,其本质上是一个指针数组。
// PageMap.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;
}
};
2.2二层基数树
适用于 32 位程序,通过分层管理指针数组,以减少内存消耗。
- 第一层:高 5 位指针数组(大小为 32)。
- 第二层:每个第一层指针指向的数组(大小为 16384)。
// PageMap.h
// 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);
}
};
2.3三层基数树
适用于 64 位程序,通过三层管理指针数组,以更高效地处理大范围页号。
- 第一层:高 17 位指针数组。
- 第二层:中间 17 位指针数组。
- 第三层:低 17 位指针数组。
// PageMap.h
// 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() {
}
};
事实上,二层和三层的基数树在思路上是相同的,通过对一个整数进行分段映射,类似于内核中页表的设计。比如:
- 前 10 位比特映射一级页表。
- 中间 10 位比特映射二级页表。
- 后 12 位比特映射页内偏移。
这种方法的主要目的是减少内存消耗,提高空间利用率,同时能够快速定位目标数据。
基数树通过分层次的指针数组管理内存映射关系,在不同程序位数和内存需求下提供了高效的内存管理解决方案。通过这种方式,可以有效减少内存消耗,快速定位目标数据,提高系统性能。
由于测试的平台选择了 32 位,可以随便选几层基数树,这里将二层哈希表的实现放在PageMap.h中。Common.h 包含它以后,将 PageHeap 的哈希表换成基数树:
然后把所有哈希操作换成 get 和 set 函数。例如:
有了基数树,PageHeap::MapObjectToSpan() 就不用加锁了。
4.优化后性能测试
当采用基数树进行优化后,我们在对其进行测试 (测试代码不变),先测申请和释放的是同一大小的内存对象:
测试 4 个线程,10 轮,每轮 10000 次申请和释放固定大小的内存空间(同一个桶):
第一次测试结果:
第二次测试结果
测试 4 个线程,10 轮,每轮 10000 次申请和释放不同大小的内存空间(放开第二条注释的代码):
可以看到,此时的性能是有很大提升的。
基数树在内存管理中的应用能够显著提升性能,主要原因如下:
1. 固定结构避免了加锁开销
动态数据结构的加锁需求:
- 传统数据结构如
map
或unordered_map
,在执行GetSpanByAddr
等读操作时需要加锁。这是因为这些数据结构的结构是动态变化的。例如,红黑树在插入数据时可能会旋转,哈希表在插入数据时可能会增容。- 当一个线程在读取映射关系时,如果另一个线程正在写入,这些结构的动态变化会导致数据不一致,从而产生风险。因此,需要加锁来保护数据结构的一致性。
基数树的固定结构:
- 基数树的结构是固定的,不会因为写入操作而发生变化。这意味着在读取映射关系时,不会遇到结构变化的问题。因此,
GetSpanByAddr
这样的读操作不需要加锁,从而避免了加锁带来的开销。2. 写操作的集中管理
写操作的集中管理:
- 在内存管理中,写映射关系只有在两个特定情况下发生:
PageCache::NewSpan
PageCache::ReleaseSpanToPageCache
- 这些写操作通过 PageCache 的全局锁来保证线程安全。即,只有在 PageCache 中才会进行写映射关系,这些操作是集中管理的,不会在频繁的读操作中出现。
读写分离:
- 读映射关系的操作主要在以下两个场景中进行:
CentralCache::RealeseMemoryUnitsToSpans
- 对外提供的释放内存接口
ConcurrentDeallocate
- 读操作和写操作是分离的,不会同时在同一个位置上进行。因此,在读取映射关系时,其他线程不会在相同位置进行写操作,这进一步保证了读操作的线程安全。
3. 提高并行化度
- 高并行度:
- 由于基数树在读映射关系时不需要加锁,这使得多个线程可以并行地进行读操作,而不需要等待其他线程释放锁。这大大提高了系统的并行化度。
- 传统数据结构在高并发环境下,由于频繁的加锁和解锁操作,会导致性能下降。基数树通过固定结构和读写分离的设计,避免了这种性能瓶颈,从而提升了整体效率。
总结:
基数树通过固定结构避免了动态变化导致的加锁需求,并且通过集中管理写操作和读写分离的设计,提高了读操作的并行化度。正是这些特性,使得基数树在内存管理中的性能显著优于传统的数据结构。
十、项目总结
在本项目中,我们通过实现一个高并发内存池,深刻理解了高效内存管理的关键技术和策略。以下是主要收获:
-
内存池设计的精髓:
- 内存池 通过池化技术解决了内存碎片问题,包括内碎片和外碎片。通过定长内存池的设计,我们学会了如何在内存分配中减少碎片,提高内存利用率。
-
多线程环境下的内存管理:
- 线程缓存(Thread Cache)的实现展示了如何通过为每个线程提供独立的内存缓存来减少锁竞争,提高性能。
- 中央缓存(Central Cache)和页面缓存(Page Cache)的设计原理强调了在高并发场景下如何管理和优化大块内存的分配和释放。
-
高效数据结构的应用:
- 基数树(Radix Tree)在内存映射中的应用,帮助我们理解了如何通过分层映射优化内存查找效率,减少内存占用。
-
性能优化策略:
- 通过实现性能测试,我们验证了定长内存池和基数树等优化手段的效果,体会到在高并发环境下进行性能优化的挑战和解决方案。
总的来说,本项目加深了我们对高效内存管理技术的理解,尤其是在处理多线程和大规模内存分配时的复杂性。掌握了这些技术后,我们能够更好地应对实际开发中遇到的内存管理问题,提高程序的性能和稳定性。