1 高并发内存池简介
高并发内存池项目是实现一个高并发的内存池,他的原型是google的一个开源项目
tcmalloc
,tcmalloc
全称Thread-Caching Malloc
,即线程缓存的malloc
,实现了高效的多线程内存管理,用于替代系统的内存分配相关的函数malloc free
。
这个项目是把tcmalloc
最核心的框架简化后拿出来,模拟实现出一个自己的高并发内存池,目的是为了学习tcamlloc
项目的精华,谷歌大厂的项目那必是含金量十足!这种方式有点类似我们之前学习STL容器的方式。但是相比STL容器部分,tcmalloc
的代码量和复杂度上升了很多,大家做好心理准备。
难度的上升,我们的收获和成长也是在这个过程中同步上升。
·tcmalloc·是大厂google开源的,可以认为当时顶尖的C++高手写出来的,他的知名度也是非常高的,不少公司都在用它,Go语言直接用它做了自己内存分配器。所以这个项目可以称之为c++学习者的必学项目!
很多程序员是熟悉这个项目的,那么有好处,也有坏处。
- 好处就是把这个项目理解扎实了,会很受面试官的认可。
- 坏处就是面试官可能也比较熟悉项目,对项目会问得比较深,比较细。如果你对项目掌握得不扎实,那么就容易碰钉子。
有兴趣可以来看看源码哦:tcmalloc源代码在这里
涉及的技术栈有以下:
- 多线程编程:
- 线程安全:确保在多线程环境下内存分配和释放操作的安全性。
- 锁机制:使用各种锁(如自旋锁、互斥锁等)来同步对共享资源的访问。
- 原子操作:利用原子操作来保证数据的一致性和线程安全。
- 内存管理:
- 内存分配策略:设计高效的内存分配算法,减少内存碎片。
- 内存池:预先分配一大块内存,按需分配给用户,减少系统调用开销。
- 缓存机制:使用线程局部缓存来提高内存分配和释放的效率。
- 数据结构与算法:
- 链表:管理空闲内存块。
- 哈希表:快速查找和定位内存块。
- 树结构:如二叉树、B树等,用于组织和管理内存块。
- 操作系统知识:
- 虚拟内存管理:理解操作系统的内存管理机制。
- 系统调用:如mmap、munmap等,用于直接操作内存。
- 编程语言:
- 当然是C++:tmalloc通常是用C或C++编写的,因为这些语言提供了接近硬件的编程能力。
2 定长池的实现
我们先来实现一个高并发内存池的小组件 — 定长池 ,来练练手!
定长池也是基于池化技术:
所谓“池化技术”,就是程序先向系统申请过量的资源,然后自己管理,以备不时之需。之所以要申请过量的资源,是因为每次申请该资源都有较大的开销,不如提前申请好了,这样使用时就会变得非常快捷,大大提高程序运行效率
2.1 定长池的设计理念
直接使用C++的new delete
或者是C语言的malloc free
都会出现一种问题:内存碎片!
当我们在堆上开辟出许多空间,然后随机释放掉一些空间,虽然我们会得到很多空间,但是此时的空间就很可能会死不连续的了,不能继续开辟出更大的空间了:
而定长池内部有一块连续的大空间和一个自由链表,每次开辟是都会在自由链表中先进行选择可以使用的空间块,如果没有就在大空间中进行取出一部分进行使用!
2.2 框架搭建
定长池需要:
- 一段连续的大空间:我们使用
char*
来进行管理,方便一个一个字节来进行处理。 - 一个自由链表:进行资源的回收使用,内部通过链表结构链接起来
- 剩余资源数:进行资源空间的管理,不足够是进行扩容!
//--- 定长池的实现 ---
#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函数的书写逻辑是:
- 如果是第一次进行new,那么就开辟一个大空间,然后取出需要的空间进行返回
- 如果不是第一次开辟,那么就要进行一个选择,如果自由链表中有内存块,就直接拿来使用。如果没有就在大空间中取出一部分进行使用!
- 每次开辟都要对剩余容量进行处理!
需要注意的是进行强制类型转换我们使用c++11通过的新接口来确保安全!
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*
的大小,真好就是对应指针的大小,就可以进行取出使用了!
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,直接按页传递内存!
#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 性能测试
我们完成了定长池的书写,接下来我们来看看他的效率怎么样!!!
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倍啊!!!很好很好!!!
这样定长池就完成了
定长池中最关键的有三点:
- 在32位 / 64位系统下取出一个指针的空间,使用二级指针,二级指针解引用一定是当前环境下只指针的大小!
- 自由链表的内存块的链接,需要特别注意,其中没有多加指针,而是是否巧妙的利用类型转换进行使用!
- 如何实现的内存分配以及资源复用?通过大空间的切割和自由链表的回收!!!