高并发内存池

目录

一、项目介绍

二、内存池介绍

1、内存池概念

2、内存池主要解决的问题

3、malloc

三、设计一个定长内存池

1、整体框架

2、内存池如何管理释放的对象

3、在内存池中我们如何申请对象 

4、定长内存池整体代码如下

5、性能对比 

四、高并发内存池整体框架

1、三个部分构成整体框架,那分别有什么作用呢

五、Threadcache

1、threadcache整体设计

2、threadcache哈希桶映射对齐规则 

3、对齐和映射相关函数的编写

4、ThreadCache类

5、threadcacheTLS无锁访问

 六、CentralCache

1、centralcache整体设计

 2、centralcache结构设计

(1)span的结构

(2)双链表的结构

(3)central cache的结构

3、centralcache核心实现

(1)慢开始反馈调节算法

(2)从中心缓存获取对象

 (3)从中心缓存获取一定数量的对象

(4)插入一段范围的对象到自由链表

七、pagecache 

1、pagecache和centralcache异同

2、在page cache获取一个n页的span的过程

3、page cache的实现方式

4、获取Span

5、从pagecache获取一个k页的span

八、回收内存

1、ThreadCache回收内存

2、CentralCache回收内存

(1)如何页找到对应的Span

(2)回收内存ReleaseListToSpans

3、pagecache回收内存

(1)page cache进行前后页的合并

九、大于256KB的大块内存申请和释放问题

1、申请过程

2、释放过程

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

十一、释放对象时优化为不传对象大小


一、项目介绍

当前项⽬是实现⼀个⾼并发的内存池,他的原型是google的⼀个开源项⽬tcmalloc,tcmalloc全称
Thread-Caching Malloc,即线程缓存的malloc,实现了⾼效的多线程内存管理,⽤于替代系统的内存分配相关的函数(malloc、free)。

tcmalloc的知名度也是非常高的,不少公司都在用它,比如Go语言就直接用它做了自己的内存分配器。

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

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

二、内存池介绍

1、内存池概念

在说内存池之前,我们得先了解一下“池化技术”。所谓“池化技术”,就是程序先向系统申请过量的资源,然后自己进行管理,以备不时之需。

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

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


内存池是指程序预先向操作系统申请一块足够大的内存,此后,当程序中需要申请内存的时候,不是直接向操作系统申请,而是直接从内存池中获取;同理,当释放内存的时候,并不是真正将内存返回给操作系统,而是将内存返回给内存池。当程序退出时(或某个特定时间),内存池才将之前申请的内存真正释放。

2、内存池主要解决的问题

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

内存碎片分为内部碎片和外部碎片:

  • 外部碎片是一些空闲的小块内存区域,由于这些内存空间不连续,以至于合计的内存足够,但是不能满足一些内存分配申请需求。
  • 内部碎片是由于一些对齐的需求,导致分配出去的空间中一些内存无法被利用。

C/C++中我们要动态申请内存并不是直接去堆申请的,而是通过malloc函数去申请的,包括C++中的new实际上也是封装了malloc函数的。

3、malloc

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

malloc的实现方式有很多种,一般不同编译器平台用的都是不同的。比如Windows的VS系列中的malloc就是微软自行实现的,而Linux下的gcc用的是glibc中的ptmalloc。

三、设计一个定长内存池

malloc其实就是一个通用的内存池,在什么场景下都可以使用,但这也意味着malloc在什么场景下都不会有很高的性能,因为malloc并不是针对某种场景专门设计的。

定长内存池就是针对固定大小内存块的申请和释放的内存池,由于定长内存池只需要支持固定大小内存块的申请和释放,因此我们可以将其性能做到极致,并且在实现定长内存池时不需要考虑内存碎片等问题,因为我们申请/释放的都是固定大小的内存块。

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


1、整体框架

  1. 定长内存池,我们需要一个指向内存的指针,便于我们管理这个内存池。

在向内存池申请内存的时候,假如只剩下1个字节,但我们需要4个字节的内存,那该怎么办?

2.所以我们需要一个变量,控制内存池的剩余字节数。

3、某个进程结束了并不会释放该内存,而会还回原来的内存池,我们设置一个自由链表,管理还回来的内存块。方便后续重复使用

template <class T>
class MemoryPool
{
public:
	T* New();
    void Delete(T* obj);
private:
    void* FreeList = nullptr;         //管理还回来的内存块的自由链表
    char* Memory_pointer = nullptr;//指向内存的指针
    size_t SurplusBytes = 0;  //内存池剩余的字节数

2、内存池如何管理释放的对象

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

因此在向自由链表插入被释放的内存块时,先让该内存块的前4个字节或8个字节存储自由链表中第一个内存块的地址,然后再让_freeList指向该内存块即可,也就是一个简单的链表头插操作。

如何让一个指针在32位平台下解引用后能向后访问4个字节,在64位平台下解引用后能向后访问8个字节? 

首先我们得知道,32位平台下指针的大小是4个字节,64位平台下指针的大小是8个字节。而指针指向数据的类型,决定了指针解引用后能向后访问的空间大小,因此我们这里需要的是一个指向指针的指针,这里使用二级指针就行了。

例如_freeList是一个void*的指针,如果这个_freeList为空,让一个内存块连接上去,void* obj = -freeList。如果这个_freeList不为空,我们头插进去,就取头内存块的4个字节或8个字节存放下一个内存块的地址,这就起到了链表的效果。头内存块是void*类型,*(void**)_freeList即能拿到头内存块的前4/8个字节。

需要注意的是,在释放对象时,我们应该显示调用该对象的析构函数清理该对象,因为该对象可能还管理着其他某些资源,如果不对其进行清理那么这些资源将无法被释放,就会导致内存泄漏。

3、在内存池中我们如何申请对象 

当我们申请对象时,内存池应该优先把还回来的内存块对象再次重复利用,因此如果自由链表当中有内存块的话,就直接从自由链表头删一个内存块进行返回即可。

 如果_FreeList自由链表里没有内存块,那么我们就在大块内存中切出定长的内存块进行返回,当内存块切出后及时更新Memory_pointer指针的指向

需要特别注意的是,由于当内存块释放时我们需要将内存块链接到自由链表当中,因此我们必须保证切出来的对象至少能够存储得下一个地址,所以当对象的大小小于当前所在平台指针的大小时,需要按指针的大小进行内存块的切分。 

此外,当大块内存已经不足以切分出一个对象时,我们就应该调用我们封装的SystemAlloc函数,再次向堆申请一块内存空间,这里我们先直接用malloc,等后面测试效率的时候,我们再直接从堆上申请。

	T* New()
	{
		T* obj = nullptr;
	    if (FreeList)         //如果FreeList有对象 直接从首部取走一个
	    {
			obj = (T*)FreeList; 
			FreeList = *(void**)FreeList;
		}
		else //FreeList内没有内存块
		{
			if (SurplusBytes < sizeof(T))
			{
				SurplusBytes = 1024 * 128; //128kb
				Memory_pointer = (char*)malloc(SurplusBytes);//剩余内存不够一个对象大小时,则重新开大块空间
				if (Memory_pointer == nullptr)
				{
					throw std::bad_alloc();
				}
			}
			obj = (T*)Memory_pointer;
			size_t SizeObj = sizeof(T) > sizeof(obj) ? sizeof(T) : sizeof(void*);//保证对象能够存储得下地址
			SurplusBytes -= SizeObj;  
			Memory_pointer += SizeObj;
		}
		new(obj)T;
		return obj;
	}

4、定长内存池整体代码如下

#pragma once
#include <iostream>
#include <vector>
#include <time.h>
using std::cout;
using std::endl;
template <class T>
class MemoryPool
{
public:
	T* New()
	{
		T* obj = nullptr;
	    if (FreeList)         //如果FreeList有对象 直接从首部取走一个
	    {
			obj = (T*)FreeList; 
			FreeList = *(void**)FreeList;
		}
		else //FreeList内没有内存块
		{
			if (SurplusBytes < sizeof(T))
			{
				SurplusBytes = 1024 * 128; //128kb
				Memory_pointer = (char*)malloc(SurplusBytes);//剩余内存不够一个对象大小时,则重新开大块空间
				if (Memory_pointer == nullptr)
				{
					throw std::bad_alloc();
				}
			}
			obj = (T*)Memory_pointer;
			size_t SizeObj = sizeof(T) > sizeof(obj) ? sizeof(T) : sizeof(void*);//保证对象能够存储得下地址
			SurplusBytes -= SizeObj;  
			Memory_pointer += SizeObj;
		}
		new(obj)T;
		return obj;
	}
	void Delete(T* obj)
	{
		// 显⽰调⽤的T的析构函数进⾏清理
		obj->~T();
		// 头插到FreeList
		*((void**)obj) = FreeList;
		FreeList = obj;
	}
private:
	void* FreeList = nullptr;         //管理还回来的内存块的自由链表
	char* Memory_pointer = nullptr;//指向内存的指针
	size_t SurplusBytes = 0;  //内存池剩余的字节数
};

5、性能对比 

下面我们将实现的定长内存池和malloc/free进行性能对比,测试代码如下:

struct TreeNode
{
	int _val;
	TreeNode* _left;
	TreeNode* _right;
	TreeNode()
		:_val(0)
		, _left(nullptr)
		, _right(nullptr)
	{
	}
};
void TestMemoryPool()
{
	std::vector<TreeNode*> v1;
	//申请释放多少轮
	const int rounds = 10;
	//每轮申请多少次
	const int N = 1000000;
	v1.reserve(N);
	int begin1 = clock();
	for (int i = 0; i < rounds; i++)
	{
		for (int j = 0; j < N; j++)
		{
			v1.push_back(new TreeNode());
		}
		for (int j = 0; j < N; j++)
		{
			delete v1[j];
		}
		v1.clear();
	}
	int end1 = clock();

	MemoryPool<TreeNode> TNPool;
	std::vector<TreeNode*> v2;
	v2.reserve(N);
	int begin2 = clock();
	for (int i = 0; i < rounds; i++)
	{
		for (int j = 0; j < N; j++)
		{
			v2.push_back(TNPool.New());
		}
		for (int j = 0; j < N; j++)
		{
			TNPool.Delete(v2[j]);
		}
		v2.clear();
	}
	int end2 = clock();
	cout << "new cost time:" << end1 - begin1 << endl;
	cout << "object pool cost time:" << end2 - begin2 << endl;
}

运行结果如下:

可以看到在这个过程中,定长内存池消耗的时间比malloc/free消耗的时间要短。这就是因为malloc是一个通用的内存池,而定长内存池是专门针对申请定长对象而设计的,因此在这种特殊场景下定长内存池的效率更高,正所谓“尺有所短,寸有所长”。 

四、高并发内存池整体框架

现代很多的开发环境都是多核多线程,因此在申请内存的时,必然存在激烈的锁竞争问题。malloc本身其实已经很优秀了,但是在并发场景下可能会因为频繁的加锁和解锁导致效率有所降低,而该项目的原型tcmalloc实现的就是一种在多线程高并发场景下更胜一筹的内存池。

在实现内存池时我们一般需要考虑到效率问题和内存碎片的问题,但对于高并发内存池来说,我们还需要考虑在多线程环境下的锁竞争问题。

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

  • Thread Cache: 线程缓存是每个线程独有的,用于小于等于256KB的内存分配,每个线程独享一个thread cache。
  • central cache:中心缓存是每个线程共享的当thread cache需要内存时会按需从central cache中获取内存,而当thread cache中的内存满足一定条件时,central cache也会在合适的时机对其进行回收。
  • page cache:页缓存中存储的内存是以页为单位进行存储及分配的,当central cache需要内存时,page cache会分配出一定数量的页分配给central cache,而当central cache中的内存满足一定条件时,page cache也会在合适的时机对其进行回收,并将回收的内存尽可能的进行合并,组成更大的连续内存块,缓解内存碎片的问题。

每一个线程都有属于自己的thread cache,说明thread cache申请内存时是不要进行加锁的,一次性获取256KB内存的情况很少,因此大部分情况申请内存是无锁的,这就提高了效率。

每个线程的thread cache会根据自己的情况向central cache申请或归还内存,这就避免了出现单个线程的thread cache占用太多内存,而其余thread cache出现内存吃紧的问题。

多线程的thread cache可能会同时找central cache申请内存,此时就会涉及线程安全的问题,因此在访问central cache时是需要加锁的,但central cache实际上是一个哈希桶的结构,只有当多个线程同时访问同一个桶时才需要加锁,所以这里的锁竞争也不会很激烈。

1、三个部分构成整体框架,那分别有什么作用呢

  1. thread cache主要解决锁竞争的问题,每个线程独享自己的thread cache,当自己的thread cache中有内存时该线程不会去和其他线程进行竞争,每个线程只要在自己的thread cache申请内存就行了。
  2. central cache主要起到一个居中调度的作用,每个线程的thread cache需要内存时从central cache获取,而当thread cache的内存多了就会将内存还给central cache,其作用类似于一个中枢,因此取名为中心缓存。
  3. page cache就负责提供以页为单位的大块内存,当central cache需要内存时就会去向page cache申请,而当page cache没有内存了就会直接去找系统,也就是直接去堆上按页申请内存块。

五、Threadcache

1、threadcache整体设计

定长内存池只支持固定大小内存块的申请释放,因此定长内存池中只需要一个自由链表管理释放回来的内存块。现在我们要支持申请和释放不同大小的内存块,那么我们就需要多个自由链表来管理释放回来的内存块,因此thread cache实际上一个哈希桶结构,每个桶中存放的都是一个自由链表。

thread cache支持小于等于256KB内存的申请,如果我们将每种字节数的内存块都用一个自由链表进行管理的话,那么此时我们就需要20多万个自由链表,光是存储这些自由链表的头指针就需要消耗大量内存,这显然是得不偿失的。

这时我们可以选择做一些平衡的牺牲,让这些字节数按照某种规则进行对齐,例如我们让这些字节数都按照8字节进行向上对齐,那么thread cache的结构就是下面这样的,此时当线程申请1~8字节的内存时会直接给出8字节,而当线程申请9~16字节的内存时会直接给出16字节,以此类推。

因此当线程要申请某一大小的内存块时,就需要经过某种计算得到对齐后的字节数,进而找到对应的哈希桶,如果该哈希桶中的自由链表中有内存块,那就从自由链表中头删一个内存块进行返回;如果该自由链表已经为空了,那么就需要向下一层的central cache进行获取了。

但此时由于对齐的原因,就可能会产生一些碎片化的内存无法被利用,比如线程只申请了6字节的内存,而thread cache却直接给了8字节的内存,这多给出的2字节就无法被利用,导致了一定程度的空间浪费,这些因为某些对齐原因导致无法被利用的内存,就是内存碎片中的内部碎片。


鉴于当前项目比较复杂,我们最好对自由链表这个结构进行封装,目前我们就提供Push和Pop两个成员函数,对应的操作分别是将对象插入到自由链表(头插)和从自由链表获取一个对象(头删),后面在需要时还会添加对应的成员函数。

// 函数用static修饰,防止多个.cpp文件重复包含该Common头文件导致链接时产生冲突
static void*& NextObj(void* obj)
{
	return *(void**)obj;  //obj的头4/8个字节
}
class Freelist  //ThreadCache的自由链表
{
public:
	void push(void* obj) //头插 用来回收空间
	{
		assert(obj);  //防止obj为空
		NextObj(obj) = _freelist;
		_freelist = obj;
	}
	void* pop() //头删,用来提供空间
	{
		assert(_freelist);
		void* obj = _freelist;
		_freelist = NextObj(obj);
		return obj;
	}
	bool Empty() //判断哈希桶是否为空
	{
		return _freelist == nullptr;
	}
private:
	void* _freelist = nullptr;   //自由链表初始化为空
};

因此thread cache实际就是一个数组,数组中存储的就是一个个的自由链表,至于这个数组中到底存储了多少个自由链表,就需要看我们在进行字节数对齐时具体用的是什么映射对齐规则了。

2、threadcache哈希桶映射对齐规则 

 上面已经说了,不是每个字节数都对应一个自由链表,这样开销太大了,因此我们需要制定一个合适的映射对齐规则。

首先,这些内存块是会被链接到自由链表上的,因此一开始肯定是按8字节进行对齐是最合适的,因为我们必须保证这些内存块,无论是在32位平台下还是64位平台下,都至少能够存储得下一个指针。

但如果所有的字节数都按照8字节进行对齐的话,那么我们就需要建立256 × 1024 ÷ 8 = 32768个桶,这个数量还是比较多的,实际上我们可以让不同范围的字节数按照不同的对齐数进行对齐,具体对齐方式如下:

虽然对齐产生的内碎片会引起一定程度的空间浪费,但按照上面的对齐规则,我们可以将浪费率控制到百分之十左右。需要说明的是,1~128这个区间我们不做讨论,因为1字节就算是对齐到2字节也有百分之五十的浪费率,这里我们就从第二个区间开始进行计算。

根据上面的公式,我们要得到某个区间的最大浪费率,就应该让分子取到最大,让分母取到最小。比如129~1024这个区间,该区域的对齐数是16,那么最大浪费的字节数就是15,而最小对齐后的字节数就是这个区间内的前16个数所对齐到的字节数,也就是144,那么该区间的最大浪费率也就是15 ÷ 144 ≈ 10.42 % 

3、对齐和映射相关函数的编写

此时有了字节数的对齐规则后,我们就需要提供两个对应的函数,分别用于获取某一字节数对齐后的字节数,以及该字节数对应的哈希桶下标。关于处理对齐和映射的函数,我们可以将其封装到一个类当中。

需要注意的是,SizeClass类当中的成员函数最好设置为静态成员函数,否则我们在调用这些函数时就需要通过对象去调用,并且对于这些可能会频繁调用的函数,可以考虑将其设置为内联函数。

在获取某一字节数向上对齐后的字节数时,可以先判断该字节数属于哪一个区间,然后再通过调用一个子函数进行进一步处理。 

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

此时我们就需要编写一个子函数,该子函数需要通过对齐数计算出某一字节数对齐后的字节数,最容易想到的就是下面这种写法。

普通写法
static inline size_t _RoundUp(size_t bytes, int AlignNum)
{
	if (bytes % AlignNum == 0)
	{
		return bytes;
	}
	else
	{
		return (bytes / AlignNum + 1) * AlignNum;
	}
}

除了上述写法,我们还可以通过位运算的方式来进行计算,虽然位运算可能并没有上面的写法容易理解,但计算机执行位运算的速度是比执行乘法和除法更快的。

//位运算写法
static inline size_t _RoundUp(size_t bytes, int AlignNum)
{
	return ((bytes + AlignNum - 1) & ~(AlignNum - 1));
}

对于上述位运算,我们以10字节按8字节对齐为例进行分析。8 − 1 = 7 ,7就是一个低三位为1其余位为0的二进制序列,我们将10与7相加,相当于将10字节当中不够8字节的剩余字节数补上了。

然后我们再将该值与7按位取反后的值进行与运算,而7按位取反后是一个低三位为0其余位为1的二进制序列,该操作进行后相当于屏蔽了该值的低三位而该值的其余位保持不变,此时得到的值就是10字节按8字节对齐后的值,即16字节。

在获取某一字节数对应的哈希桶下标时,也是先判断该字节数属于哪一个区间,然后再通过调用一个子函数进行进一步处理。

static inline size_t Index(size_t bytes)//获取对应哈希桶的下标
{
	size_t groupArray[5] = { 16,56,56,56,24 };
	if (bytes <= 128)
	{
		return _Index(bytes, 3);
	}
	else if (bytes <= 1024)
	{
		return _Index(bytes, 4) + groupArray[0];
	}
	else if (bytes <= 1024 * 8)
	{
		return _Index(bytes, 7) + groupArray[0] + groupArray[1];
	}
	else if (bytes <= 1024 * 64)
	{
		return _Index(bytes, 10) + groupArray[0] + groupArray[1] + groupArray[2];
	}
	else if (bytes <= 1024 * 256)
	{
		return _Index(bytes, 13) + groupArray[0] + groupArray[1] + groupArray[2] + groupArray[3];
	}
	else
	{
		return -1;
	}
}

此时我们需要编写一个子函数来继续进行处理,容易想到的就是根据对齐数来计算某一字节数对应的下标。

static inline size_t _Index(size_t bytes, int AlignNum)
{
	if (bytes % AlignNum == 0)
	{
		return bytes / AlignNum - 1;
	}
	else
	{
		return bytes / AlignNum;
	}
}

当然,为了提高效率下面也提供了一个用位运算来解决的方法,需要注意的是,此时我们并不是传入该字节数的对齐数,而是将对齐数写成2的n次方的形式后,将这个n值进行传入。比如对齐数是8,传入的就是3。

//位运算写法
static inline size_t _Index(size_t bytes, size_t alignShift)
{
	return ((bytes + ((unsigned long long)1 << alignShift) - 1) >> alignShift) - 1;
}

这里我们还是以10字节按8字节对齐为例进行分析。此时传入的alignShift就是3,将1左移3位后得到的实际上就是对齐数8,8 − 1 = 7 ,相当于我们还是让10与7相加。

之后我们再将该值向右移3位,实际上就是让这个值除以8,此时我们也是相当于屏蔽了该值二进制的低三位,因为除以8得到的值与其二进制的低三位无关,所以我们可以说是将10对齐后的字节数除以了8,此时得到了2,而最后还需要减一是因为数组的下标是从0开始的。

4、ThreadCache类

按照上述的对齐规则,thread cache中桶的个数,也就是自由链表的个数是208,以及thread cache允许申请的最大内存大小256KB,我们可以将这些数据按照如下方式进行定义。

现在就可以对ThreadCache类进行定义了,thread cache就是一个存储208个自由链表的数组,目前thread cache就先提供一个Allocate函数用于申请对象就行了,后面需要时再进行增加。

在thread cache申请对象时,通过所给字节数计算出对应的哈希桶下标,如果桶中自由链表不为空,则从该自由链表中取出一个对象进行返回即可;但如果此时自由链表为空,那么我们就需要从central cache进行获取了,这里的FromCentralCache函数也是thread cache类中的一个成员函数,在后面再进行具体实现。

void* ThreadCache::Allocate(size_t size)
{
	assert(size <= MAX_BYTES);
	//申请size内存,计算内存对齐后的大小
	size_t RoundUp_Size = SizeCtrol::RoundUp(size);
	//找到size对应的桶
	size_t index = SizeCtrol::Index(size);
	if (!_freeLists[index].Empty())
	{
		return _freeLists[index].pop();
	}
	else
	{
		return FromCentrolCache(size);
	}
}

我们有线程申请内存的接口,也要有回收内存的接口。

回收内存也一样,我们要有指向该内存块的变量和该内存的字节数,通过这个字节数计算出对应哈希桶的下标,再push进去

void* ThreadCache::Deallocate(void* obj, size_t size)
{
	assert(size <= MAX_BYTES);
	assert(obj);
	size_t index = SizeCtrol::Index(size);
	_freeLists[index].push(obj);
}

5、threadcacheTLS无锁访问

每个线程都有一个自己独享的thread cache,那应该如何创建这个thread cache呢?我们不能将这个thread cache创建为全局的,因为全局变量是所有线程共享的,这样就不可避免的需要锁来控制,增加了控制成本和代码复杂度。

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

//TLS - Thread Local Storage
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;

但不是每个线程被创建时就立马有了属于自己的thread cache,而是当该线程调用相关申请内存的接口时才会创建自己的thread cache,因此在申请内存的函数中会包含以下逻辑。

//通过TLS,每个线程无锁的获取自己专属的ThreadCache对象
if (pTLSThreadCache == nullptr)
{
	pTLSThreadCache = new ThreadCache;
}

 六、CentralCache

1、centralcache整体设计

当线程申请某一大小的内存时,如果threadcache的对应的自由链表不为空,直接从该链表pop下一个内存块返回,如果对应的链表为空,此时threadcache就要向centralcache申请内存。

central cache的结构与thread cache是一样的,它们都是哈希桶的结构,并且它们遵循的对齐映射规则都是一样的。这样做的好处就是,当thread cache的某个桶中没有内存了,就可以直接到central cache中对应的哈希桶里去取内存就行了。

central cache与thread cache的不同之处

central cache与thread cache有两个明显不同的地方,首先,thread cache是每个线程独享的,而central cache是所有线程共享的,因为每个线程的thread cache没有内存了都会去找centralcache因此在访问central cache时是需要加锁的。

但centralcache加锁时并不是把整个centralcahe锁上,使用的是桶锁,每一个桶对应一把锁,在多线程运行时,当不同的线程访问同一个锁才会存在锁竞争,假如一个线程访问0号桶,另一个线程访问1号桶,这并不产生竞争。

central cache与thread cache的第二个不同之处就是,thread cache的每个桶中挂的是一个个切好的内存块,而central cache的每个桶中挂的是一个个的span。
每个span管理的都是一个以页为单位的大块内存,每个桶里面的若干span是按照双链表的形式链接起来的,并且每个span里面还有一个自由链表,这个自由链表里面挂的就是一个个切好了的内存块,根据其所在的哈希桶这些内存块被切成了对应的大小。

 2、centralcache结构设计

每个程序运行起来后都有自己的进程地址空间,在32位平台下,进程地址空间的大小是2^32;而在64位平台下,进程地址空间的大小就是2^64。

页的大小一般是4K或者8K,我们以8K为例。在32位平台下,进程地址空间就可以被分成 2^32 ÷ 2^ 13 = 2^19 个页,在64位平台下,进程地址空间就可以被分成2^64÷2^13=2^51个页。

由于页号在64位平台下的取值范围是[0,2^51),因此我们不能简单的用一个无符号整型来存储页号,这时我们需要借助条件编译来解决这个问题。

软件兼容性:32位系统只能运行32位软件,而64位系统可以运行64位软件,同时也能运行32位软件

所以我们先判断是否为64位机器,如果是就直接走64位下,不是再走32位。

假设机器是32位,判断是否为64位,不是,走32位。


(1)span的结构

central cache的每个桶里挂的是一个个的span,span是一个管理以页为单位的大块内存,span的结构如下:

//管理以页为单位的大块内存
struct Span
{
	PAGE_ID _pageId = 0;        //大块内存起始页的页号
	size_t _n = 0;              //页的数量

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

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

对于span管理的以页为单位的大块内存,我们需要知道这块内存具体在哪一个位置,便于之后page cache进行前后页的合并,因此span结构当中会记录所管理大块内存起始页的页号。

至于每一个span管理的到底是多少个页,这并不是固定的,需要根据多方面的因素来控制,因此span结构当中有一个_n成员,该成员就代表着该span管理的页的数量。

上面说过threadcache里的内存不够了要向centralcache中要,对一个span,切成若干份桶对应的内存大小,所以我们定义了一个_freeList自由链表,将若干个小内存块挂接到自由链表上,threadcache可能一次性拿1个可能一次性拿多个,所以我们在span结构体中定义了一个_useCount,拿走一个小内存块, _useCount++,之前也提到过threadcache中有多余的空间会还给centralcache,threadcache还回的内存块挂接到对应桶的内存大小的自由链表上,_useCount--

每个桶当中的span是以双链表的形式组织起来的,当我们需要将某个span归还给page cache时,就可以很方便的将该span从双链表结构中移出。如果用单链表结构的话就比较麻烦了,因为单链表在删除时,需要知道当前结点的前一个结点。 


(2)双链表的结构

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

class SpanList
{
	SpanList()
	{
		Span* head = new Span;
		head->_next = head;
		head->_prev = head;
	}
	//prev Newspan pos 
	void Insert(Span* pos, Span* Newspan)
	{
		assert(pos);
		assert(Newpan);
		Span* prev = pos->_prev;

		prev->_next = Newspan;
		Newspan->_prev = prev;

		Newspan->_next = pos;
		pos->_prev = Newspan;
	}
	//prev pos next
	void Erase(Span* pos)
	{
		assert(pos);
		assert(pos != head);

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

		prev->_next = next;
		next->_prev = prev;
	}
private:
	Span* head;
};

这里Erase函数并不要对Span进行delete,因为这个Span可能会还给Pagecache

(3)central cache的结构

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

central cache和thread cache的映射规则一样,有一个好处就是,当thread cache的某个桶没有内存了,就可以直接去central cache对应的哈希桶进行申请就行了。

3、centralcache核心实现

每个线程都有一个属于自己的thread cache,我们是用TLS来实现每个线程无锁的访问属于自己的thread cache的。而central cache和page cache在整个进程中只有一个,对于这种只能创建一个对象的类,我们可以将其设置为单例模式。

单例模式可以保证系统中该类只有一个实例,并提供一个访问它的全局访问点,该实例被所有程序模块共享。单例模式又分为饿汉模式和懒汉模式,懒汉模式相对较复杂,我们这里使用饿汉模式就足够了。

#pragma once
#include "common.h"

//与ThreadCache内存对齐规则一样
const int FREE_LIST_NUM = 208;

class CentralCache
{
public:
	//提供一个全局访问点
	static CentralCache* GetInstance()
	{
		return Create_s;
	}
private:
	SpanList* _spanlist;
private:
	CentralCache(){} //构造函数
	CentralCache(const CentralCache&) = delete; //防拷贝
	static CentralCache* Create_s;  //Create a singleton
};

为了保证CentralCache类只能创建一个对象,我们需要将central cache的构造函数和拷贝构造函数设置为私有,或者在C++11中也可以在函数声明的后面加上=delete进行修饰。

CentralCache类当中还需要有一个CentralCache类型的静态的成员变量,当程序运行起来后我们就立马创建该对象,在此后的程序中就只有这一个单例了

(1)慢开始反馈调节算法

当thread cache向central cache申请内存时,central cache应该给出多少个对象呢?这是一个值得思考的问题,如果central cache给的太少,那么thread cache在短时间内用完了又会来申请;但如果一次性给的太多了,可能thread cache用不完也就浪费了。

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

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

//thread cache一次从central cache获取对象的上限
static size_t NumMoveSize(size_t size)
{
	assert(size > 0);
	int Get_object = MAX_BYTES / size;
	if (Get_object > 512)
	{
		Get_object = 512;
		return Get_object;
	}
	if (Get_object < 2)
	{
		Get_object = 2;
		return Get_object;
	}

}
/*

但就算申请的是小对象,一次性给出512个也是比较多的,基于这个原因,我们可以在FreeList结构中增加一个叫做_maxSize的成员变量,该变量的初始值设置为1,并且提供一个公有成员函数用于获取这个变量。也就是说,现在thread cache中的每个自由链表都会有一个自己的_maxSize。

此时当thread cache申请对象时,我们会比较_maxSize和计算得出的值,取出其中的较小值作为本次申请对象的个数。此外,如果本次采用的是_maxSize的值,那么还会将thread cache中该自由链表的_maxSize的值进行加一。 

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

(2)从中心缓存获取对象

每次thread cache向central cache申请对象时,我们先通过慢开始反馈调节算法计算出本次应该申请的对象的个数,然后再向central cache进行申请。

如果thread cache最终申请到对象的个数就是一个,那么直接将该对象返回即可。为什么需要返回一个申请到的对象呢?因为thread cache要向central cache申请对象,其实由于某个线程向thread cache申请对象但thread cache当中没有,这才导致thread cache要向central cache申请对象。因此central cache将对象返回给thread cache后,thread cache会再将该对象返回给申请对象的线程。

但如果thread cache最终申请到的是多个对象,那么除了将第一个对象返回之外,还需要将剩下的对象挂到thread cache对应的哈希桶当中。

size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t Minnum, size_t size)
{
	size_t index = SizeCtrol::Index(size);
	//这里我们要加锁,这里是桶锁,防止多个线程同时访问一个桶造成线程安全
	_spanlist[index]._mtx.lock();
	//拿到一个span
	Span* span = getonespan(_spanlist[index], size);
	assert(span);
	assert(span->_freeList); //拿到的span的freelist不为空

	//从span中获取n个对象
	// 起初都指向_freeList,让end不断往后走
	start = end = span->_freeList;
	size_t actualnum = 1; //start和end指向第一个节点,最小返回的是1
	//有多少拿多少个
	while (NextObj(end) != nullptr && Minnum - 1) //Minnum - 1是end要走多少步 ,NextObj(end) != nullptr防止freelist内只有3个却要4个
	{
		end = NextObj(end);
		actualnum++;
		Minnum--;
	}
	span->_freeList = NextObj(end);
	NextObj(end) = nullptr;
	span->_useCount += actualnum;
	_spanlist[index]._mtx.unlock();
	return actualnum;
}

这里我们要进行加锁,多个线程访问同一个桶会造成线程安全问题,我们设置一个桶锁,也就是在SpanList上加锁。

 (3)从中心缓存获取一定数量的对象

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

//从中心缓存中要内存对象	 
void* ThreadCache::FromCentrolCache(size_t index,size_t size)
{
	1、最开始不会一次向central cache一次批量要太多,因为要太多了可能用不完
	//2、如果你不断有size大小的内存需求,那么size_obj就会不断增长,直到上限
	size_t size_obj = min(_freeLists[index].Getmaxsize(), SizeCtrol::NumMoveSize(size));
	if (size_obj == _freeLists[index].Getmaxsize()) //第一次申请
	{
		_freeLists[index].Getmaxsize() += 1;
	}
	void* start = nullptr;
	void* end = nullptr;
	size_t actualNum = CentralCache::GetInstance()->FetchRangeObj(start, end, size_obj, size); 
	assert(actualNum >= 1);
	if (actualNum == 1)
	{
		return start;
	}
	else
	{
		//将其中的一个返回给了申请对象的线程,剩下的actualNum-1个挂到了thread cache对应的桶当中
		_freeLists[index].PushRange(start, end, actualNum - 1); //插入到threadcache哈希桶对应大小的链表上,这里是多次插入
		return start;
	}
}

这里注意我们得到size_obj的时候,min函数不用带std::,因为我们在common.h中包含的windows.h中有个min的宏,编译器会优先用宏,包含了std::会出错,所以我们只能去掉std::,用windows.h的。 

thread cache实际从central cache获得的对象的个数可能与我们传入的n值是不一样的,因此我们需要统计本次申请过程中,实际thread cache获取到的对象个数,然后根据该值及时更新这个span中的小对象被分配给thread cache的计数。

因为可能存在threadcache需要4个内存块,我这个span里只剩下3个内存块,在FromCentrolCache函数中注意while循环的条件。

(4)插入一段范围的对象到自由链表

我们在Freelist多增加一个成员函数——插入一段范围的对象到自由链表上。

void PushRange(void* start, void* end, size_t actualNum)
{
	NextObj(end) = _freelist;
	start = _freelist;
	freelist_size += actualNum;
}

七、pagecache 

1、pagecache和centralcache异同

相同:

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

不同:

首先,central cache的映射规则与thread cache保持一致,而page cache的映射规则与它们都不相同。page cache的哈希桶映射规则采用的是直接定址法,比如1号桶挂的都是1页的span,2号桶挂的都是2页的span,以此类推。

其次,central cache每个桶中的span被切成了一个个对应大小的对象,以供thread cache申请。而page cache当中的span是没有被进一步切小的,因为page cache服务的是central cache,当central cache没有span时,向page cache申请的是某一固定页数的span,而如何切分申请到的这个span就应该由central cache自己来决定。

至于page cache当中究竟有多少个桶,这就要看你最大想挂几页的span了,这里我们就最大挂128页的span,为了让桶号与页号对应起来,我们可以将第0号桶空出来不用,因此我们需要将哈希桶的个数设置为129。

   

为什么这里最大挂128页的span呢?因为线程申请单个对象最大是256KB,而128页可以被切成4个256KB的对象,因此是足够的。当然,如果你想在page cache中挂更大的span也是可以的,根据具体的需求进行设置就行了。

2、在page cache获取一个n页的span的过程

如果central cache要获取一个n页的span,那我们就可以在page cache的第n号桶中取出一个span返回给central cache即可,但如果第n号桶中没有span了,这时我们并不是直接转而向堆申请一个n页的span,而是要继续在后面的桶当中寻找span。

直接向堆申请以页为单位的内存时,我们应该尽量申请大块一点的内存块,因为此时申请到的内存是连续的,当线程需要内存时我们可以将其切小后分配给线程,而当线程将内存释放后我们又可以将其合并成大块的连续内存。如果我们向堆申请内存时是小块小块的申请的,那么我们申请到的内存就不一定是连续的了。

因此,当第n号桶中没有span时,我们可以继续找第n+1号桶,因为我们可以将n+1页的span切分成一个n页的span和一个1页的span,这时我们就可以将n页的span返回,而将切分后1页的span挂到1号桶中。但如果后面的桶当中都没有span,这时我们就只能向堆申请一个128页的内存块,并将其用一个span结构管理起来,然后将128页的span切分成n页的span和128-n页的span,其中n页的span返回给central cache,而128-n页的span就挂到第128-n号桶中。

也就是说,我们每次向堆申请的都是128页大小的内存块,central cache要的这些span实际都是由128页的span切分出来的。

3、page cache的实现方式

当每个线程的thread cache没有内存时都会向central cache申请,此时多个线程的thread cache如果访问的不是central cache的同一个桶,那么这些线程是可以同时进行访问的。这时central cache的多个桶就可能同时向page cache申请内存的,所以page cache也是存在线程安全问题的,因此在访问page cache时也必须要加锁。

但是在page cache这里我们不能使用桶锁,因为当central cache向page cache申请内存时,page cache可能会将其他桶当中大页的span切小后再给central cache。此外,当central cache将某个span归还给page cache时,page cache也会尝试将该span与其他桶当中的span进行合并。如果使用桶锁的话,效率会变小。

也就是说,在访问page cache时,我们可能需要访问page cache中的多个桶,如果page cache用桶锁就会出现大量频繁的加锁和解锁,导致程序的效率低下。因此我们在访问page cache时使用没有使用桶锁,而是用一个大锁将整个page cache给锁住。

而thread cache在访问central cache时,只需要访问central cache中对应的哈希桶就行了,因为central cache的每个哈希桶中的span都被切分成了对应大小,thread cache只需要根据自己所需对象的大小访问central cache中对应的哈希桶即可,不会访问其他哈希桶,因此central cache可以用桶锁。

此外,page cache在整个进程中也是只能存在一个的,因此我们也需要将其设置为单例模式。

​
#pragma once
#include "Common.h"

class PageCache
{
	static PageCache* GetInstance()
	{
		return PtrPc;
	}
private:
	SpanList spanlists[BUCKETS];
private:
	PageCache() = delete;
	PageCache(const PageCache&) = delete; //防拷贝
	PageCache& operator =(const PageCache& copy) = delete; //赋值拷贝
	static PageCache* PtrPc;
};

​

4、获取Span

thread cache向central cache申请对象时,central cache需要先从对应的哈希桶中获取到一个非空的span,然后从这个非空的span中取出若干对象返回给thread cache。那central cache到底是如何从对应的哈希桶中,获取到一个非空的span的呢?

首先当然是先遍历central cache对应哈希桶当中的双链表,如果该双链表中有非空的span,那么直接将该span进行返回即可。为了方便遍历这个双链表,我们可以模拟迭代器的方式,给SpanList类提供Begin和End成员函数,分别用于获取双链表中的第一个span和最后一个span的下一个位置,也就是头结点。

但如果遍历双链表后发现双链表中没有span,或该双链表中的span都为空,那么此时central cache就需要向page cache申请内存块了。

那central cache要向page cache要多少内存块呢?我们知道pagecache是以页为单位的哈希桶,我们要写个算法计算出要多少个页。

那具体是向page cache申请多大的内存块呢?我们可以根据具体所需对象的大小来决定,就像之前我们根据对象的大小计算出,thread cache一次向central cache申请对象的个数上限,现在我们是根据对象的大小计算出,central cache一次应该向page cache申请几页的内存块。

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

//central cache一次从page cache获取页的数量
static size_t NumMovePage(size_t size)
{
	size_t size_num = NumMoveSize(size);  //计算出需要多少个内存对象
	size_t bytes = size_num * size;  //num个对象对应有多少个字节数
	bytes >>= PAGE_SHIFT;         //将需要多少字节数转化为需要多少页,右移13位相当于是bytes/8k
	if (bytes <= 1)
	{
		return 1;   
	}
	else
	{
		return bytes;
	}
}

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

如何找到一个span所管理的内存块呢?首先需要计算出该span的起始地址,我们可以用这个span的起始页号乘以一页的大小即可得到这个span的起始地址,然后用这个span的页数乘以一页的大小就可以得到这个span所管理的内存块的大小,用起始地址加上内存块的大小即可得到这块内存块的结束位置。

明确了这块内存的起始和结束位置后,我们就可以进行切分了。根据所需对象的大小,每次从大块内存切出一块固定大小的内存块尾插到span的自由链表中即可。

如果不是尾插的话,头插会导致地址不连续,尾插地址连续,把这些连续内存分配给某个线程使用时,可以提高该线程的CPU缓存利用率。

 //获取一个管理空间不为空的span
Span* CentralCache::getonespan(SpanList& Spanlist, size_t size)
{
	Span* begin = Spanlist.begin();
	//在centralcache中找还有没有内存块对象
	while (begin != Spanlist.end());
	{
		if (begin->_freeList != nullptr)
		{
			return begin;
		}
		else
		{
			begin = begin->_next;
		}
	}
	//centralcache中没有,再向pagecache要
	Span* ForPcObj = PageCache::GetInstance()->NewSpan(SizeCtrol::NumMovePage(size));
	//计算span的大块内存的起始地址和大块内存的大小(字节数)
	char* start = (char*)(ForPcObj->PAGE_ID << PAGE_SHIFT);
	size_t Obj_bytes = ForPcObj->_n << PAGE_SHIFT;
	//将该Span的头尾链接起来
	char* end = start + Obj_bytes;

	//先切一块下来,方便尾插
	ForPcObj->_freeList = start;
	start += size;
	void* tail = ForPcObj->_freeList;
	while (start < end)
	{
		NextObj(tail) = start; //一个个链接
		tail = start;
		start += size;
	}
	//全部链接完成
	NextObj(tail) = nullptr;
	Spanlist.PushFront(ForPcObj);  //把这个Span头插到该哈希桶上
	return ForPcObj;
}

头插函数:

5、从pagecache获取一个k页的span

因为page cache是直接按照页数进行映射的,因此我们要从page cache获取一个k页的span,就应该直接先去找page cache的第k号桶,如果第k号桶中有span,那我们直接头删一个span返回给central cache就行了。所以我们这里需要再给SpanList类添加对应的Empty和PopFront函数。

class SpanList
{
public:
	//带头双向链表
	SpanList()
	{
		Span* head = new Span;
		head->_next = head;
		head->_prev = head;
	}
	Span* begin()
	{
		return head->_next;
	}
	Span* end()
	{
		return head;
	}
	void PushFront(Span* span)
	{
		Insert(head->_next, span);
	}
	Span* PopFront()
	{
		Span* Obj = head->_next;
		Erase(head->_next);
		return Obj;
	}
	bool Empty()
	{
		return head == head->_next;
	}
	//prev Newspan pos 
	void Insert(Span* pos, Span* Newspan)
	{
		assert(pos);
		assert(Newspan); 
		Span* prev = pos->_prev;

		prev->_next = Newspan;
		Newspan->_prev = prev;

		Newspan->_next = pos;
		pos->_prev = Newspan;
	}
	//prev pos next
	void Erase(Span* pos)
	{
		assert(pos);
		assert(pos != head);

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

		prev->_next = next;
		next->_prev = prev;
	}
private:
	Span* head = nullptr;
public:
	std::mutex _mtx; // 每个CentralCache中的哈希桶都要有一个桶锁
};

如果page cache的第k号桶中没有span,我们就应该继续找后面的桶,只要后面任意一个桶中有一个n页span,我们就可以将其切分成一个k页的span和一个n-k页的span,然后将切出来k页的span返回给central cache,再将n-k页的span挂到page cache的第n-k号桶即可。

但如果后面的桶中也都没有span,此时我们就需要向堆申请一个128页的span了,在向堆申请内存时,直接调用我们封装的SystemAlloc函数即可。

// 直接去堆上按页申请空间
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32 // Windows下的系统调用接口
	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;
}

 需要注意的是,向堆申请内存后得到的是这块内存的起始地址,此时我们需要将该地址转换为页号。由于我们向堆申请内存时都是按页进行申请的,因此我们直接将该地址除以一页的大小即可得到对应的页号。

Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0 && k < BUCKETS);
	//如果spanlists对应的哈希桶有就pop出一个
	if (!spanlists[k].Empty())
	{

		return spanlists[k].PopFront();
	}
	//spanlists对应的哈希桶没有,遍历后面的桶
	for (size_t i = k + 1;i < BUCKETS;i++)
	{
		if (!spanlists[i].Empty())
		{
			Span* nspan = spanlists[i].PopFront();
			Span* kspan = new Span;
			//在nSpan的头部切k页下来
			kspan->PAGE_ID = nspan->PAGE_ID;
			kspan->_n = k;

			nspan->PAGE_ID += k;
			nspan->_n -= k;
			//切割完后插入到对应的哈希桶上
			spanlists[nspan->_n].PushFront(nspan);
			return kspan;
		}
	}
	//整个pagecache对应的桶都没比k页还大的span了,就向系统申请一个128页的span
	Span* Newspan = new Span;
	void* ptr = SystemAlloc(BUCKETS - 1);
	Newspan->PAGE_ID = (PAGE_ID)ptr >> PAGE_SHIFT;
	Newspan->_n = BUCKETS - 1;
	//插入到pagecache的链表上
	spanlists[BUCKETS - 1].PushFront(Newspan);
	//1、复用上面的代码
	/*Span* kspan = new Span;
	kspan->PAGE_ID = Newspan->PAGE_ID;
	kspan->_n = k;

	Newspan->PAGE_ID += k;
	Newspan->_n -= k;
	spanlists[Newspan->_n]->PushFront(Newspan);
	return kspan;*/
	/*2、递归重新再调用一遍*/
	return NewSpan(k);

}

当我们向堆申请到128页的span后,需要将其切分成k页的span和128-k页的span,但是为了尽量避免出现重复的代码,我们最好不要再编写对应的切分代码。我们可以先将申请到的128页的span挂到page cache对应的哈希桶中,然后再递归调用该函数就行了,此时在往后找span时就一定会在第128号桶中找到该span,然后进行切分。

这里其实有一个问题:

当central cache向page cache申请内存时,central cache对应的哈希桶是处于加锁的状态的,那在访问page cache之前我们应不应该把central cache对应的桶锁解掉呢?

建议在访问page cache前,先把central cache对应的桶锁解掉。虽然此时central cache的这个桶当中是没有内存供其他thread cache申请的,但thread cache除了申请内存还会释放内存,如果在访问page cache前将central cache对应的桶锁解掉,那么此时当其他thread cache想要归还内存到central cache的这个桶时就不会被阻塞。

因此在调用NewSpan函数之前,我们需要先将central cache对应的桶锁解掉,然后再将page cache的大锁加上,当申请到k页的span后,我们需要将page cache的大锁解掉,但此时我们不需要立刻获取到central cache中对应的桶锁。因为central cache拿到k页的span后还会对其进行切分操作,因此我们可以在span切好后需要将其挂到central cache对应的桶上时,再获取对应的桶锁。

这里注意一下如果再NewSpan函数内部加锁的话可能会引发死锁,因为我们在这个函数的逻辑上后面递归了,有了锁又申请了锁引发死锁,所以我们加锁尽量加在函数外面。

 

八、回收内存

1、ThreadCache回收内存

当某个线程申请的对象不用了,可以将其释放给thread cache,然后thread cache将该对象插入到对应哈希桶的自由链表当中即可。

但是随着线程不断的释放,对应自由链表的长度也会越来越长,这些内存堆积在一个thread cache中就是一种浪费,我们应该将这些内存还给central cache,这样一来,这些内存对其他线程来说也是可申请的,因此当thread cache某个桶当中的自由链表太长时我们可以进行一些处理。

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

void ThreadCache::Deallocate(void* obj, size_t size) 
{
	assert(size <= MAX_BYTES);
	assert(obj);
	//找出对应的自由链表桶将对象插入
	size_t index = SizeCtrol::Index(size);
	_freeLists[index].push(obj);

	if (_freeLists[index].Getfreelistsize() > _freeLists[index].Getmaxsize())
	{
		ListTooLong(_freeLists[index], size);
	}
}

当自由链表的长度大于一次批量申请的对象时,我们具体的做法就是,从该自由链表中取出一次批量个数的对象,然后将取出的这些对象还给central cache中对应的span即可。

void ThreadCache::ListTooLong(Freelist& list, size_t size)
{
	void* start = nullptr;
	void* end = nullptr;
	list.PopRange(start, end, list.Getmaxsize());
	CentralCache::GetInstance()->ReleaseListToSpans(start, size);
}

从上述代码可以看出,FreeList类需要支持用Size函数获取自由链表中对象的个数,还需要支持用PopRange函数从自由链表中取出指定个数的对象。因此我们需要给FreeList类增加一个对应的PopRange函数,然后再增加一个_size成员变量,该成员变量用于记录当前自由链表中对象的个数,当我们向自由链表插入或删除对象时,都应该更新_size的值。

class Freelist  //ThreadCache的自由链表
{
public:
	void push(void* obj) //头插 用来回收空间
	{
		assert(obj);  //防止obj为空
		NextObj(obj) = _freelist;
		_freelist = obj;
		++freelist_size;
	}
	void* pop() //头删,用来提供空间
	{
		assert(_freelist);
		void* obj = _freelist;
		_freelist = NextObj(obj);
		--freelist_size;
		return obj;
	}
	bool Empty() //判断哈希桶是否为空
	{
		return _freelist == nullptr;
	}
	size_t& Getmaxsize()
	{
		return maxsize;
	}
	void PopRange(void* start, void* end, size_t n)
	{
		assert(n <= freelist_size);
		start = end = _freelist;
		for (size_t i = 0; i < n - 1;i++)
		{
			NextObj(end);
		}
		_freelist = NextObj(end); //自由链表指向end的下一个对象
		NextObj(end) = nullptr;
		freelist_size -= n;
	}
	void PushRange(void*& start, void*& end, size_t actualNum)
	{
		NextObj(end) = _freelist;
		start = _freelist;
		freelist_size += actualNum;
	}
	size_t Getfreelistsize()
	{
		return freelist_size;
	}
private:
	void* _freelist = nullptr;   //自由链表初始化为空
	size_t maxsize = 1;
	size_t freelist_size = 0;   //自由链表中的个数
};

当我们判断thread cache是否应该还对象给central cache时,还可以综合考虑每个thread cache整体的大小。比如当某个thread cache的总占用大小超过一定阈值时,我们就将该thread cache当中的对象还一些给central cache,这样就尽量避免了某个线程的thread cache占用太多的内存。对于这一点,在tcmalloc当中就是考虑到了的。

2、CentralCache回收内存

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

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

(1)如何页找到对应的Span

首先,我们有对象的地址,我们如何通过对象的地址知道页号呢?

我们在threadcache中所有的内存对象都是最开始从pagecache切下来的,我们这里假设一页的大小是100,那么地址0~99都属于第0页,它们除以100都等于0,而地址100~199都属于第1页,它们除以100都等于1。

我们现在可以通过对象的地址得到其所在的页号,但是我们还是不能知道这个对象到底属于哪一个span。因为一个span管理的可能是多个页。

为了解决这个问题,我们可以建立页号和span之间的映射。由于这个映射关系在page cache进行span的合并时也需要用到,因此我们直接将其存放到page cache里面。这时我们就需要在PageCache类当中添加一个映射关系了,这里可以用C++当中的unordered_map进行实现,并且添加一个函数接口,用于让central cache获取这里的映射关系。

每当page cache分配span给central cache时,都需要记录一下页号和span之间的映射关系。此后当thread cache还对象给central cache时,才知道应该具体还给哪一个span。

因此当central cache在调用NewSpan接口向page cache申请k页的span时,page cache在返回这个k页的span给central cache之前,应该建立这k个页号与该span之间的映射关系。

Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0 && k < BUCKETS);
	//如果spanlists对应的哈希桶有就pop出一个
	if (!spanlists[k].Empty())
	{
		Span* kspan = spanlists[k].PopFront();
		//建立页号与span的映射,方便central cache回收小块内存时查找对应的span
		for (int i = 0;i < kspan->_n;i++)
		{
			_idSpanMap[kspan->PAGE_ID + i] = kspan;
		}
		return kspan;
	}
	//spanlists对应的哈希桶没有,遍历后面的桶
	for (size_t i = k + 1;i < BUCKETS;i++)
	{
		if (!spanlists[i].Empty())
		{
			Span* nspan = spanlists[i].PopFront();
			Span* kspan = new Span;
			//在nSpan的头部切k页下来
			kspan->PAGE_ID = nspan->PAGE_ID;
			kspan->_n = k;

			nspan->PAGE_ID += k;
			nspan->_n -= k;
			//切割完后插入到对应的哈希桶上
			//建立页号与span的映射,方便central cache回收小块内存时查找对应的span
			spanlists[nspan->_n].PushFront(nspan);
			for (int i = 0;i < kspan->_n;i++)
			{
				_idSpanMap[kspan->PAGE_ID + i] = kspan;
			}
			return kspan;
		}
	}
	//整个pagecache对应的桶都没比k页还大的span了,就向系统申请一个128页的span
	Span* Newspan = new Span;
	void* ptr = SystemAlloc(BUCKETS - 1);
	Newspan->PAGE_ID = (PAGE_ID)ptr >> PAGE_SHIFT;
	Newspan->_n = BUCKETS - 1;
	//插入到pagecache的链表上
	spanlists[BUCKETS - 1].PushFront(Newspan);
	//1、复用上面的代码
	/*Span* kspan = new Span;
	kspan->PAGE_ID = Newspan->PAGE_ID;
	kspan->_n = k;

	Newspan->PAGE_ID += k;
	Newspan->_n -= k;
	spanlists[Newspan->_n]->PushFront(Newspan);
	return kspan;*/
	/*2、递归重新再调用一遍*/
	return NewSpan(k);

}

此时我们就可以通过对象的地址找到该对象对应的span了,直接将该对象的地址除以页的大小得到页号,然后在unordered_map当中找到其对应的span即可。

//获取从对象到span的映射
Span* PageCache::MapObjectToSpan(void* obj)
{
	PAGE_ID id = (PAGE_ID)obj >> PAGE_SHIFT;
	auto ret = _idSpanMap.find(id);
	//找到了id和span的映射关系
	if (ret != _idSpanMap.end())
	{
		return ret->second;
	}
	return nullptr;
}

注意一下,当我们要通过某个页号查找其对应的span时,该页号与其span之间的映射一定是建立过的,如果此时我们没有在unordered_map当中找到,则说明我们之前的代码逻辑有问题,因此当没有找到对应的span时可以直接用断言结束程序,以表明程序逻辑出错。

(2)回收内存ReleaseListToSpans

 这时当thread cache还对象给central cache时,就可以依次遍历这些对象,将这些对象插入到其对应span的自由链表当中,并且及时更新该span的_usseCount计数即可。

在thread cache还对象给central cache的过程中,如果central cache中某个span的_useCount减到0时,说明这个span分配出去的对象全部都还回来了,那么此时就可以将这个span再进一步还给page cache。

//把threadcache还的内存块对象还给centralcache对应的哈希桶中
void CentralCache::ReleaseListToSpans(void* start, size_t size)
{
	size_t index = SizeCtrol::Index(size);
	_spanlist[index]._mtx.lock();  //加锁
	while (start)
	{
		void* next = NextObj(start);
		Span* span = PageCache::GetInstance()->MapObjectToSpan(start); //找到对应的span
		//头插
		NextObj(start) = span->_freeList;
		span->_freeList = start;
		span->_useCount--;
		if (span->_useCount == 0) //说明之前切分的对象全部还回来了,形成一个完整的span可以换回给PageCache
		{
			_spanlist[index].Erase(span);
			span->_next == nullptr;
			span->_prev = nullptr;
			span->_freeList = nullptr;
			_spanlist[index]._mtx.unlock();//与线程向CentralCache申请内存对象的原理一样

			PageCache::GetInstance()->Page_mtx.lock();
			PageCache::GetInstance()->ReleaseSpanToPageCache(span);
			PageCache::GetInstance()->Page_mtx.unlock();
		}
		start = next;
	}
	_spanlist[index]._mtx.unlock(); //解锁
}

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

并且在central cache还span给page cache时也存在锁的问题,此时需要先将central cache中对应的桶锁解掉,然后再加上page cache的大锁之后才能进入page cache进行相关操作,当处理完毕回到central cache时,除了将page cache的大锁解掉,还需要立刻获得central cache对应的桶锁,然后将还未还完对象继续还给central cache中对应的span。

3、pagecache回收内存

如果central cache中有某个span的_useCount减到0了,那么central cache就需要将这个span还给page cache了。

这个过程看似是非常简单的,page cache只需将还回来的span挂到对应的哈希桶上就行了。但实际为了缓解内存碎片的问题,page cache还需要尝试将还回来的span与其他空闲的span进行合并。

(1)page cache进行前后页的合并

合并的过程可以分为向前合并和向后合并。如果还回来的span的起始页号是num,该span所管理的页数是n。那么在向前合并时,就需要判断第num-1页对应span是否空闲,如果空闲则可以将其进行合并,并且合并后还需要继续向前尝试进行合并,直到不能进行合并为止。而在向后合并时,就需要判断第num+n页对应的span是否空闲,如果空闲则可以将其进行合并,并且合并后还需要继续向后尝试进行合并,直到不能进行合并为止。

因此page cache在合并span时,是需要通过页号获取到对应的span的,这就是我们要把页号与span之间的映射关系存储到page cache的原因。

但需要注意的是,当我们通过页号找到其对应的span时,这个span此时可能挂在page cache,也可能挂在central cache。而在合并时我们只能合并挂在page cache的span,因为挂在central cache的span当中的对象正在被其他线程使用。

可是我们不能通过span结构当中的_useCount成员,来判断某个span到底是在central cache还是在page cache。因为当central cache刚向page cache申请到一个span时,这个span的_useCount就是等于0的,这时可能当我们正在对该span进行切分的时候,page cache就把这个span拿去进行合并了,这显然是不合理的。

鉴于此,我们可以在span结构中再增加一个_isUse成员,用于标记这个span是否正在被使用,而当一个span结构被创建时我们默认该span是没有被使用的。

当central cache向page cache申请到一个span时,需要立即将该span的_isUse改为true。

当central cache将某个span还给page cache时,也就需要将该span的_isUse改成false。

由于在合并page cache当中的span时,需要通过页号找到其对应的span,而一个span是在被分配给central cache时,才建立的各个页号与span之间的映射关系,因此page cache当中的span也需要建立页号与span之间的映射关系。

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

因此当我们申请k页的span时,如果是将n页的span切成了一个k页的span和一个n-k页的span,我们除了需要建立k页span中每个页与该span之间的映射关系之外,还需要建立剩下的n-k页的span与其首尾页之间的映射关系。

Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0 && k < BUCKETS);
	//如果spanlists对应的哈希桶有就pop出一个
	if (!spanlists[k].Empty())
	{
		Span* kspan = spanlists[k].PopFront();
		//建立页号与span的映射,方便central cache回收小块内存时查找对应的span
		for (int i = 0;i < kspan->_n;i++)
		{
			_idSpanMap[kspan->PAGE_ID + i] = kspan;
		}
		return kspan;
	}
	//spanlists对应的哈希桶没有,遍历后面的桶
	for (size_t i = k + 1;i < BUCKETS;i++)
	{
		if (!spanlists[i].Empty())
		{
			Span* nspan = spanlists[i].PopFront();
			Span* kspan = new Span;
			//在nSpan的头部切k页下来
			kspan->PAGE_ID = nspan->PAGE_ID;
			kspan->_n = k;

			nspan->PAGE_ID += k;
			nspan->_n -= k;
			_idSpanMap[nspan->PAGE_ID] = nspan;  //存放该页头id与span的对应关系
			_idSpanMap[nspan->PAGE_ID + nspan->_n - 1] = nspan;	//存放该页尾id与span的对应关系
			//切割完后插入到对应的哈希桶上
			//建立页号与span的映射,方便central cache回收小块内存时查找对应的span
			spanlists[nspan->_n].PushFront(nspan);
			for (int i = 0;i < kspan->_n;i++)
			{
				_idSpanMap[kspan->PAGE_ID + i] = kspan;
			}
			return kspan;
		}
	}
	//整个pagecache对应的桶都没比k页还大的span了,就向系统申请一个128页的span
	Span* Newspan = new Span;
	void* ptr = SystemAlloc(BUCKETS - 1);
	Newspan->PAGE_ID = (PAGE_ID)ptr >> PAGE_SHIFT;
	Newspan->_n = BUCKETS - 1;
	//插入到pagecache的链表上
	spanlists[BUCKETS - 1].PushFront(Newspan);
	//1、复用上面的代码
	/*Span* kspan = new Span;
	kspan->PAGE_ID = Newspan->PAGE_ID;
	kspan->_n = k;

	Newspan->PAGE_ID += k;
	Newspan->_n -= k;
	spanlists[Newspan->_n]->PushFront(Newspan);
	return kspan;*/
	/*2、递归重新再调用一遍*/
	return NewSpan(k);

     }

此时page cache当中的span就都与其首尾页之间建立了映射关系,现在我们就可以进行span的合并了,其合并逻辑如下: 

void PageCache::ReleaseSpanToPageCache(Span* span)
{
	//向前合并
	while (true)
	{	//向前找尾位号
		PAGE_ID ID = span->PAGE_ID - 1;
		auto ret = _idSpanMap.find(ID);
		if (ret == _idSpanMap.end()) //说明没找到,该页还未向系统申请,直接break
		{
			break;
		}
		Span* Newspan = ret->second;
		if (Newspan->_isUse = true)  //这个页正在被使用也直接break
		{
			break;
		}

		if (Newspan->PAGE_ID + span->PAGE_ID > BUCKETS - 1)  //这两个页相加大于128页也直接break
		{
			break;
		}
		//进行合并
		span->PAGE_ID = Newspan->PAGE_ID;
		span->_n += Newspan->_n;

		//移除向前合并的Newspan
		spanlists[Newspan->_n].Erase(Newspan);
		delete Newspan;
	}
	//向后对齐
	while (true)
	{
		PAGE_ID ID = span->PAGE_ID + span->_n;
		auto ret = _idSpanMap.find(ID);
		if (ret == _idSpanMap.end())      //这里与上面注释一致,就不详细注释了
		{
			break;
		}
		Span* Newspan = ret->second;
		if (Newspan->_isUse = true)  
		{
			break;
		}
		if (Newspan->PAGE_ID + span->PAGE_ID > BUCKETS - 1)  
		{
			break;
		}
		//进行合并
		span->_n += Newspan->_n;
		spanlists[Newspan->_n].Erase(Newspan);
		delete Newspan;
	}
	spanlists[span->_n].PushFront(span);
	//将新合成的页存放在_idSpanMap里
	_idSpanMap[span->PAGE_ID] = span;
	_idSpanMap[span->PAGE_ID + span->_n - 1] = span;
}

需要注意的是,在向前或向后进行合并的过程中:

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

在合并span时,由于这个span是在page cache的某个哈希桶的双链表当中的,因此在合并后需要将其从对应的双链表中移除,然后再将这个被合并了的span结构进行delete。

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

九、大于256KB的大块内存申请和释放问题

1、申请过程

之前说到,每个线程的thread cache是用于申请小于等于256KB的内存的,而对于大于256KB的内存,我们可以考虑直接向page cache申请,但page cache中最大的页也就只有128页,因此如果是大于128页的内存申请,就只能直接向堆申请了。

而我们之前实现RoundUp函数时,对传入字节数大于256KB的情况直接做了断言处理,因此这里需要对RoundUp函数稍作修改。 

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

现在对于之前的申请逻辑就需要进行修改了,当申请对象的大小大于256KB时,就不用向thread cache申请了,这时先计算出按页对齐后实际需要申请的页数,然后通过调用NewSpan申请指定页数的span即可。

static void* Request_memory(size_t n)
{ 
	//申请的字节数大于256kb,直接去PageCache申请
	if (n > MAX_BYTES)
	{
		size_t RoundUp_Size = SizeCtrol::RoundUp(n);
		size_t kpage = RoundUp_Size >> PAGE_SHIFT;

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

		void* ptr = (void*)(span->PAGE_ID << PAGE_SHIFT);
		return ptr;
	}
	else
	{
		if (pTLSThreadCache == nullptr)
		{
			pTLSThreadCache = new ThreadCache;
		}
		return pTLSThreadCache->Allocate(n);
	}
}

也就是说,申请大于256KB的内存时,会直接调用page cache当中的NewSpan函数进行申请,因此这里我们需要再对NewSpan函数进行改造,当需要申请的内存页数大于128页时,就直接向堆申请对应页数的内存块。而如果申请的内存页数是小于128页的,那就在page cache中进行申请,因此当申请大于256KB的内存调用NewSpan函数时也是需要加锁的,因为我们可能是在page cache中进行申请的。

Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0);
	//若申请的页数大于128页,直接向堆申请
	if (k > BUCKETS - 1)
	{
		void* ptr = SystemAlloc(k);
		Span* span = new Span;
		span->PAGE_ID = (PAGE_ID)ptr >> PAGE_SHIFT;
		span->_n = k;
		//将对应关系存放到unordered_map里
		_idSpanMap[span->PAGE_ID] = span;
		return span;
	}
	//如果spanlists对应的哈希桶有就pop出一个
	if (!spanlists[k].Empty())
	{
		Span* kspan = spanlists[k].PopFront();
		//建立页号与span的映射,方便central cache回收小块内存时查找对应的span
		for (int i = 0;i < kspan->_n;i++)
		{
			_idSpanMap[kspan->PAGE_ID + i] = kspan;
		}
		return kspan;
	}
	//spanlists对应的哈希桶没有,遍历后面的桶
	for (size_t i = k + 1;i < BUCKETS;i++)
	{
		if (!spanlists[i].Empty())
		{
			Span* nspan = spanlists[i].PopFront();
			Span* kspan = new Span;
			//在nSpan的头部切k页下来
			kspan->PAGE_ID = nspan->PAGE_ID;
			kspan->_n = k;

			nspan->PAGE_ID += k;
			nspan->_n -= k;
			_idSpanMap[nspan->PAGE_ID] = nspan;  //存放该页头id与span的对应关系
			_idSpanMap[nspan->PAGE_ID + nspan->_n - 1] = nspan;	//存放该页尾id与span的对应关系
			//切割完后插入到对应的哈希桶上
			//建立页号与span的映射,方便central cache回收小块内存时查找对应的span
			spanlists[nspan->_n].PushFront(nspan);
			for (int i = 0;i < kspan->_n;i++)
			{
				_idSpanMap[kspan->PAGE_ID + i] = kspan;
			}
			return kspan;
		}
	}
	//整个pagecache对应的桶都没比k页还大的span了,就向系统申请一个128页的span
	Span* Newspan = new Span;
	void* ptr = SystemAlloc(BUCKETS - 1);
	Newspan->PAGE_ID = (PAGE_ID)ptr >> PAGE_SHIFT;
	Newspan->_n = BUCKETS - 1;
	//插入到pagecache的链表上
	spanlists[BUCKETS - 1].PushFront(Newspan);
	//1、复用上面的代码
	/*Span* kspan = new Span;
	kspan->PAGE_ID = Newspan->PAGE_ID;
	kspan->_n = k;

	Newspan->PAGE_ID += k;
	Newspan->_n -= k;
	spanlists[Newspan->_n]->PushFront(Newspan);
	return kspan;*/
	/*2、递归重新再调用一遍*/
	return NewSpan(k);

     }

2、释放过程

当释放对象时,我们需要判断释放对象的大小:

因此当释放对象时,我们需要先找到该对象对应的span,但是在释放对象时我们只知道该对象的起始地址。这也就是我们在申请大于256KB的内存时,也要给申请到的内存建立span结构,并建立起始页号与该span之间的映射关系的原因。此时我们就可以通过释放对象的起始地址计算出起始页号,进而通过页号找到该对象对应的span。

static void Free_memory(void* ptr, size_t size)
{
	assert(ptr);
	assert(pTLSThreadCache);
	if (size > MAX_BYTES)
	{
		Span* span = PageCache::GetInstance()->MapObjectToSpan(ptr);

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

因此page cache在回收span时也需要进行判断,如果该span的大小是小于等于128页的,那么直接还给page cache进行了,page cache会尝试对其进行合并。而如果该span的大小是大于128页的,那么说明该span是直接向堆申请的,我们直接将这块内存释放给堆,然后将这个span结构进行delete就行了。

说明一下,直接向堆申请内存时我们调用的接口是VirtualAlloc,与之对应的将内存释放给堆的接口叫做VirtualFree,而Linux下的brk和mmap对应的释放接口叫做sbrk和unmmap。此时我们也可以将这些释放接口封装成一个叫做SystemFree的接口,当我们需要将内存释放给堆时直接调用SystemFree即可。

//直接将内存还给堆
inline static void SystemFree(void* ptr)
{
#ifdef _WIN32
	VirtualFree(ptr, 0, MEM_RELEASE);
#else
	//linux下sbrk unmmap等
#endif
}
void PageCache::ReleaseSpanToPageCache(Span* span)
{
	if (span->_n > BUCKETS - 1)
	{
		void* ptr = (void*)(span->PAGE_ID << PAGE_SHIFT);
		SystemFree(ptr);
		delete span;
		return;
	}
	//向前合并
	while (true)
	{	//向前找尾位号
		PAGE_ID ID = span->PAGE_ID - 1;
		auto ret = _idSpanMap.find(ID);
		if (ret == _idSpanMap.end()) //说明没找到,该页还未向系统申请,直接break
		{
			break;
		}
		Span* Newspan = ret->second;
		if (Newspan->_isUse = true)  //这个页正在被使用也直接break
		{
			break;
		}

		if (Newspan->PAGE_ID + span->PAGE_ID > BUCKETS - 1)  //这两个页相加大于128页也直接break
		{
			break;
		}
		//进行合并
		span->PAGE_ID = Newspan->PAGE_ID;
		span->_n += Newspan->_n;

		//移除向前合并的Newspan
		spanlists[Newspan->_n].Erase(Newspan);
		delete Newspan;
	}
	//向后对齐
	while (true)
	{
		PAGE_ID ID = span->PAGE_ID + span->_n;
		auto ret = _idSpanMap.find(ID);
		if (ret == _idSpanMap.end())      //这里与上面注释一致,就不详细注释了
		{
			break;
		}
		Span* Newspan = ret->second;
		if (Newspan->_isUse == true)  
		{
			break;
		}
		if (Newspan->_n + span->_n > BUCKETS - 1)  
		{
			break;
		}
		//进行合并
		span->_n += Newspan->_n;
		spanlists[Newspan->_n].Erase(Newspan);
		delete Newspan;
	}
	spanlists[span->_n].PushFront(span);
	//将新合成的页存放在_idSpanMap里
	_idSpanMap[span->PAGE_ID] = span;
	_idSpanMap[span->PAGE_ID + span->_n - 1] = span;
}

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

tcmalloc是要在高并发场景下替代malloc进行内存申请的,因此tcmalloc在实现的时,其内部是不能调用malloc函数的,我们当前的代码中存在通过new获取到的内存,而new在底层实际上就是封装了malloc。

为了完全脱离掉malloc函数,此时我们之前实现的定长内存池就起作用了,代码中使用new时基本都是为Span结构的对象申请空间,而span对象基本都是在page cache层创建的,因此我们可以在PageCache类当中定义一个_spanPool,用于span对象的申请和释放。

//申请span对象
Span* span = _spanPool.New();
//释放span对象
_spanPool.Delete(span);

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

这里我们将用于申请ThreadCache类对象的定长内存池定义为静态的,保持全局只有一个,让所有线程创建自己的thread cache时,都在个定长内存池中申请内存就行了。

但注意在从该定长内存池中申请内存时需要加锁,防止多个线程同时申请自己的ThreadCache对象而导致线程安全问题。

最后在SpanList的构造函数中也用到了new,因为SpanList是带头循环双向链表,所以在构造期间我们需要申请一个span对象作为双链表的头结点。

class SpanList
{
public:
	//带头双向链表
	SpanList()
	{
		head = _spanPool.New();
		head->_next = head;
		head->_prev = head;
	}
	Span* begin()
	{
		return head->_next;
	}
	Span* end()
	{
		return head;
	}
	void PushFront(Span* span)
	{
		Insert(head->_next, span);
	}
	Span* PopFront()
	{
		Span* Obj = head->_next;
		Erase(head->_next);
		return Obj;
	}
	bool Empty()
	{
		return head == head->_next;
	}
	//prev Newspan pos 
	void Insert(Span* pos, Span* Newspan)
	{
		assert(pos);
		assert(Newspan); 
		Span* prev = pos->_prev;

		prev->_next = Newspan;
		Newspan->_prev = prev;

		Newspan->_next = pos;
		pos->_prev = Newspan;
	}
	//prev pos next
	void Erase(Span* pos)
	{
		assert(pos);
		assert(pos != head);

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

		prev->_next = next;
		next->_prev = prev;
	}
private:
	Span* head;
	MemoryPool<Span> _spanPool;
public:
	std::mutex _mtx; // 每个CentralCache中的哈希桶都要有一个桶锁
};

由于每个span双链表只需要一个头结点,因此将这个定长内存池定义为静态的,保持全局只有一个,让所有span双链表在申请头结点时,都在一个定长内存池中申请内存就行了。

十一、释放对象时优化为不传对象大小

当我们使用malloc函数申请内存时,需要指明申请内存的大小;而当我们使用free函数释放内存时,只需要传入指向这块内存的指针即可。

而我们目前实现的内存池,在释放对象时除了需要传入指向该对象的指针,还需要传入该对象的大小。

原因如下:

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

如果我们也想做到,在释放对象时不用传入对象的大小,那么我们就需要建立对象地址与对象大小之间的映射。由于现在可以通过对象的地址找到其对应的span,而span的自由链表中挂的都是相同大小的对象。

因此我们可以在Span结构中再增加一个_objSize成员,该成员代表着这个span管理的内存块被切成的一个个对象的大小。

struct Span
{
	PAGE_ID PAGE_ID = 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;    //判断是否在Centralcache被使用
};

而所有的span都是从page cache中拿出来的,因此每当我们调用NewSpan获取到一个k页的span时,就应该将这个span的_objSize保存下来。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值