代码: 高并发内存池
项目背景
高并发内存池: 参考了Google的开源项目tcmalloc实现的简易版, 其主要是通过3层缓存结构实现多线程下高效的内存管理. 本次项目旨在学习其优秀的结构思想(分层结构管理内存, 减小锁的竞争, 减小外碎片)
来源: google/tcmalloc (github.com)
项目框架
ThreadCache
- 特点: 每个线程独有
- 功能1: 用于小于256KB内存的申请
CentralCache
- 特点: 所有线程共享, 全局一个
- 功能1: 当ThreadCache内存不够的时候为其提供内存(Span对象), 所以会发生竞争, 但是是桶锁(竞争较小)
- 功能2: 当ThreadCache占有过多的内存未使用时, 回收对应的内存到Span对象
PageCache
- 特点: 全局一个
- 功能1: 当CentralCache内存不够的时候为其提供内存(页为单位)
- 功能2: 当从CentralCache释放回一个Span对象后, 尝试合并其前后的Span, 以减少内存碎片
主要流程
申请内存
释放内存流程
项目预备(定长内存池)
- 专门用于特定内存的申请, 并使用链表管理这些内存
- 相比于malloc的统一, 定长内存池直接去小块内存push, pop就行, 效率会高一些
框架
// 堆上申请空间
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32
void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else
// linux下brk mmap等
#endif
if (ptr == nullptr)
throw std::bad_alloc();
return ptr;
}
template<class T>
class ObjectPool
{
public:
T* New()
{}
void Delete(T* obj)
{}
private:
char* _memory = nullptr; // 指向大块内存的指针
size_t _remainBytes = 0; // 大块内存所剩下的字节数
void* _freeList = nullptr; // 链表管理内存块
};
实现
T* New()
{
T* obj = nullptr;
// 1.先使用本地缓存的
if (_freeList)
{
void* next = *((void**)_freeList);
obj = (T*)_freeList;
_freeList = next;
}
else
{
// 2.不够重新开一个更大的空间
if (_remainBytes < sizeof(T))
{
_remainBytes = 128 * 1024;
//_memory = (char*)malloc(_remainBytes);
_memory = (char*)SystemAlloc(_remainBytes >> 13);
if (_memory == nullptr)
{
throw std::bad_alloc();
}
}
obj = (T*)_memory;
//处理T不够一个指针大小
size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
_memory += objSize;
_remainBytes -= objSize;
}
// 定位new,显示调用T的构造函数初始化
new(obj)T;
return obj;
}
项目实现
ThreadCache
- 结构: 哈希桶 -- 内部是_freeList, 挂着对应大小的内存块
- 特点: 每个线程都有ThreadCache, 申请内存时无需加锁, 效率高
需要实现的功能
- 申请内存
- 本地有内存: 从ThreadCache本地缓存中获取内存(根据内存大小-->计算在那个哈希桶-->找到自由链表-->拿出一块内存)
- 本地无内存: 找CentralCache要一批小块内存
- 回收内存
- 将内存还给ThreadCache(根据内存大小-->计算在那个哈希桶-->找到自由链表-->头插进去)
- 将自由链表的长度过长, 将这些小块还给对应的Span对象, 给CentralCache
1.实现每个线程独有ThreadCache
TLS--thread local storage : 一种变量的存储方法, 让该变量在其所在的线程内是全局访问的, 而其它线程不能访问(避免加锁)
// TLS thread local storage
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;
2.设计自由链表管理内存
思路: 使用内存块的前4/8个字节来存下一个内存块的地址, 这样就将内存块串起来了.
将对象转换为(void**)再解引用就可以取出一个指针大小的内存
3.设计内存对齐映射规则
256KB的内存若按8byte均匀划分, 需要2^15次方个哈希桶, 数量过多(内存开销大)
256KB的内存按非均匀的方式划分,整体控制在最多10%左右的内碎片浪费, 需要208个桶
[1,128] 8byte对齐 freelist[0,16)
[128+1,1024] 16byte对齐 freelist[16,72)
[1024+1,8*1024] 128byte对齐 freelist[72,128)
[8*1024+1,64*1024] 1024byte对齐 freelist[128,184)
[64*1024+1,256*1024] 8*1024byte对齐 freelist[184,208)
4.ThreadCache功能框架
class ThreadCache
{
public:
// 申请内存
void* Allocate(size_t size);
// 释放内存
void Deallocate(void* ptr, size_t size);
// 内存不够, 从CentralCache中获取一批小块内存
void* FetchFromCentralCache(size_t index, size_t size);
// 释放对象时,链表过长时,回收内存回到CentralCache对应的Span对象中
void ListTooLong(FreeList& list, size_t size);
private:
FreeList _freeLists[NFREELIST];
};
// TLS本地缓存, 只有当前线程可见 -- 线程申请内存的时候实现无锁访问
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;
CentralCache
- 结构: 哈希桶 -- 内部是SpanList管理Span对象(内部是小块内存) -- 映射关系与ThreadCache保持一致
- 特点: 桶锁(尽量减小锁的竞争,提高效率) 慢启动算法(一开始给ThradCache1个小块内存, 随着申请次数增加, 给的小块内存增加)
- 为ThreadCache提供一批小块内存
- 当Thread申请小块内存的时候, CentralCache先在对应的SpanList中寻找Span, 拿出小块内存给ThreadCache
- 若Span对象里面的小块内存不够了, 重新申请一个新的Span对象, 然后将其切割并用链表串起来, 然后再给ThreadCache
- 回收小块内存到Span对象中
- 使用一个计数_usecount来记录哪些内存被分配出去了, 若ThreadCache还内存让_usecount减小到0, 就将该Span还给PageCache
1.CentralCache功能框架
class CentralCache
{
private:
SpanList _spanLists[NFREELIST]; //spanList链表 -- 管理Span对象(内含对应的小块内存)
static CentralCache _sInst; //单例模式
CentralCache() {}
CentralCache(const CentralCache&) = delete;
public:
static CentralCache* GetInstance() { return &_sInst; }
// 获取一个非空的span
Span* GetOneSpan(SpanList& list, size_t byte_size);
// 获取一定数量的小块内存给ThreadCache
size_t FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size);
// 还一部分的小块内存给CentralCache
void ReleaseListToSpans(void* start, size_t byte_size);
};
PageCache
- 结构: 哈希桶 -- SpanList挂Span对象. 但是其是按下标桶号映射, 第i号桶, 挂的都是第i页的内存
- 特点: 回收Span的时候, 尝试根据Span前后的PageID去合并空间的Span对象, 减小内存碎片
- 申请内存
- 1.先在对应的桶中检查有没有空闲的Span
- 2.看后面的桶有没有空闲的Span, 有的话进行切割 + 返回
- 3.找到最后都没有, 则向系统使用mmap、brk或者是VirtualAlloc等方式申请128页page span挂在自由链表中. 再重复1
- 回收Span对象
- 根据PageId, 找到其前后的空闲的Span对象进行合并
1.根据PageId计算对应的内存地址
根据id --> 计算地址
- 第0页的起始地址是0第一页的起始地址是8 * 1024KB...以此类推
- 使用地址 / 8 / 1024 可以获取页数
- 使用页数 * 8 * 1024 可以获取起始地址
合并内存
2.PageCache功能框架
class PageCache
{
private:
SpanList _spanLists[NPAGES];
std::unordered_map<PAGE_ID, Span*> _idSpanMap; //记录PAGE_ID与Span的映射关系
static PageCache _sInst; //单例
PageCache() {}
PageCache(const PageCache&) = delete;
public:
std::mutex _pageMtx; //全局锁
static PageCache* GetInstance() { return &_sInst; }
// 获取从对象到span的映射
Span* MapObjectToSpan(void* obj);
// 释放空闲span回到Pagecache,并合并相邻的span
void ReleaseSpanToPageCache(Span* span);
// 获取一个K页的span
Span* NewSpan(size_t k);
};
优化
1.对于大于256KB内存的申请
- 修改接口: NewSpan 与 ConcurrentAlloc
- 若是32Page - 128Page : 直接找PageCache申请
- 若是超过128Page : 找堆申请
2.使用定长内存池,脱离new
//定义定长的span内存池以脱离使用new
ObjectPool<Span> _spanPool;
测试
运行测试
测试发现: 效率并没有malloc高...
性能分析
unordered_map的查找消耗(find遍历) + 里面的锁消耗
使用基数树优化
- key是页号, val是指针
- 使用基数树替换哈希表, 存 -- 采用直接定址法
一层基数树
- 直接定址法
二层基数树
- 使用多层去获取Span
- 总消耗内存不变, 但在64位下, 要覆盖所有页号, 一次性是开不出这么多空间的, 需要分层
- 有效利用空间: 第一层全部覆盖即可, 和后面的层按需去开空间
使用基数树来建立PageID与Span对象的映射会更快原因
- 1.读取的时候可以不加锁
- 基数树提前开好了空间(写入操作不会改结构). 读的时候不需要加锁
- 而使用hash表存其映射, 若进行写, 可能回导致底层结构变化(扩容, 新旧表的问题),所以必须加锁
- 2.查找
- 使用哈希表需要find根据id获取Span, 基数树提前开好了空间, 直接定址法查找
- 高度更低(1~3层)
- 32位下, 记录页数 = 2^32 / 一页的大小(2^13) = 2^19. 能直接开出来, 但64位就不能
- 第一二层时预先分配的(尽可能覆盖大多数地址空间) 第三层按需分配来节省空间
结果
总结
1.项目比malloc性能高的原因
- 1.ThreadCache为每个线程独有, 无需加锁访问
- 2.CentralCache加的是桶锁, 线程之间竞争较小
- 3,使用基数树预先分配且静态结构优化, 读取Span对象无需加锁, 写操作不会改变结构
2.缺陷
实际上在释放内存时由thread cache将自由链表对象归还给central cache只使用了链表过长这一个条件,但是实际中这个条件大概率不能恰好达成,那么可能出现thread cache中自由链表挂着许多未被使用的内存块,从而出现了线程结束时可能导致内存泄露的问题。
- 不过当整个进程结束后, 所有内存都会回收
- 解决方法是通过回调函数来回收这部分的内存,并且通过申请批次统计内存占有量,保证线程不会过多占有资源
3.收获
- 学习了3层缓存设计思想
- 了解了池化技术
- 了解了慢启动算法, 基数树
- 调式(学会看函数堆栈, 一层一层往上查找问题)