实现一个高并发的内存池

1.什么是内存池

1.1 池化技术

池化技术是将程序中需要经常使用的核心资源先申请出 来,放到一个,由程序源自己管理,这样可以提高资源的使用效率,它可以避免核心资源申请和释放带来的开销,也可以保证本程序占有的资源数量。 经常使用的池化技术包括内存池、线程池和连接池等。

1.2 关于内存池

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

2.内存碎片问题

造成堆利用率很低的一个主要原因就是内存碎片化。如果有未使用的存储器,但是这块存储器不能用来满足分配的请求,这时候就会产生内存碎片化问题。内存碎片化分为内部碎片和外部碎片

2.1 内碎片

内部碎片是指一个已分配的块比有效载荷大时发生的。(假设以前分配了10个大小的字节,现在只用了5个字节,则剩下的5个字节就会内碎片)。内部碎片的大小就是已经分配的块的大小和他们的有效载荷之差的和。因此内部碎片取决于以前请求内存的模式和分配器实现(对齐的规则)的模式。

2.2 外碎片

外部碎片就是当空闲的存储器的总和足够满足一个分配请求,但是没有一个单独的空闲块足够大可以处理这个请求。外部碎片取决于以前的请求内存的模式和分配器的实现模式,还取决于将来的内存请求模式。所以外部碎片难以量化。
在这里插入图片描述

3.为什么要使用内存池

  • 解决内碎片问题
  • 由于向内存申请的内存块都是比较大的,所以能够降低外碎片问题
  • 一次性向内存申请一块大的内存慢慢使用,避免了频繁的向内存请求内存操作,提高内存分配的效率
  • 但是内碎片问题无法避免,只能尽可能的降低

4.三种内存池的演变

4.1 最简单的内存分配器

一个链表指向空闲内存,分配就是遍历找到一块大小和它一致或者是比它大一些的,取出一块来,然后在修改链表,将剩余的空间挂回到链表中。释放就是放回到链表里面。注意做好标记和保护,避免二次释放,还可以优化如何查找适合大小的内存快的搜索上,减少内存碎片,但是可以增加内存池的外碎片。
优点 :实现简单
缺点:分配时搜索合适的内存块效率低,释放回归内存后归并比较消耗大,实际中不实用。

4.2 定长内存分配器

实现一个 FreeList,这个自由链表用于分配固定大小的内存块,比如用于分配 32字节对象的固定内存分配器。每个内存分配器里面有两个链表。OpenList 用于存储未分配的空闲对象,CloseList用于存储已分配的内存对象。所谓的分配就是从 OpenList 中取出一个对象放到 CloseList 里并且返回给用户, 释放又是从 CloseList 移回到 OpenList。 分配时内存如果不够,那么就需要增长OpenList,向系统申请一个更大一点的内存块,切割成相同大小的对象添加到 OpenList中。这个固定内存分配器回收的时候,统一把先前向系统申请的内存块全部还给系统
优点:简单。分配和释放的效率高,解决实际中特定场景下的问题有效。
缺点:功能单一。只能解决定长的内存需求,另外占着内存没有释放。
在这里插入图片描述关于内存池内存不够的情况,应该继续想系统去申请:
在这里插入图片描述

4.3 Hash映射的多种定长内存分配器

在定长分配器的基础上,按照不同对象大小(8,16,32,64,128,256,512,1k…64K),构造十多个固定内存分配器,分配内存时根据要申请内存大小进行对齐然后查H表,决定到底由哪个分配器负责,分配后要在内存头部的 header 处写上 cookie,表示由该块内存哪一个分配器分配的,这样释放时候你才能正确归还。如果大于64K,则直接用系统的 malloc作为分配,如此以浪费内存为代价你得到了一个分配时间近似O(1)的内存分配器。这种内存池的缺点是假设某个 FreeList 如果高峰期占用了大量内存即使后面不用,也无法支援到其他内存不够的 FreeList,达不到分配均衡的效果。

优点: :本质是定长内存池的改进,分配和释放的效率高。可以解决一定长度内存分配的问题。
缺点 :存在内碎片的问题,且将一块大内存切小以后,申请大内存无法使用,别的FreeList挂了很多空闲的内存块而分配不到,但是其他的FreeList缺不够分配。在多线程并发场景下,可能会导致线程安全的问题,可以通过加锁解决,但是锁竞争激烈,申请释放效率会降低。

在这里插入图片描述注:这种设计和STL库的耳机空间配置器的设计完全一样。

关于STL空间配置器参考:

https://blog.csdn.net/LF_2016/article/details/53511648

5.了解malloc底层原理

关于malloc底层:https://blog.csdn.net/hudazhe/article/details/79535220
malloc优点: 使用自由链表的数组,提高分配释放效率;减少内存碎片,可以合并空闲的内存(根据脚步)
malloc缺点: 为了维护隐式/显示链表需要维护一些信息,空间利用率不高;在多线程的情况下,会出现线程安全的问题,如果以加锁的方式解决,会大大降低效率。

6. 实现高并发的内存池

现在大部分的开发环境都是多核多线程,在申请内存的场景下,必然存在激烈的锁竞争问题。要实现一个高并发的内存池,必须要考虑以下几个问题:

  • 内存碎片问题
  • 性能问题
  • 多线程场景下,锁竞争问题

6.1 高并发内存池设计

在这里插入图片描述主要由三部分组成:

  • thread cache:线程缓存是每个线程独有的,用于小于64k的内存的分配,线程从这里申请内存不需要加锁,每个线程独享一个cache,这也就是这个并发线程池高效的地方。
  • Central cache:中心缓存是所有线程所共享,thread cache是按需要从Central cache中获取的对象。 Central cache周期性的回收thread cache中的对象,避免一个线程占用了太多的内存,而其他线程的内存吃紧。达到内存分配在多个线程中更均衡的按需调度的目的。Central cache是存在竞争的,所以从这里取内存对象是需要加锁
  • Page cache:页缓存是在Central cache缓存上面的一层缓存,存储的内存是以为单位存储及分配 的,Central cache没有内存对象(Span)时,从Page cache分配出一定数量的page,并切割成定长大小的小块内存,分配给Central cache。Page cache会回收Central cache满足条件的Span(使用计数为0)对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。

注:怎么实现每个线程都拥有自己唯一的线程缓存呢?
为了避免加锁带来的效率,在Thread Cache中使用thread local storage保存每个线程本地的ThreadCache的指针,这样Thread Cache在申请释放内存是不需要锁的。因为每一个线程都拥有了自己唯一的一个全局变量。

6.2 设计ThreadCache类

class ThreadCache
{
public:
	//分配内存
	void* Allocate(size_t size);
	//释放内存
	void Deallocate(void* ptr, size_t size);
	//从中心缓存中获取内存对象
	void* FetchFromCentralCache(size_t index, size_t size);
	//当自由链表中的对象超过一次分配给threadcache的数量,则开始回收
	void ListTooLong(FreeList* freelist, size_t byte);

private:
	FreeList _freelist[NLISTS];// 创建了一个自由链表数组
};

关于FreeList这个类,我们只要封装一个普通指针和链表的长度即可。

Thread Cache申请内存:

  • 只能申请在64k范围以内的大小的内存,如果大于64k,则调用VirtualAlloc直接向系统申请内存。
  • 当内存申请size<=64k时在thread cache中申请内存,先计算size在自由链表中的位置,如果自由链表中有内存对象时,直接从FistList[i]中Pop然后返回对象,时间复杂度是O(1),并且没有锁竞争,效率极高。 当FreeList[i]中没有对象时,则批量从Central cache中获取一定数量的对象,剩余的n-1个对象插入到自由链表并返回一 个对象。

Thread Cache释放内存:

  • 当释放内存小于64k时将内存释放回thread cache,先计算size在自由链表中的位置,然后将对象Push到 FreeList[i]
  • 当自由链表的长度超过一次向中心缓存分配的内存块数目时,回收一部分内存对象到Central cache

6.3 自由链表大小设计(对齐规则)

	// 控制内碎片浪费不要太大
	//[1, 128]						8byte对齐		freelist[0,16)
	//[129, 1024]					16byte对齐		freelist[17, 72)
	//[1025, 8 * 1024]				64byte对齐		freelist[72, 128)
	//[8 * 1024 + 1, 64 * 1024]		512byte对齐		freelist[128, 240)
	// 也就是说对于自由链表数组只需要开辟240个空间就可以了
// 大小类
class ClassSize
{
public:
	// align是对齐数
	static inline size_t _RoundUp(size_t size, size_t align)
	{
		// 比如size是15 < 128,对齐数align是8,那么要进行向上取整,
		// ((15 + 7) / 8) * 8就可以了
		// 这个式子就是将(align - 1)加上去,这样的话就可以进一个对齐数
		// 然后再将加上去的二进制的低三位设置为0,也就是向上取整了
		// 15 + 7 = 22 : 10110 (16 + 4 + 2)
		// 7 : 111 ~7 : 000
		// 22 & ~7 : 10000 (16)就达到了向上取整的效果
		return (size + align - 1) & ~(align - 1);
	}
	// 向上取整
	static inline size_t RoundUp(size_t size)
	{
		assert(size <= MAXBYTES);
		
		if (size <= 128)
		{
			return _RoundUp(size, 8);
		}
		if (size <= 8 * 128)
		{
			return _RoundUp(size, 16);
		}
		if (size <= 8 * 1024)
		{
			return _RoundUp(size, 128);
		}
		if (size <= 64 * 1024)
		{
			return _RoundUp(size, 512);
		}
		else
		{
			return -1;
		}
	}
	
	//求出在该区间的第几个
	static size_t _Index(size_t bytes, size_t align_shift)
	{
		//对于(1 << align_sjift)相当于求出对齐数
		//给bytes加上对齐数减一也就是,让其可以跨越到下一个自由链表的数组的元素中
		return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1;
	}
	//获取自由链表的下标
	static inline size_t Index(size_t bytes)
	{
		//开辟的字节数,必须小于可以开辟的最大的字节数
		assert(bytes < MAXBYTES);

		//每个对齐区间中,有着多少条自由链表
		static int group_array[4] = { 16, 56, 56, 112 };

		if (bytes <= 128)
		{
			return _Index(bytes, 3);
		}
		else if (bytes <= 1024) //(8 * 128)
		{
			return _Index(bytes - 128, 4) + group_array[0];
		}
		else if (bytes <= 4096) //(8 * 8 * 128)
		{
			return _Index(bytes - 1024, 7) + group_array[1] + group_array[0];
		}
		else if (bytes <= 8 * 128)
		{
			return _Index(bytes - 4096, 9) + group_array[2] + group_array[1] + group_array[0];
		}
		else
		{
			return -1;
		}
	}
};

6.4 Central Cache设计

  • Central cache本质是由一个哈希映射的span对象自由双向链表构成
  • 为了保证全局只有唯一的Central cache,这个类被可以设计成了单例模式
  • 单例模式采用饿汉模式,避免高并发下资源的竞争
    在这里插入图片描述
    注:什么是span?一个span是由多个页组成的一个span对象。一页大小是4k。
// span结构

// 对于span是为了对于thread cache还回来的内存进行管理
// 一个span中包含了内存块
typedef size_t PageID;
struct Span
{
	PageID _pageid = 0;   //起始页号(一个span包含多个页)
	size_t _npage = 0;    //页的数量
	Span* _next = nullptr; // 维护双向span链表
	Span* _prev = nullptr;

	void* _objlist = nullptr; //对象自由链表
	size_t _objsize = 0;	  //记录该span上的内存块的大小
	size_t _usecount = 0; 	  //使用计数
};

关于spanlist,设计为一个双向链表,插入删除效率较高:

class SpanList
{
public:
	// 双向循环带头结点链表
	SpanList()
	{
		_head = new Span;
		_head->_next = _head;
		_head->_prev = _head;
	}

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

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

	void Insert(Span* cur, Span* newspan)
	{
		assert(cur);
		Span* prev = cur->_prev;
		
		//prev newspan cur
		prev->_next = newspan;
		newspan->_prev = prev;
		newspan->_next = cur;
		cur->_prev = newspan;
	}

	void Erase(Span* cur)
	{
		assert(cur != nullptr && cur != _head);

		Span* prev = cur->_prev;
		Span* next = cur->_next;

		prev->_next = next;
		next->_prev = prev;
	}

	void PushBack(Span* cur)
	{
		Insert(end(), cur);
	}

	void PopBack()
	{
		Span* span = end();
		Erase(span);
	}

	void PushFront(Span* cur)
	{
		Insert(begin(), cur);
	}

	Span* PopFront()
	{
		Span* span = begin();
		Erase(span);
		return span;
	}

	// 给每一个Spanlist桶加锁
	std::mutex _mtx;
private:
	Span * _head = nullptr;
};

Central Cache申请内存:

  • 当thread cache中没有内存时,就会批量向Central cache申请一定数量的内存对象,Central cache也是一个哈希映射的Spanlist,Spanlist中挂着span,从span中取出对象给thread cache,这个过程是需要加锁的,可能会存在多个线程同时取对象,会导致线程安全的问题。
  • 当Central cache中没有非空的span时,则将空的span链在一起,然后向Page cache申请一个span对象, span对象中是一些以为单位的内存,将这个人span对象切成需要的内存大小并链接起来,最后挂到Central Cache中。
  • Central cache的中的每一个span都有一个use_count,分配一个对象给thread cache,就++use_count,当这个span的使用计数为0,说明这个span所有的内存对象都是空闲的,然后将它交给Page Cache合并成更大的页,减少内存碎片。

Central Cache释放内存:

  • 当thread cache过长或者线程销毁,则会将内存释放回Central cache中,没释放一个内存对象,检查该内存所在的span使用计数是否为空,释放回来一个时--use_count
  • 当use_count减到0时则表示所有对象都回到了span,则将span释放回Page cache,在Page cache中会对前后相邻的空闲页进行合并。

注:怎么才能将Thread Cache中的内存对象还给他原来的span呢?
答:可以在Page Cache中维护一个页号到span的映射,当Span Cache给Central Cache分配一个span时,将这个映射更新到map中去,在Thread Cache还给Central Cache时,可以查这个map找到对应的span。

6.5 PageCache设计

  • Page cache是一个以为单位的span自由链表
  • 为了保证全局只有唯一的Page cache,这个类可以被设计成了单例模式
  • 本单例模式采用饿汉模式
// 采用饿汉模式,在main函数之前单例对象已经被创建
class PageCache
{
public:
	// 获取单例模式
	static PageCache* GetInstance()
	{
		return &_inst;
	}
	// 在SpanList中获取一个span对象,如果没有或者申请内存大于128页,则直接去系统申请
	Span* NewSpan(size_t npage);
	Span* _NewSpan(size_t npage);

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

	// 从CentralCache归还span到Page,然后PageCache进行合并
	void RelaseToPageCache(Span* span);

private:
	// NPAGES是129,最大页数为128,也就是下标从1开始到128分别为1页到128页
	SpanList _pagelist[NPAGES];

private:
	PageCache() = default;
	PageCache(const PageCache&) = delete;
	PageCache& operator=(const PageCache&) = delete;
	static PageCache _inst;
	// 为了锁住SpanList,可能会存在多个线程同时来PageCache申请span
	std::mutex _mtx;
	std::unordered_map<PageID, Span*> _id_span_map;
};

在这里插入图片描述PageCache申请内存:

  • 当CentralCache向PageCache申请内存时,PageCache先检查对应位置有没有span,如果没有则向更大页寻找一个span,如果找到则分裂成两个。比如:申请的是4page,4page后面没有挂span,则向后面寻找更大的span,假设在10page位置找到一个span,则将10page span分裂为一个4page span和 一个6page span。
  • 如果找到128 page都没有合适的span,则向系统使用mmap、brk(Linux)或者是VirtualAlloc(windows)等方式申请128page span挂在自由链表中,再重复1中的过程。

PageCache释放内存:

  • 如果CentralCache释放回一个span,则依次寻找span的前后page id的span,看是否可以合并,如果能够合并继续向前寻找。这样就可以将切小的内存合并收缩成大的span,减少内存碎片。但是合并的最大页数超过128页,则不能合并。
  • 如果ThreadCache想直接申请大于64k的内存,直接去PageCache去申请,当在PageCache申请时,如果申请的内存大于128页,则直接向系统申请这块内存,如果小于128页,则去SpanList去查找。

6.6 向系统申请内存

  • Linux平台下使用brk或sbrk向系统直接申请堆内存
  • Windows平台下使用VirtualAlloc向系统申请堆内存

关于brk参考:https://www.cnblogs.com/vinozly/p/5489138.html

static inline void* SystemAlloc(size_t npage)
{
#ifdef _WIN32
	// 从系统申请内存,一次申请128页的内存,这样的话,提高效率,一次申请够不需要频繁申请
	void* ptr = VirtualAlloc(NULL, (NPAGES - 1) << PAGE_SHIFT, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
	if (ptr == nullptr)
	{
		throw std::bad_alloc();
	}
	return ptr;
#else 
#endif //_WIN32
}

static inline void SystemFree(void* ptr)
{
#ifdef _WIN32
	VirtualFree(ptr, 0, MEM_RELEASE);
	if (ptr == nullptr)
	{
		throw std::bad_alloc();
	}
#else 
#endif //_WIN32
}

7.项目不足及扩展

  • 本项目没有完全脱离malloc和free,需要使用new和delete去创建span来维护从系统申请来的堆内存
    解决方案:在项目中增加一个定长的ObjectPool的对象池,对象池的内存直接使用brk、VirarulAlloc等向系统申请,new Span替换成对象池申请内存。这样就完全脱离的malloc,就可以替换掉malloc

  • 平台兼容问题:linux系统下面,需要将VirtualAlloc替换为brk

  • 我们每次去申请内存的时候,都是使用new和delete,怎么实现才能每次申请内存的时候使用我们自己实现的内存分配器呢?
    解决方案: Linux系统下可以使用了weak alias,相当于替换别名。Windows下可以使用hook钩子技术(不太懂)。

关于hook了解与博客:https://www.cnblogs.com/feng9exe/p/6015910.html

8.项目源码

GitHub:https://github.com/hansionz/ConcurrentMemoryPool

发布了221 篇原创文章 · 获赞 200 · 访问量 19万+

猜你喜欢

转载自blog.csdn.net/hansionz/article/details/87885229
今日推荐