
⭐️在这个怀疑的年代,我们依然需要信仰。
个人主页 :YYYing.
⭐️高并发内存池项目专栏:C++项目之高并发内存池
系列上期内存: 【高并发内存池 (二)】整体框架设计与ThreadCache的初步实现
系列下期内容:暂无
目录
[📖 cc与tc的相似之处](#📖 cc与tc的相似之处)
[📖 cc与tc的差别之处](#📖 cc与tc的差别之处)
[📖 CentralCache的代码实现](#📖 CentralCache的代码实现)
[📖 PageCache框架介绍](#📖 PageCache框架介绍)
[📖 PageCache的代码实现](#📖 PageCache的代码实现)
前言:
在我们上一篇博客中,我们讲完了我们高并发内存池中整体的框架设计与我们ThreadCache的初步实现,那么今天我们就来接着我们tc来看看CentralCache与PageCache的初步实现是怎么样的。
CentralCache初步实现
我们上篇文章在前面也提到过,线程一开始向tc申请内存空间,如果tc中不够了就需要向cc申请内存空间。而实现ThreadCache的时候留下了一个FetchFromCentralCache的接口,我们将在此处接着完善这个接口
📖 cc与tc的相似之处
tc和cc相似之处就是我们cc的内部内存的存储结构仍旧是哈希,也是根据块的大小来映射,甚至二者的映射规则都是一模一样的,这样好处也有很多------当我们tc对应哈希桶没有内存空间时,可以直接找cc中相同下标的哈希桶,如tc的1号桶(16B)不够了就可以直接对应着cc中1号桶去拿,这样就方便了很多
📖 cc与tc的差别之处
1、锁
线程向tc申请空间是不需要加锁的,但是当多个tc相同位置的哈希桶没有空间的时候会并发向cc相同位置的哈希桶申请空间,此时是线程不安全的(因为cc在整个进程中只有一个),那么就需要加锁。
- 比如说t1线程向其tc的2号桶(24B)申请空间时该桶为空,且同时t2线程向其tc的2号桶(24B)申请空间该桶也为空,那么此时二者就都会并发向cc的2号桶申请空间,这时候就要加锁了,谁先拿到锁谁就先获取到空间。
前面讲整体框架的时候说过cc中会有桶锁,意思就是每一个桶都会有一把锁,当多个线程向同一个桶申请空间的时候就会发生竞争,此时才需要加锁,否则就不需要加锁。
- 比如t1线程向其tc的2号桶(24B)申请空间时该桶为空,且同时t2线程向其tc的0号桶(8B)申请空间该桶也为空,那此时t1会去找cc中的2号桶,t2会去找cc中的0号桶,二者并不会竞争同一个桶上的空间,那么就不需要加锁。
2、管理空间
我们tc中自由链表挂的是一个个空间块,而cc中自由链表挂的是一个个span(跨度)结构体,如图:
span是管理以页为单位的大块内存,也就是说span中可以有多个页。span结构体中有一个size_t类型的_n成员,这个成员表示的就是span管理了多少页。
那么span挂在哪个桶下面就会将span总的空间划分成多个对应桶表示的字节大小的空间。
比如说span挂在0号桶下面,就会被划分成多个8B的小空间,也就是上面图中那样,多个小块的空间还会用链表连起来。仿照tc的结构,那么span结构体也会有一个void* _freelist来表示划分好的多个小空间的链表的头节点。当tc空间不够向cc申请时,就是从span管理的小块空间中拿小块空间的。
每个桶下挂的span所包含的页数是不同的,桶对应代表的字节数越小,页数也就越少,同理字节越大页数也就越大。可能这里有人会疑惑为什么是这样,那么你有可能就是被上面的简图所误导了,因为我们一页内存并不是几B几B的值,而是固定为一个值,比如4KB。如0号桶代表的是4B的块,那一个span一两页就够了,假设按照一页4KB来算,那一页也都可以划分成512个4B的块了,两页都1024个了。
再比如最后一号桶为256KB,按照一页4KB的话,那这一个span就要多给些页才能表示一个256KB。
既然需要管理这些页,还要区分多个span所管理的页是从第几页到第几页,那么理应需要一个参数_pageID来表示当前span管理的开始页是几号页
可以看到图中span的结构是双向的,而小块空间的都是单向的,将span实现为一个双向链表,这样在对span链表进行增删查改的时候会更方便一些。
- 因为上篇说了CentralCache既要给tc分配空间,还要回收tc中空闲的空间,同时cc空间不够的时候还要向pc申请空间,而且cc中有span空闲的时候还要还给pc,这样cc在中间就是起一个承上启下的作用,对其相关的操作效率要高。所以span中还有两个字段,那就是span* _prev和span* _next;
📖 CentralCache的代码实现
我们先创建两个文件,CentralCache.cpp和CentralCache.h,搞一个CentralCache类,两个文件一个实现一个声明,图内为头文件声明:
1、Span实现
首先是span的实现,此处说下刚才遗漏的问题,就是PageID,也就是成员_pageID的类型,这个类型是由我们自己自定义的:
cpp
typedef size_t PageID;
定义在Common.h当中,有些人可能会怕,32位下和64位下申请内存的页数size_t塞不下,但size_t的大小是会随着平台的变化而变化的,所以我们完全不用担心这个问题,仍存疑心的同学可以自己下去算一算。
此处就剩下我们use_count是上面没说过的了:
- 这个_usecount是用来记录当前span分配出去了多少个块空间,分配一块给tc,对应就要++use_count,如果tc还回来了一块,那就- -use_count。_usecount初始值为0。
- 在回收过程中cc某span中的use_count为0的时候可以将其还给pc以供pc拼接更大的页,用来解决内存碎片问题(外碎片)。
目前我们的span参数就介绍这几个,虽然还有几个但只有后面才会用到,那我们就后面再说了。
2、SpanList实现
这里SpanList就是CentralCache中的哈希桶,此处双向链表这种基础的数据结构我就不讲了,我前面关于链表的博客是有讲的,如果不会,可以顺着链接去看看,如果你觉得不咋滴,你也可以去站内自行去找一篇博客看看。
因为要搞带头的,直接在Common.h搞一个Span成员,构造函数里面创建一个哨兵位的头结点:
cpp
class SpanList
{
public:
SpanList()
{
// 哨兵位头节点的初始化
_head = new Span;
_head->_prev = _head;
_head->_next = _head;
}
private:
Span* _head; // 哨兵位置头结点
};
此处就没必要搞什么迭代器了,重点也不在遍历上,给两个接口就行,一个进行插入Span的Insert,一个删除Span的Erase:
cpp
class SpanList
{
public:
// cc部分
void Insert(Span* pos, Span* ptr)
{
// 在pos前插入ptr
// 确保两个东西不为空
assert(pos);
assert(ptr);
Span* prev = pos->_prev;
prev->_next = ptr;
ptr->_prev = prev;
ptr->_next = pos;
pos->_prev = ptr;
}
void Erase(Span* pos)
{
// 确保pos既不为空,也不是哨兵位
assert(pos);
assert(pos != _head);
Span* prev = pos->_prev;
Span* next = pos->_next;
prev->_next = next;
next->_prev = prev;
// pos结点不用delete回收
// pos结点的span还要回收下,不能直接删掉
// 回收逻辑
}
SpanList()
{
// 哨兵位头节点的初始化
_head = new Span;
_head->_prev = _head;
_head->_next = _head;
}
private:
Span* _head; // 哨兵位置头结点
};
然后.cpp中的CentralCache类还需要一个以SpanList为元素的数组,以表示cc中的哈希结构:
cpp
#pragma once
#include"Common.h"
#define CENTRALCACHE_
#ifdef CENTRALCACHE_
class CentralCache
{
public:
private:
// 哈希桶挂的一个个span
SpanList _spanLists[FREE_LIST_SUM];
};
#endif
我们前面还说了,CentralCache中的哈希和ThreadCache中的哈希映射规则一样,所以等会可以直接复用SizeClass类中的接口,也说了CentralCache中每个哈希桶都要有一个桶锁,那么对于这里的哈希桶每个桶就是SpanList,也就是要对每一个SpanList提供一个锁,那就直接在SpanList类中添加一个互斥锁成员就行(记得引头文件):

此处单开public的原因是,此处的桶锁不一定只在该类中函数使用,也有可能会在类外函数使用。
3、饿汉创建单例CentralCache
之前TreadCache是每个线程都有一个,通过TLS实现了这个一点。而整个进程中CentralCache是只有一个的,要想要让所有tc都能访问到这一个cc对象,那用单利模式来实现这一个CentralCache是再好不过了。单例我暂时应该讲不到,估计在8月份我才会开专栏进行设计模式的讲解,这里如果不太懂就在站内搜一搜。
在cc中搞一个静态的对象成员,然后在类外初始化,注意不要直接在.h中初始化,因为可能会有多个cpp文件包含这里的CentralCache.h,如果在.h中初始化了就会发生链接错误,所以要专门在.cpp中初始化:

然后构造函数私有、delete掉拷贝构造和复制构造,并提供GetInstance接口,返回_sInst的地址:

接下来该写写tc与cc交互的逻辑了,也就是我们在ThreadCache中留下的那个接口FetchFromCentralCache。
4、tc与cc的交互
线程向tc申请空间,tc对应哈希桶中没有空间的时候就要向cc对应哈希桶中申请空间,那么此时就要从cc对应哈希桶中的span中取空间,并给线程提供一块空间。
但又有一个疑问了:tc提供的空间要提供多少呢,不能多也不能少该如何解决呢?
我们这里将采用慢开始反馈调节算法,如果学过tcp网络通信的应该知道这个,我们刚开始少给一点,如果tc对于单个大小的空间块的需求次数在增多,那cc也就不断增加单次提供的块数。
- 比如说线程对于16B(对应1号桶)空间需求较多,那么当tc的1号桶中没有空间块的时候就要向cc的1号桶中的span申请空间。
- 假如tc第一次 向cc的1号桶申请空间时,cc只先给一块16B的空间,并标记一下
- 那么当第二次 同样线程的tc过来且还是申请1号桶的空间,那cc根据前一次的标记,发现这个tc之前申请空间的时候给了一块,那这次又来了,可能后面还会再来,那就再在前一次的基础上多给这个tc一块,所以这次就给了这个tc两块16B的空间,同时也做一下标记。
- 那么当第三次 同样线程的tc过来且还是申请1号桶的空间,那cc根据前一次的标记,发现这个tc之前申请空间的时候给了两块,那这次又来了,可能后面还会再来,那就再在前一次的基础上多给这个tc一块,所以这次就给了这个tc三块16B的空间,同时也做一下标记。
- 子子孙孙无穷匮也。。。
- 其他块大小的空间同理,但此处cc提供某一大小的块数肯定也有上限。
那么现在来写写FetchFromCentralCache的逻辑。
首先我们需要有一个可以记录tc应该申请固定空间块的块数,由于空间块大小由哈希桶所在下标决定,那么这个变量就应该由对应需求块大小的自由链表来提供,所以我们在Freelist中加一个成员变量_maxSize用来表示未达上限时,能申请的最大内存空间块,并提供一个MaxSize接口来返回该值。

此处tc申请空间时,cc会根据MaxSize与下面这个函数来判断为tc提供多少块固定空间。
我们再来实现一下应对单次申请太多或太少的算法,直接写在Common.h中的SizeClass中:
cpp
static size_t NumMoveSize(size_t size)
{
// 申请空间>0
assert(size > 0);
// MAX_BYTES单个块的最大空间,也就是256K
int num = MAX_BYTES / size;
// 除之后我们需要再控制大小一次
if (num > 512) {
// 此处我们不妨计算一下,单次申请8B
// 256KB / 8B得到的是3w多的数,那这样就会
// 造成空间浪费,所以我们该小一点
num = 512;
}
if (num < 2) {
// 同理如果单次申请256KB,除下来num=1,
// 这样就太少了,如果线程要调四次256KB的
// 我们将num改为2,就可以少调几次,但也不能太大
num = 2;
}
// [2, 512],这个阈值是一次批量移动多少个对象的上限制
return num;
}
通过这两个东西就可以控制单次申请的块数,下面我们就正式可以开始FetchFromCentralCache函数的实现了。

此处我们拿到batchNum就知道cc本次要给tc提供多少块内存了,那么接下来就需要让cc拿出来了,我们在cc定义一个FetchRangeObj函数接口。
这个函数的大体思路就是从CentralCache对应index下标的哈希桶中拿出batchNum块大小为alignSize的块空间。而这个函数应该返回来大小为 batchNum * alignSize 的一段空间,而这一段空间就是从对应index的SpanList中挑出一个Span,然后再从Span中挑出大小为 batchNum * alignSize 的一段空间。
不过此时有可能会出现Span中空间不足以提供这么多的情况,所以此时就算不够还是要返回一段空间的,那么如何确定返回了多少块呢?规定一下返回值返回的是实际提供的大小为alignSize的空间的块数,并且应该给两个指针的参数,一个void* start,一个void* end,用来划定cc所提供的空间的开始和结尾。

这里start和end做了输出型参数,方便传入指针的改动,当然你用二级指针也没毛病。
不过暂时先不实现,我们继续我们之前tc中那个函数的实现。

此处actualNum可能实际上不会等于batchNum,因为单个span空间可能不够,不过FetchRangeObj一定能保证这里actualNum一定是大于等于1的,这一点在后续代码实现的时候就知道了,这里不做解释。
那么我们分配到的空间就是[start,end],此处还需要把这段空间放入tc中,而且tc对应自由链表也一定是空的,因为只有空了才会进入该函数。

将图中4块8B的空间取出,那么start和end就应该这样指

但前面tc中Allocate的实现逻辑是向cc申请空间时不仅需要给tc对应的自由链表提供多个块空间,还要同时直接给线程返回一小块空间,这里我怕有的人可能已经忘了,我再拍出来吧

所以实际应该给tc对应自由链表中插入的是[ObjNext(start), end],然后给线程返回的是start

考虑到一个个入链表效率有点低,我们再给FreeList类中定义一个插入多块空间的接口

那么此时,我们FetchFromCentralCache的代码就应该这样写:
cpp
// 向cc申请内存
void* ThreadCache::FetchFromCentralCache(size_t index, size_t alignSize)
{
size_t batchNum = min(_freelists[index].MaxSize(), SizeClass::NumMoveSize(alignSize));
// maxsize表示index位置的自由链表单次申请未到上限时,能够申请的最大块空间是多少
// Nummovesize表示tc单次向cc申请alignsize大小的空间块的最大块数
// 二者取小就是在控制本次要给tc提供内存块的数量
// 也就是没到上限取maxsize,到了就取nummovesize
if (batchNum == _freelists[index].MaxSize())
{
// 没到达上限的话,下次再申请可以多给一块
_freelists[index].MaxSize()++;
// 这就是慢开始反馈的核心
}
// ===========以上是慢开始反馈调节算法==============
void* start = nullptr;
void* end = nullptr;
// 返回值为实际获取到的块数
size_t actulNum = CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, alignSize);
assert(actulNum >= 1);
if (actulNum == 1)
{
// 如果等于1,就直接返回start
assert(start == end);
return start;
}
else
{
// 若大于1,就要给tc对应位置插入[ObjNext(start), end]的空间
_freelists[index].PushRange(ObjNext(start), end, actulNum - 1);
// 给线程返回start所指空间
return start;
}
}
现在我们来写FetchRangeObj
5、cc的FetchRangeObj
第一步当然是要计算出size所在桶的下标,直接调用前面SizeClass类中的接口就行。

此时_spanLists[index]位置处所挂的span情况是不能确定的,大致可分为下面三种:
1、当前情况下直接获取就行

2、当前情况下需要向pc申请新的span

3、当前情况下也需要向pc申请新的span

其他情况无非就是多挂了几个span,最特殊的还是这三个
但无论如何,我们出现了需要向pc申请空间的情况,所以我们需要再来一个接口
cpp
Span* GetOneSpan(SpanList& list, size_t size);
//此接口定义在cc.cpp中
至于为什么是这俩参数,我们在后续揭晓。
我们继续来写FetchRangeObj,我们上文也说了因为整个进程中只有一个cc,而且可能会有多个线程向同一个cc中的SpanList申请span中的块空间,所以要对SpanList的操作加锁,而获取到index之后就要访问特定SpanList了,所以下面就要开始加锁了:

然后我们在获取到的span中取出batchNum个大小为size的空间,如果不够batchNum个,那就有几个取几个,一开始我们让start和end指针均指向span的_freeList,也就是第一块size大小的块空间,然后让end往后挪动batchNum - 1个空间

这样start和end就指向了一段batchNum个size大小的块空间。然后现在就是要把这块空间给ThreadCache,最后让Span的_freeList指向end的next就可以了,也就是接上后续的内容

那么此时我们的逻辑还差一点就完了,也就是那如果span中不够batchNum个呢?很简单,我不相信一个学过数据结构的人想不通这块,那就是不停往后走,直到end的next为空的时候就停下,这种情况我就不画图了,真是同同又理理啊。
我们直接上整体代码:
cpp
size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size)
{
// 获取size对应到哪个哈希槽
size_t index = SizeClass::Index(size);
// 对桶操作要加桶锁
_spanLists[index]._mtx.lock();
// 获取到一个管理空间非空的span
Span* span = GetOneSpan(_spanLists[index], size);
assert(span); // span不为空
assert(span->_freelist);// span管理的空间不为空
// 起初都指向_freelist,让end不断往后走
start = end = span->_freelist;
// 函数实际返回值
size_t actualNum = 1;
// 在end的next不为空的前提下,让end走batchNum - 1步
size_t i = 0;
while (i < batchNum - 1 && ObjNext(end) != nullptr)
{
end = ObjNext(end);
++actualNum; // 记录end走了多少步
++i; // 控制条件
}
// 返回[start, end]后调整span的freelist指针
span->_freelist = ObjNext(end);
span->use_count += actualNum; // 给tc分了多少就给useCount加多少
// 返回的空间不要和原先Span的_freelist中的块相连
// 我们将end后的内容截断
ObjNext(end) = nullptr;
// 释放锁
_spanLists[index]._mtx.unlock();
return actualNum;
}
不过暂时还没法测试,申请空间的逻辑仍缺了很多,而且pc还没写,那么我们就开始我们pc的内容。
PageCache初步实现
pc和cc一样,二者核心结构都是以SpanList为哈希桶,但二者不同于tc与cc那样,不同之处还是很多的。
📖 PageCache框架介绍
我们pc中的SpanList是按照span页数进行映射的,也就是说------span中有 i 页,就会挂在第 i 号桶,看图:

此外pc中span内部不会再切分成小空间,也就不会按照我们之前讲的一页8B切好,这里就是一个总的量,然后每次cc问pc要到span后就需要自己再切分一下。
我们再看看大小,我们页数范围是[1, 128],我们按照一页8KB的规则来算,128页都1MB了,最大块空间也就256KB,这也太够了。
现在我们就正式进入代码环节。
📖 PageCache的代码实现
1、基本工作
还是老样子,一个.cpp还有一个.h,刚刚说了我们pc也是基于SpanList哈希桶的,所以PageCache的成员就是一个基于SpanList定义的一个数组,我们也说了最大是128页,所以我们先定义一个PAGE_NUM常量来表示span的最大页数。

这里我直接给了129,因为要直接映射页数,但数组的0实际是第一个元素,所以我们多开一个,i号映射i页,那么我们pc类现在就是这样的:

2、pc中span的分裂与合并
我们整个项目的三层里每一层都少不了申请和回收空间的流程,我们现在就来讲下如何向pc申请和pc如何回收cc的空间。
pc中的分裂
当central cache向page cache申请内存时,page cache先检查对应位置有没有span,如果没有则向更大页寻找一个span,如果找到则分裂成两个。
- 例子:申请的是1页page,1页page后面没有挂span,则向后面寻找更大的span,假设到128页page位置才找到一个span,则将128页pagespan分裂为一个127页page span和一个1页page span,这个1页就是给cc的。
但如果找到_spanList[128]都没有合适的span,则向系统使用mmap、brk或者是VirtualAlloc等方式申请128页page span挂在自由链表中,再重复上述中的过程。
我们现在来画图感受一下:

pc中的合并
当central cache释放回一个span,就依次寻找span所管理的页号的前后页号的页有没有空闲,看是否可以合并,如果合并继续向前寻找。这样就可以将切小的内存合并成大的span,以减少内存碎片。
比如说现在有一个页号为100的span,该span管理了10页空间,那么此时这个span管理的范围就是[100, 110],那么此时就看一下这个范围两边的页号对应的页有没有空闲,也就是99页和111页是否空闲,如果空闲就合并到这个span中,假设这里99页和111页都是空闲的,那么合并之后span就变成了页号为99,管理页数为12的span,此时管理的范围就是[99, 111]。再重复上述步骤,不断往两边扩展,当有一边找不到连续的空闲页的时候就让这一边停止,另一边也是这样找。
3、单例创建PageCache
全局只有一个pc,我们就也搞成一个饿汉单例,方法与cc不能说完全一致,只能说一模一样:

注意我们之前向cc加了桶锁,这里我们pc也需要加锁,因为可能存在cc中的多个桶中向pc申请span,不过此时我们不是向pc加桶锁了,而是对pc整体加锁,这一点和前面讲的span的分裂和合并有关,这里先暂时记住要加一个整体的锁,所以PageCache中加一个锁成员。
然后在.cpp中初始化一下:

4、cc的GetOneSpan
前面提到cc中的span不够时需要去找pc要,而我们cc也要检查一下自己有没有管理空间非空的span,检查自己与向pc要都是在GetOneSpan接口中完成的,调用后会给cc返回一个管理空间不为空的span。
那我们上面也讲了cc在获取自身桶中span的情况可以分为三种------第一种是有span且不为空,而剩下两种我们都要向pc再申请一个全新的span,那这个span怎么提供呢?我们让pc提供一个接口NewSpan(size_t k),表示pc从自己的哈希桶中拿出来一个k页的span:

不过暂时先不管这个函数,我们讲完GetOneSpan后再进行这个函数的讲解。
我们现在梳理一下当前所有Cache都没空间整个流程是什么样子的:

此处串一遍应该思路会清晰很多,我们后续测试的时候,还会再过一遍的。
下面我们就进入GetOneSpan的逻辑:
首先我们要知道一件事,GetOneSpan是为了让cc拿到一个管理空间非空的span,cc中可能有这样的span,也可能没有。
- 首先要判断cc对应index下挂的有没有管理非空空间的span,若有,则将该span返回。若没有则向pc申请新的span,直接调用NewSpan即可。
不过想要判断cc中的某个SpanList有无非空span,就要能够遍历SpanList这个链表,所以这里要在SpanList中加上两个接口Begin和End,分别代表链表的头部和尾部:

那么我们就先来找下cc中非空的span:

此时没找到的话,就要向pc中申请全新的Span了,也就是调用NewSpan,但是NewSpan的参数为span管理的页数,只有我们给pc说要多少页的span,这样pc才能根据自己的映射关系从其对应页数下标处找到对应的span。
而我们GetOneSpan的参数只有size,也就是单个块空间的大小,所以要将这个size转化成对应页数才行,而不同大小的size所需要的页数是不一样的,假设1页有8KB的话,那一页就可以提供1024个8B的块,但是32页才能提供一个256KB的块,所以需要专门实现一个较为匹配的块页匹配算法。
cpp
// 写在SizeClass类中
// 块页匹配算法
static size_t NumMovePage(size_t size) // size表示一块的大小
{
// cc中没有span为tc提供内存块时,就需要向pc申请一块span,此时需要根据一块空间
// 的大小来匹配出一个维护页空间较为合适的span,以保证span为size后不浪费或不足够
// ,还要再频繁申请相同大小的span
// nummovesize是算出tc向cc申请size大小的块时的单次最大申请次数
size_t num = NumMoveSize(size);
// num * size就是单次申请最大空间大小
size_t npage = num * size;
// PAGE_SHIFT表示一页要占多少位,一页8KB就是13位,这里右移13位
// ,其实就是除以页大小,算出来就是单次申请最大空间有多少页
npage >>= PAGE_SHIFT;
// 如果算出来为0,那就直接给1页,比如说size为8B,而num为512
// ,npage算出来就是4KB,那如果一页8KB,移位后就为0了
// ,意思是半页的空间都够8B单次申请的最大空间了,但二进制中没有0.5,此处就给1
if (npage == 0)
{
npage = 1;
}
return npage;
}
再举一个size为256KB的例子,num算出来是2,npage算出来是2 * 256KB = 512KB,除以8KB得64页。故要满足256KB的块的单次申请上限,就需要用管理64页的Span。
然后我们再在Common.h中把PAGE_SHIFT定义一下:

那这里就继续写GetOneSpan了:

这样就能让CentralCache从PageCache中获取到一个没有划分过的全新span,注意我加粗了没有划分过,CentralCache获取到这些从PageCache来的未划分的span之后需要自己根据size划分一下这些span,那么怎么划分呢?
首先我们要拿到span管理的空间的首地址,可以直接通过页号来获取,即页号左移PAGE_SHIFT位(相当于页号 * 单页大小),这就是span所管理页的首地址(看到这点肯定会懵,没事我们后面会解释)
NewSpan中实现的时候会设置好返回的span中的_pageID和_n,也就是页号和管理页数。其中的其他项都是默认值,比如_freeList是空的。
然后再算出span所管理空间的大小,即span->_n * 单页大小,这样首地址加空间大小即end,这样再通过第二个参数size就可以对span所管理的空间进行划分,划分好的空间就直接放到_freeList后面。

虽然图中看起来像线性表,其实还要用一个个块的前4 or 8个字节作为下一块的指针域,这样就是一个链表的结构。其实也是顺序表,因为新的span管理的空间一定是一块连续的空间,都是从128页的span过来的,而128页的span是直接用系统接口申请的,那就是连续的空间。
然后将这块空间放到_freelist中:

再来说一下划分整个空间的过程,定义一个tail表示当前已经划分过的末尾位置,那tail的初始位置就应该是start,然后不断让tail往后挪动,并用tail不断链接后面的块空间。

现在想要连接前后的块,那tail就要用到ObjNext,那么此时tail就应该是void*类型的(ObjNext参数为void*的),但成为void*之后就没法做+=运算了,那么我们就搞char*类型的方便进行+= size操作,不过不用定义别的指针,直接用start就行,因为start也要加整个span管理空间的大小,且后面也不用再回来用这个start。

此外我们需要把这个span放入cc对应的哈希桶中,所以我们参数带了个list,这个就是对应index的哈希桶。


现在公布GetOneSpan的代码:
cpp
Span* CentralCache::GetOneSpan(SpanList& list, size_t size)
{
// 先在cc中找一下有没有管理空间非空的span
Span* it = list.Begin();
while (it != list.End())
{
// 找到管理空间非空的span
if (it->_freelist != nullptr)
{
return it;
}
else
{
it = it->_next;
}
}
// 走到这就是没找到
// 将size转化成页数,以供pc提供一个合适的span
size_t k = SizeClass::NumMovePage(size);
Span* span = PageCache::GetInstance()->NewSpan(k);
// 这里需要强转一下,因为_pageID是PageID类型,不能直接赋值给指针
char* start = (char*)(span->_pageID << PAGE_SHIFT);
char* end = (char*)(start + (span->_n << PAGE_SHIFT));
// 开始切分span管理的空间,并将其放入span->_freelist
span->_freelist = start;
void* tail = start;
start += size;
// 链接各个块
while (start < end)
{
ObjNext(tail) = start;
start += size;
tail = ObjNext(tail);
}
ObjNext(tail) = nullptr; // 记得要把最后一位置空
// 切好span后,需要把span挂到cc对应下标的桶中去
list.PushFront(span);
return span;
}
那接下来就到我们NewSpan函数了
5、pc的NewSpan
NewSpan这个函数就是让pc拿出来一个全新的k页span给cc用。
那么我们可以捋一捋怎么拿的逻辑了:
1、首先pc先检查自己第k个桶中有没有span,如果有就返回该桶的第一个span。
2、但如果第k个桶中没有就再往下找更大页的桶中有没有span,如果有就拿出来拆开,假设找到的span中起始页号为id,管理的页数为n,那么就把这个span切分成一个起始页号为id,管理页数为k的span,和一个其实页号为id + k,管理页数为n - k的span(当然你也可以从后往前切)

3、如果k号桶中没有,且比k大的桶下面也没有,就要向系统申请一个128页的span,此时申请完后同样进行切分并返回所需要的k页span,和2中的步骤差不多,只是多了个向系统申请的步骤。
那么现在就可以开始写代码了,不过写代码前再给SpanList两个接口,一个是判断链表为空的接口(注意此处判断的是是否有span,而非span为空,后者实际在此处判断的是span中的_freelist),还有一个接口是获取非空SpanList中的第一个span

那么就可以开始写NewSpan了,分三种情况:

第一种:
第二种:
第三种:
不过在写这种的时候,我们需要向系统申请空间,不知道大家是否还记得我们之前定长内存池中正好封装了一个SystemAllco函数,这里就直接将那个函数挪到Common.h里面:

我们接着说第三种情况,pc直接向系统申请空间:

此处递归用的很妙,不过可能有的兄弟会说:这里递归不应该会比直接写出来将128页分成k页和128 - k页的span慢吗?其实并不会,以现代CPU的性能来说,一点点栈帧的消耗直接可以忽略不计,同时也要注重代码复用性,如果说这里再写一个切分的逻辑错了还得要改,不如直接用现成的。
现在给大家放出来NewSpan的完整代码:
cpp
#include"PageCache.h"
PageCache PageCache::_sInst;
// pc从_spanlist中拿出一个k页的span
Span* PageCache::NewSpan(size_t k)
{
// 申请页数一定是在[1, PAGE_NUM - 1]这个范围内的
assert(k > 0);
// 1、k号桶中有span
if (!_spanLists[k].Empty())
{
// 直接返回第一个span
Span* span = _spanLists[k].PopFront();
}
// 2、k号桶中没有span,但后面的桶有span
for (int i = k + 1; i < PAGE_NUM; i++)
{
if(!_spanLists[i].Empty())
{
Span* nSpan = _spanLists[i].PopFront();
// 开切
Span* kSpan = new Span;
// 分一个k页的span
kSpan->_pageID = nSpan->_pageID;
kSpan->_n = k;
// 和一个 n - k 页的span
nSpan->_pageID += k;
nSpan->_n -= k;
// n - k页的放回对应哈希桶
_spanLists[nSpan->_n].PushFront(nSpan);
return kSpan;
}
}
// 3、k号和后面都没span,直接向系统申请128页的span
// 直接向系统申请128页的span
void* ptr = SystemAlloc(PAGE_NUM - 1); // PAGE_NUM为129
// 开一个新的span用来维护这块空间
Span* bigSpan = new Span;
// 只需要修改_pageID和_n即可
// ,系统调用接口申请空间的时候一定能保证申请的空间是对齐的
bigSpan->_pageID = ((PageID)ptr) >> PAGE_SHIFT;
bigSpan->_n = PAGE_NUM - 1;
// 将这个span放入到对应哈希桶中
_spanLists[PAGE_NUM - 1].PushFront(bigSpan);
// 递归再次申请k页的span,这次递归一定会去第2种的逻辑
return NewSpan(k);
}
6、关于pc中的加锁问题
pc和cc不太一样,PageCache中只有一把锁,用来锁整个PageCache,就是因为Span的分裂和合并会直接影响到多个桶,并不是向CentralCache中只会在单个桶上操作,而且如果PageCache中给了PAGE_NUM个桶,对多个桶的操作可能很频繁,而且对桶的操作就几行代码,这样加上锁了之后就执行一点代码就停止了反而会因为短时间内频繁的加锁解锁而导致效率的下降。所以说不如直接用一个锁来将整体PageCache锁住。
那么目前如何给pc加锁就成了现在的问题
可能有的人会觉得在刚刚的NewSpan的开头加把锁不是正好:
但这样后面解锁就出现问题了,因为三种情况都是要在return前解锁,尤其是第三种递归调用NewSpan,前两种最起码还能使用lockguard进行上锁解锁,但第三种怎么弄呢?lockguard可是只有在函数栈帧销毁的时候才会释放锁,前两种return时栈帧就无了,此时互斥锁将会解除,但第三种lockguard的时候递归调用NewSpan,此时栈针并不会销毁,进而就不会解锁,那我们到开头申请锁的时候就会阻塞,从而造成死锁,所以我们需要在递归调用NewSpan前就解锁,这样在递归调用NewSpan的时候在递归的NewSpan内部才能拿到锁并进行加锁,但这样实际上是不安全的,所以我们得另辟蹊径。
这里介绍三种方案:
第一种解法:是直接用recursive_mutex,递归锁能够识别自己,这样在递归调用的时候同一线程进行重复加锁的时候并不会阻塞,那么也就不会产生死锁。这个感兴趣的同学可以自己去试试,我就不用这个了,我们将会用第三种
第二种解法:把这里的NewSpan改成一个子函数_NewSpan,然后再搞一个NewSpan用来调用这个_NewSpan,在调用的两边进行加锁,也就是这样:
这个你们也是自己下去试试。
第三种方案就是在调用NewSpan接口的位置上下加锁:
那么我们pc中的锁的加解就完成了,我们现在再来看cc的桶锁的问题
7、cc向pc申请span时解锁
cc向pc申请span的时候要解锁吗?
我都这么问了,那肯定是推荐解掉啦:

因为虽然可能出现多个线程的tc向cc同一个桶申请空间的情况,但同时也可能存在多执行流释放空间的情况,如果当一个cc某个桶(准确的来说还是线程在执行)在向pc申请新span的时候还占着span不放,那如果有其他线程的tc向cc中的那个桶归还空间的时候就没法还,所以既然申请新span的时候用不到这个桶的空间,那不如让还空间的线程对该桶执行相应归还的操作。
当然这样也可能存在多个线程同时向该桶申请空间的情况,但是也就那一点代码,执行完之后还是会向pc申请span,一样的。所以不如把锁放出来大家一块用,能归还空间的就还,不影响还空间的线程。
那么申请到新span后什么时候加锁呢?
我相信肯定有人会觉得刚拿到就加上不就行了,但cc刚拿到新span还要切分,切分的时候,其他线程是拿不到这个span的,所以就不会出现竞态,那我相信正确的加锁位置你应该已经知道在哪了,没错,就是在cc挂span前:

可能会有人担心怎么解锁,不必担心,出来后是可以解锁的:

那我们再来梳理一遍pc与cc的加锁解锁流程:
首先向cc自己的哈希桶中拿span的时候要加锁,如果cc的桶中没有span就要像pc申请span,那么在cc向pc申请span的时候需要将cc的桶锁解除,然后加上pc的整体的锁,申请到新的span后解除pc的整体锁,然后cc对新span进行切分,切分完毕后,再将切完的span挂到对应cc桶中时就加上锁,然后给将这个新挂上去的span管理的空间交给需要的tc之后再解锁。
此处我觉得说的还是很清楚的,如果看不懂就再结合代码多看几遍我不信看不懂哈。
8、期盼已久的代码测试
这里我只会说一些关键的测试时间节点,想要搞懂测试用例必须需要调试,这个东西不会的兄弟赶紧去学,啥都能不会唯独调试不能不会,这东西的熟练度可是会决定兄弟们以后加班时长的。
现在来看第一个测试用例:
cpp
void ConcurrentAllocTest1()
{
void* ptr1 = ConcurrentAlloc(5);
void* ptr2 = ConcurrentAlloc(8);
void* ptr3 = ConcurrentAlloc(4);
void* ptr4 = ConcurrentAlloc(6);
void* ptr5 = ConcurrentAlloc(3);
cout << ptr1 << endl;
cout << ptr2 << endl;
cout << ptr3 << endl;
cout << ptr4 << endl;
cout << ptr5 << endl;
}
那么整个流程就是这样的:


可以看到我们页号向左移13位也就是*8KB后,得到的16进制数就是向系统申请的空间的首地址。这里系统在申请空间的时候能够保证申请的空间是对齐的,所以这里才能用_pageID来规定页号,后续cc在得到新span后才能通过页号计算出首地址。
我们再来看我们第二个测试用例:
cpp
void ConcurrentAllocTest2()
{
for (int i = 0; i < 1024; i++)
{
void* ptr = ConcurrentAlloc(5);
cout << ptr << endl;
}
void* ptr = ConcurrentAlloc(3);
cout << "-------" << ptr << endl;
}
还是都申请8B块,这里for循环目的是申请完cc中0号桶中的那个span,然后再申请一块span的时候,tc中没有,向cc申请,cc中也没有,向pc中申请,pc中有一个127页的span,分出来一页给cc,自己留下126页的span,然后剩下的流程就和前面一样了。

结语
今天我们搞定了内存池的两个大头:CentralCache与PageCache的初步实现,本篇内容对我们这个项目至关重要,在最后我们也是成功接上了上次没测完的tc,但不会调试的兄弟下去一定多看看,我们后面还会有调试的场景。
我是YYYing,后面还有更精彩的内容,希望各位能多多关注支持一下主包。
无限进步,我们下次再见!
---⭐️ 封面自取 ⭐️---



