【项目】高并发内存池

简介

        内存碎片——如下,在堆上分别按照16字节、8字节、16字节、4字节、8字节将该快内存申请走,白色区域是使用完之后释放回来的,但是蓝色区域的还没释放回来。此时空余的内存空间共有40字节,但是这个时候再想要申请大于16字节小于40字节的空间,却申请不出来。

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

        内部碎片的产生:因为所有的内存分配必须起始于可被 4、8 或 16 整除(视处理器体系结构而定)的地址或者因为MMU的分页机制的限制,决定内存分配算法仅能把预定大小的内存块分配给客户。假设当某个客户请求一个 43 字节的内存块时,因为没有适合大小的内存,所以它可能会获得 44字节、48字节等稍大一点的字节,因此由所需大小四舍五入而产生的多余空间就叫内部碎片。

  外部碎片的产生:频繁的分配与回收物理页面会导致大量的、连续且小的页面块夹杂在已分配的页面中间,就会产生外部碎片。假设有一块一共有100个单位的连续空闲内存空间,范围是0~99。如果你从中申请一块内存,如10个单位,那么申请出来的内存块就为0~9区间。这时候你继续申请一块内存,比如说5个单位大,第二块得到的内存块就应该为10~14区间。如果你把第一块内存块释放,然后再申请一块大于10个单位的内存块,比如说20个单位。因为刚被释放的内存块不能满足新的请求,所以只能从15开始分配出20个单位的内存块。现在整个内存空间的状态是0~9空闲,10~14被占用,15~34被占用,25~99空闲。其中0~9就是一个内存碎片了。如果10~14一直被占用,而以后申请的空间都大于10个单位,那么0~9就永远用不上了,变成外部碎片。

池化技术

用一个池子放较大一块内存,用的时候取,不用的时候换回来,用以降低内存申请和释放的消耗。

内存池(Memory Pool)是一种内存分配方式。通常我们习惯直接使用new、malloc等API申请分配内存,这样做的缺点在于:由于所申请内存块的大小不定,当频繁使用时会造成大量的内存碎片并进而降低性能。内存池则是在真正使用内存之前,先申请分配一定数量的、大小相等(一般情况下)的内存块留作备用。当有新的内存需求时,就从内存池中分出一部分内存块,若内存块不够再继续申请新的内存。这样做的一个显著优点是尽量避免了内存碎片,使得内存分配效率得到提升。

(1)针对特殊情况,例如需要频繁分配释放固定大小的内存对象时,不需要复杂的分配算法和多线程保护。也不需要维护内存空闲表的额外开销,从而获得较高的性能。

(2)由于开辟一定数量的连续内存空间作为内存池块,因而一定程度上提高了程序局部性,提升了程序性能。

(3)比较容易控制页边界对齐和内存字节对齐,没有内存碎片的问题。

(4)当需要分配管理的内存在100M一下的时候,采用内存池会节省大量的时间,否则会耗费更多的时间。

(5)内存池可以防止更多的内存碎片的产生

(6)更方便于管理内存

现代的很多开发环境都是在多核多线程的情况下,这样就会在申请内存的时候存在,激烈的锁的竞争问题。高并发内存池解决了如下的问题:

(1)内存碎片问题

(2)性能问题

(3)多核多线程下——锁的竞争问题。

一页4k,最大可以在内存池申请到128页的内存,超过128页的直接向内存获取一个最大页,也就是128页。

高并发内存池在一般情况下并不比malloc快,只是在并发的时候比malloc快。

ThreadCache——解决性能问题

线程缓存是每个线程所独有的,用于小于128k的内存进行分配。(大于128k的直接找系统申请,因为出现这样的场景其实并不多)每个线程都有自己的thread cache, 所以从这里获取内存无需加锁,这就保证了并发内存池的高效性。

每一个Thread_Cache都是一个哈希映射的自由链表,使用哈希映射一个存储不同大小数据块的内存块池,通过根据不同大小的对象,构建不同大小的内存的分配器,进行内存的高效分配。自由链表本质上就是一个指针数组,但是我们把每个指针都定义成了一个对象,所以也可以称其为对象数组,每个对象有两个成员,每一个对象是一个_freeList,_freeList后面有一个指针,指向了后面的内存对象块,_num记录了内存对象的个数。

此处图放大看更清晰-------->

CentralCache——解决多核多线程下,锁的竞争问题以及均衡资源

中心缓存是所有线程所共享的,Thread_Cache是按需从Central_Cache中获取的对象。Central_Cache周期性的回收Thread_Cache中的对象,避免了一个线程占用太多的内存,而其他线程的内存吃紧。达到内存分配在多个线程中更均衡的按需调度的目的。Central_Cache是存在竞争的,所以从这里取内存对象是需要加锁的,不过一般从这里取内存对象的效率比较高,所以这里的竞争不是很激烈。

Thread Cache资源分配不均衡,Central Cache是进行均衡的,避免某个Thread Cache使用资源太多了。Thread Cache当中闲置的内存对象个数超过了单次向系统申请的上限个数之后,就进行一定程度的回收。

在这里加的锁称为桶锁,也就对每个不同大小的SpanList分别加锁,这样如果一个线程要申请8Bytes和另一个线程要申请24Bytes是不会产生竞争的,大大减少了锁的竞争,这里也是提高性能的关键之所在。

Central_Cache下面挂的是一个个span,span是一些内存对象的集合,这些内存是以页为单位的,记录页号,页的数量,span中自由链表对象大小,以及内存对象使用的数量。

此处图放大看更清晰-------->

PageCache

页缓存是在Central_Cache缓存上面的一层缓存,存储的内存是以页为单位存储及分配的。CentralCache没有内存对象时,从PageCache分配出一定数量的Page,并切割成定长大小的小块内存,分配给CentralCache,同时PageCache会回收CentralCache满足条件的Span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。

PageCache的映射规则和CentralCache的结构不同,span是以页为单位进行映射的,当Central_Cache中的内存满了之后,就会从Central_Cache中将内存还给Page_Cache,这是就需要页号,找到上一个页号和下一个页号,将其连接起来。

此处图放大看更清晰-------->

TLS——thread local storage

如何保证每个线程在使用高并发内存池的时候,各自拥有自己的线程缓存,而互相不干扰呢?

在这里我们使用——TLS, 线程本地存储就可以做到。它的使用非常之简单, 只要对变量的声明使用__declspec(thread)就可以了。

具体如下:

_declspec (thread) static ThreadCache*  pThreadCache = nullptr;

设计模式——单例模式

为了保证全局只有唯一的CentralCache和PageCache,所以这两个类都被设计成了单例模式。具体是用单例模式中的饿汉模式来进行创建的,这样的话这两部分在main含糊之前创建了。

为什么不用懒汉模式?

懒汉模式在第一次调用时才创建,多个线程第一次调用时,需要加锁,一加锁效率就会降低,所以不建议使用懒汉模式。

所以这里用的是饿汉模式,本身就是线程安全的,它在main函数之前就创建,而线程是在main函数之后才创建的,这样的话,就不用加锁了,也无需双重检查。

内存申请过程

此处图放大看更清晰-------->

小细节

(1)在PageCache获取内存时,会从1页的SpanList开始遍历,知道找到对应所需的页面大小的Span,如果没有的话,会找到第一个比它大的进行分割,比如需要一个2页的span,只找到了第一个比2页大的,也就是48页的span,PageCache会将48页的分割成2页的和46页,返回CentralCache2页的。然后将46页的挂会PageCahce对应46下标的spanlist中。

(2)之所以向系统申请最大页的span,原理同上,能这样能够容纳更多的需求。

(3)内存对齐和页面实现的进制,均尽可能地使用位运算,以提高效率。

(4)在CentralCache的每个Span都有一个使用计数,也就是_usecount,也就是对应Span中被ThreadCache拿走的内存对象个数,当_usecount为0时,会根据资源情况,将其还给PageCache进行页的合并。

(5)ThreadCache在向CentralCache获取内存时,存在线程安全问题,但是因为设计了桶锁的缘故,大大减少了锁的竞争。

内存释放过程

此处图放大看更清晰-------->

小细节

(1)PageCache的_idSpanMap中的映射关系会在页的分裂与合并中不断更新。

(2)向系统直接申请或者释放内存时,在Windows平台用的是 VirtualAlloc 和 VirtualFree。

项目测试

单元测试

第一步:在只实现了线程缓存类的时候对其进行单元测试,虽然其他类还没有实现,但是可以利用数据模拟现对其进行测试。

void UnitThreadCache()
{
	ThreadCache tc;
	vector<void* > v;
	for (size_t i = 0; i < 21; ++i)
	//为什么要申请21次内存?
	//(num==20)让程序运行的时候既走if语句,也走一else语句,都试试(白盒测试)
	{
		v.push_back(tc.Allocte(7));//申请7个字节
	}
	for (size_t i = 0; i < 21; ++i)
	{
		printf("[%d]->%p\n", i, v[i]);
	}
	for (auto ptr : v)
	{
		tc.Deallocte(ptr, 7);
	}
}

此时,虽然后面的模块没有实现,但是可以用数据模拟的方式,先验证该单元的功能和逻辑。

如果一个线程想要申请7个字节,那么会从中心缓存中一次性申请20个内存对齐的内存对象,虽然它只使用一个,但是之后再用的话,就不用再获取了,待20个使用完了,还需要再次使用7个字节的内存,就会再次从中心缓存中一次性申请20个内存对齐的内存对象。

如图左所示,7个字节内存对齐之后是的内存大小为8个字节的内存对象,所以第一次申请的时候前20个内存对象(对应下标[0,19])的是连续的,间隔是8个字节;下标为20的内存对象是第二次申请的,所以地址跟前20个是不连续的。

第二步:对处理内存对齐进行单元测试。

[1,128] 以8byte对齐 对应freelist[0,16) <--- 128/8 = 16

[129,1024] 以16byte对齐 对应freelist[16,72) <--- (1024-128)/16 + 16= 72

[1025,8*1024] 以128byte对齐 对应freelist[72,128) <--- (8*1024-1024)/128 + 72 = 128

[8*1024 + 1,64*1024] 以1024byte对齐 对应freelist[128,184)<---(64*1024 - 8*1024)/1024 + 128 = 184

根据边界值分析等价类划分来测试每一种情况:

第三步:当内存池中的内存不够用的时候,回向对向系统申请一块最大限制的内存,并且对其进行分割。该函数在windows平台上调用VirtualAlloc借口进行申请。

第四步:调试,在对整个项目的内存申请以及释放过程进行测试时,运行崩溃了,具体如下图所示,在将内存对象pushfront的时候为nullptr了。

这个时候,进入调BUG状态,打开VS调试中的并行堆栈窗口,具体如下所示:

在这里显示其中的一个线程在调用函数ReleaseSpanToPageCache之后发生了崩溃。双击到该函数,发现span的_pagesize为越界了,具体如下:

经过分析,是因为在ReleaseSpanToPageCache函数内部中主要实现了span的向前合并和向后合并,但是在合并过程中,忽略了控制合并之后的页面大小,也就是说,两个页面合并之后的页面大小超出了MAX_PAGES的大小,从而导致了越界崩溃的问题。

在发现问题所在之处以后,立马对函数进行了纠正,在合并过程中加入了对合并之后页面大小的控制。

性能测试及优化

利用VS中的性能探查器对高并发内存池的性能进行检测,发现在查找储存在_idSpanMap的映射关系时所用到的find函数,消耗了大部分性能,因此还需要对此进行优化。

经查阅得知,map底层是红黑树实现的,因此它的find函数时间复杂度为O(logn),而unordered_map底层是哈希表实现的,因此它的find函数时间复杂度为O(1),因此,在这里我将存储映射关系的map换成了unorder_map。

性能优化之后,再次用性能探查器进行探查,发现find所消耗的性能大大下降。

malloc在多线程并发情况下进行对比

对比malloc进行测试,用malloc和ConcurrentMalloc,申请4个线程并发执行4轮,每轮申请或者释放10000次,对比其所申请内存的时间和释放的时间以及总时间,发现其性能高于maollc的范围在10%~30%左右。

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));
				}
				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(ConcurrentMalloc(16));
				}
				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_malloc&free %u次,总计花费:%u ms\n",
		nworks, nworks * rounds * ntimes, malloc_costtime + free_costtime);
}

int main()
{
    cout << "==========================================================" << endl;
    BenchmarkMalloc(10000, 4, 10);
    cout << endl << endl;
    BenchmarkConcurrentMalloc(10000, 4, 10);
    cout << "==========================================================" << endl;
    return 0;
}

运行结果如下:

项目的不足之处

1. 项目独立性不足:

当前实现的项目中我们并没有完全脱离malloc,比如在内存池自身数据结构的管理中,如SpanList中的span等结构,我们还是使用的new Span这样的操作,new的底层使用的是malloc,所以还不足以替换malloc,因为们本身没有完全脱离它。

解决方案:项目中增加一个定长的ObjectPool的对象池,对象池的内存直接使用brk、VirarulAlloc等向系统申请,new Span替换成对象池申请内存。这样就完全脱离的malloc,就可以替换掉malloc

 2. 平台及兼容性:

(1)Linux等系统下面,需要将VirtualAlloc替换为brk等。(这是个小问题)

(2)x64系统下面,当前的实现支持不足。比如:id查找Span得到的映射,我们在改进之后使用的是unordered_map。在64位系统下面,这个数据结构在性能和内存等方面都是撑不住的,如果需要更进一步的话,可以考虑改进成基数树。

  • 6
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值