高并发内存池

本文深入解析内存池技术,包括内存池的概念、为何需要内存池、内存池的演进过程及四种内存池类型:简单内存池、O1定长内存池、哈希映射的FreeList池和高并发内存池。探讨了高并发内存池的内部结构,如ThreadCache、CentralCache和PageCache的工作原理,以及申请和释放内存的流程。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

目录

1.什么是内存池

2.为什么需要内存池

3.内存池的演变

第一种内存池:简单内存池

第二种内存池:O1定长内存池

第三种内存池:哈希映射的FreeList池

第四种内存池:高并发内存池

Thread Cache

Central Cache

Page Cache

整个内存池的模型:

申请内存:

释放内存:

申请释放的流程

大内存的申请

项目实现细节

Page

PageID

Span

空闲对象链表

PageMap

划分跨度

一次移动多个空闲对象

一次申请多个page

计算任意内存地址对应的对象大小

小结

Page Cache

空闲Span管理器

向系统申请内存

sbrk

mmap

最后的搜索

删除Span


1.什么是内存池

内存池是一种动态内存分配与管理技术。在真正使用内存之前,先申请分配一大块内存(内存池)留作备用,当程序员申请内存时,从池中取出一块动态分配,当程序释放内存时,将释放的内存再放入池内,再次申请池可以再取出来使用,并尽量与周边的空闲内存块合并。若内存池不够时,则自动扩大内存池,从操作系统中申请更大的内存池。

2.为什么需要内存池

C/C++下有一个让人头疼的问题,分配足够的内存,追踪分配的内存,在不需要的时候释放内存---这个任务相当复杂。而直接调用malloc/free、new/delete进行内存分配和释放,有以下弊端:

  1. 调用malloc/new,系统需要根据“最先匹配”、“最优匹配”或其他算法在内存空闲块表中查找一块空闲内存,调用free/delete,系统可能需要合并空闲内存块,这些会产生额外开销;
  2. 频繁使用时会产生大量内存碎片,从而降低程序运行效率;
  3. 容易造成内存泄露。

3.内存池的演变

第一种内存池:简单内存池

一个链表指向空闲内存,分配内存就是从链表中取出一块来;释放内存就是放回到链表里面。再次申请的时候先到还回来的内存中合适大小的内存块,如果没有就到链表中取,到还回来的内存中的时候有消耗,在还回来的时候可以进行归并。

优点:实现简单

缺点:分配时搜索合适的内存块效率低,释放时进行归并消耗大,实际中不使用。

第二种内存池:O1定长内存池

指定这个内存池解决的问题是多少字节的,

优点:简单,分配释放效率高,解决实际中特定场景下的问题有效。

缺点:功能单一,解决定长的问题;不知道哪一块使用了哪一块没使用;多线程使用的时候要加锁,性能损耗

第三种内存池:哈希映射的FreeList池

按照不同对象大小,构造十多个固定内存分配器,分配内存是根据内存大小查表,决定到底由哪个分配器负责。从大内存切下来8.16.24......128,还回来的时候回到对应的哈希下面,再次申请的时候可以直接从哈希表里拿出一个还回去。

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

缺点:多线程场景下锁竞争激烈,效率降低;内存碎片;申请了1亿个16字节,内存已经不够了,进程想申请24字节的内存,分配不出来

第四种内存池:高并发内存池

分为三个部分:都类似于哈希桶的结构

Thread Cache

每个线程都有一份单独的缓存,称之为Thread Cache。每个不同大小的size class都有一份单独的FreeList,缓存了n个还未被该应用程序使用的空闲对象。对象的分配直接从Thread Cache里的FreeList中返回一个空闲对象,相应的,小对象的回收也是重新放回到Thread Cache汇总对应的FreeList中。因为每个线程一个Thread Cache,因此从Thread Cache中申请或回收内存是不需要加锁的。Thread Cache被抽象为一个数组,有240个元素。

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

class ThreadCache{
    private:
        FreeList _freelist[NLISTS];//自由链表,NLISTS=240
};

static _declspec(thread) ThreadCache* tls_threadcache = nullptr;
//一个变量不想使多个线程共享访问,使用TLS, 线程本地存储. 
//tls有静态的动态的,此处定义的静态的 ,是ThreadCache指针,

Central Cache

与Thread Cache类似,Central Cache中对于每个size class也都有一个单独的链表来缓存空闲对象,称之为SpanList,供个线程的Thread Cache从中取用空闲对象。Central Cache是所有线程公有的,因此从Central Cache中取用或回收对象,是需要加锁的。为了减轻锁的开销,我们对SpanList中的每个对象加锁,而不是对整个SpanList加一个大锁。而且Thread Cache从Central Cache中一次性取用或回收多个对象。

Thread Cache资源分配不均衡,Central Cache是进行均衡的,避免某个Thread Cache使用资源太多了。Thread Cache当某个桶还回来的时候,超过了单次向系统申请的上限之后,就进行一定程度的回收。

Central Cache 是一个逻辑上的概念。

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;		// 使用计数
};
class SpanList{
    private:
	    Span* _head = nullptr;
};

class CentralCache{
    public:
	    static CentralCache* GetInstance(){
		    return &_inst;//240个对象
	    }

    private:
	    // 中心缓存链表
	    SpanList _spanlist[NLISTS];//NLISTS = 240
    private:
	    static CentralCache _inst;
};

Central Cache中的SpanList模型:
加锁的时候,是只给SpanList之中的某一个节点加锁,大大减少了锁竞争的问题。

Page Cache

linux下是brk,mmp;windows下VirtualAlloc

假设要分配一块内存,其大小经向上取整之后对应k个page,因此需要从Page Cache取一个大小为k个page的Span,过程如下:

  1. 从k个page的Span开始,到128page的Span链表,按顺序找到第一个非空链表。
  2. 取出这个非空链表的一个Span,假设有n个page,将这个Span拆分成两个Span:
         一个Span大小为k个page,作为分配结果返回。
         另一个Span大小为n-k个page,重新插入到n-k个page的Span链表中。
  3. 如果找不到非空链表,则向系统申请。

每个对象都映射一个Span,以页号映射Span,对象的指针右移12位就是页号,映射通过unordered_map。当一个Span内所有的内存都还回来了(使用usecount来计数,使用一个就加一,当usecount=0时,就代表这个span里的内存都还回来了),就可以把这个Span还给Page Cache

还给Page Cache后,进行归并,合并成更大的页,向前合并,向后合并,合并完了也不用还给系统,算出合并完了这个span有多少个页,再挂到对应的spanList上。

class PageCache{
    public:
	    static PageCache* GetInstance(){
		    return &_inst;//返回的有129个对象
	    }
    private:
	    SpanList _pagelist[NPAGES];//NPAGES = 129,下标0那个不使用
};

整个内存池的模型:

注意:中心缓存和页缓存使用的同一个对象来管理内存的,只是使用了这个对象中的不同数据成员。 

申请内存:

  1. 将要申请的内存大小进行内存对齐,再映射到对应的size class;

  2. 查看Thread Cache中该size class对应的FreeList;

  3. 假设那个节点是freelist,此时如果freelist节点里面有闲置的对象,就直接返回给线程去使用;

  4. 如果没有就到Central Cache里面去申请内存;

  5. 此时到Central Cache里来申请内存,线程需要一个对象,我们为了提升效率可以返回不止一个对象,此时有一个算法来确定到底返回多少个对象。算法:从两个值里取较小值,第一个值是Thread Cache里的FreeList的_maxsize,第二个值是根据需要申请的字节数得来的:内存池管理的最大字节是64k,如果申请的大小就是64k的话,那么内存池只能给出一个对象,但是为了效率,我们给了两个;如果内存申请的字节数很小,就给定一个上限:512。因为最小的对象是8个字节,给出512个对象,此时申请出的的字节数就是512*8=4096=1页;

    1. 此时知道了要获取多少个对象,就通过中心缓存调用函数来返回对象

      1. 因为可能有不同的线程来操作Central Cache,因此要对当前的Span进行加锁(通过需要的字节数来确定当前Span)

        1. 要想从Central Cache里获取对象,首先要获取一个Span

        2. Central Cache里面的_spanlist对应节点下面如果有span对象就直接返回

        3. 如果没有就去Page Cache获取一个新的Span

          1. 如果对应的_pagelist节点没有Span,就往更大的Span去分割,如果直到128页都没有找到就去系统申请128页出来

          2. 一个线程第一次申请内存的时候都会走到这一步,也就是向系统申请128页内存,此时就要进行Span到对象的映射。

          3. 获取页时要进行分割,把你需要的页返回,而剩下的页就挂在对应的_pagelist节点后面

        4. 此时获取到了对应的Span,因为这个Span是新的,此时的Span说白了就是一段多少页大小的内存,如果不进行处理就是这么一大段内存。我对这一大段内存的处理方法是:把新Span分割成一个一个的bytes字节数的对象,并且连接起来,对象的前4个字节(64位下是前8个字节)指向后一个对象的首地址。返回新的Span。

      2. 此时获取到了已经被处理好的Span,就要从Span里获取对象,但是实际获取的对象可能没有期望获取的对象那么多,此时要重新定义一个变量来记录实际获取的对象数量

      3. 分配出去的内存是前面一段,后面剩下的内存继续挂在Span上;

    2. 此时的Central Cache里已经有对象了

  6. 如果Span的对象多于一个,就返回一个给Thread Cache,其余多申请的对象就插入到FreeList里面;

  7. 如果第5步算法给出的最小值是_maxsize的话,此时要将_maxsize+1,便于下次申请的时候,多申请1个,提高效率;

释放内存:

  1. 释放指针所指向的内存,此时通过指针来映射相对应的span,映射关系是我们在申请内存时就做好的,此时也就用到了这个映射关系。

  2. 所谓释放内存,也就是将指针指向的位置向后移

  3. 还回来的对象已经达到了freelist节点的上限时,就说明此时threadcache里这个freelist下面的内存全部还回来了,就可以还给centralcache了

    1. 根据所还对象的字节数获得对应下标,从而获得对应的spanlist

    2. 找到还过来的内存对应的span,将该内存挂在该span的后面就好了,

    3. 将使用计数--

    4. 如果使用计数==0,就将该span还回到pagelist,并合并

      1. 向前合并

      2. 向后合并

      3. 合并完了,就知道该Span最终有多少页,再统一映射 

申请释放的流程

大内存的申请

如果是大内存(大于64kB)的申请,要将程序所要申请的内存大小向上取整到整数个page(也就是4k的倍数,产生1B~4kB的内部碎片),Thread Cache直接向Page Cache申请一个指定page数量的Span并将其起始地址返回。也就是说,如果是大内存的申请,就绕过了Central Cache。申请下来的这一大段内存也不会放在_pagelist中,但是映射到map中,释放内存的时候通过页号找span,通过span找对象的大小,来释放。

项目实现细节

在介绍高并发内存池的时候涉及到了很多概念,比如Page,Span,Size Class等,只是粗略的提及,此处做一些详尽的解释。

Page

Page是项目管理内存的基本单位,这里的page 要区分于操作系统管理虚拟内存的page,我设置的1page = 4KB。page越大,项目的速度就相对越快,但其占用的内存也会越高,简单地说就是空间环时间的道理。

PageID

项目并非只将堆内存看做是一个个的page,而是将整个虚拟内存空间都看做是page的集合。从内存地址0*0开始,每个page对应一个递增的PageID,如下图:

typedef size_t PageID;

 

对于任意内存地址ptr,用简单的移位操作来计算其所在page的PageID:ptr>>12(1<<12 = 4KB)

Span

一个或多个连续的Page组成一个Span(a contiguous run of pages)。TCMalloc以Span为单位向系统申请内存。

如图,第一个span包含2个page,第二个和第四个span包含3个page,第三个span包含5个page。

一个span记录了起始page的PageID(_npageid),以及所包含page的数量(_npage)。

一个span要么被拆分成多个相同size class的小对象用于小对象分配,要么作为一个整体用于大对象分配。当作用作小对象分配时,span的_objsize成员变量记录了其对应的size class。

span中还包含两个Span类型的指针(_prev,_next),用于将多个span以链表的形式存储。

struct Span
{
	PageID _pageid = 0;		// 页号,地址右移12位
	size_t _npage = 0;		// 页的数量

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

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

空闲对象链表

被拆分成多个小对象的Span还包含了一个记录空闲对象的链表_objlist,由CentralCache的SpanList来维护。

对于新创建的span,将其对应的内存按size class的大小均分成若干小对象,在每一个小对象的起始位置处存储下一个小对象的地址,首首相连:

但当span中的小对象经过一系列申请和回收之后,其顺序就不确定了:

可以看到,闲对象链表objlsit中有些小对象已经不再空闲了,链表中的元素顺序也已经被打乱。

空闲对象链表中的元素乱序没什么影响,因为只有当一个span的所有小对象都被释放之后,CentralCache中的SpanList才会将其还给PageHeap。

PageMap

PageMap之前没有提到过,它主要用于解决这么一个问题:给定一个page,如何确定这个page属于哪个span?

即,PageMap缓存了PageID到Span的对应关系。

Size Class

项目将每个小对象的大小(1B~64KB)分为4个类别,称之为Size Class,每个size class一个编号,从0开始递增(实际编号为0的size class是对应0字节,是没有实际意义的)。

划分跨度

  • 128字节以内,每8字节划分一个size class。
  • 144~1024字节,每16字节划分一个size class。
  • 1152字节~8192字节,每128字节划分一个size class。
  • 8704字节~64KB,每512字节划分一个size class。
  • [x,y)区间的内存浪费率:(y-x-1)/y

一次移动多个空闲对象

Thread Cache会从Central Cache中获取空闲对象,也会将超出限制的空闲对象放回Central Cache。Thread Cache和Central Cache之间的对象移动是批量进行的,即一次移动多个空闲对象。Central Cache由于是所有线程公用,因此对其进行操作时需要加锁,一次移动多个对象可以均摊锁操作的开销,提升效率。

那么一次批量移动多少呢?的内存是不定的,由size class和Thread Cache中的制约因素决定,但至少2个,至多512个。

移动数量的计算也是在size class初始化的过程中计算得出的。

一次申请多个page

对于每个size class,向系统申请内存时一次性申请n个page(一个Span),然后均分成多个小对象进行缓存,以此来均摊系统调用的开销

不同的size class对应的page数量是不同的,如何决定n的大小呢?从1个page开始递增,一直到均分成若干小对象后所剩的空间小于Span总大小的1/8为止,因此,浪费的内存被控制在12.5%以内。这是本项目减少内部碎片的一种措施。

计算任意内存地址对应的对象大小

当应用程序调用ConcurrentFree()释放内存时,需要有一种方式获取所要释放的内存地址对应的内存大小。结合前文所述的各种映射关系,在项目中按照以下顺序计算任意内存地址对应的对象大小:

  • 计算给定地址计所在的PageID(ptr >> 13)
  • 从PageMap中查询该page所在的Span
  • Span中记录了size class编号
  • 根据size class编号从Thread Cache中的FreeList数组中查询对应的对象大小

这样做的好处是:不需要在内存块的头部记录内存大小,减少内存的浪费

小结

size class的实现中有很多省空间省时间的做法:

  • 省空间
    • 控制划分跨度的最大值(8KB),减少内部碎片
    • 控制一次申请page的数量,减少内部碎片
  • 省时间
    • 一次申请多个page
    • 一次移动多个空闲对象

Page Cache

前面介绍的都是项目如何对内存进行划分,接下来看项目如何管理如此划分后的内存,这是Page Cache的主要职责。我的Page Cache中只有一条链表对Span进行维护。

空闲Span管理器

128page以内的Span称为小Span,128page以上的Span称为大Span。Page Cache对于这两种Span采取了不同的管理策略。小Span用链表,而且每个大小的Span都用一个单独的链表来管理。大Span不放到pagelist中,但是映射到map中,方便释放内存(释放内存的时候通过页号找span,通过span找对象的大小,来释放)。

向系统申请内存

项目向系统申请应用程序所使用的内存时,每次至少尝试申请64KB。这样做有两点好处:

  • 减少外部内存碎片(减少所申请内存与项目元数据所占内存的交替)
  • 均摊系统调用的开销,提升性能

sbrk

先来看如何使用sbrk()从Heap段申请内存,下图展示了SbrkSysAllocator::Alloc()的执行流程,为了说明外部碎片的产生,并覆盖到SbrkSysAllocator::Alloc()的大部分流程,假设page大小为8KB,所申请的内存大小为16KB:

  1. 假设在申请内存之前,pb(program break,可以认为是堆内存的上边界)指向红色箭头所示位置,即没有在page的边界处。
  2. 第一次sbrk申请16KB内存,因此pb移至绿色箭头所示位置。
  3. 由于需要对申请的内存按page对齐,因此需要第二次sbrk,pb指向蓝色箭头所示位置,page的边界处。
  4. 最终,返回的内存地址为黑色箭头所示位置,黑色和蓝色箭头之间刚好16KB。

可以看出,红色箭头和黑色箭头之间的内存就无法被使用了,产生了外部碎片

mmap

为了覆盖MmapSysAllocator::Alloc()的大部分情况,下图假设系统的page为4KB,项目中的page为16KB,申请的内存大小为32KB:

  1. 假设在申请内存之前,mmap段的边界位于红色箭头所示位置。
  2. 第一次mmap,会在32KB的基础上,多申请(对齐大小 - 系统page大小) = 16 -4 = 12KB的内存。此时mmap的边界位于绿色箭头所示位置。
  3. 然后通过两次munmap将所申请内存的两侧边界分别对齐到TCMalloc的page边界。
  4. 最终申请到的内存为两个蓝色箭头之间的部分,返回左侧蓝色箭头所指示的内存地址。

如果申请内存成功,则创建一个新的Span并立即删除,可将其放入空闲span的链表或set中,然后继续后面的步骤。

最后的搜索

最后,重新搜索一次空闲Span,如果还找不到合适的空闲Span,那就认为是创建失败了。

至此,创建span的操作结束。

删除Span

当Span所拆分成的小对象全部被应用程序释放变为空闲对象,或者作为大对象使用的Span被应用程序释放时,需要将Span删除。不过并不是真正的删除,而是放到空闲Span的链表中。

删除的操作非常简单,但可能会触发合并Span的操作,以及释放内存到系统的操作。

合并Span

当Span被删除时,会尝试向前向后合并一个Span。

合并规则如下:

  • 只有在虚拟内存空间上连续的Span才可以被合并。
  • 只有同是所有小对象全部被释放的Span或者被释放大对象Span才可以被合并。

TCMalloc是谷歌的一个项目,谷歌在使用malloc的过程中开发出来的一个项目,直接连接TCMALLOC的库,代码量是在5w行左右,我的项目只取了最核心的部分,没有考虑兼容平台。

按照所分配内存的大小,TCMalloc将内存分配分为三类:
 1. 小对象分配,(0, 256KB] 
 2. 中对象分配,(256KB, 1MB] 
 3. 大对象分配,(1MB, +∞)
我的项目把内存分配分为2类,小对象(0,64k],(64k,+∞)。

跨度划分、
大Span用set、
CMalloc会使用sbrk向系统申请内存,失败了就用mmap
void* ptr = VirtualAlloc(NULL, (npage) << PAGE_SHIFT, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);

与操作系统管理内存的方式类似,TCMalloc将整个虚拟内存空间划分为n个同等大小的Page,每个page默认8KB(我设置的4KB)。又将连续的n个page称为一个Span。
TCMalloc定义了PageHeap类来处理向OS申请内存相关的操作,并提供了一层缓存。可以认为,PageHeap就是整个可供应用程序动态分配的内存的抽象。
PageHeap以span为单位向系统申请内存,申请到的span可能只有一个page,也可能包含n个page。可能会被划分为一系列的小对象,供小对象分配使用,也可能当做一整块当做中对象或大对象分配。

项目不足:

1.替换系统的malloc,free:不需要替换malloc,free。在linux,unix下有弱符号这个东西gcc的weak.alias属性(其他平台有hook的钩子技术来实现),所有调用malloc的地方就会去调用这个替换,而不是malloc。new的底层也是如此

2.项目独立性不足。不足以替换malloc,span对象都是new出来的,可以实现一个定长内存池,调用virtualalloc或者brk,替换new和delete。提高效率。unordered_map也调用了malloc,可以使用基数树替换掉unordered_map,实际上谷歌的TCMALLOC项目就是使用的基数树。基数树是将long整数和指针键值相关联的机制,参考博文:https://blog.csdn.net/weixin_36145588/article/details/78365480

3.平台及其兼容性;
Linux下用brk
x64下的map;

4.大规模的申请内存时,unorder_map会拖后腿,此处也有malloc,假设32位系统有4G内存(实际上没有,系统还占用一部分)4*1024*  1024*1024,100w个页放到map里面,也还行,但是如果是64位系统呢,一页是2^12,此时有2^52个页;改进方法可以用基数数,用位来表示,用一个位来映射页号,提高效率,节省空间。基数树实现的过程中也要使用 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值