C++项目:高并发内存池

文章目录

  • 项目介绍
  • 什么是内存池
    • 池化技术
    • 内存池
    • malloc
  • 定长的内存池
    • 对比测试
  • 高并发内存池整体框架设计
  • thread cache
    • 整体设计
    • 哈希桶映射对齐规则
    • TLS无锁访问
  • Central Cache
    • Span、SpanList
    • CentralCache代码框架
    • thread cache代码补充
    • CenterCache代码实现
  • PageCaChe
    • CenterCache代码补充
    • Page Cache代码实现
  • 申请内存联调
    • 逻辑框架
  • Thread Cache回收内存
  • Central Cache回收内存
  • Page Cache回收内存
  • 释放内存联调
  • 完善改进
    • 大于256KB的大块内存申请释放
    • 使用定长内存池配合脱离使用new
    • 释放对象时优化为不传对象大小
  • 性能测试
    • 基数树优化
    • 扩展学习及项目实现的不足
  • 参考

全文约 36181 字,预计阅读时长: 103分钟


项目介绍

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

这个项目是把tcmalloc最核心的框架简化后拿出来,模拟实现出一个自己的高并发内存池,目的就是学习其精华

C++项目:高并发内存池_第1张图片

  这个项目会用到C/C++、数据结构(链表、哈希桶)、操作系统内存管理、单例模式、多线程、互斥锁等等方面的知识。


什么是内存池

池化技术

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

  • 在计算机中,有很多使用“池”这种技术的地方,除了内存池,还有连接池、线程池、对象池等。
  • 以服务器上的线程池为例,它的主要思想是:先启动若干数量的线程,让它们处于睡眠状态,当接收到客户端的请求时,唤醒池中某个睡眠的线程,让它来处理客户端的请求,当处理完这个请求,线程又进入睡眠状态。
  • 生活中的例子:上学时期的生活费,通常是向家长准备一周的或一个月的,而不是每顿饭吃完了每次都向家长要。

内存池

  内存池是指程序预先从操作系统申请一块足够大内存,此后,当程序中需要申请内存的时候,不是直接向操作系统申请,而是直接从内存池中获取。原因一每次都会有从用户态到内核态的切换,要做大量的工作。

  同理,当程序释放内存的时候,并不真正将内存返回给操作系统,而是返回内存池。当程序退出(或者特定时间)时,内存池才将之前申请的内存真正释放。

内存池主要解决的问题

内存池主要解决的当然是效率的问题,其次如果作为系统的内存分配器的角度,还需要解决一下内存碎片的问题

  再需要补充说明的是内存碎片分为外碎片和内碎片。

  • 外部碎片是一些空闲的连续内存区域太小,这些内存空间不连续,以至于合计的内存足够,但是不能满足一些的内存分配申请需求。
  • 内部碎片是由于一些对齐的需求,导致分配出去的空间中一些内存无法被利用。内碎片问题,我们后面项目就会看到,那会再进行更准确的理解。

malloc

  C/C++中我们要动态申请内存都是通过malloc去申请内存,但是我们要知道,实际我们不是直接去堆获取内存的;而是通过库函数,库函数在通过OS提供的接口去申请。由于库函数会考虑很多情况,所以效率比较慢。

  注意,此时操作系统根本就没有真正的分配物理内存。那么什么时候操作系统才会真正的分配物理内存呢?

  答案是当我们真正使用这段内存时,当我们真正使用这段内存时,这时会产生一个缺页错误,操作系统捕捉到该错误后开始真正的分配物理内存,操作系统处理完该错误后我们的程序才能真正的读写这块内存。

库函数的malloc就是一个内存池。

  • malloc() 相当于向操作系统“批发”了一块较大的内存空间,然后“零售”给程序用。当全部“售完”或程序有大量的内存需求时,再根据实际需求向操作系统“进货”。

  malloc调用brk后开始转入内核态,此时操作系统中的虚拟内存系统开始工作,扩大进程的堆区,注意额外扩大的这一部分内存仅仅是虚拟内存。

  malloc的实现方式有很多种,一般不同编译器平台用的都是不同的。比如windows的vs系列用的微软自己写的一套,linux gcc用的glibc中的ptmalloc。

malloc的此层实现原理

  • 一文了解,Linux内存管理,malloc、free 实现原理
  • malloc()背后的实现原理——内存池
  • malloc的底层实现(ptmalloc)

C++项目:高并发内存池_第2张图片


  • 向系统申请内存以页为单位:你想申请多少个页的内存?实际系统提供给的接口VirtualAlloc还是以字节为单位。
  • 根据不同的平台调用不同的接口。
  • 8KB = 8 * 1024 Byte
//根据平台使用OS提供的接口直接去堆上(物理内存)申请内存
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32  //win32 OS提供的接口
	void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else
	//linux下brk mmap等
#endif // _WIN32
	if (ptr == nullptr)
	{
		throw std::bad_alloc();
	}
	return ptr;
}

C++项目:高并发内存池_第3张图片

  进程的虚拟内存被分为若干个“段”;每个段其实还被分成了若干个“块”,我们将这个“块”称为“页”。注:绝大多数处理器上的内存页的默认大小都是 4KB,虽然部分处理器会使用 8KB、16KB 或者 64KB 作为默认的页面大小,但是 4KB 的页面仍然是操作系统默认内存页配置的主流。本文按8KB。

  内存的映射(虚拟地址和物理地址之间的转换)也是以“页”为单位的;一般来说一页的大小4K。注意:虚拟地址一般由段号、页号、页中偏移量构成,从而最终计算出你的物理地址;缺页:消除了进程全部载入内存中,按需调页(也就是换页)。以上就是Linux的段页式内存管理。Linux ——进程的虚拟地址空间,逻辑地址和物理地址,进程管理命令

  • 知道页号可以得出申请到的内存地址,根据地址可以得出页号

定长的内存池

C++项目:高并发内存池_第4张图片

malloc其实就是一个通用的大众货,什么场景下都可以用,但是什么场景下都可以用就意味着什么场景下都不会有很高的性能

  • 我们先来设计一个定长内存池当然这个定长内存池:解决固定大小的内存申请释放需求
  • 学习他目的有两层:
    • 先熟悉一下简单内存池是如何控制的,他会作为我们后面内存池的一个基础组件。
    • 性能达到极致同时不用考虑内存碎片的问题。

C++项目:高并发内存池_第5张图片

  • char* _memory:指向大块内存的指针
  • void* _freelist:对进程不用的还回来的内存进行管理。
  • Windows 32位平台下viod* 是 4 Byte; 64位下是8 Byte。
#include
#include
#include
using std::cout;
using std::endl;

#ifdef _WIN32
	#include
#else
	//
#endif 

//template
//class ObjectPool
//{
//};

//根据平台使用OS提供的接口直接去堆上(物理内存)申请内存
inline static void* SystemAlloc(size_t kpage)  //需要多少的页 8*1024 Byte
{
#ifdef _WIN32  //win32 OS提供的接口
	void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else
	//linux下brk mmap等
#endif // _WIN32
	if (ptr == nullptr)
	{
		throw std::bad_alloc();
	}
	return ptr;
}

template<class T>
class ObjectPool
{
private:
	char* _memory = nullptr;  // 指向大块内存的指针
	size_t _remainBytes = 0;	// 大块内存在切分过程中剩余字节数
	void* _freelist = nullptr;	// 还回来过程中链接的自由链表的头指针
public:
	T* New()
	{
		T* obj = nullptr;

		//头上四个字节处存着 下一个内存块儿的地址
		//32位平台下viod* 是 4; 64位下是8 个字节大小
		if (_freelist)
		{
			void* next = *((void**)_freelist);
			obj = (T*)_freelist;
			_freelist = next;
		}
		else
		{
			if (_remainBytes < sizeof(T))
			{
				//一次要大一点的 128 KB
				_remainBytes = 128 <<10;
				
				//_memory = (char*)malloc(_remainBytes);
				//直接忽略平台的差异
				_memory = (char*)SystemAlloc(_remainBytes >> 13); //要几个kpage
				if (_memory == nullptr)
				{
					throw std::bad_alloc();
				}
			}
			
			obj = (T*)_memory;
			
			//确定不同平台下T类型的大小;起码要留出一个存放下一个内存块儿地址的空间大小。
			size_t objsize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
			
			_memory += objsize;
			_remainBytes -= objsize;
		}

		new(obj)T;	//定位new,显式调用T的构造函数初始化 因为原版库函数new就会调用T类型的构造函数

		return obj;
	}

	void Delete(T* obj)
	{
		obj->~T();	//显式调用析构函数清理对象  显式调用定位new 就会显式调用析构。

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

C++项目:高并发内存池_第6张图片


对比测试

库函数 malloc 与 定长内存池的对比测试

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;
}

C++项目:高并发内存池_第7张图片


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

  现代很多的开发环境都是多核多线程,在申请内存的场景下,必然存在激烈的锁竞争问题。malloc本身其实已经很优秀,那么我们项目的原型tcmalloc就是在多线程高并发的场景下更胜一筹。

  所以实现的内存池需要考虑以下几方面的问题:1. 性能问题。2. 内存碎片问题。3. 多线程环境下,锁竞争问题。

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

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

首先把申请流程走通,其次走释放;接着测试;随后一些优化;最后项目总结。

  整个进程会有多个线程内存池Thread Cache,但只有一个Central Cache 和一个Page Cache,所以后两者都要设计成单例模式。


thread cache

  • thread cache是哈希桶结构。
    • 每个桶是由一个或多个定长内存块为对象映射的自由链表,不同的桶(内存块起始指针)映射至不同的下标,相同大的内存块儿放入到同一个桶下
    • 每个线程都会有一个thread cache对象,这样每个线程在这里获取对象和释放对象时是无锁的。
      C++项目:高并发内存池_第9张图片

整体设计

  • FreeList :管理切分好的小对象(内存块)的自由链表。起初链表下一个内存块都没有的,是下一层中心池将一个或多个页分成一个个对应的对齐内存块分配给线程内存池的。
  • 为了记录下一个内存块的地址,依然采取将内存块头4/8个字节存储。强转成二级指针,32位下是4,解引用就等于取到了头4个字节,就可以对其赋值。
  • thread cache是一个FreeList的自由链表数组。
//强转存储
static void*& NextObj(void* obj)
{
	return *(void**)obj;
}

class FreeList
{
private:
	void* _freeList; //普普通通的空类型指针
public:
	void Push(void* obj) //不用的内存块插入到链表中,头插
	{
		assert(obj);
		NextObj(obj) = _freeList;
		_freeList = obj;
	}
	void* Pop()//线程要用内存块,有就头删一个。
	{
		assert(_freeList);
		void* obj = _freeList;
		_freeList = NextObj(_freeList);
		return obj;
	}
	bool Empty()
	{
		return _freeList == nullptr;
	}
};

申请 与 释放内存

  申请内存

  1. 当内存申请size<=256KB先获取到线程本地存储的thread cache对象,计算size映射的哈希桶自由链表下标i。
  2. 如果自由链表_freeLists[i]有对象,则直接Pop一个内存对象返回
  3. 如果_freeLists[i]没有对象时,则批量从central cache中获取一定数量的对象,插入到自由链表并返回一个对象

  释放内存

  1. 当释放内存小于256 KB 时将内存释放回thread cache,计算size映射自由链表桶位置i,将对象Push到_freeLists[i]。
  2. 当链表的长度过长,则回收一部分内存对象到central cache

thread cache代码框架:

  项目开发中,一些大家常用的库会放在一个公共的头文件中,一些常用自定义的结构也会放在公共的头文件中。

#include"Common.h"
//实现线程直接申请释放的线程内存池
class ThreadCache
{
public:
	//线程申请内存
	void* Allocate(size_t size); 
	
	//释放
	void Deallocate(void* ptr, size_t size);
private:
	FreeList _freeList[]; 
};

哈希桶映射对齐规则

FreeList 每个桶下面挂多大的内存块以及用多少个桶呢?

  每个字节大小都映射一个位置?不太科学。每个桶下起码得有8个字节(64为平台下)存储下一个内存块儿的地址,那么是否就按8个字节为单位的等差数列映射呢?_freeList[0] = 8byte,_freeList[1] = 16byte;...._freeList[32768] = 256* 1024byte,也还是不太行。

  因此有前辈高手相出了下面的解决方案:不同的区域,采用不同大小内存块(对齐数),作等差数列。
C++项目:高并发内存池_第10张图片
  此刻还可以计算空间浪费率,虽然对齐产生的内碎片会引起一定程度的空间浪费,但按照上面的对齐规则,我们可以将浪费率控制到百分之十左右。1–128这个区间不做讨论,比如129~1024这个区间,该区域的对齐数是16,那么最大浪费的字节数就是15,这15个字节就是内碎片;假如申请130个字节,则分配144个字节的内存块, 15 / 144 ≈ 10.42 15/144\approx10.42 15/14410.42%。

C++项目:高并发内存池_第11张图片

  • 上面还可以得出,用208个桶。
  • 每个桶的内存块大小定好了,那么如何通过线程要申请的内存块找到数组的下标呢?
  • 这时就可以把获取对齐内存块和获取下标封装成一个类:
    • 计算对齐内存块及其下标。
    • 内存块要看在哪个区间,且向上对齐;如要9个byte,也要给16个byte。下列注释的子函数为普通写法,用的则是高手前辈的位运算写法,效率更快。

class AlignIndex
{
public:
	// 10  /  8    16    
	/*size_t _AlignUp(size_t sz,size_t alignSz)
	{
		size_t retSize;
		if (sz % alignSz != 0)
		{
			retSize = (sz / alignSz + 1) * alignSz;
		}
		else
		{
			retSize = sz;
		}
		return retSize;
	}*/
	

	static inline size_t _AlignUp(size_t bytes, size_t alignSz)
	{
		return ((bytes + alignSz - 1) & ~(alignSz - 1));//位运算效率更高
	}

	static inline size_t AlignUp(size_t size)
	{
		if (size <= 128)
		{
			return _AlignUp(size, 8); //每个区间采取的对齐数
		}
		else if (size<=1024)
		{
			return _AlignUp(size, 16);
		}
		else if (size <= 8 * 1024)
		{
			return  _AlignUp(size, 128);
		}
		else if (size <= 64 * 1024)
		{
			return _AlignUp(size, 1024);
		}
		else if (size <= 256 * 1024)
		{
			return _AlignUp(size, 8*1024);
		}
		else
		{
			assert(false);
			return -1;
		}
	}

	/*static inline size_t _Index(size_t bytes, size_t align_shift)//8,16,128,1024...
	{
		if (bytes % align_shift == 0)
		{
			return bytes / align_shift - 1;
		}
		else
		{
			return bytes / align_shift;
		}
	}*/
	static inline size_t _Index(size_t bytes, size_t align_shift)
	{
		return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1;
	}

	//获取内存块所处的下标
	static inline size_t Index(size_t bytes)
	{
		assert(bytes <= MAX_BYTES);
		static int rangei[4] = { 16,56, 56, 56 };
		if (bytes<=128)
		{
			return _Index(bytes, 3);
		}
		else if (bytes <= 1024)
		{
			return _Index(bytes, 4) + rangei[0];
		}
		else if (bytes <= 8 * 1024)
		{
			return _Index(bytes - 1024, 7) + rangei[1] + rangei[0];
		}
		else if (bytes <= 64 * 1024) 
		{
			return _Index(bytes - 8 * 1024, 10) + rangei[2] + rangei[1] + rangei[0];
		}
		else if (bytes <= 256 * 1024)
		{
			return _Index(bytes - 64 * 1024, 13) + rangei[3] + rangei[2] + rangei[1] + rangei[0];
		}
		else 
		{
			assert(false);
		}

		return -1;
	}
};

TLS无锁访问

  现在线程内存池有了,该如何让线程使用Threadcache呢?什么时候创建这个内存池呢?如何确定每个线程用的是自己的线程池呢?

Thread Local Storage(线程局部存储)TLS

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

  • thread_cache头文件中使用TLS技术,采用静态链接。有windows/和Linux下的使用方法。Windows下:
//TLS 线程局部存储
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;

-让线程调用thread_cache线程池的头文件:ConcurrentAlloc.h,让每个线程获取自己独立的thread cache

#include"Common.h"
#include"ThreadCache.h"

//线程获取thread_cache
static void* ConcurrentAlloc(size_t size)
{
	if (pTLSThreadCache == nullptr)
	{
		pTLSThreadCache = new ThreadCache;
	}

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

	return pTLSThreadCache->Allocate(size);
}

//线程释放内存
static void ConcurrentFree(void* ptr, size_t sz)
{
	assert(pTLSThreadCache);

	pTLSThreadCache->Deallocate(ptr, sz);
}

初步的单元测试

  • Unittest.cpp
#include"ConcurrentAlloc.h"

void Alloc1()
{
	for (size_t i = 0; i < 3; i++)
	{
		void* ptr = ConcurrentAlloc(6);
	}
}

void Alloc2()
{
	for (size_t i = 0; i < 3; i++)
	{
		void* ptr = ConcurrentAlloc(9);
	}
}

void TLStest()
{
	std::thread t1(Alloc1);
	t1.join();

	std::thread t2(Alloc2);
	t2.join();
}

int main()
{
	TLStest();
	return 0;
}

  不考虑定长内存池,截至目前的文件和代码:ThreadCache.hThreadCache.cppcommon.hConcurrentAlloc.hUnittest.cpp
common.h

#include 
#include 
#include 
#include 
using std::cout;
using std::endl;

static const size_t MAX_BYTES = 256 * 1024; //线程单次申请的最大内存块
static const size_t NFREELIST = 204; //桶的个数

static void*& NextObj(void* obj);...
class FreeList 
{	//...	};

class AlignIndex //获取对齐内存块和下标
{	//...	};

ThreadCache.h

#include"Common.h"

//实现线程直接申请释放的线程内存池
class ThreadCache
{
public:
	void* Allocate(size_t size);
	void Deallocate(void* ptr, size_t size);
private:
	FreeList _freeList[NFREELIST];
};

//TLS 线程局部存储
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;
  • ThreadCache.cpp
#include"ThreadCache.h"
void* FetchFromCentralCache(size_t index, size_t alignSize)
{
	cout << "hold on.." << endl;	//..待补充
	return nullptr;
}

//线程向线程内存池申请多大的内存块
void* ThreadCache::Allocate(size_t size)
{
	assert(size <= MAX_BYTES);
	size_t alignbytes = AlignIndex::AlignUp(size);	//获取对齐内存块
	size_t indexi = AlignIndex::Index(size);
	if(!_freeList[indexi].Empty())
	{
		return _freeList[indexi].Pop();
	}
	else
	{
		return FetchFromCentralCache(indexi, alignbytes);
	}
}

//释放内存 待定
void ThreadCache::Deallocate(void* ptr, size_t size)
{
	assert(ptr);
	assert(size <= MAX_BYTES);

	size_t index = AlignIndex::Index(size);
	_freeList[index].Push(ptr); //释放头插
}
  • ConcurrentAlloc.h(线程TLS无锁获取thread cache)、Unittest.cpp。

Central Cache

  • Central Cache 也是一个哈希桶结构,他的哈希桶的映射关系跟Thread Cache是一样的,通过线程内存池要的内存块快速定位到去哪个桶下面取。
    • 不同的是他的每个哈希桶位置挂是SpanList链表结构。
    • 每个映射桶下面的span中的大内存块被按映射关系切成了一个个小内存块对象,挂在span的自由链表中。头4/8个字节放下一个小的内存块的地址。
  • 线程内存池Thread Cache要内存的时候便从Central Cache 对应桶下的span的自由链表中取。
  • 一个span可以管理一个或多个页,每个页都有页号,知道页号就知道地址。(相对虚拟内存0x000000开始的)

C++项目:高并发内存池_第12张图片

申请 与 释放内存

  申请内存:

  1. thread cache中没有内存时,就会批量向central cache申请一些内存对象。这里的批量获取对象的数量使用了类似网络tcp协议拥塞控制的慢开始算法(线程池向中心池要多少内存块合适);central cache也有一个哈希映射的spanlistspanlist中挂着spanspan中取出对象给thread cache,这个过程是需要加锁的不过这里使用的是一个桶锁,尽可能提高效率。
  2. central cache映射的spanlist中所有span的都没有内存以后,则需要向page cache申请一个新的span对象,拿到span以后将span管理的内存按大小切好作为自由链表链接到一起,Thread Cache有需要就从Central Cachespan中取。
  3. central cache的中挂的spanuse_count记录分配了多少个对象出去,分配一个对象给threadcache,就++use_count

  释放内存:

  1. thread_cache过长或者线程销毁,则会将内存释放回central cache中的,释放回来时--use_count。当use_count减到0时则表示所有对象都回到了span,则将span释放回page cachePage Cache中会对前后相邻的空闲页进行合并。

Span、SpanList

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;  // 切好的小块内存的自由链表
};
  • 需要对页号的类型进行处理:64位下,8KB一页分会超出整形的最大值。_Win32没有定义_Win64
#ifdef _WIN64
	typedef unsigned long long PAGE_ID;
#elif _WIN32
	typedef size_t PAGE_ID;
#else
	//linux
#endif

SpanList

  • 双向带头循环链表:当我们需要将某个span归还给page cache时,就可以很方便的将该span从双链表结构中移出。如果用单链表结构的话就比较麻烦了,因为单链表在删除时,需要知道当前结点的前一个结点。
  • 桶锁,多个线程找到中心池对应桶时,第一个先到的就该上锁。
class SpanList 
{
private:
	Span* _head;
public:
	mutex _mtx; //桶锁
public:
	void Insert(Span* pos, Span* newspan) //给定位置的头插
	{
		assert(pos);
		assert(newspan);

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

		newspan->_next = pos;
		pos->_prev = newspan;
	}

	void Erase(Span* pos)
	{
		assert(pos);

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

		prev->_next = next;
		next->_prev = prev;
	}
	SpanList()
	{
		_head = new Span;
		_head->_next = _head;
		_head->_prev = _head;
	}
};

CentralCache代码框架

  • Central Cache至少提供一个提供给线程内存池Thread Cache分配内存的接口。接口的设计:
    • 参数:你要多少个多大的内存块,我可以通过对齐后的内存块算出下标(有相同的映射规则)。
    • span下是切分好的一个内存块链表,因此我可以返回给你一段地址区间,需要头、尾两个输出型参数。
    • 返回值:你想要那么多个,但对应的SpanList下的某一个Span里面的_freelist中实际只有这么点儿。
  • Central Cache 的第二个接口:查看该链表下哪个span可以用,返回这个span;如果没有则向Page Cache申请。
class CenterCache 
{
private:
	SpanList _spanLists[NFREELIST];
	CenterCache() {}
	CenterCache(const CenterCache&) = delete;
	
	static CenterCache _sInst;  //单例饿汉 声明一个私有的静态类型对象
public:
	static CenterCache& GetInstance()
	{
		return _sInst;
	}
	//从中心池分配内存块儿给线程池   
	size_t FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size);
	
	//桶下获取可用的span
	Span* GetOneSpan(SpanList& list, size_t byte_size);

};

---//.cpp
#include"CenterCache.h"
CenterCache CenterCache::_sInst;

thread cache代码补充

  线程内存池Thread CacheCentral Cache 索要申请,但批量要多少合适呢?如果central cache给的太少,那么thread cache在短时间内用完了又会来申请;给多了闲置浪费。

C++项目:高并发内存池_第13张图片

慢开始反馈调节算法

  当thread cache向central cache申请内存时,如果申请的是较小的对象,那么可以多给一点,但如果申请的是较大的对象,就可以少给点。

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

class AlignIndex
{
public:
	//一次性从`Central Cache` 去多少个内存块。
	static size_t NumMoveSize(size_t bytesize)
	{
		assert(bytesize > 0);

		int num = MAX_BYTES / bytesize;
		if (num < 2)
		{
			num = 2;
		}
		if (num > 512)//大佬们各种实验测试过的可能
		{
			num = 512;
		}
		return num;
	}
};

  根据僈开始算法,就算申请的是小对象,一次性给出512个也是比较多的,基于这个原因,向Thread Cacheclass FreeList中添加一个成员变量,记录批量向Thread Cache每次申请的内存块个数size_t _amountCenterSz

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

class FreeList
{
private:
	void* _freeList  = nullptr;
	size_t _amountCenterSz = 1;
public:
	size_t& AmountSz()
	{
		return _amountCenterSz;
	}
	void PushRange(void* start,void* end)
	{
		NextObj(end) = _freeList;
		_freeList = start;
	}
	//...
}

向`Central Cache 索要内存块

---//ThreadCache.h
void* FetchFromCentralCache(size_t index, size_t alignSize);
---//ThreadCache.cpp
void* ThreadCache::FetchFromCentralCache(size_t index, size_t alignSize)
{
	//僈开始反馈调节算法:要多少合适
	//要批量的内存块个数的判定
	size_t batchNum = min(_freeList[index].AmountSz(), AlignIndex::NumMoveSize(alignSize));
	if (batchNum == _freeList[index].AmountSz() ) 
	{
		_freeList[index].AmountSz()++;
	}
	//输出型参数
	void* start = nullptr;
	void* end =nullptr;
	
	//实际返回的个数:span下的自由链表的内存块个数可能不够 
	size_t actualNum = CenterCache::GetInstance().FetchRangeObj(start, end, batchNum, alignSize);
	assert(actualNum>0);//至少得有一个

	if (actualNum == 1)
	{
		return start;
	}
	else
	{
		_freeList[index].PushRange(NextObj(start), end); //挂的是剩下的内存块,当前内存块返回给外面。
		return start;
	}
}

  当线程内存池只拿到了一个;直接返回给外面用;如果拿到了多个,则返回一个;剩下的就插入到线程内存池的自由链表中。因此需要给Thread Cacheclass FreeList增加一个范围插入的接口:

class FreeList
{
private:
	void* _freeList  = nullptr;
	size_t _amountCenterSz = 1;
public:
	void PushRange(void* start,void* end,size_t n)
	{
		NextObj(end) = _freeList;
		_freeList = start;
		_size += n; 
	}
	//一次批量向central cache 要多少块内存
	size_t& AmountSz()
	{
		return _amountCenterSz;
	}
};

CenterCache代码实现

C++项目:高并发内存池_第14张图片

  • 分配指定个数和容量的内存块给Thread Cache:取到一个span以后;虽然Thread Cache指定那么多,但Central Cache 不一定够,所以不够的情况下,span下有多少个内存块就给几个。另外也说明只要获取到了对应的span,那么span里面的_freelist不为空,至少有一个小的内存块。
size_t CenterCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t alignsize)
{
	size_t index = AlignIndex::Index(alignsize);
	//只要访问`Central Cache` 就要上锁
	_spanLists->_mtx.lock();
	Span* span = GetOneSpan(_spanLists[index], alignsize);
	assert(span);
	assert(span->_freelist);
	start = span->_freelist;
	end = start;
	size_t i = 0;
	size_t actualNum = 1;  //实际至少有一个
	//够;如果不够,span中的链表里有几个给几个
	while (i< batchNum-1 && NextObj(end) != nullptr)
	{
		end = NextObj(end);
		++i;
		++actualNum;
	}
	//剩下的挂起来
	span->_freelist = NextObj(end);
	NextObj(end) = nullptr;
	_spanLists->_mtx.unlock();

	return actualNum;
}

//从哪个桶里获取一个非空的span
Span* CenterCache::GetOneSpan(SpanList& list, size_t byte_size)
{
	//。。。待补充
	return nullptr;
}

PageCaChe

  central cache和page cache 的核心结构都是spanlist的哈希桶。但是他们是有本质区别的,central cache中哈希桶,是按跟thread cache一样的大小对齐关系映射的,他的spanlist中挂的span中的内存都被按映射关系切好链接成小块内存的自由链表。而page cache 中的spanlist是按下标桶号映射的,也就是说第i号桶中挂的span都是i页内存,采用的是直接定址法。

C++项目:高并发内存池_第15张图片
  这里我们就最大挂128页的span,为了让桶号与页号对应起来,我们可以将第0号桶空出来不用,因此我们需要将哈希桶的个数设置为129。

申请与释放内存

  申请内存:

  1. 当central cache向page cache申请内存时,page cache先检查对应位置有没有span,如果没有则向更大页寻找一个span,如果找到则分裂成两个。比如:申请的是4页page,4页page后面没有挂span,则向后面寻找更大的span,假设在10页page位置找到一个span,则将10页pagespan分裂为一个4页page span和一个6页page span。
  2. 如果找到_spanList[128]都没有合适的span,则向系统使用mmap、brk或者是VirtualAlloc等方式申请128页page span挂在自由链表中,再重复1中的过程。

当central cache没有span时,向page cache申请的是某一固定页数的span,而如何切分申请到的这个span就应该由central cache自己来决定。

  释放内存:

  1. 如果central cache释放回一个span,则依次寻找span的前后page id的没有在使用的空闲span,看是否可以合并,如果合并继续向前寻找。这样就可以将切小的内存合并收缩成大的span,减少内存碎片。

CenterCache代码补充

  从中心池的桶下要小内存块,遍历这个链表,看有没有可用的span;没有则向Page Cache要。获取到以后,把页切成对应小块内存挂在span里面的自由链表上。因为要遍历SpanList,其提供开始和结束的span地址:

class SpanList 
{
private:
	Span* _head;
public:
	mutex _mtx;
public:
	Span* Begin()
	{
		return _head->_next;
	}
	Span* End()
	{
		return _head;
	}
};

  我们可以根据具体所需对象的大小来决定,central cache一次应该向page cache申请几页的内存块。central cache向page cache申请内存时,要求申请到的内存尽量能够满足thread cache向central cache申请时的上限。

class AlignIndex
{
public:
	//一次向page cache要多少个页合适
	// 单个对象8字节  512*8  4KB,还不够一个页。
	//单个对象256KB	2*256 	64页
	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;
	}
};

  进入到Page Cache申请span要将桶锁释放,用上PageCache的锁。因为此时有可能别的线程会到桶下释放内存块。新到的span插入到桶里时再获取锁。而将新的Span切分时不用加锁,因为此刻是刚申请到的span,其他线程看不到这个span。
C++项目:高并发内存池_第16张图片

//从哪个桶里获取一个非空的span
Span* CenterCache::GetOneSpan(SpanList& list, size_t byte_size)
{
	//查看当前桶的链表里有没有可以用的span
	Span* it = list.Begin();
	while (it != list.End())
	{
		if (it->_freelist != nullptr)
		{
			return it;
		}
		else
		{
			it = it->_next;
		}
	}
	//释放锁
	list._mtx.unlock();
	
	//如果走完还没有,只能找下一层page cache  要多少个页的span呢?页池是直接映射的
	PageCache::GetPageInstance()._pagemtx.lock();
	Span* span1 = PageCache::GetPageInstance().NewSpan(AlignIndex::NumMovePage(byte_size));
	PageCache::GetPageInstance()._pagemtx.unlock();

	//先算出span 的地址和大块内存的大小:用char*容易控制。
	char* start = (char*)(span1->_pageid << PAGE_SHIFT);
	size_t bytes = span1->_n << PAGE_SHIFT;
	char* end = start + bytes;

	//切成小块儿自由链表连接起来  先切一块下来尾插
	span1->_freelist = start;
	start += byte_size;
	void* tail = span1->_freelist;

	while (start<end) 
	{
		NextObj(tail) = start;
		tail = start;
		start += byte_size;
	}

	//将获得的span插入到中心池对应的桶里面。
	//访问对应桶就要加锁
	list._mtx.lock();
	list.PushFront(span1);

	return span1;
}
class SpanList 
{
private:
	Span* _head;
public:
	mutex _mtx;
public:
	void PushFront(Span* ptr)
	{
		Insert(Begin(), ptr);
	}
	bool Empty()
	{
		return _head->_next == _head;
	}
	Span* PopFront()
	{
		Span* front = _head->_next;
		Erase(front);
		return front;
	}
};


Page Cache代码实现

  • 直接映射、单例,大锁。
// 页大小转换偏移, 即一页定义为2^13,也就是8KB
static const size_t PAGE_SHIFT = 13; 

// page cache 管理span list哈希表的大小
static const size_t NPAGES = 129;

#include"Common.h"

//饿汉模式 给把大锁
class PageCache
{
private:
	
	SpanList _pageLists[NPAGES];  //直接定址法
	static PageCache _Inst;

	PageCache() {}
	PageCache(const PageCache&) = delete;
public:
	static PageCache& GetPageInstance()
	{
		return _Inst;
	}
	//获取一个K页的span
	Span* NewSpan(size_t k);
	mutex _pagemtx;
};

.cpp

  当前桶没有,则向下一个桶遍历。把大页的分成k页和n-k的,插入到对应的桶里面。都没有则向系统堆申请一个128页的内存块,调用系统接口。返回的是地址,根据虚拟内存的特性转换成对应的页号,把分裂的页数插入到对应的桶下面。

#include"PageCche.h"
PageCache PageCache::_Inst;

//获取一个K页的span
Span* PageCache::NewSpan(size_t k) //0位置空出来
{
	assert(k > 0 && k < NPAGES);
	//先检查第K个 桶有没有span
	if (!_pageLists[k].Empty())
	{
		return _pageLists[k].PopFront();
	}
	//检查后面的桶里面有没有span,如果有,大page的span可以进行切分
	for (size_t i = k+1; i < NPAGES; i++)
	{
		if (!_pageLists[i].Empty())
		{
			//k页的span返回,nspan对应桶的下面。
			Span* nspan = _pageLists[i].PopFront();
			Span* kspan = new Span;
			//nspan 头切 
			kspan->_pageid = nspan->_pageid;
			kspan->_n = k;

			//nspan的原起始页号是100,要走两页,则页号加2,页数减二
			nspan->_pageid += k;
			nspan->_n -= k;

			_pageLists[nspan->_n].PushFront(nspan);
			return kspan;
		}
	}
	//最开始啥都没有或者没找到,就向系统要一块大的128个页的。
	Span* bigspan = new Span;
	//进程地址空间返回的都是按页为对齐申请的,算页号就是除
	void* ptr = SystemAlloc(NPAGES - 1);
	bigspan->_pageid = (PAGE_ID)ptr >> PAGE_SHIFT;
	bigspan->_n = NPAGES - 1;
	_pageLists[bigspan->_n].PushFront(bigspan);

	return NewSpan(k);  //复用
}

申请内存联调

  • 测试内存块是否对齐
  • 测试起初获取8字节的内存,获取1024次不释放;到第1025次会不会再去下两层申请。
void TestConcurrentAlloc1()
{
	void* p1 = ConcurrentAlloc(6);
	void* p2 = ConcurrentAlloc(8);
	void* p3 = ConcurrentAlloc(1);
	void* p4 = ConcurrentAlloc(7);
	void* p5 = ConcurrentAlloc(8);

	cout << p1 << endl; //有可能不连续多线程下有换回会来的。目前应该是连续的。
	cout << p2 << endl;
	cout << p3 << endl;
	cout << p4 << endl;
	cout << p5 << endl;
}
void TestConcurrentAlloc2()
{
	for (size_t i = 0; i < 1024; ++i)
	{
		void* p1 = ConcurrentAlloc(6);
		cout << p1 << endl;
	}

	void* p2 = ConcurrentAlloc(8);
	cout << p2 << endl;
}


  • 修正:
#ifdef _WIN32
	#include 
#else
	// ...
#endif
//min在windows下是个宏

size_t CenterCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t alignsize)
{
	//...
	span->_useCount += actualNum;
}

逻辑框架

C++项目:高并发内存池_第17张图片


Thread Cache回收内存

  如何确定Thread Cache下的链表过长,合适回收呢?故在class FreeList增加一个链表长度的计数。只要有新的内存块回来,就加加;有线程取走就减减。

class FreeList
{
private:
	void* _freeList  = nullptr;
	size_t _amountCenterSz = 1;
	size_t _size =0;    //记录当前链表下有多少个内存块。

public:
	void Push(void* obj) //头插
	{
		assert(obj);
		NextObj(obj) = _freeList;
		_freeList = obj;
		++_size;
	}
	void PushRange(void* start,void* end,size_t n)
	{
		NextObj(end) = _freeList;
		_freeList = start;
		_size += n; 
	}
	void* Pop()//头删
	{
		assert(_freeList);
		void* obj = _freeList;
		_freeList = NextObj(_freeList);
		--_size;
		return obj;
	} 
	size_t Size()
	{
		return _size;
	}
};

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

class FreeList
{
private:
	void* _freeList  = nullptr;
	size_t _amountCenterSz = 1;
	size_t _size =0;    //记录当前链表下有多少个内存块。
public:
	//取走n个
	void PopRange(void*& start, void*& end, size_t n)
	{
		start = _freeList;
		end = start;
		for (size_t i = 0; i < n-1; i++)
		{
			end = NextObj(end);
		}
		_freeList = NextObj(end);
		NextObj(end) = nullptr;
		_size -= n;
	}
};

  当自由链表的长度大于一次批量申请的对象时,我们具体的做法就是,从该自由链表中取出一次批量个数的对象,然后将取出的这些对象还给central cache中对应的span即可。tcmalloc 不仅检测单个链表长度,还会计算整个thread cache,如果总的内存大于2m就释放给Central Cache

//还回来多大的内存块
void ThreadCache::Deallocate(void* ptr, size_t size)
{
	assert(ptr && size <= MAX_BYTES);
	size_t index = AlignIndex::Index(size);
	_freeList[index].Push(ptr); //释放头插

	//当链表个数大于一次批量申请的内存时就释放给center cache
	if (_freeList[index].Size() >= _freeList[index].AmountSz())
	{
		ListTooLong(_freeList[index], size);
	}
}

//取走慢增长个数个内存
void ThreadCache::ListTooLong(FreeList& list, size_t bytesize)
{
	void* start = nullptr;
	void* end = nullptr;
	 
	list.PopRange(start, end, list.AmountSz());

	//过长的内存块链表进行返回
	CenterCache::GetInstance().ReleaseMemoryToSpan(start, bytesize);
}

Central Cache回收内存

  当Central Cache 收到这些小的内存块时,怎么知道该插入到Central Cache 的哪个桶里呢?因为有着和`Thread Cache同样的映射规则,根据回收内存的大小计算出下标。

  那如何让这些小的内存块该还给对应的span呢?我们知道小内存块的地址,就可以知道它的页号。如果增加页号和span*的映射,就能很方便的找到属于哪个span了。
C++项目:高并发内存池_第18张图片
  因而在Page Cache返回给Central Cache 一个span时,添加映射关系刚刚好,Page Cache 只需要提供一个供Central Cache查询内存块属于哪个span*的接口。

class PageCache
{
private:
	//页号和span地址的映射 
	unordered_map<PAGE_ID, Span*> _idMapSpan;
public:
	// 获取从对象到span的映射
	Span* MapObjectToSpan(void* obj);
};

Span* PageCache::MapObjectToSpan(void* obj)
{
	PAGE_ID id = ((PAGE_ID)obj >> PAGE_SHIFT);
	auto ret = _idMapSpan.find(id);
	if (ret != _idMapSpan.end())
	{
		return ret->second;
	}
	else
	{
		assert(false);
		return nullptr;
	}
}
  • 添加映射关系:方便回收时,center cache插入到对应的span中。
Span* PageCache::NewSpan(size_t k)
{
	//检查后面的桶里面有没有span,如果有,大page的span可以进行切分
	for (size_t i = k + 1; i < NPAGES; i++)
	{
		if (!_pageLists[i].Empty())
		{
			//..
			//建立多个页号与kspan地址的映射,
			for (size_t i = 0; i < kspan->_n; i++)
			{
				_idMapSpan[kspan->_pageid + i] = kspan;
			}
			return kspan;
		}
	}
	//...
	return NewSpan(k);
}

Central Cache回收内存

  在Thread Cache还对象给Central Cache的过程中,如果Central Cache中某个span的_useCount减到0时,说明这个span分配出去的对象全部都还回来了,那么此时就可以将这个span再进一步还给Page Cache。
C++项目:高并发内存池_第19张图片

void CenterCache::ReleaseMemoryToSpan(void* start, size_t bytes)
{
	size_t index = AlignIndex::Index(bytes);
	//只要访问Central Cache的桶就加锁
	_spanLists[index]._mtx.lock();
	while (start)
	{
		void* next = NextObj(start);
		Span* span = PageCache::GetPageInstance().MapObjectToSpan(start);
		//并不是真的释放,等span 的usecount变为0 再整个span还回去。
		span->_useCount--;

		if (span->_useCount == 0)
		{
			_spanLists[index].Erase(span);
			span->_freelist = nullptr;
			span->_prev = nullptr;
			span->_next = nullptr;
			//释放桶锁,加大锁
			_spanLists[index]._mtx.unlock();
			//将整个span还给 Page Cache
			PageCache::GetPageInstance()._pagemtx.lock();
			PageCache::GetPageInstance().ReleaseSpanToPageCache(span);
			PageCache::GetPageInstance()._pagemtx.unlock();
			_spanLists[index]._mtx.lock();
		}
		start = next;
	}
	_spanLists[index]._mtx.unlock();
}

Page Cache回收内存

  如果central cache中有某个span的_useCount减到0了,那么central cache就需要将这个span还给page cache了。但实际为了缓解内存碎片的问题,page cache还需要尝试将还回来的span与其他空闲的span进行合并。

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

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

struct Span
{
	PAGE_ID _pageid = 0;//页号
	size_t _n = 0;		//几个页

	Span* _prev;  //双链表  方便不用的span释放给 page—cache
	Span* _next;

	void* _freelist = nullptr;   //存储一个页切分成的一个个小的内存块
	size_t _useCount = 0;		//记录centarl cache 分给 thread cache 的内存数量

	bool isUse = false;		//当前span是否正在被使用。
};

  由于只在Page Cache返回一个span给Central Cache时,对span中的页号与span*进行了映射;Page Cache在向前后搜索时,空闲的不一定是分走的span页号,因而也需要将余下没被分走的 n-k 页的 span进行映射。那么需要将所有的页号与span地址进行映射吗?假如:Central Cache取走了5页:页号1000 — 1004;余下123页的span:页号1005----1127。此刻取走的5页收回,要向后进行搜索合并,那么只需要知道1005相关联的span是否在使用;因为页号1005----1127的页都在一个span中。因而只需要将一个span的起始页号和尾部页号与span地址进行映射就可以了。

//获取一个K页的span
Span* PageCache::NewSpan(size_t k) //0位置空出来
{
	//...
	//检查后面的桶里面有没有span,如果有,大page的span可以进行切分
	for (size_t i = k + 1; i < NPAGES; i++)
	{
		if (!_pageLists[i].Empty())
		{
			//...
			_idMapSpan[nspan->_pageid] = nspan;
			_idMapSpan[nspan->_pageid + nspan->_n - 1] = nspan;
		}
	}
	//...
	return NewSpan(k);
}

  在进行前后页合并时,还要注意,如果前后两个span页数和大于128页,则不应合并。合法合并的span插入到对应的坐标下,并将首尾页号进行映射。由于每个span结构是new出来的,注意释放。

void PageCache::ReleaseSpanToPageCache(Span* span)
{
	size_t page1 = span->_n;
	if (page1 > NPAGES-1)
	{
		//....大于128页内存的释放
	}
	else
	{
		//向前找
		while (1)
		{
			PAGE_ID prvpage = span->_pageid - 1;
			auto ret = _idMapSpan.find(prvpage);
			//前一页没找到
			if (ret == _idMapSpan.end())
			{
				break;
			}

			//在用
			Span* prevspan = _idMapSpan[prvpage];
			if (prevspan->isUse == true)
			{
				break;
			}

			//超过128页
			if (prevspan->_n + span->_n > NPAGES - 1)
			{
				break;
			}
			span->_pageid = prevspan->_pageid;
			span->_n += prevspan->_n;
			_pageLists[prvpage].Erase(prevspan);
			delete prevspan;
		}
		//向后找
		while (1)
		{
			PAGE_ID nextpage = span->_pageid + span->_n;
			auto ret = _idMapSpan.find(nextpage);
			//后一页没找到
			if (ret == _idMapSpan.end())
			{
				break;
			}

			//在用
			Span* nextspan = _idMapSpan[nextpage];
			if (nextspan->isUse == true)
			{
				break;
			}

			//超过128页
			if (nextspan->_n + span->_n > NPAGES - 1)
			{
				break;
			}

			span->_n += nextspan->_n;

			_pageLists[nextspan->_n].Erase(nextspan);//从双链表中抹去
			delete nextspan;
		}
		_pageLists[span->_n].PushFront(span);
		span->isUse = false;
		_idMapSpan[span->_pageid] = span;
		_idMapSpan[span->_pageid + span->_n-1] = span;
	}
}

C++项目:高并发内存池_第20张图片


释放内存联调

  ConcurrentFree函数:最终效果应该向库函数那样,只需要一个指针,现在是临时方案,

//线程释放内存块
static void ConcurrentFree(void* ptr, size_t sz)
{
	assert(pTLSThreadCache);
	assert(sz<MAX_BYTES);
	pTLSThreadCache->Deallocate(ptr, sz);
}

测试

  简单的主线程测试,可以观察期间span结构的页号,页数;计算地址和页号的转换是否正确。

void TestConcurrentFree3()
{
	vector<int*> pv;
	for (size_t i = 0; i < 3; ++i)
	{
		void* p1 = ConcurrentAlloc(6);
		pv.push_back((int*)p1);
	}
	for (size_t i = 0; i < 3; ++i)
	{
		ConcurrentFree(pv[i], 6);
	}
}

完善改进

大于256KB的大块内存申请释放

C++项目:高并发内存池_第21张图片

申请

  更新class AlignIndex中关于内存块按页对齐,原来只有按特定字节的对齐。

class AlignIndex
{
public:
	static inline size_t _AlignUp(size_t bytes, size_t alignSz)
	{
		return ((bytes + alignSz - 1) & ~(alignSz - 1));//位运算效率更高
	}

	//实际size  不够对齐数的按下一层对齐数申请
	static inline size_t AlignUp(size_t size)
	{
		if (size <= 128)
		{
			return _AlignUp(size, 8);
		}
		else if (size<=1024)
		{
			return _AlignUp(size, 16);
		}
		else if (size <= 8 * 1024)
		{
			return  _AlignUp(size, 128);
		}
		else if (size <= 64 * 1024)
		{
			return _AlignUp(size, 1024);
		}
		else if (size <= 256 * 1024)
		{
			return _AlignUp(size, 8*1024);
		}
		else
		{
			//258* 8 * 1024  按1页大小进行对齐 
			return _AlignUp(size, 1 << PAGE_SHIFT);
		}
	}
	static inline size_t _Index(size_t bytes, size_t align_shift)
	{
		return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1;
	}
};

  大于256KB小于129页的不在向Thread Cache申请,而是向Page Cache申请。

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

	//cout << std::this_thread::get_id() << ":" << pTLSThreadCache << endl;
	if (size > MAX_BYTES)
	{
		size_t alignbytes = AlignIndex::AlignUp(size);
		size_t kpage = alignbytes >> PAGE_SHIFT;

		PageCache::GetPageInstance()._pagemtx.lock();
		Span* span1 = PageCache::GetPageInstance().NewSpan(kpage); //还走原来的逻辑
		PageCache::GetPageInstance()._pagemtx.unlock();
		
		//span中的页号转地址才是申请到的内存地址。
		void* ptr = (void*)(span1->_pageid << PAGE_SHIFT);
		return ptr;
	}
	else
	{
		assert(pTLSThreadCache);
		return pTLSThreadCache->Allocate(size);
	}
}

  大于128页的,向堆申请。对NewSpan做修改。

Span* PageCache::NewSpan(size_t k) 
{
	assert(k > 0);

	if (k>NPAGES-1) //> 128页
	{
		void* ptr = SystemAlloc(k);
		Span* span1 = new Span;
		span1->_pageid = (PAGE_ID)ptr >> PAGE_SHIFT;
		span1->_n = k;
		_idMapSpan[span1->_pageid] = span1;
		return span1;
	}
	//....
};

释放

  首先对ConcurrentFree修改:获取该大块内存地址对应的span,对于大于128页的调用系统接口释放。

inline static void SystemFree(void* ptr)
{
#ifdef _WIN32
	VirtualFree(ptr, 0, MEM_RELEASE);
#else
	// sbrk unmmap等
#endif
}
static void ConcurrentFree(void* ptr, size_t sz)
{
	assert(pTLSThreadCache);
	if (sz>MAX_BYTES)
	{
		Span* spn1 = PageCache::GetPageInstance().MapObjectToSpan(ptr);
		PageCache::GetPageInstance()._pagemtx.lock();
		PageCache::GetPageInstance().ReleaseSpanToPageCache(spn1);
		PageCache::GetPageInstance()._pagemtx.unlock();
	}
	else
	{
		pTLSThreadCache->Deallocate(ptr, sz);
	}
}

void PageCache::ReleaseSpanToPageCache(Span* span)
{
	size_t page1 = span->_n;
	if (page1 > NPAGES-1)
	{
		void* ptr = (void*)(page1 << PAGE_SHIFT);
		SystemFree(ptr);
		delete span;
	}
	else
	{
		//...前后合并
	}
};

测试

void BigAlloc()
{
	//257kb
	void* p1 =  ConcurrentAlloc(257 * 1024);
	ConcurrentFree(p1, 257 * 1024);

	//129页
	void* p2 = ConcurrentAlloc(129 * 8 * 1024);
	ConcurrentFree(p2, 129 * 8 * 1024);
}

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

  代码中使用new时基本都是为Span结构的对象申请空间,而span对象基本都是在page cache层创建的,因此我们可以在PageCache类当中定义一个_spanPool,用于span对象的申请和释放。项目搜索可修改多处。

class PageCache
{
private:
	//使用定长内存池替代new
	ObjectPool<Span> _spnObjectPool;
	//...
};

  还有ConcurrentAlloc 申请Thread Cache时,也会用到;这里使用一个静态的定长内存池对象.即使出了这个函数,它的值也会保持不变。不会再重新定义。

static void* ConcurrentAlloc(size_t size)
{
	if (pTLSThreadCache == nullptr)
	{
		static ObjectPool<ThreadCache> _threaCpool;
		pTLSThreadCache =_threaCpool.New();
	}
}

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

  需要在Span结构体里面添加一个记录对齐内存块大小的变量。Central Cache从Page Cache获取一个span进行切分时,进行记录。

struct Span
{
	PAGE_ID _pageid = 0;//页号
	size_t _n = 0;		//几页

	Span* _prev;  //双链表  方便不用的span释放给 page—cache
	Span* _next;

	void* _freelist = nullptr;   //存储一个页切分成的一个个小的内存块
	size_t _useCount = 0;		//记录centarl cache 分给 thread cache 的内存数量

	bool isUse = false;  //当前span是否正在被使用。
	size_t _objsize = 0;
};

  ConcurrentFree 进行释放时,可以根据大于256KB的用Page Cache进行释放,Page Cache释放需要一个span*,而我们知道地址,根据映射关系求出span即可。如果是小于256kb的,则走三层释放模型,求出的span 内部既有_objSize的大小。最外层不需要传size,三层释放模型内部还是需要的。

static void ConcurrentFree(void* ptr)
{
	assert(pTLSThreadCache);
	Span* spn1 = PageCache::GetPageInstance().MapObjectToSpan(ptr);
	
	if (spn1->_objsize>MAX_BYTES)
	{
		PageCache::GetPageInstance()._pagemtx.lock();
		PageCache::GetPageInstance().ReleaseSpanToPageCache(spn1);
		PageCache::GetPageInstance()._pagemtx.unlock();
	}
	else
	{
		pTLSThreadCache->Deallocate(ptr);
	}

}

  而unordered_map _idMapSpan;容器是不支持线程安全的,需要i在PageCache::MapObjectToSpan中添加一个unique_lock的锁。

Span* PageCache::MapObjectToSpan(void* obj)
{
	PAGE_ID id = ((PAGE_ID)obj >> PAGE_SHIFT);
	//当courrentFree时,有可能别的线程正在访问,造成线程不安全
	std::unique_lock<mutex> _lock(_pagemtx); //RAII锁
	auto ret = _idMapSpan.find(id);
	if (ret != _idMapSpan.end())
	{
		return ret->second;
	}
	else
	{
		assert(false);
		return nullptr;
	}
}

性能测试

  • 在多线程场景下对比malloc进行测试。
#include"ConcurrentAlloc.h"
#include
using std::atomic;

//ntimes:单轮次申请和释放内存的次数
//nworks:线程数
//rounds:轮次(跑多少轮)
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;
}

复杂问题的调试技巧

  1、条件断点;2、查看函数栈帧;3、疑似死循环时中断程序。上面的bug不少…

性能瓶颈分析

  相比malloc来说还是差一些。此时应分析分析我们当前项目的瓶颈在哪里,但这不能简单的凭感觉,我们应该用性能分析的工具来进行分析。VS编译器中就带有性能分析的工具的,我们可以依次点击“调试→性能和诊断”进行性能分析,注意该操作要在Debug模式下进行。

  同时我们将代码中n的值由10000调成了1000,否则该分析过程可能会花费较多时间,并且将malloc的测试代码进行了屏蔽,因为我们要分析的是我们实现的高并发内存池。如果出现VS PRF0002 使用如下选项的检测失败可能是项目路径中带有中文。

  经测试【项目设计】高并发内存池发现:调用MapObjectToSpan函数时消耗的时间是最多的,调用该函数时会消耗这么多时间就是因为锁的原因。tcmalloc当中针对这一点使用了基数树进行优化,使得在读取这个映射关系时可以做到不加锁。


基数树优化

  基数树实际上就是一个分层的哈希表,根据所分层数不同可分为单层基数树、二层基数树、三层基数树等。

  单层基数树实际采用的就是直接定址法,void* _a[页号] = span*,共有219个,32位下数组的大小就是2M。但如果是在64位平台下,此时该数组的大小是224G,这显然是不可行的,实际上对于64位的平台,我们需要使用三层基数树。

二层基数树

  这里还是以32位平台下,一页的大小为8K为例来说明,此时存储页号最多需要19个比特位。而二层基数树实际上就是把这19个比特位分为两次进行映射。

  比如用前5个比特位在基数树的第一层进行映射,映射后得到对应的第二层,然后用剩下的比特位在基数树的第二层进行映射,映射后最终得到该页号对应的span指针。二层基数树一开始时只需将第一层的数组开辟出来,当需要进行某一页号映射时再开辟对应的第二层的数组就行了。10001;10001 0000…02.

C++项目:高并发内存池_第22张图片

//二层基数树
template <int BITS> //19
class TCMalloc_PageMap2
{
private:
	static const int ROOT_BITS = 5;                //第一层对应页号的前5个比特位
	static const int ROOT_LENGTH = 1 << ROOT_BITS; //第一层存储元素的个数   2^5   32个
	static const int LEAF_BITS = BITS - ROOT_BITS; //第二层对应页号的其余比特位  14
	static const int LEAF_LENGTH = 1 << LEAF_BITS; //第二层存储元素的个数    2^14  个
	//数组中存储的元素类型
	struct Leaf
	{
		void* values[LEAF_LENGTH];
	};
	Leaf* root_[ROOT_LENGTH]; //第一层的数组
public:
	typedef uintptr_t Number;
	explicit TCMalloc_PageMap2()  
	{
		memset(root_, 0, sizeof(root_)); //将第一层的空间进行清理
		PreallocateMoreMemory(); //直接将第二层全部开辟
	}
	void* get(Number k) const
	{
		const Number i1 = k >> LEAF_BITS;        //第一层对应的下标
		const Number i2 = k & (LEAF_LENGTH - 1); //第二层对应的下标
		if ((k >> BITS) > 0 || root_[i1] == NULL) //页号值不在范围或没有建立过映射
		{
			return NULL;
		}
		return root_[i1]->values[i2]; //返回该页号对应span的指针
	}
	void set(Number k, void* v)
	{
		const Number i1 = k >> LEAF_BITS;        //第一层对应的下标
		const Number i2 = k & (LEAF_LENGTH - 1); //第二层对应的下标
		assert(i1 < ROOT_LENGTH);
		root_[i1]->values[i2] = v; //建立该页号与对应span的映射
	}
	//确保映射[start,start_n-1]页号的空间是开辟好了的
	bool Ensure(Number start, size_t n)//所有页号的空间全给开好
	{
		for (Number key = start; key <= start + n - 1;)
		{
			const Number i1 = key >> LEAF_BITS;
			if (i1 >= ROOT_LENGTH) //页号超出范围
				return false;
			if (root_[i1] == NULL) //第一层i1下标指向的空间未开辟
			{
				//开辟对应空间
				static ObjectPool<Leaf> leafPool;
				Leaf* leaf = (Leaf*)leafPool.New();
				memset(leaf, 0, sizeof(*leaf));
				root_[i1] = leaf;
			}
			key = ((key >> LEAF_BITS) + 1) << LEAF_BITS; //继续后续检查
		}
		return true;
	}
	void PreallocateMoreMemory()
	{
		Ensure(0, 1 << BITS); //将第二层的空间全部开辟好
	}
};

  此时将PageCache类当中的unorder_map用基数树进行替换,读取get,建立映射set,且MapObjectToSpan函数内部就不需要加锁了。

为什么读取基数树映射关系时不需要加锁?

  其他线程读取或写入kv的STL容器时,红黑树可能正在旋转,哈希表可能在扩容;动了整体的结构,所以需要加锁。而基数树在刚申请会去写,在二层结构下开好空间,不会动数组整体的结构,不影响别的线程查询别的映射关系;而且pagecache是一把大锁。另外读写是分离的。线程1对一个位置读写的时候,线程2不可能对这个位置进行读写。

比malloc性能高的原因

  1.第一级thread cache通过tls技术实现了无锁访问。
  2.第二级central cache加的是桶锁,可以更好的实现多线程的并行。
  3.第三级page cache通过基数树优化,有效减少了锁的竞争。


扩展学习及项目实现的不足

  不足:首先,实际上在释放内存时,由thread cache将自由链表对象归还给central cache只使用了链表过长这一个条件,但是实际中这个条件大概率不能恰好达成,那么就会出现thread cache中自由链表挂着许多未被使用的内存块,从而出现了线程结束时可能导致内存泄露的问题。解决方法就是使用动态tls或者通过回调函数来回收这部分的内存,并且通过申请批次统计内存占有量,保证线程不会过多占有资源。

  tc_malloc实际上替换到系统调用malloc不同平台替换方式不同。 基于unix的系统上的glibc,使用了weak alias的方式替换。具体来说是因为这些入口函数都被定义成了weak symbols,再加上gcc支持 alias attribute,所以替换就变成了这种通用形式:void* malloc(size_t size) THROW attribute__ ((alias (tc_malloc)))。因此所有malloc的调用都跳转到了tc_malloc的实现。GCC attribute 之weak,alias属性。Windows下需要使用hook的钩子技术来做。

  收获:通过本次项目的学习,我了解到了高效的多线程内存管理的底层实现原理,理解了内碎片与外碎片问题,学到了三级缓存的设计方案,慢增长的算法实现以及基数树使用减少锁竞争的问题。


参考

  • 【项目设计】高并发内存池
  • TCMalloc源码学习

你可能感兴趣的:(C++,c++,算法,数据结构,linux,缓存)