个人主页:Lei宝啊
愿所有美好如期而遇
一、项目介绍
这个项目做的是什么?我们要实现一个高并发的内存池,当然,我们这个项目是参考google的一个开源项目tcmalloc而实现的一个迷你版的高并发内存池,实现高效的多线程内存管理,用于代替系统提供的malloc和free。
二、内存池
1. 池化技术
池化技术就是一次性向系统申请过量的资源,然后自己管理。为什么要一次性申请过量的资源?其他人会告诉你是因为每一次申请资源都有较大开销,但是这些开销体现在哪里呢?
- 系统调用开销:在实际应用过程中,无论是申请内存,创建进程,线程等,都需要系统调用,而系统调用需要程序从用户态切换到内核态,调用完成后还要切换回去,这个过程是非常耗时的。每次资源的申请和释放都伴随着这样的过程,所以频繁的系统调用会大大增加系统的开销。
- 资源初始化和清理开销:对于数据库连接和线程等,他们的创建不仅仅是内存的申请和释放。在创建时,对数据库连接来说需要建立网络连接,对线程来说,需要分配一系列资源。这些操作对于系统来说也有一定的消耗。
- 资源竞争与同步开销:在多线程和多进程场景中,资源的分配和释放可能会引起竞争,需要采用同步机制来确保数据的一致性和线程安全,这些同步机制(信号量,锁)本身也会带来一定的开销,包括等待时间,硬件上下文切换等。
- 内存碎片及效率:频繁地申请和释放小块内存会导致内存碎片化,降低内存的利用率和访问效率(稍后我们后面会细说)。
在计算机中,有很多地方都采用了池化技术,除了内存池,还有对象池,线程池,连接池,进程池等。以线程池为例,它的主要思想是:先启动若干数量的线程,让它们处于睡眠状态,当接收到客户端的请求时,唤醒池中某个睡眠的线程,让它来处理客户端的请求,当处理完这个请求,线程又进入睡眠状态。
2. 内存池
内存池是指预先向系统申请一块较大的内存,此后,当程序需要申请内存时,不直接向系统申请,而是从内存池中获取;同理,释放内存时,也不交还给操作系统,而是交还给内存池,当程序退出时,内存池才会真正释放之前申请的内存。
3. 内存池解决的问题
内存池主要解决的是效率问题,以及内存碎片问题,什么是内存碎片?内存碎片分为内碎片和外碎片,这里我们先介绍外碎片,内碎片我们后面结合代码讲解。
此时如果我们再想申请超过256字节的内存,就申请不下来了,因为内存已经碎片化了,不连续。
官方点讲就是:外部碎片是⼀些空闲的连续内存区域太小,这些内存空间不连续,以至于合计的内存足够,但是不能满足一些的内存分配申请需求。
4. malloc
C/C++都是通过malloc去动态申请内存的(C++调用的new底层还会调用operator new, 它里面会调用malloc),但是我们要知道,实际上我们不是直接去堆申请内存的。
事实上malloc就是一个内存池。我们平时向系统申请20字节,他真的只申请20字节吗?不是的,他会申请更多,下次申请时如果足够就直接用,如果不够再继续向OS申请。
malloc的实现方式有很多种,一般不同编译器平台用的都是不同的。比如windows的vs系列用的微软自己写的⼀套,linux gcc用的glibc中的ptmalloc。
三、定长内存池
1. 解释和实现
作为程序员(C/C++)我们知道申请内存使用的是malloc,malloc其实就是⼀个通用的大众货,什么场景下都可以用,但是什么场景下都可以用就意味着什么场景下都不会有很⾼的性能。我们要实现的高并发内存池在多线程内存管理上要高效很多,接下来我们要实现的定长内存池将会作为他的一个基础组件。
_memory指向内存池开辟的空间,_freeList指向释放归还给内存池的空间,_remainCapacity指向_memory剩余的空间。
现在我们假设程序要调用New()向内存池申请空间,此时内存池是没有空间的,所以要开辟一段空间:_memory = (char*)malloc(128 * 1024); 那么_memory为什么是char*类型的呢?因为这个定长内存池我们想要设计成模版类,能够适应各种对象,如果_memory不设计成char*,那么对象申请空间后,_memory向后走时,一次移动的大小就不是一个字节,也就无法适应类似于char这样的对象或者自定义类型对象。那么开辟大小为什么是128 * 1024呢?这个我们后面会讲述。
_memory申请空间后,我们要判断他是否申请空间成功,如果申请失败,我们就抛异常:throw std::bad_alloc(); 申请成功后,就可以设置_remainCapacity的大小了。
于是我们顺利成章,定义一个T* obj作为返回值,obj = _memory, _memory += sizeof(T), _remainCapacity -= sizeof(T), 最后将obj返回。
_memory = (char*)malloc(128 * 1024);
if (_memory == nullptr)
{
throw std::bad_alloc();
}
_remainCapacity = 128 * 1024;
obj = (T*)_memory;
_memory += sizeof(T);
_remainCapacity -= sizeof(T);
那么_memory什么时候会去申请空间呢?_remainCapcity小于sizeof(T)时,申请空间,假如说,分配空间到最后几个字节,这几个字节不足以分配对象,那么这几个字节就丢弃掉不去使用,_memory再去申请。
if (_remainCapacity < sizeof(T))
{
_memory = (char*)malloc(128 * 1024);
if (_memory == nullptr)
{
throw std::bad_alloc();
}
_remainCapacity = 128 * 1024;
}
obj = (T*)_memory;
_memory += sizeof(T);
_remainCapacity -= sizeof(T);
那么有New也就有Delete,我们如何释放对象呢?
obj指向的空间现在要释放,我们就可以利用他头上的几个字节去存放地址,指向被释放的对象空间,但是对象类型是什么我们不知道,如果对象大小为char或者short等,存不下一个指针的大小怎么办?这就要修改我们的New方法。
obj = (T*)_memory;
int objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
_memory += objSize;
_remainCapacity -= objSize;
接下来我们要将释放的对象空间挂在_freeList链表上,并且我们采用头插,效率比尾插更高。
现在问题来了,头插代码怎么写?*(int*) = nullptr ?是这样吗,取他的头四个字节?可是你怎么知道这是32位平台还是64位平台,指针大小是不一样的,所以我们if判断指针大小去决定使用int*或者longlong*吗?可以,但是我们还有更好的方法。
*(void**)obj = nullptr; 将obj强转为void**,意思就是obj现在是一个指向void*的指针,解引用obj就能够操控void*大小的空间,对他进行修改,而不同平台指针大小的问题就不需要我们去担心了。
现在我们来谈Delete也就简单多了。
*(void**)obj = _freeList;
_freeList = obj;
现在,不仅仅是_memory可以分配对象空间,_freeList也可以,所以,我们之前的New逻辑可以进行更新了。
当_freeList有空间时,从头上去取空间,其实类似于头删。
if (_freeList)
{
void* next = *(void**)_freeList;
obj = (T*)_freeList;
_freeList = next;
}
所以,我们也就可以写出整体代码了,至此,定长内存池基本完成:
#pragma once
#include <iostream>
using std::cout;
using std::endl;
template<class T>
class ObjectPool
{
private:
char* _memory; // 内存池大小,char*为分配内存时,加减合理
void* _freeList; // 还回来的内存,构建成链表
int _remainCapacity; // 内存池的剩余大小
public:
ObjectPool()
:_memory(nullptr)
,_freeList(nullptr)
,_remainCapacity(0)
{}
T* New()
{
T* obj = nullptr;
if (_freeList)
{
void* next = *(void**)_freeList;
obj = (T*)_freeList;
_freeList = next;
}
else
{
//剩余空间不足以分配一个对象,且链表没有空间
if (_remainCapacity < sizeof(T))
{
_memory = (char*)malloc(128 * 1024);
if (_memory == nullptr)
{
throw std::bad_alloc();
}
_remainCapacity = 128 * 1024;
}
obj = (T*)_memory;
int objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
_memory += objSize;
_remainCapacity -= objSize;
}
new(obj)T;
return obj;
}
void Delete(T* obj)
{
obj->~T();
//将要归还的空间
*(void**)obj = _freeList;
_freeList = obj;
}
};
对于new(obj)T,叫做定位new,是在已申请的原始内存空间中调用对象的构造函数初始化对象。关于obj->~T(); 显式调用析构函数,并不是释放内存池的空间,而是去释放对象内部逻辑可能去自己申请的空间。
另外要修改的是,我们这里申请空间不使用malloc,因为他也是一个内存池,用于处理各种复杂场景,而我们这里只是需要单纯的申请空间,于是我们可以使用系统调用去申请空间,在Windows操作系统上,这个系统调用叫:VirtualAlloc
LPVOID VirtualAlloc(
LPVOID lpAddress, // 要分配的内存区域的地址
SIZE_T dwSize, // 分配的大小(以字节为单位)
DWORD flAllocationType, // 分配的类型
DWORD flProtect // 该内存的初始保护属性
);
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32
void* ptr = VirtualAlloc(0, kpage * (1 << 10), \
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else
// linux下brk mmap等
#endif
if (ptr == nullptr) throw std::bad_alloc();
return ptr;
}
_WIN32是一个在Windows编程中经常遇到的宏定义,它主要用于判断当前的编译环境是否为Windows 32位环境。_WIN32是VC(Visual C++)编译器在编译Windows程序时自动定义的一个宏。这意味着,只要在使用VC编译器编写Windows程序,无论目标平台是32位还是64位_WIN32宏通常都会被定义。但是,需要注意的是,在64位Windows系统上编译64位程序时,虽然_WIN32会被定义,但通常还会定义_WIN64宏来明确指示64位环境。
_memory = (char*)SystemAlloc(128);
2. 性能测试
#include "FixedLenMemPool.h"
#include <vector>
#include <time.h>
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 = 1000000;
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;
}
int main()
{
TestObjectPool();
return 0;
}
四、高并发内存池整体框架设计
现代开发环境大多都是多线程多进程,在申请内存的场景下,因为内存时临界资源,必然存在激烈的锁竞争问题,malloc本身其实已经足够优秀,但是我们的项目圆形tcmalloc就是在多线程高并发的场景更胜一筹,所以我们设计的内存池需要考虑下面几个问题:
- 性能问题
- 多线程环境下,锁竞争问题
- 内存碎片问题
高并发内存池主要由以下三个部分组成:
thread cache:线程缓存,每个线程独有一个thread cache,用于小于256KB的内存的分配,线程从这里申请内存不需要加锁,因为每个线程独有一个cache,这也是这个并发线程池高明的地方。
central cache:中⼼缓存是所有线程所共享,thread cache是按需从central cache中获取对象。当thread cache空间不足,就会从central cache中获取对象,central cache会在合适的时机回收thread cache中的对象,避免⼀个线程占用了太多的内存,而其他线程的内存吃紧,达到内存分配在多个线程中更均衡的按需调度的目的,central cache是存在竞争的,所以从这里取内存创建对象是需要加锁的,但是这里用的是桶锁,再一个只有thread cache没有内存创建对象时才会找central cache,所以这里竞争不会很激烈。
page cache:页缓存是在central cache缓存上面的一层缓存,存储的内存是以页为单位存储及分 配的,central cache没有内存对象时,从page cache分配出一定数量的page,并切割成定长大小的小块内存,分配给central cache。page cache如果要再申请内存,就是从系统申请了。
当⼀个span {span通常指的是一段连续的内存区域, 可以被划分为多个更小的单元(如页或对象)以供分配} 的几个跨度页的对象都回收以后,page cache会回收central cache满足条件的span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。
五、thread cache
thread cache是哈希桶结构,每个桶是一个按桶位置映射大小的的内存块对象的自由链表,不同桶的自由链表所指向的空间大小是不一样的(定长内存池办不到),也就是说,不同的对象可以向thread cache申请空间,并且,每个线程都有自己的thread cache,因此,在线程向各自的thread cache申请内存对象以及释放时,不会有锁的竞争。
我们首先创建ThreadCache类:
class ThreadCache
{
private:
public:
// 申请和释放内存对象
void* Allocate(size_t size);
void Deallocate(void* ptr, size_t size);
//从中心缓存获取内存对象
void* FetchFromCentralCache(size_t index, size_t size);
};
类属性我们怎么设计呢?根据我们上面的分析,我们需要一个哈希桶,并且每个桶的位置挂上一个自由链表,那么现在,我们可以先设计出自由链表类(有了上面定长内存池的铺垫,这个应该很好理解):
class FreeList
{
private:
void* _freelist;
void*& nextobj(void* cur)
{
return *(void**)cur;
}
public:
FreeList() :_freelist(nullptr)
{}
void push(void* obj)
{
nextobj(obj) = _freelist;
_freelist = obj;
}
void* pop()
{
void* obj = _freelist;
_freelist = nextobj(_freelist);;
return obj;
}
bool Empty()
{
return _freelist == nullptr;
}
};
我们哈希桶的设计打算使用数组,使用数组下标来映射桶的位置:
FreeList _freelist[MaxFreeListSize]; //MaxFreeListSize后面解释
到这里,我们其实可以发现一个问题,我们的自由链表中没有malloc空间之类的代码,这和我们的逻辑有关,我们设计的逻辑是,如果线程向thread cache申请内存对象,如果thread cache对应的桶的自由链表不为空,可以为他分配对象,那么就分配,否则,向central cache去申请内存对象,申请的内存对象释放时,