目录
一. 介绍
本项目的原型是google的开源项目tcmalloc,全称Thread-Caching Malloc,线程缓存的malloc。可以实现高效的多线程内存管理。
该项目将使用tcmalloc的核心部分,简化模拟实现一个高并发内存池。旨在学习tcmalloc的精华思想以及提升自身的编程经验。
该项目使用C/C++、数据结构(链表、哈希桶)、单例模式、多线程、互斥锁等知识。使用Visual Studio 2019 编程
二. 定长内存池
1. 内存池
通常存在利用空间来换取时间的思想,池化技术也如此。所谓池化技术,就是程序先向系统每次申请过量资源,自己先进行管理,来减少申请次数,提高程序的运行效率。
内存池是使用池化技术,预先向系统申请一块足够大的空间,然后当程序需要动态申请空间时,直接从内存池中获取。即通过内存池这个中间层分隔程序和系统来进行内存分配,提高效率。
实现内存池时,需要注意内存碎片的问题。其中内存碎片分为:内碎片和外碎片。
内碎片:是由于某些对齐规则(如结构体),导致部分内存无法被使用
外碎片:是由于小块的空闲内存区域不连续,导致即使总空闲的内存大小足够,但是不能满足内存分配需求
2. 定长内存池
即:内存池中都是相同的,固定大小的内存块
将通过定长内存池,来熟悉简单内存池是如果控制实现的
- 直接向堆申请内存
windows: virtualAlloc
Linux: brk和mmap
// 直接去堆上按页申请空间,申请kpage页大小的空间
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32
//kpage << 13: 将kpage页转换为字节大小
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;
}
- 定长内存池框架
//用于申请每次T类型(字节)大小的定长内存池
template<class T>
class ObjectPool
{
public:
T* New();
void Delete(T* obj);
public:
private:
char* _memory = nullptr; //大块总的内存
void* _freeList = nullptr; //小块释放的内存
size_t _remainBytes = 0; //剩余的内存字节数
};
- 介绍
当内存池没有足够内存时,申请大块内存,通过_memory指向。
使用_freeList来管理,程序释放的内存块,当再次申请内存时,优先从 _freeList中获取。
提醒:使用小块内存的前指针大小的字节数来存放下一个小块内存的起始地址,以此形参链表结构
- 实现代码
T* New()
{
T* obj = nullptr;
if (_freeList)//如果_freeList不为空
{
obj = (T*)_freeList;
//指向下一个内存块
_freeList = *(void**)_freeList;
}
else
{
//申请整块空间
if (_remainBytes < sizeof(T))
{
_remainBytes = 128 * 1024;
//_remainBytes>>13:总字节大小/1页的大小=申请多少页
_memory = (char*)SystemAlloc(_remainBytes >> 13);
if (_memory == nullptr)
{
throw std::bad_alloc();
}
}
obj = (T*)_memory;
//规定最小生成一个指针大小的空间
size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
_memory += objSize; //_memory向后移动1个小内存块大小
_remainBytes -= objSize;
}
new(obj)T; //定位new初始化
return obj;
}
void Delete(T* obj)
{
obj->~T(); //先析构
//头插到_freeList
*(void**)obj = _freeList;
_freeList = obj;
}
*(void**)obj = _freeList;
该步操作,使小块内存obj中存放下一个内存块的地址
- 性能测试
#include <iostream>
#include <vector>
#include <assert.h>
#include<windows.h>
using std::vector;
using std::cout;
using std::endl;
struct Dog
{
private:
int age;
double weight;
double length;
};
void TestObjectPool()
{
// 申请释放的轮次
const size_t Rounds = 5;
// 每轮申请释放多少次
const size_t N = 1000000;
std::vector<Dog*> v1;
v1.reserve(N);
size_t begin1 = clock();
for (size_t j = 0; j < Rounds; ++j)
{
for (int i = 0; i < N; ++i)
{
v1.push_back(new Dog);
}
for (int i = 0; i < N; ++i)
{
delete v1[i];
}
v1.clear();
}
size_t end1 = clock();
cout << "new使用的时间:" << end1 - begin1 << endl;
std::vector<Dog*> v2;
v2.reserve(N);
ObjectPool<Dog> MyPool;
size_t begin2 = clock();
for (size_t j = 0; j < Rounds; ++j)
{
for (int i = 0; i < N; ++i)
{
v2.push_back(MyPool.New());
}
for (int i = 0; i < N; ++i)
{
MyPool.Delete(v2[i]);
}
v2.clear();
}
size_t end2 = clock();
cout << "object pool使用的时间:" << end2 - begin2 << endl;
}
int main()
{
TestObjectPool();
return 0;
}
可以发现我们实现的定长内存池比new效率更高。是由定长内存池的特点——只能申请释放相同大小的内存,导致了其高效但非常的局限;而new底层的malloc则是一个通用的内存池。
三. 高并发内存池
目前许多的开发环境,都是多核多线程的,申请内存时,存在激烈的锁竞争问题。本项目的原型tcmalloc就是在多线程高并发常见下比malloc更胜一筹。
因此该项目主要考虑3个方面的问题:1.性能 2. 锁竞争 3. 内存碎片
1. 框架
1) 整体框架
在程序中,高并发内存池的结构如上图:
-
thread cache:线程缓存每个线程独享,线程直接向thread cache申请释放内存
-
central cache:中心缓存,单例,所有thread cache按需从central cache获取内存对象,同时,central cache在合适条件下回收内存对象,使多线程的内存调度更均衡。需要加桶锁操作
-
page cache: 页缓存,单例,以页为单位管理内存对象,page cache会从堆直接申请多页内存。central cache从page cache得到一定数量的page,然后切分成小块对象。在合适条件时,page cache会回收central cache的内存对象,合并成更大的页,缓解内存碎片问题。需要加锁操作
2) ThreadCache
- 当申请的内存size<=256KB时,先获取到线程的ThreadCache对象,通过size映射到哈希桶的下标i。如果i位置桶元素(自由链表)中有对象,则直接Pop一个对象返回。如果没有,则从CentralCache中获取一定数量的对象,插入到自由链表中,并返回一个对象
- 当释放内存(小于256KB)时,先计算size映射到哈希桶的下标i,然后将对象Push到_freeLists[i]。当自由链表过长时,会将一部分对象还给CentralCache
//TC CC 其结构哈希桶的个数 [0,207] <--> 下标
static const size_t FREELIST_NUM = 208;
class FreeList
{
private:
void* _head = nullptr;
size_t _size = 0; //FreeList中节点的个数
};
class ThreadCache
{
private:
FreeList _freeLists[FREELIST_NUM];//哈希桶
};
关于桶个数:
对于ThreadCache的设计,是一个哈希桶结构,桶的每个元素都是单链表,链接相同大小的内存块(多个定长内存池)
就桶的个数而言,8Byte表示64位下一个指针的大小;256KB表示线程通过ThreadCache一次所能申请的最大内存。从8byte~256KB,如果按照1Byte为公差的话,大概需要 256 × 2 10 − 8 + 1 ≈ 2 18 256\times2^{10} - 8 + 1 \approx 2^{18} 256×210−8+1≈218个桶,每个同元素都是指针类型,那ThreadCache对象的大小就至少需要 4 × 2 18 = 2 20 B y t e 4\times2^{18}=2^{20} Byte 4×218=220Byte,即 1 1 1MB空间。,但在 2 18 2^{18} 218个桶元素中,其实只会使用其中很少一部分,造成大量空间浪费
为减少浪费,可以使一定范围大小的内存按照指定的大小进行对齐,来减少桶的个数。
static size_t RoundUp(size_t bytes)
{
if (bytes <= 128)
{ //8对齐
return _RoundUp(bytes, 8);
}
else if (bytes <= 1024)
{ //16对齐
return _RoundUp(bytes, 16);
}
else if (bytes <= 8 * 1024)
{ //128对齐
return _RoundUp(bytes, 128);
}
else if (bytes <= 64 * 1024)
{ //1024对齐
return _RoundUp(bytes, 1024);
}
else if (bytes <= 256 * 1024)
{ //8*1024对齐
return _RoundUp(bytes, 8 * 1024);
}
else
{
assert(false);
return -1;
}
}
//计算bytes向上对齐后的大小(内碎片产生)
static size_t _RoundUp(size_t bytes, size_t alignNum)
{
//[1,8]-->8 [9,16]-->16 [17,24]-->24
//[129,144]-->144 ……
//if (bytes % alignNum == 0)
// return bytes;
//else
// return (bytes / alignNum + 1) * alignNum;
return ((bytes + alignNum - 1) & ~(alignNum - 1));
}
可以发现当内存越来越大时,其对齐数的间隔也会变大(从8 16 24…248*1024 256*1024),是为了减少桶数量的同时,又减少资源浪费(内碎片)
3) CentralCache
- 当被ThreadCache申请一定数量的对象内存时,先通过对象大小映射到哈希桶下标i,如果链表中有对象,这返回一定数量的内存对象。如果没有,则从PageCache中获取一个span对象,并将span管理的内存切成对象大小以自由链表链接,然后从span中返回一定数量的内存对象
- 当span中的所有对象都归还回来时,将该span释放回PageCache
class SpanList//带头双向循环链表
{
private:
Span* _head;
public:
std::mutex _spanListMtx; // 桶锁
};
class CentralCache
{
public:
static CentralCache* GetInstance()
{
return &_sInst;
}
private:
SpanList _freeLists[FREELIST_NUM]; //哈希桶
private://单例模式
CentralCache() {};
CentralCache(const CentralCache&) = delete;
static CentralCache _sInst;
};
由于CentralCache对象是单例模式,因此对某i下标桶(自由链表)的操作需要加锁访问,使用桶锁(即每个桶中都有一个互斥锁)。
关于Span:
Span是CentralCache每个桶的双向循环链表的节点对象,其成员变量中有_freeList来管理切好的内存对象
#ifdef _WIN64
typedef unsigned long long PAGE_ID;
#elif _WIN32
typedef unsigned int PAGE_ID;
#else
// linux
#endif
struct Span
{
PAGE_ID _pageId = 0; // 起始页号
size_t _n = 0; // 页的数量
Span* _prev = nullptr;//指向前一个span
Span* _next = nullptr;//指向后一个span
void* _freeList = nullptr; // 切好的小块内存的自由链表
size_t _useCount = 0; // 切好小块内存,被分配给thread cache的计数
bool _isUse = false;//表示该span是否是使用状态
};
PAGE_ID _pageId
:
- 对于32位下, 2 32 / 2 13 = 2 19 2^{32} / 2^{13} = 2^{19} 232/213=219,使用unsigned int,有4字节(最大可存 2 32 2^{32} 232)
- 在64位下, 2 64 / 2 13 = 2 51 2^{64} / 2^{13} = 2^{51} 264/213=251,使用unsigned long long,有8字节(最大可存 2 64 2^{64} 264)
4) PageCache
- 当被CentralCache申请内存时,PageCache会先根据内存需要的页数到对于位置检查有没有span,如果有则返回。如果没有则向下寻找到一个有更大页数的span,找到后分裂成两个小span,返回对应大小的内存对象,另一个挂在PageCache上。如果找到_spanLists[128]都没有,则直接向堆申请一个128页的span挂在PageCache上。再重复寻找过程。
- 当有span被CentralCache还回来,会依次判断span的前后空间对应的span是否是空闲的,如果是则合并成更大的span。
//最大有128页的span
static const size_t PAGELIST_NUM = 129;
class PageCache
{
public:
static PageCache* GetInstance()
{
return &_sInst;
}
public:
std::mutex _pageMtx;//总锁
private:
SpanList _pageLists[PAGELIST_NUM];//哈希桶
private://单例模式
PageCache() {};
PageCache(const PageCache&) = delete;
static PageCache _sInst;
};
PageCache对象也是单例模式,在操作PageCache数据时,可能会跨不同的哈希桶位置,因此需要一个总锁,来整体操作。和前面两个Cache不同,PageCache的哈希桶是按照span管理的内存页的个数来划分的,最大可管理128页大小的span。
PageCache使用mmap、brk或者VirtualAlloc等申请空间,会返回页的基址
2. 申请内存
线程直接向其对应的ThreadCache申请内存,根据内存大小映射到对于的哈希桶,若桶中的自由链表中有内存,则返回;否则ThreadCache就向CentralCache申请n个该大小的内存块。
同样根据内存大小映射到CentralCache的哈希桶,若桶中自由链表的有内存,则返回;否则CentralCache就向PageCache申请一个span(该span的页数,由内存块大小决定)。
在PageCache中,根据span的页数映射到对应的哈希桶,如果桶中有span,则返回;否则就向下找,找到更大页的span,可以将该span切分成两个小span,返回。如果直到最后一个128页的桶都没有span,则调用系统函数,先申请一个128页的span,然后再切分,返回。
3. 释放内存
当线程释放一块内存时,先通过内存大小映射到ThreadCache对应的哈希桶,然后将该内存头插到该同的自由链表上。然后进行检查,如果符合条件,就将一部分内存块归还给CentralCache。
CentralCache只有一个,但对应哈希桶上的span可能有多个,该批内存块通过**_pageId和内存地址**的关系可以归还到其对应的span上。同时span的_useCount–,当等于0则表示所有内存块都归还完成。CentralCache就会将span归还给PageCache。
先判断该span前后连续空间所管理的span的_isUse是不是false,如果是,则合并这些span成为一个页数更大的span,最后根据页数和哈希桶的映射关系,将span放入对应桶的链表中。
四. 代码实现
1. 开始
- NextObj()
在定长内存池也有该操作,该函数作用是对Obj前指针空间大小进行访问或赋值
static inline void*& NextObj(void* obj)
{
//obj所指向空间的前sizeof(void*)大小空间的数据
return *(void**)obj;
}
- FreeList
自由链表,是ThreadCache的桶元素
class FreeList
{
public:
void Push(void* obj)//头插
{
NextObj(obj) = _head;
_head = obj;
++_size;
}
void PushRange(void* start, void* end, size_t n)
{
NextObj(end) = _head;
_head = start;
_size += n;
}
void* Pop()//头删
{
assert(_head);
assert(_size);
void* obj = _head;
_head = NextObj(_head);
_size--;
return obj;
}
void PopRange(void*& start, void*& end, size_t n)
{
assert(n <= _size);
start = _head;
end = start;
size_t i = 1;
while (i < n)//end向后移动n个节点
{
end = NextObj(end);
++i;
}
_head = NextObj(end);//从链表上去除
NextObj(end) = nullptr;
_size -= n;
}
bool empty()
{
return _head == nullptr;
}
//返回当前节点数量
size_t Size()
{
return _size;
}
//返回链表应该所存放的节点个数
size_t MaxSize()
{
return _count;
}
//返回一次向CentralCache申请的个数
size_t& BatchNum()
{
return _count;
}
private:
void* _head = nullptr;
size_t _count = 1;//一次获取内存块的个数
size_t _size = 0; //FreeList中节点的个数
};
- 内存向上对齐和映射到桶下标
class SizeClass
{
public:
//计算bytes向上对齐后的大小(内碎片产生)
static size_t _RoundUp(size_t bytes, size_t alignNum)
{
return ((bytes + alignNum - 1) & ~(alignNum - 1));
}
static size_t RoundUp(size_t bytes)
{
if (bytes <= 128)
{
return _RoundUp(bytes, 8);
}
else if (bytes <= 1024)
{
return _RoundUp(bytes, 16);
}
else if (bytes <= 8 * 1024)
{
return _RoundUp(bytes, 128);
}
else if (bytes <= 64 * 1024)
{
return _RoundUp(bytes, 1024);
}
else if (bytes <= 256 * 1024)
{
return _RoundUp(bytes, 8 * 1024);
}
else
{
assert(false);
return -1;
}
}
//计算bytes大小的内存在Threadcache哈希桶的下标[0,208)
static size_t _Index(size_t bytes, size_t align_shift)
{
//[1,8]-->0 [9,16]-->1 [17,24]-->2
//[129,144]-->16 ……
//if (bytes % (1 << align_shift) == 0)
// return bytes / (1 << align_shift) - 1;
//else
// return bytes / (1 << align_shift);
return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1;
}
static 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); //2^3=8
}
else if (bytes <= 1024)
{
return _Index(bytes - 128, 4) + group_array[0];
}
else if (bytes <= 8 * 1024)
{
return _Index(bytes - 1024, 7) + group_array[1] + group_array[0];
}
else if (bytes <= 64 * 1024)
{
return _Index(bytes - 8 * 1024, 10) + group_array[2] + group_array[1]
+ group_array[0];
}
else if (bytes <= 256 * 1024)
{
return _Index(bytes - 64 * 1024, 13) + group_array[3] +
group_array[2] + group_array[1] + group_array[0];
}
else
{
assert(false);
return -1;
}
}
};
关于向上对齐,在桶个数处讨论过,通过内存大小找到哈希桶下标的函数规则也是和桶个数相关
使用位运行同理
- 大小规定
同样放在SizeClass类中
//ThreadCache所能申请最大的内存块大小
static const size_t MAX_BYTES = 256 * 1024;
//设置一页有2^13个字节大小
static const size_t PAGE_SHIFT = 13;
class SizeClass
{
public:
/*ThreadCache向CentrealCache申请n个内存块时
规定一次最多申请几个bytes大小的内存块
*/
static size_t MaxNum(size_t bytes)
{
assert(bytes > 0);
int num = MAX_BYTES / bytes;
if (num < 2)
num = 2;
if (num > 512)
num = 512;
return num;
}
/*CentralCache向PageCache申请span时
计算一次向系统获取几个页的span
单个对象 8byte
...
单个对象 256KB
*/
static size_t SizeToPageNum(size_t bytes)
{
size_t num = MaxNum(bytes);
size_t npage = num * bytes;
npage >>= PAGE_SHIFT;//总字节数/一页大小=n页
//至少有1页
if (npage == 0) npage = 1;
return npage;
}
};
- SpanList
SpanList以Span作为节点的带头双向循环链表,是CentralCache和PageCache的桶元素
class SpanList//带头双向循环链表
{
public:
SpanList()
{
_head = new Span();
_head->_prev = _head;
_head->_next = _head;
}
Span* Begin()
{
return _head->_next;
}
Span* End()
{
return _head;
}
//在pos前插入newSpan
void Insert(Span* pos, Span* newSpan)
{
assert(pos);
assert(newSpan);
//prev newSpan pos
Span* prev = pos->_prev;
prev->_next = newSpan;
newSpan->_next = pos;
newSpan->_prev = prev;
pos->_prev = newSpan;
}
//从链表去除span
void Erase(Span* span)
{
assert(span);
assert(span != _head);
//prev span next
Span* prev = span->_prev;
Span* next = span->_next;
prev->_next = next;
next->_prev = prev;
}
void PushFront(Span* span)//头插
{
Insert(Begin(), span);
}
Span* PopFront()//头删
{
Span* span = Begin();
Erase(span);
return span;
}
bool empty()
{
return Begin() == End();
}
private:
Span* _head;
public:
std::mutex _spanListMtx; // 桶锁
};
2. ThreadCache
- ThreadCache.h
class ThreadCache
{
public:
//申请内存
void* Allocate(size_t size);
//释放内存
void Deallocate(void* ptr, size_t size);
//从CentralCache获取内存块
void* FetchFromCentralCache(size_t index, size_t size);
//链表太长进行回收
void ListTooLong(FreeList& list, size_t size);
private:
FreeList _freeLists[FREELIST_NUM];
};
// TLS thread local storage
// pTLSThreadCache:简单理解每个线程独一份
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;
thread local storage,即线程局部存储(TLS),是一种变量的存储方法,这个变量在它所在的线程内是全局可访问的,但是不能被其他线程访问到,这样就保持了数据的线程独立性
- 使用
//申请size大小的内存
static void* ConcurrentAlloc(size_t size)
{
//在ThreadCache中的内存申请有最大内存限制
if (size > MAX_BYTES)
{
return nullptr;
}
else
{
//创建一个该线程的ThreadCache对象
if (pTLSThreadCache == nullptr)
{
pTLSThreadCache = new ThreadCache();
}
return pTLSThreadCache->Allocate(size);
}
}
//释放内存
static void ConcurrentFree(void* ptr, size_t size)
{
if (size > MAX_BYTES)
{
assert(false);
}
else
{
assert(pTLSThreadCache);
pTLSThreadCache->Deallocate(ptr, size);
}
}
释放时,也需要传递该内存块的大小,有了大小才能映射到ThreadCache的桶位置。但后续可以进行优化(与内存地址和_pageId的转换有关)
- Allocate()
void* ThreadCache::Allocate(size_t size)
{
assert(size <= MAX_BYTES);
//通过size映射到哈希桶下标index
size_t index = SizeClass::Index(size);
//如果桶中链表有节点,则直接返回
if (!_freeLists[index].empty())
{
return _freeLists[index].Pop();
}
else//否则,向CentralCache申请
{
//向上对齐后的空间大小
size_t alignSize = SizeClass::RoundUp(size);
return FetchFromCentralCache(index, alignSize);
}
}
- FetchFromCentralCache()
void* ThreadCache::FetchFromCentralCache(size_t index, size_t size)
{
void* start = nullptr;
void* end = nullptr;
//慢增长,从1~最大个数
size_t num = _freeLists[index].BatchNum();
if (num < SizeClass::MaxNum(size))
{ //每次增加1
_freeLists[index].BatchNum()++;
}
//调用CentralCache对象的方法申请内存块
size_t actualNum = CentralCache::GetInstance()->FetchRangeObj(start, end, num, size);
assert(actualNum >= 1);
//如果只有一个则直接返回
if (actualNum == 1)
{
return start;
}
else//如果有多个,将其余的挂着链表上
{
_freeLists[index].PushRange(NextObj(start), end, actualNum - 1);
return start;
}
}
- Deallocate()
void ThreadCache::Deallocate(void* ptr, size_t size)
{
assert(ptr);
assert(size);
size_t index = SizeClass::Index(size);
//将释放的内存块放到对应的链表上
_freeLists[index].Push(ptr);
/*检查是否存在大量未使用的内存块,如果是,则批量返回给CentraCache
当链表长度_size大于一次批量申请的内存个数_count时
就归还_count个内存块
*/
if (_freeLists[index].Size() >= _freeLists[index].MaxSize())
{
//size_t alignSize = SizeClass::RoundUp(size);//向上对齐后的空间大小
//ListTooLong(_freeLists[index], alignSize);
ListTooLong(_freeLists[index], size);
}
}
- ListTooLong()
void ThreadCache::ListTooLong(FreeList& list, size_t size)
{
void* start = nullptr;
void* end = nullptr;
list.PopRange(start, end, list.MaxSize());
//调用CentralCache对象的方法回收内存块
CentralCache::GetInstance()->ReleaseListToSpans(start, size);
}
3. CentralCache
- CentralCache.h
class CentralCache
{
public:
static CentralCache* GetInstance()
{
return &_sInst;
}
// 获取一个非空的span
Span* GetOneSpan(SpanList& list, size_t size);
// 从中心缓存获取一定数量的对象给thread cache
size_t FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size);
//将归还的内存块重新挂在对应的span上
void ReleaseListToSpans(void* start, size_t size);
private:
SpanList _freeLists[FREELIST_NUM]; //哈希桶
private://单例模式
CentralCache() {};
CentralCache(const CentralCache&) = delete;
static CentralCache _sInst;
};
使用饿汉模式,在程序运行时就创建一个静态的CentralCache对象_sInst,需要在CentralCache.cpp
文件中定义该静态成员变量
CentralCache CentralCache::_sInst;
- FetchRangeObj()
// 从中心缓存获取一定数量的对象给thread cache
//start,end:输出型参数
size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size)
{
size_t index = SizeClass::Index(size);
_freeLists[index]._spanListMtx.lock(); //桶锁,加锁
//获取一个对应的span对象
Span* span = GetOneSpan(_freeLists[index], size);
size_t num = 1;
start = span->_freeList;
end = start;
//end向后移动batchNum-1个或到空
//|start| |end|
while (num < batchNum && NextObj(end))
{
end = NextObj(end);
++num;
}
span->_useCount += num;//使用num个小内存块
span->_freeList = NextObj(end);//从span的链表中去除
NextObj(end) = nullptr;
_freeLists[index]._spanListMtx.unlock(); //桶锁,解锁
return num;//返回实际申请的个数
}
- GetOneSpan()
Span* CentralCache::GetOneSpan(SpanList& list, size_t size)
{
//遍历一遍SpanList,寻找有内存的span
Span* span = list.Begin();
while (span != list.End())
{
if (span->_freeList != nullptr)
{
return span;//找到则直接返回
}
else
{
span = span->_next;
}
}
//没有空间,则需要向PageCache申请一个span
list._spanListMtx.unlock();//桶锁,解锁
//对于其他线程而言,此时可以进入该桶(进行释放资源 或 向下申请span阻塞在_pageMtx锁竞争上)
PageCache::GetInstance()->_pageMtx.lock();//总锁,加锁
//调用PageCache对象的方法申请Span
span = PageCache::GetInstance()->NewSpan(SizeClass::SizeToPageNum(size));
span->_isUse = true;//置为使用状态
PageCache::GetInstance()->_pageMtx.unlock();//总锁,解锁
//将span切分成内存块,并挂起,以顺序切
//通过_pageId找到起始地址
char* start = (char*)(span->_pageId << PAGE_SHIFT);//设置为char*,便于指针后移
size_t bytes = span->_n << PAGE_SHIFT;//总字节数
char* end = start + bytes;
span->_freeList = start;
void* tail = start;
start += size;
while (start < end)
{
NextObj(tail) = start;
tail = NextObj(tail);
start += size;
}
NextObj(tail) = nullptr;
list._spanListMtx.lock();//桶锁,加锁
list.PushFront(span);//把span挂到CC上
return span;
}
span->_pageId << PAGE_SHIFT
,实现_pageId到空间地址的转化。在前面有提到过
- ReleaseListToSpans()
//将TC还回来的内存块重新挂在对应的span上
void CentralCache::ReleaseListToSpans(void* start, size_t size)
{
size_t index = SizeClass::Index(size);
_freeLists[index]._spanListMtx.lock(); //桶锁,加锁
while (start)
{
void* next = NextObj(start);//下一个内存块
//调用PageCache对象的方法得到start所属的span对象地址
Span* curSpan = PageCache::GetInstance()->MapObjectToSpan(start);
//将start头插到_freeList上
NextObj(start) = curSpan->_freeList;
curSpan->_freeList = start;
curSpan->_useCount--;
if (curSpan->_useCount == 0)//说明curSpan所有的内存块都被还回来了
{
_freeLists[index].Erase(curSpan);//将curSpan从链表中去除
_freeLists[index]._spanListMtx.unlock();//桶锁,解锁
PageCache::GetInstance()->_pageMtx.lock();
PageCache::GetInstance()->ReleaseSpanToPageCache(curSpan);//调用PageCache对象的方法回收span对象
PageCache::GetInstance()->_pageMtx.unlock();
_freeLists[index]._spanListMtx.lock();//桶锁,加锁
}
start = next;
}
_freeLists[index]._spanListMtx.unlock();//桶锁,解锁
}
关于MapObjectToSpan()通过某个内存块地址到其对应的Span对象的原理,下面有具体实现。可以使用map等键值对进行匹配,通过void*内存的地址找到其所在的页号,然后通过页号来找到配对到对应Span对象。
4. PageCache
- PageCache.h
class PageCache
{
public:
static PageCache* GetInstance()
{
return &_sInst;
}
// 获取从对象到span的映射
Span* MapObjectToSpan(void* obj);
// 获取一个K页的span
Span* NewSpan(size_t k);
//将CC还回来的Span挂回来
void ReleaseSpanToPageCache(Span* span);
public:
std::mutex _pageMtx;//总锁
private:
SpanList _pageLists[PAGELIST_NUM]; //哈希桶
std::map<PAGE_ID, Span*> _idSpanMap;//从PageId到存储其内容的Span*的映射
private://单例模式
PageCache() {};
PageCache(const PageCache&) = delete;
static PageCache _sInst;
};
使用饿汉模式,同样需要在PageCache.cpp
文件中定义该静态成员变量
PageCache PageCache::_sInst;
- MapObjectToSpan()
// 获取从对象到span的映射
Span* PageCache::MapObjectToSpan(void* obj)
{
PAGE_ID id = (PAGE_ID)obj >> PAGE_SHIFT;//obj所在页的页号
std::unique_lock<std::mutex> lock(_pageMtx);//加锁访问映射关系
auto ret = _idSpanMap.find(id);
if (ret != _idSpanMap.end())//找到了
{
return ret->second;
}
else
{
assert(false);
return nullptr;
}
}
在_idSpanMap中查找也需要加_pageMtx锁,是因为在id—>span的映射关系可能正在被增加或修改,这两种操作在NewSpan()或ReleaseSpanToPageCache()中存在,而使用这两个函数都会在_pageMtx加锁下进行。
- NewSpan()
// 获取一个K页的span
Span* PageCache::NewSpan(size_t k)
{
assert(k > 0);
//如果桶中有span则直接返回
if (!_pageLists[k].empty())
{
Span* kSpan = _pageLists[k].PopFront();
// 将span中每一个页的页号都与span*进行映射
for (PAGE_ID i = 0; i < kSpan->_n; ++i)
{
_idSpanMap[kSpan->_pageId + i] = kSpan;
}
return kSpan;
}
//检查有无大于k页的span,若i存在,则切分成 k和i-k两个span
for (size_t i = k + 1; i < PAGELIST_NUM; ++i)
{
if (!_pageLists[i].empty())//存在
{
Span* iSpan = _pageLists[i].PopFront();//先将从链表中取出
Span* kSpan = new Span;//创建一个kSpan
//从iSpan头切下一个kSpan
kSpan->_n = k;
kSpan->_pageId = iSpan->_pageId;
//iSpan变成i-k大小的span
iSpan->_pageId += k;
iSpan->_n -= k;
//将iSpan挂到对应链表上
_pageLists[iSpan->_n].PushFront(iSpan);
/*存储 新的未使用的iSan的前后两页id<-->Span*的映射关系
此步骤是为了将空闲span进行合并成更大span所需要的
*/
_idSpanMap[iSpan->_pageId] = iSpan;
_idSpanMap[iSpan->_pageId + iSpan->_n - 1] = iSpan;
// 将span中每一个页的页号都与span*进行映射
for (PAGE_ID i = 0; i < kSpan->_n; ++i)
{
_idSpanMap[kSpan->_pageId + i] = kSpan;
}
return kSpan;//返回
}
}
/* 走到这个位置就说明没有空闲span了
这时就去找堆要一个128页(最后大)的span
*/
Span* bigSpan = new Span;
void* ptr = SystemAlloc(PAGELIST_NUM - 1);
bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
bigSpan->_n = PAGELIST_NUM - 1;
_pageLists[bigSpan->_n].PushFront(bigSpan);
return NewSpan(k);//代码复用
}
- ReleaseSpanToPageCache()
//将CC还回来的Span挂回来
void PageCache::ReleaseSpanToPageCache(Span* span)
{
while (1) //向前合并
{
PAGE_ID pId = span->_pageId - 1;
auto ret = _idSpanMap.find(pId);
if (ret == _idSpanMap.end()) break; //没有该span
Span* prevSpan = ret->second;
if (prevSpan->_isUse == true) break;//被使用
if (span->_n + prevSpan->_n > PAGELIST_NUM - 1) break; //超过最大范围
_pageLists[prevSpan->_n].Erase(prevSpan);//将prevSpan取出
//向前进行合并
span->_pageId = prevSpan->_pageId;
span->_n += prevSpan->_n;
delete prevSpan;
}
while (1) //向后合并
{
PAGE_ID nId = span->_pageId + span->_n;
auto ret = _idSpanMap.find(nId);
if (ret == _idSpanMap.end()) break;//不存在
Span* nextSpan = ret->second;
if (nextSpan->_isUse == true) break;//在使用中
if (span->_n + nextSpan->_n > PAGELIST_NUM - 1) break;//合并后页数超过最大限度
_pageLists[nextSpan->_n].Erase(nextSpan);//将nextSpan取出
//向后进行合并
span->_n += nextSpan->_n;
delete nextSpan;
}
//合并结束
span->_isUse = false;//状态置为未使用
_pageLists[span->_n].PushFront(span);//放回pageCache
//存储 新的未使用的span的前后两页id<-->Span*的映射关系
_idSpanMap[span->_pageId] = span;
_idSpanMap[span->_pageId + span->_n - 1] = span;
}
五. 优化
1. 释放不传对象大小
- 在Span对象中新增属性_objSize用来保存该Span中内存块大小
struct Span
{
...
size_t _objSize = 0;//span中的内存块的大小
};
- 在使用PageCache对象得到一个span后,对_objSize进行赋值
Span* CentralCache::GetOneSpan(SpanList& list, size_t size)
{
...
PageCache::GetInstance()->_pageMtx.lock();
span = PageCache::GetInstance()->NewSpan(SizeClass::SizeToPageNum(size));
span->_isUse = true;
span->_objSize = size;//内存块大小为size
PageCache::GetInstance()->_pageMtx.unlock();
...
}
- 调用PageCache对象的方法得到内存块所属的span对象,然后根据_objSize得到内存大小
static void ConcurrentFree(void* ptr)
{
Span* span = PageCache::GetInstance()->MapObjectToSpan(ptr);
size_t size = span->_objSize;
if (size > MAX_BYTES)
{
assert(false);
}
else
{
assert(pTLSThreadCache);
pTLSThreadCache->Deallocate(ptr, size);
}
}
2. 脱离new
该内存池最后期望来代替malloc,因此在实现中,内部是不能过调用malloc函数的,在代码中我们使用了new来创建ThreadCache与Span对象,而new底层实际上就是malloc。
此时,最开始实现的定长内存池就起作用了,因为对定长空间的申请与释放,正是其优势。
- 在ConturrentAlloc()中创建一个静态的定长内存池,用于对ThreadCache对象的申请
在ConcurrentAlloc()中,首次创建ThreadCache对象。可能会存在多个线程同步,因此需要对tcPool加锁访问。
static void* ConcurrentAlloc(size_t size)
{
if (size > MAX_BYTES)
{
return nullptr;
}
else
{
if (pTLSThreadCache == nullptr)
{
static std::mutex poolMtx;
static ObjectPool<ThreadCache> tcPool;
poolMtx.lock();
if(pTLSThreadCache == nullptr)
pTLSThreadCache = tcPool.New();
poolMtx.unlock();
//pTLSThreadCache = new ThreadCache();
}
return pTLSThreadCache->Allocate(size);
}
}
- 在PageCache类中新增定长内存池属性,在NewSpan()和ReleaseSpanToPageCache()中通过_spanPool来申请释放Span对象
class PageCache
{
private:
ObjectPool<Span> _spanPool;
}
Span* PageCache::NewSpan(size_t k)
{
...
Span* kSpan = _spanPool.New();
//Span* kSpan = new Thread;
...
Span* bigSpan = _spanPool.New();
//Span* bigSpan = new Thread;
}
void PageCache::ReleaseSpanToPageCache(Span* span)
{
...
_spanPool.Delete(prevSpan);
//delete prevSpan;
...
_spanPool.Delete(nextSpan);
//delete nextSpan;
}
由于对PageCache对象操作都会加锁访问,因此对_spanPool的操作也是线程互斥的。
3. 大于256kb大块内存申请
由于ThreadCache中哈希桶管理的空间有限,因此当申请的内存块超过256Kb时,就需要额外处理。此时可以让其直接向PageCache申请释放内存
static size_t RoundUp(size_t bytes)
{
。。。
else//超过256*1024字节的空间
{
/*assert(false);
return -1;*/
//按一页的大小为对齐数
return _RoundUp(bytes, 1 << PAGE_SHIFT);
}
}
static void* ConcurrentAlloc(size_t size)
{
//内存申请超过ThreadCache的限制,直接向pageCache申请
if (size > MAX_BYTES)
{
//将size向上对齐
size_t alignSize = SizeClass::RoundUp(size);
//需要的页数
size_t k = alignSize >> PAGE_SHIFT;
PageCache::GetInstance()->_pageMtx.lock();
Span* span = PageCache::GetInstance()->NewSpan(k);
span->_isUse = true;//!!!可能是申请一个_n:[32,128]的
span->_objSize = alignSize;//该span的内存块大小
PageCache::GetInstance()->_pageMtx.unlock();
void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
return ptr;
}
else
{
//创建一个该线程的ThreadCache对象
if (pTLSThreadCache == nullptr)
{
static std::mutex poolMtx;
static ObjectPool<ThreadCache> tcPool;
poolMtx.lock();
if(pTLSThreadCache == nullptr)
pTLSThreadCache = tcPool.New();
poolMtx.unlock();
//pTLSThreadCache = new ThreadCache();
}
return pTLSThreadCache->Allocate(size);
}
}
Span* PageCache::NewSpan(size_t k)
{
if (k > PAGELIST_NUM - 1)//超过最大页数,直接向堆申请
{
void* ptr = SystemAlloc(k);
//Span* span = new Span();
Span* span = _spanPool.New();
span->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
span->_n = k;
span->_isUse = true;
//_idSpanMap[span->_pageId] = span;
_idSpanMap.set(span->_pageId, span);
return span;
}
...
}
256 K b = 32 ∗ 2 13 B y t e 256Kb = 32*2^{13}Byte 256Kb=32∗213Byte,即如果申请的内存块大小在[32,128]页直接的话,可以根据PageCache的逻辑返回其哈希桶上的span。但如果大小超过128页的空间,则直接向堆申请
static void ConcurrentFree(void* ptr)
{
Span* span = PageCache::GetInstance()->MapObjectToSpan(ptr);
size_t size = span->_objSize;
if (size > MAX_BYTES)
{
PageCache::GetInstance()->_pageMtx.lock();
span->_isUse = false;//!!!
PageCache::GetInstance()->ReleaseSpanToPageCache(span);
PageCache::GetInstance()->_pageMtx.unlock();
}
else
{
assert(pTLSThreadCache);
pTLSThreadCache->Deallocate(ptr, size);
}
}
void PageCache::ReleaseSpanToPageCache(Span* span)
{
//大块内存释放
if (span->_n > PAGELIST_NUM - 1)
{
void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
SystemFree(ptr);
//delete span;
_spanPool.Delete(span);
return;
}
...
}
inline static void SystemFree(void* ptr)
{
#ifdef _WIN32
VirtualFree(ptr, 0, MEM_RELEASE);
#else
// sbrk unmmap等
#endif
}
对大于256Kb的内存块,释放也是直接通过PageCache对象,当内存大小在[32,128]页间的话,可以根据PageCache的逻辑挂在其哈希桶中。若超过的话,直接释放给堆。
六. 测试
1. 对比malloc测试
#include"ConcurrentAlloc.h"
// ntimes 一轮申请和释放内存的次数
//nworks 线程数 rounds 轮次
void BenchmarkMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
std::vector<std::thread> vthread(nworks);
std::atomic<size_t> malloc_costtime = 0;//malloc消耗时间
std::atomic<size_t> free_costtime = 0;//free消耗时间
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));
v.push_back(malloc((16 + i) % 8192 + 1));
}
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, (unsigned int)malloc_costtime);
printf("%u个线程并发执行%u轮次,每轮次free %u次: 花费:%u ms\n",
nworks, rounds, ntimes, (unsigned int)free_costtime);
printf("%u个线程并发malloc&free %u次,总计花费:%u ms\n",
nworks, nworks * rounds * ntimes, (unsigned int)(malloc_costtime + free_costtime));
}
// 单轮次申请释放次数 线程数 轮次
void BenchmarkConcurrentMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
std::vector<std::thread> vthread(nworks);
std::atomic<size_t> malloc_costtime = 0;
std::atomic<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(16));
v.push_back(ConcurrentAlloc((16 + i) % 8192 + 1));
}
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, (unsigned int)malloc_costtime);
printf("%u个线程并发执行%u轮次,每轮次concurrent dealloc %u次: 花费:%u ms\n",
nworks, rounds, ntimes, (unsigned int)free_costtime);
printf("%u个线程并发concurrent alloc&dealloc %u次,总计花费:%u ms\n",
nworks, nworks * rounds * ntimes, (unsigned int)(malloc_costtime + free_costtime));
}
int main()
{
size_t n = 1000;
cout << "==========================================================" << endl;
BenchmarkConcurrentMalloc(n, 4, 10);
cout << endl << endl;
BenchmarkMalloc(n, 4, 10);
cout << "==========================================================" << endl;
return 0;
}
在测试中,发现我们实现的ConcurrentAlloc实际上还比malloc效率慢许多。接下来,我们就需要分析到底是什么原因导致效率的降低了
2. 性能瓶颈分析
-
使用vs软件提供的性能分析工具
-
消耗时间最多的地方
- 分析
我们在释放内存块时,是必须要使用到从内存块地址到对于span对象的映射的。我们采取map键值对进行映射,虽然能够满足需求,可是由于每个内存块从ThreadCache归还到CentralCache时都需要调用MapObjectToSpan()函数,而且还需要加锁互斥访问,就导致对该函数的大量调用,却在lock锁时内耗,导致效率大幅减小
3. 使用基数树进行优化
关于基数树
在tcmalloc中关于映射的处理,大佬就是使用基数树来解决的,但是相对于其完整的代码,我们简化使用其中一部分,方便学习
思路:在一页为 2 13 B y t e 2^{13}Byte 213Byte大小下,32位机器上一共有 2 32 / 2 13 = 2 19 2^{32} / 2^{13} = 2^{19} 232/213=219个页号。因此可以创建一个大数组(基数树),该数组有 2 19 2^{19} 219个元素,每个元素是一个指针类型。其占用空间 2 19 ∗ 4 = 2 21 B y t e = 2 M b 2^{19}*4=2^{21}Byte= 2Mb 219∗4=221Byte=2Mb
// Single-level array
template <int BITS>
class TCMalloc_PageMap1 {
private:
static const int LENGTH = 1 << BITS;//长度
void** array_;
public:
typedef unsigned int Number;
//初始化时直接创建空间
explicit TCMalloc_PageMap1() {
size_t size = sizeof(void*) << BITS;
size_t alignSize = SizeClass::_RoundUp(size, 1 << PAGE_SHIFT);
array_ = (void**)SystemAlloc(alignSize >> PAGE_SHIFT);
memset(array_, 0, sizeof(void*) << BITS);
}
// Return the current value for KEY. Returns NULL if not yet set,
// or if k is out of range.
void* get(Number k) const {
if ((k >> BITS) > 0) {
return NULL;
}
return array_[k];//返回k号页的内容
}
// REQUIRES "k" is in range "[0,2^BITS-1]".
// REQUIRES "k" has been ensured before.
//
// Sets the value 'v' for key 'k'.
void set(Number k, void* v) {
array_[k] = v;//建立映射
}
};
- 然后需要把PageCache类中的_idSpanMap进行修改
class PageCache
{
private:
TCMalloc_PageMap1<32 - PAGE_SHIFT> _idSpanMap;
//从PageId到存储其内容的Span*的映射
//std::map<PAGE_ID, Span*> _idSpanMap;
};
- 然后将使用的地方进行修改
Span* PageCache::MapObjectToSpan(void* obj)
{
PAGE_ID id = (PAGE_ID)obj >> PAGE_SHIFT;//obj所在页的页号
auto ret = (Span*)_idSpanMap.get(id);//用id得到Span*
assert(ret != nullptr);
return ret;
}
//_idSpanMap[span->_pageId] = span;
_idSpanMap.set(span->_pageId, span);//设置id,与span的映射关系
- 结果
此时高并发内存池也就告一段落了。
完成代码保存在github上了戳我访问
🦀🦀观看~