1.项目介绍
当前项目是实现一个高并发的内存池,它的原型是 google 的一个开源项目 tcmalloc,tcmalloc 全称 Thread - Caching Malloc,即线程缓存的 malloc,实现了高效的多线程内存管理,用于替代系统的内存分配相关的函数(malloc、free)。
我们这个项目是把 tcmalloc 最核心的框架简化后拿出来,模拟实现出一个自己的高并发内存池,目的就是学习 tcmalloc 的精华,这种方式有点类似我们之前学习 STL 容器的方式。但是相比 STL 容器部分,tcmalloc 的代码量和复杂度上升了很多。
另一方面 tcmalloc 是全球大厂 google 开源的,可以认为是当时顶尖的 C++ 高手写出来的,它的知名度也非常高,不少公司都在用它,Go 语言直接用它做了自己内存分配器。
2.什么叫作内存池?
内存池就是程序提前向操作系统要一大块连续的内存。之后程序需要用内存时,不用再一次次麻烦操作系统分配,直接从这块内存里拿就行;用完也不用立刻还给操作系统,先放回内存池存着。这样一来,能减少频繁申请和释放内存的额外开销,还能避免内存碎片,就像把一堆零散的小物件规整到一个大箱子里,要用的时候从箱子里拿,不用了再放回去,让内存管理更高效、更有序 。
其实我们常用的malloc,其实就是一个内存池;内存池主要解决的是效率问题、内存碎片问题
(内碎片:就是已经开辟好的空间里面因为内存对齐导致一些内存不能用到!)
malloc内存池:
3.定长内存池
我们先做一个定长内存池模版,这样方便后面我们可以申请各种类型的空间。把New和delete都放在模版类里面。在这个模版类里面要注意的是要用到定位new
#pragma once
#include<iostream>
using namespace std;
template<class T>
class ObjectPool
{
public:
ObjectPool()
{}
T* New()
{
T * obj = nullptr;
//把回收到的查看一下,优先用回收的!
if (_freeList)
{
void* next = *(void**)_freeList;
obj = (T*)_freeList;
_freeList = next;
return obj;
}
//剩余字节数不够了,则去开辟
if (_remainBytes < sizeof(T))
{
_memory = (char*)malloc(128 * 1028);
/*_memory = (char*)SystemAlloc(_remainBytes >> 13);*/
if (_memory == nullptr)
{
throw bad_alloc();
}
_remainBytes = 128 * 1024; // 更新剩余字节数
}
obj = (T*)_memory;
size_t objSize = sizeof(void*) > sizeof(T) ? sizeof(T) : sizeof(void*);
_memory += objSize;
_remainBytes -= objSize;
//定位new,obj 是一个指针,指向已分配好的内存地址 ,T 是要构造的对象类型。
// 定位 new 允许在已有的内存位置上构造对象,它不会分配新的内存,
// 而是直接在指定位置调用对象的构造函数来初始化对象。
new(obj) T;
return obj;
}
void Delete(T* obj)
{
obj->~T();//显示调用,把T看成一个struct threadData;
//头插
*(void**)obj = _freeList;
_freeList = obj;
}
~ObjectPool() {}
private:
char* _memory = nullptr;
size_t _remainBytes = 0;//剩余空间
void* _freeList = nullptr;
};
固定大小的内存申请释放需求:
特点:1.性能达到极致;2.不考虑内存碎片的问题
下面是定长内存池需要注意的地方:
4.高并发内存池整体框架设计
现代很多的开发环境都是多核多线程,在申请内存的场景下,必然存在激烈的锁竞争问题。malloc 本身其实已经很优秀,那么我们项目的原型 tcmalloc 就是在多线程高并发的场景下更胜一筹,所以这次我们实现的内存池需要考虑以下几方面的问题。
- 性能问题。
- 多线程环境下,锁竞争问题。
- 内存碎片问题。
concurrent memory pool 主要由以下 3 个部分构成:
- thread cache:线程缓存是每个线程独有的,用于小于 256KB 的内存的分配,线程从这里申请内存不需要加锁,每个线程独享一个 cache,这也就是这个并发线程池高效的地方。
- central cache:中心缓存是所有线程所共享,thread cache 是按需从 central cache 中获取的对象。central cache 合适的时机回收 thread cache 中的对象,避免一个线程占用了太多的内存,而其他线程的内存吃紧,达到内存分配在多个线程中更均衡的按需调度的目的。central cache 是存在竞争的,所以从这里取内存对象是需要加锁,首先这里用的是桶锁,其次只有 thread cache 的没有内存对象时才会找 central cache,所以这里竞争不会很激烈。
- page cache:页缓存是在 central cache 缓存上面的一层缓存,存储的内存是以页为单位存储及分配的,central cache 没有内存对象时,从 page cache 分配出一定数量的 page,并切割成定长大小的小块内存,分配给 central cache。当一个 span 的几个跨度页的对象都回收以后,page cache 会回收 central cache 满足条件的 span 对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。
5.ThreadCache:
线程局部存储(TLS),是一种变量的存储方法,这个变量在它所在的线程内是全局可访问的,但是不能被其他线程访问到,这样就保持了数据的线程独立性。而熟知的全局变量,是所有线程都可以访问的,这样就不可避免需要锁来控制,增加了控制成本和代码复杂度。这个操作在linux下就是“__thread”
threadcache.h:
#pragma once
#include"Comm.h"
class ThreadCache
{
public:
void* Alloc(size_t size);
void Dealloc(void* ptr, size_t size);
//从中心缓存读数据
void* FetchFromCentralCache(size_t index, size_t alignSize);
void ListTooLong(FreeList& list, size_t size);
public:
FreeList _freelists[NOFREE_LISTS];
};
static _declspec(thread) ThreadCache* pTLSThreadCache=nullptr;//这个就是linux里面的__thread int p;
pTLSThreadCach是指向 ThreadCache 类型的指针,初始化为 nullptr ,作用是为每个线程提供独立的 ThreadCache 实例,方便线程高效管理自己的私有内存缓存
threadcache.cpp:
#include"Comm.h"
#include"ThreadCache.h"
#include"CentralCache.h"
void* ThreadCache::FetchFromCentralCache(size_t index, size_t alignSize)
{
//这里就有点像TCP的慢启动了,拥塞控制
//慢开始反馈调节算法
//一次不会向centralcache要太多,要太多了有可能会用不完造成浪费
//1.如果你不要这个size内存大小,那么batchNum会不断增加
//2.alignsize越大,一次向central申请的内存会越小
//3.alignsize越小,一次向central申请的内存会越大
//当然一切都会有一个上限!
size_t batchNum = min(ThreadCache::_freelists[index].MaxSize(), SizeClass::NumMoveSize(alignSize));
//慢慢递增开始的maxsize是1;
if (_freelists[index].MaxSize() == batchNum)
{
_freelists[index].MaxSize() += 1;
}
void* start = nullptr;
void* end = nullptr;
size_t actualNum = CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, alignSize);
assert(actualNum > 0);
if (actualNum == 1)
{
//说明只获取到了一个对象。此时使用 assert(start == end) 进行断言,
// 确保 start 和 end 指向同一个对象。因为只有一个对象时,起始地址和结束地址应该相同。
assert(start == end);
return start;
}
else
{
//这是把获取到的内存块又重新连接到thread的freelist上
_freelists[index].PushRange(NextObj(start), end,actualNum-1);
return start;
}
}
void* ThreadCache::Alloc(size_t size)
{
assert(size < MAX_BYTES);
//在代码里,size 就是你要放的文件的实际宽度,而 alignSize 是经过调整后,能让文件刚好放进文件盒的宽度。
//SizeClass::RoundUp(size) 这个函数就像是一个 “调整师”,它会把 size 调整成对齐单位的倍数。
size_t alignSize = SizeClass::RoundUp(size);//?
size_t index = SizeClass::Index(size);//算出那个桶
if (!_freelists[index].empty())
{
return _freelists[index].Pop();
}
else
{
/*ThreadCache 自己的内存不够用了,要从 CentralCache 那里 “借” 内存块。
这时就必须告诉 CentralCache 要借多大的内存块,这个大小就是 alignSize。
如果不按照对齐后的大小去借,可能会导致 CentralCache 分配的内存块和 ThreadCache 的管理规则不匹配,
从而引发问题。*/
return FetchFromCentralCache(index, alignSize);
}
}
//释放
void ThreadCache::Dealloc(void* ptr, size_t size)
{
assert(ptr);
assert(size < MAX_BYTES);
//找到它原本属于那个桶,然后把它还回去,就是已经用完了,物归原主!
size_t index = SizeClass::Index(size);
_freelists[index].Push(ptr);
//当链表长度大于一次批量申请的长度的时候,就要开始还一段list给centralcache
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);
}
6.CentralCache
central cache 也是一个哈希桶结构,他的哈希桶的映射关系跟 thread cache 是一样的。不同的是他的每个哈希桶位置挂是 SpanList 链表结构,不过每个映射桶下面的 span 中的大内存块被按映射关系切成了一个个小内存块对象挂在 span 的自由链表中。
这里用的是桶锁,centralcache其实和threadcache其实比较相似,所以它们映射的形式一样,所以就是当你进行桶锁的时候,会减少很多竞争,比如你thread1里面8bytes不够了,那就向central里面的8bytes申请,申请的时候先申请了8bytes的锁,那与此同时,你16bytes也来申请的central的16的时候,就不会收到8bytes锁的影响。如果central里面对应的区域也没有内存了,就像更上层的page申请!
可以注意到的是centralcache里面每个映射下面接的的span,span下面会连接一串链表,这样的目的是,当你从thread到central来申请的时候,central为了提高效率就不会说thread要一个8bytes的大小就给你一个8bytes而是当thread要8bytes时候,我central给你一串8bytes,这样你下一次thread用的时候就会等我给你的用完再来申请!
在这个三级缓存结构中,Central Cache 的每个 Span 确实有自己的自由链表(Free List),这些链表是由 Central Cache 在收到 Page Cache 分配的 Span 后动态创建的.这些链表是 Central Cache 在收到 Page Cache 的 Span 后自行切割并初始化的。Page Cache 提供的是原始连续内存,而 Central Cache 负责将其转换为可用的小块内存链表。这种设计既保证了内存分配的高效性,又实现了分层管理的灵活性。这些链表分为:partial list 和full list;
centralcache.h
#pragma once
#include"Comm.h"
using Span = struct Span;
//使用单例模式可以确保整个应用程序中只有一个 CentralCache 实例来管理这些资源,
//避免了多个实例可能导致的资源分配混乱和不一致性。
//单例模式--饿汉模式
class CentralCache
{
//using Span = struct Span;
public:
static CentralCache* GetInstance()
{
return &_sInst;
}
//获取一个非空的span
Span* GetOneSpan(SpanList& list, size_t bytes_size);
//*&这样的start和end就是意思在函数内部可以修改指针本身的值
size_t FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t bytes_size);
void ReleaseListToSpans(void* start, size_t size);
private:
SpanList _spanlists[NOFREE_LISTS];
private:
static CentralCache _sInst;
CentralCache(){}
CentralCache(const CentralCache& inst) = delete;
CentralCache& operator=(CentralCache& other) = delete;
};
centralcache.cpp:
#include"CentralCache.h"
#include"PageCache.h"
#include"Comm.h"
CentralCache CentralCache::_sInst;
//获取一个非空的span
Span* CentralCache::GetOneSpan(SpanList& list, size_t bytes_size)
{
//先在central里面查出有没有空闲的span
Span* it = list.Begin();
while (it != list.End())
{
if (it->_lists != nullptr)
{
return it;
}
else
{
it = it->next;
}
}
//解锁:这样可以保证,其他线程释放一号桶的空间的时候,不会收到锁的阻塞
list._mtx.unlock();
//如果没有就开始去page里面要空间!
//正常理解bytes要的大小越大,给的页越大,相反也是!
//走到这里说明该桶里面并没有span,或者都是Full spans,centralcache那就要去向pagecache要一个Span
// 要注意的是pagecache是一个按照桶下标映射的哈希桶,第i号桶表示这个桶下面的span管理的都是i页page
// central找page要,关注的是要的span管理K页的span,然后就去K号桶去要
// page的锁是一把整锁,虽然也可以使用桶锁,但是太消耗性能了
// central cache 选桶锁,是因线程从对应哈希桶按需取内存,桶间操作互不干扰,
// 加桶锁不影响其他桶,可提升申请内存效率,且多数情况线程在 thread cache 就能申请到内存,
// 到 central cache 申请概率不高,桶锁竞争不激烈 。page cache 选整锁,
// 是因其申请内存时可能需遍历查找合适 span 并分裂,释放时还可能合并相邻页,
// 这些操作涉及多桶,用桶锁需频繁加解锁,开销大,而整锁可避免此问题,且实现和维护简单 。
//
//注意下面要进入Page,所以要加全锁!
PageCache::GetOnePage()->_page_mtx.lock();
Span*span=PageCache::GetOnePage()->newSpan(SizeClass::NumMovePage(bytes_size));
span->_isUse = true;
span->objSize = bytes_size;
PageCache::GetOnePage()->_page_mtx.unlock();
//对于span的切分的时候,不需要给原来的central的桶锁接着加锁,因为其他线程是已经拿不到这个span的!
//从page拿上来的span是每一个线程独享的,因此不需要加锁
//拿到整个大页,就要开始进行切分!
//行代码的主要目的是根据页号(span->page_id)计算出对应页在内存中的起始地址
// char 类型在 C/C++ 里大小固定为 1 字节,char* 指针进行算术运算时,移动步长就是 1 字节。
char* start = (char*)(span->page_id << PAGE_SHIFT);
size_t bytes = span->_n << PAGE_SHIFT;
char* end = start + bytes;
//将大块内存切成自由span连接起来用自由链表,按顺序连接(尾插)这样可以保持地址是连续的!
span->_lists = start;
start += bytes_size;//它是在原一大片的span上
void* tail = span->_lists;//这个是链表上的
//int i = 1;用于调试!
while (start < end)
{
//++i;
NextObj(tail) = start;
tail = start;
start += bytes_size;
}
NextObj(tail) = nullptr;//新加
//疑似死循环程序,中断程序,程序会在正在运行的地方停下来
//切好以后,当你要把切好的span挂到桶里面的时候就要加锁!
list._mtx.lock();
list.PushFront(span);//这个list是我centralcache里面正缺内存的那个index的list,所以直接插入即可!
return span;
}
//从central里面获取·一定数量的空间交给thread
size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t bytes_size)
{
//首先我的确认好我在spanlist里面取那个地方的空间
size_t index = SizeClass::Index(bytes_size);
//确认好了以后就得加锁了,防止多线程进行一个抢资源的情况
_spanlists[index]._mtx.lock();
//然后去取_spanlists里面的span,因为span连接着每个大小为x bytes的一个链表
Span* span = CentralCache::GetOneSpan(_spanlists[index], bytes_size);
assert(span);
assert(span->_lists);
//从span中获取batchNum个对象
//如果当下的span里面不够有多少拿多少
start = span->_lists;
end = start;
size_t i = 0;
size_t actualNum = 1;
while (i < batchNum - 1 && NextObj(end) != nullptr)
{
end = NextObj(end);
++actualNum;
++i;
}
span->_lists = NextObj(end);
NextObj(end) = nullptr;
span->_useCount += actualNum;
条件断点:
//int n = 0;
//void* cur = start;
//while (cur)
//{
// cur = NextObj(cur);
// ++n;
//}
//if (n != actualNum)
//{
// int x = 0;
//}
_spanlists[index]._mtx.unlock();
return actualNum;
}
void CentralCache::ReleaseListToSpans(void* start, size_t size)
{
//首先需要知道的就是,我是threadcacahe的那个还回来的!
size_t index = SizeClass::Index(size);
_spanlists[index]._mtx.lock();
while (start)
{
void* next = NextObj(start);
Span* span = PageCache::GetOnePage()->MapObjectToSpan(start);
NextObj(start) = span->_lists;
span->_lists = start;
span->_useCount--;
//当你切分出去的小内存块,全部用完结束以后,就要回收给上一级page了
if (span->_useCount == 0)
{
//下面四行代码属于是把要返回给上级的内存块给从桶里面已经拿出来了!
_spanlists[index].erase(span);
span->_lists = nullptr;
span->next = nullptr;
span->prev = nullptr;
//所以当我要还内存的时候,其实就可以只用page的锁了,
// 之所以要把桶锁解开是要防止在此期间会有其他线程要访问该桶里面的其他span!
_spanlists[index]._mtx.unlock();
PageCache::GetOnePage()->_page_mtx.lock();
PageCache::GetOnePage()->ReleaseSpanToPageCache(span);
PageCache::GetOnePage()->_page_mtx.unlock();
_spanlists[index]._mtx.lock();
}
start = next;
}
_spanlists[index]._mtx.unlock();
}
7.PageCache
申请内存:
- 当 central cache 向 page cache 申请内存时,page cache 先检查对应位置有没有 span,如果没有则向更大页寻找一个 span,如果找到则分裂成两个。比如:申请的是 4 页 page,4 页 page 后面没有挂 span,则向后面寻找更大的 span,假设在 10 页 page 位置找到一个 span,则将 10 页 page span 分裂为一个 4 页 page span 和一个 6 页 page span。
- 如果找到_spanList [128] 都没有合适的 span,则向系统使用 mmap、brk 或者是 VirtualAlloc 等方式申请 128 页 page span 挂在自由链表中,再重复 1 中的过程。
- 需要注意的是 central cache 和 page cache 的核心结构都是 spanlist 的哈希桶,但是他们是有本质区别的,central cache 中哈希桶,是按跟 thread cache 一样的大小对齐关系映射的,他的 spanlist 中挂的 span 中的内存都被按映射关系切好链接成小块内存的自由链表。而 page cache 中的 spanlist 则是按下标桶号映射的,也就是说第 i 号桶中挂的 span 都是 i 页内存。
释放内存:
- 如果 central cache 释放回一个 span,则依次寻找 span 的前后 page id 的没有在使用的空闲 span,看是否可以合并,如果合并继续向前寻找。这样就可以将切小的内存合并收缩成大的 span,减少内存碎片。
注意:虽然都是span但是要知道centralcache的span映射和threadcacahe一样但是与pagecache的span映射是完全不同的!
假如说centralcache要一页的空间,此时我的pagecache1页的位置没有空间,那就去找大页切分成自己要的小页!等你用完了空间再从central还给page,然后page1页和相邻的空闲页,再合并成大页!如果pagecache没有可用的空间,直接central直接向堆上要空间,用完还给堆!
pagecache.h:
#pragma once
#include"Comm.h"
#include"CentralCache.h"
#include"MPool.h"
#include"PageMap.h"
class PageCache
{
public:
static PageCache* GetOnePage()
{
return &_sInstan;
}
//获取一个K页的span
Span* newSpan(size_t k);
//获取从对象到span的映射
Span* MapObjectToSpan(void* obj);
//释放空间,回收来自central不用的完整空间,回收后以后相邻页合并
void ReleaseSpanToPageCache(Span* span);
mutex _page_mtx;
private:
SpanList _spanlist[NPAGES];
/*unordered_map<PAGE_ID, Span*> _idSpanMap;*/
unordered_map<PAGE_ID, size_t> _idSizeMap;
TCMalloc_PageMap1<32-PAGE_SHIFT> _idSpanMap;//这个用的基数树的第一层!
ObjectPool<Span> _spanPool;
private://饿汉模式
//类中声明的静态成员变量,只是告诉编译器有这么个变量存在,但并未为其分配内存
static PageCache _sInstan;//一个静态实例化的对象放在类里面就能保持有唯一的实例!
PageCache() {}
PageCache(const PageCache& other) = delete;
PageCache& operator=(const PageCache& other) = delete;
};
pagecache.cpp:
#include"PageCache.h"
//类外使用 类名::变量名 的形式进行定义,才真正为它分配内存空间,完成初始化等工作
PageCache PageCache::_sInstan;
//Span* PageCache::newSpan(size_t k)
//{
// PageCache::GetOnePage()->_page_mtx.lock();
// _newSpan(size_t k, SpanList * _spanlist);
//}
//获取一个K页的span
Span* PageCache::newSpan(size_t k)
{
assert(k > 0 && k < NPAGES);
if (k > NPAGES - 1)//也就是如果要在page里面找到大于现在page所有的容量的话,直接去系统堆上申请
{
void* ptr = SystemAlloc(k);
/*Span* span = new Span;*/
Span* span = _spanPool.New();
span->page_id = (PAGE_ID)ptr >> PAGE_SHIFT;
span->_n = k;
/*_idSpanMap[span->page_id] = span;*/
_idSpanMap.set(span->page_id, span);
return span;
}
//这里加锁会有死锁问题,除非用递归互斥锁,还有在外面调用这个函数前要加锁!
if (!_spanlist[k].Empty())
{
Span*kspan=_spanlist[k].PopFront();
//建立page_id与span的映射,方便centralcache回收小块内存,查找对应的span
for (PAGE_ID i = 0; i < kspan->_n; ++i)
{
/*_idSpanMap[kspan->page_id + i] = kspan;*/
_idSpanMap.set(kspan->page_id, kspan);
}
return kspan;
}
else//检查后面的page桶有没有空的,找大的桶,如果有,就进行切分!
{
for (size_t i = k+1; i < NPAGES; ++i)
{
if (!_spanlist[i].Empty())
{
//找到大的页以后先开始把这个spanlist整体从原来的桶移出来,然后进行切分重新分配进桶!
Span*nspan=_spanlist[i].PopFront();//记录下地址!
//Span* kspan = new Span;//它是正在需要的K页的span
Span* kspan = _spanPool.New();
//划分把span里面的属性都进行一个更新
//原理就是:从nspan上切下来K页大小的kspan,然后把kspan返回,也就是交给centralcache
//然后把剩余的nspan放到对应的桶中!
kspan->page_id = nspan->page_id;
kspan->_n = k;
nspan->page_id += k;
nspan->_n -= k;
//把剩余大小的nspan放到对应的桶里面去
_spanlist[nspan->_n].PushFront(nspan);
//这里记录的是,page里面还没有使用过的内存与地址的映射!
//不用的内存只有需要记住这个块内存的起始地址和尾地址(这里指的地址就是page_id),这也足以帮助page缓存合并!
//不用像central一样,因为它的span还有连接的内存的链表!
//存储nspan的首位页号跟nspan的映射,方便pagecache回收内存
//进行合并查找
/*_idSpanMap[nspan->page_id] = nspan;
_idSpanMap[nspan->page_id + nspan->_n - 1] = nspan;*/
_idSpanMap.set(nspan->page_id, nspan);
_idSpanMap.set(nspan->page_id+nspan->_n-1, nspan);
//将kspan给central之前,先将页号和span对应关系记录(就是分割)
for (size_t i = 0; i < kspan->_n; i++)
{
/*_idSpanMap[kspan->page_id + i] = kspan;*/
_idSpanMap.set(kspan->page_id, kspan);//page_id是起始页的编号
}
return kspan;
}
}
}
//如果程序走到了这个部分,就说明此时的pagecache里面没有任何空闲的空间可以用了
//此时就得向堆上去申请一个NPAGES-1大小的空间了
/*Span* bigSpan = new Span;*/
Span* bigSpan = _spanPool.New();
void* ptr = SystemAlloc(NPAGES - 1);
bigSpan->page_id = (PAGE_ID)ptr >> PAGE_SHIFT;
bigSpan->_n = NPAGES - 1;
_spanlist[bigSpan->_n].PushFront(bigSpan);
return newSpan(k);
}
Span* PageCache::MapObjectToSpan(void* obj)
{
PAGE_ID id = ((PAGE_ID)obj >> PAGE_SHIFT);//给地址除以8k
/*unique_lock<mutex> _RAII_lock(_page_mtx);
auto ret = _idSpanMap.find(id);
if (ret != _idSpanMap.end())
{
return ret->second;
}
else
{
assert(false);
return nullptr;
}*/
//用了基数树就不用加锁了
auto ret = (Span*)_idSpanMap.get(id);
assert(ret != nullptr);
return ret;
}
//释放空闲的span回到pagecache,并合并相邻的span
void PageCache::ReleaseSpanToPageCache(Span*span)
{
//大于128页的内存,说明这个内存是它直接在堆上申请的,所以释放的话,直接还给堆就可以了!
if (span->_n>NPAGES-1)
{
void* ptr = (void*)(span->page_id >> PAGE_SHIFT);
SystemFree(ptr);
/*delete span;*/
_spanPool.Delete(span);
return;
}
//如何知道相邻的span是否能合并?
//通过自己的page_id找到相邻的span看看是否能合并,如果该页的span在pagecache说明该span还没有被使用,可以合并
//如果在centralcache说明该span正在被使用,不能合并
//现在就有一个问题,如何知道一个span是否使用?是用span中的usecount看它的结果是不是等于0?
// 正常为0意思是没有给下一层,但是这里是行不通的!原因如下:
//假如现在有一个thread-1通过pTSL找到了自己的threadcache申请内存块,但是没有,
//就去找centralcache要,但是centralcache对应的桶下面也没有,那就只能去找pagecache了
//pagecache返回给centralcache一个span,这个span的usecount初始是为0,
//centralcache拿到这个span后对这个大的span进行切分小内存块,再挂到自己对应的桶下面
//但是这个时候如果出现了一个thread-2线程,进入到了pagecache要合并这个span,那就有问题了,
// thread-1正准备从这个span那一批去用,但是还没有拿取呢,刚切完,这个span的usecount还是为0,只有拿走了才usecount++
// thread-2把这个span和自己的span一旦进行了合并,就会造成严重的线程安全问题!!!!
//
// 因此为了解决这个办法所以才有了“bool isuse” 来记录这个span目前的状态!
//
//
// 如何通过页号(page_id)找到相邻的页?还是得用unordered_map来记录页号和span之间对应的关系
// 但是目前unordered_map只记录了给centralcache已经被使用的span的页号和span对应关系
// 并没有记录在pagecache的span的页号和span对应的关系
// 因此需要把在pagecache的span页号和对应的span关系也要记录在unordered_map中!
//
//
//对Span页前后页尝试合并,缓解内存碎片问题(外碎片)
//向前合并:
while (1)
{
PAGE_ID prevId = span->page_id - 1;
//最初的版本----普通
//auto ret = _idSpanMap.find(prevId);
前面页号不存在,就结束合并
//if (ret == _idSpanMap.end())
//{
// break;
//}
//加入基数树的版本:---
auto ret = (Span*)_idSpanMap.get(prevId);
if (ret == nullptr)
{
break;
}
//前面相邻页的span正在使用,结束合并
Span* prevSpan = ret;
if (prevSpan->_isUse == true)
{
break;
}
//前后相加超过了NPAGES,就不能合并
if (prevSpan->_n + span->_n >= NPAGES)
{
break;
}
//走到这里就是可以合并---合并代码很简单
span->page_id = prevSpan->page_id;
span->_n += prevSpan->_n;
_spanlist[prevSpan->_n].erase(prevSpan);
/*delete prevSpan;*/
_spanPool.Delete(prevSpan);
//这个可能就有疑问,那遗留在unordered_map中被合并的对应页和prevSpan之间一对一的关系难道不删除吗?
// 因为prevSpan已经被删除了,再去通过已有页去找span那就是野指针了!其实并不用删除
// 首先被合并的页已经被span管理起来了,合并结束以后会被挂在对应桶下面,并且记录该span首页和尾页与span对应关系
// 当centralcache要的时候,再把span切分成两个span,返回给centralcache的kspan每页都和kspan重新进行映射
// 留在pagecache的nspan的首页和尾页也会和nspan重新映射
// 这样的话,以前被合并,遗留下来的页又和新得span建立了映射关系,就不会又通过页找span会有野指针的问题!
//
}
// 向后合并
while (1)
{
PAGE_ID nextId = span->page_id + span->_n;
/* auto ret = _idSpanMap.find(nextId);
if (ret == _idSpanMap.end())
{
break;
}*/
auto ret = (Span*)_idSpanMap.get(nextId);
if (ret == nullptr)
{
break;
}
Span* nextSpan = ret;
if (nextSpan->_isUse == true)
{
break;
}
if (nextSpan->_n + span->_n > NPAGES - 1)
{
break;
}
span->_n += nextSpan->_n;
_spanlist[nextSpan->_n].erase(nextSpan);
/*delete nextSpan;*/
_spanPool.Delete(nextSpan);
}
_spanlist[span->_n].PushFront(span);
span->_isUse = false;
/*_idSpanMap[span->page_id] = span;
_idSpanMap[span->page_id + span->_n - 1] = span;*/
_idSpanMap.set(span->page_id, span);
_idSpanMap.set(span->page_id+span->_n-1, span);
}
8.comm.h:
#pragma once
#include <iostream>
#include <vector>
#include <algorithm>
#include <thread>
#include <ctime>
#include <cassert>
#include <mutex>
#include<unordered_map>
#include <windows.h>
using namespace std;
static const size_t MAX_BYTES = 128 * 1024;
static const size_t NOFREE_LISTS = 208;
static const size_t NPAGES = 20;
static const size_t PAGE_SHIFT = 13;
// 就是读取前面几个字节内容
#ifdef _WIN64
using PAGE_ID = unsigned long long;
#elif _WIN32
using PAGE_ID = size_t;
#endif
// 直接去堆上按页申请空间
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN64
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;
}
inline static void SystemFree(void* ptr)
{
#ifdef _WIN64
VirtualFree(ptr, 0, MEM_RELEASE);
#else
// sbrk unmmap等
#endif
}
// 插入一个小知识点:在 64 系统下是有 _WIN32 和 _WIN64 的定义的!32 系统下只有 _WIN32 的定义
static void*& NextObj(void* obj)
{
/* assert(obj);*/
return *(void**)obj;
}
// 管理好切分小对象的自由链表
class FreeList {
public:
void Push(void* obj) {
assert(obj);
// 头插
NextObj(obj) = _freeList;
_freeList = obj;
_size++;
}
void PushRange(void* start, void* end,size_t n)
{
NextObj(end) = _freeList;
_freeList = start;
_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;
}
size_t Size() const
{
return _size;
}
void* Pop() {
assert(_freeList);
void* obj = _freeList;
_freeList = NextObj(obj);
_size--;
return obj;
}
bool empty() {
return _freeList == nullptr;
}
size_t& MaxSize() { // 防止拷贝构造!减少资源浪费
return _maxsize;
}
private:
void* _freeList = nullptr;
size_t _maxsize = 1;//表示自由列表(_freeList )能容纳的最大元素数量或最大内存块数量
size_t _size;
};
class SizeClass {
// 对齐规则
// 整体控制在最多 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)
public:
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 <= static_cast<unsigned long long>(8) * 1024) {
return _RoundUp(size, 128);
}
else if (size <= static_cast<unsigned long long>(64) * 1024) {
return _RoundUp(size, 1024);
}
else if (size <= static_cast<unsigned long long>(256) * 1024) {
return _RoundUp(size, static_cast<size_t>(8) * 1024);
}
else {
return _RoundUp(size, static_cast<size_t>(1) << PAGE_SHIFT);//以页为单位对齐!
}
}
static inline size_t _Index(size_t bytes, size_t align_shift) {
return ((bytes + (static_cast<unsigned long long>(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 <= static_cast<unsigned long long>(8) * 1024) {
return _Index(bytes - 1024, 7) + group_array[1] + group_array[0];
}
else if (bytes <= static_cast<unsigned long long>(64) * 1024) {
return _Index(bytes - static_cast<unsigned long long>(8) * 1024, 10) + group_array[2] + group_array[1] + group_array[0];
}
else if (bytes <= static_cast<unsigned long long>(256) * 1024) {
return _Index(bytes - static_cast<unsigned long long>(64) * 1024, 13) + group_array[3] + group_array[2] + group_array[1] + group_array[0];
}
else {
assert(false);
}
return -1;
}
// 一次 thread cache 从 centralcache 里面拿取多少个?
static size_t NumMoveSize(size_t size) {
assert(size);
// [2,512],一次批量移动多少个对象的 (慢启动) 上限值
// 小对象一次批量上限高
// 大对象一次批量上限低
int num = MAX_BYTES / size;
if (num < 2) {
num = 2;
}
if (num > 512) {
num = 512;
}
return num;
}
// 计算一次向系统获取几个页
// 单个对象 8byte
// ...
// 单个对象 256KB
static size_t NumMovePage(size_t size)
{
size_t num = NumMoveSize(size);
//一次批量要拿多少个在central里面,计算踹就可以知道,central要向page要多少个!
size_t npage = num * size;//这里计算出来要的总字节数,可以给你一个大页!
//切记位运算,比除法运算效率更高,也更契合计算机底层的计算!
npage >>= PAGE_SHIFT;//这个相当于整个空间向页的转换,假如一个页是8K,右移13位相当于总数除以8K
if (npage == 0)
npage = 1;
return npage;
}
};
// 管理以页为单位的大块内存!
struct Span {
// 这里提前写好缺省值,就可以避免进行构造函数!省去一些步骤!
PAGE_ID page_id = 0;//管理一大块连续的内存块的其实页号
size_t _n = 0; // 管理页的数量
size_t objSize = 0;//切好小对象的大小
//双向链表
struct Span* next = nullptr;
struct Span* prev = nullptr;
//切好小块内存的自由链表
void* _lists = nullptr;//自由链表挂的是span对象一块大连续内存块按照桶位置大小切分成一块块小的内存块
size_t _useCount=0;
bool _isUse=false;
};
//关于objSize:
// 当我们调用释放内存的时候,就要用到这个objsize,的让page知道你要还还回来的内存是多大的,
//如果超过了page能容纳的内存的时候直接把central的要还的内存直接还给堆
class SpanList {
public:
SpanList()
{
_head = new Span;
_head->next = _head;
_head->prev = _head;
}
void insert(Span* pos, Span* newSpan) {
assert(pos);
assert(newSpan);
Span* prev = pos->prev;
prev->next = newSpan;
newSpan->prev = prev;
newSpan->next = pos;
pos->prev = newSpan;
}
void PushFront(Span* span)
{
insert(Begin(), span);
}
Span* PopFront()
{
Span* front = _head->next;
erase(front);
return front;
}
Span* Begin()
{
//1.条件断点
/*if (!_head->next)
{
int x = 0;
cout << "1" << endl;
}*/
//assert(_head->next != nullptr);
return _head->next;//因为Span都是带头双向链表!
}
Span* End()
{
return _head;
}
bool Empty()
{
return _head->next == _head;//切记span是双向链表
}
void erase(Span* pos) {
assert(pos);
assert(pos != _head);
Span* prev = pos->prev;
Span* next = pos->next;
prev->next = next;
next->prev = prev;
}
private:
Span* _head;//带头双向!
public:
std::mutex _mtx;
};
9. 关于centralcache和pagecache用的单例模式
单例模式就像是程序里的 “唯一管理员”,整个系统中只有它这一个实例。对于 central cache 和 page cache 来说,单例模式能确保它们在管理内存时 “号令统一”,避免多个副本导致的混乱和资源浪费。比如,若有多个 page cache 实例同时管理内存页,可能会出现同一块内存被重复分配的问题;而单例模式下,所有线程都通过这一个 “管理员” 来申请和释放内存,既能保证数据一致性,又能避免重复初始化带来的性能开销,还能更方便地实现线程安全控制。
10.基数树替换unordered_map
- 线程安全:
unordered_map
不保证线程安全,访问时要加锁,会成为性能瓶颈。基数树结构固定,不存在某个线程读时,另一个线程删改节点的情况,实现了读写分离,不用加锁就能保证线程安全,提高了效率 。 - 查找性能略优:基数树查找时间复杂度也是 O (1) ,且不用担心哈希冲突,查找性能比
unordered_map
略强 。 - 内存管理灵活:对于像内存页这种需要大量空间存储映射关系的场景,基数树可对页号分层,避免单个数组占用连续超大内存块,防止程序因内存分配问题崩溃 。在 64 位系统下,还可通过增加层数、延迟申请节点等方式,适配实际内存使用情况
疑问:unordered_map你在读这个地方,别的线程改其他位置这也不影响呀?
答案:unordered_map
在元素数量变化时可能会进行扩容或缩容操作,这会导致哈希桶的重新分配和元素的重新哈希。即使一个线程只是在读取某个特定位置的元素,而其他线程触发了结构调整,那么正在读取的线程可能会看到不一致的哈希表状态,比如读到未完全迁移或正在迁移的元素,从而导致数据错误。
PageMap.h:
#pragma once
#include"Comm.h"
#include"MPool.h"
//PageMap.h
// 性能优化
// 不管是用unordered_map还是用map保存页号与Span指针的映射关系,效率都太低了
// 原因在于,对unordered_map还是map进行读写的时候都要进行加锁解锁导致效率低
// 但是读写是必须要进行加锁的,当在对map进行写的时候可能会进行旋转,
// 对unordered_map进行写的时候可能会有扩容
// 考虑这样一个问题,当一个线程在读的时候,它读的时候位置结构可没变(位置没变)
// 但是另一个线程去写了导致这个结构旋转/扩容,导致位置改变,进而导致原先读的位置改变了,所以造成了问题
// 因此不管对这个结构读还是写都必须加锁,所以效率会低
// 如何解决?
// 这里可以使用基数树,基数树其实也是一个整数到整数(指针)的映射关系的多叉搜索树结构
// 每层其实都是一个指针数组,每层数组下标代表对应分层比特位取值的范围大小,只有最后一层叶子节点才会存对应的指针
// 假设key值等于0x840FF, 其二进制按照6bit一簇可以写成,000010 -000100 -000011 -111111,
// 从左到右的index值分别为2, 4,3, 63。那么根据key值0x840FF找到value的过程就只需要4步:
// 第一步,在最上层的节点A中找到index为2的slot,其slot[2]指针指向第二层节点中的节点B。
// 第二步,在节点B中找到index为4的slot,其slot[4]指针指向第三层节点中的节点C。
// 第三步,在节点C中找到index为3的slot,其slot[3]指针指向第三层节点中的节点D。
// 第四步,在节点D中找到index为63的slot,其slot[63]指针指向叶子节点item E。
// 由于这个结构是计算开辟好的,并且增删查改都会不改变这个结构
// 写的时候,PageCache是整体加锁的没什么问题
// 有两个地方会读,都是释放内存块的时候会去读,将地址转成页号在转成对应的Span*
// 要注意的时候,读的时候是释放,而写的时候是Span切分的时候,在读之前就已经写过了
// 因此读写是分离的!! 所以读根本不用加锁,因此使用基数树这个结构保存页号与Span*映射关系更好!
// 一页8kb
// 2^32/2^8 = 2^19
// 2^64/2^8 = 2^51
// Single-level array,一层基数树
// 一层基数树结构适用于32位,
// BITS 表示存储页号需要多少位
template <int BITS>
class TCMalloc_PageMap1 {
private:
static const int LENGTH = 1 << BITS;// 2^19位,数组里面存的是Span指针,所以指针数组大小 2^19 * 4 = 2 ^ 21 = 2M
void** array_;
public:
typedef uintptr_t Number;
//explicit TCMalloc_PageMap1(void* (*allocator)(size_t)) {
explicit TCMalloc_PageMap1() //explicit是防止隐式转换: printDistance(10.5); 错误//无法隐式转换printDistance(Distance(10)); // 正确:显式转换
{
//array_ = reinterpret_cast<void**>((*allocator)(sizeof(void*) << BITS));
size_t size = sizeof(void*) << BITS;//数组大小
size_t alignSize = SizeClass::_RoundUp(size, static_cast<size_t>(1) << PAGE_SHIFT);//按页(1页8KB)对齐
array_ = (void**)SystemAlloc(alignSize >> PAGE_SHIFT);//SystemAlloc按照页申请
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];
}
// 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;
}
};
// Two-level radix tree,两层基数树
// 也适合32位
template <int BITS> //2^31/2^8=2^19,BITS=19
class TCMalloc_PageMap2 {
private:
// Put 32 entries in the root and (2^BITS)/32 entries in each leaf.
//第一层
static const int ROOT_BITS = 5;//19位的前5个比特位
static const int ROOT_LENGTH = 1 << ROOT_BITS;//第一层数组指针大小 2^5=32
//第二层
static const int LEAF_BITS = BITS - ROOT_BITS;//19位剩下的14个比特位
static const int LEAF_LENGTH = 1 << LEAF_BITS;//第二层数组指针大小 2^14=16384
// Leaf node
struct Leaf {
void* values[LEAF_LENGTH];
};
Leaf* root_[ROOT_LENGTH]; // Pointers to 32 child nodes
void* (*allocator_)(size_t); // Memory allocator
public:
typedef uintptr_t Number;
//explicit TCMalloc_PageMap2(void* (*allocator)(size_t)) {
explicit TCMalloc_PageMap2() {
//allocator_ = allocator;
memset(root_, 0, sizeof(root_));
PreallocateMoreMemory();
}
void* get(Number k) const {
const Number i1 = k >> LEAF_BITS;//先求第一层,所以先整体右移动14位
const Number i2 = k & (LEAF_LENGTH - 1);//这块求第二层下标
if ((k >> BITS) > 0 || root_[i1] == NULL)
{
return NULL;
}
return root_[i1]->values[i2];
}
void set(Number k, void* v) {
const Number i1 = k >> LEAF_BITS;
const Number i2 = k & (LEAF_LENGTH - 1);
ASSERT(i1 < ROOT_LENGTH);
root_[i1]->values[i2] = v;
}
bool Ensure(Number start, size_t n) {
for (Number key = start; key <= start + n - 1;) {
const Number i1 = key >> LEAF_BITS;
// Check for overflow
if (i1 >= ROOT_LENGTH)
return false;
// Make 2nd level node if necessary
if (root_[i1] == NULL) {
//Leaf* leaf = reinterpret_cast<Leaf*>((*allocator_)(sizeof(Leaf)));
//if (leaf == NULL) return false;
static ObjectPool<Leaf> leafPool;
Leaf* leaf = (Leaf*)leafPool.New();
memset(leaf, 0, sizeof(*leaf));
root_[i1] = leaf;
}
// Advance key past whatever is covered by this leaf node
key = ((key >> LEAF_BITS) + 1) << LEAF_BITS;
}
return true;
}
void PreallocateMoreMemory() {
// Allocate enough to keep track of all possible pages
Ensure(0, 1 << BITS);
}
};
// Three-level radix tree,3层基数树
// 适合64位
template <int BITS>
class TCMalloc_PageMap3 {
private:
// How many bits should we consume at each interior level
static const int INTERIOR_BITS = (BITS + 2) / 3; // Round-up
static const int INTERIOR_LENGTH = 1 << INTERIOR_BITS;
// How many bits should we consume at leaf level
static const int LEAF_BITS = BITS - 2 * INTERIOR_BITS;
static const int LEAF_LENGTH = 1 << LEAF_BITS;
// Interior node
struct Node {
Node* ptrs[INTERIOR_LENGTH];
};
// Leaf node
struct Leaf {
void* values[LEAF_LENGTH];
};
Node* root_; // Root of radix tree
void* (*allocator_)(size_t); // Memory allocator
Node* NewNode() {
Node* result = reinterpret_cast<Node*>((*allocator_)(sizeof(Node)));
if (result != NULL) {
memset(result, 0, sizeof(*result));
}
return result;
}
public:
typedef uintptr_t Number;
explicit TCMalloc_PageMap3(void* (*allocator)(size_t)) {
allocator_ = allocator;
root_ = NewNode();
}
void* get(Number k) const {
const Number i1 = k >> (LEAF_BITS + INTERIOR_BITS);
const Number i2 = (k >> LEAF_BITS) & (INTERIOR_LENGTH - 1);
const Number i3 = k & (LEAF_LENGTH - 1);
if ((k >> BITS) > 0 ||
root_->ptrs[i1] == NULL || root_->ptrs[i1]->ptrs[i2] == NULL) {
return NULL;
}
return reinterpret_cast<Leaf*>(root_->ptrs[i1]->ptrs[i2])->values[i3];
}
void set(Number k, void* v) {
ASSERT(k >> BITS == 0);
const Number i1 = k >> (LEAF_BITS + INTERIOR_BITS);
const Number i2 = (k >> LEAF_BITS) & (INTERIOR_LENGTH - 1);
const Number i3 = k & (LEAF_LENGTH - 1);
reinterpret_cast<Leaf*>(root_->ptrs[i1]->ptrs[i2])->values[i3] = v;
}
bool Ensure(Number start, size_t n) {
for (Number key = start; key <= start + n - 1;) {
const Number i1 = key >> (LEAF_BITS + INTERIOR_BITS);
const Number i2 = (key >> LEAF_BITS) & (INTERIOR_LENGTH - 1);
// Check for overflow
if (i1 >= INTERIOR_LENGTH || i2 >= INTERIOR_LENGTH)
return false;
// Make 2nd level node if necessary
if (root_->ptrs[i1] == NULL) {
Node* n = NewNode();
if (n == NULL) return false;
root_->ptrs[i1] = n;
}
// Make leaf node if necessary
if (root_->ptrs[i1]->ptrs[i2] == NULL) {
Leaf* leaf = reinterpret_cast<Leaf*>((*allocator_)(sizeof(Leaf)));
if (leaf == NULL) return false;
memset(leaf, 0, sizeof(*leaf));
root_->ptrs[i1]->ptrs[i2] = reinterpret_cast<Node*>(leaf);
}
// Advance key past whatever is covered by this leaf node
key = ((key >> LEAF_BITS) + 1) << LEAF_BITS;
}
return true;
}
void PreallocateMoreMemory() {
}
};
11.拓展
1.可以更改它的单例模式中的饿汉模式改为懒汉模式;
2.在调试过程中可以通过Visual Leak detector 检测内存泄露情况!
3.有些平台不支持这样的做法,可以参考了解一下Hook技术;