项目实现:高并发内存池

目录

项目介绍

定长内存池MemoryPool

高并发内存池的设计

线程缓存ThreadCache:

中心缓存 centralCache

页缓存PageCache

加锁与解锁

内存申请

内存释放

内存大于256kb处理

代码优化

测试

性能优化

最终测试:


项目介绍

该项目是原google开源的项目tcmalloc,线程缓存的malloc,实现了高效的多线程内存管理,用于替换系统内存分配相关函数如malloc,free等,这里是将核心部分简化出来模拟实现出mini版本的,目的是为学习它。

实现高并发内存池之前,我们先开看看mallco:

我们都知道使用malloc函数回从堆区给我们开辟一块空间,而事实上malloc并不是直接向堆区申请内存的,malloc实际上就是一个内存池,malloc会向操作系统申请一大块空间,之后谁要用就分配给一小块,用完之后就需要重新申请。

我们在内存开辟的时候基本都是用malloc,但是malloc并不是在所有方面都很高效,一个东西有所长,必然有所短,虽然malloc通用,但是在其他方面性能就不是很高了,我们先以一个定长的内存池来认识一下。

定长内存池就是解决固定内存大小的内存的管理,这样性能就会很高,且不考虑内存碎片化,当然这是内存很理想的情况才会用到定长内存池,这里主要实现为了认识一下:

定长内存池MemoryPool

const size_t MAXSIZE = 128 * 1024;
template<class T>
class MemoryPool
{
public:
	
	T* New()
	{
		T* memory = nullptr;

		//优先看还回来的空间是否够用
		if (free_list != nullptr)
		{
			//头删
			void* next = *(void**)free_list;//先取第一个内存块的头,同时也是下一块内存的首地址
			memory =(T*) free_list;//把第一块内存给我用
			free_list = next;//重新指向链表  头删
			return memory;
		}
		else
		{
			// 不够用回来分配

			//剩余不够就扩容
			if (_remainbite < sizeof(T))
			{
				//开空间
				_memory = (char*)malloc(MAXSIZE);
				_remainbite = MAXSIZE;
				if (_memory == nullptr)
				{
					throw std::bad_alloc();
				}
			}
			//分配空间
			memory = (T*)_memory;
			size_t Size = sizeof(T) > sizeof(void*) ? sizeof(T) : sizeof(void*);//分配的空间至少能存一个指针的大小用来存放下一个空间
			_remainbite -= Size;
			//往后偏移
			_memory += Size;
			//显式调用构造初始化 :string,vector
			new(memory)T;
			return memory;
		}
		
	}
	void Delete(T *memory)
	{
		//显式调用析构清理
		memory->~T();
		//头插
		*(void**)memory = free_list;//先取这块内存的头
		free_list= memory;//指向整块内存的头
	}

private:
	char* _memory = nullptr;//指向的一大块空间
	size_t _remainbite ;//剩余的内存
	void* free_list=nullptr;//内存释放后还回来以链表形式链接,先把还回来的内存挂起来

};
//*(int *)memry是前四个 *(void **)是前void *个,即指针的大小

该定长的内存池,大小是固定的,先开辟一大块空间,之后没需要一点就分配一点,这里需要强调的是我们每次分配空间至少能存下一个指针的大小(*(void**)),用来之后回收的时候重复利用,回收内存块并不是直接释放,而是顺序的先挂接着,看是否需要重新使用。通过这种方式就高效的动态管理内存。

这里也不能释放,因为每一块你不知道什么时候用完,只要进程正常,就会还给os了,不会造成内存泄露。

这里申请内存也是直接可以调用系统的接口SystemAlloc,按页申请内存。

高并发内存池的设计

因为malloc本身的效率其实也不低了,因此在我们更加的优化malloc就需要从三个方面,做出改善:

1.性能问题

2.多线程竞争问题

3.内存碎片问题

高并发内存池主要分了三个部分:

1.线程缓存:每一个线程拥有自身的线程缓存,用于小于256kb的内存发分配,线程从这里申请内存不需要加锁,每一个线程独享一个线程缓存。

2.中心缓存:所有线程共享的一片空间,线程缓存向中心缓存按照需要分配的对象,再合适的时候回收,这里的中心缓存就是线程的共享资源,因此需要加锁。

3.页缓存,是中心缓存上的一层缓存,存储单位为页,当中心缓存的资源没有时,此时页缓存就向中心缓存创建多个page,再进行切割成定长大小内存块,再分配给中心缓存,当连续的几个页都被回收时,在组合成更大的页,减少内存碎片的问题。

因此我们一步一步来,先来看

线程缓存ThreadCache:

 线程缓存的设计思路是延申了定长内存池的设计,选用了自由链表的设计,将定长的内存块挂接再一起,需要的时候提供。但是内存块的大小是由许多大小的,可能是8字节,可能是8k,每一个大小对应一条自由链表,那要从1bite->256k,那也太多了,为了减少负担,我们均分这些大小,例如以,2,4,6,8,10...这样递增会减少至少一半的开销,但是同时也应出了一个小问题,就是内存碎片化,一般我们将连续空间被拆分,由于归还的效率不一样,就会产生内存碎片,这样的碎片,称为外碎片 ,而对于我们这样的小空间,就是内碎片。

因此需要控制内碎片的消耗,去极大的利用空间了:

利用MappingSize来管理内存映射:

内存我们如何映射呢,之前我们说过,不可能创建256kb个自由链表,每一条对应其大小,而是按段分,因此我们使用了如下的对齐规则:

 我们将256kb成了5个区间,每一个区间的内存对应一个对齐数,即划分该内存为自由链表时的内存块的大小。

以第一个一个区间为例,每一个内存块为8字节,之后的自由链表在128之前,都是以8字节递增的快的大小的链表

 之后就是线程之间互相各自有各自一个cache对象,相互他们是看不见的,但我们学过线程,我们知道线程其实相互之间是透明的,那么怎么样线程之间的cache对象是独立的。

Linux下的gcc提供了tls。

我们用的windows vs下提供了tls.两个tls是系统各自实现的。

TLS(线程的局部存储),使得在该方式下存储的变量在线陈内是全局访问的,但是其他线程是无法访问到的,以这种方式就保证了多线程下数据安全,也不需要用锁。

TLS可以使用动态的,也可以使用静态的。我们可以定义一个全局的该TLS指针,之后每个线程都会有独立的一份

_declspec(thread) ThreadCache* pTLSThreadCache = nullptr;

用该指针去获取线程缓存对象,在调用的内存申请的接口。

总结下来:

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

中心缓存 centralCache

中心缓存的设计与线程拥有相似之处,主要结构也是哈希桶,因为要给线程缓存返回自由链表对象,因此中心缓存的内存映射规则是一样的。

CentralCache也是按照一定大小映射哈希桶,这里的映射关系与线程自己申请时内存映射一致,区别是他的哈希桶里放的SpanList的链表结构,我们之前放的是自由链表,不过,这里的SpanList的内存比较大,因此还会在切分成一块块小的内存对象,然后挂在SpanList上。其次这里存在多线程竞争问题,需要加锁。

但是这里虽然要加锁,是在桶上加锁,只有同时向一个桶申请空间才加锁,不同桶的申请是不需要加锁的,保持高效性。

Span链表是一个管理以页为大小的内存块,每一个Span下面挂着内存块,利用usecount来进行计数,如果一个span不够用,就需继续申请新的Span,每一个span链表都映射着不同的大小内存。

具体这里的span怎么取切大块内存,这里适合threadCache一样,为了更加管理好大块内存,我们会用Span对象进行管理。

每一个 Span节点中有一条条自由链表,因为ThreadCache需要208种自由链表,因此对应的208条双向循环链表的节点的自由链表就与之对应,如果多线程同时申请一个双链表中获取自由链表,那么就需要加锁,互斥访问,且自由链表不够时就插入节点。

且在线程获取内存时,按理来说,每次给一个对应size的内存块的对象就可以了,比如(8字节),我就给一个块大小为8对象的头节点中的自由链表的一个对象,但是为了提高效率,对于这种小内存,不想ThreadCache一直频繁的向CenterCache申请,于是想要给多点,但是thread不能申请一次就给很多个,没必要。于是在自由链表中增加一个计数的max_size,第一次申请我就给一个,再问我申请,我就给两个,,,,,,申请的次数越多,我就会给的更多,且不会太多(512封顶),大大提高了效率(不会频繁的申请)。

页缓存PageCache

页缓存提供以页为单位的内存块给中心缓存,之后中心缓存,将该页通过映射的方式划分为一块块小内存。页缓存的结构也是一个哈希桶,每个桶里放的是一个Span链表,但是页缓存的映射更加简单,第几页就映射的是第几个Span链表。总共的链表的个数,也就是桶的个数是128个。

从第页号为1开始,也就是第一条链表,这里的节点保存的都是1页大小的空间,与之类似,页号为2,就是第二条链表,节点保存的大小是2页,以此类推,直到页号为128,及总共128条链表。

如果centercache需要申请2k的span,此时中心缓存内存并不会直接去找2k的大小的页的位置去要,而是先往下走,找一个比2k更大的Span,直到此时都没有,还没申请,pagecache会向系统申请128页的大小的Span,之后切分2k的大小的给中心,剩下的全部内存挂载到内存当中。至于为什么一次申请这么大的,这是因为当大内存被切分使用完还回来的时候,我们在将一片片小的页给拼接到原本挂载的大页中,减少了内存碎片,且在分配时功能更加高效了。

当中心缓存的usecoun为0的时候,说明内存都还回来了,此时中心缓存把Span对象还给页缓存,如果是小的页,就等更多的页,合并在一起,减少内存碎片的问题。

加锁与解锁

首先,多线程运行获取内存,第一步先去ThreadCache中获取内存,由于ThreadCache专门利用了TLS的线程局部存储,使的每一个线程都有独一份的指针,如果有内存,值为每一个进程提供,不存在竞争。

但是如果没有内存,就需要去中心缓存获取内存,此时多线程可能会同时访问同一个双向循环链表,此时就需要在双向循环链表的节点分配空间时加锁。

而分配空间,首先获取链表的头节点,在获取节点存储的自由链表,返回一段自由链表给threadcache,若此时链表为空就需要向页缓存申请空间。

但是此时在遍历找到对应链表时,我们就可以解开访问循环链表的锁,因为此时当前进程已经拿到了头节点,如果链表为空,解锁也不影响,因为都拿不到,此时多线程都竞争的让pagecache给中心缓存空间,因此在向pagecache申请内存完之后(获取一个pagelist的头节点),内存切分完后需要加锁,因此申请完内存,返回头节点后,再将访问链表节点的锁加上

最后centralcache拿到自由链表时解锁

拿到了内存,此时会调用函数切分内存,再返回需要的内存给链表,此时切分内存时,会将需要的给链表,剩余的挂接回页缓存中,不允许其他线程访问,因此需要加锁。

内存申请

了解完上述三大内存的工作方式,我们现在来总结一下内存申请的流程:

以申请一个8字节的内存块为例:

1.线程首先根据自身大小找到线程缓存对应的自由链表,第一次申请,链表内为空,需要向中心缓存申请内存。

2.来到中心缓存,根据自身大小找到对应链表,之后去遍历中心缓存的双向循环链表,看哪一个头节点不为空,第一次申请,整个链表为空,此时需要去获取一个节点(内存),于是向页缓存申请内存。

3.来到页缓存,根据需要的大小判断给第几页,8字节我们这里就给第一页且给一页,但是此时页缓存并没有内存,整个也哈希表也为空,因此需要向系统申请内存,第一次申请会直接申请一页128页大小(一页8k)的页,并将该页切分成第1页与第127页,之后第一页的一页会被切分成1024块大小的内存并组合成自由链表,挂接在一个中心缓存的对应的链表的头节点的自由链表上。

4.中心缓存有了内存,此时对应链表的头节点的自由链表就提供一块内存,(线程缓存申请中心缓存是一个慢增长过程,第一次给一个,第二次给两个...)

5.线程缓存拿到缓存后提供给线程使用。

内存释放

1.内存释放,还是先从threadcache处开始,线程使用完内存块后,拿到内存块此时插回对应的自由链表当中,当链表过长时(或者内存块都已经返回时)(判定条件为自由链表自身的长度大于等于申请是累加的maxsize),此时我们会将该段自由链表切下还给centralcache,返回的是指针指向的空间。

 2.来到centralcache,进行回收,首先我们先要找到是哪一个span对应的自由链表,通过链表头自身的地址经过位运算就可以找到对应的page_id,因为之前申请内存时就对每个链表的节点映射,此时就可以找到,之后就对节点的自由链表进行处理

之后就是从start到null,每遍历一次usecount减1,减到0时(比如在获取7次max_size=size),就说明整条链表为空,此时将该链表返回给pagecahce---

如何找到哪一个span,这就需要我们再从pagecache获取内存后,进行切割时,需要做标记,每一个节点对应要有一个自己的地址偏移量,比如申请8字节的话,总块数为1024块,抽象成一个循环链表,再用页号映射节点,也就是八个节点,一个节点下的自由链表为128块。

分成1页与127页:

具体如下:

1.当初如何切分的页,就怎样去合并页,在切分页的时候,如第一页即8k就是申请8字节所切分的页,这一页我们我们会有也好加偏移量映射每个链表的节点存在Unorderred_map中,到时候找节点在哪一页直接调用方法find即可,因此这一页也就是第一页,只有一个节点,对应8字节的双向循环链表的头节点(page_id+num,span)。

我们将自身需要的页进行页号+偏移量与节点的映射,抽象出链表(如上图),

同时也将剩余的一张页进行首尾的映射,因此归还回来头节点对应找出是哪一个链表的,当链表中节点usecount为零时就归还给Pagecache,返回给Pagecache对应span头节点,之后删除该链表的节点。

3.归还给Pagecache时,我们还需要将归还的节点找到,通过page_id与num找到切分时映射的双向循环链表的页,但此时只是一个节点,根据节点保存的pageid与num页号,加上之前切分页也被映射到unordered_map,因此就能找到切分时的上下(修改pageid,与num)挂载在对应的页表上,最后合并为一整页(即原本切分的页),最后释放delete这一页(一般就是128页)。

内存大于256kb处理

以上的三层内存管理也只是更好管理小于256k的内存块的申请,那么大于256kb的内存块,我们可以怎样去处理呢?

有两种情况:

a:如果内存是在32*8k到128*8k,此时该范围的大小的内存块,可以去直接找PageCache去申请对应一页的页。

b:内存块的大小大于128*8k,此时该范围大的内存pagecache最后一页也满足不了,只能让系统开辟给。

因此在申请内存时,先判断是否大于256k,然后看失去PageCache还是CentralCache,再根据页号,看是去PageCache中获取,还是直接向系统获取一个span。

代码优化

1.使用定长内存池替换程序中的new,让他人在使用tc_malloc直接替换malloc,所以tc_malloc里不能有任何malloc的东西,所以这里的在PageCache构造节点时不能用new,刚好使用我们的定长内存池替换new。

2.在双向循环链表的节点中增加成员objectsize,在pagecache且份内存块时,不仅仅要初始化span的页号,地址偏移量,freelist,同时在将我们需要的size赋给objectsize。记录需要的size,就可以使我们在释放内存时,不需要提供大小的参数,直接调用节点的成员objectsize。

3.使用智能指针的锁,在我们去找对应节点是可以使用c++提供的lock,生命周期结束自动解锁。

测试

这里用malloc与我们的ConcurrentAlloc进行对比测试,传入的参数分别为 ntimes(申请释放一轮使用的时间),rounds(轮次),nwroks(线程数).

 此时我们代码基本实现,可是效率貌似并不如malloc,此时需要去进行优化分析。

性能优化

根据我们测试,性能的主要消耗在加锁,其次时无序容器所提供的方法find上,因此我们还可以优化这里的无需容器的查找,根据tc_malloc,其改善的方法使用了基数树进行优化。

使用基数树代替unordered_map,提高查找效率:

这里还有三层的设计比一,两层的更为复杂,这里我们用一层或二层的都可以。

最终测试:

 可以看到我,我们的申请与释放势必malloc快不少的.

最终源码:高并发内存池 · 但成伟/编程学习 - 码云 - 开源中国 (gitee.com)

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

菜菜求佬带

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值