【项目日记】高并发内存池 ---项目介绍及组件定长池的实现

2024-09-06 11:28:08 浏览数 (3)

1 高并发内存池简介

高并发内存池项目是实现一个高并发的内存池,他的原型是google的一个开源项目tcmalloctcmalloc全称Thread-Caching Malloc,即线程缓存的malloc,实现了高效的多线程内存管理,用于替代系统的内存分配相关的函数malloc free

这个项目是把tcmalloc最核心的框架简化后拿出来,模拟实现出一个自己的高并发内存池,目的是为了学习tcamlloc项目的精华,谷歌大厂的项目那必是含金量十足!这种方式有点类似我们之前学习STL容器的方式。但是相比STL容器部分,tcmalloc的代码量和复杂度上升了很多,大家做好心理准备。

难度的上升,我们的收获和成长也是在这个过程中同步上升。

·tcmalloc·是大厂google开源的,可以认为当时顶尖的C 高手写出来的,他的知名度也是非常高的,不少公司都在用它,Go语言直接用它做了自己内存分配器。所以这个项目可以称之为c 学习者的必学项目!

很多程序员是熟悉这个项目的,那么有好处,也有坏处。

  • 好处就是把这个项目理解扎实了,会很受面试官的认可。
  • 坏处就是面试官可能也比较熟悉项目,对项目会问得比较深,比较细。如果你对项目掌握得不扎实,那么就容易碰钉子。

有兴趣可以来看看源码哦:tcmalloc源代码在这里

涉及的技术栈有以下:

  1. 多线程编程:
    • 线程安全:确保在多线程环境下内存分配和释放操作的安全性。
    • 锁机制:使用各种锁(如自旋锁、互斥锁等)来同步对共享资源的访问。
    • 原子操作:利用原子操作来保证数据的一致性和线程安全。
  2. 内存管理:
    • 内存分配策略:设计高效的内存分配算法,减少内存碎片。
    • 内存池:预先分配一大块内存,按需分配给用户,减少系统调用开销。
    • 缓存机制:使用线程局部缓存来提高内存分配和释放的效率。
  3. 数据结构与算法:
    • 链表:管理空闲内存块。
    • 哈希表:快速查找和定位内存块。
    • 树结构:如二叉树、B树等,用于组织和管理内存块。
  4. 操作系统知识:
    • 虚拟内存管理:理解操作系统的内存管理机制。
    • 系统调用:如mmap、munmap等,用于直接操作内存。
  5. 编程语言:
    • 当然是C :tmalloc通常是用C或C 编写的,因为这些语言提供了接近硬件的编程能力。

2 定长池的实现

我们先来实现一个高并发内存池的小组件 — 定长池 ,来练练手! 定长池也是基于池化技术:

所谓“池化技术”,就是程序先向系统申请过量的资源,然后自己管理,以备不时之需。之所以要申请过量的资源,是因为每次申请该资源都有较大的开销,不如提前申请好了,这样使用时就会变得非常快捷,大大提高程序运行效率

2.1 定长池的设计理念

直接使用C 的new delete或者是C语言的malloc free都会出现一种问题:内存碎片! 当我们在堆上开辟出许多空间,然后随机释放掉一些空间,虽然我们会得到很多空间,但是此时的空间就很可能会死不连续的了,不能继续开辟出更大的空间了:

而定长池内部有一块连续的大空间和一个自由链表,每次开辟是都会在自由链表中先进行选择可以使用的空间块,如果没有就在大空间中进行取出一部分进行使用!

2.2 框架搭建

定长池需要:

  1. 一段连续的大空间:我们使用char*来进行管理,方便一个一个字节来进行处理。
  2. 一个自由链表:进行资源的回收使用,内部通过链表结构链接起来
  3. 剩余资源数:进行资源空间的管理,不足够是进行扩容!
代码语言:javascript复制
//--- 定长池的实现 ---

#include<iostream>
#include<vector>
#include<memory>

using std::cout;
using std::endl;

template<class T>
class ObjectPool
{
public:
	T* New()
	{
		
	}
	void Delete(T*obj)
	{
		
	}

private:
	//需要一个大空间
	char* _memory = nullptr;
	//回收资源的链表
	void* _freelist = nullptr;
	//剩余字节数!
	size_t _remainBytes = 0;
};

2.3 New 与 Delete

New函数的书写逻辑是:

  1. 如果是第一次进行new,那么就开辟一个大空间,然后取出需要的空间进行返回
  2. 如果不是第一次开辟,那么就要进行一个选择,如果自由链表中有内存块,就直接拿来使用。如果没有就在大空间中取出一部分进行使用!
  3. 每次开辟都要对剩余容量进行处理!

需要注意的是进行强制类型转换我们使用c 11通过的新接口来确保安全!

代码语言:javascript复制
T* New()
{
	T* obj = nullptr;
	//先从自由链表中获取
	if (_freelist)
	{
		obj = reinterpret_cast<T*>(_freelist);
		//进行头删
		//使用二级指针 取出指针的空间进行操作
		void* next = *reinterpret_cast<void**>(_freelist);
		_freelist = next;
		return obj;
	}
	//如果是第一次创建,那么_memory为空
	if (_remainBytes < sizeof(T))
	{
		//开辟大空间进行使用
		_remainBytes = 128 * 1024;
		//_memory = reinterpret_cast<char*>( malloc (_remainBytes ) )
		//直接使用系统调用进行优化
		_memory = reinterpret_cast<char*>( SystemAlloc (_remainBytes >> 13) );
	
		//开辟失败抛出异常
		if (_memory == nullptr)
		{
			throw std::bad_alloc();
		}
	}
	obj = nullptr;
	//取出对应数量的空间
	obj = reinterpret_cast<T*>(_memory);
	//每次至少分配一个指针的大小,方便自由链表的使用
	size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
	//调整剩余参数
	_memory  = objSize;
	_remainBytes -= objSize;
	//显示调用构造函数
	new(obj)T;

	return obj;
}

Delete函数的逻辑先将资源进行析构,然后不能将空间直接进行释放,而是将该空间直接头插到自由链表中!

特别需要注意的是头插的环节,如果是第一次删除,就直接等于就可以。反之就需要进行头插,需要先在空间中取出一个指针的空间来指向下一空间!!!

取指针空间的操作要使用二级指针来进行!使用一级指针(int*)是不合适的,因为一级指针解引用不能适配32位和64位(除非进行一些特殊判断,比较麻烦)。使用二级指针void**,解引用会直接取出void*的大小,真好就是对应指针的大小,就可以进行取出使用了!

代码语言:javascript复制
void Delete(T*obj)
{
	//显示调用进行析构
	obj->~T();
	//将删除的空间插入到自由链表中
	//第一次进行插入
	if (_freelist == nullptr)
	{
		_freelist = obj;
		*reinterpret_cast<void**>(_freelist) = nullptr;
	}
	//不是第一次就进行头插
	else
	{
		*reinterpret_cast<void**>(obj) = _freelist;
		_freelist = obj;
	}
}

2.4 系统调用优化

为了追求更高的效率,我们可以使用底层的系统调用来进行: 这样就需要对不同的操作系统进行区分处理了!只针对特定场景进行处理,不在进行maloc,直接按页传递内存!

代码语言:javascript复制
#ifdef _WIN32
	#include<windows.h>
#else
// linux
#endif

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;
}

3 性能测试

我们完成了定长池的书写,接下来我们来看看他的效率怎么样!!!

代码语言:javascript复制
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;
}

可以看到效率是快了10倍啊!!!很好很好!!!

这样定长池就完成了

定长池中最关键的有三点:

  1. 在32位 / 64位系统下取出一个指针的空间,使用二级指针,二级指针解引用一定是当前环境下只指针的大小!
  2. 自由链表的内存块的链接,需要特别注意,其中没有多加指针,而是是否巧妙的利用类型转换进行使用!
  3. 如何实现的内存分配以及资源复用?通过大空间的切割和自由链表的回收!!!

0 人点赞