高并发内存中间件TCMalloc
高并发内存中间件是一个在多线程并发条件下进行内存申请的程序。是谷歌公司TCMAlloc的学习致敬,再简化版本模拟实现。
项目简介:
高并发内存中间件这个项目分为三个层次,这三个层次分别从上到下依次是ThreadCache、CentralCache、和PageCache。依靠三个层次的均衡调度来实现多线程下的内存申请与释放。
主要完成的功能:
在每一个线程都有自己的ThreadCache,ThreadCache中有一个自由链表的数组,不同的自由链表上挂载着不同大小的内存块,每个线程都可以直接在自己的ThreadCache中申请内存与释放,不需要再直接向系统中去申请,增大系统开销。在高并发多线程下,申请内存的效率与系统开销会小很多,避免多次频繁直接向系统申请。
该项目解决的问题:
-
解决内存碎片的问题(外碎片)
系统上的内存都是连续的空间地址,随着不断申请,系统中的连续内存只剩余一部分,这时如要申请更大的内存空间,系统就申请不出来。那么就会造成系统内存的利用效率更低。 -
高并发多线程环境下,申请内存的效率问题
在多线程下,所有的线程都会对内存空间争抢申请,系统只能将一个空间分配给一个线程去使用。这时肯定只有一个线程才可以申请到这个空间,那么其它申请到这个空间的线程只能等待系统的重新分配。
ThreadCache简单介绍:
在多线程下,所有的线程都会有独自的ThreadCache层,每一个ThreadCache中的自由链表专门为一个线程服务。每一个线程的申请与释放互不影响,这样申请时候就没有内存冲突,提高申请效率。
CentralCache简单介绍:
CentralCache是整个内存池的核心部分。主要有两方面的作用。
- 居中调度,均衡分配每一个ThreadCache的资源
- 当CentralCache中的内存空间过多时,需要做内存页的合并,将小的内存页合成大的内存页还给PageCache层,解决内碎片问题。
PageCache简单介绍:
PageCache是以内存页为单位管理内存。以页的方式向系统申请内存并将内存申请给CentralCache层。收回内存,释放内存归还给系统时,也是按照内存页的方式归还。
三者之间内存申请内存与释放释放的整体关系图
ThreadCache层的设计:
结构:
每一个ThreadCache中都有一个FreeList的自由链表数组,实现对不同大小内存块的管理。FreeList链表节点的含义是内存块的大小,不同的内存块下是挂载着的FreeList链表。
这里FreeList链表的跨度是从8byte到64k,也就是说内存块大小的范围是从8byte到64k(8192byte)。如果内存块的大小跨度都是以8byte为间隔,但是这样的话,一个FreeList自由链表上就有一共就有64k/8个节点,那一个FreeList为免也太长了。而且分的太细了将内存切的太细了,合并的时候也是问题。
所以我们根据我们定义的区间范围规定FreeList上内存块的跨度。图中可以看出,当内存块代大小为8、16、24时,这时候的内存跨度很小,就是8。 随着节点内存块的增大,跨度也就越来越大。
我们需要对FreeList上的节点跨度进行控制,防止FreeList节点的跨度切的太碎。
FreeList上节点的跨度: // 控制在10%左右的内存碎片
[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)
我们还要通过工具类中的对齐函数对链表中的每一个特定内存块节点的数量进行着控制。控制在[0,512]之间,也就是说每一个内存块(8byte,16byte。。。64kb)的个数不能超过512个。超过之后需要调用ListTooLong函数释放个Central层。
ThreadCache层的接口设计:
class ThreadCache
{
public:
void *Allocte(size_t size); //申请size大小的内存
void Deallocte(void* ptr, size_t size); // 将size大小的ptr内存释放ThreadCache的自由链表中
void *FetchFromCentralCache(size_t index); // THreadCache中内存不够,从中心缓存获取对象
void ListTooLong(FreeList& freeList, size_t num, size_t size);//释放内存到中心num个size内存到centralCache
private:
FreeList _freeLists[NFREE_LIST]; // 只有链表数组, NFRRRLIST就是所有数组的大小,这里只需要知道小于小于64k/8
};
我们就重点讲一下内存申请与释放的重要接口:
ThreadCache层的内存申请与释放
申请内存 :
接口 :ThreadCache::Allocte(size_t size);
申请步骤:
- 算出这个对象大小size算出自由链表FreeList的下标
- 如果size对应的FreeList下标不为空,则取出一个内存对象给线程使用;如果不存在则ThreadCache通过FetchFromCentralCache直接向CentralCache申请。
接口:void * ThreadCache::FetchFromCentralCache(size_t size)
若FreeLists上对应的size大小的内块的数量如果不够的话,找CentralCache对应的size大小的spanlist上取回一批量size大小的内存块。
释放内存:
接口: ThreadCache::Deallocte(void*ptr,size_t size);
当单个线程申请的内存使用完时,要归还回给ThreadCache中的FreeList数组对应下标freelist上
释放步骤:
- 根据这个对象的大小算出要归还的自由链表数组FreeList的下标,然后找到链表freelist的位置
- 将这个内存ptr头插进对应位置的freelist上(O(1)操作)
- 如果链表上size大小的内存块没有达到一定量,则不用释放,如果自由链表上挂载着的size内存块的数量达到一定量的情况下,调用ListTooLong函数会主动释放节点下的内存块
CentralCache层的设计
结构设计
下面的图只是为了看的更明白。其实就每一个内存块大小都对应这一个SpanList的双向链表,每一个双向链表的节点下又挂载着这一大小的内存块。
到这里我们需要引入一个新的概念:span
span的结构:
struct span
{
size_t _page_id; // PageCache 页号
int _pagesize; //
size_t _objSize; // 自由链表后对象的大小
FreeList _freeList; //对象的自由链表
int _usecount; //内存对象的使用计数
Span* _next; // 双向链表后指针
Span* _prev; // 双向链表的前指针
};
span为什么要这样设计的原因:
_pageid: 是申请的时候系统内存地址的页号,(系统地址/4k) ,span回收的时候也必须连续
_pagesize: 页的数量,记录是哪一个span分割的
_objSize: 对象的大小,记录这个span被切割成的内存块大小
_freeList: 对象要挂载到span的后面
_usecount:对象的使用计数,当使用计数为0时,说明这个span的空间全部归还回来
CentralCache层的接口设计:
class CentralCache
{
public:
//从CentralCache获取一定数量的内存对象给threadCache
size_t FetchRangeObj(void*& start, void*& end, size_t num, size_t size);
//将一定数量的对象释放到span跨度
void ReleaseListToSpans(void* start, size_t size);
//从Spanlist或者pageCache中获取一个span
Span* GetOneSpan(size_t size); //size是多大内存块的span
static CentralCache& GetInstance() //单例模式
{
static CentralCache inst;
return ist;
}
private:
CentralCache() = delete;
CentralCache(const CentralCache&) = delete ;
SpanList _spanLists[NFTRR_LIST];
};
注意:
- size_t FetchRangeObj(void*& start, void*& end, size_t num, size_t size) 是从哪个位置开始到哪个位置结束num个size大小的内存块申请给threadcache
- GetOneSpan是threadcache获取一个span,如果CentrealCache层中这个内存块节点对应的spanlist上有节点,则直接将这个内存节点申请给ThreadCache层,若spanlist中没有节点,则需要向PageCache申请这样大小的span。
- CentrealCache也是全局只有一个,所有要封装成单例模式。
CentralCache层内存的申请与释放
申请内存:
接口:Span* CentralCache::GetOneSPan(size_t size)
- 从spanlist双向链表中获取一个span,根据内存块size的大小,可以计算出所在的spanlist数组下标
- 遍历这个下标对应的spanlist双向链表,看这个size大小的spanlist中是否有一个span。
- 若含有一个span,直接将这个span作为结果返回 (尽管ThreadCache申请是几个或多个,就直接给一个对应大小的span)
- 若spanlist双向链表中没有一个span,那么就j计算申请PageCache多少页的内存,然后直接调用NewSpan函数向PageCache中申请指定页数span。
申请内存:
接口:size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t num, size_t size)
从Central获取一定数量的对象给ThreadCache
- 根据对象的大小size算出ThreadCache层的FreeList数组的下标index,找到指定的freelist链表
- 根据index也会将spanList数组中找出特定的spanlist,并获取一个span,将这一段空间头插进freelist链表中
释放内存:
接口:void CentralCache::ReleaseListToSpans(void* start, size_t size)
- 根据size大小,算出spanLists数组的下标index
- 找到对应的spanlists,然后将这一段内存循环插入spanlists上,但是要注意不一定是spanlist上的同一块span,因为有个map<page_id,span*>的映射关系。(PageCache中会提到)
- 返还给spanlist上的各个span,span接收到对象归还时,_usecount–,直到usecount ==0,那么这一整个span全部都归还回来了,如果这个span的usecount为0,那么spanlist就会释放这个span节点,调用ReleaseSpanToPageCache函数直接归还给PageCacge层。
PageCache层的设计
结构
是以页为单位span的有自由链表(一页4k,4096byte)。链表中一共有128个节点,代表了1-128对应的从内存4k到128* 4k。对应多大的内存,链表节点后面就挂有对应大小的span。
初始时,PageCache层没有内存,PageCache就以页的方式向系统申请内存,一次申请128Page的内存,也就是512MB的内存,挂载到PageCache中的128Page对应的位置。每一次被申请的时候就会将128页大小的span切割为申请的大小,剩下的span挂载到剩余的位置,以备下一次的申请,减小向内存的多次申请,提高内存的申请效率。
例如:PageCache层一开始没有内存,当CentralCache层申请内存的时候,要申请3page大小的span,此时PageCache没有内存,只能向系统申请一个128page的内存,挂载到128page对应的位置,分给3page以后,还剩下125page大小的span,挂载到125page的地方,以备下一次CentralCache的申请。
PageCache层的接口设计:
class PageCache
{
public:
Span* _NewSpan(size_t numpage); // 递归的步骤,防止产生递归锁
Span* NewSpan(size_t numpage); // 给CentralCache层申请一个numpage页的span,若没有则向系统申请一个128页的,在切割
void ReleaseSpantoPageCache(Span* span); // 将小的span合成大的span归还到PageCache中对应位置
Span* GetIDToSpan(size_t id); // 通过映射关系返回span
static PageCache& GetInstance() // 单例模式
{
static PageCache pageCacheInst;
return pageCacheInst;
}
private:
SpanList _spanLists[MAX_PAGE]; // 128页的一个span链表 MAX_PAGE=128
map<PAGE_ID,Span*> _idSpanMap; //建立 页号与span之间的映射
PageCache() = delete;
PageCache(const PageCache&) = delete;
};
注意点:
page_id是我们向系统申请的时候,系统的地址页
在PageCacge层我们又要引入一些概念:
- **需要一个映射关系map< span *,page_id>的关系映射 **。因为我们申请内存时,是向系统中申请的,系统中的内存是连续的,我们连续的申请了一段内存(128page),所以释放归还的时候也必须是连续的内存。但是系统中的申请的内存都在PageCache中被切成一部分一部分,多个span去申请给CentralCache,但是找回的时候,不知道内存是连续的,就没有办法在合并出一个完整连续的内存还给系统。所以,当我们每一次去以页的方式申请内存时,要将系统分配的地址的位置记下来,映射到每一个span中,所以一个map<span *,page_id>的映射至为重要,尽管大的span切的再碎小,我们可以通过span映射的page_id,看这个span是属于哪一个page_id的,系统中哪一个内存页的。到时候回收合并内存,归还系统的内存也是完整连续的。
- PageCache层,全局只有一个,所有要设置成单例模式。
PageCache层的内存申请与释放:
申请内存:
当CentralCache向PageCache申请内存时,PageCache先简称对应的位置有没有span。
- 如果没有则沿着PageCache向后遍历,看是否有更大的span,若有更大的span,则将这个span切分成CentralCache要申请的span,和剩余的span,再将剩余的Span挂载到PageCache中对应的cache中。例如Central申请的是3page,3page后面没有挂载对应大小的span,则向后寻找,正好8page位置上挂有span,则将8分裂成5page,挂载到PageCache中5page的位置,3page正好申请给Central层。
- 如果遍历到128Page中,还没有找到更大的span,此时说明PageCache中没有大的内存可以提供给CentralCache层了,所以此时就要向系统以页的方式申请更大的内存。例如:CentralCache层申请3page的内存,但是此时PageCache中是空的,每一个位置上都没有span.此时PageCache要向系统申请一个128page的span,然后分成一个125page的挂载到对应位置,3page的申请给Centralcache层。
释放内存:
当CentralCache层释放回来一个特定大小的span时,会检查span对应的page_id的span,也就是看和自己连续的空间是否都已经被使用完了,看是否可以合并,如果可以合并,可以合并成更大的Span,这样的就可以等到下次申请的时候,重新切割分成CentralCache想要的大小,减少内存碎片。
总结:
我们这个高并发内存池,只需要向内存中以128page的方式向内存中申请几次,没使用到的内存,还一直保存在这个内存池中。下一次申请的时候,可以不直接向系统申请,减少多次向系统申请的消耗,提高申请效率。
项目技术点
- STL容器
在项目中的PageCache层中,要记录span*与page_id的映射关系,每一个span都对应了一个地址页(4K)。合并内存释放内存的时候要通过span找到映射的地址页,然后看前后的地址页对应的span是否归还完成,一起合并归还给系统。 - 单例模式
在项目中的CentralCache与PageCache层,在多线程的环境下,全局有且只能有一个对象,所以我们要将CentralCache与PageCache设置为单例模式。 - 线程TLS(Thread-Local Storage)
在多线程,高并发的环境下,多个线程是会共享全局变量的,我们只希望同一个线程里面调用的各个函数都可以访问这个数据,不希望其它线程访问到别的线程的数据。线程的TLS就是在程序中每个线程都会分别维护一份变量的副本,并且长期存在于该线程中,对此类变量的操作不影响其他线程。因为在项目中我们的ThreadCache层对每一个线程都有独自的ThreadCache层,且只能访问自己的ThreadCache层中的FreeList数组,所以我们要将ThreadCache类设置成TLS
项目地址:
https://github.com/permanentbelief/project/tree/master/ConcurrentMemoryPool