项目 高并发内存池

目录

项目背景

项目介绍

定长内存池

定长内存池整体架构

New 方法

Delete 方法

线程缓存

TLS

线程缓存整体结构

FreeList 设计

线程缓存模型

Allocate 申请内存

计算对齐大小

计算映射位置

申请内存流程

申请内存时创建线程缓存

中心缓存

中心缓存模型

中心缓存的整体结构

SpanList 

线程从中心缓存中获取内存

从 span 中获取一批内存

页缓存

页缓存模型

页缓存整体结构

中心缓存从页缓存中获取内存

为 SpanList 提供遍历接口

计算需要的页数

页缓存获取 span

内存池释放内存

释放流程

线程缓存释放

中心缓存的释放流程

页缓存合并内存

处理大于 256k 的内存申请

大于 256k 内存申请

大于 128 页内存申请

大于 256k 内存的释放

大于 128页内存释放

内存池优化

脱离 new

优化释放不传大小

性能测试

多线程多桶申请释放测试

多线程单桶申请释放测试

性能瓶颈分析

使用基数树进行优化

基数树代码

基数树不加锁原因

优化后性能对比

多线程单桶测试

多线程多桶测试


项目背景

环境:Windows 32 + V Studio

项目源码:高并发内存池

该项目是根据 tcmalloc(谷歌开源的项目 内存池) 模仿实现的一个简易版本的,是我结合网络上的视频,博客以及代码等模拟实现的一个。

内存池

既然该项目是内存池,那么先介绍一个内存池:

1. 内存池是什么:

内存池就是提前和系统申请一大批内存,然后当有需要的时候,内存池代系统给程序提供内存以及释放。

2. 为什么要有内存池:

我们先谈一下如果没有内存池会怎么样,其一,如果没有内存池,那么我们每一次都是需要申请和释放内存的,那么我们申请和释放内存就需要向系统申请,所以效率是比较低的,为什么每一次向系统申请就效率低呢?

我们可以谈一个故事:现在有一家人住在山上,而山上是没有水的,所以山上的人家想要使用水,那么就需要下山,然后将水取到上面,那么如果一次只取一次的用水量呢?那么当我们刷牙洗脸的时候都需要下山上山,而对于我们来说,困难的并不是一次新取水,而困难的是上下山的这个过程,每一次上下山都会耗费我们大量的时间,那么这家人就可以一次性取一天的用水量,然后这一天都不需要下山取水了。

所以内存池解决的第一个问题就是效率问题。

那么内存池还解决什么问题呢?还有就是内存碎片问题,那么什么又是内存碎片呢?

上面是一块内存(图1),如果我们申请了三块内存(图2),然后我们又释放了一块内存(图3),那么我们现在要申请一块内存(图4),起始实际上的内存是够的,但是因为这些内存没有在一起,所以就无法申请,因为一次申请就需要申请一块连续的内存,所以这次申请就会失败,那么是什么原因呢?就是内存碎片。

但是这种碎片其实是外碎片,其实还有内碎片,那么内碎片是什么呢?这里先简单的介绍一下,内碎片其实就可以理解为 struct 或者 class 一个类进行内存对齐,而因为对齐会有一些浪费,这就是内碎片问题,在项目中遇到我还会在说一下。

上面的这两个是一般的内存池在乎的两个问题,而 tcmalloc 还可以解决一个问题,那就是在多线程条件下,锁竞争的问题,所以我们的这个项目也可以叫做高并发内存池。

项目介绍

这里我会对这个项目做一个整体的介绍,因为我们的项目是需要在多线程的环境下跑,所以向 malloc 或者 new 这种函数是线程不安全的,所以是需要加锁的,但是我们的项目的锁竞争问题其实是较少的,为什么呢?我们看一下我们的项目的模型:

我们的项目模型是分为三层的,如果现在有执行流来申请内存,那么首先会和自己的线程缓存申请,而线程缓存时每一个执行流特有的,也就是每一个执行流一个线程缓存,既然是每一个执行流一个线程缓存,那么也就是说线程在自己的线程缓存中申请内存是不需要加锁的,所以线程缓存就可以大大的减少加锁。

那么如果线程缓存中没有内存应该怎么办呢?这时,线程缓存就会和中心缓存中获取内存,那么如果当中心缓存中也没有内存呢?那么就会和 page Cache 申请内存,那么如果一个线程中有太多不使用的内存呢?此时中心缓存就会将线程缓存中一部分内存拿到中心缓存,然后中心缓存最后合并到 page Cache ,合并到 page Cache 后,就会将小内存合并为大内存,最后就可以解决内存碎片的问题。

下面我们也会从申请到释放按照这三块来!

定长内存池

在真正谈内存池前,我们先谈一个比较简单的内存池,定长内存池,当我们申请一块内存的时候,我们经常会申请一个类型的对象,比如一个链表的节点什么的,下面我们先看一下定长内存池的模型:

 我们首先就是申请一大块内存,然后我们按照对象大小将内存分配出去,那么如果有释放内存的,那么就让释放的内存按照单链表的形式挂到 freeList(自由链表) 上,如果下次还有来申请内存的,那么自由链表上有数据,那么就优先分配自由链表上的内存。

那么我们应该如何设计定长的内存池呢?

定长内存池整体架构

1. 我们需要一个指向大块内存的指针,来指向大块内存的其实地址,方便下一次分配大块内存

2. 如果有人释放内存,那么还需要一个指针管理释放后的内存

3. 我们怎么知道大块内存的剩余大小?所以还需要一个成员变量,管理大块内存的剩余字节数

template<class T>
class ObjectPool
{
public:
	T* New()
	{}

	void Delete(T* obj)
	{}
private:
	// 大块内存的起始地址
	char* _memory = nullptr;
	// 大块内存剩余的字节数
	size_t _remain  = 0;
	// 释放的内存
	void* _freeList = nullptr;  
};

因为是定长的内存池,所以我们可以将该内存池叫做对象池,而且我们需要提供两个方法,一个是 new 还有一个 delete 方法。

因为大块内存需要一直改变指向,所以为了方便修改,我们将指向大块内存的指针设置为 char 类型。

那么我们如何设计 New 和 Delete 函数呢?

New 函数就是有自由链表先分配自由链表,没有自由链表,分配大块内存。

Delete 函数可以将每个对象的前指针大小的字节,修改为下一块内存的地址,这样逻辑上连接起来,如何修改我们后面说一下。

New 方法

现在来申请内存的话,那么如果 _freeList 中有内存,那么就先分配 _freeList 中的内存,如果没有那么就可以分配 _memory 的内存,那么如果 _memory 的内存不足了怎么办呢?就需要向系统申请内存。

因为项目是需要在不同的环境下测试的,所以有可能我们的环境是不同的,所以可能我们的环境是 32 位,也可能是 64 位,并且还可能是 Windows 或者 Linux 下测试,不同的环境下,有些地方就是不同的,首先就是我们的指针,如果在 32 位下是 4 字节,64 位下是 8 字节,那么如果我们想要将一个值转化为一个指针的大小怎么办呢?使用 if else 吗?其实可以使用,但是相对于另一种方法比较麻烦,我们可以将一个值修改为 指针的指针,然后解引用,就可以获取到一个指针的大小了 。

void* ptr = malloc(10);
*(void**)ptr = nullptr;

例如上面,就可以将 ptr 修改为指针大小了,在 32 位下是 4 字节,在 64 位下是 8 字节。

这个是我们后面需要的一个问题解决方法,我这里提前说一下,方便后面好阐述一点。

	T* New()
	{
		T* obj = nullptr;
		// 如果自由列表中有内存,那么优先使用自由列表中的内存
		if (_freeList != nullptr)
		{
			obj = (T*)_freeList;
			_freeList = *(void**)_freeList;
			
			return obj;
		}

		// 如果剩余的字节数小于申请的字节数,那么就再向堆申请内存
		if (_remain < sizeof(T) || _remain < sizeof(void*))
		{
			_remain = 128 * 1024;
			_memory = (char*)malloc(_remain);
			if (_memory == nullptr)
			{
				throw std::bad_alloc();
			}
		}
		
		// 如果小于一个指针,那么就给一个指针(方便释放的时候连接起来)
		size_t size = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);

		obj = (T*)_memory;
		_memory += size;
		_remain -= size;

		// 定位 初始化
		new (obj)T;
		return obj;
	}

Delete 方法

Delete 方法就是将一块内存释放,但是并不是真的释放,而是需要将释放的内存挂到 _freeList 中,我们前面说了 _freeList 本质就是一个单链表,里面挂的就是一块一块内存,而每一块内存的前面指针大小的位置存放的就是下一块内存的地址,前面我们也说了,如何将一个变量看作一个指针大小,所以我们采用头插的方式来,因为是单链表,所以使用尾插的时间复杂度位 O(N),因为我们每次都需要将释放的内存中存储下一块内存的地址,所以我们的内存至少需要指针大小,所以我们在申请的时候还需要判断申请的对象是否小于一个指针大小,如果小于的话,那么就给一个指针的大小,所以当我们申请的是 char 的时候,那么就会给一个指针大小的空间,而这其中就是有浪费的,这因为对齐的浪费也就是内碎片。

	void Delete(T* obj)
	{
		// 显示调用析构,清理 obj 中的资源
		obj->~T();

		NextObj(obj) = _freeList;
		_freeList = obj;
	}

线程缓存

上面说定长内存池首先是让我们入门,其次是后面我们也需要用到定长的内存池。

那么现在我们看一下线程缓存是如何实现的,我们先介绍一下线程缓存:

TLS

线程缓存时每一个线程独有的一份,那么我们如何让每一个线程独有呢?我们知道一个进程中的线程资源大多数都是共享的,而线程的独立栈结构上的数据是线程自己的,但是独立栈上的数据是局部的,我们要是想要一个全局的呢?并且是线程私有的,那么我们就需要使用 TLS(thread local storage) 在Linux 下使用 pthread_key_t 定义线程的本地存储,而 Windows 使用 __declspec(thread) 我们的代码是在 Windows 下写的,所以我们就使用 Windows 下的接口。

线程缓存整体结构

线程缓存是线程申请内存的第一站,而在线程缓存中申请内存的话是不需要加锁的,因为每一个线程缓存中都有内存,所以不需要加速,那么线程缓存中需要哪些成员变量及其方法呢?

1. 因为线程缓存中也是需要大量的不同大小的内存,因为申请内存的话不可能只申请一种大小的内存,而是申请的内存大小可能各不相同,所以线程缓存中需要大量的不同大小的内存,而我们使用自由链表挂起来,但是自由链表只能管理大小相同的内存,所以就需要大小不同的自由链表。

2.我们一定至少是需要申请内存的一个函数接口,还有一个释放内存的一个函数接口的。

3. 那么当线程中内存不足时需要怎么办呢?需要从中心缓存中获取,所以还需要为线程缓存提供一个可以从中心缓存中获取内存的接口。

那么我们现在也有问题,既然是线程缓存每一个线程都有,那么什么时候创建线程缓存呢?我们可以在第一次申请的时候创建,但是申请的时候创建,那么如果现在两个线程同时申请呢?那么是不是就需要加锁了,但是不是说线程缓存是不需要加锁的吗?是的,那么我们就可以使用上面的 TLS来存储,让每个线程都有一个,所以在创建的时候也是互不影响的。

class ThreadCache
{
public:
	void* Allocate(size_t size);

	void Deallocate(void* obj, size_t size);

	void* FetchFromCentralCache(size_t index, size_t size);
private:
	// 每个位置下面挂的都是自由链表,根据哈希映射规则
	FreeList _freeLists[]; 
};

FreeList 是一个自由链表的结构,而这里使用了自由链表的数组,其中不同的位置映射不同大小的内存。

FreeList 设计

FreeList 中就是一个自由链表,每个自由链表中挂的内存都是相同大小的内存,因为线程缓存中一定不会是只申请一种大小的内存,所以需要不同大小的内存,而对于线程缓存而言,小于 256k 的内存都可以直接申请。

class FreeList
{
public:
	// 头插
	void Push(void* obj)
	{
		assert(obj);

		NextObj(obj) = _freeList;
		_freeList = obj;
	}
	//头删
	void* Pop()
	{
		assert(_freeList);

		void* obj = _freeList;
		_freeList = NextObj(_freeList);

		return obj;
	}

	bool Empty()
	{
		return _freeList = nullptr;
	}
private:
	void* _freeList = nullptr;
};

目前只提供这些函数以及成员变量,当需要的时候在加上即可。

下面我们可以看一下线程缓存的模型:

线程缓存模型

Allocate 申请内存

那么我们现在就来看一下线程缓存申请内存如何申请:

当我们需要申请内存的时候,如果我们想要申请 1 字节,那么我们就返回 1 字节的空间吗?并不是,我们是需要进行对齐的,因为首先,自由链表中内存的管理是使用上一块内存存储下一块内存的地址,所以申请的内存大小至少不能小于指针的大小,所以需要内存对齐,而且因为每一个映射的位置里面管理的内存大小不同,所以还需要计算出映射的位置,那么这两个位置如何计算呢?

计算对齐大小

因为我们需要经常使用计算对齐大小,以及映射位置,所以我们可以将这些公用的函数写成一个类 SizeClass 我们将里面的方法实现为公有静态的方法,我们可以在类外随便调用。

RoundUp(计算对齐大小)

我们想要的效果是,传入一个 size 然后返回将 size 对齐后的对齐数,但是我们知道因为线程缓存中可以申请的内存大小是 256k 所以如果都使用 8 字节对齐的话,那么这无疑是一个特别大的数字,所以在对齐数上,我们可以让 size 小的话,对齐数也小, size 大的话,那么对齐数也大,所以看一下下面的对齐规则,这个规则是别人简化过的,我这里是为了学习,所以就直接拿过来使用了。

[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 字节对齐,剩下的就不说了,也就是说当申请的内存越大,对齐数也就越大。

那么如果是这样对齐的话,内存的浪费大概是多少呢?其实在 10% 左右,因为前面申请的内存少,所以对齐数也就小,也就是浪费也少,而后面申请的内存大,相对的对齐数也就大,所以相对而言浪费率也其实是差不多的。

可以简单的计算一下浪费率,不过我们并不计算第一个,因为如果申请 1 字节,那么按照 8 字节对齐,那浪费率也特别大,也就是拿浪费的,除以总共的,也就是 7 / 8 基本快 90% 的浪费率,所以第一个就不参与计算,我们直接从第二个开始计算。

我们的计算使用最大的浪费数来计算,以及最小的分母,也就是当申请的内存为 129 时,给了 144 的内存,那么也就是浪费了 15 字节,那么 15 / 144 = 0.10,那么如果申请了 1025 那么实际上给了1125 字节的内存,那么浪费了 127,那么浪费率就是 127 / 1125 = 0.11 后面的我就不计算了,浪费率也就是 10% 左右。

那么根据我们上面的对齐规则我们来计算对齐数:

	static size_t RoundUp(size_t size)
	{
		if (size <= 128)
		{
			return _RoundUp(size, 8);
		}
		else if (size <= 1024)
		{
			return _RoundUp(size, 16);
		}
		else if (size <= 8192) // 8 * 1024
		{
			return _RoundUp(size, 128);
		}
		else if (size <= 65535) // 64 * 1024
		{
			return _RoundUp(size, 1024);
		}
		else if (size <= 262144) // 256 * 1024
		{
			return _RoundUp(size, 8192);
		}
		else
		{
			assert(false);
			return -1;
		}
	}

	size_t _RoundUp(size_t size, size_t alignNum)
	{
		size_t alignSize;
		if (size % alignNum)
		{
			alignSize = (size / alignNum + 1) * alignNum;
		}
		else
		{
			alignSize = size;
		}

		return alignSize;
	}

下面的这个是子函数,我们的子函数中计算对齐数使用的是乘除法,我们知道其实乘除法实际上效率是比较低的,而对于计算机而言,位运算的效率是特别高的,所以我们可以将乘除法改为位运算,那么如何改呢?我们还是看一下别人怎么写的:

	static size_t _RoundUp(size_t size, size_t alignNum)
	{
		return (size + alignNum - 1) & (~(alignNum - 1));
	}

我们可以将数据带入进去算一下:

我们申请的大小是 8 字节所以对齐数也是 8 那么简单的计算就是 15 & ~7,那么也就是,

      001111          001111
& ~000111      & 111000      =      001000  =  8

后面的我也就不计算了,有兴趣的可以自己计算一下。

计算映射位置

其实映射位置也是和上面的计算是差不多的,只是有略微的不同,前 128 字节是按照 8 字节对齐,那么就是说前 128 字节一共有 16  桶,而后面的根据对齐规则映射,分别由 56 个桶,我们以及在映射规则哪里写了桶的其实位置到结束位置,可以自己计算一下:

	static size_t Index(size_t bytes)
	{
		assert(bytes <= MAX_BYTES);
		// 每个区间有多少个链
		static int group_array[4] = { 16, 56, 56, 56 };
		if (bytes <= 128) 
		{
			return _Index(bytes, 3);
		}
		else if (bytes <= 1024) 
		{
			return _Index(bytes - 128, 4) + group_array[0];
		}
		else if (bytes <= 8 * 1024) 
		{
			return _Index(bytes - 1024, 7) + group_array[1] + group_array[0];
		}
		else if (bytes <= 64 * 1024) 
		{
			return _Index(bytes - 8 * 1024, 10) + group_array[2] + group_array[1] + group_array[0];
		}
		else if (bytes <= 256 * 1024) 
		{
			return _Index(bytes - 64 * 1024, 13) + group_array[3] + group_array[2] + group_array[1] + group_array[0];
		}
		else 
		{
			assert(false);
		}
		return -1;
	}

    static size_t _Index(size_t size, size_t alignNum)
	{
		if (size % alignNum)
		{
			return size / alignNum;
		}
		else
		{
			return size / alignNum - 1;
		}
	}

这里在 Inddex 函数中,因为映射使用的对齐数不同,所以不能直接计算,而是将前面映射的字节数减去,然后剩余的字节按照自己的对齐数映射,最后加上前面映射桶的数量。

因为这里还是使用了乘除法,因为乘除法效率较低,所以还是使用位运算,看一下别人写的:

	static size_t _Index(size_t size, size_t alignShift)
	{
		return ((size + (1 << alignShift) - 1) >> alignShift) - 1;
	}

这个 alignShift 是对齐数的转换,如果是 8 ,那么就是 3,如果是 16 那么就是 4。

这里也给简单的计算一下:

如果申请的大小是 15 字节,那么对齐数就是 8,经过简单1计算就是:((15 + 8 - 1)/ 8)- 1

最后的计算结果就是 1,也就是在 1 号桶的位置。

按照这个规则映射的话,那么一共会由 208 个桶,所以线程缓存中 FreeList 中一共有 208 个桶,所以那个数组我们可以直接给 208,但是一般这种不要硬编码进去,我们可以使用宏,不过在 C++ 中一般不建议使用宏,所以我们可以使用 const 和 inline代替宏。

所以下面我们就可以申请内存了:

申请内存流程

void* ThreadCache::Allocate(size_t size)
{
	// 线程缓存最大就是 MAX_BYTES = 256*1024
	assert(size <= MAX_BYTES);
	// 1. size 是需要对齐的,计算 size 对齐数
	size_t alignSize = SizeClass::RoundUp(size);
	// 2. 计算所需要 size 在哪一个桶
	size_t index = SizeClass::Index(size);
	// 3. 如果 index 位置的自由链表不为空,那么返回 index 位置自由链表中的一块内存
	if (!_freeLists[index].Empty())
	{
		// 弹出自由链表中的一块内存
		return _freeLists[index].Pop();
	}
	else
	{
		// 自由链表中 index 位置的自由链表为空
		// 从中心缓存中获取对象
		return FetchFromCentralCache(index, alignSize);
	}
}

也就是先计算对齐数和映射的位置,如果对应的桶中没有内存,那么就需要向中心缓存中申请内存了,而中心缓存中的隐私和线程缓存中的一样,但是里面的数据结构有差别,所以我们将需要获取的下标,以及申请的字节数传入进去,来从中心缓存中获取内存。

申请内存时创建线程缓存

因为线程缓存是每一个中都有的,所以在我们第一次申请内存的时候,我们就需要获取线程缓存对象。

这里就用到我们的 TLS 了,因为是 Windows 下,所以使用 Windows 的方法:

static __declspec(thread) ThreadCache* pTLSThreadCache = nullptr;

那么在申请内存的时候,每一个线程就提前创建 ThreadCache 对象,所以我们还需要分装一个获取内存和释放内存的接口:

static void* ConcurrentAlloc(size_t size)
{
	if (pTLSThreadCache == nullptr)
	{
		pTLSThreadCache = new ThreadCache;
	}

	return pTLSThreadCache->Allocate(size);
}
 

static void ConcurrentFree(void* obj, size_t size)
{
	assert(pTLSThreadCache);

	pTLSThreadCache->Deallocate(obj, size);
}

当 pTLSThreadCache 对象为空则创建对象,此时就不用怕 pTLSThreadCache 创建对象的时候多线程一起来的情况了,因为此时的 pTLSThreadCache 是每个线程各有的。

所以当 pTLSThreadCache 为空时,就创建 ThreadCache 对象,然后每个线程通过自己的 篇TLSThreadCache 获取内存。

中心缓存

下面就需要从中心缓存中获取内存了,那么我们先看一下中心缓存的模型,中心缓存中的映射关系和线程缓存中的隐射规则时一样的,但是里面的数据结构是不同的,所以我们先看一下中心缓存的模型。

中心缓存模型

中心缓存中,虽然映射和线程缓存是一样的,但是实际上的数据结构不同,在线程缓存中,每一个位置都挂的是自由链表,然后自由链表中挂的是内存块,但是在中心缓存中,实际上挂的是一个一个的 span 而 span 中又管理着内存,并且每一个位置的 span 中的的内存都是相同大小的,且大小和线程缓存中映射的内存块大小也是相同的。

下面我们在看一下 span 的结构:

中心缓存中挂的都是 spanList,其中 spanList 是有头双向循环的,而 span 中还有 freeList 用来管理内存,其中每个 span 都有 prev 和 next  指针,指向前后的节点。

实际上正真看起来就是这样的,但是这里看不出来是循环的。

中心缓存的整体结构

中心缓存我们根据上面的模型,我们知道我们需要一个 spanList,而我们也需要先拥有一个 span,所以我们先看一下 span 的结构:

SpanList 

class Span
{
public:
	PAGE_ID _pageId = 0; // 大块内存的页号
	size_t _n = 0;       // span 中有 _n 页

	Span* _next = nullptr;
	Span* _prev = nullptr;

	size_t _useCount = 0; // span 中被分配出去的内存个数
	void* _freeList = nullptr; // 切好的小块内存
};

因为 span 是管理大块内存的,而在 span 中将大块内存切分为小块内存然后挂到 _freeList (自由链表)中,因为中心缓存需要的不只是一个 span,所以需要的是 spanList ,所以 span 还需要可以形成双链表,也就需要 next 和 prev 指针,因为管理大块内存,所以还需要大块内存的页号,以及该大块内存中有多少也内存,还有因为 span 是需要给 thread Cache 分配内存的,所以为了方便后续归还内存,还需要加一个 useCount 的变量,统计分配出去了多少块内存,当useCount 在一次变为 0 的时候,说明该 span 中的所有内存都还完了,所以就可以将该 span 归还给 page Cache 以供 page Cache 将小块内存合并为大块内存。

那么我们现在在看一下 SpanList 结构,因为中心缓存中需要的就是 SpanList 结构,所以我们还需要为 SpanList 提供一些接口,方便中心缓存进行操作相应的桶,那么我们应该先提供哪些接口呢?我们一定需要提供获取一个 span 和将一个 span 插入的接口,那么我们还需要什么成员变量吗?

我们前面说了,线程缓存是不需要加锁的,但是如果当线程缓存没有数据时,那么线程缓存就需要和中心缓存获取内存,但是因为可能不只有一个线程,所以可能会有多个线程来访问中心缓存,那么中心缓存就只能由一个线程访问吗?不是的,因为中心缓存中是有多个桶的,因为每个线程访问中心缓存的时候,不一定访问的是同一个桶,所以当线程访问不同的桶的时候,那么就可以进行并发的访问,并不会有线程安全问题。

但是当几个线程同时访问同一个桶的时候,那么就是需要加锁的,所以我们需要在中心缓存的每一个桶中都加锁,而中心缓存的每一个桶就是 SpanList 所以我们需要在SpanList 中加入一个成员变量也就是锁,同时我们也可以提供两个接口,也就是桶加锁,和桶解锁。

// 带头双向循环链表
class SpanList
{
public:
	SpanList()
	{
		_head = new Span;
		_head->_next = _head;
		_head->_prev = _head;
	}

	void Insert(Span* pos, Span* newSpan)
	{
		assert(pos);
		assert(newSpan);

		Span* prev = pos->_prev;
		
		prev->_next = newSpan;
		newSpan->_prev = prev;

		pos->_prev = newSpan;
		newSpan->_next = pos;
	}

	void Erase(Span* pos)
	{
		assert(pos);
		assert(pos != _head);

		Span* prev = pos->_prev;
		Span* next = pos->_next;

		prev->_next = next;
		next->_prev = prev;
		// 这里是不需要 delete span 的,这个 span 是需要归还给 page Cache
	}
    // 桶加锁
	void BarrelLock()
	{
		_mtx.lock();
	}
    // 桶解锁
	void BarrelUnlock()
	{
		_mtx.unlock();
	}
private:
	Span* _head = nullptr;
	// 桶锁
	std::mutex _mtx;
};

既然有了 SpanList 那么我们也可以开始设计中心缓存的结构了,我们知道,线程刚进来的时候找线程缓存申请内存,但是不一定时一个线程,所以也就一定是多个线程缓存,那么如果线程缓存没有内存,就会找中心缓存,但是中心缓存时所有线程都共享的,所以说明的是中心缓存只有一份,既然只有一份,那么我们就可以将中心缓存设计为单例模式,单例模式分为懒汉和饿汉,饿汉模式简单一点,我们使用饿汉模式,因为懒汉模式还得加锁,所以我们的中心缓存就是一个单例模式。

那么中心缓存中应该需要哪些成员变量和方法呢?其中我们刚开始说了,需要 SpanList 用于管理内存块,那么目前就没有了,因为中心缓存时需要加桶锁的,我们将桶锁放到了 SpanList 中,所以中心缓存中每个桶中一定时有锁的,因为中心缓存需要给线程缓存提供服务,也就是可以把中心缓存中的 span 中的内存给线程缓存,而线程缓存中有一个函数就是 FetchFromCentralCache 函数需要获取一批内存,所以中心缓存中需要一个可以获取一批内存的接口,但是获取一批内存的话,那么一定是需要从 span 中获取,那么还可以为中心缓存中提供一个 获取一个 Span 的接口,但获取到 Span 那么就可以将 Span 中的内存划分给线程缓存了。

class CentralCache
{
public:
    // 单例模式
	static CentralCache* GetInstance()
	{
		return &_sInst;
	}

	// 重中心缓存中获取批量的内存,, start 指向头, end 指向尾
	size_t FetchRangeObj(void*& start, void*& end, size_t n, size_t size);

	// 从中心缓存中获取一个 span
	Span* GetOneSpan(SpanList& list, size_t size);
private:
	static CentralCache _sInst;

	CentralCache() {};
	CentralCache(const CentralCache&) = delete;
	CentralCache operator=(CentralCache) = delete;
private:
	// 因为映射规则和 thread Cache 相同,所以数组的个数和 Thread Cache 也是相同的
	SpanList _spanLists[NFREE_LIST];
};

那么我们现在就可以完成线程缓存中的从中心缓存中获取内存的函数了。

线程从中心缓存中获取内存

FetchFromCentralCache 函数是线程缓存中的函数,但是我放在了中心缓存这里,因为这个函数需要获取到一段内存,而获取内存的过程中是需要涉及到中心缓存的,所以将这个函数放到从中心缓存中获取数据。

那么如何获取呢?因为线程缓存中是不需要加锁的,是所以线程缓存中申请内存的话,效率会较高一点,虽然说中心缓存中也是桶锁,锁竞争并不是特别频繁,但是还是避免让线程一直从中心缓存中获取数据,所以线程缓存可以一次批量向中心缓存中获取一批数据,然后将申请的一块内存返回,将剩余的内存挂到线程缓存的自由链表中,这就是该函数的实现思路。

但是如果是第一次申请内存,那么第一次就给大量的数据吗?并不是,因为如果刚开始就给大量的内存,那么后面用不完还是浪费了的,所以我们可以采用慢启动的算法,也就是前期给少一点,后期给多一点,并且,如果是小对象,那么就给多一点,如果是大对象,那么就给少一点,那么我们应该如何实现这样的算法呢?

我们可以给 SizeClass 类中添加一个函数,也就是给这个函数一个对象的大小,那么这个函数就可以返回给多少比较合适,而这个函数就是判断小对象给多一点,大对象给少一定,那么我们如何实现慢启动呢?我们需要一个变量来记录,如果申请的次数越多,那么就说明这个大小的内存块是频繁申请的,所以就可以多给一点,那么我们就可以在自由链表中(FreeList)中添加一个成员变量,也就是 _maxSize ,_maxSize 就可以记录我们这一次可以给多少内存,当申请内存结束后,我们可以对 _maxSize 进行适当的增加,为了下一次获取一批内存做准备,但是我们也并不能让获取的内存数量一直增长上去,同时我们也不能让申请的内存太少了,所以我们需要控制一个上限和下限。

	static size_t NumMoveSize(size_t size)
	{
		assert(size > 0);
		// size 就是蛋哥对象的大小
		// 这里想要如果对象比较小,那么就多给一点,入轨对象比较大,那么就少给一点
		size_t batchNum = MAX_BYTES / size;
		// 控制 batchNum 的上限以及下限
		if (batchNum > 512)
			batchNum = 512;
		else if (batchNum < 2)
			batchNum = 2;

		return batchNum;
	}
void* ThreadCache::FetchFromCentralCache(size_t index, size_t size)
{
	// 慢启动,前期少,后期多,获取 batchNum 块内存
	size_t batchNum = min(SizeClass::NumMoveSize(size), _freeLists[index].MaxSize());

	if (batchNum == _freeLists[index].MaxSize())
	{
		// 慢增长
		_freeLists[index].MaxSize() += 1;
	}

	// 从中心缓存中要批量的数据,然后存放到对应桶中的 _freeList
	void* start = nullptr, *end = nullptr;
	size_t actualNum = CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, size);

	// 至少要获得一个内存块
	assert(actualNum > 0);

	if (actualNum == 1)
	{
		assert(start == end);
		return start;
	}
	else
	{
		// 获取的多个内存,将第一个内存返回,然后将剩余内存块挂到 _freeList[index] 中
		_freeLists[index].PushRange(NextObj(start), end, actualNum);
		return start;
	}

	return nullptr;
}

那么线程缓存可以通过函数(FetchRangeObj)来获取一批内存了,那么这个函数又是应该如何实现呢?

从 span 中获取一批内存

刚才我们可以让线程缓存从中心缓存中通过 FetchRangeObj获取一批内存了,那么该函数如何实现呢?

首先该函数也是需要通过 span 来分配内存的,分别让 start 指向这段内存的其实位置,end 指向这段内存的终止位置,并且返回获取到内存的实际数量,因为在 span 中不一定有足够数量的内存块分配给线程缓存,但是至少是有一个的,因为如果 span 中没有的话,那么就会到页缓存中获取内存,来分配给中心缓存,在由中心缓存分配给线程缓存,那么获取到 span 后,然后将内存分配出去后,需要将 span 中的 _useCount 需要修改,因为 _useCount 记录着这块 span 的内存分配出去的个数,还有 span 中自由链表的直线,然后返回真实的内存个数。

size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size)
{
	// 1. 先计算出在哪一个桶下面获取 span
	size_t index = SizeClass::Index(size);
	// 2. 到了这里就开始访问中心缓存了,所以需要加桶锁
	_spanLists[index].BarrelLock();
	// 3. 获取中心缓存中的一个非空的 span
	Span* span = GetOneSpan(_spanLists[index], size);
	assert(span && span->_freeList);
	// 4. 获取batchNum块 size 大小的内存
	start = span->_freeList;
	end = start;
	// 获取 batchNum 块内存,但是 span 中不一定够 batchNum 块内存,当 end 的下一块为 null 时页停止
	int actualNum = 1;
	for (int i = 0; (i < batchNum - 1) && NextObj(end); ++i)
	{
		++actualNum;
		end = NextObj(end);
	}
	// 5. 修改 span 中自由链表的指向和 span 中内存分配数
	span->_freeList = NextObj(end);
	span->_useCount += actualNum;
	// 6. 将 end 的 next 置空
	NextObj(end) = nullptr;

	//  end. 将桶锁解除
	_spanLists[index].BarrelUnlock();
	return actualNum;
}

页缓存

上面我们以及可以将获取到的 span 中的内存取出来给线程缓存,那么如果中心缓存中如何获取 span 呢?如果中心缓存中没有 span 呢?

页缓存模型

 page cache 中虽然页存储的是 span 但是映射的规则和 centralCache 并不同,中心缓存时服务给线程缓存的,所以映射规则和和线程缓存相同,方便找到相同大小的内存块存储的位置,而页缓存是给中心缓存服务的,但是中心缓存申请内存的话,并不是按照固定大小的,而是按照页为单位申请内存的,所以页缓存直接以页为映射,映射页缓存,这里的页缓存总共从 1 ~ 128 个位置,分别每个位置都是不同的页数,所以当我们想要一页的内存时,就去下标为 1 的位置去寻找,当我们想要两页的时候,就去下标为 2 的位置取内存。

页缓存中的 span 中管理内存并不是使用自由列表,而是使用页 ID,我们可以通过页ID计算出页缓存中国管理的内存的起始地址。

页缓存整体结构

页缓存中也是使用 span 管理内存,而且页缓存中使用的映射也就是直接映射,那么映射多少个桶呢?给 129 个桶,这样如果想要 1 页的,那么就去 1 号桶中去取,想要 2 页就可以去 2 号桶中取,所以 给 129 个,那么一共就有 128 个桶,最大的桶中存储的内存就是 128 页,我们也可以简单的计算一下,线程缓存最大可以分配 256kb 的内存,那么 128 * 8 * 1024 = 1024kb 也就是说,最大的桶中可以给线程缓存中最大的内存分配 4 块。

那么页缓存中需要怎么加锁呢?因为在线程缓存中,每个线程都有一份,所以可以无锁访问,而当线程缓存中没有内存,那么就会访问中心缓存,但是每个线程只会访问中心缓存中固定的一个桶,因为中心缓存中的映射和线程缓存中的映射规则是一样的,所以中心缓存中加桶锁即可,那么页缓存是否也可以加桶锁呢?起始对于加桶锁而言,页缓存更适合全部加锁,因为当线程来到页缓存中取内存的话,并不是访问固定的一个位置,而是可能发现该位置没有就会到下一个位置去找,如果下一个位置也没有,那么就去下下一个位置,所以访问的位置是不固定的,而如果加桶锁的话,那么当访问的位置没有内存,那么就回去下一个位置找,也就会发生频繁的加锁解锁,这样反而酰氯会低,所以这里还是建议加页锁,也就是访问页缓存的时候只有一个线程可以访问,所以说页缓存中需要一把大锁,对于整个页缓存。

在中心缓存的时候,因为中心缓存只会有一个,那么就把中心缓存设置为单例模式,那么页缓存要设置为单例模式吗?要的,因为页缓存也只会有一份,所以可以设置为单例模式,那么页缓存需要哪些函数呢?在页缓存中,因为我们需要从中心缓存中访问到页缓存,也就是 GetOneSpan 函数访问页缓存如果中心缓存中没有 span 那么就会从页缓存中获取一个 span,所以可以给页缓存提供一个 NewSpan 的接口。

class PageCache
{
public:
	static PageCache* GetInstance()
	{
		return &_sInst;
	}

	// 获取 k 页的 span	
	Span* NewSpan(size_t k);

private:
	static PageCache _sInst;

	PageCache() {};
	PageCache(const PageCache&) = delete;
	PageCache& operator=(PageCache&) = delete;
private:
	SpanList _spanLists[NPAGES];
	std::mutex _mtx;
};

那么我们知道了页缓存的整体结构,我们现在也可以完成中心缓存中的 GetOeSpan 函数了。

中心缓存从页缓存中获取内存

那么 GetOneSpan 函数要如何完成呢?GetOneSpan 函数被 FetchRangeObj 函数调用,用来获取一个 span 也就是 GetOneSpan 函数可以获取到一个 span 那么也就是需要先从中心缓存对于的位置查看是否有 span 如果没有的话,那么就需要从页缓存中获取一块内存了,但是如果从页缓存中获取内存的话,那么是需要对页缓存中的内存做切分的,切分为需要的大小,为什么呢?如果是从页缓存中获取内存,那么一定是中心缓存需要内存,但是中心缓存又是给线程缓存分配内存,而中心缓存给线程缓存分配内存的方式是通过自由链表的方式来分割,但是页缓存中的内存是用 pageId 来管理的,并没有使用自由链表,那么为什么不使用自由链表呢?起始我们想一下也就能知道,因为对于中心缓存来说才需要给线程缓存分配内存,而线程缓存和中心缓存要内存的时候是以对象的大小来申请的,但是页缓存是给中心缓存服务的,中心缓存申请内存并不是以对象,因为中心缓存如果没有内存,那么也会和页缓存申请一批内存,并不是一个,所以中心缓存和页缓存的内存交互是以页为单位的,所以页缓存并不会把内存切分好后挂到自由链表,因为也换成也不知道你要多大的内存,所以也换成给中心缓存返回一个 span 这个 span 中的内存由中心缓存自己切分,然后将这个 span 挂到自己的对应的位置,然后减 span 返回,返回后由中心缓存切分一批内存给线程缓存。

为 SpanList 提供遍历接口

但是如果需要访问中心缓存中的 span 的话,那么就需要遍历 SpanList ,要遍历怎么办呢?我们需要提供迭代器吗?起始并不需要,这里我们只需要使用原生的即可,我们可以提供一个 begin 和 end 接口即可,那么当有 span 中自由链表有内存,那么就可以直接返回对应的 span。

根据我们上面说的,起始我们并不需要真的提供迭代器,我们可以提供一个 begin 和 end 这两个接口返回一个 span* ,然后我们可以通过这个 span 访问到下一个 span 所以自然而然就遍历起来了。

	Span* Begin()
	{
		return _head->_next;
	}

	Span* End()
	{
		return _head;
	}

 这两个接口在 SpanList 中,着了也就不把其他的都放出来了。

计算需要的页数

那么如果中心缓存中没有 span 那么就是需要从页缓存中获取内存的,页缓存中获取内存的接口就是 NewSpan 接口,但是这个接口时以页为单位分配内存的,所以我们需要提前计算出所需要的内存,那么如何计算呢?我们之前写了一个接口就是可以计算除分配多少个对象的接口,那么我们既然知道分配对象的个数,那么又知道对象的单个大小,那么我们也就知道所需要的总字节数,总字节数除以一页的大小,那么就是我们需要的页数,我们需要的页数如果不足一页呢?那么我们当然不能什么都不返回,我们需要至少返回一页,所以我们还需要计算出所需要的页数的一个接口。

我们可以将这个接口也放到 SizeClass 中,这个类中放的都是关于内存对齐,以及计算映射位置等接口。

	static size_t NumMovePage(size_t size)
	{
		// 1. 计算需要多少个 size 大小的对象
		size_t num = NumMoveSize(size);
		// 2. 使用获取到的对象个数乘以对象大小,就是需要获取的总共内存数
		size_t npage = num * size;
		// 3. 让总内存除以一页的大小,就是需要获取的总共页数
		npage >>= PAGE_SHIFT;

		// 如果都不足一页,那么就返回一页
		if (npage == 0)
			npage = 1;
		
		return npage;
	}

Span* CentralCache::GetOneSpan(SpanList& list, size_t size)
{
	// 1. 查看中心缓存中是否有未分配的 span
	Span* it = list.Begin();
	while (it != list.End())
	{
		if (it->_freeList != nullptr)
		{
			return it;
		}

		it = it->_next;
	}

	// 2. 说明 list 中没有空闲的 span 所以需要找 page Cache 要
	
	// 计算需要几页的内存
	size_t kpage = SizeClass::NumMovePage(size);
	// 获取一个 kpage 页的 span
	Span* span = PageCache::GetInstance()->NewSpan(kpage);

	// 3. 计算出 span 的起始地址 
	char* start = (char*)(span->_pageId << PAGE_SHIFT);
	// 4. 计算总共的字节数
	size_t bytes = span->_n << PAGE_SHIFT; // PAGE_SHIFT 是一页大小的转换 8k = 13
	// 5. 计算出大块内存的最后的位置
	char* end = start + bytes;

	// 把大块内存且成自由链表连接起来,返回
	
	// 先切一块下来,方便尾插
	span->_freeList = start;
	start += size;
	void* tail = span->_freeList;

	while (start < end)
	{
		NextObj(tail) = start;
		tail = start;
		start += size;
	}
	// 将 tail 的 next 置空
	NextObj(tail) = nullptr;

	// 将这个span 头插到 spanlist 中
	list.PushFront(span);

	return span;
}

页缓存获取 span

GetOneSpan 接口中获取 span 那么如果中心缓存中没有 span 那么就会从页缓存中获取,那么页缓存中如何获取呢?

页缓存中每个地方映射的就是对应的页数,所以先看需要多少页的内存,那么在看一下改为值有没有,那么如果没有怎么办呢?没有需要找堆申请吗?其实并不需要,如果没有内存的话,那么可以去后面找更大的内存,那么找到了更大的内存就可以将需要的内存划分出去,然后将剩余的内存挂到对应的位置,例如:现在申请一个两页的内存,但是 2 号下标没有内存,所以就需要向后找,找到了 126 号下标有内存,然后将 126 号下标切出来2 页内存分配出去,剩下的 124 页内存就需要重新挂到 124 下标处,那么如果后面还是没有大块内存呢?那么此时就需要向堆申请了,那么申请多少呢?可以申请一个 128 页的内存,然后我们重复刚才的过程即可。

在下面的代码中用到了锁,所以可以提前写两个加锁解锁的函数,以供page Cache 使用。

Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0);
	// 先检查第 k 个桶中有没有 span
	if (!_spanLists[k].Empty())
	{
		return _spanLists[k].PopFront();
	}
	
	// 第 k 个桶里面是空的,检查后面的 span 是否为空,如果有那么就切分
	for (size_t i = k + 1; i < NPAGES; ++i)
	{
		if (!_spanLists[i].Empty())
		{
			// 获取到一个大于 k 页的 span
			Span* nSpan = _spanLists[i].PopFront();
			
			// 将nSpan 切出来 k 页给 kSpan
			Span* kSpan = new Span;
			kSpan->_pageId = nSpan->_pageId; 
			kSpan->_n = k;

			// 修改 nSpan 中的起始页号和页数
			nSpan->_pageId += k;
			nSpan->_n -= k;

			// 将 nSpan 剩余的大小挂到1对应的位置
			_spanLists[nSpan->_n].PushFront(nSpan);

			return kSpan; 
		}
	}

	// 这里说明没有大于 k 页的 span, 那么就需要和系统申请一个大块内存
	Span* bigSpan = new Span;
	// 按照最后一个大块内存申请
	void* ptr = SystemAlloc(NPAGES - 1);
	// 将自主转为页号
	bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
	bigSpan->_n = NPAGES - 1;

	_spanLists[bigSpan->_n].PushFront(bigSpan);

	return NewSpan(k);
}

上面用到了 PopFront 也就是 SpanList 中弹出一个元素,但是这里面我们并没有加锁,为什么呢?这里我们使用了递归,虽然说也有递归锁,但是还是有其他的解决方法,例如在外部加锁,所以我们可以在外部加锁,也就是 GetOneSpan 中。

到了这里整个申请内存的流程就走通了。

内存池释放内存

上面已近将申请的流程已经走通了,那么释放的流程又是什么样子呢?

我们说了内存池必须解决的两个问题,一个是效率问题,另一个是内存碎片的问题,那么效率的问题就是申请一大批空间,然后由内存池来管理解决,内存碎片问题怎么解决呢?

内存碎片问题需要将小块的内存合并为大块的内存,那么如何合并呢?我们这里简单的说一下这个释放流程的逻辑,那么大概就可以了解一下整体的释放的过程中三个模块分别做了什么。

释放流程

调用 ConcurrentFree 函数的时候,那么该函数就会调用线程的 Deallocate 函数,通过这个函数释放内存,那么这个函数需要做什么呢?线程缓存可以将释放的内存挂到自由链表上,但是如果有一个线程的自由链表下面由大量的内存块怎么办呢?那么我们为了让内存分布更均衡,我们可以让线程缓存的自由链表如果挂太多的内存就将自由链表中的一批量的内存还给中心缓存的 span ,再由中心缓存将 span 中内存全部还回来的 span 还给 page Cache ,page Cache 最后进行将 span 合并,将小的 span 合并为大的 span 以供后序申请大块内存。

但是这个过程中有很多细节的地方,还需要我们后续进行补充。

线程缓存释放

那么线程缓存中释放的细节是什么呢?

线程缓存中要是向释放的话,那么就先将需要释放的内存挂到自由链表里面,那么自由链表里面的内存什么时候需要归还给中心缓存的 span 呢?我们可以采用判断中心缓存的自由链表中内存块的数量,如果内存块的数量大于一次申请的最大数量,那么就让线程缓存中的自由链表的内存还给中心缓存中的 span。

那么要怎么得知自由连中内存块的个数呢?我们可以为自由链表中添加一个变量 size 专门为了统计自由链表中内存块的个数,那么当 Push 的时候加加size,当 Pop 的时候减减 size 当 PushRange 也就是插入一段范围的时候,那么 size 就需要加上这段内存的个数,当然 PopRanfe 的时候也是如此,所以为了获取 size 那么还可以提供一个接口,获取 size。

所以这里需要添加一个函数,ListTooLong 函数,就是将自由链表还给 span,但是想要将自由链表中的内存获取出来,那么就需要提供一个获取一段内存的函数接口,PopRange 函数,而这个接口需要在 FreeList 中提供。

	void PopRange(void*& start, void*& end, size_t size)
	{
		assert(size >= _size);

		start = _freeList;
		end = start;
		// 取出来 size 块内存
		for (int i = 0; i < size - 1; ++i)
		{
			end = NextObj(end);
		}
		// 修改自由链表的起始位置
		_freeList = NextObj(end);
		// 修改 end 的下一个
		NextObj(end) = nullptr;
		// 自由链表中的 _size 减  size
		_size -= size;
	}
void ThreadCache::Deallocate(void* obj, size_t size)
{
	assert(obj);
	assert(size <= MAX_BYTES);

	// 找到自由链表的桶,然后插入进去
	size_t index = SizeClass::Index(size);
	_freeLists[index].Push(obj);
    // 如果当自由链表中的内存个数大于等于一次申请的最大个数的时候,就将自由链表的内存归还给 span
	if (_freeLists[index].Size() >= _freeLists[index].MaxSize())
	{
		ListTooLong(_freeLists[index], size);
	}
}

void ThreadCache::ListTooLong(FreeList& list, size_t size)
{
	void* start = nullptr;
	void* end = nullptr;
    // 获取一批量的内存,将这批量的内存归还给中心缓存
	list.PopRange(start, end, list.MaxSize());
    // 将这一批量归还
	CentralCache::GetInstance()->ReleaseListToSpans(start, size);
}

中心缓存的释放流程

当线程缓存将自由链表中管理的内存释放给中心缓存之后,那么中心缓存又应该如何将每一块内存还回去呢?中心缓存怎么知道自己的 span 中的内存块换回来了呢?所以我们需要知道 span 和内存地址的映射,那么我们内存地址使用 PAGE_IG 来表示,所以我们可以建立一个 map 结构,里面存储的就是PAGE_ID 和 span 的映射,那么我们将这个map 放在中心缓存中吗?其实我们可以将 map 放在页缓存中,因为在中心缓存将内存还给页缓存的时候,页缓存合并 span 的时候也需要 PAGE_ID 和 span 的映射关系。所以我们将页号和span映射放在页缓存中。

那么什么时候记录分配出去的内存和 span 的映射呢?在 NewSpan 的时候,当我们将切出来的 k 页的 span 准备返回给中心缓存的时候,我们就需要将页 id 和 span 映射建立起来,所以我们需要修改前面的代码,为前面的代码添加映射关系。

Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0);
	// 先检查第 k 个桶中有没有 span
	if (!_spanLists[k].Empty())
	{
		return _spanLists[k].PopFront();
	}
	
	// 第 k 个桶里面是空的,检查后面的 span 是否为空,如果有那么就切分
	for (size_t i = k + 1; i < NPAGES; ++i)
	{
		if (!_spanLists[i].Empty())
		{
			// 获取到一个大于 k 页的 span
			Span* nSpan = _spanLists[i].PopFront();
			
			// 将nSpan 切出来 k 页给 kSpan
			Span* kSpan = new Span;
			kSpan->_pageId = nSpan->_pageId; 
			kSpan->_n = k;

			// 修改 nSpan 中的起始页号和页数
			nSpan->_pageId += k;
			nSpan->_n -= k;

			// 将 nSpan 剩余的大小挂到对应的位置
			_spanLists[nSpan->_n].PushFront(nSpan);

			// 建立 PAGE_ID 和 span 的映射 
			for (PAGE_ID i = 0; i < kSpan->_n; ++i)
			{
				_idSpanMap[i + kSpan->_pageId] = kSpan;
			}

			return kSpan; 
		}
	}

	// 这里说明没有大于 k 页的 span, 那么就需要和系统申请一个大块内存
	Span* bigSpan = new Span;
	// 按照最后一个大块内存申请
	void* ptr = SystemAlloc(NPAGES - 1);
	// 将自主转为页号
	bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
	bigSpan->_n = NPAGES - 1;

	_spanLists[bigSpan->_n].PushFront(bigSpan);

	return NewSpan(k);
}

其实这里建立的因隐射关系不止有这么一些,不过这个是为了中心缓存准备的。

那么有了映射,我们就可以在归还内存的时候,对每一个 span 中的 useCount 进行计数,当归还的时候,useCount 减为  0 的时候,那么 span 就可以还给 page Cache 了,所以我们找到 span 就是为了对 span 中的 useCount 计数。

void CentralCache::ReleaseListToSpans(void* start, size_t size)
{
    // 计算出需要归还的位置
	size_t index = SizeClass::Index(size);
    // 因为归还也是访问中心缓存,所以需要加锁
	_spanLists[index].BarrelLock();
	while (start)
	{
        //  start 的下一个位置
		void* next = NextObj(start);
        // 获取 start 内存对应的 span
		Span* span = PageCache::GetInstance()->MapObjectToSapn(start);
		// 将 start 这块内存头插到 span 中
        NextObj(start) = span->_freeList;
		span->_freeList = start;
        // 如果还了这块内存,那么就对 span 中的 useCount 进行减减操作
		span->_useCount--;
		// 说明 span 切分出去所有内存都回来了
		// 将 span 归还给 page Cache ,然后 page cache 尝试合并前后页
		if (span->_useCount == 0)
		{
			// 将这个 span 从中心缓存中移除
			_spanLists[index].Erase(span);
			span->_freeList = nullptr;
			span->_next = nullptr;
			span->_prev = 0;

			// 这里先把桶锁解除
			_spanLists[index].BarrelUnlock();

			// 加页锁,这里和申请的时候调用 NewSpan 函数一样
			PageCache::GetInstance()->PageLock();
			PageCache::GetInstance()->ReleaseSpanToPageCache(span);
			PageCache::GetInstance()->PageUnlock();

			// 这里在加桶锁 
			_spanLists[index].BarrelLock();
		}

		start = next;
	}
	_spanLists[index].BarrelUnlock();
}

页缓存合并内存

上面已经将中心缓存中的 span 还给页缓存了,页缓存要怎么样合并内存呢?

页缓存时通过 PAGE_ID 来管理内存的,那么我们要合并 span 那么我们要知道哪些 span 可以合并,首先如果是 centralCache 中的可以合并吗?不可以,因为中心缓存中的内存还正在使用,那么我们在合并内存的时候,我们怎么可以找到 span 的上一块内存,让上一块内存和 span 进行合并呢?还有我们怎么可以找到 span 的下一块内存,让下一块内存可以和 span 进行合并呢?所以我们前面在 central Cache 中使用的方法在页缓存中也需要,那么我们就需要将 span 分配给中心缓存之前,我们将未分配出去的内存,存储到 _idSpanMap 中,那么我们是也需要和分配给中心缓存的 span 一样,也是需要每一页内存都需要映射吗?并不需要,其实我们只需要找到首尾页即可。

假设现在又一个 4 页的 span 来合并,那么先进行向前合并,找到了页 id为 999 页的 span 让那后向前合并,合并后 span 的页 id 就是变成了前面的页 id,并且页数也变成了自己的页数加前面的页数。合并成功后,继续向前合并,发现前面没有了,那么就停止合并。

 那么向前合并结束后,开始向后合并,向后合并找自己的页 id 加 页号也就是 1004 找到 1004 后,让 span 的 id 不变,但是页数需要加上 nextSpan 的页数。

那么合并好之后,就可以将被合并的 span 从 spanLists 移除出去,然后将合并后的 span 插入进去。

所以我们提前还需要再修改一下 NewSpan 函数,为该函数添加 PageCache 中的映射:

Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0);
	// 先检查第 k 个桶中有没有 span
	if (!_spanLists[k].Empty())
	{
		return _spanLists[k].PopFront();
	}
	
	// 第 k 个桶里面是空的,检查后面的 span 是否为空,如果有那么就切分
	for (size_t i = k + 1; i < NPAGES; ++i)
	{
		if (!_spanLists[i].Empty())
		{
			// 获取到一个大于 k 页的 span
			Span* nSpan = _spanLists[i].PopFront();
			
			// 将nSpan 切出来 k 页给 kSpan
			Span* kSpan = new Span;
			kSpan->_pageId = nSpan->_pageId; 
			kSpan->_n = k;

			// 修改 nSpan 中的起始页号和页数
			nSpan->_pageId += k;
			nSpan->_n -= k;

			// 将 nSpan 剩余的大小挂到对应的位置
			_spanLists[nSpan->_n].PushFront(nSpan);
			// 存储 nSpan 的首尾页号和 nSpan 的映射
			_idSpanMap[nSpan->_pageId] = nSpan;
			_idSpanMap[nSpan->_pageId + nSpan->_n - 1] = nSpan;

			// 建立 PAGE_ID 和 span 的映射 
			for (PAGE_ID i = 0; i < kSpan->_n; ++i)
			{
				_idSpanMap[i + kSpan->_pageId] = kSpan;
			}
			// 将 span 是否被使用置为 true
			kSpan->_isUse = true;

			return kSpan; 
		}
	}

	// 这里说明没有大于 k 页的 span, 那么就需要和系统申请一个大块内存
	Span* bigSpan = new Span;
	// 按照最后一个大块内存申请
	void* ptr = SystemAlloc(NPAGES - 1);
	// 将自主转为页号
	bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
	bigSpan->_n = NPAGES - 1;

	_spanLists[bigSpan->_n].PushFront(bigSpan);

	return NewSpan(k);
}

 建立好映射之后,就可以开始合并 page Cache 中的内存了。

合并的流程也就是我们前面说的流程,先进行向前合并,然后进行向后合并,最后合并完成后将合并后的 span 进行插入,但是插入之后还需要将 span 再插入到映射中以供使用。

那么具体有哪些 span 可以合并?首先一定是我们已经申请到了,而我们申请到的内存都会放到 _idSpanMap 中,那么我们需要看什么才能知道内存是否被使用了呢?看 useCount 吗?并不是,因为我们想一下,在 GetOneSpan 中,从页缓存中获取到一个 span 后还没有进行切分的时候,虽然 span 返回给了中心缓存,但是并未对 useCount 进行修改,所以在这一段空白中,如果来进行合并怎么办呢?是不是就出现错误了,所以我们其实可以在 span 中添加一个 _isUse 的成员变量,来记录是否被使用,所以在分配出去的时候,我们将 _isUse 置为 true 当合并结束后,我们将 _isUse 置为 false。

除了没有申请到的内存无法合并,还有就是正在被使用的内存无法合并,那么还有没有呢?内存池的页缓存最大只能申请到 128 页 的内存,那么如果合并的页数大于 128 那么可以吗?显然是不可以的,所以当河滨的页数大于 128 的时候,那么也是不能合并的。

void PageCache::ReleaseSpanToPageCache(Span* span)
{
	// 需要对 span 中的内存碎片进行合并(外碎片)
	// 向前合并
	while (true){
        // span 的前一个地址
		PAGE_ID prevId = span->_pageId - 1;
		auto it = _idSpanMap.find(prevId);
		// 不在 SpanMap 中,所以不能合并
		if (it == _idSpanMap.end()){
			break;
		}
		Span* prevSpan = it->second;
		// 前面的页号正在被使用
		if (prevSpan->_isUse){
			break;
		}
		// 如果合并的内存大于最大的内存,那么不能合并
		if (span->_n + prevSpan->_n > NPAGES - 1){
			break;
		}
		// 到了这里就可以合并
		span->_pageId = prevSpan->_pageId;
		span->_n += prevSpan->_n;

		// 将 prevSpan 从 spanLists 移除
		_spanLists[prevSpan->_n].Erase(prevSpan);
		// 删除掉前一个 span
		delete prevSpan;
	}

	// 向后合并
	while (true){
        // span 的后一个地址
		PAGE_ID nextId = span->_pageId + span->_n;
		auto it = _idSpanMap.find(nextId);
		if (it == _idSpanMap.end()){
			break;
		}
		Span* nextSpan = it->second;
		if (nextSpan->_isUse){
			break;
		}
		if (nextSpan->_n + span->_n > NPAGES - 1){
			break;
		}

		span->_n += nextSpan->_n;
		
		_spanLists[nextSpan->_n].Erase(nextSpan);
		delete nextSpan;
	}

	// 将合并后的 span 挂起来
	_spanLists[span->_n].PushFront(span);
	// 将 span 的是否被使用置为 flase
	span->_isUse = false;
	// 将合并后的 span 的首尾页放到 _idSpanMap 中
	_idSpanMap[span->_pageId] = span;
	_idSpanMap[span->_pageId + span->_n - 1] = span;
}

这里申请和释放的流程已经走完了,后面就是对细节的一些处理!

处理大于 256k 的内存申请

线程缓存只能处理小于等于 256k 的内存申请,那么大于 256k 的内存申请怎么办呢?内存池就发挥不了作用了吗?

并不是,对于内存池而言一次申请的最大的对象可以到 128 k 只是线程缓存最大可以处理 256k 的内存。

所以如果有申请大块内存的,那么就看申请的内存大小是多少,如果是大于 256k 但是小于 128页的内存,那么就可以直接让 page cache 来处理,那么如果是大于 128页的内存,那么就直接向系统申请,当释放的时候也直接释放给系统。

大于 256k 内存申请

所以我们需要在申请内存的时候做判断,判断是否大于 256 k,如果大于 256k 那么就调用 NewSpan 函数。

static void* ConcurrentAlloc(size_t size)
{
	if (size > MAX_BYTES)
	{
        // 计算对齐数
		size_t alignSize = SizeClass::RoundUp(size);
        // 计算需要多少页的内存
		size_t kpage = alignSize >> PAGE_SHIFT;
        // 向页缓存申请内存
		PageCache::GetInstance()->PageLock();
		Span* span = PageCache::GetInstance()->NewSpan(kpage);
		PageCache::GetInstance()->PageUnlock();
        // 计算出申请内存的地址,然后返回
		void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
		return ptr;
	}
	else
	{

	}
	if (pTLSThreadCache == nullptr)
	{
		pTLSThreadCache = new ThreadCache;
	}

	return pTLSThreadCache->Allocate(size);
}

这里使用了计算对齐数,但是我们的计算对齐数的函数里面是断言不能大于 MAX_BYTES 所以我们还是需要对计算对齐数的接口进行处理,如果大于 MAX_BYTES 的内存,那么就按照 1页为单位进行对齐,然后返回对齐的对齐数,这个接口就是简单的修改,这里就不展示了。

大于 128 页内存申请

因为大于 256 k 的内存申请只要交给 NewSpan 函数还是可以完成的,但是如果是大于 128页的内存呢?这里调用 ConcurrentAlloc 函数那么该函数还是会调用 NewSpan 函数,因为 NewSpan 函数只能处理小于 128 页的内存,所以我们需要对 NewSpan 函数也进行简单的修改,所以到了 NewSpan 函数里面就需要先判断是否大于 128页,如果大于 128页,那么就需要向对申请内存,申请之后就可以直接返回吗?并不是,因为我们在释放的时候会调用 Deallocate ,但是这个函数只能处理小于 256k的内存,也就是线程缓存的释放,所以释放的时候也需要判断 size 的大小,而且大于 256k 的内存申请的时候也是直接和页缓存申请的,所以释放的时候也可以直接调用页缓存的释放,也就是 ReleaseSpanToPageCache 函数,但是这个函数里面需要 span 所以我们在向堆申请的时候还是需要记录一下申请到的内存的,但是返回的必须是一个 span 所以申请到的内存交给 span 然后将 span 和 pageId 映射起来。

Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0);
	if (k > NPAGES - 1)
	{
		void* ptr = SystemAlloc(k);
		if (ptr == nullptr)
		{
			throw std::bad_alloc();
		}

		Span* span = new Span;
		span->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
		span->_n = k;
		_idSpanMap[span->_pageId] = span;
		return span;
	}

    ...
	// 后面没有变化
}

大于 256k 内存申请的流程也已经走通了,所以看一下释放的过程。

大于 256k 内存的释放

因为申请的内存如果是小于 256k 的那么就可以直接走 Deallocate 函数就没问题,所以在释放的时候还需要判断一下释放的内存是否大于 256k ,那么如果是大于 256k 的内存是找 NewSpan 啊和拿书直接申请的,那么释放的时候也直接找 page cache 。

page cache 里面的释放需要一个 span 我们已经在申请的时候做好了映射了,所以我们可以直接取即可。

static void ConcurrentFree(void* obj, size_t size)
{
	Span* span = PageCache::GetInstance()->MapObjectToSapn(obj);
	if (size > MAX_BYTES)
	{
		PageCache::GetInstance()->PageLock();
		PageCache::GetInstance()->ReleaseSpanToPageCache(span);
		PageCache::GetInstance()->PageUnlock();
	}
	else
	{
		assert(pTLSThreadCache);

		pTLSThreadCache->Deallocate(obj, size);
	}
}

大于 128页内存释放

这里调用了 ReleaseSpanToPageCache 函数,但是 ReleaseSpanToPageCache 函数也只能处理小于 128页的内存,所以我们需要对该函数进行修改一下,判断释放的内存的大小,如果传过去的 span 里面的页数大于 128 那么说明是直接向堆申请的,所以直接向堆示范即可,那么申请的时候我们调用了 VirtualAlloc 接口,所以释放的时候我们也需要调用 VirtualFree 接口。

inline static void SystemFree(void* ptr)
{
#ifdef _WIN32
	VirtualFree(ptr, 0, MEM_RELEASE);
#else
	// sbrk unmmap等
#endif
}

有了这个函数那么就可以修改 ReleaseSpanToPageCache 函数了:

oid PageCache::ReleaseSpanToPageCache(Span* span)
{
	// 大于 128 页的 span 直接还给操作系统
	if (span->_n > NPAGES - 1)
	{
		void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
		SystemFree(ptr);
		delete span;
	}

	...
    // 后面没有变化
}

内存池优化

目前我们可以优化的有两个点:

1. 我们的内存池里面还是使用了 new ,但是我们的内存池就是为了代替 new 如果不脱离 new 那么还是不行的,所以我们这里将所有的 new 替换掉。

2. 我们释放内存的时候是需要传释放内存的大小的,但是实际上的 new 和 malloc 是不需要传大小的,所以我们也需要进行优化。

脱离 new

那么内存池想要脱离 new 如何脱离呢?前面我们在刚开始的时候说了一个定长的内存池,而在该项目中使用最多的就是 new span 所以我们可以创建一个 ObjectPool<Span> 然后使用这个类型的对象进行 New ,所以就可以脱离 new 但是如果使用了这个进行 new 的话,那么就需要使用它的 Delete 否则就是有问题的。

除了 Span 需要 New 还有其他的也需要 New 但是其他的不需要 New 的很频繁,所以可以创建静态的对象,然后进行 New,而 Span 的对象池可以放在页缓存中,因为 PageCache 中使用的 new 最多。

这部分代码的替换我们就不展示了。

优化释放不传大小

因为我们在释放的时候一定是需要大小的,那么如果不传大小就说明一定是有内存池中记录申请的大小,那么我们应该怎么做呢?其实做法有很多,其中就是使用 map 将地址和大小映射起来。

但是我们并不使用这种方法,我们知道 span 是管理内存的,在中心 cache 中 span 中将内存切好挂到自由链表上,所以在span 中的内存块大小一定是相同的,所以我们可以把内存块的大小放到 span 中,而且在释放的时候不是正好需要 span 吗?所以找到对应的 span 后然后看 size 大小,也就不需要传释放内存的大小了。

那么在那里修改 span 中的内存大小呢?在那里切就在那里改,在 GetOneSpan 函数中获取到一个 span 后,然后回进行切分,切分好后将 span 返回给线程缓存,所以在 GetOneSpan 函数中切内存的时候。

不过不能忘记当申请大块内存的时候,是没有将内存大小写入 span 的,所以也需要在申请大块内存的时候写入 span 的内存大小。

性能测试

下面会对我们自己的内存池以及 malloc 进行测试,在多线程环境下,分别进行单个桶,多个桶的测试。

测试代码:

void BenchmarkMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
	std::vector<std::thread> vthread(nworks);
	std::atomic<size_t> malloc_costtime = 0;
	std::atomic<size_t> free_costtime = 0;
	for (size_t k = 0; k < nworks; ++k)
	{
		vthread[k] = std::thread([&, k]() {
			std::vector<void*> v;
			v.reserve(ntimes);
			for (size_t j = 0; j < rounds; ++j)
			{
				size_t begin1 = clock();
				for (size_t i = 0; i < ntimes; i++)
				{
					//v.push_back(malloc(16));
					v.push_back(malloc((16 + i) % 8192 + 1));
				}
				size_t end1 = clock();
				size_t begin2 = clock();
				for (size_t i = 0; i < ntimes; i++)
				{
					free(v[i]);
				}
				size_t end2 = clock();
				v.clear();
				malloc_costtime += (end1 - begin1);
				free_costtime += (end2 - begin2);
			}
			});
	}
	for (auto& t : vthread)
	{
		t.join();
	}
	printf("%u个线程并发执行%u轮次,每轮次malloc %u次: 花费:%u ms\n",
		nworks, rounds, ntimes, malloc_costtime + 0);
	printf("%u个线程并发执行%u轮次,每轮次free %u次: 花费:%u ms\n",
		nworks, rounds, ntimes, free_costtime + 0);
	printf("%u个线程并发malloc&free %u次,总计花费:%u ms\n",
		nworks, nworks * rounds * ntimes, malloc_costtime + free_costtime);
}

// 单轮次申请释放次数 线程数 轮次
void BenchmarkConcurrentMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
	std::vector<std::thread> vthread(nworks);
	std::atomic<size_t> malloc_costtime = 0;
	std::atomic<size_t> free_costtime = 0;
	for (size_t k = 0; k < nworks; ++k)
	{
		vthread[k] = std::thread([&]() {
			std::vector<void*> v;
			v.reserve(ntimes);
			for (size_t j = 0; j < rounds; ++j)
			{
				size_t begin1 = clock();
				for (size_t i = 0; i < ntimes; i++)
				{
					//v.push_back(ConcurrentAlloc(16));
					v.push_back(ConcurrentAlloc((16 + i) % 8192 + 1));
				}
				size_t end1 = clock();
				size_t begin2 = clock();
				for (size_t i = 0; i < ntimes; i++)
				{
					ConcurrentFree(v[i]);
				}
				size_t end2 = clock();
				v.clear();
				malloc_costtime += (end1 - begin1);
				free_costtime += (end2 - begin2);
			}
			});
	}
	for (auto& t : vthread)
	{
		t.join();
	}
	printf("%u个线程并发执行%u轮次,每轮次concurrent alloc %u次: 花费:%u ms\n",
		nworks, rounds, ntimes, malloc_costtime + 0);
	printf("%u个线程并发执行%u轮次,每轮次concurrent dealloc %u次: 花费:%u ms\n",
		nworks, rounds, ntimes, free_costtime + 0);
	printf("%u个线程并发concurrent alloc&dealloc %u次,总计花费:%u ms\n",
		nworks, nworks * rounds * ntimes, malloc_costtime + free_costtime);
}


int main()
{
	size_t n = 10000;
	cout << "==========================================================" <<
		endl;
	BenchmarkConcurrentMalloc(n, 10, 10);
		// 7.使用tcmalloc源码中实现基数树进行优化
		cout << endl << endl;
	BenchmarkMalloc(n, 4, 10);
	cout << "==========================================================" <<
		endl;
	return 0;
}

这段代码可以让我们简单的看到 malloc 和 我们自己的内存池的性能。

多线程多桶申请释放测试

在多个桶,并且多线程的情况下,还是我们自己的内存池好一点,那么看一下单个桶多线程的测试。

多线程单桶申请释放测试

这里多线程测试完毕后,发现单桶的效率是比多桶的差的,为什么呢?因为当多个线程申请单个桶的时候,线程缓存中没有数据了,那么都会去找中心缓存,中心缓存本来是桶锁,也就是不申请同一个桶里面的就不会发生锁竞争问题,但是申请同一个桶,那么一定会有大量的锁竞争问题,所以自然要慢,而且在释放的时候也是需要大量的去访问 _idSpanMap 的,这个里面是加锁的,所以主要的消耗还是在释放上。

单线程就不测试了,因为单线程没有竞争问题,所以单桶的效率一定和 malloc 差不多,而多桶我们的内存池可能会快一下。

要是想要稍微准确的瓶颈分析,分析我们的内存池的性能消耗在那里了,那么我们还是需要使用特定的工具,但是因为我们的环境是 windows 的vs 下,而这个编译器自带性能测试工具,所以不需要我们自己去找,vs 的性能测试工具就在调试下的性能探查器,我的是 vs2019 不同的编译器可能叫法不同。

既然我们大概看到在多线程单桶的环境下比较慢,那么我们就测试在多线程单桶的情况下那里耗时最多。

性能瓶颈分析

性能探查器,点开后选则检测。

 然后就可以开始运行了,等运行结束后,就可以看到那个函数好事最长。

这里看到在 Deallocate 里面耗费了差不多一半的时间,所以我们看一下这个释放的函数里面是那里耗费的时间最多。

这里看到该函数里面的 ReleaseListToSpan 耗费的时间最多,几乎就是这个函数在耗时,那么我们点进去继续查看。

这个函数里面主要的耗时的函数有两个,第一就是加锁的耗时,还有一个就是判断 PAGE_ID 和 Span 映射的接口耗时多,其实判断映射里面耗时也大多是加锁的耗时。

这里看到确实也就是加锁,加锁就耗费了几乎全部的时间,所以我们知道了性能瓶颈在哪,那么我们就需要想对应的办法,那么有什么办法呢?可以让加锁的时间变少呢?或者可以不加锁吗?而且有两处加锁的耗时最长,那么两处都可以解决吗?不可以,因为 ReleaseListsToSpan 中的加锁,是不能去掉的,所以我们只能想办法去掉查看映射里面的加锁。

使用基数树进行优化

基数树在存取某些值的时候是也别快的,比如整形,而基数树存取整形和 Linux 内核的页表进行地址映射的时候的方式是差不多的,因为一次性映射不了太多,所以只能一部分一部分映射,只需要将需要的加载即可,就可以少使用大量的内存。

基数树代码

下面基数树给了三个,其中第一个就是直接映射发,第二个加了一层,也就是前面 5 个比特位映射第一层,后面的映射第二层,还有一个就是三层的映射,为什么需要这么多呢?因为如果在 64 位下只有两层是不够的,所以需要三层,但是三层并不是全部映射,而是只有将需要的映射出来。

下面我们为了方便,我们只展示第二个基数树,也就是有两层映射,因为一层映射的就是绝对映射,三层映射和两层映射差不多,而我们这个项目在 32 位下,没必要用 3 层映射,所以我们就使用 2 层映射。

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);
	}
};

下面如果我们修改的话,使用二层映射的,而且如果使用基数树是不需要进行加锁的,所以这就是基数树快的一个原因。

基数树不加锁原因

那么为什么基数树可以不加锁呢?

说基数树可以不加锁的原因之前,我们先说一下为什么红黑树需要加锁,我们想一下,当我们访问红黑树的时候,如果有正在插入的数据的时候,那么拆入节点后,红黑树是需要改变结构的,哈希表也会改变结构,如果插入的过程中扩容的话,那么也会改变结构,所以说插入和访问不能同时进行。

但是如果使用的是基数树,基数树是在创建的时候便开好了空间,所以不会再插入的时候修改结构,所以访问的时候就是没有问题的,为什么呢?我们可以想一下映射建立与访问都在什么地方,映射建立的地方有两处,第一就是在 NewSpan 的时候,当有 Span 被切分的时候,或者是大块内存被创建的时候,那么因为基数树结构不会被修改,同时每个位置都会有唯一的一个位置,还有就是在 ReleaseSpanToPage 的时候,当 span 合并完毕了,那么就会建立映射,所以建立映射的两个地方一个是刚创建出来,另一个是释放的时候,那么什么时候会访问映射呢?在删除的时候会访问映射,那么插入的时候也会访问映射,那么就不怕释放和新的 span 来临的时候有冲突吗?并不怕,因为释放和新建的时候并不可能是同一个PAGE_ID 所以并不用怕,而且插入也并不会修改基数树的结构,所以同时插入和读取是没有问题的。

最后就是将基数树把 map 替换,这块的代码就不展示了,有兴趣的可以看项目源代码。

优化后性能对比

优化后,就不会在访问映射的时候频繁加锁解锁了,而且基数树本来就是最多三层映射所以速度块,而且基数树中还不加锁,所以速度就更块了。

多线程单桶测试

现在比起之前快了特别多,同时在多线程单桶的情况下,比 malloc 也快了一半多。

多线程多桶测试

在多线程多桶的情况下,快了特别多,所以我们内存池还是有用的。

这个项目也就到这里了~

老师可能会提出以下问题: 1. 什么是内存池?为什么需要内存池? 答:内存池是一种内存管理技术,用于提高内存分配和释放的性能。它通过预先分配一定数量的内存块,并在程序运行期间重复利用这些内存块来避免频繁的内存分配和释放操作,从而提高程序的运行效率。 2. 内存池如何实现高并发? 答:内存池可以通过多线程技术来实现高并发。一般情况下,内存池会将内存块分配给不同的线程进行使用,每个线程都有自己的内存池。当多个线程同时请求内存块时,内存池可以进行加锁操作来保证线程安全。 3. 如何处理内存池中的内存碎片问题? 答:内存池中的内存碎片问题可以通过两种方式来解决。一种是使用内存池的分配算法来减少内存碎片的产生,另一种是定期对内存池进行整理和重组,以消除已有的内存碎片。 4. 如何进行内存池的扩展和收缩? 答:内存池的扩展和收缩可以通过动态调整内存池的大小来实现。当内存池中的内存块被耗尽时,可以重新分配一定数量的内存块,并将它们添加到内存池中。当内存池中的内存块处于空闲状态时,可以将它们从内存池中移除,以释放内存空间。 5. 如何测试内存池的性能? 答:测试内存池的性能可以使用一些基准测试工具,如Google Benchmark等。在测试时,可以比较内存池的分配和释放操作与系统默认的分配和释放操作之间的性能差异。同时,还可以测试内存池高并发环境下的性能表现。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Naxx Crazy

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值