目录
项目介绍
本项目设计一个高并发内存池(Concurrent Memory Pool),其原型是Google开源项目tcmalloc(Thread-Caching Malloc),即线程缓存的malloc,实现了高效的多线程内存管理,可用于替代系统的内存分配函数(malloc、free)。
这个项目是把tcmalloc最核心的框架简化后拿出来,模拟实现出一个自己的高并发内存池,目的就是学习tcamlloc的精华。
项目会用到C/C++、数据结构(链表、哈希桶)、操作系统内存管理、单例模式、多线程、互斥锁等方面的知识。
内存池介绍
池化技术
所谓“池化技术”,就是程序先向系统申请过量的资源,然后自己管理,以备不时之需。之所以要申请过量的资源,是因为每次申请该资源都有较大的开销,不如提前申请好了,这样使用时就会变得非常快捷,大大提高程序运行效率。
在计算机中,有很多使用“池”这种技术的地方,除了内存池,还有连接池、线程池、对象池等。以服务器上的线程池为例,它的主要思想是:先启动若干数量的线程,让它们处于睡眠状态,当接收到客户端的请求时,唤醒池中某个睡眠的线程,让它来处理客户端的请求,当处理完这个请求,线程又进入睡眠状态。
内存池
内存池是指程序预先从操作系统申请一块足够大内存,此后,当程序中需要申请内存的时候,不是直接向操作系统申请,而是直接从内存池中获取;同理,当程序释放内存的时候,并不真正将内存返回给操作系统,而是返回内存池。当程序退出(或者特定时间)时,内存池才将之前申请的内存真正释放。
内存池主要解决的问题
内存池主要解决的当然是效率的问题,其次如果作为系统的内存分配器的角度,还需要解决一下内存碎片的问题。
内存碎片分为内碎片和外碎片:
- 外部碎片是一些空闲的连续内存区域太小,这些内存空间不连续,以至于合计的内存足够,但是不能满足一些的内存分配申请需求。
- 内部碎片是由于一些对齐的需求,导致分配出去的空间中一些内存无法被利用。
malloc
C/C++中我们要动态申请内存并不是直接去堆申请的,而是通过malloc函数去申请的,包括C++中的new实际上也是封装了malloc函数的。
我们申请内存块时是先调用malloc,malloc再去向操作系统申请内存。malloc实际就是一个内存池,malloc相当于向操作系统“批发”了一块较大的内存空间,然后“零售”给程序用,当全部“售完”或程序有大量的内存需求时,再根据实际需求向操作系统“进货”。
malloc的实现方式有很多种,一般不同编译器平台用的都是不同的。比如windows的vs系列用的微软自己写的一套,linux gcc用的glibc中的ptmalloc。
实现定长内存池
malloc其实就是一个通用的内存池,在什么场景下都可以使用,但这也意味着malloc在什么场景下都不会有很高的性能,因为malloc并不是针对某种场景专门设计的。
定长内存池就是针对固定大小内存块的申请和释放的内存池,由于定长内存池只需要支持固定大小内存块的申请和释放,因此我们可以将其性能做到极致,并且在实现定长内存池时不需要考虑内存碎片等问题,因为我们申请/释放的都是固定大小的内存块。
定长内存池设计结构如下:
设计一个定长的内存池,为了将申请和释放与malloc分开,本项目要和malloc进行性能比较,那么各处实现就不能调用malloc以及对应的free,new和delete是C++的一个关键字,其底层调用了malloc和free,所以我们要避开使用C++的关键字,自己实现一个New和Delete。
#ifdef _WIN32
#include<Windows.h>
#else
#endif
template<class T>
class ObjectPool {
public:
T* New(){
T* obj = nullptr;
// 先从归还的自由链表中申请内存
if (_freeList) {
void* next = *(void**)(_freeList);
obj = (T*)_freeList;
_freeList = next;
}
// 若无,再从大块内存中申请
else {
// 若剩余大小不足,则申请内存
if (_remainBytes < sizeof(T)) {
_remainBytes = 1024 * 128;
_memory = (char*)SystemAlloc(_remainBytes >> 13);
if (_memory == nullptr) {
throw std::bad_alloc();
}
}
obj = (T*)_memory;
size_t objSize = sizeof(T) < sizeof(obj) ? sizeof(obj) : sizeof(T);
_remainBytes -= objSize;
_memory += objSize;
}
// 定位new,在已分配的原始内存空间中显示调用构造函数初始化一个对象
new(obj) T;
return obj;
}
void Delete(T* obj) {
obj->~T(); // 显示调用析构函数释放对象
// 头插
*(void**)obj = _freeList; // obj的前4/8个字节指向freeList
_freeList = obj; // 更新obj为freeList的头
}
private:
// 指向大块内存的指针
char* _memory = nullptr;
// 大块内存在切分过程中剩余多少字节
size_t _remainBytes = 0;
// 归还过程中自由链表的头指针
void* _freeList = nullptr;
};
需要注意的是,当内存块切分出来后,我们也应该使用定位new,显示调用该对象的构造函数对其进行初始化。同样,在释放对象时,我们应该显示调用该对象的析构函数清理该对象,因为该对象可能还管理着其他某些资源,如果不对其进行清理那么这些资源将无法被释放,就会导致内存泄漏。
既然是内存池,那么我们首先得向系统申请一块内存空间,然后对其进行管理。要想直接向堆申请内存空间,在Windows下,可以调用VirtualAlloc函数;在Linux下,可以调用brk或mmap函数。
// 直接去堆上申请内存空间
inline static void* SystemAlloc(size_t kpage) {
#ifdef _WIN32
void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#elif _WIN64
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;
}
高并发内存池整体框架设计
整体设计需要考虑的方面
- 性能问题。
- 多线程环境下,锁竞争问题。
- 内存碎片问题
整体框架设计
高并发内存池主要由以下三个部分构成:
- thread cache: 线程缓存是每个线程独有的,用于小于等于256KB的内存分配,线程从这里申请内存不需要加锁,每个线程独享一个cache,这也就是这个并发线程池高效的地方。
- central cache: 中心缓存是所有线程所共享的,当thread cache需要内存时会按需从central cache中获取内存,而当thread cache中的内存满足一定条件时,central cache也会在合适的时机对其进行回收。central cache是存在竞争的,所以从这里取内存对象是需要加锁,首先这里用的是桶锁,其次只有thread cache的没有内存对象时才会找central cache,所以这里竞争不会很激烈。
- page cache: 页缓存中存储的内存是以页为单位进行存储及分配的,当central cache需要内存时,page cache会分配出一定数量的页,并切割成定长大小的小块内存,分配给central cache。当一个span的几个跨度页的对象都回收以后,page cache会回收central cache满足条件的span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。
Thread Cache
自由链表
定长内存池只支持固定大小内存块的申请释放,因此定长内存池中只需要一个自由链表管理释放回来的内存块。
现在我们要支持申请和释放不同大小的内存块,那么我们就需要多个自由链表来管理释放回来的内存块,因此thread cache实际上一个哈希桶结构,每个桶中存放的都是一个自由链表。
自由链表中一定会有插入、删除、判空等操作,并且我们还可以记录个数_size,_maxSize这个桶最多能挂多少个,那么这么多个自由链表就需要被管理,我们设计一个管理自由链表的结构:
// 管理切分好的小对象的自由链表
class FreeList {
public:
void Push(void* obj) {
assert(obj);
NextObj(obj) = _freeList; // obj的下一个地址指向_freeList
_freeList = obj;
++_size;
}
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;
}
void* Pop() {
assert(_freeList);
void* obj = _freeList;
_freeList = NextObj(obj);
--_size;
return obj; // 返回被释放的小块内存对象
}
bool Empty() {
return _freeList == nullptr;
}
size_t& MaxSize() {
return _maxSize;
}
size_t Size() {
return _size;
}
private:
void* _freeList = nullptr;
size_t _maxSize = 1;
size_t _size = 0;
};
thread cache支持小于等于256KB内存的申请,如果我们将每种字节数的内存块都用一个自由链表进行管理的话,那么此时我们就需要20多万个自由链表,光是存储这些自由链表的头指针就需要消耗大量内存,这显然是得不偿失的。
这时我们可以选择做一些平衡的牺牲,让这些字节数按照某种规则进行对齐,例如我们让这些字节数都按照8字节进行向上对齐,那么thread cache的结构就是下面这样的,此时当线程申请1~8字节的内存时会直接给出8字节,而当线程申请9~16字节的内存时会直接给出16字节,以此类推。
因此当线程要申请某一大小的内存块时,就需要经过某种计算得到对齐后的字节数,进而找到对应的哈希桶,如果该哈希桶中的自由链表中有内存块,那就从自由链表中头删一个内存块进行返回;如果该自由链表已经为空了,那么就需要向central cache申请。
但此时由于对齐的原因,可能造成内部碎片。
对齐映射规则
对齐大小计算
该设计规则除了第一个桶的内碎片浪费大,保证其他桶内碎片浪费整体保证在10%左右。
内碎片浪费率=浪费的字节/分配的字节,比如现在有129字节,就要分配144字节,只使用第一个16byte对齐桶的1个字节,浪费15字节,但总共分配了128+16=144字节,所以内碎片浪费率=15/144=10.4%
根据设计规则,通过传入参数(字节数),进行简单逻辑判断跳转至子函数_RoundUp进行对齐后的字节数计算。
//管理对齐和映射等关系
class SizeClass
{
public:
//获取向上对齐后的字节数
static inline size_t RoundUp(size_t bytes);
//获取对应哈希桶的下标
static inline size_t Index(size_t bytes);
};
// 对齐大小计算
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 _RoundUp(size_t bytes, size_t alignNum) {
return ((bytes + alignNum - 1) & ~(alignNum - 1));
}
对齐后的字节数计算函数(_RoundUp)设计我们学习参考tcmalloc的实现,采用位运算的方式进行,该设计思路十分巧妙,值得我们去学习使用。
映射桶号计算
首先根据上面设计的对齐映射规则,我们可以计算得到对应桶号的区间,利用数组将区间桶号保存,再使用简单逻辑判断进入子函数(_Index)计算当前所在区间映射到的桶号,最终对齐映射的桶号=区间前的桶数+当前区间桶号
// 计算映射到哪一个自由链表桶
static inline size_t Index(size_t bytes) {
assert(bytes <= MAX_BYTES);
// 每个区间有多少个链
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[0] + group_array[1];
}
else if (bytes <= 64 * 1024) {
return _Index(bytes - 8 * 1024, 10) + group_array[0] + group_array[1] + group_array[2];
}
else if (bytes <= 256 * 1024) {
return _Index(bytes - 64 * 1024, 13) + group_array[0] + group_array[1] + group_array[2] + group_array[3];
}
else {
assert(false);
}
return -1;
}
static inline size_t _Index(size_t bytes, size_t align_shift) {
return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1; // 从0号桶开始映射
}
Thread Cache类
class ThreadCache {
public:
// 申请释放内存对象
void* Allocate(size_t size);
void Deallocate(void* ptr, size_t size);
// 从central cache中获取对象
void* FetchFromCentralCache(size_t index, size_t size);
// 将过长的freelists回收给CentralCache
void ListTooLong(FreeList& list, size_t size);
private:
FreeList _freeLists[NFREELIST];
};
// TLS thread local storage 线程本地存储
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;
// _declspec(thread) 用于声明线程安全函数,它可以在多线程环境中安全地被多个线程调用,而不会出现数据竞争或其他线程安全问题。
申请内存
// 申请内存对象,自由链表出一个内存对象
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);
}
}
当内存申请 size<=256KB ,先获取到线程本地存储的 Thread Cache 对象,计算 size 映射的哈希桶自由链表下标i 。
如果自由链表_freeLists[i] 中有对象,则直接 Pop 一个内存对象返回。
Pop()函数属于FreeList类中成员函数,因为是从自由链表上取走一个去使用,所以需要返回值void*。
如果_freeLists[i] 中没有对象时,则批量从 Central Cache 中获取一定数量的对象,头插入到自由链表并返回一个对象。
// 从central cache中获取内存对象
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;
}
}
对于需求不同字节大小,从Central Cache获取的分配个数又需要考虑性能, 对于分配8bytes,可以多分配一些(但要有上限),对于256*1024bytes,则少分配些(但要有下限)
采用慢开始反馈调节算法
1.最开始不会一次向Central Cache一次批量要太多,因为要太多可能用不完
2.如果不要这个size大小内存需求,那么betchNum就会不断增长,直到上限。
3.size越大,一次向Central Cache要的batchNum就越小
4.size越小,一次向Central Cache要的batchNum就越大
慢反馈调节算法
// 一次thread cache从中心缓存获取多少个
static size_t NumMoveSize(size_t size) {
assert(size > 0);
int num = MAX_BYTES / size;
if (num < 2)
num = 2;
if (num > 512)
num = 512;
return num;
}
如果只需要8Byte大小,从Central Cache获取批量数就是256*1024/8,其结果大于512,返回512个;如果需要256KB大小,从Central Cache获取批量数就是256KB/256KB=1,其结果小于2,返回2个。
这样设计批量在于确定上下限,不会使得从中心缓存获取的小块内存过多或过少,如果获取过多,一直不使用,达到一定数量时又会回收给Central Cache,多此一举,所以确定上下限。计算结果在上下限之间的就返回计算个数。
释放内存
当释放内存小于256Kb 时将内存释放回 Thread Cache ,计算 size 映射自由链表桶位置 i ,将对象 Push到_freeLists[i] 。
// 释放内存对象,自由链表收回一个内存对象
void ThreadCache::Deallocate(void* ptr, size_t size) {
assert(ptr);
assert(size <= MAX_BYTES);
// 找到映射的自由链表桶,将对象插入
size_t index = SizeClass::Index(size);
_freeLists[index].Push(ptr);
// 当链表长度大于一次批量申请的内存时,就还一段list给CentralCache
if (_freeLists[index].Size() >= _freeLists[index].MaxSize()) {
ListTooLong(_freeLists[index], size);
}
}
当链表的长度过长,则回收一部分内存对象到Central Cache 。
void ThreadCache::ListTooLong(FreeList& list, size_t size) {
void* start = nullptr;
void* end = nullptr;
list.PopRange(start, end, list.MaxSize());
CentralCache::GetInstance()->ReleaseListToSpans(start, size);
}
TLS(thread local storage)无锁访问
我们在设计中要求每一个线程都有一个独属于自己的Thread Cache类,如果我们把Thread Cache类实现为全局的,那么必然每个线程共享这个类,势必会发生竞争问题,需要加锁。
频繁的控制锁的加锁和解锁会增加时间成本,这显然和我们要的高性能不相符,所以这里提出一个变量存储方法TLS,线程局部存储TLS,该方法下:变量在当前线程下是全局可访问的,在线程和线程之间是独立局部的,这有效的实现了每个线程独属于自己的类,避免加锁。
// TLS thread local storage 线程本地存储
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;
// _declspec(thread) 用于声明线程安全函数,它可以在多线程环境中安全地被多个线程调用,而不会出现数据竞争或其他线程安全问题。
但不是每个线程被创建时就立马有了属于自己的thread cache,而是当该线程调用相关申请内存的接口时才会创建自己的thread cache,因此在申请内存的函数中会包含以下逻辑。
//通过TLS,每个线程无锁的获取自己专属的ThreadCache对象
if (pTLSThreadCache == nullptr)
{
pTLSThreadCache = new ThreadCache;
}
Central Cache
Central Cache也是一个哈希桶结构,他的哈希桶的映射关系跟Thread Cache 是一样的。不同的是他的每个哈希桶位置挂是SpanList 链表结构,不过每个映射桶下面的 span 中的大内存块被按映射关系切成了一个个小内存块对象挂在span 的自由链表中。
每个span管理的都是一个以页为单位的大块内存,每个桶里面的若干span是按照双链表的形式链接起来的,并且每个span里面还有一个自由链表,这个自由链表里面挂的就是一个个切好了的内存块,根据其所在的哈希桶这些内存块被切成了对应的大小。
页号类型
每个程序运行起来后都有自己的进程地址空间,在32位下,最高(2^32)/(2^13)=2^19,2^19我们需要4字节大小来表示,可以用size_t类型可以表示,但如果是64位下,页号最高(2^64)/(2^8)=2^51,我们需要8字节大小来表示,可以用unsigned long long类型。所以我们使用条件编译进行判断使用那种类型:
#ifdef _WIN64
typedef unsigned long long PAGE_ID;
#elif _WIN32
typedef size_t PAGE_ID;
#else
//linux
#endif
细节:64位系统下,包含了宏_WIN32和_WIN64;如果把_WIN32放在最开始判断,那么就无法识别出64位系统,会一直识别为32位,所以我们将_WIN64放在最开始判断64位系统
但实际上size_t在64位下是unsigned long long 或者unsigned _int64类型(范围:[0,2^64 -1]),32位下是unsigned int类型。如果想要编写可移植的代码,应该避免直接使用int或long类型,而是要使用size_t类型。
Span结构
central cache的每个桶里挂的是一个个的Span,Span是一个管理以页为单位的大块内存,Span的结构如下:
struct Span {
PAGE_ID _pageId = 0; // 大块内存起始页的页号
size_t _n = 0; //页的数量
Span* _next = nullptr; // 双向链表结构
Span* _prev = nullptr;
size_t _objSize = 0; //切好的小对象的大小
size_t _useCount = 0; // 切好小块内存,被分配给thread cache的数量
void* _freeList = nullptr; // 切好的小块内存的自由链表
bool _isUse = false; // 是否在被使用
};
对于Span管理的以页为单位的大块内存,我们需要知道这块内存具体在哪一个位置,便于之后page cache进行前后页的合并,因此span结构当中会记录所管理大块内存起始页的页号。
至于每一个span管理的到底是多少个页,这并不是固定的,需要根据多方面的因素来控制,因此span结构当中有一个_n成员,该成员就代表着该span管理的页的数量。
此外,每个span管理的大块内存,都会被切成相应大小的内存块挂到当前span的自由链表中,比如8Byte哈希桶中的span,会被切成一个个8Byte大小的内存块挂到当前span的自由链表中,因此span结构中需要存储切好的小块内存的自由链表。
span结构当中的_useCount成员记录的就是,当前span中切好的小块内存,被分配给thread cache的计数,当某个span的_useCount计数变为0时,代表当前span切出去的内存块对象全部还回来了,此时central cache就可以将这个span再还给page cache。
每个桶当中的span是以双链表的形式组织起来的,当我们需要将某个span归还给page cache时,就可以很方便的将该span从双链表结构中移出。如果用单链表结构的话就比较麻烦了,因为单链表在删除时,需要知道当前结点的前一个结点。
SpanList带头结点的双向链表
根据上面的描述,central cache的每个哈希桶里面存储的都是一个双链表结构,对于该双链表结构我们可以对其进行封装。
class SpanList {
public:
// head里不挂内存,相当于带头节点的链表
SpanList() {
_head = new Span;
_head->_next = _head;
_head->_prev = _head;
}
void Insert(Span* pos, Span* newSpan) {
assert(pos);
assert(newSpan);
Span* prev = pos->_prev;
prev->_next = newSpan;
newSpan->_next = pos;
newSpan->_prev = prev;
pos->_prev = newSpan;
}
void Erase(Span* pos) {
assert(pos);
assert(pos != _head);
Span* prev = pos->_prev;
Span* next = pos->_next;
prev->_next = next;
next->_prev = prev;
}
bool Empty() {
return _head->_next == _head;
}
Span* Begin() {
return _head->_next;
}
Span* End() {
return _head;
}
Span* PopFront() {
Span* front = _head->_next;
Erase(front);
return front;
}
void PushFront(Span* span) {
Insert(Begin(), span);
}
private:
Span* _head; // 头结点
public:
std::mutex _mtx; // 桶锁
};
需要注意的是,从双链表删除的span会还给下一层的page cache,相当于只是把这个span从双链表中移除,因此不需要对删除的span进行delete操作。
Central Cache结构
Central Cache:中心缓存是所有线程所共享, Thread Cache 是 按需从 Central Cache 中获取的对象。Central Cache 合适的时机回收 Thread Cache 中的对象,避免一个线程占用了太多的内存,而其他线程的内存吃紧,达到内存分配在多个线程中更均衡的按需调度的目的 。
Central Cache是存在竞争的,所以从这里取内存对象是需要加锁,首先这里用的是桶锁,其次只有 Thread Cache当 没有内存对象时才会找 Central Cache ,所以这里竞争不会很激烈 。
Central Cache是所有线程共享的,所以只设计1个,并且当程序运行的时候我们就要创建出来,所以我们用单例模式的饿汉模式。
// 单例模式
class CentralCache {
public:
// 获取实例
static CentralCache* GetInstance() {
return &_sInst;
}
// 获取一个非空的span
Span* GetOneSpan(SpanList& list, size_t byte_size);
// 从中心缓存获取一定数量的对象给thread cache
size_t FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size);
void ReleaseListToSpans(void* start, size_t size);
private:
SpanList _spanLists[NFREELIST];
private:
CentralCache() {
}
CentralCache(const CentralCache&) = delete;
static CentralCache _sInst;
};
申请内存
从中心缓存获取对象
当Thread Cache 中没有内存时,就会批量向 Central Cache 申请一些内存对象,这里的批量获取对象的数量使用了类似网络TCP协议拥塞控制的慢开始算法;Central Cache也有一个哈希映射的SpanList , SpanList 中挂着 Span ,从 span中取出对象给Thread Cache,这个过程是需要加锁的,不过这里使用的是一个桶锁,尽可能提高效率。
从Central Cache中的span取对象,那么一定是Thread Cache的桶中没有剩余的对象,因为我们是从span中获取的,那么一定是一段连续的内存,我们只需要首位地址就可以,而且需要将首位地址返回(设置为输出型参数) ,用来给Thread Cache头插挂接一段(PushRange)对象。
// 从central cache中获取内存对象
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;
}
}
从中心缓存获取一定数量的对象
这里我们要从central cache获取n个指定大小的对象,这些对象肯定都是从central cache对应哈希桶的某个span中取出来的,因此取出来的这n个对象是链接在一起的,我们只需要得到这段链表的头和尾即可,这里可以采用输出型参数进行获取。
// 从中心缓存中获取一定数量的对象给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); // 获取一个Span
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;
}
这里使用桶锁,防止多个线程同时访问一个桶,造成线程安全问题。
并且从Central Cache中的span切分(在GetOne中切分)batchNum对象给Thread Cache,但是可能实际上span并没剩下那么多,只能将剩下的分配给Thread Cache,所以需要统计一个实际值actualNum,_useCount+=actualNum更新span中切分出去的对象,保证回收不会出错。
返回实际分配到的对象数目,在Thread Cache中返回1个使用,剩余的actualNum头插挂接到Central Cache对应的桶上。
获取一个非空Span
Central Cache映射的spanList 中所有 span 的都没有内存以后,则需要向 Page Cache 申请一个新的span对象,拿到 span 以后将 span 管理的内存按大小切好作为自由链表链接到一起。然后从 span中取对象给Thread Cache。
// 获取一个非空的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中没有满足条件大小的内存
// 先把central cache的桶锁解掉,这样如果其他线程释放对象回来不会阻塞
list._mtx.unlock();
PageCache::GetInstance()->_pageMtx.lock();
Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(size));
span->_isUse = true;
span->_objSize = size;
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;
while (start < end) {
NextObj(tail) = start;
tail = NextObj(tail);
start += size;
}
NextObj(tail) = nullptr;
// 切好span以后,需要把span挂到桶里面去的时候再加锁
list._mtx.lock();
list.PushFront(span);
return span;
}
如果Central Cache当前桶有剩余的span,直接返回该span,不需要去Page Cache申请span。
如果没有剩余span,解开桶锁,进入PageCache中获取span,获取后记录使用情况和存储对象大小,并且Page Cache实际上我们也只设计了1个,所以他也需要加锁。
为什么要解开桶锁?
Central Cache是桶锁,PageCache是整个锁。在CentralCache::GetOneSpan() 中获取一个span,需要从Page获取Span时,先把桶锁解掉,如果此时线程1和2都执行GetOneSpan(),因为PageCache::NewSpan() 有整个锁,产生阻塞,也不会产生混乱。也就是说Central Cache在此时解不解锁在获取Span时作用一样,但是我可以线程1在这个桶拿Span,并且线程2在这个桶释放Span,为了提高效率,所以我们解开桶锁。
从Page Cache中获取span后,我们span中只存储了页信息,但没有他的地址信息,那我们怎么获得地址去管理连接内存对象呢?
这里就要引入一个概念:页的起始地址=页号*页大小
页的尾地址=起始地址+页的数量*页的大小
页号=页的起始地址/页大小
那么在相邻页之间地址,其地址大小小于后面一页的起始地址,除页大小必定也能得到该页的页号。这在回收中有着重要作用。
从Page Cache中获取到Span后,我们通过上面的概念,可以计算出该Span的起始地址和尾地址,我们再根据对象大小进行切分,因为内存物理上其实是连续的,而我们这里要在抽象的把他形成链式结构,我们就需要通过尾插来保证地址的连续。切好后将该Span挂在Central Cache的桶。
Central Cache的中挂的span 中_ useCount 记录分配了多少个对象出去,分配一个对象给Thread Cache,就 ++_useCount。
释放内存
当Thread Cache 过长或者线程销毁,则会将内存释放回 Central Cache 中的,释放回来时 _
useCount-- 。当 useCount 减到 0 时则表示所有对象都回到了 span ,则将 span 释放回 Page Cache ,Page Cache 中会对前后相邻的空闲页进行合并。
void CentralCache::ReleaseListToSpans(void* start, size_t size) {
size_t index = SizeClass::Index(size);
_spanLists[index]._mtx.lock();
while (start) {
void* next = NextObj(start);
// 找到对应的span,小内存(自由链表)头插
Span* span = PageCache::GetInstance()->MapObjectToSpan(start);
NextObj(start) = span->_freeList;
span->_freeList = start;
span->_useCount--;
// _useCount == 0说明span的切出去的所有小内存块都回来了
// 这个span就可以再回收给PageCache,PageCache可以再尝试去做前后页的合并
if (span->_useCount == 0) {
_spanLists[index].Erase(span);
span->_freeList = nullptr;
span->_next = nullptr;
span->_prev = nullptr;
// 释放span给PageCache时,使用PageCache的锁就可以了
// 这时把桶锁解开
_spanLists[index]._mtx.unlock();
PageCache::GetInstance()->_pageMtx.lock();
PageCache::GetInstance()->ReleaseSpanToPageCache(span);
PageCache::GetInstance()->_pageMtx.unlock();
_spanLists[index]._mtx.lock();
}
start = next;
}
_spanLists[index]._mtx.unlock();
}
头插回收一定数量对象到span,如果全部回收,即_useCount==0,则可以将该span拿给Page Cache进行页的合并。
那么如何通过地址获取对应的span呢?我们就需要调用MapObjToSpan函数来获取,这将在下面介绍。
Page Cache
Page Cache类
Page Cache我们在设计中也是只有一个, 所以设置成单例模式。并且在Page Cache中我们桶的映射规则与上面二级缓存不同,这里采用直接定址法,i号桶挂i页内存。
桶的个数根据需求而定,我们申请内存最大是256Kb,页大小为8K,也就是说我们要想申请一个256Kb的对象就必须要(256/8=32)32页的span,那么我们可以多分配一些,设置桶个数为128,128页可以申请4个256Kb对象。实际上128页就是1Mb大小。
页缓存中主要对页进行操作,所以我们有必要对页和span建立一个映射关系,方便我们查找管理,所以使用哈希表unordered_map<PAGE_ID,Span*>。
对页缓存的访问需求实际上很少,所以我们使用一个整体锁来进行管理线程安全即可,避免频繁调用锁,消耗时间。
在创建Span中,我们使用了最开始设计的定长内存池来申请和释放对象,与new和delete分离。
// 单例模式
class PageCache {
public:
// 提供一个全局访问点来访问这个唯一的实例
static PageCache* GetInstance() {
return &_sInst;
}
Span* MapObjectToSpan(void* obj);
void ReleaseSpanToPageCache(Span* span);
// 获取一个k页的span
Span* NewSpan(size_t k);
std::mutex _pageMtx; // 整个锁
private:
SpanList _spanLists[NPAGES];
ObjectPool<Span> _spanPool;
std::unordered_map<PAGE_ID, Span*> _idSpanMap;
PageCache()
{}
PageCache(const PageCache&) = delete;
static PageCache _sInst; // 保证一个类只有一个实例
};
映射查找Span
根据Central Cache申请内存部分引入的概念,我们可以得知:页的起始地址 * 页大小=页号,我们可以通过这个公式得到页号,然后在哈希表中查找到对应的span。
这里我们使用RAII原则的unique_lock,构造时加锁,出作用域对象解锁,防止程序异常退出导致死锁,优化代码。
Span* PageCache::MapObjectToSpan(void* obj) {
PAGE_ID id = ((PAGE_ID)obj >> PAGE_SHIFT);
std::unique_lock<std::mutex> lock(_pageMtx); // 函数结束会自动解锁
auto ret = _idSpanMap.find(id);
if (ret != _idSpanMap.end()) {
return ret->second; // 返回对应的span
}
else {
assert(false);
return nullptr;
}
}
申请内存
当central cache 向 page cache 申请内存时, page cache 先检查对应位置有没有 span ,如果没有则向更大页寻找一个 span ,如果找到则分裂成两个 。比如:申请的是 4 页 page , 4 页 page 后面没有挂 span ,则向后面寻找更大的 span ,假设在 10 页 page 位置找到一个 span ,则将 10 页 page span分裂为一个 4 页 page span 和一个 6 页 page span 。
如果找到_spanList[128] 都没有合适的 span ,则向系统使用 mmap 、 brk 或者是 VirtualAlloc 等方式申请128 页 page span 挂在自由链表中,再重复 1 中的过程。
// 获取一个k页的span
Span* PageCache::NewSpan(size_t k) {
assert(k > 0);
// 若k大于128页
if (k > NPAGES - 1) {
void* ptr = SystemAlloc(k);
//Span* span = new Span;
Span* span = _spanPool.New();
span->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
span->_n = k;
_idSpanMap[span->_pageId] = span;
return span;
}
// 先检查第k个桶里面有没有span
if (!_spanLists[k].Empty()) {
Span* kSpan = _spanLists[k].PopFront();
// 建立id和span的映射,方便CentralCache 回收小块内存时,查找对应的span
for (PAGE_ID i = 0; i < kSpan->_n; ++i) {
_idSpanMap[kSpan->_pageId + i] = kSpan;
}
return kSpan;
}
// 检查后面的桶里有没有span,如果有可以把它进行切分
for (size_t i = k + 1; i < NPAGES; i++) {
if (!_spanLists[i].Empty()) {
Span* nSpan = _spanLists[i].PopFront();
//Span* kSpan = new Span;
Span* kSpan = _spanPool.New();
// 在nSpan的头部切一个k页下来
// k页span返回,nSpan再挂到对应映射的位置
kSpan->_pageId = nSpan->_pageId; // 页号(前面都没有页,因此nSpan的页号赋值给kSpan)
kSpan->_n = k; // 页的数量
nSpan->_pageId += k;
nSpan->_n -= k;
_spanLists[nSpan->_n].PushFront(nSpan);
// 存储nSpan的首尾页号跟nSpan映射,方便PageCache回收内存时进行合并查找
_idSpanMap[nSpan->_pageId] = nSpan;
_idSpanMap[nSpan->_pageId + nSpan->_n - 1] = nSpan;
// 建立pageId和span的映射,方便CentralCache回收小块内存时,查找对应的span
for (size_t i = 0; i < kSpan->_n; i++) {
_idSpanMap[kSpan->_pageId + i] = kSpan;
}
return kSpan;
}
}
// 走到这里说明桶里没有大页的span了,
// 就需要去堆上要一个128页的span
/*Span* bigSpan = new Span;*/
Span* bigSpan = _spanPool.New();
void* ptr = SystemAlloc(NPAGES - 1);
///
// 疑惑:如何实现0 1 2 3...
///
bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
bigSpan->_n = NPAGES - 1; // 页的数量
_spanLists[bigSpan->_n].PushFront(bigSpan);
return NewSpan(k); // 递归
}
如果申请页大于128页,则需要向堆申请。
如果该桶还有span,则直接取出span给Central Cache,并哈希表保存页号和span的映射。
如果该桶没有,则从后面的桶中取span,并更新该span被切后的页号和页数再挂接到对应页号的桶上,建立页号和span的映射关系,方便后续回收。
如果后续桶也没有span,则向系统堆申请128页的span,挂接到128号桶,再递归调用切出要的页span。
释放内存
如果central cache 释放回一个 span, 则依次寻找 span 的前后 _pageId 的没有在使用的空闲 span,看是否可以合并,如果合并继续向前寻找。这样就可以将切小的内存合并收缩成大的 span ,减少内存碎片。
void PageCache::ReleaseSpanToPageCache(Span* span) {
// 大于128页的直接还给堆
if (span->_n > NPAGES - 1) {
void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
SystemFree(ptr);
//delete span;
_spanPool.Delete(span);
return;
}
// 对span前后的页尝试进行合并,缓解内存碎片问题
// 向前合并
while (1) {
PAGE_ID prevId = span->_pageId - 1;
auto ret = _idSpanMap.find(prevId);
// 前面的页号没有,就不合并
if (ret == _idSpanMap.end()) {
break;
}
// 前面相邻页的span在使用,也不合并
Span* prevSpan = ret->second;
if (prevSpan->_isUse == true) {
break;
}
// 合并超出128页的span不能管理,也不合并
if (prevSpan->_n + span->_n > NPAGES - 1) {
break;
}
span->_pageId = prevSpan->_pageId;
span->_n += prevSpan->_n;
_spanLists[prevSpan->_n].Erase(prevSpan);
//delete prevSpan;
_spanPool.Delete(prevSpan);
}
// 向后合并
while (1) {
PAGE_ID nextId = span->_pageId + span->_n;
auto ret = _idSpanMap.find(nextId);
// 前面的页号没有,就不合并
if (ret == _idSpanMap.end()) {
break;
}
// 前面相邻页的span在使用,也不合并
Span* nextSpan = ret->second;
if (nextSpan->_isUse == true) {
break;
}
// 合并超出128页的span不能管理,也不合并
if (nextSpan->_n + span->_n > NPAGES - 1) {
break;
}
span->_n += nextSpan->_n;
_spanLists[nextSpan->_n].Erase(nextSpan);
//delete nextSpan;
_spanPool.Delete(nextSpan);
}
_spanLists[span->_n].PushFront(span);
span->_isUse = false;
// 首尾映射
_idSpanMap[span->_pageId] = span;
_idSpanMap[span->_pageId + span->_n - 1] = span;
}
如果归还页大于128页,则直接还给堆。
首先向相邻前页合并,再向相邻后页合并。
如果相邻页没有就不合并跳出,如果相邻页正在使用就不合并跳出,如果合并页超过128,无法管理不合并跳出。
走完前后页合并逻辑后,将页挂接到Page Cache的桶并建立映射关系。
为什么要使用_isUse而不使用_useCount==0来判断相邻页是否正在被使用呢?
因为可能在给CentralCache划分span的时候,_usecount还未++,此时还是0,恰好有可能其他线程在PageCache判断此时划分给CentralCache的为0拿来合并,这就造成了线程安全的问题。
解决方法:span增加一个bool值,判断是否被使用
申请释放联调
申请内存联调
static void* ConcurrentAlloc(size_t size)
{
//通过TLS,每个线程无锁的获取自己专属的ThreadCache对象
if (pTLSThreadCache == nullptr)
{
pTLSThreadCache = new ThreadCache;
}
return pTLSThreadCache->Allocate(size);
}
释放内存联调
static void ConcurrentFree(void* ptr)
{
Span* span = PageCache::GetInstance()->MapObjToSpan(ptr);//通过映射关系找到span
size_t size = span->_objSize;
assert(TLS_ThreadCache);
TLS_ThreadCache->Deallocate(ptr, size);
}
大于256KB的大块内存申请释放问题
大块内存申请问题
三级缓存的设计主要考虑的是小于256Kb的对象,那如果大于256Kb我们如何处理呢?
在Page Cache中我曾提到256Kb需要32页,但我们Page Cache设计的最大有128页。所以如果申请对象大于32页小于等于128页,我们可以直接向Page Cache申请内存;如果大于128页,我们就需要向系统堆空间申请内存。
static void* ConcurrentAlloc(size_t size) {
if (size > MAX_BYTES) {
size_t alignSize = SizeClass::RoundUp(size);
size_t kpage = alignSize >> PAGE_SHIFT;
PageCache::GetInstance()->_pageMtx.lock();
Span* span = PageCache::GetInstance()->NewSpan(kpage);
span->_objSize = size;
PageCache::GetInstance()->_pageMtx.unlock();
void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
return ptr;
}
else {
// 通过TLS 每个线程无锁获取自己专属的ThreadCache对象
if (pTLSThreadCache == nullptr) {
//pTLSThreadCache = new ThreadCache;
ObjectPool<ThreadCache> tcPool;
pTLSThreadCache = tcPool.New();
}
cout << std::this_thread::get_id() << ":" << pTLSThreadCache << endl;
return pTLSThreadCache->Allocate(size);
}
}
大块内存释放问题
大于128页,直接向堆释放内存;小于等于128页则继续走Page Cache逻辑页合并。
static void ConcurrentFree(void* ptr) {
Span* span = PageCache::GetInstance()->MapObjectToSpan(ptr);
size_t size = span->_objSize;
// 大于128页,直接向堆释放内存
if (size > MAX_BYTES) {
PageCache::GetInstance()->_pageMtx.lock();
PageCache::GetInstance()->ReleaseSpanToPageCache(span);
PageCache::GetInstance()->_pageMtx.unlock();
}
// 小于等于128页则继续走Page Cache逻辑页合并
else {
assert(pTLSThreadCache);
pTLSThreadCache->Deallocate(ptr, size);
}
}
性能对比及基数树优化
性能对比
对比多线程下设计的高并发内存池和malloc的性能:分别对相同大小内存和不同大小内存进行申请和释放。
void BenchmarkMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
std::vector<std::thread> vthread(nworks);
size_t malloc_costtime = 0;
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);
size_t malloc_costtime = 0;
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);
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;
}
- ntimes:单轮申请、释放次数
- nworks:线程数
- rounds:轮次数
- 线程内部使用lambda表达式(C++11新特性),用于定义匿名函数,以值传递捕获k,以引用传递捕获其他父作用域的变量。
固定大小内存的申请和释放
v.push_back(malloc(16));
v.push_back(ConcurrentAlloc(16));
4个线程执行10轮操作,每轮申请释放10000次,总共申请释放了40万次,运行后可以看到,malloc的效率还是更高的。
由于此时我们申请释放的都是固定大小的对象,每个线程申请释放时访问的都是各自thread cache的同一个桶,当thread cache的这个桶中没有对象或对象太多要归还时,也都会访问central cache的同一个桶。此时central cache中的桶锁就不起作用了,因为我们让central cache使用桶锁的目的就是为了,让多个thread cache可以同时访问central cache的不同桶,而此时每个thread cache访问的却都是central cache中的同一个桶。
不同大小内存的申请和释放
v.push_back(malloc((16 + i) % 8192 + 1));
v.push_back(ConcurrentAlloc((16 + i) % 8192 + 1));
运行后可以看到,由于申请和释放内存的大小是不同的,此时central cache当中的桶锁就起作用了,ConcurrentAlloc的效率也有了较大增长,但相比malloc来说还是差一点点。
性能瓶颈分析
我们使用VS自带的性能探查器进行时间检测。
通过分析结果可以看到,Deallocate和MapObjectToSpan这两个函数就占用了一半多的时间。
而在Deallocate函数中,调用ListTooLong函数时消耗的时间是最多的。
继续往下看,在ListTooLong函数中,调用ReleaseListToSpans函数时消耗的时间是最多的。
再进一步看,在ReleaseListToSpans函数中,调用MapObjectToSpan函数时消耗的时间是最多的。
也就是说,最终消耗时间最多的实际就是MapObjectToSpan函数,我们这时再来看看为什么调用MapObjectToSpan函数会消耗这么多时间。通过观察我们最终发现,调用该函数时会消耗这么多时间就是因为锁的原因。
因此,当前项目的瓶颈点就在锁竞争上面,需要解决调用MapObjectToSpan函数访问映射关系时的加锁问题。tcmalloc当中针对这一点使用了基数树进行优化,使得在读取这个映射关系时可以做到不加锁。
基数树优化
基数树实际上就是一个分层的哈希表,根据所分层数不同可分为单层基数树、二层基数树、三层基数树等。
单层基数树
单层基数树实际采用的就是直接定址法,每一个页号对应span的地址就存储数组中在以该页号为下标的位置。
最坏的情况下我们需要建立所有页号与其span之间的映射关系,因此这个数组中元素个数应该与页号的数目相同,数组中每个位置存储的就是对应span的指针。
//单层基数树
template <int BITS>
class TCMalloc_PageMap1
{
public:
typedef uintptr_t Number;
explicit TCMalloc_PageMap1()
{
size_t size = sizeof(void*) << BITS; //需要开辟数组的大小
size_t alignSize = SizeClass::_RoundUp(size, 1 << PAGE_SHIFT); //按页对齐后的大小
array_ = (void**)SystemAlloc(alignSize >> PAGE_SHIFT); //向堆申请空间
memset(array_, 0, size); //对申请到的内存进行清理
}
void* get(Number k) const
{
if ((k >> BITS) > 0) //k的范围不在[0, 2^BITS-1]
{
return NULL;
}
return array_[k]; //返回该页号对应的span
}
void set(Number k, void* v)
{
assert((k >> BITS) == 0); //k的范围必须在[0, 2^BITS-1]
array_[k] = v; //建立映射
}
private:
void** array_; //存储映射关系的数组
static const int LENGTH = 1 << BITS; //页的数目
};
二层基数树
这里还是以32位平台下,一页的大小为8K为例来说明,此时存储页号最多需要19个比特位。而二层基数树实际上就是把这19个比特位分为两次进行映射。
比如用前5个比特位在基数树的第一层进行映射,映射后得到对应的第二层,然后用剩下的比特位在基数树的第二层进行映射,映射后最终得到该页号对应的span指针。
二层基数树实际上就是把BITS进行分层映射,在32位下,用前5比特位映射第一层,得到2^5个,后14位映射到第二层得到该页的span指针。总共占用大小2^5 * 2^14 * 4 =2^21=2M。和一层基数树开辟的大小是一样的,但是二层基数树最开始只需要开辟第一层,当需要某一页号进行映射再开辟第二层,而一层基数树一开始直接开辟全部。
//二层基数树
template <int BITS>
class TCMalloc_PageMap2
{
private:
static const int ROOT_BITS = 5; //第一层对应页号的前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; //第二层存储元素的个数
//第一层数组中存储的元素类型
struct Leaf
{
void* values[LEAF_LENGTH];
};
Leaf* root_[ROOT_LENGTH]; //第一层数组
public:
typedef uintptr_t Number;
explicit TCMalloc_PageMap2()
{
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]; //返回该页号对应span的指针
}
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; //建立该页号与对应span的映射
}
//确保映射[start,start_n-1]页号的空间是开辟好了的
bool Ensure(Number start, size_t n)
{
for (Number key = start; key <= start + n - 1;)
{
const Number i1 = key >> LEAF_BITS;
if (i1 >= ROOT_LENGTH) //页号超出范围
return false;
if (root_[i1] == NULL) //第一层i1下标指向的空间未开辟
{
//开辟对应空间
static ObjectPool<Leaf> leafPool;
Leaf* leaf = (Leaf*)leafPool.New();
memset(leaf, 0, sizeof(*leaf));
root_[i1] = leaf;
}
key = ((key >> LEAF_BITS) + 1) << LEAF_BITS; //继续后续检查
}
return true;
}
void PreallocateMoreMemory()
{
Ensure(0, 1 << BITS); //将第二层的空间全部开辟好
}
};
因此在二层基数树中有一个Ensure函数,当需要建立某一页号与其span之间的映射关系时,需要先调用该Ensure函数确保用于映射该页号的空间是开辟了的,如果没有开辟则会立即开辟。
而在32位平台下,就算将二层基数树第二层的数组全部开辟出来也就消耗了2M的空间,内存消耗也不算太多,因此我们可以在构造二层基数树时就把第二层的数组全部开辟出来。
三层基数树
上面一层基数树和二层基数树都适用于32位平台,而对于64位的平台就需要用三层基数树了。三层基数树与二层基数树类似,三层基数树实际上就是把存储页号的若干比特位分为三次进行映射。
此时只有当要建立某一页号的映射关系时,再开辟对应的数组空间,而没有建立映射的页号就可以不用开辟其对应的数组空间,此时就能在一定程度上节省内存空间。
//三层基数树
template <int BITS>
class TCMalloc_PageMap3
{
private:
static const int INTERIOR_BITS = (BITS + 2) / 3; //第一、二层对应页号的比特位个数
static const int INTERIOR_LENGTH = 1 << INTERIOR_BITS; //第一、二层存储元素的个数
static const int LEAF_BITS = BITS - 2 * INTERIOR_BITS; //第三层对应页号的比特位个数
static const int LEAF_LENGTH = 1 << LEAF_BITS; //第三层存储元素的个数
struct Node
{
Node* ptrs[INTERIOR_LENGTH];
};
struct Leaf
{
void* values[LEAF_LENGTH];
};
Node* NewNode()
{
static ObjectPool<Node> nodePool;
Node* result = nodePool.New();
if (result != NULL)
{
memset(result, 0, sizeof(*result));
}
return result;
}
Node* root_;
public:
typedef uintptr_t Number;
explicit TCMalloc_PageMap3()
{
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]; //返回该页号对应span的指针
}
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); //第三层对应的下标
Ensure(k, 1); //确保映射第k页页号的空间是开辟好了的
reinterpret_cast<Leaf*>(root_->ptrs[i1]->ptrs[i2])->values[i3] = v; //建立该页号与对应span的映射
}
//确保映射[start,start+n-1]页号的空间是开辟好了的
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); //第二层对应的下标
if (i1 >= INTERIOR_LENGTH || i2 >= INTERIOR_LENGTH) //下标值超出范围
return false;
if (root_->ptrs[i1] == NULL) //第一层i1下标指向的空间未开辟
{
//开辟对应空间
Node* n = NewNode();
if (n == NULL) return false;
root_->ptrs[i1] = n;
}
if (root_->ptrs[i1]->ptrs[i2] == NULL) //第二层i2下标指向的空间未开辟
{
//开辟对应空间
static ObjectPool<Leaf> leafPool;
Leaf* leaf = leafPool.New();
if (leaf == NULL) return false;
memset(leaf, 0, sizeof(*leaf));
root_->ptrs[i1]->ptrs[i2] = reinterpret_cast<Node*>(leaf);
}
key = ((key >> LEAF_BITS) + 1) << LEAF_BITS; //继续后续检查
}
return true;
}
void PreallocateMoreMemory()
{}
};
代码更改
现在我们用基数树对代码进行优化,此时将PageCache类当中的unorder_map用基数树进行替换即可,由于当前是32位平台,因此这里随便用几层基数树都可以。
//单例模式
class PageCache
{
public:
//...
private:
//std::unordered_map<PAGE_ID, Span*> _idSpanMap;
TCMalloc_PageMap1<32 - PAGE_SHIFT> _idSpanMap;
};
此时当我们需要建立页号与span的映射时,就调用基数树当中的set函数。
_idSpanMap.set(span->_pageId, span);
而当我们需要读取某一页号对应的span时,就调用基数树当中的get函数。
Span* ret = (Span*)_idSpanMap.get(id);
并且现在PageCache类向外提供的,用于读取映射关系的MapObjectToSpan函数内部就不需要加锁了。
//获取从对象到span的映射
Span* PageCache::MapObjectToSpan(void* obj)
{
PAGE_ID id = (PAGE_ID)obj >> PAGE_SHIFT; //页号
Span* ret = (Span*)_idSpanMap.get(id);
assert(ret != nullptr);
return ret;
}
为什么读取基数树映射关系时不需要加锁?
当某个线程在读取映射关系时,可能另外一个线程正在建立其他页号的映射关系,而此时无论我们用的是C++当中的map还是unordered_map,在读取映射关系时都是需要加锁的。
因为C++中map的底层数据结构是红黑树,unordered_map的底层数据结构是哈希表,而无论是红黑树还是哈希表,当我们在插入数据时其底层的结构都有可能会发生变化。比如红黑树在插入数据时可能会引起树的旋转,而哈希表在插入数据时可能会引起哈希表扩容。此时要避免出现数据不一致的问题,就不能让插入操作和读取操作同时进行,因此我们在读取映射关系的时候是需要加锁的。
而对于基数树来说就不一样了,基数树的空间一旦开辟好了就不会发生变化,因此无论什么时候去读取某个页的映射,都是对应在一个固定的位置进行读取的。并且我们不会同时对同一个页进行读取映射和建立映射的操作,因为我们只有在释放对象时才需要读取映射,而建立映射的操作都是在page cache进行的。也就是说,读取映射时读取的都是对应span的_useCount不等于0的页,而建立映射时建立的都是对应span的_useCount等于0的页,所以说我们不会同时对同一个页进行读取映射和建立映射的操作。
再次对比malloc进行测试
申请固定内存大小
申请不同内存大小
优化结果:多线程场景下性能比malloc好。