从零实现一个高并发内存池

308 阅读47分钟

1. 项目介绍

1.1 这个项目做的是什么?

该项目实现一个高并发的内存池,它的原型是Google的一个开源项目tcmalloc,tcmalloc全称Thread-Caching Malloc,即线程缓存的malloc,实现了高效的多线程内存管理,用于替代系统的内存分配相关的函数(malloc、free)。

图1.1.jpg

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

tcmalloc源代码

1.2 这个项目要求的知识储备

这个项目会用到C/C++、数据结构(链表、哈希桶)、操作系统内存管理、单例模式、多线程、互斥锁等方面的知识。

2. 什么是内存池

2.1 池化技术

所谓“池化技术”,就是程序事先向系统申请过量的资源,然后自己管理,以备不时之需。之所以要申请过量的资源,是因为每次申请资源都有较大的开销,不如提前申请好了,这样使用时就会变得非常快捷,大大提高程序运行效率。 在计算机中,有很多使用“池”这种技术的地方,除了内存池,还有连接池、线程池、对象池等。以服务器上的线程池为例,它的主要思想是:先启动若干数量的线程,让他们处于睡眠状态,当接收到客户端的请求时,唤醒池中某个水面的线程,让它来处理客户端的请求,当处理完这个请求,线程又进入睡眠状态。

2.2 内存池

内存池是指程序预先从操作系统申请一块足够大内存,以后,当程序中需要申请内存的时候,不是直接向操作系统申请,而是直接从内存池中获取;同理,当程序释放内存的时候,并不真正将内存返回给操作系统,而是返回内存池。当程序退出(或者特定时间)时,内存池才将之前申请的内存真正释放。

2.3 内存池主要解决的问题

内存池主要解决的当然是效率问题,其次如果作为系统的内存分配器的角度,还需要解决一下内存碎片的问题。

  • 什么是内存碎片?

图2.3.png

内存碎片分为外碎片和内碎片,外部碎片是一些闲置的连续内存区域太小,这些内存空间不连续,以至于合计的内存足够,但是不能满足一些内存分配申请需求,内部碎片是由于一些对齐的需求,导致分配出去的空间中一些内存无法被利用。

2.4 malloc

C/C++中动态申请内存都是通过malloc去申请的,但是实际不是直接去堆获取内存。malloc就是一个内存池。

图2.4.png

malloc()相当于像操作系统“批发”了一块较大的内存空间,然后“零售”给程序用。当全部“售完”或程序有大量的内存需求时,在根据实际需求向操作系统“进货”。malloc的实现方式有很多种,一般不同编译器平台用的都是不同的。比如Windows的vs系统用的微软自己写的一套,Linux gcc用的glibc中的ptmalloc。

下面有几篇关于这块细节实现的文章:

一文了解,Linux内存管理,malloc、free 实现原理

malloc()背后的实现原理——内存池

malloc的底层实现(ptmalloc)

3.设计一个定长内存池

C/C++申请内存使用的是malloc,malloc其实就是一个通用的大众货,什么场景下都可以用,但是什么场景下都可以用就意味着什么场景下都不会有很高的性能,下面我们就先来设计一个定长内存池做个开胃菜,当然这个定长内存池在后面的高并发内存池中也是有价值的,所以学习它的目的有两层,先熟悉一下简单内存池是如何控制的,第二它会作为我们后面内存池的一个基础组件。

图3.png

3.1 Windows和Linux下如何直接向堆申请页为单位的大块内存:

VirtualAlloc

brk和mmap

  • 定长内存池向堆申请空间

这里申请空间不用malloc,而是用malloc的底层,直接向系统要内存,在Windows下,可以调用VirtualAlloc函数,在Linux下,可以调用brk或mmap函数。这里以Windows为主。

#ifdef _WIN32
	#include<windows.h>
#else
// 
#endif

// 该函数短小,可设置成内联函数提高效率
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32
	// 向堆上申请kpage块8192字节空间
	void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else
	// linux下brk mmap等
#endif

	if (ptr == nullptr)
		throw std::bad_alloc();

	return ptr;
}

3.2 定长内存池中的成员变量

对于申请的大块内存,可以利用指针进行管理,再用一个变量来记录申请的内存中剩余的内存大小。指针最好为字符指针,因为字符指针一次可以走任意的字节,很灵活。

图3.2.1.png

对于释放回来的内存,我们可以利用链表来管理,这就需要一个指向链表的指针。

图3.2.2.png

所以定长内存池中设计了三个变量

  • 指向大块空间的指针
  • 记录大块内存在切分过程中剩余的字节数的变量
  • 记录回收内存自由链表的头指针

3.3 定长内存池为用户申请空间

当我们为用户申请空间时,优先使用释放回来的内存,即自由链表。将自由链表头山一块内存返回。

图3.3.1.png

如果自由链表中没有内存块,那么我们就在大块内存中切出定长的内存块进行返回。内存块切出后,及时更新_memory指针的指向,以及_remainBytes的值。

图3.3.2.png

当大块内存不够切分出一个对艾香,调用封装的SystemAlloc函数向系统申请一大块空间,再进行切分。

注意:为了让释放的内存能够并入自由链表中,我们必须保证切分出来的对象能够存下一个地址,即申请的内存至少为4字节(32位)或8字节(64位)。

template<class T>
class ObjectPool
{
public:
	T* New()
	{
		T* obj = nullptr;

		// 优先把还回来内存块对象,再次重复利用
		if (_freeList)
		{
			// 从自由链表头删一个对象
			void* next = *((void**)_freeList);
			obj = (T*)_freeList;
			_freeList = next;
		}
		else
		{
			// 剩余内存不够一个对象大小时,则重新开大块空间
			if (_remainBytes < sizeof(T))
			{
				_remainBytes = 128 * 1024;
				_memory = (char*)SystemAlloc(_remainBytes >> 13);
				// 申请内存失败抛异常
				if (_memory == nullptr)
				{
					throw std::bad_alloc();
				}
			}
			//从大块内存中切出objSize字节的内存
			obj = (T*)_memory;
			//保证对象能够存下一个地址
			size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
			// 调整成员变量
			_memory += objSize;
			_remainBytes -= objSize;
		}

		// 定位new,显示调用T的构造函数初始化
		new(obj)T;

		return obj;
	}

private:
	char* _memory = nullptr;    // 指向大块内存的指针
	size_t _remainBytes = 0;    // 大块内存在切分过程中剩余字节数
	void* _freeList = nullptr;  // 还回来过程中链接的自由链表的头指针
};

3.4 定长内存池管理回收的内存

我们用链表管理回收的内存,为了方便使用和节省空间,我们用内存块的前4个字节(32位平台)或8个字节(64位平台)记录下一个内存块的起始地址,如下图所示:

图3.4.1.png

当回收内存块时,将内存块插入自由来年表即可:

图3.4.2.png

代码实现就是链表的头插。

void Delete(T* obj)
{
	// 显示调用析构函数清理对象
	obj->~T();

	// 头插
	*(void**)obj = _freeList;
	_freeList = obj;
}

这里还存在一个问题:如何让一个指针在32位平台下解引用后能向后访问4个字节,在64位平台下解引用能向后访问8个字节?

这里我们利用二级指针,因为二级指针存储的是一级指针的地址,而一级指针会在不同的平台下成选出不同的大小(32位平台下为4个字节,64位平台下为8个字节),二级指针解引用会向后访问一级指针的大小。这个操作在下面项目中会经常使用,建议写成函数。

static void*& NextObj(void* obj)
{
	return *(void**)obj;
}

3.5 定长内存池总体代码

template<class T>
class ObjectPool
{
public:
	T* New()
	{
		T* obj = nullptr;

		// 优先使用还回来内存块对象,再次重复利用
		if (_freeList)
		{
			// 从自由链表头删一个对象返回
			void* next = *((void**)_freeList);
			obj = (T*)_freeList;
			_freeList = next;
		}
		else
		{
			// 剩余内存不够一个对象大小时,则重新开大块空间
			if (_remainBytes < sizeof(T))
			{
				_remainBytes = 128 * 1024;
				_memory = (char*)SystemAlloc(_remainBytes >> 13);
				// 申请内存失败抛异常
				if (_memory == nullptr)
				{
					throw std::bad_alloc();
				}
			}
			//从大块内存中切出objSize字节的内存
			obj = (T*)_memory;
			//保证对象能够存下一个地址
			size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
			// 调整成员变量
			_memory += objSize;
			_remainBytes -= objSize;
		}

		// 定位new,显示调用T的构造函数初始化
		new(obj)T;

		return obj;
	}

	void Delete(T* obj)
	{
		// 显示调用析构函数清理对象
		obj->~T();

		// 头插
		*(void**)obj = _freeList;
		_freeList = obj;
	}

private:
	char* _memory = nullptr;// 指向大块内存的指针
	size_t _remainBytes = 0;// 大块内存在切分过程中剩余字节数
	void* _freeList = nullptr;// 还回来过程中链接的自由链表的头指针
};

3.6 性能测试

下面将定长内存池和malloc/free进行性能对比

先用new和delete多次申请和释放TreeNode节点,利用clock函数记录表整个过程消耗的时间。再用自己设计的定长内存吃和New和Delete多次申请和释放TreeNode节点,记录整个过程消耗的时间。对两次使用的时间进行比较。

  • 测试代码如下
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();

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

这里我们调成Release版进行测试

图3.6.png

从结果中我们可以看出,设计的定长内存池要比malloc和free快一些。但是定长内存池只适用于申请和释放固定大小的内存,而malloc和free可以申请和释放任意大小的内存。为了解决定长内存池的局限性,谷歌大佬设计了tcmalloc,下面模拟实现tcmalloc简易版本。

4. 高并发内存池整体框架设计

现代很多的开发环境都是多核多线程,在申请内存的场景下,必然存在激烈的锁竞争问题。malloc本身其实已经很优秀,那么我们项目的原型tcmalloc就是在多线程高并发的场景下更胜一筹,所以这次我们实现的内存池需要考虑以下几方面的问题。

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

concurrent memory pool主要由以下3个部分构成:

  1. thread cache:线程缓存是每个线程独有的,用于小于256kb的内存的分配,线程从这里申请内存不需要加锁,每个线程独享一个cache,这也就是这个并发线程池高效的地方。
  2. central cache:中心缓存是所有线程所共享,thread cache是按需从central cache中获取的对象。central cache合适的时机收回thread cache中的对象,避免一个线程占用了太多的内存,而其它线程的内存吃紧,达到内存分配在多个线程中更均衡的按需调度的目的。central cache是存在竞争的,所以从这里取内存对象是需要加锁,首先这里用的是桶锁,其次只有thread cache没有内存对象时才会找central cache,所以这里竞争不会很激烈。
  3. page cache:页缓存实在central cache缓存上面的一层缓存,存储的内存是以页为单位存储及分配的,central cache没有内存对象时,从page cache分配出一定数量的page,并切割成定长大小的小内存块,分配给central cache。当一个span的几个跨度页的对象都回收以后,page cache会回收central cache满足条件的span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。

图4.png

5. 高并发内存池--thread cache

thread cache 是哈希桶结构,每个桶是一个按桶位置映射大小的内存块对象的自由链表。每个线程都会有一个thread cache对象,这样每个线程在这里获取对象和释放对象时是无锁的。

图5.png

当线程要申请内存时,通过计算得到对齐后的字节数,从而找到对应的哈希桶,如果哈希桶中的自由连败哦不为空,就从自由链表中中头删一块内存返回。如果哈希桶中的自由链表为空,就需要向下一层的central cache申请内存。

thread cache 代码框架如下:

class ThreadCache
{
public:
	// 申请和释放内存对象
	void* Allocate(size_t size);
	void Deallocate(void* ptr, size_t size);

	// 从中心缓存获取对象
	void* FetchFromCentralCache(size_t index, size_t size);

	// 释放对象时,链表过长时,回收内存到中心缓存
	void ListTooLong(FreeList& list, size_t size);
private:
	// 哈希桶
	FreeList _freeLists[NFREELIST];
};

// TLS thread local storage(TLS线程本地存储)
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;

哈希桶中的自由链表是单链表结构,和上文实现的定长内存池一样,通过内存块的前4位或者8位地址链接下一内存块。

代码如下:

static void*& NextObj(void* obj)
{
	return *(void**)obj;
}

class FreeList
{
public:
	// 将释放的对象头插到自由链表
	void Push(void* obj)
	{
		assert(obj);

		//头插
		NextObj(obj) = _freeList;
		_freeList = obj;

		++_size;
	}

	// 从自由链表头部获取一个对象
	void* Pop()
	{
		assert(_freeList);

		// 头删
		void* obj = _freeList;
		_freeList = NextObj(obj);
		--_size;

		return obj;
	}

	// 将释放的n个内存块头插入自由链表
	void PushRange(void* start, void* end,size_t n)
	{
		NextObj(end) = _freeList;
		_freeList = start;

		_size += n;
	}

	// 从自由链表头部获取n个内存块
	void PopRange(void*& start, void*& end, size_t n)
	{
		assert(n >= _size);
		start = _freeList;
		end = start;

		// 确定获取内存块链表结尾
		for (size_t i = 0; i < n - 1; i++)
		{
			end = NextObj(end);
		}

		_freeList = NextObj(end);
		NextObj(end) = nullptr;
		// 更新内存块剩余数量
		_size -= n;
	}

	// 判断自由链表是否为空
	bool Empty()
	{
		return _freeList == nullptr;
	}

	// 记录当前一次申请内存块的数量
	size_t& MaxSize()
	{
		return _maxSize;
	}

	// 自由链表中内存块的数量
	size_t Size()
	{
		return _size;
	}
private:
	void* _freeList=nullptr;// 指向自由链表的指针
	size_t _maxSize = 1;// 一次申请内存块的数量
	size_t _size = 0;// 记录自由链表中内存块数量
};

5.1 thread cache哈希桶映射对齐规则

  • 对象大小的对齐映射规则

对象大小的对齐映射并不是均匀的,而是成倍增长的。对象大小的对齐映射固定不变的话,如果映射值较小,就会创建大量的哈希桶,例如256kb如果按照8byte划分,则会创建32768个哈希桶。如果映射值较大,又会造成大量的空间浪费,产生内碎片问题。

为了减少空间浪费率和创建哈希桶的内存开销,我们设计了如下映射关系:

图5.1.png

5.2 空间浪费率

空间浪费率为浪费的字节数除以对齐后的字节数,以129~1024这个区间为例,该区域的对齐数是16,那么最大浪费的字节数就是15,而最小对齐后的字节数就是这个区间内的前16 个数所对齐的字节数,也就是144,那么该区间的最大浪费率就是15/(128+16)≈10.42%

内碎片占用率 = (浪费的空间 / 总空间大小) * 100%

- 对于 [128+1, 1024] 这个范围,内碎片占用率最大是 15/(128+16) ≈ 11%
- 对于 [1024+1, 8*1024] 这个范围,内碎片占用率最大是 127/(1024+128) ≈ 11%
- 对于 [81024+1, 641024] 这个范围,内碎片占用率最大是 1023/(8*1024+1024) ≈ 11%
- 对于 [641024+1, 2561024 ]这个范围,内碎片占用率最大是 (8 * 1024-1)/(64 * 1024+8*1024) ≈ 11%
  • 计算对象大小的对齐映射数

计算对象大小的对齐映射数时,我们可以先判断该字节属于哪个区间,再调用子函数完成映射

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

	return alignSize;
}

//获取向上对齐后的字节数
static inline size_t RoundUp(size_t size)
{
	if (size <= 128)
	{
		return _RoundUp(size, 8);
	}
	else if (size <= 1024)
	{
		return _RoundUp(size, 16);
	}
	else if (size <= 8 * 1024)
	{
		return _RoundUp(size, 128);
	}
	else if (size <= 64 * 1024)
	{
		return _RoundUp(size, 1024);
	}
	else if (size <= 256 * 1024)
	{
		return _RoundUp(size, 8 * 1024);
	}
	else
	{
		assert(false);
		return -1;
	}
}

子函数也可以利用位运算,位运算的速度是比乘法和除法更快的,但是这种方法不易想到

static inline size_t _RoundUp(size_t bytes, size_t alignNum)
{
	return ((bytes + alignNum - 1)&~(alignNum - 1));
}
  • 计算内存映射的哈希桶

获取字节对应的哈希桶下标时,也是先判断他在哪个区间,在调用子函数去找。

size_t _Index(size_t bytes, size_t alignNum)
{
	alignNum=1<<alignNum;
	if (bytes%alignNum == 0)
	{
		return bytes / alignNum - 1;
	}
	else
	{
		return bytes / alignNum;
	}
}
	
// 计算映射的哪一个自由链表桶
static inline size_t Index(size_t bytes)
{
	assert(bytes <= 13);

	// 每个区间有多少个链
	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;
}

5.3 thread cache申请内存

5.3.1 thread cache申请内存

  1. 当内存申请size <= 256kb,先获取到线程本地缓存的thread cache对象,计算size映射的哈希桶自由链表下标i。
  2. 如果自由链表_freeLists[i]中有对象,则直接Pop一个内存对象返回。
  3. 如果_freeLists[i]中没有对象时,则批量从central cache中获取一定数量的对象,擦汗儒道自由链表并返回一个对象。
void* ThreadCache::Allocate(size_t size)
{
	assert(size <= MAX_BYTES);
	// 计算对齐映射字节数
	size_t alignSize = SizeClass::RoundUp(size);
	// 计算映射的哈希桶下标
	size_t index = SizeClass::Index(size);

	if (!_freeLists[index].Empty())
	{
		// 从自由链表中头删一块返回
		return _freeLists[index].Pop();
	}
	else
	{
		// 向CentralCache层申请空间
		return FetchFromCentralCache(index, alignSize)
	}
}

5.3.2 thread cache向central cache获取内存

这里会用到慢开始反馈调节算法:

开始不会一次向central cache一次批量要太多,因为要太多了可能用不完,如果不断申请这个size大小的内存,那么batchNum就会不断增长,直到上限。

static const size_t MAX_BYTES = 256 * 1024;// 一次可申请的最大字节数,我们把它用宏定义表示。

// 一次thread cache从中心缓存获取多少个内存块
static size_t NumMoveSize(size_t size)
{
	assert(size > 0);

	// [2, 512],一次批量移动多少个对象的(慢启动)上限值
	// 小对象一次批量上限高
	// 大对象一次批量上限低
	int num = MAX_BYTES / size;
	if (num < 2)
		num = 2;

	if (num > 512)
		num = 512;

	return num;
}

// thread cache向central cache获取内存。
void* ThreadCache::FetchFromCentralCache(size_t index, size_t size)
{
	size_t batchNum = min(_freeLists[index].MaxSize(), SizeClass::NumMoveSize(size));

	// 慢开始算法
	if (_freeLists[index].MaxSize() == batchNum)
	{
		_freeLists[index].MaxSize() += 1;
	}

	void* start = nullptr;
	void* end = nullptr;
	// 向CentralCache申请一段内存
	size_t actualNum = CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, size);	
	assert(actualNum > 0);

	if (actualNum == 1)
	{
		assert(start == end);
		return start;
	}
	else
	{
		// 将申请的一段内存头插入对应的自由链表
		_freeLists[index].PushRange(NextObj(start), end, actualNum - 1);
		return start;
	}
}

5.3.3 thread cache TLS无锁访问

要实现每个线程无锁的访问属于自己的thread cache,我们需要用到线程局部存储TLS(Thread Local Storage),这是一种变量的存储方法,使用该存储方法的变量在它所在的线程是全局可访问的,但是不能被其它线程访问到,这样就保持了数据的线程独立性。

Thread Local Storage(线程局部存储)TLS

//TLS - Thread Local Storage
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;

但不是每个线程被创建时就立马有了属于自己的thread cache,而是当该线程调用相关申请内存的接口时才会创建自己的thread cache,因此在申请内存的函数中会包含以下逻辑:

//通过TLS,每个线程无锁的获取自己专属的ThreadCache对象
if (pTLSThreadCache == nullptr)
{
	pTLSThreadCache = new ThreadCache;
}

6. 高并发内存池--centrl cache

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

图6.png

6.1 Span

Span是一个结构体,该结构体管理以页为单位的大块内存,Span和Span间用双向链表连接起来。Span的内部有自由链表,该自由链表是根据哈希桶映射大小切分好的内存块。Span的内部还记录了内存块的使用等信息,具体结构如下:

// 在64位下PAGE_ID 8字节,在32位下PAGE_ID 4字节
#ifdef _WIN64
	typedef unsigned long long PAGE_ID;
#elif _WIN32
	typedef size_t PAGE_ID;
#else
// linux
#endif

// 管理多个连续页大块内存跨度结构
struct Span
{
	PAGE_ID _pageId;// 大块内存起始页的页号
	size_t _n = 0;// 页的数量

	Span* _next = nullptr;// 双向链表的结构
	Span* _prev = nullptr;

	size_t _objSize = 0;  // 切好的小对象的大小
	size_t _useCount = 0;// 切好小块内存,被分配给thread cache的计数
	void* _freeList = nullptr;// 切好的小块内存的自由链表

	bool _isUse=false;// 该页是否被使用
};

6.2 SpanList

不同于thread cache哈希桶上的自由链表FreeList,central cache的哈希桶上的自由链表为SpanList,链接Span的双链表

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

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

	Span* end()
	{
		return _head;
	}

	// 判空
	bool Empty()
	{
		return _head->_next == _head;
	}

	// 插入新页
	void Insert(Span* pos, Span* newSpan)
	{
		assert(pos);
		assert(newSpan);

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

	// 头插
	void PushFront(Span* span)
	{
		Insert(Begin(), span);
	}

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

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

		next->_prev = prev;
		prev->_next = next;
	}

	// 头删
	Span* PopFront()
	{
		Span* front = _head->_next;
		Erase(front);
		return front;
	}
private:
	Span* _head;
public:
	std::mutex _mtx;// 桶锁
};

6.3 central cache的框架

central cache要满足thread cache申请的内存。当central cache中没有Span时需要向下一层PageCache申请。当有内存还回来时,要能够把它连接到哈希桶对应的SpanList自由链表上方便下次使用。

static const size_t NFREELIST = 208;// central cache中有208个哈希桶

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

	// 获取一个非空的span
	Span* GetOneSpan(SpanList& list, size_t byte_size);

	// 从中心缓存获取一定数量的对象给thread cache
	size_t FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size);

	// 将一定数量的对象释放到span跨度
	void ReleaseListToSpans(void* start, size_t byte_size);
private:
	SpanList _spanLists[NFREELIST];

private:
	CentralCache()
	{}

	CentralCache(const CentralCache&) = delete;

	static CentralCache _sInst;
};

为了让每次访问到的是同一个CentralCache我们把它设置成单例模式。

6.4 central cache申请内存

  1. 当thread cache中没有内存时,就会批量向central cache申请一些内存对象,这里的批量获取对象的数量使用了类似网络tcp协议拥塞维护控制的慢开始算法;central cache也有一个哈希映射的SpanList,SpanList中悬挂着Span,从Span中取出对象给thread cache,这个过程是需要加锁的,不过这里使用的是一个桶锁,尽可能提高效率。
  2. central cache映射的SpanList中所有的Span都没有内存以后,则需要向page cache申请一个新的Span对象,拿到Span以后将Span管理的内存按大小切好作为自由链表连接到一起。然后从Span中取对象给thread cache。
  3. central cache中挂的Span中_useCount记录分配了多少个对象出去,分配一个对象给thread cache,就++_useCount。

6.4.1 central cache中从中心缓存获取对象给thread cache

从central cache获取n个指定大小的对象,找到对应的哈希桶,将自由链表SpanList头删n个内存块。这里需要加桶锁,防止多个线程在central cache的自由链表中访问同一内存块,产生线程竞争问题。

// 从中心缓存获取一定数量的对象给thread cache
size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size)
{
	size_t index = SizeClass::Index(size);
	_spanLists[index]._mtx.lock();// 桶锁

	//在对应哈希桶中获取一个非空的span
	Span* span = GetOneSpan(_spanLists[index], size);
	// 获得的页和页中的自由链表不能为空
	assert(span);
	assert(span->_freeList);

	// 从span中获取batchNum个对象
	// 如果不够batchNum个,有多少拿多少
	start = span->_freeList;
	end = start;
	size_t i = 0;
	size_t actualNum = 1;
	// 截取n个内存块
	while (i < batchNum - 1 && NextObj(end) != nullptr)
	{
		end = NextObj(end);
		++i;
		++actualNum;
	}
	span->_freeList = NextObj(end);//取完后剩下的对象继续放到自由链表
	NextObj(end) = nullptr;//取出的一段链表的表尾置空
	span->_useCount += actualNum;//更新被分配给thread cache的计数

	_spanLists[index]._mtx.unlock();// 解锁

	return actualNum;
}

6.4.2 central cache获取页

// 计算一次向系统获取几个页
static size_t NumMovePage(size_t size)
{
	size_t num = NumMoveSize(size);
	size_t npage = num*size;

	npage >>= PAGE_SHIFT;
	if (npage == 0)
		npage = 1;

	return npage;
}

// 获取一个非空的span
Span* CentralCache::GetOneSpan(SpanList& list, size_t size)
{
	// 查看当前的spanlist中是否有还有未分配对象的span
	Span* it = list.Begin();
	while (it != list.end())
	{
		if (it->_freeList)
		{
			return it;
		}
		else
		{
			it = it->_next;
		}
	}

	// 先把central cache的桶锁解掉,这样如果其他线程释放内存对象回来,不会阻塞
	list._mtx.unlock();

	// 走到这里说没有空闲span了,只能找page cache要
	PageCache::GetInstance()->_pageMtx.lock();
	Span* span=PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(size));
	span->_isUse = true;
	span->_objSize = size;
	PageCache::GetInstance()->_pageMtx.unlock();

	// 对获取span进行切分,不需要加锁,因为这会其他线程访问不到这个span

	// 计算span的大块内存的起始地址和大块内存的大小(字节数)
	char* start = (char*)(span->_pageId << PAGE_SHIFT);
	size_t bytes = span->_n << PAGE_SHIFT;
	char* end = start + bytes;

	// 把大块内存切成自由链表链接起来
	// 先切一块下来去做头,方便尾插
	span->_freeList = start;
	start += size;
	void* tail = span->_freeList;
	//尾插
	while (start < end)
	{
		NextObj(tail) = start;
		tail = start;
		start += size;
	}

	NextObj(tail) = nullptr;

	// 切好span以后,需要把span挂到桶里面去的时候,再加锁
	list._mtx.lock();
	list.PushFront(span);

	return span;
}

6.5 central cache释放内存

当thread_cache过长或者线程销毁,则会将内存释放回central cache中,释放回来时--_useCount。当_useCount减到0时则表示所有对象都回到了Span,则将Span释放回page cache,page cache中会对前后相邻的空闲页进行合并。

void CentralCache::ReleaseListToSpans(void* start, size_t size)
{
	size_t index = SizeClass::Index(size);
	_spanLists[index]._mtx.lock();
	while (start)
	{
		void* next = NextObj(start);

		Span* span = PageCache::GetInstance()->MapObjectToSpan(start);
		NextObj(start) = span->_freeList;
		span->_freeList = start;
		span->_useCount--;

		// 说明span的切分出去的所有小块内存都回来了
		// 这个span就可以再回收给page cache,pagecache可以再尝试去做前后页的合并
		if (span->_useCount == 0)
		{
			_spanLists[index].Erase(span);
			span->_freeList = nullptr;
			span->_next = nullptr;
			span->_prev = nullptr;

			// 释放span给page cache时,使用page cache的锁就可以了
			// 这时把桶锁解掉
			_spanLists[index]._mtx.unlock();

			PageCache::GetInstance()->_pageMtx.lock();
			PageCache::GetInstance()->ReleaseSpanToPageCache(span);
			PageCache::GetInstance()->_pageMtx.unlock();

			_spanLists[index]._mtx.lock();
		}

		start = next;
	}

	_spanLists[index]._mtx.unlock();
}

7. 高并发内存池--page chche

7.1 page cache整体框架

central cache和page cache的核心结构都是spanlist的哈希桶,但是它们是有本质区别的,central cache中哈希桶,是跟thread cache一样大小对齐关系映射的,它的spanlist中挂的span中的内存都被按映射关系切好链接成小块内存的自由链表。而page cache中的spanlist则是按下标桶号映射的,也就是说第i号桶中挂的span都是i页内存。

图7.1.png

static const size_t NPAGES = 129;// page cache中一共有128个哈希桶

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

	// 获取从对象到span的映射
	Span* MapObjectToSpan(void* obj);

	// 释放空闲span回到Pagecache,并合并相邻的span
	void ReleaseSpanToPageCache(Span* span);

	// 获取一个K页的span
	Span* NewSpan(size_t k);
	std::mutex _pageMtx;// page cache大锁
private:
	SpanList _spanLists[NPAGES];
	ObjectPool<Span> _spanPool;
	// 建立页号和地址间的映射
	std::unordered_map<PAGE_ID, Span*> _idSpanMap;

	PageCache()
	{}

	PageCache(const PageCache&) = delete;

	static PageCache _sInst;
};

7.2 page cache 申请内存

  1. 当central cache向page cache申请内存时,page cache先检查对应位置有没有Span,如果没有则 向更大页寻找一个Span,如果找到则分裂成两个。 比如:申请的时4页page,4页page后面没有挂Span,则向后面寻找更大的Span,假设在10页page位置找到一个Span,则将10页page Span分裂为一个4页page Span和一个6页page Span。
  2. 如果找到_SpanList[128]都没有合适的Span,则向系统使用mmap、brk或者VirtualAlloc等方式申请128页page Span挂在自由链表中,再重复1中的过程。

7.2.1 建立页号和span的映射

页号和地址有关,而计算机在不同位下地址长度不同(32位下4字节,64位下8字节),如何确定页号的大小呢?

我们可以利用如下程序。因为_WIN64包含了32位和64位,而_WIN32只包含了32位,所以我们可以先判断当前是否为64位,再判断其是否为32位。

#ifdef _WIN64
	typedef unsigned long long PAGE_ID;
#elif _WIN32
	typedef size_t PAGE_ID;
#else
// linux
#endif

这里我们先建立页号跟span的映射关系,方便释放内存对象回来查找对应位置

static const size_t PAGE_SHIFT = 13;

Span* PageCache::MapObjectToSpan(void* obj)
{
	PAGE_ID id = ((PAGE_ID)obj >> PAGE_SHIFT);//右移13位,找到对应的id

	std::unique_lock<std::mutex> lock(_pageMtx);  //加锁,RAII,出了作用域,自己解锁

	auto ret = _idSpanMap.find(id);//查找对应的span

	if (ret != _idSpanMap.end())
	{
		return ret->second;
	}
	else
	{
		assert(false);
		return nullptr;
	}
}

7.2.2 获取一个K页的span

// 获取一个K页的span
Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0 );
	
	// 如果申请的页大于128,直接去堆上申请
	if (k > NPAGES - 1)
	{
		void* ptr = SystemAlloc(k);

		Span* span = _spanPool.New();
		span->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
		span->_n = k;
		// 建立页号和地址的映射
		_idSpanMap[span->_pageId] = span;

		return span;
	}

	// 先检查第k个桶里面有没有span
	if (!_spanLists[k].Empty())
	{
		// 第k个桶里面有span直接头切一个块
		Span* kSpan = _spanLists[k].PopFront();

		// 建立id和span的映射,方便central cache回收小块内存时,查找对应的span
		for (PAGE_ID i = 0; i < kSpan->_n; ++i)
		{
			_idSpanMap[kSpan->_pageId + i] = kSpan;
		}

		return kSpan;
	}

	// 检查一下后面的桶里面有没有span,如果有可以把他它进行切分
	for (size_t i = k + 1; i < NPAGES; i++)
	{
		if (!_spanLists[i].Empty())
		{
			Span* nSpan = _spanLists[i].PopFront();
			Span* kSpan = _spanPool.New();

			// 在nSpan的头部切一个k页下来
			// k页span返回
			kSpan->_pageId = nSpan->_pageId;
			kSpan->_n = k;

			nSpan->_pageId += k;
			nSpan->_n -= k;
			// nSpan再挂到对应映射的位置
			_spanLists[nSpan->_n].PushFront(nSpan);
			
			// 存储nSpan的首位页号跟nSpan映射,方便page cache回收内存时进行的合并查找
			_idSpanMap[nSpan->_pageId] = nSpan;
			_idSpanMap[nSpan->_pageId + nSpan->_n - 1] = nSpan;

			// 建立id和span的映射,方便central cache回收小块内存时,查找对应的span
			for (PAGE_ID i = 0; i < kSpan->_n; ++i)
			{
				_idSpanMap[kSpan->_pageId + i] = kSpan;
			}

			return kSpan;
		}
	}

	// 走到这个位置就说明后面没有大页的span了
	// 这时就去找堆要一个128页的span
	Span* bigSpan = _spanPool.New();
	void* ptr = SystemAlloc(NPAGES - 1);
	bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
	bigSpan->_n = NPAGES - 1;

	_spanLists[bigSpan->_n].PushFront(bigSpan);
	
	// 调用自己,下次将128页进行拆分
	return NewSpan(k);
}

8. 申请内存流程

申请一部分内存,通过调试查看申请流程

图8.1.png

当线程第一次申请内存时,该线程需要通过TLS获取到自己专属的thread cache对象,然后通过这个thread cache对象进行内存申请。

图8.2.png

当我们申请6个字节内存时,6字节会被映射成8字节,对应的哈希桶下标为0。我们取thread cache的哈希桶申请内存时,如果该哈希桶中自由链表为空,就要向下一层central cache申请内存块。

图8.3.png

通过NumMoveSize函数计算得出,thread cache一次向central cache申请8字节大小对象的个数是512,申请的内存块太多了,我们使用慢开始算法。取出自由链表的_maxSize的值和NumMoveSize函数计算得出值的较小值。_maxSize的初始值为1,所以向central cache申请1个8字节内存块。完成后再将_maxSize的值加一,让下一次申请更多的内存块。

图8.4.png

向central cache申请内存时需要给桶加锁,避免在自由链表中产生竞争。然后再从该桶获取一个非空的span。

图8.5.png

遍历对应哈希桶的span双链表,如果存在不为空的span就将该span返回,否则就向下一层page cache申请。注意这里需要将先前加载central cache上的锁释放掉,而且向page cache申请内存需要加一个大锁。

图8.6.png

通过计算求得申请的页数为1页

图8.7.png

此时page cache没有span,它要去堆上申请128页的span,再将128页的span拆分成1页和127页的span,返回1页的span给central cache,把127页的span挂到page cache的第127号桶上。

图8.8.png

从page cache返回后,将获取到的1页span切分成一个个8字节的内存块挂到central cache的0号哈希桶上。

图8.9.png

central cache再分配给thread cache所需求的内存块

图8.10.png

此时thread cache已经申请到了内存块,完成了一次内存申请。

图8.11.png

当线程第二次申请内存块时就不会再创建thread cache了,因为第一次申请时就已经创建好了,此时该线程直接获取到对应的thread cache进行内存块申请即可。

第二次申请8字节大小的对象时,此时thread cache的0号桶中还是没有对象的,因为第一次thread cache只向central cache申请了一个8字节对象,因此这次申请时还需要再向central cache申请对象。

图8.12.png

因为慢增长算法,这一次thread cache会向central cache申请2个8字节大小的内存块。

图8.13.png

因为第一次central cache向page cache申请了一页的内存块,并将其切成了1024个8字节大小的内存块,所以此次thread cache会向central cache申请2个8字节大小的内存块时,central cache可以从0号哈希桶中直接返回,而不用再向page cache申请内存了。

注意:这里申请了2个8字节内存块,但只使用了一个,我们需要将剩下的一个内存块挂入哈希桶中。

图8.14.png

第三次申请8字节内存时,直接向thread cache获取第二次申请剩下的内存块即可。

图8.15.png

9. 内存释放

9.1 thread cache 释放内存

  1. 当释放内存小于256k时将内存释放回thread cache,计算size映射自由链表桶位置i,将对象Push到对应的_freeLists[i]。
  2. 当链表的长度过长,则回收一部分内存对象到central cache。
void ThreadCache::Deallocate(void* ptr, size_t size)
{
	assert(ptr);
	assert(size <= MAX_BYTES);

	// 找对映射的自由链表桶,对象插入进入
	size_t index = SizeClass::Index(size);
	_freeLists[index].Push(ptr);

	// 当链表长度大于一次批量申请的内存时就开始还一段list给central cache
	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);
}

9.2 central cache回收内存

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

// 将一定数量的对象释放到span跨度
void CentralCache::ReleaseListToSpans(void* start, size_t size)
{
	size_t index = SizeClass::Index(size);
	_spanLists[index]._mtx.lock();
	while (start)
	{
		void* next = NextObj(start);
		// 通过映射找到对应的span
		Span* span = PageCache::GetInstance()->MapObjectToSpan(start);
		// 内存块的链表头插入span结构的自由链表中
		NextObj(start) = span->_freeList;
		span->_freeList = start;
		span->_useCount--;// 更新分配给thread cache的计数

		// 说明span的切分出去的所有小块内存都回来了
		// 这个span就可以再回收给page cache,pagecache可以再尝试去做前后页的合并
		if (span->_useCount == 0)
		{
			_spanLists[index].Erase(span);
			span->_freeList = nullptr;
			span->_next = nullptr;
			span->_prev = nullptr;

			_spanLists[index]._mtx.unlock();

			// 释放span给page cache时,使用page cache的锁就可以了
			// 这时把桶锁解掉
			PageCache::GetInstance()->_pageMtx.lock();
			PageCache::GetInstance()->ReleaseSpanToPageCache(span);
			PageCache::GetInstance()->_pageMtx.unlock();

			_spanLists[index]._mtx.lock();
		}

		start = next;
	}

	_spanLists[index]._mtx.unlock();
}

9.3 page cache回收内存

如果central cache释放回来一个span,则依次寻找span前后page id,看有没有未在使用的空闲span,如果有,将其合并。这样就可以将切小的内存合并收缩成大的span,减少内存碎片。

void PageCache::ReleaseSpanToPageCache(Span* span)
{
	if (span->_n > NPAGES - 1)
	{
		void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
		SystemFree(ptr);
		_spanPool.Delete(span);

		return;
	}

	// 对span前后的页,尝试进行合并,缓解内存碎片问题
	while (1)
	{
		// 与span链表相连的,上一个span的页号
		PAGE_ID prevId = span->_pageId - 1;
		auto ret = _idSpanMap.find(prevId);
		// 前面的页号没有,不合并
		if (ret == _idSpanMap.end())
		{
			break;
		}

		Span* prevSpan = ret->second;
		// 前面相邻页的span在使用,不合并
		if (prevSpan->_isUse == true)
		{
			break;
		}

		// 合并出超过128页的span没办法管理,不合并
		if (prevSpan->_n + span->_n > NPAGES - 1)
		{
			break;
		}

		span->_pageId = prevSpan->_pageId;
		span->_n += prevSpan->_n;

		_spanLists[prevSpan->_n].Erase(prevSpan);
		_spanPool.Delete(prevSpan);
	}

	// 向后合并
	while (1)
	{
		// 与span链表相连的,下一个span的页号
		PAGE_ID nextId = span->_pageId + span->_n;
		auto ret = _idSpanMap.find(nextId);
		// 后面的页号没有,不合并
		if (ret == _idSpanMap.end())
		{
			break;
		}

		// 后面相邻页的span在使用,不合并
		Span* nextSpan = ret->second;
		if (nextSpan->_isUse == true)
		{
			break;
		}

		// 合并出超过128页的span没办法管理,不合并
		if (nextSpan->_n + span->_n > NPAGES - 1)
		{
			break;
		}

		span->_n += nextSpan->_n;

		_spanLists[nextSpan->_n].Erase(nextSpan);
		_spanPool.Delete(nextSpan);
	}
	// 将和并后的span插入到page cache对应的哈希桶中
	_spanLists[span->_n].PushFront(span);
	span->_isUse = false;
	_idSpanMap[span->_pageId] = span;
	_idSpanMap[span->_pageId + span->_n - 1] = span;
}

10. 内存释放流程

我们向外提供一个ConcurrentFree函数,用于释放内存块,释放内存块时每个线程通过自己的thread cache对象,调用thread cache中释放内存对象的接口即可。

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

	pTLSThreadCache->Deallocate(ptr, size);
}

现在我们释放先前申请的三个内存块,通过调试,看看流程是什么样的。

void TestConcurrentAlloc1()
{
	void* p1 = ConcurrentAlloc(6);
	void* p2 = ConcurrentAlloc(8);
	void* p3 = ConcurrentAlloc(1);

	ConcurrentFree(p1, 6);
	ConcurrentFree(p2, 8);
	ConcurrentFree(p3, 1);
}

我们释放第一个对象后,对应的自由链表中的内存块只有一个,并不会将该自由链表当中的对象进一步还给central cache。

图10.1.png

释放第二个对象和第一个对象情况类似,并不满足将内存块还给central cache的条件。

图10.2.png

释放第三个对象时,就需要将内存块还给central cache了。

图10.3.png

将内存块还给central cache首先要将内存块的自由链表切分出来。

图10.4.png

通过映射找到内存块对应的span,再将内存块链表依次头插入span结构的自由链表中。当span的切分出去的所有小块内存都还回来时,这个span就可以再回收给page cache。

图10.5.png

因为申请内存时将128页span分给了1页和127页span,所以central cache释放合成的1页span应该向后合并。这里重新将1页span和127页span合并成128页span,并将这128页的span插入到page cache对应的哈希桶(128号桶)中。之后再建立该span与其首尾页页的映射,完成释放流程。

图10.6.png

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

tcmalloc是要在高并发场景下替代malloc进行内存申请的,因此tcmalloc在实现的时候,其内部是不能调用malloc函数的,我们当前的代码中存在通过new获取到的内存,而new在底层实际上就是封装了malloc。

我们当前的代码中用到的new的地方就是page cache层向内存申请Span结构,我们可以利用一开始实现的定长内存池,来申请Span结构。为此需要在page cache类中添加如下变量成员:

//单例模式
class PageCache
{
public:
	//...
private:
	ObjectPool<Span> _spanPool;
};

然后将代码中使用new的地方替换为调用定长内存池当中的New函数,将代码中使用delete的地方替换为调用定长内存池当中的Delete函数。

//申请span对象
Span* span = _spanPool.New();
//释放span对象
_spanPool.Delete(span);

此外,每个线程第一次申请内存时都会创建其专属的thread cache,而这个thread cache目前也是new出来的,我们也需要对其进行替换,其次如果申请的内存大于256kb,可以交付page cache处理,就不需要创建?ThreadCache了。

// 原版
static void* ConcurrentAlloc(size_t size)
{
	// 通过TLS 每个线程无锁的获取自己的专属的ThreadCache对象
	if (pTLSThreadCache == nullptr)
	{
		pTLSThreadCache = new ThreadCache;
	}

	return pTLSThreadCache->Allocate(size);
}

// 优化后的版本
static void* ConcurrentAlloc(size_t size)
{
	// 如果申请的内存大于256kb,直接向page cache要
	if (size > MAX_BYTES)
	{
		size_t alignSize = SizeClass::RoundUp(size);
		size_t kpage = alignSize >> PAGE_SHIFT;

		PageCache::GetInstance()->_pageMtx.lock();	
		Span* span = PageCache::GetInstance()->NewSpan(kpage);
		span->_objSize = size;
		PageCache::GetInstance()->_pageMtx.unlock();

		void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
		return ptr;
	}
	else
	{
		// 通过TLS 每个线程无锁的获取自己的专属的ThreadCache对象
		if (pTLSThreadCache == nullptr)
		{
			//pTLSThreadCache = new ThreadCache;
			static ObjectPool<ThreadCache> tcPool;
			pTLSThreadCache = tcPool.New();
		}

		return pTLSThreadCache->Allocate(size);
	}
}

SpanList的构造函数中也用了new,因为SpanList是带头循环双向链表,所以在构造期间我们需要申请一个span对象作为双链表的头节点。

//带头双向循环链表
class SpanList
{
public:
	SpanList()
	{
		_head = _spanPool.New();
		_head->_next = _head;
		_head->_prev = _head;
	}
private:
	Span* _head;
	static ObjectPool<Span> _spanPool;
};

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

malloc在释放对象时只需要传入对象的指针即可,但是我们当前实现的高并发内存池还需要传入释放对象的大小,如何优化?

当我们释放对象时,通过映射可以直接从对象的span中获取到该对象的大小,准确来说获取到的是对象对齐后的大小。那我们可以将传入的地址映射到对应的span,通过span的成员变量获取释放对象的大小。

// 原版
static void ConcurrentFree(void* ptr, size_t size)
{
	assert(pTLSThreadCache);

	pTLSThreadCache->Deallocate(ptr, size);
}

// 优化后的版本
static void ConcurrentFree(void* ptr)
{
	// 通过地址映射到span
	Span* span = PageCache::GetInstance()->MapObjectToSpan(ptr);
	// 获取释放内存的大小
	size_t size = span->_objSize;

	if (size > MAX_BYTES)// 大于256KB的对象,直接交付page cache判断
	{
		PageCache::GetInstance()->_pageMtx.lock();
		PageCache::GetInstance()->ReleaseSpanToPageCache(span);
		PageCache::GetInstance()->_pageMtx.unlock();
	}
	else
	{
		assert(pTLSThreadCache);
		pTLSThreadCache->Deallocate(ptr, size);
	}
}

13. 多线程并发环境下,对比malloc和ConcurrentAlloc申请和释放内存效率对比

// ntimes 一轮申请和释放内存的次数
// rounds 轮次
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, (unsigned int)malloc_costtime);

	printf("%u个线程并发执行%u轮次,每轮次free %u次: 花费:%u ms\n",
		nworks, rounds, ntimes, (unsigned int)free_costtime);

	printf("%u个线程并发malloc&free %u次,总计花费:%u ms\n",
		nworks, nworks * rounds * ntimes, (unsigned int)(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, (unsigned int)malloc_costtime);

	printf("%u个线程并发执行%u轮次,每轮次concurrent dealloc %u次: 花费:%u ms\n",
		nworks, rounds, ntimes, (unsigned int)free_costtime);

	printf("%u个线程并发concurrent alloc&dealloc %u次,总计花费:%u ms\n",
		nworks, nworks * rounds * ntimes, (unsigned int)(malloc_costtime + free_costtime));
}

int main()
{
	size_t n = 10000;
	cout << "==========================================================" << endl;
	BenchmarkConcurrentMalloc(n, 4, 10);
	cout << endl << endl;

	BenchmarkMalloc(n, 4, 10);
	cout << "==========================================================" << endl;

	return 0;
}

13.1 函数的参数含义如下:

  1. ntimes:每轮次申请和释放内存的次数。
  2. nworks:创建的线程数。
  3. rounds:申请和释放轮数。

在测试函数中,我们记录每轮次申请和释放所花费的时间,然后将其累加到malloc_costtime和free_consttime上。最后我们获得数据:nwork个线程跑rounds轮,每次申请和释放ntimes次,这个过程申请所消耗的时间、释放所消耗的时间、申请和释放总共消耗的时间。

注意:为了保证线程安全,我们在定义变量时用了atomic类模板,保证操作时原子性的。

13.2 固定大小内存的申请和释放

v.push_back(malloc(16));
v.push_back(ConcurrentAlloc(16));

我们让4个线程执行10轮操作,每轮申请释放1000次(使用Release版)

图13.2.png 从结果中我们看到还是malloc效率更高一些。

13.3 不同大小内存的申请和释放

我们利用随机函数来申请和释放不同大小的内存

v.push_back(malloc((16 + i) % 8192 + 1));
v.push_back(ConcurrentAlloc((16 + i) % 8192 + 1));

图13.3.png

相比之下还是malloc更高一些。

14. 使用基数树进行优化

当前项目在页号跟span的映射上面消耗占比很大(因为map使用时需要加锁解锁),为此tcmalloc设计者针对这一点使用了基数树进行优化,使得在读取映射关系时可以做到不加锁。

14.1 基数树

基数树实际上就是一个分层的哈希表,根据所分层数不同可分为单层基数树、二层基数树、三层基数树等。

单层基数树实际采用的就是直接定址法,每一个页号对应span的地址就存储数组中在以该页号为下标的位置。

图14.1.png

14.2 单层基数树

template <int BITS>
class TCMalloc_PageMap1 {
private:
	static const int LENGTH = 1 << BITS;//页的数目
	void** array_;//存储映射关系的数组

public:
	typedef uintptr_t Number;

	explicit TCMalloc_PageMap1() {
		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);// 对申请到的内存进行清理
	}

	void* get(Number k) const {
		if ((k >> BITS) > 0) {
			return NULL;
		}
		return array_[k];// 返回该页号对应的span
	}

	void set(Number k, void* v) {
		assert((k >> BITS) == 0); // 确保k的范围在[0, 2^BITS-1]
		array_[k] = v; // 建立映射
	}
};

代码中的非类型模板参数BITS表示存储页号最多需要比特位的个数。在32位下我们传入的是32-PAGE_SHIFT,在64位下传入的是64-PAGE_SHIFT。

但是一层基数树只能用于32位平台下,在64位平台下,如果一页为8k也就是2^13字节,此时存储页号需要2^64÷2^13=2^51,51位比特位。而且64位平台下指针的大小是8字节,基数数组的大小为(2^51)*8=2^54字节,远远超出了计算机能接受的范围,在64位平台下我们使用3层以上的基数树。

14.3 二层基数树

二层基数树相对于单层基数树是分两次映射,将数据先取一部分比特位在基数树的第一层进行映射,映射后得到对应的第二层,然后用剩下的比特位在基数树第二层进行映射。

图14.3.png

// 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; // 第一层对应页号的前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;// 第二层存储元素的个数

	// 第一层数组中存储的元素类型
	struct Leaf {
		void* values[LEAF_LENGTH];
	};

	Leaf* root_[ROOT_LENGTH];             // 第一层数组
	void* (*allocator_)(size_t);          // Memory allocator

public:
	typedef uintptr_t Number;

	explicit TCMalloc_PageMap2() {
		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];//返回该页号对应span的指针
	}

	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;// 建立该页号与对应span的映射
	}
	// 确保映射[start,start_n-1]页号的空间是开辟好了的
	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) {// 第一层i1下标指向的空间未开辟
				//开辟对应空间
				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);// 将第二层的空间全部开辟好
	}
};

14.4 三层基数树

三层基数树就是做三次映射

图14.4.png

// 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; //第一、二层对应页号的比特位个数
	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];//返回该页号对应span的指针
	}

	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;//建立该页号与对应span的映射
	}
	//确保映射[start,start+n-1]页号的空间是开辟好了的
	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() {
	}
};

14.5 利用基数树修改原代码

下面就要根据基数树对原先我们实现的代码进行一些修改。

我们需要PageCache类当中的unorder_map用基数树进行替换

std::unordered_map<PAGE_ID, Span*> _idSpanMap;
// 替换为
TCMalloc_PageMap1<32 - PAGE_SHIFT> _idSpanMap;

对需要建立页号与span的映射的地方,修改为调用基数树当中的set函数。

_idSpanMap[span->_pageId] = span;
// 修改为
_idSpanMap.set(span->_pageId, span);

对需要读取某一页号对应的span时,修改为调用基数树当中的get函数

auto ret = _idSpanMap.find(nextId);
// 修改为
auto ret = (Span*)_idSpanMap.get(nextId);

并且现在PageCache类向外提供的,用于读取映射关系的MapObjectToSpan函数内部就不需要加锁了。

为什么读取基数树映射关系时不需要加锁?

当某个线程在读取映射关系时,可能有另外一个线程正在建立其他页号的映射关系,因为map的底层数据结构是红黑树,unordered_map的底层数据结构是哈希表,它们在建立映射关系时可能会改变原先的结构(红黑树会旋转,哈希表会扩容),从而导致数据不一致的问题,所以在映射读取时需要加锁。

基数树就不一样了,基数树的空间一旦开辟好了就不会发生变化,因此无论什么时候去读取页的映射,都是对应在一个固定的位置进行读取的,并且我们不会同时对同一个页进行读取映射和建立映射的操作。

14.6 修改后的性能测试

申请和释放固定内存 图14.6.1.png 申请和释放动态内存 图14.6.2.png 性能相对之前有了很大的提高。

14.7 打包成动静态库

我们可以把当前的项目打包成动静态库,方便之后的使用

打包成动静态库方法如下

图14.7.1.png

在弹出的窗口下进行选择。

图14.7.2.png

源代码

参考资料

如何设计内存池? - 码农的荒岛求生的回答 - 知乎

tcmalloc官方文档

tcmalloc浅析