高并发内存池

目录

项目介绍

内存池介绍

池化技术

内存池

内存池主要解决的问题

malloc

实现定长内存池

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

整体设计需要考虑的方面

整体框架设计

Thread Cache

自由链表

对齐映射规则

对齐大小计算​编辑

映射桶号计算

Thread Cache类

申请内存

慢反馈调节算法

释放内存

TLS(thread local storage)无锁访问

Central Cache

页号类型

Span结构

SpanList带头结点的双向链表

Central Cache结构

申请内存

从中心缓存获取对象

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

获取一个非空Span

释放内存

Page Cache

Page Cache类

映射查找Span

申请内存

释放内存

申请释放联调

申请内存联调

释放内存联调

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

大块内存申请问题

大块内存释放问题

性能对比及基数树优化

性能对比

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

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

性能瓶颈分析

基数树优化

单层基数树

二层基数树

三层基数树

代码更改

再次对比malloc进行测试 


项目介绍

        本项目设计一个高并发内存池(Concurrent Memory Pool),其原型是Google开源项目tcmalloc(Thread-Caching Malloc),即线程缓存的malloc,实现了高效的多线程内存管理,可用于替代系统的内存分配函数(malloc、free)。

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

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

内存池介绍

池化技术

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

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

内存池

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

内存池主要解决的问题

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

内存碎片分为内碎片和外碎片:

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

malloc

        C/C++中我们要动态申请内存并不是直接去堆申请的,而是通过malloc函数去申请的,包括C++中的new实际上也是封装了malloc函数的。

  我们申请内存块时是先调用malloc,malloc再去向操作系统申请内存。malloc实际就是一个内存池,malloc相当于向操作系统“批发”了一块较大的内存空间,然后“零售”给程序用,当全部“售完”或程序有大量的内存需求时,再根据实际需求向操作系统“进货”。

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

实现定长内存池

        malloc其实就是一个通用的内存池,在什么场景下都可以使用,但这也意味着malloc在什么场景下都不会有很高的性能,因为malloc并不是针对某种场景专门设计的。

  定长内存池就是针对固定大小内存块的申请和释放的内存池,由于定长内存池只需要支持固定大小内存块的申请和释放,因此我们可以将其性能做到极致,并且在实现定长内存池时不需要考虑内存碎片等问题,因为我们申请/释放的都是固定大小的内存块。

定长内存池设计结构如下:

        设计一个定长的内存池,为了将申请和释放与malloc分开,本项目要和malloc进行性能比较,那么各处实现就不能调用malloc以及对应的free,new和delete是C++的一个关键字,其底层调用了malloc和free,所以我们要避开使用C++的关键字,自己实现一个New和Delete。

#ifdef _WIN32
	#include<Windows.h>
#else

#endif

template<class T>
class ObjectPool {
public:
	T* New(){
		T* obj = nullptr;
		// 先从归还的自由链表中申请内存
		if (_freeList) {
			void* next = *(void**)(_freeList);
			obj = (T*)_freeList;
			_freeList = next;
		}
		// 若无,再从大块内存中申请
		else {
			// 若剩余大小不足,则申请内存
			if (_remainBytes < sizeof(T)) {
				_remainBytes = 1024 * 128;
				_memory = (char*)SystemAlloc(_remainBytes >> 13);
				if (_memory == nullptr) {
					throw std::bad_alloc();
				}
			}
			obj = (T*)_memory;
			size_t objSize = sizeof(T) < sizeof(obj) ? sizeof(obj) : sizeof(T);
			_remainBytes -= objSize;
			_memory += objSize;
		}

		// 定位new,在已分配的原始内存空间中显示调用构造函数初始化一个对象
		new(obj) T;  

		return obj;
	}

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

		// 头插
		*(void**)obj = _freeList;  // obj的前4/8个字节指向freeList
		_freeList = obj;  // 更新obj为freeList的头
	}

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

        需要注意的是,当内存块切分出来后,我们也应该使用定位new,显示调用该对象的构造函数对其进行初始化。同样,在释放对象时,我们应该显示调用该对象的析构函数清理该对象,因为该对象可能还管理着其他某些资源,如果不对其进行清理那么这些资源将无法被释放,就会导致内存泄漏。

        既然是内存池,那么我们首先得向系统申请一块内存空间,然后对其进行管理。要想直接向堆申请内存空间,在Windows下,可以调用VirtualAlloc函数;在Linux下,可以调用brk或mmap函数。

// 直接去堆上申请内存空间
inline static void* SystemAlloc(size_t kpage) {
#ifdef _WIN32
	void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#elif _WIN64
	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;
}

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

整体设计需要考虑的方面

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

整体框架设计

高并发内存池主要由以下三个部分构成:

  • thread cache: 线程缓存是每个线程独有的,用于小于等于256KB的内存分配,线程从这里申请内存不需要加锁,每个线程独享一个cache,这也就是这个并发线程池高效的地方
  • central cache: 中心缓存是所有线程所共享的,当thread cache需要内存时会按需从central cache中获取内存,而当thread cache中的内存满足一定条件时,central cache也会在合适的时机对其进行回收。central cache是存在竞争的,所以从这里取内存对象是需要加锁,首先这里用的是桶锁,其次只有thread cache没有内存对象时才会找central cache,所以这里竞争不会很激烈
  • page cache: 页缓存中存储的内存是以页为单位进行存储及分配的,当central cache需要内存时,page cache会分配出一定数量的页,并切割成定长大小的小块内存,分配给central cache。当一个span的几个跨度页的对象都回收以后,page cache会回收central cache满足条件的span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题

Thread Cache

自由链表

        定长内存池只支持固定大小内存块的申请释放,因此定长内存池中只需要一个自由链表管理释放回来的内存块。

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

        自由链表中一定会有插入、删除、判空等操作,并且我们还可以记录个数_size,_maxSize这个桶最多能挂多少个,那么这么多个自由链表就需要被管理,我们设计一个管理自由链表的结构:

// 管理切分好的小对象的自由链表
class FreeList {
public:
	void Push(void* obj) {
		assert(obj);
		NextObj(obj) = _freeList;  // obj的下一个地址指向_freeList
		_freeList = obj;
		++_size;
	}

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

	void PopRange(void*& start, void*& end, size_t n) {
		assert(n <= _size);

		start = _freeList;
		end = start;
		
		// 头删
		for (size_t i = 0; i < n - 1; i++) {
			end = NextObj(end);
		}

		_freeList = NextObj(end);
		NextObj(end) = nullptr;

		_size -= n;
	}

	void* Pop() {
		assert(_freeList);

		void* obj = _freeList;
		_freeList = NextObj(obj);
		--_size;

		return obj;  // 返回被释放的小块内存对象
	}

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

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

  这时我们可以选择做一些平衡的牺牲,让这些字节数按照某种规则进行对齐,例如我们让这些字节数都按照8字节进行向上对齐,那么thread cache的结构就是下面这样的,此时当线程申请1~8字节的内存时会直接给出8字节,而当线程申请9~16字节的内存时会直接给出16字节,以此类推。

        因此当线程要申请某一大小的内存块时,就需要经过某种计算得到对齐后的字节数,进而找到对应的哈希桶,如果该哈希桶中的自由链表中有内存块,那就从自由链表中头删一个内存块进行返回;如果该自由链表已经为空了,那么就需要向central cache申请。

  但此时由于对齐的原因,可能造成内部碎片。

对齐映射规则

对齐大小计算

        该设计规则除了第一个桶的内碎片浪费大,保证其他桶内碎片浪费整体保证在10%左右。

        内碎片浪费率=浪费的字节/分配的字节,比如现在有129字节,就要分配144字节,只使用第一个16byte对齐桶的1个字节,浪费15字节,但总共分配了128+16=144字节,所以内碎片浪费率=15/144=10.4%

        根据设计规则,通过传入参数(字节数),进行简单逻辑判断跳转至子函数_RoundUp进行对齐后的字节数计算。

//管理对齐和映射等关系
class SizeClass
{
public:
	//获取向上对齐后的字节数
	static inline size_t RoundUp(size_t bytes);
	//获取对应哈希桶的下标
	static inline size_t Index(size_t bytes);
};
// 对齐大小计算
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 _RoundUp(size_t bytes, size_t alignNum) {
		return ((bytes + alignNum - 1) & ~(alignNum - 1));
	}

        对齐后的字节数计算函数(_RoundUp)设计我们学习参考tcmalloc的实现,采用位运算的方式进行,该设计思路十分巧妙,值得我们去学习使用。

映射桶号计算

        首先根据上面设计的对齐映射规则,我们可以计算得到对应桶号的区间,利用数组将区间桶号保存,再使用简单逻辑判断进入子函数(_Index)计算当前所在区间映射到的桶号,最终对齐映射的桶号=区间前的桶数+当前区间桶号

// 计算映射到哪一个自由链表桶
	static inline size_t Index(size_t bytes) {
		assert(bytes <= MAX_BYTES);

		// 每个区间有多少个链
		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[0] + group_array[1];
		}
		else if (bytes <= 64 * 1024) {
			return _Index(bytes - 8 * 1024, 10) + group_array[0] + group_array[1] + group_array[2];
		}
		else if (bytes <= 256 * 1024) {
			return _Index(bytes - 64 * 1024, 13) + group_array[0] + group_array[1] + group_array[2] + group_array[3];
		}
		else {
			assert(false);
		}

		return -1;
	}
static inline size_t _Index(size_t bytes, size_t align_shift) {
		return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1;  // 从0号桶开始映射
	}

Thread Cache类

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

	// 从central cache中获取对象
	void* FetchFromCentralCache(size_t index, size_t size);

	// 将过长的freelists回收给CentralCache
	void ListTooLong(FreeList& list, size_t size);

private:
	FreeList _freeLists[NFREELIST];
};

// TLS thread local storage 线程本地存储
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;
// _declspec(thread) 用于声明线程安全函数,它可以在多线程环境中安全地被多个线程调用,而不会出现数据竞争或其他线程安全问题。
申请内存
// 申请内存对象,自由链表出一个内存对象
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);
	}
}

        当内存申请 size<=256KB ,先获取到线程本地存储的 Thread Cache 对象,计算 size 映射的哈希桶自由链表下标i 。
        如果自由链表_freeLists[i] 中有对象,则直接 Pop 一个内存对象返回。

        Pop()函数属于FreeList类中成员函数,因为是从自由链表上取走一个去使用,所以需要返回值void*。

        如果_freeLists[i] 中没有对象时,则批量从 Central Cache 中获取一定数量的对象,头插入到自由链表并返回一个对象。

// 从central cache中获取内存对象
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;
	}
}

        对于需求不同字节大小,从Central Cache获取的分配个数又需要考虑性能, 对于分配8bytes,可以多分配一些(但要有上限),对于256*1024bytes,则少分配些(但要有下限)
采用慢开始反馈调节算法
    1.最开始不会一次向Central Cache一次批量要太多,因为要太多可能用不完
    2.如果不要这个size大小内存需求,那么betchNum就会不断增长,直到上限。
    3.size越大,一次向Central Cache要的batchNum就越小
    4.size越小,一次向Central Cache要的batchNum就越大 

慢反馈调节算法
    // 一次thread cache从中心缓存获取多少个
	static size_t NumMoveSize(size_t size) {
		assert(size > 0);

		int num = MAX_BYTES / size;
		if (num < 2)
			num = 2;
		if (num > 512)
			num = 512;

		return num;
	}

        如果只需要8Byte大小,从Central Cache获取批量数就是256*1024/8,其结果大于512,返回512个;如果需要256KB大小,从Central Cache获取批量数就是256KB/256KB=1,其结果小于2,返回2个。

        这样设计批量在于确定上下限,不会使得从中心缓存获取的小块内存过多或过少,如果获取过多,一直不使用,达到一定数量时又会回收给Central Cache,多此一举,所以确定上下限。计算结果在上下限之间的就返回计算个数。

释放内存

        当释放内存小于256Kb 时将内存释放回 Thread Cache ,计算 size 映射自由链表桶位置 i ,将对象 Push到_freeLists[i] 。

// 释放内存对象,自由链表收回一个内存对象
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给CentralCache
	if (_freeLists[index].Size() >= _freeLists[index].MaxSize()) {
		ListTooLong(_freeLists[index], size);
	}
}

        当链表的长度过长,则回收一部分内存对象到Central Cache 。

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);
}
TLS(thread local storage)无锁访问

         我们在设计中要求每一个线程都有一个独属于自己的Thread Cache类,如果我们把Thread Cache类实现为全局的,那么必然每个线程共享这个类,势必会发生竞争问题,需要加锁。

        频繁的控制锁的加锁和解锁会增加时间成本,这显然和我们要的高性能不相符,所以这里提出一个变量存储方法TLS,线程局部存储TLS,该方法下:变量在当前线程下是全局可访问的,在线程和线程之间是独立局部的,这有效的实现了每个线程独属于自己的类,避免加锁。

// TLS thread local storage 线程本地存储
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;
// _declspec(thread) 用于声明线程安全函数,它可以在多线程环境中安全地被多个线程调用,而不会出现数据竞争或其他线程安全问题。

        但不是每个线程被创建时就立马有了属于自己的thread cache,而是当该线程调用相关申请内存的接口时才会创建自己的thread cache,因此在申请内存的函数中会包含以下逻辑。

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

Central Cache

        Central Cache也是一个哈希桶结构,他的哈希桶的映射关系跟Thread Cache 是一样的。不同的是他的每个哈希桶位置挂是SpanList 链表结构,不过每个映射桶下面的 span 中的大内存块被按映射关系切成了一个个小内存块对象挂在span 的自由链表中。

        每个span管理的都是一个以页为单位的大块内存,每个桶里面的若干span是按照双链表的形式链接起来的,并且每个span里面还有一个自由链表,这个自由链表里面挂的就是一个个切好了的内存块,根据其所在的哈希桶这些内存块被切成了对应的大小。

页号类型

        每个程序运行起来后都有自己的进程地址空间,在32位下,最高(2^32)/(2^13)=2^19,2^19我们需要4字节大小来表示,可以用size_t类型可以表示,但如果是64位下,页号最高(2^64)/(2^8)=2^51,我们需要8字节大小来表示,可以用unsigned long long类型。所以我们使用条件编译进行判断使用那种类型:

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

        细节:64位系统下,包含了宏_WIN32和_WIN64;如果把_WIN32放在最开始判断,那么就无法识别出64位系统,会一直识别为32位,所以我们将_WIN64放在最开始判断64位系统

        但实际上size_t在64位下是unsigned long long 或者unsigned _int64类型(范围:[0,2^64 -1]),32位下是unsigned int类型。如果想要编写可移植的代码,应该避免直接使用int或long类型,而是要使用size_t类型。

Span结构

        central cache的每个桶里挂的是一个个的Span,Span是一个管理以页为单位的大块内存,Span的结构如下:

struct Span {
	PAGE_ID _pageId = 0;  // 大块内存起始页的页号
	size_t _n = 0;  //页的数量

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

	size_t _objSize = 0;   //切好的小对象的大小
	size_t _useCount = 0;  // 切好小块内存,被分配给thread cache的数量
	void* _freeList = nullptr;  // 切好的小块内存的自由链表

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

        对于Span管理的以页为单位的大块内存,我们需要知道这块内存具体在哪一个位置,便于之后page cache进行前后页的合并,因此span结构当中会记录所管理大块内存起始页的页号。

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

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

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

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

SpanList带头结点的双向链表

        根据上面的描述,central cache的每个哈希桶里面存储的都是一个双链表结构,对于该双链表结构我们可以对其进行封装。

class SpanList {
public:
	// head里不挂内存,相当于带头节点的链表
	SpanList() {
		_head = new Span;
		_head->_next = _head;
		_head->_prev = _head;
	}

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

		Span* prev = pos->_prev;
		prev->_next = newSpan;
		newSpan->_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;
	}

	bool Empty() {
		return _head->_next == _head;
	}

	Span* Begin() {
		return _head->_next;
	}

	Span* End() {
		return _head;
	}

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

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

private:
	Span* _head;  // 头结点

public:
	std::mutex _mtx;  // 桶锁
};

  需要注意的是,从双链表删除的span会还给下一层的page cache,相当于只是把这个span从双链表中移除,因此不需要对删除的span进行delete操作。

Central Cache结构

        Central Cache:中心缓存是所有线程所共享, Thread Cache 是 按需从 Central Cache 中获取的对象。Central Cache 合适的时机回收 Thread Cache 中的对象,避免一个线程占用了太多的内存,而其他线程的内存吃紧,达到内存分配在多个线程中更均衡的按需调度的目的 。

        Central Cache是存在竞争的,所以从这里取内存对象是需要加锁,首先这里用的是桶锁,其次只有 Thread Cache当 没有内存对象时才会找 Central Cache ,所以这里竞争不会很激烈 。

        Central Cache是所有线程共享的,所以只设计1个,并且当程序运行的时候我们就要创建出来,所以我们用单例模式的饿汉模式。

// 单例模式
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);

	void ReleaseListToSpans(void* start, size_t size);

private:
	SpanList _spanLists[NFREELIST];

private:
	CentralCache() {

	}

	CentralCache(const CentralCache&) = delete;

	static CentralCache _sInst;
};
申请内存
从中心缓存获取对象

        当Thread Cache 中没有内存时,就会批量向 Central Cache 申请一些内存对象,这里的批量获取对象的数量使用了类似网络TCP协议拥塞控制的慢开始算法;Central Cache也有一个哈希映射的SpanList , SpanList 中挂着 Span ,从 span中取出对象给Thread Cache,这个过程是需要加锁的,不过这里使用的是一个桶锁,尽可能提高效率。

        从Central Cache中的span取对象,那么一定是Thread Cache的桶中没有剩余的对象,因为我们是从span中获取的,那么一定是一段连续的内存,我们只需要首位地址就可以,而且需要将首位地址返回(设置为输出型参数) ,用来给Thread Cache头插挂接一段(PushRange)对象。

// 从central cache中获取内存对象
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;
	}
}
从中心缓存获取一定数量的对象

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

// 从中心缓存中获取一定数量的对象给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);  // 获取一个Span
	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;
}

        这里使用桶锁,防止多个线程同时访问一个桶,造成线程安全问题。

        并且从Central Cache中的span切分(在GetOne中切分)batchNum对象给Thread Cache,但是可能实际上span并没剩下那么多,只能将剩下的分配给Thread Cache,所以需要统计一个实际值actualNum,_useCount+=actualNum更新span中切分出去的对象,保证回收不会出错。

        返回实际分配到的对象数目,在Thread Cache中返回1个使用,剩余的actualNum头插挂接到Central Cache对应的桶上。

获取一个非空Span

        Central Cache映射的spanList 中所有 span 的都没有内存以后,则需要向 Page Cache 申请一个新的span对象,拿到 span 以后将 span 管理的内存按大小切好作为自由链表链接到一起。然后从 span中取对象给Thread Cache。

// 获取一个非空的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中没有满足条件大小的内存
	// 先把central cache的桶锁解掉,这样如果其他线程释放对象回来不会阻塞
	list._mtx.unlock();

	PageCache::GetInstance()->_pageMtx.lock();
	Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(size));
	span->_isUse = true;
	span->_objSize = 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;

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

	NextObj(tail) = nullptr;

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

	return span;
}

        如果Central Cache当前桶有剩余的span,直接返回该span,不需要去Page Cache申请span。

        如果没有剩余span,解开桶锁,进入PageCache中获取span,获取后记录使用情况和存储对象大小,并且Page Cache实际上我们也只设计了1个,所以他也需要加锁。

为什么要解开桶锁?

Central Cache是桶锁,PageCache是整个锁。在CentralCache::GetOneSpan() 中获取一个span,需要从Page获取Span时,先把桶锁解掉,如果此时线程1和2都执行GetOneSpan(),因为PageCache::NewSpan() 有整个锁,产生阻塞,也不会产生混乱。也就是说Central Cache在此时解不解锁在获取Span时作用一样,但是我可以线程1在这个桶拿Span,并且线程2在这个桶释放Span,为了提高效率,所以我们解开桶锁。

        从Page Cache中获取span后,我们span中只存储了页信息,但没有他的地址信息,那我们怎么获得地址去管理连接内存对象呢?

这里就要引入一个概念:页的起始地址=页号*页大小

                                        页的尾地址=起始地址+页的数量*页的大小

                                        页号=页的起始地址/页大小

        那么在相邻页之间地址,其地址大小小于后面一页的起始地址,除页大小必定也能得到该页的页号。这在回收中有着重要作用。

        从Page Cache中获取到Span后,我们通过上面的概念,可以计算出该Span的起始地址和尾地址,我们再根据对象大小进行切分,因为内存物理上其实是连续的,而我们这里要在抽象的把他形成链式结构,我们就需要通过尾插来保证地址的连续。切好后将该Span挂在Central Cache的桶。

        Central Cache的中挂的span 中_ useCount 记录分配了多少个对象出去,分配一个对象给Thread Cache,就 ++_useCount。

释放内存

        当Thread Cache 过长或者线程销毁,则会将内存释放回 Central Cache 中的,释放回来时 _
useCount-- 。当 useCount 减到 0 时则表示所有对象都回到了 span ,则将 span 释放回 Page Cache ,Page Cache 中会对前后相邻的空闲页进行合并。

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* span = PageCache::GetInstance()->MapObjectToSpan(start);
		NextObj(start) = span->_freeList;
		span->_freeList = start;
		span->_useCount--;

		// _useCount == 0说明span的切出去的所有小内存块都回来了
		// 这个span就可以再回收给PageCache,PageCache可以再尝试去做前后页的合并
		if (span->_useCount == 0) {
			_spanLists[index].Erase(span);
			span->_freeList = nullptr;
			span->_next = nullptr;
			span->_prev = nullptr;

			// 释放span给PageCache时,使用PageCache的锁就可以了
			// 这时把桶锁解开
			_spanLists[index]._mtx.unlock();

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

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

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

        头插回收一定数量对象到span,如果全部回收,即_useCount==0,则可以将该span拿给Page Cache进行页的合并。

        那么如何通过地址获取对应的span呢?我们就需要调用MapObjToSpan函数来获取,这将在下面介绍。

Page Cache

Page Cache类

        Page Cache我们在设计中也是只有一个, 所以设置成单例模式。并且在Page Cache中我们桶的映射规则与上面二级缓存不同,这里采用直接定址法,i号桶挂i页内存。

        桶的个数根据需求而定,我们申请内存最大是256Kb,页大小为8K,也就是说我们要想申请一个256Kb的对象就必须要(256/8=32)32页的span,那么我们可以多分配一些,设置桶个数为128,128页可以申请4个256Kb对象。实际上128页就是1Mb大小。

        页缓存中主要对页进行操作,所以我们有必要对页和span建立一个映射关系,方便我们查找管理,所以使用哈希表unordered_map<PAGE_ID,Span*>。

        对页缓存的访问需求实际上很少,所以我们使用一个整体锁来进行管理线程安全即可,避免频繁调用锁,消耗时间。

        在创建Span中,我们使用了最开始设计的定长内存池来申请和释放对象,与new和delete分离。

// 单例模式
class PageCache {
public:
	// 提供一个全局访问点来访问这个唯一的实例
	static PageCache* GetInstance() {
		return &_sInst;
	}

	Span* MapObjectToSpan(void* obj);

	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

       根据Central Cache申请内存部分引入的概念,我们可以得知:页的起始地址 * 页大小=页号,我们可以通过这个公式得到页号,然后在哈希表中查找到对应的span。

        这里我们使用RAII原则的unique_lock,构造时加锁,出作用域对象解锁,防止程序异常退出导致死锁,优化代码。

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; // 返回对应的span
	}
	else {
		assert(false);
		return nullptr;
	}
}
申请内存

        当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 。
        如果找到_spanList[128] 都没有合适的 span ,则向系统使用 mmap 、 brk 或者是 VirtualAlloc 等方式申请128 页 page span 挂在自由链表中,再重复 1 中的过程。

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

	// 若k大于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的映射,方便CentralCache 回收小块内存时,查找对应的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;  // 页号(前面都没有页,因此nSpan的页号赋值给kSpan)
			kSpan->_n = k;  // 页的数量

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

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

			// 存储nSpan的首尾页号跟nSpan映射,方便PageCache回收内存时进行合并查找
			_idSpanMap[nSpan->_pageId] = nSpan;
			_idSpanMap[nSpan->_pageId + nSpan->_n - 1] = nSpan;

			// 建立pageId和span的映射,方便CentralCache回收小块内存时,查找对应的span
			for (size_t 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);
	///
	// 疑惑:如何实现0 1 2 3...
	///
	bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
	bigSpan->_n = NPAGES - 1; // 页的数量

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

	return NewSpan(k);  // 递归
}

如果申请页大于128页,则需要向堆申请。
如果该桶还有span,则直接取出span给Central Cache,并哈希表保存页号和span的映射。
如果该桶没有,则从后面的桶中取span,并更新该span被切后的页号和页数再挂接到对应页号的桶上,建立页号和span的映射关系,方便后续回收。
如果后续桶也没有span,则向系统堆申请128页的span,挂接到128号桶,再递归调用切出要的页span。

释放内存

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

void PageCache::ReleaseSpanToPageCache(Span* span) {
	// 大于128页的直接还给堆
	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在使用,也不合并
		Span* nextSpan = ret->second;
		if (nextSpan->_isUse == true) {
			break;
		}

		// 合并超出128页的span不能管理,也不合并
		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;
}

如果归还页大于128页,则直接还给堆。
首先向相邻前页合并,再向相邻后页合并。
如果相邻页没有就不合并跳出,如果相邻页正在使用就不合并跳出,如果合并页超过128,无法管理不合并跳出。
走完前后页合并逻辑后,将页挂接到Page Cache的桶并建立映射关系。

为什么要使用_isUse而不使用_useCount==0来判断相邻页是否正在被使用呢?

因为可能在给CentralCache划分span的时候,_usecount还未++,此时还是0,恰好有可能其他线程在PageCache判断此时划分给CentralCache的为0拿来合并,这就造成了线程安全的问题。

解决方法:span增加一个bool值,判断是否被使用

申请释放联调

申请内存联调

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

释放内存联调

static void ConcurrentFree(void* ptr)
{
	Span* span = PageCache::GetInstance()->MapObjToSpan(ptr);//通过映射关系找到span
	size_t size = span->_objSize;
	assert(TLS_ThreadCache);
	TLS_ThreadCache->Deallocate(ptr, size);
}

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

大块内存申请问题

三级缓存的设计主要考虑的是小于256Kb的对象,那如果大于256Kb我们如何处理呢?

在Page Cache中我曾提到256Kb需要32页,但我们Page Cache设计的最大有128页。所以如果申请对象大于32页小于等于128页,我们可以直接向Page Cache申请内存;如果大于128页,我们就需要向系统堆空间申请内存。

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();
		void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
		return ptr;
	}
	else {
		// 通过TLS 每个线程无锁获取自己专属的ThreadCache对象
		if (pTLSThreadCache == nullptr) {
			//pTLSThreadCache = new ThreadCache;
			ObjectPool<ThreadCache> tcPool;
			pTLSThreadCache = tcPool.New();
			}

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

		return pTLSThreadCache->Allocate(size);
	}
}

大块内存释放问题

大于128页,直接向堆释放内存;小于等于128页则继续走Page Cache逻辑页合并。

static void ConcurrentFree(void* ptr) {
	Span* span = PageCache::GetInstance()->MapObjectToSpan(ptr);
	size_t size = span->_objSize;

	// 大于128页,直接向堆释放内存
	if (size > MAX_BYTES) {
		PageCache::GetInstance()->_pageMtx.lock();
		PageCache::GetInstance()->ReleaseSpanToPageCache(span);
		PageCache::GetInstance()->_pageMtx.unlock();
	}
	// 小于等于128页则继续走Page Cache逻辑页合并
	else {
		assert(pTLSThreadCache);
		pTLSThreadCache->Deallocate(ptr, size);
	}
}

性能对比及基数树优化

性能对比

        对比多线程下设计的高并发内存池和malloc的性能:分别对相同大小内存和不同大小内存进行申请和释放。

void BenchmarkMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
	std::vector<std::thread> vthread(nworks);
	size_t malloc_costtime = 0;
	size_t free_costtime = 0;

	for (size_t k = 0; k < nworks; ++k)
	{
		vthread[k] = std::thread([&, k]() {
			std::vector<void*> v;
			v.reserve(ntimes);

			for (size_t j = 0; j < rounds; ++j)
			{
				size_t begin1 = clock();
				for (size_t i = 0; i < ntimes; i++)
				{
					//v.push_back(malloc(16));
					v.push_back(malloc((16 + i) % 8192 + 1));
				}
				size_t end1 = clock();

				size_t begin2 = clock();
				for (size_t i = 0; i < ntimes; i++)
				{
					free(v[i]);
				}
				size_t end2 = clock();
				v.clear();

				malloc_costtime += (end1 - begin1);
				free_costtime += (end2 - begin2);
			}
			});
	}

	for (auto& t : vthread)
	{
		t.join();
	}

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

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

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


// 单轮次申请释放次数 线程数 轮次
void BenchmarkConcurrentMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
	std::vector<std::thread> vthread(nworks);
	size_t malloc_costtime = 0;
	size_t free_costtime = 0;

	for (size_t k = 0; k < nworks; ++k)
	{
		vthread[k] = std::thread([&]() {
			std::vector<void*> v;
			v.reserve(ntimes);

			for (size_t j = 0; j < rounds; ++j)
			{
				size_t begin1 = clock();
				for (size_t i = 0; i < ntimes; i++)
				{
					v.push_back(ConcurrentAlloc(16));
					//v.push_back(ConcurrentAlloc((16 + i) % 8192 + 1));
				}
				size_t end1 = clock();

				size_t begin2 = clock();
				for (size_t i = 0; i < ntimes; i++)
				{
					ConcurrentFree(v[i]);
				}
				size_t end2 = clock();
				v.clear();

				malloc_costtime += (end1 - begin1);
				free_costtime += (end2 - begin2);
			}
			});
	}

	for (auto& t : vthread)
	{
		t.join();
	}

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

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

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

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

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

	return 0;
}
  • ntimes:单轮申请、释放次数
  • nworks:线程数
  • rounds:轮次数
  • 线程内部使用lambda表达式(C++11新特性),用于定义匿名函数,以值传递捕获k,以引用传递捕获其他父作用域的变量。
固定大小内存的申请和释放
v.push_back(malloc(16));
v.push_back(ConcurrentAlloc(16));

  4个线程执行10轮操作,每轮申请释放10000次,总共申请释放了40万次,运行后可以看到,malloc的效率还是更高的。

   由于此时我们申请释放的都是固定大小的对象,每个线程申请释放时访问的都是各自thread cache的同一个桶,当thread cache的这个桶中没有对象或对象太多要归还时,也都会访问central cache的同一个桶。此时central cache中的桶锁就不起作用了,因为我们让central cache使用桶锁的目的就是为了,让多个thread cache可以同时访问central cache的不同桶,而此时每个thread cache访问的却都是central cache中的同一个桶。

不同大小内存的申请和释放
v.push_back(malloc((16 + i) % 8192 + 1));
v.push_back(ConcurrentAlloc((16 + i) % 8192 + 1));

  运行后可以看到,由于申请和释放内存的大小是不同的,此时central cache当中的桶锁就起作用了,ConcurrentAlloc的效率也有了较大增长,但相比malloc来说还是差一点点。

性能瓶颈分析

我们使用VS自带的性能探查器进行时间检测。

通过分析结果可以看到,Deallocate和MapObjectToSpan这两个函数就占用了一半多的时间。

而在Deallocate函数中,调用ListTooLong函数时消耗的时间是最多的。

继续往下看,在ListTooLong函数中,调用ReleaseListToSpans函数时消耗的时间是最多的。

再进一步看,在ReleaseListToSpans函数中,调用MapObjectToSpan函数时消耗的时间是最多的。

也就是说,最终消耗时间最多的实际就是MapObjectToSpan函数,我们这时再来看看为什么调用MapObjectToSpan函数会消耗这么多时间。通过观察我们最终发现,调用该函数时会消耗这么多时间就是因为锁的原因。

        因此,当前项目的瓶颈点就在锁竞争上面,需要解决调用MapObjectToSpan函数访问映射关系时的加锁问题。tcmalloc当中针对这一点使用了基数树进行优化,使得在读取这个映射关系时可以做到不加锁。

基数树优化

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

单层基数树

        单层基数树实际采用的就是直接定址法,每一个页号对应span的地址就存储数组中在以该页号为下标的位置。

  最坏的情况下我们需要建立所有页号与其span之间的映射关系,因此这个数组中元素个数应该与页号的数目相同,数组中每个位置存储的就是对应span的指针。

//单层基数树
template <int BITS>
class TCMalloc_PageMap1
{
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, size); //对申请到的内存进行清理
	}
	void* get(Number k) const
	{
		if ((k >> BITS) > 0) //k的范围不在[0, 2^BITS-1]
		{
			return NULL;
		}
		return array_[k]; //返回该页号对应的span
	}
	void set(Number k, void* v)
	{
		assert((k >> BITS) == 0); //k的范围必须在[0, 2^BITS-1]
		array_[k] = v; //建立映射
	}
private:
	void** array_; //存储映射关系的数组
	static const int LENGTH = 1 << BITS; //页的数目
};
二层基数树

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

  比如用前5个比特位在基数树的第一层进行映射,映射后得到对应的第二层,然后用剩下的比特位在基数树的第二层进行映射,映射后最终得到该页号对应的span指针。

        二层基数树实际上就是把BITS进行分层映射,在32位下,用前5比特位映射第一层,得到2^5个,后14位映射到第二层得到该页的span指针。总共占用大小2^5 * 2^14 * 4 =2^21=2M。和一层基数树开辟的大小是一样的,但是二层基数树最开始只需要开辟第一层,当需要某一页号进行映射再开辟第二层,而一层基数树一开始直接开辟全部。

//二层基数树
template <int BITS>
class TCMalloc_PageMap2
{
private:
	static const int ROOT_BITS = 5;                //第一层对应页号的前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; //第二层存储元素的个数
	//第一层数组中存储的元素类型
	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); //将第二层的空间全部开辟好
	}
};

  因此在二层基数树中有一个Ensure函数,当需要建立某一页号与其span之间的映射关系时,需要先调用该Ensure函数确保用于映射该页号的空间是开辟了的,如果没有开辟则会立即开辟。

  而在32位平台下,就算将二层基数树第二层的数组全部开辟出来也就消耗了2M的空间,内存消耗也不算太多,因此我们可以在构造二层基数树时就把第二层的数组全部开辟出来。

三层基数树

  上面一层基数树和二层基数树都适用于32位平台,而对于64位的平台就需要用三层基数树了。三层基数树与二层基数树类似,三层基数树实际上就是把存储页号的若干比特位分为三次进行映射。

  此时只有当要建立某一页号的映射关系时,再开辟对应的数组空间,而没有建立映射的页号就可以不用开辟其对应的数组空间,此时就能在一定程度上节省内存空间。

//三层基数树
template <int BITS>
class TCMalloc_PageMap3
{
private:
	static const int INTERIOR_BITS = (BITS + 2) / 3;       //第一、二层对应页号的比特位个数
	static const int INTERIOR_LENGTH = 1 << INTERIOR_BITS; //第一、二层存储元素的个数
	static const int LEAF_BITS = BITS - 2 * INTERIOR_BITS; //第三层对应页号的比特位个数
	static const int LEAF_LENGTH = 1 << LEAF_BITS;         //第三层存储元素的个数
	struct Node
	{
		Node* ptrs[INTERIOR_LENGTH];
	};
	struct Leaf
	{
		void* values[LEAF_LENGTH];
	};
	Node* NewNode()
	{
		static ObjectPool<Node> nodePool;
		Node* result = nodePool.New();
		if (result != NULL)
		{
			memset(result, 0, sizeof(*result));
		}
		return result;
	}
	Node* root_;
public:
	typedef uintptr_t Number;
	explicit TCMalloc_PageMap3()
	{
		root_ = NewNode();
	}
	void* get(Number k) const
	{
		const Number i1 = k >> (LEAF_BITS + INTERIOR_BITS);         //第一层对应的下标
		const Number i2 = (k >> LEAF_BITS) & (INTERIOR_LENGTH - 1); //第二层对应的下标
		const Number i3 = k & (LEAF_LENGTH - 1);                    //第三层对应的下标
		//页号超出范围,或映射该页号的空间未开辟
		if ((k >> BITS) > 0 || root_->ptrs[i1] == NULL || root_->ptrs[i1]->ptrs[i2] == NULL)
		{
			return NULL;
		}
		return reinterpret_cast<Leaf*>(root_->ptrs[i1]->ptrs[i2])->values[i3]; //返回该页号对应span的指针
	}
	void set(Number k, void* v)
	{
		assert(k >> BITS == 0);
		const Number i1 = k >> (LEAF_BITS + INTERIOR_BITS);         //第一层对应的下标
		const Number i2 = (k >> LEAF_BITS) & (INTERIOR_LENGTH - 1); //第二层对应的下标
		const Number i3 = k & (LEAF_LENGTH - 1);                    //第三层对应的下标
		Ensure(k, 1); //确保映射第k页页号的空间是开辟好了的
		reinterpret_cast<Leaf*>(root_->ptrs[i1]->ptrs[i2])->values[i3] = 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 + INTERIOR_BITS);         //第一层对应的下标
			const Number i2 = (key >> LEAF_BITS) & (INTERIOR_LENGTH - 1); //第二层对应的下标
			if (i1 >= INTERIOR_LENGTH || i2 >= INTERIOR_LENGTH) //下标值超出范围
				return false;
			if (root_->ptrs[i1] == NULL) //第一层i1下标指向的空间未开辟
			{
				//开辟对应空间
				Node* n = NewNode();
				if (n == NULL) return false;
				root_->ptrs[i1] = n;
			}
			if (root_->ptrs[i1]->ptrs[i2] == NULL) //第二层i2下标指向的空间未开辟
			{
				//开辟对应空间
				static ObjectPool<Leaf> leafPool;
				Leaf* leaf = leafPool.New();
				if (leaf == NULL) return false;
				memset(leaf, 0, sizeof(*leaf));
				root_->ptrs[i1]->ptrs[i2] = reinterpret_cast<Node*>(leaf);
			}
			key = ((key >> LEAF_BITS) + 1) << LEAF_BITS; //继续后续检查
		}
		return true;
	}
	void PreallocateMoreMemory()
	{}
};
代码更改

        现在我们用基数树对代码进行优化,此时将PageCache类当中的unorder_map用基数树进行替换即可,由于当前是32位平台,因此这里随便用几层基数树都可以。

//单例模式
class PageCache
{
public:
	//...
private:
	//std::unordered_map<PAGE_ID, Span*> _idSpanMap;
	TCMalloc_PageMap1<32 - PAGE_SHIFT> _idSpanMap;
};

  此时当我们需要建立页号与span的映射时,就调用基数树当中的set函数。

_idSpanMap.set(span->_pageId, span);

  而当我们需要读取某一页号对应的span时,就调用基数树当中的get函数。 

Span* ret = (Span*)_idSpanMap.get(id);

  并且现在PageCache类向外提供的,用于读取映射关系的MapObjectToSpan函数内部就不需要加锁了。

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

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

  当某个线程在读取映射关系时,可能另外一个线程正在建立其他页号的映射关系,而此时无论我们用的是C++当中的map还是unordered_map,在读取映射关系时都是需要加锁的。

  因为C++中map的底层数据结构是红黑树,unordered_map的底层数据结构是哈希表,而无论是红黑树还是哈希表,当我们在插入数据时其底层的结构都有可能会发生变化。比如红黑树在插入数据时可能会引起树的旋转,而哈希表在插入数据时可能会引起哈希表扩容。此时要避免出现数据不一致的问题,就不能让插入操作和读取操作同时进行,因此我们在读取映射关系的时候是需要加锁的。

  而对于基数树来说就不一样了,基数树的空间一旦开辟好了就不会发生变化,因此无论什么时候去读取某个页的映射,都是对应在一个固定的位置进行读取的。并且我们不会同时对同一个页进行读取映射和建立映射的操作,因为我们只有在释放对象时才需要读取映射,而建立映射的操作都是在page cache进行的。也就是说,读取映射时读取的都是对应span的_useCount不等于0的页,而建立映射时建立的都是对应span的_useCount等于0的页,所以说我们不会同时对同一个页进行读取映射和建立映射的操作。

再次对比malloc进行测试 

申请固定内存大小

申请不同内存大小

优化结果:多线程场景下性能比malloc好。

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值