高并发内存池


一、项目背景

高并发内存池是一种专门设计用于高并发环境下的内存管理机制。它的原型是Google的一个开源项目tcmalloc,全称Thread-Caching Malloc,实现了高效的多线程内存管理,用于替换系统的内存分配相关函数malloc和free。

在高并发系统中,大量的线程或进程可能会频繁地申请和释放小块的内存,这种情况下,传统的内存分配方式(如使用操作系统的malloc和free)可能会因为频繁的系统调用、锁竞争和内存碎片等问题而导致性能瓶颈。我们可以通过下面的代码体验一下频繁的申请、释放空间

//针对这个代码,我们待会使用定长的内存池感受一下它的优势
struct TreeNode
{
	int _val;
	TreeNode* _left;
	TreeNode* _right;
	TreeNode()
		:_val(0)
		, _left(nullptr)
		, _right(nullptr)
	{}
};

void Test()
{
	// 申请释放的轮次
	const size_t Rounds = 8000;
	// 每轮申请释放多少次
	const size_t N = 100;
	std::vector<TreeNode*> v1;
	v1.reserve(N);

	//malloc和free
	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();
	cout << "new cost time:" << end1 - begin1 << endl;
}

在这里插入图片描述

高并发内存池通过以下方式来解决这些问题:

  • 预先分配内存:内存池会预先从操作系统中申请一大块内存,并将其划分为多个固定大小的内存块或可变大小的内存块(根据具体实现而定),这些内存块在内存池中进行管理,而不是直接由操作系统管理。
  • 减少系统调用:由于内存池预先分配了内存,因此当需要分配内存时,可以直接从内存池中获取,而无需频繁地向操作系统发起内存分配请求,从而减少了系统调用的次数。
  • 优化锁机制:在多线程环境下,内存池的访问需要是线程安全的。高并发内存池通常会采用高效的锁机制(如自旋锁、读写锁、无锁数据结构等),以减少锁竞争并提高并发性能。
  • 减少内存碎片:内存池通过管理固定大小的内存块或采用高效的内存分配算法,可以显著减少内存碎片的产生,提高内存的利用率和访问效率。
  • 快速分配和回收:内存池中的内存块可以快速分配和回收,因为它们已经预先分配好了,并且内存池内部通常会有高效的内存管理策略来优化分配和回收过程。
  • 可定制性:高并发内存池通常支持可定制性,允许用户根据应用程序的需求调整内存块的大小、数量和管理策略等。
      高并发内存池的应用场景非常广泛,特别是在需要处理大量并发请求和频繁内存操作的应用程序中,如数据库、Web服务器、游戏服务器、实时交易系统等。通过使用高并发内存池,这些应用程序可以显著提高内存管理的效率和性能,从而改善整体的系统性能和用户体验。

二、项目介绍

本项目实现的是一个高并发的内存池,它的原型是Google的一个开源项目tcmalloc,tcmalloc全称Thread-Caching Malloc,即线程缓存的malloc,实现了高效的多线程内存管理,用于替换系统的内存分配相关函数malloc和free。

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

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

在多核多线程的应用程序中,频繁的内存申请和释放操作往往会导致性能瓶颈和内存碎片问题。传统的内存分配方式(如malloc和free)在多线程环境下可能存在锁竞争和内存碎片等问题,影响程序的执行效率。高并发内存池项目旨在解决这些问题,通过优化内存分配和回收机制,提高多线程环境下的内存管理性能。


三、内存池的介绍

池化技术:线程、内存、数据库的连接对象都是资源,在程序中,当你创建一个线程或者在堆上申请一块内存的时候都涉及到很多的系统调用,也是非常消耗CPU的。如果你的程序需要很多类似的工作线程或者需要频繁地申请释放小块内存,在没有对这方面进行优化的情况下,这部分代码很可能会成为影响你整个程序性能的瓶颈

内存池:

  • 如何更好地管理应用程序内存的使用,同时提高内存使用的频率,这时值得每一个开发人员深思的问题。内存池(Memory Pool)就提供了一个比较可行的解决方案。
  • 内存池在创建的过程中,会预先分配足够大的内存,形成一个初步的内存池。然后每次用户请求内存的时候,就会返回内存池中的一块空闲的内存,并将这块内存的标志置为已使用。当内存使用完毕释放内存的时候,也不是真正地调用 free 或 delete 的过程,而是把内存放回内存池的过程,且放回的过程要把标志置为空闲。最后,应用程序结束就会将内存池销毁,将内存池中的每一块内存释放。

四、定长内存池

定长内存池是一种内存管理技术,用于提高程序在频繁分配和释放小块内存时的效率和性能。它预先分配一块较大的连续内存区域(通常称为“内存池”),然后将这块内存分割成多个固定大小的小块,用于后续的内存分配请求。

如何才能实现定长呢?

我们可以使用非模板参数设计内存池,让每一次申请的空间都是N

template<size_t N>
class ObjectPool
{};

此外,定长内存池也叫做对象池,在创建对象池时,对象池可以根据传入的对象类型的大小来实现“定长”,因此我们可以通过使用模板参数来实现“定长”,比如创建定长内存池时传入的对象类型是int,那么该内存池就只支持4字节大小内存的申请和“回收”。

template<class T>
class ObjectPool
{};

定长内存池应该怎么设计呢?需要包含什么成员?

首先我们需要向堆申请一块连续的空间,我们申请到这一段空间之后需要使用一个指针进行管理。这个指针的类型这里为什么设置的是char*类型呢?

原因:指针由于指向的对象不同,因而出现了解引用或者指针操作的步长问题,int类型的步长是4字节,double类型是8字节,char类型是1字节。这里使用char*类型实际上是为了我们后序指针操作方便,char*的指针++或者–都是向后移动一个字节,因此在后面记录内存池剩余容量的时候,方便计算

在这里插入图片描述

其次,我使用完当前的内存,我不用调用free或者delete释放,而是将它回收;释放回来的定长内存块也需要被管理,我们可以将这些释放回来的定长内存块链接成一个链表,这里我们将管理释放回来的内存块的链表叫做自由链表,为了能找到这个自由链表,我们还需要一个指向自由链表的指针。

在这里插入图片描述
这里_freeList指针使用void类型是因为我们还不知道传过来的对象是什么类型,最后使用void类型进行强转即可

因此定长内存池包含三个成员变量

  • char* _memory //指向连续内存的指针
  • size_t remainBytes //表示剩余的内存数量
  • void* _freeList //表示释放回来内存链表的头指针

定长内存池如何创建对象呢?

  1. 首先,如果_freeList != nullptr说明使用结束的内存,那我们首先使用_freeList里面的内存:这里其实就是一个简单的头删,我们将_freeList指向的内存给需要的对象,然后将_freeList指向当前内存的下一个内存
    在这里插入图片描述

  2. 如果_freeList == nullptr说明这个内存池要么没有申请空间;要么就是申请的空间正在使用还没有回收。这个时候我们就要看判断剩余空间_remainBytes是否满足当前所需要的空间大小,如果不满足那么重新申请空间;如果满足直接切分_memory指向的内存,再更新_remianBytes的大小

定长内存池怎么回收使用完的内存

  1. 首先我们需要析构对象的资源
  2. 需要将使用完的内存头插到_freeList指向的链表中,方便下一次内存分配

这里有一个问题:我们怎么实现链表的链接? 平常的数据结构中,我们的链表是有一个next指针的,但是在内存池中我们之后未使用/使用完的内存。没有必要定义链式结构,可以将内存的前4个字节或者前8个字节作为指针,存储下一个内存的地址。

那么问题又来了,32位下指针的大小是4字节,64位下指针的大小是8字节,我们需要使用内存的前几个字节作为指针存储下一个内存的地址,那我们怎么知道当前的机器是32位还是64位? 首先我们知道,指针指向数据的类型,决定了指针解引用后能向后访问的空间大小,因此我们这里需要的是一个指向指针的指针,这里使用二级指针就行了。因为二级指针指向的是一个指针,在32位下指针解引用向后读取4字节,63位下指针解引用向后读取8字节。

需要注意的是,我们给对象分配好空间,需要初始化对象就需要使用定位new,定位new不会调用对象的析构函数,所以需要我们显示调用析构

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

定长内存池整体代码

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

//每次在内存池中申请对象的大小
//template<size_t N>  
//class ObjectPool
//{
//
//};


//内存池也就是对象池,根据传入对象的类型实现定长
template<class T>
class ObjectPool
{
public:
	//申请对象
	T* New()
	{
		T* object = nullptr;
		//如果自由链表有释放回来的内存,首先将它分配出去
		if (_freeList != nullptr)
		{
			void* next = *((void**)_freeList); //这里的内存的前四个/前八个字节指向的是下一个内存
			object = (T*)_freeList;
			_freeList = next;
		}
		else
		{
			//这里解决的是对象的空间大小至少能存 下一个对象的地址
			size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
			//如果剩余的内存不够一个对象的大小时,则重新开辟空间
			if (_remainBytes < objSize)
			{
				_memory = (char*)malloc(128 * 1024);
				if (_memory == nullptr)
				{ 
					throw std::bad_alloc();
				}
			}
			object = (T*)_memory;
			_memory += objSize;
			_remainBytes -= objSize;
		}
		//定位new完成已经分配空间的对象初始化
		new(object)T;
		return object;
	}

	void Delete(T* obj)
	{
		//定位new必须显式调用析构函数
		obj->~T();
		//头插
		*(void**)obj = _freeList;
		_freeList = obj;
	}
private:
	char* _memory = nullptr; //指向连续内存的起始位置
	size_t  _remainBytes= 0; //剩余内存的大小
	void* _freeList = nullptr; //链接释放回来内存的头指针
};

我们将定长内存池和malloc/free进行性能对比

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

//每次在内存池中申请对象的大小
//template<size_t N>  
//class ObjectPool
//{
//
//};


//内存池也就是对象池,根据传入对象的类型实现定长
template<class T>
class ObjectPool
{
public:
	//申请对象
	T* New()
	{
		T* object = nullptr;
		//如果自由链表有释放回来的内存,首先将它分配出去
		if (_freeList != nullptr)
		{
			void* next = *((void**)_freeList); //这里的内存的前四个/前八个字节指向的是下一个内存
			object = (T*)_freeList;
			_freeList = next;
		}
		else
		{
			//这里解决的是对象的空间大小至少能存 下一个对象的地址
			size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
			//如果剩余的内存不够一个对象的大小时,则重新开辟空间
			if (_remainBytes < objSize)
			{
				_memory = (char*)malloc(128 * 1024);
				if (_memory == nullptr)
				{ 
					throw std::bad_alloc();
				}
			}
			object = (T*)_memory;
			_memory += objSize;
			_remainBytes -= objSize;
		}
		//定位new完成已经分配空间的对象初始化
		new(object)T;
		return object;
	}

	void Delete(T* obj)
	{
		//定位new必须显式调用析构函数
		obj->~T();
		//头插
		*(void**)obj = _freeList;
		_freeList = obj;
	}
private:
	char* _memory = nullptr; //指向连续内存的起始位置
	size_t  _remainBytes= 0; //剩余内存的大小
	void* _freeList = nullptr; //链接释放回来内存的头指针
};

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

void TestObjectPool()
{
	// 申请释放的轮次
	const size_t Rounds = 8000;
	// 每轮申请释放多少次
	const size_t N = 100;
	std::vector<TreeNode*> v1;
	v1.reserve(N);

	//malloc和free
	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();

	//定长内存池
	ObjectPool<TreeNode> TNPool;
	std::vector<TreeNode*> v2;
	v2.reserve(N);
	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;
}

在这里插入图片描述

传统的内存分配(如使用malloc和free)会导致频繁的系统调用,这些调用不仅开销大,而且容易在多次分配和释放后产生内存碎片。而定长内存池通过预先分配大块内存并自行管理这些内存块的分配和回收,减少了系统调用的次数,从而降低了内存碎片的产生。

五、高并发内存池整体框架设计

高并发内存池主要解决的问题是什么?

1. 内存碎片问题
内存碎片是动态内存分配中常见的问题,主要分为内碎片和外碎片:
内碎片:由于内存对齐或数据结构的固有大小,分配出去的空间中部分内存无法被有效利用,造成了浪费。
外碎片:指的是一些空闲的连续内存区域太小,无法满足较大的内存分配请求,尽管总体上可用内存可能足够。
高并发内存池通过内存分配和回收策略,如定期合并小块内存为较大的内存块,以减少内存碎片,提高内存利用率。

2. 性能问题
在高并发场景下,频繁的内存分配和释放操作会导致大量系统调用,进而降低系统性能。内存池通过预先分配一块较大的内存空间,并在内部以更高效的方式管理和分配内存块,减少了与操作系统的交互次数,从而提高了内存分配和释放的效率。

3. 多线程环境下的锁竞争问题
在多线程环境下,多个线程同时访问内存池可能会导致锁竞争。高并发内存池通常采用分层结构,如ThreadCache(线程缓存)、CentralCache(中心缓存)、PageCache(页缓存),来减少锁竞争:
ThreadCache:每个线程独享一个线程缓存,用于快速分配和释放小块内存,通常不需要加锁,提高了并发性能。
CentralCache:中心缓存被所有线程共享,用于在必要时为线程缓存提供内存,这里需要加锁来保证线程安全,因为中心缓存是所有线程都能共享的,但通常使用桶锁来减少锁竞争。
PageCache:页缓存存储的是以页为单位的内存块,用于向中心缓存提供更大的内存块,这里也可能需要加锁,但相比中心缓存,锁竞争的情况更少。

4. 资源均衡问题
在多线程环境中,为了避免一个线程占用过多资源而导致其他线程资源紧张,高并发内存池还设计了资源均衡机制。例如,当线程缓存中的内存块数量超过一定阈值时,会将部分内存块释放回中心缓存,以供其他线程使用。

高并发内存池整体框架设置:

在这里插入图片描述
高并发内存池主要由以下三个部分构成:

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

ThreadCache设计

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

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

这时我们选择做一些平衡的牺牲,让这些字节数按照某种规则进行对齐,例如我们让这些字节数都按照8字节进行向上对齐(不满足8字节的按照8字节对齐),那么ThreadCache的结构就是下面这样的,此时当线程申请1 ~ 8字节的内存时会直接给出8字节,而当线程申请9 ~16字节的内存时会直接给出16字节,以此类推
在这里插入图片描述
 因此当线程要申请某一大小的内存块时,就需要经过某种要求得到对齐后的字节数,进而找到对应的哈希桶,如果该哈希桶中的自由链表中有内存块,那就从自由链表中头删一个内存块进行返回;如果该自由链表已经为空了,那么就需要向下一层的CentralCache进行获取了。


针对上述的结构我们知道需要有一个自由链表的数组,数组里面存储的是不同对齐字节数的自由链表,针对计算好的对齐数找到对应的自由链表。找到自由链表就需要分配内存和回收内存。

class FreeList
{
public:
	void Push(void* obj)
	{
		assert(obj);
		//头插
		*(void**)obj = _freeList;
		_freeList = obj;
	}

	void* Pop()
	{
		assert(_freeList);
		//头删
		void* obj = _freeList;
		_freeList = *(void**)obj;
		return obj;
	}

	//判断链表是否为空
	bool Empty()
	{
		return _freeList == nullptr;
	}
private:
	void* _freeList = nullptr;
};

自由链表的数组就在ThreadCache类里面,这个类就是针对传递过来的申请字节数按照对齐规则进行计算,如果可以那么就按照ThreadCache进行分配。但是ThreadCache里面的自由链表的个数到底是多少个呢?

#pragma once
#include "Common.h"

class ThreadCache
{
public:
	//申请对象  需要size才能找到对应的freeList链表
	void* Allocate(size_t size);

	//释放内存对象 
	void Deallocate(void* ptr, size_t size);

	//判断自由链表是否为空
	bool Empty(size_t index);

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

private:
	FreeList _freeLists[BUCKET_MAX_SIZE];
};

//线程局部存储->为每一个使用pTLSThreadCache变量的线程创建了独立的pTLSThreadCache副本
//因此不会存在会有竞争的问题
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;
ThreadCache哈希桶映射对齐规则

上面已经说了,每个字节数都对应一个自由链表,这样开销太大了,因此我们需要制定一个合适的映射对齐规则。
  
  首先,这些内存块是会被链接到自由链表上的,因此一开始肯定是按8字节进行对齐是最合适的,因为我们必须保证这些内存块,无论是在32位平台下还是64位平台下,都至少能够存储得下一个指针,所以一开始的对齐数一定至少要是8字节。
  
  但如果所有的字节数都按照8字节进行对齐的话,那么我们就需要建立256 × 1024 ÷ 8 = 32768个桶,这个数量还是比较多的,实际上我们可以让不同范围的字节数按照不同的对齐数进行对齐,具体对齐方式如下:
在这里插入图片描述

编写对齐函数

class SizeClass
{
public:
			// 整体控制在最多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)   //也就说明一共有208个桶
	static inline 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;
	}

	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
		{
			assert(false);
			return -1;
		}
	}

	static size_t _Index(size_t bytes, size_t aligNum)
	{
		size_t index = 0;
		if (bytes % aligNum != 0)
		{
			index = bytes / aligNum;
		}
		else
		{
			index = bytes / aligNum - 1;
		}
		return index;
	} 

	static inline size_t Index(size_t bytes)
	{
		assert(bytes <= MAX_BYTES);
		//static int bucketArray[4] = { 16, 56, 56, 56 };
		if (bytes <= 128)
		{
			return _Index(bytes, 8);
		}
		else if (bytes <= 1024)
		{
			return _Index(bytes, 16);
		}
		else if (bytes <= 8 * 1024)
		{
			return _Index(bytes, 128);
		}
		else if (bytes <= 64 * 1024)
		{
			return _Index(bytes, 1024);
		}
		else if (bytes <= 256 * 1024)
		{
			return _Index(bytes, 8 * 1024);
		}
		else
		{
			assert(false);
			return -1;
		}
	}
};

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

ThreadCache类

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

//小于等于MAX_BYTES,就找thread cache申请
//大于MAX_BYTES,就直接找page cache或者系统堆申请
static const size_t MAX_BYTES = 256 * 1024;
//thread cache和central cache自由链表哈希桶的表大小
static const size_t NFREELISTS = 208;

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

void* ThreadCache::Allocate(size_t size)
{ 
	//>= 256*1024就在ThreadCache中找
	assert(size <= MAX_BYTES);
	//计算对齐之后的内存大小
	size_t alignSize = SizeClass::RoundUp(size);
	//根据size求出所在桶的下标
	size_t index = SizeClass::Index(size);    //??为什么这里求出对齐之后的大小,但是申请下标的时候还是按照之间的size进行申请的? 
	if (!_freeLists[index].Empty())
	{
		return _freeLists[index].Pop();
	}
	else
	{
		//去中心缓存中拿数据
		GetCentralCache(index, alignSize);
	}
	return nullptr;
}
ThreadCache TLS无锁访问

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

//线程局部存储->为每一个使用pTLSThreadCache变量的线程创建了独立的pTLSThreadCache副本
//因此不会存在会有竞争的问题
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;

如果线程自己的pTLSThreadCache 指针为空,那么就回去创建一个ThreadCache进行后续的申请和回收空间


static void* ConcurrentAlloc(size_t size)
{
	if (pTLSThreadCache == nullptr)
	{
		pTLSThreadCache = new ThreadCache;
	}
	return pTLSThreadCache->Allocate(size);
}

static  void* ConcurrentFree(void* ptr, size_t size)
{
	//传size:需要知道将ptr还给ThreadCache中的那一个桶
	//size后面可以不传,后面解决???
	assert(pTLSThreadCache);
	pTLSThreadCache->Deallocate(ptr, size);
}

centralcache整体框架设计

中心缓存起到一个承上启下的作用,它负责给线程缓存分配小块儿的内存,并且负责从页缓存申请大块儿内存

在这里插入图片描述

当线程申请某一大小的内存时,如果ThreadCache中对应的自由链表不为空,那么直接取出一个内存块进行返回即可,但如果此时该自由链表为空,那么这时ThreadCache就需要向CentralCache申请内存了。

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

CentralCache与ThreadCache有两个明显不同的地方:

  1. ThreadCache是每个线程独享的,而CentralCache是所有线程共享的,因为每个线程的ThreadCache没有内存了都会去找CentralCache,因此在访问CentralCache时是需要加锁的。但CentralCache在加锁时并不是将整个CentralCache全部锁上了,CentralCache在加锁时用的是桶锁,也就是说每个桶都有一个锁。此时只有当多个线程同时访问CentralCache的同一个桶时才会存在锁竞争,如果是多个线程同时访问CentralCache的不同桶就不会存在锁竞争。
  2. ThreadCache的每个桶中挂的是一个个切好的内存块,而CentralCache的每个桶中挂的是一个个的span。
CentralCache结构设计

不同平台下的页号大小

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

页的大小一般是4K或者8K,我们以8K为例。在32位平台下,进程地址空间就可以被分成 2 32 2^{32} 232 ÷ 2 13 2^{13} 213 = 2 19 2^{19} 219 个页;在64位平台下,进程地址空间就可以被分成 2 64 2^{64} 264 ÷ 2 13 2^{13} 213 = 2 51 2^{51} 251
个页。页号本质与地址是一样的,它们都是一个编号,只不过地址是以一个字节为一个单位,而页是以多个字节为一个单位。

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

#ifdef _WIN64
	typedef unsigned long long PAGE_ID;
#elif _WIN32
	typedef size_t PAGE_ID;
#else
	//linux
#endif

span的结构

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

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

	size_t _useCount = 0;       //use_count为0时,代表这个span中所有被分配出去的小块儿内存都被线程缓存还回来了
	void* _freeList = nullptr;  //切好的小块内存的自由链表
};

对于span管理的以页为单位的大块内存,我们需要知道这块内存具体在哪一个位置,便于之后PageCache进行前后页的合并,因此span结构当中会记录所管理大块内存起始页的页号。其实这里的span结构里面的成员变量就是为了寻找申请的内存地址。

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

此外,每个span管理的大块内存,都会被切成相应大小的内存块挂到当前span的自由链表中,比如8Byte哈希桶中的span,会被切成一个个8Byte大小的内存块挂到当前span的自由链表中,因此span结构中需要存储切好的小块内存的自由链表。

span结构当中的_useCount成员记录的就是,当前span中切好的小块内存,被分配给ThreadCache的计数。当某个span的_useCount计数变为0时,代表当前span切出去的内存块对象全部还回来了,此时就可以将这个span再还给PageCache。

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

//带头双向循环链表
class SpanList
{
public:
	SpanList()
	{
		_head = new Span;
		_head->_next = _head;
		_head->_prev = _head;
	}
	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;
	}
	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;
public:
	std::mutex _mtx; //桶锁
};


CentralCache的结构

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

class CentralCache
{
public:
	//...
private:
	SpanList _spanLists[NFREELISTS];
};

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


centralcache核心实现

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

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

//单例模式
class CentralCache
{
public:
	//提供一个全局访问点
	static CentralCache* GetInstance()
	{
		return &_sInst;
	}
private:
	SpanList _spanLists[NFREELISTS];
private:
	CentralCache() //构造函数私有
	{}
	CentralCache(const CentralCache&) = delete; //防拷贝

	static CentralCache _sInst;
};

慢开始反馈调节算法

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

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

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

//管理对齐和映射等关系
class SizeClass
{
public:
	//thread cache一次从central cache获取对象的上限
	static size_t NumMoveSize(size_t size)
	{
		assert(size > 0);
	
		//对象越小,计算出的上限越高
		//对象越大,计算出的上限越低
		int num = MAX_BYTES / size;
		if (num < 2)
			num = 2;
		if (num > 512)
			num = 512;
	
		return num;
	}
};

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

//管理切分好的小对象的自由链表
class FreeList
{
public:
	size_t& MaxSize()
	{
		return _maxSize;
	}

private:
	void* _freeList = nullptr; //自由链表
	size_t _maxSize = 1;
};

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

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


从中心缓存获取对象

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

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

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

//从中心缓存获取对象
void* ThreadCache::FetchFromCentralCache(size_t index, size_t size)
{
	//慢开始反馈调节算法
	//1、最开始不会一次向central cache一次批量要太多,因为要太多了可能用不完
	//2、如果你不断有size大小的内存需求,那么batchNum就会不断增长,直到上限
	size_t batchNum = std::min(_freeLists[index].MaxSize(), SizeClass::NumMoveSize(size));
	if (batchNum == _freeLists[index].MaxSize())
	{
		_freeLists[index].MaxSize() += 1;
	}
	void* start = nullptr;
	void* end = nullptr;
	size_t actualNum = CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, size);
	assert(actualNum >= 1); //至少有一个

	if (actualNum == 1) //申请到对象的个数是一个,则直接将这一个对象返回即可
	{
		assert(start == end);
		return start;
	}
	else //申请到对象的个数是多个,还需要将剩下的对象挂到thread cache中对应的哈希桶中
	{
		_freeLists[index].PushRange(NextObj(start), end);
		return start;
	}
}


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

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

//从central cache获取一定数量的对象给thread cache
size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t n, size_t size)
{
	size_t index = SizeClass::Index(size);
	_spanLists[index]._mtx.lock(); //加锁
	
	//在对应哈希桶中获取一个非空的span
	Span* span = GetOneSpan(_spanLists[index], size);
	assert(span); //span不为空
	assert(span->_freeList); //span当中的自由链表也不为空

	//从span中获取n个对象
	//如果不够n个,有多少拿多少
	start = span->_freeList;
	end = span->_freeList;
	size_t actualNum = 1;
	while (NextObj(end)&&n - 1)
	{
		end = NextObj(end);
		actualNum++;
		n--;
	}
	span->_freeList = NextObj(end); //取完后剩下的对象继续放到自由链表
	NextObj(end) = nullptr; //取出的一段链表的表尾置空
	span->_useCount += actualNum; //更新被分配给thread cache的计数

	_spanLists[index]._mtx.unlock(); //解锁
	return actualNum;
}

由于CentralCache是所有线程共享的,所以我们在访问CentralCache中的哈希桶时,需要先给对应的哈希桶加上桶锁,在获取到对象后再将桶锁解掉。

在向CentralCache获取对象时,先是在CentralCache对应的哈希桶中获取到一个非空的span,然后从这个span的自由链表中取出n个对象即可,但可能这个非空的span的自由链表当中对象的个数不足n个,这时该自由链表当中有多少个对象就给多少就行了。

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

需要注意的是,虽然我们实际申请到对象的个数可能比n要小,但这并不会产生任何影响。因为ThreadCache的本意就是向CentralCache申请一个对象,我们之所以要一次多申请一些对象,是因为这样一来下次线程再申请相同大小的对象时就可以直接在ThreadCache里面获取了,而不用再向CentralCache申请对象。

此外,如果ThreadCache最终从CentralCache获取到的对象个数是大于一的,那么我们还需要将剩下的对象插入到ThreadCache中对应的哈希桶中,为了能让自由链表支持插入一段范围的对象,我们还需要在FreeList类中增加一个对应的成员函数。

//管理切分好的小对象的自由链表
class FreeList
{
public:
	//插入一段范围的对象到自由链表
	void PushRange(void* start, void* end)
	{
		assert(start);
		assert(end);

		//头插
		NextObj(end) = _freeList;
		_freeList = start;
	}
private:
	void* _freeList = nullptr; //自由链表
	size_t _maxSize = 1;
};


PageCache

PageCache与CentralCache结构的相同之处:

  1. PageCache与central cache一样,它们都是哈希桶的结构,并且page cache的每个哈希桶中里挂的也是一个个的span,这些span也是按照双链表的结构链接起来的。

PageCache与CentralCache结构的不同之处:

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

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


页缓存分配内存的全过程

如果CentralCache要获取一个n页的span,那我们就可以在PageCache的第n号桶中取出一个span返回给CentralCache即可,但如果第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返回给CentralCache,而128-n页的span就挂到第128-n号桶中。

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


PageCache的实现方式

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

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

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

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

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

//单例模式
class PageCache
{
public:
	//提供一个全局访问点
	static PageCache* GetInstance()
	{
		return &_sInst;
	}
private:
	SpanList _spanLists[NPAGES];
	std::mutex _pageMtx; //大锁
private:
	PageCache() //构造函数私有
	{}
	PageCache(const PageCache&) = delete; //防拷贝

	static PageCache _sInst;
};
PageCache中获取Span

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

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

//带头双向循环链表
class SpanList
{
public:
	Span* Begin()
	{
		return _head->_next;
	}
	Span* End()
	{
		return _head;
	}
private:
	Span* _head;
public:
	std::mutex _mtx; //桶锁
};

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

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

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

//管理对齐和映射等关系
class SizeClass
{
public:
	//central cache一次向page cache获取多少页
	static size_t NumMovePage(size_t size)
	{
		size_t num = NumMoveSize(size); //计算出thread cache一次向central cache申请对象的个数上限
		size_t nPage = num*size; //num个size大小的对象所需的字节数

		nPage >>= PAGE_SHIFT; //将字节数转换为页数
		if (nPage == 0) //至少给一页
			nPage = 1;

		return nPage;
	}
};

代码中的PAGE_SHIFT代表页大小转换偏移,我们这里以页的大小为8K为例,PAGE_SHIFT的值就是13。

//页大小转换偏移,即一页定义为2^13,也就是8KB
static const size_t PAGE_SHIFT = 13;

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

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

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

为什么是尾插呢?因为我们如果是将切好的对象尾插到自由链表,这些对象看起来是按照链式结构链接起来的,而实际它们在物理上是连续的,这时当我们把这些连续内存分配给某个线程使用时,可以提高该线程的CPU缓存利用率。

//获取一个非空的span
Span* CentralCache::GetOneSpan(SpanList& spanList, size_t size)
{
	//1、先在spanList中寻找非空的span
	Span* it = spanList.Begin();
	while (it != spanList.End())
	{
		if (it->_freeList != nullptr)
		{
			return it;
		}
		else
		{
			it = it->_next;
		}
	}

	//2、spanList中没有非空的span,只能向page cache申请
	Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(size));
	//计算span的大块内存的起始地址和大块内存的大小(字节数)
	char* start = (char*)(span->_pageId << PAGE_SHIFT);
	size_t bytes = span->_n << PAGE_SHIFT;

	//把大块内存切成size大小的对象链接起来
	char* end = start + bytes;
	//先切一块下来去做尾,方便尾插
	span->_freeList = start;
	start += size;
	void* tail = span->_freeList;
	//尾插
	while (start < end)
	{
		NextObj(tail) = start;
		tail = NextObj(tail);
		start += size;
	}
	NextObj(tail) = nullptr; //尾的指向置空
	
	//将切好的span头插到spanList
	spanList.PushFront(span);

	return span;
}

获取一个k页的span

当我们调用上述的GetOneSpan从CentralCache的某个哈希桶获取一个非空的span时,如果遍历哈希桶中的双链表后发现双链表中没有span,或该双链表中的span都为空,那么此时CentralCache就需要向page cache申请若干页的span了,下面我们就来说说如何从PageCache获取一个k页的span。

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

//带头双向循环链表
class SpanList
{
public:
	bool Empty()
	{
		return _head == _head->_next;
	}
	Span* PopFront()
	{
		Span* front = _head->_next;
		Erase(front);
		return front;
	}
private:
	Span* _head;
public:
	std::mutex _mtx; //桶锁
};

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

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

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

//获取一个k页的span
Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0 && k < NPAGES);
	//先检查第k个桶里面有没有span
	if (!_spanLists[k].Empty())
	{
		return _spanLists[k].PopFront();
	}
	//检查一下后面的桶里面有没有span,如果有可以将其进行切分
	for (size_t i = k + 1; i < NPAGES; i++)
	{
		if (!_spanLists[i].Empty())
		{
			Span* nSpan = _spanLists[i].PopFront();
			Span* kSpan = new Span;
			//在nSpan的头部切k页下来
			kSpan->_pageId = nSpan->_pageId;
			kSpan->_n = k;

			nSpan->_pageId += k;
			nSpan->_n -= k;
			//将剩下的挂到对应映射的位置
			_spanLists[nSpan->_n].PushFront(nSpan);
			return kSpan;
		}
	}
	//走到这里说明后面没有大页的span了,这时就向堆申请一个128页的span
	Span* bigSpan = new Span;
	void* ptr = SystemAlloc(NPAGES - 1);
	bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
	bigSpan->_n = NPAGES - 1;

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

	//尽量避免代码重复,递归调用自己
	return NewSpan(k);
}

这里其实有一个问题:当CentralCache向PageCache申请内存时,CentralCache对应的哈希桶是处于加锁的状态的,那在访问PageCache之前我们应不应该把CentralCache对应的桶锁解掉呢?

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

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

这里为了让代码清晰一点,只写出了加锁和解锁的逻辑,我们只需要将这些逻辑添加到之前实现的GetOneSpan函数的对应位置即可。

spanList._mtx.unlock(); //解桶锁
PageCache::GetInstance()->_pageMtx.lock(); //加大锁

//从page cache申请k页的span

PageCache::GetInstance()->_pageMtx.unlock(); //解大锁

//进行span的切分...

spanList._mtx.lock(); //加桶锁

//将span挂到central cache对应的哈希桶


ThreadCache回收内存

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

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

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

//释放内存对象
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);
	}
}

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

//释放对象导致链表过长,回收内存到中心缓存
void ThreadCache::ListTooLong(FreeList& list, size_t size)
{
	void* start = nullptr;
	void* end = nullptr;
	//从list中取出一次批量个数的对象
	list.PopRange(start, end, list.MaxSize());
	
	//将取出的对象还给central cache中对应的span
	CentralCache::GetInstance()->ReleaseListToSpans(start, size);
}

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

//管理切分好的小对象的自由链表
class FreeList
{
public:
	//将释放的对象头插到自由链表
	void Push(void* obj)
	{
		assert(obj);

		//头插
		NextObj(obj) = _freeList;
		_freeList = obj;
		_size++;
	}
	//从自由链表头部获取一个对象
	void* Pop()
	{
		assert(_freeList);

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

		return obj;
	}
	//插入一段范围的对象到自由链表
	void PushRange(void* start, void* end, size_t n)
	{
		assert(start);
		assert(end);

		//头插
		NextObj(end) = _freeList;
		_freeList = start;
		_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); //自由链表指向end的下一个对象
		NextObj(end) = nullptr; //取出的一段链表的表尾置空
		_size -= n;
	}
	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;
};

而对于FreeList类当中的PushRange成员函数,我们最好也像PopRange一样给它增加一个参数,表示插入对象的个数,不然我们这时还需要通过遍历统计插入对象的个数。

因此之前在调用PushRange的地方就需要修改一下,而我们实际就在一个地方调用过PushRange函数,并且此时插入对象的个数也是很容易知道的。当时thread cache从central cache获取了actualNum个对象,将其中的一个返回给了申请对象的线程,剩下的actualNum-1个挂到了thread cache对应的桶当中,所以这里插入对象的个数就是actualNum-1。

void* ThreadCache::GetCentralCache(size_t index, size_t size) //这个size是对齐之后的内存大小
{
	//慢开始的调节算法
	//batNum计算实际上向CentralCache申请对象的个数
	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(*(void**)start, end, actualNum - 1);
		return start;
	}
}

当ThreadCache的某个自由链表过长时,我们实际就是把这个自由链表当中全部的对象都还给CentralCache了,但这里在设计PopRange接口时还是设计的是取出指定个数的对象,因为在某些情况下当自由链表过长时,我们可能并不一定想把链表中全部的对象都取出来还给CentralCache,这样设计就是为了增加代码的可修改性。

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


CentralCache回收内存

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

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

如何根据对象的地址得到对象所在的页号?

首先我们必须理解的是,某个页当中的所有地址除以页的大小都等该页的页号。比如我们这里假设一页的大小是100,那么地址0 ~ 99都属于第0页,它们除以100都等于0,而地址100~199都属于第1页,它们除以100都等于1。

如何找到一个对象对应的span?

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

为了解决这个问题,我们可以建立页号和span之间的映射。由于这个映射关系在Page Cache进行span的合并时也需要用到,因此我们直接将其存放到PageCache里面。这时我们就需要在PageCache类当中添加一个映射关系了,这里可以用C++当中的unordered_map进行实现,并且添加一个函数接口,用于让CentralCache获取这里的映射关系。(下面代码中只展示了PageCache类当中新增的成员)

#pragma once
#include"Common.h"
#include "ObjectPool.h"

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

	//回去从对象到span的映射
	Span* MapObjectToSpan(void* obj);

	//获取一个K页的span
	Span* NewSpan(size_t k); 

	//释放空闲span回到PageCache,并合并相邻的span
	void ReleaseSpanToPageCahce(Span* span);
private:
	std::unordered_map<PAGE_ID, Span*> Id_Span_map;
	SpanList _spanLists[NPAGES];
	static PageCache _sInstan;
	PageCache()
	{}
	PageCache(const PageCache&) = delete;
	ObjectPool<Span> _spanPool;
public:
	std::mutex _pageMtx;
};

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

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

//获取一个k页的span
Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0 && k < NPAGES);
	//先检查第k个桶里面有没有span
	if (!_spanLists[k].Empty())
	{
		Span* kSpan = _spanLists[k].PopFront();

		//建立页号与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;
			//在nSpan的头部切k页下来
			kSpan->_pageId = nSpan->_pageId;
			kSpan->_n = k;

			nSpan->_pageId += k;
			nSpan->_n -= k;
			//将剩下的挂到对应映射的位置
			_spanLists[nSpan->_n].PushFront(nSpan);

			//建立页号与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;
	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了,直接将该对象的地址除以页的大小得到页号,然后在unordered_map当中找到其对应的span即可。

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

回收内存

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

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

//将一定数量的对象还给对应的span
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);
		//将对象头插到span的自由链表
		NextObj(start) = span->_freeList;
		span->_freeList = start;

		span->_useCount--; //更新被分配给thread cache的计数
		if (span->_useCount == 0) //说明这个span分配出去的对象全部都回来了
		{
			//此时这个span就可以再回收给page cache,page cache可以再尝试去做前后页的合并
			_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(); //解锁
}

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

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


PageCache回收内存

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

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


PageCache进行前后页的合并

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

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

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

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

鉴于此,我们可以在span结构中再增加一个_isUse成员,用于标记这个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;  //切好的小块内存的自由链表

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

因此当CentralCache向PageCache申请到一个span时,需要立即将该span的_isUse改为true。

span->_isUse = true;

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

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

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

//获取一个k页的span
Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0 && k < NPAGES);
	//先检查第k个桶里面有没有span
	if (!_spanLists[k].Empty())
	{
		Span* kSpan = _spanLists[k].PopFront();

		//建立页号与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;
			//在nSpan的头部切k页下来
			kSpan->_pageId = nSpan->_pageId;
			kSpan->_n = k;

			nSpan->_pageId += k;
			nSpan->_n -= k;
			//将剩下的挂到对应映射的位置
			_spanLists[nSpan->_n].PushFront(nSpan);
			//存储nSpan的首尾页号与nSpan之间的映射,方便page cache合并span时进行前后页的查找
			_idSpanMap[nSpan->_pageId] = nSpan;
			_idSpanMap[nSpan->_pageId + nSpan->_n - 1] = nSpan;

			//建立页号与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;
	void* ptr = SystemAlloc(NPAGES - 1);
	bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
	bigSpan->_n = NPAGES - 1;

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

	//尽量避免代码重复,递归调用自己
	return NewSpan(k);
}

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

//释放空闲的span回到PageCache,并合并相邻的span
void PageCache::ReleaseSpanToPageCache(Span* span)
{
	//对span的前后页,尝试进行合并,缓解内存碎片问题
	//1、向前合并
	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;

		//将prevSpan从对应的双链表中移除
		_spanLists[prevSpan->_n].Erase(prevSpan);

		delete prevSpan;
	}
	//2、向后合并
	while (1)
	{
		PAGE_ID nextId = span->_pageId + span->_n;
		auto ret = _idSpanMap.find(nextId);
		//后面的页号没有(还未向系统申请),停止向后合并
		if (ret == _idSpanMap.end())
		{
			break;
		}
		//后面的页号对应的span正在被使用,停止向后合并
		Span* nextSpan = ret->second;
		if (nextSpan->_isUse == true)
		{
			break;
		}
		//合并出超过128页的span无法进行管理,停止向后合并
		if (nextSpan->_n + span->_n > NPAGES - 1)
		{
			break;
		}
		//进行向后合并
		span->_n += nextSpan->_n;

		//将nextSpan从对应的双链表中移除
		_spanLists[nextSpan->_n].Erase(nextSpan);

		delete nextSpan;
	}
	//将合并后的span挂到对应的双链表当中
	_spanLists[span->_n].PushFront(span);
	//建立该span与其首尾页的映射
	_idSpanMap[span->_pageId] = span;
	_idSpanMap[span->_pageId + span->_n - 1] = span;
	//将该span设置为未被使用的状态
	span->_isUse = false;
}

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

如果没有通过页号获取到其对应的span,说明对应到该页的内存块还未申请,此时需要停止合并。
如果通过页号获取到了其对应的span,但该span处于被使用的状态,那我们也必须停止合并。
如果合并后大于128页则不能进行本次合并,因为page cache无法对大于128页的span进行管理。
  在合并span时,由于这个span是在PageCache的某个哈希桶的双链表当中的,因此在合并后需要将其从对应的双链表中移除,然后再将这个被合并了的span结构进行delete。

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


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

申请过程

之前说到,每个线程的ThreadCache是用于申请小于等于256KB(32页)的内存的,而对于大于256KB的内存,我们可以考虑直接向PageCache申请,但PageCache中最大的页也就只有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
	{
		//大于256KB的按页对齐
		return _RoundUp(bytes, 1 << PAGE_SHIFT);
	}
}

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

static void* ConcurrentAlloc(size_t size)
{
	if (size > MAX_BYTES) //大于256KB的内存申请
	{
		//计算对齐之后的字节数
		size_t alignSize = SizeClass::RoundUp(size);
		//根据字节数计算需要的页数
		size_t kPage = alignSize >> PAGE_SHIFT;

		//向page cache申请kPage页的span
		PageCache::GetInstance()->_pageMtx.lock();
		Span* span = PageCache::GetInstance()->NewSpan(kPage);
		PageCache::GetInstance()->_pageMtx.unlock();

		void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
		return ptr;
	}
	else
	{
		//通过TLS,每个线程无锁的获取自己专属的ThreadCache对象
		if (pTLSThreadCache == nullptr)
		{
			pTLSThreadCache = new ThreadCache;
		}
		cout << std::this_thread::get_id() << ":" << pTLSThreadCache << endl;

		return pTLSThreadCache->Allocate(size);
	}
}

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

//获取一个k页的span
Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0);
	if (k > NPAGES - 1) //大于128页直接找堆申请
	{
		void* ptr = SystemAlloc(k);
		Span* span = new Span;
		span->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
		span->_n = k;
		//建立页号与span之间的映射
		_idSpanMap[span->_pageId] = span;
		return span;
	}
	//先检查第k个桶里面有没有span
	if (!_spanLists[k].Empty())
	{
		Span* kSpan = _spanLists[k].PopFront();

		//建立页号与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;
			//在nSpan的头部切k页下来
			kSpan->_pageId = nSpan->_pageId;
			kSpan->_n = k;

			nSpan->_pageId += k;
			nSpan->_n -= k;
			//将剩下的挂到对应映射的位置
			_spanLists[nSpan->_n].PushFront(nSpan);
			//存储nSpan的首尾页号与nSpan之间的映射,方便page cache合并span时进行前后页的查找
			_idSpanMap[nSpan->_pageId] = nSpan;
			_idSpanMap[nSpan->_pageId + nSpan->_n - 1] = nSpan;

			//建立页号与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;
	void* ptr = SystemAlloc(NPAGES - 1);
	bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
	bigSpan->_n = NPAGES - 1;

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

	//尽量避免代码重复,递归调用自己
	return NewSpan(k);
}

释放过程

当释放对象时,我们需要判断释放对象的大小:释放的内存小于256KB,ThreadCache释放;释放的内存[32页, 128页],释放给PageCache;释放的内存大于128页,释放给堆。

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

static  void ConcurrentFree(void* ptr)
{
	//根据归还对象的地址计算页号,在页号和span的哈希里面找到对应的span
	Span* span = PageCache::GetInstance()->MapObjectToSpan(ptr);
	size_t size = span->_objSize;
	if (size > MAX_BYTES)
	{

		PageCache::GetInstance()->_pageMtx.lock();
		PageCache::GetInstance()->ReleaseSpanToPageCahce(span);
		PageCache::GetInstance()->_pageMtx.unlock();
	}
	else
	{
		assert(pTLSThreadCache);
		//小对象还给ThreadCache
		pTLSThreadCache->Deallocate(ptr, size);
	}
}

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

//释放空闲的span回到PageCache,并合并相邻的span
void PageCache::ReleaseSpanToPageCache(Span* span)
{
	if (span->_n > NPAGES - 1) //大于128页直接释放给堆
	{
		void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
		SystemFree(ptr);
		delete span;
		return;
	}
	//对span的前后页,尝试进行合并,缓解内存碎片问题
	//1、向前合并
	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;

		//将prevSpan从对应的双链表中移除
		_spanLists[prevSpan->_n].Erase(prevSpan);

		delete prevSpan;
	}
	//2、向后合并
	while (1)
	{
		PAGE_ID nextId = span->_pageId + span->_n;
		auto ret = _idSpanMap.find(nextId);
		//后面的页号没有(还未向系统申请),停止向后合并
		if (ret == _idSpanMap.end())
		{
			break;
		}
		//后面的页号对应的span正在被使用,停止向后合并
		Span* nextSpan = ret->second;
		if (nextSpan->_isUse == true)
		{
			break;
		}
		//合并出超过128页的span无法进行管理,停止向后合并
		if (nextSpan->_n + span->_n > NPAGES - 1)
		{
			break;
		}
		//进行向后合并
		span->_n += nextSpan->_n;

		//将nextSpan从对应的双链表中移除
		_spanLists[nextSpan->_n].Erase(nextSpan);

		delete nextSpan;
	}
	//将合并后的span挂到对应的双链表当中
	_spanLists[span->_n].PushFront(span);
	//建立该span与其首尾页的映射
	_idSpanMap[span->_pageId] = span;
	_idSpanMap[span->_pageId + span->_n - 1] = span;
	//将该span设置为未被使用的状态
	span->_isUse = false;
}

说明一下,直接向堆申请内存时我们调用的接口是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
}

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

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

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

原因如下:

如果释放的是大于256KB的对象,需要根据对象的大小来判断这块内存到底应该还给Page Cache,还是应该直接还给堆。
如果释放的是小于等于256KB的对象,需要根据对象的大小计算出应该还给ThreadCache的哪一个哈希桶。
  如果我们也想做到,在释放对象时不用传入对象的大小,那么我们就需要建立对象地址与对象大小之间的映射。由于现在可以通过对象的地址找到其对应的span,而span的自由链表中挂的都是相同大小的对象。

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

//管理以页为单位的大块内存
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;        //是否在被使用
};

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

Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(size));
span->_objSize = size;

代码中有两处,一处是在CentralCache中获取非空span时,如果CentralCache对应的桶中没有非空的span,此时会调用NewSpan获取一个k页的span;另一处是当申请大于256KB内存时,会直接调用NewSpan获取一个k页的span。


此时当我们释放对象时,就可以直接从对象的span中获取到该对象的大小,准确来说获取到的是对齐以后的大小。

static void ConcurrentFree(void* ptr)
{
	Span* span = PageCache::GetInstance()->MapObjectToSpan(ptr);
	size_t size = span->_objSize;
	if (size > MAX_BYTES) //大于256KB的内存释放
	{
		PageCache::GetInstance()->_pageMtx.lock();
		PageCache::GetInstance()->ReleaseSpanToPageCache(span);
		PageCache::GetInstance()->_pageMtx.unlock();
	}
	else
	{
		assert(pTLSThreadCache);
		pTLSThreadCache->Deallocate(ptr, size);
	}
}

读取IDtoSpan的映射关系时候是需要锁
我们将页号与span之间的映射关系是存储在PageCache类当中的,当我们访问这个映射关系时是需要加锁的,因为STL容器是不保证线程安全的。

对于当前代码来说,如果我们此时正在PageCache进行相关操作,那么访问这个映射关系是安全的,因为当进入PageCache之前是需要加锁的,因此可以保证此时只有一个线程在进行访问。

但如果我们是在CentralCache访问这个映射关系,或是在调用ConcurrentFree函数释放内存时访问这个映射关系,那么就存在线程安全的问题。因为此时可能其他线程正在Page Cache当中进行某些操作,并且该线程此时可能也在访问这个映射关系,因此当我们在Page Cache外部访问这个映射关系时是需要加锁的。

实际就是在调用PageCache对外提供访问映射关系的函数时需要加锁,这里我们可以考虑使用C++当中的unique_lock,当然你也可以用普通的锁。

//获取从对象到span的映射
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;
	}
}

八、多线程环境下对比malloc测试

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次: 花费:%u ms\n",
		nworks, rounds, ntimes, malloc_costtime);
	printf("%u个线程并发执行%u轮次,每轮次free %u次: 花费:%u ms\n",
		nworks, rounds, ntimes, free_costtime);
	printf("%u个线程并发malloc&free %u次,总计花费:%u ms\n",
		nworks, nworks*rounds*ntimes, malloc_costtime + free_costtime);
}

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(ConcurrentAlloc(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++)
				{
					ConcurrentFree(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次: 花费:%u ms\n",
		nworks, rounds, ntimes, malloc_costtime);
	printf("%u个线程并发执行%u轮次,每轮次concurrent dealloc %u次: 花费:%u ms\n",
		nworks, rounds, ntimes, free_costtime);
	printf("%u个线程并发concurrent alloc&dealloc %u次,总计花费:%u ms\n",
		nworks, nworks*rounds*ntimes, malloc_costtime + free_costtime);
}

int main()
{
	size_t n = 10000;
	cout << "==========================================================" <<
		endl;
	BenchmarkConcurrentMalloc(n, 4, 10);
	cout << endl << endl;
	BenchmarkMalloc(n, 4, 10);
	cout << "==========================================================" <<
		endl;
	return 0;
}

其中测试函数各个参数的含义如下

  • ntimes:单轮次申请和释放内存的次数
  • nworks:线程数
  • rounds:轮次
  1. 在测试函数中,我们通过clock函数分别获取到每轮次申请和释放所花费的时间,然后将其对应累加到malloc_costtime和free_costtime上。最后我们就得到了,nworks个线程跑rounds轮,每轮申请和释放ntimes次,这个过程申请所消耗的时间、释放所消耗的时间、申请和释放总共消耗的时间。
  2. 注意,我们创建线程时让线程执行的是lambda表达式,而我们这里在使用lambda表达式时,以值传递的方式捕捉了变量k,以引用传递的方式捕捉了其他父作用域中的变量,因此我们可以将各个线程消耗的时间累加到一起。
  3. 我们将所有线程申请内存消耗的时间都累加到malloc_costtime上, 将释放内存消耗的时间都累加到free_costtime上,此时malloc_costtime和free_costtime可能被多个线程同时进行累加操作的,所以存在线程安全的问题。鉴于此,我们在定义这两个变量时使用了atomic类模板,这时对它们的操作就是原子操作了。

固定大小内存的申请和释放

v.push_back(malloc(16));
v.push_back(ConcurrentAlloc(16));

此时4个线程执行10轮操作,每轮申请释放10000次,总共申请释放了40万次,运行后可以看到,malloc的效率还是更高的。
在这里插入图片描述
由于此时我们申请释放的都是固定大小的对象,每个线程申请释放时访问的都是各自Thread Cache的同一个桶,当ThreadCache的这个桶中没有对象或对象太多要归还时,也都会访问CentralCache的同一个桶。此时CentralCache中的桶锁就不起作用了,因为我们让Central Cache使用桶锁的目的就是为了,让多个ThreadCache可以同时访问CentralCache的不同桶,而此时每个ThreadCache访问的却都是CentralCache中的同一个桶。


不同大小内存的申请和释放

v.push_back(malloc((16 + i) % 8192 + 1));
v.push_back(ConcurrentAlloc((16 + i) % 8192 + 1));

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值