一、高并内存池概念
内存池(Memory Pool) 是一种动态内存分配与管理技术。 通常情况下,程序员习惯直接使用 new、delete、malloc、free 等API申请分配和释放内存,这样导致的后果是:当程序长时间运行时,由于所申请内存块的大小不定,频繁使用时会造成大量的内存碎片从而降低程序和操作系统的性能。内存池则是在真正使用内存之前,先申请分配一大块内存(内存池)留作备用,当程序员申请内存时,从池中取出一块动态分配,当程序员释放内存时,将释放的内存再放入池内,再次申请池可以 再取出来使用,并尽量与周边的空闲内存块合并。若内存池不够时,则自动扩大内存池,从操作系统中申请更大的内存池。
由于现在硬件条件已经很成熟,大多数运行环境都是多核的,为了提高效率,则高并发这一情况应运而生,对于高并发内存池,则是基于多线程并发申请使用的一个内存池称为高并发内存池。
二、项目介绍
本项目参考了谷歌 tcmalloc 设计模式,设计实现了高并发的内存池。基于 win10 环境 VS2013,采用 C++进行编程,池化技术、多线程、TLS、单例模式、互斥锁、链表、哈希等数据结构。该项目利用了 thread cache、central、cache、page cache 三级缓存结构,基于多线程申请释放内存的场景,最大程度提高了效率,解决了绝大部分内存碎片问题。
三、项目细节
(一)项目设计目标
现代很多的开发环境都是多核多线程,在申请内存的场景下,必然存在激烈的锁竞争问题。所以这次我们实现的内存池需要考虑以下几方面的问题。
1. 内存碎片问题。
2. 性能问题。
3. 多核多线程环境下,锁竞争问题。
(二)项目结构
- concurrent memory pool主要由线程缓存(threadcache)、中心缓存(centralcache)、页缓存(pagecache)3个部分构成,如下图
1.thread cache
- 为了保证效率,我们使用线程局部存储(thread local storage,TLS)技术保存每个线程本地的ThreadCache的指针,这样大部分情况下申请释放内存是不需要锁的,线程缓存是每个线程独有的,用于小于64k的内存的分配,但并不是一定是要64k,只是前人总结的一个合适值。线程从这里申请内存不需要加锁,每个线程独享一个cache,这也就是这个并发线程池高效的地方。
class ThreadCache
{
public:
// 申请和释放内存对象
void* Allocate(size_t size);
void Deallocate(void* ptr, size_t size);
// 从中心缓存获取对象
void* FetchFromCentralCache(size_t index, size_t size);
// 释放对象时,链表过长时,回收内存回到中心缓存
void ListTooLong(FreeList& list, size_t size);
private:
//创建了一个哈希结构,每个位置里存放一个FreeList对象
FreeList _freeLists[NFREELISTS];
};
//TLS技术
static __declspec(thread) ThreadCache* tls_threadcache = nullptr;
该结构每个位置存放一个FreeList,每个自由链表下都可以挂自己的内存块。
申请内存:
1. 当内存申请size<=64k时在thread cache中申请内存,计算size在自由链表中的位置,如果自由链表中有内存对象时,直接从FistList[i]中Pop一下对象,时间复杂度是O(1),且没有锁竞争。
2. 当FreeList[i]中没有对象时,则批量从central cache中获取一定数量的对象,插入到自由链表并返回一个对象。
释放内存:
1. 当释放内存小于64k时将内存释放回thread cache,计算size在自由链表中的位置,将对象Push到FreeList[i].
2. 当链表的长度过长,则回收一部分内存对象到central cache。
2. central cache
- 中心缓存是所有线程所共享,本质是由一个哈希映射的span对象自由链表构成thread cache是按需从central cache中获取的对象。central cache周期性的回收thread cache中的对象,避免一个线程占用了太多的内存,而其他线程的内存吃紧。达到内存分配在多个线程中更均衡的按需调度的目的。central cache是存在竞争的,所以从这里取内存对象是需要加锁,不过一般情况下在这里取内存对象的效率非常高,所以这里竞争不会很激烈。
- 这里要注意,要保证centralcache是全局唯一的,这里我们需要将centralcache类设计成单例模式。
class CentralCache
{
public:
//唯一获得该对象的接口
static CentralCache* GetInstance()
{
return &_sInst;
}
// 从中心缓存获取一定数量的对象给thread cache
size_t FetchRangeObj(void*& start, void*& end, size_t n, size_t byte_size);
// 从SpanList或者page cache获取一个span
Span* GetOneSpan(SpanList& list, size_t byte_size);
// 将一定数量的对象释放到span跨度
void ReleaseListToSpans(void* start, size_t byte_size);
private:
SpanList _spanLists[NFREELISTS]; // 按对齐方式映射
private:
CentralCache()
{}
//封死它的拷贝构造
CentralCache(const CentralCache&) = delete;
CentralCache& operator=(const CentralCache&) = delete;
//定义全局唯一类
static CentralCache _sInst;
};
申请内存:
1. 当thread cache中没有内存时,就会批量向central cache申请一些内存对象,central cache也有一个哈希映射的freelist,freelist中挂着span,从span中取出对象给thread cache,这个过程是需要加锁的。
2. central cache中没有非空的span时,则将空的span链在一起,向page cache申请一个span对象,span对象中是一些以页为单位的内存,切成需要的内存大小,并链接起来,挂到span中。
3. central cache的span中有一个use_count,分配一个对象给thread cache,就++use_count。
释放内存:
1. 当thread_cache过长或者线程销毁,则会将内存释放回central cache中的,释放回来时--use_count。当use_count减到0时则表示所有对象都回到了span,则将span释放回pagecache,page cache中会对前后相邻的空闲页进行合并。
这里要注意centralcache中span是用双向链表进行连接的,每个span对象里都有一个list,就是每个向pagecache要的span都已经被切好了,比如说8k大小桶中,一块大的span里已经被切分成许多8k大小的内存块,他们分别用list进行内部连接,他当treadcache需要时,就给其一定数量的。但是整个span整体是被挂在spanlist中。
可以用珍珠项链可以形容,许多串整体的珍珠项链被挂在一个支架上,当顾客突然想要这串项链中的3个珍珠,那么就需要将珍珠项链里的绳子拆开取出那三个给顾客,这里支架就相当于spanlist,珍珠项链里的绳子相当于span中的list。
3.page cache
页缓存是在central cache缓存上面的一层缓存,存储的内存是以页为单位存储及分配的,central cache没有内存对象时,从page cache分配出一定数量的page,并切割成定长大小的小块内存,分配给central cache。page cache会回收central cache满足条件的span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。
class PageCache
{
public:
static PageCache* GetInstance()
{
return &_sInst;
}
// 向系统申请k页内存挂到自由链表
void* SystemAllocPage(size_t k);
Span* NewSpan(size_t k);
// 获取从对象到span的映射
Span* MapObjectToSpan(void* obj);
// 释放空闲span回到Pagecache,并合并相邻的span
void ReleaseSpanToPageCache(Span* span);
private:
SpanList _spanList[NPAGES]; // 按页数映射
//std::mutex _map_mtx; //专门给map用的锁
std::unordered_map<PageID, Span*> _idSpanMap;
std::recursive_mutex _mtx;
private:
PageCache()
{}
PageCache(const PageCache&) = delete;
PageCache& operator=(const PageCache&) = delete;
// 单例
static PageCache _sInst;
};
申请内存:
1. 当central cache向page cache申请内存时,page cache先检查对应位置有没有span,如果没有则向更大页寻找一个span,如果找到则分裂成两个。比如:申请的是4page,4page后面没有挂span,则向后面寻找更大的span,假设在10page位置找到一个span,则将10page span分裂为一个4page span和一个6page span。
2. 如果找到128 page都没有合适的span,则向系统使用mmap、brk或者是VirtualAlloc等方式申请128page span挂在自由链表中,再重复1中的过程。
释放内存:
1. 如果central cache释放回一个span,则依次寻找span的前后page id的span,看是否可以合并,如果合并继续向前寻找。这样就可以将切小的内存合并收缩成大的span,减少内存碎片。
- 这里要注意该结构下的span都是未切分的一个个整体span,当每次centralcache需要时在进行切分,并且将每个span地址与页号通过哈希map进行映射关系建立。这里一页我们按照window下1页4k为基准进行设置。
4.对象大小的映射对齐、向中心缓存获取内存个数及向pagecache获取页大小计算
// 8 + 7 = 15
// 7 + 7
// ...
// 1 + 7 = 8
static size_t Index(size_t size)
{
return ((size + (2 ^ 3 - 1)) >> 3) - 1;
}
// 管理对齐和映射等关系
class SizeClass
{
public:
// 控制在1%-12%左右的内碎片浪费
// [1,128] 8byte对齐 freelist[0,16)
// [129,1024] 16byte对齐 freelist[16,72)
// [1025,8*1024] 128byte对齐 freelist[72,128)
// [8*1024+1,64*1024] 1024byte对齐 freelist[128,184)
// [1,8] +7 [8,15] 8
// [9, 16] +7 [16,23] 16
static inline size_t _RoundUp(size_t bytes, size_t align)
{
return (((bytes)+align - 1) & ~(align - 1));
}
// 对齐大小计算,浪费大概在1%-12%左右
static inline size_t RoundUp(size_t bytes)
{
//assert(bytes <= MAX_BYTES);
if (bytes <= 128) {
return _RoundUp(bytes, 8);
}
else if (bytes <= 1024) {
return _RoundUp(bytes, 16);
}
else if (bytes <= 8192) {
return _RoundUp(bytes, 128);
}
else if (bytes <= 65536) {
return _RoundUp(bytes, 1024);
}
else
{
return _RoundUp(bytes, 1 << PAGE_SHIFT);
}
return -1;
}
static inline size_t _Index(size_t bytes, size_t align_shift)
{
return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1;
}
// 计算映射的哪一个自由链表桶
static inline size_t Index(size_t bytes)
{
assert(bytes <= MAX_BYTES);
// 每个区间有多少个链
static int group_array[4] = { 16, 56, 56, 56 };
if (bytes <= 128) {
return _Index(bytes, 3);
}
else if (bytes <= 1024) {
return _Index(bytes - 128, 4) + group_array[0];
}
else if (bytes <= 8192) {
return _Index(bytes - 1024, 7) + group_array[1] + group_array[0];
}
else if (bytes <= 65536) {
return _Index(bytes - 8192, 10) + group_array[2] + group_array[1] + group_array[0];
}
assert(false);
return -1;
}
// 一次从中心缓存获取多少个
static size_t NumMoveSize(size_t size)
{
if (size == 0)
return 0;
// [2, 512],一次批量移动多少个对象的(慢启动)上限值
// 小对象一次批量上限高
// 小对象一次批量上限低
int num = MAX_BYTES / size;
if (num < 2)
num = 2;
if (num > 512)
num = 512;
return num;
}
// 计算一次向系统获取几个页
// 单个对象 8byte
// ...
// 单个对象 64KB
static size_t NumMovePage(size_t size)
{
size_t num = NumMoveSize(size);
size_t npage = num*size;
npage >>= 12;
if (npage == 0)
npage = 1;
return npage;
}
};
5.FreeList、span、SpanList结构
void BenchmarkMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
// nworks个线程
//每个线程跑rounds轮
//每个线程跑ntimes次
std::vector<std::thread> vthread(nworks);// 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)//每个线程跑rounds轮
{
size_t begin1 = clock();
for (size_t i = 0; i < ntimes; i++)//每个线程跑ntimes次
{
v.push_back(malloc(260));//线程开辟空间
}
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); // n
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(260));
}
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()
{
cout << "==========================================================" << endl;
BenchmarkMalloc(10000, 4, 10);
cout << endl << endl;
BenchmarkConcurrentMalloc(10000, 4, 10);
cout << "==========================================================" << endl;
system("pause");
return 0;
}
5.加锁场景
a.在centralcache结构中需要进行加桶锁,也就是给FetchRangeObj函数和ReleseListToSpans函数进行加。
b.在pagecache结构中加大锁,也就是NewSpan函数和ReleaseSpanToPageCache函数进行加,还有就是在对span地址和页号映射时需要的MapObjectToSpan函数进行加。
四、项目测试
测试时一定要确保编译器在relese情况下,而不是debug!!!
五、项目总结
(一)优点
- 增加动态申请的效率
- 减少陷入内核的次数
- 减少系统内存碎片
- 提升内存使用率
- 尽量减少锁竞争
- 应用于多核多线程场景
(二)不足
1.当前实现的项目中我们并没有完全脱离malloc,比如在内存池自身数据结构的管理中,如SpanList中的span等结构,我们还是使用的new Span这样的操作,new的底层使用的是malloc,所以还不足以替换malloc,因为们本身没有完全脱离它。
解决方案:malloc、new(频繁申请的小块内存,如:span->对象池 内存大块内存:virtualalloc/brk/mmap)map、thread、mutex....
2.平台及兼容性
linux等系统下面,需要将VirtualAlloc替换为brk等。这个是小问题 。
x64系统下面,当前的实现支持不足。比如:id查找Span得到的映射,我们当前使用的是map<id, Span*>。在64位系统下面,这个数据结构在性能和内存等方面都是撑不住。需要改进后基数树。
(三)面试常见问题
1.如何去替代malloc?替代malloc的原理是什么?
不同平台替换方式不一样,linux下使用weak alias的方式进行替换,window下使用hook的钩子技术进行替换,不用更改原来的代码,只需要使用钩子将代码中使用malloc的地方勾过来让其执行该内存池代码,所有的malloc的调用都跳转到了tc_malloc的实现。它也通常用来写外挂,用来进行系统层面函数更改。Map使用基数树来进行替换,malloc使用对象池或者virtual alloc申请大块内存。
2.能不能把threadcache和centralcache合并掉,减掉一层?
不能,Central核心作用是承上启下的作用,假设把centracache直接去掉,就意味着threadcache和centralcache直接进行对接,会产生一个问题,pagecache中的span有些是切好的,有些是没有切好的,而且不是一下子就给threadcache,有可能给一部分,可能会留下一部分,这是第一个问题,第二个问题是还回来是还一部分,切过的和没切过的混在一起会有问题,比如申请一块大块内存,在pagecache中找,但是却不知道找到的到底是切好的还是没切好的,虽然USecount也能进行判断,但是切的多了混在一起找的时候查找效率没有那么高。再其次均衡调度作用就不明显了,threadcache中8字节专门给centralcahe8字节用的,但是如果在pagecache中就比较混乱,因为pagecache是按照页进行映射的,更大的问题在于centralcache加的是桶锁,pagecache虽然也是一个一个映射的桶,但是它涉及到一个span的合并和切分,span会在各个桶之间流动,就不能使用桶锁,就必须使用一把大锁进行锁住,但是centralcache就不会再各个桶之间进行流动。
小结:
1.Centralcache均衡多个线程之间的同一个大小的内存需求
2.他的span都是至少有部分在用的,区分pagecache都是大块完整。
3.它实现的是桶锁,因为一个span只会给一个桶用,不会再桶之间流动,效率更高,如果没有他的话,pagecache是一把大锁,因为pagecache中的span需要切小和合大,会在多个映射桶之间流动。
3.max一定是64k吗?一定是以8k对齐按照我们那种分段映射对齐吗?
不一定,这个根据设计者需求,可能换个人参数就全变了,依据64k控制10%左右的浪费设计了映射规则,后面为什么是128页呢也就是一次性要了0.5兆?这个也是不确定的,这个值至少要比最大的单个对象大小大,也就是至少大于16个页,最少也得5、60页大小,但是不能过分大,太大表示一次对系统要的太大了,会造成浪费。
4.threadcache销毁了,如果他还有内存没给centrlcache怎么办?假设这个线程有内存泄露或者它还没有达到listtolang哪个条件,有可能有一些内存还没有还回来或者挂在threadcache中,但是这个线程销毁了,那么这个内存就没有回到这个centralcache,centralcache也不会回到pagecache,会耽搁小页合大,还会导致一些内存的泄露,有没有什么办法解决呢?
解决方法就是给这个项目注册一个回调函数,只要线程结束,函数作用是把threadcache里面给clear掉,把每个桶数据都往下一个还,在此创建tls时就进行处理,在new线程空间时同时注册一个回调函数,一旦崩溃就是清理掉这个回调函数。
六、项目源码
关于项目源码centralcache和pagecache设计成两种单例模式,一种为饿汉,一种为懒汉。