【项目】C++实现高并发内存池

目录

简单介绍--内存池:

前言:         

背景:

代码:

内存池的技术:

内存池解决的问题:

提示:

框架的整体设计及实现:

ThreadCahe

CentralCache

PageCache

_freeList

Span

ThreadCache  :

1、铺垫

2、主要接口

3、小结

CentralCache:

1、铺垫

2、单例模式之--用饿汉不用懒汉

3、主要接口

4、小结

Pagecache:

1、铺垫

2、主要接口

3、小结

优化:

1、大于256K

2、定长内存池

3、定义_objectSize

4、基数树PageMap -- 锁消耗

5、动、静态库

6、去除mallo/free

补漏细节:

后序完善


简单介绍--内存池:

前言:         

        此次项目的名称是高并发内存池,它的原型是google的一个开源项目tcmalloc,意思是线程缓存malloc(Thread-Caching Malloc),它实现了高效多线程的内存管理,比我们认识的malloc更加高效,甚至我们可以用此来代替。

        此项目当然不会实现一个完整的tcmalloc,我们的目的不是为了造一个更好的轮子,而是学习其中的精髓,所以我们要模拟实现出一个自己理解的高并发内存池,在这期间,可能但是我们的收获也是伴随着来的。有些地方可能会难以理解,因为这是我个人对此项目的总结,可能有什么地方遗漏了某些知识,如果部分地方留下疑问,或想要更加简单地理解,需要自行到网上查找视频,又或者在下方留言。

背景:

        采用vs2019,windows下32位系统,涉及C/C++(部分C++11)、数据结构,命名尽量贴合实义;

代码:

        分为多页,可按对应章节查看,建议采用《高并发内存池(4)内存池的申请释放调试》

        gitee地址:高并发内存池: 高并发内存池项目,采用C/C++语言 (gitee.com)

        github地址:peaceMan1999/high-concurrency-memory-pool: 高并发内存池项目,由C/C++语言组成 (github.com)

内存池的技术:

池化技术 

        就是程序先向系统申请过量的资源,然后自己管理,减少用时创建时的开销。

内存池

        管理申请得来的内存的地方,就像一个小卖铺,你不是问厂家拿,而是小卖部帮你问厂家拿,以后等你需要就向小卖铺拿。

内存池解决的问题:

  • 空间浪费问题

        这解决了效率问题,以及空间的利用率,我们使用malloc向系统申请空间的时候,会在堆上申请一段空间给你,你能保证,它申请的是连续的吗?这些零零散散的空间称之为内存碎片,也叫作外碎片。

        这些碎片空有大有小,却不能合在一起创建一块大的内存,而内存池所解决的,就是当内存释放时,如何把内存碎片合并起来,才能提高内存的空间利用率。

  •  减少消耗

        如果每一次使用才申请会占用CPU的时间,申请地越多,消耗就越大,效率就越低,我们预先申请大量的内存空间供给需求,有效提高了效率,且结合内存碎片的整合,合理地控制收纳。

  • 锁竞争带来的消耗

        每个线程对数据结构的读写都需要锁,锁对其他线程的影响是很大的,后期我将带大家解决锁消耗的问题。

提示:

  • 文章可配合对应的代码食用~        
  • 定长内存池:在后面对内存池起到有效地优化作用,暂时不讲,想看的可以在优化章节查看。

框架的整体设计及实现:

  • 内存池的框架由3个部分组成,分别为:ThreadCache(线程缓存)、CentralCache(最新缓存)和 PageCache(页缓存),它们三个是整个内存池的精髓所在,所以我们要对它们重点讲解。

ThreadCahe

  • 有多少个线程就有多少个ThreadCache,每一个都是独立的,用一个哈希表结构建立,所以只需要对每一条链表(每一个桶)进行加锁即可,用于对需求小于256kb内存的对象进行分配,可以理解为--高效。

CentralCache

  • 当ThreadCache没有内存了,就会找到CentralCache,而CentralCache是中心缓存,是所有线程共享的,ThreadCache是其中按需获取的对象,CentralCache也是哈希表结构,需要对桶进行加锁,涉及线程安全,因为只有没有内存了才会找下层,所以竞争不是很激烈,且每一个Span满了可以回收。(这里Span里是分配给ThreadCache内存的管理者)

PageCache

  • 当满足一定条件,一个Span的几个的几个跨度页的对象都回收以后,PageCache会回收CentralCache满足条件的Span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。后期优化可不用前后合并。
  • PageCache已经是最底层了,它不够了找谁?找系统直接malloc,不,我们不用malloc,我们用更加高效的申请方式。

_freeList

  • _freeList是一个单链表,它上面挂着对应byte大小的小块内存,这些小块内存都链接起来,它们挂在了ThreadCache上,供线程使用。

  • 问:那么我们将用上面办法把它们链接起来呢?用int吗?32位下没有问题64位下跑不动,因为指针是8字节,而int是4字节,如何做到共存?
  • 答:我们可以用这个小块内存的前4个字节(32位下)指向后一个内存块,我们用一个巧妙的接口NextObj()即可,它用(void**)强转成指针类型,这样就可以根据系统变化而变化了。

Span

  • Span是管理所有链表的结构体,它的成员有:
	PAGE_ID _pageId = 0; // 页号
	size_t _n = 0; // 页数量,页的倍数,就是PC中的每一个下标
	Span* _next = nullptr; // 双链表
	Span* _prev = nullptr;
	size_t _usecount = 0; // 属于归还内存
	void* _SpanFreeList = nullptr; // 切好的自由链表
	size_t _objectSize = 0; // 为了Free时方便
	bool _IsUse = false; // 默认没人用
  •  页号是一个以8k为单位的数值,代表了内存上的具体位置;页倍数代表了当前申请内存的大小是几个8k;因为是一个双链表,所以要有前后指针;_usercount代表每个Span原先的自由链表的归还的内存数量,为0时代表全部归还,可以回收;_SpanFreeList是指向自由链表的头节点;_objectSize 在后期优化时讲解;_IsUse代表当前Span有无线程在使用;

ThreadCache  :

1、铺垫

        看名字就知道ThreadCache是专门面对线程的,它是由一个哈希桶结构,每个桶上都是一个自由链表(单链表),这些自由链表称为:_freeList,而管理这些链表的哈希表我喜欢称它为_TCFreeList[],TC代表ThreadCache。(_freeList是一个单链表结构,具有简单的删除和增加函数),一般建议把公共的放在一个大的头文件上,这里我定义为Common.h。在Common.h中,我们定义一个类FreeList用来管理切分好的对象的_freeList。

        开辟_TCFreeList[]的大小是MAXBUCKETS,意思是最多桶数208,为什么是208,因为专门有人测试过这样一个范围:

整体控制在最多10%左右的内碎片浪费:
[1,128]					    8byte对齐			freelist[0,16)
[128+1,1024]				16byte对齐			freelist[16,72)
[1024+1,8*1024]			    128byte对齐			freelist[72,128)
[8*1024+1,64*1024]		    1024byte对齐		    freelist[128,184)
[64*1024+1,256*1024]		8*1024byte对齐		freelist[184,208)

        为什么呢?例如申请129字节,对齐的化就要补齐15个字节,向16字节对齐,15/144约等于10%,到这里就会问,如果对齐8字节那不是更节约?是的,但是这肯定会增加桶的数量,这样在进行哈希表的增删查改就会消耗的更多,一个ThreadCache没什么,多了就消耗大了,所以这个范围控制了桶的数量,页控制了内碎片的浪费。

        我们在读写桶时,会有线程冲突的问题,设整个表的大锁没必要,因为每个线程只会在自己对应的桶里拿内存,所以我们要设桶锁。这里我们采用了TLS的方法解决,TLS简单来说就是该变量只能自己看,别的线程无法查看,我们把锁设为TSL,这样各线程就互不干扰了。

static _declspec(thread) ThreadCache* pTSLThreadCache = nullptr;

2、主要接口

  • ThreadCache主要实现4个接口:申请内存、释放内存、还有我不够用了找CentralCache拿,我不用还给CentralCache接口。
  • 申请内存:在申请内存时,会通过类SizeClass中的计算接口RoundUp和Index,算出需要对齐的byte大小和所在的桶,随后检查_TCFreeList[index]中有无可以用内存,没有就问CentralCache拿。
  • 释放内存:会先把对应的_freeList自由链表弹开,这样就能做到与ThreadCache解耦,而不是delete释放掉,这个内存块可以还给CentralCache,让它回收零碎的内存。
  • 获取内存:当CentralCache分配内存时,会把自由链表挂到对应的桶上,那就算随便拿了吗?不是,假如线程一次需要10块,我们并不知道他下一次是几块,可能20又或者不用了,所以需要根据改内存块的流量数据来控制数量,这里采用慢增长的方式,类似于网络传输时拥塞速度的道理。所以获取到内存后先返回一块用着,其余的先挂在链表中。
  • 归还内存:当线程归还内存时,需要及时回收避免产生内存碎片,但不是一次归还全部空闲的内存,而是根据流量的变化控制数量。

3、小结

  • 主要是面对一个单独的线程,通过流量控制数量,不够了就找CentralCache拿即可,空闲了就归还适当的内存块,避免产生过多的内存碎片。

CentralCache:

1、铺垫

        CentralCache也是一个哈希桶结构,每一个桶下都是一个个Span,Span指向的就是内存块链接的自由链表,桶的下标是以页位单位划分,通常在ThreadCache索要时,不只是创建一份,而是预先创建多份作为缓冲,因为有可能该ThreadCache需求旺盛。

       CentralCache的哈希表我称之为_CCSpanList[MAXBUCKETS],MAXBUCKETS是最大桶数量208,每一个Span都用双链表链接,我们用一个类SpanList来管理这些Span,一个下标对应一个SpanList,所以只需要桶锁即可。

2、单例模式之--用饿汉不用懒汉

  • 由于全局中只有一个CentralCache,所以我们将它设计成单例模式,在头文件中声明,在CentralCache.cpp文件中定义且只在一个文件中定义。单例模式我们常用的有饿汉和懒汉,那么我们用那种呢?我们用饿汉,因为饿汉是在main之前创建的,创建时不会于线程产生竞争关系,如果是懒汉,在创建时会与其他线程争夺CPU,会降低效率。

3、主要接口

  • CentralCache有3个接口:处理PageCache分配的内存、给予ThreadCache内存和将内存归还给PageCache。
  • GetOneSpan():假设我们需要申请8字节对齐的内存,首先会在8字节下标的桶内遍历有无可用的Span,如果有就返回头节点,如果没有可用的Span,就需要向PageCache申请内存需求,会调用PageCache的接口。当拿到PageCache给的大块内存后,是一个纯粹的内存块,需要对其进行8字节的组装(按对其字节),在返还一个装有自由链表的Span节点。在读写过程中,我们需要对该桶进行加锁,也就是桶锁。但向PageCache申请内存时可解开桶锁,唤醒其他线程,因为收到内存需要时间,且不影响桶结构。
  • GetRangeObj():会调用GetOneSpan()获取到Span,该过程需要桶锁,可能获取到的Span可能因为使用过或者需求量大而不满足需求,但不影响,先把当前桶的内存块拿完,不够可以再拿其他Span的,最后返回一个实际获取到的内存块的数量给ThreadCache,_usecount要加上实际获取数量。
  • ReleaseListToSpan():用地址通过Map(PageCache会讲)找到映射的Span,插入回去,无需排序,再对_usecount进行加法,如果检测到为0,说明从该Span中申请的内存块已经全部归还,可用提示PageCache进行合并处理的了。

4、小结

  • 注意读写时桶锁的加锁与解锁操作,且_usecount不要忘记加减,CC作为中间,起调和作用,分配速度要慢反馈,根据流量控制。

Pagecache:

1、铺垫

        PageCache也也也是哈希结构,但有一点不同的是,PageCache不是按照Byte区分,而是按Page页区分,每个桶上挂着的都是对应页的Span用SpanList链接管理。且PageCache也是全局唯一的,同样采用饿汉模式。而这个哈希表我称之为_PCSpanList[],大小是[MAXPAGES]129大小,为什么是129不是128,因为方便操作。

        在PageCache内,由于CentralCache需要不同大小的Span,可能会影响线程安全,所以要加锁,但是这里不用桶锁,因为各个桶之间的交流太频繁,比如遍历桶,对桶锁加锁解锁会严重影响内存消耗,所以要设置一个大锁,对PageCache进行加锁解锁。

        为了更好的找到对应的Span,我们需要建立好映射关系,所以创建一个数据结构unordered_map<PAGE_ID, Span*> _IdSpanMap,可用通过页号来查找对应的Span。

2、主要接口

  • PageCache的接口有3个:申请内存、找到映射内存、合并内存接口。
  • NewSpan():收到CentralCache的内存申请,根据给定的page大小,在对应下标的桶中查找有无可用的Span,如果有就返回一个,如果没有就需要找_PCSpanList[page+n]下标的桶,如果下一个桶有,那么就把当前的一个Span切成一个page和一个n页大小的Span,返回page页的Span,n页的Span挂起到_PCSpanList[n]上。如果到128都没有,那就必须找堆要一个大块内存后再进行切分。在切分过程中要注意建立映射,挂起的Span只需映射头和尾即可,而返回的需要进行每一个page大小的映射。为了避免数据冗余,可以再调用一次递归去复用。
  • MapObjectToSpan():给定一个void*的地址,可通过÷8k的方式找到对应的PageId,为什么可用找到?因为都是在一个内存当中,细品一下~(也可以用>>13,记得转PAGE_ID类型)。
  • ReleaseSpanToPC():当_usecount为0时,CentralCache会提示PageCache调用ReleaseSpanToPC()进行内存合并,先通过_IdSpanMap映射找到对应的Span,先向前查找对应空闲的Span的尾,所以在建立映射时挂起的只需要建立首尾映射的原因,通过_IsUse()可找到是否空闲。如果前面一直有就一直合,如果没有了就向后合并,合并完后再挂起。

3、小结

  • 在操作PageCache和_IdSpanMap时需要注意加锁问题,在后期优化中会解决加锁问题以及前后页合并问题。
  • PageCache的大多是回收和创建机制,为了脱离malloc和free,我们采用了更加底层的接口VirtualAlloc和VirtualFree,以及在建立映射关系时在对应的地方映射进哈希中。
  • 到此,最核心的3个部分讲解完毕了!

优化:

1、大于256K

  • 小于256kb的3层结构已经完成,那么大于256kb的该如何申请呢?(256kb = 32 * 8kb)
  • a、如果是32页到128页之间的可以找PageCache,因为CentralCache可以处理256kb,而PageCache能处理128*8kb。
  • b、大于128*8kb就要找堆拿了,之前的RoundUp我们设了超过256kb就报错,这次可以改为以一页单位对齐,也就是8*1024Byte大小对齐。包括NewSpan()、ConcurrentFree()也要修改为直接找堆要,且要建立映射。

2、定长内存池

  • 定长内存池的优点在于,在不同类型的释放创建时,可以交给对象来处理,定长内存池中已经预先开辟好对应的空间,可以将new和delete替换为ObjectPool的New()和Delete()处理,记得事先定义一下ObjectPool<Span>,静态就用static。

3、定义_objectSize

  • 在Span结构体中添加成员_objectSize,一页里的内存切出来的都是意义大小的自由链表,不如在GetOneSpan时加入_objectSize,是值切好的小对象的大小,也就是Byte,这样当释放时就用自己输入参数Byte,只需通过映射找到Span,通过自身的_objectSize就可知道切片大小。
  • 当然,也可以设计一个unordered_map<PAGE_ID,size>的哈希表映射。如果是大于128页的,就自己找堆。

4、基数树PageMap -- 锁消耗

  • 由于锁竞争的消耗是很大的,所以我们要尽量避免线程竞争锁,但是又不可以不锁,因为map的哈希和红黑树不保证线程安全,得加锁,例如访问的时候可能别人在进行修改,这样就不好了,所以我们利用一个叫作基数树的数据结构来代替map。
  • 基数树分为3中,第3种适用于x64系统。
  • 第1种采用了一层基数树,其实也是一个哈希的指针数组,它的大小的BITS,是32-PAGESHIFT位(也就是32-13=19位),也就是一次性开辟一个2^19个次方int类型的数组,前面一部份存指针,剩下的部份存Span。好处是一次开辟已经创建了对应的映射关系,且是数组结构,不会因为读写而改变结构,红黑树写时还要旋树,解决了加锁的问题。
  • 第2和第3种采用了二层和三层结构,第一层是32位(1<<5)存的是指向下一层的指针,每个指针指向2^13块空间,如果哪块没有使用的话就不用开这2^13的空间。
  • 基于基数树,我们在ReleaseSpanToPC()就不需要前后合并了,且把查找find()改为get(),映射[]改为set。
  • 总结:a、只有在NewSpan()和ReleaseSpanToPC()是才回去建立映射;b、且基数树提前开好了空间,不会动结构;c、实现读写分离,线程1对一个位置读写时,线程2不可能读写,都读不用加锁。

5、动、静态库

  • 一般建议把该项目打包成一个库。
  • vs2019下:解决方案右击->属性->常规->配置类型->动/静态库。

6、去除mallo/free

  • 我们发现了许多malloc的弊端,会产生内存碎片等等,统一改用系统接口VirtualAlloc和VirtualFree。
  • 我们能否将高并发内存池替换到系统调用malloc呢?可以的,Linux下用:void* malloc(size_t size) THROW attribute__ ((alias (tc_malloc))),这样所有的malloc都跳转到tc_malloc上。Windows下只能用hook的钩子技术。

补漏细节:

_RoundUp:

  •  _RoundUp中 ((bytes + alignByte - 1) & ~(alignByte - 1)) 是什么意思?假设需要申请的bytes是8字节,实际对齐字节alignByte是8字节,那么就是(8+8-1)&~(8-1)= 15 & ~7。这就特别考虑位运算了:15是前四个字节为1,7是前3个字节为1,7取反再&上15,&是0&任何都是0,那就是只有第四位为1,也就是8字节,前不超16后不及4,妙哉妙哉~

宏判断:

  • 由于_WIN64下既有32位又有64位,如果_WIN32先放前面就会先采取_WIN32的方式,所以要把_WIN64放在_WIN32之前。

Index:

  • Index()中为何要加上前面的桶数,因为_Index()计算的是在当前区间的几号桶,所以不能刻舟求剑,需要加上前面的桶数。

ReleaseListToSpan:

  • 在合并前后页之前,要对Span进行排序吗?不用,因为它们都是一块内存中切分的,只需要找到头和尾即可。

后序完善

后续会再阅读相关书籍完善......

  • 7
    点赞
  • 43
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值