高并发内存池

前言

池化技术是向系统申请过量的资源,自己管理这些资源,为后面需要做准备。

项目介绍

当前项目是实现一个高并发内存池,来自geogle开源项目tcmalloc,Thread-Cacheing Malloc,线程缓存的malloc,实现了高效多线程内存管理,可以替代系统的内存分配相关的函数malloc。

内存池作用

可以解决效率问题和内存碎片问题。

一次性多拿一些空间比多次拿定量适合的空间效率快,前者向系统申请空间的次数少,后者次数多,次数多就会导致效率低下,前者虽然有空间的消耗,但是效率高,拿空间换效率。

内存碎片

上面申请空间是连续申请一片空间,就会有一种情况,有些空间申请完后释放,新的申请来了,总剩余空间是足够的,但是不是连续,就会申请失败,这就是外部碎片。而这个项目有操作处理这个问题。

tcmalloc和malloc关系

C/C++程序申请空间不是直接去堆上,而是借助一个函数申请的,也就是malloc函数,释放就是free。C++中的new里面是调用了malloc,delete内部也是调用了free函数。malloc和free都是属于用户操作接口这一层,进程是用户级别需要变为内核级别拿到空间,所有会调用系统调用获取空间。

malloc实际上也是一个内存池,会向系统申请过量的空间资源,每次申请空间malloc会先检查自己的剩余空间是否足够,足够就会调用系统调用申请空间,直接剩余空间分配过去。

malloc和tcmalloc都是可以的,在不同的地方优势不同,tcmalloc在多线程效率上比malloc快。

定长内存池

(实现tcmalloc会用到这一部分)

1.memory指针指向一块连续空间

2.内存池管理需要有申请和释放,用户申请的空间用完需要释放,但是这里的释放不会还回去OS,而是存储起来,下一次申请可能可以用这些存储起来的,而且还回去是有要求的,申请多少就要free多少,不能只free一部分会报错的,大部分空间都空了,但是有一部分还在使用就free不了。就可以用一个链表管理这些被还回来的空间,把每个还回来的空间的头部分存储一个地址,这个地址指向下一个还回来的空间,就可以串起来这些资源。

实现部分

模板类是因为不知道申请空间给对象的具体多少,不同类型对象申请的空间不一样,memory是指向大块内存的指针,remainbytes是剩余空间的字节数,freelist是申请空间还回来的头指针。

申请空间先检查freelist是否有空间,有就可以取出来用,这里直接返回的空间是够用的,因为申请后被还回的,被申请的空间肯定得满足,则freelsit上的空间肯定也是够的。freelsit上没有,则判断申请对象大小是否超出剩余字节数,不够申请就需要调用SystemAlloc函数,可以向系统申请空间,这里有条件编译,任何是在window系统上编译,编译器会执行 #ifdef _WIN32#endif 之间的代码。这里<<13是要得到页的数量,这里就是16页大小,objSize的大小之所以这样计算是因为T可能会小于一个指针大小,要保证能存下一个指针大小,申请了objsize大小,memory地址就需要后移objsize,剩余字节数也需要减少。这里有个问题就是不同位下的指针大小不同,32位是4个字节,64位是8个字节,可以用一个二级指针在对其解引用,就可以得到一个指针大小了,

*(**void)就可以不用判断个数了。申请空间后就可以调用new(obj) T构造函数,obj是指向内存地址的指针,也就是被构造存储位置,T要构造对象的类型。delete函数就是调用对象析构函数,把这个地址头插到freeList中。

VirtualAlloc函数是windows的申请空间系统调用函数。

cpp 复制代码
#pragma once
#include "Common.h"

// 定长内存池
//template<size_t N>
//class ObjectPool
//{};


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;
}
static void*& NextObj(void* obj)
{
	return *(void**)obj;
}




template<class T>
class ObjectPool
{
public:
	T* New()
	{
		T* obj = nullptr;

		// 优先把还回来内存块对象,再次重复利用
		if (_freeList)
		{
			void* next = *((void**)_freeList);
			obj = (T*)_freeList;
			_freeList = next;
		}
		else
		{
			// 剩余内存不够一个对象大小时,则重新开大块空间
			if (_remainBytes < sizeof(T))
			{
				_remainBytes = 128 * 1024;
				//_memory = (char*)malloc(_remainBytes);
				_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 += objSize;
			_remainBytes -= objSize;
		}

		// 定位new,显示调用T的构造函数初始化
		new(obj)T;

		return obj;
	}

	void Delete(T* obj)
	{
		// 显示调用析构函数清理对象
		obj->~T();

		// 头插
		*(void**)obj = _freeList;
		_freeList = obj;
	}

private:
	char* _memory = nullptr; // 指向大块内存的指针
	size_t _remainBytes = 0; // 大块内存在切分过程中剩余字节数

	void* _freeList = nullptr; // 还回来过程中链接的自由链表的头指针
};

PROT_READ | PROT_WRITE:表示分配的内存区域可以读写

MAP_PRIVATE | MAP_ANONYMOUS:表示分配匿名内存,不与文件关联。

-1:表示文件描述符,对于匿名内存,设置为 -1

高并发内存池框架

第一层是thread cache ,第二层是central cache,第三层是page cache。

第一层thread-cache

一个进程有几个线程,就会有几个thread-cache(tc),每一个线程都会有对应的tc,一个管理空间的对象,每个线程不需要加锁,因为每一个线程都有属于自己的tc,到自己的tc中去申请空间,单次向tc申请的空间最大位256KB。

第二层central-cache

当线程的tc中空间不够时,就会向上申请,也就是cc,这里需要注意,当多个线程向cc申请空间时,不一定要有锁,只有对同一个桶申请才要加锁,cc是由哈希桶构成的,每一个桶也有着空间。

cc也会收回给tc的空间,当tc申请了很多空间,并且这些空间释放(不是delete的),则tc的freelist就会过长,所以cc就要收回多余空间。当tc向cc申请空间时,cc也不足于给tc就会向上申请,也就是pagecache.

第三层page-cache

pc中会有多个span的结构体,span会管理多个页大小空间,pc是一个管理页,一页就是4/8KB,这里有解决碎片问题。

tc实现

tc的freelist是链表,所以需要写一些对链表的操作,如增删查

size表示链表中存在的个数,maxsize的作用是后面满增长需求,就像好学生要多给一点奖励,申请次数多的就会多给一些空间。

cpp 复制代码
class FreeList
{
public:
	void Push(void* obj)
	{
		assert(obj);

		// 头插
		//*(void**)obj = _freeList;
		NextObj(obj) = _freeList;
		_freeList = obj;

		++_size;
	}

	void PushRange(void* start, void* end, size_t n)
	{
		NextObj(end) = _freeList;
		_freeList = start;

		// 测试验证+条件断点
		/*int i = 0;
		void* cur = start;
		while (cur)
		{
			cur = NextObj(cur);
			++i;
		}

		if (n != i)
		{
			int x = 0;
		}*/

		_size += n;
	}

	void PopRange(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;
	}

	void* Pop()
	{
		assert(_freeList);

		// 头删
		void* obj = _freeList;
		_freeList = NextObj(obj);
		--_size;

		return obj;
	}

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

	size_t& MaxSize()
	{
		return _maxSize;
	}

	size_t Size()
	{
		return _size;
	}

private:
	void* _freeList = nullptr;
	size_t _maxSize = 1;
	size_t _size = 0;
};

thread-cache哈希结构

tc有一个哈希表,每一个哈希桶表示一个链表。

lass ThreadCache

{

public:

// 申请和释放内存对象

void* Allocate(size_t size);

void Deallocate(void* ptr, size_t size);

// 从中心缓存获取对象

void* FetchFromCentralCache(size_t index, size_t size);

// 释放对象时,链表过长时,回收内存回到中心缓存

void ListTooLong(FreeList& list, size_t size);

private:

FreeList _freeLists[NFREELIST];

};

这里间隔是要增长的,不能定长的分,前者的碎片问题是优于后者的。这里size就是要申请的空间,比如申请7B那么就会得到8B的空间,这里对齐数的意义就是减少内碎片的问题,内碎片就是因为最小分配单位也会有间隙,这里的间隙就是内碎片,比如7B拿到8B就有1B是用不到的,这里的1B就是内碎片。不同size对应不同的哈希桶,下标为0的哈希桶就是8B空间,下标1的是16B空间。按照下面的对齐规则就需要208个桶。

// 整体控制在最多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)

C++中尽量避免使用宏,可以用static const替换。单次申请空间不会超过256KB,所以定义一个MAX_BYTES;

thread cache类中需要的方法,首先得有申请空间方法,申请空间有了就需要释放空间的方法,这里申请空间是有两个方向的,第一在freelist中申请,如果没有就需要往上申请,也就是在central cache中申请,而释放空间还需要有一个判断,就是前面提到申请空间次数越多分配越多,那么释放空间就会导致freelist中的长度越来越长,就需要收回一部分到cc中。

接下来需要写一个把size转换成对齐数的方法。

这里是根据不同区间来对应不同的对齐数,因为位运算效率高,但也难想,注释掉则是容易做到的。注意这里有static主要是把函数变为静态成员函数,调用这个函数就不用创建一个类对象再去使用这个方法,可用直接使用这个函数。

cpp 复制代码
/*size_t _RoundUp(size_t size, size_t alignNum)
	{
		size_t alignSize;
		if (size % alignNum != 0)
		{
			alignSize = (size / alignNum + 1)*alignNum;
		}
		else
		{
			alignSize = size;
		}

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

	static inline size_t RoundUp(size_t size)
	{
		if (size <= 128)
		{
			return _RoundUp(size, 8);
		}
		else if (size <= 1024)
		{
			return _RoundUp(size, 16);
		}
		else if (size <= 8*1024)
		{
			return _RoundUp(size, 128);
		}
		else if (size <= 64*1024)
		{
			return _RoundUp(size, 1024);
		}
		else if (size <= 256 * 1024)
		{
			return _RoundUp(size, 8*1024);
		}
		else
		{
			return _RoundUp(size, 1<<PAGE_SHIFT);
		}
	}

还需要知道size对应的下标位置,才能找到对应哪一个桶。

cpp 复制代码
/*size_t _Index(size_t bytes, size_t 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)
	{
		return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1;
	}

	// 计算映射的哪一个自由链表桶
	static inline size_t Index(size_t 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;
	}

Allocate函数实现

cpp 复制代码
void* ThreadCache::Allocate(size_t size)
{
	assert(size <= MAX_BYTES);
	size_t alignSize = SizeClass::RoundUp(size);
	size_t index = SizeClass::Index(size);

	if (!_freeLists[index].Empty())
	{
		return _freeLists[index].Pop();
	}
	else
	{
		return FetchFromCentralCache(index, alignSize);
	}
}

FetchFromCentralCache函数实现

batchnum就是当次申请的批量,这里要比较这个下标maxsize和NumMoveSize里的俩值最小一个,这个函数保证了申请的批量在2-512之间,小对象一次申请批量多,打对象申请的少,maxsize虽然名字上是最大值,其实可以看成是已经申请次数,开始是1,那么最小肯定是maxsize的1,如果batchnum等于maxsize,maxsize就加1,只要maxsize小于nummovesize函数返回的值,那么每次申请都会加1,类似于奖励机制,要的越多给的越多。定义start和end,再调用cc层面的方法获取空间,这里的start和end是输出型参数,cc的函数执行完,start和end区间就是有效可用的空间,actualNum是实际获取数量,如果等于1,就放回start,大于1,就需要放回1个,其余的插入到freeList中存储。如三个线程,分别申请4,5,6B大小,都是下标为0的桶,那么第一次batchnum=1,第二次batchnum=2,所以当到6B时,就不用到cc中申请空间了,因为freeList还有一个可用空间。

cpp 复制代码
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就越大
	size_t batchNum = min(_freeLists[index].MaxSize(), SizeClass::NumMoveSize(size));
	if (_freeLists[index].MaxSize() == batchNum)
	{
		_freeLists[index].MaxSize() += 1;
	}

	void* start = nullptr;
	void* end = nullptr;
	size_t actualNum = CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, size);
	assert(actualNum > 0);

	if (actualNum == 1)
	{
		assert(start == end);
		return start;
	}
	else
	{
		_freeLists[index].PushRange(NextObj(start), end, actualNum-1);
		return start;
	}
}
cpp 复制代码
// 一次thread 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;
	}

Deallocate实现

这里的size已经是对齐数大小了,因为申请空间大小一定是对齐数的倍数,那么释放的肯定是申请空间的,这里释放就是到对应的桶位置插入,还需要判断这里的大小和maxsize的大小,以maxsize作为链表是否过长判断标准,大于就调用函数去缩减。也就是调用poprange函数,把maxsize的个数从链表取出。

cpp 复制代码
void ThreadCache::Deallocate(void* ptr, size_t size)
{
	assert(ptr);
	assert(size <= MAX_BYTES);

	// 找对映射的自由链表桶,对象插入进入
	size_t index = SizeClass::Index(size);
	_freeLists[index].Push(ptr);

	// 当链表长度大于一次批量申请的内存时就开始还一段list给central cache
	if (_freeLists[index].Size() >= _freeLists[index].MaxSize())
	{
		ListTooLong(_freeLists[index], size);
	}
}

void ThreadCache::ListTooLong(FreeList& list, size_t size)
{
	void* start = nullptr;
	void* end = nullptr;
	list.PopRange(start, end, list.MaxSize());

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

TLS thread local storage

线程的本地存储,每一个线程都要有自己的空间存储threadcache,线程几乎是一起共享进程的虚拟地址空间,每个线程有独立的栈,寄存器等。进程的全局变量每个线程都是共享的,而TLS,线程局部存储,只能被自己线程访问,其它线程不能访问除自己之外的。

// TLS thread local storage

static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;

线程申请和释放空间接口

这里线程申请空间先判断size的大小是否超出限制,没有还要判断本地线程存储是否为空,一开始都是空的,就会分配一个空间来创建本地线程存储,创建后就可以调用方法去申请空间,这里不用new是因为new底层还是malloc的,而这个项目是tcmalloc,就不能有malloc出现,就用到前面的定长内存池。释放空间也是判断size做出不同方法。

cpp 复制代码
#pragma once

#include "Common.h"
#include "ThreadCache.h"
#include "PageCache.h"
#include "ObjectPool.h"

static void* ConcurrentAlloc(size_t size)
{
	if (size > MAX_BYTES)
	{
		size_t alignSize = SizeClass::RoundUp(size);
		size_t kpage = alignSize >> PAGE_SHIFT;

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

		void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
		return ptr;
	}
	else
	{
		// 通过TLS 每个线程无锁的获取自己的专属的ThreadCache对象
		if (pTLSThreadCache == nullptr)
		{
			static ObjectPool<ThreadCache> tcPool;
			//pTLSThreadCache = new ThreadCache;
			pTLSThreadCache = tcPool.New();
		}

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

		return pTLSThreadCache->Allocate(size);
	}	
}

static void ConcurrentFree(void* ptr)
{
	Span* span = PageCache::GetInstance()->MapObjectToSpan(ptr);
	size_t size = span->_objSize;

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

central-cache实现

cc和tc结构相似,cc内部也是哈希结构,根据块的大小来映射。映射规则也一样,在tc哈希桶位置申请,没有则到cc同样位置哈希桶申请空间,方便之处。不同在于锁,因为线程有本地存储存储,所以不用锁,而cc需要,因为多线程可能会同时访问同一个桶,就需要对这个桶加锁。cc链表存储的span结构体,span是管理页的大块内存,span中可用有多个页,其成员_n记录了页的个数。

span挂载不同桶,则内部被切分的个数和大小不一样。比如0号桶,就会分成多个8B空间,用链表串在一起。每个桶下挂的span包含的页数页不同,桶对应的字节数也就是下标,下标越小页数少,下标大页数多。这里的页数就需要管理,span内部就有_pageId来表示span管理的页是几号页,这里的页号和地址是相互转换的。span的usecount是表示空间的使用情况,为0就表示没有使用,这个变量后面可用用来解决外部碎片问题,通过这个变量可用知道这个空间是否被使用,没有就可以和其它空闲空间合并。这里PAGE_ID类型随平台不同而变换。

cpp 复制代码
#ifdef _WIN64
	typedef unsigned long long PAGE_ID;
#elif _WIN32
	typedef size_t PAGE_ID;
#else
	// linux
#endif
// 管理多个连续页大块内存跨度结构
struct Span
{
	PAGE_ID _pageId = 0; // 大块内存起始页的页号
	size_t  _n = 0;      // 页的数量

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

	size_t _objSize = 0;  // 切好的小对象的大小
	size_t _useCount = 0; // 切好小块内存,被分配给thread cache的计数
	void* _freeList = nullptr;  // 切好的小块内存的自由链表

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

SpanList实现

用来管理span结构体,同一个桶可能会挂载多个span,就需要有对span对象的操作方法。

cpp 复制代码
// 带头双向循环链表 
class SpanList
{
public:
	SpanList()
	{
		_head = new Span;
		_head->_next = _head;
		_head->_prev = _head;
	}

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

	Span* End()
	{
		return _head;
	}

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

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

	Span* PopFront()
	{
		Span* front = _head->_next;
		Erase(front);
		return front;
	}

	void Insert(Span* pos, Span* newSpan)
	{
		assert(pos);
		assert(newSpan);

		Span* prev = pos->_prev;
		// prev newspan pos
		prev->_next = newSpan;
		newSpan->_prev = prev;
		newSpan->_next = pos;
		pos->_prev = newSpan;
	}

	void Erase(Span* pos)
	{
		assert(pos);
		assert(pos != _head);

		// 1、条件断点
		// 2、查看栈帧
		/*if (pos == _head)
		{
		int x = 0;
		}*/

		Span* prev = pos->_prev;
		Span* next = pos->_next;

		prev->_next = next;
		next->_prev = prev;
	}

private:
	Span* _head;
public:
	std::mutex _mtx; // 桶锁
};

单例模式获取centralcache

centralcache是只有一个的,那么就需要用到单例模式,所有的tc只能访问到这一个cc。静态成员需要在类外说明,可能有多个文件包含了这个头文件,就会有多个位置初始化这个成员,从而出现问题,要在cpp文件里初始化。

cpp 复制代码
#pragma once

#include "Common.h"

// 单例模式
class CentralCache
{
public:
	static CentralCache* GetInstance()
	{
		return &_sInst;
	}

	// 获取一个非空的span
	Span* GetOneSpan(SpanList& list, size_t byte_size);

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

	// 将一定数量的对象释放到span跨度
	void ReleaseListToSpans(void* start, size_t byte_size);
private:
	SpanList _spanLists[NFREELIST];

private:
	CentralCache()
	{}

	CentralCache(const CentralCache&) = delete;

	static CentralCache _sInst;
};

函数ThreadCache::FetchFromCentralCache(size_t index, size_t alignSize)

知道对应下标,可用找到cc对应桶位置,从cc获取空间放回给tc。而获取数量就可以batchnum来决定。实际从cc获取的空间是batchnum*aligesize大小空间,从桶中找到到一个span,在从span中获取这么大的空间,也会有span中不够的情况,前面被取过,则cc就会到pc中就申请空间。batchnum和actualnum可能不同,当这个桶开始时从pc获取空间,第一个tc从这个桶申请空间走了,但是还剩了一些,这些就在span中,当第二个线程来了,span不为空,就会把这个span分配过去,如何取batchnum个,但是可能没到batchnum个,就因为到nullptr而结束,这样actualnum就会小于batchnum。但是能保证大于1,也就是一定有一个,只是奖励没了,不会多给几个放到freelist中存储。

实现部分

获取span,第一步是去空闲链表spanlist中找,是否有可用的,找到就可以放回,在空闲链表里的就可能是actualnum<batchnum,可能是前面取了一部分的,要是spanlist里没有就要到pc申请了,pc申请的就是完整的span,actualnum=batchnum了,还要设置申请新span的属性,isUse是是否使用的意思,这里被申请了就是被用了为true,把span中要切份的大小objsize,这里是把span进行切块,每块都是objsize的大小,start表示起始地址,这里的地址使用页号转换的,页号是地址<<13,所以就可以通过页号反向得到地址,而页数就是空间头到尾的大小,就可以得到end地址,有首位地址以及每一份的大小,就可以切分span,把切分好的span放到spanlist中。此时fetchrangeobj就有span了,根据每一块空间的头是下一块的地址特性,这里在newspan中以及做好了切分,所以就可以根据这个拿batchnum个size大小空间,给span的useconut加上actualnum,把实际数量返回。

释放函数,这里是拿到一个地址,因为申请的空间可能是多块,所以需要循环,保存start的下一块地址在next,根据哈希映射(页号和span为key和value),就可以找到对应的span,然后把这个申请空间的地址插入到通过映射知道的span的freelist中,判断usecount是否为0,不为0就说明还有这个span中有一部分还在使用,因为span是被切分的,start=next,进行第二次,映射得到的还是同一个span,因为释放的是一个连续地址,申请的时候是在一个span中切分好的,所以就会把这个地址也插入到同一个freelist中,usecount--,直到这个span切分出去的空间都还回来了,就执行if里面的,把cc这个下标的span删除,然后把内部元素也置空,然后把span交给pc处理,要进行合并空闲span。

cpp 复制代码
#include "CentralCache.h"
#include "PageCache.h"

CentralCache CentralCache::_sInst;

// 获取一个非空的span
Span* CentralCache::GetOneSpan(SpanList& list, size_t size)
{
	// 查看当前的spanlist中是否有还有未分配对象的span
	Span* it = list.Begin();
	while (it != list.End())
	{
		if (it->_freeList != nullptr)
		{
			return it;
		}
		else
		{
			it = it->_next;
		}
	}

	// 先把central cache的桶锁解掉,这样如果其他线程释放内存对象回来,不会阻塞
	list._mtx.unlock();

	// 走到这里说没有空闲span了,只能找page cache要
	PageCache::GetInstance()->_pageMtx.lock();
	Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(size));
	span->_isUse = true;
	span->_objSize = size;
	PageCache::GetInstance()->_pageMtx.unlock();

	// 对获取span进行切分,不需要加锁,因为这会其他线程访问不到这个span

	// 计算span的大块内存的起始地址和大块内存的大小(字节数)
	char* start = (char*)(span->_pageId << PAGE_SHIFT);
	size_t bytes = span->_n << PAGE_SHIFT;
	char* end = start + bytes;

	// 把大块内存切成自由链表链接起来
	// 1、先切一块下来去做头,方便尾插
	span->_freeList = start;
	start += size;
	void* tail = span->_freeList;
	int i = 1;
	while (start < end)
	{
		++i;
		NextObj(tail) = start;
		tail = NextObj(tail); // tail = start;
		start += size;
	}

	NextObj(tail) = nullptr;

	// 1、条件断点
	// 2、疑似死循环,可以中断程序,程序会在正在运行的地方停下来
	//int j = 0;
	//void* cur = span->_freeList;
	//while (cur)
	//{
	//	cur = NextObj(cur);
	//	++j;
	//}

	//if (j != (bytes / size))
	//{
	//	int x = 0;
	//}

	// 切好span以后,需要把span挂到桶里面去的时候,再加锁
	list._mtx.lock();
	list.PushFront(span);

	return span;
}

// 从中心缓存获取一定数量的对象给thread 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 = GetOneSpan(_spanLists[index], size);
	assert(span);
	assert(span->_freeList);

	// 从span中获取batchNum个对象
	// 如果不够batchNum个,有多少拿多少
	start = span->_freeList;
	end = start;
	size_t i = 0;
	size_t actualNum = 1;
	while ( i < batchNum - 1 && NextObj(end) != nullptr)
	{
		end = NextObj(end);
		++i;
		++actualNum;
	}
	span->_freeList = NextObj(end);
	NextObj(end) = nullptr;
	span->_useCount += actualNum;

	//// 条件断点
	int j = 0;
	void* cur = start;
	while (cur)
	{
		cur = NextObj(cur);
		++j;
	}

	if (j != actualNum)
	{
		int x = 0;
	}

	_spanLists[index]._mtx.unlock();

	return actualNum;
}

void CentralCache::ReleaseListToSpans(void* start, size_t size)
{
	size_t index = SizeClass::Index(size);
	_spanLists[index]._mtx.lock();
	while (start)
	{
		void* next = NextObj(start);

		Span* span = PageCache::GetInstance()->MapObjectToSpan(start);
		NextObj(start) = span->_freeList;
		span->_freeList = start;
		span->_useCount--;

		// 说明span的切分出去的所有小块内存都回来了
		// 这个span就可以再回收给page cache,pagecache可以再尝试去做前后页的合并
		if (span->_useCount == 0)
		{
			_spanLists[index].Erase(span);
			span->_freeList = nullptr;
			span->_next = nullptr;
			span->_prev = nullptr;

			// 释放span给page cache时,使用page cache的锁就可以了
			// 这时把桶锁解掉
			_spanLists[index]._mtx.unlock();

			PageCache::GetInstance()->_pageMtx.lock();
			PageCache::GetInstance()->ReleaseSpanToPageCache(span);
			PageCache::GetInstance()->_pageMtx.unlock();

			_spanLists[index]._mtx.lock();
		}

		start = next;
	}

	_spanLists[index]._mtx.unlock();
}

pagecache框架

pc也有哈希桶,pc是按照页数的个数作为下表,1到128,最大为128页。

pc哈希桶里面的都是按照页数来映射的,span内部不会切分小块,在第几号桶里面的span大小就是几页。

pagecache实现

newspan函数就是获取span了,参数k就是几页的意思,如果k大于128页就直接系统调用函数开辟,把页号和span进行映射,再去k号桶检查是否有span,找到后就把从页号到页号+页数和span逐一进行映射,如果没有就下部,往下找,找更大的桶进行拆分,比如申请1页大小,但是只有127有,则就把127变成1和126,把126插入126下标桶中,放回1页的span,这里的拆分就是把_n的数量从127到126,因为首地址+_n就是end,所以127页大小就是首地址+127在<<13就是127页的区间地址,则把首地址给1页span,新span的_n为1,则就是一页大小,把旧span-1就是126页大小,把要放回的span,从span到span+n的页号进行逐一映射,因为cc中有根据页号判断这个span是否所有的切分都使用结束的情况。最后面就是pc没有空间了,需要用系统调用函数申请128页空间,申请好后旧插入到128页桶中,然后递归重新再来,在拆分那里就会得到需要的span。

MapObjectToSpan函数就是通过地址转换成页号,然后在哈希表里面去找span。

ReleaseSpanToPageCache函数,这里如果释放的span大于128页,直接返回给堆,接着就是合并,先向前找,通过页号-1来得到前一个页号,然后先去映射表找,没有就退出,接着判断isuse是否为true,为true就是再使用也break,两个总和大于128也break,都没问题就可用合并,把当前页号变为前一个页号,页数加上前面页数合成新的页数,在spanlist中删除前一个span,因为已经和当前span合体了,重新循环继续向前,直到break。先后找也是一个逻辑。while循环结束,就把新的span插入到spanlist中,更新isuse为false未使用,头尾进行映射。

cpp 复制代码
#include "PageCache.h"

PageCache PageCache::_sInst;

// 获取一个K页的span
Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0);

	// 大于128 page的直接向堆申请
	if (k > NPAGES-1)
	{
		void* ptr = SystemAlloc(k);
		//Span* span = new Span;
		Span* span = _spanPool.New();

		span->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
		span->_n = k;

		_idSpanMap[span->_pageId] = span;
		return span;
	}

	// 先检查第k个桶里面有没有span
	if (!_spanLists[k].Empty())
	{
		Span* kSpan = _spanLists[k].PopFront();

		// 建立id和span的映射,方便central cache回收小块内存时,查找对应的span
		for (PAGE_ID i = 0; i < kSpan->_n; ++i)
		{
			_idSpanMap[kSpan->_pageId + i] = kSpan;
		}

		return kSpan;
	}

	// 检查一下后面的桶里面有没有span,如果有可以把他它进行切分
	for (size_t i = k+1; i < NPAGES; ++i)
	{
		if (!_spanLists[i].Empty())
		{
			Span* nSpan = _spanLists[i].PopFront();
			//Span* kSpan = new Span;
			Span* kSpan = _spanPool.New();

			// 在nSpan的头部切一个k页下来
			// k页span返回
			// nSpan再挂到对应映射的位置
			kSpan->_pageId = nSpan->_pageId;
			kSpan->_n = k;

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

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


			// 建立id和span的映射,方便central cache回收小块内存时,查找对应的span
			for (PAGE_ID i = 0; i < kSpan->_n; ++i)
			{
				_idSpanMap[kSpan->_pageId + i] = kSpan;
			}

			return kSpan;
		}
	}

	// 走到这个位置就说明后面没有大页的span了
	// 这时就去找堆要一个128页的span
	//Span* bigSpan = new Span;
	Span* bigSpan = _spanPool.New();
	void* ptr = SystemAlloc(NPAGES - 1);
	bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
	bigSpan->_n = NPAGES - 1;

	_spanLists[bigSpan->_n].PushFront(bigSpan);

	return NewSpan(k);
}

Span* PageCache::MapObjectToSpan(void* obj)
{
	PAGE_ID id = ((PAGE_ID)obj >> PAGE_SHIFT);

	std::unique_lock<std::mutex> lock(_pageMtx);

	auto ret = _idSpanMap.find(id);
	if (ret != _idSpanMap.end())
	{
		return ret->second;
	}
	else
	{
		assert(false);
		return nullptr;
	}
}

void PageCache::ReleaseSpanToPageCache(Span* span)
{
	// 大于128 page的直接还给堆
	if (span->_n > NPAGES-1)
	{
		void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
		SystemFree(ptr);
		//delete span;
		_spanPool.Delete(span);

		return;
	}

	// 对span前后的页,尝试进行合并,缓解内存碎片问题
	while (1)
	{
		PAGE_ID prevId = span->_pageId - 1;
		auto ret = _idSpanMap.find(prevId);
		// 前面的页号没有,不合并了
		if (ret == _idSpanMap.end())
		{
			break;
		}

		// 前面相邻页的span在使用,不合并了
		Span* prevSpan = ret->second;
		if (prevSpan->_isUse == true)
		{
			break;
		}

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

		span->_pageId = prevSpan->_pageId;
		span->_n += prevSpan->_n;

		_spanLists[prevSpan->_n].Erase(prevSpan);
		//delete prevSpan;
		_spanPool.Delete(prevSpan);
	}

	// 向后合并
	while (1)
	{
		PAGE_ID nextId = span->_pageId + span->_n;
		auto ret = _idSpanMap.find(nextId);
		if (ret == _idSpanMap.end())
		{
			break;
		}

		Span* nextSpan = ret->second;
		if (nextSpan->_isUse == true)
		{
			break;
		}

		if (nextSpan->_n + span->_n > NPAGES-1)
		{
			break;
		}

		span->_n += nextSpan->_n;

		_spanLists[nextSpan->_n].Erase(nextSpan);
		//delete nextSpan;
		_spanPool.Delete(nextSpan);
	}

	_spanLists[span->_n].PushFront(span);
	span->_isUse = false;
	_idSpanMap[span->_pageId] = span;
	_idSpanMap[span->_pageId+span->_n-1] = span;
}

利用基数树性能优化

前面性能问题在数据量大了之后,unordered_map查找消耗大,还有锁的竞争大,所以就可以用基数树来代替哈希表,tcmalloc源码有三棵树,每棵树的层数是不一样的,有1,2,3层。

单层基数树

两层基数树

  1. 第一级:区域目录(Root)​

    • 你准备了一个有32个格子的档案柜(1 << 5)。这个柜子就是你的第一级索引。

    • 一本书的编号很长,比如 00010110 00111100 01010(共19位为例)。你决定取这个编号的​​前5位​ ​(00010)来决定这本书属于哪个区域。00010换算成十进制是 2,于是你知道,所有以00010开头的书,信息都放在第2个格子里。

  2. ​第二级:书架目录(Leaf)​

    • 你走到第2个格子,发现里面放的并不是书的位置,而是​​另一个小册子​ ​。这个小册子专门记录以00010开头的所有书。

    • 现在你用编号剩下的​​14位​ ​(1100011110001010)在这个小册子里查找。这个小册子也有很多行,行号就是这后14位组成的数字。在这个行里,你终于找到了这本书的具体位置:"西馆,科幻区,第108架"。

三层基数树

  1. 第一层:国家目录(Root - 大区)​

    • 你有一个总档案柜,只有​​8个格子​ ​(1 << 3)。每个格子代表一个​​大区​​(例如:亚洲区、欧洲区、北美区...)。

    • 你取书号的最前面的​​3位​ ​来决定这本书属于哪个大区。比如001开头的书都属于"亚洲区"。

  2. ​第二层:城市目录(Middle - 分区)​

    • 你走到"亚洲区"的格子,里面放的并不是书目,而是另一个档案柜。这个柜子有​​16个格子​ ​(1 << 4),每个格子代表亚洲区下的一个​​分区​​(例如:东亚分区、东南亚分区...)。

    • 你取书号的​​接下来4位​ ​,来决定这本书属于亚洲区的哪个分区。比如0011表示"东亚分区"。

  3. ​第三层:具体图书馆目录(Leaf - 馆藏)​

    • 你走到"东亚分区"的格子,里面终于放着一本​​馆藏目录册​​了。这个册子记录着所有存放在东亚地区图书馆的书籍。

    • 你用书号​​最后剩余的位​​(比如64-3-4=57位)在这个馆藏目录册里查找,最终找到这本书的具体位置:"中国,北京市,某图书馆,3楼A区第101架"。

​这个过程就是三层基数树的工作原理​​:

  • ​页号​​ = 超长的完整书编号(例如64位)

  • ​第一层数组(Root)​ ​:用页号的​​最高几位​​(e.g., 3位)索引,找到对应的"大区"档案柜(第二层数组的指针)。

  • ​第二层数组(Middle)​ ​:用页号的​​中间几位​​(e.g., 4位)索引,在上一步找到的"大区"柜子里,找到对应的"分区"格子(第三层数组的指针)。

  • ​第三层数组(Leaf)​ ​:用页号的​​最低位​ ​(所有剩余位)索引,在上一步找到的"分区"格子里,最终找到你想要的数据(​​Span指针​​)。

总流程

相关推荐
报错小能手3 小时前
linux学习笔记(18)进程间通讯——共享内存
linux·服务器·前端
. . . . .3 小时前
数据库迁移migration
数据库
shixian10304113 小时前
Django 学习日志
数据库·学习·sqlite
de之梦-御风3 小时前
【Linux】 开启关闭MediaMTX服务
linux·运维·服务器
Morphlng3 小时前
wstunnel 实现ssh跳板连接
linux·服务器·网络·ssh
IT 小阿姨(数据库)4 小时前
PostgreSQL通过pg_basebackup物理备份搭建流复制备库(Streaming Replication Standby)
运维·服务器·数据库·sql·postgresql·centos
小蒜学长5 小时前
springboot基于javaweb的小零食销售系统的设计与实现(代码+数据库+LW)
java·开发语言·数据库·spring boot·后端
云边有个稻草人5 小时前
从内核调优到集群部署:基于Linux环境下KingbaseES数据库安装指南
linux·数据库·金仓数据库管理系统
EnCi Zheng5 小时前
JPA 连接 PostgreSQL 数据库完全指南
java·数据库·spring boot·后端·postgresql