【项目】C++实现高并发内存池

一、项目介绍

1.1 项目原型

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

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

该项目主要涉及C/C++、数据结构(链表、哈希桶)、操作系统内存管理、单例模式、多线程、互斥锁等方面的技术。

1.2 池化技术

所谓“池化技术”,就是程序先向系统申请过量的资源,然后自己进行管理,以备不时之需。

之所以要申请过量的资源,是因为申请和释放资源都有较大的开销,不如提前申请一些资源放入“池”中,当需要资源时直接从“池”中获取,不需要时就将该资源重新放回“池”中即可。这样使用时就会变得非常快捷,可以大大提高程序的运行效率。

在之前的文章中提到过线程池:【linux】基于单例模式实现线程池
它的主要实现就是:先启动若干数量的线程,并让它们处于睡眠状态,当接收到客户端的请求时,唤醒池中某个沉睡的线程,让它来处理客户端的请求,当处理完这个请求后,线程又进入睡眠状态。

1.3 内存池主要解决的问题

内存池主要解决的就是效率的问题,它能够避免让程序频繁的向系统申请和释放内存。其次,内存池作为系统的内存分配器,还需要尝试解决内存碎片的问题。

内存碎片分为内部碎片和外部碎片
内部碎片:由于采用固定大小的内存分区,当一个进程不能完全使用分给它的固定内存区域时就产生了内部碎片。
外部碎片:有一些空闲的小块内存区域,由于这些内存空间不连续,以至于合计的内存足够,但是不能满足一些内存分配申请需求。

内部碎片的内存是已经被分配出去的资源,而外部碎片是没有被分出去的资源,不属于任何进程。
内存池尝试解决的是外部碎片的问题,同时也尽可能的减少内部碎片的产生。

1.4 malloc理解

在这里插入图片描述
malloc实际就是一个内存池,malloc会向操作系统申请一大块内存空间,然后取出一部分给我们使用。如果不够再去向操作系统申请大块内存。

二、定长内存池实现

malloc是一个通用的内存池,可以在任意场景下使用,所以注定效率不会高,可以类比模板函数

定长的意思就是每次申请和释放的都是固定大小的内存,所以不用考虑内存碎片的问题。

我们可以通过实现定长内存池来熟悉一下对简单内存池的控制,其次,这个定长内存池后面会作为高并发内存池的一个基础组件。

定长内存池即要申请也要释放,申请的内存可以用一个_memory指针管理起来,释放的内存也可以用_freeList指针管理起来。所以当还需要申请资源的时候直接先找_freeList要,_freeList中没有了才会找操作系统要,大大提升了效率。

在这里插入图片描述

  • 如何实现定长?

可以使用非类型模板参数template <size_t N>,但是更好的方式是根据传入对象的类型来推导对象的大小template <class T>

  • 需要哪些成员变量?

首先要有_memory_freeList(自由链表),而因为_memory需要进行切分,就会涉及地址的++操作,void*不能直接使用,所以可以用char*类型。但是我们怎么知道_memory还有多少对象呢?因为没有nullptr,并不知道还剩多少空间,所以应该加上一个成员变量_remainBytes记录剩余空间的大小。

  • 如何管理_freeList的对象?

对于还回来的定长内存块,我们可以用自由链表将其链接起来,但我们并不需要为其专门定义链式结构,我们可以让内存块的前4个字节(32位平台)或8个字节(64位平台)作为指针,存储后面内存块的起始地址即可。如果有内存归还回来了直接头插即可。

  • 有可能32位平台有可能是64位,怎么知道解引用后访问几个字节呢?

使用二级指针:当我们需要访问一个内存块的前4/8个字节时,我们就可以先该内存块的地址先强转为二级指针,由于二级指针存储的是一级指针的地址,二级指针解引用能向后访问一个指针的大小,因此在32位平台下访问的就是4个字节,在64位平台下访问的就是8个字节,此时我们访问到了该内存块的前4/8个字节。

static void*& NextObj(void* obj)
{
    
    
	return *(void**)obj;
}
  • 如何直接向堆申请空间?

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

// window
#ifdef WIN32
	#include <Windows.h>
#else
	//linux(brk, mmap)
#endif

// 直接从堆上申请空间
inline static void* SystemAlloc(size_t kpage)
{
    
    
#ifdef _WIN32
	void* ptr = VirtualAlloc(0, kpage * (1 << 13), MEM_COMMIT | MEM_RESERVE,
		PAGE_READWRITE);
#else
	// linux下brk mmap等
#endif
	if (ptr == nullptr)
		throw std::bad_alloc();
	return ptr;
}
  • 内存池如何申请对象?

申请对象时首先要看自由链表中是否有还回来的内存块
如果有,先从自由链表中获取内存(头删)
如果没有就从堆区申请空间
这里要注意因为我们申请的内存释放后是要挂到自由链表中,所以这块内存的大小至少要有一个指针的大小
还有一种情况是自由链表的空间不足一个对象的大小,这时也要从堆区申请空间。
申请的对象要用定位new来初始化。
最后要记得及时更新_memory指针的指向,以及_remainBytes的值。

T* New()
{
    
    
	T* obj = nullptr;
	// 先用归还的内存
	if (_freeList)
	{
    
    
		void* next = NextObj(_freeList);
		obj = (T*)_freeList;
		_freeList = next;
		return obj;
	}
	// 剩余空间不足一个对象大小
	if (_remainBytes < sizeof(T))
	{
    
    
		_remainBytes = 128 * 1024;
		_memory = (char*)SystemAlloc(_remainBytes >> 13);
		if (_memory == nullptr)
		{
    
    
			throw std::bad_alloc();
		}
	}
	obj = (T*)_memory;
	// 不足一个指针,就给一个指针大小,方便链接
	size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
	_memory += sizeof(T);
	_remainBytes -= sizeof(T);
	// 定位new初始化对象
	new (obj)T;
	return obj;
}
  • 内存池如何释放对象?

释放对象要注意的只有先显示调用析构函数把对象管理的空间清理掉,防止内存泄漏。
把对象头插即可。

void Delete(T* obj)
{
    
    
	// 显示调用析构
	obj->~T();
	if (_freeList == nullptr)
	{
    
    
		_freeList = obj;
		NextObj(obj) = nullptr;
	}
	else
	{
    
    
		// 头插
		NextObj(obj) = _freeList;
		_freeList = obj;
	}
}
  • 整体代码与性能测试
// 定长内存池
template <class T>
class FixedPool
{
    
    
public:
	T* New()
	{
    
    
		T* obj = nullptr;
		// 先用归还的内存
		if (_freeList)
		{
    
    
			void* next = NextObj(_freeList);
			obj = (T*)_freeList;
			_freeList = next;
			return obj;
		}
		// 剩余空间不足一个对象大小
		if (_remainBytes < sizeof(T))
		{
    
    
			_remainBytes = 128 * 1024;
			_memory = (char*)SystemAlloc(_remainBytes >> 13);
			if (_memory == nullptr)
			{
    
    
				throw std::bad_alloc();
			}
		}
		obj = (T*)_memory;
		// 不足一个指针,就给一个指针大小,方便链接
		size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
		_memory += sizeof(T);
		_remainBytes -= sizeof(T);
		// 定位new初始化对象
		new (obj)T;
		return obj;
	}

	void Delete(T* obj)
	{
    
    
		// 显示调用析构
		obj->~T();
		if (_freeList == nullptr)
		{
    
    
			_freeList = obj;
			NextObj(obj) = nullptr;
		}
		else
		{
    
    
			// 头插
			NextObj(obj) = _freeList;
			_freeList = obj;
		}
	}
private:
	char* _memory = nullptr;// 大块内存
	void* _freeList = nullptr;// 归还的内存
	size_t _remainBytes = 0;// 剩余空间大小
};

struct A
{
    
    
	int _a = 0;
	int _b = 1;
	double* _p1 = nullptr;
	double* _p2 = nullptr;
};

void TestFixedPool()
{
    
    
	// 申请释放的轮次
	const size_t Rounds = 3;
	// 每轮申请释放多少次
	const size_t N = 1000000;
	size_t begin1 = clock();
	std::vector<A*> v1;
	v1.reserve(N);
	for (size_t j = 0; j < Rounds; ++j)
	{
    
    
		for (int i = 0; i < N; ++i)
		{
    
    
			v1.push_back(new A);
		}
		for (int i = 0; i < N; ++i)
		{
    
    
			delete v1[i];
		}
		v1.clear();
	}
	size_t end1 = clock();
	FixedPool<A> TNPool;
	size_t begin2 = clock();
	std::vector<A*> v2;
	v2.reserve(N);
	for (size_t j = 0; j < Rounds; ++j)
	{
    
    
		for (int i = 0; i < N; ++i)
		{
    
    
			v2.push_back(TNPool.New());
		}
		for (int i = 0; i < 100000; ++i)
		{
    
    
			TNPool.Delete(v2[i]);
		}
		v2.clear();
	}
	size_t end2 = clock();
	cout << "new time:" << end1 - begin1 << endl;
	cout << "Fixed pool time:" << end2 - begin2 << endl;
}

这个对比的是我们自己实现的内存池和new/delete的效率。

  • 结果:

在这里插入图片描述
可以看到定长内存池的效率要比new/delete快得多。

三、高并发内存池的三层申请内存框架设计

现代很多的开发环境都是多核多线程,因此在申请内存的时,必然存在激烈的锁竞争问题。所以这次我们实现的高并发内存池需要考虑到以下几方面的问题:

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

  • 高并发内存池整体框架设计(三层)

在这里插入图片描述

thread cache:线程缓存是每个线程独有的,线程从这里申请内存不需要加锁
central cache:中心缓存是所有线程所共享,达到内存分配在多个线程中更均衡的按需调度的目的
page cache:回收满足条件的span对象,合并相邻的页,组成更大的页,缓解内存碎片的问题

细节问题:

每个线程的thread cache会根据自己的情况向central cache申请或归还内存,这就避免了出现单个线程的thread cache占用太多内存,而其余thread cache出现内存吃紧的问题。
central cahche的结构实际上是一个哈希桶结构,多个thread cache访问同一个桶锁的概率较小,所以锁的竞争不会很激烈。

接下来就详细介绍每个层。

3.1 thread cache层的设计

3.1.1 thread cache整体框架

类比定长内存池用一个自由链表来管理归还的内存,但是定长内存池中的是固定大小的内存块,而现在我们需要申请不同大小的内存块,所以需要多个自由链表

但是也不可能每个字节数都有一个自由链表,这时我们可以选择做一些平衡的牺牲,让这些字节数按照某种规则进行对齐。

我们让这些字节数都按照8字节进行向上对齐,那么thread cache的结构就是下面这样的,此时当线程申请1~ 8字节的内存时会直接给8字节内存,而当线程申请9~16字节的内存时会直接给16字节内存,以此类推。
总的来说就是向上补齐凑整
在这里插入图片描述
这也会导致产生内部碎片,比如申请了5字节,给了8字节,有三个字节就浪费了。


因为要管理多个链表,所以可以用类进行封装方便管理。

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

	// 从自由链表头部获取一个对象
	void* pop_front()
	{
    
    
		assert(_freeList);
		void* obj = _freeList;
		_freeList = NextObj(obj);
		return obj;
	}
	
	// 判空
	bool empty()
	{
    
    
		return _freeList == nullptr;
	}
private:
	void* _freeList = nullptr;
};

3.1.2 哈希桶映射对齐规则

上面说过要实现向上对齐,将一段范围的值映射进同一个桶。

  • 如何对齐?

首先要保证能存下一个指针的大小,也就是8字节,但如果所有的字节数都按照8字节进行对齐的话,那么我们就需要建立256 × 1024 ÷ 8 = 32768 个桶。
所以我们让让不同范围的字节数按照不同的对齐数进行对齐,得到的大小一定是对齐数的整数倍

具体规则如下:

字节数 对齐数 哈希桶的下标
[1, 128] 8byte对齐 [0, 16)
[128 + 1, 1024] 16byte对齐 [16, 72)
[1024 + 1, 8 × 1024] 128byte对齐 [72, 128)
[8 × 1024 + 1, 64 × 1024] 1024byte对齐 [128, 184)
[64 × 1024 + 1,256 × 1024] 8*1024byte对齐 [184, 208)

解释:

比方说看16字节对齐的一行,字节数之差为1024 - 128 - 1 = 895,除以对齐数:895 / 16 = 55,可以看到哈希桶的下标刚好就是有55个桶。根据这种规则就可以算出在哪个桶了。
如果申请129字节大小,就要对齐到16的整数倍,也就是申请144字节。

  • 对齐规则函数

对齐规则函数要实现对齐,比如给7个字节,函数帮我们对齐到8,给6个字节,函数也帮我们对齐到8.
上面的每个范围的对齐数都要讨论。

正常的思维是算出给定字节数有多少个对齐数(除运算),然后把个数+1再乘以对齐数,这样就实现了向上对齐。
但是有高手实现了一个函数,主要是通过位运算实现:

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

// 对齐的大小计算
static inline size_t RoundUp(size_t bytes)
{
    
    
	if (bytes <= 128)
	{
    
    
		return _RoundUp(bytes, 8);
	}
	else if (bytes <= 1024)
	{
    
    
		return _RoundUp(bytes, 16);
	}
	else if (bytes <= 8 * 1024)
	{
    
    
		return _RoundUp(bytes, 128);
	}
	else if (bytes <= 64 * 1024)
	{
    
    
		return _RoundUp(bytes, 1024);
	}
	else if (bytes <= 256 * 1024)
	{
    
    
		return _RoundUp(bytes, 8 * 1024);
	}
	else
	{
    
    
		return _RoundUp(bytes, 1 << PAGE_SHIFT);
	}
	return -1;
}

计算出对齐大小后,需要知道这个资源块位于哪一个自由链表桶。

正常思维是如果模对齐数结果是0,那么桶的位置就是大小除以对齐数再-1(桶的位置从0开始),如果模对齐数的结果不为0,那么直接除就行了(自动向下取整)。

接下来是大佬写法:

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

// 计算映射的桶
static inline size_t Index(size_t bytes)
{
    
    
	assert(bytes <= MAX_BYTES);
	// 每个区间的桶数
	int Group[4] = {
    
     16, 56, 56, 56 };
	if (bytes <= 128)
	{
    
    
		return _Index(bytes, 3);
	}
	else if (bytes <= 1024)
	{
    
    
		return _Index(bytes - 128, 4) + Group[0];
	}
	else if (bytes <= 8 * 1024)
	{
    
    
		return _Index(bytes - 1024, 7) + Group[0] + Group[1];
	}
	else if (bytes <= 64 * 1024)
	{
    
    
		return _Index(bytes - 8 * 1024, 10) + Group[0] + Group[1] + Group[2];
	}
	else if (bytes <= 256 * 1024)
	{
    
    
		return _Index(bytes - 64 * 1024, 13) + Group[0] + Group[1] + Group[2] + Group[3];
	}
	else
	{
    
    
		assert(false);
	}
	return -1;
}

这里_Index的第二个参数是对齐数移位值,比如说字节数小于128的时候,对齐数是8,也就是2^3,所以传3。

  • thread cache类

按照上述的对齐规则,thread cache中桶的个数,也就是自由链表的个数是208,以及thread cache允许申请的最大内存大小256KB。

static const size_t MAX_BYTES = 256 * 1024;// 最大申请256KB
static const size_t NFREELISTS = 208;// 最大208个自由链表桶

现在就可以对thread cache类开始定义了,thread cache就是个存储208个自由链表的数组

class ThreadCache
{
    
    
public:
	// 申请对象
	void* Allocate(size_t size);
	// 释放内存对象
	void deAllocate(void* ptr, size_t size);
private:
	FreeList _freeLists[NFREELISTS];
};

目前先实现两个函数,Allocate就是申请对象,如果自由链表不为空就先从自由链表中获取,如果为空就从central cache获取,这个后面介绍。deAllocate就是把要释放的对象挂回对应的自由链表桶。当然如果一个桶挂的数量过多也要还回central cache中,这个先不考虑,后边回收机制会讲解。

// 申请对象
void* ThreadCache::Allocate(size_t size)
{
    
    
	// 小于256KB
	assert(size <= MAX_BYTES);
	size_t alignSize = SizeRule::RoundUp(size);// 对齐之后的内存块大小
	size_t idx = SizeRule::Index(size);// 自由链表桶的位置
	if (!_freeLists[idx].empty())
	{
    
    
		// 不为空就从自由链表获取
		return _freeLists[idx].pop_front();
	}
	else
	{
    
    
		// 为空就向下一层central cache要alignSize大小
	}
}

// 释放内存对象
void ThreadCache::deAllocate(void* ptr, size_t size)
{
    
    
	assert(ptr && size <= MAX_BYTES);
	// 找到自由链表桶插入进去
	size_t idx = SizeRule::Index(size);
	_freeLists[idx].push_front(ptr);
}

3.1.3 thread cacheTLS无锁访问

在这里插入图片描述
每个线程都有自己独享的thread cache,那应该如何创建这个thread cache呢?
如果直接使用全局变量的话是行不通的,因为全局变量所有线程共享。

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

  • 静态TLS声明方法
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;

当我们申请内存的时候要先获取自己的thread cache对象,然后调用Allocate,所有我们可以把这个过程封装起来。

// ConcurrentAlloc.h
// 申请
static void* ConcurrentAlloc(size_t size)
{
    
    
	// 每个线程无锁获取ThreadCache对象
	if (pTLSThreadCache == nullptr)
	{
    
    
		pTLSThreadCache = new ThreadCache;
	}
	return pTLSThreadCache->Allocate(size);
}

// 释放
static void ConcurrentFree(void* ptr, size_t size)
{
    
    
	assert(pTLSThreadCache);
	pTLSThreadCache->deAllocate(ptr, size);
}

3.2 central cache层的设计

3.2.1 central cache的整体框架

thread cache申请空间时如果此时自由链表桶为空,则需要向centra cache中申请内存。
而central cache和thread cache它们俩都是桶结构。并且遵循的内存对齐规则是一样的,这样就有个好处:当thread cache的某个桶中没有内存了,就可以直接到central cache中对应的哈希桶里去取内存就行了

  • central cache与thread cache的不同之处

1️⃣ 首先thread cache是每个线程所独享的,而central cache是所有线程共享的。因此在访问central cache时是需要加锁的。但central cache在加锁时并不是将整个central cache全部锁上了,central cache在加锁时用的是桶锁,也就是说每个桶都有一个锁。此时只有当多个线程同时访问central cache的同一个桶时才会存在锁竞争,如果是多个线程同时访问central cache的不同桶就不会存在锁竞争
2️⃣ thread cache的每个桶中挂的是一个个切好的内存块,而central cache的每个桶中挂的是一个个的span(以页为单位的大块内存,不同哈希桶的的span所分配的页数会有所不同),span下面挂载的才是真正被切好的小块对象。
下面的图只是为了看的更明白。其实就每一个内存块大小都对应这一个SpanList的双向链表,每一个双向链表的节点下又挂载着这一大小的内存块。在这里插入图片描述
3️⃣ central cache的哈希桶需要设计为带头双向链表,因为Span收回了页后,需要还给下一层的page cache,需要满足任意位置的插入删除。

  • span

span是一个跨度,管理的是以页为单位的大块内存。每个span下面挂的页的数量是不一样的,既然要管理多个页,那么就要有页号。越往下需要的页数越多,因为越大需要更多的页才能切出一个对象。 一个span中的对象可能会分给不同的thread chche。

一般的页的大小为4k或者8k, 像32位机器能划分成2^32 / 2^13 = 2^19 页,但是64位机器的页数直接指数翻倍,达到2^64 / 2^13 = 2^51 页,为了解决64位机器页数过多的问题(解决页号大小存不下的问题),可以采用条件编译进行区分,注意win64同时有win64和win32的宏定义,所以一定要先判断当前机器是否存在_WIN64:

#ifdef _WIN64
typedef unsigned long long PAGE_ID;
#elif _WIN32
typedef size_t PAGE_ID;
#else
// linux
#endif
  • span的结构
// 管理多个页大块内存
struct Span
{
    
    
	PAGE_ID _pageId = 0;// 大块内存起始页号
	size_t _n = 0;// 页数

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

	size_t _useCnt = 0;// 分配给thread cache的计数
	void* _freeList = nullptr;// 切好小内存的链表
};

span为什么要这样设计的原因:

_pageId: 是申请的时候系统内存地址的页号,(系统地址/8k) ,span回收的时候也必须连续
_n: 页的数量,记录是哪一个span分割的
_prev和_next: 双链表结构,当我们需要将某个span归还给page cache时,就可以很方便的将该span从双链表结构中移出。
_useCnt:对象的使用计数,当使用计数为0时,说明这个span的空间全部归还回来
_freeList: 对象要挂载到span的后面

一般来说只有所有span用完的时候才会申请新的span(找page cache),但是也有从thread cache归还回来的情况。

  • 双链表结构

根据上面的描述,central cache的每个哈希桶里面存储的都是一个双链表结构,对于该双链表结构我们可以对其进行封装。

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

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

	Span* end()
	{
    
    
		return _head;
	}

	void insert(Span* pos, Span* newSpan)
	{
    
    
		assert(pos);
		assert(newSpan);
		newSpan->_prev = pos->_prev;
		pos->_prev->_next = newSpan;
		pos->_prev = newSpan;
		newSpan->_next = pos;
	}

	bool empty()
	{
    
    
		return _head->_next == _head;
	}

	void push_front(Span* span)
	{
    
    
		insert(begin(), span);
	}

	Span* pop_front()
	{
    
    
		Span* front = begin();
		erase(front);
		return front;
	}

	void erase(Span* pos)
	{
    
    
		assert(pos);
		assert(pos != _head);
		pos->_prev->_next = pos->_next;
		pos->_next->_prev = pos->_prev;
	}

	void lock()
	{
    
    
		_mtx.lock();
	}

	void unlock()
	{
    
    
		_mtx.unlock();
	}
private:
	Span* _head;
	std::mutex _mtx;// 桶锁
};

这里的删除并不是真正意义上的删除,而是把这块span还给page cache。

  • central cache结构

central cache的映射规则和thread cache是一样的,因此central cache里面哈希桶的个数也是208,central cache每个哈希桶中存储就是我们上面定义的双链表结构。

class CentralCache
{
    
    
public:

private:
	SpanList _spanList[NFREELISTS];
};

3.2.2 central cache单例模式

thread cache是每个线程独有的,但是central cache和page cache都是所有线程共享的,整个进程只有一个,对于这种只能创建一个对象的类,我们可以将其设置为单例模式。这里采取饿汉模式的方法实现单例。

class CentralCache
{
    
    
public:
	static CentralCache& GetSingleton()
	{
    
    
		return _cSingle;
	}
private:
	SpanList _spanList[NFREELISTS];
private:
	// 构造私有+防拷贝
	CentralCache(){
    
    }
	CentralCache(const CentralCache&) = delete;
	CentralCache& operator=(const CentralCache&) = delete;
	// 单例
	static CentralCache _cSingle;
};

为了保证只有一个对象,所以要做构造私有+防拷贝操作。要注意在源文件中定义。

// 定义单例模式
CentralCache CentralCache::_cSingle;

3.2.3 慢开始反馈调节算法

当thread cache向central cache申请内存时,central cache应该给多少个对象呢?其实从central取出一个对象就够用了,但是如果central cache给的太少,那么thread cache在短时间内用完了又会来申请;但如果一次性给的太多了,可能thread cache用不完也就浪费了。而且我们知道central cache从上到下的对象大小是不断变大的,例如thread cache需求8byte内存块和需求256KB内存块,central cache分配的对象数肯定是不相同的。

鉴于此,我们这里采用了一个慢开始反馈调节算法。当thread cache向central cache申请内存时,如果申请的是较小的对象,那么可以多给一点,但如果申请的是较大的对象,就可以少给一点。

通过下面这个函数,我们就可以根据所需申请的对象的大小计算出具体给出的对象个数,并且可以将给出的对象个数控制到2~512个之间。也就是说:
就算thread cache要申请的对象再小,我最多一次性给出512个对象;就算thread cache要申请的对象再大,我至少一次性给出2个对象。

// threadchahe一次从中心缓存获取多少个对象
static size_t NumMoveSize(size_t size/*单个对象大小*/)
{
    
    
	assert(size > 0);
	// [2, 512],一次批量移动多少个对象的(慢启动)上限值
	// 小对象一次批量上限高
	// 小对象一次批量上限低
	int num = MAX_BYTES / size;
	if (num < 2)
		num = 2;
	if (num > 512)
		num = 512;
	return num;
}

但是就算是申请小对象,一次性给出512个也是较多的,很可能会浪费掉。所以这里就可以使用慢增长策略:

在FreeList结构中增加一个叫做_maxSize的成员变量,该变量的初始值设置为1,并且提供一个公有成员函数用于获取这个变量。也就是说,现在thread cache中的每个自由链表都会有一个自己的_maxSize。

// 自由链表
class FreeList
{
    
    
public:
	size_t& max_size()
	{
    
    
		return _maxSize;
	}
private:
	void* _freeList = nullptr;
	size_t _maxSize = 1;
};

当thread cache向central cache申请对象的时候,会先比较上面计算的结果和_maxSize的最小值,这个值就是要申请的对象数量,再判断入过取的是_maxSize,就让_maxSize += 1,如果觉得太慢可以增加多一点。

因此,thread cache第一次向central cache申请某大小的对象时,申请到的都是一个,但下一次thread cache再向central cache申请同样大小的对象时,因为该自由链表中的_maxSize增加了,最终就会申请到两个。直到该自由链表中_maxSize的值,增长到超过计算出的值后就不会继续增长了,此后申请到的对象个数就是计算出的个数。

// 从中心缓存获取对象
void* ThreadCache::FetchFromCentralCache(size_t idx, size_t size)
{
    
    
	// 慢开始反馈调节算法
	size_t batchNum = min(SizeRule::NumMoveSize(size), _freeLists[idx].max_size());
	if (batchNum == _freeLists[idx].max_size())
	{
    
    
		_freeLists[idx].max_size() += 1;
	}
	// return 
}

3.2.4 从central cache获取对象

知道了获取几个对象后接下来就是要从central cache中获取批量对象了。

  • 从central cache获取一定数量的对象流程

central cache里面挂的span下面挂的是单链表链接起来的对象,从这里拿到一批对象需要两个指针一个指向头,一个指向尾部,但是还有一点要注意的是有可能span里面并没有那么多对象,那么只能要多少给多少,返回一个真实获取的数量。上面也说了thread cache每次其实只需要一个对象就够了,剩下的都会挂到自由链表中。

如果span下面只有一个对象,导致只获取了一个,那么刚好满足需求,直接返回,如果获取了多个对象,那么只需要返回第一个对象,其他的插入到自由链表中,以后获取就直接从自由链表中获取。

// 从中心缓存获取对象
void* ThreadCache::FetchFromCentralCache(size_t idx, size_t size)
{
    
    
	// 慢开始反馈调节算法
	size_t batchNum = min(SizeRule::NumMoveSize(size), _freeLists[idx].max_size());
	if (batchNum == _freeLists[idx].max_size())
	{
    
    
		_freeLists[idx].max_size() += 1;
	}
	void* start = nullptr, * end = nullptr;
	// span不一定有那么多,实际有actualsize
	size_t actualNum = CentralCache::GetSingleton().FetchRangObj(start, end, batchNum, size);
	assert(actualNum >= 1);// 至少得申请一个
	if (actualNum == 1)
	{
    
    
		assert(start == end);
		return start;
	}
	else
	{
    
    
		// 头返回,剩下的插入自由链表中
		_freeLists[idx].push_range(NextObj(start), end, actualNum - 1);
		return start;
	}
}
  • 插入一段范围的对象到自由链表
// 头插入多个对象(n个)到自由链表
void push_range(void* start, void* end, size_t n)
{
    
    
	NextObj(end) = _freeList;
	_freeList = start;
}
  • 从central cache获取一定数量的对象的方法

这里我们要从central cache获取batchNum个指定大小的对象,这些对象肯定都是从central cache对应哈希桶的某个span中取出来的,因此取出来的这batchNum个对象是链接在一起的,我们只需要得到这段链表的头和尾即可,这里可以采用输出型参数进行获取,因为span中的对象可能不够,所以要返回真实的对象个数。

// 从中心缓存中获取一定数量的对象给threadcache
size_t CentralCache::FetchRangObj(void*& start, void*& end, size_t batchNum, size_t size)
{
    
    
	// 桶的位置
	size_t idx = SizeRule::Index(size);
	// 加上桶锁
	_spanList[idx].lock();
	// 首先找到一个非空的span
	Span* span = GetOneSpan(_spanList[idx], size);
	assert(span);
	assert(span->_freeList);
	// 开始切分
	start = span->_freeList;
	end = start;
	size_t i = 0;
	size_t actualsize = 1;// 真实对象数量
	while (i < batchNum - 1 && NextObj(end) != nullptr)
	{
    
    
		i++;
		actualsize++;
		end = NextObj(end);
	}
	span->_freeList = NextObj(end);
	NextObj(end) = nullptr;
	span->_useCnt += actualsize;
	_spanList[idx].unlock();
	return actualsize;
}

这里每次分配出去对象_useCnt就会++,还回来就会–,如果为0就表示都还回来了。

这里的GetOneSpan函数就是获取一个非空的span,而如果没有就会向page cache申请,因为涉及到page cache,所以在后边3.3.2 从central cache获取一个非空span讲解。

3.3 page cache层的设计

3.3.1 page cache的整体框架

page cache与central cache一样,它们都是哈希桶的结构,并且page cache的每个哈希桶中里挂的也是一个个的span,这些span也是按照双链表的结构链接起来的。

在这里插入图片描述

这里的1号桶表示每个span下面挂的是1页大小的内存,3号桶就表示每个桶内挂着三页大小的内存。page cache的哈希桶是以页为单位进行划分,这是一个直接定址法的映射,central cache需要多少页的缓存,就去page cache对应页大小的哈希桶去拿。page cache哈希桶中的页之间使用带头双向循环链表进行连接,并不会切成小块的对象。

  • 为什么最大的页定成128页?

因为线程申请单个对象最大是256KB,而128页可以被切成4个256KB的对象,因此是足够的。为了让桶号与页号对应起来,我们可以将第0号桶空出来不用,因此我们需要将哈希桶的个数设置为129。

static const size_t NPAGES = 129;// page cache最大桶数

而因为整个进程只有一个page cache,所以也要设置成单例模式:

class PageCache
{
    
    
public:
	static PageCache& GetSingleton()
	{
    
    
		return _pSingle;
	}
private:
	SpanList _spanLists[NPAGES];
	std::mutex _mtx;
private:
	// 构造私有+防拷贝
	PageCache() {
    
    }
	PageCache(const PageCache&) = delete;
	PageCache& operator=(const PageCache&) = delete;
	// 单例
	static PageCache _pSingle;
};
  • 怎么在page cache获取k页span?

如果central cache要获取一个n页的span,那我们就可以在page cache的第n号桶中取出一个span返回给central cache即可,但如果第n号桶中没有span了,这时我们并不是直接转而向堆申请一个n页的span,而是要继续在后面的桶当中寻找span
比方说central cache现在要申请4页,而4页的桶为空,而后边的7号桶不为空,那么我们就可以把7页分成4页和3页,4页返回,3页挂到3号桶。
但是如果后边的桶都为空,就必须要向堆申请了,直接向堆申请以页为单位的内存时,我们应该尽量申请大块一点的内存块,因为此时申请到的内存是连续的,当线程需要内存时我们可以将其切小后分配给线程,而当线程将内存释放后我们又可以将其合并成大块的连续内存
每次向堆申请的都是128页大小的内存块,central cache要的这些span实际都是由128页的span切分出来的。

  • 为什么page cache必须要整体加锁,不能用桶锁?

通过获取k页span的流程可以看到如果当前桶没有,就要向下遍历,如果是桶锁就要频繁的申请和释放锁,效率低下。

3.3.2 从central cache获取一个非空span

这里就要解决上面遗留的问题:thread cache向central cache申请对象时,central cache需要先从对应的哈希桶中获取到一个非空的span,然后从这个非空的span中取出若干对象返回给thread cache。

具体流程:

遍历central cache对应哈希桶当中的双链表,如果该双链表中有非空的span,那么直接将该span进行返回即可。
但如果遍历双链表后发现双链表中没有span,或该双链表中的span都为空,那么此时central cache就需要向page cache申请内存块了。
这里要思考的是向page cache申请多大的内存块?
这里可以增加一个函数,用来根据对象的大小计算出,central cache一次应该向page cache申请几页的内存块

  • central cache一次向page cache获取多少页?

可以先根据对象的大小计算出,thread cache一次向central cache申请对象的个数上限,然后将这个上限值乘以单个对象的大小,就算出了具体需要多少字节,最后再将这个算出来的字节数转换为页数,如果转换后不够一页,那么我们就申请一页,否则转换出来是几页就申请几页。也就是说,central cache向page cache申请内存时,要求申请到的内存尽量能够满足thread cache向central cache申请时的上限

// threadchahe一次从central cache获取多少个对象
static size_t NumMoveSize(size_t size/*单个对象大小*/)
{
    
    
	assert(size > 0);
	// [2, 512],一次批量移动多少个对象的(慢启动)上限值
	// 小对象一次批量上限高
	// 小对象一次批量上限低
	int num = MAX_BYTES / size;
	if (num < 2)
		num = 2;
	if (num > 512)
		num = 512;
	return num;
}

// central cache一次向page cache获取多少页
static size_t NumMovePage(size_t bytes)
{
    
    
	// 申请对象的个数上限
	size_t num = NumMoveSize(bytes);
	// num个size大小的对象所需的字节数
	size_t npage = num * bytes;
	// 算出页数
	npage >>= PAGE_SHIFT;
	if (npage == 0)
	{
    
    
		npage = 1;
	}
	return npage;
}

代码中的PAGE_SHIFT代表页大小转换偏移,我们这里以页的大小为8K为例,PAGE_SHIFT的值就是13。

static const size_t PAGE_SHIFT = 13;// 右移13位,8k

当central cache申请到若干页的span后,还需要将这个span切成一个个对应大小的对象挂到该span的自由链表当中。

既然要切分这个大的内存块,首先就要知道这个内存块的起始地址。

  • 如何获得span的起始地址?

用这个span的起始页号乘以一页的大小即可得到这个span的起始地址,然后用这个span的页数乘以一页的大小就可以得到这个span所管理的内存块的大小,用起始地址加上内存块的大小即可得到这块内存块的结束位置。

根据所需对象的大小,每次从大块内存切出一块固定大小的内存块尾插到span的自由链表中即可。

  • 为什么要尾插?

因为我们如果是将切好的对象尾插到自由链表,这些对象看起来是按照链式结构链接起来的,而实际它们在物理上是连续的,这时当我们把这些连续内存分配给某个线程使用时,可以提高该线程的CPU缓存利用率。

在这里插入图片描述

// 获取一个非空的span
Span* CentralCache::GetOneSpan(SpanList& list, size_t size)
{
    
    
	Span* it = list.begin();
	while (it != list.end())
	{
    
    
		if (it->_freeList != nullptr)// span挂有对象
		{
    
    
			return it;
		}
		it = it->_next;
	}
	// 无空闲的span,向page cache要
	Span* span = PageCache::GetSingleton().NewSpan(SizeRule::NumMovePage(size));
	// Span的起始地址
	char* start = (char*)(span->_pageId << PAGE_SHIFT);
	// Span大小
	size_t bytes = span->_n << PAGE_SHIFT;
	// 结尾地址
	char* end = start + bytes;
	// 把大块内存切成自由链表挂到span
	// 切下来一块当头节点
	span->_freeList = start;
	start += size;
	// 尾节点方便尾插
	void* tail = span->_freeList;
	while (start < end)
	{
    
    
		NextObj(tail) = start;
		tail = NextObj(tail);
		start += size;
	}
	NextObj(tail) = nullptr;
	// 切好之后挂回桶
	list.push_front(span);
	return span;
}

这里的NewSpan就是向page cache获取k页的span。

3.3.3 从page cache获取k页的span

central cache会向page cache申请含有k页的一块span。具体申请流程:

1️⃣ 首先直接先去找page cache的第k号桶,如果第k号桶中有span,那我们直接头删一个span返回给central cache就行了。
2️⃣ 如果page cache的第k号桶中没有span,我们就应该继续找后面的桶,只要后面任意一个桶中有一个n页span,我们就可以将其切分成一个k页的span和一个n-k页的span,然后将切出来k页的span返回给central cache,再将n-k页的span挂到page cache的第n-k号桶即可。
3️⃣ 如果一直往后找,所有哈希桶上都没有内存了,那么page cache将会去堆上申请128页的内存,递归调用一次自己,完成128页对象的切分、挂起逻辑。

这里向堆索要128页的大块内存的时候返回的是一个指针(地址),怎么转换成页号呢?

  • 地址转页号

由于我们向堆申请内存时都是按页进行申请的,因此我们直接将该地址除以一页的大小即可得到对应的页号。

// 获取k页Span
Span* PageCache::NewSpan(size_t k)
{
    
    
	assert(k > 0 && k < NPAGES);
	// 直接找k页的桶
	if (!_spanLists[k].empty())
	{
    
    
		return _spanLists[k].pop_front();
	}
	// 第k个桶是空的,检查后边的桶
	for (size_t i = k + 1; i < NPAGES; i++)
	{
    
    
		if (!_spanLists[i].empty())
		{
    
    
			Span* nspan = _spanLists[i].pop_front();
			// 切分成k页和n - k页
			Span* kspan = new Span;
			// 从nspan的头部切一个k页span
			kspan->_pageId = nspan->_pageId;// 页号
			kspan->_n = k;// 页数
			nspan->_pageId += k;
			nspan->_n -= k;
			// 把剩下的页再挂起来
			_spanLists[nspan->_n].push_front(nspan);
			return kspan;
		}
	}
	// 后面也没有span
	// 向堆要128页span
	Span* spanfromheap = new Span;
	void* ptr = SystemAlloc(NPAGES - 1);
	// 地址转页号
	spanfromheap->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
	spanfromheap->_n = NPAGES - 1;
	_spanLists[NPAGES - 1].push_front(spanfromheap);
	// 有了大块span后递归调用自己
	return NewSpan(k);
}
  • 递归调用逻辑

当申请了128页的大块内存后,接下来就是切分过程,而切分过程上面已经实现了,所以可以直接调用自己进行切分。

3.3.4 page cache上锁过程

page cache不像central cache一样用的是桶锁,而是只有一个锁把整个page cache锁起来,但是这个锁肯定不能加在NewSpan函数中,因为可能自己调用自己形成死锁,当然可以用递归锁来解决,或者分离出一个子函数来加锁。

这里我们解决的办法是在central cache访问page cache的时候加锁。


还有一个问题:在central cache向page cache要内存的时候,此时central cache一定是加锁状态的(桶锁)。那么这个线程进入page cache以后要不要把桶锁解开呢?

答案是解开好,因为有可能其他线程是来归还内存的,如果在访问page cache前将central cache对应的桶锁解掉,那么此时当其他thread cache想要归还内存到central cache的这个桶时就不会被阻塞。

加锁逻辑:

Span* CentralCache::GetOneSpan(SpanList& list, size_t size)
{
    
    
	// 挂有span就返回
	list.unlock();
	// 无空闲的span,向page cache要
	PageCache::GetSingleton().lock();
	// 从page cache申请k页的span
	PageCache::GetSingleton().unlock();
	// 对span进行切分
	list.lock();
	// 切好之后挂回桶
}

四、高并发内存池的三层回收内存框架设计

4.1 thread cache回收内存

线程申请的内存如果不用了就会挂到自由链表中。
如果一直往自由链表挂如内存,就会导致内存堆积形成浪费,此时我们应该把这些堆积的内存还给central cache,以便其他的线程使用。

  • 自由链表多长会回收?

如果thread cache某个桶当中自由链表的长度超过它一次批量向central cache申请的对象个数(max_size(),那么此时我们就要把该自由链表当中的这些(一次批量的)对象还给central cache。

// 释放内存对象
void ThreadCache::deAllocate(void* ptr, size_t size)
{
    
    
	assert(ptr && size <= MAX_BYTES);
	// 找到自由链表桶插入进去
	size_t idx = SizeRule::Index(size);
	_freeLists[idx].push_front(ptr);
	if (_freeLists[idx].size() > _freeLists[idx].max_size())
	{
    
    
		ListTooLong(_freeLists[idx], size);
	}
}

如果链表过长,就头删一个批量的对象,用输出型参数获取删除的这部分链表,在把这个链表还给central cache对应的span中。

// 释放对象时发现链表过长,回收到central cache
void ThreadCache::ListTooLong(FreeList& list, size_t size)
{
    
    
	void* start = nullptr, * end = nullptr;
	// 取出一个批量的内存
	list.pop_range(start, end, list.max_size());
	
	CentralCache::GetSingleton().ReleaseListToSpans(start, size);
}

这里再pop_range的时候就把end指针的_next置空了,所以ReleaseListToSpans函数不需要end,给size是为了算出对应的桶位置。而ReleaseListToSpans的具体实现在central cache的内存回收中。

自由链表新增了几个成员函数:

// 自由链表
class FreeList
{
    
    
public:
	// 将释放的对象头插到自由链表
	void push_front(void* obj)
	{
    
    
		assert(obj);
		NextObj(obj) = _freeList;
		_freeList = obj;
		_size++;
	}

	// 从自由链表头部获取一个对象
	void* pop_front()
	{
    
    
		assert(_freeList);
		void* obj = _freeList;
		_freeList = NextObj(obj);
		_size--;
		return obj;
	}

	bool empty()
	{
    
    
		return _freeList == nullptr;
	}

	// 头插入多个对象(n个)到自由链表
	void push_range(void* start, void* end, size_t n)
	{
    
    
		NextObj(end) = _freeList;
		_freeList = start;
		_size += n;
	}

	// 头删n个对象,用输出型参数返回
	void pop_range(void*& start, void*& end, size_t n)
	{
    
    
		assert(n <= _size);
		start = _freeList;
		end = start;
		for (size_t i = 0; i < n - 1; i++)
		{
    
    
			end = NextObj(end);
		}
		_freeList = NextObj(end);
		NextObj(end) = nullptr;
		_size -= n;
	}

	size_t size()
	{
    
    
		return _size;
	}

	size_t& max_size()
	{
    
    
		return _maxSize;
	}
private:
	void* _freeList = nullptr;
	size_t _maxSize = 1;
	size_t _size = 0;// 个数
};

4.2 central cache回收内存

当thread cache中某个自由链表太长时,会将自由链表当中的这些对象还给central cache中的span。

但是需要注意的是,还给central cache的这些对象不一定都是属于同一个span的,因为span中的对象有可能分给了不同的线程,central cache中的每个哈希桶当中可能都不止一个span,因此当我们计算出还回来的对象应该还给central cache的哪一个桶后,还需要知道这些对象到底应该还给这个桶当中的哪一个span

  • 如何根据对象的地址得到对象所在的页号?

某个页当中的所有地址除以页的大小都等该页的页号。比如我们这里假设一页的大小是10,那么地址0 ~ 9都属于第0页,它们除以10都等于0,而地址10 ~ 20都属于第2页,它们除以10都等于1。

  • 如何找到一个对象对应的span?

创建一个unordered_map<PAGE_ID,Span*>,使内存对象位于的页ID和桶的指针对应,从而找到挂载位置。

因为在page cache也需要用到这个哈希表,所以直接是现在page cache内,提供一个成员函数方便central cache获取。

class PageCache
{
    
    
public:
	// 获取从对象到span的映射
	Span* MapObjToSpan(void* obj);
private:
	std::unordered_map<PAGE_ID, Span*> _idSpan;
};
Span* PageCache::MapObjToSpan(void* obj)
{
    
    
	// 通过地址算页号
	PAGE_ID id = (PAGE_ID)obj >> PAGE_SHIFT;
	if (_idSpan.count(id))
	{
    
    
		return _idSpan[id];
	}
	else
	{
    
    
		assert(false);
		return nullptr;
	}
}
  • 何时记录id和span的映射关系?

每当page cache分配span给central cache时,也就是在NewSpan中切分返回之前,都需要记录一下页号和span之间的映射关系。此后当thread cache还对象给central cache时,才知道应该具体还给哪一个span。一共有两种情况:一种是直接在k页的桶找到了,另一种查后边的桶找到了,都要建立映射关系。

// 获取k页Span
Span* PageCache::NewSpan(size_t k)
{
    
    
	assert(k > 0 && k < NPAGES);
	// 直接找k页的桶
	if (!_spanLists[k].empty())
	{
    
    
		Span* kspan = _spanLists[k].pop_front();
		// 建立id 与 span的映射关系
		for (PAGE_ID i = 0; i < kspan->_n; i++)
		{
    
    
			_idSpan[kspan->_pageId + i] = kspan;
		}
		return kspan;
	}
	// 第k个桶是空的,检查后边的桶
	for (size_t i = k + 1; i < NPAGES; i++)
	{
    
    
		if (!_spanLists[i].empty())
		{
    
    
			Span* nspan = _spanLists[i].pop_front();
			// 切分成k页和n - k页
			Span* kspan = new Span;
			// 从nspan的头部切一个k页span
			kspan->_pageId = nspan->_pageId;// 页号
			kspan->_n = k;// 页数
			nspan->_pageId += k;
			nspan->_n -= k;
			// 把剩下的页再挂起来
			_spanLists[nspan->_n].push_front(nspan);
			// 建立id 与 span的映射关系
			for (PAGE_ID i = 0; i < kspan->_n; i++)
			{
    
    
				_idSpan[kspan->_pageId + i] = kspan;
			}
			return kspan;
		}
	}
	// 后面也没有span
	// 向堆要128页span
	Span* spanfromheap = new Span;
	void* ptr = SystemAlloc(NPAGES - 1);
	// 地址转页号
	spanfromheap->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
	spanfromheap->_n = NPAGES - 1;
	_spanLists[NPAGES - 1].push_front(spanfromheap);
	// 有了大块span后递归调用自己
	return NewSpan(k);
}
  • central cache回收内存

thread cache还回来的对象按照哈希表的映射找到对应的span,然后把这些对象挂到span的自由链表中,并且把span的_useCnt–,当减为0时,就说明这块span的对象全部归还回来了,可以还给page cache了。

// 将一定数量从thread cache回收的对象挂到对应的span
void CentralCache::ReleaseListToSpans(void* start, size_t size)
{
    
    
	size_t idx = SizeRule::Index(size);
	// 桶锁
	_spanList[idx].lock();
	while (start)
	{
    
    
		void* next = NextObj(start);
		// 根据地址算出页号,找到对应的span
		Span* span = PageCache::GetSingleton().MapObjToSpan(start);
		// 头插入span的_freeList中
		NextObj(start) = span->_freeList;
		span->_freeList = start;

		span->_useCnt--;
		// span切出去的对象全部换回来了
		// 说明这个span可以还给page cache了
		if (span->_useCnt == 0)
		{
    
    
			_spanList[idx].erase(span);
			// 只需要span管理的这些内存回来了即可
			// _pageId和_n不能变
			span->_freeList = nullptr;
			span->_next = span->_prev = nullptr;
			// 解桶锁,加大锁
			_spanList[idx].unlock();
			PageCache::GetSingleton().lock();
			// 归还span到page cache
			PageCache::GetSingleton().ReleaseSpanToPageCache(span);
			PageCache::GetSingleton().unlock();
			_spanList[idx].lock();
		}
		start = next;
	}
	_spanList[idx].unlock();
}

如果要把某个span还给page cache,我们需要先将这个span从central cache对应的双链表中移除,然后再将该span的自由链表置空,因为page cache中的span是不需要切分成一个个的小对象的,以及该span的前后指针也都应该置空,因为之后要将其插入到page cache对应的双链表中。但span当中记录的起始页号以及它管理的页数是不能清除的,否则对应内存块就找不到了。

这里既然反正都是要置空的,那么为什么还要把对象先挂到span,在置空呢?

因为central chche要做到均衡调度,有可能挂上去的对象其他线程会申请。

4.3 page cache回收内存

当一个span的_useCnt减为0时,就说明这块span的对象全部归还回来了,可以还给page cache了。

这里还回来的span还需要进行前后页合并操作

  • 为什么要前后页合并?

因为还回来的span是不连续的,而可能这些span是可以合并成连续的,把这些span合并起来就可以缓解外部碎片问题

  • 如何进行前后页合并?

合并既要向前去合并也要向后去合并。假设换回来的span的起始页号是num那么我们就判断num - 1页对应的span是否空闲,如果空闲就合并,然后继续向前延伸,直到不能合并为止。向后的话想要找到下一个span的起始页号得num +_n才能找到。

通过页号找到span的过程在上面的_idSpan已经实现了。

  • 怎么知道span是否空闲?

在合并时我们只能合并挂在page cache的span,因为挂在central cache的span当中的对象正在被其他线程使用。
可是我们不能通过span结构当中的_useCount成员,来判断某个span到底是在central cache还是在page cache。因为当central cache刚向page cache申请到一个span时,这个span的_useCount就是等于0的,这时可能当我们正在对该span进行切分的时候(_useCnt还没变化),page cache就把这个span拿去进行合并了。
为了解决这个问题,可以给span增加一个参数_isUse,标记这个span是否正在被使用。

// 管理多个页大块内存
struct Span
{
    
    
	PAGE_ID _pageId = 0;// 大块内存起始页号
	size_t _n = 0;// 页数

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

	size_t _useCnt = 0;// 分配给thread cache的计数
	void* _freeList = nullptr;// 切好小内存的链表

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

只要当前span被分给了central cache,就要让 _isUse = true
在这里插入图片描述
还回来的时候就 _isUse = false,但是不是一换回来就直接修改,先要把前后页尝试合并完了挂到page cache的时候再变。

这里还有一个问题,可能会找不到page cache中的span。
观察上面的获取k页span的函数:

// 获取k页Span
Span* PageCache::NewSpan(size_t k)
{
    
    
	assert(k > 0 && k < NPAGES);
	// 直接找k页的桶
	if (!_spanLists[k].empty())
	{
    
    
		Span* kspan = _spanLists[k].pop_front();
		// 建立id 与 span的映射关系
		for (PAGE_ID i = 0; i < kspan->_n; i++)
		{
    
    
			_idSpan[kspan->_pageId + i] = kspan;
		}
		return kspan;
	}
	// 第k个桶是空的,检查后边的桶
	for (size_t i = k + 1; i < NPAGES; i++)
	{
    
    
		if (!_spanLists[i].empty())
		{
    
    
			Span* nspan = _spanLists[i].pop_front();
			// 切分成k页和n - k页
			Span* kspan = new Span;
			// 从nspan的头部切一个k页span
			kspan->_pageId = nspan->_pageId;// 页号
			kspan->_n = k;// 页数
			nspan->_pageId += k;
			nspan->_n -= k;
			// 把剩下的页再挂起来
			_spanLists[nspan->_n].push_front(nspan);
			// 建立id 与 span的映射关系
			for (PAGE_ID i = 0; i < kspan->_n; i++)
			{
    
    
				_idSpan[kspan->_pageId + i] = kspan;
			}
			return kspan;
		}
	}
	// 后面也没有span
	// 向堆要128页span
	Span* spanfromheap = new Span;
	void* ptr = SystemAlloc(NPAGES - 1);
	// 地址转页号
	spanfromheap->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
	spanfromheap->_n = NPAGES - 1;
	_spanLists[NPAGES - 1].push_front(spanfromheap);
	// 有了大块span后递归调用自己
	return NewSpan(k);
}

这里把给central cache的每个页号和span都建立了映射关系,但是留在page cache的nspan却没有映射关系。

与central cache中的span不同的是,在page cache中,只需建立一个span的首尾页号与该span之间的映射关系。因为当一个span在尝试进行合并时,如果是往前合并,那么只需要通过一个span的尾页找到这个span,如果是向后合并,那么只需要通过一个span的首页找到这个span。也就是说,在进行合并时我们只需要用到span与其首尾页之间的映射关系就够了。

在这里插入图片描述

  • 合并过程
// central cache归还span给page cache
void PageCache::ReleaseSpanToPageCache(Span* span)
{
    
    
	// 对前后页尝试合并
	// 向前合并
	while (true)
	{
    
    
		PAGE_ID preId = span->_pageId - 1;
		// 无前面的页号
		if (!_idSpan.count(preId))
		{
    
    
			break;
		}
		Span* preSpan = _idSpan[preId];
		// 前面的页号被使用
		if (preSpan->_isUse == true)
		{
    
    
			break;
		}
		// span超过了128页
		if (preSpan->_n + span->_n > NPAGES - 1)
		{
    
    
			break;
		}
		// 合并
		span->_pageId = preSpan->_pageId;
		span->_n += preSpan->_n;
		// 将preSpan从对应的双向链表删除
		_spanLists[preSpan->_n].erase(preSpan);
		// 不是释放自由链表,而是删除申请的preSpan的结构体
		delete preSpan;
	}
	// 向后合并
	while (true)
	{
    
    
		PAGE_ID nextId = span->_pageId + span->_n;
		// 无后边的页号
		if (!_idSpan.count(nextId))
		{
    
    
			break;
		}
		Span* nextSpan = _idSpan[nextId];
		// 后面的页号被使用
		if (nextSpan->_isUse == true)
		{
    
    
			break;
		}
		// span超过了128页
		if (nextSpan->_n + span->_n > NPAGES - 1)
		{
    
    
			break;
		}
		// 合并
		// 起始页号不用变
		span->_n += nextSpan->_n;
		// 将nextSpan从对应的双向链表删除
		_spanLists[nextSpan->_n].erase(nextSpan);
		delete nextSpan;
	}

	// 把span挂起来
	_spanLists[span->_n].push_front(span);
	span->_isUse = false;
	// 把首尾id和span关联起来,方便其他的span合并
	_idSpan[span->_pageId] = span;
	_idSpan[span->_pageId + span->_n - 1] = span;
}

一些细节:

  • 什么时候停止合并?

1️⃣ 如果没有通过页号获取到其对应的span,说明对应到该页的内存块还未申请,此时需要停止合并。
2️⃣ span处于被使用的状态,必须停止合并。
3️⃣ 如果合并后大于128页则不能进行本次合并,因为page cache无法对大于128页的span进行管理。

  • 为什么delete找到的span?

这里的delete并不是清理掉span挂的内存,而是把span这个结构体清理掉,因为span是new出来的,把它从双向链表取下来就可以delete掉了。

在合并结束后,除了将合并后的span挂到page cache对应哈希桶的双链表当中,还需要建立该span与其首位页之间的映射关系,便于此后合并出更大的span。

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

之前我们讲的每个线程最大申请的内存是256kb,直接走三层缓存。那么如果申请的大于256kb怎么办呢?

如果线程单次申请对象大于等于32page,小于等于128page,即256KB <= 申请大小 <= 1024KB时,可以直接向page cache要内存,因为page cache最大可以管理128页的内存块。
如果线程单次申请对象大于128page,即大于1024KB时。线程将直接向堆区要内存。

这里首先要修改一下向上内存对齐的函数,要考虑大于256kb的情况:

static inline size_t RoundUp(size_t bytes)
{
    
    
	if (bytes <= 128)
	{
    
    
		return _RoundUp(bytes, 8);
	}
	else if (bytes <= 1024)
	{
    
    
		return _RoundUp(bytes, 16);
	}
	else if (bytes <= 8 * 1024)
	{
    
    
		return _RoundUp(bytes, 128);
	}
	else if (bytes <= 64 * 1024)
	{
    
    
		return _RoundUp(bytes, 1024);
	}
	else if (bytes <= 256 * 1024)
	{
    
    
		return _RoundUp(bytes, 8 * 1024);
	}
	else
	{
    
    
		// 以页为单位进行对齐
		return _RoundUp(bytes, 1 << PAGE_SHIFT/*8k*/);
	}
	return -1;
}

申请内存的逻辑也要加上大于256kb的情况:

// 申请
static void* ConcurrentAlloc(size_t size)
{
    
    
	if (size > MAX_BYTES)// 大于256kb
	{
    
    
		size_t alignSize = SizeRule::RoundUp(size);
		// 页数
		size_t kPage = alignSize >> PAGE_SHIFT;
		PageCache::GetSingleton().lock();
		Span* span = PageCache::GetSingleton().NewSpan(kPage);
		PageCache::GetSingleton().unlock();
		// 获得地址
		void* addr = (void*)(span->_pageId << PAGE_SHIFT);
		return addr;
	}
	else
	{
    
    
		// 每个线程无锁获取ThreadCache对象
		if (pTLSThreadCache == nullptr)
		{
    
    
			pTLSThreadCache = new ThreadCache;
		}
		return pTLSThreadCache->Allocate(size);
	}
}

这个kPage可能大于128页,所以NewSpan函数也需要修改:

// 获取k页Span
Span* PageCache::NewSpan(size_t k)
{
    
    
	assert(k > 0);
	// 大于128页
	if (k > NPAGES - 1)
	{
    
    
		// 直接向堆区获取内存
		void* addr = SystemAlloc(k);
		Span* span = new Span;
		span->_pageId = (PAGE_ID)addr >> PAGE_SHIFT;
		span->_n = k;
		// 建立映射关系
		_idSpan[span->_pageId] = span;
		return span;
	}
	else
	{
    
    
		// 直接找k页的桶
		if (!_spanLists[k].empty())
		{
    
    
			Span* kspan = _spanLists[k].pop_front();
			// 建立id 与 span的映射关系
			for (PAGE_ID i = 0; i < kspan->_n; i++)
			{
    
    
				_idSpan[kspan->_pageId + i] = kspan;
			}
			return kspan;
		}
		// 第k个桶是空的,检查后边的桶
		for (size_t i = k + 1; i < NPAGES; i++)
		{
    
    
			if (!_spanLists[i].empty())
			{
    
    
				Span* nspan = _spanLists[i].pop_front();
				// 切分成k页和n - k页
				Span* kspan = new Span;
				// 从nspan的头部切一个k页span
				kspan->_pageId = nspan->_pageId;// 页号
				kspan->_n = k;// 页数
				nspan->_pageId += k;
				nspan->_n -= k;
				// 把剩下的页再挂起来
				_spanLists[nspan->_n].push_front(nspan);
				// 存储nspan的首尾页号与span的映射关系,方便page cache合并查找
				_idSpan[nspan->_pageId] = nspan;
				_idSpan[nspan->_pageId + nspan->_n - 1] = nspan;
				// 建立id 与 span的映射关系
				for (PAGE_ID i = 0; i < kspan->_n; i++)
				{
    
    
					_idSpan[kspan->_pageId + i] = kspan;
				}
				return kspan;
			}
		}
		// 后面也没有span
		// 向堆要128页span
		Span* spanfromheap = new Span;
		void* ptr = SystemAlloc(NPAGES - 1);
		// 地址转页号
		spanfromheap->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
		spanfromheap->_n = NPAGES - 1;
		_spanLists[NPAGES - 1].push_front(spanfromheap);
		// 有了大块span后递归调用自己
		return NewSpan(k);
	}
}
  • 为什么要映射_pageId与span呢?

因为释放的时候只会给内存的起始地址,需要通过_idSpan找到对应的span。
这样就可以统一的方式释放内存(把这块内存释放给page cache挂起来)。

  • 释放流程

释放之前要先判断对象的大小

释放内存大小 释放给谁
x ≤ 256KB(32页) 释放给thread cache
32页 < x ≤ 128页 释放给page cache
x≥128页 释放给堆
// 释放
static void ConcurrentFree(void* ptr, size_t size)
{
    
    
	// 大于32页
	if (size > MAX_BYTES)
	{
    
    
		// 通过映射关系找到span
		Span* span = PageCache::GetSingleton().MapObjToSpan(ptr);

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

这里的span的页数如果小于等于128页,那么直接可以在page cache合并并挂起来。而如果大于了128页,这块内存就要释放给堆了。

直接向堆申请内存时我们调用的接口是VirtualAlloc,与之对应的将内存释放给堆的接口叫做VirtualFree,而Linux下的brk和mmap对应的释放接口叫做sbrk和unmmap。

// 直接将内存还给堆
inline static void SystemFree(void* ptr)
{
    
    
#ifdef _WIN32
	VirtualFree(ptr, 0, MEM_RELEASE);
#else
	//linux下sbrk unmmap等
#endif
}
// central cache归还span给page cache
void PageCache::ReleaseSpanToPageCache(Span* span)
{
    
    
	// 大于128页
	if (span->_n > NPAGES - 1)
	{
    
    
		void* addr = (void*)(span->_pageId << PAGE_SHIFT);
		SystemFree(addr);
		delete span;
	}
	else
	{
    
    
		// 对前后页尝试合并
		// 向前合并
		while (true)
		{
    
    
			PAGE_ID preId = span->_pageId - 1;
			// 无前面的页号
			if (!_idSpan.count(preId))
			{
    
    
				break;
			}
			Span* preSpan = _idSpan[preId];
			// 前面的页号被使用
			if (preSpan->_isUse == true)
			{
    
    
				break;
			}
			// span超过了128页
			if (preSpan->_n + span->_n > NPAGES - 1)
			{
    
    
				break;
			}
			// 合并
			span->_pageId = preSpan->_pageId;
			span->_n += preSpan->_n;
			// 将preSpan从对应的双向链表删除
			_spanLists[preSpan->_n].erase(preSpan);
			// 不是释放自由链表,而是删除申请的preSpan的结构体
			delete preSpan;
		}
		// 向后合并
		while (true)
		{
    
    
			PAGE_ID nextId = span->_pageId + span->_n;
			// 无后边的页号
			if (!_idSpan.count(nextId))
			{
    
    
				break;
			}
			Span* nextSpan = _idSpan[nextId];
			// 后面的页号被使用
			if (nextSpan->_isUse == true)
			{
    
    
				break;
			}
			// span超过了128页
			if (nextSpan->_n + span->_n > NPAGES - 1)
			{
    
    
				break;
			}
			// 合并
			// 起始页号不用变
			span->_n += nextSpan->_n;
			// 将nextSpan从对应的双向链表删除
			_spanLists[nextSpan->_n].erase(nextSpan);
			delete nextSpan;
		}

		// 把span挂起来
		_spanLists[span->_n].push_front(span);
		span->_isUse = false;
		// 把首尾id和span关联起来,方便其他的span合并
		_idSpan[span->_pageId] = span;
		_idSpan[span->_pageId + span->_n - 1] = span;
	}
}

六、用定长内存池替换malloc

这个项目的目的就是使用内存池来替换malloc,但是我们发现项目里面有很多的new,而new底层就是调用了malloc。
在这里插入图片描述
为了完全的脱离malloc,可以使用前面实现过的定长内存池。

通过观察代码可以发现使用new的地方基本都是申请span,所以我们可以在page cache中定义一个定长内存池。

class PageCache
{
    
    
private:
	FixedPool<Span> _spanPool;
};

接下来就可以把所有new的地方替换成定长内存池中的New。
所有delete的地方也可以替换成定长内存池中的Delete。

// Span* span = new Span;
Span* span = _spanPool.New();

// delete span;
_spanPool.Delete(span);

6.1 线程池的定义与线程安全

每个线程第一次申请内存时都会创建其专属的thread cache,而这个thread cache目前也是new出来的,我们也需要对其进行替换。

用于申请span的定长内存池定义在page cache中,因为page cache是全局唯一的,那么内存池也是全局唯一的,但是申请线程的内存池怎么保证全局唯一呢?

将用于申请ThreadCache类对象的定长内存池定义为静态的,保持全局只有一个。

static FixedPool<ThreadCache> _tcPool;

而为了保证线程安全还得定义一个全局静态锁:

// 每个线程无锁获取ThreadCache对象
if (pTLSThreadCache == nullptr)
{
    
    
	static std::mutex _tcMtx;
	static FixedPool<ThreadCache> _tcPool;
	_tcMtx.lock();
	//pTLSThreadCache = new ThreadCache;
	pTLSThreadCache = _tcPool.New();
	_tcMtx.unlock();
}

七、释放内存的优化参数

在这里插入图片描述
在释放内存的时候我们发现每次都要传递对象的大小,那么有没有办法不传递对象的大小呢?
首先要知道为什么要传递大小:

如果释放的是大于256KB的对象,根据对象的大小来判断内存到底应该还给page cache,还是应该直接还给堆。
如果释放的是小于等于256KB的对象,根据对象的大小计算出应该还给thread cache的哪一个哈希桶。

  • 不传递对象大小的方法

方法一:

我们通过传进来的对象的地址就可以算出页号: (PAGE_ID)(addr >> PAGE_SHIFT),而前面我们创建了一个unordered_map<PAGE_ID,Span*>的映射关系,现在我们可以再创建一个unordered_map<PAGE_ID, size_t>的映射关系,就是通过页号获取对象的大小

还是定义在page cache中:
在这里插入图片描述
方法二:

还是通过页号找到span,既然能够找到span,那么我们何不在Span结构中再增加一个_objSize成员,该成员代表着这个span管理的内存块被切好的一个个小对象的大小

在这里插入图片描述

  • span什么时候进行切分?

无空闲的span的时候找page cache要一块然后开始切分。

在这里插入图片描述

当然如果span大于256kb的时候,就不会走三层缓存了,此时我们可以在申请的时候把
_objSize处理好。

在这里插入图片描述

  • 最终释放内存函数:
// 释放
static void ConcurrentFree(void* ptr)
{
    
    
	// 通过地址获取span
	Span* span = PageCache::GetSingleton().MapObjToSpan(ptr);
	size_t size = span->_objSize;
	// 大于32页
	if (size > MAX_BYTES)
	{
    
    
		// 通过映射关系找到span
		Span* span = PageCache::GetSingleton().MapObjToSpan(ptr);

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

八、读取映射关系时的加锁问题

什么时候会访问_idSpan呢?

首先在page cache会访问,而这个过程是线程安全的,因为访问page cache会加锁。
其次在调用ConcurrentFree函数的时候会使用哈希表找到对应的span,还有在central cache回收对象挂到span的时候也得通过哈希表找到span。

这里就会存在问题,因为可能我们读取哈希表的时后,有的线程正在page cache中修改哈希表。
所以读取的时候也得加锁保证线程安全。

  • 如何加锁?

实际就是在调用page cache对外提供访问映射关系的函数时需要加锁。直接使用page cache类的锁就行,因为函数属于这个类。

Span* PageCache::MapObjToSpan(void* obj)
{
    
    
	// 通过地址算页号
	PAGE_ID id = (PAGE_ID)obj >> PAGE_SHIFT;
	lock();
	if (_idSpan.count(id))
	{
    
    
		unlock();
		return _idSpan[id];
	}
	else
	{
    
    
		unlock();
		assert(false);
		return nullptr;
	}
}

当然可以使用std::unique_lock<std::mutex> lock(_pageMtx)更加方便,不用自己解锁。

九、性能瓶颈分析

在这里插入图片描述
可以看到此时的内存池在多线程环境下申请和释放内存的速度比不上库中的malloc和free。

9.1 VS性能分析工具

  • 我们可以依次点击“调试→性能探查器”进行性能分析。
    在这里插入图片描述
  • 选择"检测"后点击"开始"
    在这里插入图片描述
    而在deAllocate函数中,调用ListTooLong函数时消耗的时间是最多的。
    在这里插入图片描述
    在ListTooLong函数中,调用ReleaseListToSpans函数时消耗的时间是最多的。
    在这里插入图片描述
    在ReleaseListToSpans函数中,调用MapObjectToSpan函数时消耗的时间是最多的。
    在这里插入图片描述
    通过观察我们最终发现,调用该函数时会消耗这么多时间就是因为锁的原因。
    在这里插入图片描述
    通过Visual Studio自带的性能探查器分析可知,绝大部分的性能消耗来自于unordered_map<PAGE_ID, Span*> _idSpanMap的加锁、解锁、查找。

解决方法:
使用基数树来取代unordered_map优化性能。

十、使用基数树替代unordered_map以提高内存池性能

哈希表采用的方法是除模取余,哈希表的容器大小是可能会变大的。
而基数树则是直接定值法,先把所有可能的数值全部开好空间,直接找到位置。

// Single-level array
template <int BITS>//BITS:存储的页号需要多少位,32-PAGE_SHIFT例如2^32-2^13,BITS=32-13
class TCMalloc_PageMap1 
{
    
    
private:
	static const int LENGTH = 1 << BITS;//需要多少个值存储页号,直接定址法
	void** array_;
public:
  • 相关接口

此时当我们需要建立页号与span的映射时,就调用基数树当中的set函数。

_idSpanMap.set(span->_pageId, span)

而当我们需要读取某一页号对应的span时,就调用基数树当中的get函数。

Span* ret = (Span*)_idSpanMap.get(id)
  • 替换哈希表
class PageCache
{
    
    
public:
private:
	// std::unordered_map<PAGE_ID, Span*> _idSpan;
	TCMalloc_PageMap1<32 - PAGE_SHIFT> _idSpan;
};
  • 为什么基数树不需要加锁?

首先说说哈希表为什么要加锁:插入数据时其底层的结构可能会发生变化,而我们知道哈希表采取定址的方式是模容器大小,所以会出现线程安全问题。
而基数树是采用了一一映射的关系,4G的内存转换成的id都会一个专属的坑位用于存储,同一时间,最多仅有一个线程会对某个位置进行读操作或写操作, 也就是不会同时对同一个页进行读取映射和建立映射的操作。这样一来,所有的内存对象的增查互不影响,查找时就可以不用再加锁了。

在这里插入图片描述

十一、项目源码

Github:高并发内存池



猜你喜欢

转载自blog.csdn.net/qq_66314292/article/details/131501580