目录
Central Cache向Page Cache申请内存接口
一,项目整体框架回顾
首先,项目整体可以划分为三层:Thread Cache,Central Cache和Page Cache.
Thread Cache结构
第一层thread cache是每个线程独有的,每个线程都有一份自己的thread cache对象,来完成自己的申请内存和释放内存的工作。
结构特点:
thread cache是一个类似于哈希桶的结构,每个位置上挂着的都是一些小块内存,这些小块内存是以自由链表的形式组织在一起的。当申请内存空间时,都是从对应的位置(对应的哈希桶)中头删一个内存块,返回给上层使用。
为了使每个线程只看到自己的thread cache的对象,用到了线程局部存储机制。所谓线程局部存储,本质上是一种存储变量 的方法,这个变量在它所在的线程内可以访问,其他线程访问不到。这样就实现了每个线程独享一个thread cache
申请内存:
由于每个线程独享一个thread cache,所以可以直接找thread cache申请内存,不需要加锁。这里就会涉及到thread cache的内存对齐规则,当需要申请的内存大小是n时,我们需要计算出其对应的两个值:实际申请的内存块大小(对齐数),对应在哪个桶中(也就是哪个位置)。
如果对应的桶中还有小块内存,也就是对应的桶不为空时,直接返回一个 内存块给上层。
如果对应的桶中没有内存块,或者已经分配完了,则此时就需要向下一层Central Cache申请。
Central Cache结构
Central Cache为thread Cache提供服务。当thread cache内存不够时,会向Central Cache申请。
所以Central Cache是需要加锁的,但是不是给整体的Central Cache加锁,而是对某个桶加锁。
结构特点:
Central Cache结构与thread cache结构很像,也是哈希桶结构.只不过thread cache管理的是小块内存,而Central Cache管理的内存块更大,在这里称作是span。
一个span,管理的是以页为单位的内存块,一页按照8KB来计算。不同位置上的span大小不一样。
所以Central Cache每个位置上挂的是一个一个的span,span的大小是按照页来计算的。
为thread cache分配内存:
由于thread cache和Central Cache的内存对齐规则是一样的,当thread cahce对应位置上没有小块内存时,就会来找Central Cache申请,申请的位置是一样的。这也就是为什么要将Central Cache设计的和thread cahce一样,申请内存时,直接找相同的位置即可。
而Central Cache每个位置上都是span结构,总不能把整个span返回给thread cache吧。
所以,在span内部,是将一个大的内存块,切分成小内存块,然后使用自由链表管理起来。
所以,总体申请内存的过程如下:
thread cahce对应的位置没有内存了,会像Central Cache申请,会来到相同的位置。这里会存在多个线程并发访问的问题,需要加锁,也就是对当前位置(桶)加锁即可,不需要对整个Central Cache加锁。
然后查看这个位置上的span链表,找到一个有内存块的span,也就是非空的span。从中切分一些返回给thread cache,然后将第一个内存块返回该上层使用,剩下的插入到thread cache对应位置中。
下次再申请时,直接找thread cache,因为无锁,申请高高效。
但是,如果Central Cache对应位置的内存也是用完了,此时就需要像下一层Page Cache申请
二,Page Cache大致框架
Page Cache是为上层Central Cache提供服务的,当 Central Cache对应位置没有内存时,就需要像Page Cache申请。
而Central Cache每个位置上挂的是一个span结构,在给 Central Cache分配内存的时候,返回的应该也是一个span结构。所以Page Cache每个位置上挂的也是一个一个的span。
和Central Cache一样,Page Cache每个位置上都是span链表,大致结构如下:
如上图所示,Page Cache也是一个类似于哈希桶的设计。它与thread cache和Central Cache不同 ,它们两个还需要采用什么麻烦的内存对齐规则,才能找到对应的位置(桶)。
Page Cache相当于是直接定址法的哈希结构,1页就映射1号桶,2页就映射2号桶......
因为上层最多一层申请256KB。而128页位置的span大小是128*8KB,所以分为128页够用了。
如果Central Cache申请的span大小是n页的,就去n号位置找(具体的分配过程在代码部分讲解)
三,Page Cache申请内存实现
Page Cache也是所有的线程共享的,和Central Cache一样,需要设计成单例模式。
//和Central Cache一样,设计成单例模式
class PageCache
{
public:
PageCache() = delete;
PageCache(const PageCache&) = delete;
PageCache operator=(const PageCache&) = delete;
//获取单例对象
static PageCache* GetInstance()
{
return &_sInst;
}
//给Central Cache分配一个span
//参数size表示该span的大小,即该span包含多少个页
span* NewSpan(size_t size);
private:
SpanList _pageList[NPAGENUM];
static PageCache _sInst;
};
Central Cache向Page Cache申请内存接口
在上一篇文章中,实现了thread cahce向Central Cache申请内存的接口。
大致思路是:
先找到对应Central Cache中的位置,然后遍历该位置,找一个非空的span,然后将给span中的一些内存块切分出来,第一块返回给上层使用,剩下的让thread cache保存着。
在代码部分,获取一个非空的span时,只是调用了对应的接口,没有完全实现,因为 这部分和Page Cache有关。
现在我们需要实现这个接口,也就是获取到对应位置的一个非空span。
上图是Central Cache的大致结构,要找一个非空的span,就需要遍历整个 spanlist。
如果找到一个非空的span,就直接返回该span。
如果找不到,也就是当前位置为空,或者所有的span都已经分配出去了,此时就需要向一层Page Cache申请对应大小的span,我们可以直接调用Page Cache提供的接口(在后面实现)。
- thread cache内存不足时,Central Cache会分配更多的内存给thread cache。
- 同样的,Central Cahce对应位置的span都为空时,Page Cache也会分配更多的内存给Central Cache。
- 所以,此时我们需要计算分配多大的span合适,也就是span包含多少个页。在thread cache向Central Cache申请内存的时候,使用的是慢开始反馈调节算法,每次分配的个数呈现出慢增长的趋势。
- 我们知道现在想要申请的内存块大小是size。我们可以计算出Central Cache给thread cache分配的最大个数n,就是上限个数(也就是在满开始反馈调节算法中使用到的)
- 然后我们就可以计算出需要分配的总字节数n*size。
- 最后看看n*size包含多少个页,采用向上对齐。size_t num=n*size/8KB,这样就可以计算出页的大小。
当从Page Cache申请到一个span后,这个 span是一个大块内存。我们需要进行切分,切分成小块内存,使用自由链表管理起来。
完整代码如下:
//thread chache向Central Cache申请内存时,获取的内存块的个数的上限
static size_t NumMoveSize(size_t size)
{
assert(size > 0);
//MAX_BYTES是256KB
int num = MAX_BYTES / size;
//size较大时,num的结果可能是1,num=2,多分配一个
if (num < 2)
num = 2;
//size较小是,num的结果可能很大,甚至上万个
//512相当于一个上限
if (num >= 512)
num = 512;
return num;
}
//现在知道要申请的内存块大小是size,计算分配多大的span合适
//也就是计算span,是管理多少页的
static size_t NumPageSize(size_t size)
{
//首先计算,thread cache向Central Cache申请时的上限个数
size_t num = NumMoveSize(size);
//计算总的字节数
size_t byte = num * size;
byte >>= PAGE_SHIFT;//除以 8KB,计算出页数
if (byte == 0)
byte = 1;
return byte;
}
//从对应的哈希桶,也就是spanlist中,获取一个非空的span
span* CentralCache::GetOneSpan(SpanList& list, size_t size)
{
//list就是对应的哈希桶
//从这个桶中查找一个非空的span,返回
//如果这个桶没有span了,则需要向下层 Page Cache申请
//遍历这个spanlist,spanlist是带头双向循环链表结构
auto it = list.Begin();
while (it != list.End())
{
if (it != nullptr)
{
//找到非空的span,也就是Central Cache层还有span,直接返回
return it;
}
else
{
it = it->_next;
}
}
//走到这,说明该spanlist已经没有空闲的span了,需要向下层申请
//这里就会面临向下层申请多大的span合适
//太大,可能浪费太多,太小,可能会导致频繁的申请
//所以,这里和thread cache向Central Cache申请内存类似,需要计算一个合适的值
//参数代表申请的span的大小
span* sp = PageCache::GetInstance()->NewSpan(SizeClass::NumPageSize(size));
assert(sp);
//从Page Cache获取到一个span后,将该span进行切分
//切成小块内存,形成自由链表,小块内存的大小就是申请时候的size
//计算这个span总的大小
//页数*一页的大小(8KB)
size_t bytes = sp->_n << PAGE_SHIFT;
//根据sp的起始页号,推断出这个sp的起始地址
//一页是8KB,第几个页乘以8KB即可推导出起始地址
char* start = (char*)(sp->_pageID << PAGE_SHIFT);
char* end = start + bytes;//end指向整个span的结尾
//切分,每一个内存块的大小是size并以自由链表的形式组织在一起
//先将第一块切下来,尾插到自由链表中
sp->_freelist = start;
start += size;
//进行尾插
void* tail = sp->_freelist;
while (start < end)
{
NextObj(tail) = start;//尾插一个start
tail = start;//tail中心指向尾
start += size;
}
//sp的结构已经设置好了,现在将它分配给Central Cache
//将它插入到对应的哈希桶中,也就是参数中的list
list.pushFront(sp);
//至此,返回的span,就是一个包含小块内存的结构
return sp;
}
接下来,就需要实现Page Cache的NewSpan接口了。
从Page Cache中获取span接口
//参数k表示该span的页数,即该span包含多少个页
span* NewSpan(size_t k);
从Page Cache中获取一个span,span的大小为k页。
而在上面的内容中,我们了解到Page Cache是一个直接定址法的哈希桶。
所以想申请大小为size页的span,直接去对应size位置的桶找即可。
- 但这里与前面不同,thread cache对应位置为空时,会找下一层申请,Central Cache为空时,也会找下一层申请。但是如果Page Cache对应某个位置为空时,他会去下面的位置继续找,直到找到一个非空的。比如,当访问Page Cache的3号桶 为空时,会继续找比他更大的桶,假设7号桶非空,那么此时就会将7号桶切成3页和4页,然后将4页挂到4号桶上,3页返回给Central Cache。
- 但是如果整个Page Cache都为空,那么此时就会像下一层申请,也就是像操作系统申请一大块的空间。
从一个大小为n页的span中,切分出大小为k的span。
可以总结为以下3步:假设申请的span为k页。
- 先去Page Cache对应的位置(下标为k)查找是否有span。如果有 ,直接返回该span。
- 如果没有,去找更大的span,从下标为k+1开始遍历整个Page Cache,如果找到了一个非空span,将该span进行切分,一部分返回,另一部分重新挂到Page Cache对应的位置上。
- 如果遍历Page Cache也没有找到一个非空的span,就需要向操作系统申请。一次申请128页大小的空间。
还有一个问题,就是如果向操作系统申请了一大块空间,如何将这一大块空间挂到Page Cache对应的位置。本质就是将这一大块空间使用span管理起来。然后将span挂到对应的位置上即可。
向操作系统申请内存,返回的是这块内存的起始地址,是一个指针类型。需要根据返回的地址,计算出这 个大块内存对应的起始页号。如下图:
完整代码如下:
//win64中包含了win32和win64的定义
//win32中只包含了win32的定义
#ifdef _WIN64
typedef unsigned long long PAGE_ID;
#elif _WIN32
typedef size_t PAGE_ID;
#endif
//申请的内存上限
static const size_t MAX_BYTES = 256 * 1024;
//自由链表的个数,也就是哈希桶的个数
static const size_t NFREELISTS = 208;
//Page Cache哈希桶的个数,下标从1开始
static const size_t NPAGENUM = 129;
//一页的大小(指数)
static const size_t PAGE_SHIFT = 13;
// 直接去堆上申请空间
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32
void* ptr = VirtualAlloc(0, kpage * (1 << 12), MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE);
#else
// linux下调用brk mmap等
#endif
if (ptr == nullptr)
throw std::bad_alloc();
return ptr;
}
//给Central Cache分配一个span
//参数k表示该span的大小,即该span包含多少个页
span* PageCache::NewSpan(size_t k)
{
//查看下标为k的位置是否有span
if (!_pageList[k].Empty())
{
//直接返回
return _pageList[k].PopFront();
}
//遍历下面的位置,切分出大小为k页
for (int i = k + 1; i < NPAGENUM; i++)
{
if (!_pageList[i].Empty())
{
//找到一个非空的,开始切分
span* kspan = new span;
span* nspan = _pageList[i].PopFront();//拿到一个span
kspan->_pageID = nspan->_pageID;
kspan->_n = k;
nspan->_pageID += k;
nspan->_n -= k;
//将n-k页的span,头插到n-k的桶中
_pageList[nspan->_n].pushFront(nspan);
return kspan;
}
}
//走到这里说明没有大块的span了,需要找操作系统申请
//直接要一个128页的span
span* bigspan = new span;
void* ptr = SystemAlloc(NPAGENUM - 1);//128页
span* sp = new span;
sp->_pageID = (PAGE_ID)ptr >> (PAGE_SHIFT);//起始页号
sp->_n = NPAGENUM - 1;//页的个数
//从操作系统申请到内存后,再递归调用自己一次获取span
return NewSpan(k);
}
Page Cache加锁问题
Page Cache也是所有线程共享的,在高并发环境下,会存在线程安全问题,所以是需要加锁的。
在Central Cache中,使用的是桶锁,每个线程申请内存时,到对应位置的桶去申请内存,所以加的是桶锁。
Page Cache也是哈希桶结构,当然也可以设计成桶锁,每个桶有一把锁。但是通过上面申请内存的过程,我们有可能不是只针对某一个桶,而是去遍历下面所有的桶,所以如果设计成桶锁的结构,就会面临频繁的申请锁和释放锁,导致系统频繁的进行保存上下文和切换上下文的操作,效率太低。
所以在向Page Cache申请内存时,需要将整个结构加锁。
申请内存完整过程
每个线程启动时,首先向对应的thread cache申请内存,不需要加锁,如果有内存,直接返回给上层使用。
如果不存在,会向Central Cache申请,需要加锁,使用的是桶锁,Central Cache会多分配几个给thread cache。
如果Central Cache也为空,此时会找到一下层Page Cache,需要对整个结构进行加锁。
注意:在Central Cache层使用桶锁后,如果来到下一层Page Cache,就需要将该锁解开。
因为有可能会有其他线程向Central Cache归还内存。