高并发内存池

目录

项目介绍

这个项目是什么

什么是内存池

池化技术

内存池

内存池主要解决的问题

malloc

设计一个定长的内存池

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

高并发内存池--thread cache

自由链表的哈希桶跟对象大小的映射

高并发内存池--central cache

高并发内存池--page cache

内存回收释放

thread cache

central cache

page cache

申请大内存问题

脱离new和delete

释放对象不传入大小

优化性能


项目介绍

这个项目是什么

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


什么是内存池

池化技术

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

内存池

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

内存池主要解决的问题

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

高并发内存池_第1张图片

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

malloc

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


设计一个定长的内存池

        我们要知道申请内存使用的是malloc,malloc其实就是一个通用的大众货,什么场景
下都可以用,但是什么场景下都可以用就意味着什么场景下都不会有很高的性能,下面我们就先来设计一个定长内存池,当然这个定长内存池在我们后面的高并发内存池中也是有价值的,所以实现它目的有两层,先熟悉一下简单内存池是如何控制的,第二他会作为我们后面内存池的一个基础组件。

固定大小的内存申请释放要求

特点:

1. 性能达到极致

2. 不考虑内存碎片等问题

 

        在这个定长内存池的类中,定义3个成员变量,_memory(申请的大块内存的指针),_remainBytes(剩余的可用内存字节数),_freeList(释放的内存块链接到的自由链表)。

  

高并发内存池_第2张图片

        因为char类型占用1个字节,所以使用char类型更便于计算。在一开始默认是没有可用的内存的,所以直接申请一块定长的内存块,申请完后,先把首个T类型大小的空间给obj,这就是申请的一个内存块,再计算_memory和_remainBytes的大小,最后在用定位new,因为空间已经申请好了,只需要它做初始化操作就可以了。

        每次要优先把还回来的内存块重复利用,自由链表头删一个内存块来返回,再来说一下为什么要用(void**),在32位环境下,指针是4byte,64位下是8byte,所以在32位下要用int,64位下要用long long,因为void*在不同环境下是对应的,所以定义这个二级指针再解引用就是对应的环境应该使用的byte,是指针就可以,这样做就是保证自由链表每个内存块的首个4或8字节要存放下一个内存块的地址。

        释放的时候就把内存块中显示调用析构函数,释放完后再头插进自由链表。

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

template
class ObjectPool
{
public:
	T* New()
	{
		T* obj = nullptr;

		// 优先把还回来的内存块对象,再次重复利用
		if (_freeList)
		{
			void* next = *(void**)_freeList;
			obj = (T*)_freeList;
			_freeList = next;
			return obj;
		}
		else
		{
			// 剩余的内存不够一个对象的大小时,则重新开大块空间
			if (_remainBytes < sizeof(T))
			{
				_remainBytes = 128 * 1024;
				_memory = (char*)SystemAlloc(_remainBytes >> 13);
				if (_memory == nullptr)
				{
					throw std::bad_alloc();
				}
			}

			obj = (T*)_memory;
            
            //这样计算objSize以防T是一个char类型,但是自由链表存放下一个地址的是每个内存块首个void*大小的部分,所以在T和void*中选择一个较大值
			size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
			_memory += objSize;
			_remainBytes -= objSize;
		}
		// 定位new,显示调用T的构造函数初始化
		new(obj)T;

		return obj;
	}

	void Delete(T* obj)
	{
		//显示调用析构函数清理对象
		obj->~T();

		//头插
		*(void**)obj = _freeList;
		_freeList = obj;

	}

private:
	char* _memory = nullptr;  // 指向大块内存的指针
	size_t _remainBytes = 0;  // 大块内存在切分过程中剩余的字节数
	void* _freeList = nullptr;// 还回来过程中链接的自由链表的头指针
};


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

        现代很多的开发环境都是多核多线程,在申请内存的场景下,必然存在激烈的锁竞争问题。malloc本身其实已经很好了,这个项目的原型tcmalloc就是在多线程高并发的场景下更胜一筹,所以这次实现的内存池需要考虑以下几方面的问题
1. 性能问题。
2. 多线程环境下,锁竞争问题。
3. 内存碎片问题。
 
高并发内存池_第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对象,并且合并相邻的页,组成更大的页,缓解内存碎片 的问题。


高并发内存池--thread cache

        thread cache是哈希桶结构,每个桶是一个按桶位置映射大小的内存块对象的自由链表。每个线程都会有一个thread cache 对象,这样每个线程在这里获取对象和释放对象时是无锁的。 
 
高并发内存池_第4张图片

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

申请内存: 

1. 当内存申请size<=256KB,先获取到线程本地存储的thread cache对象,计算size映射的哈希桶自由链表下标index。
2. 如果自由链表_freeLists[index]中有对象,则直接Pop一个内存对象返回。
3. 如果_freeLists[index]中没有对象时,则批量从central cache中获取一定数量的对象,插入到自由链表并返回一个对象。
4.如果申请的内存超过了256KB,
释放内存:
1. 当释放内存小于256k时将内存释放回thread cache,计算size映射自由链表桶位置i,将对象push到_freeLists[i]。
static const size_t MAX_BYTES = 256 * 1024;
static const size_t NFREELIST = 208;

static void*& NextObj(void* obj)
{
	return *(void**)obj;
}

// 管理切分好的小对象的自由链表
class FreeList
{
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(obj);
        --_size;
		return obj;
	}

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

	bool Empty()
	{
		return _freeList == nullptr;
	}

	size_t& MaxSize()
	{
		return _maxSize;
	}

    size_t& Size()
	{
		return _size;
	}
private:
	void* _freeList = nullptr;
	size_t _maxSize = 1;
	size_t _size = 0; // 链表中内存块的个数
};

//

class ThreadCache
{
public:
	// 申请和释放内存对象
	void* Allocate(size_t size);

	void Deallocate(void* ptr, size_t size);

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

private:
	FreeList _freeLists[NFREELIST];
};

//TLS thread local storage
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;

//

void* ThreadCache::FetchFromCentralCache(size_t index, size_t size)
{
	// 慢开始反馈调节算法
	// 最开始不会一次向central cache要一次批量要太多,因为要太多了可能用不完
	// 如果不断有size大小内存需求,那么batchNum就会不断增长(指的是MaxSize),直到上限(也就是512)
	// size越大(小),一次向central cache要的batchNum就越小(大)
	size_t batchNum = min(_freeLists[index].MaxSize(), SizeClass::NumMoveSize(size));// 通常是要加::,但是windows.h下有min宏;NumMoveSize函数也在下面讲解

	if (_freeLists[index].MaxSize() == batchNum)
	{
		_freeLists[index].MaxSize() += 1;
	}

    // 计算这一批申请了多少个,start和end使用指针引用传参,这样可以拿到首尾的地址
	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
	{
        // 在代码运行阶段,通过batchNum拿到的内存块可能非常多,所以拿第一个,之后的插入链表
		_freeLists[index].PushRange(NextObj(start), end, actualNum - 1);
		return start;
	}
}

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

void ThreadCache::Deallocate(void* ptr, size_t size)
{
	assert(ptr);
	assert(size <= MAX_BYTES);

	// 找出映射的自由链表桶,对象插入进入
	size_t index = SizeClass::Index(size);
	_freeLists[index].Push(ptr);
    
    // ...
}
 
//

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

	return pTLSThreadCache->Allocate(size);	
}

static void ConcurrentFree(void* ptr, size_t size)
{
	assert(pTLSThreadCache);

	pTLSThreadCache->Deallocate(ptr, size);
}

自由链表的哈希桶跟对象大小的映射

为什么要计算对齐数呢:
申请的字节范围 使用的对齐数 对应的桶
[1,128] 8byte _freeLists[0,16)
[128+1,1024] 16byte _freeLists[16,72)
[1024+1,8*1024] 128byte _freeLists[72,128)
[8*1024+1,64*1024] 1024byte _freeLists[128,184)
[64*1024+1,256*1024] 8*1024byte _freeLists[184,208)
这样做的话可以控制内碎片浪费,申请的太大太小都不合适
这些对齐映射规则的部分使用的都是位运算,使用位运算的好处就是效率会高一些。
下面这三个例子来解释一下:
        根据上面图可以知道,自由链表是分为8byte,16byte的,就算申请的内存不够,也会向上补齐到对齐的内存。
        例如8byte对齐,7按位取反后,最后3个比特位都是0,其余的全是1,按位与后,后3位全变成0,其余位不变,而且其余位的1也是8的整数倍,这样最后的值一定是8byte对齐的。
        其他byte对齐的规则也是差不多的,对齐数:[要申请的byte+对齐数-1, 下一个对齐数),去掉多余的数,保证对齐。
高并发内存池_第5张图片

         之后就会根据这个对齐数到对应的桶去申请内存,还是这个公式,size:[size+对齐数-1, 下一个对齐数),左右位移相当于乘除2的n次方,这就好比说把24个苹果平均分成8份,可以分给几个人。

高并发内存池_第6张图片         当向central cache申请内存的时候,申请的个数使用的是一个慢启动的算法,计算结果在[2, 512]中,返回后在根据maxSize决定申请多少个。

// 计算对象大小的对齐映射规则
class SizeClass
{
public:

	static inline size_t _RoundUp(size_t size, size_t alignNum)
	{
		return ((size + 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);
		}
	}

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

    // 一次从中心缓存获取多少个
	static size_t NumMoveSize(size_t size)
	{
		assert(size > 0);
		if (size == 0)
			return 0;
		// [2, 512],一次批量移动多少个对象的(慢启动)上限值
		// 小对象一次批量上限高
		// 小对象一次批量上限低
		int num = MAX_BYTES / size;
		if (num < 2)
			num = 2;
		if (num > 512)
			num = 512;
		return num;
	}
};


高并发内存池--central cache

        central cache也是一个哈希桶结构,他的哈希桶的映射关系跟 thread cache 是一样的。不同的是他的每个哈希桶位置挂是SpanList 链表结构,不过每个映射桶下面的 span 中的大内存块被按映射关系切成了一个个小内存块对象挂在span 的自由链表中。
        这个哈希桶中8byte位置对应的是页被切成8byte大小的对象,对应位置存放对应大小的span。
高并发内存池_第7张图片
高并发内存池_第8张图片

申请内存:
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中_usecount记录分配了多少个对象出去,分配一个对象给thread cache,就++_usecount。
 
释放内存:

central cache和page cache的释放到后面再来讲解,先把申请的逻辑解释明白。

static const size_t NFREELIST = 208;

// 管理多个连续页大块内存跨度结构
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; // 切好的小块内存的自由链表
};

// 带头双向循环链表
class SpanList
{
public:
	SpanList()
	{
		_head = new Span;
		_head->_next = _head;
		_head->_prev = _head;
	}

	Span* Begin()
	{
		return _head->_next;
	}
	Span* End()
	{
		return _head;
	}
    
    bool Empty()
	{
		return _head->_next == _head;
	}

	void PushFront(Span* span)
	{
		Insert(Begin(), span);
	}

	Span* PopFront()
	{
		Span* front = _head->_next;
		Erase(front);
		return front;
	}

	void Insert(Span* pos, Span* newSpan)
	{
		assert(pos);
		assert(newSpan);

		Span* prev = pos->_prev;
		// prev newSpan pos
		prev->_next = newSpan;
		newSpan->_next = pos;
		newSpan->_prev = prev;
		pos->_prev = newSpan;
	}
	void Erase(Span* pos)
	{
		assert(pos);
		assert(pos != _head);

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

		prev->_next = next;
		next->_prev = prev;
	}

private:
	Span* _head;     // 头结点
public:
	std::mutex _mtx; // 桶锁
};

//

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

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

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

private:
	SpanList _spanLists[NFREELIST];
	
private:
	CentralCache()
	{}

	CentralCache(const CentralCache&) = delete;

	static CentralCache _sInst;
};

//

CentralCache CentralCache::_sInst;

// 获取一个非空的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;
		}
	}

	// ... 在介绍page cache部分的时候再补全

	// 到这里说明没有空闲的span,只能找下一层的page 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;
	}

    // 把内存块切好之后,把尾部置为空
	NextObj(tail) = nullptr;

    // 用start和end两个指针保存好从central cache中拿到的内存,之后的内存块不动
	span->_freeList = NextObj(end);
	NextObj(end) = nullptr;
    span->_usecount += actualNum;

	_spanLists[index]._mtx.unlock();

	return actualNum;
}


高并发内存池--page cache

高并发内存池_第9张图片

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

        1page的大小是8kb,也就是2的13次方byte。

static const size_t NPAGES = 129;
static const size_t PAGE_SHIFT = 13;

typedef size_t PAGE_ID;

PageCache PageCache::_sInst;

// 获取一个K页的span
Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0 && k < NPAGES);

	// 先检查第k个桶里面有没有span
	if (!_spanLists[k].Empty())	
	{
		return _spanLists[k].PopFront();
	}

	// 检查一下后面的桶里面有没有span,如果有可以把他它进行切分
	for (size_t i = k + 1; i < NPAGES; ++i)
	{
		if (!_spanLists[i].Empty()) // 找到了大页内存
		{
			Span* nSpan = _spanLists[i].PopFront();
			Span* kSpan = new Span;

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

			nSpan->_pageId += k;
			nSpan->_n -= k;
            
            // 切好了之后再放到对应的桶中
			_spanLists[nSpan->_n].PushFront(nSpan);

			return kSpan;
		}
	}

	// 走到这个位置就说明后面没有大页的span了
	// 这时就去找堆要一个128页的span
	Span* bigSpan = new Span;
	void* ptr = SystemAlloc(NPAGES - 1);
	bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
	bigSpan->_n = NPAGES - 1;
    
    // 把大页的内存放进桶里,这样就有大页的内存了,非常巧妙地再次调用这个函数就可以找到这块大页内存
	_spanLists[bigSpan->_n].PushFront(bigSpan);

	return NewSpan(k);
}

//

// 计算一次向系统获取几个页    
static size_t NumMovePage(size_t size) // 此函数在SizeClass中
{
	size_t num = NumMoveSize(size); // 计算出需要拿多少个
	size_t npage = num * size; // 计算要多少字节,之后右移算出要多少page,最少给一页
	npage >>= PAGE_SHIFT;
	if (npage == 0)
		npage = 1;
	return npage;
}

// 获取一个非空的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));
	PageCache::GetInStance()->_pageMtx.unlock();
	
	// 对获取的span进行切分,不需要加锁,因为这会让其他线程访问不到这个span

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

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

    // 把内存块切好之后,把尾部置为空
	NextObj(tail) = nullptr;

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

	return span;
}

//

class PageCache
{
public:
	static PageCache* GetInStance()
	{
		return &_sInst;
	}
	// 获取一个K页的span
	Span* NewSpan(size_t k);

	std::mutex _pageMtx;

private:
	SpanList _spanLists[NPAGES];

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

	static PageCache _sInst;
};


内存回收释放

thread cache

当链表的长度过长,则回收一部分内存对象到central cache。
void ThreadCache::Deallocate(void* ptr, size_t size)
{
	assert(ptr);
	assert(size <= MAX_BYTES);

	// 找出映射的自由链表桶,对象插入进入
	size_t index = SizeClass::Index(size);
	_freeLists[index].Push(ptr);
	 
	// 当链表长度大于一次批量申请的内存时就开始还一段list给central cache
	if (_freeLists[index].Size() >= _freeLists[index].MaxSize())
	{
		ListTooLong(_freeLists[index], size);
	}
}

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

central cache

        当thread_cache 过长或者线程销毁,则会将内存释放回 central cache 中的,释放回来时 --_usecount。当_ usecount 减到 0 时则表示所有对象都回到了 span ,则将 span 释放回 page cache ,page cache中会对前后相邻的空闲页进行合并。
// 在Span类中添加一个成员变量来判断是否被使用,如果已经被使用就不要再合并了
bool _isUse = false;	// 是否在被使
// ...
// 到这里说明没有空闲的span,只能找下一层的page cache要
PageCache::GetInStance()->_pageMtx.lock();
Span* span = PageCache::GetInStance()->NewSpan(SizeClass::NumMovePage(size));
// 拿到span之后就修改使用情况
span->_isUse = true;
PageCache::GetInStance()->_pageMtx.unlock();
// ...

// 将一定数量的对象释放到span跨度
void CentralCache::ReleaseListToSpans(void* start, size_t size)
{
	size_t index = SizeClass::Index(size);
	_spanLists[index]._mtx.lock();

	while (start)
	{
        // 把每个内存块头插
		void* next = NextObj(start); 
		Span* span = PageCache::GetInStance()->MapObjectToSpan(start);
		NextObj(start) = span->_freeList;
		span->_freeList = start;
		span->_usecount--;
		// 说明span的切分出去的所有小块内存都回来了
		// 这个span就可以再回收给page cache,page cache可以尝试去做前后页合并
		if (span->_usecount == 0)
		{
			_spanLists[index].Erase(span); // 把span链表头删,连接好
			span->_freeList = nullptr; // 不使用了置空
			span->_next = nullptr;
			span->_prev = nullptr;

			// 释放span给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

         如果central cache释放回一个span,则依次寻找span的前后page id的没有在使用的空闲span,看是否可以合并,如果合并继续向前寻找。这样就可以将切小的内存合并收缩成大的span,减少内存碎片。
// ... 在NewSpan中添加,在切分大page的时候
// 存储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;
}
// ...

// 获取一个K页的span
Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0 && k < NPAGES);

	// 先检查第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 = _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);
}

//

// 向PageCache类中添加map存放<页号,Span*>
std::unordered_map _idSpanMap;

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

// 释放空间span回到PageCache,合并相邻的span
void PageCache::ReleaseSpanToPageCache(Span* span)
{
	// 对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;
	}

	// 向后合并
	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;
	}
	
    // 把合并之后的span在插入到链表中。并设置为未使用,之后把首尾映射到map中
	_spanLists[span->_n].PushFront(span);
	span->_isUse = false;
	_idSpanMap[span->_pageId] = span;
	_idSpanMap[span->_pageId + span->_n - 1] = span;
}


申请大内存问题

1. 在申请的内存小于256kb的时候,采用thread central page cache三层缓存的方式。

2. 申请的内存大于256kb分为两种情况:

    (1)申请的内存还在128page=128*8kb的范围内,还是向page cache申请。

    (2)大于这个范围就直接向系统的堆申请。

3. 释放的情况也是一样的。

inline static void SystemFree(void* ptr)
{
#ifdef _WIN32
	VirtualFree(ptr, 0, MEM_RELEASE);
#else
	// sbrk unmmap等
#endif
}

//

// 释放空间span回到PageCache,合并相邻的span
void PageCache::ReleaseSpanToPageCache(Span* span)
{
	if (span->_n > NPAGES - 1) // 大于128page,所以不是向pagecache要的,要直接释放
	{
		void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
		SystemFree(ptr);
		delete span;

		return;
	}

	// 对span前后的页,尝试进行合并,缓解内存碎片问题
    // ...
}

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

    // 申请的内存超过128page
	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;
	}

    // 如果没有超过128page,还是向page cache申请

	// 先检查第k个桶里面有没有span
    // ...
}

//

static void* ConcurrentAlloc(size_t size)
{
    if (size > MAX_BYTES)
	{
		size_t alignSize = SizeClass::RoundUp(size);
		size_t kpage = alignSize >> PAGE_SHIFT;

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

		void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
		return ptr;
	}

	// 通过TLS每个线程无锁的获取自己的专属的ThreadCache对象
    // ...
}

static void ConcurrentFree(void* ptr, size_t size)
{
	if (size > MAX_BYTES)
	{
		Span* span = PageCache::GetInStance()->MapObjectToSpan(ptr);

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


脱离new和delete

        因为实现的这个内存池可以提高效率,但是其中也有使用了new和delete,new使用的也是malloc,一开始实现的定长内存池的性能也要比malloc高,所以把上面的代码中使用过new和delete的地方修改一下。

// 向PageCache类中加入一个成员变量,因为在page cache中使用了很多new申请一个span
ObjectPool _spanPool;
// 把所有使用new的地方替换成:
_spanPool.New();
// 把使用delete的地方替换成:
_spanPool.Delete();

// 还有创建thread cache的时候,使用TLS申请的空间也是用new实现的,这里也要修改
static ObjectPool tcPool;
pTLSThreadCache = tcPool.New();


释放对象不传入大小

// 想要释放内存的时候不传入大小,可以先把内存的大小保存起来
// 向span类中加入一个成员函数_objSize
size_t _objSize = 0;
//这个变量就是要申请完新的span后再赋值

static void* ConcurrentAlloc(size_t size)
{
	if (size > MAX_BYTES)
	{
		size_t alignSize = SizeClass::RoundUp(size);
		size_t kpage = alignSize >> PAGE_SHIFT;

		PageCache::GetInStance()->_pageMtx.lock();
		Span* span = PageCache::GetInStance()->NewSpan(kpage);
		span->_objSize = size;
		PageCache::GetInStance()->_pageMtx.unlock();
        // ...
}



// 还有一个需要加锁的地方就是根据地址映射span的时候,其他线程可能会对块内存进行读写
Span* PageCache::MapObjectToSpan(void* obj)
{
	PAGE_ID id = ((PAGE_ID)obj >> PAGE_SHIFT);

	std::unique_lock lock(_pageMtx);
    // ...
}



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


优化性能

        通过上面的修改,这个项目已经可以跑起来了,但是性能方面还是不能和malloc相比,那是哪里出的问题呢?

        经过一些测试,知道了一是因为锁的竞争导致的效率低,另一个在MapObjectToSpan这个函数中的锁,所以说锁的消耗是非常大的。因为这个锁的原因,tcmalloc源码中使用的是基数树来解决锁竞争的问题。

        这里的基数树分为1层或者多层,这里就介绍1层和2层的情况,3层其实也差不多。

        一层就是直接定值法,BITS的定值根据32位还是64位来定,根据地址直接映射到对应的位置。

高并发内存池_第10张图片

// 一层
template  // 存储页号需要多少位 通常是 32/64 - PAGE_SHIFT
class TCMalloc_PageMap1
{
private:
    static const int LENGTH = 1 << BITS; // 1 << (32/64 - PAGE_SHIFT) = 19/51
    void** array_;

public:
    typedef uintptr_t Number;

    explicit TCMalloc_PageMap1()
    {
        // 计算需要多少字节,再对齐
        size_t size = sizeof(void*) << BITS;
        size_t alignSize = SizeClass::_RoundUp(size, 1 << PAGE_SHIFT);
        array_ = (void**)SystemAlloc(alignSize >> PAGE_SHIFT);
        memset(array_, 0, sizeof(void*) << BITS);
    }

    void* get(Number k) const
    {
        // 检查是否超过了范围
        if ((k >> BITS) > 0)
        {
            return NULL;
        }
        return array_[k];
    }

    void set(Number k, void* v)
    {
        array_[k] = v;
    }
};

 这是两层的

高并发内存池_第11张图片

// 两层
template 
class TCMalloc_PageMap2
{
private:
    static const int ROOT_BITS = 5;
    static const int ROOT_LENGTH = 1 << ROOT_BITS; // 2^5个根

    static const int LEAF_BITS = BITS - ROOT_BITS; // 19 - 5 = 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; // 右移叶的位数就可以拿到代表在哪个根的5位地址
        const Number i2 = k & (LEAF_LENGTH - 1); // 将代表叶的二进制位都变成1,其余都为0,这样就可以拿到在哪个叶的14位地址
        // 检查是否超出范围或者根都没有创建好
        if ((k >> BITS) > 0 || root_[i1] == NULL)
        {
            return NULL;
        }
        return root_[i1]->values[i2];
    }
    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;
    }
    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)
            {
                static ObjectPool 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类中把原来使用的map变成基数树
TCMalloc_PageMap1<32 - PAGE_SHIFT> _idSpanMap;

// 把对应的位置都更改为(Span*)_idSpanMap.get() / _idSpanMap.set( , )
Span* PageCache::MapObjectToSpan(void* obj)
{
	PAGE_ID id = ((PAGE_ID)obj >> PAGE_SHIFT);
	auto ret = (Span*)_idSpanMap.get(id);
	assert(ret != nullptr);
	return ret;
}

最后在说一下为什么要去掉这个锁来优化这个项目:

1. 如果不加锁,其他线程在读写的过程中可能会改变这个结构,最后导致读取出错。
2. map使用的红黑树,更改的时候可能导致树的旋转。
3. 哈希表可能在扩容的时候出现问题,旧表还没有复制给新表,读取的还是原来的表。

所以这就是为什么要加锁。

基数树的优点:

1. 这个结构本身在读写的过程就很快。
2. 它在映射之前就会把内存先开好,不管是插入还是删除都不会改变这个结构。
3. 读写是分离的。
4. 只有在NewSpan和ReleaseSpanToPageCache这两个函数中会去写入,但是在进入函数之前都是加了锁的。
其实不加锁也是可以的,因为不管是读写都不会对同一个页进行。


项目源码

gitee:https://gitee.com/du-shunhao-1/cpp/tree/master/ConcurrentMemoryPool/ConcurrentMemoryPool

你可能感兴趣的:(C++,数据结构,算法,c++,c语言,数据结构,链表)