当前位置: 首页 > news >正文

从零实现一个高并发内存池 - 1

C++ 高性能内存池解析

在 C++ 开发中,内存管理一直是影响程序性能的关键因素之一。传统的内存分配方式如 mallocfree 在高并发场景下往往存在性能瓶颈。为了解决这一问题,很多优秀的内存池方案应运而生,其中 Google 的 tcmalloc(Thread-Caching Malloc)是一个杰出的代表。本文将深入解析 tcmalloc 的核心原理,并探讨如何实现一个高性能的内存池。

其他的malloc相关实现

C/C++中我们要动态申请内存都是通过malloc去申请内存,但是我们要知道,实际我们不是直接去堆获取内存的,
malloc就是一个内存池。malloc()相当于向操作系统“批发”了一块较大的内存空间,然后“零售”给程序用。当全部“售完”或程序有大量的内存需求时,再根据实际需求向操作系统“进货”。
malloc的实现方式有很多种,一般不同编译器平台用的都是不同的。比如windows的vs系列用的微软自己写的一套,linux gcc用的glibc中的ptmalloc。下面有几篇关于这块的文章,大概可以去简单看看了解一下,关于ptmalloc,学完我们的项目以后,有兴趣大家都可以去看看他的实现细节。

一文了解,Linux内存管理,malloc、free 实现原理

malloc()背后的实现原理 - 内存池 

malloc的底层实现 - ptmalloc

windows和Linux下如何直接向堆申请⻚为单位的⼤块内存:

VirtuallAlloc()

brk() 和 mmap() 

一、内存池的概念与作用

(一)什么是内存池

内存池是一种池化技术,程序预先从操作系统申请一块足够大的内存,此后,当程序中需要申请内存时,并不直接向操作系统申请,而是从内存池中获取;同理,释放内存时,也并非真正将内存返回给操作系统,而是返回内存池。当程序结束时,才会将之前申请的内存真正释放。

(二)内存池的主要作用(解决的主要问题)

  • 提高内存分配效率 :每次向操作系统申请内存都有较大的开销,内存池通过预先申请过量的资源,避免频繁向操作系统申请和释放内存,大大提高了程序运行效率

向操作系统申请内存就像我们找自己父母(管钱的)要生活费,拿到钱有两种方式,除去池化技术这种方式,剩下的就比如今天早上吃早餐花了5块钱,然后打电话给妈妈,转钱,中午吃午饭,一样,打电话,找妈妈要钱....也就是每一次要花钱都需要找爸爸妈妈,这些钱都是零碎的,频繁的向家里要钱到的,这样效率肯定是非常低的,都消耗在了每一次要前还需要向爸妈沟通,那么用池化技术该如何解决这个问题呢?就是大概我一个月花个1000块钱,那么就在月初直接拿1000块钱,存在自己的钱包里,这样这个月就不需要再向家里要钱了,这样每一次花钱的效率就高了。

  • 解决内存碎片问题内存碎片分为外碎片和内碎片。外碎片是由于内存分配后剩余的空闲块太小,无法满足后续的内存分配请求;内碎片则是由于内存分配时需要满足对齐要求,导致分配出去的空间中一些内存无法被利用。内存池通过合理的管理策略,可以有效缓解这两种碎片问题。

在进程地址空间中,先申请了 256Byte 的 vector、256Byte 的 map、512Byte 的 mysql、128Byte 的 list 等不同大小的内存块,这些内存块在内存中是连续分配的。当 vector 和 list 对象销毁后,释放了各自占用的 256Byte 和 128Byte 空间,但这两个释放后的空间与之前未被释放的 mysql 占用的 512Byte 空间以及 map 占用的 256Byte 空间交替存在,导致内存中出现了多块不连续的空闲空间,从而产生了内存碎片。

现在需要申请超过 256Byte 的空间,但现有的空闲空间虽然总共有 384Byte(256Byte+128Byte),却因为碎片化而不连续。内存管理系统在分配内存时,通常需要找到一块连续的、足够大的空闲内存区域来满足申请。由于不存在一块连续的超过 256Byte 的空闲空间,所以无法成功申请到所需的内存。 

二、开胃菜:定长内存池

作为程序员 (C/C++) 我们知道申请内存使用的是 malloc,malloc 其实就是一个通用的大众货,什么场景下都可以用,但是什么场景下都可以用就意味着什么场景下都不会有很高的性能,下面我们就先来设计一个定长内存池做个开胃菜,当然这个定长内存池在我们后面的高并发内存池中也是有价值的,所以学习它的目的有两层,先熟悉一下简单内存池是如何控制的,第二它会作为我们后面内存池的一个基础组件。

我们实现定长内存池的详细细节会体现在代码中的注释中!!!

#pragma once
#include<iostream>
#include<vector>
#include<ctime>
#include<windows.h>//方便,不使用using namespace std;是因为防止污染
using std::cout;
using std::endl;//实现代码中的穿插:
// 直接去堆上按页申请空间
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;
}//定长内存池//实现定长
//方法1
//template<size_t N>
//class ObjectPool
//{
//	//..........
//};//方法2:我们上面的使用template<size_t N>是可以实现定长的,但是为了和后面的代码有更强的连接性,我们使用下面这种
template<class T>
class ObjectPool
{
public:T* New(){T* obj = nullptr;if (_freeList)//不为空{//说明_freeList当下还有正在“休息”的可用内存块,我们优先叫醒他,没必要再去切当前的内存块 --- 效率的提升!//进行对链表的头删void* next = *((void**)_freeList);//还要注意优先级哦,就是要加上括号哈!//当前的_freeList头部存在下一个节点的地址,我们先提出来,因为头删后,我们要保证_freeList位置的正确性obj = (T*)_freeList;_freeList = next;}else{//剩余内存不够一个对象大小时,需重新开辟大块空间if (_remainBytes < sizeof(T)){_remainBytes = 128 * 1024;//_memory = (char*)malloc(_remainBytes);//我们不要直接去malloc可以吗?直接不走malloc,直接去调用系统!可以的!!//Windows的API是使用VirtuallAlloc()//Linux是brk()或mmap()//这样更纯粹一点!_memory = (char*)SystemAlloc(_remainBytes >> 13);//一页是8K,所以右移1024*8==>>2的13次方if (_memory == nullptr){//申请失败,抛异常throw std::bad_alloc();}}obj = (T*)_memory; //向内存池,也就是申请出来的大块内存申请部分空间!//注意:如果T是char呢?或者任何小于指针长度的类型呢?那么指针不就存不下了吗?这是我们应该要注意到的!size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);_memory += objSize;  //大块内存被取走一部分了,当然要继续指向可用部分的开始,以便下一次申请的方便_remainBytes -= objSize; //使用了当然是要保证确实是使用了,剩下多少可用的了!}//注意!!!//仅仅将空间开辟出来还是有一点点不足的,像T是一个自定义类型等等,我们是开了空间,并没有初始化//对于一个开辟出空间的,我们可以调用构造函数进行初始化 --- 定位new:显式调用T的构造函数初始化new(obj)T;//后面Delete也是如此!return obj;}void Delete(T* obj){//显式调用析构函数清理对象obj->~T();//并不是释放obj!!!只是将T:如vector的开辟的空间销毁//画图去理解,理解好细节是很有帮助的!// ***************************************************************************************************// ***************************************************************************************************//if (_freeList == nullptr)//{//	_freeList = obj;//	//使用指针类型的特性来截取前4个比特位,来存放指向下一个节点的指针(32位下就是4位,64位下就是8位)//	//*(int*)obj = nullptr;//	//不过我们并不是每一台机器都是4位的,我们可以通过sizeof来进行 if - else,但是我们下面还有一个更加巧妙的方式!!!很重要//	*(void**)obj = nullptr; //这样不管是32位还是64位下都没有问题:obj被强转成了(void**),然后前面一个*解引用,那么就是sizeof(void*)的大小了!!!//}//else//{//	//我们使用头插是效率很高的!不然还需要遍历找尾!//	//头插//	*(void**)obj = _freeList;//	_freeList = obj;//}// ***************************************************************************************************// ***************************************************************************************************//使用到了头插,所以我们也不需要if - else了:*(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 = 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);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;
}

这段代码的主要目的是比较使用传统的 newdelete 方式与使用对象池方式在申请和释放大量对象时的性能差异。通过多轮次的申请和释放操作,统计两种方式的耗时,从而展示对象池在频繁申请释放内存场景下的性能优势。

我们可以发现:

//Debug下
new cost time:213
object pool cost time:40//Release下
new cost time:39
object pool cost time:2

三、tcmalloc 的核心框架

(一)tcmalloc 简介

tcmalloc 是 Google 开源的一个高性能内存分配器,全称是 Thread-Caching Malloc,即线程缓存的 malloc。它是基于 ptmalloc(glibc 中的内存分配器)改进而来,专门针对多线程高并发场景进行了优化,用于替代系统的内存分配相关函数(mallocfree 等)。(注意是在多线程环境下,普通环境下tcmalloc未必比malloc free高效)

现代很多的开发环境都是多核多线程,在申请内存的场景下,必然存在激烈的锁竞争问题。malloc 本身其实已经很优秀,那么我们的项目原型 tcmalloc 就是在多线程高并发的场景下更胜一筹。所以这次我们实现的内存池需要考虑以下几方面的问题:

  1. 性能问题

  2. 多线程环境下,锁竞争问题

  3. 内存碎片问题

(二)tcmalloc 的核心组件

tcmalloc 的内存分配框架主要由三个部分构成:thread cache(线程缓存)、central cache(中央缓存)和 page cache(页缓存)。(三层)

  • thread cache (线程缓存):每个线程都有一个独立的 thread cache,用于管理小于 256KB 的内存分配。线程从这里申请内存不需要加锁,因此效率极高因为每一个线程独享一个thread cache)。当线程需要分配内存时,会先从自己的 thread cache 中获取;若 thread cache 中没有足够的内存,则会从 central cache 中批量获取。

  • central cache (中央缓存):中央缓存是所有线程共享的。线程缓存按需从中央缓存中获取对象。中央缓存在合适的时机回收线程缓存中的对象,避免一个线程占用了太多内存,而其他线程内存紧张,从而达到内存分配在多个线程中更均衡的按需调度的目的。由于中央缓存是共享的,从这里获取内存对象需要加锁。这里使用的是桶锁机制,并且由于线程缓存通常能够满足需求,只有在线程缓存没有内存对象时才会访问中央缓存,因此这里的锁竞争不会很激烈。(只有访问同一个桶的时候,因此锁竞争并没有那么激烈)。(thread cache没有内存了就向下一层去申请内存,这个中央缓存和我们上面实现的定长内存池类似)

  • page cache (页面缓存):页面缓存位于中央缓存之上,存储的内存是以页为单位进行分配和管理的。当中央缓存没有内存对象时,页面缓存会分配一定数量的页,并将其切割成固定大小的小块内存,分配给中央缓存。当一个 span(页面跨度)的所有对象都被回收后,页面缓存会回收中央缓存中符合条件的 span 对象,并且合并相邻的页面,组成更大的页面,从而缓解内存碎片的问题。

更多精彩在下文哦! 

相关文章:

  • [ctfshow web入门] web72
  • Linux精确列出非法 UTF-8 字符的路径或文件名
  • logback 日志归档,解决主日志和归档日志分别定义不同的周期
  • EXCEL Python 实现绘制柱状线型组合图和树状图(包含数据透视表)
  • Redis Cluster 集群搭建和集成使用的详细步骤示例
  • 获取accesstoken时,提示证书解析有问题,导致无法正常获取token
  • NumPy 2.x 完全指南【十】基础索引
  • 网络协议与系统架构分析实战:工具与方法全解
  • 五大静态博客框架对比:Hugo、Hexo、VuePress、MkDocs、Jekyll
  • 聊天项目总结
  • 多边形,矩形,长方体设置
  • livenessProbe 和 readinessProbe 最佳实践
  • 函数加密(Functional Encryption)简介
  • Postgresql与openguass对比
  • WiFi密码查看器打开软件自动获取数据
  • 开发者版 ONLYOFFICE 协作空间:3.1版本 API 更新
  • 视频编解码学习十一之视频原始数据
  • Redis扫盲
  • Unity 2D 行走动画示例工程手动构建教程-AI变成配额前端UI-完美游戏开发流程
  • 亚马逊云科技:引领数字时代的云服务先锋
  • 山西临汾哪吒主题景区回应雕塑被指抄袭:造型由第三方公司设计
  • 排污染黑海水后用沙土覆盖黑泥?汕尾环保部门:非欲盖弥彰
  • 事关心脏健康安全,经导管植入式人工心脏瓣膜国家标准发布
  • 时隔4年多,这一次普京和泽连斯基能见面吗?
  • 马上评丨75万采购300元设备,仅仅终止采购还不够
  • 来伊份:已下架涉事批次蜜枣粽产品,消费者可获额外补偿,取得实物后进一步分析