项目:高并发内存池(超详细讲解)

目录

定长内存池

高并发内存池

整体框架

thread cache的整体设计

哈希桶映射对齐规则

threadcacheTLS无锁访问

centralcache整体设计

centralcache结构设计

centralcache核心实现

pagecahe整体设计

申请内存过程联调

回收内存设计

threadcache回收内存

centralcache回收内存

pagecache回收内存

释放内存过程联调

大于256KB的大块内存申请问题

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

释放对象时优化为不传对象大小

基数数优化性能瓶颈


关于高并发内存池

高并发内存池,他的原型是google的大的开源项目tcmalloc,即线程缓存的malloc, 实现了高效的多线程内存管理,用于替代系统的内存分配相关的函数(malloc、free) 。

我们这个项目是把tcmalloc最核心的框架简化后拿出来,模拟实现出一个自己的高并发内存池,目的就是学习tcamlloc的精华

内存池可以解决效率问题、内存碎片问题

效率问题例如生活费问题,是直接要一个月的生活费,还是每天要每天的生活费,这两种方式的效率是不同的

内存碎片问题:

外碎片:
在我们的进程地址空间中,不同的进程需要申请不同大小的空间,例如vector申请128Byte,string申请512Byte,mysql申请128Byte,这时系统释放了vector申请128Byte和mysql申请128Byte,此时空余出来了256Byte的空间,但是这两部分空间确实不连续的,如果有一个进程需要申请256Byte的空间,是不能满足要求的
外碎片导致的是有足够的空间,但是申请不出大块的空间

内碎片:
内碎片指的是在哈希桶里,假设只有8字节,16字节的桶,那我申请5字节的内存,只能给我8字节,剩余3字节没有用;我申请10字节内存的话,只能给16字节,剩余6字节没有用,这种碎片问题称为内碎片问题
内碎片是由于对齐的需求,一些小块的空间利用不上


定长内存池

首先写一个定长内存池,来作为最后高并发内存池的一部分

刚开始的代码:


此时有一个问题,当开辟的大块内存被切分完了,下一次进入时memory指向了其他位置,且memory并不为空,此时使用完的情况没有办法解决,所以加了一个_remainBytes变量,用于表示大块内存在切分过程中,剩余的字节数

所以就不用_memory == nullptr,直接判断_remainBytes是否为0,这时又有一个问题,假设需要10字节,但是剩余5字节,此时虽然_memory不是空, 但是也需要开辟新的_memory,所以判断条件改为_remainBytes < sizeof(T)即可

New函数最终改为:


有New函数,也就定义Delete函数,参数是T* obj

Delete函数中需要处理还回去的内存块链接的问题

我们需要用内存块的前4个字节(32位环境)存下一个内存块的地址,所以可以将obj强转为int*的,这样obj就能够存地址了,然后解引用就可以改变存的地址的具体的数值了

obj是int*的,解引用就是int,占的字节数是4

但是这时候就有一个问题,int*是4字节的,32位下可以用,但是如果是64位平台下,一个地址需要的字节数是8,这时就跑不动了

所以改为*(void**)obj = nullptr,将obj强转为void**,obj是void**类型的,解引用是void*类型的,这里的void*是指针类型,在32位下是4,在64位下是8,就完美的解决了这个问题
这里int**也是可以的,只要是二级指针就可以


下面思考如何把还回来的内存块链接起来呢,难道是每次都找尾结点,然后插入到最后面吗?这样可以但是没必要,因为每次找尾结点都需要O(N)的时间复杂度,我们可以每次将还回来的内存块头插即可:

即将原本freeList指针指向的内存存入obj中,在将freeList指针指向obj,就完成了头插操作

同时,在freeList为空时,上述操作也可以满足要求,所以改为:


下面思考,难道每次都是一申请内存,就从大的代码块中申请吗?并不是,因为在申请时,有可能已经有内存块还回来,并存到freeList锁指向的自由链表中了,所以优先使用还回来的内存块对象,重复利用

所以需要对自由链表进行头删:

next是此时自由链表中的第二个内存块,需要做的是如上图所示的操作        

next是存在第一个内存块的前4/8个字节(后面只说4字节)上,而第一个内存块的前4字节是freeList指针所指向的,所以next的内存可以*((void**)_freeList)获得,接着将_freeList指向的第一个内存块给obj,然后改变指向,_freeList指向next,最后返回头部的内存块obj

然后把这个重复利用的逻辑和上面实现的判断剩余内存够不够的逻辑,用if、else结合起来,即先判断能不能重复利用,再在申请大块内存:


此时大逻辑已经形成,但是如果一个内存块只有4个字节大小,而在64位环境下,指针需要8个字节大小的空间,这个问题怎么解决呢?

很简单,申请内存时,先三目运算符比较一下申请的内存块大小是否小于sizeof(void*)大小,如果小于,说明该内存块存不下一个指针,此时我们就多开一点,开的空间大小开到一个指针大小;如果不小于sizeof(void*),那就正常开辟sizeof(T)即可:


这里的New和Delete函数,都只是开空间,但是没有初始化,所以需要使用定位new显示初始化,使用了定位new,也就需要显示的调用析构函数:

Delete函数:


经过调试发现,在New函数中,如果是第一次申请空间,malloc以后,_remainBytes依旧是0,但是我们却_remainBytes -= objSize了,所以会出错,下面加以改正:


下面是ObjectPool这个定长内存池的完整代码:
在ObjectPool.h中

#include <iostream>
#include <vector>
#include <time.h>

using std::cout;
using std::endl;

//定长内存池
template<class T>
class ObjectPool
{
public:
	T* New()
	{
		T* obj = nullptr;
		//优先使用还回来的内存块对象,重复利用
		if (_freeList)
		{
			void* next = *((void**)_freeList);
			obj = (T*)_freeList; // obj是T*的,_freeList是void*的,需要强转
			_freeList = next;
		}
		else
		{
			//剩余的内存不够一个对象大小时,重新开一个大块空间
			if (_remainBytes < sizeof(T))
			{
				_remainBytes = 128 * 1024;
				//假设这里开辟128KB的大小
				_memory = (char*)malloc(_remainBytes);
				//如果malloc后返回nullptr,说明开辟失败,抛异常
				if (_memory == nullptr)
				{
					throw std::bad_alloc();
				}
			}
			obj = (T*)_memory;//这时定义一个指针指向申请的内存块的最开始
			size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
			_memory += objSize;//_memory指针+=T类型所占空间大小
			_remainBytes -= objSize;//剩余字节减去使用的字节
		}
		//只开空间没有初始化,所以调用定位new
		new(obj)T;

		return obj;//最后返回obj
	}

	void Delete(T* obj)
	{
		//调用了定位new,所以需要显示的调用析构函数
		obj->~T();
		//头插自由链表
		*(void**)obj = _freeList;
		_freeList = obj;
	}

private:
	//使用char是因为1个char是一字节,申请内存时需要几字节,加几即可,比较方便
	char* _memory = nullptr; //指向大块内存的指针
	//自由链表,由于不知道什么类型,直接void*即可,用于管理每一个使用完的内存块
	//由前一个内存块的前4/8个字节存下一个内存块的地址即可,不需要定义一个结构体,在结构体中定义指针来指向
	void* _freeList = nullptr;//还回来过程中链接的自由链表的头指针

	size_t _remainBytes = 0; //大块内存在切分过程中,剩余的字节数
};

下面是测试用例,用于测试普通的malloc和定长内存池,在开辟相同资源的时候的效率:

在Release下,一共申请释放3轮,每轮10完个数据,观察结果:

可以看到,我们写的定长内存池,效率远远高于普通的malloc


高并发内存池

整体框架

高并发内存池主要由以下3个部分构成:

1. thread cache:线程缓存是每个线程独有的,进程中有几个线程,就会创建出几个thread cache,用于小于256KB的内存的分配,线程从这里申请内存不需要加锁,每个线程独享一个cache,这也就是这个并发线程池高效的地方。

2. central cache:中心缓存是所有线程所共享,thread cache没有内存时,会找它的下一层 central cache获取内存,central cache合适的时机回收thread cache中的对象,避免一个线程占用了太多的内存,而其他线程的内存吃紧,达到内存分配在多个线程中更均衡的按需调度的目的
        central cache是存在竞争的,所以从这里取内存对象是需要加锁,首先这里用的是桶锁,如果不同的thread cache申请的是不同的桶,那就不需要加锁;其次只有thread cache的没有内存对象时才会找central cache,所以这里竞争不会很激烈。

3. page cache:页缓存是在central cache缓存上面的一层缓存,存储的内存是以页为单位存储及分配的(一页大约4K或8K),central cache没有内存对象时,从page cache分配出一定数量的page,并切割成定长大小 的小块内存,分配给central cache。

        当一个span的几个跨度页的对象都回收以后,page cache 会回收central cache满足条件的span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。

我们 实现的内存池需要考虑以下几方面的问题:

1. 性能问题
 2. 多线程环境下,锁竞争问题
 3. 内存碎片问题。


thread cache的整体设计

thread cache是哈希桶结构,每个桶是一个按桶位置映射大小的内存块对象的自由链表

每个线程都会 有一个thread cache对象,这样每个线程在这里获取对象和释放对象时是无锁的,所以效率很高

common.h是公共头文件,把大家都需要的放到这里面,例如头文件等等

ThreadCache.h和ThreadCache.cpp是thread cache的头文件和源文件

接下来的哈希桶,就不像刚开始实现的定长内存池一样自己定义freeList了,在common.h中定义一个类FreeList,用于管理切分好的小对象的自由链表,因为后面的central cache也会用,所以写到common.h中

在common.h中:

有个Push个Pop函数,为了方便,加一个全局函数NextObj,用于获取Obj后面的内存块:

这时NextObj可以获取到Obj对象头部的4/8个字节,返回值再加个&,就可以赋值也可以写这4/8个字节了:

所以Push函数可以改为:

这时头删也就可以完成了,即_freeList指向的地址给obj,再改变_freeList的指向,改为指向obj的下一个内存块的地址,即指向obj前4/8字节的地址:


在ThreadCache.h中:

之所以申请和释放内存函数都需要传入size,是因为需要靠传入的size大小,判断所需要的字节数映射的哈希桶的哪一个桶

在common.h中创建了FreeList类,所以ThreadCache中创建一个自由链表的数组,因为哈希桶中每一个位置都是一个FreeList

.h声明,.cpp定义,ThreadCache.cpp:


哈希桶映射对齐规则

给了一个大小,那我需要切多大的内存块给你呢,这里就处理这个问题

因为thread cache是对于小于256KB的内存进行分配的,所以在common.h中定义一个最大的字节数,C++中不提倡使用宏定义,一般采用static const,所以在common.h中定义一个静态变量MAX_BYTES:

1KB = 1024Byte,所以256 * 1024即表示为256KB的大小

而这里的哈希桶,是需要计算对象大小的对齐映射规则的,所以需要在common.h中,再创建一个SizeClass的类:

这里需要注意的是,第一个桶,最少需要给8Byte的大小,因为每一内存块在自由链表中,需要前4/8个字节存储下一个内存块的地址,所以在64位平台下,最少需要8字节才能够存下地址

之所以需要向下面这种处理方式,是因为如果256KB的内存都按照8字节对齐,256*1024/8大概3万多个桶,需要开的桶太多了,所以采取下面这种方式,开的桶少且可以控制最多10%左右的内碎片浪费的问题

下面这种处理方式整体控制在最多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)

前1-128字节,如果申请比较少,浪费会多一点,但是没办法,因为上面也说到了,第一个桶最少需要开辟8字节的

举个例子内存在[128+1,1024]这个范围中,假设这16个字节只用了一个字节,所以浪费了15个字节,而这时使用的内存是128+16=144Byte,浪费了15 / 144 = 0.1041,大概就是百分之10左右的内碎片浪费,下面的计算以此类推

上面加粗的第三列就是256字节中,需要每个内存之间划分的桶的个数,1-128字节中,需要0-16共16个桶...


所以总共是208个桶,就定义一个静态全局的const变量NFREE_LIST为208

在ThreadCache.h中,类成员自由链表数组的个数就给定为NFREE_LIST


在SizeClass类中,创建了RoundUp函数,里面根据上面的内存划分,划分如下的代码:


下面再创建一个_RoundUp函数,是RoundUp函数的子函数,用于进行具体的计算:

我们初步的思想就是以下这样的:

下面是一种比较精妙的方法实现:

下面举个例子说明:如果给的大小时1~8字节,那么肯定分配的就是8字节的哈希桶,所以假设大小为1~8的最大值8,此时
((size + alignNum - 1) & ~(alignNum - 1)),就变为了
((8+8-1) & ~(8-1))=15&~(7)=001111 &~ 000111=001111 & 111000 = 001000
算出来结果是8,可以看到,后面的alignNum - 1永远都是7,按位取反后低3位都为0,所以无论和谁按位与,低3位都为0
而当所需内存大小为8时,此时8已经是1~8中最大的数了,8+8-1的二进制也只是001111,所以1~8之间比8小的数,完成第一个括号里的运算后结果都会小于001111,且第4位为1,所以与(~7)的二进制数按位与,都为001000,所以都会取到8

之所以会使用这种方式运算,是因为计算机执行位运算比执行乘除运算快


另一方面,_RoundUp和RoundUp函数是用SizeClass类封装起来的,而成员函数都是独立使用的,所以可以改为静态的,如果不改为静态的,就必须要用对象才能调用,所以改成静态的和inline函数,所以_RoundUp和RoundUp函数最终改为:


下面需要Index函数计算映射的哪一个自由链表桶

_Index是Index的子函数,原始思路如下:

举个例子,如果内存是8,那就是第0个桶(从0开始),8 % 8 == 0,所以需要8/8-1=0
而如果%8不为0,则直接/8即可,例如1~7随便取一个6,6%8 != 0,所以直接6/8=0,算出映射的是第0个自由链表桶

而下面也有一个更为奇妙的思路,同样是静态内联函数:

同样套值去解释:

如果是1~8之间,align_shift都为3,因为2^3=8,此时第一个括号里的值(1 << align_shift) - 1)计算出来是7,7+bytes,bytes最小1,最大8,所以(bytes + (1 << align_shift) - 1)最小8,最大15,此时再右移3位,相当于就变为了1,再-1为0,即1~8之间是第0个桶

最后还有-1是因为,我们的下标是从0开始的

Index实现如下:

group_array数组存的是分的每个对齐区间的自由链表数

每次到下一个区间时,传入的第一个参数需要将前几个区间的内存数减掉,例如在[129,1024]这个区间里,传入的第一参数应该先减去128,才是在第二个区间内的内存数

最后返回的自由链表的下标需要加上前几个区间的链表数


此时在ThreadCache.cpp中可以写一些简易的代码了:

其中common.h中自由链表的判空函数没有写,所以补充完整:

还需要注意到,上面的else语句中的FetchFromCentralCache函数,就是自由链表没有了,需要向第二层central cache获取内存的函数了,该函数有两个参数,第一个是计算的index下标,第二个是原内存所对应的对齐的字节数


threadcacheTLS无锁访问

线程局部存储(TLS) ,是一种变量的存储方法,这个变量在它所在的线程内是全局可访问的,但
是不能被其他线程访问到,这样就保持了数据的线程独立性。而熟知的全局变量,是所有线程都可
以访问的,这样就不可避免需要锁来控制,增加了控制成本和代码复杂度。

window下的tls分为静态的和动态的tls,动态的tls需要调用它的函数全区获取创建,会复杂一些
所以这里就使用静态的tls

静态的tls需要声明_declspec(thread),所以我们在ThreadCache.h中声明:

这里的pTLSThreadCache就是每个线程都有一份,例如进程里有三个线程,那么就有三个pTLSThreadCache变量,并且每个线程只能获取到自己的

且.h有可能在多个cpp中包含,每个cpp生成的.obj中都有pTLSThreadCache,此时链接的时候就有两份,所以就冲突了,因此需要设为静态的


实际中别人申请内存,每个线程获取自己的pTLSThreadCache,不可能还要自己创建一个ThreadCache的对象,再去调用Allocate函数申请内存,所以必须还要再封装一层

所以创建一个ConcurrentAlloc.h的头文件

加个static,全局静态函数表示在当前文件可见


经过调试发现_freeLists自由链表数组没有初始化,由于_freeLists数组是在ThreadCache类中的,类型是FreeList,是自定义类型,所以调用FreeList的构造函数,而FreeList类中也没有构造函数,并且只有一个内置类型void* _freeList,我们没有写构造函数,它不会调用构造函数初始化,所以我们可以用c++11的特性直接在成员变量那里,将FreeList类中的void* _freeList初始化为nullptr


接着通过调试:

在ConcurrentAlloc函数中加上了打印语句,方便观察现象:

观察到每次两个线程t1t2都是同一个空间:

通过TLS,每个线程无锁的获取自己的专属的ThreadCache对象


centralcache整体设计

每个线程没有内存时找的是Thread cache,每个线程独享一个Thread cache,如果你申请的大小对应的自由链表里有内存,就去自由链表下面获取,而如果没有内存时,就只能去找下一层central cache了

central cache是一个哈希桶结构,他的哈希桶的映射关系跟thread cache是一样的。所有线程没有内存都会找它

central cache的每个哈希桶位置挂是SpanList链表结构,不过每个映射桶下面的span中的大内存块被按映射关系切成了一个个小内存块对象挂在span的自由链表中。

哈希桶的每个位置下面挂的都是Span对象链接的链表,不同的是
1、8Byte映射位置下面挂的span中的页被切成8Byte大小的对象的自由链表。
2、256KB位置的span中的页被切成256KB大小对象的自由链表

存在两个线程同时申请同一块内存(同一个桶)的情况,所以central cache需要加锁

而这里的锁是桶锁,并不是整个哈希桶全加锁,而是每个桶会有一个锁

如果两个线程同时找到了1号桶,这时才会有一个线程获取锁进入,另一个线程阻塞等待,直到第一个线程结束,第二个线程才能进入

而如果两个线程找的是不同的桶,那这时不存在竞争,所以对效率影响并不大


申请内存:

①当thread cache中没有内存时,就会批量向central cache申请一些内存对象;central cache也有一个哈希映射的spanlist,spanlist中挂着span,从span中取出对象给thread cache,这个过程是需要加锁的,不过这里使用的是单个的桶锁,可以尽可能提高效率。

②central cache映射的spanlist中所有span的都没有内存以后,则需要向page cache申请一个新的span对象,拿到span以后将span管理的内存按大小切好作为自由链表链接到一起。然后从span中取对象给thread cache。

③central cache的中挂的span中use_count记录分配了多少个对象出去,分配一个对象给thread cache,就++use_count,

释放内存:

当thread_cache过长或者线程销毁,则会将内存释放回central cache中的,释放回来时--use_count。当use_count减到0时则表示所有对象都回到了span,则将span释放回page cache,page cache中会对前后相邻的空闲页进行合并,从而解决外碎片的相关问题


centralcache结构设计

首先创建CentralCache.h和CentralCache.cpp

因为central cache在use_count为0时,会将span释放回page cache,所以会涉及到任意位置删除的操作,所以设置为带头双向循环链表,时间复杂度是O(1),简单高效

在common.h中,需要定义一个结构体Span,管理多个连续页大块内存跨度结构


Span里有页号,在32位平台下,一页假设8KB=2^13 Byte,所以共有2^32 / 2^13 = 2^19个页
而在64位平台下,共有2^64 / 2^13 = 2^51个页

在32位平台下2^19个页,个数并不是特别多,使用size_t(size_t是unsigned int的)是可以的,而64位平台下2^51个页,个数却太多了,不能用size_t,那么怎么解决呢?

32位64位可以用条件编译解决,表示如果定义了_WIN32,就执行typedef size_t PAGE_ID;如果没有定义_WIN32,就执行typedef unsigned long long PAGE_ID;

即表示,在32位的环境下,PAGE_ID表示的类型是size_t的,而64位环境下,PAGE_ID表示的类型是unsigned long long的

但是上面这种写法还是有问题的,因为_WIN64才能用来判断Windows系统的编译环境是x86(32位)还是x64(64位)
因为在_WIN32配置下,_WIN32有定义,_WIN64没有定义
但是在_WIN64配置下,_WIN32和_WIN64都有定义

所以上面写的这种条件编译,无论32还是64位环境,_WIN32都有定义,不能区分执行,所以需要换一下顺序判断即可

所以Span里就有了页号PAGE_ID _pageId


所以Span的基础成员有以下几个,同时给缺省值,就不用写构造函数了:


所以这时需要在common.h里再加一个带头双向循环链表的类SpanList,因为上面说到了,central cache是有桶锁的,所以在带头双向循环链表的成员中,需要加一个锁成员:


centralcache核心实现

由于central cache的映射关系和thread cache是一样的,所以thread cache有多少个桶,central cache就有多少个桶,所以大小也是NFREE_LIST


上面说到,thread cache里如果有内存,就用thread cache的内存,要是没有,就需要去central cache里获取,那么如果获取呢?

可以使用全局变量,但是这种方式不是最优的,central cache和page cache都要求全局只有唯一一个,所以这里可以使用单例模式

单例模式分为饿汉模式和懒汉模式两种,懒汉模式相比于饿汉模式略复杂,所以这里就使用饿汉模式了

单例模式首先需要定义一个静态成员,类内声明类外初始化

其次为了只有唯一一个,不想让别人定义,需要将构造函数变为私有权限的,而这里的构造函数只需要定义出来,不需要在其中写内容,因为编译器会对自定义类型自动调用它的构造函数
而SpanList是自定义类型,所以会自动调用SpanList的构造函数初始化,SpanList的构造函数我们也已经写了,所以在这里构造函数中我们什么内容都不需要写

除了构造函数,为了防止拷贝它,拷贝构造也需要写在private里,并且加上delete关键字,这样别人也无法调用到

还需要一个公有的静态函数GetInstance,获取这里的实例对象


当ThreadCache没有内存时,向centralcache要内存,那么一次只要一份吗?

每次需要内存时就向centralcache中要,一次要一份,这样是不好的,因为centralcache里面是有桶锁,而如果恰巧两个线程竞争的是同一个桶,这时效率就会非常低了

所以当ThreadCache向centralcache申请内存时,centralcache多给一些给ThreadCache

那么一次给多少又是一个问题,不能给太少,也不能给太多,小对象给多点还能接受,大对象就不能给太多了,所以这里采用慢开始的调节算法,NumMoveSize函数也放入common.h的SizeClass类中:

num最开始是MAX_BYTES / size,MAX_BYTES是256KB,size是一个对象的大小,如果申请的这个对象比较小,那么除出来的num就会非常大,所以就会给num赋值512,反之对象若是比较大,num就赋值为2,如果是中间值就正常处理,这里的操作只是为了限制最大最小值


如果内存是8Byte,这时的num就远远大于512了,所以就会一开始直接给512份,但是512份又太多了,如果线程只用了一两百份,又过于浪费了

所以为了解决这个问题
在common.h的FreeList类中,新加一个成员_maxSize,初始化为1,由于_maxSize是私有,所以又加了一个MaxSize函数(传引用返回),来调用_maxSize

这时在ThreadCache.cpp中,从中心缓存获取对象的函数FetchFromCentralCache,中就可以这样实现:

最开始的时候MaxSize是1,NumMoveSize(size)最大值是512,所以在min函数中比较后,batchNum的初始值值一定为1,所以第一次申请内存时进入if语句,将MaxSize的值+=1,变为2

这样下一次申请内存时,在min函数里比较完,batchNum就变为2了,就会给2份

以此类推,如果同一个大小的内存不断地申请,那么这里的batchNum也就随着MaxSize的增大而增大,最大不超过512

当然如果申请的内存比较大,那么在NumMoveSize中,num被赋值为2,所以在申请内存时,也不会分配给超过2份的大内存

慢开始反馈调节算法的功能:
1、最开始不会一 次向central cache一次批量要太多,因为要太多了可能用不完
2、如果你不断的有这个size大小内存需求,那么batchNum就会不断增长,直到上限
3、size越大,一次向central cache要的batchNum就越小
4、size越小,一次向central cache要的batchNum就越大


接下来,central cache需要分配的份数有了,就该具体向central cache申请内存了,在CentralCache.h中,要有一个从中心缓存获取内存的函数FetchRangeObj:

FetchRangeObj函数的参数,其中start和end是ThreadCache向central cache申请的几份内存的头指针和尾指针,span中有自由链表,如果要多份的,就需要有所需链表的头尾指针
其中batchNum表示取几份,size表示要取的一份内存多大

在ThreadCache.cpp中的从中心缓存获取对象的函数FetchFromCentralCache函数中就需要继续加工了:


下面继续处理,因为可能申请多份内存,而我们之前common.h中的FreeList类中只提供了头插单个内存块的函数,所以为了方便使用,再加入一个新函数PushRange:

完整实现的FetchFromCentralCache函数如下:

这里是NextObj(start)而不是start,是因为start指向的内存块需要return被使用,所以是将start后面的内存块链接到自由链表中,以备后续使用


上面的逻辑实现完后,就该在CentralCache.cpp中实现FetchRangeObj函数了,因为刚刚FetchRangeObj函数只在.h中声明了,还没有实现

而在实现FetchRangeObj时,又需要用到GetOneSpan函数,来获取一个非空的span

所以CentralCache.cpp中:

下面是FetchRangeObj函数的初步实现:

之所以说是初步的实现,是因为上面是只考虑了CentralCache中,ThreadCache所需的内存够的情况,不够的情况还没有考虑

例如ThreadCache申请5份,而CentralCache只有4份,这时再执行上面的代码,就会出现野指针的问题,所以这时的策略是:CentralCache有几份就给几份:

在while循环中,加上了NextObj(end) != nullptr,就可以保证找到了该桶中的最后一个内存块的地址,不能继续往后拿了,可以保证不会出现上面提及的野指针问题

又定义了一个actualNum,表示实际拿到的内存数,最后return出去

而actualNum最开始定义的是1,而不是0,是因为如果需要3个内存,while循环只会循环2次,所以actualNum是从1开始++的


pagecahe整体设计

page cache也是一个哈希桶,但是page cache的结构却与前两层不同,如下:

虽然pagecache和CentralCache的哈希桶都是挂的span,但是还是有其他本质的区别:

第一、CentralCache和ThreadCache的映射规则保持一致,而pagecache却是第1个桶(从1开始)里面挂的是1页的span,第2个桶挂的是2页的span.....一直到最后有128个桶,最后一个桶挂的128页的span

第二、CentralCache的span被切成了小对象,供给ThreadCache用,ThreadCache用完后,如果剩余太多了可以还给CentralCache;而pagecache里的大内存span不切小,因为pagecache是给CentralCache服务的,而CentralCache没有span了以后,就只需要计算需要向pagecache申请几页的span即可


CentralCache只需要关注需要几页的span,恰巧pagecache就是一个直接定址法的映射,1页的就在第1个桶,2页的就在第2个桶....

同样的,CentralCache将内存给ThreadCache,ThreadCache不用了还回CentralCache后,span中的_useCount减到0后,就需要把span再接着还给pagecache,还的时候pagecache就会找前后的页进行合并,算出来最终是几页的span,插入到pagecache哪个桶里也就很简单,是几就找pagecache对应位置的桶即可


有了page的最大值为128,那就再定义一个静态全局的const变量NPAGES
且为了直接定址,即几号桶就对应几页,也就是从1号桶开始,所以NPAGES就定义为129:


线程一的ThreadCache没有内存找CentralCache,而CentralCache也没有于是找pagecache,
同样的,线程二的ThreadCache没有内存找CentralCache,而CentralCache也没有的话那就也找pagecache

所以这里我们就明白了,pagecache也需要加锁

而这里不能使用桶锁了,要给整体上锁,因为与后面的合并有关

与CentralCache一样, 全局也是只有一个,所以也要设置为单例模式,同样设置为饿汉模式,与CentralCache一样,饿汉模式就不细说了

所以在PageCache.h中: 

同样,静态成员_sInst在类内声明,在类外初始化,所以在PageCache.cpp中初始化:


 所以接下来引入的函数就是NewSpan函数:

需要几页的span,就在pagecache里面找对应位置要

那么如果我们要2页的span,但是pagecache里面2页span对应的位置没有,那么我们应该直接去堆中申请吗?

显然不是,因为我们在堆中申请,一般是申请一个大页的内存,大页的内存才是连续的,连续的内存切小后,还回来后还能合并成大的内存
而如果连续申请小页的内存,他们释放后是不连续的,就不能合并了 

所以假设当我们想要2页的span时,2页span对应的桶位置没有,那我们就找下面的3页的span,将3页的span切分为2页的span和1页的span,2页的可以拿去使用,1页的span就继续挂到1页的桶后面


上面所说的最极端的情况,即需要2页的span,但是往后找,一直到128页的span,依旧没有内存给CentralCache使用,这时pagecache需要做的事情就是,向系统(堆)申请一个128页的span,然后挂到128页span对应的桶后面

接下来进行的操作就是,把这个128页的span的其中2页的span切下来给上一层CentralCache使用,接着把剩下的126页的span挂到126页的桶后面

那么下一次,CentralCache再向pagecache申请内存时,就是由126页的span继续切分使用

以此类推


下面说说还内存回来的逻辑:

ThreadCache使用完内存后,还给pagecache,CentralCache中的_useCount分出去一块小内存就++,还回来一块小内存就--,当_useCount--到0时,说明该span切分的小内存全部还回来了,这时CentralCache就会将还回来的这个span再还给pagecache
pagecache通过页号,查看前后的相邻页是否空闲,是的话就合并,合并出更大的页,解决内存碎片问题

例如还回来的span是页号是50,有5页的span,即50、51、52、53、54这5页都是这个span的,所以这时pagecache就需要找前面的第49页是否空闲,再找后面的第55页是否空闲,如果有空闲,就合并,再继续往后找,直到找不到空闲的内存为止....


上面说到了pagecache不使用桶锁,而是整体锁,其实并不是桶锁不能解决,理由如下:

桶锁也能解决,但是如果把pagecache设置为桶锁,那么如果两个线程所需要的span对应的桶都为空,那么就都需要往下找比所需的内存大且不为空的桶,这时如果设置的是桶锁,就会频繁的加锁解锁,效率会非常低,所以这里的解决思路是整体加锁

那么至于为什么pagecache使用桶锁会效率非常低,而CentralCache却可以使用桶锁提高效率呢?
因为CentralCache如果需要8字节,那就只会找8字节对应的桶,如果8字节所对应的桶为空,只会继续向下一层pagecache申请内存
并不会像pagecache一样,还需要不断的往下找更大内存的桶,频繁的加锁解锁,从而导致效率比较低,所以pagecache需要整体加锁


pagecache中获取Span 上

下面该实现的就是GetOneSpan函数了:

在FetchRangeObj中,已经把对应的桶和大小size传入了

但是此时传入的桶有没有span我们也不知道,所以就得遍历这个桶,那么遍历就需要Begin和End了,这两个函数我们还没有实现,所以在SpanList中实现:

begin表示头指针的下一个位置,end表示最后一个内存的下一个位置,即头指针


需要知道,GetOneSpan只是找一个非空的span,FetchRangeObj才是计算需要在这个非空的span中取几个内存块

初步实现GetOneSpan:

接下来实现CentralCache没有空闲内存的情况,需要用到的函数就是上面提到过的NewSpan函数,在pagecache中获取一个k页的span,那么这里的参数k,到底传多少呢,小内存需要的非常少,大内存需要的却很多,所以这里又要引入NumMovePage函数,用于计算一次向系统获取几页


NumMovePage又需要用到定义一个PAGE_SHIFT,表示页的转换,例如一页如果是8KB的话,那就是2^13Byte,所以PAGE_SHIFT就为13,所以再定义一个全局静态const变量PAGE_SHIFT:


NumMovePage的实现如下:

首先利用前面实现的NumMoveSize函数,计算一次thread cache从中心缓存获取多少个对象,赋值给num

npage=num*size,表示总共需要的字节数

下面一行的npage >>= PAGE_SHIFT,npage右移13位,就表示npage除2^13,可以根据总字节数算出对应所需的页数

如果npage太小,右移完的结果为0了,就最少给一页


下面实现CentralCache没有空闲内存,需要向pagecache申请内存的情况

需要注意的是,如果SpanList中有非空的span,那直接返回这个span地址即可,因为CentralCache中的span是切分好的

如果SpanList中没有非空的span,那就需要到pagecache中获取span,这里的span是大块内存,没有被切分为小块,所以从pagecache中获取的span,我们还需要手动处理,将大块内存切成自由链表链接起来,才能够正常使用这个span

在把大块内存切成自由链表链接起来的过程中,建议链接的时候尾插,因为span大块内存是连续的,尾插虽然是链式结构,但是物理上也是连续的,每次取内存是从头部获取,所以是把连续的内存分给别人用,CPU缓存利用率会比较高,所以建议尾插:

GetOneSpan函数的完整实现:

补充一点,getonespan函数,在tail尾插到最后一个的时候,需要将最后一个tail的下一个位置置空:

在上述函数的最后,使用了PushFront函数,因为没有实现,所以在common.h的SpanList类中补全: 


pagecache中获取Span 中

下面就该实现在PageCache.cpp中:获取一个k页的span的NewSpan函数了

上面也已经说了大致思路:
假设我们想要2页的span时,2页span对应的桶位置没有,那我们就找下面的3页的span,将3页的span切分为2页的span和1页的span,2页的可以拿去使用,1页的span就继续挂到1页的桶后面

最极端的情况是:需要2页的span,但是往后找,一直到128页的span,依旧没有内存给CentralCache使用,这时pagecache需要做的事情就是,向系统(堆)申请一个128页的span,然后挂到128页span对应的桶后面

接下来进行的操作就是,把这个128页的span的其中2页的span切下来给上一层CentralCache使用,接着把剩下的126页的span挂到126页的桶后面
那么下一次,CentralCache再向pagecache申请内存时,就是由126页的span继续切分使用
以此类推......


那么第一个操作判断pagecache中k页对应的那个桶是否为空,就需要在SpanList类中新加函数:Empty:

如果第k个桶有内存,就需要PopFront函数,进行头删操作,且返回头部的内存地址:


NewSpan函数的切分操作:

当第k个桶没有span时,往下找,第n个桶有span了,那么就开始切分:

首先把第n个桶的头部的span拿下来,假设这个span共有n页,我们自己需要k页,所以就把n页的span切分成k页的span和n-k页的span
k页的span返回给CentralCache,n-k页的span挂到第n-k号桶中去即可

假设将n页的span从头部切除,那么操作就是先将n页的span的页号给new出来的kSpan,再将kSpan的页数赋值为k,此时nSpan的页号就需要+=k,nSpan的页数-=k:

最后还需要将n-k页的span挂到第n-k个桶中:


pagecache中获取Span 下

那么这时NewSpan函数就结束了吗,并没有,还有一种情况,如果找到最后,还是没有找到非空的span
我们最开始其实就是这种情况的,因为最开始创建初始化后,每个桶后面并没有挂大块的span,需要向堆上申请一个128页的span,直接向堆上申请,Windows就调用VirtualAlloc函数,

调用后得到一个指针,也就是这个128页的span的地址,将这个地址除8K就可以算出当前的页号,页数自然是128了,因为向堆上申请都是直接申请128页的span的

此时只需将所申请的128页的span插入到第128号桶里去,然后再自己调用一次NewSpan,就可以执行刚刚实现的切分逻辑,不用再写一遍刚刚的逻辑了


上述的NewSpan函数,还需要注意,需要加锁,有以下几种方法:

第一、在函数刚开始加锁,但是这里的锁不能普通的mutex,因为NewSpan函数最后有递归,如果加锁后调用递归,那就自己把自己锁住了,所以这里只能加recursive_mutex锁,这个锁专门支持递归锁,递归互斥锁在递归调用时会检查,自己调用自己的时候就不会锁住了

第二、将NewSpan改为_NewSpan函数,重写写一个NewSpan函数,在NewSpan函数里加锁,并且在NewSpan函数里调用_NewSpan,这样就算递归调用的也是递归的_NewSpan函数,并不是加锁的NewSpan函数,也能够解决这里的问题

但是这里采取下面的方式:

上面说到锁,线程1进入CentralCache中,有桶锁,那么如果CentralCache中没有span,线程1进入pagecache时,CentralCa che的桶锁需要解锁吗

答案是需要解锁,因为线程1假如申请的8字节的桶,没有span,于是去pagecache中申请,而其他线程要想8字节的桶还内存时,都没办法还,会被阻塞起来,所以最好还是解开CentralCache的桶锁

所以,这里在CentralCache.cpp中:获取一个非空的span的GetOneSpan函数中,在找pagecache要内存的代码前, 就需要解锁:

接着继续在GetOneSpan里加pagecache的锁,为了加_pageMtx锁,就需要将_pageMtx锁设为公有(或新建一个获取锁的函数):

然后就在GetOneSpan函数,在向pagecache要内存的地方加_pageMtx锁:

红框圈起来部分的下面,是对获取的span进行切分,这部分不需要加锁,因为这时其他线程访问不到这个span

然后在切好span以后,需要把span挂到桶里面去的时候,再加桶锁,最终GetOneSpan完整代码如下:


申请内存过程联调

首先,如果一个线程申请内存,一直到pagecache中还是没有申请到,这时就需要向系统也就是堆申请,向堆上申请,就需要用到VirtualAlloc接口,所以需要包含头文件windows.h

但是windows.h包含后,编译的时候发现在min这里报错了:

原因是我们这里使用的是std头文件里的min,而包含windows.h后,windows.h里面也有min的宏函数,所以这两个就冲突了,所以改正方式就是用windows.h里的min,把std删掉就解决问题了:


而windows.h只有Windows系统有,Linux没有,所以这里包含windows.h的头文件时,需要使用条件编译:

即表示,如果在Windows系统中才包含windows.h这个头文件,Linux就不包含了


下面开始调试,调试代码如下:


一步步理顺前面实现的逻辑:

首先准备进入ConcurrentAlloc中:

TLS等于nullptr就new一个,然后return到Allocate中:

由于p1申请的是7字节,所以下面计算对齐数应该是8,8字节对应的就是ThreadCache的第0个桶中:

刚开始ThreadCache的自由链表肯定为空,所以进入FetchFromCentralCache函数,从CentralCache中获取内存:

从中心缓存获取对象的FetchFromCentralCache函数,最开始是慢开始反馈调节算法,所以batchNum为1:

因为batchNum与_freeLists[index].MaxSize()相等,所以_freeLists[index].MaxSize()+=1,表示下一次改为申请2份内存

接着会进入CentralCache的FetchRangeObj函数要内存

进入FetchRangeObj函数后,计算出来下标为0号桶,接着就准备进入GetOneSpan函数,获取一个非空的span

由于当前程序刚运行,第一次申请内存,所以CentralCache里面对应的桶也没有内存,所以这时就需要问pagecache要内存,所以需要进入NewSpan函数

而进入NewSpan函数,需要传入k,k表示需要k页的span,所以这时进入NumMovePage计算,当前8字节的内存申请,需要几页的span,最后算出来是1,最小值

而NewSpan函数需要先找pagecache中,当前对应所需内存的桶有没有span,如果当前桶没有,再往后面的桶找,我们知道这是第一次申请内存,所以当前和后面的桶都没有,所以需要向堆上申请

所以往下走,程序成功向堆上申请了一个头指针为ptr的大块内存,发现页号是2992,所以我们就利用这里的页号反向计算出ptr的地址,来证明我们上面的实现逻辑是正确的:

页号是2992,每一页是8*1024字节,所以2992*8*1024算出来就是2992页的起始地址,通过最下面的红框176 0000和上面的ptr地址0x0176 0000一样,反向证明了上面实现逻辑的正确性

最开始向堆申请的内存大小时128页的span

申请完成后,先插入到pagecache中的第128号桶中,观察调试窗口的_spanList的第128个桶的情:

再递归调用NewSpan函数,在NewSpan函数中,先判断第k个桶没有内存后,再从第k+1个桶往下找时,就能够找到第128个桶中有刚刚申请出来的内存了,接着把这个span取出来,给nSpan,观察链表,第128个桶被取出来了

与上面的页号不同,因为重新调试了一次

原本nSpan的的页号是1960,现在给了KSpan,且kSpan的页数为1,所以nSpan的页号变为了1961,页数由128变为了127

接着再将切分完剩下的nSpan,插入到第127号桶中去

执行完NewSpan函数的递归后,回到了递归语句的后面

接着又回到了CentralCache的GetOneSpan函数中,此时已经获得了k页的span:

下面就该对获取的span进行切分,我们获取了1页的span,由于申请的是7字节,最后被对齐成了8字节,所以这里切分也就按8字节切分,1页的span是8*1024字节,按8字节切分,能够切分成1024块,下面的i可以看出,确实切分了1024块

然后再把span插入到CentralCache对应的桶中去

 如下所示,第0号桶中插入了刚刚切分好的,页号为1960的span

接着回到FetchRangeObj函数中,由于batchNum为1,所以实际取走的数量actualNum就为1

所以就只取走了一块,剩下的挂起来即可,接着返回ThreadCache.cpp的FetchFromCentralCache函数中,获得的actualNum为1,所以进入if语句,return出start即可

第一次申请的过程结束,成功return了内存

以上就是申请内存的流程

接下来打印一下,p1到p5:

发现申请的小块内存正好是连续的,因为pagecache拿到一个大的span,我们的CentralCache里面切分时,每次切完是尾插的,所以按序申请时,内存会正好是连续的


在调试过程中,发现了前面实现CentralCache.cpp中的FetchRangeObj函数时遗漏了_useCount++的操作,补上


接着第二个测试函数:

先申请1024次,在第1025次申请时调试观察, 是否第一次从pagecache中拿的一页得span,被划分的1024块全部用完,观察从pagecache的第127个桶再取一个span,接着将剩下的126页的span继续链接到第126号桶里,然后拿着划分给我们的这一个span,继续执行上述的操作

如果能够完整的调试下来,说明我们的代码到目前为止,申请的逻辑没什么大问题了


回收内存设计

threadcache回收内存

有申请内存,自然也就需要回收内存了

ThreadCache现在的长度超过它一次批量获得的长度时,说明这会这些内存就暂时用不上了,这时就需要ThreadCache将一次批量的内存还回CentralCache了

实现上述操作的函数就是ListTooLong函数

在Deallocate中的逻辑如下所示:


下面就该实现ListTooLong函数了,ListTooLong函数中使用PopRange函数切分,给PopRange函数传入start、end、MaxSize,切分完后,最后执行CentralCache的ReleaseListToSpans函数


在CentralCache中需要有ReleaseListToSpans函数,用于把一段内存还给CentralCache的操作,参数只给start不给end是因为,ThreadCache中的内存块数量到MaxSize时,最后一个内存块是指向nullptr的,所以遍历但nullptr停止即可


现在就需要完善我们的自由链表了,首先实现我们的Size()函数,用于记录数据的个数:


其中的PushRange函数,原本只有start和end两个参数,但是为了_size+=n好加,不用再遍历计算次数,给PushRange函数多加一个参数n,表示一次插入了几个内存块

所以在ThreadCache的FetchFromCentralCache函数中,使用PushRange的地方加上一个参数,因为actualNum表示实际取得的块数,需要使用一个,所以还剩actualNum-1个插入


自由链表中还有一个接口是PopRange,也需要实现:

PopRange就是从链表中需要删除一段范围的接口,范围给start和end,start和end就作为输出型参数加上引用:

首先将_freeList指针指向的地址赋值给start和end,所以当前start和end指向第1个内存块的地址,所以走到第n块,只需要走n-1步
到了第n块内存后,将_freeList指向end块的前4/8字节的地址,即end的下一个内存块的地址,指向完毕后,将第end块的下一个位置指向nullptr,PopRange函数的逻辑完成

最后再提供一个Size的接口,自由链表就改造完成了:


centralcache回收内存

ThreadCache刚刚把一些内存块还给了CentralCache,那么这些内存块可能并不都是一个span里的,因为CentralCache的一个桶下面可能会挂着多个span,这些内存块属于哪个span是不确定的,那么我们是怎么知道哪个对象是属于哪个span里的呢?下面说说思路:

例如第1000页,它的内存就是1000*8*1024=8192000,16进制是0x007D 0000,即起始地址就是0x007D 0000
第1001页,它的内存就是1001*8*1024=8200192,16进制是0x007D 2000,即起始地址就是0x007D 2000
所以我们使用这里的起始地址/(8*1024),可以算出来页号,那么在这一页里的地址/(8*1024),同样可以算出页号,因为整数除法会舍去余数,不够整除就是丢掉余数,例如2/2==1,且3/2==1

所以上面这种方式可以计算出某一个内存块属于哪一页,但是并不知道在哪一个span里面,有下面的方式:

暴力遍历CentralCache里每一个span,直到找该内存块所在的页数所对应的span,假设内存块有n个,span也有n个,挨个遍历,这种方式就是O(N^2)的时间复杂度了,所以这种方法的效率太低了

所以我们可以使用一种映射关系,创建一个unordered_map的映射,将页号与span*建立映射关系,我们可以通过地址计算出页号,接着通过这个映射关系就可以找到这个span的指针,然后就可以把内存块挂到对应的span里去

所以首先应该在pagecache.h中的PageCache类里,加上unordered_map的成员,因为pagecache回收内存也需要这个映射关系,所以unordered_map就放到pagecache里

想要有这个映射关系,pagecache分给CentralCache的span就需要记录一下页数,所以在PageCache.cpp中的NewSpan函数中,就需要加以下代码:

同时,下面的从k+1个桶开始,如果找到了需要建立映射关系,但是上面的if语句,判断的如果第k个桶不为空,如果不为空,直接return了,这时这里的span并没有建立映射关系,所以同样需要建立映射关系,改变为如下:


在PageCache里还需要加一个MapObjectToSpan函数,用于获取从对象到span的映射:

所以接下来实现MapObjectToSpan函数:


做完PageCache里的处理,现在开始实现CentralCache里的ReleaseListToSpans函数:

具体的思路都在代码中有注释


pagecache回收内存

ReleaseSpanToPageCache拿到CentralCache给的span后,不能直接插入到对应页数的桶中,这样只会导致小的内存块越来越多,造成外碎片的问题,而是应该对span的前后页进行合并,从而缓解外碎片的问题

例如现在有一个页号为2000,页数为1的span,它需要先找页号为2001页和1999页的span,看是否空闲,如果空闲就合并, 继续找,直到找不到为止

那么这时有一个问题,CentralCache和Pagecache的桶里挂的都是span,我怎么知道我找到的与CentralCache刚刚返回的span相邻的span是在哪一层呢,Pagecache挂的span可以合并, 而CentralCache挂的span可能是正在使用的span, 是不能合并的

那么CentralCache里的span可不可以通过span里的_useCount是否等于,来判断是否可以合并呢?
当然不能,因为如果有一种情况,CentralCache中没有ThreadCache所需的内存,于是向Pagecache申请了一个span,当把这个span刚切分成一块块的小内存链接起来,还没有分配给ThreadCache时,这时的_useCount是0
而此时两一个线程从CentralCache拿到的span正在找前后页span合并,突然找到了这个span,于是就合并了, 这就造成了线程安全的问题,所以用判断_useCount是否为0的方式不能解决上述问题


所以解决的思路就是在Span类,新加一个成员, bool类型的_isUse,表示是否在被使用,初始值给false,表示不使用,所以在Pagecache里的span都可以合并:

在CentralCache的GetOneSpan函数中,如果CentralCache没有空闲span会问Pagecache要,获得的这个span需要改为true,表示要被使用,就无法合并了,满足上述需求


将CentralCache中的span是否正在使用的问题解决后,还有一个问题,我们映射的只是从Pagecache中分给CentralCache的span,但是我们Pagecache自己里面的span并没有映射,如果CentralCache还回来的span,刚好是与Pagecache里面的span相邻,也是可以合并的,那么Pagecache里面的span是需要每一页都得映射吗?

并不需要,只需要映射Pagecache里的span的首尾页号即可,因为从CentralCache中还回来的span找相邻的span时,只会找它的前一个和后一个位置的页号,如果相邻就合并,并不关心大span中间的页数

这里记录首尾页号同时映射该span还有一个好处:就是假设回收的span页数为1005往前找时,前面相邻的span有5页,分别是1000~1004页,找的是前面span的1004页,而前面span的1004页和1000页映射的都是一个span的位置,所以_idSpanMap[1005-1]时,直接返回的就是前一个span的首地址,即1000页的地址,便于与当前span进行合并
当然合并的过程中也需要判断合并的页数不能超过128页

所以Pagecache给CentralCache的span每一个都映射,而Pagecache剩下的span,映射首尾页号即可,方便pagecache回收内存时进行的合并查找,所以在Pagecache的NewSpan函数中,在kSpan的映射前,加入nSpan的映射语句:


所以下面该pagecache回收内存了,刚刚在CentralCache中,如果ThreadCache空余的内存到一定的数量,会还回给CentralCache,而CentralCache中的span如果_useCount--到0了,就该还给Pagecache了,结合上面的讲述,下面实现Pagecache的ReleaseSpanToPageCache函数:


释放内存过程联调

下面在调试中,观察释放内存的流程:

使用TestConcurrentAlloc1函数进行调试,直接运行到断点的地方


首先进入ConcurrentFree函数

再进入Deallocate函数释放内存,7字节算出下标为0,此时_freeLists的第0个桶中的_size是2,表示当前桶中有两个内存块

接着还p2:

此时将p2页也还进第0个桶,这时第0个桶的_size变为3

再还p3,此时进入Deallocate函数,观察窗口,第0个桶的_maxSize为4,_size也为4


所以就能够回收到中心缓存了,进入ListTooLong函数,此时_maxSize为4,_size也为4,所以需要执行PopRange函数

执行完PopRange函数,观察是否将4个内存块都取出来了,观察窗口可知,_size为0,成功取出


所以就该进入ReleaseListToSpans函数,将一定数量的对象释放到span跨度,通过映射关系计算当前内存是属于哪一个span的

下面自己手动计算一下,验证代码计算的是否正确,下面是调试窗口的值,可以知道计算出来当前的start对应的span是1664页

计算器验证,使用start对应的16进制的起始地址,除一页即8*1024Byte的大小,可以看到也是1664页,说明代码逻辑没有问题


所以继续往下执行,此次释放p3,满足ThreadCache向CentralCache还内存的逻辑,所以将里面的4块内存都还给CentralCache
而剩下的p4p5释放后,并不满足,所以就先存到ThreadCache中,先不释放
最后释放完后,_useCount是2,所以还不能还给pagecache

所以我们多申请几个内存,调试一下还给Pagecache的逻辑:


当走到ConcurrentFree(p7, 8);时,此时_useCount刚好减为0,所以span可以还给Pagecache,进行合并处理

所以接下来进入ReleaseSpanToPageCache函数,观察发现没有找到前面的一页span,所以开始找后面一页:

找到了后一页,发现没有在使用,并且相加页数也不会超过128

再往下执行,这1页的span和127页的span,合成了128页的span

所以就将这个合并起来的128页的span插入到第128个桶里

到此,基本逻辑就走通了


下面可以进行多线程执行的情况:

可以看到线程一还给Pagecache时,没有合并成功,因为线程二正在使用

到了线程二,能够先和前面的span合并,这里的前面的span即线程一刚刚释放的span,再与后面的span合并,即pagecache中剩余的126页的span,最后合并为128页的span后,存入第128号桶中,程序结束


大于256KB的大块内存申请问题

当申请的内存小于256KB时,用三层缓存处理

那么申请的内存大于256KB怎么处理呢?

首先256KB = 256 / 8 = 32页,我们知道Pagecache中,最大的桶存了128页的span,所以可以考虑如果申请的内存大于256KB,向Pagecache申请

这时就有两种情况
①申请的内存 > 32页 &&  <=128页 ,向Pagecache申请
②申请的内存 >= 128页,找系统的堆申请


所以下面就需要改一下ConcurrentAlloc函数,需要判断一下申请的是否大于256KB:

在内存大于256KB时,第一步就需要将内存以页为单位对齐,所以需要改造一下SizeClass里的RoundUp函数:


下面是ConcurrentAlloc的实现:


接下来就是改善NewSpan函数,因为NewSpan函数申请的内存是不能超过128页的,要是超过128页,Pagecache就放不了了,所以进入NewSpan函数需要判断申请的内存是否超过128页

如果大于128页,就找堆直接要,new一个span,之后返回:

在这里需要将地址映射起来,方便后面ConcurrentFree统一逻辑实现,不用再具体分类


申请的流程要改,ConcurrentFree函数释放的流程当然也要改:


ConcurrentFree函数的实现:


那么具体在ReleaseSpanToPageCache函数中,怎么样能使用一个逻辑,不用管是否是在堆上申请的还是在Pagecache中申请的呢?

其中大于256KB小于128页的内存,还是逻辑不变,找span合并,之后还回Pagecache中,只有大于128页的内存需要处理:

那么大于128页的内存既然是VirtualAlloc申请的,所以还需要补充一个函数,使用VirtualFree释放:


所以ReleaseSpanToPageCache函数中,需要补充以下语句:


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

tcmalloc是可以替换malloc的,那么要替换malloc,tcmalloc内部是不能使用malloc的,否则你说要替换malloc,结果你自己里面却是在使用malloc,这就不合理

而我们上面实现的代码中,有很多地方都使用了new Span,我们知道new的底层是malloc实现的,相当于我们的内存池底层还是使用的malloc,所以下面加以改进

我们在实现这个项目,还写了一个定长内存池,并且还与malloc进行了性能对比,定长内存池的性能比malloc显著提高,所以我们就可以使用定长内存池来替代malloc:

上面都是在new Span,所以我们可以使用已经实现的定长内存池:ObjectPool,在Pagecache中包含ObjectPool.h,Pagecache的成员新增一个ObjectPool<Span>类型的成员:

在PageCache.cpp中

把所有的new Span的代码都换为spanPool.New():

再把所有的delete 的代码都换为spanPool.Delete():

在ConcurrentAlloc.h中

把new也改为定长内存池的格式,因为全局只有一个定长内存池,所以需要加锁:


释放对象时优化为不传对象大小

下面还需要做一个优化,正常的申请内存时,给一个大小,正常释放内存时,给一个指针就可以释放了

但是我们所实现的释放内存的函数,却还需要传释放内存的大小:

我们想改为:

应该如何优化一下呢?

有很多种方式处理,例如:

第一种方法:我们前面使用的unordered_map,映射的是页号和span,那么我们可以再加一个页号和size的映射,因为我们知道地址,就可以(地址>>8*1024)算出来页号,再通过页号和size的映射关系,得到size,这种方式就不需要传入size了,因为可以自己算出来

第二种方法:我们可以通过ptr,找到页号,再通过映射关系,找到对应的span,所以我们可以在span中增加一个成员,size_t _objSize = 0;

同时,在CentralCache中的GetOneSpan函数中,在向pagecache获取到内存后,将获取到的span的size赋值给_objSize

上面是申请的内存小于128页时,在找pagecache要内存后,将size赋值给_objSize,那么当申请的内存大于128页时,是向堆上直接申请的,这时需要在ConcurrentAlloc.h里,在申请内存大于128页的情况下,将size赋值给_objSize

所以此时,在释放内存时,就只需要传入地址即可,不再需要加入所申请的内存大小了


最后一个问题,STL的容器并不保证线程安全的问题,所以我们所使用的unordered_map哈希表需要自己加锁,有很多地方都在访问unordered_map,而有些线程在读,有些线程可能在写,所以是需要加锁的

其中有些地方访问map是加了锁的,例如:在pagecache中的NewSpan和ReleaseSpanToPageCache函数都访问了map,但是这两处都是加了锁的,因为在CentralCache里面,访问NewSpan和ReleaseSpanToPageCache函数时,都已经将桶锁解了,再将pagecache的大锁加锁,这时再访问map是安全的,因为外面加大锁了


但是这里的读map是没有加锁的,可能是有线程安全问题的:


所以下面在MapObjectToSpan函数里面,直接加一个unique_lock锁,出函数作用域自动解锁

如果设的是普通的锁,还得每一个return前面都写一遍解锁,比较麻烦


基数数优化性能瓶颈

因为通过对比malloc和我们自己实现的高并发内存池,发现性能还是会比malloc低一些,通过VS的性能分析,于是采用基数树优化一下性能

实现了三棵基数树

每一棵树都给了非类型的模版参数BITS,如果是32位,BITS就是32-PAGE_SHIFT = 32 - 13 = 19,如果是64位,那么BITS就是64-PAGE_SHIFT = 64-13 = 51

所以这个BITS本质就是页号最多需要多少位,因为一页是2^13字节,而32位系统是2^32,所以总共有2^32 / 2^13 = 2^19页,上面的BITS也算的是19

第一颗基数树是采用的直接定址法映射的哈希表实现的,一层的树

它的成员LENGTH是1<<19,也就是表示需要多少位存储页号,相当于开辟了一个数组,数组共有2^19个元素,数组的每个位置存储的是一个Void*类型的指针

相当于本质是存储的<page_id,span*>的映射,页号是几就去对应的位置去找

占用内存:2^19 * 4 = 2^21(32位下),2^19个位置,每个位置占用4字节,共占用2^21的内存,2^10是1KB,2^20是1MB,所以2^21就是2MB的空间

这里的第一棵树,是针对于32位的情况,64位就不适用了,64位需要使用后面的三层基数树来解决


第二课基数树,两层的基数树:

同样适用于32位

两层的基数树,是个多叉树

第一层,ROOT_BITS给了5,也就是第一层给了5位,也就是2^5 = 32即32个位置

分两层,第一层下面的每个叶子是一个指针数组values,共有LEAF_LENGTH,其中LEAF_LENGTH是1<<LEAF_BITS,而LEAF_BITS是BITS - ROOT_BITS,即19-5 = 14,所以LEAF_LENGTH = 1 << 14 = 2^14

所以叶子那一层的指针数组,每一个数组共有2^14个位置,每个叶子是一个指针数组,第一层每一个位置都指向一个指针数组

占用内存:2^5 * 2^14 * 4 = 2^21,即第一层2^5个位置,每个位置对应第二层的一个指针数组,每个指针数组2^14个位置,每个位置都存储的指针,4字节,所以和第一棵树一样,都是占用2MB的内存

给了一个页号,32位,低19位可以存储这个页号,低19位的前5位决定是在第一层的哪个位置,后14位是几,就决定在第二次的哪个位置

也就是拿到一个页号,就先取低19位中的第14-18位这5位,计算在第一层的第几个位置,0-13位则计算在指针数组的哪个位置


三层的基数树,适用于64位的情况

与第二层一样,前几位表示第一层的下标,中间几位表示第二层的下标,后面几位表示第三层的下标,就不详细说明了


那么这时问题来了,既然第一层和第二次占用空间一样大,为什么还要两棵不同的树,不是多余吗?

其实还是不同的,如果是一层的基数树,一次就得将空间全部开辟出来,因为这是一个连续的数组,两层的则不需要

一层的优势是:直接一次性开好空间,访问也非常简单,页号是几,就去第几个位置去找,写也是一样的,如下所示:

而两层的基数树,给一个页号,需要先>>14位,算出来该页号在第一层对应是第几个位置,再将这5位&为0,保留低14位,计算第二层数组中的下标

所以两/三层基数树的优势:就是不用一次开辟那么多空间,如果哪些位置没有映射,那么第二层的数组就可以先不开辟,可以节省空间


下面改造代码:

首先将Pagecache.h里的使用unordered_map的_idSpanMap改造为使用基数树的:

下面将所有使用_idSpanMap[]的地方都改为TCMalloc_PageMap1中的set函数

以此类推,下面的改动就不截图了

至关重要的一点优化是,下面不需要加锁,直接_idSpanMap.get(id)即可,这里也是不用加锁使用基数树比之前快的重要的改进

至于为什么这里不需要加锁呢,之前的unordered_map需要加锁,是因为一个线程可能在_idSpanMap里面正在写,而此时另一个线程却在读,如果是红黑树或哈希表的结构,每次改变时有可能会改变结构的,肯定是不能两个线程同时访问这个结构的,所以需要加锁

而这个基数树,当需要访问空间时会直接开辟出来,并且是读写分离的,只有下面两个函数中会进行写即执行set函数:

只有在上述两个函数中回去建立id和span的映射,即是进行写操作,而这两个函数,在CentralCache里执行前, 都是加了锁的:

且基数树写之前会提前开好空间,在写的过程中不会改变结构

基数树的读写是分离的,线程1对这个位置进行读写的时候,线程2不可能对这个位置进行读写

因为假设线程1申请的内存是100页,对100页位置对应的下标进行写操作,其他线程不可能会对这个位置进行写操作

而在读某一页的时候,是不可能有线程正在写的,因为写是在刚刚开辟好空间,或者释放的时候才去写,是span没有线程使用时才去写,而读是span有线程使用时进行读,所以读写是分离的


还有合并时的这几处地方也需要改动:


源代码:

高并发内存池: 该仓库用于上传高并发内存池项目的源代码


项目到此就结束啦

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值