tcmalloc(google开源项目核心部分模拟实现)

TcMalloc项目实现--高并发内存池(google开源项目核心部分模拟实现)

  • 一.项目介绍
  • 二.什么是内存池
      • 2.1 池化技术
      • 2.2 内存池
      • 2.3 内存池
  • 三.定长内存池
  • 四.整体框架设计
      • ThreadCache
      • Central Cache
      • Page Cache
  • 五.Thread Cache整体设计
      • 5.1thread cache结构设计
      • 5.2thread cache的内存申请和释放
  • 六.Central Cache
      • 6.1central cache的结构
      • 6.2span结构
  • 七.Page Cache整体设计
      • 7.1PageCache实现
          • 1.构建一个PageCache.h与PageCache.cpp
          • 2.NewSpan函数
          • 3.ReleaseSpanToPageCache()
  • 八.回收内存
      • 8.1 threadcache 回收内存
      • 8.2 centralcache内存回收
          • 1.ReleaseListToSpans()
      • 8.3总:
  • 九.大于256KB内存申请问题
      • 9.1申请内存
      • 9.2释放内存时
  • 十.项目总结
      • 与malloc比较

一.项目介绍

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

二.什么是内存池

2.1 池化技术

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

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

2.2 内存池

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

2.3 内存池

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

tcmalloc(google开源项目核心部分模拟实现)_第1张图片
还需要补充说明一点:
内存碎片分为内碎片和外碎片
外碎片是一些连续空闲的连续内存区太小,以至于虽然合计的内存空间足够,但是因为其不连续,不能满足一些内存分配申请需求(就如上图,虽然有384byte的空间,但是却没有大于256byte的连续空间,以至于我们申请超过256byte的连续空间则申请不出来)
内碎片我们后面具体遇到再讲解

三.定长内存池

下面我们先来设计一个定长内存池,设计该定长内存池主要有以下两个作用:

1.熟悉一下内存池是如何实现的
2.其会作为我们后面高并发内存池的一个基础组件

tcmalloc(google开源项目核心部分模拟实现)_第2张图片
由此图可以发现,在不同的场景下,我们需要使用不同的定长内存池,
这正是因为定长内存池就如它的名字一样,只能分配定长的内存空间,
对于不同的内存大小需求,我们就需要定义分配不同内存大小的定长内存池

tcmalloc(google开源项目核心部分模拟实现)_第3张图片
这张图则展示了定长内存池给程序分配内存的过程

1.当程序第一次申请空间时,先申请一大块内存空间,用_memory记录其初始位置
2.当程序需要使用size大小的内存时,将大块内存切分成size的小块内存(实际就是将_memory给程序)。然后_memory的指向向后移size大小
3.当程序后面申请空间时,如果_freeList(自由链表)上挂的有内存块,则从自由链表上将内存块分配给程序。
4.当程序释放内存时,不是将内存释放给系统,而是将内存块归还给内存池,内存池将释放的空间挂入自由链表_freeList,可供下次申请使用。

:_freeList不是构建了一个链表结构,用_next去指向下一个内存块。
而是用前一个空间的前4个字节(32位)或者前8个字节(64位)记录下一个内存块的起始地址进行抽象链接的

具体代码实现如下:

#pragma once

//直接去堆上按页申请空间
inline static void* SystemAlloc(size_t kpage) {
#ifdef _WIN32
	void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else
#endif
	if (ptr == nullptr) {
		throw std::bad_alloc();
	}
	return ptr;
}
//定长内存池
//template
//class ObjectPoll{
//};


template<class T>
class ObjectPool {
private:
	//C++11特性,用默认构造函数即可将其初始化
	char* _memory = nullptr; //指向一大块内存空间的指针
	void* _freeList = nullptr; //自由指针,指向还回来空间组成的链表
	size_t _remainBytes = 0; //_memory中还剩余的字节数
public:
	T* New(){
		T* obj = nullptr;
		//当_freeList对象不为空时,去重新利用该部分空间
		if (_freeList) {
			//去前面指向下一个节点的空间
			void* next = *(void**)_freeList;
			obj = (T*)_freeList;
			_freeList = next;
		}
		else {
			//剩余内存不够时,重新去申请一个大空间
			if (_remainBytes < sizeof(T)) {
				_remainBytes = 128 * 1024;
				_memory = (char*)SystemAlloc(_remainBytes >> 13);
				//_memory = (char*)malloc(_remainBytes);
				if (_memory == nullptr) {
					throw std::bad_alloc();
				}
			}
			obj = (T*)_memory;
			//如果T对象的大小小于指针大小,那么无法在_freeList中存储下一个节点的地址
			//或者存储nullptr
			//所以在T对象的大小小于指针大小时,让其大小为指针大小
			size_t objsize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
			_memory += objsize;
			_remainBytes -= objsize;
		}
		//定位new,显示调用构造函数初始化对象
		new(obj)T;

		return obj;
	}

	void Delete(T* obj) {
		//将释放的空间挂接在自由链表下
		*(void**)obj = _freeList;
		_freeList = obj;

		//显示的调用析构函数清理对象
		obj->~T();
	}
};

对于定长内存池的实现,我们得掌握以下两个知识点:
1.因为我们实现了一个自己的内存池,去帮助程序申请空间,释放空间,
以此用于取代malloc,所以我们内存池申请内存不再调用malloc,而是
直接用windows下的VirtualAlloc直接向堆申请内存空间。
2.实现自由链表的抽象链接
具体实现为:将内存块指针obj强转为(void**)类型,再进行解引用,此时其类型还应该为void*类型,在32位下位四字节,在64位下为八字节,我们再给其赋值下一个内存块的起始地址。
tcmalloc(google开源项目核心部分模拟实现)_第4张图片

四.整体框架设计

现在的很多开发环境都是多核多线程,在申请内存的场景下,必然存在激烈的锁竞争问题,malloc本身其实已经很优秀了,但我们的项目tcmalloc之所以称为高并发,就是因为其在多线程高并发的场景下更胜一筹,所以我们实现时就要考虑以下的问题:

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

为什么要提及这些问题呢,就是因为tcmalloc解决了这些问题,可以比malloc更快更稳定的在高并发场景下运行。

下面我们来看看其的整体框架吧
tcmalloc(google开源项目核心部分模拟实现)_第5张图片
该内存池主要由三部分组成,分别为thread cache,central cache,page cache。
我先分别阐述一下三个部分:

ThreadCache

线程缓存是每个线程独有的(运用了线程局部缓存TLS技术),用于小于256KB的内存的分配(注意:不是thread cache只有256KB的内存空间哦,而是小于等于256KB的内存申请都是去找thread cache申请),**线程从这里申请内存不需要加锁,因为每个线程独享一个cache,这也就是这个并发线程池高效的地方。

Central Cache

中心缓存是所有线程共享,thread cache按需从thread cache获取的对象,而Central Cache在合适的时机回收thread cache中的对象,避免一个线程占用了太多的内存,而其他线程的内存吃紧。让内存分配在多个线程中更均衡的按需调度central cache中是存在锁的竞争的,因为每个thread cache都会去找同一个central cache要内存,在central cache用的是桶锁(后面具体讲central cache的结构时会进行说明因为只有在thread cache中没有内存对象时才会找central cache要内存,所以这里的竞争不会很激烈

Page Cache

页缓存是在central cache缓存上面的一层缓存,存储的内存是以页为单位存储及分配的,central cache没有内存对象时,会从page cache分配出一定数量的page,并切割成定长大小的小块内存,分配给page cache。(怎么切割后面具体讲解page cache时会讲到),当一个span 的几个跨度页的对象都回收以后,page cache会回收central cache满足条件的span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。

五.Thread Cache整体设计

5.1thread cache结构设计

在定长内存池时,我们是开辟了一大块内存空间,然后用自由链表接收释放回来的内存块,在这里,如果我们对每一个空间大小都创建一个自由链表,则需要256*1024个自由链表(因为小于等于256KB的内存都向thread cache申请)。这样设计的话需要的自由链表太多了。

基于上面的问题,我们可以设计出一个thread cache哈希桶结构,每个桶是一个按桶位置映射大小的内存块对象的自由链表。每个线程都会有一个thread cache对象,这样每个线程在这里获取对象和释放对象是无锁的

tcmalloc(google开源项目核心部分模拟实现)_第6张图片
tcmalloc(google开源项目核心部分模拟实现)_第7张图片

由上图的映射规则可知,我们的前128byte是按8byte对齐的,什么意思呢?
就是其中有以8byte大小的自由链表,有以16byte大小的自由链表。也就是说其前128byte是按8的倍数定义自由链表,一共有16个自由链表

在129到1024byte,是按16byte对齐的,什么意思呢?
跟前128byte类似,也就是按16的倍速+128去定义自由链表,一共有56个自由链表

后面的分配跟前面规则类似。
最后将这些自由链表封装成一个哈希桶结构就完成了对thread cache基础结构的构建

而哈希桶结构我们可用一个自由链表的数组来实现

这样我们不仅可以用208个自由链表完成对thread cache结构的建立,整体还控制在最多只有10%的内存碎片。

注:这个时候的内存碎片是内碎片,那内碎片是什么意思呢?
比如我们需要申请5字节的内存空间,但我们哈希桶中最小空间的自由链表为8byte,此时我们还要3字节的空间都用不了了,这些没用的空间相当于碎片化了,叫内碎片

5.2thread cache的内存申请和释放

既然我们thread cache的基础结构构建好了,那我们怎么去申请和释放内存空间呢?

1.申请空间
我们在项目构建时,会建立一个Common.h类,在其中我们会定义一个名为SizeClass的类,在类中会定义两个函数RoundupIndex分别去计算对齐数(申请多大的空间)和去哪个桶中申请。

Roundup函数如下:

static inline size_t _RoundUp(size_t bytes, size_t alignNum) {
		return ((bytes + alignNum - 1) & ~(alignNum - 1));
	}

	//计算其对齐数,总共要给他多少空间
	static size_t RoundUp(size_t size) {
		assert(size <= MAX_BYTES);

		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 {
			//1 << PAGE_SHIFT为对齐数
			return _RoundUp(size, 1 << PAGE_SHIFT);
		}
	}

Index函数如下:

static inline size_t _Index(size_t bytes, size_t align_shift) {
		return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1;
	}

	static size_t Index(size_t size) {
		assert(size <= MAX_BYTES);
		
		static int group_array[4] = { 16,56,56,56 };
		if (size <= 128) {
			//8为2^3,传入次方数
			return _Index(size, 3);
		}
		else if (size <= 1024) {
			//16为2^4
			//得加上前128byte所占的16个桶
			return _Index(size-128, 4) + group_array[0];
		}
		else if (size <= 8 * 1024) {
			//128为2^7
			//类似的加上之前
			return _Index(size - 1024, 7) + group_array[0] + group_array[1];
		}
		else if (size <= 64 * 1024) {
			//1024为2^10
			return _Index(size - 8 * 1024, 10) + group_array[0] + group_array[1]+ group_array[2];
		}
		else if (size <= 256 * 1024) {
			//8*1024为2^13
			return _Index(size - 64 * 1024, 13) + group_array[0] + group_array[1] + group_array[2] + group_array[3];
		}
		else {
			assert(false);
			
		}
		return -1;
	}

两个函数的构建也是按照上面图片的映射规则构建的,对上面函数不理解的,可以看看注释和对上面映射规则的讲解。

有些人还会问为什么上面的计算规则要用内联函数,因为我们申请和释放内存时会频繁调用这两个函数。

2.线程局部存储TLS
在创建线程时,怎么让其与thread cache对应联系起来呢?并且怎么创建其才是每个线程独有的呢?

为了让thread cache 为每个线程独有,我们得用到TLS(线程局部存储)
线程局部存储(TLS),是一种变量的存储方法,这个变量在它所在的线程内是全局可访问的,但是不能被其他的线程访问到,这样就保证了数据的线程独立性
这样的好处就是,每个线程独一份thread cache,小于256KB的内存申请不用再加锁申请,大大提高了效率

那怎么定义TLS呢
定义TLS特别简单,只用在ThreadCache结构中加一段这样的代码

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

这样只要我们的线程创建好,就会有这样的指针,对线程内是全局的,其他线程无法访问。(第一次访问时,做个判断,先创建一个ThreadCache即可)

因为申请内存时,不可能让线程自己来获取自己的pTLSThreadCache对象,所以我们在Thread Cache结构之上在封装一个ConcurrentAlloc来管理内存分配即可。这样后面大于256KB的内存申请,也可以直接经过ConcurrentAlloc的结构去申请了。

3.申请和释放
申请和释放就没多少说的,大家看一下代码应该就能理解。
部分解释放在了注释里
唯一需要说明的点,就是Deallocate释放内存连同上层封装的ConcurrentAlloc释放内存还需要传入内存大小,这个后面会进行优化

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 {
	    //该函数作用是去向Central Cache申请内存
		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);

	//大于一次批量的,即开始向central cache归还内存
	if (_freeLists[index].Size() >= _freeLists[index].MaxSize()) {
		ListTooLong(_freeLists[index], size);
	}
}

4.与central cache联系
当我们程序申请内存空间时,先去计算其要申请哪个桶,如果该桶的自由链表下挂的有空间,就取下来分配给程序
如果没有,则去向Central cache要空间

六.Central Cache

6.1central cache的结构

tcmalloc(google开源项目核心部分模拟实现)_第8张图片
central cache也是一个哈希桶结构,他的哈希桶的映射关系跟thread cache是一样的。不同的是他的哈希桶位置挂的是SpanList链表结构,不过每个映射桶下面的span中的大内存块被按映射切成了一个个小内存块对象挂在span的自由链表中。

1.thread cache是每个线程独享一个,而central cache是所有线程没有内存都会找它,所以其需要给每个桶加锁
正是因为是桶锁,所以两个线程去申请不同桶的空间时,不会存在竞争

怎样实现所有线程都去找同一个thread cache呢?
将thread cache类定义为单例模式即可

2.thread cache去要一个X空间,central cache不一定给它一个,而是给它几个,因为线程再需要X空间时,直接去找thread cache要了,而不是再来找central cache(这里具体给多少有个类似于网络tcp协议拥塞控制的慢开始算法,后面会讲解)

3.每个span的页数,在不同大小的内存桶下不一样,桶中的内存块大小越大,可能span页数越多越大

6.2span结构

看了上面的,突然冒出了span和SpanList那又是什么东西啊

1.span --管理多个连续页大块内存的跨度结构

我们先来看一下span的代码

//定义在Common.h,因为其不仅要给central cache用,还要给page cache用
class Span {
public:
	PAGE_ID _pageId = 0; //页号
	size_t _n = 0; //页的数量
	Span* _prev = nullptr; //指向前一个节点
	Span* _next = nullptr; //指向后一个节点
	size_t _useCount = 0; //计数,记录分配了多少个对象出去,
	void* _freeList = nullptr; //自由指针
	bool _isUse = false;
	size_t _objSize = 0; //去记录该span一个小对象的大小,方便释放对象时可以不用传入对象大小 
};

//双向带头循环链表
class SpanList {
private:
	Span* _head;     //头节点
//设置为公有,不然不好设置锁去拿取资源
public:
	std::mutex _mtx; //桶锁
public:
	SpanList() {
		_head = new Span;
		_head->_prev = _head;
		_head->_next = _head;
	}

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

	Span* End() {
		return _head;
	}

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

	void PushFront(Span* newspan) {
		Insert(_head->_next, newspan);
	}

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

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

		Span* pre = pos->_prev;
		pre->_next = newSpan;
		newSpan->_prev = pre;
		newSpan->_next = pos;
		pos->_prev = newSpan;
	}

	void Erase(Span* pos) {
		assert(pos);
		assert(pos != _head);

		//条件断点+查看栈帧
		/*if (pos != _head) {
			int x = 0;
		}*/
		Span* pre = pos->_prev;
		pre->_next = pos->_next;
		pos->_next->_prev = pre;
	}
};

从上面的代码结构我们发现,span是一个双向链表结构,其中还有一些东西我们一一解释

1.PAGE_ID _pageId; 页号,其算是代表该页在内存中的位置,

2.size_t n; 表示页的数量,就是该span有几页的内存空间,相当于表示该span的内存大小。
对于后面页的合并,切分也有作用

3.size_t _usecount=0; 计数,分配一个内存对象出去则++,
还回来一个内存对象则--,
当span减到0表示所有对象都回到了span,
则将span释放回page cache,page cache会对前后相邻页进行合并

4._freeList表示span这个大内存空间切分成一个个小的内存块对象挂在该自由链表上

5._isUse 表示该span是否使用,用来区分,刚从page cache 获取的span和因为归还
空间_usecount减为0的span,此时该span因为_usecount减为0,将要释放给page cache
而刚才page cache获取的span对象其_usecount也为0
所以_isUse是为了区分他们

对page_id的理解
page_id相当于就是一个内存的起始地址除以页数
也就是说,有了内存的起始地址,那么用一个循环对地址进行对齐数大小的整数加减,就可以切割内存了;
例如:假设一页的大小为1KB,那么page_id和地址与数量之间的关系就如下图:
tcmalloc(google开源项目核心部分模拟实现)_第9张图片

假设我的span只有中间的阴影部分的页内存,那么我的span结构中重要的数据为:

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

所以其内存大小就为页的数量一页的大小,该内存块的起始地址就为页号一页的大小

对于SpanList的一些解释

1.在central cache的哈希桶中,一个桶中可能有多个span,所以在我们去找
内存对象时,得再spanList中去遍历,
一个span用完了才能用下一个span
2.存在多个span都有一部分被申请出去的情况,因为thread cache可能还回
来再用完的span中,因为该span之前用完了,所以我们用了下一个span
3.为什么SpanList要设计为双向链表结构,因为如果有一个span全部回来了,
我们要将该span归还给page cache,如果单向链表不方便找到那个span,
也不方便进行删除。
所以我们运用双向链表,即简单又高效,插入删除为O(1);

2.页号
现在我们继续对页号进行细节处理

tcmalloc(google开源项目核心部分模拟实现)_第10张图片
假设一页为2的13次方也就是8KB
那么在32位程序下,2^32 / 2 ^ 13=2 ^ 19个页,也就是大概五十万个页
在64位程序下,2 ^ 64 / 2 ^ 13 = 2 ^ 51个页,如果我们继续用size_t来记录页号,那么在32位下,size_t显然够用,但在64位下,size_t显然就不够用了
所以我们用条件编译那处理此情况

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

这里有个小细节,就是我们得先对_WIN64进行判断,再对_WIN32进行判断
因为在_WIN32配置下,_WIN32有定义,_WIN64没有定义
在x64配置下,_WIN32和_WIN64都有定义

3.慢开始反馈调节
thread cache来要空间时,具体给多少?
我们采用慢开始反馈调节算法
具体实现如下:

//thread cache 去 central cache中去拿对象的规则
	static size_t NumMoveSize(size_t size) {
		assert(size > 0);
		//[2,512]一次批量移动多少个对象的上下限
		//小对象一次批量上限高
		//大对象一次批量上限低

		//batch ...批量
		size_t batchNum = MAX_BYTES / size;
		//限制下限
		if (batchNum < 2) {
			batchNum = 2;
		}

		//限制上限
		if (batchNum > 512) {
			batchNum = 512;
		}

		return batchNum;
	}

在该函数实现中,其定义了一个上限和下限,
上限主要是限制小空间,在去拿空间时,不要超过512
下限主要是要求大空间至少都得拿两个走

那有人问了,这里哪里体现了慢开始反馈调节,因为这个代码还有一部分在这:
该代码是thread cache去向central cache要空间的函数实现

void* ThreadCache::FetchFromCentralCache(size_t index, size_t size) {
	//慢开始反馈调节算法
	//最开始不会向central cache一次批量太多,因为太多用不完
	//如果你不断有这个size大小内存需求,那么batchNum就不会增长,直到上限
	//size越大,一次向central cache要的batchNum就越小
	//size越小,一次central cache要的batchNum就越大
	size_t batchNum = min(_freeLists[index].MaxSize(),SizeClass::NumMoveSize(size));
	if (batchNum == _freeLists[index].MaxSize()) {
		//如果觉得1增长的太慢,用2,3也可以
		_freeLists[index].MaxSize() += 1;
	}
	void* start = nullptr;
	void* end = nullptr;
	//实际获取的数量
	int actualNum = CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, size);
	assert(actualNum > 0);
	if (actualNum == 1) {
		assert(start == end);
		return start;
	}
	else {
		//start要返回,给线程运用
		//start之后的挂在thread cache中
		_freeLists[index].PushRange(NextObj(start), end,actualNum-1);
		return start;
	}
}

下面代码是_freeList中的一部分代码,这三部分协调作用,共同完成了慢开始反馈调节

class FreeList {
private:
	void* _freeList = nullptr;
	//限制向central cache要空间的数量
	size_t _maxSize = 1;
	size_t _size = 0;
public:
	size_t& MaxSize() {
		return _maxSize;
	}
};

总的来说,就是先开始取的空间数量为1,然后为2,慢慢的,一直增长,直到与SizeClass中的NumMoveSize(size)一样大,将以其为上限,取的数量不会再增大
所以:

1.最开始不会向central cache一次批量要太多,因为要太多了用不完
2.如果你不断有这个size大小的内存需求,那么batchNum就不会断增长,直到上限
3.size越小,一次向central cache要的batchNum就越大
4.size越大,一次向central cache要的batchNum就越小

4.central cache中的一些函数接口简单介绍
就简单说明一下一些接口函数,方便后续总表理解整个过程

1.FetchRangeObj() 从中心缓存获取一定数量的对象给thread cache
2.GetOneSpan() 先判断central cache中的桶中有没有,有的话就直接取出,没有的话调用page cache中的NewSpan()获取一个span
3.RealeaseListToSpans() 将thread cache中的一部分空间对象归还给central cache中的span,如果central cache中的span中的_usecount==0,将该span归还给page cache

七.Page Cache整体设计

page cache结构
tcmalloc(google开源项目核心部分模拟实现)_第11张图片
可看出page cache虽然也是哈希桶结构,但明显与central cache和thread cache明显不一样了,因为他是以每页为为单位划分一个桶,一共有128个桶
在每个桶下面还是挂的span,每个span的内存大小与桶划分的大小对于。其也是一个SpanList结构。只不过每个span下面没有划分了小空间的自由链表。

申请内存
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 _spanLists的哈希桶,但他们有本质区别,central cache中哈希桶,是按跟thread cache一样的大小对齐关系映射的,他的_spanLists中挂的span中的内存都按映射关系切好链接成小块内存的自由链表,而page cache中的_spanLists则是按下标桶号映射的,也就是说第i号桶中挂的span都是i页内存。
释放内存
如果central cache释放回一个span,则依次寻找span的前后page id的没有在使用的空闲span,看是否可以合并,如果合并继续向前寻找,这样就可以将切小的内存合并收缩成大的span,减少内存碎片。

为什么这里要用128页为最大页呢
没有为什么,根据自己需求去选择,如果用256页为最大页也可以

7.1PageCache实现

1.构建一个PageCache.h与PageCache.cpp

PageCache.h中构建PageCache结构并且定义会使用的接口函数
PageCache.cpp中实现那些接口函数

//单例模式
class PageCache {
public:
	static PageCache* GetInstance() {
		return &_sInstance;
	}

	Span* NewSpan(size_t k);

	//返回一个该PAGE_ID对应的Span*
	Span* MapObjectToSpan(void* obj);

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

public:

	//全局锁,避免在一个位置没有找到span,继续向后找span时,用桶锁会频繁加锁解锁
	std::mutex _pageMtx;

private:
	PageCache(){}
	PageCache(const PageCache&) = delete;
private:
	//与CentralCache中的_spanLists不一样,
	//CentralCache中的_spanLists是按照对象大小划分桶,每个桶下链接一个span链表,
	//          每个span有划分为对象空间的自由链表
	//PageCache中的_spanLists是按照页数划分为桶,代表span的大小,同时span下没有
	//          划分的自由链表
	SpanList _spanLists[NPAGES];
	ObjectPool<Span> _spanPool;

	//声明
	static PageCache _sInstance;

	//PAGE_ID与Span*的映射关系
	std::unordered_map<PAGE_ID, Span*> _idSpanMap;
	
};

PageCache类的设计成单例模式,因为central cache去访问page cache也应该访问的是同一个page caceh。

page cache的锁还是桶锁吗
page cache中对哈希桶的锁不能再是桶锁了,得是全局锁**std::mutex _pageMtx;
所以在去PageCache要空间时,可以将CentralCache的桶锁解掉,用全局锁加锁,这样其他去访问CentralCache的即可访问,再拿到空间返回时,我们再重新申请锁即可

因为如果申请4页的span,而4页的桶下已经没有剩余的span了。那么我们会继续向更大页的桶中去寻找span,
所以如果是桶锁,我们去向后找,会不断加锁解锁,消耗很大,效率降低

所以对于PageCache它会在一个范围进行索引访问,并且多个线程访问同一个桶的概率大大提升,
使用桶锁就会导致频繁加锁解锁,导致效率降低,相反,使用整体锁,每个线程就只需加解一次

对于CentralCache,不同线程访问的大概率不是一个位置的桶,所以这个时候加桶锁就非常合适
如果加整体锁,反而会造成大量线程等待,降低效率
2.NewSpan函数

这里就不贴源代码了,讲一下实现,

过程

获取K页的span,但在PageCache中的_spanLists中没有,那么去找大一点span,去分成小的span
如果到128page还没有可用的span,那么就会去找堆要。

注意

1.分成的小页span在后面CentraCache释放空间时会进行合并为大页span,以缓解内存碎片问题
2.找堆要时,堆也不会给小的span,而是给一块128page的空间,去切成各种空间的span,后面把这些切小的span可以合并。

如:需要2页的span,就把该128页的span分为2页的span和126页的span,2页的span返回给CentralCache用,126页的span挂在PageCache的第126号桶下

切分过程
因为我们PageCachespanCentralCache时,还需要将span切分成小对象,挂在自由链表下面,因为CentralCache的哈希桶下的span下面存在切分好的小对象

计算起始地址

之前讲过页号的作用,起始地址=页号*页的大小,页的大小我们定义为2^13
所以 起始地址=页号 << 13

所占字节数=页的数量*一页的大小
所以 所占字节数=页的数量  << 13

知道span的起始地址了,知道所占字节数了
就可以将其拆分为小对象了

大块内存的链接
跟之前的自由链表链接方法一样,用前一个内存块的前四个字节或前八个字节指向下一个内存块即可

3.ReleaseSpanToPageCache()

该函数将CentralCache的span回收回PageCache。
在回收CentralCache的内存时,注意要看其前后相邻也是否空闲,如果空闲与前后相邻页进行合并,以此减少内存碎片

合并过程
由图可知,假如此时回收回来的span页号为100,页的数量为x。
我们合并时,得找前后相邻页。
先找前面相邻页99是否空闲,通过前面在span中新增的_isUse去判断其是否被使用,如果没有被使用,则合并,如果被使用了,即结束向前合并。同时如果在合并时合并后的span总页数会大于128或者等于128则结束合并
再向后寻找100+x页是否空闲,如果空闲继续往后寻找。
tcmalloc(google开源项目核心部分模拟实现)_第12张图片
所以那我们怎么实现去查找前后相邻页是否空闲呢?
可构建一个unordered _idSpanMap结构,
然后在PageCache给CentralCache内存时,将那些span,全部加 _idSpanMap,并且将PageCache空闲的Span全部加入 _idSpanMap结构,这样可以快速判断回收回来的页前后是否空闲
我们加入_idSpanMap建立映射关系时,只用加这个span的前后页号与其建立关系即可,因为我们合并时,也只是找一个Span的前后页号(前页号为span中的页号,后页号为span中的页号+页的数量-1)
合并后需要把其合并前页的映射从_idSpanMap中删去,然后建立合并后span与页号的映射

八.回收内存

前面讲ThreadCacheCentralCache时并没有讲其怎么回收内存,而讲PageCache时,但讲解了回收内存与合并前后页减少内存碎片

8.1 threadcache 回收内存

threadcache回收内存非常简单

1.某个线程这块内存不用了,调Dealloc将内存回收释放给threadcache,threadcache算好该内存
  映射的哪个桶,将该内存插入到对应的桶里去
2.如果对应的桶太长了,会向centralcache去归还内存(不一定要全部归还)

代码实现

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

   //找到对应空间的桶位置,将其放入
   size_t index = SizeClass::Index(size);
   _freeLists[index].Push(ptr);

   //大于一次批量的,即开始向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;
   //取出需要释放回central cache的内存对象
   list.PopRange(start, end, list.MaxSize());

   //调用CentralCache的接口去将该对象释放回Span
   CentralCache::GetInstance()->RealeaseListToSpans(start, size);
}

注:ListTooLong()函数将内存对象释放回centralcache

8.2 centralcache内存回收

1.ReleaseListToSpans()

该函数将从threadcache回收的内存挂回span

我们怎么知道该小对象属于哪个span
tcmalloc(google开源项目核心部分模拟实现)_第13张图片

页号 = 内存地址数 / 2^13
所以在x到x+2^13这个内存范围内的值除以2^13次方还是等于2000

找到页号后,根据_idSpanMap结构即可找到与页号对应的Span

但是我们span只有前后页在_idSpanMap有映射关系。所以我们在切分小对象时,传入Span中每一页的页号与Span的映射关系

所以我只要用对象的内存首地址除以2^13次方即可算出其是哪个span下分出去的对象

8.3总:

总的来说整个回收过程就是:
如果CentralCache中的span _usecount等于0,则说明切分给threadcache的小块内存全都回来了,则CentralCache把这个span还给PageCache,PageCache通过页号,查看前后的相邻页是否空闲,是的话就合并,合并出更大的页,解决内存碎片问题

九.大于256KB内存申请问题

9.1申请内存

1.<=256KB
通过三层缓存去申请内存,通过threadcache->centralcache->pagecache
2.>256KB
分为两种情况
32*8k < size <= 128 * 8K则去找pagecache要页空间
size>128*8K时,直接去找系统要空间

9.2释放内存时

32*8k < size <= 128 * 8K则将空间归还给pagecache
size>128*8K时,则直接将内存空间归还给系统

十.项目总结

tcmalloc(google开源项目核心部分模拟实现)_第14张图片

在后续继续进行了一些优化
有:

1. 定长内存池去配合脱离new的使用,因为我们之前申请内存还在用malloc,
   当然不用定长内存池也行,直接调用系统调用接口申请内存,但是定长内存池
   相当于也减少了反复找系统要内存的开销
2. 释放对象时不传入内存大小
   加入一个unordered_map<PAGE_ID,size_t>去存储页号和大小即可解决
3. 因为锁的消耗和unordered_map查找的消耗非常大,所以可以引入基数树来
   替代unordered_map,以此来优化unordered_map内的查找消耗和锁的竞争
   消耗

以上的优化跟我们学习tcmalloc的基本架构没有太大关系,优化了代码和效率提升

我实现tcmalloc的核心代码思路主要是要了解ThreadCache、CentralCache
、PageCache的内存申请和释放,了解它是如何做到在多线程高并发场景下比malloc更胜一筹

与malloc比较

在项目中,构建了一个Benchmark.cpp去比较与malloc的效率

#define _CRT_SECURE_NO_WARNINGS
#include"ConcurrentAlloc.h"

// ntimes 一轮申请和释放内存的次数
// rounds 轮次
void BenchmarkMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
	std::vector<std::thread> vthread(nworks);
	std::atomic<size_t> malloc_costtime = 0;
	std::atomic<size_t> free_costtime = 0;

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

	return 0;
}

**这是在最大申请空间为8K时的对比
tcmalloc(google开源项目核心部分模拟实现)_第15张图片
这是在最大申请为256KB的内存申请对比
tcmalloc(google开源项目核心部分模拟实现)_第16张图片
可见当申请内存空间小于256KB时,tcmalloc的效率明显优于malloc

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