Concurrent Memory Pool:高并发内存池(三层结构)

Concurrent Memory Pool:高并发内存池(三层结构)

首先了解一下池化技术和内存池

池化技术

池是在计算机技术中经常使用的一种设计模式主要是将程序中需要经常使用的核心资源先申请出来,放到一个池内,由程序自己管理,这样可以提高资源的使用效率,也可以保证本程序占有的资源数量。经常使用的池技术包括内存池、线程池和连接池等,其中尤以内存池和线程池使用最多。

内存池

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

内存碎片问题

如下图所示,假设蓝色框表示正在使用的内存空间,那么中间被隔开的地方就无法申请到更大的内存空间,这就导致了内存碎片问题,这种内存碎片叫做内存外碎片
Concurrent Memory Pool:高并发内存池(三层结构)_第1张图片
还有一种内存内碎片的问题,是指当我们在设计内存池时,把内存组织起来分配给你,分配的都是固定的大小,那么假如你申请10字节的内存空间,内存池给了你16字节的空间,那么还有6字节的内存空间就没有使用,这种现象叫做内存内碎片问题。

并发内存池concurrent memory pool

设计这个并发内存池concurrent memory pool主要就是要解决一下几个问题。

  1. 内存碎片问题。(尽量缓解)
  2. 性能问题。(提高申请内存的效率)
  3. 多核多线程环境下,锁竞争问题。

并发内存池concurrent memory pool主要由3部分组成: thread cache(线程缓存)、central cache(中心缓存)、page cache(页缓存)

整体结构如下:
Concurrent Memory Pool:高并发内存池(三层结构)_第2张图片
thread cache:线程缓存是每个线程独有的,线程从这里申请内存不需要加锁
central cache:中心缓存是所有线程所共享,达到内存分配在多个线程中更均衡的按需调度的目的
page cache:回收满足条件的span对象,合并相邻的页,组成更大的页,缓解内存碎片的问题

1.第一层thread cache

线程缓存(thread cache) 是每个线程独有的,用于小于64kb的内存的分配,线程从这里申请内存不需要加锁,这里应用到线程本地储存TLS(Thread Local Storage),保证每个线程独享一个thread cache,这样当线程来进行申请内存空间时就不会出现锁竞争问题。

那么大于64kb(16页)的呢?就直接去page cache里申请。
那么大于128页的呢?就直接去系统里申请。

thread cache基本框架:

class ThreadCache//线程缓存
{
private:
	FreeList _freeList[NLISTS]; // 自由链表(对象数组)
};

class FreeList//FreeList对象
{
private:
	void* _list = nullptr;
	size_t _size = 0;
	size_t _maxsize = 1;
};

thread cache本质是由一个哈希映射的对象自由链表构成。
Concurrent Memory Pool:高并发内存池(三层结构)_第3张图片
thread cache是一个对象数组,里面的每一个元素都是一个FreeList,每个FreeList都是一个对象,该对象是一个list的链表,后面挂的都是一个一个的内存块,8字节的FreeList对象后面链的都是8字节的内存块,16字节的FreeList对象后面链的都是16字节的内存块,一 一对应。

FreeList是以八字节间隔的。为什么不以4字节分隔呢? 原因是链在后面的list链表的存储方式是用前一个内存块的前四个或八个字节来存放下一个对象的地址,当在32位平台下指针是4字节,而64位平台下指针是8字节,如果是64位机器的话第一个链表的指针就存不下了,因此间隔应该设为8字节。

这里利用了TLS(Thread Local Storage)线程本地储存,该操作保证了每个过来申请内存的线程都有自己独立的thread cache,这就是这个内存池高效的地方。当线程过来申请内存时,都会去自己的线程缓存里拿内存,因此在这一层线程的内存申请不需要加锁,保证了申请时的效率。

申请内存的过程:
线程要申请内存不是直接去申请的,是被动去申请的,当一个线程来申请内存空间时,给它创建一个thread cache,同时调用并发申请接口去申请内存。(TLS保证了每个线程都有一个自己的thread cache)

当线程申请的内存小于等于64kb时,就直接在thread cache里对应位置的链表处申请,时间复杂度O(1),并且没有锁竞争。如果此时对应位置的链表上并没有挂内存块的话,就会去central cache里去申请一块批量的内存,然后返回一块空间,并将剩下的内存块挂在自己的thread cache里的对应FreeList里。

多线程并发的去central cache里获取批量内存时central cache要加锁,并且central cache是一个单例模式,全局只有一个,虽然这里会加锁导致线程串行去申请批量的内存,但是每个线程每次来申请的内存是批量的。假如一次申请的批量内存是50个,那么之后的49次都不需要再去找central cache要了,这也是这个项目效率比较高的原因。

释放内存的过程:

当某个线程用完一块内存要归还给它自己的thread cache时,直接根据内存块的大小找到对应的位置插入到对应位置的FreeList链表中。

如果链表的长度过长,就要回收一部分内存块到central cache里,保证每一个FreeList里不会挂太多的内存块,保证了资源的平衡。

2.第二层central cache

中心缓存是所有线程所共享,thread cache是按需从central cache中获取的对象。central cache周期性的回收thread cache中的对象,避免一个线程占用了太多的内存。达到内存分配在多个线程中更均衡的按需调度的目的。central cache是存在竞争的,所以从这里取内存对象是需要加锁,并且central cache在全局只有一个,因此要设计为单例模式,不过一般情况下在这里取内存对象的效率非常高,并且一次给thread cache的内存是批量的,所以这里竞争不会很激烈。

central cache基本框架:

  1. central cache要设计成一个单例模式(全局只有一个),它是由一个SpanList的自由链表(对象数组)组成。
class CentralCache//中心缓存
{
public:
	static CentralCache* GetInstance(){	return &_inst; }//获取单例对象
private:
	SpanList _spanlist[NLISTS];//SpanList的自由链表
private:
	CentralCache(){}//构造函数私有
	//防拷贝
	CentralCache(CentralCache&) = delete;
	CentralCache& operator=(CentralCache&) = delete;
	static CentralCache _inst;//单例对象
};

  1. 每一个SpanList都是一个双向带头循环链表,其中每个元素都是一个span
class SpanList//带头双向循环链表
{
private:
	Span* _head;//头节点
};
  1. span节点里又包括很多东西,因为要考虑承上(thread cache)启下(page cache)。其中_pageid_npage是给page cache用的,标识的是页号和页数;_prev_next是span节点里存放的前一个和后一个;_list是这个span下挂的自由链表,里面挂的是对应大小的内存块;_objsize是这里挂的内存块一块的大小;_usecount是对象的使用计数,当从该span拿走多少个对象时,_usecount就加多少,当所有对象都归还给这个span时,_usecount就会变为0,此时就可以将这个span移出该SpanList并检查是否可以合并。
struct Span//节点
{
	PageID _pageid = 0; // 页号
	size_t _npage = 0;  // 页数
	Span* _prev = nullptr;
	Span* _next = nullptr;
	void* _list = nullptr;	 // 链接对象的自由链表
	size_t _objsize = 0;	 // 对象的大小
	size_t _usecount = 0;	 // 对象使用计数
};

central cache的结构如下:
thread cache是一个一个的对象链表,central cache是一个一个的span链表(SpanList),每一个span是一个对象链表。
Concurrent Memory Pool:高并发内存池(三层结构)_第4张图片
申请内存的过程:

当thread cache里对应FreeList链表里没有挂内存块时,就会来central cache里拿内存,SpanList也是一个哈希映射的链表,根据要申请内存块的大小找到对应的SpanList,在SpanList中找一个不为空的span,然后将这个span中提前切好的内存块批量的给thread cache。

但是当在对应的SpanList链表里没有一个不为空的span时,就会去下一层page cache里去申请一个span对象,然后span对象里是以页为单位的内存,将这个span中的内存切成对应的大小,然后将这个span对象挂在这个SpanList里。

当thread cache来central cache里的span里拿内存块时,span中的_usecount会加上拿走内存块的个数。

释放内存的过程

当线程被销毁或者对应的thread cache里某个FreeList里挂的内存太多时,就会将内存批量的释放回central cache,释放回来的内存会释放到对应的span中。span里的_usecount会减去归还的内存块的个数,当span中的_usecount等于0时,就说明所有内存已经归还完毕,此时就需要把这个span弹出该SpanList,然后让这个span回page cache里检查看能不能进行span的合并。(所以在SpanList里不会存在_usecount等于0的span,因为一旦某个span的_usecount为0时,就会被移出这个SpanList,然后去page cache里进行span的合并)

但是这里归还回来的内存块极有可能来自不同的span,所以那我怎么知道每个内存块来自于哪个span呢?这里用到了STL中的关联容器map,建立了PageID和span的映射(key,value),同一个span切出来的内存块PageID都和span的PageID相同,这样就能很好的找出某个内存块属于哪一个span了。

3.第三层page cache

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

page cache基本框架

class PageCache
{
public:
	static PageCache* GetInstance(){ return &_inst; }//获取这个单例对象
private:
	SpanList _spanlist[NPAGES];
	std::map<PageID, Span*> _idspanmap;//建立PageID和span的映射
private:
	PageCache(){}//构造函数私有
	//防拷贝
	PageCache(const PageCache&) = delete;
	PageCache& operator=(const PageCache&) = delete;
	static PageCache _inst;//单例对象
};

page cache的结构如下:
Concurrent Memory Pool:高并发内存池(三层结构)_第5张图片
申请内存的过程:
当central cache的SpanList里没有span或者没有不为空的span时,central cache就会向page cache里申请一个span。首先会去看对应页数的SpanList里有没有挂span,如果有直接返回一个对应页数的span,如果没有就会向后遍历,寻找较大页数的span进行切分。如果找到128页的位置都没有找到一个span时,就会直接去系统VirtualAlloc一块128页的内存,然后进行切分。

具体的切分过程是这样的:假如我已经向系统申请了一块128页的内存然后挂在128页的SpanList上,此时我需要1页的span时,我会将这个128页的span切分成一个1页的span和一个127页的span,然后直接将这个1页的span切分好返回给central cache,然后central cache,然后将这个127页的span挂在127页的SpanList上。

释放内存的过程:
如果central cache的某个span的_usecount为0时,就会被释放回page cache里,此时page cache看到有个span回来了,会依次寻找span的前后PageID的span,看是否可以合并,如果合并继续向前寻找。这样就可以将切小的内存合并收缩成大的span,减少内存碎片。

具体合并的过程是这样的:假如现在有一个PageID为50的3页的span,有一个PageID为53的6页的span。这两个span就可以合并,会合并成一个PageID为50的9页的span,然后挂在9页的SpanList上。

你可能感兴趣的:(项目)