【从零实现高并发内存池】Central Cache从理解设计到全面实现

📢博客主页:https://blog.csdn.net/2301_779549673

📢博客仓库:https://gitee.com/JohnKingW/linux_test/tree/master/lesson

📢欢迎点赞 👍 收藏 ⭐留言 📝 如有错误敬请指正!

📢本文由 JohnKi 原创,首发于 CSDN🙉

📢未来很长,值得我们全力奔赴更美好的生活✨

文章目录

  • [🏳️‍🌈一、central cache](#🏳️‍🌈一、central cache)
    • [1.1 整体设计](#1.1 整体设计)
    • [1.2 不同之处](#1.2 不同之处)
    • [1.3 结构设计](#1.3 结构设计)
      • [1.3.1 Span 类](#1.3.1 Span 类)
      • [1.3.2 SpanList 类](#1.3.2 SpanList 类)
    • [1.4 Central Cache 类](#1.4 Central Cache 类)
      • [1.4.1 单例模式](#1.4.1 单例模式)
      • [1.4.2 慢开始反馈调节算法](#1.4.2 慢开始反馈调节算法)
      • [1.4.3 从中心缓存获取对象](#1.4.3 从中心缓存获取对象)
  • 👥总结

🏳️‍🌈一、central cache

1.1 整体设计

当线程申请某一大小的内存时,如果thread cache中对应的自由链表不为空,那么直接取出一个内存块进行返回即可,但如果此时该自由链表为空,那么这时thread cache就需要向central cache申请内存了。
./,

central cache 的结构与 thread cache 是相同的,都是哈希桶结构 ,并且它们遵循同样的的哈希桶对齐映射规则。这样的好处是:当thread cache的某个桶中没有内存了,就可以直接到central cache申请内存。

1.2 不同之处

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

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

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

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

1.3 结构设计

1.3.1 Span 类

在内存池设计中,Span 是 ​管理大块内存的核心数据结构它连接了中心缓存(Central Cache)与页堆(Page Heap),承担着内存块的分割、分配与回收的核心职责。

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

但是,这样也会有一个难题,就是 页号的类型应该设置成什么呢?

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


页大小:操作系统默认页大小通常为 ​4KB​(可配置为 2MB/1GB 大页)。

页号计算公式​:

bash 复制代码
PAGE_ID = Physical_Address >> Page_Shift;

例如:物理地址 0x12345000,页大小为 4KB(Page_Shift=12),则页号 _PAGEID = 0x12345

若物理地址超过 32 位范围(如 0x2000000000),X86 的 uint32_t 会溢出,导致页号计算错误。

X86:32 位页号足够处理 4GB 内存的连续性检测。
​X64:必须使用 64 位页号,否则大内存场景(如 128GB)会出现错误合并或无法合并。

所以我们可以通过 预编译指令 动态选择数据类型

bash 复制代码
#ifdef _WIN64
	typedef unsigned long long PAGE_ID;
#elif _WIN32
	typedef size_t PAGE_ID;
#endif

这里需要注意的是 先判断是否是 _WIN64,在判断是否是 _WIN32

原因在于

  • 在 64 位 Windows 环境下,会先检查 _WIN32,由于 _WIN32 总是被定义,条件 #ifdef _WIN32 会直接成立

Span 类结构

bash 复制代码
struct Span {
	PAGE_ID _PAGEID = 0;	// 大块内存起始页的页号
	size_t _n = 0;			// 页的数量

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

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

_PAGEID标识内存块在操作系统分页系统中的起始位置。

例如:_PAGEID=100 表示该内存块从第100页开始(每页大小通常为4KB或8KB)。
_n表示该内存块包含的连续页数,用于计算总大小(总大小 = _n * 页大小)。
_next 与 _prev双向链表结构。将多个 Span 组织成 ​双向链表,用于中心缓存(Central Cache)管理不同大小的内存块。

例如:Central Cache 中可能存在多个链表,每个链表管理相同页数(_n)的 Span。
_userCount跟踪当前 Span 中有多少个小块内存被分配给线程缓存(Thread Cache)。

当 _userCount == 0 时,表示所有内存已归还,Span 可被回收至页堆。
_freeList维护未分配的小块内存链表,供 Thread Cache 快速分配。

链表节点通过 NextObj(obj) 宏(即 (void*)obj)连接

1.3.2 SpanList 类

我们已经说了 每个Span都是一个双向链表,所以我们需要对他进行一个封装。

这个类成员需要有一个头节点(哨兵位),一个桶锁并有增删、判空等功能

构造函数

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

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

插入

bash 复制代码
		void Insert(Span* pos, Span* newSpan) {
			assert(pos);
			assert(newSpan);

			Span* prev = pos->_prev;
			prev->_next = newSpan;
			newSpan->_prev = prev;
			newSpan->_next = pos;
			pos->_prev = newSpan;
		}

删除

注意:从双链表删除的span会还给下一层的page cache,相当于只是把这个span从双链表中移除,因此不需要对删除的span进行delete操作。

bash 复制代码
		void Erase(Span* pos) {
			assert(pos);
			// 不能删除哨兵位
			assert(pos != _head);
			// 删除 pos 结点
			Span* prev = pos->_prev;
			Span* next = pos->_next;
			prev->_next = next;
			next->_prev = prev;
		}

1.4 Central Cache 类

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

1.4.1 单例模式

1、为了保证 CentralCache类 只能创建一个对象,我们需要将 central cache 的 构造函数 和 拷贝构造函数 设置为私有(C++98方法),或者在C++11中也可以在函数声明的后面加上=delete进行修饰。

2、CentralCache类当中还需要有一个CentralCache类型的静态的成员变量,**程序运行起来后我们就立马创建该对象(类外创建),在此后的程序中就只有这一个单例了。**当

bash 复制代码
// 单例模式
class CentralCache {
	public:
		static CentralCache* GetInstance() {
			return &_sInst;
		}

	private:
		SpanList _spanLists[NFREELIST];

	private:
		// C++98,构造函数私有
		CentralCache(){}
		// C++11,禁止拷贝
		CentralCache(const CentralCache&) = delete;

		static CentralCache _sInst;
};

1.4.2 慢开始反馈调节算法

thread cachecentral cache 申请内存时,central cache应该给出多少个对象呢?
如果central cache给的太少,那么thread cache在短时间内用完了又会来申请;但如果一次性给的太多了,可能thread cache用不完也就浪费了。

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

慢开始反馈调节算法

  1. 最开始不会一次向 central cache 一次批量要太多,因为要太多了可能用不完
  2. 如果你不要这个 size 大小内存需求,那么 batchNum 就会不断增长,知道上线
  3. size 也越多,一次向 central cache 要的内存就越少 512
  4. size 也越少,一次向 central cache 要的内存就越多 2
bash 复制代码
		// 一次 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。

bash 复制代码
class FreeList {
	public:
		// ...
	private:
		void* _freeList = nullptr;
		size_t _maxSize = 1;
};

1.4.3 从中心缓存获取对象

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

bash 复制代码
void* ThreadCache::FetchFromCentralCache(size_t index, size_t size) {
	 // 慢开始反馈调节算法
	 // 1. 最开始不会一次向 central cache 一次批量要太多,因为要太多了可能用不完
	 // 2. 如果你不要这个 size 大小内存需求,那么 batchNum 就会不断增长,知道上线
	 // 3. size 也越多,一次向 central cache 要的内存就越少 512
	 // 4. size 也越少,一次向 central cache 要的内存就越多 2
	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 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(NextObj(start), end);
		return start;
	}

	return nullptr;
}

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

bash 复制代码
// 从中心缓冲获取一定数量的对象给 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 = 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 && NextObj(end) != nullptr) {
		end = NextObj(end);
		++i;
		++actualNum;
	}
	span->_freeList = NextObj(end);
	NextObj(end) = nullptr;
	span->_userCount += actualNum;

	_spanLists[index]._mtx.unlock();

	return actualNum;
}  

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


👥总结

本篇博文对 【从零实现高并发内存池】Central Cache从理解设计到全面实现 做了一个较为详细的介绍,不知道对你有没有帮助呢

觉得博主写得还不错的三连支持下吧!会继续努力的~

相关推荐
光电大美美-见合八方中国芯11 分钟前
【平面波导外腔激光器专题系列】1064nm单纵模平面波导外腔激光器‌
网络·数据库·人工智能·算法·平面·性能优化
洋芋爱吃芋头36 分钟前
spark缓存-persist
大数据·缓存·spark
时序数据说38 分钟前
通过Linux系统服务管理IoTDB集群的高效方法
大数据·linux·运维·数据库·开源·时序数据库·iotdb
TE-茶叶蛋1 小时前
HTTP请求与缓存、页面渲染全流程
网络协议·http·缓存
CopyLower1 小时前
解决 Redis 缓存与数据库一致性问题的技术指南
数据库·redis·缓存
多多*1 小时前
分布式ID设计 数据库主键自增
数据库·sql·算法·http·leetcode·oracle
我爱夜来香A1 小时前
SQL进阶:如何把字段中的键值对转为JSON格式?
数据库·sql·json
micromicrofat1 小时前
mongodb升级、改单节点模式
数据库·mongodb
爱编程的王小美2 小时前
本地MySQL连接hive
数据库·hive·mysql
珹洺2 小时前
数据库系统概论(七)初识SQL与SQL基本概念
数据库·sql