欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 科技 > IT业 > 【从零实现高并发内存池】- 项目介绍、原理 及 内存池详解

【从零实现高并发内存池】- 项目介绍、原理 及 内存池详解

2025/4/19 16:38:37 来源:https://blog.csdn.net/2301_77954967/article/details/147192668  浏览:    关键词:【从零实现高并发内存池】- 项目介绍、原理 及 内存池详解

📢博客主页:https://blog.csdn.net/2301_779549673
📢博客仓库:https://gitee.com/JohnKingW/linux_test/tree/master/lesson
📢欢迎点赞 👍 收藏 ⭐留言 📝 如有错误敬请指正!
📢本文由 JohnKi 原创,首发于 CSDN🙉
📢未来很长,值得我们全力奔赴更美好的生活✨

在这里插入图片描述

在这里插入图片描述

文章目录

  • 🏳️‍🌈一、项目介绍
    • 1.1 这个项目是做什么的
    • 1.2 要求用到的知识
  • 🏳️‍🌈二、内存池详解
    • 2.1 池化技术
    • 2.2 内存池
    • 2.3 内存池主要解决的问题
    • 2.4 malloc
  • 🏳️‍🌈三、模拟定长内存池
    • 3.1 成员变量
    • 3.1 管理已释放的内存空间
    • 3.2 构建对象内存空间
    • 3.3 析构函数
  • 🏳️‍🌈四、性能测试
  • 👥总结


🏳️‍🌈一、项目介绍

1.1 这个项目是做什么的

当前项目是实现一个高并发的内存池,他的原型是 google 的一个开源项目 tcmalloctcmalloc全称Thread-CachingMalloc即线程缓存的malloc,实现了高效的多线程内存管理,用于替代系统的内存分配相关的函数 (malloc、free)

我们这个项目是把 tcmalloc 最核心的框架简化后拿出来,模拟实现出一个自己的高并发内存池 ,目的就是学习tcamlloc的精华,这种方式有点类似我们之前学习STL容器的方式。但是相比STL容器部分,tcmalloc的代码量和复杂度上升了很多,大家要有心理准备。当前另一方面,难度的上升,我们的收获和成长也是在这个过程中同步上升。

另一方面 tcmalloc 是全球大厂 google 开源的,可以认为当时顶尖的C++高手写出来的,他的知名度也是非常高的,不少公司都在用它,Go语言直接用它做了自己内存分配器。所以很多程序员是熟悉这个项目的,那么有好处,也有坏处。好处就是把这个项目理解扎实了,会很受面试官的认可。坏处就是面试官可能也比较熟悉项目,对项目会问得比较深,比较细。如果你对项目掌握得不扎实,那么就容易碰钉子。

tcmalloc 源代码 https://gitee.com/mirrors/tcmalloc

1.2 要求用到的知识

这个项目会用到C/C++数据结构(链表、哈希桶)操作系统内存管理单例模式多线程互斥锁等等方面的知识,没有网络。难度算是T1

🏳️‍🌈二、内存池详解

2.1 池化技术

所谓 “池化技术”,就是程序 先向系统申请过量的资源,然后自己管理,以备不时之需。之所以要申请过量的资源,是因为每次申请该资源都有较大的开销,不如提前申请好了,这样使用时就会变得非常快捷,大大提高程序运行效率。

所以这种技术往往用在体量大的工程中,小的体量不如malloc等

在计算机中,有很多使用“池”这种技术的地方,除了内存池,还有连接池、线程池、对象池等。以服务器上的线程池为例,它的主要思想是:先启动若干数量的线程,让它们处于睡眠状态,当接收到客户端的请求时,唤醒池中某个睡眠的线程,让它来处理客户端的请求,当处理完这个请求,线程又进入睡眠状态。

2.2 内存池

内存池是指 程序预先从操作系统申请一块足够大内存,此后,当程序中需要申请内存的时候,不是直接向操作系统申请,而是直接从内存池中获取;

同理,当程序释放内存的时候,并不真正将内存返回给操作系统,而是返回内存池当程序退出(或者特定时间)时,内存池才将之前申请的内存真正释放

2.3 内存池主要解决的问题

内存池主要解决的当然是效率的问题,其次如果作为系统的内存分配器的角度,还需要解决一下内存碎片的问题。那么什么是内存碎片呢?

再需要补充说明的是内存碎片分为 外碎片内碎片,上面我们讲的外碎片问题。

  • 外部碎片 是一些空闲的连续内存区域太小,这些内存空间不连续,以至于合计的内存足够,但是不能满足一些的内存分配申请需求。
  • 内部碎片 是由于一些对齐的需求,导致分配出去的空间中一些内存无法被利用。内碎片问题,我们后面项目就会看到,那会再进行更准确的理解。

在这里插入图片描述

2.4 malloc

C/C++ 中我们要动态申请内存都是通过 malloc 去申请内存,但是我们要知道,实际我们不是直接去堆获取内存的

malloc 就是一个内存池。malloc() 相当于向操作系统“批发”了一块较大的内存空间,然后“零售”给程序用。当全部“售完”或程序有大量的内存需求时,再根据实际需求向操作系统“进货”

malloc 的实现方式有很多种,一般不同编译器平台用的都是不同的。比如 windows 的 vs 系列用的微软自己写的一套,linux gcc 用的 glibc 中的 ptmalloc。

下面有几篇关于这块的文章,大概可以去简单看看了解一下,关于 ptmalloc,学完我们的项目以后,有兴趣大家可以去看看他的实现细节。

一文了解,Linux内存管理,malloc、free实现原理
malloc()背后的实现原理–内存池
malloc的底层实现(ptmalloc)

在这里插入图片描述

🏳️‍🌈三、模拟定长内存池

作为程序员(C/C++)我们知道申请内存使用的是 mallocmalloc 其实就是一个通用的大众货,什么场景下都可以用,但是什么场景下都可以用就意味着什么场景下都不会有很高的性能

下面我们就先来设计一个 定长内存池 做个开胃菜,当然这个定长内存池在我们后面的高并发内存池中也是有价值的,所以学习他目的有两层,先熟悉一下简单内存池是如何控制的第二他会作为我们后面内存池的一个基础组件

在这里插入图片描述

3.1 成员变量

因为 内存池 利用的是 池化技术 ,所以,我们需要一个 空间,此外,为了回收利用这些 空间,还需要一个接收释放空间的链表,就如下图一样
在这里插入图片描述
为了使我们在判断时更加方便,可以再定义一个 记录 剩余内存字节数 的变量

	private:char* _memory = nullptr;	// 指向大块内存的指针size_t _remainBytes = 0;		// 剩余内存字节数void* _freeList = nullptr;		// 空闲链表,指向下一个空闲对象的地址
  • memory
    指向分配的大块内存。
    内存池以“页”为单位申请内存(如 128KB),避免频繁系统调用。
  • remainBytes
    记录当前内存块中剩余的可用字节数。
    用于判断是否需要申请新的内存块。
  • freeList
    空闲链表的头节点,用于管理已释放的对象。
    每个节点存储下一个可用对象的地址(类似链表指针)。

3.1 管理已释放的内存空间

我们对于一个个已经使用完的内存空间,使用 链表 将他们串连起来就行了,确保每一个节点的头几位能够存放下下一个地址的地址大小,也就是 void*

在这里插入图片描述

但是因为下一个对象内存空间尚未确定,可能是 int,也可能是 long long,所以我们可以利用 二级指针,先将该内存块的地址先强转为二级指针,由于二级指针存储的是一级指针的地址,所以再解引用就能得到下一个对象的地址。

  • void*:一个通用指针类型,可以指向任意类型的数据(但丢失了类型信息)。
  • void** :指向 void* 的指针,即它存储的是 void* 指针的地址。
static void*& NextObj(void* obj) {// 转换为 void** 类型,再解引用,得到下一个对象的内存地址// obj是一个指针,指向一个对象的内存地址//  对象内存地址// [ next_ptr (8字节) ][ 对象数据 (sizeof(T)字节) ]// obj 是对象的起始地址// void** pp = (void**)&obj; // pp 指向 obj 的地址(即对象内存的起始位置)// return *(void**)obj; // 取出下一个对象的内存地址	return *(void**)obj;
}

3.2 构建对象内存空间

其实就是构造函数,这里我们需要注意

  • 先将利用已释放的对象内存空间
  • 如果没有,就判断当前的剩余空间够不够,不够的话,再重新分配一块新的内存
  • 判断这样得到的对象内存空间是否有足够的开头空间,来存储下一个对象的地址
  • 确保显示调用构造函数,一定要 new 一下】
T* New() {T* obj = nullptr;// 先把内存池中之前释放的内存块的对象取出来,再次利用if (_freeList) {void* next = *((void**)_freeList);obj = (T*)_freeList;_freeList = next; // 取出下一个对象} else {// 池化技术:提前分配一块大内存,避免频繁的内存分配和释放// 如果内存池中内存不够一个对象大小时,则重新分配一块新的内存if (_remainBytes < sizeof(T)) {_remainBytes = 128 * 1024;_memory = (char*)SystemAlloc(_remainBytes >> 13);if (_memory == nullptr) {// throw bad_alloc(); 表示主动抛出一个内存分配失败的异常throw std::bad_alloc();}_memory += sizeof(T);_remainBytes -= sizeof(T);}obj = (T*)_memory;// 这里判断一下当前分配的内存,至少需要能够存下下一个对象地址的大小size_t objSize = sizeof(T) < sizeof(void*)? sizeof(void*): sizeof(T); // 取对象大小和指针大小的最大值_memory += objSize;_remainBytes -= sizeof(T);}// 定位 new,显示调用T的构造函数初始化new (obj) T; // 在已分配的内存 obj 上调用 T 的构造函数return obj;
}

3.3 析构函数

先理清楚空闲链表的结构。

通常,空闲链表是一个单链表,每个节点保存下一个可用对象的地址。当释放一个对象时,需要将它插入链表的头部,这样下次分配时可以直接取用。插入操作通常是头插法,即把新节点的next指向原来的头节点,然后更新头节点为新节点。

void Delete(T* obj) {// 显示调用析构函数,释放对象资源obj->~T();// 头插// 这里选择使用 void** 而不是 int* 是因为 void*// 是一个通用指针类型,可以指向任意类型的数据 另外 使用 int**// 等二级指针的效果是一样的// 他的目的都是为了指向目标类型的地址,可以自己分辨是 int 还是 long long// 类型// void*:一个通用指针类型,可以指向任意类型的数据(但丢失了类型信息)。// void** :指向 void* 的指针,即它存储的是 void* 指针的地址。*(void**)obj = _freeList;_freeList = obj;
}

🏳️‍🌈四、性能测试

既然已经模拟实现了 定长内存池,那就来对比一下与普通的 malloc 之间的差距

这里我们先将测试数据设大一点

// 每轮申请释放多少次
const size_t N = 10000000;

在这里插入图片描述

来个十万试试
在这里插入图片描述

但是如果设为100或者更小的数据,就看不出差距了
在这里插入图片描述

#pragma once#include <iostream>
#include <vector>using std::cout;
using std::endl;#ifdef _WIN32
#include<windows.h>
#else
// 
#endif// 定长内存池
//template<size_t N>
//class ObjectPool
//{};// 直接去堆上按页申请空间
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;
}template<typename T>
class ObjectPool {public:T* New(){T * obj = nullptr;// 先把内存池中之前释放的内存块的对象取出来,再次利用if (_freeList) {void* next = *((void**)_freeList);obj = (T*)_freeList;_freeList = next; // 取出下一个对象}else {// 池化技术:提前分配一块大内存,避免频繁的内存分配和释放// 如果内存池中内存不够一个对象大小时,则重新分配一块新的内存if (_remainBytes < sizeof(T)) {_remainBytes = 128 * 1024;_memory = (char*)SystemAlloc(_remainBytes >> 13);if (_memory == nullptr) {// throw bad_alloc(); 表示主动抛出一个内存分配失败的异常throw std::bad_alloc();}_memory += sizeof(T);_remainBytes -= sizeof(T);}obj = (T*)_memory;// 这里判断一下当前分配的内存,至少需要能够存下下一个对象地址的大小size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T); // 取对象大小和指针大小的最大值_memory += objSize;_remainBytes -= sizeof(T);}		// 定位 new,显示调用T的构造函数初始化new(obj)T;	// 在已分配的内存 obj 上调用 T 的构造函数return obj;}void Delete(T* obj) {// 显示调用析构函数,释放对象资源obj->~T();// 头插// 这里选择使用 void** 而不是 int* 是因为 void* 是一个通用指针类型,可以指向任意类型的数据// 另外 使用 int** 等二级指针的效果是一样的// 他的目的都是为了指向目标类型的地址,可以自己分辨是 int 还是 long long 类型// void*:一个通用指针类型,可以指向任意类型的数据(但丢失了类型信息)。// void** :指向 void* 的指针,即它存储的是 void* 指针的地址。*(void**)obj = _freeList;_freeList = obj;}private:char* _memory = nullptr;	// 指向大块内存的指针size_t _remainBytes = 0;		// 剩余内存字节数void* _freeList = nullptr;		// 空闲链表,指向下一个空闲对象的地址
};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 = 10000000;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;
}

👥总结

本篇博文对 【从零实现高并发内存池】- 项目介绍、原理 及 内存池详解 做了一个较为详细的介绍,不知道对你有没有帮助呢

觉得博主写得还不错的三连支持下吧!会继续努力的~

请添加图片描述

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com

热搜词