高并发内存池项目

目录

1. 项目介绍

2. 池化技术

2.1. 内存池

2.2. 内碎片和外碎片

2.2.1. 内碎片

2.2.2. 外碎片

2.3. malloc 的了解

3. 定长内存池的实现

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

5. 申请内存逻辑

5.1. Thread Cache 的结构设计

5.2. Thread Cache 哈希桶的对齐规则 

5.3. Thread Cache TLS 无锁访问

5.4. Central Cache 的整体设计

5.5. Central Cache 的结构设计 

5.6. Central Cache 的核心实现

5.7. Page Cache 的整体设计

5.8. Page Cache 中获取Span

5.9. 申请内存过程联调

6. 释放内存逻辑

6.1. Thread Cache 回收内存

6.2. Central Cache 回收内存

6.3. Page Cache 回收内存

6.4. 释放内存过程联调

7. 大于 256 kb的内存申请和释放

8. 使用定长内存池代替 new 和 delete

9. 释放对象时优化为不穿对象大小

10. 性能瓶颈分析

11. 使用基数树进行优化

11.1 基数树的了解

11.2. 基数树的实现

11.3. 优化后的性能测试

12. 源码 


1. 项目介绍

这个项目是一个高并发内存池项目,其原型是 Google 的一个开源项目 TCMalloc,其全称为Thread Caching Malloc,即线程缓存的 malloc,旨在提供高效的多线程内存管理,可以用它替代语言提供的内存分配相关的接口 (malloc、 free)。

TCMalloc 的源码内容非常多,有非常复杂的细节设计,我们在这里不可能全部实现,因此,我们的宗旨就是:学习和理解 TCMalloc 最核心的框架设计,并模拟实现一个 mini 版的高并发内存池。

TCMalloc 是 Google 的开源项目,可以这样认为,这个项目是由当时顶尖的 C++ 高手实现的,学习它有非常多的好处,同时这个项目对内存的处理非常细节,一不小心,就会导致程序 crash,且是多线程的程序,一旦出错,查错的成本非常高,因此,在实现的过程中,一定要小心,小心,再小心。

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

2. 池化技术

对于池化技术,我们已经见过了很多次,诸如进程池、线程池,再加这里的内存池,其核心思想就是为了避免频繁的创建与销毁对象所带来的额外成本, 进而提高效率。

2.1. 内存池

内存池就是程序会预先申请一块空间,程序在运行过程中:

  • 当需要申请内存时,不是直接向操作系统申请内存,而是向内存池获取一块内存;
  • 当需要释放内存时,并不是将内存归还给操作系统,而是将这段内存返回给内存池,只有当进程退出后,这段空间才会全部还给操作系统。

这样带来的好处就是:

  • 其一,避免了频繁的申请和释放内存所带来的性能开销,提高了效率;
  • 其二,因为这段内存空间在程序运行期间不会归还给操作系统,即可以反复使用,因此提高了内存的复用率;
  • 其三,这块内存是一段连续的大块内存,可以很好的减少内存碎片 (外碎片),同时,可以提高缓存命中率,进而提高内存的访问效率。

2.2. 内碎片和外碎片

在上面,我们谈论了一个话题,即内存碎片,但实质上,内存碎片可以分为两者情况:

  • 内碎片:内碎片是指内存块内部中有一部分空间没有被使用;
  • 外碎片:当系统进行动态内存分配(如 malloc 和 free)时,内存会被分成大小不一的碎片,虽然总的空闲内存足够大,但无法分配满足连续性要求的较大块内存。

2.2.1. 内碎片

比如,某个执行流要申请 7 byte 的空间,但由于内存池中某个规则,内存池返回了一个 8byte 的内存空间,此时,这种情况就属于一种内碎片,因为这个执行流不会使用最后一个 byte 的空间,即这个 byte 的空间就被浪费了, 如下:

2.2.2. 外碎片

比如,系统中还剩下 300 kb 的空间,但由于这段空间是零散分布的,即这段空间不连续,但此时某个执行流想要申请 256 kb 的空间,却申请不出来,这种情况就是外碎片,如下:

内存池不仅要解决效率问题,对于碎片问题也需要处理,处理方案:

  • 对于内碎片而言,不能完全避免,我们只能将内碎片中浪费空间的比率控制在一个范围中;
  • 对于外碎片而言,我们需要提供一种策略,将零散分布的内存合并成为一个更大的内存块。

具体细节,只有在项目的实现中详谈了。

2.3. malloc 的了解

在 C/C++ 中,都是通过 malloc 向堆申请动态资源,但是,malloc 也并不是直接向系统申请资源的,而是调用相关系统调用实现的。

实际上, malloc 本身也是一个内存池,其通过系统接口会向系统申请一段大空间,然后再将这段空间分配给程序使用。

3. 定长内存池的实现

接下来,我们就来先设计一个定长的内存池,其目的有两个:

  • 其一:让我们来了解一下内存池是如何管理内存对象的;
  • 其二:这里的定长内存池是项目中的一个基础组件。

内存池需要对外提供的功能,至少有两个,比如:

  • New,程序申请空间,从内存池中弹出一个内存对象;
  • Delete,程序释放空间,将一个内存对象添加到内存池中。

第一个问题,我们如何表示这个定长内存池呢?

方案一:通过非类型的模板参数表示定长内存池

template <size_t size>
class object_pool
{};

采用非类型的模板参数表示定长内存池,即每次申请和释放内存时,都是一个 size 大小的内存对象。

方案二:通过类型的模板参数表示定长内存池

template <class T>
class object_pool
{};

方案二也是一个定长的内存池,每次申请和释放内存时,都是一个 sizeof(T) 大小的内存对象。

我们采用方案二实现定长内存池,假设下面这段空间就是内存池所要管理的空间,如下:

那么内存池是不是应该有一个成员属性,标识这段大块内存呢? 是的。如下:

template <class T>
class object_pool
{
private:
    // 大块内存的起始位置
    void* _memory;   
};

但是,我们知道,这段空间是由内存池管理的,内存池会将这段内存分配给程序使用,换言之,这个大块内存是会变化的 (切割内存对象或者增添内存对象),那么这里的 _memory 就会存在指针的移动,如果我们将其类型定义为 void* ,而 void* 类型的指针变量无法进行加减运算,故我们将其定义为 char*,因为 char* 的指针变量 +- pos 正好移动 pos 个字节,更方便移动,如下:

template <class T>
class object_pool
{
private:
    // 大块内存的起始位置
    char* _memory;  
};

内存池分配内存对象

当程序需要内存时,就需要调用内存池提供的接口,内存池会返回一个内存对象,那么最开始的时候,不就是将这个大块内存切割一个内存对象返回给程序吗? 就好比下面的过程:

可以看到,程序申请内存时,内存池的处理很简单,只需要进行指针的移动即可。

既然存在程序申请内存的情况,那么也存在程序释放内存的情况,释放内存不就是将内存单元对象返回给内存池吗?

那么,难道说,释放内存的逻辑也是对 _memory 进行指针的移动吗? 可是,这种是不好控制的,如下:

如果此时要归还的是D这个内存对象,那么还好说,_memory -= sizeof(T),即 _memory 向前移动一个 T 类型的大小,就成功的将这个内存对象归还给了内存池,没有问题。

可是,如果要归还的是A、B、C这三个对象呢? 此时单纯的进行指针移动就不能正确的归还对象了。

因此,我们发现,当程序归还内存对象给内存池时,内存池是需要组织这些还回来的内存对象,而为了不增添额外的数据结构的负担,这里采用了一种非常妙的方案,通过一个 _freeList 来组织这些内存对象,我们还是借助上面的例子,比如此时要归还A、B、C三个对象,那么如何组织呢?如下图所示:

让每一个内存对象的前4/8字节的内容为下一个内存对象的起始地址,这样,就可以将所有还回来的内存对象组织起来,这个结构,我们称之为自由链表,它不存储任何额外的数据,只需要维持这些内存对象的关系即可,这个成员我们用 _freeList 表示,如下:

template <class T>
class object_pool
{
private:
    // 大块内存的起始位置
	char* _memory;  
    // 将归还回来的内存对象, 通过这个 _freeList 组织起来
	void* _freeList; 
};

既然要求每个内存对象的前4/8字节存储下一个内存对象的起始地址,那么前提是不是要求这个内存对象的大小至少是4/8字节呢? 因此,我们可以提供一个成员属性,标识每个内存对象的最小字节数,如下:

template <class T>
class object_pool
{
private:
	// 大块内存的起始位置
	char* _memory;   
	// 将归还回来的内存对象, 通过这个 _freeList 组织起来
	void* _freeList; 
	// 每个内存对象的最小字节数, 因为我们需要保证每个单元都能够存储一个指针(32位和64位下都满足)
	size_t _minSize;
};

那么这个 _minSize 如何初始化呢?

  • 方案一: 条件编译;
    • 通过条件编译,32位下是4byte,64位下是8byte。
  • 方案二: 指针的大小。
    • 一个指针的大小,在32位下是4byte,64位下是8byte。

我们采用方案二。

当程序向内存池申请空间时,内存池所管理的内存如果还可以切分一个内存对象,那么就切分即可,如果不能切割呢? 内存池是不是应该继续重新开辟一块连续的大空间,以便后序分配给程序使用,可是,内存池如何知道当前所管理的内存块还能不能切分内存对象呢?

因此,我们可以记录一个值,表明当前内存块还剩多少空间,如果不能分配一个内存对象,那么内存池会重新开辟一块空间,如下:

template <class T>
class object_pool
{
private:
	// 大块内存的起始位置
	char* _memory;   
	// 将归还回来的内存对象, 通过这个 _freeList 组织起来
	void* _freeList; 
	// 每个内存对象的最小字节数, 因为我们需要保证每个单元都能够存储一个指针(32位和64位下都满足)
	size_t _minSize;
	// 标定当前大块内存的剩余字节数
	size_t _surplusSize;
};

内存池在开辟一段连续的空间时,这段空间多大呢? 这个就因人而异了,我的选择是,每次内存池开辟的连续空间的大小为 128 kb,这个不是一定的,可以变化,如下:

template <class T>
class object_pool
{
private:
	// 大块内存的起始位置
	char* _memory;   
	// 将归还回来的内存对象, 通过这个 _freeList 组织起来
	void* _freeList; 
	// 每个内存对象的最小字节数, 因为我们需要保证每个单元都能够存储一个指针(32位和64位下都满足)
	size_t _minSize;
	// 标定当前大块内存的剩余字节数
	size_t _surplusSize;
	// 每个大块内存的固定字节数 (初始情况下), 在这里约定为 128 kb.
	const static size_t _maxSize = 128 * 1024;
};

上面就是我们的定长内存池的基础属性,我们可以写一个构造函数初始化这些成员属性,如下:

template <class T>
class object_pool
{
public:
	object_pool() :_memory(nullptr), _freeList(nullptr), _surplusSize(0), _minSize(sizeof(void*)) {}
private:
	// 大块内存的起始位置
	char* _memory;   
	// 将归还回来的内存对象, 通过这个 _freeList 组织起来
	void* _freeList; 
	// 每个内存对象的最小字节数, 因为我们需要保证每个单元都能够存储一个指针(32位和64位下都满足)
	size_t _minSize;
	// 标定当前大块内存的剩余字节数
	size_t _surplusSize;
	// 每个大块内存的固定字节数 (初始情况下), 在这里约定为 128 kb.
	const static size_t _maxSize = 128 * 1024;
};

对于一个内存对象而言,它最小应该是能够存储一个指针的大小(无论是32位还是64位的程序),因此,我们可以通过一个接口获取当前内存对象的字节数,如下:

// 获取内存单元的字节数, 保证这个内存单元能够存储一个指针, 无论是在32位, 还是64位下.
// 在满足能够存储一个指针的前提, 这个内存单元就是类型的大小. 
// 换言之:
// 如果此时这个类型 < 指针的大小, 那么就需要向上调整, 以满足每个内存单元能够存储一个指针.
// 如果此时这个类型 >= 指针的大小, 那么内存单元的大小就是类型的大小.
inline size_t GetMemoryUnitSize() const
{
	return _minSize > sizeof(T) ? _minSize : sizeof(T);
}

内存池开辟一段连续的空间,通过什么接口开辟呢? 我们想要脱离 malloc 和 free,直接通过系统调用实现,并对其进行一层封装,如下:

// 封装系统接口
// 以页为单位申请内存
inline static void* SystemAlloc(size_t page_num)
{
#ifdef _WIN32
    // 这里我们约定 1 page = 1 << 13, 故这里的 PageShift 就是 13
	void* ptr = VirtualAlloc(0, page_num << PageShift, MEM_COMMIT | MEM_RESERVE,
		PAGE_READWRITE);
#else
	// Linux下, brk mmap等
#endif
	if (ptr == nullptr)
		throw std::bad_alloc();
	return ptr;
}

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

有了上面的准备,我们就可以实现定长内存池的 New 和 Delete 接口了。

New, 程序向内存池申请空间,内存池返回一个内存对象,存在两种情况:

  • 内存池是管理一段内存空间,如果当前这段内存空间不存在或者这段内存空间不能再切分一个内存对象时,那么内存池需要重新开辟一段空间,并更新相关字段,切分一个内存对象返回;
  • 同时,我们知道,切分出去的每个内存对象归还给内存池的时候,内存池会通过 _freeList 将这些内存对象组织起来,因此,只要 _freeList 有对象,那么返回一个对象即可。

对于第一种情况,没什么说的,只是一个指针的移动,在这里解释一下第二种情况,假如当前自由链表 _freeList 中有 A、B、C三个内存对象,如下:

首先,毋庸置疑的是,我们需要弹出一个内存对象,就是 pop 一个内存对象,但由于这个链表是一个单链表,出于效率角度,我们选择 pop_front,那么如何弹出呢?

  1. 保存下一个内存对象,也就是 B 对象的地址;
  2. 获得第一个内存对象,即A对象,也就是_freeList;
  3. 更新_freeList, 将B对象的值给 _freeList;
  4. 返回A对象的地址。

如何获得下一个内存对象的地址?  每个内存对象的前4/8字节存储的就是下一个内存对象的地址,因此,我们需要访问每个内存对象前4/8字节的内容,可是,这里的 _freeList 的类型是 void* ,void* 的指针变量是不支持解引用的,且要求这个指针变量解引用访问的是4/8字节的内容,因此,我们可以采用如下的方案:

void* objNext = *(void**)_freeList;

虽然 void* 的指针变量不能解引用,但是 void** 的指针变量是可以解引用的,并且访问的是前4/8字节的内容,因此,我们就可以通过这样的方案访问每个内存对象的前4/8字节的内容,以便于获取下一个内存对象的地址。 

最后,当获取一个内存对象后,在返回之前,可以显式调用这个类型的构造函数 (定位new),初始化对象的资源。

我们的 New 实现如下:

// 每次 object_pool::New 时, 分配的是一个对象大小的空间
T* New()
{
	// 需要返回的目标内存单元
	T* obj;
	// 在获取内存单元时, 优先考虑自由链表中是否存在元素
	// 如果存在, 只需要将自由链表的元素返回即可, 这个动作就是 pop
	// 由于 _freeList 是单链表, 出于效率角度, 选择 pop_front
	if (_freeList)
	{
		// 保存下一个内存对象
		void* objNext = *(void**)_freeList;
		// 获得第一个元素		
		obj = (T*)_freeList;
		// 更新 _freeList
		_freeList = objNext;
	}
	else
	{
        // 如果当前内存块不足以切分一个内存对象时, 那么重新开辟一段新空间
		if (_surplusSize < GetMemoryUnitSize())
		{
			//_memory = (char*)malloc(_maxSize);
			_memory = (char*)Xq::SystemAlloc(_maxSize >> PageShift);
			if (!_memory)
			{
				throw std::bad_alloc();
			}
            // 设置当前空间的剩余字节数
			_surplusSize = _maxSize;
		}
		obj = (T*)_memory;
		// 大块内存向后移动一个内存单元的大小
		_memory += GetMemoryUnitSize();
		// 更新大块内存的剩余字节数
		_surplusSize -= GetMemoryUnitSize();
	}
	// 定位new, 显式调用构造, 初始化对象的资源
	new(obj)T;
	return obj;
}

在这里补充一点:
我们是通过一个内存对象的前4/8个字节存储下一个内存对象的地址,那么在使用这个内存对象时,这个内存对象的内容会被覆盖吗,答案,会覆盖。
但是,我们管理这个内存对象的时候 (在自由链表中),不存在覆盖,只有当这个内存对象切分出去 (给别人用的时候) 才会存在覆盖,因此不影响 _freeList 的管理。

Delete, 当程序使用完内存时,要归还内存对象,即将内存对象添加到内存池中。

我们说过,内存池需要通过 _freeList 将归还的内存对象组织起来,当一个内存对象归还给内存池时,这个动作不就是一个 push 的动作吗?是的,又因为,_freeList 是一个单链表,出于效率角度,我们选择 push_front,比如:

此时D就是要归还的内存对象,那么如何归还呢?

  • 首先显式调用这个类型的析构函数,对资源进行释放;
  • 将 D 对象的前4/8字节的内容存储为A对象;
  • 更新_freeList,即将D对象赋值给 _freeList; 

如下:

Delete 的实现如下: 

void Delete(T* obj)
{
	// 在删除之前, 需要对这个存储单元的资源进行释放
	obj->~T();
	// 删除的本质, 将这段内存单元 (obj) 通过 _freeList 维护起来, 也就是 push 的动作
	// _freeList 是一个单链表, 出于效率的角度, 我们选择头插
	*(void**)obj = _freeList;
	_freeList = obj;
}

至此,我们的定长内存池就实现好了,我们可以对其进行一个性能测试,代码如下:

struct TreeNode
{
	int _val;
	TreeNode* _left;
	TreeNode* _right;
	TreeNode() :_val(0), _left(nullptr), _right(nullptr){}
};

static void TestObjectPool()
{
	// 申请释放的轮次
	const size_t Rounds = 5;
	// 每轮申请释放多少次
	const size_t N = 1000000;
	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();

	Xq::object_pool<TreeNode> TNPool;
	std::vector<TreeNode*> v2;
	v2.reserve(N);
	size_t begin2 = clock();
	for (size_t j = 0; j < Rounds; ++j)
	{
		for (int i = 0; i < N; ++i)
		{
			v2.push_back(TNPool.New());
		}
		for (int i = 0; i < N; ++i)
		{
			TNPool.Delete(v2[i]);
		}
		v2.clear();
	}
	size_t end2 = clock();

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

调用这个接口,运行三次,得到如下结果:

 

可以看到,此时的定长内存池在申请/释放内存对象时比调用 malloc/free 时更快一点。 

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

当今的很多开发环境都是多核多线程,再申请/释放内存时,存在着线程安全问题,为了保证线程安全,需要加锁保证线程安全,故存在着激烈的锁竞争,这对性能的开销是非常大的,而 malloc 本身已经足够优秀,但在高并发场景下,高并发内存池有着更好的效率。

而这次我们所要实现的高并发内存池项目所要考虑的问题:

  • 性能问题;
  • 内存碎片问题;
  • 在多执行流场景下,锁竞争所带来的性能消耗问题。

高并发内存池项目大致可以分为三层框架,如图所示:

Thread Cache:每个线程都有一个独属于自己的 Thread Cache (TLS - Thread Local Storage),其用于小于等于256kb的内存申请/释放,因为 Thread Cache 是线程私有的,故不存在竞争,也就不存在线程安全问题,因此,线程从 Thread Cache 申请/释放内存不需要加锁,故并发能力高,这也就是高并发线程池高效的原因之一。

但 Thread Cache 并不能满足所有的内存申请和释放,情况有两种:

其一:对于大于 256kb 的内存申请和释放,Thread Cache 无法满足,需要直接访问 Page Cache 获取内存对象或者通过 system call 直接访问系统堆 ;

其二:如果当前执行流的 Thread Cache 已经没有内存了,此时就需要访问下一层,也就是 Central Cache 获取内存对象。

Central Cache:当执行流自己私有的 Thread Cache 没有内存了,那么就会访问 Central Cache,Central Cache 会返回一批的内存对象插入到当前执行流的 Thread Cache 中,Thread Cache 再返回一个内存对象。

同时,假如某个 Thread Cache 在程序运行中,堆积了大量的内存对象 (未使用的内存对象),那么 Central Cache 也会对这些对象进行回收,防止一个执行流占用太多内存,且不使用,导致其他执行流的内存吃紧,这样可以使多个线程尽可能均衡的按需调度的使用内存。

其次,Central Cache 是多个执行流共享的,那么在多线程场景下,多个执行流可能会因为自己私有的 Thread Cache 没有对象或者 Thread Cache 中的没有使用的对象太多,导致多个执行流同时访问 Central Cache,很显然,这里的 Central Cache 存在着竞争,即存在线程安全问题,因此需要加锁保护,但是,这里所用的锁是桶锁 (Central Cache 是一个哈希桶结构),只有当多个执行流访问同一个桶时,才会竞争锁资源,因此,这里的竞争不会太激烈,对性能消耗不高。

Page Cache:当执行流因为自己私有的 Thread Cache 没有内存时,会去访问 Central Cache,假如此时的 Central Cache 也没有内存呢? 那么这个执行流就需要在向下一层访问,也就是访问 Page Cache,Page Cache 会返回一个 Span 对象给 Central Cache,这个 Span 对象是由若干个连续页组成的内存块,Central Cache 获取这个 Span 对象后,会将其切割为若干个内存对象,并链入到 Central Cache 对应的桶中,在返回给 Thread Cache 一批的内存对象,再由 Thread Cache 返回一个内存对象给程序。

当 Thread Cache 把从 Central Cache 获取的内存对象全部归还给了 Central Cache,那么此时这个 Span 对象就是一个完整的由若干个页组成的大块内存,继而 Central Cache 会将这个 Span 对象返回给 Page Cache,让 Page Cache 对这个 Span 对象进行前后页的合并,进而组成更大的页,以缓解外碎片问题。

同时,与 Central Cache 一样,这里也存在着多个执行流同时访问 Page Cache 的情况,因此也存在着竞争,即有线程安全问题,因此也需要进行加锁保护,但是这里用什么锁,我们后续到了实现在讨论。

可以看到,高并发内存池本质上就是两个逻辑:申请和释放逻辑。

我们的实现思路是:先理解和实现申请逻辑,只要把申请内存的链路理解通透,释放逻辑就是水到渠成了。

5. 申请内存逻辑

5.1. Thread Cache 的结构设计

我们知道, Thread Cache 是为了解决小于等于256kb的内存申请和释放,因此,也存在着不同的内存需求 (8byte、16byte、24byte、......、256kb),因此,为了能够让 Thread Cache 满足众多的内存需求,这里的 Thread Cache 是一个哈希桶结构,每一个桶挂载的是不同大小的若干个内存对象,这里的若干个内存对象通过自由链表维护起来,如下:

毋庸置疑的是,Thread Cache 这个哈希桶中的每一个桶都是一个自由链表,这个自由链表用来管理相同大小的内存对象,因此我们可以编写一个类型用于专门标识自由链表结构,其次,对于自由链表来说,对于它的操作大致可以分为两种,push和pop,即插入一个内存对象和弹出一个内存对象, 它的框架如下:

class FreeList
{
public:
	// push: 将一个内存单元对象链入到链表中
	void push(void* obj) {}
	// pop: 从自由链表中弹出一个内存单元对象
	void* pop(void) {}
private:
	void* _freeList; // 自由链表的起始地址
};

首先,无论是 pop 还是 push 操作,都无法避免访问一个内存单元的前4/8字节的内容,我们将这个操作封装为一个接口,如下:

// 首先, 由于需要频繁的获得一个内存单元对象的前4/8字节, 故将其封装成一个接口
void* NextMemory(void* obj)
{
	return *(void**)obj;
}

同时,访问一个内存单元的前4/8字节的内容,既会涉及到读,也会涉及到写,因此,我们选择返回引用,如下:

// 首先, 由于需要频繁的获得一个内存单元对象的前4/8字节, 故将其封装成一个接口
// 同时, 既会涉及到读, 也涉及到写, 故选择引用返回
void*& NextMemory(void* obj)
{
	return *(void**)obj;
}

其次,这个接口可能会在多个源文件中引用,为了避免链接问题,将其符号限制为内部符号,即用 static 修饰该函数,如下:

// 首先, 由于需要频繁的获得一个内存单元对象的前4/8字节, 故将其封装成一个接口
// 同时, 既会涉及到读, 也涉及到写, 故选择引用返回
// 其次, 这个接口可能会在多个源文件中引用, 为了避免链接问题, 将其符号限制为内部符号, 即用static修饰该函数
static void*& NextMemory(void* obj)
{
	return *(void**)obj;
}

最后,NextMemory 会被频繁调用,为了避免频繁的建立和销毁函数栈帧,影响性能,故我们将其声明为 inline,当然,这只是一种建议,结果是由编译器决定的

// 首先, 由于需要频繁的获得一个内存单元对象的前4/8字节, 故将其封装成一个接口
// 同时, 既会涉及到读, 也涉及到写, 故选择引用返回
// 其次, 这个接口可能会在多个源文件中引用, 为了避免链接问题, 将其符号限制为内部符号, 即用static修饰该函数
// 最后, NextMemory会被频繁调用, 为了避免频繁的建立和销毁函数栈帧, 影响性能, 故我们将其声明为 inline
static inline void*& NextMemory(void* obj)
{
	return *(void**)obj;
}

push 如何实现呢?

首先,由于 _freeList 是一个单链表,出于效率角度,我们选择头插,具体步骤如下:

  • 将要插入的节点的前4/8字节的内容赋值为 _freeList;
  • 更新_freeList 即可。

实现如下:

// push: 将一个内存单元对象链入到链表中
void push(void* obj)
{
	// 要链入的内存单元对象不能为空
	assert(obj);
	// 将内存单元对象头插到链表中
	NextMemory(obj) = _freeList;
	// 更新 _freeList
	_freeList = obj;
}

pop 如何实现呢?

与 push 同理,我们选择头删,具体步骤如下:

  • 用变量记录当前的 _freeList,用以返回;
  • 更新 _freeList;
  • 注意:将当前要返回的节点的前4/8字节的内容置为空,因为此时这个节点的前4/8字节的内容还是下一个内存对象,为了避免一些不必要的问题,在这里最好处理一下。

实现如下:

// pop: 从自由链表中弹出一个内存单元对象
void* pop(void)
{
	// 自由链表不能为空
	assert(_freeList);
	// 保存要返回的内存单元对象
	void* ret = _freeList;
	// 更新自由链表的起始地址
	_freeList = NextMemory(_freeList);
	// 将要返回的前4/8字节的内容置为空
	NextMemory(ret) = nullptr;
	// 返回这个内存单元对象
	return ret;
}

我们说过, Thread Cache 是一个哈希桶结构,每个桶是一个自由链表,而现在自由链表已经实现了,那么 Thread Cache 的框架也就出来了,如下:

class ThreadCache
{
public:
	// 返回一个内存对象
	void* Allocate(size_t apply_size);
	// 回收一个内存对象
	void Deallocate(void* obj, size_t apply_size);
private:
	FreeList _freeList[???]; // 可是这个数组该多大呢?
};

上面有一个问题,这个数组该多大呢?即需要多少个自由链表呢?

我们知道,Thread Cache 是处理小于等于 256 kb 的内存申请和释放,如果对每个大小的内存对象都要有一个自由链表,那么就存在如下多的自由链表:

此时就需要创建 26w+ 个自由链表,这实在是太多了,因此,我们不能对每个大小的内存对象都建立一个自由链表,而应该以一种规则,减少桶的数量。

因此,Thread Cache 这个哈希桶中是需要存在着一套对齐规则的,对于特定范围内的内存申请,返回一定的字节数。举个例子: 比如程序要申请3byte、4byte、5byte、6byte、7byte的内存对象, Thread Cache 统一都返回 8byte 的内存对象 ,这样就极大的减少了桶 (自由链表) 的数量,但又带来了新的问题,比如程序要申请3byte,但是 Thread Cache 却返回了8byte 的内存对象,那么剩下的 5byte 就没有被使用,而这就是一种内存碎片,具体为内碎片,但我们说过,内碎片不能完全杜绝,只能将其控制在一定的范围内。

5.2. Thread Cache 哈希桶的对齐规则 

那么 Thread Cahe 这个哈希桶的对齐映射规则具体是如何实现的呢?规则如下:

                 内存申请的范围   向上的对齐数   当前范围内桶的范围     当前范围内桶的个数     
      [1 byte,   128 byte]         8 byte        [0,  16)                16
      [128 + 1 byte,   1024 byte]         16 byte        [16,  72)                56
      [1024 + 1 byte,   8 * 1024 byte]         128 byte        [72,  128)                56
      [8 * 1024 + 1 byte,   64 * 1024 byte]         1024 byte        [128,  184)                56
      [64 * 1024 + 1 byte,   256 * 1024 byte]         8 * 1024 byte        [184, 208)                24

在这里解释一下,为什么 [1, 128] 这个 range 中选择8byte对齐,而非4byte对齐。

这是因为我们实现的 FreeList 要求所有的内存对象都能够存储一个指针的大小,而指针在32位和64位程序中大小是不一样的,前者4byte,后者8byte,这里为了统一处理,在 [1, 128] 这个区间中选择8byte对齐。

上面这套对齐映射规则将桶的数量大大降低,此时一共会有 208 个桶,已经非常好了。

同时,这套规则的内碎片浪费会控制在 10% 左右, 第一个区间额外讨论,因为如果你要申请 1byte,它会返回 8byte,此时的浪费率就很高,但是,浪费的字节数确是很少的,此时才浪费 7个byte,这是可以接受的。

而对于其他的区间,比如 [128 + 1 byte,   1024 byte],如果此时要申请 129byte,那么按照 16byte 向上对齐,那么 Thread Cache 会返回一个 144byte 的内存对象,此时的浪费率:

此时我们就将内碎片的浪费率控制在 10% 左右,这是可以接受的,对齐规则的理论已经论述完毕,如何实现这个对齐规则呢?

这套对齐规则需要提供两个接口:

  • AdjustUp,通过用户所要申请的字节数,再根据对齐规则,返回对其后的字节数。
  • BucketIndex,通过用户要申请的字节数,根据对其规则,返回对应桶的下标。

框架如下:

class ManageSize
{
public:
    // 核心目的: 通过用户所要申请的字节数, 再根据对齐规则, 返回对其后的字节数
    static size_t AdjustUp(size_t apply_size);
    // 核心目的: 通过用户要申请的字节数, 根据对其规则, 返回对应桶的下标
    static size_t BucketIndex(size_t apply_size);
};

AdjustUp, 通过用户所要申请的字节数,再根据对齐规则,返回对其后的字节数,实现如下:

// 核心目的: 通过apply_size以及适应的对齐数返回合适的字节数
static size_t AdjustUp(size_t apply_size)
{
	if (apply_size <= 128)
	{
		// 按照8字节对齐
		// 这个区间[1, 128]会有如下桶:
		// 8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88, 96, 104, 112, 120, 128 
		// 在这个区间中有16个桶.
		return _AdjustUp(apply_size, 8);
	}
	else if (apply_size <= 1024)
	{
		// 按照16字节对齐
		// 该区间有56个桶, 原理同上
		return _AdjustUp(apply_size, 16);
	}
	else if (apply_size <= 8 * 1024)
	{
		// 按照128字节对齐
		// 该区间有56个桶, 原理同上
		return _AdjustUp(apply_size, 128);
	}
	else if (apply_size <= 64 * 1024)
	{
		// 按照 1024 字节对齐
		// 该区间有56个桶, 原理同上
		return _AdjustUp(apply_size, 1024);
	}
	else if (apply_size <= 256 * 1024)
	{
		// 按照 8 * 1024 字节对齐
		// 该区间有24个桶, 原理同上
		return _AdjustUp(apply_size, 8 * 1024);
	}
	else
	{
		// 对于超过256kb的对齐, 暂时不处理
        assert(false);
        return -1;
	}
}

_AdjustUp 的实现思路如下:

用申请的字节数模上对齐数,如果为0,那么这个字节数就是对齐的,如果为非0,那么就需要向上对其,实现如下:

// 通过 apply_size 和 align_num 返回合适的 size
// apply_size: 用户要申请的内存对象的大小
// 举个栗子: apply_size = 7, align_num = 8
// 那么就需要返回8 (依据对齐数,返回向上取整的大小)
static inline size_t _AdjustUp(size_t apply_size, size_t align_num)
{
	return apply_size % align_num == 0 ? apply_size : align_num * (apply_size / align_num + 1);
}

上面是普通处理方案,我们见一见高手的处理方案,如下:

static inline size_t _AdjustUp(size_t apply_size, size_t align_num)
{
	// 高手的处理方案
	return ((apply_size + align_num - 1) & ~(align_num - 1));
}

举个例子,用以理解高手的操作, 比如 apply_size 为 15 byte, align_num 为 8byte,如下:        

总结:

  • apply_size + align_num -  1,如果 apply_size  正好是 align_num 的倍数,最终的结果不会变化;如果 apply_size  不是 align_num 的倍数,最终结果会向上调整。
  • ~(align_num - 1) ,计算 align_num - 1 的补码(即将其所有位取反),如上,align_num = 8,则 align_num - 1 是 7,即 0111,故补码是 1000,这相当于将 apply_size + align_num - 1 的最低几位清零,以达到对齐的效果。
  • 通过与 ~(align_num - 1)进行按位与运算,将 apply_size 调整为 align_num 的倍数。
  • 这种做法比采用除法和乘法的方式更高效,因为位运算的更高效。

BucketIndex,通过用户要申请的字节数,根据对其规则,返回对应桶的下标,实现如下:

// 核心目的: 通过apply_size返回桶的下标
static size_t BucketIndex(size_t apply_size)
{
	// ApplySize 不能超过 ThreadCache中最大的桶
	assert(apply_size <= ThreadCacheMaxSize);
	// 需要注意的是, 在计算中间桶时, 需要加上前面区间中桶的个数
	// 为此,我们记录下每个区间桶的个数, 如下:
	/*
	* [1, 128]:                      16个桶
	* [129, 1024]:                   56个桶
	* [1025, 8 * 1024]:              56个桶
	* [8 * 1024 + 1, 64 * 1024]:     56个桶
	* [64 * 1024 + 1, 256 * 1024]:   24个桶
	*/
	// 最后一个桶的个数不用记录到这个数组中
	static size_t RangeBucketNum[4] = { 16, 56, 56, 56 };

	if (apply_size <= 128)
	{
		return _BucketIndex(apply_size, 8);
	}

	else if (apply_size <= 1024)
	{
		// 因为这是 [129, 1024] 之间的桶, 故要加上 [1, 128] 这个区间中的桶
		return _BucketIndex(apply_size, 16) + RangeBucketNum[0];
	}

	else if (apply_size <= 8 * 1024)
	{
		// 因为这是 [1025, 8 * 1024] 之间的桶, 故要加上 [1, 128] 和 [129, 1024] 这两个区间中的桶
		return _BucketIndex(apply_size, 128) + RangeBucketNum[0] + RangeBucketNum[1];
	}

	else if (apply_size <= 64 * 1024)
	{
		// 同上
		return _BucketIndex(apply_size, 1024) + RangeBucketNum[0] + RangeBucketNum[1] + RangeBucketNum[2];
	}

	else if (apply_size <= 256 * 1024)
	{
		// 同上
		return _BucketIndex(apply_size, 8 * 1024) + RangeBucketNum[0] + RangeBucketNum[1] + RangeBucketNum[2] + RangeBucketNum[3];
	}
	else
	{
		// 非法情况, 直接断死
		assert(false);
		return -1;
	}
}

_BucketIndex 实现如下:

// 普通的处理方案
static inline size_t _BucketIndex(size_t apply_size, size_t align_num /* 对齐数 */)
{
	return apply_size % align_num == 0 ? apply_size / align_num - 1 : apply_size / align_num;
}

上面是普通的处理方案,高手是通过对齐数的移位数 (align_shift) 来实现的,因此,我们要修改一下 BucketIndex,如下:

// 核心目的: 通过apply_size返回桶的下标
static size_t BucketIndex(size_t apply_size)
{
	// ApplySize 不能超过 ThreadCache中最大的桶
	assert(apply_size <= ThreadCacheMaxSize);
	// 需要注意的是, 在计算中间桶时, 需要加上前面区间中桶的个数
	// 为此,我们记录下每个区间桶的个数, 如下:
	/*
	* [1, 128]:                      16个桶
	* [129, 1024]:                   56个桶
	* [1025, 8 * 1024]:              56个桶
	* [8 * 1024 + 1, 64 * 1024]:     56个桶
	* [64 * 1024 + 1, 256 * 1024]:   24个桶
	*/
	// 最后一个桶的个数不用记录到这个数组中
	static size_t RangeBucketNum[4] = { 16, 56, 56, 56 };

	if (apply_size <= 128)
	{
        // 这里是对齐数的移位数, 2 ^ 3 == 8
		return _BucketIndex(apply_size, 3 /* 2^3 == 8 */);
	}

	else if (apply_size <= 1024)
	{
		// 因为这是 [129, 1024] 之间的桶, 故要加上 [1, 128] 这个区间中的桶
		return _BucketIndex(apply_size, 4) + RangeBucketNum[0];
	}

	else if (apply_size <= 8 * 1024)
	{
		// 因为这是 [1025, 8 * 1024] 之间的桶, 故要加上 [1, 128] 和 [129, 1024] 这两个区间中的桶
		return _BucketIndex(apply_size, 7) + RangeBucketNum[0] + RangeBucketNum[1];
	}

	else if (apply_size <= 64 * 1024)
	{
		// 同上
		return _BucketIndex(apply_size, 10) + RangeBucketNum[0] + RangeBucketNum[1] + RangeBucketNum[2];
	}

	else if (apply_size <= 256 * 1024)
	{
		// 同上
		return _BucketIndex(apply_size, 13) + RangeBucketNum[0] + RangeBucketNum[1] + RangeBucketNum[2] + RangeBucketNum[3];
	}
	else
	{
		// 非法情况, 直接断死
		assert(false);
		return -1;
	}
}

高手的处理方案,_BucketIndex 实现如下:

static inline size_t _BucketIndex(size_t apply_size, size_t align_shift /* 2^align_shift == 对齐数 */)
{
	return ((apply_size + (1 << align_shift) - 1) >> align_shift) - 1;
}

理解思路

加上对齐数

  • 1 << align_shift,本质上就是 2 ^ align_shift,表示的就是对齐数;
  • 比如 align_shift = 3,那么 1 << 3 = 8;
  • apply_size + 8 - 1 使得 apply_size 向上调整,以确保其对齐到 8;
  • 如果 apply_size = 13,会变为 13 + 8 - 1 = 20。

右移位运算

  • (apply_size + (1 << align_shift) - 1) >> align_shift:这步通过右移 align_shift 位,本质上就是除以 2 ^ align_shift,计算桶的位置;
  • 比如,对 20 >> 3 等效于 20 / 8 = 2。

减 1

  • 最后的减一操作将桶位置转换为从 0 开始的桶索引。

现在,我们已经确立了桶的个数,因此, Thread Cache 这个哈希桶的结构如下:

// ThreadCache 这一层中桶的总个数
const static size_t ThreadCacheBucketNum = 208;

class ThreadCache
{
	// 返回一个内存对象
	void* Allocate(size_t apply_size);
	// 回收一个内存对象
	void Deallocate(void* obj, size_t apply_size);
private:
	FreeList _freeList[ThreadCacheBucketNum]; 
};

有了上面的铺垫,我们就可以实现 Thread Cache::Allocate 了,实现思路如下:

依据传递的大小和 BucketIndex 来得到目标桶的下标,查看目标桶是否存在内存对象:

  • 如果有,返回即可;
  • 如果没有,就需要访问下一层获取内存对象。

实现如下:

void* Xq::ThreadCache::Allocate(size_t apply_size)
{
	// Thread Cache 只处理 <= 256kb 的内存
	assert(apply_size <= ThreadCacheMaxSize);
	// 依据ApplySize, 计算对应的桶的下标 (找到目标 FreeList)
	size_t bucket_index = Xq::ManageSize::BucketIndex(apply_size);
	// 要返回的目标内存单元对象
	void* obj = nullptr;
	// 从目标 FreeList 返回一个内存单元对象
	if (!_freeList[bucket_index].empty())
	{
		obj = _freeList[bucket_index].pop();
		return obj;
	}
	else
	{
		// apply_size 对齐后的大小
		size_t align_size = Xq::ManageSize::AdjustUp(apply_size);
		// 如果目标 FreeList 为空, 那么就需要向下一层 (CentralCache) 要内存
		obj = GainFromCentralCache(align_size, bucket_index);
		return obj;
	}
}

首先,我们需要提供对 FreeList 结构再补充一个接口,即 empty,如下:

// 当前自由链表是否为空
bool empty(void)
{
    return _freeList == nullptr;
}

其次,如果当前桶没有内存对象,那么就需要向下一层获取 (也就是 Central Cache),Thread Cache 需要提供一个接口,Thread Cache 通过这个接口向下一层获取一批的内存对象,该接口如下:

// 从 CentralCache 获取内存单元对象
// align_size: 对齐后的大小
// bucket_index: 对应的桶的下标
void* Xq::ThreadCache::GainFromCentralCache(size_t align_size, size_t bucket_index);

5.3. Thread Cache TLS 无锁访问

我们说过,Thread Cache 是每个线程都会私有一份的,如何保证每个线程私有呢?

这时候就需要引入 TLS,全称为 Thread Local Storage,即线程的局部存储,TLS 数据的生命周期由线程管理,从线程创建到线程终止。

TLS 不同于全局变量或者静态变量,TLS 使得每个线程可以拥有独立的数据副本,而不会互相干扰,因此不会存在竞争,即不存在线程安全问题,在多线程场景下,可以提高并发能力,进而提高效率。

因此,我们可以通过 TLS 使每个线程都私有一份 Thread Cache,如下:

// 在多线程场景下, ThreadCache 这一层是会被频繁访问的
// 如果采用加锁的方式保证线程安全, 那么对效率的影响是很大的
// 而为了提高效率, 我们采用 TLS (Thread Local Storage) 的方案保证每个线程独享一个 ThreadCache
// 这样既保证了线程安全, 也提高了访问效率
// 这里我们采用静态的TLS
#ifdef _WIN32
static __declspec(thread) ThreadCache* TLSThreadCacheObject = nullptr;
#else
static __thread ThreadCache* TLSThreadCacheObject = nullptr;
#endif

高并发内存池需要对外提供两个接口,这两个接口用于内存的申请和释放,如下:

// 提供给上层的申请空间的接口
static void* ConcurrentAllocate(size_t size);

// 提供给上层的释放空间的接口
// 这里暂时需要提供 apply_size, 以便于能够调用 ThreadCache::Deallocate, 后续处理
static void ConcurrentDeallocate(void* obj, size_t apply_size);

在 ConcurrentAllocate 中需要对当前执行流的 TLS 进行检查,如果没有被创建,那么需要创建之,如果已经创建了,直接调用 Thread Cache 中的 Allocate 申请内存,如下:

// 我们约定 ThreadCache 这一层只处理 <= 256 kb 的内存申请
// 我们将 256 kb 称之为 ThreadCache所能处理的最大字节数
const static size_t ThreadCacheMaxSize = 256 << 10;

// 提供给上层的申请空间的接口
static void* ConcurrentAllocate(size_t size)
{
	// 如果当前执行流的 Thread Cache 为空, 那么 new 一个属于当前执行流的 Thread Cache (TLS)
	if (Xq::TLSThreadCacheObject == nullptr)
	{
		Xq::TLSThreadCacheObject = new Xq::ThreadCache;
	}

	// 如果要申请的字节数 <= 256 kb, 通过 Thread Cache 处理
	if (size <= ThreadCacheMaxSize)
	{
		return Xq::TLSThreadCacheObject->Allocate(size);
	}
	// 如果大于 > 256 kb, 暂不处理
	else
	{
		return nullptr;
	}
}

我们需要验证 TLS,是否符合我们的预期,即每个执行流是否都有独属于自己的 Thread Cache 呢? 验证 demo 如下:

void TLS1(void)
{
	std::vector<void*> Vptr;
	Vptr.reserve(10);
	for (size_t i = 0; i < 10; ++i)
	{
		void* obj = ConcurrentAllocate(6);
		Vptr.push_back(obj);
	}
}

void TLS2(void)
{
	std::vector<void*> Vptr;
	Vptr.reserve(10);
	for (size_t i = 0; i < 10; ++i)
	{
		void* obj = ConcurrentAllocate(3);
		Vptr.push_back(obj);
	}
}

void TLSTest(void)
{
	std::thread tls1(TLS1);
	std::thread tls2(TLS2);

	tls1.join();
	tls2.join();
}

运行程序,打开并行监视窗口,现象如下:

第一个线程

Xq::TLSThreadCacheObject = new Xq::ThreadCache 之前:

Xq::TLSThreadCacheObject = new Xq::ThreadCache 之后:

第二个线程

Xq::TLSThreadCacheObject = new Xq::ThreadCache 之前:

 Xq::TLSThreadCacheObject = new Xq::ThreadCache 之后:

从结果来看,符合预期,每个执行流都有独属于自己的 Thread Cache,因此可以做到无锁访问,故访问效率高。

5.4. Central Cache 的整体设计

当某个执行流私有的 Thread Cache 中特定的桶 (自由链表) 没有内存对象,此时这个执行流就可以向下一层,也就是 Central Cache 申请内存对象,Central Cache 会返回给 Thread Cache 若干个内存对象。

Central Cache 也是一个哈希桶的结构,并且,其和 Thread Cache 的对齐规则一致。

在多执行流场景下,多个执行流都有可能访问 Central Cache, 故这里的 Central Cache 就是一个临界资源,因此,存在线程安全问题,为了保证数据的一致性,需要对 Central Cache 进行加锁保护,同时,由于 Central Cache 是一个哈希桶的结构,因此,出于效率的角度,在这里的锁实际上是一个桶锁,当访问 Central Cache 时,只有当不同的线程同时访问同一个桶的时候,才会存在线程安全问题,故这里的竞争并不会太激烈,换言之,串行化程度不会太高,对性能不会影响太大。

Thread Cache 这个哈希桶的每一个桶都是一个自由链表,每个自由链表管理的是不同的内存对象 (8byte、16byte、32byte 等等的内存对象),而 Central Cache 这个哈希桶的每一个桶是一个带头的双向循环链表,每个链表管理的是不同的 Span 对象。

Span 它代表的是若干个连续 page 的内存跨度,换言之,一个 Span 就是由若干个连续 page 组成的。

同时,Central Cache 和 Thread Cache 的对齐规则是一致的,因此,Central Cache 这个哈希桶中的存储结构:

每个桶会有若干个 Span 对象,这一个一个的 Span 对象,会被切分成若干个内存对象,并通过自由链表将若干个内存对象维护起来。

比如,在 8byte 这个桶中,每个 Span 对象 (若干个 page 组成的) 会被切分为若干个 8byte 的内存对象,并且这若干个 8byte 的内存对象也会以 freeList 的形式组织起来,其他内存对象同理。

如下图所示:

5.5. Central Cache 的结构设计 

每个 Span 对象是由若干个连续 page 组成的内存跨度,因此我们需要用一个结构来描述这个 Span:

  • 首先,既然 Span 是由若干个连续 page 组成的内存跨度,那么应该有一个属性用来描述当前 Span 对象有多少个 page;
  • 其次,后续我们会知道,在释放逻辑中,为了缓解外碎片问题,我们需要对连续页的 Span 对象进行合并,因此,我们需要知道每个 Span 对象的起始页的页号,通过页号判定前后页是否可以合并;
  • 然后,我们说过,每个 Span 对象中的若干个连续 page 组成的内存,会根据对齐规则切分为若干个内存对象,并通过自由链表将这些内存对象维护起来,因此每个 Span 对象需要一个自由链表;
  • 另外,我们说过,Central Cache 这个哈希桶中的每个桶都是若干个不同的 Span 对象,而这些 Span 对象是通过一个带头双向循环链表组织起来的,因此,我们需要一个前驱指针和后继指针;
  • 最后,一个 Span 对象的内存空间是会被切分为若干个内存对象的,这些内存对象是会分配给 Thread Cache 使用的,如果这个 Span 对象中分配出去的内存对象都归还回来了 ,那么这个 Span 对象应该还给下一层,也就是 Page Cache,Page Cache 再根据相关逻辑判断这个 Span 对象是否可以进行前后页的合并,因此,我们需要一个值标识当前 Span 对象中有多少个内存对象正被使用。

这就是目前 Span 所需要的属性,后续会根据情况进行调整,Span 结构如下:

struct Span
{
	size_t _pageId = 0;  // 大块内存起始页的页号
	size_t _pageNum = 0;  // 页的数量
	// 每个 Span 对象会将其拆分为若干个内存对象
	// 若干个内存对象会被链在 _freeList 
	void* _freeList = nullptr;

	// 当前Span的使用情况, 即有多少个内存对象正被使用
	size_t _useCount = 0;

	// 若干个 Span 对象通过一个带头的双向链表维护起来
	Span* _next = nullptr;
	Span* _prev = nullptr;
};

这里存在一个问题,页号的类型固定为 size_t 可以吗?首先,我们约定页的大小为 1 << 13,那么:

  • 在 32 位程序中,会有 2 ^ 19 个页;
  • 在 64 位程序中,会有 2 ^ 51 个页。

因此,可以看见,当我们描述页号的时候,由于不同程序的页数的上限不同,固定用 size_t 描述页号,是不合适的,在此,我们采用条件编译解决这个问题,注意:

  • 在 32 位程序中,会有 _WIN32 这个宏,这是 Windows 自带的;
  • 在 64 位程序中,也有 _WIN32 这个宏,但同时会有 _WIN64 这个宏。

因此,我们有如下定义:

// 注意: 在32位环境下, 只有 _WIN32 这个宏
// 而在64位环境下, 会有 _WIN32 和 _WIN64这两个宏
#ifdef _WIN64
typedef unsigned long long PAGE_ID;
#else
typedef unsigned int PAGE_ID;
#endif

故这里的页号的类型就为 PAGE_ID,如下:

struct Span
{
	PAGE_ID _pageId = 0;  // 大块内存起始页的页号
    // ...
};

Central Cache 中的 Span 对象是由一个带头双向循环链表维护起来的,因此,我们需要实现一个 SpanList,用来组织 Span 对象,而这里的 SpanList 就是 Central Cache 的每个桶,目前而言,我们只需要实现一个 Insert 和 Erase 即可,如下:

class SpanList
{
public:
	void empty_initialize()
	{
		_head = new Span;
		_head->_next = _head;
		_head->_prev = _head;
	}

	SpanList()
	{
		empty_initialize();
	}

	// 将一个 Span 对象链入到当前桶中
	void Insert(Span* pos, Span* newSpan)
	{
		assert(pos);
		assert(newSpan);
		Span* posPrev = pos->_prev;
		posPrev->_next = newSpan;
		newSpan->_prev = posPrev;
		newSpan->_next = pos;
		pos->_prev = newSpan;
	}

	// 将目标 Span 对象从当前桶中弹出来
	void Erase(Span* pos)
	{
		assert(pos);
		assert(pos != _head);
		Span* posPrev = pos->_prev;
		Span* posNext = pos->_next;
		posPrev->_next = posNext;
		posNext->_prev = posPrev;

		// 可以看到, 我们此时并没有将这个 pos 给释放掉
		// 当我们调用 Erase 时, 只是将这个 Span 对象给下一层, 也就是 PageCache
		// 这里只是将这个 Span 对象从 CentralCache 对应桶中移除掉, 而并非真正的释放它
	}
private:
	// 链表的哨兵位头节点
	Span* _head = nullptr;
};

同时,我们说过,在多执行流场景下,Central Cache 会存在线程安全问题,故需要加锁,出于效率角度,这里的锁是一把桶锁,那么这个桶锁如何设计呢?

很简单,Central Cache 的每一个桶不就是 SpanList 结构吗? 那么只需要在这个结构中添加一把锁不就行了吗?如下:

class SpanList
{
public:
    // ...

    // 获得这把锁 
    std::mutex& GetBucketMtx()
    {
	    return _bucketMtx;
    }
private:
	// 链表的哨兵位头节点
	Span* _head = nullptr;
    // 桶锁 
	std::mutex _bucketMtx;
};

然后,我们说过, Central Cache 本质上也是一个哈希桶,并且 Central Cache 的对齐规则和 Thread Cache 的对齐规则一直,因此,Thread Cache 和 Central Cache 的桶的数量是一致的,那么 Central Cache 的宏观结构如下:

// CentralCache 这一层中桶的总个数, 和 ThreadCache 桶的个数一致
const static size_t CentralCacheBucketNum = ThreadCacheBucketNum;

namespace Xq
{
	class CentralCache
	{
	private:
		/*
		* Central Cache 也是一个哈希桶
		* 并且其对其映射规则和 Thread Cache 一致
		* 换言之, Thread Cache 有多少个桶, Central Cache 就有多少个桶
		* 但与之不同的是, Thread Cache 每个桶挂的是若干个内存单元对象
		* 而 Central Cache 每个桶挂的是若干个 Span 对象
		*/
		Xq::SpanList _spanList[CentralCacheBucketNum];
	};
}

5.6. Central Cache 的核心实现

每个执行流都会私有一个 Thread Cache,如果当前的 Thread Cache 没有内存对象时,就会向下一层访问 Central Cache 获取内存对象,换言之,Central Cache 会被多个执行流访问,那么这个 Central Cache 我们如何设计呢?

一般情况下, 对于 Central Cache 和 Page Cache 这种可能会被多个执行流同时访问的对象,我们希望它全局只有一个,那么怎么做呢? 我们可以用单例模式来实现,而单例模式分为饿汉和懒汉,而我们在这里采用懒汉模式实现单例模式,如下:

namespace Xq
{
	class CentralCache
	{
	private:
		// 将构造和拷贝构造私有化
		CentralCache() {}
		CentralCache(const CentralCache&);
		//CentralCache(const CentralCache&) = delete;
	public:
		// 一般单例模式会对外提供一个接口, 获取这个唯一实例
		static CentralCache* GetOnlyInstance()
		{
			// 双重检查锁
			if (_ptrOnlyCentralCache == nullptr)
			{
				CentralCacheOnlyInstanceMutex.lock();
				if (_ptrOnlyCentralCache == nullptr)
				{
					_ptrOnlyCentralCache = new CentralCache;
				}
				CentralCacheOnlyInstanceMutex.unlock();
			}
			return _ptrOnlyCentralCache;
		}

	private:
		// 内嵌一个回收类
		// 回收该唯一实例
		class central_cache_recovery
		{
		public:
			~central_cache_recovery()
			{
				if (_ptrOnlyCentralCache)
				{
					delete _ptrOnlyCentralCache;
					_ptrOnlyCentralCache = nullptr;
				}
			}
			static central_cache_recovery _auto_delete_ptr_only_central_cache;
		};

	private:
		/*
		* Central Cache 也是一个哈希桶
		* 并且其对其映射规则和 Thread Cache 一致
		* 换言之, Thread Cache 有多少个桶, Central Cache 就有多少个桶
		* 但与之不同的是, Thread Cache 每个桶挂的是若干个内存单元对象
		* 而 Central Cache 每个桶挂的是若干个 Span 对象
		*/
		Xq::SpanList _spanList[CentralCacheBucketNum];
		// 懒汉实现单例
		static CentralCache* _ptrOnlyCentralCache;
	};
}

注意:类的静态成员需要在类外定义,而这里最好不要把定义放在 .h 文件中,因为这个 .h 文件会被多个源文件引用,存在链接问题,故我们选择在 .cc 文件中定义,如下:

// 定义CentralCache唯一实例
Xq::CentralCache* Xq::CentralCache::_ptrOnlyCentralCache = nullptr;
// 定义一个静态成员变量,进程结束时,系统会自动调用它的析构函数从而释放CentralCache单例对象
Xq::CentralCache::central_cache_recovery Xq::CentralCache::central_cache_recovery::_auto_delete_ptr_only_central_cache;

我们知道, Thread Cache 是通过 ThreadCache::Allocate 返回一个内存对象,如果 Thread Cache 对应的桶中有内存对象,那么返回这个内存对象即可,如果没有内存对象,那么就需要通过ThreadCache::GainFromCentralCache 访问下一层,也就是通过 Central Cache 这一层获取内存对象,但我们知道,Central Cache 是涉及到线程安全的,因此会通过加锁保证线程安全,尽管这里是桶锁,竞争不是很激烈,但频繁的访问 Central Cache 还是会增加竞争锁的概率,进而导致效率降低,因此,当 Thread Cache 向 Central Cache 索要内存单元对象时,Central Cache 会根据相关策略返回一批的内存对象,Thread Cache 返回其中的一个内存对象,并将剩余的内存对象链入到 Thread Cache 对应的桶中, 这样就降低了执行流频繁的访问 Central Cache,间接的提高了效率。

那么 Central Cache 是如何判定应该返回多少个内存对象的呢?

因此,Thread Cache 应该通过相关的逻辑告诉 Central Cache 理论上应该返回多少个内存对象。

这个逻辑如下:

// Thread Cache 一次从 Central Cache 获取多少个内存对象 (理论上)
static inline size_t NumMemoryUnitTarget(size_t align_size)
{
	// 用Thread Cache 处理的最大字节数 / 当前内存对象的大小
	size_t num = ThreadCacheMaxSize / align_size;
	// 对于小的内存对象来说, 比如 8byte, 那么将会有32768个内存对象
	// 这就太多了, 很可能无法完全使用, 因此我们约定一个上限, 比如 512
	if (num > 512) num = 512;
	// 但对于大的内存对象来说, 比如 256kb, 那么只有一个内存对象
	// 考虑到有点少, 我们一次给2个
	if (num < 2) num = 2;
	return num;
}

依据上面的逻辑,如果要申请的内存对象的大小为 8 byte,那么这个理论值就是 512,即 Central Cache 一次要返回 512 个内存对象,而一个线程第一次申请如此多的内存对象,很有可能出现使用不完的情况,导致内存利用率低,当系统内存吃紧的时候,这种是不利的,因此,我们又引入一个机制,慢启动机制,由于 Thread Cache 是一个哈希桶,且每个桶是一个 FreeList 结构,因此,我们将这个慢启动机制作为 FreeList 的一个成员属性,初始值就为1,如下:

// 自由链表结构
// 核心目的: 用于将切分好的内存对象组织起来
class FreeList
{
public:
	// push: 将一个内存对象链入到链表中
	void push(void* obj) { /*省略*/ }
	// pop: 从自由链表中弹出一个内存对象
	void* pop(void) { /*省略*/ }
    // empty: 当前自由链表是否为空
	bool empty(void) { /*省略*/ }

	// 获得慢增长机制的值
	size_t& get_slow_start_num()
	{
		return _slowStartNum;
	}
private:
	void* _freeList = nullptr;   // 自由链表的起始地址
	size_t _slowStartNum = 1;  // 慢开始机制的起始数字
};

当 Thread Cache 向 Central Cache 索要一批内存对象时:

首先根据 NumMemoryUnitTarget 接口得到理论上应该获取多少个内存对象,这个值我们称之为理论值;

在取这个理论值和慢启动机制的值中的较小值,这个较小值我们称之为预期值;

如果这个预期值就是慢启动机制的值,那么慢启动机制进行更新,我在这里的设计就是 ++慢启动机制的值,具体如下:

// 当 Thread Cache 没有对应的内存单元对象时, 会向 Central Cache 索取, Central Cache 会根据相关算法返回批量的内存单元对象.
void* Xq::ThreadCache::GainFromCentralCache(size_t align_size, size_t bucket_index)
{
	// 先计算理论值, 即 Thread Cache 理论上一次性从 Central Cache 索要内存对象的个数
	size_t num = Xq::NumMemoryUnitTarget(align_size);
	// 在取预期值, 即理论值和慢启动机制的值中的较小值
	size_t expect_num = min(num, _freeList[bucket_index].get_slow_start_num());
	// 如果预期值 expect_num 就是这个慢开始机制的值, 那么进行慢增长
	if (expect_num == _freeList[bucket_index].get_slow_start_num())
	{
		_freeList[bucket_index].get_slow_start_num() += 1;
	}

    // 此时的 expect_num 就是预期从 Central Cache 获取的内存对象的个数
	// 接下来, Thread Cache 就要调用 Central Cache 的接口, 获取内存单元对象
}

此时达到的目的:

  • Thread Cache 最开始不会向 Central Cache 一次性要太多的内存对象,因为太多了,可能用不完,内存的利用率不高;
  • 如果 Thread Cache 对特定的内存对象的需求非常大,那么这个慢开始的值会不断增大,直至增长到理论上内存对象的个数 (256kb / 要申请的字节数);
  • 要申请的字节 (内存对象越大) 越多,一次性向 Central Cache 申请的内存对象的个数就越少;
  • 要申请的字节 (内存对象越小) 越少,一次性向 Central Cache 申请的内存对象的个数越多 (动态增长的,取慢增长的值和理论上内存对象的个数的较小值)。

当我们得到从 Central Cache 获取的内存对象的预期个数后 (预期值),我们需要调用 Central Cache 提供的接口,获取一批的内存对象,但有没有一种可能,此时的 Central Cache 没有预期值这么多的内存对象,只有一部分,那么 Thread Cache 要不要呢? 当然要,此时 Central Cache 有多少给多少,故这个接口的返回值就是实际上返回的内存对象的个数,这个接口如下:

// 从 Central Cache 获取 expect_num 个内存单元对象,align_size 是对齐后的字节数
// 这若干个内存单元的起始地址为 start, 结束地址为 end, 且 start 和 end 都为输出型参数
// 返回值为 actual_num, 即实际获取了几个内存单元对象
size_t Xq::CentralCache::GainRangeMemoryObj(void*& start, void*& end, size_t expect_num, size_t align_size);

此时 Thread Cache 就需要调用这个接口获取一批的内存对象,返回一个内存对象给程序使用,并将剩余的内存对象链入到 Thread Cache 相匹配的桶中,如下:

// 当 Thread Cache 没有对应的内存单元对象时, 会向 Central Cache 索取, Central Cache 会根据相关算法返回批量的内存单元对象.
void* Xq::ThreadCache::GainFromCentralCache(size_t align_size, size_t bucket_index)
{
	// 先计算理论值, 即 Thread Cache 理论上一次性从 Central Cache 索要内存对象的个数
	size_t num = Xq::NumMemoryUnitTarget(align_size);
	// 在取预期值, 即理论值和慢启动机制的值中的较小值
	size_t expect_num = min(num, _freeList[bucket_index].get_slow_start_num());
	// 如果预期值 expect_num 就是这个慢开始机制的值, 那么进行慢增长
	if (expect_num == _freeList[bucket_index].get_slow_start_num())
	{
		_freeList[bucket_index].get_slow_start_num() += 1;
	}

    // 此时的 expect_num 就是预期从 Central Cache 获取的内存对象的个数
    // 接下来, Thread Cache 就要调用 Central Cache 的接口, 获取内存单元对象
	void* start = nullptr;
	void* end = nullptr;
	size_t actual_num = Xq::CentralCache::GetOnlyInstance()->GainRangeMemoryObj(start, end, expect_num, align_size);
	// 至少获取一个内存单元对象
	assert(actual_num >= 1);  

    // 如果只有一个内存对象, 返回这个内存对象即可
	if (actual_num == 1)
	{
		assert(start == end);
		return start;
	}
	else
	{
        // 返回一个内存对象, 并将剩余的内存对象链入到匹配的哈希桶中
		// 第一种方案:
		//_freeList[bucket_index].pushRangeMemory(start, end);
		//return _freeList[bucket_index].pop();
		//  第二种方案: 
		_freeList[bucket_index].pushRangeMemory(Xq::NextMemory(start), end);
		return start;
	}
}

因此,这里的 FreeList 结构还需要提供一个接口,该接口将一批的内存对象链入到自由链表中,这个接口如下:

// 将若干个内存对象链入到 _freeList 中
void pushRangeMemory(void* start, void* end);

那么如何实现呢,我们通过下面这个例子理解:

我们需要将 start 到 end 这一批内存对象链入到自由链表中,实现思路如下:

  • end 的前4/8字节的内容存储为 _freeList;
  • 更新_freeList 即可。

如下:

依据上图的思路, 实现如下: 

// 将若干个内存对象链入到 _freeList 中
void pushRangeMemory(void* start, void* end)
{
	NextMemory(end) = _freeList;
	_freeList = start;
}

接下来,我们就需要让 Central Cache 返回一批的内存对象,也就是要实现这个接口,如下:

// 从 Central Cache 获取 expect_num 个内存对象,align_size 是对齐后的字节数
// 这若干个内存对象的起始地址为 start, 结束地址为 end, 且 start 和 end 都为输出型参数
// 返回值为 actual_num, 即实际获取了几个内存对象
size_t Xq::CentralCache::GainRangeMemoryObj(void*& start, void*& end, size_t expect_num, size_t align_size);

我们说过, Central Cache 是一个哈希桶结构,每个桶是一个 SpanList ,每个 SpanList 挂的是一个一个的 Span 对象,而每一个 Span 对象又会被切分为若干个内存对象,因此,要返回若干个内存对象,我们首先需要从特定桶中找到一个非空的 Span 对象 ( _freeList != nullptr ),在从这个 Span 对象中返回预期个内存对象,但有可能当前 Span 中的内存对象的个数少于预期值,因此,这种情况,有几个就返回几个,故在返回内存对象时,我们记录一个实际值,用于表示最终返回的内存对象的个数,故实现步骤如下:

  1. 找到目标桶;
  2. 从目标桶中获取一个非空的Span对象;
  3. 从这个Span对象返回若干个内存对象;
  4. 更新这个Span的_freeList;
  5. 返回实际获取的内存对象个数。

在这里存在两个问题:

问题一:如果当前 Central Cache 对应的桶没有 Span 对象,怎么办?

那么就需要通过相关接口向下一层 (Page Cache) 获取一个 Span 对象。

问题二:如何表示这里的若干个内存对象呢?

通过 start 和 end 这两个输出型参数,它们代表的就是要返回的若干个内存对象。

我们详细解释一下第三步,即如何从一个Span对象返回若干个内存对象,采用下面的例子用以说明:

假如我们要返回三个内存单元对象,那么:

首先,让 start 和 end 首先都指向 _freeList。

其次,为了可以将最后一个内存单元的前4/8字节的内容置为空,我们选择走 expect_num - 1 步,即最终 end 是要返回的最后一个内存对象,如下图所示:

但如果我们此时要返回四个内存单元对象,那么依据上面的思路,结果如下:

那么此时访问 end 的前4/8字节,就会导致进程挂掉,因为此时的 end 是一个 nullptr,故我们在循环时需要检查当前 end ,如果end 是最后一个节点,那么就需要停下来,如何判定当前 end 是最后一个节点呢?如果当前end的前4/8字节为nullptr,那么就是当前 _freeList 的最后一个节点。

依据上面的理解,实现如下:

size_t Xq::CentralCache::GainRangeMemoryObj(void*& start, void*& end, size_t expect_num, size_t align_size)
{
	// 得到对应的桶
	size_t bucket_index = Xq::ManageSize::BucketIndex(align_size);

	// 通过 CentralCache::GetOneNonEmptySpan 获取一个非空的Span对象
	Span* span = GetOneNonEmptySpan(_spanList[bucket_index], align_size);
	// 确定是一个合法的Span
	assert(span);
	assert(span->_freeList);

	// 让 start 和 end 初始值为当前 Span中的 _freeList
	start = span->_freeList;
	end = start;

	size_t i = 0;
	size_t actual_num = 1;
	// 要走expect_num - 1步 && 保证end的下一个内存对象不为空
	while (i < expect_num - 1 && Xq::NextMemory(end) != nullptr)
	{
		end = Xq::NextMemory(end);
		++i;
		++actual_num;
	}
	// 更新 Span 中的 _freeList
	span->_freeList = Xq::NextMemory(end);
	// 将 end 的前4/8字节置为空
	Xq::NextMemory(end) = nullptr;
	// 返回实际获取内存对象的个数
	return actual_num;
}

同时,我们说过,在多执行流场景下,从 Central Cache 获取内存,是存在竞争的,故为了线程安全,这里需要一把锁,而这里的锁实际上就是一个桶锁 ( SpanList::锁 ),当 Thread Cache 要从 Central Cache 获取内存对象时,首先需要加锁 (桶锁) 保证线程安全,更改如下:

size_t Xq::CentralCache::GainRangeMemoryObj(void*& start, void*& end, size_t expect_num, size_t align_size)
{
	// 得到对应的桶
	size_t bucket_index = Xq::ManageSize::BucketIndex(align_size);

	// 我们说过,  在多线程场景下, 多个执行流同时访问 Central Cache, 会存在线程安全问题
	// 为了保证安全性且效率问题, 我们将这把锁设置为一把桶锁, 只有不同的执行流访问同一个桶时
	// 才会存在线程安全问题, 故既保证了线程安全问题且串行化程度不高
	_spanList[bucket_index].GetBucketMtx().lock();

	// 通过 CentralCache::GetOneNonEmptySpan 获取一个非空的Span对象
	Span* span = GetOneNonEmptySpan(_spanList[bucket_index], align_size);
	// 确定是一个合法的Span
	assert(span);
	assert(span->_freeList);

	// 让 start 和 end 初始值为当前 Span中的 _freeList
	start = span->_freeList;
	end = start;

	size_t i = 0;
	size_t actual_num = 1;
	// 要走expect_num - 1步 && 保证end的下一个内存对象不为空
	while (i < expect_num - 1 && Xq::NextMemory(end) != nullptr)
	{
		end = Xq::NextMemory(end);
		++i;
		++actual_num;
	}
	// 更新 Span 中的 _freeList
	span->_freeList = Xq::NextMemory(end);
	// 将 end 的前4/8字节置为空
	Xq::NextMemory(end) = nullptr;
    // 释放桶锁
	_spanList[bucket_index].GetBucketMtx().unlock();
	// 返回实际获取内存对象的个数
	return actual_num;
}

Central Cache 通过 GetOneNonEmptySpan 获取一个非空的 Span 对象:

  • 第一种情况:Central Cache 对应的桶中有一个非空的 Span 对象,那么将这个 Span 对象弹出即可;

  • 第二种情况: Central Cache 对应的桶中没有非空的 Span 对象,那么此时就需要向下一层 ( Page Cache ) 获取一个非空的 Span 对象。

GetOneNonEmptySpan 实现框架如下:

Xq::Span* Xq::CentralCache::GetOneNonEmptySpan(SpanList& span_list, size_t align_size)
{
	Span* cur = span_list.Begin();
	while (cur != span_list.End())
	{
		// 如果当前的 Span 对象有内存对象, 那么这个Span就是返回的对象
		if (cur->_freeList != nullptr)
		{
			return cur;
		}
		// 如果当前的 Span 对象没有内存对象, 遍历下一个即可
		else
		{
			cur = cur->_next;
		}
	}

	// 如果上面没有, 说明当前这个桶没有合适的Span对象
	// 那么就需要向下一层, 也就是通过 Page Cache 这一层要一个Span对象
    // 等了解了 Page Cache 的结构, 在处理后续过程
}

要遍历这个带头的双向循环链表,我们需要提供一个 Begin 和 End,在这里就不用迭代器了,而用指针替代,Begin 和 End 的位置如下所示:

在 SpanList 中就要提供 Begin() 和 End() 两个接口,如下:

// 在这里就不用迭代器了, 就用指针即可
// 返回第一个有效节点
Span* Begin()
{
	return _head->_next;
}

// 返回最后一个有效节点的下一个位置
Span* End()
{
	return _head;
}

5.7. Page Cache 的整体设计

Page Cache 也是一个哈希桶结构,每个桶也是一个 SpanList 结构,每个桶挂载的是若干个 Span 对象,但与 Central Cache 不同的是:

  • 其一, Central Cache 的对齐规则和 Thread Cache 一致;而 Page Cache 的对齐规则与它们不同;
  • 其二, Central Cache 中每个桶中的每个 Span 对象的空间会被切分为若干个内存对象挂载到 Span 中的 _freeList;而 Page Cache 中每个桶中的每个 Span 对象不会被切分若干个内存对象。

那么 Page Cache 的对齐规则是什么呢?

首先,Span 是由若干个页组成的内存跨度,而这里就是根据桶的下标映射不同数目的页组成的 Span 对象,比如:下标为 1 的桶映射的 Span 对象就是1page 构成的 Span,下标为 2 的桶映射的 Span 对象就是 2page 构成的 Span,以此类推,最后一个桶是 128page 构成的 Span,我们将 0 号下标的位置空了出来,其主要是为了使映射关系更简单一点,如下图所示:

当 Central Cache 向 Page Cache 要一个非空的 Span 对象时,那么 Central Cache 要告诉 Page Cache 我要多少页的 Span 对象,比如,Central Cache 要 1 Page 的 Span 对象,那么 Page Cache 首先会检查 1号下标的桶是否有 Span 对象,如果有,那么弹一个Span 对象即可,如果没有呢?那么此时 Page Cache 就会向系统堆申请一个 Span 对象,而 Span 对象是由若干个页组成的内存跨度,那么此时申请多少页的内存呢?如果此时只申请 1 Page 的 Span 对象,那么后续再要 Span 时,是不是又要去系统堆申请内存? 这会带来一些问题,比如,内存外碎片的问题会加重,因为每次都是去系统堆申请内存,那么这些内存是零散分布的,外碎片问题无法缓解,因此,可不可以每次向系统堆申请内存时,都申请一个 128Page 的 Span 对象? 如果此时程序要 1Page 的Span,Page Cache 可以将 128Page 的内存切成 1个 1Page 的 Span 对象和 1个 127Page 的 Span 对象,如果要 2Page 的 Span,我也可以切成 2Page 的 Span 和一个 126Page 的 Span,这样有一个好处是:

未来当切出去的 Span 对象返回给 Page Cache 时,可以对这个 Span 对象进行前后页的合并,因为,每次向系统堆申请内存时,都是一大块连续的内存,此时就可以通过合并前后页没有被使用的 Span 对象,以缓解外碎片问题。

同时,在多执行流场景下,存在多个执行流同时访问 Page Cache,故 Page Cache 也是存在线程安全问题的,而为了保证线程安全问题,需要加锁保证线程安全,那么这里也是桶锁吗?

最好不要使用桶锁,因为在 Page Cache 这一层,每一次获取一个 Span 对象时以及每次对 Span 对象进行前后页合并时,都可能不止访问一个桶,如果是桶锁,那么会存在频繁的加锁和解锁,这会对性能产生一定的消耗,因此,这里最好不要使用桶锁,而是使用一把大锁,只要访问 Page Cahce,就必须要通过这把大锁访问 Page Cache。

最后,Page Cache 和 Central Cache 一样,我们希望它全局只有一个,因此设计为单例模式,并通过懒汉模式实现单例。

有了如上的理解,Page Cache 的核心框架如下:

namespace Xq
{
	class PageCache
	{
	private:
		PageCache() {}
		PageCache(const PageCache&) = delete;
	public:

		// 获取 Page Cache 的唯一实例
		static PageCache* GetOnlyInstance()
		{
			// 双重检查锁
			if (_ptrOnlyPageCache == nullptr)
			{
				PageCacheInstanceMutex.lock();
				if (_ptrOnlyPageCache == nullptr)
				{
					_ptrOnlyPageCache = new PageCache;
				}
				PageCacheInstanceMutex.unlock();
			}
			return _ptrOnlyPageCache;
		}
		
	private:
		// 内嵌一个回收类
		// 回收该唯一实例
		class page_cache_recovery
		{
		public:
			~page_cache_recovery()
			{
				if (_ptrOnlyPageCache)
				{
					delete _ptrOnlyPageCache;
					_ptrOnlyPageCache = nullptr;
				}
			}
			static page_cache_recovery _auto_delete_ptr_only_page_cache;
		};
	private:
		// Page Cache 也是一个哈希桶, 每个桶也是一个 SpanList
		SpanList _pageList[PageCacheBucketNum];
		// Page Cache 唯一实例
		static PageCache* _ptrOnlyPageCache;
		// 通过一把大锁, 保证线程安全
		std::mutex _mtx;
	};
}

与 Central Cache 一样,这些静态成员属性最好将其放在 .cc 文件中定义,以免产生链接问题,如下:

// 定义PageCache唯一实例
Xq::PageCache* Xq::PageCache::_ptrOnlyPageCache = nullptr;
// 定义一个静态成员变量,进程结束时,系统会自动调用它的析构函数从而释放PageCache单例对象
Xq::PageCache::page_cache_recovery Xq::PageCache::page_cache_recovery::_auto_delete_ptr_only_page_cache;

5.8. Page Cache 中获取Span

当 Central Cache 通过 Xq::CentralCache::GetOneNonEmptySpan 接口获取一个非空的 Span 对象时,Central Cache 会访问 Central Cache 对应的桶是否有一个非空的 Span 对象,如果有,返回一个 Span 对象即可,如果没有,就需要向下一层,也就是通过 Page Cache 获取一个非空的 Span 对象,可是我们说过, Page Cache 中的 Span 对象是没有对内存进行切分的,因此,当从 Page Cache 获取一个 Span 对象后,我们需要再将这个 Span 对象切分为特定大小的若干个内存对象,同时, Page Cache 涉及到线程安全问题,因此,从 Page Cache 获取 Span 对象时,需要通过 Page Cache 中的这把大锁保证线程安全。

我们知道 Span 对象是由若干个页组成的内存跨度,那么当 Central Cache 通过 Page Cache 获取一个 Span 对象时,Central Cache 是不是应该告诉 Page Cache,我要多少页的 Span 对象呢?因此, Page Cache 是需要通过页数返回特定的 Span 对象,其接口如下:

// 返回一个Span对象
Xq::Span* Xq::PageCache::NewSpan(size_t page_num);

可是 Central Cache 在 Xq::CentralCache::GetOneNonEmptySpan 接口中只知道内存对象的大小,如何得知需要多少个页的 Span 对象呢?此时就需要一个接口,用来根据所需要的内存对象的大小判定这个Span对象需要多少个页,其接口如下:

// 我们约定 1 Page 的大小为 8 kb, 即 1 << 13
// 故这里的 PageShift, 即页的移位数就是13

// 依据 align_size (对齐后的内存的大小) 判定索要创建的Span对象需要多少个页
static inline size_t NumMovePage(size_t align_size)
{
	size_t num = NumMemoryUnitTarget(align_size);
	size_t page_num = num * align_size;

	page_num >>= PageShift;

	// 至少要给一页
	if (page_num == 0)
		page_num = 1;

	return page_num;
}

举两个例子:

比如要申请的内存对象大小为 8byte:

那么此时 num = 512, page_num = 4096

page_num >>= PageShift,即 page_num >>= 13,此时的 page_num = 0,最后调整为1

如果要申请的内存对象大小为 256kb:

那么 num = 2,page_num = 512 kb

page_num >>= PageShift,即 page_num >>= 13,此时的 page_num = 64,页数就是64;

通过上面这个接口,就可以通过要申请的内存对象的大小获得要申请的 Span 大小的页数。

当从 Page Cache 获取一个 Span 对象后,由于这个 Span 对象中的 _freeList 为空,即没有对内存进行切分,因此,我们需要对这个 Span 对象进行切分为若干个内存对象。

首先,我们需要解释页号和大块内存的关系,页的移位数即 PageShift,如果页的大小是 8kb,那么 PageShift 就是13,其作用:

  • 通过页号求大块内存的起始地址。大块内存的起始地址 = 页号 << PageShift;
  • 通过大块内存的起始地址得页号。页号 = 大块内存的起始地址 >> PageShif。

上面这个关系是我们自己约定的,因此在创建 Span 对象中定义页号时,是需要我们遵守上面这个规则的。

假如此时已经通过 Page Cache 获取了一个 Span 对象 ,我们如何将其切分为若干个大小为 align_size 的内存对象呢?

  • 第一步:根据 Span 对象中的页号得到大块内存对象的起始地址;
  • 第二步:将这块大块的内存以 align_size 为单位进行切分,并将每个内存对象依次挂接在 Span 中的 _freeList 中;
  • 第三步:将这个 Span 对象插入进 Central Cache 的当前桶中,也就是 SpanList 中;
  • 第四步:返回这个 Span 对象。

需要注意的是:为了保证插入进 _freeList 中的内存对象是有序的,我们选择尾插,但由于 _freeList 是一个单链表,因此在切分过程中,我们记录一个尾指针,具体实现如下:

// 通过Span对象的页号获取大块内存的起始地址
char* memory_start_pos = reinterpret_cast<char*>(obj->_pageId << PageShift);
// 大块内存的整体大小 = 页的数量 * 页的大小, 也相当于, 页的数量 << 页的大小
size_t memory_size = obj->_pageNum << PageShift;
// 通过大小得到这段空间的结尾
char* memory_end_pos = memory_start_pos + memory_size;

// 接下来, 我们就要将整个大块内存切分为若干个内存对象, 并将其挂接在 Span 中的 _freeList 中
// 为了保证这个大块内存的顺序性, 我们选择尾插, 为了避免尾插所带来的效率问题, 我们记录一个尾指针
void* tail = nullptr;
// 我们先切一个内存对象
obj->_freeList = memory_start_pos;
tail = obj->_freeList;
memory_start_pos += align_size;
// 切完一个内存对象后, 就按照下面的逻辑切分剩下的内存
while (memory_start_pos < memory_end_pos)
{
	Xq::NextMemory(tail) = memory_start_pos;
	tail = memory_start_pos;
	memory_start_pos += align_size;
}

// 将 tail (也就是当前 Span 中的_freeList 的最后一个内存对象) 前4/8个字节置为 nullptr
Xq::NextMemory(tail) = nullptr;

有了上面的基础,我们就可以完善这个 Xq::CentralCache::GetOneNonEmptySpan 接口了,当我们准备要从 Page Cache 获取一个 Span 对象时,此时是可以将 Central Cache 对应的桶锁释放掉的,因为要访问 Page Cache 是需要加锁的,这把锁是 Page Cache 中的大锁,保证获取的这个 Span 对象只有持有这把大锁的执行流可以访问,其他执行流无法访问,因为此时这个 Span 对象没有插入到 Central Cache 对应的桶中,故其他执行流访问不到,因此,后续过程中对该 Span 对象的切分工作不需要加锁,释放桶锁带来的好处是什么呢? 要注意:Central Cache 这一层可不仅仅只有申请内存的逻辑,还有释放内存的逻辑,即如果你不释放桶锁,那么当前桶的释放逻辑也会被阻塞,这是不好的,因为这会增加访问 Page Cache 的概率,而 Page Cache 是一把大锁,串行化程度很高,过多访问,对性能是有消耗的,因此,我们需要在访问 Page Cache 之前将对应桶中的桶锁释放掉,但是,当获取 Span 对象并完成对其的切分工作之后,再将这个 Span 对象插入到 Central Cache 中对应桶之前,需要加锁保护,因为此时可能有其他执行流也在访问当前桶,为了避免线程安全问题,此时是需要加上桶锁的,其代码如下所示:

Xq::Span* Xq::CentralCache::GetOneNonEmptySpan(SpanList& span_list, size_t align_size)
{
	Span* cur = span_list.Begin();
	while (cur != span_list.End())
	{
		// 如果当前的 Span 对象有内存对象, 那么这个Span就是返回的对象
		if (cur->_freeList != nullptr)
		{
			return cur;
		}
		// 如果当前的 Span 对象没有内存对象, 遍历下一个即可
		else
		{
			cur = cur->_next;
		}
	}

	// 如果上面没有, 说明当前这个桶没有合适的Span对象
	// 那么就需要向下一层, 也就是通过 Page Cache 这一层要一个Span对象

	// 同时, 我们需要判定这个Span对象需要多少个 Page, 因为 Span 是以页为单位的多个页的内存跨度
	// 比如, 对于 8byte 的内存对象, 1 Page就绰绰有余了;
	// 但对于 256 kb 的内存对象, 则需要很多页.
	// 因此需要一个接口 ( size_t NumMovePage(size_t apply_size) ) , 用来根据所需要的内存对象的大小判定这个Span对象需要给多少个页.
	//size_t page_num = Xq::NumMovePage(align_size);

	// 在这之前, 我们需要将这个桶锁解掉, 因为, 此时可能存在某个执行流在当前桶
	// 归还内存的场景, 为了不阻塞其他线程, 需要将桶锁释放掉.
	span_list.GetBucketMtx().unlock();

	// 同理, 在多线程场景下, 访问 Page Cache 也是存在线程安全问题的
	// 而 Page Cache 就不能再使用桶锁了, 因为 Page Cache 一次可能不止访问一个桶
	// 存在访问多个桶的场景, 如果使用桶锁, 必然存在频繁的加锁和释放锁, 且存在执行流频繁的上下文切换
	// 是一个不小的性能开销, 故 Page Cache 保证线程安全采用的是一个大锁, 通过这个锁保证 Page Cache 的线程安全
	Xq::PageCache::GetOnlyInstance()->GetPageCacheMtx().lock();
	//Xq::Span* obj = Xq::PageCache::GetOnlyInstance()->NewSpan(page_num);
	Xq::Span* obj = Xq::PageCache::GetOnlyInstance()->NewSpan(Xq::NumMovePage(align_size));
	// 一个合法的 Span 对象
	assert(obj);
	Xq::PageCache::GetOnlyInstance()->GetPageCacheMtx().unlock();

	// 当从Page Cache 获取一个Span对象后, 对这个Span对象的切分工作不需要加锁
	// 因为此时这个Span对象并没有链入到 Central Cache 的桶中, 其他执行流访问不到这个 Span 对象
	// 既然都访问不到, 也就不存在竞争, 自然不存在线程安全问题

	// 当获取一个 Span 对象后, 我们需要通过 Span 中的页号记算出大块内存的起始地址.
	// 如何计算大块内存的起始地址呢? 大块内存的起始地址 = 页号 * 页的大小, 也就相当于 页号 << PageShift 
	// 比如, 页的大小是8k, 那么页号 << PageShift 就是大块内存的起始地址
	// 为了更好地对指针变量的移动, 我们将该指针变量的类型定义为 char*
	char* memory_start_pos = reinterpret_cast<char*>(obj->_pageId << PageShift);

	// 大块内存的整体大小 = 页的数量 * 页的大小, 也相当于, 页的数量 << 页的大小
	size_t memory_size = obj->_pageNum << PageShift;
	// 通过大小得到这段空间的结尾
	char* memory_end_pos = memory_start_pos + memory_size;

	// 接下来, 我们就要将整个大块内存切分为若干个内存对象, 并将其挂接在 Span 中的 _freeList 中
	// 为了保证这个大块内存的顺序性, 我们选择尾插, 为了避免尾插所带来的效率问题, 我们记录一个尾指针
	void* tail = nullptr;
	// 我们先切一个内存对象
	obj->_freeList = memory_start_pos;
	tail = obj->_freeList;
	memory_start_pos += align_size;
	// 切完一个内存对象后, 就按照下面的逻辑切分剩下的内存
	while (memory_start_pos < memory_end_pos)
	{
		Xq::NextMemory(tail) = memory_start_pos;
		tail = memory_start_pos;
		memory_start_pos += align_size;
	}

	// 将 tail (也就是当前 Span 中的_freeList 的最后一个内存对象) 前4/8个字节置为 nullptr
	Xq::NextMemory(tail) = nullptr;

	// 将这个 Span 对象链入进这个桶中
	// 这个过程就设计了访问Central Cache 的特定桶, 存在竞争, 即存在线程安全问题
	// 因此需要加上桶锁保护
	span_list.GetBucketMtx().lock();
	span_list.PushFront(obj);

	// 返回这个 Span 对象
	return obj;
}

可以看到,SpanList 需要一个接口 PushFront,实现如下:

void PushFront(Span* obj)
{
	Insert(Begin(), obj);
}

那么接下来,我们就需要完成 Page Cache::NewSpan,通过页数返回特定的 Span 对象,如何实现呢?

我们说过, Page Cache 也是一个哈希桶,每个桶挂载的是若干个页组成的 Span 对象,比如1号桶挂载的就是若干个由 1page 构成的 Span 的对象,128号桶挂载的就是若干个由 128page 构成的 Span 对象,那么思路就很简单了:

  • 第一种情况:我们只需要根据传递的页数遍历 Page Cache 中对应的桶,看是否存在 Span 对象,如果存在,返回即可。可是如果没有呢?
  • 第二种情况:我们同样说过,最开始的时候,Page Cache 没有一个 Span 对象,当 Central Cache 通过接口向 Page Cache 获取 Span 对象时,Page Cache 会通过系统调用直接向系统堆申请一个 128page 的 Span 对象,此时如果要 1page 的Span对象,Page Cache 就可以将1个 128Page 的 Span 对象切分为1个 1page 的 Span 对象和1个 127page 的 span 对象,换言之,当我们需要一个 1page 的 Span 对象时,如果当前 1 号桶没有,我们可以遍历后面的桶,如果后面的桶存在 Span 对象,就对其进行切分,并返回一个目标页数的 Span 对象。
  • 第三种情况:可是如果最开始的时候呢?那么就会申请1个 128 page 的Span对象,那么此时该怎么办呢? 难道再写一个切分的逻辑吗? 不需要,如果走到这里,说明前面两种情况都不满足,此时只需要将这个 128 page 的 Span 对象插入到 128 的桶中,并再次复用该接口,就可以满足第二种情况。

有了上面的理解,其实现如下:

// 返回一个Span对象
Xq::Span* Xq::PageCache::NewSpan(size_t page_num)
{
	assert(page_num > 0);


	// Case 1: 先遍历当前桶有没有 Span 对象, 如果存在, 那么返回这个 Span 对象即可
	if (!_pageList[page_num].Empty())
	{
		Span* obj = _pageList[page_num].PopFront();
		return obj;
	}

	// Case 2: 看后面的桶是否存在 Span 对象
	// 如果当前桶没有 Span 对象, 那么就去遍历一下后面的桶有没有 Span 对象
	// 因为如果后面有 Span 对象, 可以将它进行切分为一个 目标 Span 对象和剩余的一个 Span对象
	// 举个栗子: 假如我要申请 2 Page 的 Span 对象, 但是这个桶没有对象, 我们就可以向后面遍历
	// 假如 16 Page 有 Span 对象, 我们就可以将其拆分为 1个 2Page 的 Span 对象和 一个 14 Page 的 Span 对象
	// 并这个 14 Page 的 Span对象链入到 14 号 桶中, 当然, 再拆分的过程中, 需要对 Span 对象的属性进行更新
	// 从当前桶的下一个桶开始遍历
	for (size_t pos = page_num + 1; pos < PageCacheBucketNum; ++pos)
	{
		if (!_pageList[pos].Empty())
		{
			// 这个是被切的 Span 对象
			Span* objSpan = _pageList[pos].PopFront();
			// 这个是要返回 Span 对象
			Span* span = new Span;
			// 设置页号
			span->_pageId = objSpan->_pageId;
			// 设置页数
			span->_pageNum = page_num;

			// 更新被切的Span对象的页号和页数
			objSpan->_pageId += page_num;
			objSpan->_pageNum -= page_num;

            // 将被切的对象重新插入到对应的桶
			_pageList[objSpan->_pageNum].PushFront(objSpan);
			return span;
		}
	}

	// Case 3: 开一个 128Page 的 Span 对象, 将其插入到128桶中, 并服用该接口
	// 走到这里说明, 遍历了所有的桶, 都没有找到一个 Span 对象
	// 那么此时 Page Cache 会向堆申请一个 128 page 的内存对象
	Span* max_span = new Span;
	// 申请一个 128 Page 的大块内存
	void* memory_start_pos = SystemAlloc(PageCacheBucketNum - 1);
	// 设置相关属性字段
	// 页号: 大块内存的起始地址 >> PageShift
	max_span->_pageId = reinterpret_cast<PAGE_ID>(memory_start_pos) >> PageShift;
	// 页数: 128 Page 的大块内存自然是128页咯
	max_span->_pageNum = PageCacheBucketNum - 1;
	// 我们将这个 max_span 对象插入进 128 Page 的桶中
	_pageList[max_span->_pageNum].PushFront(max_span);
	// 复用这个接口
	return NewSpan(page_num);
}

5.9. 申请内存过程联调

上面我们已经将申请内存的过程走通了,再次,我们通过调试来走一下这个流程,测试代码如下:

void AllocateTest1()
{
	ConcurrentAllocate(7);
}

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

进入 ConcurrentAllocate 接口,得到如下现象:

当前执行流的 Thread Cache 为空,因此首先需要创建独属于当前执行流的 Thread Cache (TLS)。

同时,因为此时要申请的内存大小为 7 byte,故会通过 Thread Cache 提供的 Allocate 申请内存对象,如下:

进入 Allocate 中,根据 apply_size 得到对应桶的下标,发现当前桶中的 _freeList 为空,既没有内存对象,如下:

故会访问下一层,也就是通过接口向 Central Cache 获取内存对象,如下:

进入 ThreadCache::GainFromCentralCache 中,根据 align_size 得到理论值,再获取理论值和当前桶的慢增长的值中的较小值作为预期值,如下:

如果预期值就是慢增长的值,那么更新慢增长的值,如下:

接下来就要通过调用 Central Cache::GainRangeMemoryObj 接口获取预期值个大小为 align_size 的内存对象,如下:

首先,Central Cache 是单例模式,这是第一次调用,因此会创建这个唯一实例,如下:

结果如下:

通过这个唯一实例调用 Central Cache::GainRangeMemoryObj ,首先计算 Central Cache 中桶的下标,并从这个桶中返回一个非空的 Span 对象,如下:

调用 CentralCache::GetOneNonEmptySpan 获取一个非空的 Span 对象,首先,遍历当前桶,看是否有非空的 Span 对象,如下:

可以看到,当前桶没有Span对象,故需要向下一层也就是 Page Cache 获取一个 Span 对象,如下:

与 Central Cache 同理,Page Cache 也是一个单例模式,在这里不展示了,跳过。

当通过 Page Cache 获取一个 Span 对象时,有三种情况。

第一种情况,查看 Page Cache 对应的桶中是否有 Span 对象,如下:

如果当前桶没有,那么查看后面的桶是否存在 Span 对象,如果有,进行切分,如下:

这里的循环也跳过了,因为此时 Page Cache 所有的桶都没有 Span 对象,此时就是第三种情况,即所有的桶都没有 Span,那么 Page Cache 会向系统堆申请一个 128page 的 Span 对象,如下:

通过系统调用开辟一个 128page 的大块内存,如下:  

如何设置 Span 对象的起始页号呢 ?

因为地址本质上是一个整数,故将该地址 >> PageShift 就是 128page 的起始页号,如下:

验证如下: 

页号转化为大块内存的起始地址

大块内存的起始地址转化为Span对象的起始页号

验证现象符合结果,接下来,设置这个 Span 对象的页号和页数,如下:

将这个 Span 对象链入到 Page Cache 中的 128 号桶中,如下:

插入成功,复用该接口,此时就会满足第二种场景,中间的循环我就跳过了,直接跳到第二种场景中的 pos == 128 ,如下:

得到如下结果:  

创建一个 Span 对象用以返回,并对获取的 Span 对象进行切分,实质上就是更新页号和页数,如下:

切分结果如下:

再将这个 objSpan 即被切分的 Span 插入进 Page Cache 中 127 号桶中,结果如下:

返回这个 1 page 的 Span 对象,如下所示:

接下来,就要对这个 Span 对象进行切分,即将其切分为若干个大小为 align_size 的内存对象,首先获得大块内存的起始地址,大块内存的整体大小,大块内存的终止位置,如下:

接下里的逻辑就是具体的切分逻辑了,如下所示:

我们只看第一个,如下所示:

将内存切分完毕后,必须将最后一个内存对象的前4/8字节的内容置为空,这就是细节问题提,如果不注意,这里往往是问题产生的原因。

接下来,就要将这个 Span 对象链入到 Central Cache 的0号桶中,也就是这里的 span_list,如下所示:

返回这个 Span 对象,如下所示:

从当前 Span 对象获取预期值个内存对象,这一批内存对象所在的范围用 start 和 end 来标识,如下:

通过循环 (此次不会进去),获得若干个内存对象后,需要更新 Span 中的 _freeList,如下:

更新结果如下:

start 和 end 标识的是要返回的若干个内存对象,但是此时的 end 的前4/8字节的内容依旧指向下一个内存对象,也就是这儿的 _freeList,验证如下:

因此,为了避免不必要的错误,我们必须将end 的前4/8字节的内容置为空,如下:

接下来,释放桶锁,并返回实际获取的内存对象的个数,如下:

此时的 start 和 end 是相等的,标识一块内存对象,如下:

返回这个内存对象,如下:

返回给 ConcurrentAllocate 接口,如下:

至此,我们的申请内存过程的联调到此结束。

6. 释放内存逻辑

内存池对外提供的释放内存的接口,如下:

// 提供给上层的释放空间的接口
// 这里暂时需要提供 apply_size, 以便于能够调用 ThreadCache::Deallocate, 后续处理
static void ConcurrentDeallocate(void* obj, size_t apply_size)
{
	assert(obj);
	assert(Xq::TLSThreadCacheObject);

	// 如果要释放的内存 <= 256kb, 通过 Thread Cache 处理
	if (apply_size <= ThreadCacheMaxSize)
	{
		size_t align_size = Xq::ManageSize::AdjustUp(apply_size);
		Xq::TLSThreadCacheObject->Deallocate(obj, align_size);
	}
	// 如果大于 >= 256kb, 暂时不处理
	else
	{
		//
	}
}

6.1. Thread Cache 回收内存

Thread Cache 回收内存的接口如下:

// obj: 用户要释放的内存单元对象
// apply_size: 是用户申请时提供的大小
// 该接口需要通过对齐apply_size, 将这个obj链入到对应的桶中
void Deallocate(void* obj, size_t apply_size);

那么这个接口如何实现呢? 很简单,根据传入的 apply_size 确定是 Thread Cache 中的哪一个桶,找到目标桶后,再将这个内存对象链入到这个桶中。

void Xq::ThreadCache::Deallocate(void* obj, size_t align_size)
{
	// Thread Cache 只处理 <= 25kb 的内存
	assert(align_size <= ThreadCacheMaxSize);
	// obj 不能为空
	assert(obj);
	// 依据align_size, 计算对应的桶的下标 (找到目标 FreeList)
	size_t bucket_index = Xq::ManageSize::BucketIndex(align_size);
	// 将这个内存对象链入到对应的桶中即可
	_freeList[bucket_index].push(obj);
}

可是我们说过, Thread Cache 这一层如果某个桶 (FreeList) 中挂载的内存对象太多了 (未使用的),那么就将这些未使用的内存对象归还给下一层,也就是归还给 Central Cache。

那么通过什么逻辑来判定呢? 我们这里采用一种方案,只要当前 Thread Cache 中某个桶当中的内存对象个数 >= 当前桶的慢增长的值,那么我们就将当前慢增长的值个的内存对象通过相关接口归还给 Central Cache,这个接口如下:

// 将批量的内存对象归还给 Central Cache 对应桶的对应的 Span 对象
void FreeListTooLong(FreeList& freeList, size_t align_size);

依据上面的分析,ThreadCache::Deallocate 的完整逻辑如下:

void Xq::ThreadCache::Deallocate(void* obj, size_t align_size)
{
	// Thread Cache 只处理 <= 25kb 的内存
	assert(align_size <= ThreadCacheMaxSize);
	// obj 不能为空
	assert(obj);
	// 依据align_size, 计算对应的桶的下标 (找到目标 FreeList)
	size_t bucket_index = Xq::ManageSize::BucketIndex(align_size);
	// 将这个内存对象链入到对应的桶中即可
	_freeList[bucket_index].push(obj);

	// 如果当前桶挂载的内存对象的个数 >= 当前桶一次批量获取的内存对象 (慢增长的值)
	// 那么就将此时一次批量的内存对象归还给 Central cache 的特定桶中的 Span 对象
	if (_freeList[bucket_index].get_cur_memory_num() >= _freeList[bucket_index].get_slow_start_num())
	{
		FreeListTooLong(_freeList[bucket_index], align_size);
	}
}

可是这里隐藏一个问题,上面这个过程是需要 Thread Cache 每个桶 (FreeList) 的当前挂载的内存对象个数,因此,在 FreeList 中添加一个成员属性,表示当前FreeList中有多少个内存对象,如下:

// 自由链表结构
// 核心目的: 用于将切分好的内存对象组织起来
class FreeList
{
public:
    // 省略

	// 获得当前自由链表结构内存对象的个数
	size_t get_cur_memory_num()
	{
		return _curMemoryNum;
	}
private:
	void* _freeList = nullptr;   // 自由链表的起始地址
	size_t _slowStartNum = 1;  // 慢开始机制的起始数字
	size_t _curMemoryNum = 0; // 当前内存对象的个数
};

有了当前内存对象的个数这个成员属性,那么 push 和 pop 是不是应该也要更改一下,比如 push,当插入一个内存对象时,当前内存对象的个数 + 1,因此,有如下更改:

// push: 将一个内存对象链入到链表中
void push(void* obj)
{
	// 要链入的内存对象不能为空
	assert(obj);
	// 将内存对象头插到链表中
	NextMemory(obj) = _freeList;
	// 更新 _freeList
	_freeList = obj;
    // ++当前内存对象的个数 
	++_curMemoryNum;
}

// pop: 从自由链表中弹出一个内存对象
void* pop(void)
{
	// 自由链表不能为空
	assert(_freeList);
	// 保存要返回的内存对象
	void* ret = _freeList;
	// 更新自由链表的起始地址
	_freeList = NextMemory(_freeList);
	// 将要返回的前4/8字节的内容置为空
	NextMemory(ret) = nullptr;
    // --当前内存对象的个数
	--_curMemoryNum;
	// 返回这个内存对象
	return ret;
}

同时,pushRangeMemory 这个接口是不是也要更新呢?如下:

// 将批量的内存对象插入到 FreeList 中
void pushRangeMemory(void* start, void* end);

可是如何更新这个值呢?

  • 方案一:在函数内部遍历一遍,得到内存对象的个数,但这效率太低了;
  • 方案二:更改接口,外部显示传递一个值,表明这段空间 [start, end] 的内存对象的个数。

我们选择第二种,更改如下:

// 将 num 个内存对象链入到 _freeList 中
void pushRangeMemory(void* start, void* end, size_t num)
{
	NextMemory(end) = _freeList;
	_freeList = start;
	_curMemoryNum += num;
}

既然更改了这个接口,那么就要在调用原接口做出更改,其位置如下:

调用这个接口的位置在 ThreadCache::GainFromCentralCache 中,更改如下:

// 当 Thread Cache 没有对应的内存单元对象时, 会向 Central Cache 索取, Central Cache 会根据相关算法返回批量的内存单元对象.
void* Xq::ThreadCache::GainFromCentralCache(size_t align_size, size_t bucket_index)
{	
    // 省略... 

	// 如果只有一个内存对象, 返回这个内存对象即可
	if (actual_num == 1)
	{
		assert(start == end);
		return start;
	}
	else
	{
		// 返回一个内存对象, 并将剩余的内存对象链入到匹配的哈希桶中
		// 第一种方案:
		//_freeList[bucket_index].pushRangeMemory(start, end, actual_num);
		//return _freeList[bucket_index].pop();

		//  第二种方案: 
		_freeList[bucket_index].pushRangeMemory(Xq::NextMemory(start), end, actual_num - 1);
		return start;
	}
}

那么接下来,我们就要实现 ThreadCache::FreeListTooLong,将批量的内存对象返回给 Central Cache,其接口如下:

void Xq::ThreadCache::FreeListTooLong(FreeList& freeList, size_t align_size);

如何实现呢?

我们首先需要从当前桶中 (FreeList) 弹出若干个内存对象 (数量为当前桶的慢增长的值),因此,我们又需要提供一个接口,即从 FreeList 中 pop 若干个内存对象,实现如下

// 从当前桶中取出 num 个内存对象
void popRangeMemory(void*& start, void*& end, size_t num)
{
	assert(_freeList);
	// 最多只能取 _curMemoryNum 个内存对象
	assert(_curMemoryNum >= num);
	start = _freeList;
	end = start;
	// 为了方便, 将最后的 end 的前4/8个字节置为 nullptr
	// 我们选择遍历 num - 1步 (画图分析得到的结论)
	for (size_t i = 0; i < num - 1; ++i)
	{
		end = Xq::NextMemory(end);
	}
	// 更新 _freeList
	_freeList = Xq::NextMemory(end);
	// end 的前4/8字节置为 nullptr
	Xq::NextMemory(end) = nullptr;
	// 更新 _curMemoryNum
	_curMemoryNum -= num;
}

通过上面的接口,可以从 Thread Cache 特定桶中取出若干个内存对象,取出之后,我们还需要调用 Central Cache 的相关接口,将这一批内存对象插入到特定的 Span对象中,ThreadCache::FreeListTooLong 的实现如下:

void Xq::ThreadCache::FreeListTooLong(FreeList& freeList, size_t align_size)
{
	// 通过 FreeList::PopRangeMemory 从当前的 FreeList 中取出 memory_target_num 个内存对象
	void* start = nullptr;
	void* end = nullptr;
	freeList.popRangeMemory(start, end, freeList.get_slow_start_num());

	// 此时要归还的若干个内存对象就是 [start, end]
	// 接下来, 我们就要通过 Central Cache::RealeseMemoryUnitsToSpans
	// 将若干个内存对象归还给 Central Cache的Span对象
	// 此时之需要传递 start 即可, 因为可以通过遍历的方式访问所有的内存对象
	Xq::CentralCache::GetOnlyInstance()->RealeseMemoryUnitsToSpans(start, align_size);
}

6.2. Central Cache 回收内存

Central Cache 回收内存的接口如下:

// start 是若干个内存对象的起始地址, 通过遍历, 可以获得所有的内存对象
// align_size: 代表当前内存对象对齐后的字节数, 以方便计算 Central Cache 中的特定桶
void RealeseMemoryUnitsToSpans(void* start, size_t align_size);

我们知道, Central Cache 是一个哈希桶结构,每个桶都是一个 SpanList,挂载的是若干个 Span 对象,而这些 Span 对象会被切分为若干个内存对象,如果这些分配出去的内存对象都还给了 Span,那么 Central Cache 会将这个 Span 对象在换给下一层,而上面的基础是不是需要知道当前有多少个内存对象正被使用呢?因此,当 Span 将内存对象分配的时候,应该更新一下当前有多少个内存对象正被使用,同理,当 Span 分配出去的内存对象还回来的时候,也应该更新一下当前有多少个内存对象正被使用。

因此在 Central::GainRangeMemoryObj 中分配对象时,我应该更新当前有多少个内存对象正被使用,如下:

size_t Xq::CentralCache::GainRangeMemoryObj(void*& start, void*& end, size_t expect_num, size_t align_size)
{
	// 省略

	// 更新 Span 中的 _useNum, 代表有多少个内存对象正在使用
	span->_useCount += actual_num;
	// 释放桶锁
	_spanList[bucket_index].GetBucketMtx().unlock();
	// 返回实际获取内存对象的个数
	return actual_num;
}

同理,那么在 Central::RealeseMemoryUnitsToSpans 中释放内存对象时,也要更新当前有多少个内存对象正被使用。

那么 Central::RealeseMemoryUnitsToSpans 接口如何实现呢?

很简单,我们只需要将这一批的内存对象一个一个的归还给 Central Cache 中特定的 Span 对象即可,注意,这一批的内存对象可不一定是同一个 Span 对象中的内存对象,因此,我们需要把每个内存对象匹配的归还给对应的 Span 对象。

那么如何知道要归还给哪一个 Span 对象呢?这个问题实际上不好解决,我们在这里直接引出设计者们的思路:  

这一批内存对象首先一定是从 Central Cache 中特定的 Span 对象切出来的,而 Span 是什么呢? 它是由若干个页组成的内存跨度,而我们知道,我们可以通过一块内存的起始地址 >> PageShift 就可以得到对应的页号,那么现在的问题就是,如何通过页号得到对应的 Span 对象呢?

方案一:我们可以遍历 Central Cache 特定桶中的 Span 对象,查看这个页号是否在遍历的 Span 对象中的 [页号, 页号 + 页数 - 1]  这个范围中 ,可是,这种方案会导致归还一个内存对象的时间复杂度达到了 O(N),效率太低。

为了提高效率,我们引出方案二。

方案二:将页号和 Span 对象通过一个哈希表映射起来,此时就可以通过每个内存单元对象所在的页号快速得到与之匹配的 Span 对象。

因此,我们可以建立一个映射表,这张表维护的是页号与 Span 的映射关系。

那么这个映射关系放在哪里呢?

由于这个映射关系不仅 Central Cache 需要使用, Page Cache 也要使用,故我们将这个哈希表定义在 Page Cache 中。

这个哈希表如下定义:

namespace Xq
{
	class PageCache
	{
        // 省略
	private:
		// Page Cache 也是一个哈希桶, 每个桶也是一个 SpanList
		SpanList _pageList[PageCacheBucketNum];
		// Page Cache 唯一实例
		static PageCache* _ptrOnlyPageCache;
		// 通过一把大锁, 保证线程安全
		std::mutex _mtx;

		// 保存页号和 Span 对象的映射关系
		std::unordered_map<PAGE_ID, Xq::Span*> _PageIdToSpanMap;
	};
}

那么什么时候建立映射关系呢?分两种情况:

  • 情况一:如果这个 Span 对象要从 Page Cache 返回给 Central Cache,那么就需要将这个 Span 对象的所有页与这个 Span 对象建立起映射关系;
  • 情况二:如果这个 Span 对象在 Page Cache 中,比如进行切分的 Span 对象,那么只需要将起始页和结束页与这个 Span 对象建立起映射关系即可,这主要是为了前后页的合并。

我们在这里提供一个接口,将从特定的页号开始的若干个页与特定的 Span 对象建立映射关系,该接口如下:

// 将页号和Span对象添加到_PageIdToSpanMap映射表中
inline static void AddAndUpdatePageIdToSpanMap(PAGE_ID page_id, size_t page_num, Span* span, std::unordered_map<PAGE_ID, Xq::Span*>& PageIdToSpanMap)
{
	for (PAGE_ID i = 0; i < page_num; ++i)
	{
		PageIdToSpanMap[page_id + i] = span;
	}
}

综上所述,在 Page Cache::NewSpan 中就需要建立映射关系,有如下三种情况:

  • 对于第一种情况,因为这个 Span 对象是返回给 Central Cache 的,故这个 Span 对象的所有页都要与当前 Span 建立映射;
  • 对于第二种情况,其中的 span 要返回给 Central Cache ,因此这个 span 对象的所有页都要与 span 建立映射,而被切分的 Span 对象不返回给 Central Cache,而是继续插入到 Page Cache 的桶中,因此只需要维护起始页和结束页的映射关系即可;
  • 对于第三种情况,因为会复用该接口,因此也可以归纳为第一种情况或者第二种情况。

实现如下:

// Case 1: 先遍历当前桶有没有 Span 对象, 如果存在, 那么返回这个 Span 对象即可
if (!_pageList[page_num].Empty())
{
	Span* obj = _pageList[page_num].PopFront();
	Xq::AddAndUpdatePageIdToSpanMap(obj->_pageId, obj->_pageNum, obj, _PageIdToSpanMap);
	return obj;
}
// Case 2: 看后面的桶是否存在 Span 对象
// 如果当前桶没有 Span 对象, 那么就去遍历一下后面的桶有没有 Span 对象
// 因为如果后面有 Span 对象, 可以将它进行切分为一个 目标 Span 对象和剩余的一个 Span对象
// 举个栗子: 假如我要申请 2 Page 的 Span 对象, 但是这个桶没有对象, 我们就可以向后面遍历
// 假如 16 Page 有 Span 对象, 我们就可以将其拆分为 1个 2Page 的 Span 对象和 一个 14 Page 的 Span 对象
// 并这个 14 Page 的 Span对象链入到 14 号 桶中, 当然, 再拆分的过程中, 需要对 Span 对象的属性进行更新
// 从当前桶的下一个桶开始遍历
for (size_t pos = page_num + 1; pos < PageCacheBucketNum; ++pos)
{
	if (!_pageList[pos].Empty())
	{
		// 这个是被切的 Span 对象
		Span* objSpan = _pageList[pos].PopFront();
		// 这个是要返回 Span 对象
		Span* span = new Span;
		// 设置页号
		span->_pageId = objSpan->_pageId;
		// 设置页数
		span->_pageNum = page_num;
		// 更新被切的Span对象的页号和页数
		objSpan->_pageId += page_num;
		objSpan->_pageNum -= page_num;

		// 将 span 所在的页号和 span 添加到映射表中
		// 注意: 由于Span对象是多个页的跨度, 故存在多个页共同映射一个Span对象的可能
		Xq::AddAndUpdatePageIdToSpanMap(span->_pageId, span->_pageNum, span, _PageIdToSpanMap);

		// 对于 objSpan 而言, 因为它此时没有返回给 Central Cache
		// 故不需要将所有的页都与这个 Span 对象建立映射
		// 但为了合并, 我们只需要将这个 Span 对象的起始和末尾的页与这个 Span 对象建立映射即可
		_PageIdToSpanMap[objSpan->_pageId] = objSpan;
		_PageIdToSpanMap[objSpan->_pageId + objSpan->_pageNum - 1] = objSpan;

		// 将被切的对象重新插入到对应的桶
		_pageList[objSpan->_pageNum].PushFront(objSpan);
		return span;
	}
}

此时我们就可以保证每个分配给 Central Cache 的 Span 对象一定是会保存到映射表中的,因此,我们可以提供一个接口,让其通过大块内存的起始地址得到目标 Span 对象,该接口如下:

Span* GetSpanByAddr(void* start)
{
	// 在这里可以使用 RAII 的锁, 保证线程安全
	std::unique_lock<std::mutex> lock(_mtx);
	PAGE_ID page_id = reinterpret_cast<PAGE_ID>(start) >> PageShift;
	auto ret = _PageIdToSpanMap.find(page_id);
	if (ret != _PageIdToSpanMap.end())
	{
		return ret->second;
	}
	else
	{
		assert(false);
		return nullptr;
	}
}

此时,我们就可以实现 Central Cache::RealeseMemoryUnitsToSpans 接口,通过该接口将一批的内存对象依次归还给相匹配的 Span 对象,实现思路如下:

首先,我们可以通过这若干个内存对象的起始地址也就是 start 进行遍历 (访问每个内存对象的前4/8字节) 访问所有内存对象。

其次,每归还一个内存对象时,需要先保存下一个内存对象,再通过这个内存对象的起始地址根据映射表以及 GetSpanByAddr 接口得到所匹配的 Span 对象,并将这个内存对象头插进这个 Span 中的 _freeList,并更新这个 Span 对象的 _freeList;

然后,每将一个内存对象归还给 Span 对象时,那么这个 Span 对象中的 _useCount 会 -= 1,如果 _useCount == 0,说明这个 Span 对象中的若干个内存对象都归还回来了,因此,将这个 Span 对象归还给 Page Cahce,注意:在归还过程中,首先要将这个 Span 对象从 Central Cache 的当前桶中 pop 出来,并对相关属性进行设置,但页号和页数是绝对不能更改的。

最后,Central Cache 会被多个执行流访问,存在线程安全问题,因此需要加锁保护,这里的锁是一把桶锁,不过需要注意的是,在将 Span 对象归还给 Page Cache 时 ,需要加上 Page Cache 的大锁,此时可以将 Central Cache 的桶锁释放掉,当访问完 Page Cache 也一定要把 Central Cache 的锁重新加上。

依据上面的理解,Central Cache::RealeseMemoryUnitsToSpans 实现如下:

// start 是若干个内存对象的起始地址, 通过遍历, 可以获得所有的内存对象
// align_size: 代表当前内存对齐后的字节数, 以方便计算 Central Cache 中的特定桶
void Xq::CentralCache::RealeseMemoryUnitsToSpans(void* start, size_t align_size)
{
	// 计算要归还Central Cache 的哪个桶
	size_t bucket_index = Xq::ManageSize::BucketIndex(align_size);

	// 因为接下来的过程会访问 Central Cache 的特定桶, 故为了保证线程安全
	// 因此需要通过桶锁保证线程安全
	_spanList[bucket_index].GetBucketMtx().lock();

	// 其次, 我们要将每个内存对象归还给这个桶下的对应的 Span 对象
	// 注意: 不同的内存对象可能要归还不同的 Span 对象, 因此我们要匹配的归还
	// 思路: 每个 Span 对象切出去的内存对象的页号是一定的
	// 因此, 我们可以算出每个内存对象所在的页号, 并将其归还给匹配的 Span 对象
	// 可是, 有一个问题, 假如 Central Cache 的特定桶中有很多个 Span 对象, 那么归还内存对象时要找目标的Span对象, 如果采用遍历的方式
	// 那么归还一个内存对象就是 O(N)的时间复杂度, 效率太低, 为了提高效率, 我们将页号和 Span 对象通过一个哈希表映射起来
	// 此时就可以通过每个内存对象所在的页号快速得到与之匹配的 Span 对象

	// 同时, 由于这个映射关系不仅 Central Cache 需要使用, Page Cache 也要使用, 故我们将这个哈希表定义在 Page Cache 中
	// 这个哈希表我们定义为: _PageIdToSpanMap

	while (start)
	{
		// 保存下一个内存对象
		void* next = Xq::NextMemory(start);
		// 通过起始地址和映射表得到目标 Span 对象
		Xq::Span* span = Xq::PageCache::GetOnlyInstance()->GetSpanByAddr(start);
		// 将这个内存对象头插进这个 Span 中的_freeList
		Xq::NextMemory(start) = span->_freeList;
		// 更新_freeList
		span->_freeList = start;
		
		// _useCount 代表一个 Span 对象中有多少个内存对象正被使用
		// 那么当一个内存对象链入进 Span 中, 那么_useCount 就会 - 1
		--span->_useCount;

		// 如果一个 Span 对象中的所有内存对象都归还了, 即 _useCount = 0
		// 那么就可以将这个 Span 对象归还给 Page Cache, 进而让 Page Cache 进行 Span 对象的合并, 即页的合并, 以解决外碎片问题
		if (span->_useCount == 0)
		{
			// 此时需要先将这个 Span 对象从 SpanList 中移除 (太坑人了~~~~)
			_spanList[bucket_index].Erase(span);
			// 此时这个 Span 对象所管理的内存就是若干个完整的页, 不再需要 _freeList 进行管理了
			span->_freeList = nullptr;
			// 其他属性进行适当处理即可
			span->_prev = nullptr;
			span->_next = nullptr;

			// 与申请逻辑一致, 接下里需要访问 Page Cache, 而 Page Cache 是通过一把整体的锁保证线程安全的
			// 因此, 为了避免阻塞其他线程访问 Central Cache 这个桶 (申请或者释放)
			// 我们应该将这个桶锁解开, 并在此加上 Page Cache 的锁, 保证访问 Page Cache 的线程安全
			_spanList[bucket_index].GetBucketMtx().unlock();
			Xq::PageCache::GetOnlyInstance()->GetOnlyInstance()->GetPageCacheMtx().lock();
			Xq::PageCache::GetOnlyInstance()->ReleaseSpanToPageCache(span);
			// 访问 Page Cache 结束后, 并解开 Page Cache 的锁 (以防止死锁)
			Xq::PageCache::GetOnlyInstance()->GetOnlyInstance()->GetPageCacheMtx().unlock();

			// 同时加上 Central Cache 这个桶的锁
			_spanList[bucket_index].GetBucketMtx().lock();
		}

		// 更新 start
		start = next;
	}
	_spanList[bucket_index].GetBucketMtx().unlock();
}

我们说过,当Central Cache 中的 Span 切分出去的内存对象如果都归还给了 Span (Span->_useCount == 0),那么说明这个 Span 对象就可以返回给 Page Cache 了,Central Cache 会用过调用 Page Cache::ReleaseSpanToPageCache 接口将 Span 对象归还给 Page Cache。

6.3. Page Cache 回收内存

我们说过,如果 Central Cache 中的某个 Span 对象中的 _useCount == 0,那么就需要将当前这个 Span 对象归还给 Page Cache,同时,我们知道,Span 是若干个页组成的内存跨度,而 Page Cache 为了缓解外碎片问题,会对 Span 对象进行前后页的合并,那么现在问题就来了,我们如何判定某个 Span 对象是否可以被合并呢?

有人说,通过 _useCount 来判定,这是不好的,因为一个 Span 对象最初分配给 Central Cache 时,其 _useCount == 0,此时要进行合并吗? 答案是,不能,因为此时这个 Span 对象接下来就要进行内存切分,再进行合并,是不符合逻辑的,因此,我们不能通过 _useCount 来判定某个 Span 对象是否可以被合并。

事实上,我们发现,对于一个 Span 对象而言,如果它是在 Central Cache 的桶中挂载的,那么这个 Span 对象是可能被使用的,而如果它在 Page Cache 的桶中挂载,那么这个 Span 对象一定没有被使用,因此,我们在这里在 Span 中添加一个成员属性,用于表示当前 Span 对象的使用状态,如下:

struct Span
{
	// 省略 ...
    // 添加一个成员属性, 用于表示当前 Span 对象的使用状态
	// 默认为false, 代表没有被使用
	bool _isUse = false;
};

只要这个 Span 对象分配给了 Central Cache ,我们就将这个成员属性 _isUse 设置为 true,表示其正被使用。

那么Page Cache 是什么时候将一个 Span 对象分给了 Central Cache 呢? 其在 CentralCache::GetOneNonEmptySpan 中,因此,在这个接口中,当获取一个 Span 对象时,将其使用状态标识为 true,如下:

Xq::Span* Xq::CentralCache::GetOneNonEmptySpan(SpanList& span_list, size_t align_size)
{
	// 省略 ...
	Xq::PageCache::GetOnlyInstance()->GetPageCacheMtx().lock();
	Xq::Span* obj = Xq::PageCache::GetOnlyInstance()->NewSpan(Xq::NumMovePage(align_size));
	// 只要一个 Span 对象分配给了 Central Cache, 就将其标识为正在使用
	obj->_isUse = true;
	Xq::PageCache::GetOnlyInstance()->GetPageCacheMtx().unlock();
	// 省略 ...
}

因为 Span 对象中的 _isUse 默认为 false,因此只需要将归还给 Page Cache 的 Span 对象设置为 false,即可。

可是,我们知道,归还的 Span 对象是需要进行前后页合并的工作,因此,我们统一处理,当合并工作完成后,我们在统一的将 _isUse 置为 false。

那么接下来的问题就是,一个 Span 对象归还给 Page Cache,是如何进行前后页的合并的呢?实现这个功能的接口如下:

// 将一个 Span 对象归还给 Page Cache, 并进行前后页的合并, 以缓解外碎片问题
void Xq::PageCache::ReleaseSpanToPageCache(Span* span);

我们通过一个例子进行解释,假如要归还的这个 Span 对象的页号是100,页数是10,那么如何进行前后页的合并呢?如下图所示:

但需要注意的是,向前合并可不仅仅只合并一次,只要满足合并的条件,就可以一直合并,因此这是一个循环的过程,向后合并亦是同理。

那么什么情况才能合并呢?或者我们换个角度,不能合并的情况有哪些呢?

  • case 1:所在的页号没有 Span 对象,此时是不能合并的;
  • case 2:所在的页号匹配的 Span 对象中的 _isUse 为 true,即当前 Span 对象正被使用,也不能合并;
  • case 3:因为 Page Cache 是一个哈希桶的结构,管理的是若干个 Span 对象,而其中最大的 Span 对象是 128page 的内存跨度,因此,如果合并之后的页数超过了 128page ,那么也不合并,因为此时 Page Cache 无法管理这个 Span 对象。

在合并的过程中,注意的细节:

  • 其一:如果一旦合并了 Span 对象,那么一定存在着将旧的 Span 对象从特定桶中移除,同时,将新的 Span 对象插入到对应的桶中;
  • 其二:合并的 Span 对象链入到对应的桶中,为了让其他的 Span 对象可以合并自身,也需要将这个合并的 Span 对象的起始页和终止页添加到映射表中,同时将 _isUse 标识为 false,表示其未被使用,可被合并。

其实现如下:

// 将 Span 对象归还给 Page Cache, 以便于合并出更大的Span对象, 即合并出更大的页, 已解决外碎片问题
void Xq::PageCache::ReleaseSpanToPageCache(Span* span)
{
	// 向前合并
	while (true)
	{
		// 当前Span对象所在的前一个页
		PAGE_ID prevId = span->_pageId - 1;
		auto ret = _PageIdToSpanMap.find(prevId);

		if (ret == _PageIdToSpanMap.end())
		{
			// 说明没有这个 Span 对象, 结束向左合并的过程
			break;
		}

		if (ret->second->_isUse == true)
		{
			// 说明当前这个 Span 对象正在被使用, 结束向左合并的过程
			break;
		}

		if (ret->second->_pageNum + span->_pageNum > PageCacheBucketNum - 1)
		{
			// 如果合并后的页数 > 128, 那么这次合并也取消
			// 因为, 如果合并了, Page Cache 无法对其进行管理
			break;
		}

		// 走到这里, 就可以进行合并了
		Span* prevSpan = ret->second;
		// 更新页号
		span->_pageId = prevSpan->_pageId;
		// 更新页数
		span->_pageNum += prevSpan->_pageNum;
		// 将这个 Span 对象先从对应的桶中移除
		_pageList[prevSpan->_pageNum].Erase(prevSpan);
		// 再释放掉这个 Span 对象, 因为内存已经合并到了 span 中
		delete prevSpan;
		prevSpan = nullptr;
	}

	// 向后合并
	while (true)
	{
		// 当前Span对象所在的后一个页
		PAGE_ID nextId = span->_pageId + span->_pageNum;
		auto ret = _PageIdToSpanMap.find(nextId);
		if (ret == _PageIdToSpanMap.end())
		{
			// 说明没有这个 Span 对象, 结束向右合并的过程
			break;
		}

		if (ret->second->_isUse == true)
		{
			// 说明当前这个 Span 对象正在被使用, 结束向右合并的过程
			break;
		}

		if (ret->second->_pageNum + span->_pageNum > PageCacheBucketNum - 1)
		{
			// 如果合并后的页数 > 128, 那么这次合并也取消
			// 因为, 如果合并了, Page Cache 无法对其进行管理
			break;
		}

		// 走到这里, 就可以进行合并了
		Span* obj_span = ret->second;
		// 此时就不用更新页号, 只需要更新页数即可
		span->_pageNum += obj_span->_pageNum;
		// 将这个 Span 对象先从对应的桶中移除
		_pageList[obj_span->_pageNum].Erase(obj_span);
		// 再释放掉这个 Span 对象, 因为内存已经合并到了 span 中
		delete obj_span;
		obj_span = nullptr;
	}

	// 将 span 这个对象插入到新的桶中
	_pageList[span->_pageNum].PushFront(span);
	// 建立映射关系
	_PageIdToSpanMap[span->_pageId] = span;
	_PageIdToSpanMap[span->_pageId + span->_pageNum - 1] = span;
	// 将 span 对象的状态置为 false, 表明此时没有被使用, 可以被其他执行流合并
	span->_isUse = false;
}

6.4. 释放内存过程联调

测试代码如下:

void DeallocateTest1()
{
	void* ptr1 = ConcurrentAllocate(4);
	void* ptr2 = ConcurrentAllocate(6);
	ConcurrentDeallocate(ptr1, 4);
	ConcurrentDeallocate(ptr2, 6);
}

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

这里申请两个释放两个主要是为了能够观察将 Span 归还给 Page Cache 的场景,对于第一次的申请和释放,我们就不看了,直接跳转到第二次的释放,如下:

进入 ConcurrentDeallocate 中,因为此时这个内存对象的字节是 <= 256 kb,故会走 Thread Cache 释放内存,如下:

进入 ThreadCache::Deallocate 根据apply_size,得到 Thread Cache 中的特定桶,如下:

接下里,将这个内存对象链入进当前桶中,如下所示:

此时的 Thread Cache 0 号桶中挂载的内存对象个数就 >= 慢增长的值,因此会通过 ThreadCache::FreeListTooLong 取出慢增长值数量个内存对象,

我们首先通过内存窗口看一看 _freeList 这三个内存对象分别是什么,如下:

 第一个内存对象:

 第二个内存对象: 

第三个内存对象: 

依据 popRangeMemory 的逻辑,那么最终结果是 start 就是第一个内存对象,end 就是第三个内存对象,是否如此呢? 现象如下:

结果符合预期,接下里,我们就要将这一批的内存对象返回给匹配的 Span 对象,如下:

保存下一个内存对象,并通过当前的内存对象找到匹配的 Span 对象,如下:

将这个内存对象 (start) 头插进 Span 中的 _freeList 中,并更新 _freeList 和 _useCount,如下:

接下来,我们选择跳过中间的一些步骤,直接跳转到第三个内存对象,因为此时 _useCount == 0,如下:

因为这个 Span 中的内存对象都归还回来了,因此需要将其交还给 Page Cache,但此时这个 Span 对象还在 Central Cache 的0号桶中,因此,首先需要将这个 Span 对象从特定桶中移除,如下:

移除前:

移除后:  

结果符合预期,因为 Page Cache 中不会对 Span 所管理的内存跨度进行切分,因此 _freeList 可以设置为空,前驱指针和后继指针也可以置为 nullptr,如下:

接下来,就要通过 PageCache::ReleaseSpanToPageCache 接口将这个 Span 对象归还给 Page Cache,并对其进行前后页的合并。

依据申请逻辑,这个 1page 肯定是通过 128page 切分为一个 1page 和 一个 127page 的 Span 对象得到的,因此,我们可以看看 127 page 所在的页号,如下:

可以看到,这个 127 page 所在的 Span 是当前 Span 后面的页,因此,向前合并的逻辑我们就不用观察了,直接跳转到向后合并的逻辑,如下:

得到这个 127page 的 Span 对象,后续过程只需要更新页数即可,起始页号就是要归还对象的页号,在这里是 2800,如下:

注意,此时的合并只需要保留这个要归还的对象即可,这个 127 page 的 Span 对象可以从 127 桶中移除并释放掉这个 Span 对象,如下:

将这个要归还的 span 对象插入到对应的桶中,在这里就是 128 号桶,如下:

插入前:

插入后:

最后,为了其他的 Span 对象可以对我这个 Span 对象进行合并,需要将这个 Span 对象的起始页和终止页也添加到映射表中,并同时,将这个对象的使用状态设置为 false,代表没有被使用,可以被合并,如下:

上面就是释放内存的大致流程,符合我们的结构设计。

7. 大于 256 kb的内存申请和释放

我们的内存池,对于内存申请和释放大只有两种情况:

  • 情况一,小于等于 256kb 的内存申请和释放,我们就通过三层缓存申请释放内存;
  • 情况二,大于 256kb 的内存申请和释放,也分为两种情况:
    • case 1:如果是在 32page (256kb) 到 128page 之间的内存申请和释放,我们直接访问 Page Cache;
    • case 2:如果大于 128page 的内存申请和释放,那么直接通过 system call 从系统堆申请内存和直接将内存释放给操作系统。

对于后两种情况 (大于256 kb的内存申请和释放) , 统一处理,都调用 Page Cache 提供的接口,只不过在其中特殊处理一下 case 2, 通过系统调用直接向系统堆申请,或者直接将内存释放给系统堆。

首先,我们需要修改对外提供的内存申请接口,如下:

// 提供给上层的申请空间的接口
static void* ConcurrentAllocate(size_t size)
{
	// 如果当前执行流的 Thread Cache 为空, 那么 new 一个属于当前执行流的 Thread Cache (TLS)
	if (Xq::TLSThreadCacheObject == nullptr)
	{
		//Xq::TLSThreadCacheObject = new Xq::ThreadCache;
		static Xq::object_pool<Xq::ThreadCache> TCPool;
		Xq::TLSThreadCacheObject = TCPool.New();
	}

	// 如果要申请的字节数 <= 256 kb, 通过 Thread Cache 处理
	if (size <= ThreadCacheMaxSize)
	{
		return Xq::TLSThreadCacheObject->Allocate(size);
	}
	// 如果大于 > 256 kb, 直接找 Page Cache 要内存
	else
	{
		// 对于 > 256 kb 的内存申请, 可以分为两种情况
		// case 1: 如果在 32 page (256kb) 到 128 page 之间, 即 [32 page, 128 page], 那么直接通过 Page Cache 获取一个 Span 对象, 并链入到 Page Cache 的桶中
		// case 2: 如果 > 128 page 的内存申请, 那么直接通过系统堆申请
		// 对于上面两种情况, 我们采用统一的方案处理, 即都通过 Page Cache::NewSpan 获取 Span 对象

		// 实现步骤:
		// 首先, 获取对齐后的字节数, 并将其转化为 page
		// 因此我们要修改 ManageSize::AdjustUp 这个接口, 让其对于 > 256 kb 的内存申请, 以页为单位对齐
		size_t align_size = Xq::ManageSize::AdjustUp(size);
		// 将获取的字节数转换为页数
		size_t page_num = align_size >> PageShift;
		// 调用 PageCache::NewSpan 获取一个 Span 对象, 通过这个 Span 对象, 获取大块内存的起始地址
		// 在多线程场景下, 访问 Page Cache 是存在线程安全问题的, 因此需要加锁保护
		Xq::PageCache::GetOnlyInstance()->GetPageCacheMtx().lock();
		Xq::Span* span = Xq::PageCache::GetOnlyInstance()->NewSpan(page_num);
		// 设置这个 Span 对象的内存对象的大小, 用于标识这个Span对象是向系统堆申请的 (> 128 page), 还是走 Page Cache 申请的 [32page, 128 page]
		span->_memoryUnitObjectSize = align_size;
		Xq::PageCache::GetOnlyInstance()->GetPageCacheMtx().unlock();

		// 通过 Span 对象中的页号获取大块内存的起始地址
		return reinterpret_cast<void*>(span->_pageId << PageShift);
	}
}

可以看到,我们的对齐规则需要调整, 对于大于 256kb 的内存申请,按页为单位进行对齐,如下:

// 核心目的: 通过apply_size以及适应的对齐数返回合适的字节数
static size_t AdjustUp(size_t apply_size)
{
	if (apply_size <= 128)
	{
		return _AdjustUp(apply_size, 8);
	}
	else if (apply_size <= 1024)
	{
		return _AdjustUp(apply_size, 16);
	}
	else if (apply_size <= 8 * 1024)
	{
		return _AdjustUp(apply_size, 128);
	}
	else if (apply_size <= 64 * 1024)
	{
		return _AdjustUp(apply_size, 1024);
	}
	else if (apply_size <= 256 * 1024)
	{
		return _AdjustUp(apply_size, 8 * 1024);
	}
	else
	{
		// 如果大于 256 kb 的内存申请, 以 page 为单位对齐
		return _AdjustUp(apply_size, 1 << PageShift);
	}
}

同时,我们也要修改对外提供的释放内存接口,如下:

// 提供给上层的释放空间的接口
// 这里暂时需要提供 apply_size, 以便于能够调用 ThreadCache::Deallocate, 后续处理
static void ConcurrentDeallocate(void* obj, size_t apply_size)
{
	assert(obj);
	assert(Xq::TLSThreadCacheObject);

	// 如果要释放的内存 <= 256kb, 通过 Thread Cache 处理
	if (apply_size <= ThreadCacheMaxSize)
	{
		size_t align_size = Xq::ManageSize::AdjustUp(apply_size);
		Xq::TLSThreadCacheObject->Deallocate(obj, align_size);
	}
	// 如果大于 >= 256kb, 暂时不处理
	else
	{
		// 这个接口自身保证线程安全
		Xq::Span* span = Xq::PageCache::GetOnlyInstance()->GetSpanByAddr(obj);
		// 对于大于 > 256 kb 的内存释放, 我们也可以分为两种情况:
		// case 1: [32 page, 128 page] 的内存释放, 走原来的逻辑即可
		// case 2: > 128 page 的内存释放, 我们就要通过 Page Cache 的哈希表 (页号到Span的映射) 找到这个 Span 对象, 进行释放
		// 在多线程场景下, 访问 Page Cache 存在线程安全问题, 因此加锁保护
		Xq::PageCache::GetOnlyInstance()->GetPageCacheMtx().lock();
		// 调用 PageCache::ReleaseSpanToPageCache 释放这个 Span 对象
		Xq::PageCache::GetOnlyInstance()->ReleaseSpanToPageCache(span);
		Xq::PageCache::GetOnlyInstance()->GetPageCacheMtx().unlock();
	}
}

那么 Page Cache 中的接口如何实现呢? 很简单,对于大于 256 kb的内存申请和释放,直接调用系统调用处理,申请内存如下:

// 返回一个Span对象
Xq::Span* Xq::PageCache::NewSpan(size_t page_num)
{
	assert(page_num > 0);

	// 如果申请的页数在 [32, 128] 之间, 那么就走 else 中的逻辑 (原来的逻辑), 获取一个 Span 对象
	// 如果申请的页数 > 128 page, 那么就需要特殊处理一下, 直接从系统堆获取一个大块内存, 并将一个 Span 对象中的页号和大块内存的起始地址联系起来

	if (page_num > PageCacheBucketNum - 1)
	{
		// new 一个 Span 对象
		Span* obj = new Span;
		// 申请一个大块内存
		void* memory = SystemAlloc(page_num);
		// 将Span中的页号和这个大块内存联系起来
		obj->_pageId = reinterpret_cast<PAGE_ID>(memory) >> PageShift;
		// 设置一下页数
		obj->_pageNum = page_num;
		// 因为这个 Span 对象无法被 Page Cache 管理起来, 也就无法被 Central Cache 使用
		// 因此, 我们不会将大块内存切分为若干个内存对象挂在 _freeList 中, 其他属性也不需要再设置
		// 但是, 这个 Span 对象需要被释放, 因此, 我们仍旧需要将这个 Span 对象和页号建立起映射关系
		// 以便于通过大块内存的起始地址找到页号, 进而通过页号找到这个 Span 对象
		_PageIdToSpanMap[obj->_pageId] = obj;
		// 返回这个 Span 对象
		return obj;
	}
	else
	{
		// 省略
	}
}

同上,释放内存如下:

// 将 Span 对象归还给 Page Cache, 以便于合并出更大的Span对象, 即合并出更大的页, 已解决外碎片问题
void Xq::PageCache::ReleaseSpanToPageCache(Span* span)
{
	// 如果这个 Span 对象的页数 > 128, 那么就需要调用系统接口, 释放这个大块内存
	if (span->_pageNum > PageCacheBucketNum - 1)
	{
		// 大块内存的起始地址
		void* obj = reinterpret_cast<void*>(span->_pageId << PageShift);
		// 调用系统接口, 释放这个大块内存
		SystemFree(obj);
		obj = nullptr;
		// 释放这个 Span 对象
		delete span;
		span = nullptr;
	}
	// 如果这个 Span 对象的页数 < 128, 走下面的逻辑即可
	else
	{
		// 省略...
	}
}

8. 使用定长内存池代替 new 和 delete

接下来,我们就需要通过定长的内存池取代 new 和 delete,主要是用来取代 Span、ThreadCache (TLS) 的new 和 delete,单例模式可以不用定长内存池,在这里举个例子即可,比如,申请 Span 对象时,采用定长内存池的 New,释放 Span 对象时,采用定长内存池的 Delete,如下:

class PageCache
{
    // 省略 ...
    // span 对象池
    object_pool<Span> _spanPool;
};

在申请和释放Span对象时,采用如下方案:

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

// 释放 Span 对象
_spanPool.Delete(span);

9. 释放对象时优化为不穿对象大小

可以看到,此时对外提供的释放内存接口是需要带一个额外参数 (apply_size) 的,如下:

static void ConcurrentDeallocate(void* obj, size_t apply_size);

那么能不能将这个参数 (apply_size) 给省略呢?

首先,这个 apply_size 是给三层缓存释放内存使用的,即针对于小于等于 256 kb 的内存释放,而我们知道,三层缓存中的每个内存对象都来自于一个 Span 对象,我们可以通过一个内存对象找到对应的 Span 对象,而 Span 对象切分的若干个内存对象是同等大小的,因此,我们可以在 Span 对象中记录一个属性,这个属性用于表示当前切分的内存对象的大小,这样,我们就可以通过内存对象找到 Span 对象进而得到当前内存对象是多大,故达到省去这个参数的目的。

首先,在 Span 对象中添加的属性,如下:

struct Span
{
	// 省略 ...

	// 当前Span对象中的内存对象的大小
	// 这里的每个内存对象的大小与 Thread Cache 的对齐映射规则一致
	// 比如, 在第0号桶中, 那么每个内存对象就是 8 byte
	size_t _memoryUnitObjectSize = 0;
};

那么这个属性什么时候设置呢?

什么时候设置取决于这个 Span 对象是在什么获得的,即在获得 Span 对象的位置设置这个成员属性,那么什么时候获得的这个 Span 对象呢?在 CentralCache::GetOneNonEmptySpan 中,如下:

Xq::Span* Xq::CentralCache::GetOneNonEmptySpan(SpanList& span_list, size_t align_size)
{
	// 省略 ...
	Xq::PageCache::GetOnlyInstance()->GetPageCacheMtx().lock();
	Xq::Span* obj = Xq::PageCache::GetOnlyInstance()->NewSpan(Xq::NumMovePage(align_size));
	// 一个合法的 Span 对象
	assert(obj);
	// 只要一个 Span 对象分配给了 Central Cache, 就将其标识为正在使用
	obj->_isUse = true;
	// 设置 Span 对象的每个内存对象的大小
	obj->_memoryUnitObjectSize = align_size;
	Xq::PageCache::GetOnlyInstance()->GetPageCacheMtx().unlock();
	// 省略...
}

那么如何将对外提供的释放接口省去这个 apply_size 呢?如下:

static void ConcurrentDeallocate(void* obj)
{
	assert(obj);
	assert(Xq::TLSThreadCacheObject);

	// 通过地址, 以及 Page Cache 的哈希表 (页号到Span的映射), 找到对应的 Span 对象
	// GetSpanByAddr 自身要保证线程安全
	Xq::Span* span = Xq::PageCache::GetOnlyInstance()->GetSpanByAddr(obj);
    // 获得这个内存对象的大小
	size_t size = span->_memoryUnitObjectSize;

	// 如果要释放的内存 <= 256kb, 通过 Thread Cache 处理
	if (span->_memoryUnitObjectSize <= ThreadCacheMaxSize)
	{
		Xq::TLSThreadCacheObject->Deallocate(obj, size);
	}
	// 如果大于 > 256kb, 直接访问 Page Cache
	else
	{
		// 省略
	}
}

此时,我们就省去了这个参数。

10. 性能瓶颈分析

上面我们已经完成了高并发内存池的核心框架,接下来,我们通过一个测试代码来测试程序是否符合预期,如下:

#include "ConcurrentAlloc.h"
#include <vector>
#include <atomic>
#include <thread>

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.load());
	printf("%u个线程并发执行%u轮次,每轮次free %u次: 花费:%u ms\n",
		nworks, rounds, ntimes, free_costtime.load());
	printf("%u个线程并发malloc&free %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(ConcurrentAllocate(16));
					//v.push_back(ConcurrentAllocate((16 + i) % 8192 + 1));
				}
				size_t end1 = clock();
				size_t begin2 = clock();
				for (size_t i = 0; i < ntimes; i++)
				{
					ConcurrentDeallocate(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;
	std::cout << "==========================================================" << std::endl;
	BenchmarkConcurrentMalloc(n, 4, 5);
	std::cout << std::endl << std::endl;
	BenchmarkMalloc(n, 4, 5);
	std::cout << "==========================================================" << std::endl;
	return 0;
}

上面测试代码的思路是:创建若干个线程让它们分别通过内存池或者 malloc/free 来申请和释放内存,并分别查看二者所花费的时间,结果如下:

第一次结果:

第二次结果:

第三次结果:

可以清楚的看到,我们的内存池的目前的效率是比 malloc/free 低很多的,这里有一个次要原因,是因为我们申请和释放的是一样大小的内存对象,因此,此时会频繁的访问 Central Cache 中的一个桶,此时桶锁所带来的串行化程度是很高的,因此,我们来测试一下每次申请不同的内存对象大小的情况,更改如下:

// 将内存池和 malloc 申请对象更改为如下:
v.push_back(ConcurrentAllocate((16 + i) % 8192 + 1));
v.push_back(malloc((16 + i) % 8192 + 1));

测试三次,得到如下结果:

第一次结果:

第二次结果:

第三次结果:

可以看到,此时二者的效率几乎是差不多的,但是,在多线程场景下,高并发内存池这样的效率是不尽人意的,因此,我们要分析分析,是什么原因导致高并发内存池的效率如此之低的呢?

我们在此采用 vs 中的性能与诊断工具 (注意:使用时,要将其切换为 Debug 模式),如下:

接下来,准备开始性能和诊断,过程如下:

因为我们要观察的是函数对性能的影响,因此:

接下来就等待这个工具完成即可,现象如下:

进一步观察,得到如下结果:

进一步观察 ConcurrentDeallocate 接口,如下:

可以看到,GetSpanByAddr 由于要自身保证线程安全,且这把锁是一把大锁,对性能的消耗是非常大的。

那么 FreeListTooLong 对性能的影响也是非常高的,那么是什么原因呢?如下:

可以看到,依旧是 GetSpanByAddr 这个函数消耗了主要的性能。

总的来看,主要是 GetSpanByAddr 这个接口消耗了非常多的性能,那么 GetSpanByAddr 是在干什么呢? 是通过映射表 (unordered_map) 以及一个内存对象获得对应的 Span,为了保证这个映射表的安全性,因此,通过一把锁保证了线程安全,而这把锁就是对性能造成影响的主要原因,那么有没有什么方法对其进行性能优化呢?

TCMalloc 的设计者们采用的思路是用基数树对其进行优化。

11. 使用基数树进行优化

11.1 基数树的了解

基数树本质上依旧是一种映射关系,其采用分段映射(分层次的映射)的思想维护映射关系,其思想可以参考内核中的页表的设计。

11.2. 基数树的实现

在这里实现了三颗基数树,对其的宏观理解,如下:

  • 一层基数树:
    • 一层基数树适用于 32 位程序;
    • 一层基数树使用直接定址法实现页号到Span指针的映射,其本质上是一个指针数组。
  • 二层基数树:
    • 两层基数树适用于 32 位程序,通过分层管理指针数组,以减少内存消耗;
    • 第一层:高5位指针数组(大小为32);
    • 第二层:每个第一层指针指向的数组(大小为16384)。
  • 三层基数树:
    • 三层基数树适用于 64 位程序,通过三层管理指针数组,以更高效地处理大范围页号。
    • 第一层:高17位指针数组。
    • 第二层:中间17位指针数组。
    • 第三层:低17位指针数组。

事实上,二层的基数树和三层的基数树本质上的思路是一样的,就是对一个整数采取了分段映射,在内核中页表的设计也采用了这样的思路,比如前10个比特位,映射的是一级页表,中间的10个比特位,映射的是二级页表,后12个比特位是页内偏移,这种方法的主要目的是为了减少内存消耗,提高空间利用率,同时也能快速定位目标数据。

具体实现和理解如下:

#ifndef _PAGE_MAP_H
#define _PAGE_MAP_H

#include "ObjectPool.h"
#include <cstring>

/*
*  前置认识:
*  一层基数树和二层基数树是给32位程序使用的
*  三层基数树是给64位程序使用的
*/

namespace Xq
{
	// 一层的基数树
	// 其是一个采用直接定址法的哈希表 (页号到Span*的映射), 本质上就是一个指针数组
	// 这里的 BITS 代表的是表示页号的最大值所需要的位数
	// 举个例子: 
	// 在32位程序中, 假如 1 page = 1 << 13, 那么页号的上限就是 1 << (32 - 13) - 1,
	// 即此时的 BITS 就是 19
	// 64位程序亦是同理
	template <int BITS>
	class TCMalloc_PageMap1 {
	public:

		explicit TCMalloc_PageMap1() 
		{
			// 因为其本质上是一个指针数组
			// 故先计算指针数组的整体大小
			size_t size = sizeof(void*) << BITS;
			// 并以页的大小为对其单位, 进行对齐
			size_t align_size = Xq::ManageSize::_AdjustUp(size, 1 << PageShift);
			// 调用系统接口, 开辟空间
			_array = (void**)Xq::SystemAlloc(align_size >> PageShift);
			memset(_array, 0, sizeof(void*) << BITS);
		}

		// 根据页号, 返回对应的Span*
		// 如果超出页号的范围, 返回 nullptr
		void* get(PAGE_ID page_id) const 
		{
			if ((page_id >> BITS) > 0) 
			{
				return nullptr;
			}
			return _array[page_id];
		}

		// 构建页号和 Span* 的映射关系
		// 要求: page_id 必须是一个合法的页号, 即 page_id 属于 [0, 2 ^ BITS - 1]
		void set(PAGE_ID page_id, void* ptr) 
		{
			_array[page_id] = ptr;
		}
	private:
		void** _array;
	};

	// 两层的基数树
	// 这里的 BITS 与上同理, 代表的是表示页号的最大值所需要的位数
	template <int BITS>
	class TCMalloc_PageMap2 
	{
	public:
		explicit TCMalloc_PageMap2() 
		{
			// 将第一层这个指针数组全部置为0
			memset(_root, 0, sizeof(_root));
		}

		/*explicit TCMalloc_PageMap2() 
		{
			memset(_root, 0, sizeof(_root));
			// 一次性将第二层全部开辟
			PreallocateMoreMemory();
		}*/

		// 根据页号, 返回对应的Span*
		// 如果不存在, 或者超出页号的范围, 返回 nullptr
		void* get(PAGE_ID page_id) const 
		{
			// pos1代表的是低19个比特位中的高5个比特位, 即第一层的位置
			const PAGE_ID pos1 = page_id >> LEAF_BITS;
			// (LEAF_LENGTH - 1) 低19个比特位中的低14个比特位, 其值为全1
			// page_id & (LEAF_LENGTH - 1) 得到的就是 page_id 中低19个比特位中的低14个比特位的值
			// 即pos2就是第二层中某个指针数组中的特定位置
			const PAGE_ID pos2 = page_id & (LEAF_LENGTH - 1);
			// 如果该页号超过了范围或者目标指针数组不存在, 返回空
			if ((page_id >> BITS) > 0 || _root[pos1] == nullptr)
			{
				return nullptr;
			}
			// 先根据pos1得到目标指针数组(第二层中的特定数组)
			// 再根据pos2访问该数组中的特定位置
			return _root[pos1]->_values[pos2];
		}

		void set(PAGE_ID page_id, void* ptr) 
		{
			// pos1 和 pos2 同上
			// pos1为第一层的位置
			// pos2为第二层中特定指针数组的位置
			const PAGE_ID pos1 = page_id >> LEAF_BITS;
			const PAGE_ID pos2 = page_id & (LEAF_LENGTH - 1);
			// 确保pos1为合法位置
			assert(pos1 < ROOT_LENGTH);
			// 确保这个页号所对应的目标指针数组 (第二层中特定的指针数组) 是存在的 (无则创建)
			bool ret = Ensure(page_id, 1);
			assert(ret);
			_root[pos1]->_values[pos2] = ptr;
		}
		
		// 确保从起始页 start 开始的 page_num 个页面的指针数组 (第二层中特定的指针数组) 已经初始化且存在
		bool Ensure(PAGE_ID start, size_t page_num) 
		{
			for (PAGE_ID key = start; key <= start + page_num - 1;)
			{
				// pos1为第一层的位置, 其代表的是特定的指针数组 (第二层中特定的指针数组)
				const PAGE_ID pos1 = key >> LEAF_BITS;
				// 如果 pos1 越界了, 返回false
				if (pos1 >= ROOT_LENGTH)
					return false;
				// 如果这个特定的指针数组不存在, 那么就需要创建之
				if (_root[pos1] == nullptr)
				{
					// 通过我们自己实现的定长内存池, 创建这个指针数组
					static Xq::object_pool<Leaf> LeafPool;
					Leaf* leaf = LeafPool.New();
					memset(leaf, 0, sizeof(*leaf));
					_root[pos1] = leaf;
				}

				// 使用位操作来更新 key,跳过当前叶节点范围,移动到下一个叶节点的开始位置
				// 此操作确保循环遍历的是紧接着的下一个叶节点
				// 这里的叶节点就是第二层中的某个指针数组
				key = ((key >> LEAF_BITS) + 1) << LEAF_BITS;
			}
			return true;
		}

		// 提前将第二层中所有的指针数组开辟好
		void PreallocateMoreMemory() 
		{
			Ensure(0, 1 << BITS);
		}

	private:
		// 这个基数树是一个二层的结构
		// 第一层: 是索引第二层的一个条目
		// 在学习Linux的时候 ,我们知道, 页表本质上不是一张页表, 而是多张页表
		// 其目的是为了减少内存消耗, 提高空间利用率
		// 这里的思想与页表的设计思想如出一辙
		// 因为二层的基数树是给32位程序使用的
		// 第一层通过低19个比特位 (我们约定页的大小是 1 << 13) 中的高5个比特位决定第二层的位置, 即是第二层中的哪一个指针数组?
		// 因此, 第一层有 2 ^ 5 个位置, 即 32 个条目

		// 第二层: 本质上是若干个指针数组, 会存在 2 ^ 5个指针数组, 存储的是页号(数组下标)到 Span* 的映射
		// 低19个比特位中的低14个比特位决定了这个页号对应的是哪一个 Span* 
		static const int ROOT_BITS = 5;                   // 第一层的位数 (低19个比特位中的高5个比特位)
		static const int ROOT_LENGTH = 1 << ROOT_BITS;    // 第一层的长度, 2 ^ 5, 即32
		static const int LEAF_BITS = BITS - ROOT_BITS;    // 第二层的位数 (低19个比特位中的低14给比特位)
		static const int LEAF_LENGTH = 1 << LEAF_BITS;    // 第二层的长度, 2 ^ 14, 即16384
		// 第二层的结构, 本质上就是一个指针数组, 表示的是页号和Span*的映射关系
		struct Leaf
		{
			void* _values[LEAF_LENGTH];
		};

		// 第一层的结构, 也是一个指针数组, 通过低19个比特位中的高5个比特位锁定第二层中特定的指针数组
		Leaf* _root[ROOT_LENGTH];
	};


	// 三层的基数树, 专门为64位程序准备的
	// 这里的 BITS 与上同理, 代表的是表示页号的最大值所需要的位数
	// 假设 1 page = 1 << 13, 那么 BITS = 64 - 13 = 51
	template <int BITS>
	class TCMalloc_PageMap3 
	{
	public:
		explicit TCMalloc_PageMap3() 
		{
			_root = NewNode();
		}
		void* get(PAGE_ID page_id) const 
		{
			// 第一层的位置
			const PAGE_ID pos1 = page_id >> (LEAF_BITS + INTERIOR_BITS);
			// 第二层的位置
			const PAGE_ID pos2 = (page_id >> LEAF_BITS) & (INTERIOR_LENGTH - 1);
			// 第三层的位置
			const PAGE_ID pos3 = page_id & (LEAF_LENGTH - 1);
			// 如果页号超过范围了或者第一层不存在或者第二层不存在, 返回 nullptr
			if ((page_id >> BITS) > 0 
				|| _root->_ptrs[pos1] == nullptr 
				|| _root->_ptrs[pos1]->_ptrs[pos2] == nullptr) 
			{
				return nullptr;
			}
			return reinterpret_cast<Leaf*>(_root->_ptrs[pos1]->_ptrs[pos2])->_values[pos3];
		}
		void set(PAGE_ID page_id, void* ptr)
		{
			// 检查页号是否越界
			assert(page_id >> BITS == 0);
			// 第一层的位置
			const PAGE_ID pos1 = page_id >> (LEAF_BITS + INTERIOR_BITS);
			// 第二层的位置
			const PAGE_ID pos2 = (page_id >> LEAF_BITS) & (INTERIOR_LENGTH - 1);
			// 第三层的位置
			const PAGE_ID pos3 = page_id & (LEAF_LENGTH - 1);
			Ensure(page_id, 1);
			reinterpret_cast<Leaf*>(_root->_ptrs[pos1]->_ptrs[pos2])->_values[pos3] = ptr;
		}

		// 确保从起始页 start 开始的 page_num 个页面的指针数组 (第二层和第三层中特定的指针数组) 已经初始化且存在
		bool Ensure(PAGE_ID start, size_t page_num) 
		{
			for (PAGE_ID key = start; key <= start + page_num - 1;)
			{
				const PAGE_ID pos1 = key >> (LEAF_BITS + INTERIOR_BITS);
				const PAGE_ID pos2 = (key >> LEAF_BITS) & (INTERIOR_LENGTH - 1);
				// 检查是否越界
				if (pos1 >= INTERIOR_LENGTH || pos2 >= INTERIOR_LENGTH)
					return false;
				// 如果目标指针数组为空 (第二层中的某个指针数组), 那么开辟这个指针数组
				if (_root->_ptrs[pos1] == nullptr) 
				{
					Node* node = NewNode();
					if (node == nullptr) return false;
					_root->_ptrs[pos1] = node;
				}
				// 如果目标指针数组为空 (第三层中的某个指针数组), 那么开辟这个指针数组
				if (_root->_ptrs[pos1]->_ptrs[pos2] == nullptr) 
				{
					static Xq::object_pool<Leaf> LeafPool;
					Leaf* leaf = LeafPool.New();
					if (leaf == nullptr) return false;
					memset(leaf, 0, sizeof(*leaf));
					_root->_ptrs[pos1]->_ptrs[pos2] = reinterpret_cast<Node*>(leaf);
				}

				// 使用位操作来更新 key,跳过当前叶节点范围,移动到下一个叶节点的开始位置
				// 此操作确保循环遍历的是紧接着的下一个叶节点
				// 这里的叶节点是第三层中的某个指针数组
				key = ((key >> LEAF_BITS) + 1) << LEAF_BITS;
			}
			return true;
		}
		// 提前将第二层和第三层中所有的指针数组开辟好
		// 但对于64位程序来说, 这个内存消耗就非常大了
		// 因此不能一次性申请, 只能按需申请, 故这里的这个接口没有实现
		void PreallocateMoreMemory() {}

	private:
		// 合法页号的有效位数是低51个比特位, 高13个比特位为0
		// 第一层和第二层 (内层) 所占用的比特位
		static const int INTERIOR_BITS = (BITS + 2) / 3;             // 17
		// 第一层和第二层 (内层) 的长度
		static const int INTERIOR_LENGTH = 1 << INTERIOR_BITS;       // 2 ^ 17
		// 第三层所占用的比特位
		static const int LEAF_BITS = BITS - 2 * INTERIOR_BITS;       // 17
		// 第三层的长度
		static const int LEAF_LENGTH = 1 << LEAF_BITS;               // 2 ^ 17
		// 第一层和第二层的结构, 本质上是一个指针数组
		// 第一层: 通过低51个比特位中的高17个比特位锁定第二层中特定的指针数组
		// 第二层: 通过低34个比特位中的高17个比特位锁定第三层中特定的指针数组
		struct Node 
		{
			Node* _ptrs[INTERIOR_LENGTH];
		};
		// 第三层的结构, 也是一个指针数组
		// 用低17个比特位获得特定的Span*对象
		struct Leaf 
		{
			void* _values[LEAF_LENGTH];
		};
		// 三层基数树的根节点
		Node* _root;
		// 通过定长内存池 Allocate 一个 Node*
		Node* NewNode() 
		{
			static Xq::object_pool<Node> NodePool;
			Node* result = NodePool.New();
			if (result != nullptr) 
			{
				memset(result, 0, sizeof(*result));
			}
			return result;
		}
	};
}

#endif

有了基数树,我们需要对 Page Cache 中的这个映射表进行改造,首先,我们对这个映射表的类型有如下定义:

#define PAGEMAP1
//#define PAGEMAP2

// 注意: 在32位环境下, 只有 _WIN32 这个宏
// 而在64位环境下, 会有 _WIN32 和 _WIN64这两个宏
#ifdef _WIN64
typedef Xq::TCMalloc_PageMap3<BITS - PageShift> ConcurrentMemoryPageMap;
#else
	#ifdef PAGEMAP1
	typedef Xq::TCMalloc_PageMap1<BITS - PageShift> ConcurrentMemoryPageMap;
	#elif defined(PAGEMAP2)
	typedef Xq::TCMalloc_PageMap2<BITS - PageShift> ConcurrentMemoryPageMap;
	#endif
#endif

在 PageCache 中的映射表如下:

namespace Xq
{
	class PageCache
	{
		// 省略 ... 

		ConcurrentMemoryPageMap& GetPageIdToSpanMap()
		{
			return _PageIdToSpanMap;
		}

		Span* GetSpanByAddr(void* start)
		{
			PAGE_ID page_id = reinterpret_cast<PAGE_ID>(start) >> PageShift;
			Span* ret = (Span*)_PageIdToSpanMap.get(page_id);
			assert(ret);
			return ret;
		}
	private:
		// 省略 ... 

		// 保存页号和 Span 对象的映射关系
		// 这里的 ConcurrentMemoryPageMap 有如下可能:
		// 在32位程序下, 如果定义了PAGEMAP1: Xq::TCMalloc_PageMap1<BITS - PageShift>
		// 在32位程序下, 如果定义了PAGEMAP2: Xq::TCMalloc_PageMap2<BITS - PageShift>
		// 在64位程序下: Xq::TCMalloc_PageMap3<BITS - PageShift>
		ConcurrentMemoryPageMap _PageIdToSpanMap;
	};
    // 通过基数树的 set 接口建立 page 和 Span 对象的映射关系
	inline static void AddAndUpdatePageIdToSpanMap(PAGE_ID page_id, size_t page_num, Span* span, ConcurrentMemoryPageMap& PageIdToSpanMap)
	{
		for (PAGE_ID i = 0; i < page_num; ++i)
		{
			PageIdToSpanMap.set(page_id + i, span);
		}
	}
}

核心更改就在于此, 在其他使用映射表的地方也要做出更改,都采用 set 建立页和span对象的映射关系和采用 get 通过页号获取对应的 Span 对象,在这里就不一一展示了,如若想了解的,可以看下源码。

11.3. 优化后的性能测试

当采用基数树进行优化后,我们在对其进行测试 (测试代码不变),先测申请和释放的是同一大小的内存对象:

第一次结果:

第二次结果:

第三次结果:

接下来测试每次申请和释放不同大小的内存对象,结果如下:

第一次结果: 

第二次结果: 

第三次结果: 

可以看到,此时的性能是有很大提升的。   

接下来,我们探讨最后一个问题,基数树能够提升的原因什么呢?

其最主要的原因是:

首先,我们想一下,map 或者 unordered_map 在实现 GetSpanByAddr 读映射关系的时候为什么需要加锁呢?

因为,这两个数据结构的结构是动态变化的,即当数据插入到这两个结构中可能会导致其结构发生变化,比如红黑树会进行旋转,哈希表会进行增容,如果这个时候,一个线程在读取映射关系,但由于另一个线程写映射关系,导致结构发生变化,这是是存在风险的,因此需要加锁保护。

但是对于基数树而言,由于它的结构是固定的,不会存在写映射关系的时候导致结构发生变化的情况,这也是读映射关系 GetSpanByAddr 这个接口不加锁的一个原因。

另一个,对于映射关系而言,只有写才会导致线程安全问题,那么什么时候才会进行写映射关系呢?一个就是 PageCache::NewSpan,另一个是 PageCache::ReleaseSpanToPageCache,这两个接口是通过 PageCache 这把大锁保证线程安全的,换言之,只有在 Page Cache 中才会存在写映射关系的情况,那么什么时候会读映射关系呢?

一个是在 CentralCache::RealeseMemoryUnitsToSpans 中,另一个是在对外提供释放内存的接口,即 ConcurrentDeallocate 中,可以发现,读写是分离的,即一个线程在读映射关系中某个位置时,另一个线程不会在这个映射关系中的这个位置进行写操作,故基数树在读映射关系 GetSpanByAddr 时,不需要加锁,因此,并行化度高,进而提高效率。

12. 源码 

源码链接: C++高并发内存池项目: 主要是记录自己实现高并发内存池项目的过程和遇到的问题以及是如何解决问题的。 - Gitee.com

  • 24
    点赞
  • 32
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值