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
#endifusing std::cout;
using std::endl;// 直接去堆上按页申请空间
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else// linux下brk mmap等
#endifif (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位系统下取出一个指针的空间,使用二级指针,二级指针解引用一定是当前环境下只指针的大小!
- 自由链表的内存块的链接,需要特别注意,其中没有多加指针,而是是否巧妙的利用类型转换进行使用!
- 如何实现的内存分配以及资源复用?通过大空间的切割和自由链表的回收!!!