📚本篇摘要
本篇文章将衔接上篇线程向ThreadCache申请内存,发现所有的桶都是空然后再执行 FetchFromCentralCache操作向CentralCache以至PageCache申请内存,一步步分发下去的过程,因此,此篇将细节介绍单线程整个申请内存的流程(不涉及向上层归还),欢迎阅读!
一. 🔥CentralCache整体结构
上面也说到了ThreadCache里面没内存块就要向CentralCache里面申请,那么CentralCache里面是什么样呢?
💡请看下图:

对于CentralCache结构是个spanlist(这里span可以理解成一个跨度:比如8B块,16B块…)的一个链表(大小是208,和之前的ThreadCache一样)。
而每个spanlist上面是一个span的双向链表,然后每个span下面挂的都是对应的spanlist所属于的多少B的内存块,如上图所示。
这里spanlist和freelist不同的是:freelist使用的是一个无头结点的链表,而这里的spanlist使用了头结点。
🔍可能会有下面两点疑问:
为什么spanlist要使用双向链表?
对于spanlist这个结构,不仅CentralCache这里用到,后面的PageCache也用到这个结构,而比如从PageCache里面申请的内存块链,切割好后插入到CentralCache对应的span里面采取的头插,总之,很多操作都是类似头插,头删之类的,多了头结点更加方便(无需考虑这个链是否一开始为空的情况,直接连在head后面即可)。
为什么需要把spanlist搞成双向的链表?
因为比如从CentralCache里找非空的span(利用的就是双向链表实现的迭代器遍历),以及释放内存块进行归还的时候,方便查找等。
二· 📝如何设计CentralCache
构成CentralCache的组件准备
首先先看根据注释看下span的结构:
//一个跨度的连续内存块(一个span放一页连续内存)
struct Span {
PAGE_ID _pageid;//当前span的首页的页号;
int use_count = 0;//span中分配个thread内存块的个数
Span* _pre=nullptr;//span前驱指针
Span* _next = nullptr;//span后驱指针
void* _freelist = nullptr;//span地下挂的内存块
size_t _n;//这个span有多少页
};
- 这里对于页号设置要符合不同位数机器的均衡(比如防止除8kb后
对应页号溢出等问题发生,因此对类型进行分类)
//根据不同位机器对应指针大小不同来确定指针到底应该多少字节
//64位默认直接定义 _WIN64与WIN32;但是32位只默认定义WIN32;因此应该把_WIN64放前面防止64位直接使用了_WIN32!
#ifdef _WIN64
typedef unsigned long long PAGE_ID;
#elif _WIN32
typedef size_t PAGE_ID;
#else
// linux
#endif
对于spanlist只需要把它通过头结点完成双向链表的链接即可,然后又因为对于多线程对于CentralCache的每一个spanlist访问(是临界资源,需要保护),因此进行加锁操作(发现访问的临界资源是这个类对象,因此使用类锁)。其次就是提供了一些头插,头删,插入,删除,以及后续为了支持查找非空span进行的迭代器遍历操作的接口。
spanlist框架如下:
class SpanList {
public:
SpanList() {
_head=new Span();
_head->_pre = _head;
_head->_next = _head;
}
void Insert(Span * newspan,Span*pos) {
assert(newspan);
assert(pos);
Span* pre = pos->_pre;
newspan->_next = pos;
newspan->_pre = pre;
pre->_next = newspan;
pos->_pre = newspan;
}
Span* Begin()
{
return _head->_next;
}
Span* End()
{
return _head;
}
bool Empty()
{
return _head->_next == _head;
}
void PushFront(Span*newspan) {
assert(newspan);
Insert(newspan, Begin());
}
Span* PopFront() {
Span* front = Begin();
Erase(Begin());
return front;
}
void Erase(Span*pos) {
//这里把pos位置的Span拆下来先不要释放或者Delete后续可能回收等操作
assert(pos);
assert(pos != _head);//不允许删除头span
Span* pre = pos->_pre;
Span* next = pos->_next;
pre->_next = next;
if(next)next->_pre = pre;
pos->_pre = nullptr;
pos->_next = nullptr;
}
~SpanList(){}
public:
//多个线程有可能同时访问同一个Spanlist,共享资源存在竞争需要加锁
std::mutex _mutex;// 直接声明即可,无需显式初始化
private:
Span* _head;
};
entralCache结构
这里需要的是多个线程能访问到同一个CentralCache,因此把它搞成单例模式(饿汉模式,之前对于Linux的线程同步与互斥的线程池那里讲过),之后提供的接口就是根据要的内存块大小以及数量进行从自身拿,如果没有的话就去对应的大小的页进行申请然后挂回去。
懒汉单例设计套路:
对类内成员设计静态成员变量,类内声明,类外初始化。
把拷贝构造,赋值重载等都禁用掉。
提供获取这个单例的接口函数,线程每次都调用它。
CentralCache类如下:
class CentralCache {
public:
CentralCache() {};
CentralCache operator=(const CentralCache) = delete;
CentralCache(const CentralCache&) = delete;
static CentralCache* get_instance() {
return &_instance;
}
//通过size获得到对应的哪个spanlsit,然后截取对应的blocknum个块构成start-end链;如果span里面块不够blocknum;就返回实际的多少个
//保证start和end两端是闭区间的(也就是它俩都是索取到的内存块):
size_t FetchSizeObj(void * &start, void*& end,size_t blocknum,size_t size);
//哪个spanlist中获取size的实际大小内存块,然后去找一个非空的span,如果都没有就拿着size去向页缓存获取对应的有内存块的span
Span* GetOneSpan(SpanList &sl, size_t block_size);
private:
SpanList _spanlists[FREELIST_NUMS];
static CentralCache _instance;//单例模式,中心缓存是多个线程共有的,确保它们每次拿到的是同一个中心缓存!
//饿汉模式
};
三· 🚀CentralCache相关接口设计
之前对于线程申请内存到了FetchFromCentralCache环节我们就结束了,下面来补充下这个环节:
FetchFromCentralCache设计思路
首先采用的是慢开始反馈调节算法,根据每次需要的内存块的大小确定要申请多少块挂回去(如果每次都是要一块给一块,有点不太合理!)。
🛠️下面的函数就根据对应块大小来确定多少(有下限和上限):
//从CentralCache中申请多少块real_size 大小的内存块,真正给Threadcache的时候采用的是慢开始反馈调节算法
static inline size_t GetBlockNum(size_t real_size) {
assert(real_size);
size_t num = MAX_BYTES / real_size;
if (num <= 2) num = 2;
if (num >= 512) num = 512;
return num;
}
之后要进行的是慢开始反馈,因此它每次申请的这些块的数量不能直接就涨上去,给它一个慢慢增长的过程,对于每个ThreadCache的对应大小的每个freelist如果没了就去向CentralCache对应位置非空span去要,因此如果这个freelist多次去申请,也就是很频繁,因此可以适当给它多挂回去点,因此提供了一个关于freelist的maxsize的接口:
类成员变量:
size_t _AdaptApplyBlockSize = 1;//每次向CentralCache获取对应大小内存块的个数
//这里放在对应的线程的freelsit里而不是对应的CentralCache里,因为某线程频繁的申请对应大小块内存,下次给它对应的块
//就应该多点;但是如果是放在CentralCache里,那么所有的线程申请的时候都会按照这个来不符合实际!
接口函数:
size_t &Get_AdaptApplyBlockSize() {
return _AdaptApplyBlockSize;
}
下面就是每次拿着GetBlockNum的返回值(也就是理应的块数量和它对比,如果比它(AdaptApplyBlockSize)大也就是需要慢增长,因此给它AdaptApplyBlockSize-1数量块挂回去(因为要提供一个去给线程用,其他的挂回去)),对应的AdaptApplyBlockSize进行增加(至于多少可以自行确定来决定它慢增长的速度),如果比它小,此时就只能取GetBlockNum的返回值了。
🛠️具体实现如下:
//慢开始反馈调节算法:
//有防止浪费,适应等优点:
//要的块大就少给,小就多给,不是一次性给很多,根据频繁程度依次累加,当到了这个最大值就不会再累加,有个申请固定顶峰置 !
size_t blocknum = min(_freelists[index].Get_AdaptApplyBlockSize(),
Calculate::GetBlockNum(block_size));
//如果每次对应线程在选择这个大小块申请,就说明用这个比较频繁,依次累加数量申请:
if (blocknum == _freelists[index].Get_AdaptApplyBlockSize())
{
_freelists[index].Get_AdaptApplyBlockSize() += 1; //控制给内存块的速度
}
FetchSizeObj设计思路
这个函数需要给它传递start,end,然后把对应的内存块链链接好返回,之后因为考虑到可能CentralCache对应的span可能没有那么多内存块,因此还需要一个realsize(作为函数返回值)记录真实申请的块数量。
首先进行对应的GetOneSpan拿到非空span(这里后面实现),之后就是进行从对于span的freelist里面找对应大小的块给它截断。
☀️下面模拟下这个截断过程:
假设我们通过慢开始反馈算法得到的blocknum是4:

start 与end指针(开始都是只想freelsit首地址)开始移动,发现end只需移动三步:

- 之后进行截断(把对应的最后一块的存放的下一块地址改成空,然后剩下的给
freelist链上): 
- 但是如果是对应的
span里的内存块不够呢?(因此这里还有个终止条件就是end的保存的下一个为空就要停止了):

-
但是还需要
realsize来记录真实的数量(防止不够的情况)。 -
还有就是对于临界资源的加锁解锁,当访问这
spanlist,需要加锁,当切断后就可以解锁了。
🛠️ 代码如下:
size_t CentralCache::FetchSizeObj(void*& start, void*& end, size_t blocknum, size_t block_size) {
int index=Calculate::Index(block_size);//根据size找到实际对应字节数的spanlist
//(CentralCache的内存块大小对应索引和ThreadCache那里索引对应关系一样)
_spanlists[index]._mutex.lock();
//得到一个非空span:
Span* span = GetOneSpan(_spanlists[index], block_size);
assert(span);
assert(span->_freelist);
//分两种情况span内存块一种够,一种不够:
start = span->_freelist;
end = start;
int i = blocknum - 1;
int realsize=1;//记录真实获取了多少个块:如果够realsize== blocknum;不够realsize==该span的最大块
while (i--&& *(void**)end!=nullptr) {
//后续会把每个内存块的指针大小的空间放上下一个内存块的地址,这里默认已经完成
end = *(void**)end;
realsize++;
}
span->_freelist = *(void**)end;//只申请一个内存块的时候也适用(当span只有一个块的时候也成立)!
*(void**)end = nullptr;
_spanlists[index]._mutex.unlock();
return realsize;
}
💡GetOneSpan 设计思路
🎨对于GetOneSpan来说,会有两种情况:
自身对应的spanlist遍历一下,发下有非空span,直接进行返回即可。
自身全是空,就需要获得单例的PageCache对象去调用FetchSpan返回一个span进行切分后挂回去即可。
对于第一种情况:
直接利用我们之间搞得对应spanlist的迭代器进行遍历即可:
Span* it = sl.Begin();
//先查找当前list是否存在非空span(挂着的freelist不为空)
while (it != sl.End()) {
if (it->_freelist) return it;
it = it->_next;
}
这里如果当前spanlist没有非空span,也就是不用这个spanlist了,此时要么是去PageCache里面了,之后就是自己切块,因此此时要对CentralCache对应的spanlist进行解锁操作(因为后续可能会有线程进行从ThreadCache到CentralCache的内存归还)。
//此时要去对应的PageCache里面拿一页内存块然后进行分割:
//这个sl用不到了把锁解开,防止后续归还到这个list有内存块:
sl._mutex.unlock();
对于第二种情况:
- 就是需要向页缓存申请新的
span了(后面讲具体实现,这里假设可以得到新的span),这里由于页缓存也是临界资源故需要加锁(这里是整体锁不是桶锁,后面讲PageCache说明),此时我们就拿到了一个没有切换的span,根据对应的block_size进行切分:
这里从
PageCache里获得span里面被设置了起始页号pageid以及多少页n;这里先不用管(在设计PageCache里面内存块span的时候,页号pageid就是按照地址÷一页大小获得的,而n也是一直保持着这个span有多少页)
🎃因此先记住结论:
span的freelist的起始地址=起始页号pageid x 一页大小(8kb)
这段span里freelist的总内存大小= n x 一页大小(8kb)
从PageCache获得的span以及它里面挂着的大块span都是页的整数倍。
下面就开始切割成对应block_size大小的块:
采取尾插的形式进行切分:
首先确定好start end指针进行标记(按照上面的结论):
char* start = (char*)((span->_pageid) << PAGE_SIZE_index);
span->_freelist = start;
size_t bytes = (span->_n) << PAGE_SIZE_index;
char* end = start + bytes;
初始化准备:

-
第一次切割:
-

- 第二次切割:

- 最后一次切割(cur停止后对应的这块内存前4/8字节本身就是nullptr因此可以不用处理了):

⚠️注意: 这里是start<end(不是cur<end);否则就会出BUG咯
- 切割完后把对应的span头插到对应的spanlist然后返回这个span即可。
🛠️ 代码实现:
Span* CentralCache::GetOneSpan(SpanList& sl, size_t block_size) {
Span* it = sl.Begin();
//先查找当前list是否存在非空span(挂着的freelist不为空)
while (it != sl.End()) {
if (it->_freelist) return it;
it = it->_next;
}
//此时要去对应的PageCache里面拿一页内存块然后进行分割:
//这个sl用不到了把锁解开,防止后续归还到这个list有内存块:
sl._mutex.unlock();
//对于PageCache整体加锁(如果要是单个PageCache桶加锁,因为后面如果对应的页没有就要遍历PageCache的每个spanlist,此时又需要走一步加一个锁太麻烦,不如直接整体锁)
PageCache::get_instance()->_mutex.lock();
Span* span=PageCache::get_instance()->FetchSpan(Calculate::GetPageNum(block_size));
PageCache::get_instance()->_mutex.unlock();
//此时拿到一个整体的块进行切割:
char* start = (char*)((span->_pageid) << PAGE_SIZE_index);
span->_freelist = start;
size_t bytes = (span->_n) << PAGE_SIZE_index;
char* end = start + bytes;
char* cur = start;//保存头进行尾插
start += block_size;
//防止最后一块内促块不足block_size大小导致的内存非法访问!
while (start+block_size<end) {//这里是start不是cur;当cur到达最后一块,此时start到达最后一块的后面,此时最后一块不用填地址了默认就是nullptr
*(void**)cur = start;
cur = start;
start += block_size;
}
//完成切割:
sl._mutex.lock();//插回对它的CentralCache的spanlist里
sl.PushFront(span);
return span;//直接给它,让CentralCache的该list用这块span的下面的内存块进行切取给对应的threadcache(外加拿出一块返回用)
}
到这里可以发现个
对于ThreadCache以及CentralCache都有个特点:比如自身这里没有对应块内存了,就会在自身的申请函数里面搞一个上级类对象的单例(这个单例进行自己调用自己的类函数返回对应的内存块)。
四·📝如何设计PageCache
对于PageCache它的构成和之前的CentralCache以及ThreadCache都不同,尽管它是spanlist的数组,而这个数组是按照页大小分布来分配对于的spanlist的,每个spanlist中只有span,每个span都是对应这个数组下标的页大小。

🚀PageCache结构
这里就是对应的spanlist的数组,只不过大小是129(因为下标0-128都要有),然后暂时就先提供一个从其中获得对应大块span的接口函数。
其次就是这里也是单例模式(因为共享的同一个页缓存),其次就是这里搞的是整体锁。(因为对PageCache操作的逻辑是先去对应页大小的spanlist去找看是否有非空span,没有就去往后遍历这个数组找到非空进行大span切分操作,如果选择桶锁的话进去一次就要加一次锁然后出来再解锁,比较麻烦,倒不如直接搞成整体锁来只允许单线程访问这个PageCache)。
这里就验证了为什么span里面要有n(为了span分两块进行挂接的时候方便分割)以及pageid(为了进行大块span切分的时候能通过这种映射关系拿到对应的内存的首地址)。
类结构如下:
class PageCache {
public:
std::mutex _mutex;// 直接声明即可,无需显式初始化
PageCache(){}
PageCache(const PageCache &x) = delete;
PageCache operator=(const PageCache) = delete;
static PageCache* get_instance() {
return &_instance;
}
Span* FetchSpan(size_t page);
private:
SpanList _spanlists[MAX_PAGE + 1];
static PageCache _instance;
};
五. 💡PageCache相关接口设计
FetchSpan设计思路
函数工作流程:
通过对应的计算判断是要申请的总大小是多少页,然后去对应的页查找非空span,如果有就直接返回。
首先根据对应的CentralCache里面多大内存块来根据GetBlockNum确定是多少块,然后换算成页:
//根据对应的block-size确定对应的多少块计算出总的大小所匹配的页大小然后方便后面去PageCache申请!
static inline size_t GetPageNum(size_t real_size) {
size_t num= GetBlockNum(real_size);
size_t n = num * real_size;
size_t pagenum= n >> PAGE_SIZE_index;
if (pagenum == 0) pagenum = 1;
return pagenum;
}
- 之后按照这个页进行从
PageCache的对应页的spanlist开始查找了:
assert(page > 0 && page <= MAX_PAGE);
//判断当前页号为page对应的spanlist是否有非空span
if (!_spanlists[page].Empty()) return _spanlists[page].PopFront();
- 如果当前页无非空span,就往下遍历找到非空span进行切割再挂到对应的两个页大小的spanlist里面去。
- 这里找到
非空span需要进行切分成两个span分别对对应页的spanlist进行头插挂进去(需要更改的span里变量只有n,pageid),切分的时候注意操作即可。
//当前没有往后走找然后进行分割出page大小的和n-page大小的页然后挂到对应位置
//进行大页span切割(放回对应需要page的list里,另一块放回对应n-page里):
for (int i = page + 1; i <= MAX_PAGE; i++) {
if (!_spanlists[i].Empty()) {
Span* nspan=_spanlists[i].PopFront();
Span* newspan = new Span();
newspan->_pageid = nspan->_pageid;
newspan->_n = page;
nspan->_pageid += page;
nspan->_n -= page;
_spanlists[nspan->_n].PushFront( nspan);
return newspan;
}
}
六 .⏳测试从ThreadCache再到PageCache整个申请内存过程
- 调试准备工作:


- 先进行单线程去从ThreadCache申请对应内存块的调试 ,观察从 ThreadCache 一直到PageCache这个流程是否存在bug。

- 这里先申请6个也就是8字节块。

- 可以看到是对应的下标为0号的哈希桶。

- 通过慢开始调节算法得到第一次线程缓存无内存块,故向中心缓存要1块8字节内存块,然后用给出去用,自己线程缓存对应桶挂的最后还是空。

- 这里直接先跳过向页缓存申请128页并切成127页+1页的块;最后拿到1页的块的span。

发现对应的pageid和对应的大块内存首地址对应关系一致,并且拿到的这个span是8kb,即一页的块;则证明对于PageCache获得对应页的大块span这个函数是成立的,依次上面直接跳过是成立的。


- 这里可以发现对于32位机器 地址是4字节,把这个大的span分成8字节8字节的,然后每块前四字节保存下一块的首地址 发现这个切分方法是成立的。

对于后面挂到CentralCache的链,以及ThreadCache再从CentralCache要内存块的过程也是没问题的(理论上申请的这些块都应该是8字节连续的)。

- 发现最后测试的连续的8 字节以下的内存 返回的地址也是8字节连续的,与程序测试理论值相同,因此这块1页的被使用是没有问题的。
下面测试一下对那127页切分是否有问题,以及多次申请地址是否一直连续:



- 发现申请这1024次八字节的内存块首地址也是连续的,即就是PageCache分出的那页;然后最首地址和申请完1024再次申请,对那127页切出来的第一页来用的首地址之间的差值正是8kb;因此整个申请内存块的逻辑是没有大问题的。
测试部分代码:
//测试除了PageCache到k+1-128找非空span的其他功能
void test1() {
void* p1 = ConcurrentAlloc(6);
void* p2 = ConcurrentAlloc(8);
void* p3 = ConcurrentAlloc(1);
void* p4 = ConcurrentAlloc(7);
void* p5 = ConcurrentAlloc(8);
cout << p1 << endl;
cout << p2 << endl;
cout << p3 << endl;
cout << p4 << endl;
cout << p5 << endl;
}
void test2() {//测试PageCache到k+1-128找非空span
for (size_t i = 0; i < 1024; ++i)
{
void* p1 = ConcurrentAlloc(6);
cout << p1 << endl;
}
void* p2 = ConcurrentAlloc(8);
cout << p2 << endl;
}
int main() {
//test1();
test2();
}
总之,整个申请内存的过程,按照设计的思路来讲暂时是没有大问题的。
八.🎉 本篇小结
本篇文章完成了整个申请内存的过程,也是通过介绍的这些函数接口的实现方案,最后测试也是没有出现问题,但是自己实现过程是非常久的(虽然不算难),比如对应的页号值写成n了,这俩货互换了,还有比如判空函数的失误等等,不该犯的错误导致最终测试的时候找bug直接干了N多个小时,总结就是:仔细仔细再仔细,你的疏漏会在最后给你沉痛的以击!
AI大模型学习福利
作为一名热心肠的互联网老兵,我决定把宝贵的AI知识分享给大家。 至于能学习到多少就看你的学习毅力和能力了 。我已将重要的AI大模型资料包括AI大模型入门学习思维导图、精品AI大模型学习书籍手册、视频教程、实战学习等录播视频免费分享出来。
一、全套AGI大模型学习路线
AI大模型时代的学习之旅:从基础到前沿,掌握人工智能的核心技能!

因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获取
二、640套AI大模型报告合集
这套包含640份报告的合集,涵盖了AI大模型的理论研究、技术实现、行业应用等多个方面。无论您是科研人员、工程师,还是对AI大模型感兴趣的爱好者,这套报告合集都将为您提供宝贵的信息和启示。

因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获
三、AI大模型经典PDF籍
随着人工智能技术的飞速发展,AI大模型已经成为了当今科技领域的一大热点。这些大型预训练模型,如GPT-3、BERT、XLNet等,以其强大的语言理解和生成能力,正在改变我们对人工智能的认识。 那以下这些PDF籍就是非常不错的学习资源。

因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获
四、AI大模型商业化落地方案

因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获
作为普通人,入局大模型时代需要持续学习和实践,不断提高自己的技能和认知水平,同时也需要有责任感和伦理意识,为人工智能的健康发展贡献力量
534

被折叠的 条评论
为什么被折叠?



