【高并发内存池】第三弹---构建Central Cache的全方位指南——从整体设计到核心实现

✨个人主页:熬夜学编程的小林

💗系列专栏: 【C语言详解】 【数据结构详解】【C++详解】【Linux系统编程】【Linux网络编程】【项目详解】

目录

[1、central cache](#1、central cache)

1.1、整体设计

1.2、结构设计

1.2.1、Span类

1.2.2、SpanList类

1.2.3、CentralCache类

1.3、核心实现

1.3.1、实现单例模式

1.3.2、慢开始反馈调节算法

1.3.3、从中心缓存获取对象


1、central cache

1.1、整体设计

  • 当线程申请某一大小的内存时,如果thread cache中对应的自由链表不为空,那么直接取出一个内存块进行返回即可,但如果此时该自由链表为空,那么这时thread cache就需要向central cache申请内存了
  • central cache的结构与thread cache 是相同的,都是哈希桶结构,并且它们遵循同样的的哈希桶对齐映射规则 。这样的好处是:当thread cache的某个桶中没有内存了,就可以直接到central cache申请内存

thread cache 与 central cache不同点

thread cache 与 central cache 有两个明显不同的地方。

1、thread cache是每个线程独享的,而central cache是所有线程共享的,因为每个线程的thread cache没有内存了都会去找central cache,因此在访问central cache时是需要加锁的。

  • central cache在加锁时并不是将整个central cache全部锁上了,central cache在加锁时用的是桶锁,也就是说每个桶都有一个锁。此时只有当多个线程同时访问central cache的同一个桶时才会存在锁竞争,如果是多个线程同时访问central cache的不同桶就不会存在锁竞争。

**2、thread cache的每个桶中挂的是一个个切好的内存块,而central cache的每个桶中挂的是一个个的span,**每个映射桶下面的span中的大内存块被按映射关系切成了一个个小内存块对象挂在span的自由链表中

每个span管理的都是一个以页为单位的大块内存,每个桶里面的若干span是按照双链表的形式链接起来的,并且每个span里面还有一个自由链表,这个自由链表里面挂的就是一个个切好了的内存块,根据其所在的哈希桶这些内存块被切成了对应的大小。

1.2、结构设计

central cache的结构包括Span类,Span类型的双链表以及CentralCache类

1.2.1、Span类

Span类成员变量 包括页号,页的数量,双向链表结构,切好的小块内存分配给thread cache的引用技术和切好的小块内存的自由链表

此处页号的类型应该设置成什么呢?

1、每个程序运行起来后都有自己的进程地址空间,在32位平台下,进程地址空间的大小是2^32;而在64位平台下,进程地址空间的大小就是2^64。

2、页的大小一般是4K或者8K,我们以8K为例。在32位平台下,进程地址空间就可以被分成 2^32 / 2^13 = 2^19 个页;在64位平台下,进程地址空间就可以被分成 2^64 / 2^13 = 2^51 个页。页号本质与地址是一样的,它们都是一个编号,只不过地址是以一个字节为一个单位,而页是以多个字节为一个单位。

3、由于页号在64位平台下的取值范围是 [0,2^51),因此我们不能简单的用一个无符号整型来存储页号 ,这时我们需要借助条件编译来解决这个问题

复制代码
// _WIN64中定义了_WIN32 和 _WIN64
#ifdef _WIN64
    typedef unsigned long long PAGE_ID;
#elif _WIN32
    typedef size_t PAGE_ID;
#else
	// Linux
#endif
  • 注意:在32位下,_WIN32有定义,_WIN64没有定义;而在64位下,_WIN32和_WIN64都有定义。因此在条件编译时,我们应该先判断_WIN64是否有定义,再判断_WIN32是否有定义

Span类结构

复制代码
// 管理多个连续页大块内存跨度结构
struct Span
{
	PAGE_ID _pageID = 0; // 大块内存起始页的页号
	size_t _n = 0; // 页的数量

	Span* _next = nullptr; // 双向链表结构
	Span* _prev = nullptr;

	size_t _useCount = 0; // 切好的小内存块,被分配给thread cache的计数
	void* _freeList = nullptr; // 切好的小块内存的自由链表
};

1、对于span管理的以页为单位的大块内存,我们需要知道这块内存具体在哪一个位置,便于之后page cache进行前后页的合并,因此span结构当中会记录所管理大块内存起始页的页号

2、至于每一个span管理的到底是多少个页,这并不是固定的,需要根据多方面的因素来控制,因此span结构当中有一个_n成员,该成员就代表着该span管理的页的数量

3、每个桶当中的span是以双链表的形式组织起来 的,当我们需要将某个span归还给page cache时,就可以很方便的将该span从双链表结构中移出。如果用单链表结构的话就比较麻烦了,因为单链表在删除时,需要知道当前结点的前一个结点。

4、每个span管理的大块内存,都会被切成相应大小的内存块挂到当前span的自由链表中,比如8Byte哈希桶中的span,会被切成一个个8Byte大小的内存块挂到当前span的自由链表中,因此span结构中需要存储切好的小块内存的自由链表

5、span结构当中的**_useCount** 成员记录的就是,当前span中切好的小块内存,被分配给thread cache的计数当某个span的_useCount计数变为0时,代表当前span切出去的内存块对象全部还回来了,此时central cache就可以将这个span再还给page cache。

1.2.2、SpanList类

根据上面的描述,central cache的每个哈希桶里面存储的都是一个双链表结构 ,对于该双链表结构我们可以对其进行封装

SpanList类成员变量包含一个Span类型的头指针和桶锁成员函数暂时只实现构造,插入和删除函数

基本结构

复制代码
// 管理Span的链表结构
class SpanList
{
public:
	SpanList();
	// 在pos之前插入newSpan
	void Insert(Span* pos,Span* newSpan);
	// 删除pos位置
	void Erase(Span* pos);
private:
	Span* _head;     // 双向链表头结点指针
public: 
	std::mutex _mtx; // 桶锁,方便类外访问
};

构造函数

构造函数创建头结点,并设置成双向循环链表结构。

复制代码
SpanList()
{
	_head = new Span;
	_head->_prev = _head;
	_head->_next = _head;
}

插入函数

复制代码
// 在pos之前插入newSpan
void Insert(Span* pos,Span* newSpan)
{
	assert(pos);
	assert(newSpan);
	Span* prev = pos->_prev;
	// prev newSpan pos
	prev->_next = newSpan;
	newSpan->_prev = prev;
	newSpan->_next = pos;
	pos->_prev = newSpan;
}

删除函数

复制代码
// 删除pos位置
void Erase(Span* pos)
{
	assert(pos);
	// 不能删除哨兵位
	assert(pos != _head);
	Span* prev = pos->_prev;
	Span* next = pos->_next;
	// prev next
	prev->_next = next;
	next->_prev = prev;
}
  • 注意:从双链表删除的span会还给下一层的page cache,相当于只是把这个span从双链表中移除,因此不需要对删除的span进行delete操作。

1.2.3、CentralCache类

central cache的映射规则和thread cache是一样的,因此central cache里面哈希桶的个数也是208 ,但central cache每个哈希桶中存储就是我们上面定义的双链表结构

复制代码
class CentralCache
{
public:
    // ...
private:
	SpanList _spanLists[NFREELIST];
};
  • 后续需要再增加成员函数即可。

1.3、核心实现

1.3.1、实现单例模式

  • 1、每个线程都有一个属于自己的thread cache ,我们是用TLS来实现每个线程无锁的访问属于自己的thread cache的。而central cache和page cache在整个进程中只有一个 ,对于这种只能创建一个对象的类,我们可以将其设置为单例模式
  • 2、单例模式可以保证系统中该类只有一个实例,并提供一个访问它的全局访问点,该实例被所有程序模块共享。单例模式又分为饿汉模式和懒汉模式,懒汉模式相对较复杂,我们这里使用饿汉模式就足够了。
复制代码
// 单例模式(饿汉模式)
class CentralCache
{
public:
	// 提供一个全局访问点
	static CentralCache* GetInstance()
	{
		return &_sInit;
	}
private:
	SpanList _spanLists[NFREELIST];
private:
	// C++98,构造函数私有
	CentralCache()
	{}
	// C++11,禁止拷贝
	CentralCache(const CentralCache&) = delete;

	static CentralCache _sInst;
};
  • 1、为了保证CentralCache类只能创建一个对象 ,我们需要将central cache的构造函数和拷贝构造函数设置为私有(C++98方法),或者在C++11中也可以在函数声明的后面加上=delete进行修饰
  • 2、CentralCache类当中还需要有一个CentralCache类型的静态的成员变量当程序运行起来后我们就立马创建该对象(类外创建),在此后的程序中就只有这一个单例了。
复制代码
// 类外初始化静态成员
CentralCache CentralCache::_sInst;

3、central cache还需要提供一个公有的成员函数(GetInstance) ,用于获取该对象,此时在整个进程中就只会有一个central cache对象了。

1.3.2、慢开始反馈调节算法

当thread cache向central cache申请内存时,central cache应该给出多少个对象呢?这是一个值得思考的问题,如果central cache给的太少,那么thread cache在短时间内用完了又会来申请;但如果一次性给的太多了,可能thread cache用不完也就浪费了

根据上面的分析,我们在这里采用一个慢开始反馈调节算法。当thread cache向central cache申请内存时,如果申请的是较小的对象,那么可以多给一点,但是如果申请的是较大的对象,就可以少给一点****(类似网络tcp协议拥塞控制的慢开始算法)

通过下面这个函数,我们就可以根据所需申请的对象的大小计算处具体给出的对象个数,并且可以将给出的对象个数控制到2~512个。换就话说,就算thread cache申请的对象再小,我最多一次性给出512个对象; 就算thread cache申请的对象再大,我至少一次性给出2个对象

复制代码
class SizeClass
{
public:	
    // 一次thread cache从中心缓存获取多少个
	static size_t NumMoveSize(size_t size)
	{
		assert(size > 0);

		// [2, 512],一次批量移动多少个对象的(慢启动)上限值
		// 小对象一次批量上限高
		// 小对象一次批量上限低
		int num = MAX_BYTES / size;
		if (num < 2)
			num = 2;

		if (num > 512)
			num = 512;

		return num;
	}
};

注意:该函数封装在SizeClass类中,且为静态成员函数,无需创建对象调用。

上面的函数确实可以让申请对象合理化,但是就算申请的是小对象,一次性给出512个也是比较多的,基于这个原因,我们可以在FreeList这个类中增加一个_maxSize的成员变量,该变量初始值设置为1,并且提供一个公有成员函数用于获取这个变量(返回值需要使用引用,后序需要修改)。也就是说thread cache的每个自由链表都有自己的_maxSize。

复制代码
// 管理切分好的小块对象的自由链表
class FreeList
{
public:
    // ...
	size_t& MaxSize()
	{
		return _maxSize;
	}

private:
	void* _freeList = nullptr;
	size_t _maxSize = 1;
};

此处当thread cache申请对象时,我们会比较_maxSize和计算得出的值取出其中的较小值作为申请对象的个数。此外,如果使用的是_maxSize的值,需要将_maxSize的值+1(也就是前面返回值使用引用的原因)

因此,thread cache第一次向central cache申请某大小的对象时,申请到的都是一个,但下一次thread cache再向central cache申请同样大小的对象时,因为该自由链表中的_maxSize增加了,最终就会申请到两个直到该自由链表中_maxSize的值,增长到超过计算出的值后就不会继续增长了,此后申请到的对象个数就是计算出的个数(类似网络tcp协议拥塞控制的慢开始算法)

1.3.3、从中心缓存获取对象

每次thread cache向central cache申请对象时,我们先通过慢开始反馈调节算法计算本次应该申请的对象个数,然后再向central cache进行申请。

  • 如果thread cache最终申请到的对象个数就是一个,那么直接将该对象返回即可
  • 如果thread cache最终申请到的对象时多个,那么除了将第一个对象返回之外,还需要将剩下的对象挂接到thread cache对应的哈希桶当中
复制代码
// 从中心缓存获取对象
void* ThreadCache::FetchFromCentralCache(size_t index, size_t size)
{
	// 慢开始反馈调节算法
	// 1、最开始不会一次向central cache一次批量要太多,因为要太多可能用不完
	// 2、如果你不要这个size大小内存需求,那么batchNum就会不断增长,直到上限
	// 3、size越大,一次向 central cache 要的batchNum就越小
	// 4、size越小,一次向 central cache 要的batchNum就越大
	// 对第四点进行优化:取_maxSize与batchNum较小值,如果_maxSize较小需要每次+1
	size_t batchNum = std::min(_freeLists[index].MaxSize(), SizeClass::NumMoveSize(size));
	if (_freeLists[index].MaxSize() == batchNum)
	{
		_freeLists[index].MaxSize() += 1;
	}
	void* start = nullptr;
	void* end = nullptr;
	// size_t FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size);
	size_t actualNum = CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, size);
	assert(actualNum >= 1); // 至少得有一个

	// 申请到对象的个数是一个,则直接将这一个对象返回即可
	if (actualNum == 1)
	{
		assert(start == end);
		return start;
	}
	// 申请到对象的个数是多个,还需要将剩下的对象挂到thread cache中对应的哈希桶中
	else
	{
		_freeLists[index].PushRange(Next(start),end);
		return start;
	}
}

从中心缓存获取一定数量的对象

该函数要从central cache获取n个指定大小的对象,这些对象肯定是从central cache对应的哈希桶的某个span中取出来的,因此取出来的n个对象是链接在一起的,我们只需要得到这段自由链表的头和尾即可,此处采用输出型参数进行获取

复制代码
// 从中心缓存获取一定数量的对象给thread cache
size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size)
{
	size_t index = SizeClass::Index(size);
	// 上锁
	_spanLists[index]._mtx.lock();

	// 获取一个非空的Span
	Span* span = GetOneSpan(_spanLists[index], size);
	assert(span);
	assert(span->_freeList);

	// 从span中获取batchNum个对象
	// 如果不够batchNum个,有多少拿多少
	start = span->_freeList;
	end = start;
	size_t i = 0;
	size_t actualNum = 1;
	while (i < batchNum - 1 && Next(end) != nullptr)
	{
		end = Next(end);
		++i;
		++actualNum;
	}
	span->_freeList = Next(end);
	Next(end) = nullptr;

	// 取消锁
	_spanLists[index]._mtx.unlock();

	return actualNum;
}

1、由于central cache是所有线程共享的,所以我们在访问central cache中的哈希桶时,需要先给对应的哈希桶加上桶锁在获取到对象后再将桶锁解除

2、在向central cache获取对象时,先是在central cache对应的哈希桶中获取到一个非空的span对象,然后从这个sapn的自由链表中取出n个指定大小的对象 ,但是可能这个非空span的自由链表当中对象不足n个,这时该自由链表当中有多少个对象给多少即可

  • 换句话说,thread cache实际从central cache获得的对象个数可能与我们传入的n的值不一样,因此我们需要统计本次申请过程中,central cache实际获取到的对象个数,将该值作为返回值进行返回
  • 注意:虽然我们实际申请到的对象个数可能比n要小,但是这不会产生任何影响。因为thread cache的本意就是向central cache申请一个对象,我们之所以要一次多申请一些对象,是因为这么操作,下次线程再申请相同大小的对象时就可以直接在thread cache里面获取了,而不需再向central cache申请象。

获取一个非空的span

获取一个非空的span函数暂时先直接返回nullptr即可,后序再实现。

复制代码
// 获取一个非空的span
Span* CentralCache::GetOneSpan(SpanList& list, size_t size)
{
	// ...
	return nullptr;
}

插入一段范围的对象到自由链表

如果thread cache最终从central cache获取到的对象个数是大于1个,那么我们还需要将剩下的对象插入到thread cache中对应的哈希桶中,为了能让自由链表支持插入一段范围的对象,我们还需要在FreeList类中增加一个对应的成员函数(此处使用头插即可)

复制代码
// 头插一段范围的对象到自由链表
void PushRange(void* start, void* end)
{
	assert(start);
	assert(end);

	Next(end) = _freeList; // 链接成循环
	_freeList = start;     // 更新起点
}
相关推荐
梭七y4 分钟前
leetcode日记(105)买卖股票的最佳时机Ⅱ
算法·leetcode·职场和发展
为什么要内卷,摆烂不香吗12 分钟前
【无标题】
开发语言·php
江沉晚呤时17 分钟前
深入解析 C# 中的装饰器模式(Decorator Pattern)
java·开发语言·javascript·jvm·microsoft·.netcore
爱编程的小赵33 分钟前
第十五届蓝桥杯C/C++组:宝石组合题目(从小学奥数到编程题详解)
c语言·c++·蓝桥杯
graceyun36 分钟前
初阶数据结构(C语言实现)——6.1插入排序详解(思路图解+代码实现)
c语言·数据结构·排序算法
mljy.41 分钟前
C++《红黑树》
c++
MiyamiKK5743 分钟前
leetcode_双指针 11. 盛最多水的容器
python·算法·leetcode·职场和发展
努力的飛杨1 小时前
学习记录-js进阶-性能优化
开发语言·javascript·学习
不去幼儿园1 小时前
【强化学习】Reward Model(奖励模型)详细介绍
人工智能·算法·机器学习·自然语言处理·强化学习
Vacant Seat1 小时前
回溯-单词搜索
java·数据结构·算法·回溯