|
文章目录
- 项目介绍
- 当前项目做的是什么?
- 技术栈
- 内存池是什么?
- 池化技术
- 内存池
- 内存池主要解决的问题
- malloc
- 定长内存池
- 学习目的
- 定长内存池设计
项目介绍
当前项目做的是什么?
这个项目是实现一个高并发的内存池
, 它的原型是 Google 的一个开源项目 TCMalloc, 全称是 Thread-Caching Malloc, 即线程缓存的 malloc, 实现了高效的多线程内存管理
, 用于替代系统中的内存分配函数(malloc, free).
这个项目并不是把 TCMalloc 从头到尾实现一遍, 而是把 TCMalloc 中最核心的框架简化后拿出来, 模拟实现一个我们自己的 mini 版本的高并发内存池, 目的就是学习 TCMalloc 的精华, 有点类似我们之前学习 STL 容器的方式(模拟实现, 不是为了造轮子, 而是向当时顶尖的C++前辈学习, 同时也方便我们更好的理解这部分内容).
技术栈
这个项目的技术栈主要用到了 C/C++, 数据结构(链表, 哈希桶), 单例模式, 操作系统内存管理, 互斥锁, 多线程等方面的知识.
内存池是什么?
池化技术
所谓的"池化技术", 指的是程序先向系统申请过量的资源, 然后自己管理, 以备不时之需.
之所以要申请过量的资源, 是因为每次申请该资源都有较大的开销, 所以我们提前申请好了, 这样使用时就会变得非常快捷, 以便提高程序的运行效率
.
在计算机中, 有很多使用"池"这种技术的地方, 除了内存池, 常见的还有连接池, 线程池, 对象池等. 我们之前学习过线程池, 所以这里就以服务器上的线程池为例, 它的主要思想是: 一开始先启动若干数量的线程, 让它们处于睡眠状态, 当接受到客户端的请求时, 唤醒池中某个睡眠的线程, 让它来处理客户端的请求, 当处理完这个请求, 线程又进入睡眠状态.
内存池
内存池是指程序预先从操作系统中申请一块足够大的内存, 在此之后, 当程序中需要申请内存的时候, 不再是直接向操作系统申请, 而是直接从内存池中获取; 同理, 当程序释放内存的时候, 并不是真正将内存返回给操作系统, 而是返回给内存池. 当程序退出或在特定的时间, 内存池才将之前申请的内存真正释放
.
内存池主要解决的问题
内存池主要解决的当然是效率的问题, 这是毋庸置疑的, 其次如果站在系统的内存分配器的角度, 还需要解决一下内存碎片的问题. 说到这里, 那什么是内存碎片呢?
string 对象和 list 对象销毁后, 释放空间, 所以图中还有256Bytes的空间, 但是此时我们要申请超过128Bytes的空间却申请不出来, 因为这两块空间不连续了, 即所谓的碎片化.
这里还需要补充说明的是: 内存碎片实际上分为外碎片和内碎片, 上面我们讲的是外碎片问题.
- 外碎片是一些空闲的小块内存区域,由于这些内存空间不连续,以至于合计的内存足够,但是不能满足一些内存分配申请需求。
- 内碎片是由于一些对齐的需要,导致分配出去的空间中一些内存无法被利用。
malloc
对于 malloc 函数, 我们是不陌生的, 因为在 C/C++ 中我们要动态申请内存都是通过调用 malloc 函数去申请(C++中的 new, 底层也是封装了 malloc 函数), 但是我们要知道, 实际上我们不是直接去堆上获取内存的, 我们所熟知的 malloc, 本质就是一个内存池
.
用一个形象的比喻就是, malloc 函数相当于向操作系统"批发"了一块较大的内存空间, 然后"零售"给程序用. 当全部"售完"或程序有大量的内存需求时, 再根据实际需求向操作系统"进货".
malloc 的实现方式有很多种, 一般不同的编译器平台用的都是不同的. 比如常见的Windows的VS系列的编译器用的是微软自己写的一套, Linux gcc 用的是 glibc 中的 ptmalloc.
定长内存池
学习目的
作为 C/C++ 程序员, 我们知道申请内存使用的是 malloc, malloc 其实就是一个通用的大众货, 什么场景下都可以用, 这也就意味着它在什么场景下都不会有很高的性能, 而我们所学习的 tcmalloc 在多线程环境下比 malloc 性能高得多
.
之所以先实现一个定长内存池, 是因为它在我们后面的高并发内存池中也是有价值的, 所以学习定长内存池有两个目的:
- 熟悉简单内存池是如何控制的;
- 将作为高并发内存池的一个基础组件.
定长内存池设计
所谓的定长内存池, 顾名思义就是固定大小的内存.
在讲解之前呢, 我们先解决一个问题:
如何直接向堆申请内存?
因为是内存池, 所以我们首先得向系统申请一块内存空间,然后对其进行管理。如果想直接向堆申请内存空间,在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;
}
如何实现定长内存池中的定长?
一开始我们可以使用非类型的模板参数, 使得在该内存池中申请到的对象的大小都是N。
template<size_t N>
class ObjectPool
{};
但是考虑到定长内存池在后面会作为高并发内存池的一个基础组件, 所以这里我们使用模板参数来实现定长. 比如创建定长内存池时传入的对象类型是int,那么该定长内存池就只支持4字节大小内存的申请和释放。
template<class T>
class ObjectPool
{};
如何设计定长内存池?
我们直接向堆中申请大块内存后, 需要使用一个指针对其进行管理, 由于需要对大块内存进行切分, 所以仅一个指针是不够的, 还需要一个整型变量用于标识内存块中剩余字节数.
同时为了便于对大块内存的切分操作, 该指针类型使用char*, 而不是void*, 因为指针类型决定了执行±操作向前或向后走一步的步长, 而 void* 的解引用和±操作都是没有意义的.
补充内容:指针类型的意义
- 指针类型决定了指针在解引用的时候一次能访问几个字节(也叫指针的权限)
- 指针类型决定了指针向前或向后走一步的步长(±整数),单位是字节.
释放回来的小块内存也是需要被管理的, 那如何管理呢?可以使用一个链表对其进行管理, 定义一个指针指向这个链表的头, 我们把这个管理释放回来的小块内存的链表叫做自由链表.
对于释放回来的小块内存, 不需要专门为它们定义一个链式结构, 我们可以让小块内存的前4个字节(32位平台)或前8个字节(64位平台)存储后一个小块内存的首地址.
综上所述, 定长内存池的成员变量有:
- _memory: 指向大块内存的指针
- _leftBytes: 大块内存剩余的字节数
- _freeList: 管理还回来的内存对象的自由链表
内存池如何申请对象?
内存池申请对象的时候需要注意, 可以从大块内存中取, 也可以从自由链表中取, 如果自由链表有内存块对象的时候, 优先从自由链表中取, 即头删, 时间复杂度是O(1).
如果自由链表中没有内存块对象的时候, 那么我们就要在大块内存中切出定长的内存对象, 需要注意切出后及时更新 _memory 的指向和 _leftBytes 的值.
当大块内存剩余的字节数不足以存储下一个地址的值时, 则需要调用上方的 SystemAlloc 函数重新开辟大块空间.
代码实现:
// 申请空间
T* New()
{
T* obj = nullptr;
// 如果自由链表有对象, 直接取出一个(头删)
// 优先从自由链表中取内存
if (_freeList)
{
obj = (T*)_freeList;
_freeList = *((void**)_freeList);
}
else
{
// 注意:不管内存块对象有对大, 至少要存的下一个地址的值
size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
// 剩余内存不够开辟一个对象大小时, 则重新开辟大块内存
if (_leftBytes < objSize)
{
_leftBytes = 128 * 1024;
// _memory = (char*)malloc(_leftBytes);
_memory = (char*)SystemAlloc(_leftBytes >> 13);
if (_memory == nullptr)
{
throw std::bad_alloc();
}
}
obj = (T*)_memory;
_memory += objSize;
_leftBytes -= objSize;
}
// 使用定位new调用T的构造函数初始化
// 对已分配的原始内存空间中显示调用构造函数初始化
new(obj)T;
return obj;
}
内存池如何管理释放的对象?
将释放回来的内存块对象头插进自由链表, 时间复杂度是O(1)
试想我们如何保证一个指针解引用后在32位平台下能够访问4个字节, 在64位平台下能够访问8个字节? 前面我们说了, 指针类型决定了指针在解引用操作时一次能够访问几个字节.
所以只要是二级指针
都能够完成上述要求.
我们将其封装成为一个函数:
void*& NextObj(void* ptr)
{
return (*(void**)ptr);
}
还有一点需要注意,在释放对象的时候,我们应该显示调用该对象的析构函数清理该对象,因为该对象可能还管理着其他某些资源,如果不对其进行清理那么这些资源将无法被释放,从而导致内存泄漏。
代码实现:
// 释放对象
void Delete(T* obj)
{
// 显示调用T的析构函数进行清理
obj->~T();
// 头插到_freeList
NextObj(obj) = _freeList;
_freeList = obj;
}
定长内存池整体代码
//定长内存池
template<class T>
class ObjectPool
{
public:
T* New()
{
T* obj = nullptr;
// 如果自由链表有对象, 直接取出一个(头删)
// 优先从自由链表中取内存
if (_freeList)
{
obj = (T*)_freeList;
_freeList = NextObj(_freeList);
}
else
{
// 注意:不管T对象有对大, 至少要存的下一个地址的值(4/8)
size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
// 剩余内存不够开辟一个对象大小时, 则重新开辟大块内存
if (_leftBytes < objSize)
{
_leftBytes = 128 * 1024;
// _memory = (char*)malloc(_leftBytes);
_memory = (char*)SystemAlloc(_leftBytes >> 13);
if (_memory == nullptr)
{
throw std::bad_alloc();
}
}
obj = (T*)_memory;
_memory += objSize;
_leftBytes -= objSize;
}
// 使用定位new调用T的构造函数初始化
// 对已分配的原始内存空间中显示调用构造函数初始化
new(obj)T;
return obj;
}
void*& NextObj(void* ptr)
{
return (*(void**)ptr);
}
void Delete(T* obj)
{
// 显示调用T的析构函数进行清理
obj->~T();
// 头插到_freeList
NextObj(obj) = _freeList;
_freeList = obj;
}
private:
char* _memory = nullptr; // 指向大块内存的指针
size_t _leftBytes = 0; // 大块内存剩余的字节数
void* _freeList = nullptr; // 管理还回来的内存对象的自由链表(头指针)
};
性能测试
对比测试 malloc, free 与定长内存池:
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);
//malloc和free
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();
//定长内存池
ObjectPool<TreeNode> TNPool;
std::vector<TreeNode*> v2;
v2.reserve(N);
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;
}
结果如下:
我们发现使用定长内存池中的 New 和 Delete 明显比 malloc 和 free 消耗的时间少, 这是因为在申请定长的内存时, 定长内存池比 malloc 要高效, 毕竟定长内存池是为了申请定长的内存对象而专门设计的.