C++实现高并发内存池

文章目录

  • 完整代码
  • 项目介绍
  • 内存池
  • 开胃菜--先设计一个定长的内存池
  • 高并发内存池整体框架设计
  • thread cache
  • central cache
  • page cache
  • 回收、释放
  • tcmalloc源码中实现基数树进行优化

完整代码

代码链接

项目介绍

当前项目是实现一个高并发的内存池,他的原型是google的一个开源项目tcmalloc,tcmalloc全称Thread-Caching Malloc,即线程缓存的malloc,实现了高效的多线程内存管理,用于替代系统的内存分配相关的函数(malloc、free)。
这个项目是把tcmalloc最核心的框架简化后拿出来,模拟实现出一个自己的高并发内存池,目的就是学习tcamlloc的精华。

tcmalloc源代码

内存池

池化技术
所谓“池化技术”,就是程序先向系统申请过量的资源,然后自己管理,以备不时之需。之所以要申请过量的资源,是因为每次申请该资源都有较大的开销,不如提前申请好了,这样使用时就会变得非常快捷,大大提高程序运行效率。
在计算机中,有很多使用“池”这种技术的地方,除了内存池,还有连接池、线程池、对象池等。以服务器上的线程池为例,它的主要思想是:先启动若干数量的线程,让它们处于睡眠状态,当接收到客户端的请求时,唤醒池中某个睡眠的线程,让它来处理客户端的请求,当处理完这个请求,线程又进入睡眠状态。

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

内存池主要解决的问题
内存池主要解决的当然是效率的问题,其次如果作为系统的内存分配器的角度,还需要解决一下内存碎片的问题。
C++实现高并发内存池_第1张图片

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

malloc
C/C++中我们要动态申请内存都是通过malloc去申请内存,但是我们要知道,实际我们不是直接去堆获取内存的,而malloc就是一个内存池。malloc() 相当于向操作系统“批发”了一块较大的内存空间,然后“零售”给程序用。当全部“售完”或程序有大量的内存需求时,再根据实际需求向操作系统“进货”。malloc的实现方式有很多种,一般不同编译器平台用的都是不同的。比如windows的vs系列用的微软自己写的一套,linux gcc用的glibc中的ptmalloc。

开胃菜–先设计一个定长的内存池

先熟悉一下简单内存池是如何控制的,这个定长内存池也会作为后面内存池的一个基础组件

解决固定大小的内存申请释放需求
特点:
1、性能达到机制
2、不考虑内存脆片等问题

C++实现高并发内存池_第2张图片

首先申请一块大块空间,用_memory指向这块空间。

比如从_memory中分别取出内存块A、内存块B、内存块C使用(形象上说取出,实际地址空间都还是连续的)。当某个内存块使用完后归还时(假设是B,第一次归还),我们用 void* _freeList 指针存B的地址(头结点),后面要归还的内存块再一一往后链接。
每个内存块的前4/8个字节存储要链接在其后面的内存块的地址。

static void*& NextObj(void* obj) //取obj的头4/8个字节,& 也可以写
{
	return *((void**)obj);
}

New:从大块空间出取出一块给对象T使用。
不断取出内存块使用,当空间中剩余内存不够一个对象大小时,则丢弃剩余空间,重新开辟新的大块空间。所以需要int _remains变量记录大块空间中剩余的字节数。
如果自由链表中有内存块(_freeList != nullptr),就优先从自由链表中取出内存块使用。如果没有再从大块空间中取。

	T* New()
	{
		T * obj = nullptr;
		//如果自由链表有对象,直接取一个
		if(_freeList)
		{
			obj = (T*)_freeList;
			_freeList = *((void**)_freeList);
		}
		else
		{
			if (_remains < sizeof(T))
			{
				_remains = 128 * 1024;
				//_memory = (char*)malloc(_remains);
				_memory = (char*)SystemAlloc(_remains >> 13); //除8k
				if (_memory == nullptr)
				{
					throw std::bad_alloc();
				}
			}

			obj = (T*)_memory;
			size_t objSize = sizeof(T) > sizeof(void*) ? sizeof(T) : sizeof(void*);
			_memory += objSize;
			_remains -= objSize;
		}

		//对已经有的一块空间初始化,使用定位new显示调用T的构造函数初始化
		new(obj)T;
		return obj;
	}

Delete:当归还一个对象时,将对象(内存块)头插到自由链表中,再次重复利用。

	void Delete(T* obj)
	{
		//显示调用T的析构函数
		obj->~T();
		//头插
		*((void**)obj) = _freeList;
		_freeList = obj;
	}

注意:如果对象T的大小比指针大小还小,那么从大块空间取内存块时就取指针的大小,方便还回来时连接到一起。

申请内存时,我们可以直接调用系统,找堆按页申请内存,脱离malloc,
这里一页给8K。

//直接去堆上按页申请空间
#ifdef _WIN32
	#include 
#else
	//...
#endif

inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32
	//kpage*8*1024
	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;
}

该定长内存池在接下来的项目中代替new和delete

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

现代很多的开发环境都是多核多线程,在申请内存的场景下,必然存在激烈的锁竞争问题。malloc本身其实已经很优秀,项目的原型tcmalloc就是在多线程高并发的场景下更胜一筹,所以实现的内存池需要考虑以下几方面的问题。

  1. 性能问题
  2. 多线程环境下,锁竞争问题
  3. 内存碎片问题

concurrent memory pool主要由以下3个部分构成:
C++实现高并发内存池_第3张图片

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

thread cache

thread cache是哈希桶结构,每个桶是一个按桶位置映射大小的内存块对象的自由链表。

C++实现高并发内存池_第4张图片
申请内存:

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

释放内存:

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

管理切分好的小对象的自由链表

  1. Push
    头插,取obj对象头上的4个字节(以下表述都假设是32位系统下),指向自由链表的第一个节点,再让obj变为第一个。
	void Push(void* obj) //插入一个对象到自由链表
	{
		assert(obj);
		//头插
		//*(void**)obj = _freeList;
		NextObj(obj) = _freeList;
		_freeList = obj;

		++_size;
	}
  1. Pop
    头删。用obj指向第一个节点,再让_freeList指向obj的下一个节点。
	void* Pop()
	{
		assert(_freeList);

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

		return obj;
	}

计算对象大小的对齐映射规则
当一个对象被切小了挂到自由链表中时,64位下至少也要8字节来存储地址,所以这里刚开始以8字节对齐。但所有都以8字节对齐的话需要建的自由链表太多了,这里用一种规则来对齐。(向上对齐,但会有内碎片浪费)

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)


	//移位运算比加减乘除效率高
	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 _RoundUp(size, 1 << PAGE_SHIFT);
		}
	}

比如说需要129字节的空间,在[128+1, 1024]范围内是按16字节对齐,15÷144=10%左右,即有10%左右的内碎片浪费。

计算映射的哪一个自由链表桶

	//size_t _Index(size_t bytes, size_t alignNum)
	//{
	//	if (bytes % alignNum == 0)
	//	{
	//		return bytes / alignNum - 1;
	//	}
	//	else
	//	{
	//		return bytes / alignNum;
	//	}
	//}

	// 1 << 3 = 8
	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 group_array[4] = { 16, 56, 56, 56 };
		if (bytes <= 128)
		{
			return _Index(bytes, 3);
		}
		else if (bytes <= 1024)
		{
			return _Index(bytes - 128, 4) + group_array[0];
		}
		else if (bytes <= 8 * 1024)
		{
			return _Index(bytes - 1024, 7) + group_array[1] + group_array[0];
		}
		else if (bytes <= 64 * 1024)
		{
			return _Index(bytes - 8 * 1024, 10) + group_array[2] + group_array[1] + group_array[0];
		}
		else if (bytes <= 256 * 1024)
		{
			return _Index(bytes - 64 * 1024, 13) + group_array[3] + group_array[2] + group_array[1] + group_array[0];
		}
		else
		{
			assert(false);
		}

		return -1;
	}

TLS–thread local storage
线程本地存储,是一种变量的存储方法,这个变量在它所在的线程内是全局可访问的,但是不能被其它线程访问到,这样就保持了数据的线程独立性。
分为静态TLS和动态TLS,这里使用静态TLS
_declspec(thread) DWORD data = 0; 声明了_declspec(thread)的变量,会为每个线程创建一个单独的拷贝。

//TLS thread local storage
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;
		//通过TLS 每个线程无锁地获取自己专有的threadcache
		if (pTLSThreadCache == nullptr)
		{
			static ObjectPool<ThreadCache> tcPool;
			//pTLSThreadCache = new ThreadCache;
			pTLSThreadCache = tcPool.New();
		}

		return pTLSThreadCache->Allocate(size);

central cache

C++实现高并发内存池_第5张图片

central cache也是一个哈希桶结构,他的哈希桶的映射关系跟thread cache是一样的。不同的是他的每个哈希桶位置挂的是SpanList链表结构,每个桶下面的span是以页为单位的大块内存。

哈希桶的每个位置下面挂的都是Span对象链接的链表。如8Byte映射位置下面挂的span中的页被切成一个个8Byte大小的对象的自由链表。假设一页有8K,一个小对象的大小是8byte,8K * 1024 ÷ 8byte = 1024 块,也就是一页可以被切成1024块。而页数可以用一个变量_n来控制。
spanList是带头双向循环链表

申请内存

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

释放内存

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

Span结构
在32位程序中,进程地址空间大小是4G,即2 ^ 32字节,假设一页是2 ^13,即8K,页数 = 2 ^ 32 / 2 ^ 13 = 2 ^ 19个页
在64位程序中,2 ^ 64 / 2 ^ 13 = 2 ^ 51
所以在这里用条件编译

注意:在Win32配置下,_WIN32有定义,_WIN64没有定义。
在x64配置下,_WIN32和_WIN64都有定义,所以要把_WIN64放前面。

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

管理多个连续页大块内存跨度结构:

//管理多个连续页大块内存跨度结构
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;  //是否在被使用
};

注意:在SpanList,Erase pos位置的节点时,不delete pos,因为这个节点还要还回page cache中。

每个线程都有独享的thread cache,但一个进程中只有一个central cache,每个thread cache都可以很容易地找到central cache,这里central cache设计成单例模式(饿汉模式)
在.h中声明:

#pragma once

#include "Common.h"

//单例模式 饿汉模式
class CentralCache
{
public: 
	static CentralCache* GetInstance()
	{
		return &_sInst;
	}

	//获取一个非空的span
	Span* GetOneSpan(SpanList& list, size_t size);

	//从中心缓存获取一定数量的对象给thread cache
	size_t FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size);

	//将一定数量的对象释放到span跨度
	void ReleaseListToSpans(void* start, size_t byte_size);
private:
	SpanList _spanLists[NFREE_LIST];

private:
	CentralCache()
	{}

	CentralCache(const CentralCache&) = delete;

	static CentralCache _sInst;
};

当thread cache中的_freeList为空时,向central cache中要一个批量的内存batchNum。
如果刚开始给固定的8或几个,可能导致小对象给少了,大对象给多了。
这里用慢开始反馈调节算法。

thread cache一次从中心缓存获取多少个
比如当个对象大小是8字节,MAX_BYTES为256KB,除下来的num太大。如果num大于512个,就让其等于512;如果单个对象大小就是256KB,num=1太小,让其等于2,即控制上下限。

	// 一次thread cache 从中心缓存获取多少个
	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;
	}

thread cache从中心缓存获取
刚开始时只给1个对象,如果接下来不断有这个size大小的内存需求,那么给的批量个数就逐渐增加。当增加到上限值时,就都以上限值为批量数。
当batch为1个时,直接返回;大于一个时,从central cache中获取当前的batchNum个,把这段范围的对象插入到自由链表中,再返回这段范围的第一个对象。
但central cache中不一定有足够的对象给thread cache,当所拥有的数量小于batchNum时,有多少就获取多少个。
在自由链表中增加两个函数:

	void PushRange(void* start, void* end, size_t n)
	{
		NextObj(end) = _freeList;
		_freeList = start;

		_size += n;
	}

	size_t& MaxSize()
	{
		return _maxSize;
	}
void* ThreadCache::FetchFromCentralCache(size_t index, size_t size)
{
	//慢开始反馈调节算法
	//1、最开始不会一次向central cache一次批量要太多,因为要太多了可能用不完
	//2、如果你不断有这个size大小内存需求,那么batchNum就不会断增长,知道上限
	//3、size越大,一次向central cache要的batchNum就越小
	//4、size越小,一次向central cache要的batchNum就越大
	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(NextObj(start), end, actualNum - 1);
		return start;
	}
}

以上是在thread cache对象中需要的接口,在central cache也要实现一个获取一定数量对象给thread cache的接口。
计算出对应central cache中桶的下标,加锁,在对应_spanLists[index]中获取一个非空的span。假设batchNum是3,而span中有4个小对象。让start指向头结点,end往后走batchNum-1步,再让_freeList指向Nextobj(end)。
C++实现高并发内存池_第6张图片

但当span中个数比batchNum少时,end不能走到空,否则程序会崩溃,所以当NextObj(end) == nullptr时,不再继续。

//从中心缓存获取一定数量的对象给thread cache
size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size)
{
	size_t index = SizeClass::Index(size);
	_spanLists[index]._mtx.lock();

	Span* span = GetOneSpan(_spanLists[index], size);
	assert(span);
	assert(span->_freeList);

	//从span中获取batchNum个对象
	//如果不够batchNum个,有多少拿多少个
	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->_freeList = NextObj(end);
	NextObj(end) = nullptr;
	span->_useCount += actualNum;

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

page cache

C++实现高并发内存池_第7张图片

申请内存

  1. 当central cache向page cache申请内存时,page cache先检查对应位置有没有span,如果没有则向更大页寻找一个span,如果找到则分裂成两个。比如:申请的是4页page,4页page后面没有挂span,则向后面寻找更大的span,假设在10页page位置找到一个span,则将10页page span分裂为一个4页page span和一个6页page span。
  2. 如果找到_spanList[128]都没有合适的span,则向系统使用mmap、brk或者是VirtualAlloc等方式申请128页page span挂在自由链表中,再重复1中的过程。
  3. 需要注意的是central cache和page cache的核心结构都是spanlist的哈希桶,但它们是有本质区别的,central cache中哈希桶是按跟thread一样的大小对齐关系映射的,他的spanlist中挂的span中的内存都被按映射关系切好连接成小块内存的自由链表。而page cache中的spanlist则是按下标桶号映射的,也就是说第i号桶中挂的span都是i页的内存。

释放内存

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

最大的对象是256KB,最大的桶是128page,如果一页是8K,那么能切4个对象,差不多足够。
同样使用饿汉模式。
page cache使用一把大锁锁整个page cache,不使用桶锁,因为比如一个申请1页的span,一个申请2页的span,如果都没有,每次往后找一次都要加一下锁,频繁加锁解锁,效率更低。就像在一个for循环里加锁和在for循环外加一次锁,是后者效率更高。而central cache在是通过size算出固定的桶的位置,不会到别的桶,不会频繁加锁解锁。

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

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

	//释放空闲span回到Pagecache,合并相邻的span
	void ReleaseSpanToPageCache(Span* span);

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

	std::mutex _pageMtx;

private:
	SpanList _spanLists[NPAGES];
	ObjectPool<Span> _spanPool;

	std::unordered_map<PAGE_ID, Span*> _idSpanMap;

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

	static PageCache _sInst; 
};

刚开始时,每个桶都没有span。此时要一个2页的span,2页的桶没有,不断往后找,直到128页的也没有,就去向堆申请。申请一个128page span,然后分成2page 和 126page,2page给central cache,126page挂到126page的桶中。

如果central cache中的span usecount==0,说明切分给thread cache的小内存块都回来了,则central cache把这个span还给page cache。page cache通过页号,查看前后的相邻页是否空闲,是的话就合并,合并出更大的页,解决内存碎片问题。(外碎片)

thread cache向central cache中获取一个非空span,当central cache中没有空闲span时,向page cache要。
算获取的span的起始地址:页号从0开始分配,第0页的起始地址是0.页号 << 13 即 页号*8K ,再强转成 char*就是该页号的起始地址。
算span的大块内存的大小:页数 << 13 即 页数 *8K
将从page cache中获取的span插入central cache的桶中时用尾插,这样地址就还是顺序的。start指向该页起始地址,取出一块链接到自由链表中做头,方便尾插。tail指向自由链表尾节点,最后让tail指向空。
最后返回这个span

获取一个K页的span
如果第K个桶中没有,往后找直到第n个桶中有span,将这个span切分成一个k页的span和一个n-k页的span。k页的span返回给central cache,n-k页的span挂到第n-k桶。
如果没有大块的span,这时就要去堆申请一个128页的span,把这个128页的span插入到对应桶中,再递归调用一次自己。
使用封装的SystemAlloc,返回一个指针,将这个地址右移13位,即乘上8K,再强转成整型(32位是size_t,64位是unsigned long long),即可算出页号。

在FetchRangeObj中,对应的桶已经加上锁,再传入GetOneSpan。如果当前spanlist中没有span,则先把central cache的桶锁解掉,这样如果其它线程释放内存回来不会阻塞。然后将page cache加锁,再从中获取span,再解锁。
获取到span后需要进行切分,此时span还未push到central cache中,此时也不需要加桶锁,因为此时其它线程访问不到这个span。
切分完成后,在将span插入到central cache前,再加桶锁。

//获取一个K页的span
Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0);
	//大于128页的直接向堆申请
	if (k > NPAGES - 1)
	{
		void* ptr = SystemAlloc(k);
		//Span* span = new Span;
		Span* span = _spanPool.New();
		span->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
		span->_n = k;
		_idSpanMap[span->_pageId] = span;
		return span;
	}

	//先检查第k个桶里有没有span
	if (!_spanLists[k].Empty())
	{
		Span* kSpan = _spanLists[k].PopFront();

		//建立id和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;
			Span* kSpan = _spanPool.New();

			//在nSpan的头部切一个k页下来(尾部也可)
			//k页span返回
			//nSpan再挂到对应映射的位置
			kSpan->_pageId = nSpan->_pageId;
			kSpan->_n = k; //页数

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

			_spanLists[nSpan->_n].PushFront(nSpan);
			//存储nSpan的首位页号跟nSpan映射,方便page cache回收内存时
			//进行合并查找
			_idSpanMap[nSpan->_pageId] = nSpan;
			_idSpanMap[nSpan->_pageId + nSpan->_n - 1] = nSpan;

			//建立id和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;
	Span* bigSpan = _spanPool.New();
	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
Span* CentralCache::GetOneSpan(SpanList& list, size_t size)
{
	//查看当前的spanlist中是否有还未分配对象的span
	Span* it = list.Begin();
	while (it != list.End())
	{
		if (it->_freeList != nullptr)
		{
			return it;
		}
		else
		{
			it = it->_next;
		}
	}

	//先把central cache的桶锁解掉,这样如果其它线程释放内存回来不会阻塞
	list._mtx.unlock();

	//走到这里说明没有空闲span了 只能找page cache要
	PageCache::GetInstance()->_pageMtx.lock();
	Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(size));
	span->_isUse = true;
	span->_objSize = size;
	PageCache::GetInstance()->_pageMtx.unlock();

	//对获取的span进行切分 不需要加桶锁,因为这会其它线程访问不到这个span

	char* start = (char*)(span->_pageId << PAGE_SHIFT);  //span的起始地址
	size_t bytes = span->_n << PAGE_SHIFT; //span的大块内存的大小(字节数)
	char* end = start + bytes;

	//把大块内存切成自由链表链接起来 尾插
	//1.先切一块下来去做头,方便尾插
	span->_freeList = start;
	start += size;
	void* tail = span->_freeList;
	int i = 1;
	while (start < end)
	{
		++i;
		NextObj(tail) = start;
		tail = NextObj(tail); //tail = start
		start += size;
	}

	NextObj(tail) = nullptr;

	//切好span以后,需要把span挂到桶里面去的时候,再加锁
	list._mtx.lock();
	list.PushFront(span);

	return span;
}

回收、释放

内存块不用了释放回thread cache,链接到自由链表上。当链表长度大于一次批量申请的内存时就开始释放一段list给central cache。
这里释放的是当前一次批量要获取的大小,把这段链表从自由链表中pop掉,再链接到对应的span上

	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);
		NextObj(end) = nullptr;
		_size -= n;
	}
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);
	}
}

//释放对象时,链表过长时,回收内存到中心缓存
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);
}

当thread cache的小块回收进central cache时,如何知道这个小块属于哪个span?
比如在第2000页中,里面的任意一个内存块的地址/8K都等于2000。
C++实现高并发内存池_第8张图片
知道一个内存块的地址后,就可以算出属于哪个页。
在page cache中增加一个unordered_map的成员,key为页号,value为Span*,
这样就可以通过算的页号获取这个小块属于哪个span。
在page cache的NewSpan时,获取span放到对应映射的位置后,建立id和span的映射,方便central cache回收小块时查找对应span。
找到对应span后,将小块内存头插进去。
当span的_useCount等于0时,说明这个span切分出去的所有小块内存都回来了,这个span就可以再回收给page cache,page cache可以再尝试去做前后页的合并。
span释放给page cache时,使用page cache的锁就可以了。这时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;
	}
}
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);
		NextObj(start) = span->_freeList;
		span->_freeList = start;
		span->_useCount--;

		//说明span的切分出去的小块内存都回来了
		//这个span就可以再回收给page cache,pagecache可以再尝试去做前后页的合并
		if (span->_useCount == 0)
		{
			_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();
}

如果page cache中的大块span不断被切,切成多个小页的span,给central cache、thread cache使用。当内存释放给page cache时,就会导致有非常多小块内存,存在外碎片问题。所以当释放回page cache时,要对span前后的页尝试进行合并。
比如换回来一个5页span,pageId(起始页号)是2000,那么往前找pageId是1999的span,往后找pageId是2005的span,假如1999号页的span页数是2,20005页的页数是5,那么总共就合并成12页的span,页号是1998。但如果前后还有完整的span,就继续找,直到没有了再合并。
这时就需要在_idSpanMap中通过pageId查找前后相邻的span,
但如何识别这个span现在是在central cache中还是在page cache中?
如果用_useCount来判断,当其等于0时,有可能在thread cache从central cache获取一个非空span时,而central cache中也没有,就要从page cache中获取,此时刚要++_useCount,这个span就被page cache拿去合并,就会产生线程安全问题。
只有在page cache中的span才是没有在被使用的,否则有可能是正在被切分的。所以增加一个_isUse变量。当central cache从page cache中获取span进行切分前,将_isUse变为true。
在page cache切分成k页的和n-k页的span时,k页是要给central cache用的,所以要将span的每一页都建立映射。而nSpan是要留在page cache中的,只需存储nSpan的首尾页号跟nSpan映射,方便page cache回收内存时进行合并查找。
向前合并:不断合并,如果前面的页号没有,或前面相邻页的span在使用,或前面相邻页的页数和当前页的页数合并超过128页,都不继续合并了。
向后合并类似。

void PageCache::ReleaseSpanToPageCache(Span* span)
{
	//大于128 page的直接还给堆
	if (span->_n > NPAGES - 1)
	{
		void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
		SystemFree(ptr);
		//delete span;
		_spanPool.Delete(span);

		return;
	}

	//对span前后的页,尝试进行合并,缓解内存碎片问题
	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;

		_spanLists[prevSpan->_n].Erase(prevSpan);
		//delete prevSpan;
		_spanPool.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 == true)
		{
			break;
		}

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

		span->_n += nextSpan->_n;

		_spanLists[nextSpan->_n].Erase(nextSpan);
		//delete nextSpan;
		_spanPool.Delete(nextSpan);
	}

	_spanLists[span->_n].PushFront(span);
	span->_isUse = false;
	_idSpanMap[span->_pageId] = span;
	_idSpanMap[span->_pageId + span->_n - 1] = span;
}

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

  1. <= 256KB -> 三层缓存
  2. 大于 256KB
    a、328K ~1288K -> page cache
    b、size > 128*8K -> 找系统堆

大于128页的page释放时直接还给堆

inline static void SystemFree(void* ptr)
{
#ifdef _WIN32
	VirtualFree(ptr, 0, MEM_RELEASE);
#else
	// sbrk unmmap等
#endif
}
	if (size > MAX_BYTES)
	{
		size_t alignSize = SizeClass::RoundUp(size);
		size_t kpage = alignSize >> PAGE_SHIFT;
		
		PageCache::GetInstance()->GetInstance()->_pageMtx.lock();
		Span* span = PageCache::GetInstance()->NewSpan(kpage);
		span->_objSize = size;
		PageCache::GetInstance()->GetInstance()->_pageMtx.unlock();
		
		void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
		return ptr;
	}
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);
	}
}

tcmalloc源码中实现基数树进行优化

多线程下,和malloc对比:
每次获取相同大小内存块时,malloc的速度比较快。因为concurrent alloc都在central cache的同一桶内获取,锁竞争大。
C++实现高并发内存池_第9张图片

均衡获取不同大小内存块:
C++实现高并发内存池_第10张图片

使用性能探测工具可以看到,MapObjectToSpan中加的锁占了大量时间
C++实现高并发内存池_第11张图片
基数树其实就是一个直接定址法映射的哈希表,key是页号,value是指针

一层:BITS就是存储页号需要的位的偏移。
如果一页8K,32位下,2^ 32 / 2 ^13 = 2 ^19 ,即BITS=19.
这个数组是一个指针数组,数组总共占用内存是2^21,32位下一层结构就够了。

// Single-level array
template <int BITS>
class TCMalloc_PageMap1 {
private:
	static const int LENGTH = 1 << BITS;
	void** array_;

二层:

// Two-level radix tree
template <int BITS>
class TCMalloc_PageMap2 {
private:
	// Put 32 entries in the root and (2^BITS)/32 entries in each leaf.
	static const int ROOT_BITS = 5;
	static const int ROOT_LENGTH = 1 << ROOT_BITS;

	static const int LEAF_BITS = BITS - ROOT_BITS;
	static const int LEAF_LENGTH = 1 << LEAF_BITS;

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

	Leaf* root_[ROOT_LENGTH];             // Pointers to 32 child nodes
	void* (*allocator_)(size_t);          // Memory allocator

第一层BITS固定是5,32个位置。第二层有5个指针数组,每个数组有2^ 14个位置,一共也是2 ^ 19个位置。第一层低19位存储页号,高13位都是0。低19位的前5个位决定页号在第二层的哪一个数组(每个位置映射一个数组),后14位决定一个页号在第二层数组的哪个位置。

三层同样,三层是为64位准备的。虽然空间大小上和一层的一样,但一层要开一个非常大的数组。三层还可以节省空间,等到要访问时再开某一个数组。

1、只有在ReleaseSpanToPageCache和NewSpan这两个函数中会建立id和span的映射,也就是说会去写。
2、基数树,写之前会提前开好空间,写数据过程中,不会动结构。
3、读写是分离的。线程1对一个位置读写的时候,线程2不可能对这个位置读写。

C++实现高并发内存池_第12张图片

你可能感兴趣的:(C++,c++)