高并发内存池到底高在哪?

目录

一、什么是内存池?

二、为什么需要内存池?

三、常见内存池是如何设计的?

四、实现一个高并发的内存池

   一>ThreadCache:

   二> CentralCache:

   三>PageCache:

   四>系统堆

   五>该项目的优点

   六>当前存在的问题和改进?


一、什么是内存池?

       内存池是一种提高内存分配效率一种池化技术。简单来说,就是在我们使用内存之前,提前向系统申请一定数量的内存块,当程序需要内存时,直接从中取出合适大小的内存块使用,使用结束后将该内存还给内存池,这样一来,就实现了内存的重复利用,也减少了和系统交互的次数,提高了性能。

二、为什么需要内存池?

程序中经常需要分配一定大小的内存,使用完毕之后再释放这部分内存。一般情况下,调用malloc/free或new/delete进行内存分配和释放时,会产生弊端:

1.若程序在内存申请和释放的代码段中有bug,申请的内存就无法释放,导致内存泄漏。

2.频繁调用malloc或new,由于申请的内存块大小不一,会产生内存碎片,使得小内存无法使用。

3.频繁调用malloc或new和系统交互,系统要在内存空闲表中查找合适大小的内存,产生额外开销,降低程序和操作系统的性能。

    ● 内存碎片是什么?答:简单来说就是有碎片但是无法使用。

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

      外碎片:还没有被分配出去但是由于太小了无法分配给申请内存的进程使用。频繁的分配与回收物理页面会导致大量的、连                      续且小的页面块夹杂在已分配的页面中间,就会产生外部碎片。假设有一块一共有100个字节的连续空闲内存间,                        先后分配给进程0-40和60-80这两块内存,那么还剩余41-59和81-99的内存,由于这两块内存不连续,这时如果要                        申请30字节的内存是申请不出来的,因此这两块内存叫做外碎片。

三、常见内存池是如何设计的?

1.普通内存池:在使用内存之前,预先向系统申请一定数量的大小相等或不等的内存块,用一个链表将这些内存块按大小链接起来。当有内存需求时,就从中取出合适大小的内存块使用,使用结束后将这块内存插回链表中。

优点:1.实现简单。

           2.提高了内存的分配效率,使用内存时只需要判断一下链表所指向内存块的大小,进行指针的偏移查找合适的内存块。减                少了和系统交互的时间。

           3.不需要单独释放内存,而是将内存块挂回链表中,以便二次使用。

缺点:查找合适内存时需要遍历链表,效率不高。

2.定长内存池:在使用内存之前,预先向系统申请一定数量的内存块Block,将Block切分为大小相等的内存块用链表挂起来,有内存需求时只需要从链表中头删,当链表中没有可使用的内存块时,再申请新的内存块NewBlock,将Block和NewBlock链接起来。

优点:是普通内存池的改进,简单,内存分配和释放的效率高。

缺点:只能解决内存大小一定的场景。

简单实现:

3.哈希映射的内存池:在定长内存池的基础上,按照不同对象大小,构造多个固定的内存分配器,分配内存时根据所需内存大小进行对齐然后在哈希表找到对应的位置,再在该位置的_freelist找一块空闲的内存块使用,释放内存时,同样找到该内存大小在哈希表的对应位置,然后将内存块插入自由链表。(比如:需要45bytes的内存大小,就将45向上对齐到48bytes,然后在5号位置_freelist所指向的内存块链表中取出一块使用,使用完后再还给这里)

 

优点:是定长内存池的改进,可以解决一定长度的内存分配问题。

缺点:1.存在内存碎片问题,如果有大的内存块空闲,但是小的内存块使用频率高,小内存块使用完了,这样就会导致有空闲内存但是无法使用。

           2.多线程并发场景下,会导致线程安全问题,加锁后会锁的消耗导致申请效率低。

      综上已经分析过了常见内存池的特点,那么到底如何解决这些问题?

四、实现一个高并发的内存池解决下面三大问题:

1.内存碎片。(明明有内存但是却无法使用)

2.多线程并发场景的锁竞争问题。(加锁解锁消耗高,代码万一有bug产生死锁?)

3.性能问题。(实现内存池的基础)

申请内存的过程:

第一层:每个线程有一块自己的缓存,当需要内存时,线程去自己的thread_cache中取内存。(解决并发锁竞争问题)

第二层:有的线程会特别吃内存,有的线程可能不太消耗内存,为了使资源均衡,当线程的thread cache中没有内存,就去central cache中取一定数量的对象,将其切分给线程使用。(资源均衡)

第三层:当central cache中没有内存,就去向page cache中申请内存。(合并内存解决内存碎片问题)

第四层:page cache没有内存时向系统申请一块大内存。

释放内存时:

第一层:当线程使用完内存就将内存挂到thread cache的自由链表中。

第二层:当自由链表长度到一定值,说明已经将thread cache的内存还完了,开始将内存块还给central cache。

第三层:当cengtral cache的某一块被切分的内存全部还回来,开始向page cache释放,page cache将小的内存合并为大的内存。

这些cache具体都是些什么鬼?

一>ThreadCache:

        本质是一个哈希映射的对象自由链表。线程缓存是每个线程独有的,用于小于64k的内存的分配,线程从这里申请内存不需要加锁,每个线程独享一个cache,这也是这个并发线程池高效的地方。

申请内存:
1. 当内存申请大小<=等于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。


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

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

	//自由链表太长开始释放到中心缓存
	void ListTooLong(FreeList* freelist,size_t byte);
private:
	FreeList _freelist[NLISTS];
	//int tid;
	//ThreadCache* next;
};
//tls保证线程有自己独立的全局变量
static _declspec(thread) ThreadCache* tls_threadcache = nullptr;

●这里提一下TLS(thread local storage)线程本地存储,保证线程拥有自己的ThreadCache指针,这样大部分情况下线程都是使    用自己的ThewadCache,申请释放内存是不需要加锁的。

 TLS分为静态的TLS动态的TLS,这里使用的是静态的,每个线程时创建时都有自己的线程缓存。

static _declspec(thread) ThreadCache* tls_threadcache=nullptr;

●对齐规律:控制在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] 512byte对齐 freelist[128,240)
static inline size_t _Roundup(size_t size, size_t align)
	{
		return (size + (align - 1))&~(align - 1);
	}

	//获得对齐后的size
	static inline size_t Roundup(size_t size)
	{
		assert(size < MAXBYTES);

		if (size <= 128)
		{
			return _Roundup(size, 8);
		}
		else if (size <= 1024)
		{
			return _Roundup(size, 16);
		}
		else if (size <= 8192)
		{
			return _Roundup(size, 128);
		}
		else if (size <= 65536)
		{
			return _Roundup(size, 512);
		}
		else
		{
			return -1;
		}
	}
	

二> CentralCache:

        1.本质是一个哈希映射的span对象自由链表

        2.全局只有唯一的CentralCache,单例模式。

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

申请内存:
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
释放内存:
       当thread_cache过长或者线程销毁,则会将内存释放回central cache中的,释放回来时--use_count,当use_count减到0时则表示所有对象都回到了span,则将span释放回page cache。


//单例模式
class CentralCache
{
public:
	static CentralCache* GetInstance()
	{
		return &_inst;
	}
	
	//从中心缓存获取一定数量的对象给ThreadCache
	size_t FetchRangeObj(void*& start, void*& end, size_t n, size_t bytes);

	//获取一个span对象
	Span* GetOneSpan(SpanList* spanlist, size_t bytes);

	//将一定数量的对象释放到span跨度
	void ReleaseListToSpans(void* start, size_t byte_size);

private:
	//中心缓存自由链表
	SpanList _spanlist[NLISTS];
private:
	CentralCache() = default;
	CentralCache(const CentralCache&) = delete;
	CentralCache& operator=(const CentralCache&) = delete;

	static CentralCache _inst;
};

●Span是什么?

//Span对象有一个对象链表
struct Span
{
	PageID _pageid=0;//页号:页号*一页大小=计算起始地址
	size_t npage=0;  //页数:计算大小

	Span* _next=nullptr;
	Span* _prev=nullptr;

	void* _objlist = nullptr;//对象自由链表
	size_t _objsize = 0;//对象个数
	size_t _usecount = 0;//使用计数
};

●spanlist??

//带头循环双向链表,以页为单位
class SpanList
{
public:
	SpanList()
	{
		_head = new Span;
		_head->_next = _head;
		_head->_prev = _head;
	}
	std::mutex _mtx;
private:
	Span* _head = nullptr;
};

三>PageCache:

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

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


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

	//申请一个新的span
	Span* NewSpan(size_t npage);
	Span* _NewSpan(size_t npage);


	//获取对象到span的映射
	Span* MapObjectToSpan(void* obj);
	
	//释放空闲span到pagecache,合并相邻页
	void ReleaseSpanToPageCache(Span* span);

private:
	SpanList _pagelist[NPAGES];

private:
	PageCache() = default;
	PageCache(const PageCache&) = delete;
	PageCache& operator=(const PageCache&) = delete;
	static PageCache _inst;//全局只有唯一对象

	std::mutex _mtx;

	//std::unordered_map<PageID, Span*> _id_span_map;
	std::map<PageID, Span*> _id_span_map;
};

四>系统堆

       当PageCache没有缓存时,就直接向系统堆申请一个128page span挂在PageCache的自由链表中。_WIN32使用VirtualAlloc,其他系统使用brk或mmap。


inline static void* SystemAlloc(size_t npage)
{
	//需要向系统申请内存
	void* ptr = VirtualAlloc(NULL, (NPAGES - 1) << PAGE_SHIFT, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
	if (ptr == nullptr)
	{
		throw std::bad_alloc();
	}
	return ptr;
}
inline static void SystemFree(void* ptr)
{
	VirtualFree(ptr,0,MEM_RELEASE);
	if (ptr == nullptr)
	{
		throw std::bad_alloc();
	}
}

 

五>该项目的优点

1.高并发:每一个线程都有自己的线程缓存,这样当有内存需求的时候,线程就不用向系统申请空间而是直接去自己的线程缓存中     取合适大小的内存,不会牵扯到多个线程访问统一资源需要加锁解锁可能造成的死锁问题和效率问题。

2.性能高:中心缓存的存在均衡了各个线程的资源,解决了有的线程占着内存不使用而有的内存吃紧的问题。

3.解决了内存碎片问题:将内碎片浪费控制在12%左右,减少了外碎片的存在;线程缓存将多余的空闲内存还给中心缓存的span     后,若该span的使用计数为0,页缓存就会回收该span,并且向前后页合并,将小的内存合并成大内存供下次内存使用。

六>当前存在的问题和改进?

 1、独立性不足:

       1>并没有完全脱离malloc,在内存池自身数据结构的管理中,如SpanList中的span结构还是使用的new Span这样的操                     作,new的底层使用的是malloc,所以还不足以替换malloc。

       2>在页缓存中使用unordered_map,而它的底层就是空间配置器,空间配置器申请内存时会使用malloc。
      解决方案:项目中增加一个定长的ObjectPool的对象池,对象池的内存直接使用brk、VirarulAlloc等向系统申请,new                                     Span替换成对象池申请内存。这样就完全脱离的malloc,就可以替换掉malloc。
 2.平台及兼容性不足
    1>linux等系统下面,需要将VirtualAlloc替换为brk。
    2>x64系统下面,实现支持不足。比如id查找Span得到的映射使用的是map<id,Span*>。在64位系统下面,这个数据结构在性           能和内存等方面撑不住。

    3>改进:使用基数树

       基数树参考:https://blog.csdn.net/yang_yulei/article/details/46371975

 3.替换系统的malloc/free

      当前实现的并发内存池比malloc/free是更加高效,如何替换系统调用的malloc?
     1>基于Linux使用了weak alias的方式替换。具体来说是因为这些入口函数都被定义成了weak symbols,再加上gcc支持 alias           attribute,所以替换就变成了这种通用形式:void* malloc(size_t size) THROW attribute__ ((alias (tc_malloc))),因此             所有malloc的调用都跳转到了tc_malloc的实现
     2>对于其他平台,需要使用hook的钩子技术来做。

         钩子技术原理参考:https://blog.csdn.net/qq_36381855/article/details/79962673

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值