[项目设计] 从零实现的高并发内存池(一)

🌈 博客个人主页Chris在Coding

🎥 本文所属专栏[高并发内存池]

❤️ 前置学习专栏[Linux学习]

 我们仍在旅途                     

目录

前言

        项目介绍

1.内存池

        1.1 什么是内存池

        池化技术

        内存池

        1.2 为什么需要内存池

        效率原因

        内存碎片问题

        1.3 实现定长内存池理解池化技术

        定长内存池的设计

        _freelist的设计

        New()和Delete()的设计

        与malloc()做性能对比


前言

        项目介绍

首先,本项目的原型是Google的一个开源项目TCMalloc,TCMalloc是谷歌开发的一种内存分配器(Memory Allocator),专门用于优化大规模的多线程应用程序的内存分配和管理。TCMalloc的全称是Thread-Caching Malloc,意为线程缓存内存分配器。它最初是为谷歌的服务器应用程序而开发的,旨在解决大规模多线程服务器应用程序中内存分配性能不佳的问题。

Google作为世界级大厂,学习并复现该项目, 我们能体会到其精妙的项目架构设计

1.内存池

        1.1 什么是内存池

池化技术

池 是在计算机技术中经常使用的一种设计模式,其内涵在于:将程序中需要经常使用的核心资源
先申请出来,放到一个池内,由程序自己管理,这样可以提高资源的使用效率,也可以保证本程
序占有的资源数量。 经常使用的池技术包括内存池、线程池和连接池等,其中尤以内存池和线程
池使用最多。

内存池

内存池(Memory Pool)是一种动态内存分配与管理技术,旨在优化内存的分配和释放过程,减少内存碎片化,提高程序和操作系统的性能。通常情况下,程序员直接使用诸如new、delete、malloc、free等API进行内存的申请和释放,但这种方式容易导致内存碎片化问题,尤其在长时间运行的情况下性能表现更为明显。
 

内存池的工作原理是在程序启动时预先申请一大块内存作为池,之后在程序运行过程中,从这个内存池中动态分配内存给程序使用。当程序需要内存时,可以直接从内存池中取出一个空闲的内存块;当程序释放内存时,将释放的内存块重新放回内存池中,以备后续使用。同时,内存池会尽可能地与周边的空闲内存块合并,以减少内存碎片的产生。如果内存池的空闲内存不足以满足程序需求,内存池会自动扩大,向操作系统申请更大的内存空间。

内存池的优点包括:

  • 减少内存碎片化:通过提前申请一大块内存并动态分配管理,内存池可以有效减少内存碎片的产生。
  • 提高性能:内存池避免了频繁的内存申请和释放操作,从而减少了系统开销,提高了程序和操作系统的性能。

        1.2 为什么需要内存池

效率原因

假设你是一个图书管理员,你管理着一个图书馆的书籍。每当有读者来借书时,你需要为他们分配一本书。如果每次有读者来借书时都去印刷新书(动态分配内存),等读者归还书籍后再销毁书籍(释放内存),那么你将花费大量的时间和精力来管理这个过程,而且频繁的印刷和销毁书籍会带来一定的成本和效率损失。

相反,如果你有一个固定数量的书库(内存池),这些书籍已经预先准备好并且处于待命状态。当读者来借书时,你只需要从书库中选择一本可用的书分配给他们,归还后书籍继续留在书库中等待下一个读者的到来。这样可以避免频繁的书籍印刷(内存分配)和销毁(内存释放),节省了时间和成本,提高了效率。

内存碎片问题

假设我们直接在堆上直接申请内存,依次申请出8ytes,24bytes,10bytes,20bytes,此时堆上还剩8bytes没被申请,而这时候我们向堆归还了原先的24bytes和20bytes的内存,这时如果我们想再去堆上申请一个30bytes的内存是申请不出来的,但是实际上我们此时空余的内存达到了52bytes,但可惜的是我们无法凑出连续的30bytes内存块.而这也就是所谓的外部内存碎片问题.

内存碎片问题通常被分为内部碎片问题和外部碎片问题。这两种碎片问题都是指在内存管理过程中未被有效利用的内存空间,但它们的来源和解决方法略有不同。

  • 内部碎片问题(Internal Fragmentation): 内部碎片是指已经被分配给进程或对象的内存空间中,由于未被完全利用而产生的空闲部分。这种情况通常出现在动态内存分配中,比如在分配内存时,由于分配的内存块大于所需大小,导致分配的内存中存在一些未被使用的空间。内部碎片问题通常由内存分配算法引起,例如,如果使用固定大小的内存块进行分配(如固定大小的内存池),而分配的内存大小与需求不匹配,就会产生内部碎片。

  • 外部碎片问题(External Fragmentation): 外部碎片是指未分配给任何进程或对象的内存空间中的零散碎片。这些碎片可能由已释放的内存块留下,但由于它们的大小和位置不符合当前内存分配需求,因此无法被有效利用。外部碎片问题通常由内存释放操作引起,释放的内存块使得内存空间出现了不连续的空闲区域,这些零散的空闲区域无法满足大块内存分配的需求,从而产生了外部碎片。

        1.3 实现定长内存池理解池化技术

malloc

在C/C++编程中,当我们需要动态分配内存时,确实不是直接与操作系统打交道,而是通过诸如malloc这样的函数来完成。malloc是一个C标准库提供的函数,它帮助我们从计算机系统的堆内存区域请求一定数量的连续内存空间。
在C++中,new关键字则是用来进行动态内存分配的另一种方式,它本质上是对malloc功能的扩展和封装,不仅分配内存,还能自动调用构造函数初始化对象(对于类类型数据)。当你使用new分配数组或对象时,它会确保每个元素或对象都被正确初始化。而delete关键字在释放内存的同时,也会调用析构函数来清理对象。

不同编译器和操作系统环境下,malloc的具体实现可能会有所不同。例如,在Windows下的Visual Studio编译器中,malloc由微软进行了特定的优化实现;而在Linux系统下,采用glibc库的编译器(如GCC)则使用ptmalloc作为默认的malloc实现。这些实现通常包含高级算法,旨在提高内存分配的效率、减少碎片并满足各种内存需求。

定长内存池的设计

malloc函数作为一个通用的内存分配器,它可以接受任意大小的内存请求,并试图在堆上找到合适的连续空间进行分配。然而,由于它的通用性,它必须处理各种尺寸的请求,这可能导致内部碎片(即分配出的内存块之间存在无法使用的空隙),并且每次分配或释放都需要一定的查找和维护成本,所以对于特定场景而言,其性能可能不如针对性设计的内存管理方案。


定长内存池(Fixed-size Memory Pool)正是这样一种针对性的设计,它适用于那些内存块大小固定的场景,例如在大量创建和销毁相同大小对象的应用中。在定长内存池中,内存预先按照固定大小进行划分,申请和释放内存的操作可以简化为从池中取出或归还一个预设大小的内存块,无需像malloc那样复杂的寻找合适大小的空间。这种做法能够显著减少内存分配和释放的开销,消除内部碎片。

通过从零实现一个定长内存池,我们可以更直接的理解池化技术,同时也是在为高并发内存池项目的实现做铺垫。

template<class T, long long Nums>
class ObjMemoryPool
{
public:ObjMemoryPool():_freelist(nullptr), _pool(nullptr), _remainder(0){}T* New(){}void Delete(T* obj){}
private:void* _freelist;char* _pool;size_t _remainder;
};

我们首先写出定长内存池类ObjMemoryPool的大致框架,同时使用可变模板参数class T表示定长内存池存储的对象的类型和long long Nums来表示每次内存池预先申请多少个T大小的空间。

同时我们后面会通过一个_freelist来实现对回收对象的管理,_remainder则表示内存池剩余的字节数,同时我们会留出接口New(),Delete(),来调用以申请和销毁对象。

_freelist的设计

这里我们将_freelist(自由链表)设计成一个不带头的单向链表,把每个回收的对象当作链表的节点链接存储维护。

我们会从每一个对象的内存块中取出一块来存放下一个内存块的地址来实现指针链接

但是这个时候我们会遇到一个问题:指针的大小在32位和64位下的大小不一致,如何在32位平台取出4个字节,而在64位平台下取出8个字节呢?

void* nextobj = *(void**)obj;

这里通过将当前obj对象的指针强转为void**类型,即认为obj指向一个指针类型,这时候直接解引用该指针就一定是取出一个指针大小的内存块,我们就可以在其中读取或者写入下一个内存块的地址。

New()和Delete()的设计

由于是_freelist是单向链表,这里我们将节点的插入与取出都设计成O(1)时间复杂度的头插和头删。

Delete()

void Delete(T* obj)
{obj->~T();*(void**)obj = _freelist;_freelist = obj;
}

Delete()接口的实现最简单,我们只需要去调用内存块中对应类型的析构函数,再直接将其头插进_freelist里面即可。

 直接向堆申请空间

这里我们的项目将摆脱与malloc的联系,直接向堆申请空间使用和管理。

要想直接向堆申请内存空间,在Windows下,可以调用VirtualAlloc函数;在Linux下,可以调用brk或mmap函数。这里我在Windows下便选择使用VirtualAlloc函数。

#ifdef _WIN32#include <windows.h>
#endifinline static void* SystemAlloc(size_t kpage)	{
#ifdef _WIN32void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#endifif (ptr == nullptr)throw std::bad_alloc();return ptr;
}
inline static void SystemFree(void* ptr)
{
#ifdef _WIN32VirtualFree(ptr, 0, MEM_RELEASE);
#endif
  • 0:此参数指定要分配的区域的起始地址。当设置为0时,系统会确定在哪里分配该区域。
  • kpage << 13:此参数指定要分配的内存大小,VirtualAlloc 函数返回的内存区域地址会在页边界上对齐,页大小通常是4KB(即2的12次方字节),这里就是指我们一次申请的内存块大小为kpage页
  • MEM_COMMIT表示根据需要分配已提交页面的物理存储或后备空间。
  • MEM_RESERVE表示保留进程虚拟地址空间的一段范围,而不分配任何实际的物理存储。
  • PAGE_READWRITE:此参数指定区域的内存保护。在这种情况下,它允许对分配的内存进行读取和写入。

 _RoundUp()对齐

inline size_t _RoundUp(size_t size, size_t alignsize)
{if (size % alignsize == 0){return size / alignsize;}return size / alignsize + 1;
}

既然我们是选择以页为单位申请内存块, 这里我们在申请内存前就要将申请的内存大小向上取整对齐页的大小,这里的alignsize使用时就传入一页的字节大小。

New()

T* New()
{T* allocate = nullptr;if (_freelist){allocate = (T*)_freelist;_freelist = *(void**)_freelist;}else {if (_remainder < sizeof T){size_t kpage = (_RoundUp(sizeof T * Nums, 1 << 13) >> 13);_remainder = sizeof T * Nums;_pool = (char*)malloc(_remainder);if (_pool == nullptr){throw std::bad_alloc();}}allocate = (T*)_pool;size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);_pool += objSize;_remainder -= objSize;}new(allocate) T(); //定位new,显示调用T的构造函数初始化if (allocate == nullptr){int n = 0;}return allocate;
}
  • 用于从内存池中分配一个新的对象。如果有空闲对象可用,则从 _freelist 中获取一个,并将 _freelist 指向下一个空闲对象。
  • 如果没有空闲对象,则从内存池 _pool 中分配新的内存,如果剩余空间不足以容纳一个对象,则重新分配内存池。
  • 在分配的内存位置使用定位 new 构造一个类型为 T 的对象,并返回指向该对象的指针。

与malloc()做性能对比

完整代码:

#pragma once
#include <iostream>
#include <vector>
#include <time.h>
using std::cout;
using std::endl;
inline size_t _RoundUp(size_t size, size_t alignsize)
{if (size % alignsize == 0){return size / alignsize;}return size / alignsize + 1;
}
template<class T, long long Nums>
class ObjMemoryPool
{
public:ObjMemoryPool():_freelist(nullptr), _pool(nullptr), _remainder(0){}T* New(){T* allocate = nullptr;if (_freelist){allocate = (T*)_freelist;_freelist = *(void**)_freelist;}else {if (_remainder < sizeof T){size_t kpage = (_RoundUp(sizeof T * Nums, 1 << 13) >> 13);_remainder = sizeof T * Nums;_pool = (char*)malloc(_remainder);if (_pool == nullptr){throw std::bad_alloc();}}allocate = (T*)_pool;size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);_pool += objSize;_remainder -= objSize;}new(allocate) T(); //定位new,显示调用T的构造函数初始化if (allocate == nullptr){int n = 0;}return allocate;}void Delete(T* obj){obj->~T();*(void**)obj = _freelist;_freelist = obj;}
private:void* _freelist;char* _pool;size_t _remainder;
};
struct TreeNode
{int _val;TreeNode* _left;TreeNode* _right;TreeNode():_val(0), _left(nullptr), _right(nullptr){}
};
int main()
{// 申请释放的轮次const size_t Rounds = 5;// 每轮申请释放多少次const size_t N = 100000;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);ObjMemoryPool<TreeNode,10000> 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]);}int x=1;v2.clear();}size_t end2 = clock();cout << "new cost time:" << end1 - begin1 << endl;cout << "object pool cost time:" << end2 - begin2 << endl;return 0;
}

1)定义了一个简单的二叉树节点结构 TreeNode,做为被测试申请释放的obj类型

2)在 main() 函数中:

  • 设置了两组实验参数:每轮申请释放的次数 N 和申请释放的轮次 Rounds。
  • 创建了两个空的 vector 容器 v1 和 v2,用于存放申请的节点指针。
  • 对第一组实验使用了直接使用 new 的方式进行内存分配和释放。通过循环在每轮中申请 N 个节点,然后释放它们,并清空容器。
  • 对第二组实验使用了自定义的对象内存池 ObjMemoryPool 进行内存分配和释放。同样地,通过循环在每轮中申请 N 个节点,然后释放它们,并清空容器。 记

3)录了每组实验的开始和结束时间,并输出两组实验的时间差,以比较它们的性能。 最后输出了两组实验的耗时情况。

测试结果:

这里我们可以明显看到,此时定长内存池的申请释放效率明显快过malloc和free

 

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/718206.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

word使用bib添加参考文献

文章目录 安装TexLive安装bibtex4word使用在word中添加参考文献使用bibtex4word在word中添加参考文献设置参考文献格式为毕业论文格式 参考 安装TexLive 从下载地址下载镜像iso文件texlive2023.iso双击打开iso镜像文件运行 install-tl-windows.bat点击安装非常非常非常耐心地安…

Shell学习 - 2.20 Shell exit命令:退出当前进程

exit 是一个 Shell 内置命令&#xff0c;用来退出当前 Shell 进程&#xff0c;并返回一个退出状态&#xff1b;使用$?可以接收这个退出状态&#xff0c;这一点已在《Shell $?》中进行了讲解。 exit 命令可以接受一个整数值作为参数&#xff0c;代表退出状态。如果不指定&…

Linux命令-clock命令(用于调整 RTC 时间)

说明 clock命令用于调整 RTC 时间。 RTC 是电脑内建的硬件时间&#xff0c;执行这项指令可以显示现在时刻&#xff0c;调整硬件时钟的时间&#xff0c;将系统时间设成与硬件时钟之时间一致&#xff0c;或是把系统时间回存到硬件时钟。 语法 clock [--adjust][--debug][--dir…

客户端/服务器协议是啥意思?

客户端/服务器协议是指在网络通信中&#xff0c;客户端和服务器之间进行数据传输时所使用的规定。简单来说&#xff0c;客户端是用户使用的设备&#xff0c;如电脑或手机&#xff0c;而服务器则是提供数据或服务的远程计算机。当客户端需要获取数据或服务时&#xff0c;它会向服…

【RT-DETR有效改进】结合SOTA思想利用双主干网络改进RT-DETR(全网独家创新,重磅更新)

一、本文介绍 本文给大家带来的改进机制是结合目前SOTAYOLOv9的思想利用双主干网络来改进RT-DETR&#xff08;本专栏目前发布以来改进最大的内容&#xff0c;同时本文内容为我个人一手整理全网独家首发 | 就连V9官方不支持的模型宽度和深度修改我都均已提供&#xff0c;本文内…

【活动】金三银四,前端工程师如何把握求职黄金期

随着春意盎然的气息弥漫大地&#xff0c;程序员群体中也迎来了一年一度的“金三银四”求职热潮。这个时间段对于广大前端工程师而言&#xff0c;不仅象征着生机勃发的新起点&#xff0c;更是他们职业生涯中至关重要的转折点。众多知名公司在这一时期大规模开启招聘通道&#xf…

ChatGPT 4.0使用之论文阅读

文章目录 阅读环境准备打开AskYourPDF进入主站 粗读论文直接通过右侧边框进行提问选中文章内容翻译或概括插图的理解 总结 拥有了GPT4.0之后&#xff0c;最重要的就是学会如何充分发挥它的强大功能&#xff0c;不然一个月20美元的费用花费的可太心疼了&#xff08;家境贫寒&…

WP外贸营销型网站模板

WordPress外贸独立站主题 简洁实用的WordPress外贸独立站主题&#xff0c;适合时尚服装行业搭建wordpress企业官网使用。 零件配件WordPress外贸建站模板 汽车行业零配件WordPress外贸建站模板&#xff0c;卖配件、零件的外贸公司可以使用的WordPress主题。 https://www.jia…

RocketMQ—消费者的两种消费模式

RocketMQ—消费者的两种消费模式 RocketMQ消息消费的模式分为两种&#xff1a;负载均衡模式和广播模式&#xff0c;负载均衡模式表示多个消费者交替消费同一个主题里面的消息&#xff1b;广播模式表示每个每个消费者都消费一遍订阅的主题的消息。 负载均衡模式 CLUSTERING 集…

vue2 element 实现表格点击详情,返回时保留查询参数

先直观一点&#xff0c;上图 列表共5条数据&#xff0c;准备输入Author过滤条件进行查询 进入查看详情页&#xff0c;就随便搞了个按钮 啥都没调啦 点击返回后 一开始准备用vuex做这个功能&#xff0c;后来放弃了&#xff0c;想到直接用路由去做可能也不错。有时间再整一套…

一篇文章了解和使用Map和Set(HashMap/TreeMap/HashSet/TreeSet)

[本节目标] *掌握HashMap/TreeMap/HashSet/TreeSet的使用 *掌握了解HashSet和HashSet背后的哈希原理和简单的实现 1. 搜索树 1.1 概念 二叉搜索树又称二叉排序树,它或者是一颗空树,或者是具有以下性质的二叉树: 1.若它的左子树不为空&#xff0c;则左子树上所有节点的值都…

【一起学习Arcade】(2):Geometry函数

第二篇记录下Geometry函数&#xff0c;相对于其它语言&#xff0c;Arcade对Geometry的支持是一大亮点&#xff0c;这使得它的上限被大大提高了。 三、Geometry函数 1、Angle【角度】 单位为度&#xff08;0-360&#xff09;&#xff0c;正北为90度&#xff0c;只考虑x-y平面。…

07OpenCV 图像模糊

文章目录 图像掩膜操作模糊原理均值滤波高斯滤波中值滤波双边滤波算子代码 图像掩膜操作 图像掩膜操作 模糊原理 Smooth/Blur是图像处理中最简单和常用的操作之一 使用操作的原因之一就是为了给图像预处理时候减低噪声 图像噪声是指存在于图像数据中的不必要的或多余的干扰信…

RK3568开发笔记-qt程序运行报错Failed to move cursor on screen

目录 前言 一、qt程序运行报错 二、异常解决 总结 前言 最近在进行 RK3568 平台上的 Qt 程序开发时&

使用 Docker 部署 MrDoc 在线文档管理系统

1&#xff09;MrDoc 介绍 MrDoc 简介 MrDoc 觅思文档&#xff1a;https://mrdoc.pro/ MrDoc 使用手册&#xff1a;https://doc.mrdoc.pro/p/user-guide/ MrDoc 可以创建各类私有化部署的文档应用。你可以使用它进行知识管理、构建团队文库、制作产品手册以及在线教程等。 Mr…

在Java中如何使用Lambda表达式进行函数式编程

在Java中如何使用Lambda表达式进行函数式编程 在Java中&#xff0c;使用Lambda表达式进行函数式编程主要涉及以下几个步骤&#xff1a; 理解函数式接口&#xff1a; 函数式接口是一个只有一个抽象方法的接口。Java 8引入了FunctionalInterface注解&#xff0c;用于标记这样的接…

linux安全--DNS欺骗,钓鱼网站搭建

目录 一&#xff0c;实验准备 首先让client能上网 1&#xff09;实现全网互通&#xff0c;实现全网互通过程请看 2&#xff09;SNAT源地址转换 3&#xff09;部署DHCP服务 4)配置DHCP服务 5&#xff09;启动服务 6&#xff09;安装DNS服务 7&#xff09;DNS配置 8)启动DNS…

【Python笔记-设计模式】策略模式

一、说明 策略模式是一种行为设计模式&#xff0c;它定义了一系列算法&#xff0c;将每个算法封装起来&#xff0c;并使它们可以互相替换。 (一) 解决问题 在需要根据不同情况选择不同算法或策略&#xff0c;规避不断开发新需求后&#xff0c;代码变得非常臃肿难以维护管理。…

如何将图片保存成视频(imageio、opencv和ffmpeg)

测试下来发现&#xff0c;imageio 速度比 cv2 的要慢&#xff0c;所以普通保存推荐 cv2&#xff0c;要gpu加速需要额外配置或者修改 imageio 底层也是调用的ffmpeg&#xff0c;以下是python代码 import imageio import os# 读取要保存为视频的图片 images [] for filename …

UE 打包窗口及鼠标状态设置

UE 打包窗口及鼠标状态设置 打包后鼠标不锁定 显示鼠标图标 打包后设置窗口模式 找到打包路径下的配置文件GameUserSettings&#xff0c;设置相关项目 FullscreenMode0表示全屏模式&#xff0c;1表示窗口全屏模式&#xff0c;2表示窗口模式