Bootstrap

C++项目---- 高并发内存池(项目解析与源代码)

一、项目介绍

        我们写的这个高并发内存池是google公司开源的一个叫tcmalloc的项目,tcmalloc全称 Thread-Caching Malloc,即线程缓存的malloc,实现了高效的多线程内存管理,用于替代系统的内存分配相关的函数如malloc、free,它的知名度也是非常高的,不少公司都在用它,甚至Go语言直接用它做了自己内存分配器。

        这个项目是把tcmalloc最核心的框架简化后拿出来,模拟实现出一个自己的高并发内存池,目的就是学习tcamlloc的精华,通过这个项目的实现学习C++高手的设计思路

二、什么是内存池

池化技术

        池化技术就是程序预先向操作系统申请一大批资源,然后自己管理起来,以备后续使用,那为什么要提前申请资源呢?因为后续程序多次少量申请资源的开销是挺大的,不如提前就申请一大批资源,供后续使用,这样可以大大的提高程序效率。

        在计算机中很多地方都是用了 “池” ,例如:线程池、内存池、连接池、对象池等,以服务器上的线程池来讲,他的思想就是先申请一批线程,让这些线程先休眠起来,当后续客户端发送过来请求时,就唤醒一个线程来处理,当请求处理完成再次让线程休眠起来供后续的请求使用。

内存池概述

        而我们今天的内存池指的是,程序预先向操作系统申请一大批内存空间,当程序后续需要申请内存时,不直接向操作系统申请,而是从我们申请的内存池中申请,同理,当程序要释放这段空间时,并不是将这段空间返还给操作系统,而是还给内存池,当程序退出时,内存池才会将申请的一大批内存返还给操作系统。

        那我们设计的内存池需要解决什么样的问题呢?

首先最重要的一定是先解决效率的问题,如果要作为系统内存分配器的话,还需要解决一下内存碎片的问题,接下来我们简单了解一下什么是内存碎片

假设我们当前要申请500个字节的空间,由于之前有的对象释放了自己空间,这些空间的总和其实是大于500字节的,但是由于他们并不是连续的空间,而是碎片化的,这些空间并不能被有效的利用。其实内存碎片化分为内碎片和外碎片,我们上述讲的就是外碎片的问题,而内碎片是由于一些内存对齐对则而产生的问题,后续我们的项目中会有所体现。

malloc

        C/C++中我们要动态申请内存都是通过malloc去申请内存,C++中的new也是通过封装malloc实现的,实际我们不是直接去堆获取内存的, malloc其实就是一个内存池。

        malloc的实现方 式有很多种,一般不同编译器平台用的都是不同的。比如windows的vs系列用的微软自己写的一套, linux gcc用的glibc中的ptmalloc。

三、设计一个定长的内存池

malloc在什么场景 下都可以用,但是什么场景下都可以用就意味着什么场景下都不会有很高的性能。

而定长的内存池就是每次在内存池中申请的空间大小都是相等的内存池,所以我们可以将内存池的性能提升到极致,因为我们申请的内存块都是定长的,不需要考虑内存碎片的问题,通过这个开胃菜我们可以简单熟悉一下内存池是如何控制的,并且它到在项目中也是一个组件

设计思路:
成员对象设计

        首先,我们需要向OS申请一片内存空间,这段空间可以通过一个指针来管理,而为了方便对这段空间进行操作,建议将这个指针定义为char类型的,这样我们想要移动指针直接给他加减n就可以了,维护这段空间,我们还需要一个整数类型来保存它剩余的空间大小

而对于要释放的空间我们也需要管理起来,实现思路是将要释放的内存块管理起来组成一个链表,我们将这个链表称为自由链表,为了管理这个自由链表,我们还需要定义一个指针。

所以要实现定长内存池,我们需要如下的成员对象:

char* _memory=nullptr; //要开辟的大内存块
size_t _remain_size=0; //内存块后边剩余的字节大小
void* _free_list;	   //维护返还内存的链表指针
如何做到定长

我们可以利用非类型模版参数,让内存池内次申请的内存块大小都是固定的

template<size_t N>
class ObjectPool
{};

也可以通过模版来实现,我们将内存池设计为模版类,创建一个内存池需要给他赋一个类型,这样每次申请的对象都是一个类型的,也就做到了定长,这里我们采用模版类的方式

template<class T>
class ObjectPool
{};
如何管理内存池中要释放的内存块

        上述说了我们要通过自由链表的方式俩管理这些内存块,其实我们并不需要设计一个链表的结构,可以用这些内存块的前4个字节(32位)或者前8个字节(64位)作为一个指针指向下一个内存块,在用一个头指针来维护这个链表即可

但是这里有一个问题:如何能让一个指针在32位下访问前四个字节,在64位下访问前8个字节?

        例如,我们想要访问一段空间的前四个字节,假设这个空间的其实地址为a,那我们可以先将这个空间的地址强制转换为 int* 类型,然后解引用,因为解引用后是int类型,这样就可以访问这个空间的前四个字节了,指针的类型决定了他解引用后可以访问的字节大小。我们现在也可以利用这个思路,由于指针在不同位数的机器下的大小是不同的,所以我们可以将这个空间的地址强转为一个二级指针类型,再解引用,这样在32位机器下,就可以访问前四个字节,在64位机器下就可以访问前8个字节了。

当一个内存块要释放时,我们可以直接采用头插的方式将它加入到自由链表中,因为这样很方便不需要再遍历一遍链表了。

void Delete(T* obj)
{
	//显示调用obj的析构函数
	obj->~T();
    //头插
	*(void**)obj = _free_list;
    _free_list = *(void**)obj;
}
内存池如何申请一个对象

        当内存池想要申请一个对象时,优先应该使用前边要释放的内存,及在_free_list中维护的内存块;如果_free_list为空,说明前面的空间都还没有释放,那我们就需要分配内存池后边的空间,如果后面的空间不够申请一个对象,那内存池就需要重新向OS申请一块大内存,再申请对象

T* New()
{
	T* obj = nullptr;
	//如果有返回的小内存块,则先用返还的小的,否则再用大内存块后面的
	if (_free_list)
	{
		obj = (T*)_free_list;
		_free_list = *(void**)obj;
	}
	else
	{
		//如果后面的空间大小不够一个T类型对象的大小,那就新开辟一个空间
		if (_remain_size < sizeof(T))
		{
			_remain_size = 128 * 1024;
			_memory = (char*)malloc(_remain_size);
			if (_memory == nullptr)
			{
				std::cerr << "malloc false!" << std::endl;
				exit(-1);
			}
		}
		obj = (T*)_memory;
		//由于要用指针管理小的内存块,为了避免T的大小小于指针的大小,T的大小大于指针大小,那就让指针向后走T的大小,否则就走指针的大小
		size_t objsize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
		_memory += objsize;
		_remain_size -= objsize;
	}
    //对象的空间分配好了,还没结束,给他调用一下构造函数
	new(obj)T;
	return obj;
}

注意这里还有一个小问题,因为我们上面的自由链表是利用内存块前4个字节或者8个字节作为指针来维护的,那如果我们申请的对象大小都不够指针的大小怎么办呢?所以在分配空间时就需要判断一下,如果对象的大小大于当前机器的指针大小,那就让_memory向后走sizeof(obj),否则就让他+=一个指针的大小。

让内存池直接向堆按页申请空间

要想直接向堆申请内存空间,在Windows下,可以调用VirtualAlloc函数;在Linux下,可以调用brk或mmap函数。

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

//直接去堆上申请按页申请空间
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32
	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;
}
定长内存池完整代码:
#pragma once
#include<iostream>
#ifdef _WIN32
#include <Windows.h>
#else
//...
#endif

//直接去堆上申请按页申请空间
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32
	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;
}

template<class T>
//定长内存池
class ObjectPool
{
public:
	T* New()
	{
		T* obj = nullptr;
		//如果有返回的小内存块,则先用返还的小的,否则再用大内存块后面的
		if (_free_list)
		{
			obj = (T*)_free_list;
			_free_list = *(void**)obj;
		}
		else
		{
			//如果后面的空间大小不够一个T类型对象的大小,那就新开辟一个空间
			if (_remain_size < sizeof(T))
			{
				_remain_size = 128 * 1024;
				_memory = (char*)SystemAlloc(_remain_size>>13);
				if (_memory == nullptr)
				{
					throw std::bad_alloc();
				}
			}
			obj = (T*)_memory;
			//由于要用指针管理小的内存块,为了避免T的大小小于指针的大小,T的大小大于指针大小,那就让指针向后走T的大小,否则就走指针的大小
			size_t objsize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
			_memory += objsize;
			_remain_size -= objsize;
		}
		//对象的空间分配好了,还没结束,给他调用一下构造函数
		new(obj)T;
		return obj;
	}
	void Delete(T* obj)
	{
		//显示调用obj的析构函数
		obj->~T();
		//if (_free_list == nullptr)
		//{
		//	_free_list = *(void**)obj;
		//}
		//else
		//{
		//	//头插
		//	*(void**)obj = _free_list;
		//	_free_list = *(void**)obj;
		//}

		*(void**)obj = _free_list;
		_free_list = obj;
	}
private:
	char* _memory=nullptr; //要开辟的大内存块
	size_t _remain_size=0; //内存块后边剩余的字节大小
	void* _free_list=nullptr;	   //维护返还内存的链表指针
};
定长内存池与malloc性能对比测试:
#include"ObjictPool.h"
#include<vector>
using namespace std;
struct TreeNode
{
	int _val;
	TreeNode* _left;
	TreeNode* _right;
	TreeNode()
		:_val(0)
		, _left(nullptr)
		, _right(nullptr)
	{}
};
void TestObjectPool()
{
	// 申请释放的轮次
	const size_t Rounds = 3;
	// 每轮申请释放多少次
	const size_t N = 100000;
	size_t begin1 = clock();
	std::vector<TreeNode*> v1;
	v1.reserve(N);
	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();
	ObjectPool<TreeNode> TNPool;
	size_t begin2 = clock();
	std::vector<TreeNode*> v2;
	v2.reserve(N);
	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 < 100000; ++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;
}

int main()
{
	TestObjectPool();
	return 0;
}

这份代码是通过比较malloc和定长内存池申请和释放大量空间的速率来比较性能强弱的,我们调到release模式下来看一下结果怎么样

可以看到定长内存池相比于malloc的性能是更高的,这是因为malloc要考虑所有场景下的使用,而我们的定长内存池只需要考虑一个场景即可,所以在特定场景下定长内存池的性能是很高的,这也是"寸有所长,尺有所短"的道理

四、高并发内存池整体设计框架

项目解决的问题

        现在很多的开发环境都是多核多线程,在申请内存的场景下,必然存在激烈的锁竞争问题。malloc本身其实已经很优秀,那么我们项目的原型tcmalloc就是在多线程高并发的场景下效率更高。实现一个内存池需要考虑效率和内存碎片的问题,但是对于高并发内存池来说,还需要考虑在多线程的环境下,锁的竞争问题

整体设计框架

模块说明

高并发内存池一共分为三个模块:

  • thread cache:线程缓存是每个线程独有的,用于小于256KB的内存的分配
  • central cache:中心缓存是所有线程所共享,thread cache是按需从central cache中获取的对象。
  • page cache:页缓存是在central cache缓存上面的一层缓存,存储的内存是以页为单位存储及分配的,central cache没有内存对象时,从page cache分配出一定数量的page,并切割成定长大小的小块内存,分配给central cache。当一个span的几个跨度页的对象都回收以后,page cache 会回收central cache满足条件的span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。

说明:

        在申请内存时,一次性申请大于256K的情况是很少见的,而每一个线程都独享一个cache,这意味着线程在thred cache中申请内存是不需要加锁的,这就是高并发内存池的高效之处

        当thread cache中内存不足时,它就会向central cache申请内存,而central cache并不是它申请一次就给他分配一个内存块,而是根据实际情况一次性给他分配多个,将剩余的内存块挂接到thread cache内部供以后使用。

        当thread cache空闲的内存较多时,他还会将部分内存归还给central cache,让这部分内存可以分配给其他的线程,这样可以避免内存的浪费和其他thread cache内存吃紧的问题。

        在thread cache模块申请内存是不需要加锁的,而当多个thread cache中内存不足,同时向central cache申请内存时,此时就需要加锁,但是此时的锁并不是直接锁整个central cahce的而是桶锁,因为只有多个线程同时访问centarl cache的同一个桶时才会发生竞争,所以central cache的锁竞争问题不是很激烈

五、Thread Cache模块实现

thread cache设计

在定长内存池中由于申请的内存块都是同样大小的,所以只需要一个freelist来管理,但是对于我们的项目我们就需要考虑多个场景了,所以我们可以用多个freelist来管理释放的内存块,他的结构其实就是哈希桶结构。

但是我们要考虑一个问题,申请内存的场景是有很多的,如果我们对于每个字节都用freelist来管理,这样的开销是很大的,管理256K的字节就需要 256*1024 个指针来管理,有点得不偿失。

我们可以让他按照一种规则来向上对齐,例如申请1~8字节就给他分配8个字节,申请9~16字节就给他分配16个字节,依次类推。

按照这样的对齐规则,其实会产生一些内存碎片的,例如我要申请6个字节的内存,但是实际却给我分配了8个字节,那其中就有两个字节无法被利用,这样的碎片就是内碎片

当申请某个大小的内存时,就需要先计算出他的对齐内存是多少,然后在根据对齐内存的大小找到对应的桶,如果这个桶下面挂着内存块,那就直接去一个下来直接分配,否则就需要到下一层central cache申请内存

哈希桶的结构就是freelist的一个数组,首先我们先将freelist类设计出来

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

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

	void* Pop()
	{
		assert(_free_list != nullptr);
		void* obj = _free_list;
		_free_list = NextObj(obj);
		return obj;
	}

	bool Empty()
	{
		return _free_list == nullptr;
	}

private:
	void* _free_list=nullptr;
};
thread cache对齐规则设计

        由于我们利用内存块的前4个字节(32位)或者前8个字节(64位)作为指针,所以第一个对齐内存大小是8字节最合适了,如果所有字节都按8字节对齐的话,一共就需要 256 * 1024 / 8 个桶,可见消耗还是很大的,实际我们可以让不同范围的字节按照不同的对齐数来对齐:

字节范围对齐数桶下标
[1, 128]8 byte对齐freelist[0,16)
[128+1,1024]16 byte对齐freelist[16,72)
[1024+1,8*1024]128 byte对齐freelist[72,128)
[8*1024+1,64*1024]1024 byte对齐freelist[128,184)
[64*1024+1,256*1024]8*1024 byte对齐freelist[184,208)

这样只一共需要208个桶就可以管理这个256K字节了

空间浪费率

上述的设计方案可以内存的浪费率控制在10%左右

空间浪费率= 浪费的内存大小 / 实际分配的内存大小

我们以 [1024+1,8*1024] 范围来举例:

我们计算一下这个范围内的最大空间浪费率,最大的浪费字节大小是127,对应的申请内存是1025字节。要申请一个大小为1025字节的内存,但是其实分配了1025+128个字节,

浪费率= 127 / 1152 * 100%=11%

对齐与映射函数编写

        当我们制定好对齐规则以后,我们还需要实现两个函数,一个用于确定申请内存大小对齐后的字节大小,一个用于根据对齐的字节大小找到对应的桶

        我们可以把这两个函数封装到一个类中,建议将他们定义为static,这样就可以直接通过类名访问

class SizeClass
{
public:
    //确定size对应的对齐后的字节数
	static size_t Roundup(size_t size);
    //确定size对应的哈希桶
	static size_t Index(size_t size);
};

确定一个size对齐后的字节大小,我们可以先确定这个size的字节范围,在根据他的对齐数来确定

static size_t Roundup(size_t size)
{
	//整体控制在最多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)
	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
	{
		//申请的内存大于256kb不在这个模块申请
		assert(false);
		return -1;
	}
}

所以我们还需要编写一个子函数,来实现根据具体开辟的大小和对应的对齐数来确定最后对齐的字节数。如果要申请的字节数大小取模与对齐数正好为0,那就不用对齐,直接给他开辟这么多空间就好了,如果不为0的话,我们可以取 (size / AlignNum + 1) * AlignNum ,这样可以让他向上对齐

static size_t _Roundup(size_t size, size_t AlignNum)
{
	if (size % AlignNum == 0)
		return size;
	else
		return (size / AlignNum + 1) * AlignNum;
}

其实这个函数也可以通过位运算来实现,相比于上面的写法,位运算的计算效率更高。

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

我们以 size=7字节 来举例,它对应的对齐数为8字节。

确定哈希桶的位置和上面的思路一样,先确定申请字节数处于哪个范围,再根据对应的对齐数找到桶的位置

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

static size_t Index(size_t size)
{
	int arry[5] = { 16,56,56,56,56 };
	if (size <= 128)
	{
		return _Index(size, 8);
	}
	else if (size <= 1024)
	{
		return _Index(size - 128, 16) + arry[0];
	}
	else if (size <= 8 * 1024)
	{
		return _Index(size - 1024, 128) + arry[0] + arry[1];
	}
	else if (size <= 64 * 1024)
	{
		return _Index(size - 8 * 1024, 1024) + arry[0] + arry[1] + arry[2];
	}
	else if (size <= 256 * 1024)
	{
		return _Index(size - 64 * 1024, 8 * 1024) + arry[0] + arry[1] + arry[2] + arry[3];
	}
	else
	{
		//申请的内存大于256kb不在这个模块申请
		assert(false);
		return -1;
	}
}

同样,上面的子函数也可以用位运算来实现,不过这里第二个参数传的不是对齐数了,而是对齐数对应2的几次方的那个指数,同样主函数中的第二个参数也要改一下

static inline size_t _Index(size_t size, size_t align_shift)
{
	return ((size + (1 << align_shift) - 1) >> align_shift) - 1;
}
threadcache类

   threadcache模块中可以申请的最大内存为256k,根据上述的对齐规则,一共需要208个桶,我们可以先将他们的信息先定义出来

// 小于等于MAX_BYTES,就找thread cache申请
// 大于MAX_BYTES,就直接找page cache或者系统堆申请
static const size_t MAX_BYTES = 256 * 1024;
// thread cache 和 central cache自由链表哈希桶的表大小
static const size_t NFREELISTS = 208;

 threadcache.h

        目前,在这个类中我们先写其中的三个接口用于申请和管理内存,这个类还有其他的接口我们到后续的模块完成后再补充

class ThreadCache
{
public:
	void* Allocate(size_t size);
	void Deallocate(void* ptr, size_t size);
	void* FetchFromCentralCache(size_t index,size_t size);
private:
	FreeList _freelists[NFREELISTS];//哈希桶
};

 Allocate函数实现思路:

        我们只知道一个开辟一个对象所需的大小size,首先我们需要先确定这个size对齐后的字节数大小是多少,然后根据这个大小找到对应的哈希桶,如果这个桶中存在内存块就直接pop出来一个内存块即可,如果这个桶没有挂接的内存块,就需要到下一层centralcache去申请内存块了

void* ThreadCache::Allocate(size_t size)
{
	assert(size < MAXBYTE);
	void* obj = nullptr;
	//首先需要找到开辟这个大小需要的对齐数是多少
	size_t alignsize = SizeClass::Roundup(size);
	//找到是哪一个桶
	size_t index = SizeClass::Index(size);
	if (!_freelists[index].Empty())
	{
		obj = _freelists[index].Pop();
	}
	else
	{
		obj = FetchFromCentralCache(index,size);
	}
	return obj;
}

Deallocate函数实现思路:

        这个函数的参数给我们提供了要释放的内存地址和它对应的大小,我们的目的不是释放这段空间而是让这个内存块重新挂接到对应的哈希桶中供以后使用,所以我们需要先根据size求出它对应的桶的位置,再将它挂接回去

void ThreadCache::Deallocate(void* ptr,size_t size)
{
	assert(ptr);
	assert(size < MAXBYTE);
	//找到是哪一个桶
	size_t index = SizeClass::Index(size);
	_freelists[index].Push(ptr);
}

对于 FetchFromCentralCache这个函数,由于它涉及到下一层,我们先不实现它,我们知道他是向centralcache申请内存即可

threadcacheTLS无锁访问 

        每一个线程都有自己独享的 threadcache ,那应该怎么创建出threadcache呢?首先我们一定不能把他定义为全局的,因为全局变量是所有线程所共享的,这样就要求我们加锁处理了。

        我们可以通过TLS机制来实现这个问题,线程局部存储(TLS),是一种变量的存储方法,这个变量在它所在的线程内是全局可访问的,但是不能被其他线程访问到,这样就保持了数据的线程独立性,这里我们采用静态TLS的方式,只需要声明一下即可:

// TLS thread local storage
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;

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

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

六、Central Cache模块实现

centralcache设计

        centralcache的结构也是一个哈希桶的结构,他的哈希桶的映射关系是和threadcache是一样的,不同的是threadcache的桶挂接的是一个一个的内存块,而centralcache的桶中挂接的是span,不过哈希桶中挂接的span也会根据映射规则,被划分为一个一个的小内存块挂接在span内部的自由链表中。

        每个桶中挂接的span是以带头双向循环链表维护起来的,因为这个有个问题,我们上面提过当threadcache中桶挂接的内存块数量过多时就需要将一部分还给centralcache,那centralcache中向下一层申请的内存也需要在一定条件下还给下一层,这就需要我们可以返回去修改某一个span

        在threadcache中申请内存是不需要加锁的,因为每个线程都独享一个threadcache,而当threadcache的某个桶没有内存块时,就需要到centralcache对应的桶中申请内存,这个过程就需要加锁了,因为centralcache只有一个,有可能多个线程同时访问centralcache中的同一个哈希桶,所以需要给每个桶设计一把锁

span的结构
struct Span
{
	PAGE_ID id=0; // 大块内存起始页的页号
	size_t n=0; //页的数量

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

	size_t usecount = 0; //span中被使用的内存块的数量,当usecount为0时应该将他还给下一层
	void* freelist=nullptr; //自由链表
};

PAGE_ID的类型

        进程都有自己的进程地址空间,在32机器下,空间的大小为2^32,在64位机器下,空间大小为2^64,页的大小通常为4KB或者8KB,我们以8KB为例,32位机器下的地址空间可以被分为2^19个页,64位机器下的地址空间可以被分为2^51个,页号的本质和地址一样,地址是以1个字节位一个单位,而页号是以多个字节为一个单位。通过上面的简单计算,可以发现在32位机器下一个无符号整数就可以表示完全部页号,但是在64位机器下,就需一个无符号长整型才能表示完,也就是说在不同的机器下PAGE_ID需要被定义为不同的类型。

        我们可以通过条件编译来实现:

#ifdef _WIN64
typedef unsigned long long PAGE_ID;
#else
typedef size_t PAGE_ID;
#endif

要注意,在64位机器下_WIN32和_WIN64都被定义了,但是在32位机器下只定义了_WIN32,所以我们可以先判断是否定义了_WIN64

带头双向循环链表设计

要注意删除节点时不要delete,因为这个span还会还给下一层,这里只是将他移出链表结构

//带头双向循环span链表
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;

		newspan->_next = pos;
		pos->_prev = newspan;
		prev->_next = newspan;
		newspan->_prev = prev;
	}
	void Erase(Span* pos)
	{
		assert(pos);
		Span* prev = pos->_prev;
		Span* next = pos->_next;

		prev->_next = next;
		next->_prev = prev;
	}
private:
	Span* _head;
public:
	std::mutex _mtx;
};
centralcache结构
class CentralCache
{
public:
	static CentralCache* GetInstance()
	{
		return &_sInst;
	}
private:
	CentralCache() {};
	CentralCache(const CentralCache&) = delete;

	SpanList _spanLists[NFREELISTS]; //centralcache 的哈希桶结构
	//单例模式
	static CentralCache _sInst;
};

centralcache的本质也是一个哈希桶结构,桶的数量与threadcache中的一致都是208,。需要注意的是每一个线程都有自己独享的threadcache,也就是threadcache在程序中可以有多份,但是centralcache却只有一份,所以centralcache很适合设计为单例模式,这里我采用的是饿汉模式

centralcache核心实现

慢开始调节算法

        当threadcache中某个桶内存不足时会向centralcache申请内存,那给threadcache分配多少个对象合适呢?如果分配的少了,threadcahce可能短时间又会来centralcache中申请,这就有可能设计到锁的问题了,效率会有所影响,但是如果一次性给他分配的多了,他可能会用不完导致内存浪费,所以可以采用慢开始的调节算法。

        对于内存较小的对象可以多给他分配点,对于内存较大的对象就给他分配少一点,于是我们可以实现如下函数,对于较小的对象,我们上限只能给他分配512个,而对于较大的对象我们至少给他分配两个。

clas SizeClass
{
    //......
    static size_t NumMoveSize(size_t size)
	{
		//想让小内存多分配点
		//大内存分配少一点
		int Num = MAX_BYTES / size;

		if (Num == 1)
		{
			Num = 2;
		}

		if (Num > 512)
		{
			Num = 512;
		}
		return Num;
	}
};

   但是对于较小的对象一次性直接给他分配512个也不是很合适,所以我们可以在threadcache的每个桶中添加一个数maxsize,我们将他设置为1,每次threadcache的某个桶向centralcache申请内存时,我们取该桶的maxsize和NumMoveSize()的较小值,如果数量等于maxsize的话,我们每次就让maxsize+=1,这样就达到了慢开始的策略,当一个桶需求量较大时,他就会不断地向centralcache申请内存,而每次给他分配的内存块数量也会越来越大,但是上限就是NumMoveSize的大小。

从中心缓存分配内存

由于分配给threadcache的内存块不止一个,所以我们需要用两个指针作为参数来接受给他分配的内存起始和最终地址,其次该函数还会收到threadcach想要申请的内存块数量和对象内存对齐后的字节大小。首先我们需要确定要从中心缓存的哪一个桶申请内存,对于GetOneSpan函数来说,由于他也涉及到下一层,我们先不实现它,我们目前就认为他可以给我们返回一个当前桶中的一个非空span对象,获得span对象后,我们需要考虑一个问题,这个span中可能存在batchnum个内存块,也可能不够,但是它至少会有一个,因为他是非空的,所以我们的策略是不够的话有多少就给他多少,分配好后,注意将这段内存从span的自由链表中剥离,并让这段空间最后的指针指向nullptr,还要改变span对象中维护的usecount数量

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);
	//这个span可能有batchNum个内存块,也可能没有这么多
	//那就有多少给多少
	size_t i = 0;
	start = span->freelist;
	end = start;
	//这个span至少有一个内存块,因为span是非空的
	int actualNum = 1;
	while (i < batchNum - 1 && NextObj(end) != nullptr)
	{
		end = NextObj(end);
		++i;
		++actualNum;
	}
	span->freelist = NextObj(end);
	NextObj(end) = nullptr;
	span->usecount += actualNum;
	_spanLists[index]._mtx.unlock();
	return actualNum;
}
补充threadcache的向中心内存申请缓存

首先确定要想centralcache申请的内存块数量,调用对应函数,然后我们需要看一下给我们实际分配内存块的数量,如果只分配了一个的话,就直接返回这个内存块的地址,供对象使用,如果分配了多个的话,我们就需要将后面的内存块挂接到对应的桶后面

void* ThreadCache::FetchFromCentralCache(size_t index,size_t size)
{	
	//threadcache每次向centralcache申请内存,并不是只给他分配一个内存块,可以给他分配多个,然后挂到threadcache的freelists中下次使用
	//分配内存块的数量采用慢开始的策略
	//根据向中心缓存申请的频率分配的内存块数量逐渐递增,直到上限不再增长
	//小内存块的分配数量上限大,大内存块的分配数量上限小
	size_t batchNum = min(_freelists[index].MaxSize(), SizeClass::NumMoveSize(size));
	if (batchNum == _freelists[index].MaxSize())
	{
		_freelists[index].MaxSize() += 1;
	}
	//从centralcache获取内存
	void* start = nullptr;
	void* end = nullptr;
	int actualNum=CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, size);
	assert(actualNum > 0);
	if (actualNum == 1)
	{
		assert(start == end);
		return start;
	}
	_freelists[index].PushRange(NextObj(start), end);
	return start;
}

七、Page Cache模块实现

pagecache设计

        pagecache与centralcache一样都是哈希桶的结构,后面挂的都是一个一个的span,这些span也是用带头双向循环链表维护起来的,但不同点是centralcache的哈希桶是和threadcache一样的大小对齐关系映射的,这是因为当threadcache的某个桶中没有内存时就需要到centralcache对应的桶结构中申请内存块,而centralcache的某个桶中也没有内存时,它是会pagecache申请一个合适页大小的span的,所以pagecache中的桶是按照桶的下标进行映射的,也就是这个桶的下标为多少,这个桶后面挂的span就是多少页的。

对于桶设计多少,就要看想让最大挂多少页的span了,这里我们认为128个桶就足够了,因为128页的span对应的大小大概是128*8K,可以划分4个大小为256k的对象,是足够的。

由于我们想让数组下标映射对应的桶,所以我们将数组大小定义为129

// page cache 管理span list哈希表大小
static const size_t NPAGES = 129;
// 页大小转换偏移, 即一页定义为2^13,也就是8KB
static const size_t PAGE_SHIFT = 13;
在pagecache中获取k页sapn的过程

        centralcache某个桶内存不够了,要向pagecache申请k页的span,对于pagecahce来说首先看一下第k个桶后面是否还挂着span,如果有的话直接将这个span返回即可,如果没有的话,我们就继续遍历下面的桶,因为下面桶挂的span的页数一定是大于k的,所以我们可以将下面的span切分为一个k页的span和一个n-k页的span,然后我们返回这个k页的span,再重新将n-k页的span重新挂接到合适的位置。如果遍历完所有的桶都没有的话,就向堆申请一个128页的span,然后在对这个128的span进行切分。

        所以pagecache中挂的所有span其实都是由向堆申请的128页的span切分而来的

pagecache结构

        在多线程的条件下,threadcache内存不足就需要向centralcache申请内存,如果多线程访问的是centralcache的不同桶的话就可以一起访问,这就有可能会发生centralcache的多个桶向pagecache申请内存了,所以pagecache也存在线程安全问题,需要加锁处理。

        与centralcache不同的是,pagecache中的锁不是桶锁了,是一个可以锁住pagecache的大锁,centralcache设计为桶锁的原因是因为thradcache是向centralcache对应的桶申请内存的,允许threadcache同时访问centralcache的不同桶。

        我们上面提到,当centralcache向pagecache申请一个k页的span,是有可能会发生遍历所有桶的情况的,如果这里设计的是桶锁的话,就会不断地加锁解锁,相比于直接锁住pagecache然后竞争访问,这样的效率反而会降低。

        与centralcache一样,pagecache只会申请一个对象,所以也将它设置为单例模式。

class PageCache
{
public:
	static PageCache* GetInstance()
	{
		return &_pInst;
	}
	Span* NewSpan(size_t k);
private:
	//单例模式
	PageCache()
	{}
	PageCache(const PageCache&) = delete;
	static PageCache _pInst;
	SpanList _spanLists[NPAGES];
public:
	std::mutex _page_mtx;
};
申请k页span具体实现

        首先我们先实现centralcache中的GetOneSpan。centralcache要在对应的桶中获取一个非空的span,首先应该遍历这个桶,如果存在非空的span就返回,如果没有的话就需要向pagecache申请一个span。

        申请的这个span大小也应该看对象的大小,我们可以先计算出threadcache可以向centralcache申请内存块的上限,然后乘以对象的大小算出,最多需要多少的字节,在用这个大小除页的大小,就可以计算出需要几页,如果不够一页的话就给他分配一页。

class SizeClass
{    
    //.....
    static size_t NumPageSize(size_t size)
	{
		size_t num = NumMoveSize(size);//获取这个字节下可以分配对象数量的上限
		size_t nPage = num * size;
		nPage >>= PAGE_SHIFT;
		if (nPage == 0)
		{
			nPage = 1;
		}
		return nPage;
	}
};

然后就可以向pagecache申请一个k页的span,当centralcache申请成功后,还需要将这个空间划分为对应对象大小的一个一个的内存块,然后尾插span的自由链表中。尾插是因为是想让这些连起来的内存块地址相对是连续的,当我们把这些连续内存分配给某个线程使用时,可以提高该线程的CPU缓存利用率。

Span* CentralCache::GetOneSpan(SpanList& list, size_t size)
{
	//首先找一下这个桶是否存才非空的桶
	Span* it = list.Begin();
	while (it != list.End())
	{
		//该span下的自由链表还挂着内存块
		if (it->freelist != nullptr)
		{
			return it;
		}
		else
		{
			//下一个span
			it = it->_next;
		}
	}

	list._mtx.unlock(); //这里把桶锁解了可以避免后续这个桶中内存的归还被阻塞

	//到这里说明这个桶没有非空的span,就需要到pagecache中申请span
	PageCache::GetInstance()->_page_mtx.lock();
	Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumPageSize(size));
	PageCache::GetInstance()->_page_mtx.unlock();

	//获取这个大内存空间的起始地址和空间大小
	char* start = (char*)(span->id << PAGE_SHIFT);
	size_t bytes = span->n << PAGE_SHIFT;
	char* end = start + bytes;

	//切出一个头节点便于切分
	span->freelist = start;
	start += size;
	void* tail = span->freelist;
	int i = 1;
	while (start < end)
	{
		++i;
		NextObj(tail) = start;
		tail = NextObj(tail);
		start += size;
	}
	NextObj(tail) = nullptr;

	list._mtx.lock(); //设计桶的添加,重新上锁
	list.PushFront(span);
	return span;
}

注意:

有一个问题,centralcache的桶向pagecache申请空间时,centralcache的桶锁要不要解开?

建议解开,虽然此时别的线程可能也会向这个桶要内存,这个桶中也一定是没有内存可用的,但是访问这个桶的还有向他归还内存的,如果解开的话,此时当别的threadcache归还内存时就不会被阻塞了。

所以我们可以在调用newspan之前将桶锁解掉,然后加上pagecache的大锁,避免pagecache的线程安全问题,当申请完成后,在解开这个大锁,这时,我们不需要立即加上桶锁,可以等这段空间被切分挂接好后在加上桶锁返回,因为当我们申请好这个空间没有返回时,只有当前的线程可以访问到这段空间。

接下来我们根据上面获取k页span的思路实现NewSpan

Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0);
	//首先看一下k页对应的桶是否还有桶
	if (!_spanLists[k].Empty())
	{
		return _spanLists[k].PopFront();
	}

	for (size_t i = k + 1; i < NPAGES; i++)
	{
		//如果存在span的话就切分
		if (!_spanLists[i].Empty()) 
		{
			Span* nSpan = _spanLists[i].PopFront();
			Span* kSpan = new Span;

			kSpan->id = nSpan->id;
			kSpan->n = k;

			nSpan->id += k;
			nSpan->n -= k;

			//将剩余的部分重新挂接到合适的桶
			_spanLists[nSpan->n].PushFront(nSpan);
			return kSpan;
		}
	}

	//到这里说明后面都没有span,那就需要到堆上申请
	void* ptr = SystemAlloc(NPAGES-1);
	Span* bigspan = new Span;
	bigspan->id = (PAGE_ID)ptr >> PAGE_SHIFT;
	bigspan->n = NPAGES-1;
	_spanLists[NPAGES - 1].PushFront(bigspan);
	return NewSpan(k);
}

八、内存回收

 threadcache内存回收

        当某个线程申请的的对象要释放时,就需要将他的空间还给threadcache,也就是将这个空间挂接到threadcache对应桶的自由链表中。

        但是随着对象的不断释放,这个自由链表的长度可能会不断增大,长度过大的话肯定是不好的,因为这么多空间可能用不完造成资源浪费,而且可能造成其他线程内存紧张的问题。

        所以当某个桶的挂接的内存块的数量大于目前可以向centralcache申请的内存块的数量时,我们就将一部分内存块还给central

void ThreadCache::Deallocate(void* ptr, size_t size)
{
	assert(ptr);
	assert(size < MAXBYTE);
	//找到是哪一个桶
	size_t index = SizeClass::Index(size);
	_freelists[index].Push(ptr);
	//当自由链表挂的数量多余目前一次性申请的数量时就归还给centralcache的spans
	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;
	//从自由链表中弹出MaxSize()个内存块
	list.PopRange(start, end, list.MaxSize());
	//将这些内存块还给centralcache对应桶中对应的span
	CentralCache::GetInstance()->ReleaseListToSpans(start, size);
}

为了可以判断自由链表中挂接了多少内存块,所以我们还需要在FreeList类中添加一个成员size,用于记录自由链表的长度,我们还需要在合适的位置维护起来size什么时候加什么时候减等等,我们还需要提供一个函数PopRange用于将自由链表中的一段空间拿出来,用于归还centralcache

class FreeList
{
public:
	void Push(void* obj)
	{
		assert(obj);
		//头插
		//*(void**)obj = _free_list;
		NextObj(obj) = _free_list;
		_free_list = obj;
		_size++;
	}

	void PushRange(void* start, void* end,size_t n)
	{
		assert(start && end);
		NextObj(end) = _free_list;
		_free_list = start;

		_size += n;
	}

	void PopRange(void*& start,void*& end,size_t n)
	{
		assert(n <= _size);
		start = _free_list;
		end = start;
		for (size_t i = 0; i < n - 1; i++)
		{
			end=NextObj(end);
		}
		_free_list = NextObj(end);
		NextObj(end) = nullptr;
		_size -= n;
	}

	void* Pop()
	{
		assert(_free_list != nullptr);
		void* obj = _free_list;
		_free_list = NextObj(obj);
		_size--;
		return obj;
	}

	bool Empty()
	{
		return _free_list == nullptr;
	}

	size_t& MaxSize()
	{
		return _maxsize;
	}

	size_t Size()
	{
		return _size;
	}
private:
	void* _free_list=nullptr;
	size_t _maxsize=1;
	size_t _size = 0;
};
centralcache内存回收

        当threadcache的某个桶自由链表长度过长时他就会归还给我们一段空间,这段空间是一个一个的内存块组成的,要注意的是这些内存块可能来自centralcache的对应桶中的不同的span,而我们需要做的就是将这些内存块准确无误的还给对应的span

        首先我们知道每一个内存块的地址,我们可以通过这个地址确定它对应span的页号(地址>>PAGE_SHIFT,即除以8K),但是我们通过页号并不能直接找到对应的span,因为一个span可能有很多页, 所以我们需要建立一个从页号到span的映射,我们可以用哈希表来实现,由于后续PageCache模块也需要用到这个映射关系,所以我们就将他设计为pagecache的成员了。

class PageCache
{
private:
    //.....
	std::unordered_map<PAGE_ID, Span*> _idSpanMap;
};

有了这个哈希表,我们还需要再申请内存的对应模块中,将映射关系建立好。centralcache每个桶挂接的span都是由pagecache给的,所以在pagecache分配给centralcache内存之前,我们就需要把从页号到span的映射关系建立好

Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0);
	//首先看一下k页对应的桶是否还有桶
	if (!_spanLists[k].Empty())
	{
		Span* kSpan= _spanLists[k].PopFront();
		//将k页的span的每一页与kspan做好映射关系,方便内存归还
		for (size_t i = 0; i < kSpan->n; i++)
		{
			_idSpanMap[kSpan->id + i] = kSpan;
		}
		return kSpan;
	}

	for (size_t i = k + 1; i < NPAGES; i++)
	{
		//如果存在span的话就切分
		if (!_spanLists[i].Empty()) 
		{
			Span* nSpan = _spanLists[i].PopFront();
			Span* kSpan = new Span;

			kSpan->id = nSpan->id;
			kSpan->n = k;

			nSpan->id += k;
			nSpan->n -= k;

			//将剩余的部分重新挂接到合适的桶
			_spanLists[nSpan->n].PushFront(nSpan);
			//将k页的span的每一页与kspan做好映射关系,方便内存归还
			for (size_t i = 0; i < kSpan->n; i++)
			{
				_idSpanMap[kSpan->id + i] = kSpan;
			}
			return kSpan;
		}
	}

	//到这里说明后面都没有span,那就需要到堆上申请
	void* ptr = SystemAlloc(NPAGES-1);
	Span* bigspan = new Span;
	bigspan->id = (PAGE_ID)ptr >> PAGE_SHIFT;
	bigspan->n = NPAGES-1;
	_spanLists[NPAGES - 1].PushFront(bigspan);
	return NewSpan(k);
}

有了映射关系,归还的过程就变得简单了,我们遍历threadcache归还的这些内存块,然后根据内存块的起始地址确定PAGE_ID,继而通过映射关系找到对应的span,然后将这个内存块头插回span的自由链表中,这样threadcache的归还就彻底完成了。

但是我们还要注意,当span的一个对象被归还时,他的usecount就会--,当usecount为0时说明这个span划分的内存块都回来了,此时我们就要将这个span归还给pagecache了。

void CentralCache::ReleaseListToSpans(void* start, size_t size)
{
	//这段空间的内存块不一定只属于一个span,因为他们的释放是无序的
	//我们只知道每个内存块的地址,这个地址可以计算出其对应span的页号,所以我们需要一个映射关系可以让页号映射到对应的span
	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的usecount减到0了,说明这个span分配出去的内存都回来了,那就归还给pagecache让他合并
		if (span->usecount == 0)
		{
            _spanLists[index].Erase(span);
			span->freelist = nullptr;
			span->_next = nullptr;
			span->_prev = nullptr;
			
			_spanLists[index]._mtx.unlock();

			PageCache::GetInstance()->_page_mtx.lock();
			PageCache::GetInstance()->ReleaseSpanToPageCache(span);
			PageCache::GetInstance()->_page_mtx.unlock();
			_spanLists[index]._mtx.lock();
		}
		start = next;
	}
	_spanLists[index]._mtx.unlock();
}
pagecache内存回收

        当centralcache归还一个span时,并不是根据这个span的页数直接挂接到对应的桶中,假设central归还的都是一些一页两页的span,我们都将它直接挂到桶中,如果下次centralcache要申请一个大点的span,而之前那些归还的span即使内存都连续,并且内存和足够这个申请的大小,我们也无法利用这些碎片化的span对象。

        所以我们要做的就是尽可能的对这个span进行合并,我们可以先检查这个span的前一页,如果这个页对应的span存在,并且内存块都被归还了,那我们就可以合并它,然后继续向前访问直到条件不符合,那我们在向后面合并,最后将合并的这个大span重新挂接到合适位置,这样centralcache下次申请较大的span我们就不需要向堆再申请了,他要是申请小的span我们还可以切分,这样就可以很好的解决外碎片的问题,提高内存的利用率。

        但是我们不能通过span中的usecount来判断某个span是centralcache的还是pagecache的,因为当central cache刚向page cache申请到一个span时,这个span的_useCount就是等于0的,这时可能当我们正在对该span进行切分的时候,page cache就把这个span拿去进行合并了,这显然是不合理的。

        所以我们可以在span中再添加一个成员,用于标记这个span是否正在使用。当这个span被分配给了centralcache,我们就将他设置为true,当centralcache将这个span归还给pagecache中再将他设置为false

struct Span
{
    ......
	bool _isUse = false; //默认是未被使用
};

pagecache模块中span合并实现: 

要注意合并时,我们还需要判断一下合并后的页数是否大于128,如果大于128我们要停止合并,因为我们最大可以维护的span的页数就是128,其次,在合并完成后要注意将原有的span从对应桶中删除,然后释放掉它对应的span结构,避免内存泄漏

void PageCache::ReleaseSpanToPageCache(Span* span)
{
	//合并span的前面的页
	while (1)
	{
		PAGE_ID prev_id = span->id - 1;
		//没找到前一页,就结束合并
		auto ret =_idSpanMap.find(prev_id);
		if (ret == _idSpanMap.end())
		{
			break;
		}
		
		Span* prevspan = ret->second;

		//上一页对应的span还在使用,结束合并
		if (prevspan->_isUse == true)
		{
			break;
		}
		//加上上一页对应的span大于128页,结束合并
		if (prevspan->n + span->n > 128)
		{
			break;
		}
		
		span->id = prevspan->id;
		span->n += prevspan->n;
		//将prevspan从原来的位置删除
		_spanLists[prevspan->n].Erase(prevspan);
		delete prevspan;
	}
	//向后合并
	while (1)
	{
		PAGE_ID next_id = span->id+span->n;
		//没找到下一页,就结束合并
		auto ret = _idSpanMap.find(next_id);
		if (ret == _idSpanMap.end())
		{
			break;
		}

		Span* nextspan = ret->second;

		//下一页对应的span还在使用,结束合并
		if (nextspan->_isUse == true)
		{
			break;
		}
		//加上下一页对应的span大于128页,结束合并
		if (nextspan->n + span->n > 128)
		{
			break;
		}
		span->n += nextspan->n;
		//将prevspan从原来的位置删除
		_spanLists[nextspan->n].Erase(nextspan);
		delete nextspan;
	}
	//将合并后的span重新挂到合适位置
	_spanLists[span->n].PushFront(span);
	span->_isUse = false;
}

九、大于256K的大内存对象申请

内存申请

threadcache只能申请内存小于256k的对象,如果对象大小大于256K的话, 如果内存对齐后,页数小于128页,可以直接向pagecache中申请,如果大于128页就只能向堆申请了

第一步需要改变一下SizeClass中Roundup的对齐规则,添加上对大于256k内存的对齐,让他按页大小对齐

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 <= 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
	{
		//申请的内存大于256kb
		//按页对齐
		return _Roundup(size, 1 << PAGE_SHIFT);
	}
}

接下来我们在申请内存时可以进行判断,如果申请的size小于256K,那就然他通过threadcache来申请内存,否则,我们需要先计算出他的对齐后的字节数,计算出要申请的页大小,然后直接通过NewSpan来申请一段空间。

static void* ConcurrentAlloc(size_t size)
{
	//  128*8*1024>=size>256*1024  向pagecache申请
	//  size>128*8*1024 向堆申请
	if (size>MAX_BYTES)
	{
		size_t alignsize = SizeClass::Roundup(size);
		size_t kpage = alignsize >> PAGE_SHIFT;

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

		void* ptr =(void*)(span->id << PAGE_SHIFT);
		return ptr;
	}
	else
	{
		if (pTLSThreadCache == nullptr)
		{
			static ObjectPool<ThreadCache> _objThreadPool;
			pTLSThreadCache = _objThreadPool.New();
		}
		//std::cout << std::this_thread::get_id() << " " << pTLSThreadCache << std::endl;
		return pTLSThreadCache->Allocate(size);
	}
}

上面说过size大于256k存在两种情况,当页数小于128可以直接在pagecache申请内存,否则只能向堆申请内存,所以还需要改变一下NewSpan的函数,添加一下在堆申请的逻辑,要注意直接向堆申请span的时候也需要将span的页号和span建立映射关系,方便释放内存的时候可以找到对应的span

Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0);
	if (k > NPAGES-1)
	{
		void* ptr = SystemAlloc(k);
		//Span* span = new Span;
		Span* span = _objSpanPool.New();
		span->id = (PAGE_ID)ptr >> PAGE_SHIFT;
		span->n = k;

		_idSpanMap[span->id] = span;
		return span;
	}
	//首先看一下k页对应的桶是否还有桶
	if (!_spanLists[k].Empty())
	{
		Span* kSpan= _spanLists[k].PopFront();
		//将k页的span的每一页与kspan做好映射关系,方便内存归还
		for (size_t i = 0; i < kSpan->n; i++)
		{
			_idSpanMap[kSpan->id + i] = kSpan;
		}
		return kSpan;
	}

	for (size_t i = k + 1; i < NPAGES; i++)
	{
		//如果存在span的话就切分
		if (!_spanLists[i].Empty()) 
		{
			Span* nSpan = _spanLists[i].PopFront();
			Span* kSpan = _objSpanPool.New();

			kSpan->id = nSpan->id;
			kSpan->n = k;

			nSpan->id += k;
			nSpan->n -= k;

			//将剩余的部分重新挂接到合适的桶
			_spanLists[nSpan->n].PushFront(nSpan);
			//将k页的span的每一页与kspan做好映射关系,方便内存归还
			for (size_t i = 0; i < kSpan->n; i++)
			{
				_idSpanMap[kSpan->id + i] = kSpan;
			}

			//将要nSpan的起始页和最终页与nSpan做好映射关系
			_idSpanMap[nSpan->id] = nSpan;
			_idSpanMap[nSpan->id + nSpan->n - 1] = nSpan;
			return kSpan;
		}
	}

	//到这里说明后面都没有span,那就需要到堆上申请
	void* ptr = SystemAlloc(NPAGES-1);
	Span* bigspan = _objSpanPool.New();
	bigspan->id = (PAGE_ID)ptr >> PAGE_SHIFT;
	bigspan->n = NPAGES-1;
	_spanLists[NPAGES - 1].PushFront(bigspan);
	return NewSpan(k);
}
 内存释放

当释放对象时,需要判断它的大小,如果小于256k,那就让他还给threadcache然后逐层向上归还,否则,直接调用ReleaseSpanToPageCache函数

static void ConcurrentFree(void* ptr, size_t size)
{
	if (size > MAX_BYTES) //大于256KB的内存释放
	{
		Span* span = PageCache::GetInstance()->MapObjectToSpan(ptr);

		PageCache::GetInstance()->_page_mtx.lock();
		PageCache::GetInstance()->ReleaseSpanToPageCache(span);
		PageCache::GetInstance()->_page_mtx.unlock();
	}
	else
	{
		assert(pTLSThreadCache);
		pTLSThreadCache->Deallocate(ptr, size);
	}
}

对于大于256K的对象,首先通过它的起始地址找到对应的span,判断他的大小是否大于128页,如果是的话就需要直接还给堆,否则,就让这个空间继续向两边合并,最后重新挂到pagecache合适的桶下面。

void PageCache::ReleaseSpanToPageCache(Span* span)
{
	//处理直接向堆申请的内存
	if (span->n > NPAGES - 1)
	{
		void* ptr = (void*)(span->id << PAGE_SHIFT);
		SystemFree(ptr);
		_objSpanPool.Delete(span);
		return;
	}
	
	//合并span的前面的页
	while (1)
	{
		PAGE_ID prev_id = span->id - 1;
		//没找到前一页,就结束合并
		auto ret =_idSpanMap.find(prev_id);
		if (ret == _idSpanMap.end())
		{
			break;
		}
		
		Span* prevspan = ret->second;

		//上一页对应的span还在使用,结束合并
		if (prevspan->_isUse == true)
		{
			break;
		}
		//加上上一页对应的span大于128页,结束合并
		if (prevspan->n + span->n > 128)
		{
			break;
		}
		
		span->id = prevspan->id;
		span->n += prevspan->n;
		//将prevspan从原来的位置删除
		_spanLists[prevspan->n].Erase(prevspan);
		_objSpanPool.Delete(prevspan);
	}
	//向后合并
	while (1)
	{
		PAGE_ID next_id = span->id+span->n       ;
		//没找到下一页,就结束合并
		auto ret = _idSpanMap.find(next_id);
		if (ret == _idSpanMap.end())
		{
			break;
		}

		Span* nextspan = ret->second;

		//下一页对应的span还在使用,结束合并
		if (nextspan->_isUse == true)
		{
			break;
		}
		//加上下一页对应的span大于128页,结束合并
		if (nextspan->n + span->n > 128)
		{
			break;
		}
		span->n += nextspan->n;
		//将prevspan从原来的位置删除
		_spanLists[nextspan->n].Erase(nextspan);
		_objSpanPool.Delete(nextspan);
	}
	//将合并后的span重新挂到合适位置
	_spanLists[span->n].PushFront(span);
	span->_isUse = false;
	_idSpanMap[span->id] = span;
	_idSpanMap[span->id+span->n-1] = span;
}

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

 我们写的高并发内存池的目标是在高并发场景下,可以替代malloc,实现更高效的内存管理,所以项目中向堆申请内存不能使用new,因为new的底层就是malloc封装实现的。

我们可以引入项目开头写的定长内存池,项目中使用new的场景基本上都是在new Span对象,所以我们可以在pagecache中添加一个定长内存池,让他专门负责span的申请与释放

class PageCache
{
private:
    ......
	ObjectPool<Span> _objSpanPool;
};

然后在new对象时调用定长内存池的New成员函数,在释放内存时调用Delete成员函数。

//申请span对象
_objSpanPool.New();

//释放span对象
_objSpanPool.Delete(span);

另外每个线程的threadcache也是new出来的,所以也给他添加一个定长内存池,这里把他设置为静态的,全局只有一份,让所有线程都在这个定长内存池中申请threadcache

if (pTLSThreadCache == nullptr)
{
	static ObjectPool<ThreadCache> _objThreadPool;
	pTLSThreadCache = _objThreadPool.New();
}
 pTLSThreadCache->Allocate(size);

十一、释放内存不传大小

我们在释放内存时需要传入对象的地址和给他它的大小,因为需要根据这个大小,判断这个内存是要通过threadcache逐步归还,还是直接调用ReleaseSpanToCache归还给pagecache或者还给堆。对于小于256k的对象,我们还需要根据他找到对应的桶,逐步向上归还。

想让我们自己的释放函数与free传参相同,不用传对象的大小,就需要建立一个对象地址到对象大小的映射,而我们可以通过地址找到对应的span,而span中自由链表中挂的内存块大小都是相同的,所以可以在span中添加一个成员objsize用于保存切分的内存块的大小。

所有的span都是通过NewSpan函数申请而来的,所以我们需要维护objsize的地方有两处,第一个是CentralCache调用NewSpan获取span,第二个是申请大于256k内存获取span

Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumPageSize(size));
span->_objSize = size;

当释放内存时,就不需要传对象的大小了,可以直接通过地址找到对应的span,继而找到这个span切分的内存块大小

static void ConcurrentFree(void* ptr)
{
	Span* span = PageCache::GetInstance()->MapObjectToSpan(ptr);
	size_t size = span->_objsize;
	if (size > MAX_BYTES)
	{
		PageCache::GetInstance()->_page_mtx.lock();
		PageCache::GetInstance()->ReleaseSpanToPageCache(span);
		PageCache::GetInstance()->_page_mtx.unlock();
	}
	else
	{
		pTLSThreadCache->Deallocate(ptr, size);
	}
}
访问映射关系线程安全问题

我们保存映射关系的结构是保存在pagecache中的,对于当前代码来说,在pagecache模块中访问这个映射关系是线程安全的,进到这个模块一定加锁了,可以保证只有一个线程在访问映射关系,但是在centralcache模块的归还内存和ConcurrentPool中释放内存都需要访问这个映射关系,此时就需要加锁了。外部访问映射关系都是通过这个接口,所以我们对这个接口加一个锁就可以了

Span* PageCache::MapObjectToSpan(void* obj)
{
	PAGE_ID id = (PAGE_ID)obj>> PAGE_SHIFT;
	std::unique_lock<std::mutex> lock(_page_mtx);
	auto ret = _idSpanMap.find(id);
	if (ret != _idSpanMap.end())
	{
		return ret->second;
	}
	return nullptr;
}

十二、多线程下与malloc性能对比

测试代码:

#include"ConcurrentPool.h"
// ntimes 一轮申请和释放内存的次数
// rounds 轮次
using std::cout;
using std::endl;

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轮次,每轮次concurrent alloc %u次: 花费:%u ms\n", nworks, rounds, ntimes, malloc_costtime.load());
	printf("%u个线程并发执行%u轮次,每轮次concurrent dealloc %u次: 花费:%u ms\n", nworks, rounds, ntimes, free_costtime.load());
	printf("%u个线程并发concurrent alloc&dealloc %u次,总计花费:%u ms\n", nworks, nworks * rounds * ntimes, malloc_costtime.load() + free_costtime.load());
}

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.load());
	printf("%u个线程并发执行%u轮次,每轮次concurrent dealloc %u次: 花费:%u ms\n",nworks, rounds, ntimes, free_costtime.load());
	printf("%u个线程并发concurrent alloc&dealloc %u次,总计花费:%u ms\n",nworks, nworks * rounds * ntimes, malloc_costtime.load() + free_costtime.load());
}

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

参数介绍:

  • ntimes:每一次申请和释放内存的次数
  • nworks:执行流的个数
  • rounds:执行的轮数

 在测试函数中,我们通过clock函数分别获取到每轮次申请和释放所花费的时间,然后将其对应累加到malloc_costtime和free_costtime上。最后我们就得到了,nworks个线程跑rounds轮,每轮申请和释放ntimes次,这个过程申请所消耗的时间、释放所消耗的时间、申请和释放总共消耗的时间。

  注意,我们创建线程时让线程执行的是lambda表达式,而我们这里在使用lambda表达式时,以值传递的方式捕捉了变量k,以引用传递的方式捕捉了其他父作用域中的变量,因此我们可以将各个线程消耗的时间累加到一起。

  我们将所有线程申请内存消耗的时间都累加到malloc_costtime上, 将释放内存消耗的时间都累加到free_costtime上,此时malloc_costtime和free_costtime可能被多个线程同时进行累加操作的,所以存在线程安全的问题。鉴于此,我们在定义这两个变量时使用了atomic类模板,这时对它们的操作就是原子操作了。

固定内存申请释放性能比较

首先先将模式调为Release,先测试申请固定内存的对象,线程数量为4个一轮申请释放一万个对象,一共10轮

v.push_back(ConcurrentAlloc(16));

v.push_back(malloc(16));

一共申请释放了10万个对象,可以发现,malloc的申请释放效率还是很高的,甚至都超越我们的高并发内存池了,这是因为我们每次申请释放对象都是在一个桶中,当多个线程的threadcache桶中内存块过长或者不足时都会向centralcache的一个桶竞争,此时桶锁就没有意义了,大量的时间都是浪费在锁的竞争上,相比于固定内存对象的申请,申请释放变化内存的对象我们的高并发内存池有着更高的效率

变化内存申请释放性能比较 

条件于上面的测试相同,只改变了申请内存的大小

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

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

 很明显我们的高并发内存池效率比malloc更快了一点点

性能瓶颈问题 

我们通过vs的性能分析工具来看一下我们的项目是哪里最消耗时间,首先点击 "测试",点击 "性能探查器",再选择 "检测" ,然后确定

可以发现最占时间的其实是锁

我们在点开看详细看一下,发现是unique_lock这个锁最消耗时间

而使用unique_lock的地方就是MapObjectToSpanz这个函数,即我们当前项目的性能瓶颈就出在MapObjectToSpan这个函数上

所以我们需要解决调用 MapObjectToSpanz 获取映射关系的加锁问题,tcmalloc中基于这个问题使用了基数树来进行优化,可以在获取这个映射时不需要加锁,这样就解决了锁竞争导致的性能下降

十三、使用基数树进行优化

基数我们可以把他看做一个分层的哈希表,通过分层的数量可以分为单层基数树、二层基数树和三层基数树

单层基数树

单层基数树采用的是直接定址法,页号对应的span就存储在数组对应的下标的位置,

// Single-level array
template <int BITS>
class TCMalloc_PageMap1 {
private:
	static const int LENGTH = 1 << BITS;
	void** array_;
public:
	typedef uintptr_t Number;
	explicit TCMalloc_PageMap1() {
		size_t size = sizeof(void*) << BITS;//需要申请的内存大小
		size_t alignsize = SizeClass::_Roundup(size, 1 << PAGE_SHIFT);//按页对齐后的大小
		array_ = (void**)SystemAlloc(alignsize >> PAGE_SHIFT);
		memset(array_, 0, sizeof(void*) << BITS);
	}
	// Return the current value for KEY. Returns NULL if not yet set,
	// or if k is out of range.
	void* get(Number k) const {
		if ((k >> BITS) > 0) {
			return NULL;
		}
		return array_[k];
	}
	// REQUIRES "k" is in range "[0,2^BITS-1]".
	// REQUIRES "k" has been ensured before.
	//
	// Sets the value 'v' for key 'k'.
	void set(Number k, void* v) {
		array_[k] = v;
	}
};

代码中的非类型模版参数BITS表示保存所有页号需要的位数,类成员LENGTH表示页号的数量。以32位机器下,页大小为8K举例,页的数量一共有2^32 / 2^13 =2^19 个,32位机器下的指针大小为4个字节,所以单层基数树开辟数组所占空间位 2^19 * 4 = 2^21 = 2M的内存,不是很大,所以32位机器使用单层基数树就可以了,但是64为机器就不行了,页的数量一共有2^51个,需要开辟数组的大小为 2^54=2^24G 的内存,是不合理的,64位机器需要使用3层基数树

二层基数树

二层基数树是将映射分为了两层,还以32位机器,页大小为8k举例,此时一共需要19个比特位来映射页号,二层基数树可以以前5个字节作为第一层映射,将后14个字节作为第二层映射

开辟第一层所需大小小为 2^5 * 4 = 2^7 字节,开辟第二层所需的大小就为2^5 * 2^14 * 4 = 2^21字节,也是2M,他与单层基数树所需的空间是一致的,但是与单层基数树不同的是,使用二层基数树刚开始可以只开辟第一层的空间,当需要开辟第二层的空间时在开辟,可以节省空间。

所以二层基数树提供了一个函数Ensure,这个函数需要再开辟第二层空间时调用,由于32位下,二层基数树所需的空间一共才2M,我们可以将他放到构造函数中,直接将两层的空间一起开辟好

// Two-level radix tree
template <int BITS>
class TCMalloc_PageMap2 {
private:
	// Put 32 entries in the root and (2^BITS)/32 entries in each leaf.
	static const int ROOT_BITS = 5;
	static const int ROOT_LENGTH = 1 << ROOT_BITS;
	static const int LEAF_BITS = BITS - ROOT_BITS;
	static const int LEAF_LENGTH = 1 << LEAF_BITS;
	// Leaf node
	struct Leaf {
		void* values[LEAF_LENGTH];
	};
	Leaf* root_[ROOT_LENGTH]; // Pointers to 32 child nodes
	void* (*allocator_)(size_t);// Memory allocator
public:
	typedef uintptr_t Number;
	explicit TCMalloc_PageMap2() {
		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) {
				static ObjectPool<Leaf> leafpool;
				Leaf* leaf = (Leaf*)leafpool.New();
				if (leaf == NULL) return false;
				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);
	}
};
三层基数树

三层基数树的原理与二层基数树相同,将映射分为了3层,此时我们就不能将所有空间直接开辟好了,因为在64位机器下所需要的空间是很大的,我们在建立映射关系前需要提前确保对应的空间开辟好。

//三层基数树
template <int BITS>
class TCMalloc_PageMap3
{
private:
	static const int INTERIOR_BITS = (BITS + 2) / 3;       //第一、二层对应页号的比特位个数
	static const int INTERIOR_LENGTH = 1 << INTERIOR_BITS; //第一、二层存储元素的个数
	static const int LEAF_BITS = BITS - 2 * INTERIOR_BITS; //第三层对应页号的比特位个数
	static const int LEAF_LENGTH = 1 << LEAF_BITS;         //第三层存储元素的个数
	struct Node
	{
		Node* ptrs[INTERIOR_LENGTH];
	};
	struct Leaf
	{
		void* values[LEAF_LENGTH];
	};
	Node* NewNode()
	{
		static ObjectPool<Node> nodePool;
		Node* result = nodePool.New();
		if (result != NULL)
		{
			memset(result, 0, sizeof(*result));
		}
		return result;
	}
	Node* root_;
public:
	typedef uintptr_t Number;
	explicit TCMalloc_PageMap3()
	{
		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);                    //第三层对应的下标
		Ensure(k, 1); //确保映射第k页页号的空间是开辟好了的
		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); //第二层对应的下标
			if (i1 >= INTERIOR_LENGTH || i2 >= INTERIOR_LENGTH) //下标值超出范围
				return false;
			if (root_->ptrs[i1] == NULL) //第一层i1下标指向的空间未开辟
			{
				//开辟对应空间
				Node* n = NewNode();
				if (n == NULL) return false;
				root_->ptrs[i1] = n;
			}
			if (root_->ptrs[i1]->ptrs[i2] == NULL) //第二层i2下标指向的空间未开辟
			{
				//开辟对应空间
				static ObjectPool<Leaf> leafPool;
				Leaf* leaf = leafPool.New();
				if (leaf == NULL) return false;
				memset(leaf, 0, sizeof(*leaf));
				root_->ptrs[i1]->ptrs[i2] = reinterpret_cast<Node*>(leaf);
			}
			key = ((key >> LEAF_BITS) + 1) << LEAF_BITS; //继续后续检查
		}
		return true;
	}
	void PreallocateMoreMemory()
	{}
};

基于单层基数树进行优化

假设我们当前的环境时32位,我们就使用单层基数树替换之前我们构建映射关系的哈希表

class PageCache
{
    ....
private:
    ....
	//std::unordered_map<PAGE_ID, Span*> _idSpanMap;
	TCMalloc_PageMap1<32 - PAGE_SHIFT> _idSpanMap;
};

当我们需要构建映射关系时可以调用set方法 :

_idSpanMap.set(span->id, span);

当需要靠页号来获取对应span时可以调用get方法:

auto ret = _idSpanMap.get(id);

另外在MapObjectToSpan函数中我们也不需要加锁处理了

Span* PageCache::MapObjectToSpan(void* obj)
{
	PAGE_ID id = (PAGE_ID)obj>> PAGE_SHIFT;
	/*std::unique_lock<std::mutex> lock(_page_mtx);
	auto ret = _idSpanMap.find(id);
	if (ret != _idSpanMap.end())
	{
		return ret->second;
	}*/
	auto ret = _idSpanMap.get(id);
	if (ret != nullptr)
	{
		return (Span*)ret;
	}
	return nullptr;
}

为什么不需要加锁处理了?

在一个线程获取映射关系时,其他的线程可能正在建立其他的页号和span的映射关系,之前我们保存映射关系所使用的结构是unordered_map,我们也可以使用map,map的底层结构为红黑树,unordered_map的底层结构为哈希表,但不论是哪个,在插入数据时都可能发生结构的改变,例如红黑树可能会发生旋转,哈希表可能会发生扩容等等,此时线程在获取映射关系时可能或取到错误的信息,所以需要加锁处理。

但是对于基数树来说,在获取映射关系时,它的底层空间就是开辟好的,并且它是不会改变的,所以无论什么时候获取映射关系都是在固定的位置获取。并且我们的读取和写入操作时分离的,不能能同时读取和写入同一个映射,因为只有在在释放内存时才会获取映射关系(通过span获取释放空间的大小、centralcache通过映射还给对应的span),而只有在pagecache中才会建立映射(切分span分给centralcache、span归还合并后重新构建映射),也就是说获取映射的span都是正在使用的,而建立映射的span当时还是没有被使用的,所以读取和写入映射关系是分离的。

  重新与malloc进行性能比较

   在Release环境下,申请固定长度大小对象的性能比较

申请变化长度对象的性能比较,此时我们的高并发内存池的效率变为了malloc的好几倍

项目源码:张得帅c/高并发内存池项目 

;