高并发内存池的设计与实现
1.概述
1.1 池化技术
池 是一种常见的工程中的模块。池的核心概念就是:将程序中经常需要使用的核心资源先申请出来,放到池内,需要的时候直接取出,从而提高资源的使用效率,也可以保证本程序占有的资源数量。 经常使用的池化技术包括,内存池,线程池和各种连接池。
1.1.2 内存池
内存池是一种动态管理内存的池化技术,原先我们使用new、delete或malloc、free的时候都会频繁从系统申请内存和释放内存,不仅效率低还会产生内存碎片。内存池则是在我们需要申请内存之前,先直接向系统申请一大块内存,然后对这一个大快内存进行管理,这样每次我们申请内存都是从内存池中获取。
以往内存池的实现大多都存在一些缺点比如STL的空间配置器,malloc的底层实现。比如:
- 申请的内存无法复用
- 内存碎片
- 多线程情况,加锁导致的效率低。
主要原因是
- 每次申请的内存大小都可能不同,导致产生内存碎片
- 释放内存后,内存难以进行合并。
2.设计与实现
高并发内存池 - 适用于多线程情况下的内存使用。并且它还优化了内存碎片和解决了内存释放合并的问题。
高并发内存池由以下三个模块构成
- thread cache:每个线程独享一个该结构,用于申请小于64k的内存,如果是多线程情境下,每个线程都从自己的该结构中申请和释放内存,不存在加锁,从根本上提高了并发的效率。也是这个内存池高效地地方。
- central cache:中心缓存向各个线程的thread cache分配内存,并且从thread cache回收内存,防止单个线程占用过多内存。再整个项目中起到承上启下作用,向上从page cache获取内存,向下给与Thread cache内存。central cache的访问需要加锁,但是由于每次都会从central获取很多内存,因此这里的锁竞争并不频繁。
- page cache:通过向系统申请内存,并且按页的大小进行划分。向下给central cache分配内存,主要功能为了将从central cache返回的内存进行页的合并,形成更大的页,保证连续内存页的内存必须连接在一起,从而有效的缓解内存碎片问题。
2.1 thread cache
通过维护一个特殊的“链表”数组,保存内存池中空闲的内存。特殊在于:
控制内存碎片大小,即如果要申请6字节的内存,不是直接给用户6个空间大小的内存,而是通过对齐给与8个字节的内存。根据申请的大小不同,会有不同的对齐数将其对齐,得到从内存池申请内存的大小。
// 控制在[1%,12%]左右的内碎片浪费 // [1,128] 8byte对齐 freelist[0,16) // [129,102 4] 16byte对齐 freelist[16,72) // [1025,8*1024] 128byte对齐 freelist[72,128) // [8*1024+1,64*1024] 1024byte对齐 freelist[128,1024)
这样设计的好处是
- 维护的数组长度不会过长
- 如果都是按8字节对齐,那么在申请小内存,那么内存碎片浪费占比比较大,而大内存内存碎片浪费率较低。为了保持平衡,不同范围的数拥有不同的对齐数
链表的特殊性,因为这里的可以分配出去内存对象是8字节。链表可以通过在当前内存块中保存下一块内存块的首地址,这样就能链成一个单链表。具体实现方法可以在当前内存空间的前四/八个字节设置为下一块内存的地址。
实现方式:实现
申请流程:
- 当内存申请<=64k从这里申请,根据要申请的大小计算其在数组中的位置,判断该位置的链表是否有内存对象,有的话直接从链表中pop掉,返回给用户,时间复杂度o(1)。
- 当链表中没有内存对象,从central cache获取一定数量的内存对象,链接到该链表后,从中返回1个给用户。
释放流程:
- 用户释放空间,计算该内存对象在数组中的位置,插入到该位置的链表中。
- 并且此时检查链表长度是否过长,过长则将一部分的内存对象返回给central cache。
class ThreadCache
{
public:
//申请内存和释放内存
void* Allocte(size_t size);
void Deallocte(void* ptr, size_t size);
//从中心缓存获取对象
void* FetchFromCentralCache(size_t index);
//如果自由链表超过一定长度就要释放给中心缓存
void ListTooLong(FreeList& freeList, size_t num, size_t size);
private:
//维护一个内存链表数组 链表是特殊链表 可以通过过数组下标获得对应大小内存
FreeList _freeLists[NFREE_LIST];
};
//局部线程TSL 在全局里它值为空 在每个线程中为其申请空间。
_declspec(thread) static ThreadCache* pThreadCache = nullptr;
2.2 Central Cache
central cache维护一个链表数组,链表的每个节点都是一个Span对象。
下面详细解释Span对象和其作用。
typedef size_t PageID; struct Span { PageID _pageid = 0; // Starting page number size_t _pagesize = 0; // Number of pages in span Span* _next = nullptr; // Used when in link list Span* _prev = nullptr; // Used when in link list void* _freelist = nullptr; size_t _use_count = 0; size_t _objsize = 0; };
_pageid是页号,因为内存是按页分配的,一页为4k,因此只需要用内存地址/4k就能得到一个唯一的值,这个值称为页号。并且每一页上的内存的页号都是一样的。之所以这样设计,是因为Page Cache向系统是按页申请内存的。一个Span至少拥有1页的内存。
_pagesize是当前Span维护的页的数量。
_objsize将该Span按多大内存对象进行分配使用。
_freelist将该页所有内存对象用Thread cache那样的链表连接起来。
_use_count保存堆存对象被使用的数量。当值为0,说明没有被使用,此时可以返回Page Cache。
Span的作用
我们通过一个叫做Radix(后文讲)的数据结构,将每一个pageid和其Span对象构成key-value的类型存入radix中(这里可以把radix理解成unoredered_map)。
之所以这样是因为一个Span可能包含多个页,我们可以通过pageid找到对应的Span,这样方便最后在Page Cache中合并连续的内存页。
申请流程:
- 根据Thread cache需要的内存大小,计算出在数组中的位置,遍历Span链表,将Span链表中_freelist不为空的返回。
释放流程:
- 从Thread cache中返回的内存计算其pageid
- 有了pageid通过radix查找到其Span对象
- 将返回的内存对象插入到Span对象中的freelist上。
- 判断此时use_count是否为0,为0则将Span对象返还给PageCache
class CentralCache
{
public:
//从中心缓存获取一定数量的对象给ThreadCache
size_t FetchRangeObj(void*& start, void*& end, size_t num, size_t size);
//将一定数量的对象释放到span跨度
void ReleseListToSpans(void* start, size_t size);
//从spanlist 或者Page Cache获取一个span
Span* GetOneSpan(size_t size);
static CentralCache& GetCentralCacheInstance()
{
static CentralCache inst;
return inst;
}
private:
CentralCache()
{}
CentralCache(const CentralCache&) = delete;
SpanList _spanlists[NFREE_LIST];
};
2.3 Page Cache
Page Cache也维护一个链表数组,只不过该数组是根据页的大小设计的。将页一样大的Span链接在一起。
Page Cache中最大的页有128页,也就是512k大小,这样设计是因为内存碎片的产生一般都是因为小内存的申请和释放,因此较大的如果比512K还大的内存申请,直接向系统申请即可,不必再采用内存池。
Page Cache直接向Central cache返回一个Span对象。
申请流程:
- Central Cache向Page Cache发起一个获取Span对象的请求
- Page Cache根据要获取的Span对象中页的大小计算出所在数组下标。
- 如果该数组对应的Span链表有Span对象,则直接返回。
- 如果没有,则要进行切页。
- 通过从小到大遍历数组,找到一个比当前需要的页大的Span对象。
- 将其切分成两个Span对象,一个满足需要页大小的Span对象,一个是剩余页构成的Span对象。
- 将得到的两个新的Span对象插入到Radix中
- 返回切页后满足条件页大小的Span对象
释放流程:
- 从Central cachee获取到一个返回的Span对象
- 根据Span对象的Pageid计算其前一页的Pageid,查看前面页对应的Span对象是否没有被使用,如果没有就将两个Span对象合并,这是向前合并。同样的方法找到后面一页对应的Span向后合并。这样小的页的Span就合并成大的页的Span。
- 如果当前Span超过128页就将其返回给系统。
class PageCache
{
public:
Span* _NewSpan(size_t numpage);
Span* NewSpan(size_t numpage);
//向系统申请numpage页内存挂到自由链表
//void SystemAllocPage(size_t numpage);
void ReleaseSpanToPageCache(Span* span);
Span* GetIdToSpan(PAGE_ID id);
static PageCache& GetPageCacheInstance()
{
static PageCache inst;
return inst;
}
private:
PageCache()
{}
PageCache(const PageCache&) = delete;
//维护一个以页大小为哈希映射的 自由链表
SpanList _spanLists[MAX_PAGES];
Radix<Span*> _idSpanRadix;
std::mutex _mtx;
};
3.细节问题
3.1每个线程如何获取到一个Thread cache?
使用TLS:它主要是为了避免多个线程同时访存同一全局变量或者静态变量时所导致的冲突,尤其是多个线程同时需要修改这一变量时。为了解决这个问题,我们可以通过TLS机制,为每一个使用该全局变量的线程都提供一个变量值的副本,每一个线程均可以独立地改变自己的副本,而不会和其它线程的副本冲突。从线程的角度看,就好像每一个线程都完全拥有该变量。而从全局变量的角度上来看,就好像一个全局变量被克隆成了多份副本,而每一份副本都可以被一个线程独立地改变。
3.2 radix
4.其他
4.1项目源码
4.2 项目缺点
- 没有完全脱离New,但是也不构成大碍,可以根据用户选择自行选择用哪个内存池申请内存。
4.3效率比较测试(和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));
}
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(ConcurrentMalloc(16));
}
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 malloc %u次: 花费:%u ms\n",
nworks, rounds, ntimes, malloc_costtime);
printf("%u个线程并发执行%u轮次,每轮次concurrent free %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);
}