高并发内存池

高并发内存池

项目简介

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

项目相关知识: c/c++ , 数据结构(链表, 哈希桶) , 操作系统内存管理, 单例模式, 多线程, 互斥锁等等

[tcmalloc源代码](tcmalloc: TCMalloc (google-perftools) 是用于优化C++写的多线程应用,比glibc 2.3的malloc快。这个模块可以用来让MySQL在高并发下内存占用更加稳定。 (gitee.com))


内存池相关概念

池化技术

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

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

也可以举一个很简单的例子:小红家里离取水的地方很远,但是每天都从家里跑到大老远的地方取水,于是小红为了舒服一点就想出了一个法子,在家门口弄了个蓄水池子,将水都弄到蓄水池里,往后用水都用蓄水池的,等蓄水池的水不够用了,再去取水的地方去取水到蓄水池里

内存池

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

我们平时c语言使用的就是malloc,free操作内存时,实际上就是调用malloc或free实现的分配和归还内存的算法从malloc管理的内存池中获取内存或释放内存。

内存池主要解决的问题
  • 效率问题
  • 内碎片和外碎片问题

效率问题:

没有内存池的情况下,就是相当于每次当前面需要使用内存时,要自己调用系统接口去向操作系统要(涉及到内核态的切换),效率和性能肯定都是不高的,因为程序里面可能会频繁的涉及到内存申请操作;而内存池就是提前向操作系统申请一大块内存空间,而后进行处理,后续直接调用申请接口就可以进行使用了

内外碎片问题

内碎片: 就是我们在进行内存分配时,因为对齐需求的原因,导致多分配空间的问题。

例如: 我们现在要申请5字节的空间,我们都是以为自己申请的就是5字节的空间,但实际上分配的是8字节的空间(更好管理),即3字节的空间我们是不会用上的,就会造成浪费。-- 但内碎片一般来说都是暂时的,危害并不会很大

外碎片:外部碎片是一些空闲的连续内存区域太小,这些内存空间不连续,以至于合计的内存足够,但是不能满足一些的内存分配申请需求

如图:就是较为典型的外碎片的例子


项目预备知识

向系统申请大块内存

Windows下申请大块内存的接口:

LPVOID VirtualAlloc(
LPVOID lpAddress, // 要分配的内存区域的地址
DWORD dwSize, // 分配的大小
DWORD flAllocationType, // 分配的类型
DWORD flProtect // 该内存的初始保护属性
);

详解可看:

VirtualAlloc_百度百科 (baidu.com)

因为该接口在后面使用较多, 所以可以考虑将其封装成内联函数,。

cpp 复制代码
// 直接调用系统调用 以一页(8kb 8 * 1024)为单位开辟大块内存
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32
	void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else
	// linux下brk mmap等
#endif
	if (ptr == nullptr)
		throw std::bad_alloc(); // 开辟失败则直接进行抛异常

	return ptr;
}
定长内存池设计

作为程序员(C/C++)我们知道申请内存使用的是malloc,malloc其实就是一个通用的大众货,什么场景下都可以用,但是什么场景下都可以用就意味着什么场景下都不会有很高的性能

而所谓定长内存池, 在C++中实际上对应的就是对象,即一个只能开辟固定大小的内存的对象。有以下俩种定义方式

使用非类型模板参数,一开始就指定好内存对象的大小

cpp 复制代码
template<size_t N>
class ObjectPool
{

};

或者是使用模板, 分配固定的类型对象

cpp 复制代码
template<class T>
class ObjectPool
{
    
};

我们采用第二种方式来实现:

定长内存池的对象的定义:

cpp 复制代码
template<class T>
class ObjectPool
{
public:
	T* New() {}
	void Delete(T* obj) {}
private:
	char* _memory = nullptr;	// 指向大块内存的指针
	size_t _RemainSize = 0;		// 当前大块内存所剩大小
	void* _freeList = nullptr;	// 指向已经回收了的内存的指针
};

说明:

  • _memory: 指向还未被分配出去的内存

  • _RemainSize: 所剩的内存大小

  • _freeList: 管理释放的内存对象, 使用单链表的方式组织起来(该内存对象的头部指向下一个内存块)

    所以一个内存对象大小在32位环境下至少为4字节,64位环境下至少为8字节,但为了方便, 我们这里就统一定成至少8字节大小

实现思路:

  • 先调用系统调用获取大块内存
  • 开放New接口分配固定大小的内存对象出去
  • 使用单链表的方式组织释放回来的内存对象大小(至少为8字节),申请内存对象时会优先去自由链表进行获取,如果有直接从自由链表中分配给用户, 没有再去做分配逻辑。

具体实现:

cpp 复制代码
template<class T>
class ObjectPool
{
public:
	T* New()
	{
		T* obj;
		// 优先使用已经归还的内存块
		if (_freeList != nullptr)
		{
			void* next = *(void**)_freeList;
			obj = (T*)_freeList;
			_freeList = next;
		}
		else
		{
			// 当所剩内存大小不足以开辟为新对象分配内存时,重新申请大块内存
			if (_RemainSize < sizeof(T))
			{
				_RemainSize = 128 * 1024;
				//_memory = (char*)malloc(_RemainSize); // 每次都申请128kb的大内存块
				_memory = (char*)SystemAlloc(_RemainSize >> 13);  // 左移13位等于 / 8 * 1024
				if (_memory == nullptr)
				{
					throw std::bad_alloc();
				}
			}
			obj = (T*)_memory;
			size_t ObjSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
			_memory += ObjSize;
			_RemainSize -= ObjSize;
		}
		// 使用定位new,显示初始化对象
		new(obj) T;
		
		return obj;
	}

	void Delete(T* obj)
	{
		obj->~T();

		//使用链表头插的方式,将归还的内存块组织起来
		*(void**)obj = _freeList; // 使用内存块的前4/8个字节充当next指针
		_freeList = obj;
	}
private:
	char* _memory = nullptr;	// 指向大块内存的指针
	size_t _RemainSize = 0;		// 当前大块内存所剩大小
	void* _freeList = nullptr;	// 指向已经回收了的内存的指针
};

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

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

定长内存池对比malloc分配效率

测试代码:

使用定产内存池和malloc进行5轮次的10万次的申请和释放固定大小的内存对象

cpp 复制代码
struct TreeNode
{
	int _val;
	TreeNode* _left;
	TreeNode* _right;
    
	TreeNode()
		:_val(0)
		, _left(nullptr)
		, _right(nullptr)
	{}
};

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

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

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

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

	size_t end1 = clock();

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

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

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

实验结果:

可以看到定长内存池对于指定大小的内存对象分配效率还是比malloc高的, 正是因为malloc适用的内存分配场景多,兼顾的条件多, 所以意味着malloc在多数场景下都不会有太高的效率


TLS技术

我们知道在一个进程中,所有线程是共享同一个地址空间的。所以,如果一个变量是全局的或者是静态的,那么所有线程访问的是同一份,如果某一个线程对其进行了修改,也就会影响到其他所有的线程。不过我们可能并不希望这样,所以更多的推荐用基于堆栈的自动变量或函数参数来访问数据,因为基于堆栈的变量总是和特定的线程相联系的。

不过如果某些时候(比如可能是特定设计的dll),我们就是需要依赖全局变量或者静态变量,那有没有办法保证在多线程程序中能访问而不互相影响呢?答案是有的。操作系统帮我们提供了这个功能------TLS线程本地存储。TLS的作用是能将数据和执行的特定的线程联系起来,可以实现只有特定的线程才能访问和操作该数据。

实现TLS有俩种方法: 静态和动态

静态使用十分简单只需加上下面一句话,就可以为每个线程创建一个ThreadCache 对象数据:

c++ 复制代码
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr; 

具体介绍可以参考以下俩篇博客

[Window TLS](线程本地存储TLS(Thread Local Storage)的原理和实现------分类和原理 - 无我 - C++博客 (cppblog.com))

Linux gcc TLS


总体框架

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

  • thread cache :线程缓存是每个线程独有的,用于小于256KB的内存的分配,线程从这里申请内存不需要加锁,每个线程独享一个thread cache对象
  • central cache :中心缓存是所有线程所共享,thread cache是按需从central cache中获取**的对象。central cache合适的时机回收thread cache中的对象,避免一个线程占用了太多的内存,而其他线程的内存吃紧,**达到内存分配在多个线程中更均衡的按需调度的目的。central cache是存在竞争的,所以从这里取内存对象是需要加锁,首先这里用的是桶锁,其次只有thread cache的没有内存对象时才会找central cache,所以竞争也不会很激烈。
  • page cache :页缓存是在central cache缓存上面的一层缓存,存储的内存是以页为单位存储 及分配的,central cache没有内存对象时,从page cache分配出一个管理一定数量的page的span对象,并切割成定长大小的小块内存,分配给central cache 。当一个span的几个跨度页的对象都回收以后,page cache会回收central cache满足条件的span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。

进一步说明:

该项目实现的申请内存的逻辑大概是这样走的:

  • 先通过所申请的内存大小进行分流:大于128kb直接调用系统接口申请,小于128kb通过thread Cache里面内存对齐数算法和内存大小映射算法,映射到符合申请字节大小的链表,如果该链表具有内存节点,则将此节点取下,如果该链表为空,就去Central Cache 一次取大量内存节点下来
  • Central Cache获取到Thread Cache的需求后,会根据上面的映射关系,找到对应大小的Span链表处,获取到一个非空的Span对象,从Span对象的内存链表中拿取内存节点到ThreadCache中,如果没有找到一个非空的Span对象,那么CentralCache就需要继续向上层去要Span对象
  • PageCache接收到CentralCache的需求后,也会根据对应的内存对齐数大小映射到对应的SpanList中,取下一个非空的Span对象分配给CentralCache,如果没有,就去管理内存页更多的SpanList链表中去切分Span对象

三层缓存各自的作用:

Thread Cache: 根据所申请的内存大小,分配对应的内存对象给线程,负责直接和线程打交道,回收内存合并交付给Central Cache

Central Cache: 管理一个个已切分好的Span对象链表,实时为ThreadCache补货,以及在某个Span对象内存没有被使用的情况下将Span对象归还给PageCache,合并成更大的内存

Page Cache: 管理一个个未切分好的Span对象链表(都是较为集中的大块内存),在Central Cache进行取货时将Span对象管理的内存页切割成对应大小的内存块,在Central Cache还货时,试着将Span管理的内存页合并成更大块的内存页


申请内存

Thread Cache 设计

底层管理内存时,实际上是将一大块内存,抽象成一小块一小块分开的内存使用一定的数据结构将其进行管理起来的。(为了简单,本项目底层管理内存对象都是链表的形式进行组织的)

具体类设计:

c++ 复制代码
class ThreadCache
{
public:
	// 申请和释放内存对象
	void* Allocate(size_t size);
	void Deallocate(void* ptr, size_t size);

	// 从中心缓存获取内存对象到对应的哈希桶中
	void* FetchFromCentralCache(size_t index, size_t size);

	// 释放对象时,链表过长时,回收内存回到中心缓存
	void ListTooLong(FreeList& list, size_t size);
private:
	FreeList _freeLists[NFREELIST]; // 内存哈希桶
};

// TLS thread local storage
// 为每个线程分配一个 ThreadCache 对象 每个线程只能访问自己的ThreadCache 对象
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;

FreeList目前需要实现以下俩个功能: push, pop, 支持头插和头删操作即可(O(1)的时间复杂度)

cpp 复制代码
// 管理内存
class FreeList
{
public:
	void Push(void *obj)
	{
		assert(obj);

		// 头插
		NextObj(obj) = _freeList;
		_freeList = obj;
		++_size;
	}
	
	void* Pop()
	{
		assert(_freeList);

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

		--_size;
		return obj;
	}
private:
	void* _freeList = nullptr;	// 头指针
};

ThreadCache内存对象管理思路:

  • 适用哈希桶的方式管理内存链表,相同大小的内存对象将会被挂着同一个哈希桶
  • 对内存的分配和回收对应的都是对内存链表的插入和删除操作(内存链表类似于上面定长内存池实现的自由链表)
自由链表的哈希桶与内存对象大小的映射关系

我们知道内存对象大小至少为8字节, 而我们默认ThreadCache对象的管理的最大内存为 256 KB, 所以如果以8字节大小作为对齐数大小, 则意味着我们至少需要 256 * 128个哈希桶(256 * 1024 / 8), 哈希桶的数量接近3万多, 这个数量是糟糕的。

而下面是tcmalloc中选用的字节对齐算法, 最后只需要208 个哈希桶, 下面进行分析。

计算实际申请字节数和映射的哈希桶号算法

内存对象大小 默认对齐字节数大小 映射的哈希桶桶号 对齐后的字节数大小
[1, 128] 8 [0, 16) 对齐到8的倍数
(128, 1024] 16 [16, 72) 对齐到16的倍数
(1024,8 * 1024] 128 [72, 128) 对齐到128的倍数
(8 * 1024, 64 * 1024] 1024 [128, 184) 对齐到1024的倍数
(64 * 1024, 256 * 1024] 8 * 1024 [184, 208) 对齐到8 * 1024的倍数
  • 计算默认对齐字节数的方法就是:根据内存对象大小所在的区间的进行选择对应的对齐数大小

计算内存对象实际开辟字节的大小

我所能想到的对应计算公式就是: TrueSize = ( Size / 8 + Size % 8 ? 0 : 1) * alignNum

例如计算开辟7字节大小时实际开辟的字节大小: TrueSize = (7 /8 + 1 ) * 8 = 8

c++ 复制代码
// bytes :所申请内存对象的大小 	alignNum: 默认字节数大小
static inline size_t _RoundUp(size_t bytes, size_t alignNum)
{
    return ((bytes + alignNum - 1) & ~(alignNum - 1));
}

使用大佬的方法计算上面的例子:bytes + alignNum - 1 = 7 + 8 - 1 , ~(alignNum - 1) = 1...1000

俩者一与得 8 ,即开辟7字节大小内存对象时,需要向上对齐到8字节

计算映射的哈希桶号的方法:

结合计算出来的对齐字节数计算哈希桶号公式:(当前字节数大小 - 内存对象最小开区间)/ 默认对齐字节数大小 + 前层已经映射了的桶数; 例如我们现在要计算的是申请 23 字节大小的内存对象映射到的是几号桶: 23 的默认对齐数大小是8,当前字节数 - 内存对象的左开区间 :23 - 0 = 23, 23 / 8 + 0 = 2 ,所以23号内存映射的哈希桶号就是2

c++ 复制代码
// bytes: 所需申请的内存大小 align_shift: 默认对齐数的位数
static  inline size_t _Index(size_t bytes, size_t align_shift)
{
    return ( (bytes + ( (1 << align_shift) - 1) ) >> align_shift) - 1;
}

以上这种算法就是我想出来出来的映射到该桶号的算法,下面来分析大佬桶号计算的方法

bytes align_shift (1 << align_shift) - 1 (bytes + ( (1 << align_shift) - 1) ) >> align_shift (bytes + ( (1 << align_shift) - 1) ) >> align_shift-1
23 3 7 23 + 7 = 30 >> 3 = 3 2

注: 实际上对于一个数进行左移align_shift操作就相当于对其数进行/ 2^align_shift操作

c++ 复制代码
// 获取当前对象向上对齐之后所需字节数
static inline size_t _RoundUp(size_t bytes, size_t alignNum)
{
    return ((bytes + alignNum - 1) & ~(alignNum - 1));
}
static inline size_t RoundUp(size_t size)
{
    if (size <= 128)
    {
        return _RoundUp(size, 8);
    }
    else if (size <= 1024)
    {
        return _RoundUp(size, 16);

    }
    else if (size <= 8 * 1024)
    {
        return _RoundUp(size, 128);

    }
    else if (size <= 64 * 1024)
    {
        return _RoundUp(size, 1024);

    }
    else if (size <= 256 * 1024)
    {
        return _RoundUp(size, 8 * 1024);
    }
    else
    {
        
    }

    return -1;
}
// 获取当前该去那个哈希桶里请求内存 
// align_shift 对齐字节数的2进制表示的指数
// 例: 5 3 -- (5 + 8 - 1) >> 3 - 1 = 1 - 1 = 0 号哈希桶
// 例: 9 3 -- (9 + 8 - 1) >> 3 - 1 = 2 - 1 = 1 号哈希桶	
static  inline size_t _Index(size_t bytes, size_t align_shift)
{
    return ( (bytes + ( (1 << align_shift) - 1) ) >> align_shift) - 1;
}
static inline size_t Index(size_t size)
{
    if (size <= 128)
    {
        return _Index(size, 3);
    }
    else if (size <= 1024)
    {
        return _Index(size, 4);
    }
    else if (size <= 8 * 1024)
    {
        return _Index(size, 7);
    }
    else if (size <= 64 * 1024)
    {
        return _Index(size, 10);
    }
    else if (size <= 256 * 1024)
    {
        return _Index(size, 13);
    }
    else 
    {
        assert(false);
    }

    return -1;
}

注: 将该函数设置为内联函数的原因是,该代码逻辑简单,使用频繁,没有循环,则可以选择使用内联函数直接在调用处直接展开,介绍函数堆栈的创建和销毁

Allocate

ThreadCache分配内存对象逻辑逻辑

  1. 根据用户所需申请的内存对象大小, 计算出对齐之后的内存对象大小 和 对齐的内存对象大小存放的桶号( 桶里存放的都是对齐数大小内存块)
  2. 然后到对应的哈希桶查看是否有空闲的内存, 如果有则直接从哈希桶的内存链表分配出去
  3. 如果没有就去ThreadCache中获取一批下来 (补货逻辑)

**Allocate **-- 逻辑1, 2

说明:

  • MAX_BYTES: 宏定义, 为ThreadCache管理的最大内存对象(256KB)
c++ 复制代码
void* ThreadCache::Allocate(size_t size)
{
	assert(size <= MAX_BYTES);

	size_t alignSize = Sizeclass::RoundUp(size); // 获取对齐之后的内存对象大小 
	size_t index = Sizeclass::Index(size);		// 获取当前内存对象大小存放的桶号
	
    // 如果哈希桶中具有空闲内存, 则直接进行内存对象弹出
	if (!_freeLists[index].empty())
	{
		return _freeLists[index].Pop();
	}
	else
	{
		return FetchFromCentralCache(index, alignSize); // 补货
	}
}

FetchFromCentralCache -- 3

逻辑3中还包含一个问题, 一次从CentralCache中获取多少个内存对象合适, 获取多了会造成内存浪费(没有被利用到), 分配少又会造成线程频繁向CentralCache对象获取内存对象的情况, 而且还要中衡内存对象的大小,(小则多分配, 大则少分配) 所以下面引入一个慢增长算法:

实现逻辑:

  • 通过ThreadCache的最大管理内存和内存对象的对齐后的大小, 计算出初步的值
  • 我们规定当前从CentralCache中获取的对象个数最多为512个, 最少为俩个
cpp 复制代码
// thread cache一次从中心缓存获取多少个内存对象  size 为内存对齐后的大小
static size_t NumMoveSize(size_t size)
{
    assert(size > 0);
    
    // [2, 512],一次批量移动多少个对象的(慢启动)上限值
    // 小对象一次批量上限高
    // 大对象一次批量上限低
    int num = MAX_BYTES / size;

    if (num < 2)
        num = 2;

    if (num > 512)
        num = 512;

    return num;
}

FetchFromCentralCache接口实现思路:

  • 对于小内存对象来说, 一开始就获取512个还是太多, 所以我们新增一个字段(_maxSize)到该哈希桶的链表当中(表示当前从获取CentralCache的对象数量+1), 每次获取对象时取慢增长的返回值和该字段中的较小值
cpp 复制代码
size_t _maxSize = 1;      // 用于 ThreadCache 向 Central Cache拿取内存时的满增长机制

解决了从CentralCache中获取多少个对象的问题, 还要解决将CentralCache中获取到的内存对象插入到ThreadCache对应的哈希桶中的问题。

  • 如果获取到的对象个数为1个, 则直接进行返回, 不需要进行插入到哈希桶中
  • 如果获取到的对象个数为多个, 则返回链表中的第一个对象,将剩余对象插入到对应的哈希桶中(涉及到插入一批对象到链表中, 所以单链表需要提交一个接口PushRange
cpp 复制代码
void PushRange(void* start, void* end)
{
    assert(start);
    assert(end);
    // 范围插入, 也是采取头插的操作
    NextObj(end) = _freeList;
    _freeList = start;
}

FetchFromCentralCache具体实现

  • 通过慢增长启动机制 和 哈希桶中的自由链表的字段_maxSize中的较小值获取到真正从CentralCache中获取的内存对象个数batchNum, 调用CentralCache对象的接口(后续会详细介绍)
  • 获取到内存对象后,如果是一个, 则直接返回, 如果是多个, 则先将链表中的第一个对象返回, 而后将剩余的对象插入到哈希桶中
cpp 复制代码
void* ThreadCache::FetchFromCentralCache(size_t index, size_t size)
{
	// 慢增长启动机制
	// 内存对象较小,一次从CentralCache获取的数量就多
	// 内存对象较大,一次从CentralCache 获取的内存对象数量就少
	size_t batchNum = min(Sizeclass::NumMoveSize(size), _freeLists[index].MaxSize());
	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
	{
        // 将剩余的内存对象插入到哈希桶去
		_freeLists[index].PushRange(NextObj(start), end, actualNum - 1);
		return start;
	}

	return nullptr;
}

Central Cache 设计

主体设计思路:

  • 也是采用哈希桶设置, 哈希桶的数量设置对应于ThreadCache哈希桶的数量, 分别是一一对应的, 获取内存对象方便
  • CentralCache的哈希桶下挂的不是_FreeList单链表, 而是由一个个Span对象组织起来的双链表,(Span对象是以页(8KB)为单位管理内存块的), 而Span下挂的是内存单链表, 所以ThreadCacheCentralCache对象获取内存对象时, 实际上是从Span中获取
  • 采取(饿汉)单例模式实现, 因为该对象会被多个线程进行访问, 所以会有线程安全的问题存在, 所以需要进行加锁, 为了减小锁竞争和申请内存的效率, 这里采用桶锁的方式实现。

CentralCache定义

  • 将构造函数私有化, 以及将拷贝函数和赋值函数进行禁掉
cpp 复制代码
// 设计为单例模式 饿汉模式 
class CentralCache
{
public:
    // 获取该单例对象
	static CentralCache* GetInstance()
	{
		return &_Inst;
	}
private:
	SpanList _spanLists[NFREELIST];  // Span哈希桶
private:
	CentralCache() {}
	CentralCache(const CentralCache&) = delete;
	static CentralCache _Inst;  // 单例对象
};

注意: 类内静态成员需要在类外进行初始化


Span 以及 SpanList 定义

Span对象目前实现:

  • 目前需要记录的字段: 管理的起始页数(方便对内存地址进行转换), 管理的总页数, _freeList (管理的内存对象链表), 前后指针
cpp 复制代码
struct Span
{
	PAGE_ID _pageId = 0;			// 管理的起始页数
	size_t _n = 0;					// 管理的总页数
	
	Span* _prev = nullptr;
	Span* _next = nullptr;

	void*  _freeList = nullptr;		// 内存链表
};

SpanList定义

  • 实现俩个接口: 在某个位置前面进行插入(insert), 删除某个位置的元素(erase)
cpp 复制代码
class SpanList
{
public:
	SpanList()
	{
		_head = new Span();
		_head->_prev = _head;
		_head->_next = _head;
	}
	// 在pos前面插入
	void insert(Span* pos, Span* ptr)
	{
		assert(pos);
		assert(ptr);

		Span* prev = pos->_prev;
		// prev ptr pos 
		prev->_next = ptr;
		ptr->_prev = prev;
		pos->_prev = ptr;
		ptr->_next = pos;
	}
	// 删除pos节点
	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 _mutex;   //哈希桶锁
};
分配内存对象给ThreadCache

FetchRangeObj

  • 通过内存对象大小, 获取到对应的哈希桶号, 先进行加锁操作, 然后获取一个非空的Span对象(通过接口GetOneSpan), 然后进行内存对象分配
cpp 复制代码
size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size)
{
	size_t index = Sizeclass::Index(size);
	
	_spanLists[index]._mutex.lock();
	
	Span* span = GetOneSpan(_spanLists[index], size);
	assert(span);
	assert(span->_freeList);

	// 如果没能从当前span对象获取够 batchNum 个 size大小的内存对象
	// 有多少那拿多少
	start = span->_freeList;
	end = start;
	size_t i = 0;
	size_t actualNum = 1;
    // 进行内存的切分
	while (i < batchNum - 1 && NextObj(end) != nullptr)
	{
		end = NextObj(end) ;
		i++;
		++actualNum;
	}
    
	// 更新Span对象的内存链表头指针指向位置
	span->_freeList = NextObj(end);
	NextObj(end) = nullptr; // 取出的内存对象去关联

	_spanLists[index]._mutex.unlock();

	return actualNum;
}

进一步说明:

  • 线程缓存ThreadCacheCentralCache中获取内存对象时, 不一定能获取到指定的内存对象个数, 不够数量采取有多少拿多少的策略(这样时间起来, 逻辑更加简单)
  • 上述获取内存的操作都需要加锁操作, 因为该哈希桶是多个线程共享的, 所以进行操作时, 需要将桶锁先加上

Page Cache设计

设计思路:

  • PageCache依旧是以哈希桶作为管理容器, 同中心缓存一样保存的也是Span对象(管理的是未切分好的大块内存), 以页的间隔为桶号划分, 最大管理内存为128 页(1024 KB), 所以需要128个桶号, 为了使管理的页数和桶号对应上, 所以将0号桶舍弃, 总共需要129个桶(即129个SpanList)
  • 每次都从系统中申请128页内存进行管理, 在需要时将大页的Span对象切分成俩个Span对象。
  • PageCache也是采用单例的方式进行实现, 因为是多个线程共享, 而PageCache被线程访问的次数不会太频繁, 所以这里采用的是大锁, 而不是桶锁(原因之一, 后续申请内存的时候会介绍原因2)

PageCache类定义

cpp 复制代码
class PageCache
{
public:
	static PageCache* GetInstance()
	{
		return &_PageInst;
	}

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

	std::mutex _PageMtx;
private:
	SpanList _spanLists[NPAGES];	
	PageCache(){}
	PageCache(const PageCache&) = delete;

	static PageCache _PageInst;
};
GetOneSpan

为了逻辑更清晰, 我们设计了一个从CentralCache的哈希桶中获取一个非空的Span接口, 因为其中包括找不到非空Span对象的情况, 会需要去SpanCache中获取的情况, 所以我们将其上述的FetchRange分开, 这样逻辑更加清晰。

其中还需要解决的一个问题是, 我们一次获取多大的Span对象合适, 即获取的内存是多大一页合适, 我们通过内存对象大小, 和分配算法NumMoveSize(ThreadCache一次最多从CentralCache中获取的对象个数来决定), 将俩个数相乘之后, 换算成页的单位, 最小申请内存为一页。

cpp 复制代码
// 计算一次向Page cache获取几个页
// 单个对象 8byte
// ...
// 单个对象 256KB
// 例: 8 num = 512 npage = 4096 < 8192 所以npage = 1 即 对齐数为8的默认分配管理一个内存页的span对象
static size_t NumMovePage(size_t size)
{
    size_t num = NumMoveSize(size);
    size_t npage = num * size;
	// 计算获取多少页的内存合适
    npage >>= PAGE_SHIFT;
    if (npage == 0)
        npage = 1;

    return npage;
}

实现逻辑:

  • 遍历对应的哈希桶, 找到第一个非空的Span对象, 将其进行返回
  • 没找到非空的Span对象, 则需要去SpanCache中获取一个非空的Span对象回来, 然后将该Span对象管理的内存块, 切分成一个个内存对象之后, 将该Span对象插入到对应的哈希桶中, 并将该Span对象返回

具体实现:

cpp 复制代码
Span* CentralCache::GetOneSpan(SpanList& list, size_t size)
{
	//先从CentralCache中的SpanList中的span进行查找 查看是否有空闲内存的span
	for (Span* it = list.Begin(); it != list.End(); it = it->_next)
	{
		if (it->_freeList != nullptr)
		{
			return it;
		}
	}

	// 先将桶锁释放,自己去PageCache中获取span对象, 这样可以不堵塞其他线程归还内存给对应的SpanList
	list._mutex.unlock();

	// 走到这 说明对应的哈希桶中的span都没有空闲内存了 所以需要向PageCache中申请
	PageCache::GetInstance()->_PageMtx.lock();
	Span* NewSpan = PageCache::GetInstance()->NewSpan( Sizeclass::NumMovePage(size) );
	PageCache::GetInstance()->_PageMtx.unlock();

	// 这部分并不需要桶锁 因为其他线程不会访问到这个span对象
	assert(NewSpan);
	// 获取大块内存的起始位置 和 大小
	char* start = (char*)(NewSpan->_pageId << PAGE_SHIFT);
	size_t bytes = NewSpan->_n << PAGE_SHIFT;
	char* end = start + bytes;

	// 先切分出一个头出来,方便尾插
	NewSpan->_freeList = start;
	void* tail = start;
	start += size;
	size_t i = 0;

	// 开始切分Span为小内存 而后将Span对象挂接到对应的CentralCache哈希桶中
	while (start < end)
	{
		i++;
		NextObj(tail) = start;
		tail = NextObj(tail); // 迭代往后走 相当于 = start;
		start += size;
	}

	// 因为tail最后的前4 或 8 位可能存储着非法的内存地址 如果不将tail前4 或8位字节置为空就会出现非法内存访问
	NextObj(tail) = nullptr;

	list._mutex.lock();
	// 将NewSpan插入到对应的哈希桶中去
	list.PushFront(NewSpan);


	return NewSpan;
}

进一步说明:

  • 当我们进去SpanCache对象中获取对象时, 可以先将CentralCache对象的桶锁的解掉, 然后加上SpanCache对象的大锁(后续会介绍),这样并不会造成线程问题, 因为同样的申请内存操作会被堵塞, 但是其他线程的释放内存操作不会被堵塞, 所以在该线程去SpanCache对象中获取Span对象时, 先将桶锁解掉, 这样可以不堵塞其他线程释放内存操作。
NewSpan

PageCache中获取一个K页内存的对象的接口

  • 先到对应的桶中进行寻找, 如果该桶中没有Span对象, 就继续往上一个桶去进行寻找, 直到找到一个不为空的哈希桶
  • 然后对该Span对象进行切分, 切分成一个(n - k)页的对象, 一个k页对象, 将K页对象进行返回, 然后将(n-k页)对象插入到对应的哈希桶去。
  • 如果遍历完了所有桶都没有找到Span对象, 则需要重新向系统申请128页的内存进行管理, 然后进行递归调用就好
cpp 复制代码
Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0);

	// 如果Page哈希桶中不为空 则返回第一个span对象
	if (!_spanLists[k].Empty())
	{
		Span* kSpan =  _spanLists[k].PopFront();
		return kSpan;
	}
	// 当前k页的span哈希桶为空 , 则去找上层不为空的span哈希桶切分span对象
	for (size_t n = k + 1; n < NPAGES; n++)
	{
		if (!_spanLists[n].Empty())
		{
			Span* nSpan = _spanLists[n].PopFront();
			Span* kSpan = new Span;

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

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

			_spanLists[nSpan->_n].PushFront(nSpan);
			return kSpan;
		}
	}

	// 遍历完PageCache都没有获得空间 就需要去向堆区申请128页 
	Span* BigSpan = new Span();
	void* ptr = SystemAlloc(NPAGES - 1);
	BigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
	BigSpan->_n = NPAGES - 1;

	_spanLists[NPAGES - 1].PushFront(BigSpan);

	// 递归重新再次进行开辟空间
	return NewSpan(k);
}

进一步说明:

  • PageCache中不使用桶锁的其二原因就是, 因为进行Span对象切分时,如果使用的是桶锁, 操作就会变得及其复杂。

释放内存

ThreadCache释放内存

为了防止一个ThreadCache对象中的内存对象太多, 而其他ThreadCache对象中的内存大少, 导致内存占用不均衡, 且ThreadCache占用内存过多, 导致内存池中没有大块内存可以进行分配(外碎片问题), 所以我们需要为ThreadCache添加一个内存回收算法。

该算法主要包含的决策内容是:当ThreadCache的内存链表中的对象超过多少时, 就将其还给CentralCache中心缓存

在该项目的设定就是如果当前ThreadCache中的空闲对象等于_maxSize字段了, 就将_maxSize对象还给中心缓存CentralCache中去,对应的内存链表_FreeList的操作就是弹出一序列对象, 其中还需要添加一个字段(_size), 记录当前的链表中有多少个内存对象,且需要将PushRange接口也做出一些改变, 添加插入的元素个数, 方便更新_size字段, 否则还需要进行遍历操作。

弹出对象采用传入头尾指针的方式, 获取弹出的内存对象链表

cpp 复制代码
void PopRange(void*& start, void*& end, size_t n)
{
    assert(n <= _size);

    start = _freeList;
    end = start;
    //往后迭代n - 1次 寻找到要弹出队列的最后一个节点
    for (size_t i = 0; i < n - 1; i++)
    {
        end = NextObj(end);
    }
    _freeList = NextObj(end);
    // 将取出的链表去关联
    NextObj(end) = nullptr;

    _size -= n;
}
void PushRange(void* start, void* end, size_t n)
{
    assert(start);
    assert(end);
    // 范围插入
    NextObj(end) = _freeList;
    _freeList = start;

    // 更新当前内存链表中的内存对象数量
    _size += n;
}

当前的_FreeList实现的接口具有:

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

		// 头插
		NextObj(obj) = _freeList;
		_freeList = obj;
		++_size;
	}
	
	void* Pop()
	{
		assert(_freeList);

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

		--_size;
		return obj;
	}
	void PushRange(void* start, void* end, size_t n)
	{
		assert(start);
		assert(end);
		// 范围插入
		NextObj(end) = _freeList;
		_freeList = start;
		
        // 更新当前内存链表中的内存对象数量
		_size += n;
	}
	// 一次弹出n个内存对象
	void PopRange(void*& start, void*& end, size_t n)
	{
		assert(n <= _size);

		start = _freeList;
		end = start;
		//往后迭代n - 1次 寻找到要弹出队列的最后一个节点
		for (size_t i = 0; i < n - 1; i++)
		{
			end = NextObj(end);
		}
		_freeList = NextObj(end);
		NextObj(end) = nullptr;

		_size -= n;
	}
	size_t& MaxSize()
	{
		return _maxSize;
	}
	size_t Size()
	{
		return _size;
	}
	bool empty()
	{
		return _freeList == nullptr;
	}
private:
	void* _freeList = nullptr;
	size_t _maxSize = 1;      // 用于 ThreadCache 向 Central Cache拿取内存时的满增长机制
	size_t _size = 0;
};

ThreadCache释放内存接口实现

  • 将释放的内存插入到对应的哈希桶中
  • 进行判断, 是否满足条件, 将内存对象还给中心缓存

为了逻辑清晰, 单独涉及一个接口ListTooLong进行内存对象的归还

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

	size_t index = Sizeclass::Index(size);
	_freeLists[index].Push(ptr);

	// 当空闲的内存对象大于当前的一次最大申请次数时,就将空间交换给 CentralCache
	if (_freeLists[index].Size() >= _freeLists[index].MaxSize())
	{
		ListTooLong(_freeLists[index], size);
	}
}

ListTooLong接口实现:

获取到对应的内存链表头部和尾部, 通过CentralCache的接口将内存对象归还ReleaseListToSpans(后续会详细介绍)

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

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

CentralCache回收内存

CentralCache的其中之一的作用就是在合适的时候, 将Span对象归还给上层缓存SpanCache, 进行合并出更大块的内存, 缓解外碎片问题。

从ThreadCache中回收内存以及释放内存给SpanCache

ReleaseListToSpans

CentralCache释放内存给SpanCache的时机, 我们在此做出约定, 当一个Span对象分配ThreadCache的内存对象全部回来时, 就将该Span对象还给SpanCache, 所以需要在Span中添加一个字段

cpp 复制代码
size_t _use_Count = 0;			// 当前正在被使用的内存对象

但我们发现单靠这个变量是否需要将该Span对象进行释放是不够的, 因为刚刚分配好的Span对象的 _use_Count也是0, 所以可能会将刚刚分配的Span对象仍在(FetchRangeObj)接口进行分配时, 就被拿去进行回收了, 所以还需要添加一个字段判断当前Span对象是否正在被使用

cpp 复制代码
bool _Isuse = false;			// 判断当前span对象管理的内存页是否有人使用

在CentralCache从SpanCache获取到该对象时, 将_Isuse字段置为true, 当所有的内存对象都还回来时, 再置为false。

因为从ThreadCache中回收内存对象还需要判断该内存对象来自于哪个Span对象, 所以需要在分配内存的时候, 为该Span对象建立起对应的映射关系, 利用key:value模型建立起 {页号, Span对象的映射}. 所以我们可以在PageCache添加一个成员,

cpp 复制代码
std::unordered_map<PAGE_ID, Span*> _idSpanMap

说明:

  • PAGE_ID是一个宏定义参数, 分别定义了该值在32环境下和64位环境下的取值

  • 因为64位下都定义了 _WIN64_WIN32, 所有我们先进行_WIN64的判断

    cpp 复制代码
    #ifdef _WIN64
    	typedef unsigned long long PAGE_ID;
    #elif _WIN32
    	typedef  size_t PAGE_ID;
    #endif

所以我们从PageCache对象获取Span对象时, 需要建立起页号与Span对象的映射, 改进之后的NewSpan接口

  • 分配k页对象时, 需要将每一页都与该Span对象建立起映射
  • 从n页对象分解出(n - k)页对象以及k页对象时, 需要给k页对象建立映射, 以及将(n - k)页对象首页和尾页都与Span对象建立起映射(方便后续的合并操作)
cpp 复制代码
Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0);

	// 如果Page哈希桶中不为空 则返回第一个span对象
	if (!_spanLists[k].Empty())
	{
		Span* kSpan =  _spanLists[k].PopFront();
		// 将kSpan的每一个内存页 都与kSpan建立起映射 因为后续kSpan的内存页会被切分 回收时需要重新拼成kSpan
		for (size_t i = 0; i < kSpan->_n; i++)
		{
			_idSpanMap[kSpan->_pageId + i] = kSpan;
		}
		return kSpan;
	}
	// 当前k页的span哈希桶为空 , 则去找上层不为空的span哈希桶切分span对象
	for (size_t n = k + 1; n < NPAGES; n++)
	{
		if (!_spanLists[n].Empty())
		{
			Span* nSpan = _spanLists[n].PopFront();
			Span* kSpan = new Span;


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

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

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

			// 将切分出来的nSpan页的首页和尾页建立起映射关系
			_idSpanMap[nSpan->_pageId] = nSpan;
			_idSpanMap[nSpan->_pageId + nSpan->_n - 1] = nSpan;
			// 将kSpan的每一个内存页 都与kSpan建立起映射 因为后续kSpan的内存页会被切分 回收时需要重新拼成kSpan
			for (size_t i = 0; i < kSpan->_n; i++)
			{
				_idSpanMap[kSpan->_pageId + i] = kSpan;
			}
			return kSpan;
		}
	}

	// 遍历完PageCache都没有获得空间 就需要去向堆区申请128页 
	Span* BigSpan = new Span();
	void* ptr = SystemAlloc(NPAGES - 1);
	BigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
	BigSpan->_n = NPAGES - 1;

	_spanLists[NPAGES - 1].PushFront(BigSpan);

	// 递归重新再次进行开辟空间
	return NewSpan(k);
}

且为了方便获取映射关系, 我们再设计一个接口, 只需要传入该内存对象的地址, 就可以获取到其对应的Span对象

MapObjectToSpan函数的实现

  • 通过对该地址进行页号的转换(>>PAGE_SHIFT), 就可以得到对应的页号, 然后在哈希表中进行寻找, 如果找到了对应的映射关系, 直接返回即可。
  • 因为_idSpanMap也属于一个临界资源, 所以我们获取映射关系时,最好也对其进行加锁, 以防线程安全问题的出现(我们正在读取某个映射关系时, 同时该映射关系可能真正被另一个线程进行删除)
cpp 复制代码
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())
	{
		assert(false);
		return nullptr;
	}
	return ret->second;
}

ReleaseListToSpans具体实现

  • 归还的时候, 通过内存对象的地址, 获取到对应的Span对象, 然后将该对象插入到该Span对象中, 更新Span对象当前被使用的对象个数(_use_count)
  • 当前Span对象的全部被还回来时, 即(_use_count等于0时)且将字段(_Isuse置成false), 就可以将该对象还给PageCache中去, 后续的PageCache会将该Span对象进行合并, 合并成更大块的内存
cpp 复制代码
void CentralCache::ReleaseListToSpans(void* start, size_t size)
{
	size_t index = Sizeclass::Index(size);
	_spanLists[index]._mutex.lock();
	while (start)
	{
		void* next = NextObj(start);
		Span* span = PageCache::GetInstance()->MapObjectToSpan(start);
		NextObj(start) = span->_freeList;   // 头插归还
		span->_freeList = start;
		span->_use_Count--;

		// 将span的内存空间全部还回来时,就可以尝试将span还给PageCache对象了
		if (span->_use_Count == 0)
		{
			_spanLists[index].Erase(span);
			span->_next = nullptr;
			span->_prev = nullptr;
			span->_freeList = nullptr;
			
			span->_Isuse = false;

			_spanLists[index]._mutex.unlock();

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

			_spanLists[index]._mutex.lock();
		}
		start = next;
	}

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

进一步说明:

  • 与前面获取对象类似, 我们可以再进行将对象释放回去给PageCache时先将桶锁解除掉, 然后再加上PageCache对象的大锁

PageCache回收内存
  • PageCache回收内存的机制更为简单, 就是将还回来的Span对象管理的内存进行合并, 合并成更大页的内存, 分为向其合并和向后俩种方式

往前合并

  • 查找当前页的前一页的Span对象, 是否存在映射关系, 且已经使用完毕了,则进行向前合并, 然后一直循环下去, 直到不能合并为止
cpp 复制代码
while (1)
{
    PAGE_ID prevId = span->_pageId - 1;
    auto ret = _idSpanMap.find(prevId);
    //不存在停止合并
    if (ret == _idSpanMap.end())
    {
        break;
    }
    Span* prevSpan = ret->second;
    // 该内存页正在使用着
    if (prevSpan->_Isuse)
    {
        break;
    }
    // 合并的内存页大小超过 128 合并了没法管理
    if (span->_n + prevSpan->_n > NPAGES - 1)
    {
        break;
    }
    // 开始合并
    span->_pageId = prevSpan->_pageId;
    span->_n += prevSpan->_n;

    _idSpanMap.erase(prevId);
    delete prevSpan;
}

向后合并也是类似

ReleaseSpanToPageCache实现:

  • 将Span对象合并好之后, 将旧的Span对象删除, 依旧删除其对应的映射关系
cpp 复制代码
void PageCache::ReleaseSpanToPageCache(Span* span)
{
	// 不着急将span对象归还 先判断是否能合并出更大的连续空间
	// 往前合并Span管理的内存页
	while (1)
	{
		PAGE_ID prevId = span->_pageId - 1;
		auto ret = _idSpanMap.find(prevId);
		 //不存在停止合并
		if (ret == _idSpanMap.end())
		{
			break;
		}

		Span* prevSpan = ret->second;
		// 该内存页正在使用着
		if (prevSpan->_Isuse)
		{
			break;
		}
		// 合并的内存页大小超过 128 合并了没法管理
		if (span->_n + prevSpan->_n > NPAGES - 1)
		{
			break;
		}
		// 开始合并
		span->_pageId = prevSpan->_pageId;
		span->_n += prevSpan->_n;

		_spanLists[prevSpan->_n].Erase(prevSpan);
        // 删除旧的映射关系
		_idSpanMap.erase(prevId);
		delete prevSpan;
	}

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

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

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

		// 开始合并
		span->_n += nextSpan->_n;

		_spanLists[nextSpan->_n].Erase(nextSpan);
		_idSpanMap.erase(nextId);
		
		delete nextSpan;
	}

	_spanLists[span->_n].PushFront(span);
	span->_Isuse = false;
    // 建立新的映射关系
	_idSpanMap[span->_pageId] = span;
	_idSpanMap[span->_pageId + span->_n - 1] = span;
}

内存联调测试

我们已经将内存池的接口设计好了, 接下来就可以封装成一个开辟内存的接口, 和释放内存的接口开放给用户了。

ConcurrentAlloc

cpp 复制代码
static void* ConcurrentAlloc(size_t size)
{
    // 线程第一次进行内存申请时,需要先创建一个属于自己的ThreadCache对象
    if (pTLSThreadCache == nullptr)
    {
        pTLSThreadCache = new ThreadCache();
    }
    return pTLSThreadCache->Allocate(size);
}

ConcurrentFree

cpp 复制代码
static void ConcurrentFree(void* ptr, size_t size)
{   
    assert(pTLSThreadCache);
    pTLSThreadCache->Deallocate(ptr, size);
}

测试一

  • 进行7次申请, 并打印前面的5个申请的内存, 查看地址是否连续, 而后进行7次释放, 检验运行是否正常。
cpp 复制代码
void TestConcurrentAlloc1()
{
	void* p1 = ConcurrentAlloc(7);
	void* p2 = ConcurrentAlloc(1);
	void* p3 = ConcurrentAlloc(8);
	void* p4 = ConcurrentAlloc(5);
	void* p5 = ConcurrentAlloc(3);
	void* p6 = ConcurrentAlloc(8);
	void* p7 = ConcurrentAlloc(8);


	cout << p1 << endl;
	cout << p2 << endl;
	cout << p3 << endl;
	cout << p4 << endl;
	cout << p5 << endl;

	ConcurrentFree(p1, 7);
	ConcurrentFree(p2, 1);
	ConcurrentFree(p3, 8);
	ConcurrentFree(p4, 5);
	ConcurrentFree(p5, 3);
	ConcurrentFree(p6, 8);
	ConcurrentFree(p7, 8);
}

推算一下运行结果:

  • 申请p1内存时, ThreadCache中对应的哈希桶初始时并没有内存块, 所以需要到CentralCache中去获取一批对象, 根据MaxSize和慢启动增长机制, 得到申请对象个数为1, 而后就去CentralCache中获取对象, 但是中心缓存这时候也还没有Span对象, 所以CentralCache也得先向PageCache进行申请内存, 而此时PageCache中也并还没有内存块, 所以需要调用系统调用申请128KB的内存, 然后递归调用, 将该内存块切成1页和127页的, 然后将1页内存的Span对象返回给CentralCache, 进行内存对象切分, 而后再返回一个内存对象给ThreadCache
  • 第二次申请也是类似, 不过这次ThreadCacheCentralCache中获取内存就能获得到了, 因为CentralCache中心缓存当中具有存货了, 而此时的MaxSize已经增长到了2, 所以ThreadCache会从 CentralCache中获取俩个内存对象, 第三次, 第四次也是类似
  • 释放内存时, 申请了7个对象时, 此时MaxSize已经增长到了5, 而且ThreadCache中还有3个内存对象仍未分配, 所以ThreadCache线程缓存释放了俩个对象之后, 会还5个对象给 中心缓存, 而后释放到最后一个内存时, 刚好又攒够了5个, 此时线程缓存又会将内存释放给 CentralCache, 而后该Span对象中的内存对象都已经全部还回来了, 此时 CentralCache对象就会将该Span对象还给 PageCache, 然后PageCache会将该页对象和 127页对象进行合并,然后又会合并成128 页的内存对象

代码运行结果:

通过打印的提示代码, 我们发现程序运行结果时没有问题的。


解决大块内存的申请和释放问题

我们之前对于ThreadCache对象申请超过256KB的内存对象的处理都是直接报错的, 而我们现在就是对其进行处理, 首先是超过256KB大小的内存对象都以页的为对齐大小。

cpp 复制代码
static inline size_t RoundUp(size_t size)
{
    if (size <= 128)
    {
        return _RoundUp(size, 8);
    }
    else if (size <= 1024)
    {
        return _RoundUp(size, 16);

    }
    else if (size <= 8 * 1024)
    {
        return _RoundUp(size, 128);

    }
    else if (size <= 64 * 1024)
    {
        return _RoundUp(size, 1024);

    }
    else if (size <= 256 * 1024)
    {
        return _RoundUp(size, 8 * 1024);
    }
    else
    {
        return _RoundUp(size, 1 << PAGE_SHIFT);
    }

    return -1;
}

实现Allocate函数时, 凡是超过256KB的内存对象都直接去PageCache中进行申请, 修整后的ConcurrentAlloc函数

cpp 复制代码
static void* ConcurrentAlloc(size_t size)
{
	//  [32 * 8k 128 * 8k] 直接去找PageCache 要
	//  > 128 * 8k 直接找堆申请
	if (size > MAX_BYTES)
	{
		size_t AlignSize = Sizeclass::RoundUp(size);
		PAGE_ID kpage = AlignSize >> PAGE_SHIFT;

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

		void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
		return ptr;
	}
	else
	{
		if (pTLSThreadCache == nullptr)
		{
			static ObjectPool<ThreadCache> _tcPool;
			pTLSThreadCache = new ThreadCache();
		}
		return pTLSThreadCache->Allocate(size);
	}
}

包括内存释放的过程也是需要进行调整, 超过256KB的内存对象也是交给PageCache对象进行处理

cpp 复制代码
static void ConcurrentFree(void* ptr, size_t size)
{
	Span* span = PageCache::GetInstance()->MapObjectToSpan(ptr);

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

PageCache中的内存获取和内存释放也需要做出改变, 当申请的内存对象小于128页就像平常处理即可, 超过128页内存大小, 也是直接找系统进行申请和释放。

申请

cpp 复制代码
if (k > NPAGES  - 1)
{
    void* ptr = SystemAlloc(k);
    Span* span = new Span;
    span->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
    span->_n = k;

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

释放

cpp 复制代码
// 超过128page 的内存直接使用systemfree进行释放
if (span->_n > NPAGES - 1)
{
    void* ptr = (void *)(span->_pageId << PAGE_SHIFT);
    SystemFree(ptr);
    delete span;
    
    return;
}

优化释放内存时需要传输释放的内存大小

我们发现free接口是不需要对释放的内存对象大小进行传输的, 只需要传输所需要释放的内存的地址即可,而我们的内存池也是可以实现的, 即在Span对象中多添加一个字段_size(当前内存块的大小), 在获取Span对象时, 将该字段进行填充, 而后在释放对应的内存对象时, 只需通过地址拿到与其相关联的Span对象即可, 然后获取该成员变量的值就好

GetOneSpan修改

cpp 复制代码
PageCache::GetInstance()->_PageMtx.lock();
Span* NewSpan = PageCache::GetInstance()->NewSpan( Sizeclass::NumMovePage(size) );
NewSpan->_Isuse = true;
NewSpan->_ObjSize = size;
PageCache::GetInstance()->_PageMtx.unlock();

ConcurrentFree修改

cpp 复制代码
static void ConcurrentFree(void* ptr)
{
	Span* span = PageCache::GetInstance()->MapObjectToSpan(ptr);
	size_t size = span->_ObjSize;

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

从而就实现了, 释放内存时不需要进行传递释放内存对象的大小


使用定长内存池优化摆脱 new和 delete

我们当前的代码中仍有使用了malloc的地方, 例如调用newdelete的地方, 而new 和 delete 实际上是封装了malloc 和 free俩个成员函数, 所以我们并没有真正意义上脱离了 mallocfree, 所以我们就可以使用我们的定长内存池去替换掉newdelete.

代码中使用到newdelete的地方分别是

  • 线程第一次获取ThreadCache的对象时
  • PageCache对象进行内存进行申请和释放时创建Span对象以及销毁Span对象。

**修改一:**在定义每个线程的ThreadCache对象时,将代码修改成下面这样

cpp 复制代码
//pTLSThreadCache = new ThreadCache();
static ObjectPool<ThreadCache> _tcPool;
pTLSThreadCache = _tcPool.New();

修改二:将所有定义Span对象和释放Span对象的语句都改成如下形式

cpp 复制代码
// Span* BigSpan = new Span();
Span* BigSpan = _spanPool.New();
// delete span
_spanPool.Delete(span);

测试效率

  • 分别在多轮次的多次的内存申请和释放下进行对比malloc 和 我们当前的内存池进行对比

测试代码:

cpp 复制代码
// ntimes 一轮申请和释放内存的次数
// rounds 轮次
// nworks 工作的线程数
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.load());

	printf("%u个线程并发执行%u轮次,每轮次free %u次: 花费:%u ms\n",
		nworks, rounds, ntimes, free_costtime.load());

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


// 单轮次申请释放次数 线程数 轮次
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.load());

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

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

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

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

	return 0;
}

说明:

  • malloc_costtime , free_costtime 是临界资源, 多线程访问的资源,所以为了线程安全, 我们将这俩个变量都定义成原子的。
  • 代码中通过lambda表达式定义线程处理函数

申请随机内存大小的对象:

  • debug模式下运行结果:
  • release模式下的运行结果:

申请统一的内存对象时的效率:

  • debug模式下运行结果:
  • release模式下运行结果:

对比结果我们发现, 我们自己写的内存池在对于单个对象的分配和释放效率仍就不如我们的malloc


性能分析

我们继续拿上面的代码进行性能分析, 只需要使用我们写的内存池代码进行分析即可

测试代码:

cpp 复制代码
int main()
{
	size_t n = 10000;
	cout << "==========================================================" << endl;
	BenchmarkConcurrentMalloc(n, 4, 10);
	cout << endl << endl;

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

	return 0;
}

VS2022进行性能分析的方式:

先点击调试模式

进入到该页面, 直接点击开始即可

然后等待结果就好,时间可能会比较漫长

  • 通过分析报告我们发现占用时间比例最多的正是ConcurrentFree时的获取映射关系(MapObjectToSpan)的加锁问题导致的。
  • 即加锁大大降低了多线程进行内存释放时, 获取内存对象与Span对象的映射关系时的速度,但是不进行加锁又不能保证线程安全问题, 而为了获取映射关系又将整个PageCache类独占, 使得效率大打折扣, 所以我们可以改用另外一种结构进行存储映射关系

基数树优化

在这里就不阐述基数树的原理, 直接展示代码, 感兴趣的可以到网上查询相关资料

cpp 复制代码
// Three-level radix tree
template <int BITS>
class TCMalloc_PageMap3 {
private:
	// How many bits should we consume at each interior level
	static const int INTERIOR_BITS = (BITS + 2) / 3; // Round-up
	static const int INTERIOR_LENGTH = 1 << INTERIOR_BITS;

	// How many bits should we consume at leaf level
	static const int LEAF_BITS = BITS - 2 * INTERIOR_BITS;
	static const int LEAF_LENGTH = 1 << LEAF_BITS;

	// Interior node
	struct Node {
		Node* ptrs[INTERIOR_LENGTH];
	};

	// Leaf node
	struct Leaf {
		void* values[LEAF_LENGTH];
	};

	Node* root_;                          // Root of radix tree
	void* (*allocator_)(size_t);          // Memory allocator

	Node* NewNode() {
		Node* result = reinterpret_cast<Node*>((*allocator_)(sizeof(Node)));
		if (result != NULL) {
			memset(result, 0, sizeof(*result));
		}
		return result;
	}

public:
	typedef uintptr_t Number;

	explicit TCMalloc_PageMap3(void* (*allocator)(size_t)) {
		allocator_ = allocator;
		root_ = NewNode();
	}

	void* get(Number k) const {
		const Number i1 = k >> (LEAF_BITS + INTERIOR_BITS);
		const Number i2 = (k >> LEAF_BITS) & (INTERIOR_LENGTH - 1);
		const Number i3 = k & (LEAF_LENGTH - 1);
		if ((k >> BITS) > 0 ||
			root_->ptrs[i1] == NULL || root_->ptrs[i1]->ptrs[i2] == NULL) {
			return NULL;
		}
		return reinterpret_cast<Leaf*>(root_->ptrs[i1]->ptrs[i2])->values[i3];
	}

	void set(Number k, void* v) {
		ASSERT(k >> BITS == 0);
		const Number i1 = k >> (LEAF_BITS + INTERIOR_BITS);
		const Number i2 = (k >> LEAF_BITS) & (INTERIOR_LENGTH - 1);
		const Number i3 = k & (LEAF_LENGTH - 1);
		reinterpret_cast<Leaf*>(root_->ptrs[i1]->ptrs[i2])->values[i3] = v;
	}

	bool Ensure(Number start, size_t n) {
		for (Number key = start; key <= start + n - 1;) {
			const Number i1 = key >> (LEAF_BITS + INTERIOR_BITS);
			const Number i2 = (key >> LEAF_BITS) & (INTERIOR_LENGTH - 1);

			// Check for overflow
			if (i1 >= INTERIOR_LENGTH || i2 >= INTERIOR_LENGTH)
				return false;

			// Make 2nd level node if necessary
			if (root_->ptrs[i1] == NULL) {
				Node* n = NewNode();
				if (n == NULL) return false;
				root_->ptrs[i1] = n;
			}

			// Make leaf node if necessary
			if (root_->ptrs[i1]->ptrs[i2] == NULL) {
				Leaf* leaf = reinterpret_cast<Leaf*>((*allocator_)(sizeof(Leaf)));
				if (leaf == NULL) return false;
				memset(leaf, 0, sizeof(*leaf));
				root_->ptrs[i1]->ptrs[i2] = reinterpret_cast<Node*>(leaf);
			}

			// Advance key past whatever is covered by this leaf node
			key = ((key >> LEAF_BITS) + 1) << LEAF_BITS;
		}
		return true;
	}

	void PreallocateMoreMemory() {
	}
};

说明:

  • 使用get(Number k)进行获取内存块和Span对象的映射关系,k表示内存页号
  • 使用 set(Number k, void* v)进行映射关系的建立, k表示页号, v表示Span对象

基数树在访问和插入数据时不需要进行加锁的原因

  • 基数树是构建了之后, 底层就不会有太大改变了, 而哈希表和红黑树底层每次进行插入删除操作时, 会导致底层的数据结构发生改变, 而基数树是一种静态的数据结构, 所以插入操作并不会改变底层的数据结构
  • 在本项目中, 对基数树的插入操作和查询操作都不是同时发生的, 建立映射关系大多数在PageCache中进行获取和归还Span对象时, 而查询操作一个是在调用删除函数时, 通过映射关系获取到内存对象的大小, 然后对该对象进行释放,另一个是在中心缓存CentralCache进行内存释放操作时, 通过映射关系获取Span对象, 将内存对象插入到该Span对象当中去, 以上操作都不是同时对同一个Span对象发生的, 所以也就不会存在线程安全问题了。
相关推荐
湖南罗泽南17 分钟前
Windows C++ TCP/IP 两台电脑上互相传输字符串数据
c++·windows·tcp/ip
只是有点小怂24 分钟前
受害者缓存(Victim Cache)
缓存
可均可可1 小时前
C++之OpenCV入门到提高005:005 图像操作
c++·图像处理·opencv·图像操作
zyx没烦恼1 小时前
【STL】set,multiset,map,multimap的介绍以及使用
开发语言·c++
机器视觉知识推荐、就业指导1 小时前
基于Qt/C++与OpenCV库 实现基于海康相机的图像采集和显示系统(工程源码可联系博主索要)
c++·qt·opencv
myloveasuka2 小时前
类与对象(1)
开发语言·c++
simpleGq2 小时前
Redis知识点整理 - 脑图
数据库·redis·缓存
ROC_bird..3 小时前
STL - vector的使用和模拟实现
开发语言·c++
机器视觉知识推荐、就业指导3 小时前
C++中的栈(Stack)和堆(Heap)
c++
运维小文3 小时前
服务器硬件介绍
运维·服务器·计算机网络·缓存·硬件架构