本篇文章,和大家分享和一些和项目相关的知识。本次的内容主要是模拟实现一个高并发内存池。

项目介绍

我们这个项目的原型是google的tcmalloc,tcmalloc的全称是Thread-Caching Malloc。我们之前使用的malloc,free,本身就是一个内存池,只不过google的这个在多线程方面更高效。

那我们是不是把tcmalloc这个项目,全部搞一遍吗?并不是。我们做这个项目的目的是,学习tcmalloc的精华,学习他们先进的思想。所以,我们的项目只是把tcmalloc最核心的框架简化出来,模拟实现一个自己的高并发内存。

什么是内存池?

池化技术

所谓池化技术,就是程序向系统申请过量的资源,然后自己管理起来。以备不时之需。举个生活中的例子:一个村中每次用水,都需要到河边去取水。后来,他们建了一个蓄水池,将河边的水储存在蓄水池中。这样他们洗衣服,洗菜的每次用水,就不用到河边去了,直接从蓄水池中取就可以了。

内存池也是同样的道理。

内存池

内存池指程序预先向操作系统申请一大块内存。往后,程序每次申请内存,就不需要向操作系统申请了,而是直接从内存池获取。

内存池主要解决的问题

内存池主要解决两个问题,一个是效率问题。举个例子,你一日三餐都需要花钱,你是一次要够30元效率高,还是分三次,到了饭点再向父母要钱效率高?当然是一次要够30元效率高啦!不然匿名,每次都需要花时间和父母和沟通。另一个是内存碎片问题。程序申请内存,是向进程地址空间的堆申请的。假设我们的vecotr申请了256Byte,map申请了256Byte,mysql申请了512Byte,list申请了128Byte。

项目-高并发内存池_多线程

过了一会,vector和list把空间释放了。腾出来了,384Byte的空间。可当我们申请300Byte的空间,我们没法申请成功。为什么?因为腾出来了的384Byte的空间,是不连续的。我们称之为碎片化。内存碎片又分为内碎片外碎片。下图这种情况,就是外碎片。内碎片我们待会结合代码说。

项目-高并发内存池_内存池_02

malloc

虽然我们申请内存是向堆申请的,但在C/C++中,我们不是直接向堆申请内存的,而是通过malloc函数申请。有人会说C++不是用new吗?new的底层封装了malloc,本质还是用的malloc。malloc本身就是一个内存池。它和操作系统的关系,如下图所示:

项目-高并发内存池_多线程_03

malloc只是规定了向操作系统申请一大批内存,然后零售给程序。对于malloc的具体实现有很多种,也就是内存池的实现方式有很多种。感兴趣大家可以自己去了解。

malloc也是一个内存池,那我们为什么不模仿它呢?主要是tcmalloc更优秀。

开胃小菜-定长内存池

我们从一个定长内存池开始写起。定长内存池,它只能进行固定大小内存的申请。其有两个特点:一是性能达到机制,二是不考虑内存碎片化。

项目-高并发内存池_多线程_04

定长内存池的实现,这里有两种方式实现。

一种是非类型的模板参数

项目-高并发内存池_多线程_05

但考虑到这部分代码要要作为整个项目的一部分,我们还是使用模板类型参数。

项目-高并发内存池_多线程_06

定长内存池,主要有两个功能。

第一个功能:申请空间

申请空间,我们需要做什么?首先,我们得向操作系统申请一段空间。然后,用一个指针来记录这段空间的位置。有这么多类型的指针,那我们用什么指针来作记录呢?当然是用char来记录啦!假设我们定义了一个char* _memory变量,记录内存池空间的起始位置。当程序向我们申请4Byte的空间,我们需要把最开始的4Byte大小的空间拿出来,给程序。最开始的4Byte大小的空间被使用了,下一次程序向内存池要空间,我们就不能把最开始的位置的地址返回给程序了。那我们应该返回哪里的地址?那不就是_memory+4的位置吗?加4,不正好是一个整形的大小吗?为什么我们不用int*,那如果我们申请的空间是3Byte呢?

项目-高并发内存池_高并发_07

有了思路,实现起来就很简单了。

项目-高并发内存池_高并发_08

第二个功能:回收空间

我们回收空间,不是将空间释放,而是将这些空间重新管理起来。那怎么去管理呢?我们要回收的空间是一段的,一段的。每一段空间,我们可以看作一个节点。让每段空间的前4个字节充当指针,保存下一段空间的地址。这样我们就可以,以链表的方式将回收的空间管理起来了。那我们怎么让回收的空间形成一个链表呢?我们可以定义一个无类型指针_freeList,每回收一段空间,我们就采用头插的方式,把回收的空间链入链表。

项目-高并发内存池_高并发_09

有了思路,我们开始实现

项目-高并发内存池_tcmalloc_10

项目-高并发内存池_多线程_11

定长内存池的申请回收,我们基本完成了。但还有一些细节需要完善

第一处细节,我们可以优先使用回收的空间

项目-高并发内存池_内存池_12

第二处细节,指针的大小可能是4字节也可能是8字节,如果我们回收的空间,不够4字节或8字节,无法存储地址怎么办?很简单,我们在开空间的时候,把不够4字节或8字节的空间开够4字节或8字节即可。

项目-高并发内存池_tcmalloc_13

第三处细节,我们分配到的空间还不是一个对象,我们还需要使用new对空间作初始化。

项目-高并发内存池_高并发_14

与之对应的我们还要显示调用析构函数,来清理对象

项目-高并发内存池_多线程_15

下图位置再修饰一下。

项目-高并发内存池_内存池_16

至此,我们的定长内存池就基本完成了,我们可以写一个测试代码来测试一下。

//测试代码
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();

	std::cout << "new cost time:" << end1 - begin1 << std::endl;
	std::cout << "object pool cost time:" << end2 - begin2 << std::endl;
}

写完测试代码,直接调用即可

项目-高并发内存池_tcmalloc_17

从运行结果看,我们不难看出内存池的效率更高

项目-高并发内存池_内存池_18

malloc其实也是内存池,我们还可以摒弃malloc,直接使用系统调用。

项目-高并发内存池_多线程_19

我们再测试一下性能。运行结果还是我们的进程池更快一些。

项目-高并发内存池_多线程_20

项目整体框架设计

tcmalloc比malloc在多线程上更快,其原因是malloc主要考率效率问题和碎片化问题,而tcmalloc还多考虑了多线程环境下,锁竞争的问题

我们这个项目模拟的内存池,分为三层:第一层是thread cache。这一层的用于小于254KB的内存分配,每一个线程都会有自己的thread cache对象,不存在锁的竞争问题。第二层是central cache。这一层为所有线程共享,存在锁的竞争问题。如果tread cache那一层的空间用完了,就会向central cache这一层申请空间。tread cache不仅会分配空间,还有回收空间。central cache会在合适的时机,将tread cache的空间回收。第三层是page cache。这一层以页为单位分配存储空间,用于缓解内存碎片问题。central cache的空间用完了,就会向page cache申请。当central cache回收的空间到达一定程度后,page cache就会合并成页回收。

项目-高并发内存池_高并发_21

thread cache整体设计

还记得我们的定长内存池吗?我们向操作系统申请一大块内存,然后,切分成一小块一小块来进行使用。使用完后,我们会以链表的形式,把回收的内存小块链接起来,形成一个链表。这个链表与我们之前使用的链表不太一样,它不存储任何的数据。我们把这种用于管理小块内存,不存储数据的链表称为自由链表

项目-高并发内存池_内存池_22

tcmalloc是需要满足不同大小内存申请需求的,而我们的定长内存池只能满足固定大小的。它们两者上有联系吗?

tcmalloc,我们是不是可以考虑使用多个自由链表,每个大小都用一个自由链表挂起来。可这样问题又来了?thread cache是要满足小于254KB的分配申请,如果我们每个大小都挂一个自由链表,那链表的数量将达20几万。

每个大小都挂一个链表,显然是不行的。于是,就有了下图的方案。我们做一些性能上的牺牲,8字节,16字节,24字节这样阶梯式的挂链表。怎么理解呢?如果你申请的空间小于8字节,我们直接给你开够8字节。如果你申请的空间大于8字节,小于或等于16字节,我都给你开16个字节。

项目-高并发内存池_多线程_23

因为这样设计的缘故,我们会发现,有时8字节的空间,我们只用了5个字节或者6个字节,还会剩下3个或2个字节没有使用。这些无法利用的空间,也是一种碎片化,我们称这种碎片化为内碎片化

项目-高并发内存池_tcmalloc_24

那什么是外碎片化呢?外碎片化是,一段连续的空间被分配成若干块分配出去了。有几块被回收了,总共300KB。但由于回收的这几块内存不连续,没法满足300KB的空间申请。简单来说,外碎片就是有内存,但没法分配。


思路有了,我们开始实现。


创建三个文件,一个是Common.h,用于存放其他源文件和头文件都会用到的东西。二是ThreadCache.cpp,三是ThreaCache.h。

自由链表是大家都会用到了

我们在Common.h中,实现一下

项目-高并发内存池_高并发_25

Pop和Push都用到了*(void**),我们可以优化成一个函数

项目-高并发内存池_tcmalloc_26

在TreadCache.h中,我们需要提供了申请释放空间的函数和一个自由链表的数组

项目-高并发内存池_tcmalloc_27

我们在TreadCache.cpp中,实现这两个函数。

虽然说这里也用到了哈希桶,但这里的映射规则与以往不同。以前,我们是通过一个key值来找某个桶,再找数据,也就是一个值对应一个桶。而现在,我们是一段范围找一个桶,也就是不管你是要1字节还是2字节,我们都是找8字节。

所以,我们在实现Allocate之前,需要建立好一个映射规则。

这个规则,我们用一个SizeClass类来实现,放在common.h头文件里

如果说我们每隔8字节挂一个链表,链表的数量依然会很大。所以,我们采用梯度式间隔挂链表。什么意思呢?结合下图,我们来理解一下。在1到128字节,这个区间,我们每隔8字节挂一个链表。比如8,16,24。在129字节到1024字节这个区间,我们则采用每隔16字节挂一个链表,比如:144,160。那什么叫8byte对齐呢?就是不满8字节,补齐8字节。你要1字节,我给你8字节。那freeList[,)是什么意思?它表示存放链表的相应区间。

注意,我们挂链表是从8字节开始的。为什么?因为64环境下的指针大小为8字节。

项目-高并发内存池_内存池_28

增加一个RoundUp函数,来计算申请空间对齐后的大小

项目-高并发内存池_高并发_29

上面这种RoundUp的写法只是为了便于理解,高手是下图那样写的。

项目-高并发内存池_多线程_30

怎么理解高手的写法呢?换成二进制你就理解了。假设对齐数是16,如果你需要9字节,我们会给你16字节。根据上面的写法就是9+对齐数16减一,等于24。多出来了8字节。如果是10就会多出来9字节,你仔细观察就会发现。这些多出来的部分,就是由最低的四个bite位组成。所以,我们只需要无脑将最低的四个bite位置零即可。

项目-高并发内存池_tcmalloc_31

然后,我们再加上一层封装便于函数的调用

项目-高并发内存池_tcmalloc_32

确定了对齐后的大小后,我们需要确定这个大小的内存,在数组中的位置,也就是那个自由链表。

我们用一个Index函数来计算位置,_Index是Index的子函数 

项目-高并发内存池_内存池_33

每个区间的链表和起来,共208个链表,所以,数组的大小为208

项目-高并发内存池_高并发_34

映射规则完成了,我们开始实现Allocate函数

项目-高并发内存池_高并发_35

在FreeList类中,加一个Empty的实现

项目-高并发内存池_多线程_36

链表为空就要去下一层central cache获取

项目-高并发内存池_多线程_37

每个线程都有自己的thread cache。我们如何保证每个线程拥有自己的thred cache,又如何确定哪个thread cache是哪个线程的呢?

我们需要用到线程局部存储(thread local stroage),简称TLS。详细介绍:Thread Local Storage(线程局部存储)TLS - 知乎 (zhihu.com)

具体操作如下:

第一步:定义一个pTLSthreadcache变量

项目-高并发内存池_内存池_38

第二步:增加一个ConcurrentAlloc.h文件,再封装一层函数,确保每个线程都有自己thread cache 。

项目-高并发内存池_高并发_39

第三步:完善DeAllocate函数,并且简单的实现FetchFromCentralCache函数

项目-高并发内存池_内存池_40

项目-高并发内存池_多线程_41

第四步:在UnitTest.cpp文件中,简单的写一个测试,测试thread cache的功能

项目-高并发内存池_tcmalloc_42

编译运行,我们就能看到每个进程都有自己对应的thread cache。

项目-高并发内存池_tcmalloc_43

central cache整体设计

我们先来简单的理解central cache的整体设计思路。

central cache这一层用的也是哈希桶的思想,只不过,central cache这一层挂的是span,span是以页为单位的大块内存。每个桶的span的数量以及一个span由多少个页组成,都是不同的。比如说下图中,可能8字节后面的span数量会有三个,每个span又会以8字节为单位被划分成很多的小块内存。而256字节位置后面则只有两个span,但这两个span会比8字节后面的span大。

thread cache那一层申请内存,首先会从哈希桶的自由链表中申请。如果自由链表中没有内存块了,就会向central cache这一层申请。central cache这一层是所有线程共享,在线程申请释放内存时,需要加锁。这里加的锁是桶锁。什么意思?当多个线程进行申请释放内存时,如果它们访问的都是8字节位置的链表,就需要加锁。如果它们访问的不是同一个位置的链表,就不需要加锁。

项目-高并发内存池_多线程_44

thread cache一直释放回收内存,不会一直的存放在自由链表中。当回收的内存达到一定程度后,会还给central cache。central cache又可以将这些回收内存提供给其他的thread cache使用,做到均衡调度。均衡调度是central cache的其中一个价值,它的另一个价值是缓解外碎片问题。在Span中,会有一个_usecount变量,用来统计Span的使用情况。我以8字节为划分单位的span为例。一个span,会被划分成很多个8字节的小块内存。这些小块内存,每被使用一个,变量_usecount就会加一。当_usecount减为零的时候,表示span中的所有小块内存,都被回收了。当一个span的内存全部回收后,又会归还给page cache,进而连接组成一个页,从而缓解外碎片问题。为什么说缓解而不是解决呢?因为回收的内存,就是不连续,你也没办法。这个我们到代码中,在详细了解。

项目-高并发内存池_内存池_45

有了思路,我们开始实现

第一步:创建两个文件,一个是CentralCache.h,另一个是CentralCache.cpp

第二步:在Common.h,定义实现Span。为不定义在Central Cache呢?因为Span不只是central cache这一层需要使用,page cache这一层也需要使用。

补充一个知识:Span中的第一个属性是页号。32位的系统,物理空间是4G,如果一个页的大小是8KB,就会有2的19次方个页,用size_t类型存储即可。但64位的系统会有2的51次方个页,需要使用unsigned long long来进行存储,对于Linux又需要另行考虑。针对这个问题我们需要使用条件编译,来定义一个可变的类型,来适应不同的环境

项目-高并发内存池_tcmalloc_46

项目-高并发内存池_高并发_47

项目-高并发内存池_tcmalloc_48

第三步:定义实现带头双向链表

项目-高并发内存池_高并发_49

第四步:使用C++中的mutex来加锁

项目-高并发内存池_多线程_50

第五步:central cache为所有线程所共有,只有一个,我们可以使用单例模式

项目-高并发内存池_tcmalloc_51

第六步:我们每次从Central Cache申请多少个内存块,在SizeClass类中,用一个函数来实现

项目-高并发内存池_tcmalloc_52

第七步:实现ThreadCache.h中的FetchFromCentralCache函数

在FreeList中,增加一个_maxSize变量和一个获取该变量值的函数

项目-高并发内存池_tcmalloc_53

在CentralCache类中,增加获取单例的方法和获取对象的声明

项目-高并发内存池_多线程_54

在FreeList类中增加一个PushRange函数,允许头插一段链表

项目-高并发内存池_tcmalloc_55

项目-高并发内存池_多线程_56

第八步:在CentralCache.cpp中,完善FetchRanObj函数

项目-高并发内存池_高并发_57

GetOneSpan函数需考虑page cache这一层,我们先在CentralCache类中,做一个定义

项目-高并发内存池_高并发_58

page cache整体设计

我们先理解一下page cache的整体思路。

page cache这一层,也是采用的哈希桶的方式,每个桶链接的是spn。它的span中的内存,不再划分成小块,就是一页一页的。还有就是它的映射规则也与前两层不同,page cache这一层是直接以页数来作为映射规则。什么意思呢?哈希桶的第一个桶,它的span中的页数,就是一。第二个桶的span中的页数就是固定的两个页。page central这一层的桶,总共是128个,也就是span的最大页数是128。

项目-高并发内存池_tcmalloc_59

central cache中,无法满足thread cache的申请时,就会向page cache申请。假设,central cache向page cache申请一个两页大小的span,而page cache的第二个桶,没有span,它会向第三个桶申请。依次是第四个桶,第五个,第六个.......如果第128个桶也没有,page cache会向系统申请一个128页大小的span。然后,把这个具有128页的span分成两个span,一个是两页的span和一个是126页的span,两页的返回给central cache,126页的链入第126个桶。这样设计的原因是,为了缓解内存碎片问题。由于我们一开始申请的内存,就是连续。当central cache归还span时,我们可以检索与该span相邻的空间,是否被回收。如已被回收,我们就可以将它们合并成更大的页。

page cache这一层,需要加锁,这里加的锁是全局的锁,而不是桶锁。

项目-高并发内存池_高并发_60

有了思路,我们开始实现page cache

用一个变量来保存,page cache的哈希桶个数

项目-高并发内存池_tcmalloc_61

创建两个文件,一个叫PageCache.h,一个叫PageCache.cpp

page cache为所有线程所共享,只有一个,采用单例模式实现。

项目-高并发内存池_多线程_62

我们先实现central cache获取span的功能

central cache这一层获取span,需要遍历整个链表,找到不为空的span

我们在SpanList类中增加一个遍历链表的方法

项目-高并发内存池_内存池_63

具体实现如下:

项目-高并发内存池_多线程_64

确定申请页数的函数NumMovePage放在SizeClass类中

项目-高并发内存池_高并发_65

PushFront函数放在SpanList类中

我们后面生成页号的方式是,用地址除以一个页的大小。现在,我们知道页号,想知道开辟空间的地址,使用页号乘以一个页的大小即可

项目-高并发内存池_内存池_66

central cache中没有可分配的空间后,会通过NewSpan函数找page cache,要内存。

NewSpan函数的具体实现如下:

项目-高并发内存池_tcmalloc_67

在SpanList类中,增加一个链表判空的函数Empty和头删函数PopFront

项目-高并发内存池_多线程_68

项目-高并发内存池_高并发_69

在Common.h中,增加一个向系统申请内存的函数

项目-高并发内存池_高并发_70

如果你不理解,切分的过程,可以参考下图例子,理解一下

项目-高并发内存池_内存池_71

刚刚GetOneSpan函数的实现,我们没有考虑加锁的问题。下面,我们把锁处理一下。

第一个:在下图的位置,我们需要在线程进入page cache之前,把桶锁解除,避免影响其他线程进行内存回收。

项目-高并发内存池_高并发_72

第二个:我们把_pageMtx锁公开,允许访问。在下图位置,加上锁,确保只有一个线程对page cache进行访问。这个锁,也可以加在NewSpan函数中,但由于NewSpan函数是递归函数,不能直接加锁。有两种解决方案,一种是给NewSpan函数设置子函数_NewSpan,再增加一个NewSpan函数,调用_NewSpan,在调用前后进行加锁解锁。第二种方式,使用递归锁。

项目-高并发内存池_tcmalloc_73

第三个:如下图位置,我们在访问桶之前,需要把锁加上。

项目-高并发内存池_内存池_74

测试

我们在UnitTest.cpp增加两个测试用例,来测试一下我们的程序是否能跑通

项目-高并发内存池_多线程_75

为方便观察,我们在GetOneSpan函数中的下图位置,增加一个变量i,统计实际切分的个数

项目-高并发内存池_内存池_76

运行测试testConcurrentAlloc1,我们发现程序崩了。原因出在两处位置

第一处是下图中的位置,actualNum应该是大于0,而我们前面写的代码是大于1

项目-高并发内存池_tcmalloc_77

第二处是下图位置,这里应该调用的是NumMovePage,我们前面写的代码是调用NumMoveSize

项目-高并发内存池_内存池_78

做完如上两处的修改,就能正常运行testConcurrentAlloc1函数。通过逐步调试观察的方式,程序基本上是没什么问题的

项目-高并发内存池_高并发_79

运行testConcurrentAlloc2函数,主要是观察,内存二次向系统申请内存过程有没有问题。程序会向显示器打印两次1024,下图截取了部分运行结果

项目-高并发内存池_多线程_80

central cache这一层的span中的小块内存每使用一个,我们就需要在FetchRangeObj函数的位置对_useCount变量做更新。

项目-高并发内存池_tcmalloc_81

thread cache回收内存

思路:

当thread cache某一个桶的挂的内存数量大于一次批量申请的数量时,我们就向central cache归还一次批量的内存。举个例子,假如我们此时向系统申请的内存,系统会给我们返回100个内存对象,那我们就在自由链表的内存对象数量大于等于100的时候,回收100个内存对象。

我们这里只考虑了内存对象的个数,在tcmalloc中,还考虑了thread cache的内存大小,当thread cache的总内存大小大于2MB时,会对哈希桶进行一次清理

具体实现如下:

项目-高并发内存池_高并发_82

项目-高并发内存池_多线程_83

项目-高并发内存池_多线程_84

项目-高并发内存池_内存池_85

修改之前调用PushRange的位置

项目-高并发内存池_tcmalloc_86

在Central cache.h中,声明ReleaseListToSpans

项目-高并发内存池_多线程_87

 central cache回收内存

思路:

thread cache归还的内存,可能来自这个span,也可能来自另一个span。那我们怎么知道一块内存来自那一个span呢?

很简单,来自同一个页的内存块,它们的地址除以一个页的大小,所得到的数字,正好和span的页号相同

项目-高并发内存池_多线程_88

这个我们可以用一个简单的测试用例测试:

项目-高并发内存池_高并发_89

项目-高并发内存池_内存池_90

当我们知道了一个小块内存属于那个span后,就可以遍历哈希表,找到相应的span,归还内存。但这样的方式,太慢了,我们可以用哈希表的思想做一个优化

具体实现如下:

优化用的哈希表_idSpanMap,放到PageCache类中,待会page cache也会用到

项目-高并发内存池_高并发_91

我们在PageCache.cpp文件的NewSpan函数,如下位置,增加一段代码,建立起id和span的映射关系

项目-高并发内存池_高并发_92

实现找映射关系的函数

项目-高并发内存池_高并发_93

实现ReleaseListToSpans

项目-高并发内存池_内存池_94

在PageCache.h中,声明ReleaseSpanToPageCache

项目-高并发内存池_高并发_95

page cache回收内存

思路:

central cache还回来的内存,不能直接挂回哈希表。我们需要将span前后相邻的span进行合并,形成更大的页,以此来解决外碎片问题。那我们怎么找到span前后相邻的span呢?同样的使用哈希表的方式, 这里我们并不需要把没有使用的span的所有页都存到,_idSpanMap中,只需要将未使用span地址的起始页,和最后一页映射放到_idSpanMap中即可。那我们怎么知道一个span有没有使用呢?我们可以在Span的结构体中加一个变量,用来记录Span有没有使用。

项目-高并发内存池_多线程_96

项目-高并发内存池_多线程_97

具体实现:

项目-高并发内存池_高并发_98

分配出去的span,将_isUse变量设置成true

项目-高并发内存池_多线程_99

在NewSpan函数中,给没被使用的span,建立关系

项目-高并发内存池_多线程_100

实现ReleaseSpanToPageCache

项目-高并发内存池_内存池_101

测试

第一个测试用例:

项目-高并发内存池_内存池_102

通过调试,我们不难发现,FreeList类中的_size函数,我们没有进行初始化

项目-高并发内存池_tcmalloc_103

第二个测试用例:

项目-高并发内存池_多线程_104

大于256KB内存的申请

思路:

大于258KB的内存申请,分为两种情况,一种是小于等于128*8KB,另一种是大于128*8KB。

第一种情况,我们可以直接找page cache要。第二种情况,则是向系统申请

项目-高并发内存池_tcmalloc_105

具体实现:

项目-高并发内存池_多线程_106

修改ConcurrentAlloc函数

项目-高并发内存池_tcmalloc_107

修改SizeClass中的RoundUp函数

项目-高并发内存池_多线程_108

修改NewSpan函数

项目-高并发内存池_高并发_109

修改ConcurrentFree函数

项目-高并发内存池_tcmalloc_110

修改ReleaseSpanToPageCache函数

项目-高并发内存池_高并发_111

在Common.h中,增加SystemFree函数

项目-高并发内存池_内存池_112

在UnitTest.cpp中,增加一个BigAlloc测试函数

项目-高并发内存池_多线程_113

使用定长内存池配合脱离使用new

我们模拟tcmalloc,就是用来替代malloc的。可我们在项目里又使用了new,本质还是使用了malloc,这是不是矛盾了呢?

还记得我们前面实现的定长内存池,我们可以用我们实现的定长内存池来替代使用了new的地方。

具体实现如下:

ObjectPool.hpp中,除了ObjectPool类,其他的代码都注释掉

项目-高并发内存池_多线程_114

增加一个span内存池变量_spanPool

项目-高并发内存池_tcmalloc_115

把PageCache.cpp中,所有使用了new Span的地方全部改成_spanPool.New()

项目-高并发内存池_tcmalloc_116

项目-高并发内存池_tcmalloc_117

项目-高并发内存池_tcmalloc_118

把PageCache.cpp中,所有使用了delete Span的地方全部改成_spanPool.Delete(span)

项目-高并发内存池_内存池_119

项目-高并发内存池_多线程_120

项目-高并发内存池_多线程_121

ConcurrentAlloc.hpp中的new也可以修改一下

项目-高并发内存池_高并发_122

释放对象,不传对象大小

我们在使用free和delete的时候,都传了指针,并没有传对象的大小。

我们怎么实现这个功能呢?

我们可以在Span对象中,增加一个_objSize变量,用来记录对象的大小,这样我们就可以只传指针了。

具体实现:

项目-高并发内存池_内存池_123

在GetOneSpan函数中,成功获取到span后,记录对象的大小

项目-高并发内存池_tcmalloc_124

修改ConcurrentFree函数,只用传指针

项目-高并发内存池_内存池_125

修改ConcurrentAlloc函数,记录大于256KB的申请的对象

项目-高并发内存池_高并发_126

更改测试用例,再测一遍程序

项目-高并发内存池_内存池_127

多线程下,对比mallloc

创建一个Betchmark.cpp文件,进一步测试我们程序

项目-高并发内存池_高并发_128

注释掉,UnitTest.cpp文件中的主函数后,编译运行,直接报错

经过漫长的调试,发现四处bug

第一处:NewSpan函数下图位置,需要先建立好映射再返回

项目-高并发内存池_多线程_129

第二处:GetOneSpa函数中,切分完小块内存后,需要将尾部置空

项目-高并发内存池_高并发_130

第三处:ConcurrentAlloc函数中,我们用static修饰,tcPool变量,它相当于全局变量,属于所有线程的共享资源,需要加锁保护

项目-高并发内存池_高并发_131

我这里给定长内存池,增加了一个互斥锁成员

项目-高并发内存池_tcmalloc_132

第四处:我们在ConcurrentFree函数中,调用MapObjectToSpan的时候,也需要加锁

项目-高并发内存池_内存池_133

MapObjectToSpan函数中,访问了_idSpanMap这个共享资源。如果你在访问_idSpanMap的时候,其他线程对它进行修改,你所获得的迭代器就会失效

项目-高并发内存池_多线程_134

同理,在ReleaseListToSpans函数中,也要加上锁

项目-高并发内存池_多线程_135

除了上面这种加锁的方式,我们还可以直接在MapObjectToSpan函数中加锁,这样我们只用修改一处地方

项目-高并发内存池_高并发_136

调试的详细过程就变演示了。

调试中有三个好用的技巧。

第一个是条件断点。

当条件满足,程序就会停下来,而不是崩掉

项目-高并发内存池_高并发_137

第二个是调用堆栈

项目-高并发内存池_多线程_138

第三个:如果程序疑似死循环了,可以采用全部中断,这样程序会在运行的位置停下来

项目-高并发内存池_内存池_139

性能提升

通过测试,我们不难发现,我们模拟的tcmalloc很慢

项目-高并发内存池_内存池_140

通过vs2022自带的性能分析工具,我们不难发现,时间都花费在了unique_lock的锁上

项目-高并发内存池_内存池_141

那我们怎么进行优化?我们可以借鉴谷大佬的经验,也采用基数树的方式

基数树,我们直接借助tcmalloc中实现的基数树。但拿过来的基数树不能直接使用,需要做一些修改。

基数树,共有三颗,第一颗有一层,第二颗有两层,第三颗有三层。

第一颗树非常简单,说白了就是一个数组。它存储的是id和span的映射关系,id是几,对应数组下标为几,本质还是哈希。查找很快。基数树的实现,采用了非类型模板参数。下图中的BITS表示存储页号,最多需要多少个比特位,32位需要19,64位需要51。变量LENGTH表示的是数组元素个数。以32位为例,一层大概需要2MB的空间,但64位需要的空间很大,不能只用一层来实现。

项目-高并发内存池_高并发_142

第二棵树,有两层。它是分层哈希。

第二棵树和第一颗树的总体大小是一样,大家可算一下。下图中,RooT_LENGTH是第一层的长度,LEAF_LENGTH是第二层的长度。我们的页号总共需19位,放到内存中就是0到18,一共19位。14到18,一共5位,这5位决定你在第一层的那个位置。0到13,一共14位,决定你在第二层的那个位置。

项目-高并发内存池_高并发_143

第三棵树,有三层,这是为64位的环境准备的。

 我们创建一个PageMap.h来存放基数的实现

#define _CRT_SECURE_NO_WARNINGS
#pragma once
#include "Common.h"
#include "ObjectPool.h"

// Single-level array
template <int BITS>
class TCMalloc_PageMap1 {
private:
	static const int LENGTH = 1 << BITS;
	void** array_;

public:
	typedef uintptr_t Number;

	//explicit TCMalloc_PageMap1(void* (*allocator)(size_t)) {
	explicit TCMalloc_PageMap1() {
		//array_ = reinterpret_cast<void**>((*allocator)(sizeof(void*) << BITS));
		//写死了适配32位的环境
		size_t size = sizeof(void*) << BITS;
		size_t alignSize = SizeClass::_RoundUp(size, 1 << PAGE_SHIFT);
		array_ = (void**)SystemAlloc(alignSize >> PAGE_SHIFT);
		memset(array_, 0, sizeof(void*) << BITS);
	}

	// Return the current value for KEY.  Returns NULL if not yet set,
	// or if k is out of range.
	void* get(Number k) const {
		if ((k >> BITS) > 0) {
			return NULL;
		}
		return array_[k];
	}

	// REQUIRES "k" is in range "[0,2^BITS-1]".
	// REQUIRES "k" has been ensured before.
	//
	// Sets the value 'v' for key 'k'.
	void set(Number k, void* v) {
		array_[k] = v;
	}
};

// Two-level radix tree
template <int BITS>
class TCMalloc_PageMap2 {
private:
	// Put 32 entries in the root and (2^BITS)/32 entries in each leaf.
	static const int ROOT_BITS = 5;
	static const int ROOT_LENGTH = 1 << ROOT_BITS;

	static const int LEAF_BITS = BITS - ROOT_BITS;
	static const int LEAF_LENGTH = 1 << LEAF_BITS;

	// Leaf node
	struct Leaf {
		void* values[LEAF_LENGTH];
	};

	Leaf* root_[ROOT_LENGTH];             // Pointers to 32 child nodes
	void* (*allocator_)(size_t);          // Memory allocator

public:
	typedef uintptr_t Number;

	//explicit TCMalloc_PageMap2(void* (*allocator)(size_t)) {
	explicit TCMalloc_PageMap2() {
		//allocator_ = allocator;
		memset(root_, 0, sizeof(root_));

		PreallocateMoreMemory();
	}

	void* get(Number k) const {
		const Number i1 = k >> LEAF_BITS;
		const Number i2 = k & (LEAF_LENGTH - 1);
		if ((k >> BITS) > 0 || root_[i1] == NULL) {
			return NULL;
		}
		return root_[i1]->values[i2];
	}

	void set(Number k, void* v) {
		const Number i1 = k >> LEAF_BITS;
		const Number i2 = k & (LEAF_LENGTH - 1);
		ASSERT(i1 < ROOT_LENGTH);
		root_[i1]->values[i2] = v;
	}

	bool Ensure(Number start, size_t n) {
		for (Number key = start; key <= start + n - 1;) {
			const Number i1 = key >> LEAF_BITS;

			// Check for overflow
			if (i1 >= ROOT_LENGTH)
				return false;

			// Make 2nd level node if necessary
			if (root_[i1] == NULL) {
				//Leaf* leaf = reinterpret_cast<Leaf*>((*allocator_)(sizeof(Leaf)));
				//if (leaf == NULL) return false;
				static ObjectPool<Leaf>	leafPool;
				Leaf* leaf = (Leaf*)leafPool.New();

				memset(leaf, 0, sizeof(*leaf));
				root_[i1] = leaf;
			}

			// Advance key past whatever is covered by this leaf node
			key = ((key >> LEAF_BITS) + 1) << LEAF_BITS;
		}
		return true;
	}

	void PreallocateMoreMemory() {
		// Allocate enough to keep track of all possible pages
		Ensure(0, 1 << BITS);
	}
};

// Three-level radix tree
template <int BITS>
class TCMalloc_PageMap3 {
private:
	// How many bits should we consume at each interior level
	static const int INTERIOR_BITS = (BITS + 2) / 3; // Round-up
	static const int INTERIOR_LENGTH = 1 << INTERIOR_BITS;

	// How many bits should we consume at leaf level
	static const int LEAF_BITS = BITS - 2 * INTERIOR_BITS;
	static const int LEAF_LENGTH = 1 << LEAF_BITS;

	// Interior node
	struct Node {
		Node* ptrs[INTERIOR_LENGTH];
	};

	// Leaf node
	struct Leaf {
		void* values[LEAF_LENGTH];
	};

	Node* root_;                          // Root of radix tree
	void* (*allocator_)(size_t);          // Memory allocator

	Node* NewNode() {
		Node* result = reinterpret_cast<Node*>((*allocator_)(sizeof(Node)));
		if (result != NULL) {
			memset(result, 0, sizeof(*result));
		}
		return result;
	}

public:
	typedef uintptr_t Number;

	explicit TCMalloc_PageMap3(void* (*allocator)(size_t)) {
		allocator_ = allocator;
		root_ = NewNode();
	}

	void* get(Number k) const {
		const Number i1 = k >> (LEAF_BITS + INTERIOR_BITS);
		const Number i2 = (k >> LEAF_BITS) & (INTERIOR_LENGTH - 1);
		const Number i3 = k & (LEAF_LENGTH - 1);
		if ((k >> BITS) > 0 ||
			root_->ptrs[i1] == NULL || root_->ptrs[i1]->ptrs[i2] == NULL) {
			return NULL;
		}
		return reinterpret_cast<Leaf*>(root_->ptrs[i1]->ptrs[i2])->values[i3];
	}

	void set(Number k, void* v) {
		ASSERT(k >> BITS == 0);
		const Number i1 = k >> (LEAF_BITS + INTERIOR_BITS);
		const Number i2 = (k >> LEAF_BITS) & (INTERIOR_LENGTH - 1);
		const Number i3 = k & (LEAF_LENGTH - 1);
		reinterpret_cast<Leaf*>(root_->ptrs[i1]->ptrs[i2])->values[i3] = v;
	}

	bool Ensure(Number start, size_t n) {
		for (Number key = start; key <= start + n - 1;) {
			const Number i1 = key >> (LEAF_BITS + INTERIOR_BITS);
			const Number i2 = (key >> LEAF_BITS) & (INTERIOR_LENGTH - 1);

			// Check for overflow
			if (i1 >= INTERIOR_LENGTH || i2 >= INTERIOR_LENGTH)
				return false;

			// Make 2nd level node if necessary
			if (root_->ptrs[i1] == NULL) {
				Node* n = NewNode();
				if (n == NULL) return false;
				root_->ptrs[i1] = n;
			}

			// Make leaf node if necessary
			if (root_->ptrs[i1]->ptrs[i2] == NULL) {
				Leaf* leaf = reinterpret_cast<Leaf*>((*allocator_)(sizeof(Leaf)));
				if (leaf == NULL) return false;
				memset(leaf, 0, sizeof(*leaf));
				root_->ptrs[i1]->ptrs[i2] = reinterpret_cast<Node*>(leaf);
			}

			// Advance key past whatever is covered by this leaf node
			key = ((key >> LEAF_BITS) + 1) << LEAF_BITS;
		}
		return true;
	}

	void PreallocateMoreMemory() {
	}
};

上面的代码,第三颗树没有做修改

下面,我们将unordered_map改成第一颗基数树

项目-高并发内存池_高并发_144

把向_idspanMap写数据方式改一下

项目-高并发内存池_多线程_145

项目-高并发内存池_高并发_146

项目-高并发内存池_内存池_147

项目-高并发内存池_内存池_148

项目-高并发内存池_tcmalloc_149

项目-高并发内存池_高并发_150

·通过测试,我们不难发现我们模拟的tcmalloc变高效了

项目-高并发内存池_多线程_151

 为什么采用基数树的方式更快呢?这是因为,基数树的结构是确定的,并且多个线程对基数树进行同时读写的情况互不影响,不需要加锁。而我们使用unordered_map的时候,必须加锁。如果不加锁,一个线程在访问unordered_map时,又来了一个线程,这个线程对unordered_map进行了修改,造成unordered_map底层的数据结构发生了变化,就会出现错误返回的现象。

好了,到这里,我们本次的分享就到此结束了,不知道我有没有说明白,给予你一点点收获。关于更多和项目相关的知识,会在后面的文章更新。如果你有所收获,别忘了给我点个赞,这是对我最好的回馈,当然你也可以在评论发表一下你的收获和心得,亦或者指出我的不足之处。如果喜欢我的分享,别忘了给我点关注噢。