【高并发内存池】第八弹---脱离new的定长内存池与多线程malloc测试

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

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

目录

1、使用定长内存池配合脱离使用new

2、释放对象时不传对象大小

3、多线程环境下对比malloc测试

3.1、固定大小内存的申请和释放

3.2、不同大小内存的申请和释放


1、使用定长内存池配合脱离使用new

1、tcmalloc是要在高并发场景下替代malloc进行内存申请的 ,因此tcmalloc在实现的时,其内部是不能调用malloc函数的,我们当前的代码中存在通过new获取到的内存,而new在底层实际上就是封装了malloc

2、为了完全脱离掉malloc函数,此时我们之前实现的定长内存池 就起作用了,代码中使用new时基本都是为Span结构的对象申请空间 ,而span对象基本都是在page cache层创建 的,因此我们可以在PageCache类当中定义一个_spanPool,用于span对象的申请和释放

复制代码
// 单例模式
class PageCache
{
public:
	//...
private:
    //...
	ObjectPool<Span> _spanPool; // 定长内存池替代new
};

然后将代码中使用new的地方替换为调用定长内存池当中的New函数将代码中使用delete的地方替换为调用定长内存池当中的Delete函数

复制代码
// 申请Span对象
Span* span = _spanPool.New(); // 替换new
// 释放Span对象
_spanPool.Delete();           // 替换delete

PageCache::NewSpan,PageCache::ReleaseSpanToPageCache两个函数 需要将new或者delete替换成功New或者Delete

PageCache::NewSpan

复制代码
// 获取一个K页的span(加映射版本)
Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0);
	// 大于128页直接找堆申请
	if (k > NPAGES - 1)
	{
		void* ptr = SystemAlloc(k);
		//Span* span = new Span;
		// 申请Span对象
		Span* span = _spanPool.New(); // 替换new
        //...
	}
	// 1.检查第k个桶里面有没有Span,有则返回
    //...
	// 2.检查一下后面的桶里面有没有span,有则将其切分
	for (size_t i = k + 1; i < NPAGES; i++)
	{
		if (!_spanLists[i].Empty())
		{
			Span* nSpan = _spanLists[i].PopFront();
			//Span* kSpan = new Span;
			// 申请Span对象
			Span* kSpan = _spanPool.New(); 
            //...
		}
	}
	// 3.没有大页的span,找堆申请128页的span
	//Span* bigSpan = new Span;
	// 申请Span对象
	Span* bigSpan = _spanPool.New();
    //...
	// 递归调用自己(避免代码重复)
	return NewSpan(k);
}

PageCache::ReleaseSpanToPageCache

复制代码
// 释放空闲的span回到PageCache,并合并相邻的span
void PageCache::ReleaseSpanToPageCache(Span* span)
{
	// 大于128页直接释放给堆
	if (span->_n > NPAGES - 1)
	{
		void* ptr = (void*)(span->_pageID << PAGE_SHIFT);
		SystemFree(ptr);
		//delete span;
		// 释放span对象
		_spanPool.Delete(span);
		return;
	}
	// 对span的前后页,尝试进行合并,缓解内存碎片问题
	// 1、向前合并
	while (true)
	{
        //...
		//delete prevSpan;
		// 释放span对象
		_spanPool.Delete(prevSpan);
	}
	// 向后合并
	while (true)
	{
        //...
		//delete nextSpan;
		// 释放span对象
		_spanPool.Delete(nextSpan);
	}
	// 将合并后的span挂到对应的双链表中
    //...
	// 将该span设置为未使用状态
	span->_isUse = false;
}

注意:当使用定长内存池当中的New函数申请Span对象时,New函数通过定位new也是对Span对象进行了初始化的



此外,每个线程第一次申请内存时都会创建其专属的thread cache,而这个thread cache目前是new出来的,我们需要对其替换

复制代码
// 通过TLS 每个线程无锁的获取自己的专属的ThreadCache对象
if (pTLSThreadCache == nullptr)
{
	static ObjectPool<ThreadCache> tcPool;
	//pTLSThreadCache = new ThreadCache;
	pTLSThreadCache = tcPool.New();
}

这里我们将用于申请ThreadCache类对象的定长内存池定义为静态的,保持全局只有一个,让所有线程创建自己的thread cache时,都在个定长内存池中申请内存就行了。

2、释放对象时不传对象大小

1、当我们使用malloc函数申请内存时需要指明申请内存的大小;而当我们使用free函数释放内存时只需要传入指向这块内存的指针即可

2、而我们目前实现的内存池在释放对象时除了需要传入指向该对象的指针,还需要传入该对象的大小

原因:

  • 如果释放的是大于256KB的对象 ,需要根据对象的大小来判断这块内存到底应该还给page cache,还是应该直接还给堆
  • 如果释放的是小于等于256KB的对象 ,需要根据对象的大小计算出应该还给thread cache的哪一个哈希桶

如果我们也想做到,在释放对象时不用传入对象的大小,那么我们就需要建立对象地址与对象大小之间的映射 。由于现在可以通过对象的地址找到其对应的span而span的自由链表中挂的都是相同大小的对象

因此我们可以在Span结构中再增加一个_objSize成员,该成员代表着这个span管理的内存块被切成的一个个对象的大小

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

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

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

	bool _isUse = false;	   // 是否在被使用
};

而所有的span都是从page cache中拿出来的,因此每当我们调用NewSpan获取到一个k页的span时,就应该将这个span的_objSize保存起来

复制代码
Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(size));
span->_objSize = size;

代码中有两处,一处是在central cache中获取非空span时 ,如果central cache对应的桶中没有非空的span,此时会调用NewSpan获取一个k页的span;另一处是当申请大于256KB内存时 ,会直接调用NewSpan获取一个k页的span

复制代码
// 获取一个非空的span
Span* CentralCache::GetOneSpan(SpanList& list, size_t size)
{
    //...
    Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(size));
    span->_objSize = size;
    //...
}

// 申请内存
static void* ConcurrentAlloc(size_t size)
{
    //...
    Span* span = PageCache::GetInstance()->NewSpan(kPage);
    span->_objSize = size;
    //...
}


当我们释放对象时 ,就可以直接从对象的span中获取到该对象的大小,准确来说获取到的是对齐以后的大小

复制代码
// 释放内存(无对象大小版本)
static void ConcurrentFree(void* ptr)
{
	Span* span = PageCache::GetInstance()->MapObjectToSpan(ptr);
	size_t size = span->_objSize;
	// 大于256KB的内存释放
	if (size > MAX_BYTES)
	{
		PageCache::GetInstance()->_pageMtx.lock();
		PageCache::GetInstance()->ReleaseSpanToPageCache(span);
		PageCache::GetInstance()->_pageMtx.unlock();
	}
	else
	{
		assert(pTLSThreadCache);

		pTLSThreadCache->Deallocate(ptr, size);
	}
}

读取映射关系时的加锁问题

我们将页号与span之间的映射关系是存储在PageCache类当中的当我们访问这个映射关系时是需要加锁的 ,因为STL容器是不保证线程安全的

对于当前代码来说,如果我们此时正在page cache进行相关操作,那么访问这个映射关系是安全的,因为当进入page cache之前是需要加锁的,因此可以保证此时只有一个线程在进行访问。

但如果我们是在central cache访问这个映射关系,或是在调用ConcurrentFree函数释放内存时访问这个映射关系 ,那么就存在线程安全的问题。因为此时可能其他线程正在page cache当中进行某些操作,并且该线程此时可能也在访问这个映射关系,因此当我们在page cache外部访问这个映射关系时是需要加锁的。

实际就是在调用page cache对外提供访问映射关系的函数时需要加锁,这里我们可以考虑使用C++当中的unique_lock,当然你也可以用普通的锁。

复制代码
// 获取从对象到span的映射
Span* PageCache::MapObjectToSpan(void* obj)
{
	PAGE_ID id = (PAGE_ID)obj >> PAGE_SHIFT; // 计算页号

	std::unique_lock<std::mutex> lock(_pageMtx); // C++11锁,构造加锁,析构解锁
	auto ret = _idSpanMap.find(id);
	if (ret != _idSpanMap.end())
	{
		return ret->second;
	}
	else
	{
		assert(false);
		return nullptr;
	}
}

3、多线程环境下对比malloc测试

前面我们只是对代码进行了一些基础的单元测试,下面我们在多线程场景下对比malloc进行测试

复制代码
// ntimes 一轮申请和释放内存的次数
// nworks 线程数
// rounds 轮次
void BenchmarkMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
	std::vector<std::thread> vthread(nworks);
	// atomic (const atomic&) = delete;
	// atomic& operator= (const atomic&) = delete;
	// std::atomic<size_t> malloc_costtime = 0; // 错误
	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, (unsigned int)malloc_costtime);

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

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


// 单轮次申请释放次数 线程数 轮次
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, (unsigned int)malloc_costtime);

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

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

int main()
{
	size_t n = 1000;
	cout << "==========================================================" << endl;
	BenchmarkConcurrentMalloc(n, 4, 10);
	cout << endl << endl;

	BenchmarkMalloc(n, 4, 10);
	cout << "==========================================================" << endl;

	return 0;
}

注意:atomic类不支持拷贝构造和赋值重载,因此此处需要使用构造函数,且打印时间时需要将类型强转成无符号整数。

测试函数各个参数的含义如下:

  • ntimes:单轮次申请和释放内存的次数。
  • nworks:线程数。
  • rounds:轮次。

在测试函数中,我们通过clock函数分别获取到每轮次申请和释放所花费的时间 ,然后将其对应累加到malloc_costtime和free_costtime上 。最后我们就得到了,nworks个线程跑rounds轮,每轮申请和释放ntimes次,这个过程申请所消耗的时间、释放所消耗的时间、申请和释放总共消耗的时间

注意

  • 我们创建线程时让线程执行的是lambda表达式 ,而我们这里在使用lambda表达式时,以值传递的方式捕捉了变量k,以引用传递的方式捕捉了其他父作用域中的变量,因此我们可以将各个线程消耗的时间累加到一起。
  • 我们将所有线程申请内存消耗的时间都累加到malloc_costtime上, 将释放内存消耗的时间都累加到free_costtime上,此时malloc_costtime和free_costtime可能被多个线程同时进行累加操作的,所以存在线程安全的问题。鉴于此,我们在定义这两个变量时使用了atomic类模板,这时对它们的操作就是原子操作了

3.1、固定大小内存的申请和释放

测试16字节大小内存的申请和释放

复制代码
v.push_back(malloc(16));
v.push_back(ConcurrentAlloc(16));

此时4个线程执行10轮操作,每轮申请释放10000次,总共申请释放了40万次 ,运行后可以看到,malloc的效率还是更高的

由于此时我们申请释放的都是固定大小的对象,每个线程申请释放时访问的都是各自thread cache的同一个桶 ,当thread cache的这个桶中没有对象或对象太多要归还时,也都会访问central cache的同一个桶。此时central cache中的桶锁就不起作用了,因为我们让central cache使用桶锁的目的就是为了,让多个thread cache可以同时访问central cache的不同桶,而此时每个thread cache访问的却都是central cache中的同一个桶



3.2、不同大小内存的申请和释放

测试不同大小内存的申请和释放

复制代码
v.push_back(malloc((16 + i) % 8192 + 1));
v.push_back(ConcurrentAlloc((16 + i) % 8192 + 1));

运行后可以看到,由于申请和释放内存的大小是不同的,此时central cache当中的桶锁就起作用了 ,ConcurrentAlloc的效率也有了较大增长,但相比malloc来说还是差一点点

相关推荐
徐小黑ACG4 分钟前
GO语言 使用protobuf
开发语言·后端·golang·protobuf
0白露1 小时前
Apifox Helper 与 Swagger3 区别
开发语言
Tanecious.2 小时前
机器视觉--python基础语法
开发语言·python
叠叠乐2 小时前
rust Send Sync 以及对象安全和对象不安全
开发语言·安全·rust
想跑步的小弱鸡2 小时前
Leetcode hot 100(day 3)
算法·leetcode·职场和发展
Tttian6224 小时前
Python办公自动化(3)对Excel的操作
开发语言·python·excel
xyliiiiiL4 小时前
ZGC初步了解
java·jvm·算法
爱的叹息4 小时前
RedisTemplate 的 6 个可配置序列化器属性对比
算法·哈希算法
Merokes5 小时前
关于Gstreamer+MPP硬件加速推流问题:视频输入video0被占用
c++·音视频·rk3588
独好紫罗兰5 小时前
洛谷题单2-P5713 【深基3.例5】洛谷团队系统-python-流程图重构
开发语言·python·算法