【C++高并发内存池篇】内存闪电战:单线程申请的纳秒级缓存穿透战术,从 Thread Cache 到物理页的闪电战兵法!

📚本篇摘要
本篇文章将衔接上篇线程向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();

  1. 如果当前页无非空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大模型商业化落地方案

因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获

作为普通人,入局大模型时代需要持续学习和实践,不断提高自己的技能和认知水平,同时也需要有责任感和伦理意识,为人工智能的健康发展贡献力量

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值