Bootstrap

高并发内存池项目

文章目录

tcmalloc源代码

1.什么是内存池

1.1池化技术

所谓“池化技术”,就是程序先向系统申请过量的资源,然后自己管理,以备不时之需。之所以要申请过量的资源,是因为每次申请该资源都有较大的开销,不如提前申请好了,这样使用时就会变得非常快捷,大大提高程序运行效率。

在计算机中,有很多使用“池”这种技术的地方,除了内存池,还有连接池、线程池、对象池等。以服务器上的线程池为例,它的主要思想是:先启动若干数量的线程,让它们处于睡眠状态,当接收到客户端的请求时,唤醒池中某个睡眠的线程,让它来处理客户端的请求,当处理完这个请求,线程又进入睡眠状态。

1.2内存池

内存池是指程序预先从操作系统申请一块足够大内存,此后,当程序中需要申请内存的时候,不是直接向操作系统申请,而是直接从内存池中获取;

同理,当程序释放内存的时候,并不真正将内存返回给操作系统,而是返回内存池。当程序退出(或者特定时间)时,内存池才将之前申请的内存真正释放。

1.3内存池主要解决的问题

内存池解决的问题主要是两个:

  1. 效率
  2. 内存碎片
  • 效率

每次申请资源向操作系统进行系统调用,进行内核态用户态的切换等,是需要时间的。因此批量申请足够大的内存再进行分配就可以减少这部分的时间消耗。

image-20220221202604441

  • 内存碎片

内存碎片分为外碎片和内碎片。由于我们实现的时候如果按照每个字节为单位用自由链表进行空闲内存块的管理会导致自由链表数量过大和繁琐。因此我们实际上是按照固定字节对齐数来进行空间内存块进行管理的。由此就会产生申请的空间大小小于获得空闲内存块大小的,产生了内碎片

而在一大块内存中由于申请和归还的乱序性会导致碎片空间的总和大于申请的空间,但是由于空间不连续而没法申请的情况。即产生了外碎片

image-20220221203118634

1.4malloc

C/C++中我们要动态申请内存都是通过malloc去申请内存,但是我们要知道,实际我们不是直接去堆获取内存的。

而malloc实际上是一个内存池。malloc() 相当于向操作系统“批发”了一块较大的内存空间,然后“零售”给程序用。当全部“售完”或程序有大量的内存需求时,再根据实际需求向操作系统“进货”。

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

之后可以学习:

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

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

malloc的底层实现(ptmalloc)

2.设计定长内存池

基于此,先实现一个对固定大小对象进行内存分配的定长内存池。

malloc其实就是一个通用的大众货,什么场景下都可以用,但是什么场景下都可以用就意味着什么场景下都不会有很高的性能

这个定长内存池学习目的有两层,第一是熟悉一下简单内存池是如何控制的,第二作为后面内存池的一个基础组件。

2.1结构设计

对于内存,可以先以malloc进行向系统申请内存。

申请内存

  • 若当前空闲内存块大于申请内存块大小,则将当前指针分配给申请对象。并且移动头指针。
  • 若当前空闲内存块小于申请内存块大小,则要再次向操作系统申请一块内存。

释放内存:

  • 为了高效管理切好的固定大小内存块,我们使用自由链表(单链表结构)来进行管理。由于每个内存块相同,因此不同关心顺序,直接在自由链表上进行头插头删就可以达到 O ( 1 ) O(1) O(1)的时间复杂度
  • 通过 ∗ ( v o i d ∗ ∗ ) *(void**) (void)在内存块首部获得一根指针的字节大小,用来指向下一个内存块的地址。该种做法的好处是在32位平台和64位平台都可以表示任意一个地址。

image-20220220121748043

image-20220220091513348

基于上述讨论,可以得出定长内存池需要的成员变量为:

char* _memory;//记录已经申请的内存的当前起始位置
size_t _leftSize = 0;//给缺省,当前剩余的字节数量 
void* _freeList;//记录返回内存块的自由链表指针变量

如何处理32/64位下内存块不足存下一个指针变量的大小的链接情况?

分配内存的时候当不足一个指针变量的时候给予最少一个指针变量的大小空间。

如何管理切好的小块内存

使用自由链表,进行头删头插就可以完成目标,整体时间复杂度是 O ( 1 ) O(1) O(1)

如何对在获得的内存块上显式调用构造函数

定位new,new(类型*)类名。

如何对释放的内存块进行清空

析构函数可以显式调用

2.2windows和Linux下直接向堆申请页为单位的大块内存

更进一步地,可以绕开语言层面提供的接口直接使用系统调用向进程地址空间的堆申请内存。

VirtualAlloc

brk和mmap

2.3代码实现

#pragma once
#include<iostream>
#include<vector>
#include<time.h>
using std::cout;
using std::endl;

/*直接通过系统调用向堆申请内存*/
#ifdef _WIN32	// _WIN32
	#include<windows.h>
#else
//linux头文件
#endif 

/*直接去堆上按页申请内存*/
inline static void* SystemAlloc(size_t kpage)
{
#ifdef  _WIN32
	/*1<<13为8KB,可以按页大小进行分配*/
	void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else
	//linux

#endif 
	if (ptr == nullptr)
	{ 
		throw std::bad_alloc();
	}
	return ptr;
}

template<class T>
class ObjectPool
{
public:
	ObjectPool()
		:_memory(nullptr)
		, _freeList(nullptr)
	{}
	~ObjectPool()
	{}
	T* New()
	{
        std::unique_lock<std::mutex> _uqmtx(_mtx);//后期调用补充的
		T* obj = nullptr;
		if (_freeList != nullptr) /*先去回收的空间中寻找,进行头删*/
		{
			void* next = *((void**)_freeList);
			obj = (T*)_freeList;
			_freeList = next;
		}
		else {
			if ( _leftSize < sizeof(T) )
			{
				_leftSize = 128*1024;//先一次申请128K空间
				//_memory = (char*)malloc(_leftSize);
				_memory =(char*) SystemAlloc(_leftSize >> 13);/*转换成页大小*/
				if (_memory == nullptr)
				{
					throw std::bad_alloc();
				}
			}
			int allocSize = sizeof(T) >= sizeof(void*) ? sizeof(T) : sizeof(void*); /*保证至少分配出指针变量的大小*/
			obj = (T*)_memory;
			_memory += allocSize; /*分配空间*/
			_leftSize -= allocSize;
		}
		/*定位new调用对象构造函数*/
		new(obj)T;

		return obj;
	}
	void Delete(T* obj)/*进行头插存储新的内存节点*/
	{
		/*显式调用析构函数进行清理*/
		obj->~T();
		*(void**)obj = _freeList;
		_freeList = (void*)obj;
	}
private:
	char* _memory;//记录已经申请的内存的当前起始位置
	size_t _leftSize = 0;//给缺省,当前剩余的字节数量 
	void* _freeList;//记录返回内存块的自由链表指针变量
    std::mutex _mtx;//后期调用补充的
};

struct TreeNode
{
	int _val;
	TreeNode* _left;
	TreeNode* _right;

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

void TestObjectPool()
{
	// 申请释放的轮次
	const size_t Rounds = 10;

	// 每轮申请释放多少次
	const size_t N = 100000;

	std::vector<TreeNode*> v1;
	v1.reserve(N);

	size_t begin1 = clock();
	for (size_t j = 0; j < Rounds; ++j)
	{
		for (int i = 0; i < N; ++i)
		{
			v1.push_back(new TreeNode);
		}
		for (int i = 0; i < N; ++i)
		{
			delete v1[i];
		}
		v1.clear();
	}

	size_t end1 = clock();

	std::vector<TreeNode*> v2;
	v2.reserve(N);

	ObjectPool<TreeNode> TNPool;
	size_t begin2 = clock();
	for (size_t j = 0; j < Rounds; ++j)
	{
		for (int i = 0; i < N; ++i)
		{
			v2.push_back(TNPool.New());
		}
		for (int i = 0; i < N; ++i)
		{
			TNPool.Delete(v2[i]);
		}
		v2.clear();
	}
	size_t end2 = clock();

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

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

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

  1. 性能问题。
  2. 多线程环境下,锁竞争问题。 (tcmalloc增加的部分)
  3. 内存碎片问题。

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

  1. thread cache:线程缓存是每个线程独有的,用于小于256KB的内存的分配,线程从这里申请内存不需要加锁,每个线程独享一个cache,这也就是这个并发线程池高效的地方。 thread cache中的内存不是无穷无尽的,内存不够了线程就去下一层central cache获取。
  2. central cache:中心缓存是所有线程所共享,thread cache是按需从central cache中获取的对象。central cache合适的时机回收thread cache中的对象,避免一个线程占用了太多的内存,而其他线程的内存吃紧(起到均衡调度的作用),达到内存分配在多个线程中更均衡的按需调度的目的central cache是存在竞争的,所以从这里取内存对象是需要加锁,首先这里用的是桶锁(当多个线程访问同一个桶时加锁),其次只有thread cache的没有内存对象时才会找central cache,所以这里竞争不会很激烈。
  3. page cache:页缓存是在central cache缓存上面的一层缓存,存储的内存是以页为单位存储及分配的,central cache没有内存对象时,从page cache分配出一定数量的page,并切割成定长大小的小块内存,分配给central cache。**当一个span的几个跨度页的对象都回收以后,page cache 会回收central cache满足条件的span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。**当page cache没有缓存之后就会使用系统调用(windows—VirtualAlloc,linux——mmap和brk向系统进行申请堆上的空间。)

image-20220220115351802

4.高并发内存池——thread cache

4.1thread cache整体设计

如何设计thread cache的结构?

从定长内存池进行引申,定长内存池针对某个对象大小或者固定大小可以对大内存进行切割。通过自由链表进行管理切好的小块内存。头插头删的时间复杂度是 O ( 1 ) O(1) O(1)

如何对不同字节(不固定大小)的内存块进行管理呢?一种想法是对每种字节数创建自由链表。thread cache用于小于256KB的内存的分配,那么就需要256*1024个自由链表。

thread cache做了一定的平衡,进行一些固定字节大小的自由链表进行分配。

如下图,<=8字节时候在第一个自由链表进行分配,>8&&<=16的时候进行第二个自由链表分配。此时牺牲一定的空间会产生内碎片

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

由于每个freelist的结构是一致的,所以可以将其封装成freelist类,在中间层的central cache中该结构也被使用到。而threadcache中为多个哈希桶,每个哈希桶是一个FreeList。

申请内存:

  1. 当内存申请size<=256KB,先获取到线程本地存储的thread cache对象,计算size映射的哈希桶自由链表下标i。
  2. 如果自由链表_freeLists[i]中有对象,则直接Pop一个内存对象返回。
  3. 如果_freeLists[i]中没有对象时,则批量从central cache中获取一定数量的对象,插入到自由链表并返回一个对象。

释放内存:

  1. 当释放内存小于256k时将内存释放回thread cache,计算size映射自由链表桶位置i,将对象Push到_freeLists[i]。
  2. 当链表的长度过长,则回收一部分内存对象到central cache。
image-20220221112810309
/*管理好切分出来的自由链表*/
class FreeList
{
public:
	FreeList() = default;
	~FreeList() = default;

	/*内存块存入——头插*/
	void Push(void* ptr)
	{
		assert(ptr);

		GetBytes(ptr) = _freelist;
		_freelist = ptr;
	}
	/*取出内存块——头删除*/
	void* Pop()
	{
		void* obj = _freelist;
		_freelist = GetBytes(_freelist);
		return obj;
	}
	
	bool Empty()
	{
		return _freelist == nullptr;
	}
	int& GetMaxSize()
	{
		return _maxSize;
	}

	void PushRange(void* start , void* end)
	{
		GetBytes(end) = _freelist;
		_freelist = start;
	}

private:
	void* _freelist = nullptr;
	int _maxSize = 1;/*一次能获取的最大容量*/
};

4.2自由链表的哈希桶跟对象大小的映射关系

4.2.1理论设计

具体来说,要采用设计多少个自由链表?

若统一采用8bytes对齐–>256*1024/8=32768的哈希桶个数,稍微有一点多。

经过简化并且兼顾内碎片的考虑,采用如下的映射关系。

第一个阶段浪费的比较多,但是没办法,因为自由链表需要存地址,在64位环境下需要8bytes。

比如129字节只能扔进144字节自由链表,浪费了15个字节,15/144 ≈ 0.104。

比如1025字节只能扔进1152字节自由链表,浪费了127个字节,127/1152 ≈ 0.110。

比如1023/9216 ≈0.111。

这样处理最后只有208个自由链表的哈希桶。除了第一行是要固定的之外,范围和对齐字节数都在原来的基础上*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)

这部分计算对齐和映射关系的部分采用设计一个用于管理对齐和映射关系的类,在thread cache和central cache中都可以使用。

可以看到,除了开始时的对齐数不是*8,之后的字节大小和字节对齐部分都是*8。

4.2.2计算字节数对应的实际freelist大小

按照理论设计,我们可以写出下列原生代码:

size_t _Index(size_t bytes, size_t alignNum)
{
        if (bytes % alignNum == 0)
        {
        	return bytes / alignNum - 1;
        }
        else
        {
        	return bytes / alignNum;
        }
}

但是我们知道%运算是比较慢的,而且作为一个被频繁使用的函数,我们可以考虑将其优化为位运算,转化成可以发现如下式子。

1 ∼ 8 − > 8 字 节 1\sim 8->8字节 18>8

9 ∼ 16 − > 16 字 节 9\sim16 -> 16字节 916>16

在这个范围内就类似÷8向上取整后的结果再*8

而之前学习过向上去取整方法: ( x + k − 1 ) / k (x+k-1)/k (x+k1)/k

因此可以先完成前面部分 x + k − 1 = = s i z e + a l i g n N u m − 1 x+k-1 == size + alignNum - 1 x+k1==size+alignNum1

此时还有后面的除法和乘法部分,转化位运算类似转化成8进制,之后的1从二进制从新开始算起来就是乘上对应的数字

image-20220221213633710

private:
	static inline int _RoundUp(int size, int alignNum)/*根据对齐数计算在哪个freelist里*/
	{
		return (size + alignNum - 1) & ~(alignNum - 1);
	}
public:
	static inline int RoundUp(int size)
	{
		if (size >= 1 && size <= 128)
		{
			return _RoundUp(size, 8);
		}
		else if (size <= 1024)
		{
			return _RoundUp(size, 16);
		}
		else if (size <= 8 * 1024)
		{
			return _RoundUp(size, 128);
		}
		else if (size <= 64 * 1024)
		{
			return _RoundUp(size, 1024);
		}
		else if (size <= 256 * 1024)
		{
			return _RoundUp(size, 8 * 1024);
		}
		else {
			assert("RoundUp false");
			return -1;
		}
	}
4.2.3计算字节数对应的实际哈希桶下标

这里的计算方法就是4.2.2中提及的向上取整,由于下标从0开始,因此计算的时候先按照1进行计算,然后再统一减去,另外由于这里都是2的整数次幂,所以转化成位运算进行处理

static inline int _Index(int size ,int Num)
{
    return ( (size +(1<<Num) -1 ) >>(Num) ) -1 ;
}
// 计算映射的哪一个自由链表桶
	static inline int Index(int size)
	{
		static int bucketNum[4] = { 16,56,56,56 };
		if (size <= 128)
		{
			return _Index(size, 3);
		}
		else if (size <= 1024)
		{
			return _Index(size - 128, 4) + bucketNum[0];
		}
		else if (size <= 8 * 1024)
		{
			return _Index(size - 1024, 7) + bucketNum[0] + bucketNum[1];
		}
		else if (size <= 64 * 1024)
		{
			return _Index(size - 64 * 1024, 10) + bucketNum[0] + bucketNum[1] + bucketNum[2];
		}
		else if (size <= 256 * 1024)
		{
			return _Index(size - 256 * 1024, 13) + bucketNum[0] + bucketNum[1] + bucketNum[2] + bucketNum[3];
		}
		else {
			assert("Index false..");
			return -1;
		}
	}

4.3创建thread cache时使用TLS无锁访问

每个线程都有一个thread cache对象,这个thread cache对象是怎么创建的?什么时候创建的呢?给哪个线程创建呢?

若在全局进行创建,按照线程的访问时间来创建thread cache,new的时候是从同一个内存池获取内存,由定长内存池中的设计可以知道,里面存在对标识内存的指针的–,该操作不是原子操作,因此多线程同时访问就会需要锁保证线程安全。

这里就引出Thread Local Storage(线程局部存储)TLS

linux下的Thread Local Storage(线程局部存储)TLS - 知乎 (zhihu.com)

windows下的线程本地存储(Thread Local Storage) - 坦坦荡荡 - 博客园 (cnblogs.com)

线程局部存储(TLS),是一种变量的存储方法,这个变量在它所在的线程内是全局可访问的,但是不能被其他线程访问到,这样就保持了数据的线程独立性。而熟知的全局变量,是所有线程都可以访问的,这样就不可避免需要锁来控制,增加了控制成本和代码复杂度

img
class ThreadCache
{
public:
	// 申请和释放内存对象
	void* Allocate(int size);

	void Delocate(void* ptr, size_t size);

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

private:
	FreeList _freelistbucket[MAX];
};

static _declspec(thread) ThreadCache* threadCachePtr = nullptr;/*tls*/

4.4thread cache申请内存

4.4.1thread cache向freelist[index]申请内存

对于申请如图8byte的情况,直接获取内存即可。

image-20220224140158896

void* ThreadCache::Allocate(int size)
{
	assert(size <= MAX_BYTES);
	int Align = SizeClass::RoundUp(size);
	int index = SizeClass::Index(size);

	if (!_freelistbucket[index].Empty())
	{
		void* ptr = _freelistbucket[index].Pop();
		return ptr;
	}
	else {/*此时情况下面讨论*/
		void* ptr = FetchFromCentralCache(index, Align);
		return ptr;
	}
}
4.4.2thread cache向下层申请内存的慢开始控制算法

对于申请如图8byte的情况,由于对应的哈希桶为空,因此需要向central cache获取内存。

image-20220224141725926

每次向thread cache向central cache申请内存时的块数采用慢开始控制算法。

  • 当thread cache中没有内存时,就会批量向central cache申请一些内存对象,这里的批量获取对象的数量使用了类似网络tcp协议拥塞控制的慢开始算法;如果只是要多少给多少那么上层无锁的thread cache就没意义了。
  • 使用实际一次能分配的大小和本身变化的需求大小来进行慢开始反馈调节算法达到对应效果
    • 最开始不会一次向central cache一次批量申请太多,因为要太多了可能用不完
    • 如果申请次数慢慢增多,那么batchNum就会不断增长,直到上限。
    • size越大,一次向central cache要的batchNum就越小。
    • size越小,一次向central cache要的batchNum就越大。

实现过程:一个页大小对于划分成可以分配的小内存块会产生很多块。对于256KB,保证其至少获得2块。对于<512B的内存块,防止数量过多浪费只给予512个;申请的过程类似拥塞控制取决于能获得的上限和当前拥塞窗口的上限的较小值

// 一次thread cache从中心缓存获取多少个
	static inline size_t NumMoveSize(size_t size)
	{
		int cnt = MAX_BYTES / size;

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

这里通过慢启动获得对应内存字节数并且存入ThreadCache中。

寻找central cache中对应哈希桶中的非空Span对象,若Span对象足够获取batchNum个块,则分配batchNum个,否则有多少获取多少。

image-20220224142729994

image-20220224142820627

void* ThreadCache::FetchFromCentralCache(size_t index, size_t size)
{
	/*慢启动调节*/
	// 1、最开始不会一次向central cache一次批量要太多,因为要太多了可能用不完
	// 2、如果你不要这个size大小内存需求,那么batchNum就会不断增长,直到上限
	// 3、size越大,一次向central cache要的batchNum就越小
	// 4、size越小,一次向central cache要的batchNum就越大
	int batchNum =  SizeClass::NumMoveSize(size) < _freelistbucket[index].GetMaxSize() ? SizeClass::NumMoveSize(size) : _freelistbucket[index].GetMaxSize();

	if (batchNum == _freelistbucket[index].GetMaxSize())
	{
		_freelistbucket[index].GetMaxSize()++;
	}

	void* start = nullptr; void* end = nullptr;

	/*如果够拿实际需要的;如果不够,那么当前能拿多少就拿多少*/
	int actualNum = CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, size);

	assert(actualNum >= 1);
	
    /*多申请的部分挂到freelist中*/
	if (actualNum == 1)
	{
		assert(start == end);
		return start;
	}
	else
	{
		_freelistbucket[index].PushRange(GetBytes(start), end);/*将获得的内存链表存入thread cache中*/
		return start;
	}
}

4.5thread cache释放内存

释放内存的条件我们简化为:当freelist实际长度大于一次实际要批量申请的内存时就开始释放整个freelist还给central cache。但是实际接口设计成传入的释放个数方便以后进行修改。

在thread cache方面获取要处理的内存串的头指针和尾指针,传给下层central cache进行处理。

image-20220224143731009

void ThreadCache::Delocate(void* ptr, size_t size)
{
	assert(ptr);
	assert(size <= MAX_BYTES);
	/*找对映射的自由链表桶,对象插入进入*/
	int index = SizeClass::Index(size);
	_freelistbucket[index].Push(ptr);

	/*制定规则释放threadcache中的多余的资源到原先的Central的span对象中*/
	if (_freelistbucket[index].size() >= _freelistbucket[index].GetMaxSize())
	{
		/*获得要删除的一串*/
		void* start = nullptr;
		void* end = nullptr;

		_freelistbucket[index].PopRange(start, end, _freelistbucket[index].size());

		CentralCache::GetInstance()->ReturnToCentralCache(start,size);
	}
}

5.高并发内存池——central cache

5.1 central cache的整体设计

central cache也是一个哈希桶结构,他的哈希桶的映射关系跟thread cache是一样的。

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

span是一个以页为单位的大块内存对象。每一个页是固定大小,按照自由链表的大小进行分割成一个个小对象。

  • 申请内存:
  1. 当thread cache中没有内存时,就会批量向central cache申请一些内存对象,这里的批量获取对象的数量使用了类似网络tcp协议拥塞控制的慢开始算法;central cache也有一个哈希映射的spanlist,spanlist中挂着span,从span中取出对象给thread cache,由于不同线程可能访问central cache的同一个哈希桶。因此这个过程是需要加锁的,不过这里使用的是一个桶锁,尽可能提高效率。
  2. central cache映射的spanlist中所有span的都没有内存以后,则需要向page cache申请一个新的span对象,拿到span以后将span管理的内存按大小切好作为自由链表链接到一起。然后从span中取对象给thread cache。
  3. central cache的中挂的span中use_count记录分配了多少个对象出去,分配一个对象给thread cache,就++use_count
  • 释放内存:
  1. 当thread_cache过长或者线程销毁,则会将内存释放回central cache中的,释放回来时–use_count(起到均衡的效果)。当use_count减到0时则表示所有对象都回到了span,则将span释放回page cache, page cache中会对前后相邻的空闲页进行合并(解决外部碎片的问题)。

image-20220222112435280

5.2 central cache的结构设计

  • 以页为单位的大内存管理span的定义及spanlist定义
    1. 由于涉及到span的指定对象删除,因此设计成双向带头链表比较好处理
    2. 8k一页在32位下页号用size_t可以表示,在64位下就不够了。因此要使用条件编译处理32位和64位。注意64位下也是含有 _ W I N 32 \_WIN32 _WIN32的宏,所以条件编译要注意顺序是先判断_WIN32
    3. 在整体结构图上来说,thread cache是全局唯一共享访问,使用单例模式来创建。
/*管理多个连续页大块内存跨度结构*/
class Span {
public:
	PageID _pageId = 0;/*页号,不同平台下不同*/
	size_t _n = 0;/*页的数量*/
	Span* _prev = nullptr;
	Span* _next = nullptr;
	size_t _useCount = 0;/*已经被使用的数量*/
	bool _used = 0;/*标记是否被使用*/
	size_t _objSize = 0;/*记录Span对象被分割的size大小,用于释放的时候直接获取objSize从而只用指针进行delete*/
	void* _freeList = nullptr;
};

上述的过程是描述Span的过程,下面的过程是利用双向连链表组织的过程

class SpanList
{
public:
	SpanList()
	{
		_spanHead = new Span();
		_spanHead->_prev = _spanHead->_next = _spanHead;
	}
	Span* begin()
	{
		return _spanHead->_next;
	}
	Span* end()
	{
		return _spanHead;
	}
	bool Empty()
	{
		return _spanHead->_next == _spanHead;
	}
	void PushFront(Span* newnode)
	{
		assert(newnode);
		insert(begin(), newnode);
	}
	Span* PopFront()
	{
		assert(!Empty());
		Span* newnode = begin();
		erase(newnode);
		return newnode;
	}
	void insert(Span* pos,Span* newnode)
	{
		assert(newnode);
		assert(pos);
		Span* _left = pos->_prev;

		pos->_prev = newnode;
		newnode->_prev = _left;
		newnode->_next = pos;
		_left->_next = newnode;
	}
	/*这里的erase不释放空间,到时候交付给PageCache*/
	void erase(Span* pos)
	{
		assert(pos);
		assert(pos->_next != pos);
		
		Span* fr = pos->_prev;
		Span* bk = pos->_next;
		fr->_next = bk;
		bk->_prev = fr;
	}

private:
	Span* _spanHead;
	
public:
	std::mutex _mtx;
};

同样,全局唯一的对象采用单例模式。

/*单例模式-饿汉模式静态对象*/
class CentralCache
{
public:
	static CentralCache* GetInstance()
	{
		return &centralCache;
	}

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

	/*获取一个非空的Span对象*/
	Span* FetchOneNonEmptySpan(SpanList& list,int size);

	/*将PthreadCache中多余内存块串挂回对应的_spanList[index]的Span对象中去*/
	void ReturnToCentralCache(void* ptr,size_t size);

private:
	CentralCache(){}

	CentralCache(const CentralCache&) = delete;
	CentralCache& operator=(const CentralCache&) = delete;
	
	static CentralCache centralCache;
	SpanList _spanLists[MAX];
};

5.3 central cache的核心设计

5.3.1分配内存
5.3.1.1central cache直接分配内存给thread cache

首先是找到一块非空的Span对象,在该对象下截取一定数量的内存块;内存块的起始位置和终止位置使用输出型参数进行控制。对于找到的Span对象有多少内存算多少。

image-20220224145405077

/*在Central的哈希桶中找非空的Span对象*/
Span* CentralCache::FetchOneNonEmptySpan(SpanList& list, int size)
{
	/*如果本来有非空Span对象*/
	Span* it = list.begin();
	while (it != list.end())
	{
		if (it->_freeList != nullptr) return it;
		it = it->_next;
	}
}
// 从中心缓存获取一定数量的对象给thread cache
int CentralCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size)
{
	size_t index = SizeClass::Index(size);
	
	_spanLists[index]._mtx.lock();/*桶锁*/

	Span* span = FetchOneNonEmptySpan(CentralCache::_spanLists[index],size);

	assert(span);
	assert(span->_freeList);
	// 从span中获取batchNum个对象
	// 如果不够batchNum个,有多少拿多少
	start = end = span->_freeList;

	int actualNum = 1;
	size_t i = 0;

	while (i < batchNum - 1 && GetBytes(end) != nullptr )
	{
		end = GetBytes(end);
		i++;
		actualNum++;
	}

	span->_freeList = GetBytes(end);
	GetBytes(end) = nullptr;
	span->_useCount += actualNum;

	CentralCache::_spanLists[index]._mtx.unlock();

	return actualNum;
}
5.3.1.2central cache没有非空Span向page cache申请内存

假如central cache中的对应哈希桶没有非空Span,此时就要再向下Page cache寻找内存。

FetchOneNonEmptySpan接口中向page cache申请内存。

在这里申请的一块Span的页数大小原则上尽量满足thread cache一次批量申请内存的大小。

单个对象8bytes~单个对象256KB,512(个)*8=4096=4k,都不够一个页,至少给一个页。16Bytes*512(个)=8K,刚好一个页。

2(个)*256KB=512KB/8K = 64页。

可以看出越大要的页数更多一点点。而Page cache中是按照页数为分割的SpanList哈希桶。

image-20220222083937988

计算申请page数量的代码

// 一次thread cache从central cache获取多少个内存块对象
	static inline size_t NumMoveSize(size_t size)
	{
		int cnt = MAX_BYTES / size;

		// [2, 512],一次批量移动多少个对象的(慢启动)上限值
		// 小对象一次批量上限高
		// 大对象一次批量上限低
		if (cnt < 2)
			cnt = 2;
		if (cnt > 512)
			cnt = 512;
		return cnt;
	}
// 计算一次向系统获取几个页
	// 单个对象 8byte
	// ...
	// 单个对象 256KB
	static size_t NumMovePage(size_t size)
	{
		size_t num = NumMoveSize(size);
		size_t npage = num*size;

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

		return npage;
	}

**对于从Page cache获得的Span页都要进行分割成块,每一块都是哈希桶映射的自由链表固定大小。**采用尾插进行分割,实际上链成的一段物理内存仍然是连续的,有利于缓存命中。获得新Span后并分割好后头插入进central对应的哈希桶。

image-20220222105113502

image-20220225205457874

/*在Central的哈希桶中找非空的Span对象*/
Span* CentralCache::FetchOneNonEmptySpan(SpanList& list, int size)
{
	Span* it = list.begin();
	while (it != list.end())
	{
		if (it->_freeList != nullptr) return it;
		it = it->_next;
	}
	std::cout << "CentralCache内未找到非空Span对象" << std::endl;
	/*说明此时不存在非空Span,CentralCache要向PageCache申请内存*/
	int numPage = SizeClass::SpanMovePage(size);

	assert(numPage >= 1);

	/*释放桶锁,此时申请内存由下面的锁保证,释放桶锁是为了保证释放内存时能进入Central的哈希桶*/
	list._mtx.unlock();

	/*对PageCache的访问上锁*/
	PageCache::GetInstance()->_pageCacheMtx.lock();

	/*CentralCache获得span后要插入对应的哈希桶,并且对获得的span对象进行分割*/
	Span* span = PageCache::GetInstance()->FetchSpanFromPageCache(numPage);
	
	span->_used = true;/*标记在使用*/
	span->_objSize = size; /*标记Span对象要切割的size大小*/

	PageCache::GetInstance()->_pageCacheMtx.unlock();

	/*每个线程获得独立的span,分割可以不加锁,因为这会儿其他线程访问不到这个span*/

	/*使用尾插,对缓存命中率更高*/
	char* start = (char*)(span->_pageId << PAGE_SHIFT);
	size_t bytes = span->_n << PAGE_SHIFT;
	char* end = start + bytes;

	span->_freeList = start;/*先切一块拿来当头*/
	start += size;
	void* tail = span->_freeList;

	/*切割Span对象*/
	while (start < end)
	{
		GetBytes(tail) = (void*)start;
		tail = GetBytes(tail);
		start += size;
	}
	
    GetBytes(tail) = nullptr;
	/*将新Span对象插入CentralCache对应的哈希桶,需要上锁*/
	list._mtx.lock();

	list.PushFront(span);

	return span;
}

image-20220223103929705

5.3.2释放内存
5.3.2.1central cache补回Span对象

由于thread cache释放的时候顺序是混乱的,因此需要确定释放的内存块应该归还到spanlists桶中的哪个span对象中。

解决方式:利用归还的内存块地址÷页大小获得对应的页号。直接暴力处理是 O ( N 2 ) O(N^2) O(N2),可以利用page cache建立好的哈希表进行一个页号和span*的映射达到 O ( N ) O(N) O(N)归还。

Tips:

  • Span对象的大小完全可以超过一个page,因此当从PageCache获取Span对象的时候PageCache要按照Span对象的页大小循环建立映射。建立映射的过程是pagecache的事。

    • // 建立id和span的映射,方便central cache回收小块内存时,查找对应的span,span对象多page,直接通过地址映射过来需要每个小块最后都映射到头Span
      for (PageID i = 0; i < kSpan->_n; i++)
      {
          _pageToSpanMap[kSpan->_pageId +i] = kSpan;
      }
      
  • 内存地址通过页偏移大小可以获得页号,因此根据之前的map就可以在central cache中找到对应的span对象进行归回。

image-20220223121101386

void CentralCache::ReturnToCentralCache(void* ptr,size_t size)
{
	assert(ptr);
	int index = SizeClass::Index(size);
	_spanLists[index]._mtx.lock();/*访问桶的期间上桶锁*/
	
	while (ptr)
	{
		void* next = GetBytes(ptr);
		/*获得该内存块所在的PageID*/
		Span* span = PageCache::GetInstance()->FindPageToSpanMap(ptr);

		/*头插回span*/
		GetBytes(ptr) = span->_freeList;
		span->_freeList = ptr;
	
        //...当Span补全时的逻辑:
        
		ptr = next;
	}

	_spanLists[index]._mtx.unlock();
}
5.3.2.2central cache将Span对象交还page cache

当Span对象补回到没线程使用的时候就可以将span对象交还给pagecache,这个措施是为了解决外碎片问题,保证page cache能分配出更大的内存满足分配需求。

注意我们在page cache中只考虑span的页号的数量,因此从central cache中解除span的时候将span进行一个指针的清空。

解下Span对象的时候就可以解开桶锁了,因为下一步是去pagecache的操作,回来的时候再打开锁。

    //Span补全时的逻辑
	span->_useCount--;
    /*检索CentralCache的对应哈希桶中是否有可以拿来合并的Span对象*/
    if (span->_useCount == 0)
    {
        _spanLists[span->_pageId].erase(span);

        span->_freeList = span->_prev = span->_next = nullptr;
        // 释放span给page cache时,使用page cache的锁就可以了
        _spanLists[index]._mtx.unlock();/*解开桶锁*/

        PageCache::GetInstance()->_pageCacheMtx.lock();
        PageCache::GetInstance()->CombineBigSpan(span);
        PageCache::GetInstance()->_pageCacheMtx.unlock();

        _spanLists[index]._mtx.lock();/*加锁*/
    }

6.高并发内存池——page cache

6.1page cache结构设计

page cache结构也是哈希桶,但是映射关系和上两层不一样,central cache某一个哈希桶找不到非空span的时候向page cache直接申请几页的内存,因此page cache的哈希桶下标表示对应页数。

比如对于申请256KB的对象,128*8KB/256kb=4,分配最大的内存块能分配4个span。

同理,page cache是全局唯一对象,采用全局统一访问,使用单例模式。

image-20220222083937988

申请内存:

  1. 当central cache向page cache申请内存时,page cache先检查对应位置有没有span,如果没有则向更大页寻找一个span,如果找到则分裂成两个。比如:申请的是4页page,4页page后面没有挂span,则向后面寻找更大的span,假设在10页page位置找到一个span,则将10页page span分裂为一个4页page span和一个6页page span。
  2. 如果找到_spanList[128]都没有合适的span,则向系统使用mmap、brk或者是VirtualAlloc等方式申请128页page span挂在自由链表中,再重复1中的过程
  3. 需要注意的是central cache和page cache的核心结构都是spanlist的哈希桶,但是他们是有本质区别的,central cache中哈希桶,是按跟thread cache一样的大小对齐关系映射的,他的spanlist中挂的span中的内存都被按映射关系切好链接成小块内存的自由链表。而page cache 中的spanlist则是按下标桶号映射的,也就是说第i号桶中挂的span都是i页内存。

因此对于初始状态:

image-20220222093355495

释放内存:

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

image-20220222094858413

此时pagecache采用桶锁还是整体锁

考虑如下场景:

若采用桶锁,在两个线程访问的时候若申请不到就会向3page去申请,因而此时对三个桶都进行了加锁,此时线程A处理完3page,剩余2page挂到2page的时候,线程b又去访问3page,回来发现2page已经有内存了。整个处理过程会导致频繁加锁,效率不高。

若采用整体锁,只会生成线程A处理完3page或者线程B处理完3page的情况。虽然降低了并行,但是串行执行完的效率可以更高。

类似下述两个处理场景:

mtx.lock();
    for(int i =0; i<n;i++) cout<<"hello"<<endl;
mtx.unlock();
for(int i = 0 ;i <n ;i++)
{
    mtx.lock();
    cout<<"hello"<<endl;
    mtx.unlock();
}

image-20220222095056712

6.2Page cache中获取span

如果当前哈希桶不为空,直接就头删获取span对象。如果当前哈希桶为空,往后遍历找到一个非空的桶,PopFront()该对象,将其切分成一个k页和span._n-k页的两个span对象。前者返回给central cache,后者PushFront()到page cache的哈希桶中。

当所有都是空(如初始情况),此时向系统的堆申请连续的内存。

最后部分为了完成复用,类似哈希stl中设计的那样递归调用一下自己。因为为了处理这部分的递归加锁问题可以使用递归互斥锁或者分离出子函数进行加锁。

注意我们对于Span对象的内存起始位置通过页号计算,内存大小通过Span对象的块数计算。

page cache分配span的时候,是否要解开上层的桶锁?

答案是解开。虽然这时候如果申请内存仍然是空。但是如果是释放内存,就会导致被锁住释放不了。

因此可以将page cache的申请内存过程的锁放到central cache中开关锁方便控制管理。

而且为了合并span的需求和处理内存碎片的需要,在分配的时候需要进行map映射的建立。

image-20220224160224328

image-20220224160233676

/*向PageCache获取Span对象*/
Span* PageCache::FetchSpanFromPageCache(int buckID)
{
	assert(buckID >= 1 && buckID < NUM_PAGE);

	if (!_pageLists[buckID].Empty())
	{
        Span* kSpan =_pageLists[buckID].PopFront();
        for (PageID i = 0; i < kSpan->_n; i++)
		{
			_pageToSpanMap[kSpan->_pageId +i] = kSpan;
		}
		return kSpan;
	}
	/*此时可以解除桶锁,因为申请内存的线程都锁住了,解锁可以保证释放内存不受影响*/

	int k = buckID + 1;
	for (; k < NUM_PAGE; k++)
	{
		/*进行拆分成两个Span块*/
		if (!_pageLists[k].Empty())
		{
			std::cout << "k=" << k << std::endl;
			/*进行分配*/
			Span* nSpan = _pageLists[k].PopFront();
			Span* kSpan = new Span;
			/*页号和页数可以计算出内存块的大小和起始,因此维护这两个就可以。页号也是为了到时候释放的时候可以合并内存碎片*/

			/*重新挂接的*/
			kSpan->_pageId = nSpan->_pageId;
			kSpan->_n = buckID;

			nSpan->_n = k - buckID;
			nSpan->_pageId += buckID;

			_pageLists[nSpan->_n].PushFront(nSpan);

			/*存储nSpan的首位页号跟nSpan映射,方便page cache回收内存时*/
			//进行合并的查找
			_pageToSpanMap[nSpan->_pageId] = nSpan;
			_pageToSpanMap[nSpan->_pageId + nSpan->_n - 1] = nSpan;

			// 建立id和span的映射,方便central cache回收小块内存时,查找对应的span,span对象多page,直接通过地址映射过来需要每个小块最后都映射到头Span
			for (PageID i = 0; i < kSpan->_n; i++)
			{
				_pageToSpanMap[kSpan->_pageId +i] = kSpan;
			}

			return kSpan;
		}
	}

	/*搜到最后说明没内存了,向系统的堆进行申请空间*/
	void* ptr = SystemAlloc((NUM_PAGE-1));
	Span* span = new Span;

	span->_n = NUM_PAGE - 1;
	span->_pageId = (PageID)(ptr) >> PAGE_SHIFT;

	_pageLists[NUM_PAGE - 1].PushFront(span);

	/*递归调用自身复用拆分*/

	return FetchSpanFromPageCache(buckID);

}

6.3Page回收合并

Tips:

  • PageCahce的锁的上锁和释放都放在central进行使用。
  • 合并的时候要考虑是否不存在,是否有对象在使用以及是否可能超过128page,所以要注意最多合成到128page。
  • 合并过程要把原来cache page中的内存块erase掉,合成新块后进行新块的_use,map的映射关系更新,Insert挂到Page cache中。
void PageCache::CombineBigSpan(Span* span)
{
	/*往前合并*/
	while (1)
	{
		auto it = _pageToSpanMap.get(span->_pageId - 1);
		
		// 前面的页号没有,不合并了
		if (it == _pageToSpanMap.end()) break;

        Span* prev = it->second;
		// 前面相邻页的span在使用,不合并了
		if (prev->_used == true) break;

		// 合并出超过128页的span没办法管理,不合并了
		if (prev->_n + span->_n > NUM_PAGE - 1) break;

		span->_n += prev->_n;
		span->_pageId = prev->_pageId;
		
		_pageLists[prev->_n].erase(prev);
		delete prev;
		prev = nullptr;
	}
	/*向后合并*/
	while (1)
	{
		auto it = _pageToSpanMap.find(span->_pageId + span->_n);
	
		if (it == _pageToSpanMap.end()) break;

        Span* next = it->second;
		if (next->_used == true) break;
	
		if (next->_n + span->_n > NUM_PAGE - 1) break;
		
		span->_n += next->_n;

		_pageLists[next->_n].erase(next);
		delete next;
		next = nullptr;
	}

	_pageLists[span->_n].PushFront(span);
	span->_used = false;/*新合成的块也是没人使用的*/

	_pageToSpanMap[span->_pageId] = span;
	_pageToSpanMap[span->_pageId + span->_n -1] = span;
}

7.细节优化及测试

7.1大于256KB的内存申请问题

  1. <=256KB——>三层缓存
  2. >256KB——>256/8=32page
    1. 如果32*8K<=size<=128*8K,可以直接向page cache申请内存。以页为单位进行对齐。
    2. size > 128*8K,直接找系统堆。

申请和释放的时候都使用原来Page Cache的接口,对该函数内部逻辑特判上述的两种情况。同时记得申请的时候将Span*建立好映射。

ConAllocate.h

static inline void* ConAllocate(size_t size)
{
	if (size > MAX_BYTES)
	{
		int Align = SizeClass::RoundUp(size);/*获得对齐字节数*/

		int needPages = Align >> PAGE_SHIFT;
		
		PageCache::GetInstance()->_pageCacheMtx.lock();/*访问PageCache公共资源要加锁*/
		Span* span = PageCache::GetInstance()->FetchSpanFromPageCache(needPages);
		PageCache::GetInstance()->_pageCacheMtx.unlock();/*释放PageCache锁*/

		span->_freeList = (void*)(span->_pageId << PAGE_SHIFT);

		return span->_freeList;
	}
	else {
		if (threadCachePtr == nullptr)
		{
			threadCachePtr = new ThreadCache;
		}

		std::cout << std::this_thread::get_id() << ":" << threadCachePtr << std::endl;

		return threadCachePtr->Allocate(size);
	}
}

static inline void DeAllocate(void* ptr ,size_t size)
{
	assert(ptr);
	if (size > MAX_BYTES)
	{
		Span* span = PageCache::GetInstance()->FindPageToSpanMap(ptr);
		assert(span);
		PageCache::GetInstance()->CombineBigSpan(span);
	}
	else
	{
		threadCachePtr->Delocate(ptr, size);
	}
}

PageCache.cpp

Span* PageCache::FetchSpanFromPageCache(int buckID)
{
	if (buckID > NUM_PAGE - 1)
	{
		/*直接向系统申请*/
		void* ptr = SystemAlloc(buckID);
		Span* span = new Span;

		span->_pageId = (PageID)(ptr) >> PAGE_SHIFT;
		span->_n = buckID;
		span->_used = true;
		_pageToSpanMap[span->_pageId] = span;
		
		return span;
	}
    else
    {
        //...原来的逻辑
    }
}

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

之前提及的定长内存池在这里派上用场。

比如内存池想要替代malloc,那么我们内存池程序内部就不能使用new。(不然就形成了来回的调用)反观之前的代码,比如page cache中new Span使用了new,thread cache中也使用了new,ConAllocate中也使用了new。

将使用new的地方替换成由最开始的定长内存池实现的new,使用delete的地方替换成使用定长内存池的delete

thread cache大约是2k字节,原来的内存池大小够用。

但是这里使用stl的map仍然会出现使用new,这个问题在最后解决。

/*全局唯一,设计成单例模式*/
class PageCache
{
public:
	PageCache(const PageCache&) = delete;
	PageCache& operator=(const PageCache&) = delete;
	
	static PageCache* GetInstance()
	{
		return &_pageCache;
	}
	
	/*向PageCache获取Span对象*/
	Span* FetchSpanFromPageCache(int buckID);

	/*查找_pageToSpanMap的接口*/
	Span* FindPageToSpanMap(void* ptr);

	/*将从CentralCache中获得的span对象进行前后查找合并成大Span对象*/
	void CombineBigSpan(Span* span);
private:
	PageCache() {};
	static PageCache _pageCache;
	SpanList _pageLists[NUM_PAGE];

	std::unordered_map<PageID,Span*> _pageToSpanMap;/*用于PageCache合并成大块Span解决外碎片问题*/
	ObjectPool<Span> _poolSpan;/*使用定长内存池*/
public:
	std::mutex _pageCacheMtx;
};
static inline void* ConAllocate(size_t size)
{
	if (size > MAX_BYTES)
	{
		int Align = SizeClass::RoundUp(size);/*获得对齐字节数*/

		int needPages = Align >> PAGE_SHIFT;
		
		PageCache::GetInstance()->_pageCacheMtx.lock();/*访问PageCache公共资源要加锁*/
		Span* span = PageCache::GetInstance()->FetchSpanFromPageCache(needPages);
		PageCache::GetInstance()->_pageCacheMtx.unlock();/*释放PageCache锁*/

		span->_freeList = (void*)(span->_pageId << PAGE_SHIFT);

		return span->_freeList;
	}
	else {
		if (threadCachePtr == nullptr)
		{
			static ObjectPool<ThreadCache> _threadCachePool;/*定长内存池*/
			threadCachePtr = _threadCachePool.New();
		}

		//std::cout << std::this_thread::get_id() << ":" << threadCachePtr << std::endl;

		return threadCachePtr->Allocate(size);
	}
}

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

因为申请内存的时候我们是分类别去申请内存的,释放内存的时候也得知道size。

如何优化?

  1. 可以专门用一个容器存储页号和size_t的映射
    1. 因为每一个Span对象切割成的size_t的大小都是固定的。
  2. 在Span对象中增加size_t _objSize标记切割成的小对象的的大小
    1. 在central获得整块Span进行切割的时候设置该变量。
    2. 申请超过128pages的时候通过pagecache向堆申请返回的span*指针进行设置。
/*管理多个连续页大块内存跨度结构*/
class Span {
public:
	PageID _pageId = 0;/*页号,不同平台下不同*/
	size_t _n = 0;/*页的数量*/
	Span* _prev = nullptr;
	Span* _next = nullptr;
	size_t _useCount = 0;/*已经被使用的数量*/
	bool _used = 0;/*标记是否被使用*/
	size_t _objSize = 0;/*记录Span对象被分割的size大小,用于释放的时候直接获取objSize从而只用指针进行delete*/
	void* _freeList = nullptr;
};
static inline void DeAllocate(void* ptr)
{
	assert(ptr);
	Span* span = PageCache::GetInstance()->FindPageToSpanMap(ptr);
	size_t size = span->_objSize;/*由此ptr对应的内存块大小*/
	
	if (size > MAX_BYTES)
	{	
		assert(span);
		PageCache::GetInstance()->_pageCacheMtx.lock();/*访问PageCache公共资源要加锁*/
		PageCache::GetInstance()->CombineBigSpan(span);
		PageCache::GetInstance()->_pageCacheMtx.unlock();/*释放PageCache锁*/
	}
	else
	{
		threadCachePtr->Delocate(ptr, size);
	}
}

7.4STL容器线程不安全需要加锁

像之前的unordered_map中并不是线程安全,当多线程访问的时候要加锁。

在pagecache中申请新的Span可能访问unordered_map进行插入或修改;合并Span的时候也可能对unordered_map进行修改和插入;centralcache中释放内存的时候在读map获取页号;central释放小块补回Span的时候也访问map进行读。

对于pagecache中的map写访问的时候整个pagecache的锁是加上的(桶锁解开用于归还内存的线程,page cache的大锁加了),写之间不冲突。

但是central补全Span前访问的时候进行读map是没有加锁的,存在线程安全问题(这部分访问要加锁解锁)。(写的时候会改变map的结构比如红黑树的旋转或者哈希的扩容导致出现问题)

因此当前情况访问map是要进行加锁的。

但是考虑到方便性利用RAII思想对map的FindPageToSpanMap接口进行加锁。

但是注意此时比如线程最后释放内存的时候就不能将访问map放到加了pagecache锁的临界区中,此时就会产生死锁。

image-20220224204603459

/*查找_pageToSpanMap的接口*/
Span* PageCache::FindPageToSpanMap(void* ptr)
{
	/*地址转化成页号*/
	PageID pageid = (PageID)(ptr) >> PAGE_SHIFT;

	/*RAII思想*/
	std::unique_lock<std::mutex> _ulock(_pageCacheMtx);

	auto it = _pageToSpanMap.find(pageid);
	if (it == _pageToSpanMap.end())
	{
		assert(false);
		return nullptr;
	}
	else return it->second;
}

7.5多线程环境下性能测试及性能瓶颈

性能测试不是我们说哪里大就是哪里大的,我们需要用工具来进行分析。linux底下有独立的工具,windows在debug模式的调试(D)中有性能诊断,选择检测(I)。

检测发现占比大的部分为释放内存还有pagecache中的锁和map中的unique_lock锁。

测试代码

#include"ConAllocate.h"
#include<atomic>

// ntimes 一轮申请和释放内存的次数
// rounds 轮次
void BenchmarkMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
	std::vector<std::thread> vthread(nworks);
	std::atomic<size_t> malloc_costtime = 0;
	std::atomic<size_t> free_costtime = 0;

	for (size_t k = 0; k < nworks; ++k)
	{
		vthread[k] = std::thread([&, k]() {
			std::vector<void*> v;
			v.reserve(ntimes);

			for (size_t j = 0; j < rounds; ++j)
			{
				size_t begin1 = clock();
				for (size_t i = 0; i < ntimes; i++)
				{
					v.push_back(malloc(16));
					//v.push_back(malloc((16 + i) % 8192 + 1));
				}
				size_t end1 = clock();

				size_t begin2 = clock();
				for (size_t i = 0; i < ntimes; i++)
				{
					free(v[i]);
				}
				size_t end2 = clock();
				v.clear();

				malloc_costtime += (end1 - begin1);
				free_costtime += (end2 - begin2);
			}
			});
	}

	for (auto& t : vthread)
	{
		t.join();
	}

	printf("%u个线程并发执行%u轮次,每轮次malloc %u次: ",
		nworks, rounds, ntimes);
	cout << "花费: " << malloc_costtime << "ms" << endl;

	printf("%u个线程并发执行%u轮次,每轮次free %u次: ",
		nworks, rounds, ntimes);

	cout << "花费: " << free_costtime << "ms" << endl;

	printf("%u个线程并发malloc&free %u次,",
		nworks, nworks * rounds * ntimes );
	cout << "总计花费:" << malloc_costtime + free_costtime << " ms" << endl;
}


// 单轮次申请释放次数 线程数 轮次
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(ConAllocate(16));
					//v.push_back(ConcurrentAlloc((16 + i) % 8192 + 1));
				}
				size_t end1 = clock();

				size_t begin2 = clock();
				for (size_t i = 0; i < ntimes; i++)
				{
					DeAllocate(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次: ",
		nworks, rounds, ntimes);
	cout << "花费: " << malloc_costtime << "ms" << endl;

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

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

int main()
{
	size_t n = 1000;
	cout << "==========================================================" << endl;
	BenchmarkConcurrentMalloc(n, 100, 100);
	cout << endl << endl;

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

	return 0;
}

8.复杂问题的调试技巧

8.1断言+条件断点快速定位

  • 加断言可以快速定位,当断言位置出现崩溃的时候怎么复现问题

方法:将断言屏蔽掉,打条件断点

例如:

assert(pos != _head);

修改为:

if( pos == _head )
{
	    int x =0; //随便写什么,用来定位到该位置用的
}

8.2调试窗口的调用堆栈

当定位到错误之后,如何知道谁调用当前函数?

Vs->调试(D)->调用栈帧。(监视窗口只能看当前栈帧的变量)

双击堆栈就可以回到上层的调用函数。然后就可以一层一层的分析。

找到出问题的地方就可以打开监视窗口,然后输入对应的变量进行具体观察。

8.3测试验证+条件断点

举个例子,当发现链表中实际拥有的数量不如我们预计的那样多怎么处理?

我们先调堆栈找位置,然后分析可能导致的函数,利用条件断点验证该函数正确性。

比如:

void PushRange(void* start ,void* end,size_t n)
{
    GetBytes(end) = _freelist;
    _freelist = start;
    
    //测试验证+条件断点
    int i = 0;
    void* cur = start;
    while(cur)
    {
       	cur = GetBytes(cur);
        ++i;
    }
    if(n != i)
    {
        int x =0 ;//条件断点
    }
    _size +=n;
}

当调试到同一级别的时候(不存在调用堆栈),怀疑哪个函数传出来的数据就进入该函数打条件断点。

比如:此时怀疑startend有问题,我们可以进入FetchRangObj中打和上面场景调试类似的条件断点。(当然这里是输出型参数所以打在这一层也没关系)。

void* ThreadCache::FetchFromCentralCache(size_t index, size_t size)
{
	/*慢启动调节*/
	// 1、最开始不会一次向central cache一次批量要太多,因为要太多了可能用不完
	// 2、如果你不要这个size大小内存需求,那么batchNum就会不断增长,直到上限
	// 3、size越大,一次向central cache要的batchNum就越小
	// 4、size越小,一次向central cache要的batchNum就越大
	int batchNum =  static_cast<int>(SizeClass::NumMoveSize(size)) < _freelistbucket[index].GetMaxSize() ? SizeClass::NumMoveSize(size) : _freelistbucket[index].GetMaxSize();

	if (batchNum == _freelistbucket[index].GetMaxSize())
	{
		_freelistbucket[index].GetMaxSize()++;
	}

	void* start = nullptr; void* end = nullptr;

	/*如果不能满足batchNum,那么实际能拿多少就拿多少;如果能满足就直接取batchNum个*/
	int actualNum = CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, size);

	assert(actualNum >= 1);

	/*返回一块,剩下的挂起来*/
	if (actualNum == 1)
	{
		assert(start == end);
		return start;
	}
	else
	{
		_freelistbucket[index].PushRange(GetBytes(start), end , actualNum-1);
		return start;
	}

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

	Span* span = FetchOneNonEmptySpan(CentralCache::_spanLists[index],size);

	assert(span);
	assert(span->_freeList);
	// 从span中获取batchNum个对象
	// 如果不够batchNum个,有多少拿多少
	start = end = span->_freeList;

	int actualNum = 1;
	size_t i = 0;

	while (i < batchNum - 1 && GetBytes(end) != nullptr )
	{
		end = GetBytes(end);
		i++;
		actualNum++;
	}

	span->_freeList = GetBytes(end);
	GetBytes(end) = nullptr;
	span->_useCount += actualNum;

	CentralCache::_spanLists[index]._mtx.unlock();

	return actualNum;
}

此时发现这一块的逻辑没有问题,我们再尝试把条件断点打到FetchOneNonEmptySpan中去。

此时发现内存中链表存在循环引用。因此发现是切割Span对象的时候tail的next没有置nullptr。

image-20220225113034414

解决完之后发现还有多删,erase等一些问题,解决完后在单线程情况下就没有问题了。

8.4全部中断

当我们本应该进入到断点范围但是总是进不去的时候,可能是前面的代码部分发生了死循环。此时调试(D)->全部中断就可以进入到死循环部分的代码逻辑观察是否实际死循环了。

9.针对性能瓶颈使用基数树进行优化

使用基数树可以将map进行优化,可以释放减少锁的使用。

这里参考tcmalloc源码中的基数树。源码中提供了三棵基数树。

基数树并不是很高级的数据结构,只是将页号进行了分层,刚好适用当前场景。

#pragma once
#include"Common.h"
#include"ObjectPool.h"
#define ASSERT assert

// Single-level array
template <int BITS>
class TCMalloc_PageMap1 {
private:
    static const int LENGTH = 1 << BITS;
    void** array_;
public:
    typedef uintptr_t Number;
    //explicit TCMalloc_PageMap1(void* (*allocator)(size_t)) {
    explicit TCMalloc_PageMap1() {
        int size = sizeof(void*) << BITS;
        int Align = SizeClass::RoundUp(size);
        array_ = reinterpret_cast<void**>(Align);
        memset(array_, 0, sizeof(void*) << BITS);
    }
    // Return the current value for KEY. Returns NULL if not yet set,
    // or if k is out of range.
    void* get(Number k) const {
        if ((k >> BITS) > 0) {
            return NULL;
        }
        return array_[k];
    }
    // REQUIRES "k" is in range "[0,2^BITS-1]".
    // REQUIRES "k" has been ensured before.
    //
    // Sets the value 'v' for key 'k'.
    void set(Number k, void* v) {
        array_[k] = v;
    }
};
// Two-level radix tree
template <int BITS>
class TCMalloc_PageMap2 {
private:
    // Put 32 entries in the root and (2^BITS)/32 entries in each leaf.
    static const int ROOT_BITS = 5;
    static const int ROOT_LENGTH = 1 << ROOT_BITS;
    static const int LEAF_BITS = BITS - ROOT_BITS;
    static const int LEAF_LENGTH = 1 << LEAF_BITS;
    // Leaf node
    struct Leaf {
        void* values[LEAF_LENGTH];
    };
    Leaf* root_[ROOT_LENGTH];             // Pointers to 32 child nodes
    void* (*allocator_)(size_t);          // Memory allocator
public:
    typedef uintptr_t Number;
    //explicit TCMalloc_PageMap2(void* (*allocator)(size_t)) {
    explicit TCMalloc_PageMap2() {
        memset(root_, 0, sizeof(root_));
        PreallocateMoreMemory();
    }
    void* get(Number k) const {
        const Number i1 = k >> LEAF_BITS;
        const Number i2 = k & (LEAF_LENGTH - 1);
        if ((k >> BITS) > 0 || root_[i1] == NULL) {
            return NULL;
        }
        return root_[i1]->values[i2];
    }
    void set(Number k, void* v) {
        const Number i1 = k >> LEAF_BITS;
        const Number i2 = k & (LEAF_LENGTH - 1);
        if (i1 >= ROOT_LENGTH)
        {
            int x = 10;
        }
        ASSERT(i1 < ROOT_LENGTH);
        root_[i1]->values[i2] = v;
    }
    bool Ensure(Number start, size_t n) {
        for (Number key = start; key <= start + n - 1;) {
            const Number i1 = key >> LEAF_BITS;
            // Check for overflow
            if (i1 >= ROOT_LENGTH)
                return false;
            // Make 2nd level node if necessary
            if (root_[i1] == NULL) {
                //Leaf* leaf = reinterpret_cast<Leaf*>((*allocator_)(sizeof(Leaf)));
                //if (leaf == NULL) return false;
                static ObjectPool<Leaf> _objectPoolLeaf;
                Leaf* leaf = (Leaf*) _objectPoolLeaf.New();
                memset(leaf, 0, sizeof(*leaf));
                root_[i1] = leaf;
            }
            // Advance key past whatever is covered by this leaf node
            key = ((key >> LEAF_BITS) + 1) << LEAF_BITS;
        }
        return true;
    }
    void PreallocateMoreMemory() {
        // Allocate enough to keep track of all possible pages
        Ensure(0, 1 << BITS);
    }
};
// Three-level radix tree
template <int BITS>
class TCMalloc_PageMap3 {
private:
    // How many bits should we consume at each interior level
    static const int INTERIOR_BITS = (BITS + 2) / 3; // Round-up
    static const int INTERIOR_LENGTH = 1 << INTERIOR_BITS;
    // How many bits should we consume at leaf level
    static const int LEAF_BITS = BITS - 2 * INTERIOR_BITS;
    static const int LEAF_LENGTH = 1 << LEAF_BITS;
    // Interior node
    struct Node {
        Node* ptrs[INTERIOR_LENGTH];
    };
    // Leaf node
    struct Leaf {
        void* values[LEAF_LENGTH];
    };
    Node* root_;                          // Root of radix tree
    void* (*allocator_)(size_t);          // Memory allocator
    Node* NewNode() {
        Node* result = reinterpret_cast<Node*>((*allocator_)(sizeof(Node)));
        if (result != NULL) {
            memset(result, 0, sizeof(*result));
        }
        return result;
    }
public:
    typedef uintptr_t Number;
    explicit TCMalloc_PageMap3(void* (*allocator)(size_t)) {
        allocator_ = allocator;
        root_ = NewNode();
    }
    void* get(Number k) const {
        const Number i1 = k >> (LEAF_BITS + INTERIOR_BITS);
        const Number i2 = (k >> LEAF_BITS) & (INTERIOR_LENGTH - 1);
        const Number i3 = k & (LEAF_LENGTH - 1);
        if ((k >> BITS) > 0 ||
            root_->ptrs[i1] == NULL || root_->ptrs[i1]->ptrs[i2] == NULL) {
            return NULL;
        }
        return reinterpret_cast<Leaf*>(root_->ptrs[i1]->ptrs[i2])->values[i3];
    }
    void set(Number k, void* v) {
        ASSERT(k >> BITS == 0);
        const Number i1 = k >> (LEAF_BITS + INTERIOR_BITS);
        const Number i2 = (k >> LEAF_BITS) & (INTERIOR_LENGTH - 1);
        const Number i3 = k & (LEAF_LENGTH - 1); reinterpret_cast<Leaf*>(root_->ptrs[i1]->ptrs[i2])->values[i3] = v;
    }
    bool Ensure(Number start, size_t n) {
        for (Number key = start; key <= start + n - 1;) {
            const Number i1 = key >> (LEAF_BITS + INTERIOR_BITS);
            const Number i2 = (key >> LEAF_BITS) & (INTERIOR_LENGTH - 1);
            // Check for overflow
            if (i1 >= INTERIOR_LENGTH || i2 >= INTERIOR_LENGTH)
                return false;
            // Make 2nd level node if necessary
            if (root_->ptrs[i1] == NULL) {
                Node* n = NewNode();
                if (n == NULL) return false;
                root_->ptrs[i1] = n;
            }
            // Make leaf node if necessary
            if (root_->ptrs[i1]->ptrs[i2] == NULL) {
                Leaf* leaf = reinterpret_cast<Leaf*>((*allocator_)(sizeof(Leaf)));
                if (leaf == NULL) return false;
                memset(leaf, 0, sizeof(*leaf));
                root_->ptrs[i1]->ptrs[i2] = reinterpret_cast<Node*>(leaf);
            }
            // Advance key past whatever is covered by this leaf node
            key = ((key >> LEAF_BITS) + 1) << LEAF_BITS;
        }
        return true;
    }
    void PreallocateMoreMemory() {}
};
  • 一层基数树(适用于32位)

image-20220225155838433

对于只有一层的来说页号是多少就直接去对应的位置获取指针。

image-20220225161208755

  • 两层基数树(适用于32位)

image-20220225155940469

image-20220225160753785

对于两层的访问来说,需要先右移14位获得一层下标,再获得第二层的下标。

image-20220225161254414

Ensure确保从start起始页号开始往后的n页的页号位置对应的内存都申请好了。

image-20220225161612353

  • 三层基数树(适用于64位)

64位假设8K为一页,需要51位存储页号,放层单层或两层太大了。51-5=46。假如放成两层,第二层仍有个数为2^46的数组大小,太大了开不出来。

这样看起来分层似乎对总体空间没有影响。但是实际上每一层的空间不用全部申请出来,实际访问的时候才申请空间。

为什么使用map/unordered_map需要加锁?而使用基数树不加锁呢?

  • map场景

因为在原来的FetchSpanFromPageCacheCombineBigSpan中,执行到该函数内部的线程在pagecache内直接使用map会进行写/插入/修改。而当进入pagecache后为了保证效率是释放桶锁的,保证释放内存块能进行,因此释放过程[DeAllocateReturnToCentralCache]中调用FindPageToSpanMap是进行读,而此时pagecache内部的线程会并发进行map的直接访问写,最大的原因是会动红黑树或者哈希的结构,所以要加锁

  • 基数树场景

在创建出Span对象的时候,首先会使用EnSure函数把对应的空间申请好,插入数据查找数据都不会动原本结构,并且读写是分离的。

写的时只有两个场景:FetchSpanFromPageCache(切分2页的Span对象)和CombineBigSpan释放合并成大Span的时候会进行写。

释放的场景:DeAllocateReturnToCentralCache

总结:

  1. 只有在这两个函数中会去建立id和Span的映射,也就是说会去写。(这两个函数不可能是同一个Span对象而且pagecache是一把大锁)
  2. 基数树写之前会提前开好空间,写数据过程中,不会动数据结构。
  3. 读写是分离的。线程1对一个位置读写的时候,线程2不可能对这个位置进行读写。(写是在没人用的时候,读是在有人用的时候)

image-20220225201621192

此时我们就可以将原来的哈希表改成基数树进行使用了。使用基数树的get和set方法进行查找和修改。

#pragma once
#include"Common.h"
#include"ObjectPool.h"
#include "PageMap.h"
#include"CentralCache.h"
#include <unordered_map>

/*全局唯一,设计成单例模式*/
class PageCache
{
public:
	PageCache(const PageCache&) = delete;
	PageCache& operator=(const PageCache&) = delete;
	
	static PageCache* GetInstance()
	{
		return &_pageCache;
	}
	
	/*向PageCache获取Span对象*/
	Span* FetchSpanFromPageCache(int buckID);

	/*查找_pageToSpanMap的接口*/
	Span* FindPageToSpanMap(void* ptr);

	/*将从CentralCache中获得的span对象进行前后查找合并成大Span对象*/
	void CombineBigSpan(Span* span);
private:
	PageCache() {};
	static PageCache _pageCache;
	SpanList _pageLists[NUM_PAGE];

	//std::unordered_map<PageID,Span*> _pageToSpanMap;/*用于PageCache合并成大块Span解决外碎片问题*/
#ifdef _WIN64
	TCMalloc_PageMap2<64 - PAGE_SHIFT> _pageToSpanMap;
#elif _WIN32
	TCMalloc_PageMap2<32 - PAGE_SHIFT> _pageToSpanMap;
#endif
	ObjectPool<Span> _poolSpan;
public:
	std::mutex _pageCacheMtx;
};

image-20220225211709550

10.参考资料

;