高并发内存池项目(concurrent memory pool)

一、高并内存池概念

内存池(Memory Pool) 是一种动态内存分配与管理技术。 通常情况下,程序员习惯直接使用 new、delete、malloc、free 等API申请分配和释放内存,这样导致的后果是:当程序长时间运行时,由于所申请内存块的大小不定,频繁使用时会造成大量的内存碎片从而降低程序和操作系统的性能。内存池则是在真正使用内存之前,先申请分配一大块内存(内存池)留作备用,当程序员申请内存时,从池中取出一块动态分配,当程序员释放内存时,将释放的内存再放入池内,再次申请池可以 再取出来使用,并尽量与周边的空闲内存块合并。若内存池不够时,则自动扩大内存池,从操作系统中申请更大的内存池。

由于现在硬件条件已经很成熟,大多数运行环境都是多核的,为了提高效率,则高并发这一情况应运而生,对于高并发内存池,则是基于多线程并发申请使用的一个内存池称为高并发内存池。


二、项目介绍

本项目参考了谷歌 tcmalloc 设计模式,设计实现了高并发的内存池。基于 win10 环境 VS2013,采用 C++进行编程,池化技术、多线程、TLS、单例模式、互斥锁、链表、哈希等数据结构。该项目利用了 thread cache、central、cache、page cache 三级缓存结构,基于多线程申请释放内存的场景,最大程度提高了效率,解决了绝大部分内存碎片问题。

三、项目细节

(一)项目设计目标

现代很多的开发环境都是多核多线程,在申请内存的场景下,必然存在激烈的锁竞争问题。所以这次我们实现的内存池需要考虑以下几方面的问题。
1. 内存碎片问题。
2. 性能问题。
3. 多核多线程环境下,锁竞争问题。
 

(二)项目结构

  • concurrent memory pool主要由线程缓存(threadcache)、中心缓存(centralcache)、页缓存(pagecache)3个部分构成,如下图

 ​​​

1.thread cache

  •  为了保证效率,我们使用线程局部存储(thread local storage,TLS)技术保存每个线程本地的ThreadCache的指针,这样大部分情况下申请释放内存是不需要锁的,线程缓存是每个线程独有的,用于小于64k的内存的分配,但并不是一定是要64k,只是前人总结的一个合适值。线程从这里申请内存不需要加锁,每个线程独享一个cache,这也就是这个并发线程池高效的地方。
class ThreadCache
{
public:

	// 申请和释放内存对象
	void* Allocate(size_t size);
	void Deallocate(void* ptr, size_t size);

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

	// 释放对象时,链表过长时,回收内存回到中心缓存
	void ListTooLong(FreeList& list, size_t size);
private:

    //创建了一个哈希结构,每个位置里存放一个FreeList对象
	FreeList _freeLists[NFREELISTS];
};

//TLS技术
static __declspec(thread) ThreadCache* tls_threadcache = nullptr;

该结构每个位置存放一个FreeList,每个自由链表下都可以挂自己的内存块。

申请内存:
1. 当内存申请size<=64k时在thread cache中申请内存,计算size在自由链表中的位置,如果自由链表中有内存对象时,直接从FistList[i]中Pop一下对象,时间复杂度是O(1),且没有锁竞争。
2. 当FreeList[i]中没有对象时,则批量从central cache中获取一定数量的对象,插入到自由链表并返回一个对象。
释放内存:
1. 当释放内存小于64k时将内存释放回thread cache,计算size在自由链表中的位置,将对象Push到FreeList[i].
2. 当链表的长度过长,则回收一部分内存对象到central cache。
 

2. central cache

  • 中心缓存是所有线程所共享,本质是由一个哈希映射的span对象自由链表构成thread cache是按需从central cache中获取的对象。central cache周期性的回收thread cache中的对象,避免一个线程占用了太多的内存,而其他线程的内存吃紧。达到内存分配在多个线程中更均衡的按需调度的目的。central cache是存在竞争的,所以从这里取内存对象是需要加锁,不过一般情况下在这里取内存对象的效率非常高,所以这里竞争不会很激烈。
  • 这里要注意,要保证centralcache是全局唯一的,这里我们需要将centralcache类设计成单例模式。

class CentralCache
{
public:
    //唯一获得该对象的接口
	static CentralCache* GetInstance()
	{
		return &_sInst;
	}

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

	// 从SpanList或者page cache获取一个span
	Span* GetOneSpan(SpanList& list, size_t byte_size);

	// 将一定数量的对象释放到span跨度
	void ReleaseListToSpans(void* start, size_t byte_size);
private:
	SpanList _spanLists[NFREELISTS]; // 按对齐方式映射

private:
	CentralCache()
	{}
    //封死它的拷贝构造
	CentralCache(const CentralCache&) = delete;
    CentralCache& operator=(const CentralCache&) = delete;
    //定义全局唯一类
	static CentralCache _sInst;
};

申请内存:
1. 当thread cache中没有内存时,就会批量向central cache申请一些内存对象,central cache也有一个哈希映射的freelist,freelist中挂着span,从span中取出对象给thread cache,这个过程是需要加锁的。
2. central cache中没有非空的span时,则将空的span链在一起,向page cache申请一个span对象,span对象中是一些以页为单位的内存,切成需要的内存大小,并链接起来,挂到span中。
3. central cache的span中有一个use_count,分配一个对象给thread cache,就++use_count。
释放内存:
1. 当thread_cache过长或者线程销毁,则会将内存释放回central cache中的,释放回来时--use_count。当use_count减到0时则表示所有对象都回到了span,则将span释放回pagecache,page cache中会对前后相邻的空闲页进行合并。

这里要注意centralcache中span是用双向链表进行连接的,每个span对象里都有一个list,就是每个向pagecache要的span都已经被切好了,比如说8k大小桶中,一块大的span里已经被切分成许多8k大小的内存块,他们分别用list进行内部连接,他当treadcache需要时,就给其一定数量的。但是整个span整体是被挂在spanlist中。

可以用珍珠项链可以形容,许多串整体的珍珠项链被挂在一个支架上,当顾客突然想要这串项链中的3个珍珠,那么就需要将珍珠项链里的绳子拆开取出那三个给顾客,这里支架就相当于spanlist,珍珠项链里的绳子相当于span中的list。

 3.page cache

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

class PageCache
{
public:
	static PageCache* GetInstance()
	{
		return &_sInst;
	}

	// 向系统申请k页内存挂到自由链表
	void* SystemAllocPage(size_t k);

	Span* NewSpan(size_t k);

	// 获取从对象到span的映射
	Span* MapObjectToSpan(void* obj);

	// 释放空闲span回到Pagecache,并合并相邻的span
	void ReleaseSpanToPageCache(Span* span);
private:
	SpanList _spanList[NPAGES];	// 按页数映射

	//std::mutex _map_mtx;    //专门给map用的锁
	std::unordered_map<PageID, Span*> _idSpanMap;
	std::recursive_mutex _mtx;


private:
	PageCache()
	{}

	PageCache(const PageCache&) = delete;
    PageCache& operator=(const PageCache&) = delete;
	// 单例
	static PageCache _sInst;
};

 申请内存:
1. 当central cache向page cache申请内存时,page cache先检查对应位置有没有span,如果没有则向更大页寻找一个span,如果找到则分裂成两个。比如:申请的是4page,4page后面没有挂span,则向后面寻找更大的span,假设在10page位置找到一个span,则将10page span分裂为一个4page span和一个6page span。
2. 如果找到128 page都没有合适的span,则向系统使用mmap、brk或者是VirtualAlloc等方式申请128page span挂在自由链表中,再重复1中的过程。
释放内存:
1. 如果central cache释放回一个span,则依次寻找span的前后page id的span,看是否可以合并,如果合并继续向前寻找。这样就可以将切小的内存合并收缩成大的span,减少内存碎片。

  • 这里要注意该结构下的span都是未切分的一个个整体span,当每次centralcache需要时在进行切分,并且将每个span地址与页号通过哈希map进行映射关系建立。这里一页我们按照window下1页4k为基准进行设置。


 4.对象大小的映射对齐、向中心缓存获取内存个数及向pagecache获取页大小计算

// 8 + 7 = 15
// 7 + 7
// ...
// 1 + 7 = 8
static size_t Index(size_t size)
{
	return ((size + (2 ^ 3 - 1)) >> 3) - 1;
}

// 管理对齐和映射等关系
class SizeClass
{
public:
	// 控制在1%-12%左右的内碎片浪费
	// [1,128]					8byte对齐	     freelist[0,16)
	// [129,1024]				16byte对齐		 freelist[16,72)
	// [1025,8*1024]			128byte对齐	     freelist[72,128)
	// [8*1024+1,64*1024]		1024byte对齐     freelist[128,184)

	// [1,8]   +7 [8,15]    8
	// [9, 16] +7 [16,23]   16
	static inline size_t _RoundUp(size_t bytes, size_t align)
	{
		return (((bytes)+align - 1) & ~(align - 1));
	}

	// 对齐大小计算,浪费大概在1%-12%左右
	static inline size_t RoundUp(size_t bytes)
	{
		//assert(bytes <= MAX_BYTES);

		if (bytes <= 128) {
			return _RoundUp(bytes, 8);
		}
		else if (bytes <= 1024) {
			return  _RoundUp(bytes, 16);
		}
		else if (bytes <= 8192) {
			return  _RoundUp(bytes, 128);
		}
		else if (bytes <= 65536) {
			return  _RoundUp(bytes, 1024);
		}
		else
		{
			return _RoundUp(bytes, 1 << PAGE_SHIFT);
		}

		return -1;
	}

	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 <= 8192) {
			return _Index(bytes - 1024, 7) + group_array[1] + group_array[0];
		}
		else if (bytes <= 65536) {
			return _Index(bytes - 8192, 10) + group_array[2] + group_array[1] + group_array[0];
		}

		assert(false);

		return -1;
	}

	// 一次从中心缓存获取多少个
	static size_t NumMoveSize(size_t size)
	{
		if (size == 0)
			return 0;

		// [2, 512],一次批量移动多少个对象的(慢启动)上限值
		// 小对象一次批量上限高
		// 小对象一次批量上限低
		int num = MAX_BYTES / size;
		if (num < 2)
			num = 2;

		if (num > 512)
			num = 512;

		return num;
	}

	// 计算一次向系统获取几个页
	// 单个对象 8byte
	// ...
	// 单个对象 64KB
	static size_t NumMovePage(size_t size)
	{
		size_t num = NumMoveSize(size);
		size_t npage = num*size;

		npage >>= 12;
		if (npage == 0)
			npage = 1;

		return npage;
	}
};

5.FreeList、span、SpanList结构

void BenchmarkMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
	// nworks个线程
	//每个线程跑rounds轮
	//每个线程跑ntimes次

	std::vector<std::thread> vthread(nworks);// 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)//每个线程跑rounds轮
			{
				size_t begin1 = clock();
				for (size_t i = 0; i < ntimes; i++)//每个线程跑ntimes次
				{
					v.push_back(malloc(260));//线程开辟空间
				}
				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); // n
	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(260));
				}
				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()
{
	cout << "==========================================================" << endl;
	BenchmarkMalloc(10000, 4, 10);
	cout << endl << endl;

	BenchmarkConcurrentMalloc(10000, 4, 10);
	cout << "==========================================================" << endl;

	system("pause");
	return 0;
}

5.加锁场景

a.在centralcache结构中需要进行加桶锁,也就是给FetchRangeObj函数和ReleseListToSpans函数进行加。

b.在pagecache结构中加大锁,也就是NewSpan函数和ReleaseSpanToPageCache函数进行加,还有就是在对span地址和页号映射时需要的MapObjectToSpan函数进行加。

四、项目测试

测试时一定要确保编译器在relese情况下,而不是debug!!!

五、项目总结

(一)优点

  1. 增加动态申请的效率
  2. 减少陷入内核的次数
  3. 减少系统内存碎片
  4. 提升内存使用率
  5. 尽量减少锁竞争
  6. 应用于多核多线程场景

(二)不足

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

解决方案:malloc、new(频繁申请的小块内存,如:span->对象池     内存大块内存:virtualalloc/brk/mmap)map、thread、mutex....

2.平台及兼容性
linux等系统下面,需要将VirtualAlloc替换为brk等。这个是小问题 。
x64系统下面,当前的实现支持不足。比如:id查找Span得到的映射,我们当前使用的是map<id, Span*>。在64位系统下面,这个数据结构在性能和内存等方面都是撑不住。需要改进后基数树。

(三)面试常见问题

1.如何去替代malloc?替代malloc的原理是什么?

不同平台替换方式不一样,linux下使用weak alias的方式进行替换,window下使用hook的钩子技术进行替换,不用更改原来的代码,只需要使用钩子将代码中使用malloc的地方勾过来让其执行该内存池代码,所有的malloc的调用都跳转到了tc_malloc的实现。它也通常用来写外挂,用来进行系统层面函数更改。Map使用基数树来进行替换,malloc使用对象池或者virtual alloc申请大块内存。

2.能不能把threadcache和centralcache合并掉,减掉一层?

不能,Central核心作用是承上启下的作用,假设把centracache直接去掉,就意味着threadcache和centralcache直接进行对接,会产生一个问题,pagecache中的span有些是切好的,有些是没有切好的,而且不是一下子就给threadcache,有可能给一部分,可能会留下一部分,这是第一个问题,第二个问题是还回来是还一部分,切过的和没切过的混在一起会有问题,比如申请一块大块内存,在pagecache中找,但是却不知道找到的到底是切好的还是没切好的,虽然USecount也能进行判断,但是切的多了混在一起找的时候查找效率没有那么高。再其次均衡调度作用就不明显了,threadcache中8字节专门给centralcahe8字节用的,但是如果在pagecache中就比较混乱,因为pagecache是按照页进行映射的,更大的问题在于centralcache加的是桶锁,pagecache虽然也是一个一个映射的桶,但是它涉及到一个span的合并和切分,span会在各个桶之间流动,就不能使用桶锁,就必须使用一把大锁进行锁住,但是centralcache就不会再各个桶之间进行流动。

小结:

1.Centralcache均衡多个线程之间的同一个大小的内存需求

2.他的span都是至少有部分在用的,区分pagecache都是大块完整。

3.它实现的是桶锁,因为一个span只会给一个桶用,不会再桶之间流动,效率更高,如果没有他的话,pagecache是一把大锁,因为pagecache中的span需要切小和合大,会在多个映射桶之间流动。

3.max一定是64k吗?一定是以8k对齐按照我们那种分段映射对齐吗?

不一定,这个根据设计者需求,可能换个人参数就全变了,依据64k控制10%左右的浪费设计了映射规则,后面为什么是128页呢也就是一次性要了0.5兆?这个也是不确定的,这个值至少要比最大的单个对象大小大,也就是至少大于16个页,最少也得5、60页大小,但是不能过分大,太大表示一次对系统要的太大了,会造成浪费。

4.threadcache销毁了,如果他还有内存没给centrlcache怎么办?假设这个线程有内存泄露或者它还没有达到listtolang哪个条件,有可能有一些内存还没有还回来或者挂在threadcache中,但是这个线程销毁了,那么这个内存就没有回到这个centralcache,centralcache也不会回到pagecache,会耽搁小页合大,还会导致一些内存的泄露,有没有什么办法解决呢?

解决方法就是给这个项目注册一个回调函数,只要线程结束,函数作用是把threadcache里面给clear掉,把每个桶数据都往下一个还,在此创建tls时就进行处理,在new线程空间时同时注册一个回调函数,一旦崩溃就是清理掉这个回调函数。

六、项目源码

关于项目源码centralcache和pagecache设计成两种单例模式,一种为饿汉,一种为懒汉。

https://gitee.com/ishao97/ishao97.git

  • 2
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
### 回答1: 对于 Python 项目高并发设计,可以采用多线程或多进程的方式来实现。 多线程可以通过 Python 标准库中的 `threading` 模块来实现,优点是简单易用,缺点是 Python 的 Global Interpreter Lock (GIL) 会限制多线程的性能。 多进程可以通过 Python 标准库中的 `multiprocessing` 模块来实现,优点是可以充分利用多核 CPU 的性能,缺点是进程间通信和数据共享比较复杂。 还可以使用第三方库如 asyncio,gevent,concurrent.futures等来实现高并发 此外,还可以采用服务器端框架(如 Flask, Django, Tornado 等)来实现高并发。 ### 回答2: 在Python项目高并发设计中,有几个关键的方面需要考虑。 首先,需要使用适当的并发编程模型。Python提供了多种并发编程模型,如多线程、多进程、协程等。在选择合适的模型时,需要考虑项目的具体需求和性能要求。比如,如果项目需要处理大量的IO操作,可以选择使用协程,因为它能够高效地处理并发的IO操作。 其次,需要充分利用Python提供的并发相关的库和工具。Python提供了一些功能强大的库和工具,如asyncio、gevent、multiprocessing等。这些库和工具能够帮助我们实现高效的并发设计。比如,使用asyncio库可以轻松地编写异步并发的代码;使用multiprocessing库可以方便地进行多进程并发处理。 另外,要进行合适的资源管理和优化。高并发项目通常需要管理大量的资源,如内存、数据库连接、网络带宽等。合理地管理这些资源可以提升项目的性能和并发能力。比如,可以使用连接池技术来管理数据库连接,减少连接的创建和关闭的开销;可以使用缓存来减轻对数据库和其他资源的访问压力。 最后,还需要进行适当的性能测试和优化。通过性能测试可以找到项目的瓶颈和瓶颈所在,然后根据测试结果进行优化。比如,可以通过优化算法、增加服务器的硬件资源、使用负载均衡等方式来提高项目的并发能力和性能。 综上所述,在Python项目高并发设计中,需要选择合适的并发编程模型,充分利用Python提供的并发相关的库和工具,进行合适的资源管理和优化,以及进行适当的性能测试和优化。这些措施能够帮助我们实现高效、稳定的高并发项目。 ### 回答3: Python项目高并发设计是指在开发过程中,考虑到项目可能面临大量并发请求的情况下,采取一系列措施来提高项目的并发能力和性能。 首先,可以通过使用多线程或多进程来实现并发处理。Python的多线程模块threading或多进程模块multiprocessing可以很好地支持并发,将处理任务分配给多个线程或进程同时执行,提高项目的并发能力。 其次,可以利用协程来实现高并发。协程是一种轻量级的线程,可以在单线程内实现多个任务的切换,提高并发处理的效率。Python中的协程库asyncio提供了对协程的支持,可以利用协程来实现高并发项目设计。 另外,可以使用消息队列来实现高并发。消息队列可以实现任务的异步处理,将大量请求放入消息队列中,再由多个消费者并发地处理这些请求,减轻单个服务器的压力,提高并发能力和性能。 还可以使用缓存来提高项目的并发处理能力。通过将频繁请求的数据或计算结果缓存起来,可以减少对数据库或其他数据源的访问,提高响应速度,提高项目的并发能力。 另外,可以对数据库进行优化,如建立索引、合理使用数据库连接池等,提高数据库的并发性能。 此外,在项目开发中,还需要考虑到并发请求可能引发的问题,如资源竞争、死锁等,需要合理地设计并发控制机制来解决这些问题。 总之,Python项目高并发设计需要综合考虑多方面的因素,包括多线程、协程、消息队列、缓存、数据库优化等,通过合理的设计和技术选型,来提高项目的并发能力和性能。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

ishao97

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

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

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

打赏作者

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

抵扣说明:

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

余额充值