
⭐️在这个怀疑的年代,我们依然需要信仰。
个人主页 :YYYing.
⭐️高并发内存池项目专栏:C++项目之高并发内存池
系列上期内存:【C++项目之高并发内存池 (一)】项目介绍与定长内存池的构建
系列下期内容:暂无
目录
[📖 整体框架设计](#📖 整体框架设计)
[📖 tc------ThreadCache](#📖 tc——ThreadCache)
[📖 cc------CentralCache](#📖 cc——CentralCache)
[📖 pc------PageCache](#📖 pc——PageCache)
[📖 ThreadCache实现](#📖 ThreadCache实现)
[TLS------Thread Local Storage](#TLS——Thread Local Storage)
[📖 线程申请和释放空间的接口](#📖 线程申请和释放空间的接口)

前言:
我们上篇博客讲完了我们内存池的很多基础知识,并做了一个定长内存池来练练手,而从这篇博客就开始了真正的挑战,也就是本专栏的核心中的核心------高并发内存池。
📖 整体框架设计
我们tcmalloc将内存池设计为三大部分------线程缓存,中心缓存与页缓存。

当然,这三层我们目前肯定是看不懂的,没事,我们继续往后学,都是人做出来的,别人能看懂的,我们一定也能看懂。
| 现代很多的开发环境都是多核多线程,在申请内存的场景下,必然存在激烈的锁竞争问题。
我们前面也说了正常的内存池都会考虑到两个问题------效率问题与内存碎片问题,当然malloc对于这两个问题都考虑到了,不过我们的tcmalloc还考虑到了更深一层,也就是多线程环境下的锁竞争的问题,所以我们两者设计的框架也是完全不一样的。
那么我们接下来就开始将这三层设计。
📖 tc------ThreadCache

对于tc来说,我想要有几点强调的:
-
一个进程中有几个线程,就会有几个tc,也就是每一个线程都会有其对应的tc,可以认为tc就是一个类的对象,内部包含一些数据结构,并保存了一些空间。你可以简单理解成上一篇的定长内存池,也是一些数据结构,并保存一些空间。
-
假设有三个线程,分别为t1、t2、t3。每个线程去动态申请内存时不需要加锁,因为每个线程独享一个tc,如果tc中有空闲空间,线程在申请的时候只会去自己的tc中申请,也就是类似于我们上一篇的那个空闲链表。
-
单次向tc申请的最大上限为256KB,如果单次申请小于256KB的,那线程在自己tc中申请就够了,且绝大部分情况是申请不到256KB的,你想想,256KB都到26w字节的级别了,早就已经足够解决大部分的问题了。这样我们就可以解决大部分情况下的锁竞争,因为我们只用接触自己的tc。若单次申请大于256KB的话,tc就会采用其他方法去申请空间,后面再细说。
📖 cc------CentralCache

我们线程的tc的空间用完过后,会向cc去申请更多的空间
不过当且仅当两个或两个以上的线程用完自己的tc空间后才会并发的访问cc去申请更多空间,此时也不一定会触发我们的线程安全问题。因为我们cc的内部是由哈希桶实现的,哈希桶有多个,每个桶都会串一串数,只有当多个线程并发地申请同一个桶的时候才会出现线程安全问题,但我们每一个桶都会有一个针对桶的锁,所以这样就在保证效率的同时又保证了线程的安全性。
cc会在合适的时机回收tc中的对象空间,比如t1原先申请了很多内存,用完之后空闲了很多,那么此时cc就可以回收掉t1的tc中空闲的空间,如果此时t2急着用空间,正好就可以将这些空闲空间给t2。这样就起到了一个均衡调度的功能。
当然,如果我们cc内存不够了,我们就会继续向上申请,也就是向pc申请。而且cc回收来的内存没来得及分给让tc申请,那么就会交给pc来解决内存碎片的问题。
📖 pc------PageCache

我们pc中会组织很多个叫做span的结构体,span中会管理多个页大小的空间,且会通过一些方式去标记这些span,当这几个页的内存都回来之后pc就会回收cc中满足条件的span。
pc根据名字就可以知道其是管理页的,一页就是4KB或者8KB,回收回来的页,如果页号与其它页的页号可以拼成很多相邻页号,就会将这些相邻的页合并成更大的页,通过这样的方式去合并来解决内存碎片问题。
📖 ThreadCache实现
tc框架讲解
我们tc的框架是基于我们之前定长内存池的,之前的定长内存池我们只有一条自由链表,但现在我们线程每次向tc申请空间可不是固定长度的,此时想要满足线程申请的不同大小的空间(比如说3B、6B、18K、236K等)就要对应产生不同的_freelist用来回收对应大小的小块空间,所以现在要有多条自由链表。
但我们自由链表的数目呢,如果要给每个字节都设置一个链表那也太多了,所以我们要做出些牺牲,我们第一个链表挂申请8B的(不足8B的)

当然虽然我们图上像是相隔8B,实际肯定不会隔这么少,因为哪怕只隔8B到最后也有3w左右个_freelist,不过我们先这么记。然后可能也有人会看出来,我们如果用这种方法,那么对于类似小于8B之类的数肯定就需要补齐了,那么这样就会造成一定的空间浪费。
事实确实是如此,但我们实现了以空间换时间的操作,这样就能增加我们内存池的效率。这些浪费的内存空间都是在用的地方碎片化了,这里的碎片就是内碎片,也即因为一些对齐的原因而产生的一些用不上的空间,这就是内碎片。
而前面讲的外碎片是一片连续的空间被切分成好多块分出去,只有部分还回来了,但是它们不连续,导致虽然有足够的空间,但是申请不出想要的大块空间。
ok,到这不难看出,我们线程若想要size大小的空间,就会向tc去申请,而tc内部是哈希桶的数据结构,而哈希最重要的就是映射,那第一个就是映射8B的_freelist,第二个就是16B的_freelist。。。。。。以此类推一直到256KB

每来一个size都要有一套规则去计算对应的桶在哪里,比如要20B就找对应24B的桶,看24B对应的自由链表下面有没有挂空闲的空间,如果有了就拿一块给对应线程,这就和我们定长内存池很像了,但如果没有就向下一层的cc。
tc代码实现
我们先上来创建三儿个头文件

但我们后续的头文件肯定不只有tc,还有至少两个------cc和pc,所以此处我们还需要一个Conmmon.h的公有头文件,用于放入通用的一些头文件。大概先写这么多吧。

ThreadCache.h中定义同名的类,类中要提供两个接口,一个Allocate用来申请空间,一个Deallocate用来回收空间:

我们tc所用的数据结构是哈希表,而哈希表的每个槽都可以看作一个桶,那么我们就把每个桶当做一个自由链表,现在我们先实现一下这个哈希表。
自由链表类FreeList
我们先来说说自由链表,与我们之前的定长内存池差不多,不需要搞链表节点,空间就是节点,然后一提供一回收:

那么可以回忆一下,我们之前链表是怎么操作的,我们代码可以写为下面这样:

考虑到代码的可读性,我们此处给*(void**)整一个接口,专门来返回这个值,不过需要注意的是我们需要在此处取一下引用,因为我们函数返回后要进行赋值操作。
如果没有引用,返回的是一个右值,因为ObjNext返回值是一个拷贝,是一个临时对象,而临时对象具有常属性,不能被修改,也就是一个右值,右值无法进行赋值操作。
最后我们在每个函数中加入assert提升我们的安全性。
tc中的哈希结构
刚刚也说了,tc中要有一个哈希表,每个哈希桶都是一个自由链表,那就可以给一个存放FreeList的数组,不过数组开多大呢?

这就要谈谈我们之前说到的问题了,我们前面说了不能两两相隔8B,这样会有太多的哈希桶,那么我们真正的对齐规则就应该是下面这样的:

我们现在来看看这个规则,size范围很好理解,就是指线程申请的空间范围,[1, 128]就是指申请空间在1~128B以内的;对齐数的话,就是当前在当前哈希桶下标范围下(可以理解为在一串哈希槽内)我们是每8个字节之间对齐的,也就是如果申请3B,就要对齐到8B,如果申请的是13B就要对齐到16B,就像这样找到对应大于size的8的最小倍数。对齐到几B就给线程提供几B的空间。
那么哈希桶下标范围就很容易理解了,就如同我们上面所说,在这个范围就是按照每几个B对齐,下标为0的哈希桶(自由链表)连接的就是大小为8B的块空间,下标为1的哈希桶(自由链表)连接的就是大小为16B的块空间,下标为2的哈希桶(自由链表)连接的就是大小为24B的块空间,以此类推。
128 + 1, 1024\]就是指申请空间在129\~1024B以内的,对齐数就是16B,也就是如果申请130B,就要对齐到128 + 16 = 144B,也就是申请130B就会给线程144B。下标为16的哈希桶连接的就是144B的块空间。 当然,这里其实对tcmalloc的对齐规则做了一定的简化,实际上是会更复杂的,但无本质区别。 那么我们在之前说了这样可能会造成一定的空间浪费,但实现了以空间换时间的操作,那么实际上造成了多少的空间浪费呢? 对于1\~128字节的,这里浪费率会比较高,比如1B的,开8B,浪费了7/8的空间,不过这里是小空间,问题不大。每个范围中浪费率最高的是第一个,也就是1B、128+1B、1024+1B、8\*1024+1B、64\*1024+1B,这些浪费率肯定是最高的,因为每个范围中的size越往后,分母(分配的空间)就越大,而浪费的空间是定的,在这里我们不妨算几个: > 128 + 1B空间浪费率: > >  > > 1024 + 1B空间浪费率: > >  > > 8\*1024 + 1B空间浪费率: > >  > > 64\*1024 + 1B空间浪费率: > >  我们不难发现,此处我们可以将空间浪费率都控制在了10%左右。 按照这样的规则对齐的话,最终总共只需要208个桶。直接在Common.h中定义一个FREE_LIST_NUM表示哈希表中自由链表的个数:  那ThreadCache中我们变量应该定义为:  *** ** * ** *** ### SizeClass类 当线程申请一个size时,要计算出对应对齐之后的字节数,那么专门搞一个SizeClass类来计算对齐后的字节数,在内部提供对应接口,我们在Common.h中定义:  SizeClass中的接口搞成静态的,虽然其中的函数是被封装了的,但要独立使用。  考虑到此处每种做法相似,我们这里直接给一个子函数,用来计算对应分区中size对齐后的字节数,然后每个分区都调用这个子函数,这样就能求出size对齐后的字节数是多少了。 不过我们这个子函数还有其他写法,是大佬们研究出来的二进制写法,效率会更高:  感兴趣的小伙伴可以去试试,因为是简单计算,我们在这里就不过多提及了。 这里只是把size对齐后的字节数算出来了,线程在申请size的时候还要计算出该字节数应该对应到哪一个槽位也就是哈希桶,因为这里实现哈希是直接用数组搞的,所以就是要求出对应的下标。所以还要在SizeClass中加一个Index接口用来求size对应下标:  *** ** * ** *** ### ThreadCache类中的Allocate 我们在此新创建一个ThreadCache.cpp的文件专门实现接口  Allocate函数申请内存时,首先就是要确认我们申请的大小和哈希槽下标:  这俩点弄完后面就简单了,就跟我们当时做定长内存池一样,去查看我们对应哈希槽(桶)的位置去拿取内存空间就行,但如果tc没有内存了,那么我们tc就会向cc去拿,不过我们cc还未实现,这里就简单写个接口了。  至于为什么是这两个参数,我们当然后面会讲,现在无需在意。 然后我们还需要在自由链表类中加上一个判断哈希桶是否为空的接口:  那么,我们就可以补全了:  这里FetchFromCentralCache第二个参数直接给alignSize,就是指tc向cc申请空间的时候就不需要考虑对齐的问题了,直接申请整块的大小。 *** ** * ** *** ### ThreadCache类中的Deallocate 现在再写下回收的内容,此处就很简单了:  不过是不是还漏了些什么,之前我们说过,tc有空闲空间时,cc是可以回收这部分空间的,所以这块我们是没写完的,不过得等我们后续写完cc再说。 *** ** * ** *** ### TLS------Thread Local Storage 这里要说的TLS不是网络中的那个TLS。这里的TLS是thread local storage,也就是线程的本地存储。 之前在讲Linux系统编程的时候说过,一个进程可能有多个线程,多个线程几乎是共享整个进程的虚拟地址空间,每个线程有独立的栈、寄存器等独有的空间或数据,这里想要每个线程都有一个ThreadCache,那如何让线程与线程之间的ThreadCache不会相互影响呢?如何控制某个ThreadCache一定属于某个线程呢?一个进程要创建多少个ThreadCache呢?别着急,我们现在来揭开这层面纱。 没错,答案就是我们的标题:TLS。线程局部存储(TLS),是一种变量的存储方法,这个变量在它所在的线程内是全局可访问的,但是不能被其他线程访问到,这样就保持了数据的线程独立性,避免了一些加锁操作,控制成本更低。 关于TLS我在这里不会做讲解,如果有机会我会单独 水。。啊不,钻研一篇出来的(),如果你实在感兴趣,那么你可以在站内找一篇你看的顺眼的qwq 首先我们要在ThreadCache.h中定义一个全局的TLS对象:  到此ThreadCache就已经大体实现完毕了,不过有很多细节上的东西要做优化,等后面几篇再详谈这些东西。 *** ** * ** *** ## 📖 线程申请和释放空间的接口 整个项目每个线程要直接调用的接口就俩,一个申请空间,一个释放空间:  注释也写了,ConcurrentAlloc其实就是原项目中的tcmalloc,这两个接口使用起来就相当于malloc和free,调用malloc传参就是传一个大小,调用free就是传一个指针(size是为了过等会的测试点,之后会删)。 ### ConcurrentAlloc 这里是线程会调用这个ConcurrentAlloc函数,内部实现的时候首先要找对应的ThreadCache,先通过ThreadCache来申请空间,那么此时刚刚定义的TLS全局的ThreadCache指针pTLSThreadCache就排上用场了,直接通过pTLSThreadCache指针new一个对象出来:  我在这块先写好了查看此线程的id,与对应的pTLSThreadCache空间的地址。我们来测试一下  此处的测试代码我就给了,但项目代码现在就不给了,还是很乱的且不全,我也希望观众能自己下去敲一敲感受一下。 ```cpp #include"ThreadCache.h" #include"ConcurrentAlloc.h" #include"Common.h" void Alloc1() { for (int i = 0; i < 5; i++) { ConcurrentAlloc(6); } } void Alloc2() { for (int i = 0; i < 5; i++) { ConcurrentAlloc(7); } } void AllocTest() { std::thread t1(Alloc1); t1.join(); std::thread t2(Alloc2); t2.join(); } int main() { AllocTest(); return 0; } ``` *** ** * ** *** ### ConcurrentFree 此处就很简单了,我们直接上代码:  这里第二个参数不能确定,但是为了测试这里就先给ConcurrentFree多给一个参数size,不然这里跑不了,后面在写代码的时候再将Concurrent的第二个参数去掉 不过现在还测试不了,目前阶段缺的东西有点多,后面补充补充再测试。 *** ** * ** *** ## 结语 今天我们将我们的内存池开了个头,为大家讲解了我们的**整体框架,三层缓存,还有tc层的实现**,那后面还有很多内容值得我们学习,希望这个系列能为你带来学习上的帮助。 **我是YYYing,****后面还有更精彩的内容,希望各位能多多关注支持一下主包。** **无限进步,****我们下次再见!** *** ** * ** *** ## **---⭐️** **封面自取** **⭐️---** 