从零实现一个高并发内存池 - 1

C++ 高性能内存池解析

在 C++ 开发中,内存管理一直是影响程序性能的关键因素之一。传统的内存分配方式如 mallocfree 在高并发场景下往往存在性能瓶颈。为了解决这一问题,很多优秀的内存池方案应运而生,其中 Google 的 tcmalloc(Thread-Caching Malloc)是一个杰出的代表。本文将深入解析 tcmalloc 的核心原理,并探讨如何实现一个高性能的内存池。

其他的malloc相关实现

C/C++中我们要动态申请内存都是通过malloc去申请内存,但是我们要知道,实际我们不是直接去堆获取内存的,
malloc就是一个内存池。malloc()相当于向操作系统“批发”了一块较大的内存空间,然后“零售”给程序用。当全部“售完”或程序有大量的内存需求时,再根据实际需求向操作系统“进货”。
malloc的实现方式有很多种,一般不同编译器平台用的都是不同的。比如windows的vs系列用的微软自己写的一套,linux gcc用的glibc中的ptmalloc。下面有几篇关于这块的文章,大概可以去简单看看了解一下,关于ptmalloc,学完我们的项目以后,有兴趣大家都可以去看看他的实现细节。

一文了解,Linux内存管理,malloc、free 实现原理

malloc()背后的实现原理 - 内存池 

malloc的底层实现 - ptmalloc

windows和Linux下如何直接向堆申请⻚为单位的⼤块内存:

VirtuallAlloc()

brk() 和 mmap() 

一、内存池的概念与作用

(一)什么是内存池

内存池是一种池化技术,程序预先从操作系统申请一块足够大的内存,此后,当程序中需要申请内存时,并不直接向操作系统申请,而是从内存池中获取;同理,释放内存时,也并非真正将内存返回给操作系统,而是返回内存池。当程序结束时,才会将之前申请的内存真正释放。

(二)内存池的主要作用(解决的主要问题)

  • 提高内存分配效率 :每次向操作系统申请内存都有较大的开销,内存池通过预先申请过量的资源,避免频繁向操作系统申请和释放内存,大大提高了程序运行效率

向操作系统申请内存就像我们找自己父母(管钱的)要生活费,拿到钱有两种方式,除去池化技术这种方式,剩下的就比如今天早上吃早餐花了5块钱,然后打电话给妈妈,转钱,中午吃午饭,一样,打电话,找妈妈要钱....也就是每一次要花钱都需要找爸爸妈妈,这些钱都是零碎的,频繁的向家里要钱到的,这样效率肯定是非常低的,都消耗在了每一次要前还需要向爸妈沟通,那么用池化技术该如何解决这个问题呢?就是大概我一个月花个1000块钱,那么就在月初直接拿1000块钱,存在自己的钱包里,这样这个月就不需要再向家里要钱了,这样每一次花钱的效率就高了。

  • 解决内存碎片问题内存碎片分为外碎片和内碎片。外碎片是由于内存分配后剩余的空闲块太小,无法满足后续的内存分配请求;内碎片则是由于内存分配时需要满足对齐要求,导致分配出去的空间中一些内存无法被利用。内存池通过合理的管理策略,可以有效缓解这两种碎片问题。

在进程地址空间中,先申请了 256Byte 的 vector、256Byte 的 map、512Byte 的 mysql、128Byte 的 list 等不同大小的内存块,这些内存块在内存中是连续分配的。当 vector 和 list 对象销毁后,释放了各自占用的 256Byte 和 128Byte 空间,但这两个释放后的空间与之前未被释放的 mysql 占用的 512Byte 空间以及 map 占用的 256Byte 空间交替存在,导致内存中出现了多块不连续的空闲空间,从而产生了内存碎片。

现在需要申请超过 256Byte 的空间,但现有的空闲空间虽然总共有 384Byte(256Byte+128Byte),却因为碎片化而不连续。内存管理系统在分配内存时,通常需要找到一块连续的、足够大的空闲内存区域来满足申请。由于不存在一块连续的超过 256Byte 的空闲空间,所以无法成功申请到所需的内存。 

二、开胃菜:定长内存池

作为程序员 (C/C++) 我们知道申请内存使用的是 malloc,malloc 其实就是一个通用的大众货,什么场景下都可以用,但是什么场景下都可以用就意味着什么场景下都不会有很高的性能,下面我们就先来设计一个定长内存池做个开胃菜,当然这个定长内存池在我们后面的高并发内存池中也是有价值的,所以学习它的目的有两层,先熟悉一下简单内存池是如何控制的,第二它会作为我们后面内存池的一个基础组件。

我们实现定长内存池的详细细节会体现在代码中的注释中!!!

#pragma once
#include<iostream>
#include<vector>
#include<ctime>
#include<windows.h>

//方便,不使用using namespace std;是因为防止污染
using std::cout;
using std::endl;

//实现代码中的穿插:
// 直接去堆上按页申请空间
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32
	void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else
	// linux下brk mmap等
#endif

	if (ptr == nullptr)
		throw std::bad_alloc();

	return ptr;
}

//定长内存池

//实现定长
//方法1
//template<size_t N>
//class ObjectPool
//{
//	//..........
//};

//方法2:我们上面的使用template<size_t N>是可以实现定长的,但是为了和后面的代码有更强的连接性,我们使用下面这种
template<class T>
class ObjectPool
{
public:
	T* New()
	{
		T* obj = nullptr;
		if (_freeList)//不为空
		{
			//说明_freeList当下还有正在“休息”的可用内存块,我们优先叫醒他,没必要再去切当前的内存块 --- 效率的提升!
			//进行对链表的头删
			void* next = *((void**)_freeList);//还要注意优先级哦,就是要加上括号哈!//当前的_freeList头部存在下一个节点的地址,我们先提出来,因为头删后,我们要保证_freeList位置的正确性
			obj = (T*)_freeList;
			_freeList = next;
		}
		else
		{
			//剩余内存不够一个对象大小时,需重新开辟大块空间
			if (_remainBytes < sizeof(T))
			{
				_remainBytes = 128 * 1024;
				//_memory = (char*)malloc(_remainBytes);//我们不要直接去malloc可以吗?直接不走malloc,直接去调用系统!可以的!!
				//Windows的API是使用VirtuallAlloc()
				//Linux是brk()或mmap()
				//这样更纯粹一点!
				_memory = (char*)SystemAlloc(_remainBytes >> 13);//一页是8K,所以右移1024*8==>>2的13次方
				if (_memory == nullptr)
				{
					//申请失败,抛异常
					throw std::bad_alloc();
				}
			}
			obj = (T*)_memory; //向内存池,也就是申请出来的大块内存申请部分空间!
			//注意:如果T是char呢?或者任何小于指针长度的类型呢?那么指针不就存不下了吗?这是我们应该要注意到的!
			size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
			_memory += objSize;  //大块内存被取走一部分了,当然要继续指向可用部分的开始,以便下一次申请的方便
			_remainBytes -= objSize; //使用了当然是要保证确实是使用了,剩下多少可用的了!
		}

		//注意!!!
		//仅仅将空间开辟出来还是有一点点不足的,像T是一个自定义类型等等,我们是开了空间,并没有初始化
		//对于一个开辟出空间的,我们可以调用构造函数进行初始化 --- 定位new:显式调用T的构造函数初始化
		new(obj)T;//后面Delete也是如此!
		return obj;
	}

	void Delete(T* obj)
	{
		//显式调用析构函数清理对象
		obj->~T();//并不是释放obj!!!只是将T:如vector的开辟的空间销毁
		//画图去理解,理解好细节是很有帮助的!
		// ***************************************************************************************************
		// ***************************************************************************************************
		//if (_freeList == nullptr)
		//{
		//	_freeList = obj;
		//	//使用指针类型的特性来截取前4个比特位,来存放指向下一个节点的指针(32位下就是4位,64位下就是8位)
		//	//*(int*)obj = nullptr;
		//	//不过我们并不是每一台机器都是4位的,我们可以通过sizeof来进行 if - else,但是我们下面还有一个更加巧妙的方式!!!很重要
		//	*(void**)obj = nullptr; //这样不管是32位还是64位下都没有问题:obj被强转成了(void**),然后前面一个*解引用,那么就是sizeof(void*)的大小了!!!
		//}
		//else
		//{
		//	//我们使用头插是效率很高的!不然还需要遍历找尾!
		//	//头插
		//	*(void**)obj = _freeList;
		//	_freeList = obj;
		//}
		// ***************************************************************************************************
		// ***************************************************************************************************


		//使用到了头插,所以我们也不需要if - else了:
		*(void**)obj = _freeList;
		_freeList = obj;

	}
private:
	char* _memory = nullptr; //指向大块内存的指针
	size_t _remainBytes = 0; //大块内存在切分过程中剩余字节数

	void* _freeList = nullptr; //从起初申请的内存归还回来过程中需要链接管理起来的管理者 - 自由链表的头指针
};

测试效率:

struct TreeNode
{
	int _val;
	TreeNode* _left;
	TreeNode* _right;

	TreeNode()
		:_val(0)
		, _left(nullptr)
		, _right(nullptr)
	{}
};

void TestObjectPool()
{
	// 申请释放的轮次
	const size_t Rounds = 5;

	// 每轮申请释放多少次
	const size_t N = 100000;

	std::vector<TreeNode*> v1;
	v1.reserve(N);

	size_t begin1 = clock();
	for (size_t j = 0; j < Rounds; ++j)
	{
		for (int i = 0; i < N; ++i)
		{
			v1.push_back(new TreeNode);
		}
		for (int i = 0; i < N; ++i)
		{
			delete v1[i];
		}
		v1.clear();
	}

	size_t end1 = clock();

	std::vector<TreeNode*> v2;
	v2.reserve(N);

	ObjectPool<TreeNode> TNPool;
	size_t begin2 = clock();
	for (size_t j = 0; j < Rounds; ++j)
	{
		for (int i = 0; i < N; ++i)
		{
			v2.push_back(TNPool.New());
		}
		for (int i = 0; i < N; ++i)
		{
			TNPool.Delete(v2[i]);
		}
		v2.clear();
	}
	size_t end2 = clock();

	cout << "new cost time:" << end1 - begin1 << endl;
	cout << "object pool cost time:" << end2 - begin2 << endl;
}

这段代码的主要目的是比较使用传统的 newdelete 方式与使用对象池方式在申请和释放大量对象时的性能差异。通过多轮次的申请和释放操作,统计两种方式的耗时,从而展示对象池在频繁申请释放内存场景下的性能优势。

我们可以发现:

//Debug下
new cost time:213
object pool cost time:40

//Release下
new cost time:39
object pool cost time:2

三、tcmalloc 的核心框架

(一)tcmalloc 简介

tcmalloc 是 Google 开源的一个高性能内存分配器,全称是 Thread-Caching Malloc,即线程缓存的 malloc。它是基于 ptmalloc(glibc 中的内存分配器)改进而来,专门针对多线程高并发场景进行了优化,用于替代系统的内存分配相关函数(mallocfree 等)。(注意是在多线程环境下,普通环境下tcmalloc未必比malloc free高效)

现代很多的开发环境都是多核多线程,在申请内存的场景下,必然存在激烈的锁竞争问题。malloc 本身其实已经很优秀,那么我们的项目原型 tcmalloc 就是在多线程高并发的场景下更胜一筹。所以这次我们实现的内存池需要考虑以下几方面的问题:

  1. 性能问题

  2. 多线程环境下,锁竞争问题

  3. 内存碎片问题

(二)tcmalloc 的核心组件

tcmalloc 的内存分配框架主要由三个部分构成:thread cache(线程缓存)、central cache(中央缓存)和 page cache(页缓存)。(三层)

  • thread cache (线程缓存):每个线程都有一个独立的 thread cache,用于管理小于 256KB 的内存分配。线程从这里申请内存不需要加锁,因此效率极高因为每一个线程独享一个thread cache)。当线程需要分配内存时,会先从自己的 thread cache 中获取;若 thread cache 中没有足够的内存,则会从 central cache 中批量获取。

  • central cache (中央缓存):中央缓存是所有线程共享的。线程缓存按需从中央缓存中获取对象。中央缓存在合适的时机回收线程缓存中的对象,避免一个线程占用了太多内存,而其他线程内存紧张,从而达到内存分配在多个线程中更均衡的按需调度的目的。由于中央缓存是共享的,从这里获取内存对象需要加锁。这里使用的是桶锁机制,并且由于线程缓存通常能够满足需求,只有在线程缓存没有内存对象时才会访问中央缓存,因此这里的锁竞争不会很激烈。(只有访问同一个桶的时候,因此锁竞争并没有那么激烈)。(thread cache没有内存了就向下一层去申请内存,这个中央缓存和我们上面实现的定长内存池类似)

  • page cache (页面缓存):页面缓存位于中央缓存之上,存储的内存是以页为单位进行分配和管理的。当中央缓存没有内存对象时,页面缓存会分配一定数量的页,并将其切割成固定大小的小块内存,分配给中央缓存。当一个 span(页面跨度)的所有对象都被回收后,页面缓存会回收中央缓存中符合条件的 span 对象,并且合并相邻的页面,组成更大的页面,从而缓解内存碎片的问题。

更多精彩在下文哦! 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值