文章目录
- 本章内容概述
- 一、项目背景
- 二、项目架构
- 1. 内存池分析
- 2. 内存池设计
- 三、项目实现
- 1. 链栈 StackAlloc
- 2. 内存池 Mempoola
- 成员变量和功能实现
- 分配器构造函数
- 分配器析构函数
- 分配器分配内存函数
- 分配器回收内存函数
- 分配器构造内存函数
- 分配器析构内存函数
- 本章总结
本章内容概述
本文是笔者对在工作期间进行的项目开发进行总结和分析,项目主体为 基于 Linux 高性能内存池的数据缓存队列
,本质上是一个可以进行高速数据交换的容器,在本文中笔者将从项目需求出发,分析项目设计和项目难点,提出解决思路,希望对读者有所帮助。
一、项目背景
项目最初,Leader 的要求是设计一个可以同时缓存多种数据类型的容器
,容器需要保证先入先出
的存取规则,因此笔者理所当然的选择了对 STL 中的 deque 队列进行封装,并自行设计了数据结构满足多种数据的存取,插入数据可以通过重载实现,这也是第一版。
但是在测试过程中,由于数据输入频率很高
,但是取出速率较慢
,因此数据很容易在队列中积压
,栈区内存开销
会很大,而栈区内存只有在进程结束后才被回收,因此会导致整个进程内存占用较大
,为了解决这个问题,笔者选择将类中成员类型修改为指针
类型,这也就是第二版。
修改为指针后,当数据进入队列时分配堆区内存,当数据被取走后便可以即刻释放内存,从而一定程度上减少内存开销。虽然解决了内存开销问题,但是随着数据输入频率越来越高,渐渐产生了新的问题,即每次存入数据都需要在堆区申请内存,取出数据需要释放内存,频繁的内存申请与释放存在一定的安全隐患
,而且最关键的是,程序效率也会有所下降,笔者查阅后发现,是因为每次 new delete 都需要和操作系统进行对接,这里存在一定的开销,会影响程序效率,而且可能会出现内存不足的情况,为了解决这个问题,笔者查阅一些资料后决定选择内存池化技术
进行解决,感兴趣的读者可以参考项目开源地址,出于一些原因,在这里笔者并未给出通过内存池实现的数据缓存队列,相信感兴趣的读者可以通过给出的内存池实现和队列设计可以自行实现。
接下来笔者将着重分析在项目开发过程中如何实现这样一个可以进行动态内存分配
、减少系统申请
、提高程序效率
的内存池。
二、项目架构
笔者看来,本项目的核心在于内存池的设计与实现思路,因此会着重分析内存池相关代码,数据缓冲队列因为一些原因,笔者不在本文中详细讨论。
1. 内存池分析
在 C++ 开发过程中,内存管理通常需要开发者着重考虑,内存操作不当通常会导致整个程序的崩溃,堆区内存在带来便捷的同时也会产生许多隐患,如申请的内存忘记释放、使用释放后的内存等等,每次申请内存都需要向操作系统申请,这些操作都存在开销,而且还存在申请内存失败的意外情况,为了解决这一系列问题,智慧的设计者提出了内存池的设计想法。
内存池,是指通过池化技术提前规划出一片可供调用的内存,即在分配之前预备一块相当的内存,每当需要向堆区申请内存时,无需反复 new/delete,直接从预备的内存中取走即可。这样的设计可以降低调用 new/delete 的频率,也就是减少了向操作系统申请的频率,同时可以减少内存碎片的产生,稳定内存分配性能。
在设计之前,可以先从理论上分析内存池的可行性:假设在一个进程中,需要在堆区申请内存 n 次,每次消耗时间为 T,则总时间开销为 n * T;如果采用内存池技术,仅需 T 的时间便可获取到全部内存,因此,内存池设计具备可行性。
2. 内存池设计
既然需要一次性向内存申请足够大的一块内存,那么就需要程序开发者自行维护这一块内存的安全性和稳定性,这也是内存池设计的核心难点所在,笔者接下来会着重分析。
内存池的核心,是设计一个可以存储块状内存、根据数据类型进行分配、回收内存的内存分配器
,这样的分配器在 STL 中就存在,但是默认的 allocator 分配器中,allocate 和 deallocate 仅仅对 new & delete 进行封装,并没有特殊处理,因此笔者将自行实现一个具备 allocate & deallocate 的内存池 Mempoola。
除此之外,为了验证内存池设计的性能,还需要设计一个可以进行动态内存分配的数据结构作为载体,分别使用默认分配器和内存池作为内存分配工具,进行相同的数据输入输出,观察两者的分配效率。常用的测试工具为链表栈结构,因此,笔者将首先实现一个链表栈,作为测试分配其性能的工具。
三、项目实现
1. 链栈 StackAlloc
链表栈,是一种以链表形式组成的,具备栈功能的数据结构,遵循先入后出的存取规则,设计相对来说比较简单,主要探讨几处功能实现。
数据组织
,数据以节点形式逐个相连,每个节点保存着数据和前驱节点,维护一个头节点指向当前的最后一个节点。
数据存入
,需要存入数据时,生成一个新的节点,新节点的前驱节点指向原头节点,再将头节点指向新的节点即可。
数据取出
,将头节点数据取出,再将头节点前移即可。
链表栈的具体实现并不困难,感兴趣的读者可以参考项目开源地址,笔者就不在此处赘述了。
2. 内存池 Mempoola
内存池的设计与实现是本项目的重中之重,接下来笔者会结合源码,详细分析代码的设计思路和实现方式。
成员变量和功能实现
内存池成员变量
,首先分析,如何管理池中的大片内存,进行分配、回收和组织,代码如下:
// 用于存储内存池中的对象槽,只能被实例化为一个存放对象的槽或者实例化为一个指向存放对象槽的槽指针
union Slot_
{
T element;
Slot_ *next;
};
// 数据指针,本质为字符指针
typedef char *data_pointer_;
// 对象槽类型,可以存放一个对象
typedef Slot_ slot_type_;
// 对象槽指针,指向一个对象槽
typedef Slot_ *slot_pointer_;
// 指向当前的,还未分配完的内存区块
slot_pointer_ currentBlock_;
// 指向当前内存区块的,一个可以分配出的对象槽
slot_pointer_ currentSlot_;
// 指向当前内存区块的最后一个,可以分配出的对象槽
slot_pointer_ lastSlot_;
// 指向当前内存池中,全部空闲的对象槽
slot_pointer_ freeSlots_;
分析代码,不难看出,内存池会维护一片内存块,内存块可以分割为多个存放对象的对象槽,同时有一个空闲槽指针,指向空闲的对象槽。
函数功能实现
,分配器主要负责的功能有:分配内存、初始化内存、析构内存、回收内存等四个操作,此外还需要注意内存池本身的构造和析构函数,一共需要实现六个函数的功能,接下来笔者逐个分析。
分配器构造函数
分配器构造函数
,负责初始化分配器的各个成员变量,代码如下:
// 默认构造, 初始化所有内存指针
MemoryPool() noexcept
{
// 当前内存块,初始状态下为空指针
currentBlock_ = nullptr;
// 当前内存块的一个可分配对象槽,初始为空
currentSlot_ = nullptr;
// 当前内存区块的最后一个可分配对象槽,初始为空
lastSlot_ = nullptr;
// 当前内存区块中的空闲对象槽,初始也为空
freeSlots_ = nullptr;
}
分配器析构函数
分配器析构函数
,将内存区块依次释放,直至区块为空即可,代码如下:
// 析构函数,销毁当前内存池
~MemoryPool() noexcept
{
// 循环销毁内存池中分配的内存区块
slot_pointer_ curr = currentBlock_;
while (curr != nullptr)
{
slot_pointer_ prev = curr->next;
operator delete(reinterpret_cast<void *>(curr));
curr = prev;
}
}
分配器分配内存函数
分配器分配内存函数
,这个函数是分配器的核心函数,需要仔细分析,代码如下:
// 分配内存区块,并返回一个数据指针
pointer allocate(size_t n = 1, const T *hint = 0)
{
// 首先检查空闲链表有无空间,如果有空闲的对象槽,那么直接将空闲区域交付出去
if (freeSlots_ != nullptr)
{
// 强制类型转换为指定数据类型的指针
pointer result = reinterpret_cast<pointer>(freeSlots_);
// 空闲指针指向下一块空闲对象槽
freeSlots_ = freeSlots_->next;
return result;
}
// 空闲对象槽指针为空,则从内存区块中进行分配
else
{
// 当前对象槽超出当前内存块最后一个对象槽,表示当前区块已经全部分配,则分配一个新的内存区块
if (currentSlot_ >= lastSlot_)
{
// 分配一个新的内存区块,并指向前一个内存区块
// 申请一个指定大小的内存区块,获得一个字符指针
data_pointer_ newBlock = reinterpret_cast<data_pointer_>(operator new(BlockSize));
// 将字符指针强制类型转换为对象槽指针,并指向当前内存区块,从而连接全部区块
reinterpret_cast<slot_pointer_>(newBlock)->next = currentBlock_;
// 更新当前区块为获得新的内存区块后的区块指针
currentBlock_ = reinterpret_cast<slot_pointer_>(newBlock);
// 填补整个区块来满足元素内存区域的对齐要求
// 区块体指针指向区块保存一个区块指针后的地址
data_pointer_ body = newBlock + sizeof(slot_pointer_);
// 对区块体指针强制类型转换,转换为 long
uintptr_t result = reinterpret_cast<uintptr_t>(body);
// 区块体对齐大小
size_t bodyPadding = (alignof(slot_type_) - result) % alignof(slot_type_);
// 起始位置之前应当留出对齐内存所需空间
currentSlot_ = reinterpret_cast<slot_pointer_>(body + bodyPadding);
// 末尾位置 为 区块起始位置加区块大小减去一个数据大小+1
lastSlot_ = reinterpret_cast<slot_pointer_>(newBlock + BlockSize - sizeof(slot_type_) + 1);
}
// 将当前对象槽强制类型转换后返回指定数据类型指针
return reinterpret_cast<pointer>(currentSlot_++);
}
}
分析代码,可以看出,每一个区块都由区块的首指针连接,剩余部分在考虑到内存对齐后,被划分为一个个对象槽,等待分配。
分配器回收内存函数
分配器回收内存函数
,内存回收相对简单许多,仅需要将带回收的内存挂在空闲指针上等待下次分配即可,代码如下:
// 销毁指针 p 指向的内存区块
void deallocate(pointer p, size_t n = 1)
{
if (p != nullptr)
{
// reinterpret_cast 是强制类型转换符
// 要访问 next 必须强制将 p 转成 slot_pointer_
// 将待销毁的指针指向空闲对象槽,下次即可分配出
reinterpret_cast<slot_pointer_>(p)->next = freeSlots_;
// 空前槽节点更新
freeSlots_ = reinterpret_cast<slot_pointer_>(p);
}
}
分配器构造内存函数
分配器构造内存函数
,在分配给内存后,需要调用对象构造函数对内存初始化,代码如下:
// 调用对象构造函数,使用 std::forward 转发变参模板
template <typename U, typename... Args>
void construct(U *p, Args &&...args)
{
new (p) U(std::forward<Args>(args)...);
}
分配器析构内存函数
分配器析构内存函数
,在回收内存后,需要调用对象析构函数对内存清空,代码如下:
// 调用对象析构函数
template <typename U>
void destroy(U *p)
{
p->~U();
}
内存池的实现到这里就结束了,内存池只是池化技术的一种,池化技术的应用范围十分广泛,最典型的是线程池的应用,线程池在笔者的另一个项目中有所实现,感兴趣的读者可以阅读基于 Linux 的 Ngina-server 通信架构 C++ 实现,更加深入的线程池相关技术拓展,笔者会在另一篇文章中详细分析,敬请期待。
本章总结
本章主要分析了笔者在项目开发过程中遇到的内存池相关问题,手动实现了一个内存池用于提升程序效率,希望对读者有所帮助。
最后,我是Alkaid#3529,一个追求不断进步的学生,期待你的关注!