一、项目介绍
我们写的这个高并发内存池是google公司开源的一个叫tcmalloc的项目,tcmalloc全称 Thread-Caching Malloc,即线程缓存的malloc,实现了高效的多线程内存管理,用于替代系统的内存分配相关的函数如malloc、free,它的知名度也是非常高的,不少公司都在用它,甚至Go语言直接用它做了自己内存分配器。
这个项目是把tcmalloc最核心的框架简化后拿出来,模拟实现出一个自己的高并发内存池,目的就是学习tcamlloc的精华,通过这个项目的实现学习C++高手的设计思路
二、什么是内存池
池化技术
池化技术就是程序预先向操作系统申请一大批资源,然后自己管理起来,以备后续使用,那为什么要提前申请资源呢?因为后续程序多次少量申请资源的开销是挺大的,不如提前就申请一大批资源,供后续使用,这样可以大大的提高程序效率。
在计算机中很多地方都是用了 “池” ,例如:线程池、内存池、连接池、对象池等,以服务器上的线程池来讲,他的思想就是先申请一批线程,让这些线程先休眠起来,当后续客户端发送过来请求时,就唤醒一个线程来处理,当请求处理完成再次让线程休眠起来供后续的请求使用。
内存池概述
而我们今天的内存池指的是,程序预先向操作系统申请一大批内存空间,当程序后续需要申请内存时,不直接向操作系统申请,而是从我们申请的内存池中申请,同理,当程序要释放这段空间时,并不是将这段空间返还给操作系统,而是还给内存池,当程序退出时,内存池才会将申请的一大批内存返还给操作系统。
那我们设计的内存池需要解决什么样的问题呢?
首先最重要的一定是先解决效率的问题,如果要作为系统内存分配器的话,还需要解决一下内存碎片的问题,接下来我们简单了解一下什么是内存碎片
假设我们当前要申请500个字节的空间,由于之前有的对象释放了自己空间,这些空间的总和其实是大于500字节的,但是由于他们并不是连续的空间,而是碎片化的,这些空间并不能被有效的利用。其实内存碎片化分为内碎片和外碎片,我们上述讲的就是外碎片的问题,而内碎片是由于一些内存对齐对则而产生的问题,后续我们的项目中会有所体现。
malloc
C/C++中我们要动态申请内存都是通过malloc去申请内存,C++中的new也是通过封装malloc实现的,实际我们不是直接去堆获取内存的, malloc其实就是一个内存池。
malloc的实现方 式有很多种,一般不同编译器平台用的都是不同的。比如windows的vs系列用的微软自己写的一套, linux gcc用的glibc中的ptmalloc。
三、设计一个定长的内存池
malloc在什么场景 下都可以用,但是什么场景下都可以用就意味着什么场景下都不会有很高的性能。
而定长的内存池就是每次在内存池中申请的空间大小都是相等的内存池,所以我们可以将内存池的性能提升到极致,因为我们申请的内存块都是定长的,不需要考虑内存碎片的问题,通过这个开胃菜我们可以简单熟悉一下内存池是如何控制的,并且它到在项目中也是一个组件
设计思路:
成员对象设计
首先,我们需要向OS申请一片内存空间,这段空间可以通过一个指针来管理,而为了方便对这段空间进行操作,建议将这个指针定义为char类型的,这样我们想要移动指针直接给他加减n就可以了,维护这段空间,我们还需要一个整数类型来保存它剩余的空间大小
而对于要释放的空间我们也需要管理起来,实现思路是将要释放的内存块管理起来组成一个链表,我们将这个链表称为自由链表,为了管理这个自由链表,我们还需要定义一个指针。
所以要实现定长内存池,我们需要如下的成员对象:
char* _memory=nullptr; //要开辟的大内存块
size_t _remain_size=0; //内存块后边剩余的字节大小
void* _free_list; //维护返还内存的链表指针
如何做到定长
我们可以利用非类型模版参数,让内存池内次申请的内存块大小都是固定的
template<size_t N>
class ObjectPool
{};
也可以通过模版来实现,我们将内存池设计为模版类,创建一个内存池需要给他赋一个类型,这样每次申请的对象都是一个类型的,也就做到了定长,这里我们采用模版类的方式
template<class T>
class ObjectPool
{};
如何管理内存池中要释放的内存块
上述说了我们要通过自由链表的方式俩管理这些内存块,其实我们并不需要设计一个链表的结构,可以用这些内存块的前4个字节(32位)或者前8个字节(64位)作为一个指针指向下一个内存块,在用一个头指针来维护这个链表即可
但是这里有一个问题:如何能让一个指针在32位下访问前四个字节,在64位下访问前8个字节?
例如,我们想要访问一段空间的前四个字节,假设这个空间的其实地址为a,那我们可以先将这个空间的地址强制转换为 int* 类型,然后解引用,因为解引用后是int类型,这样就可以访问这个空间的前四个字节了,指针的类型决定了他解引用后可以访问的字节大小。我们现在也可以利用这个思路,由于指针在不同位数的机器下的大小是不同的,所以我们可以将这个空间的地址强转为一个二级指针类型,再解引用,这样在32位机器下,就可以访问前四个字节,在64位机器下就可以访问前8个字节了。
当一个内存块要释放时,我们可以直接采用头插的方式将它加入到自由链表中,因为这样很方便不需要再遍历一遍链表了。
void Delete(T* obj)
{
//显示调用obj的析构函数
obj->~T();
//头插
*(void**)obj = _free_list;
_free_list = *(void**)obj;
}
内存池如何申请一个对象
当内存池想要申请一个对象时,优先应该使用前边要释放的内存,及在_free_list中维护的内存块;如果_free_list为空,说明前面的空间都还没有释放,那我们就需要分配内存池后边的空间,如果后面的空间不够申请一个对象,那内存池就需要重新向OS申请一块大内存,再申请对象
T* New()
{
T* obj = nullptr;
//如果有返回的小内存块,则先用返还的小的,否则再用大内存块后面的
if (_free_list)
{
obj = (T*)_free_list;
_free_list = *(void**)obj;
}
else
{
//如果后面的空间大小不够一个T类型对象的大小,那就新开辟一个空间
if (_remain_size < sizeof(T))
{
_remain_size = 128 * 1024;
_memory = (char*)malloc(_remain_size);
if (_memory == nullptr)
{
std::cerr << "malloc false!" << std::endl;
exit(-1);
}
}
obj = (T*)_memory;
//由于要用指针管理小的内存块,为了避免T的大小小于指针的大小,T的大小大于指针大小,那就让指针向后走T的大小,否则就走指针的大小
size_t objsize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
_memory += objsize;
_remain_size -= objsize;
}
//对象的空间分配好了,还没结束,给他调用一下构造函数
new(obj)T;
return obj;
}
注意这里还有一个小问题,因为我们上面的自由链表是利用内存块前4个字节或者8个字节作为指针来维护的,那如果我们申请的对象大小都不够指针的大小怎么办呢?所以在分配空间时就需要判断一下,如果对象的大小大于当前机器的指针大小,那就让_memory向后走sizeof(obj),否则就让他+=一个指针的大小。
让内存池直接向堆按页申请空间
要想直接向堆申请内存空间,在Windows下,可以调用VirtualAlloc函数;在Linux下,可以调用brk或mmap函数。
#ifdef _WIN32
#include <Windows.h>
#else
//...
#endif
//直接去堆上申请按页申请空间
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;
}
定长内存池完整代码:
#pragma once
#include<iostream>
#ifdef _WIN32
#include <Windows.h>
#else
//...
#endif
//直接去堆上申请按页申请空间
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;
}
template<class T>
//定长内存池
class ObjectPool
{
public:
T* New()
{
T* obj = nullptr;
//如果有返回的小内存块,则先用返还的小的,否则再用大内存块后面的
if (_free_list)
{
obj = (T*)_free_list;
_free_list = *(void**)obj;
}
else
{
//如果后面的空间大小不够一个T类型对象的大小,那就新开辟一个空间
if (_remain_size < sizeof(T))
{
_remain_size = 128 * 1024;
_memory = (char*)SystemAlloc(_remain_size>>13);
if (_memory == nullptr)
{
throw std::bad_alloc();
}
}
obj = (T*)_memory;
//由于要用指针管理小的内存块,为了避免T的大小小于指针的大小,T的大小大于指针大小,那就让指针向后走T的大小,否则就走指针的大小
size_t objsize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
_memory += objsize;
_remain_size -= objsize;
}
//对象的空间分配好了,还没结束,给他调用一下构造函数
new(obj)T;
return obj;
}
void Delete(T* obj)
{
//显示调用obj的析构函数
obj->~T();
//if (_free_list == nullptr)
//{
// _free_list = *(void**)obj;
//}
//else
//{
// //头插
// *(void**)obj = _free_list;
// _free_list = *(void**)obj;
//}
*(void**)obj = _free_list;
_free_list = obj;
}
private:
char* _memory=nullptr; //要开辟的大内存块
size_t _remain_size=0; //内存块后边剩余的字节大小
void* _free_list=nullptr; //维护返还内存的链表指针
};
定长内存池与malloc性能对比测试:
#include"ObjictPool.h"
#include<vector>
using namespace std;
struct TreeNode
{
int _val;
TreeNode* _left;
TreeNode* _right;
TreeNode()
:_val(0)
, _left(nullptr)
, _right(nullptr)
{}
};
void TestObjectPool()
{
// 申请释放的轮次
const size_t Rounds = 3;
// 每轮申请释放多少次
const size_t N = 100000;
size_t begin1 = clock();
std::vector<TreeNode*> v1;
v1.reserve(N);
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();
ObjectPool<TreeNode> TNPool;
size_t begin2 = clock();
std::vector<TreeNode*> v2;
v2.reserve(N);
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 < 100000; ++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;
}
int main()
{
TestObjectPool();
return 0;
}
这份代码是通过比较malloc和定长内存池申请和释放大量空间的速率来比较性能强弱的,我们调到release模式下来看一下结果怎么样
可以看到定长内存池相比于malloc的性能是更高的,这是因为malloc要考虑所有场景下的使用,而我们的定长内存池只需要考虑一个场景即可,所以在特定场景下定长内存池的性能是很高的,这也是"寸有所长,尺有所短"的道理
四、高并发内存池整体设计框架
项目解决的问题
现在很多的开发环境都是多核多线程,在申请内存的场景下,必然存在激烈的锁竞争问题。malloc本身其实已经很优秀,那么我们项目的原型tcmalloc就是在多线程高并发的场景下效率更高。实现一个内存池需要考虑效率和内存碎片的问题,但是对于高并发内存池来说,还需要考虑在多线程的环境下,锁的竞争问题
整体设计框架
模块说明
高并发内存池一共分为三个模块:
- thread cache:线程缓存是每个线程独有的,用于小于256KB的内存的分配
- central cache:中心缓存是所有线程所共享,thread cache是按需从central cache中获取的对象。
- page cache:页缓存是在central cache缓存上面的一层缓存,存储的内存是以页为单位存储及分配的,central cache没有内存对象时,从page cache分配出一定数量的page,并切割成定长大小的小块内存,分配给central cache。当一个span的几个跨度页的对象都回收以后,page cache 会回收central cache满足条件的span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。
说明:
在申请内存时,一次性申请大于256K的情况是很少见的,而每一个线程都独享一个cache,这意味着线程在thred cache中申请内存是不需要加锁的,这就是高并发内存池的高效之处
当thread cache中内存不足时,它就会向central cache申请内存,而central cache并不是它申请一次就给他分配一个内存块,而是根据实际情况一次性给他分配多个,将剩余的内存块挂接到thread cache内部供以后使用。
当thread cache空闲的内存较多时,他还会将部分内存归还给central cache,让这部分内存可以分配给其他的线程,这样可以避免内存的浪费和其他thread cache内存吃紧的问题。
在thread cache模块申请内存是不需要加锁的,而当多个thread cache中内存不足,同时向central cache申请内存时,此时就需要加锁,但是此时的锁并不是直接锁整个central cahce的而是桶锁,因为只有多个线程同时访问centarl cache的同一个桶时才会发生竞争,所以central cache的锁竞争问题不是很激烈
五、Thread Cache模块实现
thread cache设计
在定长内存池中由于申请的内存块都是同样大小的,所以只需要一个freelist来管理,但是对于我们的项目我们就需要考虑多个场景了,所以我们可以用多个freelist来管理释放的内存块,他的结构其实就是哈希桶结构。
但是我们要考虑一个问题,申请内存的场景是有很多的,如果我们对于每个字节都用freelist来管理,这样的开销是很大的,管理256K的字节就需要 256*1024 个指针来管理,有点得不偿失。
我们可以让他按照一种规则来向上对齐,例如申请1~8字节就给他分配8个字节,申请9~16字节就给他分配16个字节,依次类推。
按照这样的对齐规则,其实会产生一些内存碎片的,例如我要申请6个字节的内存,但是实际却给我分配了8个字节,那其中就有两个字节无法被利用,这样的碎片就是内碎片
当申请某个大小的内存时,就需要先计算出他的对齐内存是多少,然后在根据对齐内存的大小找到对应的桶,如果这个桶下面挂着内存块,那就直接去一个下来直接分配,否则就需要到下一层central cache申请内存
哈希桶的结构就是freelist的一个数组,首先我们先将freelist类设计出来
static void*& NextObj(void* obj)
{
return *(void**)obj;
}
class FreeList
{
public:
void Push(void* obj)
{
assert(obj);
//头插
//*(void**)obj = _free_list;
NextObj(obj) = _free_list;
_free_list = obj;
}
void* Pop()
{
assert(_free_list != nullptr);
void* obj = _free_list;
_free_list = NextObj(obj);
return obj;
}
bool Empty()
{
return _free_list == nullptr;
}
private:
void* _free_list=nullptr;
};