【高并发内存池】第三弹---构建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;     // 更新起点
}
相关推荐
求梦8203 分钟前
【力扣hot100题】反转链表(18)
算法·leetcode·职场和发展
智航GIS15 分钟前
10.7 pyspider 库入门
开发语言·前端·python
NAGNIP15 分钟前
机器学习特征工程中的特征选择
算法·面试
跟着珅聪学java20 分钟前
JavaScript 底层原理
java·开发语言
l1t21 分钟前
DeepSeek辅助编写的利用位掩码填充唯一候选数方法求解数独SQL
数据库·sql·算法·postgresql
项目題供诗24 分钟前
C语言基础(二)
c语言·开发语言
Z1Jxxx25 分钟前
反序数反序数
数据结构·c++·算法
副露のmagic26 分钟前
更弱智的算法学习 day25
python·学习·算法
求梦82027 分钟前
【力扣hot100题】移动零(1)
算法·leetcode·职场和发展
J_liaty29 分钟前
RabbitMQ面试题终极指南
开发语言·后端·面试·rabbitmq