【C++项目之高并发内存池 (五)】一些小细节和性能优化及整体测试

⭐️在这个怀疑的年代,我们依然需要信仰。

个人主页 :YYYing.

⭐️高并发内存池项目专栏:C++项目之高并发内存池

系列上期内存:【高并发内存池 (四)】三层缓存的空间回收流程详解

系列下期内容:暂无


目录

前言:

[📖 需要强调的细节](#📖 需要强调的细节)

1、申请和释放大于256KB的空间

申请流程

释放流程

大空间申请测试

2、使用定长内存池替换new

3、释放时不传对象大小

4、调用MapObjToSpan的时候加锁

[📖 优化前的性能测试](#📖 优化前的性能测试)

[📖 使用基数树对内存池进行性能优化](#📖 使用基数树对内存池进行性能优化)

1、单层基数树

2、两层基数树

3、三层基数树

[📖 优化后代码与性能测试](#📖 优化后代码与性能测试)

1、优化代码

2、优化后性能测试

[📖 结语](#📖 结语)

---⭐️封面自取⭐️---



前言:

那么这一篇讲完后这个项目差不多就完结了,此篇我们讲解一些项目中剩下的细节与性能优化,如果不优化的话,碰上较大数据量的效率会非常低劣,本篇最后将会奉上此项目的gitee仓库。

📖 需要强调的细节

1、申请和释放大于256KB的空间

· 我们之前讲的申请与释放都是基于单次申请和释放空间不超过256KB,但实际上是一定会有单次申请大于256KB的情况的,那么此时我们又该如何解决呢?

申请流程

回忆一下,我们pc中的span最大是不是128页,假设一页现在是8KB,那么pc中最大的span管理的空间就是128 * 8KB = 1024KB,也就是最大的span可以管理1M的空间。

既然tc中单次申请空间不能超过256KB,那么超过256KB的内存就不要向tc申请了,我们不妨直接去向pc申请,只要单次申请的空间在(256KB, 1024KB]之间的,就可以直接向pc要,pc对于这些空间是可以管够的。

那如果连1M也超过了呢?简单,那就直接向OS去申请,不过也需要经过一下pc,pc也要对要到的空间进行管理。

但不管是申请多少页,我们都要对齐到一个完整的块大小才能向下层申请,这里超过256KB的空间也是,那么就要修改一下前面的对齐中的规则,如果size大于了256KB,那就直接按照页大小来对齐。

在一开始申请空间的时候,直接在ConcurrentAlloc特判:

cpp 复制代码
// 其实就是tcmalloc,线程调用这个函数申请空间
static void* ConcurrentAlloc(size_t size)
{
	// 如果申请空间超过256KB,就直接找下层的去要
	if (size > MAX_BYTES)
	{
		size_t alignSize = SizeClass::RoundUp(size); // 先按照页大小对齐
		size_t k = alignSize >> PAGE_SHIFT; // 算出来对齐之后需要多少页

		PageCache::GetInstance()->_pageMtx.lock(); // 对pc中的span进行操作,加锁
		Span* span = PageCache::GetInstance()->NewSpan(k); // 直接向pc要
		PageCache::GetInstance()->_pageMtx.unlock(); // 解锁

		void* ptr = (void*)(span->_pageID << PAGE_SHIFT); // 通过获得到的span来提供空间
		return ptr;
	}
	else // 申请空间小于256KB的就走原先的逻辑
	{
		/* 因为pTLSThreadCache是TLS的,每个线程都会有一个,且相互独立,所以不存在竞
		争pTLSThreadCache的问题,所以这里只需要判断一次就可以直接new,不存在线程安全问题*/
		if (pTLSThreadCache == nullptr)
		{
			 pTLSThreadCache = new ThreadCache;
		}

		//cout << std::this_thread::get_id() << " " << pTLSThreadCache << endl;

		return pTLSThreadCache->Allocate(size);
	}
}

那么再来修改一下NewSpan的逻辑,NewSpan的参数是表示需要多少页,那么这里就是要补充一下超过128页的逻辑,小于等于128页的都可以复用原先的代码:

申请的流程我们要改的就这点。


释放流程

释放跟我们刚才申请一样,不走tc,直接走pc,还是在开始回收的地方特判。

cpp 复制代码
// 线程调用这个函数用来回收空间
static void ConcurrentFree(void* ptr, size_t size)
{			/*这里第二个参数size后面会去掉的,
			这里只是为了让代码能跑才给的*/
	assert(ptr);

	// 通过ptr找到对应的span,因为前面申请空间的
	// 时候已经保证了维护的空间首页地址已经映射过了
	Span* span = PageCache::GetInstance()->MapObjectToSpan(ptr);

	// 通过size判断是不是大于256KB的,是了就走pc
	if (size > MAX_BYTES)
	{
		PageCache::GetInstance()->_pageMtx.lock(); // 记得加锁
		PageCache::GetInstance()->ReleaseSpanToPageCache(span); // 直接通过span释放空间
		PageCache::GetInstance()->_pageMtx.unlock(); // 解锁
	}
	else // 不是大于256KB的就走tc
	{
		pTLSThreadCache->Deallocate(ptr, size);
	}
}

然后补充ReleaseSpanToPageCache的逻辑,就是加上释放大于256KB的逻辑,但也还是和申请的NewSpan一样,不需要考虑释放256KB到1024KB的span,只需要释放大于128页的空间就可以了,也就是大于128页的时候直接还给os,但是前面没有写直接将空间还给os的逻辑,这里写一个:

然后补充:


大空间申请测试

这里就将测试代码直接给出了。

cpp 复制代码
void BigAlloc()
{
	void* p1 = ConcurrentAlloc(257 * 1024);
	ConcurrentFree(p1, 257 * 1024);

	void* p2 = ConcurrentAlloc(129 * 8 * 1024);
	ConcurrentFree(p2, 129 * 8 * 1024);
}

这里p1就是257KB的,对齐之后33页,你看你申请之后span是不是33页的,会走NewSpan中原先的逻辑,释放的时候走ReleaseSpanToPageCache中原先的逻辑,其他就没啥了。

第二个p2就是129页的,也就是大于128页的,申请的时候走NewSpan中申请大于128页的逻辑,释放的时候走ReleaseSpanToPageCache中大于128页的逻辑,也就是都向OS申请和释放。你看看你的大致流程是不是这,没有报错就应该没啥问题。报错了就慢慢调试吧。


2、使用定长内存池替换new

这个项目做完以后如果要求没那么高的话是可以替代malloc的,说要求没那么高是因为这个项目也就一千多行代码,想要完全替代malloc是现实的,真想完全替代就用谷歌开源的tcmalloc,这里只是为了学习tcmalloc的部分核心才实现的一个简易版本。

先来换一下每个线程的TLSThreadCache:

这里pTLSThreadCache是不需要进行Delete的,因为整个流程一直在使用。

然后就是Span的new与delete,因为只有在PageCache中new了Span,所以直接在PageCache中搞一个定长内存池的成员:

我就不带着一起改了,把PageCache.cpp所有的new span和delete span改为_spanPool.New()和.Delete()就行。

到这里其实还差点意思,这里如果进行多线程测试的话还会出问题,因为pTLSThreadCache是每个线程独有的一个对象,但是为它申请空间的objPool就不是了,我们不妨回顾一下之前的定长内存池:

我们不妨极端一点------t1就直接停在了红框的那个函数,t1停在这个红色框函数前已经对_remainentBytes进行了修改,然后我们t2运行到了上面对_remainentBytes的特判处,那么对于t2来说此时_remainentBytes肯定是大于T的,那么就会直接跳到下面的 obj = (T*)_memory; 处,但此时_memory是nullptr,t2这块就直接获取到了一个空指针,给定位new传空指针会直接导致程序崩掉,所以这里是线程不安全的,就需要加锁,我们直接在定长内存池的类中定义了。

然后我们此处就直接在创建ThreadCache这加锁了,至于为什么------这里的创建ThreadCache只会让每个线程执行一次,同时后面的span对象在创建的时候也会用这里的定长内存池,所以说这里如果加到了New中,后面的span在申请的时候也需要加上这里的_poolMtx,就会影响效率。

而不给span加锁是因为,pc在创建和删除的时候是会加桶锁的。

然后接着测试下,多线程,大空间的那几个例子,能跑应该就没啥问题。


3、释放时不传对象大小

这是应该上个世纪留下来的问题了,没想到我们项目结束才解决(doge,我给可能忘了的兄弟说下就是我们 ConcurrentFree 的第二个参数:

那如何做到不需要传这个参数呢?也就是怎么才能直接根据ptr得到其所指空间的大小。

我们在这里选择在span中加入成员_objSize,表示span所管理的页被切分成的块大小。

当然我们也可以直接去建立页号该页被切分成的块大小之间的映射,具体思路差不多是在pc中再添加一个映射,在获取到新span后,在cc中进行切分的同时将页号与块大小的映射关系搞好。然后ConcurrentFree中先通过指针右移PAGE_SHIFT位得到页号,再根据页号的映射找到该页的块大小,那么这个块大小就是ptr所指向的空间的大小。自己可以下去写一下,不过这个效率可能会慢。

我们在span中加入一个新的成员变量_objSize:

然后我们在cc中将这个变量进行计算:

这个值后续也不用管了,因为pc中并不会拿这个字段干什么事。还有一个就是NewSpan中申请大于128页的span也需要加上统计_objSize,可以在NewSpan中加,也可以直接在ConcurrentAlloc中加,在NewSpan中加的时候就直接给成页数左移PAGE_SHIFT位就行,在ConcurrentAlloc中加就直接给成申请的size就行,只要在Free的时候能通过span找到_objSize,知道_objSize是大于MAX_BYTES的就行。大于MAX_BYTES就走的不是前面正常的逻辑。

这里就在ConcurrentAlloc中添加了:

这样我们在ConcurrentFree的时候就可以先通过ptr左移PAGE_SHIFT位算出来页号,然后再根据页号与span*的映射关系找到对应的span,最后根据span中的_objSize就可以得知ptr所指的空间的大小了:


4、调用MapObjToSpan的时候加锁

在调用MapObjToSpan的时候加锁,是因为STL不是线程安全的,PageCache中用的unordered_map不是线程安全的,如果增删的时候导致原先unordered_map的结构发生了改动(扩容导致原空间丢失什么的),此时查找的时候如果还在查找原先的结构,就可能会找出来一个野指针的span,所以说MapObjToSpan要加锁。

当然也是有两种方法:函数内部与调用函数两边。

但又因为MapObjToSpan并不像NewSpan那样是递归的,所以可以直接在MapObjToSpan函数内部加一个互斥锁,只需要加pc中的那一把锁就行。

cpp 复制代码
// 页地址找span
Span* PageCache::MapObjectToSpan(void* obj)
{
	// 通过块地址找到页号
	PageID id = (((PageID)obj) >> PAGE_SHIFT);

	// 此处用智能锁
	std::unique_lock<std::mutex> lc(_pageMtx);

	// 通过哈希找到页号对应span
	auto ret = _idSpanMap.find(id);

	if (ret != _idSpanMap.end())
	{
		return ret->second;
	}
	else
	{
		assert(false);
		return nullptr;
	}
}

📖 优化前的性能测试

这里与malloc对比下性能,给一个benchmark.cpp的文件,测试代码随便看两眼就行:

cpp 复制代码
/*这里测试的是让多线程申请ntimes*rounds次,比较malloc和刚写完的ConcurrentAlloc的效率*/
#include"ConcurrentAlloc.h"
// ntimes 一轮申请和释放内存的次数
// rounds 轮次
// nwors表示创建多少个线程
void BenchmarkMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
	std::vector<std::thread> vthread(nworks);
	std::atomic<size_t> malloc_costtime = 0;
	std::atomic<size_t> free_costtime = 0;

	for (size_t k = 0; k < nworks; ++k)
	{
		vthread[k] = std::thread([&, k]() {
			std::vector<void*> v;
			v.reserve(ntimes);

			for (size_t j = 0; j < rounds; ++j)
			{
				size_t begin1 = clock();
				for (size_t i = 0; i < ntimes; i++)
				{
					//v.push_back(malloc(16)); // 每一次申请同一个桶中的块
					v.push_back(malloc((16 + i) % 8192 + 1));// 每一次申请不同桶中的块
				}
				size_t end1 = clock();

				size_t begin2 = clock();
				for (size_t i = 0; i < ntimes; i++)
				{
					free(v[i]);
				}
				size_t end2 = clock();
				v.clear();

				malloc_costtime += (end1 - begin1);
				free_costtime += (end2 - begin2);
			}
			});
	}

	for (auto& t : vthread)
	{
		t.join();
	}

	printf("%u个线程并发执行%u轮次,每轮次malloc %u次: 花费:%u ms\n",
		nworks, rounds, ntimes, malloc_costtime.load());

	printf("%u个线程并发执行%u轮次,每轮次free %u次: 花费:%u ms\n",
		nworks, rounds, ntimes, free_costtime.load());

	printf("%u个线程并发malloc&free %u次,总计花费:%u ms\n",
		nworks, nworks * rounds * ntimes, malloc_costtime.load() + free_costtime.load());
}


// 								单轮次申请释放次数 线程数 轮次
void BenchmarkConcurrentMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
	std::vector<std::thread> vthread(nworks);
	std::atomic<size_t> malloc_costtime = 0;
	std::atomic<size_t> free_costtime = 0;

	for (size_t k = 0; k < nworks; ++k)
	{
		vthread[k] = std::thread([&]() {
			std::vector<void*> v;
			v.reserve(ntimes);

			for (size_t j = 0; j < rounds; ++j)
			{
				size_t begin1 = clock();
				for (size_t i = 0; i < ntimes; i++)
				{
					//v.push_back(ConcurrentAlloc(16));
					v.push_back(ConcurrentAlloc((16 + i) % 8192 + 1));
				}
				size_t end1 = clock();

				size_t begin2 = clock();
				for (size_t i = 0; i < ntimes; i++)
				{
					ConcurrentFree(v[i]);
				}
				size_t end2 = clock();
				v.clear();

				malloc_costtime += (end1 - begin1);
				free_costtime += (end2 - begin2);
			}
			});
	}

	for (auto& t : vthread)
	{
		t.join();
	}

	printf("%u个线程并发执行%u轮次,每轮次concurrent alloc %u次: 花费:%u ms\n",
		nworks, rounds, ntimes, malloc_costtime.load());

	printf("%u个线程并发执行%u轮次,每轮次concurrent dealloc %u次: 花费:%u ms\n",
		nworks, rounds, ntimes, free_costtime.load());

	printf("%u个线程并发concurrent alloc&dealloc %u次,总计花费:%u ms\n",
		nworks, nworks * rounds * ntimes, malloc_costtime.load() + free_costtime.load());
}

int main()
{
	size_t n = 10000;
	cout << "==========================================================" << endl;
	// 这里表示4个线程,每个线程申请10万次,总共申请40万次
	BenchmarkConcurrentMalloc(n, 4, 10);
	cout << endl << endl;

	// 这里表示4个线程,每个线程申请10万次,总共申请40万次
	BenchmarkMalloc(n, 4, 10);
	cout << "==========================================================" << endl;

	return 0;
}

可以看到,这里每次申请不同桶下的块空间只是稍微比malloc快一点,在差一点的电脑上甚至有可能比malloc还会慢,没事我们后面还会优化

再来看看我们每次申请同一个桶下的块空间是怎么样的;

可以看到这次直接干不过malloc了,我们还可以再缩小到 1w/线程 的程度,在我这测的情况和上面两种对比是一样的。那么现在写出来的ConcurrentAlloc整体的性能其实还是没有malloc高,尤其是free的时候,差的还是蛮多的,所以我们需要进行优化处理。


📖 使用基数树对内存池进行性能优化

我们上面的性能问题,很大程度是取决于数据量太大造成了unordered_map查找消耗比较大,还有就是锁的消耗很大,如果你很好奇为什么,那你可以去进行性能探查:

此处感兴趣的可以去站内搜搜,我这里就不解释了。

那么实际上tcmalloc中就是用基数树来进行映射存储,而非我们写的哈希表。tcmalloc源码中基数树给了3棵,你可以把基数树理解成一个多叉树,而每棵的层数是不一样的。分别是1层、2层、3层。学过操作系统中内存管理的同学可能对这个东西还是很好理解的。


1、单层基数树

不用想,单层的基数树肯定是最简单的,就是一个数组,严格的来说其实是一个哈希表,一个用直接定址法来映射的哈希表,其中的 K-V 关系就是 页号-span*。

页号就是一个数组,那么在页号位数没有那么大的情况下,把数组的大小开到最大的页号,假设数组就是arr,那么直接arr[页号]就能找到该页号对应的页,这就是单层基数树。

不过基数树本身内部不是span*,而是void*,但也没有关系,都是地址,能找到对应地址就行。

那么来看一下单层基数树的代码(简单过一眼就行,不要细看):

cpp 复制代码
// Single-level array
template <int BITS>
class TCMalloc_PageMap1 {
private:
	static const int LENGTH = 1 << BITS; // 数组要开的长度
	void** array_; // 底层存放指针的数组

public:
	typedef uintptr_t Number;

	explicit TCMalloc_PageMap1() {// 开空间
		size_t size = sizeof(void*) << BITS;
		size_t alignSize = SizeClass::_RoundUp(size, 1 << PAGE_SHIFT);
		array_ = (void**)SystemAlloc(alignSize >> PAGE_SHIFT);
		memset(array_, 0, sizeof(void*) << BITS);
	}

	// Return the current value for KEY.  Returns NULL if not yet set,
	// or if k is out of range.
	void* get(Number k) const { // 通过k来获取对应的指针
		if ((k >> BITS) > 0) {
			return NULL;
		}
		return array_[k];
	}

	// REQUIRES "k" is in range "[0,2^BITS-1]".
	// REQUIRES "k" has been ensured before.
	//
	// Sets the value 'v' for key 'k'.
	void set(Number k, void* v) { // 将v设置到k下标
		array_[k] = v;
	}
};

先来说一下非类型模版参数BITS是啥,BITS表示存储所有的页号至少需要多少比特位。

而且这个变量是按照平台来定的:比如说32位下,按一页8KB来算,页内偏移就是13位(8KB = 2^13),所以页号能占32 - 13 = 19位,那么就是32 - PAGE_SHFIT,上面PAGE_SHFIT定义的是13,所以这里BITS就是19。也就是说数组要开2^19 个span*大小的空间,也就是 2^19 * 4 ⇒ 2^21,也就是2M,所以说这里一个数组要开2M的空间,完全是在可接受范围内的。

但如果是64位呢?64 - PAGE_SHFIT ⇒ 51,64位下的一个指针是8B大小,那总共就要开2 ^51 * 8 ⇒ 2^54,但哪怕1TB也才2^40,这都干到2^14TB了,这个对于普通电脑来说简直是一粒蜉蝣见青天,所以说一层肯定不行,其实两层也不行,所以我们需要用三层的。

里面有两个接口,一个get,一个set,分别是获取某个页号对应的span*,和将某个span*放到对应的数组下标处。详细的逻辑就不说了。下面来说两层的,等三个都说完,再来说为啥基数树不需要加锁。


2、两层基数树

其实和一层的差不多,就是多了一层数组,而且这里很像一级页表,二级页表那样,所以我刚才说如果你学过OS的话这里应该会熟悉一些。

先挑出来页号的前几位来决定第一层数组的大小,然后后几位来决定第二层数组的大小。这里32位下,页号有19位,那么挑出来前5位来作为第一层数组的直接定址,然后后面的14位用来第二层的直接定址,看图:

然后:

那么第一层中每个元素指向的都是一个数组,也就是后14位的数组,而第二层中每个元素就是一个span*:

就这么easy,这里第二层和第一层也没啥大区别,32位下,这里最后总共的span*也照样会开到2M的空间,但是比第一点好的是,这里在前期可以稍微节省一点空间,因为如果前5位中如果有一个数还没有映射的时候就可以先不开其对应14位的二层数组,比如前五位为0x00000还没有映射到第一层时,那就不把第一层0号下标对应的二层数组开出来。当需要映射的时候再开。

我们再把第二层的代码给下:

cpp 复制代码
// Two-level radix tree
template <int BITS>
class TCMalloc_PageMap2 {
private:
	// Put 32 entries in the root and (2^BITS)/32 entries in each leaf.
	static const int ROOT_BITS = 5; // 32位下前5位搞一个第一层的数组
	static const int ROOT_LENGTH = 1 << ROOT_BITS;

	static const int LEAF_BITS = BITS - ROOT_BITS; // 32位下后14位搞成第二层的数组
	static const int LEAF_LENGTH = 1 << LEAF_BITS;

	// Leaf node
	struct Leaf { // 叶子就是后14位的数组
		void* values[LEAF_LENGTH];
	};

	Leaf* root_[ROOT_LENGTH];             // 根就是前5位的数组
public:
	typedef uintptr_t Number;

	//explicit TCMalloc_PageMap2(void* (*allocator)(size_t)) {
	explicit TCMalloc_PageMap2() { // 直接把所有的空间都开好
		memset(root_, 0, sizeof(root_));
		PreallocateMoreMemory(); // 直接开2M的span*全开出来
	}

	void* get(Number k) const {
		const Number i1 = k >> LEAF_BITS;
		const Number i2 = k & (LEAF_LENGTH - 1);
		if ((k >> BITS) > 0 || root_[i1] == NULL) {
			return NULL;
		}
		return root_[i1]->values[i2];
	}

	void set(Number k, void* v) {
		const Number i1 = k >> LEAF_BITS;
		const Number i2 = k & (LEAF_LENGTH - 1);
		ASSERT(i1 < ROOT_LENGTH);
		root_[i1]->values[i2] = v;
	}

	// 确保从start开始往后的n页空间开好了
	bool Ensure(Number start, size_t n) {
		for (Number key = start; key <= start + n - 1;) {
			const Number i1 = key >> LEAF_BITS;

			// Check for overflow
			if (i1 >= ROOT_LENGTH)
				return false;

			// 如果没开好就开空间
			if (root_[i1] == NULL) {
				static ObjectPool<Leaf>	leafPool;
				Leaf* leaf = (Leaf*)leafPool.New();

				memset(leaf, 0, sizeof(*leaf));
				root_[i1] = leaf;
			}

			// Advance key past whatever is covered by this leaf node
			key = ((key >> LEAF_BITS) + 1) << LEAF_BITS;
		}
		return true;
	}

	// 提前开好空间,这里就把2M的直接开好
	void PreallocateMoreMemory() {
		// Allocate enough to keep track of all possible pages
		Ensure(0, 1 << BITS);
	}
};

里面有一个Ensure函数,是为了确保某些页号对应的空间开好了,如果没开好就直接在这个函数中开。


3、三层基数树

一样的逻辑,主要是给64位用的,结构如下:

其中第三层也就是绿色的部分,前两层中的数组都是存放下一层中每个数组的指针,也就是说前两层放的都是数组指针,最后一层放的是span*。

同理,这样的结构可以不需要把所有的空间在初始的情况下都开好,这样就能保证需要的页都能映射到对应的span,而且一个进程是不可能将所有的页全部映射的,这样把不需要映射的页对应的数组就不开了,这样就能节省空间,从而再64位下就能用了。

再给下代码,不过代码如果此处没学过分配器的话,看起来还是有点难度的,没事我们等会会讲:

cpp 复制代码
// 当 BITS 较大(如 35 以上)时,两层树会导致根节点或叶节点过大,
// 使用三层可以更均匀地分割索引位,减少单级数组长度。
// ---------- 三层基数树(64 位系统,BITS = 51 左右)----------
//// Three-level radix tree
template <int BITS>
class TCMalloc_PageMap3 {
private:
	// How many bits should we consume at each interior level
	static const int INTERIOR_BITS = (BITS + 2) / 3; // Round-up
	static const int INTERIOR_LENGTH = 1 << INTERIOR_BITS;

	// How many bits should we consume at leaf level
	static const int LEAF_BITS = BITS - 2 * INTERIOR_BITS;
	static const int LEAF_LENGTH = 1 << LEAF_BITS;

	// Interior node
	struct Node {
		Node* ptrs[INTERIOR_LENGTH];
	};

	// Leaf node
	struct Leaf {
		void* values[LEAF_LENGTH];
	};

	Node* root_;                          // Root of radix tree
	void* (*allocator_)(size_t);          // Memory allocator

	Node* NewNode() {
		Node* result = reinterpret_cast<Node*>((*allocator_)(sizeof(Node)));
		if (result != NULL) {
			memset(result, 0, sizeof(*result));
		}
		return result;
	}

public:
	typedef uintptr_t Number;

	explicit TCMalloc_PageMap3(void* (*allocator)(size_t)) {
		allocator_ = allocator;
		root_ = NewNode();
	}

	void* get(Number k) const {
		const Number i1 = k >> (LEAF_BITS + INTERIOR_BITS);
		const Number i2 = (k >> LEAF_BITS) & (INTERIOR_LENGTH - 1);
		const Number i3 = k & (LEAF_LENGTH - 1);
		if ((k >> BITS) > 0 ||
			root_->ptrs[i1] == NULL || root_->ptrs[i1]->ptrs[i2] == NULL) {
			return NULL;
		}
		return reinterpret_cast<Leaf*>(root_->ptrs[i1]->ptrs[i2])->values[i3];
	}

	void set(Number k, void* v) {
		ASSERT(k >> BITS == 0);
		const Number i1 = k >> (LEAF_BITS + INTERIOR_BITS);
		const Number i2 = (k >> LEAF_BITS) & (INTERIOR_LENGTH - 1);
		const Number i3 = k & (LEAF_LENGTH - 1);
		reinterpret_cast<Leaf*>(root_->ptrs[i1]->ptrs[i2])->values[i3] = v;
	}

	bool Ensure(Number start, size_t n) {
		for (Number key = start; key <= start + n - 1;) {
			const Number i1 = key >> (LEAF_BITS + INTERIOR_BITS);
			const Number i2 = (key >> LEAF_BITS) & (INTERIOR_LENGTH - 1);

			// Check for overflow
			if (i1 >= INTERIOR_LENGTH || i2 >= INTERIOR_LENGTH)
				return false;

			// Make 2nd level node if necessary
			if (root_->ptrs[i1] == NULL) {
				Node* n = NewNode();
				if (n == NULL) return false;
				root_->ptrs[i1] = n;
			}

			// Make leaf node if necessary
			if (root_->ptrs[i1]->ptrs[i2] == NULL) {
				Leaf* leaf = reinterpret_cast<Leaf*>((*allocator_)(sizeof(Leaf)));
				if (leaf == NULL) return false;
				memset(leaf, 0, sizeof(*leaf));
				root_->ptrs[i1]->ptrs[i2] = reinterpret_cast<Node*>(leaf);
			}

			// Advance key past whatever is covered by this leaf node
			key = ((key >> LEAF_BITS) + 1) << LEAF_BITS;
		}
		return true;
	}
};

📖 优化后代码与性能测试

1、优化代码

这里我只搞64位的代码,感兴趣的32位可以下去玩玩,64位会了的话玩32位简直是降维打击。

首先直接将PageCache中的unordered_map替换成三层的基数树,那这个时候一个大问题就来了,怎么定义和这个基数树变量呢?

我们看回我们的原函数,我们发现类中的成员变量有这么一个东西:

这个东西就是我们的内存分配器了,正如字面上那样说的,分配器就是一个负责"给内存"的工具 。在 C++ 里,最常见的分配器就是 new / malloc,它们从 上拿内存。但有时候,我们不想用默认的堆,而想从自己管理的内存池、或者直接从操作系统拿内存,这时候就需要自定义分配器

可以看到,我们的根节点、结点与叶子结点都是用了分配器,但树内部完全不知道内存是怎么来的,它只相信分配器会给它一块可用的内存

那我们的哈希表定义就应该是这样,不光要定义变量,这个变量的初始化就交给了我们的构造函数,然后传入我们的分配器函数参数,如果是一层基数树就不用什么分配器了,直接定义即可。

那么分配器是这样的,这里的new其实是一种普通写法,但我们之前就说了将new换成向系统申请,所以这里就写成向系统申请:

cpp 复制代码
// 分配器函数
inline void* PageMapAlloc(size_t size)
{
	//return ::operator new(size);

	size_t pages = (size + (1 << PAGE_SHIFT) - 1) >> PAGE_SHIFT;
	void* ptr = SystemAlloc(pages);
	if (ptr == nullptr)
	{
		throw std::bad_alloc();   // 与你的 SystemAlloc 失败行为一致
	}
	return ptr;
	// 如果需要释放,也要提供对应的释放函数,但基数树不需要释放(或由系统回收),暂可忽略
}

那么我们的基数树就定义完了,现在来看PageCache内要改的代码段。

把相关原先用unordered_map中的获取span*换成调用get方法,设置span*的换成调用set方法。但由于位置太过分散,我就直接说咋改就行了。

都在PageCache中:

  • 对于原先位_idSpanMap[页号] = span的都改为_idSpanMap.set(页号, span),然后我们需要在set之上再写一个_idSpanMap.Ensure(页号, 1),这步的意思就是遍历从 start 到 start+n-1 的所有页号,对每个页号:
  1. 计算它的三段索引(i1 根索引,i2 中间索引,i3 叶子索引)。

  2. 检查路径上是否存在对应的节点(中间节点、叶子节点),如果没有,就当场调用分配器创建并清零

  3. 这样,走完这个范围后,所有这些页号对应的叶子槽位就都已经存在了,后续的 set 可以直接往里面写值

如果不写这个是会报错的(记得把二层和一层都注释掉)。

  • 对于原先位_idSpanMap.find(页号)的都改为_idSpanMap.get(页号);而且此时的返回值就是Span,不是原先的迭代器了,所以用到auto ret = _idSpanMap.find(页号)的地方都直接变成auto ret = _idSpanMap.get(页号)即可。判断中有的地方是ret != _idSpanMap.end(), 直接改成ret != nullptr就行。return ret->second的改为return (Span*)ret,至于为什么不能直接用Span*接收,是因为我们返回的是void*型。

此时我们就可以直接将MapIdToSpan中的锁去掉了:

但为什么可以去掉呢?这里是在对基数树进行读操作,进行读操作的地方只有两个,一个是在ConcurrentFree里,另一个在ReleaseListToSpans中,二者都存在于回收的逻辑当中。而对于基数树的写操作也是只有两个函数会走到,一个是NewSpan,一个是ReleaseSpanToPageCache。

那么写操作,就是对于数组中的某一个元素进行写入,直接写入一个span*的指针。

读操作,就是通过下标找到对应span*,从而得到这个span*。

但对于一个页号的映射,会不会出现一个线程读某个页号,另一个线程写同一个页号?还有基数树的结构在整个流程中会变化吗?

我们来依次回答一下这两个问题:

1、首先,我们调用MapObjectToSpan读一个页号,那么这个页号一定是不会在pc中的,而是在cc中的,因为MapObjectToSpan的调用是在回收逻辑中的,通过start指针来找span,这个span是要还回去的,一定不在pc中。

而对于写操作,NewSpan是在将Span从pc中拿出去之前就映射了,也就是对基数树进行写操作,而ReleaseSpanToPageCache是在Span归还到pc中之后才映射的。所以读写操作是一定不会同时对一个span操作的。
2、事实上我们基数树在整个流程中并不会变化,因为我们数组都是提前开好的,或是遇到了之后在原先的基础之上再开空间,不会修改原先的空间。

而unordered_map遇到容量不够的情况时会出现扩容等情况,假如说线程t1在扩容前的结构上进行查找,而线程t2进行了扩容,那么t1线程此时就有可能会找到一个野指针,但t1并不知情,t1拿着这个野指针解引用其找到的Span*就会崩掉,所以STL中的unordered_map在整个流程中是可能修改其本身的结构的,所以我们um是线程不安全的,需要加锁。

但既然基数树的结构不会修改,那整个流程中查找到的就一定是一棵树不会改变的树,在这一点的基础上配合第二点,就不需要加锁了。


2、优化后性能测试

测试代码还是上面那个:

4个线程,每个申请10w次,申请不同桶中的块的结果:

4个线程,每个申请10w次,申请一个桶中的块的结果:

4个线程,每个申请1w次,申请不同桶中的块的结果:

4个线程,每个申请1w次,申请一个桶中的块的结果:

可以看到是比malloc快了不少的,在4个线程,每个申请10w次,申请不同桶中的块的情况下甚至能快了20倍左右。

那到此为止这个项目就彻底完成了,再强调一遍,这个项目只是为了学习大佬的成果,从而提升自己,并不是为了完全复刻tcmalloc的源码,只是把其中特别核心的内容拿出来讲的,如果你真的对源码感兴趣可以去github上看。我在第一篇中也给出了源码传送门。

那么在此处我就把源码公开来,githubgitee,两个你们喜欢用哪个就看哪个吧。


📖 结语

不过当前实现的并发内存池在单用户日常使用情况是比malloc/free是更加高效的,那么我们能否替换到系统调用malloc呢?

  • 答案是可以的。但是不同平台替换方式不同。

一般而言,像本篇中实现出来的东西可以做成动静态库,这样后续可以直接引相应的头文件直接用,想要生成动静态库也很简单,修改一下生成文件就行:

选好之后,我们再生成解决方案:

然后就看文件夹中有没有.dll的文件,有的话就没问题,静态库也是同理。

OK啊,正式完结了,也许后面可能还会进行这个项目的优化与总结,不过近期我们是完结了。

我是YYYing,后面还有更精彩的内容,希望各位能多多关注支持一下主包。

无限进步,我们下次再见!


---⭐️ 封面自取 ⭐️---

相关推荐
2301_789015621 小时前
Linux:基础指令(二)
linux·运维·服务器·c语言·开发语言·c++·算法
闻缺陷则喜何志丹1 小时前
【区间合并】P7912 [CSP-J 2021] 小熊的果篮|普及+
c++·算法·洛谷·区间合并
REDcker10 小时前
C++变量存储与ELF段布局详解 从const全局到rodata与nm_readelf验证实践
java·c++·面试
王老师青少年编程12 小时前
csp信奥赛C++高频考点专项训练之字符串 --【字符串排序】:合并序列
c++·字符串·csp·高频考点·信奥赛·字符串排序·合并序列
handler0113 小时前
UDP协议与网络通信知识点
c语言·网络·c++·笔记·网络协议·udp
神仙别闹13 小时前
基于QT(C++)实现学生成绩管理系统
数据库·c++·qt
君义_noip14 小时前
CSP-S 2025 入门级 第一轮(初赛) 完善程序(1)
c++·算法·信息学奥赛·初赛·csp 第一轮
蜡笔小马16 小时前
07.C++设计模式-组合模式
c++·设计模式·组合模式
liulilittle16 小时前
TCP UCP v1.0:BBR 的非破坏性约束层
网络·c++·网络协议·tcp/ip·算法·c·通信