目录
项目简介
本项目实现了一个具有三层缓存机制的高并发内存池,项目原型为 google 的开源项目 tcmalloc,tcmalloc 全称 Thread-Caching Malloc,即线程缓存的 malloc,实现了高效的多线程内存管理,用于替代系统的内存分配相关的函数(malloc、free)。
技术栈
C/C++,数据结构(链表、哈希桶),操作系统内存管理,单例模式,多线程,互斥锁。
内存池
内存池是指程序预先从操作系统申请一块足够大内存,此后,当程序中需要申请内存的时候,不是直接向操作系统申请,而是直接从内存池中获取;同理,当程序释放内存的时候,并不真正将内存返回给操作系统,而是返回内存池。当程序退出(或者特定时间)时,内存池才将之前申请的内存真正释放。
内存池解决的主要问题
内存池最关注的问题有两点:一是效率问题,二是内存碎片的问题。
效率问题
首先,我们知道申请内存使用的是 malloc,此函数设计得比较通用,什么场景下都可以用,这也就意味着什么场景下都不会有很高的性能。
malloc 是线程安全函数,但不是可重入函数。在多线程场景下申请内存时,malloc 通过递归锁实现了线程安全,保证多个线程互斥申请内存,这也就使得多线程不能并发申请内存。本项目为每个线程设计了一个 Thread Cache,使得多线程场景下可以并发申请内存。
关于 malloc / free 相关问题可以参考以下文章:
【C语言】一文详解 malloc / free 分配内存和释放内存相关问题-CSDN博客
内存碎片问题
外部碎片:当内存中有足够数量的区域来满足方法的内存请求,但是由于提供的内存是不连续的,因此无法满足进程的内存请求,会导致外部碎片,详见下图:
内部碎片:在分配给方法的内存比请求的内存稍大的情况下,分配的内存和请求的内存之间的差异
称为内部碎片。例如:页帧(page frame)是内存的最小可分配单元,也开始称作页框,Linux 下页帧的大小为4KB,若用户申请3KB,系统会根据最小可分配单元分配4KB,在用户层面仅分配了3KB,于是就只使用3KB,多出来的1KB就是内部碎片。
整体框架设计
现代很多的开发环境都是多核多线程,在申请内存的场景下,必然存在激烈的锁竞争问题。malloc 本身其实已经很优秀,那么我们项目的原型 tcmalloc 就是在多线程高并发的场景下更胜一筹,所以我们实现的内存池需要考虑以下几方面的问题。
- 性能问题。
- 内存碎片问题。
- 多线程环境下,锁竞争问题。
高并发内存池主要由如下3个部分组成:
- Thread Cache:线程缓存是每个线程独有的,用于小于256KB的内存的分配,线程从这里申请内存不需要加锁,每个线程独享一个 Cache,这也就是这个并发线程池高效的地方。
- Central Cache:中心缓存是所有线程所共享,Thread Cache 是按需从 Central Cache 中获取的对象。Central Cache 合适的时机回收 Thread Cache 中的对象,避免一个线程占用了太多的内存,而其他线程的内存吃紧,达到内存分配在多个线程中更均衡的按需调度的目的。Central Cache是存在竞争的,所以从这里取内存对象是需要加锁,首先这里用的是桶锁,其次只有 Thread Cache 的没有内存对象时才会找 Central Cache,所以这里竞争不会很激烈。
- Page Cache:页缓存是在 Central Cache缓存上面的一层缓存,存储的内存是以页为单位存储及分配的,Central Cache没有内存对象时,从 Page Cache 分配出一定数量的 page,并切割成定长大小的小块内存,分配给 Central Cache。当一个 span 的几个跨度页的对象都回收以后,Page Cache 会回收 Central Cache 满足条件的span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。
Thread Cache
Thread Cache 是哈希桶结构,每个桶是一个按桶位置映射对应内存块对象大小的 FreeList 自由链表。采用 TLS 技术使每个线程都有一个 Thread Cache 对象,这样每个线程在这里获取对象和释放对象时是无锁的。
代码框架
#pragma once
#include "Common.h"
class ThreadCache
{
public:
// 申请和释放内存对象
void* Allocate(size_t size);
void Deallocate(void* ptr, size_t size);
// 从中心缓存获取对象
void* FetchFromCentralCache(size_t index, size_t size);
// 当释放对象而链表过长时,将回收的内存还给central cache
void ListTooLong(FreeList& list, size_t size);
private:
FreeList _freeLists[NFREELIST];
};
// TLS:thread local storage
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;
Central Cache
Central Cache 也是一个哈希桶结构,他的哈希桶的映射关系跟 Thread Cache 是一样的。不同的是他的每个哈希桶位置挂是 SpanList 链表结构,不过每个映射桶下面的 span 中的大内存块被按映射关系切成了一个个小内存块对象挂在 span 的自由链表中。
代码框架
#pragma once
#include "Common.h"
// 单例模式饿汉版
class CentralCache
{
public:
static CentralCache* GetInstance()
{
return &_sInst;
}
// 向page cache申请一个非空span
Span* GetOneSpan(SpanList& spanlist, size_t size);
// 从central cache获取一定数量的对象给thread cache
size_t FetchRangeObj(void*& begin, void*& end, size_t batchNum, size_t size);
// 将一定数量的对象释放给span
void ReleaseListToSpans(void* start, size_t byte_size);
private:
CentralCache()
{}
CentralCache(const CentralCache&) = delete;
private:
SpanList _spanLists[NFREELIST];
static CentralCache _sInst;
};
Page Cache
Page Cache 依然用的桶结构,每个桶下挂的一个 SpanList,每个桶直接按照桶的下标映射 SpanList。
代码框架
#pragma once
#include "Common.h"
#include "ObjectPool.h"
class PageCache
{
public:
static PageCache* GetInstance()
{
return &_sInst;
}
// 获取一个K页的span
Span* NewSpan(size_t k);
// 获取从对象到span的映射
Span* MapObjectToSpan(void* obj);
// 释放空闲span回到给page cache,并合并相邻的span
void ReleaseSpanToPageCache(Span* span);
private:
PageCache()
{}
~PageCache()
{}
PageCache(const PageCache&) = delete;
PageCache& operator=(const PageCache&) = delete;
public:
std::mutex _pageMtx;
private:
SpanList _spanLists[NPAGES];
std::unordered_map<PAGE_ID, Span*> _idSpanMap;
ObjectPool<Span> _spanPool;
static PageCache _sInst;
};
申请内存流程
Thread Cache
size<=256KB
- 当内存申请 size<=256KB,先获取到线程本地存储的 Thread Cache 对象,计算 size 映射的哈希桶自由链表下标 i,size 的映射关系如下表。
- 如果自由链表 _freeLists[i] 中有对象,则直接 Pop 一个内存对象返回。
- 如果 _freeLists[i] 中没有对象时,则批量从 Central Cache 中获取一定数量的对象,插入到自由链表并返回一个对象。
申请的内存数 | 对齐数 | 哈希桶分区 |
---|---|---|
[1, 128] | 8 byte | freelist[0, 16) |
[128+1, 1024] | 16 byte | freelist[16, 72) |
[1024+1, 8*1024] | 128 byte | freelist[72, 128) |
[8*1024+1, 64*1024] | 1024 byte | freelist[128, 184) |
[64*1024+1, 256*1024] | 8192 byte | freelist[184, 208) |
256KB<size<=128*8k,直接向 Page Cache 申请内存。
size>128*8k,直接向系统堆申请内存。
Central Cache
- 当 Thread Cache 中没有内存时,就会批量向 Central Cache 申请一些内存对象,这里的批量获取对象的数量使用了类似网络 tcp 协议拥塞控制的慢开始算法;Central Cache 也有一个哈希映射的 SpanList,SpanList 中挂着 span,从span中取出对象给 Thread Cache,这个过程是需要加锁的,不过这里使用的是一个桶锁,尽可能提高效率。
- Central Cache 映射的 SpanList 中所有 span 的都没有内存以后,则需要向 Page Cache 申请一个新的 span 对象,拿到 span 以后将 span 管理的内存按大小切好作为自由链表链接到一起。然后从 span 中取对象给 Thread Cache。
- Central Cache 中挂的 span 中 use_count 记录分配了多少个对象出去,分配一个对象给Thread Cache,就 ++use_count。
Page Cache
- 当 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中的过程。
- 需要注意的是 Central Cache 和 Page Cache 的核心结构都是 SpanList 的哈希桶,但是他们是有本质区别的,Central Cache 中哈希桶,是按跟 Thread Cache 一样的大小对齐关系映射的,Central Cache 的 SpanList 中挂的 span 中的内存都被按映射关系切好链接成小块内存的自由链表。而 Page Cache 中的 SpanList则是按下标桶号映射的,也就是说第 i 号桶中挂的 span 都是 i 页内存。
释放内存流程
Thread Cache
size<=256KB
- 当释放内存小于256KB时将内存释放回 Thread Cache,计算 size 映射自由链表桶位置 i,将对象 Push 到 _freeLists[i]。
- 当链表的长度过长,则回收一部分内存对象到 Central Cache。
256KB<size<=128*8k,向 Page Cache 释放内存。
size>128*8k,向系统堆释放内存。
Central Cache
当 Thread Cache 过长或者线程销毁,则会将内存释放回 Central Cache中的,释放回来时--use_count。当 use_count 减到0时则表示所有对象都回到了 span,则将 span 释放回 page cache,Page Cache 会对前后相邻的空闲页进行合并。
Page Cache
如果 Central Cache 释放回一个 span,则依次寻找 span 的前后 page_id 的没有在使用的空闲 span,看是否可以合并,如果合并继续向前寻找。这样就可以将切小的内存合并收缩成大的 span,减少内存碎片。
代码测试结果
每次申请固定内存(16byte)
每次申请随机内存(1~8192byte)
性能分析
可以看到,在多次申请固定内存时,我们实现的内存池和系统的 malloc 在效率上还是略有差距。
故使用 Visual Studio 自带的性能检测工具分析代码。
优化
发现在获取页号到 Span 的函数里为了保护该临界资源加了锁,这把锁占用了较多的资源,故采用类似基数树的哈希表进行优化,结构如下:
此前,我们使用 unordered_map 或 map 维护 PageId 与 Span* 的映射关系,在对该表进行写时都会对数据结构进行修改,比如哈希表的扩容、红黑树的结点旋转,多线程对这种临界资源的修改就需要加锁。
采用类似基数树的哈希表进行优化后,不用再对查表过程加锁,原因如下:
- 对该表进行构造时就开好了空间,二同一个线程的读写是分离的,不会改变整个结构。
- 多线程对表进行读写时,由于外面还有 Page Cache 里的一把大锁的保护,保证了不同线程互斥地对表进行读写的。
总结
内存碎片分析
申请的内存数 | 对齐数 | 哈希桶分区 |
---|---|---|
[1, 128] | 8 byte | freelist[0, 16) |
[128+1, 1024] | 16 byte | freelist[16, 72) |
[1024+1, 8*1024] | 128 byte | freelist[72, 128) |
[8*1024+1, 64*1024] | 1024 byte | freelist[128, 184) |
[64*1024+1, 256*1024] | 8192 byte | freelist[184, 208) |
结合上表计算内存碎片浪费量。
申请 129 byte内存,浪费 15 / (128 + 16) = 10.42% 内存
申请 1025 byte内存,浪费 127 / (1024 + 128) = 11.02% 内存
申请 8 * 1024 + 1 byte内存,浪费 1023 / (8192 + 1024) = 11.1% 内存
申请 64 * 1024 + 1 byte内存,浪费 8095 / (65536 + 8096) = 10.99% 内存
整体控制在最多 11% 左右的内碎片浪费。
效率分析
对优化后的代码进行测试。
每次申请固定内存(16byte)
每次申请随机内存(1~8192byte)
经过测试,我们实现的高并发内存池比 malloc 和 free 的效率要高大约15%。