【项目】从零实现一个高并发内存池


目录

一、项目介绍

1、该项目的原型

2、该项目所涉及到的技术及博主往期参考文章

3、池化技术

4、内存池的内碎片和外碎片

二、先来看一个定长内存池设计

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

1、thread cache的实现

1.1thread cache整体框架

1.2哈希桶映射对齐规则

1.3Thread Local Storage(TLS)线程局部存储实现无锁访问

2、central cache的实现

2.1central cache的整体框架

2.2页的管理

2.3使用单例模式生成全局静态central cache对象(饿汉模式)

2.4使用慢开始反馈调节算法解决内存页的配给问题

2.5central cache如何取出内存对象?

3、page cache的实现

3.1page cache的整体框架

3.2central cache如何向pagecache申请内存?

3.3线程在central cache内存不足向page cache申请内存时加解锁情况

四、三层缓存的内存回收机制

1、线程缓存的回收机制

2、中央缓存的回收机制

2.1中央缓存回收线程缓存归还的内存对象

2.2中央缓存向页缓存归还内存页的机制

3、页缓存的回收机制

3.1页缓存尝试对前后的页进行合并,减少外碎片

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

六、使用定长内存池使该项目脱离new/delete

七、高并发内存池的性能瓶颈分析

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

1、两种不同的基数树

2、为什么使用基数树这种数据结构不需要进行加锁


项目代码链接:高并发内存池: 高并发内存池项目,该项目提取了谷歌开源项目tcmalloc核心部分,能够在多线程环境下,提高申请和释放内存的效率。 (gitee.com)

适配环境:Windows X86

一、项目介绍

1、该项目的原型

该项目的原型是Google的一个开源项目Thread-Caching Malloc,即线程缓存的malloc,简称tcmalloc。它是当时Google顶尖的C++高手们写出来的,知名度非常高,不少公司都有使用它作为性能优化,Go语言直接将它作为内存分配器。

本项目抽取了tcmalloc最核心的部分,从零搭建一个高并发内存池。在多线程环境下能更加高效地替代系统内存分配相关函数malloc、free等。

2、该项目所涉及到的技术及博主往期参考文章

语言:C/C++(含C++11)

【C++11】C++11常用特性详解

数据结构:单链表、带头双向循环链表、哈希桶

【数据结构】单链表的实现

【数据结构】带头双向循环链表的实现

【C++】哈希(unordered系列关联式容器)

操作系统:内存管理、多线程、互斥锁

【C语言】realloc、malloc、calloc

【C++】动态内存管理和泛型编程

【C++11】多线程

【Linux系统编程】多线程的创建、等待、终止

【Linux系统编程】多线程的互斥与同步

设计模式:单例模式

【C++】特殊类设计+单例模式

【Linux系统编程】基于单例模式懒汉实现方式的线程池

3、池化技术

        程序向操作系统每次申请资源的时候,都会有一定的开销,面对频繁申请和释放资源的场景,这个开销将会增大。采用池化技术可以减少这部分开销,池化技术就是程序预先向操作系统申请一块过量资源作为“蓄水池”,线程需要资源了就去这个内存池申请,可以解决小块资源因频繁申请释放所造成系统效率降低内存碎片问题。(其实库函数malloc的本质也是一个内存池。)

        在计算机中,有很多用到池化技术的地方,除了内存池,还有对象池、连接池、线程池等,以服务器上的线程池为例:先启动若干数量的线程,让它处于睡眠状态,当接收客户端的请求时,唤醒线程池中的某个线程,用以处理客户端的请求,当处理完这个请求时,该线程重新回到睡眠状态。

4、内存池的内碎片和外碎片

内碎片:需要的内存的大小小于内存池提供的内存块的大小,多余的空间被称为内碎片;

外碎片:向内存池申请和归还资源时,虽然内存池剩余资源大于某次需求的资源,但是由于内存空间不连续,导致空间申请失败,这种被称为外碎片。

二、先来看一个定长内存池设计

        设计一个定长的内存池,_memory指向内存池的起始地址,每次申请将获得一个T类型大小的资源块,同时_memory+=sizeof(T)进行地址移动用以标定下次申请资源的起始地址。

        归还资源时将每个被归还的资源头插进链表_freeList中,同时每个资源块的前4/8个字节作为指向下一个被归还资源块的指针。

T* New()接口:该接口用于资源块的申请

1、如果有归还的资源块,优先重复利用归还的资源块。需要注意的是定长资源块分配到最后的时候,可能不够一个资源块的大小了,这时候就放弃最后剩余的那一点空间,重新开辟一块新的内存池;

2、申请内存池的时候使用Windows的VirtualAlloc接口,脱离malloc直接去堆区按页申请空间;(Linux中相似接口有brk、mmap)

3、需要考虑到T类型大小可能比一个指针还要小的情况,这种情况需要将每个资源块的定长调整为一个指针大小;(起码得放的下一个指针吧,不然到时候怎么头插呀)

4、做完资源块的开空间工作,还需要显式调用T类型的构造函数,即定位new。(为什么不一开始就做好资源块的构造工作呢?因为我们是要在已申请的内存池中申请空间,所有构造数据得写入资源块中,如果空间还没分配好就调用构造,那么构造好的数据会写入其他地方!)

void Delete(T* obj)接口:该接口用于清理归还资源块的数据(注意是只清理,不是释放)

1、对于归还的资源块,首先调用T类型的析构函数进行资源清理,清理完毕后将资源块头插至链表_freeList上。

2、定长内存池不释放吗?不释放,内存池的意义就是为了频繁申请和释放资源的业务而生的,它的资源块是可以反复利用的,并不是内存泄漏!进程不停业务不停,进程寄了内存池资源自然也就释放了。

#pragma once
#include <iostream>
#include <exception>
#include <vector>
#include <ctime>

#ifdef _WIN32
	#include <Windows.h>
#else
	//包含linux下brk mmap等头文件
#endif
using std::cout;
using std::endl;
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32
	void* ptr = VirtualAlloc(0, kpage <<13, MEM_COMMIT | MEM_RESERVE,
		PAGE_READWRITE);
#else
	// linux下brk mmap等
#endif
	if (ptr == nullptr)
		throw std::bad_alloc();
	return ptr;
}
//template <size_t N>//N代表每个内存块的大小
//class ObjectPool//定长内存池
//{};
template <class T>//T代表内存对象,每个内存对象的大小是一样的,表示内存块的大小
class ObjectPool//定长内存池
{
public:
	T* New()//内存池单次资源申请
	{
		T* obj = nullptr;
		//申请资源时优先重复利用已归还的资源块
		if (_freeList != nullptr)
		{
			obj = (T*)_freeList;
			_freeList = *(void**)_freeList;
		}
		else
		{
			if (_remainBytes < sizeof(T))//当剩余内存小于一个T对象时,重新开辟一块新内存池
			{
				_remainBytes = 128 * 1024;
				_memory = (char*)SystemAlloc(_remainBytes>>13);//128KB换算成每页8kb,得16页
				if (nullptr == _memory)
				{
					throw std::bad_alloc();
				}
				
			}
			//内存池单次资源申请
			obj = (T*)_memory;
			size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
			_memory += objSize;
			_remainBytes -= objSize;
		}
		//使用定位new,显式调用T的构造函数初始化构造函数
		new(obj)T;
		return obj;
	}
	void Delete(T* obj)
	{
		obj->~T();//显式调用obj的析构函数,清理对象
		//归还资源块时,进行单链表的头插
		*(void**)obj = _freeList;//找到资源块的头4/8个字节,取决于32位还是64位机器
		_freeList = obj;
	}
private:
	char* _memory = nullptr;//内存池的起始地址
	size_t _remainBytes = 0;//内存池剩余内存
	void* _freeList = nullptr;//指向归还资源块的单链表头指针
};
struct TreeNode
{
	int val;
	TreeNode* _left;
	TreeNode* _right;
	TreeNode()
		:val(0)
		,_left(nullptr)
		,_right(nullptr)
	{}
};
void TestObjectPool()//测试代码
{
	//申请释放的轮次
	const size_t Rounds = 5;
	//每轮申请释放多少次
	const size_t N = 10000;
	std::vector<TreeNode*> v1;
	v1.reserve(N);
	size_t begin1 = clock();
	for (size_t j = 0; j < Rounds; ++j)
	{
		for (int i = 0; i < N; ++i)
		{
			v1.push_back(new TreeNode);
		}
		for (int i = 0; i < N; ++i)
		{
			delete v1[i];
		}
		v1.clear();
	}
	size_t end1 = clock();
	std::vector<TreeNode*> v2;
	v2.reserve(N);
	ObjectPool<TreeNode> TNPool;
	size_t begin2 = clock();
	for (size_t j = 0; j < Rounds; ++j)
	{
		for (int i = 0; i < N; ++i)
		{
			v2.push_back(TNPool.New());
		}
		for (int i = 0; i < N; ++i)
		{
			TNPool.Delete(v2[i]);
		}
		v2.clear();
	}
	size_t end2 = clock();
	cout << "new cost time:" << end1 - begin1 << endl;
	cout << "object pool cost time:" << end2 - begin2 << endl;
}

        测试代码对定长内存池和关键字new进行对比,通过批量性申请树节点,定长内存池的速度更胜一筹。定长内存池只是一个方向,本项目的实际设计思路请看下文:

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

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

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

高并发内存池主要由三个部分构成:

1、thread cache:线程缓存,用于小于256KB的内存使用分配。一个进程里面创建的每个线程,都会有自己独立的thread cache,线程从这里索要资源将不需要加锁,而传统的malloc会在多线程环境下对内存申请进行加锁操作,这就是高并发内存池高效的根本。

2、central cache:中央缓存,当线程缓存不够用了,线程就会去中央缓存申请对象资源,同时中央缓存会在合适的时机收回资源,防止其他线程申请时资源吃紧。中央缓存中存在资源竞争,线程在该处申请资源需要对其进行加锁保护,因为这里用的是哈希桶锁,两个线程申请资源时发生哈希冲突了才加锁,其次只有线程缓存用尽了才去中央缓存申请资源,所以中央缓存的竞争并不激烈。

3、page cache:页缓存,当中央缓存内存对象分配耗尽时,从页缓存以页为单位分配出一定数量的内存,并切割成定长大小的内存块,分配给中央缓存。当中央缓存的一个span的几个跨度页对象全部收回之后,页缓存会回收满足条件的span对象,并合并相邻的页,缓解内存碎片的问题。

如果页缓存也不够用,那么就会使用VirtualAlloc等接口去堆区申请资源。

1、thread cache的实现

1.1thread cache整体框架

        通过定长内存池的设计,可以发现定长内存池在定长情况下是优于malloc的,但是它只能应对一种长度的对象使用,那我们可以设计一个内存池,其中有多种定长可供需要选择。

        thread cache是哈希桶的结构,哈希表根据内存块对象的大小进行映射,每个哈希桶挂载当前哈希桶设定大小的内存块。每个线程都有一个thread cache对象,所以在这里申请资源是不需要加锁的。

        资源申请时,按照大于等于需求的空间提供资源块,例如我只要10byte,你只能给我16byte,没办法,多给的6字节将成为内碎片。资源块归还后,资源块将重新头插至对应的哈希桶中。

1.2哈希桶映射对齐规则

        thread cache共计256KB的空间,我们不可能把每一个定长的值都映射进哈希表,只能采取一定的规则,将一段范围的值映射进同一个桶。

        那么这个范围怎么定呢?总共256KB的空间,对齐间隔长了容易造成内碎片,短了会导致哈希桶过多。

        首先第一个桶一定是8字节,因为64位机器下的指针大小为8字节,内存块的大小起码容得下指针对吧!起始长度有了,那么对齐间隔应该怎么定?

整体控制在最多10%左右的内碎片浪费

[1,128]         8byte对齐         freelist[0,16)

[128+1,1024]         16byte对齐         freelist[16,72)

[1024+1,8*1024]         128byte对齐         freelist[72,128)

[8*1024+1,64*1024]         1024byte对齐         freelist[128,184)

[64*1024+1,256*1024]         8*1024byte对齐         freelist[184,208)

        解释:内存需求为129字节,按照上述规则,适配129字节的桶为128+16=144字节;内存池给我144字节的内存块,内碎片占比=(144-129)/144=10.4%。

        此时外部需求bytes个字节的资源块,通过以下代码输出该资源块向上对齐后的内存块的大小(注释子函数为正常人写法,高亮子函数为tcmalloc高手写法):

/*size_t _RoundUp(size_t size, size_t alignNum)
{
    size_t alignSize = 0;
    if (size % alignNum != 0)
    {
        alignSize = ((size / alignNum) + 1) * alignNum;
    }
    else
        alignSize = size;
    return alignSize;
}*/
static inline size_t _RoundUp(size_t bytes, size_t alignNum)//bytes:需要申请的字节;alignNum:对齐数
{
    return (bytes + alignNum - 1) & ~(alignNum - 1);
}
//用于返回申请内存对应的内存块
static inline size_t RoundUp(size_t bytes)
{
    if (bytes <= 128)
    {
        return _RoundUp(bytes, 128);
    }
    else if (bytes <= 1024)
    {
        return _RoundUp(bytes, 1024);
    }
    else if (bytes <= 8 * 1024)
    {
        return _RoundUp(bytes, 8*1024);
    }
    else if (bytes <= 64 * 1024)
    {
        return _RoundUp(bytes, 64*1024);
    }
    else if (bytes <= 256 * 1024)
    {
        return _RoundUp(bytes, 256*1024);
    }
    else
    {
        assert(false);
        return -1;
    }
}

通过以下代码找到该资源块向上对齐后的资源块位于哪一个桶

//static inline size_t _Index(size_t bytes,size_t alignNum)//bytes:需要申请的字节;alignNum:对齐数
//{
//	if (bytes % alignNum == 0)
//	{
//		return bytes / alignNum - 1;
//	}
//	else
//		return bytes / alignNum;
//}
static inline size_t _Index(size_t bytes,size_t align_shift)//bytes:需要申请的字节-上一个区间的大小;align_shift:对齐数的移位值
{
    return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1;
}
// 计算映射的哪一个自由链表桶
static inline size_t Index(size_t bytes)//bytes:需要申请的字节
{
    assert(bytes <= MAX_BYTES);
    // 每个区间有多少个链
    static int group_array[4] = { 16, 56, 56, 56 };
    if (bytes <= 128) {
        return _Index(bytes, 3);
    }
    else if (bytes <= 1024) {
        return _Index(bytes - 128, 4) + group_array[0];
    }
    else if (bytes <= 8 * 1024) {
        return _Index(bytes - 1024, 7) + group_array[1] + group_array[0];
    }
    else if (bytes <= 64 * 1024) {
        return _Index(bytes - 8 * 1024, 10) + group_array[2] + group_array[1]
            + group_array[0];
    }
    else if (bytes <= 256 * 1024) {
        return _Index(bytes - 64 * 1024, 13) + group_array[3] +
            group_array[2] + group_array[1] + group_array[0];
    }
    else {
        assert(false);
    }
    return -1;
}

1.3Thread Local Storage(TLS)线程局部存储实现无锁访问

class ThreadCache
{
public:
	//申请和释放对象
	void* Allocate(size_t size);
	void Deallocate(void* ptr, size_t size);
	// 从中央缓存获取对象
	void* FetchFromCentralCache(size_t index, size_t size);//index:计算位于哪一个桶;size:对齐之后的内存块大小
private:
	FreeList _freeList[NFREE_LIST];//挂载208个哈希桶的哈希表
};

//每个线程都有一份自己的pTLSThreadCache
static _declspec(thread)ThreadCache* pTLSThreadCache = nullptr;//pTLSThreadCache:指向Threadcache对象的指针

        此代码使用了 Microsoft Visual C++ 的 __declspec(thread) 关键字,用于指定变量为线程局部存储变量(Thread Local Storage,TLS)。这意味着每个线程都会拥有一个该变量的副本,且该变量只能被该线程访问和修改,不会被其他线程共享。每个线程都有自己的 ThreadCache 实例,因此可以避免线程间的竞争和同步问题,提高程序的并发性和性能。

        ThreadCache具体接口实现,注意某个线程的线程缓存不够了会去向中央缓存要内存。

void* ThreadCache::Allocate(size_t bytes)//申请对象
{
	assert(bytes <= MAX_BYTES);
	size_t alignNum = SizeClass::RoundUp(bytes);//对齐之后的内存块大小
	size_t index = SizeClass::Index(bytes);//计算位于哪一个桶
	if (!_freeList[index].Empty())//去对应的哈希桶中的自由链表中申请资源块
	{
		return _freeList[index].Pop();
	}
	else//如果对应的自由链表已经没有资源块了,那就要去中央缓存申请资源了
	{
		return FetchFromCentralCache(index, alignNum);//index:计算位于哪一个桶;size:对齐之后的内存块大小
	}
}
void ThreadCache::Deallocate(void* ptr, size_t bytes)//释放对象
{
	assert(ptr);
	assert(bytes <= MAX_BYTES);
	size_t index = SizeClass::Index(bytes);//算出位于几号桶
	//头插
	_freeList[index].Push(ptr);
}

使用如下两个封装接口实现外部传入需求字节,返回和销毁对应的资源块。

//加上static,防止该头文件被源文件重复包含导致函数重定义
static void* ConcurrentAlloc(size_t size)//线程通过该函数去各自的线程缓存申请内存块
{
	if (nullptr == pTLSThreadCache)//这里不用加锁,每个线程独有一份pTLSThreadCache
	{
		pTLSThreadCache = new ThreadCache;
	}
	//cout << std::this_thread::get_id() << ":" << pTLSThreadCache << endl;
	return pTLSThreadCache->Allocate(size);
}
static void ConcurrentFree(void* ptr,size_t size)//外部调用该函数,释放内存块
{
	assert(pTLSThreadCache);
	pTLSThreadCache->Deallocate(ptr, size);
}

2、central cache的实现

2.1central cache的整体框架

        entral cache和thread cache很像,也是一个按规则条件进行映射的哈希桶结构,它们都有208个哈希桶,内存块在thread cache和central cache中对应的桶的位置是一样的。不同的是:

1、该区域的哈希桶需要加锁保护,用的锁是桶锁(每一个哈希桶都会有一个锁,保证线程安全)

2、central cache的哈希桶需要设计为带头双向链表,因为Span收回了页后,需要还给下一层的page cache,需要满足任意位置的插入删除。

3、central cache需要设置为单例模式,而thread cache每个变量的独有

4、该区域的哈希桶上挂载的不是一个个切好的小块对象,而是一个个Span(以页为单位的大块内存,不同哈希桶的的Span所分配的页数会有所不同),Span下面挂载的才是真正被切好的小块对象。

5、thread cache从central cache中申请的资源块在被归还时是会头插至thread cache中,当某个线程频繁的向central cache申请资源块,释放时全部拿到自己“兜里”,这会导致线程缓存越来越大。所以就需要借出去的内存块全部释放后,thread cache需要按页归还当时申请的资源对象。central cache就可以收回这些内存给其他需要内存的线程,实现均衡调度。

6、那么central cache是怎么知道“借出去”的内存块thread cache用完被收回了呢?可以在类中新增一个成员变量,记录“借出去”多少内存对象,每收回一个则--,等于0即可发起回收。

7、同理,central cache内存不足时,会向下一层申请空间,下一层回收页后,将继续整合,减少内存碎片(外碎片)。

2.2页的管理

        我们可以定义一个类Span,用于管理多个连续页的大块内存,那么这些页得需要一个编号吧(类似地址),可以在类中定义一个_spanId的成员变量代表页编号。

        像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;
#elif
	//Linux等平台
#endif

//管理多个连续页的大块内存结构(带头双向循环链表的节点——页节点)
struct Span
{
	PAGE_ID _pageId = 0;//页号,类似地址。32位程序2^32,每一页8K,即2^32/2^13=2^19页;64位会有2^51页。
	size_t _n = 0;//页数
	//带头双向循环链表
	Span* _next = nullptr;//记录上一个页的地址
	Span* _prev = nullptr;//记录下一个页的地址
	size_t _useCount = 0;//已经“借给”Thread Cache的小块内存的计数
	void* _freeList = nullptr;//未被分配的切好的小块内存自由单链表,挂载在页下
};
//页的带头双向循环链表(带桶锁)
class SpanList
{
	SpanList()
	{
		_head = new Span;
		assert(_head);
		_head->_prev = _head;
		_head->_next = _head;
	}
	void Insert(Span* pos, Span* newSpan);//在pos位置之前插入
	void Earse(Span* pos);
private:
	Span* _head;//哨兵位
	std::mutex _mtx;//桶锁
};

2.3使用单例模式生成全局静态central cache对象(饿汉模式)

        在thread caceh中,我们使用TLS实现了每个线程独有的thread caceh,使其在该区域实现无锁访问。而central cache是需要使用桶锁保证线程安全的,那么必然需要让所有线程都能访问到同一份central cache,这不就是单例模式么,生成一份静态的全局对象,供所有线程进行访问。

//单例饿汉模式
class CentralCache
{
public:
	//使用偷家函数获取静态单例对象,加static是因为静态方法无需对象即可调用
	static CentralCache* GetInStance()
	{
		return &_sInst;
	}
private:
	SpanList _spanLists[NFREE_LIST];//208个桶
private:
	static CentralCache _sInst;//静态单例对象(不要在.h文件定义,因为源文件包含了该头文件即可看到单例对象)
	//构造函数私有+禁用拷贝构造和赋值
	CentralCache(const CentralCache&) = delete;
	CentralCache& operator=(const CentralCache&) = delete;
};
CentralCache CentralCache::_sInst;//单例对象的定义(在源文件定义)

2.4使用慢开始反馈调节算法解决内存页的配给问题

当某个thread cache向central cache提出内存申请的时候,有两个问题需要关注:

1、central cache每次应该给它多少页的内存?给多了会发生资源闲置;给少了线程申请频率变高,访问冲突概率增大,效率降低。

2、thread cache申请不同大小的内存对象,central cache的分配规则又是如何?例如thread cache需求8byte内存块和需求256KB内存块,central cache分配的页数肯定是不相同的。

        根据thread cache需要申请的内存对象大小来决定内存对象的分配个数,小对象多分(控制上限),大对象少分。

// thread cache一次从central cache获取多少个对象
static size_t NumMoveSize(size_t size)//size:单个对象的大小
{
    assert(size > 0);
    // [2, 512],一次批量移动多少个对象的(慢启动)上限值
    // 小对象一次批量上限低
    int num = MAX_BYTES / size;//256KB/size
    if (num < 2)//申请256KB的大对象,分配2个对象
        num = 2;
    if (num > 512)//小对象,num大于512,仅分配512个对象
        num = 512;
    return num;
}

        如果申请8字节大小的内存对象,那么按照上方代码逻辑,central cache每次都将分配出512个8字节的对象,如果thread cache仅需要用到几个,那会存在大量的资源空闲,所以需要新增以下逻辑,实现每种不同大小的内存对象的慢增长分配(_freeList类新增成员变量size_t MaxSize=1):

void* ThreadCache::FetchFromCentralCache(size_t index, size_t size)
{
	//慢开始调节算法
	size_t batchNum = std::min(_freeList[index].MaxSize(),SizeClass::NumMoveSize(size));
	if (batchNum == _freeList[index].MaxSize())
	{
		_freeList[index].MaxSize() += 1;
	}
	return nullptr;
}

2.5central cache如何取出内存对象?

这里就是一些基本的链表操作:

size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size)
{
	size_t index = SizeClass::Index(size); //计算线程申请的内存对象向上对齐后位于哪一个桶
	_spanLists[index]._mtx.lock();//线程进来先上锁
	Span* span = CentralCache::GetOneSpan(_spanLists[index], size);//获取非空的页
	assert(span);
	assert(span->_freeList);
	end = start;
	for (size_t i = 0; i < batchNum - 1; ++i)
	{
		end = NextObj(end);
	}
	span->_freeList= NextObj(end);
	NextObj(end) = nullptr;
	_spanLists[index]._mtx.unlock();
	return batchNum;
}

        但是需要取出的对象个数大于当前页中剩余的对象个数时,上面这样写代码就会越界/空指针解引用崩溃。如情况二,该种情况需要控制end不要走到空,当前页对象剩余多少拿走多少。

/**
  * @brief  从中央缓存获取一定数量的对象给thread cache
  * @param  start:申请对象的起始地址
  * @param  end:申请对象的结束地址
  * @param  batchNum:通过调节算法得出的中央缓存应该给线程缓存的对象个数
  * @param  size:线程缓存申请的单个对象大小
  * @retval 中央缓存实际给的对象个数,因为当前页的资源对象可能不足了。
  */
size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size)
{
	size_t index = SizeClass::Index(size); //计算线程申请的内存对象向上对齐后位于哪一个桶
	_spanLists[index]._mtx.lock();//线程进来先上锁
	Span* span = CentralCache::GetOneSpan(_spanLists[index], size);//获取非空的页
	//从该非空页中拿走对象交给threan cache
	assert(span);
	assert(span->_freeList);
	end = start;
	size_t i = 0;
	while (i < batchNum - 1 && NextObj(end) != nullptr)
	{
		++i;
		end = NextObj(end);
	}
	span->_freeList= NextObj(end);
	NextObj(end) = nullptr;
	_spanLists[index]._mtx.unlock();
	return i + 1;
}

3、page cache的实现

3.1page cache的整体框架

        page cache和thread cache、central cache还是有一定区别。page cache的哈希桶是以页为单位进行划分,这是一个直接定址法的映射,central cache需要多少页的缓存,就去page cache对应页大小的哈希桶去拿。page cache哈希桶中的页之间使用带头双向循环链表进行连接,并不会切成小块的对象。

page cache细节解答:

1、page cache的逻辑

假如central cache需要两页的对象,但是page cache两页的对象刚好用完了,这时会去找两页以上的哈希桶要page并进行拆分,将其中的两页内存交付给central cache,剩余内存会挂载到剩余内存所映射的哈希桶中。如果向上找,所有哈希桶均为空,这时page cache会向堆区要一个128页的大块内存进行分配。

2、page cache为什么也要设计成单例模式?

page cache和thread cache并不像thread cache一样,利用TLS使各线程独占一份thread cache,而是在全局独有一份供各线程加锁访问,所以需要设计成单例模式。(同样使用饿汉模式)

3、为什么最大的页定成128页?

这是因为central cache和thread cache最大的内存对象仅256KB,按照上层代码设计,一次可能申请2块这样的内存对象,共计512KB,那么128页按照32位平台下4字节一页计算所得刚好为512字节,完美匹配。(当然64位环境下128页的大小为1M,更加够了)

4、为什么page cache不能和central cache一样采用桶锁?却只能整体加锁呢?

可以用桶锁但效率反而降低。正如第一点所说,线程发现目标桶的对象没有了,会在page cache向上去找更大的内存对象,说白了就是向上遍历哪个桶不为空,用了桶锁在遍历时会出现某个线程频繁申请和释放锁的情况,拖慢了该线程遍历的速度。所以page cache需要整体加锁。

5、如何进行内存回收?

如果central cache中的span的useCount等于0,说明切分给thread cache的小块内存全都回来了,则central cache把这个span还给page cache,page cache通过页号,查看前后相邻的页是否空闲,如若空闲,则进行合并,解决内存碎片问题。

3.2central cache如何向pagecache申请内存?

1、线程申请内存的流程

        上文提及线程发现thread cache内存不足时,会使用慢开始反馈调节算法向central cache申请内存。如果central cache对应的哈希桶中的内存对象为空,将会向page cache申请内存。page cache分配内存的方式与慢开始反馈算法有一定联系,例如线程缓存申请8字节的对象,通过慢开始反馈调节算法获取应该分配的对象个数2个,再乘以8字节得16字节,再右移13位(相当于除8KB),最后不满一页按一页算。

/**
  * @brief  central cache向page cache申请内存,page cache分配内存算法
  * @param  size:单个对象的大小
  * @retval 返回申请的页数
  */
static size_t NumMovePage(size_t size)
{
    size_t num = NumMoveSize(size);//返回线程缓存单次从中央缓存获取多少个对象
    size_t npage = num * size;
    npage >>= PAGE_SHIFT;//右移13位,相当于除等8KB
    if (npage == 0)
        npage = 1;
    return npage;
}

2、切分内存

1、根据页号回推页的起始地址虚拟地址

2、获取到的页切分成小块对象,并将对象尾插进central cache的对应自由链表中

        插入时需要保证插完后一个个小对象的内存仍旧是连续的,这样可以让thread cache在申请内存使用时,保证内存连续性,提高内存使用率。插入方法:先取一块做头,从左往右一次进行尾插即可。

3、从page cache获取k页的span

看到上面那张截图的NewSpan了吗?它就是用来获取K页span的方法。

        想要从page cache获取3页的对象,可以直接去找3page要一块,头删拿走即可。如果想要一块2page的对象,但图上2page这个哈希桶已经没有对象了,那就需要去3page拿一块内存,其中2page给线程,剩余的1page挂到1page的哈希桶上。如果一直往后找,所有哈希桶上都没有内存了,那么page cache将会去堆上申请128页的内存,递归调用一次自己,完成128页对象的切分、挂起逻辑。

/**
  * @brief  从page cache获取k页的span
  * @param  k:页数
  * @retval 返回获取到的span的地址
  */
Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0 && k < NPAGES);
	//先看一下映射桶有没有对象,有的话直接拿走
	if (!_spanLists[k].Empty())
	{
		return _spanLists->PopFront();
	}
	//检查一下后面的哈希桶里有没有span
	for (auto i = k+1; i < NPAGES; ++i)
	{
		if (!_spanLists[i].Empty())
		{
			//拿出来切分,切分成k页和i-k页,k页的span给central cache,i-k页的span挂到i-k的哈希桶
			Span* nSpan = _spanLists[i].PopFront();
			Span* kSpan = new Span;
			//对nSpan进行头切k页
			kSpan->_pageId = nSpan->_pageId;
			kSpan->_n = k;
			nSpan->_pageId += k;
			nSpan->_n -= k;
			//将剩余对象头插进哈希桶
			_spanLists[nSpan->_n].PushFront(nSpan);
			return kSpan;
		}
	}
	//找遍了还是没有,这时,page cache就要找堆要一块128页的span
	Span* bigSpan = new Span;
	void* ptr = SystemAlloc(NPAGES - 1);
	bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
	bigSpan->_n = NPAGES - 1;
	//将bigSpan插入到对应的桶中
	_spanLists[bigSpan->_n].PushFront(bigSpan);
	return PageCache::NewSpan(k);//递归调用自己,让下一个栈帧处理切分、挂起逻辑
}

3.3线程在central cache内存不足向page cache申请内存时加解锁情况

四、三层缓存的内存回收机制

1、线程缓存的回收机制

        当线程缓存中的哈希桶中的链表长度大于一次批量申请的内存时,就开始执行回收。(之前说过,线程缓存一次批量申请的内存会被慢开始反馈调节算法所增大,随着该桶向中央缓存申请的内存次数的增多,下一次回收链表的长度就会越长)

2、中央缓存的回收机制

2.1中央缓存回收线程缓存归还的内存对象

        线程缓存回收的一个个内存对象的地址可不是连续的,它们的地址可能位于中央缓存同一个哈希桶的不同span中,那么应该如何将一个个内存正确的挂载到合适的span中呢?创建一个unordered_map<PAGE_ID,Span*>,使内存对象位于的页ID和桶的指针对应,从而找到挂载位置。看下图的例子:

        这时可能有朋友会问了,线程缓存归还对象,直接遍历一遍所归还的对象所属的页ID,再遍历一遍中央缓存对应的哈希桶的_sapnLists,找到内存对象对应的span不就行了吗。这种思想确实简单。但是时间复杂度O(n^2),有点感人,所以就新增了一个unordered_map容器,用于建立对象与span*的映射关系。unordered_map的查找时间复杂度O(1),遍历内存对象的时间复杂度是O(N),所以这里使用unordered_map容器,使时间复杂度降为O(N)。

2.2中央缓存向页缓存归还内存页的机制

        当中央缓存的_useCount(内存对象借给线程缓存的数量)等于0的时候,说明当前span中所有借给线程缓存的内存对象都回来了,这时就要调用页缓存的ReleaseSpanToPageCache函数进行页的回收。下图代码为本节2.1和2.2所描述的代码实现:

3、页缓存的回收机制

3.1页缓存尝试对前后的页进行合并,减少外碎片

        页缓存当初分配给中央缓存的大块内存,由中央缓存切分成小块后被线程缓存所申请,这样内存块的地址起始已经乱了,当页缓存重新收到这些一块块较小的内存块,就需要尝试对前后的页进行合并,以减少外碎片的问题。

        只有位于页缓存内的内存对象才能发起合并,那么应该怎么区分某个span目前位于页缓存呢?Span类中有一个_useCount变量,不要因为这个变量为0说明内存就位于页缓存,因为中央缓存可能刚从页缓存申请到大块内存,还没切好进行分配,_useCount等于0,此时如果页缓存发起回收,就会出现问题。正确的解决方法是在Span类中新增一个bool _isUse=false;成员变量,如果span被分配给了中央缓存,该成员变量将修改为true。若该变量为false,证明其处于页缓存,可以对其进行合并。

        例如span的页id为2000,当前span内有连续6页内存,那么就需要继续寻找相邻的页是否存在,如图中的两个例子:

        合并时以基准span为中心,向其两边判断周边的span的id是否处于可被回收的状态,循环这个步骤不断向两边合并,直到span两边的id均处于不可合并状态或者span达到最大128页亦或者找不到哈希map中的id和span对应关系(说明该span的id,操作系统并没有分配给page cache,不属于操作系统分配给内存池的内存,自然在哈希中没有对应关系了)。

/**
  * @brief  页缓存回收中央缓存的span,并合并相邻的span
  * @param  span:span的地址
  * @retval 无返回值
  */
void PageCache::ReleaseSpanToPageCache(Span* span)
{
	//对span前后的页,尝试进行合并
	//向前合并
	while (1)
	{
		PAGE_ID prevId = span->_pageId - 1;//先判断一下前一个span是否可以被回收
		auto ret = _idSpanMap.find(prevId);
		if (ret == _idSpanMap.end())//说明ret位置的span不可被回收
		{
			break;
		}
		Span* prevSpan = ret->second;
		if (prevSpan->_isUse == true)//说明该span正在中央缓存,无法回收
		{
			break;
		}
		if (prevSpan->_n + span->_n > NPAGES-1)//超过108页
		{
			break;
		}
		//进行合并
		span->_pageId = prevSpan->_pageId;
		span->_n += prevSpan->_n;
		_spanLists[prevSpan->_n].Erase(prevSpan);
		delete prevSpan;
	}
	//向后合并
	while (1)
	{
		PAGE_ID nextId = span->_pageId +span->_n;//先判断一下前一个span是否可以被回收
		auto ret = _idSpanMap.find(nextId);
		if (ret == _idSpanMap.end())//说明ret位置的span不可被回收
		{
			break;
		}
		Span* nextSpan = ret->second;
		if (nextSpan->_isUse == true)//说明该span正在中央缓存,无法回收
		{
			break;
		}
		if (nextSpan->_n + span->_n > NPAGES - 1)//超过108页
		{
			break;
		}
		//进行合并
		span->_n += nextSpan->_n;
		_spanLists[nextSpan->_n].Erase(nextSpan);
		delete nextSpan;
	} 
	//走到这里,将span头插至对应页缓存中的哈希桶中
	_spanLists[span->_n].PushFront(span);//
	span->_isUse = false;//确保回收的大块内存可回收
	_idSpanMap[span->_pageId] = span;//建立映射
	_idSpanMap[span->_pageId+span->_n-1] = span;//建立映射
}

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

        如果线程单次申请小于256K的缓存,得走正常流程,也就是向三层缓存要内存;

        如果线程单次申请对象大于等于32page,小于等于128page,即256KB<=申请大小<=1024KB时,可以直接向page cache要内存;

        如果线程单次申请对象大于128page,即大于1024KB时。线程将直接向堆区要内存。

六、使用定长内存池使该项目脱离new/delete

        pageCache.cpp多处位置使用了new和delete。本文在第二小节已经写了一个定长内存池的设计,而本项目new和delete均是以span为单位的,刚好契合了定长内存池的设计。同时本文第二节已证明定长内存池申请空间的速度远快于new的速度,所以此处使用定长内存池的申请和释放内存的接口非常合适。

        另外使用STL的unordered_map,在建立映射关系时,内部会用到new,后文使用基数树脱离unordered_map。

七、高并发内存池的性能瓶颈分析

        可以看到此时的内存池在多线程环境下申请和释放内存的速度比不上库中的malloc和free。通过Visual Studio自带的性能探查器分析可知,绝大部分的性能消耗来自于unordered_map<PAGE_ID, Span*> _idSpanMap的加锁、解锁、查找。

故适用基数树来取代unordered_map优化性能。

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

1、两种不同的基数树

使用一级、二级基数树优化性能:

        第一级采用直接定址法,在32位环境下,将所有的id与span*建立一一对应的映射关系。需要开辟的空间=2^(19)*4字节=2M。但是64位环境下就不合适了,因为64位下需要开辟的空间=2^(51)*8=4TB。

        第二级占用的空间和第一级基数树一模一样,同样不适合64位机器。当一个id过来了,这个int类型的id的低19位是有效id(高位全是0),这19位中的高5位决定这个id将处于第一层的哪一位,这19位中的低14位将决定其位于第二层的哪一位。对于每一个id,都有一个独有的映射位置。项目中,一级和二级基数树任选一颗使用即可。

2、为什么使用基数树这种数据结构不需要进行加锁

        先说一下unordered_map为什么需要加锁吧!本项目的unordered_map用于建立PAGE_ID与Span*的映射关系,该哈希用于查找和增加映射关系。

其中查找用于:

1、释放对象时需要查找映射关系。(ConcurrentFree)

2、线程缓存归还的内存对象可能不处于中央缓存的同一个哈希桶中,每个归还的内存对象都需要调用查找确认其应当归还至中央缓存的哪一个哈希桶。(ReleaseListToSpans)

其中增加映射关系用于:(从页缓存出去的内存都会被建立映射关系)

1、下级缓存向页缓存申请的所有对象,都要在unordered_map中一一建立映射关系(NewSpan)

2、页缓存回收中央缓存的span,并合并相邻的span需要建立映射(ReleaseSpanToPageCache)

        虽然unordered_map查找效率O(1),但是多线程环境下为了避免插入的影响,查找时必须加锁。为了保证线程安全,查找的时候是绝对不能增加映射关系的,因为哈希存在扩容,亦或是冲突时新增的链表节点会影响查找的准确性。而基数树是采用了一一映射的关系,4G的内存转换成的id都会一个专属的坑位用于存储,同一时间,最多仅有一个线程会对某个位置进行读操作或写操作。这样一来,所有的内存对象的增查互不影响,查找时就可以不用再加锁了。

猜你喜欢

转载自blog.csdn.net/gfdxx/article/details/131116337