前言
池化技术是向系统申请过量的资源,自己管理这些资源,为后面需要做准备。
项目介绍
当前项目是实现一个高并发内存池,来自geogle开源项目tcmalloc,Thread-Cacheing Malloc,线程缓存的malloc,实现了高效多线程内存管理,可以替代系统的内存分配相关的函数malloc。
内存池作用
可以解决效率问题和内存碎片问题。
一次性多拿一些空间比多次拿定量适合的空间效率快,前者向系统申请空间的次数少,后者次数多,次数多就会导致效率低下,前者虽然有空间的消耗,但是效率高,拿空间换效率。
内存碎片


上面申请空间是连续申请一片空间,就会有一种情况,有些空间申请完后释放,新的申请来了,总剩余空间是足够的,但是不是连续,就会申请失败,这就是外部碎片。而这个项目有操作处理这个问题。
tcmalloc和malloc关系
C/C++程序申请空间不是直接去堆上,而是借助一个函数申请的,也就是malloc函数,释放就是free。C++中的new里面是调用了malloc,delete内部也是调用了free函数。malloc和free都是属于用户操作接口这一层,进程是用户级别需要变为内核级别拿到空间,所有会调用系统调用获取空间。
malloc实际上也是一个内存池,会向系统申请过量的空间资源,每次申请空间malloc会先检查自己的剩余空间是否足够,足够就会调用系统调用申请空间,直接剩余空间分配过去。
malloc和tcmalloc都是可以的,在不同的地方优势不同,tcmalloc在多线程效率上比malloc快。
定长内存池
(实现tcmalloc会用到这一部分)
1.memory指针指向一块连续空间

2.内存池管理需要有申请和释放,用户申请的空间用完需要释放,但是这里的释放不会还回去OS,而是存储起来,下一次申请可能可以用这些存储起来的,而且还回去是有要求的,申请多少就要free多少,不能只free一部分会报错的,大部分空间都空了,但是有一部分还在使用就free不了。就可以用一个链表管理这些被还回来的空间,把每个还回来的空间的头部分存储一个地址,这个地址指向下一个还回来的空间,就可以串起来这些资源。

实现部分
模板类是因为不知道申请空间给对象的具体多少,不同类型对象申请的空间不一样,memory是指向大块内存的指针,remainbytes是剩余空间的字节数,freelist是申请空间还回来的头指针。
申请空间先检查freelist是否有空间,有就可以取出来用,这里直接返回的空间是够用的,因为申请后被还回的,被申请的空间肯定得满足,则freelsit上的空间肯定也是够的。freelsit上没有,则判断申请对象大小是否超出剩余字节数,不够申请就需要调用SystemAlloc函数,可以向系统申请空间,这里有条件编译,任何是在window系统上编译,编译器会执行 #ifdef _WIN32
和 #endif
之间的代码。这里<<13是要得到页的数量,这里就是16页大小,objSize的大小之所以这样计算是因为T可能会小于一个指针大小,要保证能存下一个指针大小,申请了objsize大小,memory地址就需要后移objsize,剩余字节数也需要减少。这里有个问题就是不同位下的指针大小不同,32位是4个字节,64位是8个字节,可以用一个二级指针在对其解引用,就可以得到一个指针大小了,
*(**void)就可以不用判断个数了。申请空间后就可以调用new(obj) T构造函数,obj是指向内存地址的指针,也就是被构造存储位置,T要构造对象的类型。delete函数就是调用对象析构函数,把这个地址头插到freeList中。
VirtualAlloc函数是windows的申请空间系统调用函数。
cpp
#pragma once
#include "Common.h"
// 定长内存池
//template<size_t N>
//class ObjectPool
//{};
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32
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;
}
static void*& NextObj(void* obj)
{
return *(void**)obj;
}
template<class T>
class ObjectPool
{
public:
T* New()
{
T* obj = nullptr;
// 优先把还回来内存块对象,再次重复利用
if (_freeList)
{
void* next = *((void**)_freeList);
obj = (T*)_freeList;
_freeList = next;
}
else
{
// 剩余内存不够一个对象大小时,则重新开大块空间
if (_remainBytes < sizeof(T))
{
_remainBytes = 128 * 1024;
//_memory = (char*)malloc(_remainBytes);
_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;
_remainBytes -= objSize;
}
// 定位new,显示调用T的构造函数初始化
new(obj)T;
return obj;
}
void Delete(T* obj)
{
// 显示调用析构函数清理对象
obj->~T();
// 头插
*(void**)obj = _freeList;
_freeList = obj;
}
private:
char* _memory = nullptr; // 指向大块内存的指针
size_t _remainBytes = 0; // 大块内存在切分过程中剩余字节数
void* _freeList = nullptr; // 还回来过程中链接的自由链表的头指针
};
PROT_READ | PROT_WRITE
:表示分配的内存区域可以读写
MAP_PRIVATE | MAP_ANONYMOUS
:表示分配匿名内存,不与文件关联。
-1
:表示文件描述符,对于匿名内存,设置为-1
高并发内存池框架

第一层是thread cache ,第二层是central cache,第三层是page cache。
第一层thread-cache
一个进程有几个线程,就会有几个thread-cache(tc),每一个线程都会有对应的tc,一个管理空间的对象,每个线程不需要加锁,因为每一个线程都有属于自己的tc,到自己的tc中去申请空间,单次向tc申请的空间最大位256KB。
第二层central-cache
当线程的tc中空间不够时,就会向上申请,也就是cc,这里需要注意,当多个线程向cc申请空间时,不一定要有锁,只有对同一个桶申请才要加锁,cc是由哈希桶构成的,每一个桶也有着空间。
cc也会收回给tc的空间,当tc申请了很多空间,并且这些空间释放(不是delete的),则tc的freelist就会过长,所以cc就要收回多余空间。当tc向cc申请空间时,cc也不足于给tc就会向上申请,也就是pagecache.
第三层page-cache
pc中会有多个span的结构体,span会管理多个页大小空间,pc是一个管理页,一页就是4/8KB,这里有解决碎片问题。
tc实现

tc的freelist是链表,所以需要写一些对链表的操作,如增删查
size表示链表中存在的个数,maxsize的作用是后面满增长需求,就像好学生要多给一点奖励,申请次数多的就会多给一些空间。
cpp
class FreeList
{
public:
void Push(void* obj)
{
assert(obj);
// 头插
//*(void**)obj = _freeList;
NextObj(obj) = _freeList;
_freeList = obj;
++_size;
}
void PushRange(void* start, void* end, size_t n)
{
NextObj(end) = _freeList;
_freeList = start;
// 测试验证+条件断点
/*int i = 0;
void* cur = start;
while (cur)
{
cur = NextObj(cur);
++i;
}
if (n != i)
{
int x = 0;
}*/
_size += n;
}
void PopRange(void*& start, void*& end, size_t n)
{
assert(n <= _size);
start = _freeList;
end = start;
for (size_t i = 0; i < n - 1; ++i)
{
end = NextObj(end);
}
_freeList = NextObj(end);
NextObj(end) = nullptr;
_size -= n;
}
void* Pop()
{
assert(_freeList);
// 头删
void* obj = _freeList;
_freeList = NextObj(obj);
--_size;
return obj;
}
bool Empty()
{
return _freeList == nullptr;
}
size_t& MaxSize()
{
return _maxSize;
}
size_t Size()
{
return _size;
}
private:
void* _freeList = nullptr;
size_t _maxSize = 1;
size_t _size = 0;
};
thread-cache哈希结构
tc有一个哈希表,每一个哈希桶表示一个链表。
lass 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[NFREELIST];
};
这里间隔是要增长的,不能定长的分,前者的碎片问题是优于后者的。这里size就是要申请的空间,比如申请7B那么就会得到8B的空间,这里对齐数的意义就是减少内碎片的问题,内碎片就是因为最小分配单位也会有间隙,这里的间隙就是内碎片,比如7B拿到8B就有1B是用不到的,这里的1B就是内碎片。不同size对应不同的哈希桶,下标为0的哈希桶就是8B空间,下标1的是16B空间。按照下面的对齐规则就需要208个桶。
// 整体控制在最多10%左右的内碎片浪费
// [1,128] 8byte对齐 freelist[0,16)
// [128+1,1024] 16byte对齐 freelist[16,72)
// [1024+1,8*1024] 128byte对齐 freelist[72,128)
// [8*1024+1,64*1024] 1024byte对齐 freelist[128,184)
// [64*1024+1,256*1024] 8*1024byte对齐 freelist[184,208)

C++中尽量避免使用宏,可以用static const替换。单次申请空间不会超过256KB,所以定义一个MAX_BYTES;

thread cache类中需要的方法,首先得有申请空间方法,申请空间有了就需要释放空间的方法,这里申请空间是有两个方向的,第一在freelist中申请,如果没有就需要往上申请,也就是在central cache中申请,而释放空间还需要有一个判断,就是前面提到申请空间次数越多分配越多,那么释放空间就会导致freelist中的长度越来越长,就需要收回一部分到cc中。
接下来需要写一个把size转换成对齐数的方法。
这里是根据不同区间来对应不同的对齐数,因为位运算效率高,但也难想,注释掉则是容易做到的。注意这里有static主要是把函数变为静态成员函数,调用这个函数就不用创建一个类对象再去使用这个方法,可用直接使用这个函数。
cpp
/*size_t _RoundUp(size_t size, size_t alignNum)
{
size_t alignSize;
if (size % alignNum != 0)
{
alignSize = (size / alignNum + 1)*alignNum;
}
else
{
alignSize = size;
}
return alignSize;
}*/
// 1-8
static inline size_t _RoundUp(size_t bytes, size_t alignNum)
{
return ((bytes + alignNum - 1) & ~(alignNum - 1));
}
static inline size_t RoundUp(size_t size)
{
if (size <= 128)
{
return _RoundUp(size, 8);
}
else if (size <= 1024)
{
return _RoundUp(size, 16);
}
else if (size <= 8*1024)
{
return _RoundUp(size, 128);
}
else if (size <= 64*1024)
{
return _RoundUp(size, 1024);
}
else if (size <= 256 * 1024)
{
return _RoundUp(size, 8*1024);
}
else
{
return _RoundUp(size, 1<<PAGE_SHIFT);
}
}
还需要知道size对应的下标位置,才能找到对应哪一个桶。
cpp
/*size_t _Index(size_t bytes, size_t alignNum)
{
if (bytes % alignNum == 0)
{
return bytes / alignNum - 1;
}
else
{
return bytes / alignNum;
}
}*/
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 <= 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;
}
Allocate函数实现
cpp
void* ThreadCache::Allocate(size_t size)
{
assert(size <= MAX_BYTES);
size_t alignSize = SizeClass::RoundUp(size);
size_t index = SizeClass::Index(size);
if (!_freeLists[index].Empty())
{
return _freeLists[index].Pop();
}
else
{
return FetchFromCentralCache(index, alignSize);
}
}
FetchFromCentralCache函数实现
batchnum就是当次申请的批量,这里要比较这个下标maxsize和NumMoveSize里的俩值最小一个,这个函数保证了申请的批量在2-512之间,小对象一次申请批量多,打对象申请的少,maxsize虽然名字上是最大值,其实可以看成是已经申请次数,开始是1,那么最小肯定是maxsize的1,如果batchnum等于maxsize,maxsize就加1,只要maxsize小于nummovesize函数返回的值,那么每次申请都会加1,类似于奖励机制,要的越多给的越多。定义start和end,再调用cc层面的方法获取空间,这里的start和end是输出型参数,cc的函数执行完,start和end区间就是有效可用的空间,actualNum是实际获取数量,如果等于1,就放回start,大于1,就需要放回1个,其余的插入到freeList中存储。如三个线程,分别申请4,5,6B大小,都是下标为0的桶,那么第一次batchnum=1,第二次batchnum=2,所以当到6B时,就不用到cc中申请空间了,因为freeList还有一个可用空间。
cpp
void* ThreadCache::FetchFromCentralCache(size_t index, size_t size)
{
// 慢开始反馈调节算法
// 1、最开始不会一次向central cache一次批量要太多,因为要太多了可能用不完
// 2、如果你不要这个size大小内存需求,那么batchNum就会不断增长,直到上限
// 3、size越大,一次向central cache要的batchNum就越小
// 4、size越小,一次向central cache要的batchNum就越大
size_t batchNum = min(_freeLists[index].MaxSize(), SizeClass::NumMoveSize(size));
if (_freeLists[index].MaxSize() == batchNum)
{
_freeLists[index].MaxSize() += 1;
}
void* start = nullptr;
void* end = nullptr;
size_t actualNum = CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, size);
assert(actualNum > 0);
if (actualNum == 1)
{
assert(start == end);
return start;
}
else
{
_freeLists[index].PushRange(NextObj(start), end, actualNum-1);
return start;
}
}
cpp
// 一次thread cache从中心缓存获取多少个
static size_t NumMoveSize(size_t size)
{
assert(size > 0);
// [2, 512],一次批量移动多少个对象的(慢启动)上限值
// 小对象一次批量上限高
// 小对象一次批量上限低
int num = MAX_BYTES / size;
if (num < 2)
num = 2;
if (num > 512)
num = 512;
return num;
}
Deallocate实现
这里的size已经是对齐数大小了,因为申请空间大小一定是对齐数的倍数,那么释放的肯定是申请空间的,这里释放就是到对应的桶位置插入,还需要判断这里的大小和maxsize的大小,以maxsize作为链表是否过长判断标准,大于就调用函数去缩减。也就是调用poprange函数,把maxsize的个数从链表取出。
cpp
void ThreadCache::Deallocate(void* ptr, size_t size)
{
assert(ptr);
assert(size <= MAX_BYTES);
// 找对映射的自由链表桶,对象插入进入
size_t index = SizeClass::Index(size);
_freeLists[index].Push(ptr);
// 当链表长度大于一次批量申请的内存时就开始还一段list给central cache
if (_freeLists[index].Size() >= _freeLists[index].MaxSize())
{
ListTooLong(_freeLists[index], size);
}
}
void ThreadCache::ListTooLong(FreeList& list, size_t size)
{
void* start = nullptr;
void* end = nullptr;
list.PopRange(start, end, list.MaxSize());
CentralCache::GetInstance()->ReleaseListToSpans(start, size);
}
TLS thread local storage
线程的本地存储,每一个线程都要有自己的空间存储threadcache,线程几乎是一起共享进程的虚拟地址空间,每个线程有独立的栈,寄存器等。进程的全局变量每个线程都是共享的,而TLS,线程局部存储,只能被自己线程访问,其它线程不能访问除自己之外的。
// TLS thread local storage
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;
线程申请和释放空间接口
这里线程申请空间先判断size的大小是否超出限制,没有还要判断本地线程存储是否为空,一开始都是空的,就会分配一个空间来创建本地线程存储,创建后就可以调用方法去申请空间,这里不用new是因为new底层还是malloc的,而这个项目是tcmalloc,就不能有malloc出现,就用到前面的定长内存池。释放空间也是判断size做出不同方法。
cpp
#pragma once
#include "Common.h"
#include "ThreadCache.h"
#include "PageCache.h"
#include "ObjectPool.h"
static void* ConcurrentAlloc(size_t size)
{
if (size > MAX_BYTES)
{
size_t alignSize = SizeClass::RoundUp(size);
size_t kpage = alignSize >> PAGE_SHIFT;
PageCache::GetInstance()->_pageMtx.lock();
Span* span = PageCache::GetInstance()->NewSpan(kpage);
span->_objSize = size;
PageCache::GetInstance()->_pageMtx.unlock();
void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
return ptr;
}
else
{
// 通过TLS 每个线程无锁的获取自己的专属的ThreadCache对象
if (pTLSThreadCache == nullptr)
{
static ObjectPool<ThreadCache> tcPool;
//pTLSThreadCache = new ThreadCache;
pTLSThreadCache = tcPool.New();
}
//cout << std::this_thread::get_id() << ":" << pTLSThreadCache << endl;
return pTLSThreadCache->Allocate(size);
}
}
static void ConcurrentFree(void* ptr)
{
Span* span = PageCache::GetInstance()->MapObjectToSpan(ptr);
size_t size = span->_objSize;
if (size > MAX_BYTES)
{
PageCache::GetInstance()->_pageMtx.lock();
PageCache::GetInstance()->ReleaseSpanToPageCache(span);
PageCache::GetInstance()->_pageMtx.unlock();
}
else
{
assert(pTLSThreadCache);
pTLSThreadCache->Deallocate(ptr, size);
}
}
central-cache实现
cc和tc结构相似,cc内部也是哈希结构,根据块的大小来映射。映射规则也一样,在tc哈希桶位置申请,没有则到cc同样位置哈希桶申请空间,方便之处。不同在于锁,因为线程有本地存储存储,所以不用锁,而cc需要,因为多线程可能会同时访问同一个桶,就需要对这个桶加锁。cc链表存储的span结构体,span是管理页的大块内存,span中可用有多个页,其成员_n记录了页的个数。

span挂载不同桶,则内部被切分的个数和大小不一样。比如0号桶,就会分成多个8B空间,用链表串在一起。每个桶下挂的span包含的页数页不同,桶对应的字节数也就是下标,下标越小页数少,下标大页数多。这里的页数就需要管理,span内部就有_pageId来表示span管理的页是几号页,这里的页号和地址是相互转换的。span的usecount是表示空间的使用情况,为0就表示没有使用,这个变量后面可用用来解决外部碎片问题,通过这个变量可用知道这个空间是否被使用,没有就可以和其它空闲空间合并。这里PAGE_ID类型随平台不同而变换。
cpp
#ifdef _WIN64
typedef unsigned long long PAGE_ID;
#elif _WIN32
typedef size_t PAGE_ID;
#else
// linux
#endif
// 管理多个连续页大块内存跨度结构
struct Span
{
PAGE_ID _pageId = 0; // 大块内存起始页的页号
size_t _n = 0; // 页的数量
Span* _next = nullptr; // 双向链表的结构
Span* _prev = nullptr;
size_t _objSize = 0; // 切好的小对象的大小
size_t _useCount = 0; // 切好小块内存,被分配给thread cache的计数
void* _freeList = nullptr; // 切好的小块内存的自由链表
bool _isUse = false; // 是否在被使用
};
SpanList实现
用来管理span结构体,同一个桶可能会挂载多个span,就需要有对span对象的操作方法。
cpp
// 带头双向循环链表
class SpanList
{
public:
SpanList()
{
_head = new Span;
_head->_next = _head;
_head->_prev = _head;
}
Span* Begin()
{
return _head->_next;
}
Span* End()
{
return _head;
}
bool Empty()
{
return _head->_next == _head;
}
void PushFront(Span* span)
{
Insert(Begin(), span);
}
Span* PopFront()
{
Span* front = _head->_next;
Erase(front);
return front;
}
void Insert(Span* pos, Span* newSpan)
{
assert(pos);
assert(newSpan);
Span* prev = pos->_prev;
// prev newspan pos
prev->_next = newSpan;
newSpan->_prev = prev;
newSpan->_next = pos;
pos->_prev = newSpan;
}
void Erase(Span* pos)
{
assert(pos);
assert(pos != _head);
// 1、条件断点
// 2、查看栈帧
/*if (pos == _head)
{
int x = 0;
}*/
Span* prev = pos->_prev;
Span* next = pos->_next;
prev->_next = next;
next->_prev = prev;
}
private:
Span* _head;
public:
std::mutex _mtx; // 桶锁
};
单例模式获取centralcache
centralcache是只有一个的,那么就需要用到单例模式,所有的tc只能访问到这一个cc。静态成员需要在类外说明,可能有多个文件包含了这个头文件,就会有多个位置初始化这个成员,从而出现问题,要在cpp文件里初始化。
cpp
#pragma once
#include "Common.h"
// 单例模式
class CentralCache
{
public:
static CentralCache* GetInstance()
{
return &_sInst;
}
// 获取一个非空的span
Span* GetOneSpan(SpanList& list, size_t byte_size);
// 从中心缓存获取一定数量的对象给thread cache
size_t FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size);
// 将一定数量的对象释放到span跨度
void ReleaseListToSpans(void* start, size_t byte_size);
private:
SpanList _spanLists[NFREELIST];
private:
CentralCache()
{}
CentralCache(const CentralCache&) = delete;
static CentralCache _sInst;
};

函数ThreadCache::FetchFromCentralCache(size_t index, size_t alignSize)
知道对应下标,可用找到cc对应桶位置,从cc获取空间放回给tc。而获取数量就可以batchnum来决定。实际从cc获取的空间是batchnum*aligesize大小空间,从桶中找到到一个span,在从span中获取这么大的空间,也会有span中不够的情况,前面被取过,则cc就会到pc中就申请空间。batchnum和actualnum可能不同,当这个桶开始时从pc获取空间,第一个tc从这个桶申请空间走了,但是还剩了一些,这些就在span中,当第二个线程来了,span不为空,就会把这个span分配过去,如何取batchnum个,但是可能没到batchnum个,就因为到nullptr而结束,这样actualnum就会小于batchnum。但是能保证大于1,也就是一定有一个,只是奖励没了,不会多给几个放到freelist中存储。


实现部分
获取span,第一步是去空闲链表spanlist中找,是否有可用的,找到就可以放回,在空闲链表里的就可能是actualnum<batchnum,可能是前面取了一部分的,要是spanlist里没有就要到pc申请了,pc申请的就是完整的span,actualnum=batchnum了,还要设置申请新span的属性,isUse是是否使用的意思,这里被申请了就是被用了为true,把span中要切份的大小objsize,这里是把span进行切块,每块都是objsize的大小,start表示起始地址,这里的地址使用页号转换的,页号是地址<<13,所以就可以通过页号反向得到地址,而页数就是空间头到尾的大小,就可以得到end地址,有首位地址以及每一份的大小,就可以切分span,把切分好的span放到spanlist中。此时fetchrangeobj就有span了,根据每一块空间的头是下一块的地址特性,这里在newspan中以及做好了切分,所以就可以根据这个拿batchnum个size大小空间,给span的useconut加上actualnum,把实际数量返回。
释放函数,这里是拿到一个地址,因为申请的空间可能是多块,所以需要循环,保存start的下一块地址在next,根据哈希映射(页号和span为key和value),就可以找到对应的span,然后把这个申请空间的地址插入到通过映射知道的span的freelist中,判断usecount是否为0,不为0就说明还有这个span中有一部分还在使用,因为span是被切分的,start=next,进行第二次,映射得到的还是同一个span,因为释放的是一个连续地址,申请的时候是在一个span中切分好的,所以就会把这个地址也插入到同一个freelist中,usecount--,直到这个span切分出去的空间都还回来了,就执行if里面的,把cc这个下标的span删除,然后把内部元素也置空,然后把span交给pc处理,要进行合并空闲span。
cpp
#include "CentralCache.h"
#include "PageCache.h"
CentralCache CentralCache::_sInst;
// 获取一个非空的span
Span* CentralCache::GetOneSpan(SpanList& list, size_t size)
{
// 查看当前的spanlist中是否有还有未分配对象的span
Span* it = list.Begin();
while (it != list.End())
{
if (it->_freeList != nullptr)
{
return it;
}
else
{
it = it->_next;
}
}
// 先把central cache的桶锁解掉,这样如果其他线程释放内存对象回来,不会阻塞
list._mtx.unlock();
// 走到这里说没有空闲span了,只能找page cache要
PageCache::GetInstance()->_pageMtx.lock();
Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(size));
span->_isUse = true;
span->_objSize = size;
PageCache::GetInstance()->_pageMtx.unlock();
// 对获取span进行切分,不需要加锁,因为这会其他线程访问不到这个span
// 计算span的大块内存的起始地址和大块内存的大小(字节数)
char* start = (char*)(span->_pageId << PAGE_SHIFT);
size_t bytes = span->_n << PAGE_SHIFT;
char* end = start + bytes;
// 把大块内存切成自由链表链接起来
// 1、先切一块下来去做头,方便尾插
span->_freeList = start;
start += size;
void* tail = span->_freeList;
int i = 1;
while (start < end)
{
++i;
NextObj(tail) = start;
tail = NextObj(tail); // tail = start;
start += size;
}
NextObj(tail) = nullptr;
// 1、条件断点
// 2、疑似死循环,可以中断程序,程序会在正在运行的地方停下来
//int j = 0;
//void* cur = span->_freeList;
//while (cur)
//{
// cur = NextObj(cur);
// ++j;
//}
//if (j != (bytes / size))
//{
// int x = 0;
//}
// 切好span以后,需要把span挂到桶里面去的时候,再加锁
list._mtx.lock();
list.PushFront(span);
return span;
}
// 从中心缓存获取一定数量的对象给thread cache
size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size)
{
size_t index = SizeClass::Index(size);
_spanLists[index]._mtx.lock();
Span* span = GetOneSpan(_spanLists[index], size);
assert(span);
assert(span->_freeList);
// 从span中获取batchNum个对象
// 如果不够batchNum个,有多少拿多少
start = span->_freeList;
end = start;
size_t i = 0;
size_t actualNum = 1;
while ( i < batchNum - 1 && NextObj(end) != nullptr)
{
end = NextObj(end);
++i;
++actualNum;
}
span->_freeList = NextObj(end);
NextObj(end) = nullptr;
span->_useCount += actualNum;
//// 条件断点
int j = 0;
void* cur = start;
while (cur)
{
cur = NextObj(cur);
++j;
}
if (j != actualNum)
{
int x = 0;
}
_spanLists[index]._mtx.unlock();
return actualNum;
}
void CentralCache::ReleaseListToSpans(void* start, size_t size)
{
size_t index = SizeClass::Index(size);
_spanLists[index]._mtx.lock();
while (start)
{
void* next = NextObj(start);
Span* span = PageCache::GetInstance()->MapObjectToSpan(start);
NextObj(start) = span->_freeList;
span->_freeList = start;
span->_useCount--;
// 说明span的切分出去的所有小块内存都回来了
// 这个span就可以再回收给page cache,pagecache可以再尝试去做前后页的合并
if (span->_useCount == 0)
{
_spanLists[index].Erase(span);
span->_freeList = nullptr;
span->_next = nullptr;
span->_prev = nullptr;
// 释放span给page cache时,使用page cache的锁就可以了
// 这时把桶锁解掉
_spanLists[index]._mtx.unlock();
PageCache::GetInstance()->_pageMtx.lock();
PageCache::GetInstance()->ReleaseSpanToPageCache(span);
PageCache::GetInstance()->_pageMtx.unlock();
_spanLists[index]._mtx.lock();
}
start = next;
}
_spanLists[index]._mtx.unlock();
}

pagecache框架
pc也有哈希桶,pc是按照页数的个数作为下表,1到128,最大为128页。


pc哈希桶里面的都是按照页数来映射的,span内部不会切分小块,在第几号桶里面的span大小就是几页。
pagecache实现
newspan函数就是获取span了,参数k就是几页的意思,如果k大于128页就直接系统调用函数开辟,把页号和span进行映射,再去k号桶检查是否有span,找到后就把从页号到页号+页数和span逐一进行映射,如果没有就下部,往下找,找更大的桶进行拆分,比如申请1页大小,但是只有127有,则就把127变成1和126,把126插入126下标桶中,放回1页的span,这里的拆分就是把_n的数量从127到126,因为首地址+_n就是end,所以127页大小就是首地址+127在<<13就是127页的区间地址,则把首地址给1页span,新span的_n为1,则就是一页大小,把旧span-1就是126页大小,把要放回的span,从span到span+n的页号进行逐一映射,因为cc中有根据页号判断这个span是否所有的切分都使用结束的情况。最后面就是pc没有空间了,需要用系统调用函数申请128页空间,申请好后旧插入到128页桶中,然后递归重新再来,在拆分那里就会得到需要的span。
MapObjectToSpan函数就是通过地址转换成页号,然后在哈希表里面去找span。
ReleaseSpanToPageCache函数,这里如果释放的span大于128页,直接返回给堆,接着就是合并,先向前找,通过页号-1来得到前一个页号,然后先去映射表找,没有就退出,接着判断isuse是否为true,为true就是再使用也break,两个总和大于128也break,都没问题就可用合并,把当前页号变为前一个页号,页数加上前面页数合成新的页数,在spanlist中删除前一个span,因为已经和当前span合体了,重新循环继续向前,直到break。先后找也是一个逻辑。while循环结束,就把新的span插入到spanlist中,更新isuse为false未使用,头尾进行映射。
cpp
#include "PageCache.h"
PageCache PageCache::_sInst;
// 获取一个K页的span
Span* PageCache::NewSpan(size_t k)
{
assert(k > 0);
// 大于128 page的直接向堆申请
if (k > NPAGES-1)
{
void* ptr = SystemAlloc(k);
//Span* span = new Span;
Span* span = _spanPool.New();
span->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
span->_n = k;
_idSpanMap[span->_pageId] = span;
return span;
}
// 先检查第k个桶里面有没有span
if (!_spanLists[k].Empty())
{
Span* kSpan = _spanLists[k].PopFront();
// 建立id和span的映射,方便central cache回收小块内存时,查找对应的span
for (PAGE_ID i = 0; i < kSpan->_n; ++i)
{
_idSpanMap[kSpan->_pageId + i] = kSpan;
}
return kSpan;
}
// 检查一下后面的桶里面有没有span,如果有可以把他它进行切分
for (size_t i = k+1; i < NPAGES; ++i)
{
if (!_spanLists[i].Empty())
{
Span* nSpan = _spanLists[i].PopFront();
//Span* kSpan = new Span;
Span* kSpan = _spanPool.New();
// 在nSpan的头部切一个k页下来
// k页span返回
// nSpan再挂到对应映射的位置
kSpan->_pageId = nSpan->_pageId;
kSpan->_n = k;
nSpan->_pageId += k;
nSpan->_n -= k;
_spanLists[nSpan->_n].PushFront(nSpan);
// 存储nSpan的首位页号跟nSpan映射,方便page cache回收内存时
// 进行的合并查找
_idSpanMap[nSpan->_pageId] = nSpan;
_idSpanMap[nSpan->_pageId + nSpan->_n - 1] = nSpan;
// 建立id和span的映射,方便central cache回收小块内存时,查找对应的span
for (PAGE_ID i = 0; i < kSpan->_n; ++i)
{
_idSpanMap[kSpan->_pageId + i] = kSpan;
}
return kSpan;
}
}
// 走到这个位置就说明后面没有大页的span了
// 这时就去找堆要一个128页的span
//Span* bigSpan = new Span;
Span* bigSpan = _spanPool.New();
void* ptr = SystemAlloc(NPAGES - 1);
bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
bigSpan->_n = NPAGES - 1;
_spanLists[bigSpan->_n].PushFront(bigSpan);
return NewSpan(k);
}
Span* PageCache::MapObjectToSpan(void* obj)
{
PAGE_ID id = ((PAGE_ID)obj >> PAGE_SHIFT);
std::unique_lock<std::mutex> lock(_pageMtx);
auto ret = _idSpanMap.find(id);
if (ret != _idSpanMap.end())
{
return ret->second;
}
else
{
assert(false);
return nullptr;
}
}
void PageCache::ReleaseSpanToPageCache(Span* span)
{
// 大于128 page的直接还给堆
if (span->_n > NPAGES-1)
{
void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
SystemFree(ptr);
//delete span;
_spanPool.Delete(span);
return;
}
// 对span前后的页,尝试进行合并,缓解内存碎片问题
while (1)
{
PAGE_ID prevId = span->_pageId - 1;
auto ret = _idSpanMap.find(prevId);
// 前面的页号没有,不合并了
if (ret == _idSpanMap.end())
{
break;
}
// 前面相邻页的span在使用,不合并了
Span* prevSpan = ret->second;
if (prevSpan->_isUse == true)
{
break;
}
// 合并出超过128页的span没办法管理,不合并了
if (prevSpan->_n + span->_n > NPAGES-1)
{
break;
}
span->_pageId = prevSpan->_pageId;
span->_n += prevSpan->_n;
_spanLists[prevSpan->_n].Erase(prevSpan);
//delete prevSpan;
_spanPool.Delete(prevSpan);
}
// 向后合并
while (1)
{
PAGE_ID nextId = span->_pageId + span->_n;
auto ret = _idSpanMap.find(nextId);
if (ret == _idSpanMap.end())
{
break;
}
Span* nextSpan = ret->second;
if (nextSpan->_isUse == true)
{
break;
}
if (nextSpan->_n + span->_n > NPAGES-1)
{
break;
}
span->_n += nextSpan->_n;
_spanLists[nextSpan->_n].Erase(nextSpan);
//delete nextSpan;
_spanPool.Delete(nextSpan);
}
_spanLists[span->_n].PushFront(span);
span->_isUse = false;
_idSpanMap[span->_pageId] = span;
_idSpanMap[span->_pageId+span->_n-1] = span;
}

利用基数树性能优化
前面性能问题在数据量大了之后,unordered_map查找消耗大,还有锁的竞争大,所以就可以用基数树来代替哈希表,tcmalloc源码有三棵树,每棵树的层数是不一样的,有1,2,3层。
单层基数树

两层基数树

第一级:区域目录(Root)
你准备了一个有32个格子的档案柜(
1 << 5
)。这个柜子就是你的第一级索引。一本书的编号很长,比如
00010110 00111100 01010
(共19位为例)。你决定取这个编号的前5位 (00010
)来决定这本书属于哪个区域。00010
换算成十进制是2
,于是你知道,所有以00010
开头的书,信息都放在第2
个格子里。第二级:书架目录(Leaf)
你走到第
2
个格子,发现里面放的并不是书的位置,而是另一个小册子 。这个小册子专门记录以00010
开头的所有书。现在你用编号剩下的14位 (
1100011110001010
)在这个小册子里查找。这个小册子也有很多行,行号就是这后14位组成的数字。在这个行里,你终于找到了这本书的具体位置:"西馆,科幻区,第108架"。
三层基数树

第一层:国家目录(Root - 大区)
你有一个总档案柜,只有8个格子 (
1 << 3
)。每个格子代表一个大区(例如:亚洲区、欧洲区、北美区...)。你取书号的最前面的3位 来决定这本书属于哪个大区。比如
001
开头的书都属于"亚洲区"。第二层:城市目录(Middle - 分区)
你走到"亚洲区"的格子,里面放的并不是书目,而是另一个档案柜。这个柜子有16个格子 (
1 << 4
),每个格子代表亚洲区下的一个分区(例如:东亚分区、东南亚分区...)。你取书号的接下来4位 ,来决定这本书属于亚洲区的哪个分区。比如
0011
表示"东亚分区"。第三层:具体图书馆目录(Leaf - 馆藏)
你走到"东亚分区"的格子,里面终于放着一本馆藏目录册了。这个册子记录着所有存放在东亚地区图书馆的书籍。
你用书号最后剩余的位(比如64-3-4=57位)在这个馆藏目录册里查找,最终找到这本书的具体位置:"中国,北京市,某图书馆,3楼A区第101架"。
这个过程就是三层基数树的工作原理:
页号 = 超长的完整书编号(例如64位)
第一层数组(Root) :用页号的最高几位(e.g., 3位)索引,找到对应的"大区"档案柜(第二层数组的指针)。
第二层数组(Middle) :用页号的中间几位(e.g., 4位)索引,在上一步找到的"大区"柜子里,找到对应的"分区"格子(第三层数组的指针)。
第三层数组(Leaf) :用页号的最低位 (所有剩余位)索引,在上一步找到的"分区"格子里,最终找到你想要的数据(Span指针)。
总流程

