池化技术是计算机中的一种设计模式,内存池是常见的池化技术之一,它能够有效的提高内存的申请和释放效率以及内存碎片等问题,但是传统的内存池也存在一定的缺陷,高并发内存池相对于普通的内存池它有自己的独特之处,解决了传统内存池存在的一些问题。
new/delete用于c++中动态内存管理而malloc/free在c++和c中都可以使用,本质上new/delete底层封装了malloc/free。无论是上面的那种内存管理方式,都存在以下两个问题:
针对直接使用new/delete、malloc/free存在的问题,定长内存池的设计思路是:预先开辟一块大内存,程序需要内存时直接从该大块内存中“拿”一块,提高申请和释放内存的效率,同时直接分配大块内存还减少了内存碎片问题。
对于STL中的空间配置器就是采用的这种方式,当申请小于128字节的内存就是用定长内存池,当超过时,就直接使用malloc和free
定长内存时详解链接:https://blog.csdn.net/MEANSWER/article/details/118343707
基于以上原因,设计高并发内存池需要解决以下三个问题:
高并发内存池整体框架由以下三部分组成,各部分的功能如下:
线程从这里申请内存不需要加锁,每个线程独享一个ThreadCache,这也就是这个并发内存池高效的地方
(这就是tcmalloc名字的本质来源,在这里具体的实现采用的是TLS(thread local storage 本地线程存储,可以理解为每个线程独有的全局变量,但是是本地的)))。(本质上ThreadCache里面就是由hash映射的定长的内存桶)注:怎么实现每个线程都拥有自己唯一的线程缓存呢?
我们的并发内存池项目对外只暴露两个接口,对于申请的接口就是ConcurrentAlloc(),如果申请的内存大于64KB,直接走的就是PageCache所提供的的NewSpan(),但是走这个也有可能有两种情况,一种是介于16页——128页之间,一开始就会直接的申请上来一块128页的内存然后进行切分,返回你需要的那一部分。大于128页直接调用VirtualAlloc进行向系统申请内存。如果是小于64KB,那么就会走ThreadCache所提供的Allocate()接口,计算出要找哪一个索引下标的freelist,如果有就直接返回,没有则会调会FetchFromCentralCache(),通过你要的内存size计算出需要给你返回的批量个数(慢启动方式)以及实际上真正能给你返回的数量调用CentralCache的FetchRangeObj(),但是有可能CentralCache中的SpanList[i]下没有Span或者内存都被用完了,所以首先就是得到块有内存的Span,调用GetOneSpan()接口,如果该Span中的list不为nullptr说明还有内存,如果为空就需要向PageCache要一块Span,调用NewSpan()接口。计算索引看PageCache下是否有合适的页,如果没有则需要向后找,在没有就只能向系统直接申请一块128页的内存,然后进行切分了。由于CentralCache中的Span都是切好的,所以在得到这个新的页的时候,也应该按照对应的内存大小将他切分好然后在返回CentralCache
一块块内存还回来挂接在ThreadCache中对应的FreeList中,当其中一个FreeList挂接的太长的时候就需要进行归还给CentralCache(这里选择归还的条件就是自由链表中的内存个数Size大于MaxSize),从该FreeList中取出MaxSize个内存归还到CentralCache,但是每一块小内存都可能来自于不同的Span(根据每一块的小内存的起始地址算出它所对应的页号,然后还有一个map可以通过页号找到所对应的Span,那么就可以确保每一块小内存都归还给当初所切出来的Span中),在CentralCache中的每一个大块Span里面有一个usecount,如果为0的时候,说明分给ThreadCache的内存就都还回来了,那么为了能够合成更大的页,就需要再把该Span还回PageCache中,每一个大块的Span里面都有一个PageID(页号)和页数,那么就可以进行在PageCache中进行前后的搜索,找到是否还有大块的Span没有使用然后进行合并,成为更大的页。
ThreadCache的主要功能就是为每一个线程提供64K以下大小内存的申请。为了方便管理,需要提供一种特定的管理模式,来保存未分配的内存以及被释放回来的内存,以方便内存的二次利用。这里的管理通常采用将不同大小的内存映射在哈希表中,链接起来。而内存分配的最小单位是字节,64k = 102464Byte如果按照一个字节一个字节的管理方式进行管理,至少也得需要102464大小的哈希表对不同大小的内存进行映射。为了减少哈希表长度,这里采用不同段的内存使用不同的内存对齐规则,将浪费率保持在1%~12%之间。具体结构如下:
具体说明如下:
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;
}
ThreadCache.h
#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);
// 释放对象时,链表过长时,回收内存回到中心缓存
void ListTooLong(FreeList& list, size_t size);
private:
FreeList _freeLists[NFREELISTS];
};
static __declspec(thread) ThreadCache* tls_threadcache = nullptr;
申请内存:
释放内存:
Centralcache作为Threadcache和Pagecache的桥梁,起到承上启下的作用。它需要向Threadcache提供切割好的小块内存,同时他还需要回收Threadcache中的过多内存,在分配给其他其他Threadcache使用,起到资源调度的作用。如果Centralcache中的span已经完全由Threadcache归还回来还需要向下层交付,以便合成更大的页,解决内存碎片的问题它的结构如下:
#pragma once
#include "Common.h"
//CentralCache是要加锁的,但是锁的力度不需要太大,只需要加一个桶锁,因为只有多个线程同时取一个Span
//要保证CentralCache和PageCache对象都是全局唯一的,所以直接使用单例模式
//且这里使用的是饿汉模式---一开始就进行创建(main函数之前就进行了创建)
class CentralCache
{
public:
//返回指针挥着引用都是可以的
static CentralCache* GetInstance()
{
return &_inst;
}
// 从中心缓存获取一定数量的对象给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]; // 按对齐方式映射 这里的span被切过了,并且有一部分小对象已经切分出去了
private:
CentralCache() = default;
CentralCache(const CentralCache&) = delete;
CentralCache& operator=(const CentralCache&) = delete;
static CentralCache _inst;
};
申请内存:
释放内存:
Pagecache是以页为单位进行内存管理的,它是将不同页数的内存利用哈希进行映射,最多映射128页内存,具体结构如下:
PageCache.h
#pragma once
#include "Common.h"
#include "PageMap.h"
class PageCache
{
public:
static PageCache* GetInstance()
{
return &_sInst;
}
// 向系统申请k页内存挂到自由链表
void* SystemAllocPage(size_t k);
Span* NewSpan(size_t k);
//获取从对象到span的映射
Span* MapObjectToSpan(void* obj);
void ReleaseSpanToPageCache(Span* span);
private:
SpanList _spanList[NPAGES]; // 按页数映射
//std::map _idSpanMap; //这里是有可能多个页都映射同一个Span的
//tcmalloc 基数树 效率更高
TCMalloc_PageMap2<32-PAGE_SHIFT> _idSpanMap;
std::recursive_mutex _mtx;
private:
PageCache() = default;
PageCache(const PageCache&) = delete;
PageCache& operator=(const PageCache&) = delete;
static PageCache _sInst;
};
申请流程:
释放流程:
Page Cache向系统申请内存时,前边我们说过每次直接申请128页的内存。这里需要说明的是,我们的项目中不能出现任和STL中的数据结构和库函数,因此这里申请内存直接采用系统调用VirtualAlloc。下面对VirtualAlloc详细解释:
函数声明如下:
LPVOID VirtualAlloc{
LPVOID lpAddress, // 要分配的内存区域的地址
DWORD dwSize, // 分配的大小
DWORD flAllocationType, // 分配的类型
DWORD flProtect // 该内存的初始保护属性
};
参数说明:
VirtualAlloc详解链接:https://baike.baidu.com/item/VirtualAlloc/1606859?fr=aladdin
基数树详解链接:https://blog.csdn.net/weixin_36145588/article/details/78365480
void BenchmarkMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
//创建nworks个线程
std::vector<std::thread> vthread(nworks);
size_t malloc_costtime = 0;
size_t free_costtime = 0;
//每个线程循环依次
for (size_t k = 0; k < nworks; ++k)
{
//铺货k
vthread[k] = std::thread([&, k]() {
std::vector<void*> v;
v.reserve(ntimes);
//执行rounds轮次
for (size_t j = 0; j < rounds; ++j)
{
size_t begin1 = clock();
//每轮次执行ntimes次
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(hcAlloc(16));
}
size_t end1 = clock();
size_t begin2 = clock();
for (size_t i = 0; i < ntimes; i++)
{
hcFree(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(100000, 4, 10);
cout << endl << endl;
BenchmarkConcurrentMalloc(100000, 4, 10);
cout << "==========================================================" << endl;
return 0;
}
该函数使用了C++11库里面的thread和lambda表达式。一共4个线程,每个线程申请释放内存10000次一共执行4轮来对比库里面的malloc和free
在release版本下,我们所写的并发内存池项目申请和释放内存的确是要更好一些的。
-项目的独立性不足:
ConcurrentMemoryPool原码链接: https://gitee.com/meanswer/concurrent-memory-pool