高并发内存池(三):CentralCache与PageCache的实现

目录

​Span

页与页号

WIN32、_WIN32、_W64的区别

 条件编译

初步实现SpanList的功能函数

CentralCache的实现

主体框架

为ThreadCache分配内存结点

补充内容1

补充内容2 

FetchRangeObj函数的实现

申请一个span

补充内容1

补充内容2

GetOneSpan函数的实现

PageCache的实现

主体框架

关于整体锁的解释

Span的分裂

Span的合并

获取新span

补充内容1

补充内容2

NewSpan函数的实现

加解锁问题


​Span

基本概念:管理大块连续内存(由一个或多个页组成)的数据结构,

struct Span
{
	size_t _PageId = 0;//当前span管理的连续页的起始页的页号
	size_t _n = 0;//当前span管理的页的数量

	Span* _next = nullptr;
	Span* _prev = nullptr;

	size_t _useCount = 0;//当前span中切好小块内存,被分配给thread cache的数量
	void* _freelist = nullptr; //管理当前span切分好的小块内存的自由链表
};

页与页号

OS进行内存管理的基本单位是页,一页的大小通常是4 KB,但也可能更大

32位环境下进程地址空间是4GB,即2^32 = 4,294,967,296 字节

64位环境下进程地址空间是2^34GB,即2^64 = 很大的字节

若规定一页为8KB,那么在32位环境下一共有2^32 / 2^13 = 2^19个页,在64位环境下一共有2^64 / 2^13 = 2^51个页

WIN32、_WIN32、_W64的区别

_WIN32 是一个预定义宏,在所有 Windows 平台上都会定义,无论是 32 位还是 64 位 Windows 系统

功能:区分当前的执行环境是否是 Windows 平台

#ifdef _WIN32
    // win32或win64环境
#else
    // 不是win的环境
#endif

_WIN64 是一个预定义宏,仅在 64 位 Windows 平台上定义

功能:区分当前的执行环境是否是Windows 64平台

#ifdef _WIN64
    // win64环境
#else
    // win32环境或其它环境
#endif

WIN32是一个历史遗留的宏,在较早的 Windows 编程中常被用来表示 Windows API。然而,它并不总是自动定义的,特别是在现代编译环境中,使用 WIN32 宏的地方通常是开发者自己手动定义的,或者是特定库或项目中用来标识使用 Win32 API 的代码段

功能:声明当前代码依赖于 Win32 API 

#ifdef WIN32
    // Code that uses Win32 API
#endif

在实际项目中,WIN32 可能需要手动在项目设置或代码中定义,如:
#define WIN32

 条件编译

//仅考虑windows平台
#ifdef _WIN64
	typedef unsigned long long PAGE_ID;//64位机器下取用unsigned long long,它的取值范围是2^64
#elif _WIN32
	typedef size_t PAGE_ID;//32位机器下取用size_t,它的取值范围是2^32
#endif
  • windows32位环境下_WIN64无定义,_WIN32有定义,windows64位环境下,二者均有定义
  • 我看了网上有些高并发内存池项目会在这里添加一个运行环境判断的条件编译,即在windows64环境下页号的类型PAGE_ID为unsigned long long,windows32环境下页号的类型为size_t,在32位下选择取值范围为2^32-1的size_t而不是2^64-1的unsigned long long可以理解为不需要那么大的变量来存放页号,但是64位环境下size_t的范围也会变为2^64-1为什么还要再去选择unsigned long long呢?所以我这里的建议是不用加

初步实现SpanList的功能函数

//管理某个桶下所有span的数据结构(带头双向循环链表)
class SpanList
{
public:
	//构造初始的SpanList
	SpanList()
	{
		_head = new Span;
		_head->_next = _head;
		_head->_prev = _head;
	}

	//在pos位置前插入
	//位置描述:prev newspan pos
	void Insert(Span* pos, Span* newSpan)
	{
		assert(pos && newSpan);
		Span* prev = pos->_prev;
		prev->_next = newSpan;
		newSpan->_prev = prev;
		newSpan->_next = pos;
		pos->_prev = newSpan;
	}

	//删除pos位置的span
	//位置描述:prev pos next
	void Erase(Span* pos)
	{
		assert(pos && pos != _head);//指定位置不能为空且删除位置不能是头节点
	
		//暂存一下位置
		Span* prev = pos->_prev;
		Span* next = pos->_next;

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

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

CentralCache的实现

补充内容不再说明需要在哪里补充函数的声明,当前阶段的所有函数声明都在主体框架中

基本概念:每个桶下的挂的是双向循环链表SpanList,便于插入和删除span,因为要CentralCache既要分配和回收ThreadCache的内存,又要向PageCache申请内存,会有频繁的span插入和删除操作

桶锁机制的解释:当多个线程向自己的TreadCache中的相同位置的桶申请内存失败时,就会并发的向CentralCache中相应位置的桶申请空间,此时就需要加锁,避免线程安全问题(t1线程向其threadcache的2号桶申请空间时该桶为空,此时若t2线程也向其threadcache的2号桶申请空间且该桶也为空,那么二者就会并发向CentralCache的2号桶申请空间,此时就要加锁,谁先拿到锁谁就先获取到空间)

主体框架

基本概念:为保证一个进程在程序执行过程中只有一个CentralCache对象,我们要采用饿汉模式

饿汉模式的实现方式:

  1. 构造和拷贝构造函数私有化
  2. 创建静态成员变量并提供公有的调用函数
class CentralCache
{
public:
	//获取实例化好的CnetralCache类型的静态成员对象的地址
	static CentralCache* GetInstance()
	{
		return &_sInst;
	}

	//为ThreadCache分配一定数量的内存结点
	size_t FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size);

	//从SpanList中获取一个非空的span,如果SpanList没有则会去问PageCache申请
	Span* GetOneSpan(SpanList& list, size_t size);

	//将ThreadCache归还的重新挂在CentralCache中的某个span上
	void ReleaseListToSpans(void* start, size_t size);

private:
	SpanList _spanLists[NFREELIST];

	//单例模式的实现方式是构造函数和拷贝构造函数私有化
	CentralCache() {}
	CentralCache(const CentralCache&) = delete;

	static CentralCache _sInst;//静态成员变量在编译时就会被分配内存
};

注意事项:静态成员变量_sInst的定义不要放在头文件里,而是放在CentralCache.cpp中,否则如果这个头文件被多个源文件包含,那么每个包含该头文件的源文件都会生成 _sInst 的一个定义,这将导致链接器在链接阶段报错,提示符号重复定义

为ThreadCache分配内存结点

补充内容1

在FreeList类中新增PushRange函数,用于插入除分配给的头节点外的其它内存结点

(注意这里传入的start指向的是n个结点中头结点的下一个结点,头结点被拿去直接用了)

//一次性插入n个结点
void PushRange(void* start, void* end,size_t n)
{
	NextObj(end) = _freeList;
	_freeList = start;
}

补充内容2 

 在FetchFromCentralCache函数中新增一部分内容

//向CentralCache申请内存空间
void* ThreadCache::FetchFromCentralCache(size_t index, size_t size)
{
	//慢调节算法
	size_t batchNum = min(_freeLists[index].MaxSize(), SizeClass::NumMoveSize(size));//bathcNum ∈ [2,512]
	
	if (_freeLists[index].MaxSize() == batchNum)
	{
		_freeLists[index].MaxSize() += 1;
	}

	//上述部分是满调节算法得到的当前理论上一次性要向CentralCache申请的结点个数
	//下面是计算实际一次性可从CentralCache中申请到的结点个数

	//输出型参数,传入FetchRangeObj函数的是它们的引用,会对start和end进行填充
	void* start = nullptr;
	void* end = nullptr;

	//actualNum表示实际上可以从CentralCache中获取到的内存结点的个数
	size_t actualNum = CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, size);
	assert(actualNum >= 1);//actualNum必定会大于等于1,不可能为0,因为FetchRangeObj还有GetOneSpan函数
	if (actualNum == 1)
	{
		assert(start == end);//此时start和end应该都指向该结点
		return start;//直接返回start指向的结点即可
	}
	else
	{
		//如果从CentralCache中获取了多个内存结点,则将第一个返回给ThreadCache,然后再将剩余的内存挂在ThreadCache的自由链表中
		_freeLists[index].PushRange(NextObj(start), end);
		return start;
	}
}

FetchRangeObj函数的实现

注意事项:因为_spanLists是私有的,所以需要指明是CentralCache类下的FetchRangeObj函数才能在该函数中使用_spanLists,否则会出现_spanLists是未声明的标识符

//实际可以从CentralCache中获取到的内存结点的个数
size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size)
{
	size_t index = SizeClass::Index(size);
	Span* span = GetOneSpan(_spanLists[index], size);//获取一个非空span

	//span为空或者span管理的空间为空均不行
	assert(span);
	assert(span->_freelist);

	//尝试从span中获取batchNum个对象,若没有这么多对象的话,有多少就给多少
	start = end = span->_freelist;
	size_t actualNum = 1;//已经判断过的自由链表不为空,所以肯定有一个

	size_t i = 0;
	//NextObj(end) != nullptr用于防止actualNum小于bathcNum,循环次数过多时NexeObj(end)中的end为nullptr,导致的报错
	while (i < batchNum - 1 && NextObj(end) != nullptr)
	{
		end = NextObj(end);
		++i;
		++actualNum;
	}
	
	//更新当前span的自由链表中
	span->_freelist = NextObj(end);
	NextObj(end) = nullptr;

	span->_useCount += actualNum;//当前span中有actualNum个内存结点被分配给ThreadCache
	return actualNum;
}

补充:在GetOneSpan函数中,会先判断当前span所处的SpanList上是否有自由链表非空的span,有则会直接提供该span,如果没有就会去找PageCache要,正常情况下保底获取一个非空的span

申请一个span

补充内容1

        在SpanList类中新增了Begin、End、PushFront三个函数 

//返回指向链表头结点的指针
Span* Begin()
{
	return _head->_next;
}

//返回指向链表尾结点的指针
Span* End()
{
	return _head;
}

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

补充内容2

在SizeClass类中新增NumMovePage函数

//一次性要向堆申请多少个页
static size_t NumMovePage(size_t size)
{
	size_t batchnum = NumMoveSize(size);
	size_t npage = (batchnum * size) >> PAGE_SHIFT;//(最多可以分配的内存结点个数 * 单个内存结点的大小) / 每个页的大小
		
	if (npage == 0)//所需页数小于1,就主动给分配一个
		npage = 1;
	return npage;
}

GetOneSpan函数的实现

//获取非空span
Span* CentralCache::GetOneSpan(SpanList& list, size_t size)
{
	//遍历CentralCache当前桶中的SpanList寻找一个非空的span
	Span* it = list.Begin();
	while (it != list.End())
	{
		if (it->_freelist != nullptr) 
		{
			return it;
		}
		else
		{	
			it = it->_next;
		}
	}

    size_t k = SizeClass::NumMovePage(size);
    Span* span = PageCache::GetInstance()->NewSpan(k);
    
	//从PageCache中获取的span是没有进行内存切分的,需要进行切分并挂在其自由链表下

	//1、计算span管理下的大块内存的起始和结尾地址
	//起始地址 = 页号 * 页的大小
	
	char* start = (char*)(span->_PageId << PAGE_SHIFT);//选择char*而不是void*,为了后续+=size的时移动size个字节
	//假设span->_PageId = 5,PAGE_SHIFT = 13,5 >> 13 = 40960(字节)
	//整数值 40960 表示内存中的一个地址位置,通过 (char*) 显示类型转换后,start 就指向了这个内存地址,即span的起始地址

	char* end = (char*)(start + (span->_n << PAGE_SHIFT));//end指向span的结束地址,span管理的内存大小 = span中页的个数 * 页大小 

	//2、将start和end指向的大块内存切成多个小块内存,并尾插至自由链表中(采用尾插,使得即使被切割但在物理上仍为连续空间,加快访问速度)
	
	//①先切下来一块作为头结点,便于尾插
	span->_freelist = start;
	void* tail = start;
	start += size;
	
	//循环尾插
	while(start < end)
	{
		NextObj(tail) = start;//当前tail指向的内存块的前4/8个字节存放下一个结点的起始地址,即start指向的结点的地址
		start += size;//更新start
		tail = NextObj(tail);//更新tail
	}
	
	NextObj(tail) = nullptr;
	list.PushFront(span);//将获取到的span插入当前桶中的SpanList
	return span;//此时该span已经放在了CentralCache的某个桶的SpanList中了,返回该span的地址即可
}

PageCache的实现

基本概念:PageCache中的每个桶是按span中管理的页数进行映射的(直接映址法),即i号桶中都是管理i页的span(为了保证这一点桶的个数应该为129个0号桶不用),且span不会进行切分,PageCache的初始状态为全空(下图是程序已经开始执行了产生的效果)

主体框架

基本概念:与CentralCache一样的原因,也是用饿汉模式

class PageCache
{
public:
	static PageCache* GetInstance()
	{
		return &_sInst;
	}

	//获取一个管理k页的span,也许获取的是PageCache现存的span,也许是向堆新申请的span
	Span* NewSpan(size_t k);

	std::mutex _pageMtx;//pagecache不能用桶锁,只能用全局锁,因为后面可能会有span的合并和分裂
private:
	SpanList _spanLists[NPAGES];

	PageCache() {}
	PageCache(const PageCache&) = delete;

	static PageCache _sInst;
};

关于整体锁的解释

...以下内容是我让AI制造的场景,便于理解为什么不用桶锁...

场景描述:假设有 4 个线程 T1、T2、T3 和 T4,这些线程各自的 ThreadCache 中的内存不足,因此它们需要向 CentralCache 申请新的内存块。然而,CentralCache 中的相关桶已经无法满足需求,因此这 4 个线程必须同时向 PageCache 申请新的 span。

内存分配需求

  • T1 需要申请一个 4 页的 span
  • T2 需要申请一个 8 页的 span
  • T3 需要申请一个 2 页的 span
  • T4 需要申请一个 6 页的 span

PageCache 中目前只有两个空闲的 span:一个 8 页的 span 和一个 16 页的 span。

桶锁机制下的操作流程:

  1. 线程同时申请锁

    • T1、T2、T3 和 T4 同时尝试申请 PageCache 中不同桶的锁来获取内存。
    • PageCache 使用桶锁机制,因此每个线程试图加锁并访问自己需要的内存桶。
  2. 锁争用与分配

    • T1 成功获取了一个桶的锁,并分配了 4 页的 span。此时,PageCache 中 8 页的 span 被分配了 4 页,剩余 4 页。
    • T2 试图申请 8 页的 span,但发现其中的 4 页已经被 T1 占用,无法满足其需求。于是,T2 释放锁并尝试申请下一个可用的 span(即 16 页的 span)。
    • T3 成功获取了另一个桶的锁,并分配了 2 页的 span。此时,16 页的 span 被分配了 2 页,剩余 14 页。
    • T4 需要 6 页的 span,它试图申请 16 页的 span,并成功分配了 6 页。此时,16 页的 span 剩余 8 页。
  3. 复杂的锁释放与重新申请

    • T2 最初试图申请 8 页的 span 失败后,它释放了最初的锁,并重新尝试申请 16 页的 span。由于 T4 已经使用了 6 页,T2 可以使用剩下的 8 页的 span。这要求 T2 再次获取新的锁并进行分配。
    • 由于所有线程都在并发运行,它们频繁地获取和释放不同桶的锁,这导致了大量的锁竞争和上下文切换。每次 T2 失败后,它都必须重新尝试申请下一个可用的 span,这种反复操作使得加解锁操作频繁发生。
  4. 频繁加解锁带来的问题

    • 锁争用:由于 T1、T2、T3 和 T4 在不同的时间点争用不同的锁,系统必须频繁地处理锁申请和释放。这种锁争用增加了每个线程在申请内存时的等待时间。
    • 上下文切换:当一个线程无法获取所需的锁时,它会被阻塞,等待其他线程释放锁。这种情况下,系统可能会频繁地切换上下文,保存和恢复线程的执行状态,这不仅消耗 CPU 资源,还可能导致 CPU 缓存失效,从而降低系统的整体性能。
    • 内存碎片化:多个线程同时尝试分配内存块,并且桶锁机制只允许线程在各自的桶中操作,可能会导致内存碎片化问题。例如,T2 的 8 页需求在多次尝试后,可能不得不分配两个不连续的 4 页 span,这会增加内存管理的复杂性和后续内存操作的开销。
  5. 总开销累积

    • 加解锁开销:在这种高并发场景下,频繁的锁操作增加了系统的锁管理开销,尤其是在多个线程频繁竞争同一资源的情况下。每次加锁和解锁都会引发系统调用或内核级别的操作,增加了 CPU 的负担。
    • 系统瓶颈:当线程频繁争用资源并且系统必须处理复杂的分配和锁管理时,CPU 资源被大量消耗在锁的管理和上下文切换上,而不是实际的工作任务上。最终,这种场景可能成为系统的性能瓶颈,导致吞吐量下降和响应时间延长。

结论:在这个复杂的场景下,多个线程(T1、T2、T3 和 T4)同时向 PageCache 申请内存块,并且桶锁机制导致频繁的锁争用和上下文切换。这种情况会显著增加 CPU 的消耗,导致系统性能下降。频繁的加解锁操作不仅浪费了大量的 CPU 时间,还可能导致内存碎片化和系统瓶颈问题。在这种高并发环境下,考虑采用整体锁或者优化锁的粒度,可能会更好地平衡并发性能与锁管理的开销

Span的分裂

① 当Central Cache向Page Cache申请内存时,Page Cache先检查对应桶(会计算分配的页数,以它判断位置的标准)位置有没有span,没有则寻找一个管理更多页的span,然后将该span管理的页进行分裂(申请的是4页span但没有,找到了一个管理10页的span,就将该span分为一个管理4页和6页的span)

② 如果一直没找到则PageCache要使用mmap、brk或者是VirtualAlloc等方式向堆申请一个管理128页的span,然后再分裂span(128 -> 2 + 126)

Span的合并

①每当Central Cache归还回一个span,则在PageCache中判断能否与其相邻页合并成一个管理更多页的span(必须相邻,因为span管理的是连续的内存),从而避免每次没有合适的大块span就要向堆申请,

合并规则:某一侧的相邻页不为空闲就停下,直到两侧的相邻页均不空闲就停止扩展

获取新span

补充内容1

在SpanList类中新增Empty和PopFront函数

//头删
Span* PopFront()
{
	Span* front = _head->_next;//_head->_next指向的是那个有用的第一个结点而不是哨兵位
	Erase(front);
	return front;//删掉后就要用,所以要返回删掉的那块内存的地址	
}

//判断是否为空
bool Empty()
{
	return _head->_next == _head;
}

补充内容2

将SystemAlloc函数从定长内存池的头文件ObjectPool.h中移到公共头文件Common.h中

//Windows环境下通过封装Windows提供的VirtualAlloc函数,直接向堆申请以页为单位的内存,而不使用malloc/new
inline static void* SystemAlloc(size_t kpage)//kpage表示页数
{
#ifdef _WIN32
	void* ptr = VirtualAlloc(0, kpage << PAGE_SHIFT, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#endif
	if (ptr == nullptr)
		throw std::bad_alloc();//申请失败的抛异常
	return ptr;
}

NewSpan函数的实现

切分规则:

  1. 切分成一个管理k页的span和一个n-k页的span
  2. 管理k页的span分配给CentralCache
  3. 管理n-k页的span挂到PageCache的第n-k号桶中
//获取一个非空span
Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0 && k < NPAGES);
	
    //先检查PageCache的第k个桶中有没有span,有就直接头删并返回
	if (!_spanLists[k].Empty())
	{
		return _spanLists[k]->PopFront();
	}

	//走到这儿代表k号桶为空,检查后面的桶有没有大的span,分裂一下
	for (size_t i = k + 1; i < NPAGES; ++i)//因为第一个要询问的肯定是k桶的下一个桶所以i = k + 1
	{
		//后续大桶有span,执行span的分裂
		if (!_spanLists[i].Empty())
		{
			Span* nSpan = _spanLists[i].PopFront();
			Span* kSpan = new Span;

			//在nSpan头部切一个k页的span下来
			kSpan->_PageId = nSpan->_PageId;
			kSpan->_n = k;

			nSpan->_PageId += k;//nSpan管理的首页页号变为了i + k
			nSpan->_n -= k;//nSpan管理的页数变为了i - k

			_spanLists[nSpan->_n].PushFront(nSpan);将nSpan重新挂到PageCache中的第nSpan->_n号桶中,即第i - k号桶

			return kSpan;
		}
	}

	//走到这里就说明PageCache中没有合适的span了,此时就去找堆申请一个管理128页的span
	Span* bigSpan = new Span;
	void* ptr = SystemAlloc(NPAGES - 1);//ptr指向从堆分配的内存空间的起始地址
	bigSpan->_PageId = (size_t)ptr >> PAGE_SHIFT;//页号 = 起始地址 / 页大小
	bigSpan->_n = NPAGES - 1;

	_spanLists[bigSpan->_n].PushFront(bigSpan);
	return NewSpan(k);//重新调用一次自己,那么此时PageCache中就有一个管理k页的span了,可以从PageCache中直接分配了,在for循环中就会return
	//可以代码复用,且循环只有128次的递归消耗的资源很小
}

 递归调用NewSpan有点🐂,反正我想不出来

加解锁问题

多线程环境下如果不加锁会出现问题:

①初始链表状态:

[Head] -> [Block1] -> [Block2] -> [Block3] -> ...

两个线程并发申请内存:

  1. 线程A 读取了 Head,发现 Head 指向 Block1,并准备将 Head 更新为 Block2
  2. 线程B 在几乎同一时刻也读取了 Head,也发现 Head 指向 Block1
  3. 线程A 完成了操作,将 Head 更新为 Block2,并返回了 Block1 给调用者。
  4. 线程B 随后也完成了操作,它并没有意识到 Head 已经被线程A更新,所以它还是认为 Head 指向的是 Block1,并将 Block1 返回给它的调用者。

        在这个过程中,两个线程分别都获取了 Block1,这是因为两者几乎同时读取到了相同的 Head 值(Block1)。虽然线程A先更新了 Head,但由于线程B已经读到了旧的 Head 值,它认为自己获取的也是 Block1,最终就导致两个线程都拿到了同一个内存块,导致同一块内存被重复分配

t1线程在PageCache中分裂span时,刚分裂完得到自己想要的span就停止了,t2线程也需要管理同样页数的span,然后刚好看见了t1分裂好的span就把这个span拿走了,等到t1线程回来时发现自己分裂好的span没了

因此适当的加解锁可以避免线程冲突问题

请查看下面的文字和图片说明进行加解锁代码的添加

加锁位置1:FetchRangeObj中执行GetOneSpan前

解锁位置1:GetOneSpan中执行size_t k = SizeClass::NumMovePage(size)前

加锁位置2:GetOneSpan中执行NewSpan前

解锁位置2:GetOneSpan中执行NewSpan后

加锁位置3:GetOneSpan中执行PushFront前

解锁位置3:FetchRangeObj中执行返回actualNum前

 加锁后代码会在后面阶段性总结时出现,这里就不额外展示了,知道需要加锁和加锁位置即可

~over~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值