高并发内存池
1.什么是内存池
1.1 池化技术
池化技术是将程序中需要经常使用的核心资源
先申请出 来,放到一个池
,由程序员自己管理,这样可以提高资源的使用效率,它可以避免核心资源申请和释放带来的开销,也可以保证本程序占有的资源数量。 经常使用的池化技术包括内存池、线程池和连接池
等。
下面我将简要解释常见的池化技术,包括内存池、线程池和连接池:
-
内存池(Memory Pool): 内存池是一种用于管理内存分配和释放的技术,通过预先分配一定数量的内存块,并在需要时从这些内存块中分配给应用程序。这有助于减少动态内存分配的开销、降低内存碎片,并提高内存管理的效率。
-
线程池(Thread Pool): 线程池是一组预先创建的线程,它们等待执行任务。当应用程序有任务需要执行时,可以将任务提交到线程池,线程池中的空闲线程将负责执行这些任务。线程池的使用可以避免频繁创建和销毁线程的开销,提高线程的复用率,以及更好地管理并发任务。
-
连接池(Connection Pool): 连接池用于管理数据库连接或其他资源密集型连接的池化技术。连接池在应用程序启动时预先创建一定数量的连接,并在需要时将这些连接分配给应用程序使用。这减少了每次请求都需要建立新连接的开销,提高了数据库连接的效率。
这些池化技术都是为了提高资源的使用效率、降低开销、提高系统性能而设计的。它们在不同的应用场景中都有广泛的应用,特别是在需要频繁使用和释放资源的情况下,如网络服务器、数据库访问等。
1.2 关于内存池
内存池(Memory Pool)是一种动态内存分配与管理技术。 通常情况下,我们可以直接使用 new、 delete、malloc、free 等 API 申请分配和释放内存。这样导致的后果是:当程序长时间运行时,由于所申请内存块的大小不定,频繁使用时会造成大量的内存碎片从而降低程序和操作系统的性能。
内存池则是在真正使用内存之前,先申请分配一大块内存(内存池)留作备用,当程序员申请内存时,从池中取出一块内存,当程序员释放内存时,将释放的内存再放入池内,再次申请池可以再取出来使用,并尽量与周边的空闲内存块合并。若内存池不够时,则自动扩大内存池,从操作系统中申请更大的内存池。
以下是一些关键点:
-
内存池的动态管理: 内存池是一种动态内存分配与管理技术。它在程序运行之初申请一大块内存,称为内存池,在程序的整个生命周期内用于分配和释放小块内存。
-
内存池的备用: 内存池的主要目的是在真正需要内存之前,先预先分配一部分内存,作为备用。当程序需要分配内存时,可以直接从内存池中取出一块内存,而不是使用标准的动态内存分配函数。
-
内存池的优势: 内存池的使用有助于减少内存碎片,提高内存利用率。由于内存池是在程序运行时预先分配的,因此可以更高效地管理内存,降低内存分配和释放的开销。
-
自动扩展: 如果内存池的空间不够,一些实现会支持自动扩展内存池的能力。这意味着当内存池中的内存不足以满足分配请求时,内存池可以动态地向操作系统请求更多的内存。
-
合并空闲内存块: 内存池还可以尽量合并周围的空闲内存块,以提高内存的整体利用效率。
2.内存碎片问题
造成堆利用率很低的一个主要原因就是内存碎片化
。内存碎片是指系统中存在未被使用的小块内存,这些小块内存虽然总和可以满足分配请求的总大小,但却无法满足某个具体请求的大小。
内存碎片可以分为两种主要类型:外部碎片和内部碎片。
-
外部碎片(External Fragmentation): 外部碎片指的是未被使用的、散落在已分配和已使用内存块之间的小块内存。当有一些小块内存散落在内存中,但无法组合成足够大的连续块以满足大块内存请求时,就会发生外部碎片。
-
内部碎片(Internal Fragmentation): 内部碎片是已经分配给某个特定程序或任务的内存块中未被使用的部分。它是由于内存块的分配方式而导致的,例如分配的内存块可能稍微大于实际需要的内存大小,导致剩余部分无法被有效利用。(假设以前分配了
10
个大小的字节,现在只用了5
个字节,则剩下的5
个字节就会内碎片
)。
内存碎片问题的解决方案包括:
-
内存池管理: 使用内存池技术可以减少内存碎片。内存池通过预先分配一定数量的内存块,然后在应用程序需要时分配这些块,从而减少外部碎片和提高内存利用率。
-
紧凑算法: 定期执行内存紧缩操作,将已分配的内存块进行整理,以减少外部碎片。这可能涉及将已分配的内存块移到一起,以便形成更大的连续块。
-
动态内存分配算法: 使用更智能的内存分配算法,避免过度分配导致的内部碎片。例如,使用按需分配的策略,只分配应用程序实际需要的内存大小。
-
内存压缩: 一些系统可以采用内存压缩技术,将一些不活跃的内存数据进行压缩,以释放出更多的可用内存空间。
解决内存碎片问题对于系统的性能和稳定性都非常重要,特别是在长时间运行的系统中,内存碎片可能会导致内存不足的问题。
3.为什么要使用线程池
使用内存池的主要目的是提高程序的性能和资源利用效率。以下是一些使用内存池的原因:
-
减少内存碎片: 内存池可以减少内存碎片,因为它在程序启动时预先分配一大块内存,而不是在需要时每次分配小块内存。这有助于减小外部碎片,提高内存利用率。
-
降低内存分配和释放的开销: 使用标准的动态内存分配函数(如
malloc
、free
)可能会涉及到昂贵的系统调用。内存池通过预先分配内存,可以减少频繁的系统调用,从而降低内存分配和释放的开销。 -
提高内存分配的效率: 内存池可以通过直接从预分配的内存块中获取内存,而无需调用系统调用,提高内存分配的效率。这对于需要频繁分配小块内存的应用场景尤其重要。
-
资源复用: 内存池可以更好地管理和复用已分配的内存块。内存块的重复利用可以减少动态内存分配和释放的次数,提高系统的性能。
-
自动内存池扩展: 一些内存池实现支持自动扩展功能,当内存池中的内存不足时,它可以动态地从操作系统中请求更多的内存,而不会导致程序的崩溃。
-
合并空闲内存块: 内存池可以尝试合并相邻的空闲内存块,从而提高整体内存的利用效率。
4.三种内存池的演变
4.1 基于链表的内存分配器
一个链表指向空闲内存,分配就是遍历找到一块大小和它一致或者是比它大一些的,取出一块来,然后在修改链表,将剩余的空间挂回到链表中。释放就是放回到链表里面。注意做好标记和保护,避免二次释放,还可以优化如何查找适合大小的内存快的搜索上,减少内存碎片,但是可以增加内存池的外碎片。这种分配器的设计相对简单,适用于一些嵌入式系统或者对内存分配性能要求不高的场景。
- 优点 :实现简单
- 缺点:分配时搜索合适的内存块效率低,释放回归内存后归并比较消耗大,实际中不实用。
以下是一些关键特点和注意事项:
-
空闲链表: 使用链表来维护空闲内存块的列表。每个节点表示一个空闲内存块,包含起始地址和大小信息。这样的链表可以被遍历,以找到适当大小的内存块。
-
分配过程: 在分配过程中,遍历链表找到大小符合需求的空闲内存块,然后从中分配一块出去。如果分配的块大小比找到的块大,可以考虑将剩余的部分作为新的空闲块插入到链表中。
-
释放过程: 将释放的内存块重新插入到空闲链表中。要注意标记已释放的内存块,以防止二次释放的问题。
-
内存碎片: 这种简单的分配器容易产生内存碎片,即分散的小块内存。为了减少内存碎片,可以考虑使用合并相邻的空闲内存块,或者在分配时尽量选择大小适当的内存块。
-
性能和复杂度: 这种内存分配器的性能较为简单,但它可能不够高效,特别是在面对大量的内存分配和释放操作时。对于某些应用场景,性能可能不是主要考虑因素,而简洁性和易于理解可能更为重要。
虽然这种内存分配器的设计简单,但在一些特定的嵌入式系统、小型应用或教学用途中可能仍然是有用的。在实际应用中,可能会根据性能需求选择更复杂的内存分配器,比如伙伴分配器、slab 分配器等。
4.2 定长内存分配器
实现一个 FreeList,这个自由链表用于分配固定大小的内存块,比如用于分配 32 字节对象的固定内存分配器。每个内存分配器里面有两个链表。OpenList 用于存储未分配的空闲对象,CloseList 用于存储已分配的内存对象。所谓的分配就是从 OpenList 中取出一个对象放到 CloseList 里并且返回给用户, 释放又是从 CloseList 移回到 OpenList。 分配时内存如果不够,那么就需要增长 OpenList,向系统申请一个更大一点的内存块,切割成相同大小的对象添加到 OpenList 中。这个固定内存分配器回收的时候,统一把先前向系统申请的内存块全部还给系统。
- 优点:简单。分配和释放的效率高,解决实际中特定场景下的问题有效。
- 缺点:功能单一。只能解决定长的内存需求,另外占着内存没有释放。
以下是 FreeList 实现的一些关键点:
-
OpenList 和 CloseList: FreeList 中的两个链表分别用于存储未分配的空闲对象(OpenList)和已分配的内存对象(CloseList)。
-
分配操作: 分配操作从 OpenList 中取出一个对象,将其移动到 CloseList 中,并返回给用户。如果 OpenList 中没有足够的空闲对象,则需要增长 OpenList。这个过程可以通过向系统申请更大一点的内存块,并切割成相同大小的对象来实现。
-
释放操作: 释放操作将内存对象从 CloseList 移回到 OpenList 中。这样,对象就可以被重新分配给其他请求。
-
内存回收: 当固定内存分配器回收时,它将先前向系统申请的内存块全部还给系统。这确保了在 FreeList 不再需要的时候,占用的内存能够完全释放。
-
优点: 这种固定大小内存分配器的优点在于它的简单性和高效性。它适用于特定场景下对固定大小内存块的高效管理。
-
缺点: 由于 FreeList 是用于分配固定大小的内存块,它的功能相对单一。此外,它可能占用一定的内存,并且在某些情况下可能会导致内存浪费。
这种实现在特定场景下是非常有效的,尤其是对于需要 频繁分配和释放相同大小内存块 的应用。然而,在其他情况下,可能需要考虑更复杂的内存分配策略,比如适应性分配器或者使用其他数据结构来优化内存管理。
关于内存池内存不够的情况,应该继续向系统去申请:
4.3 Hash 映射的多种定长内存分配器
在定长分配器的基础上,按照不同对象大小(8,16,32,64,128,256,512,1k…64K),构造十多个固定内存分配器,分配内存时根据要申请内存大小进行对齐然后查H表,决定到底由哪个分配器负责,分配后要在内存头部的 header 处写上 cookie,表示由该块内存哪一个分配器分配的,这样释放时候你才能正确归还。如果大于 64K,则直接用系统的 malloc 作为分配,如此以浪费内存为代价你得到了一个分配时间近似 O(1) 的内存分配器。这种内存池的缺点是假设某个 FreeList 如果高峰期占用了大量内存即使后面不用,也无法支援到其他内存不够的 FreeList,达不到分配均衡的效果。
以下是一些关键点的总结:
-
多种定长内存分配器: 针对不同的对象大小,构建了多个固定内存分配器,每个分配器负责一定范围大小的内存分配。这可以有效地降低内存碎片,提高内存的利用效率。
-
哈希映射: 使用哈希表来映射不同大小的内存块到相应的定长内存分配器。当需要分配内存时,通过对齐并查找哈希表,确定由哪个分配器负责。
-
Cookie 标记: 在分配的内存块的头部添加 Cookie,用于标记由哪个分配器分配的。这样可以确保在释放时能够正确归还到相应的定长内存分配器。
-
大内存直接使用系统 malloc: 当申请的内存大小超过一定阈值(64K),直接使用系统的
malloc
,避免切分大块内存导致的内存碎片问题。 -
分配均衡问题: 由于每个分配器负责一定范围的大小,可能存在某个分配器高峰期占用了大量内存,导致其他分配器无法充分利用空闲内存。这是一个分配均衡的问题,有可能会浪费一些内存。
-
多线程并发: 在多线程并发场景下,需要考虑线程安全性。可以通过加锁等手段解决,但是锁的激烈竞争可能会降低分配释放效率。
在实际应用中,需要根据具体的需求和性能要求权衡各种因素,例如内存利用率、分配速度、线程安全性等。这种内存分配器在解决了一些问题的同时,也引入了一些新的挑战,需要根据实际情况进行调优和权衡。
优点:
- 高效的分配和释放: 根据对象大小进行不同的内存分配器,可以提高分配和释放的效率。对于特定大小的对象,直接查表找到对应的内存分配器,减少了查找的时间,近似O(1)的复杂度。
- 适应不同对象大小: 通过构造多个不同大小的固定内存分配器,可以适应不同大小的对象的内存需求,灵活性较高。
- 分配时间近似O(1): 对于小于64K的对象,分配器能够在常数时间内完成分配,提高了分配的速度。
缺点:
- 存在内碎片问题: 由于划分为不同大小的内存块,可能存在内碎片问题,导致内存浪费。
- 分配不均衡: 如果某个 FreeList 占用了大量内存,即使后面不用,也无法支援到其他内存不够的 FreeList,可能导致分配不均衡的情况。
- 不适用于大内存分配: 对于大于64K的对象,直接使用系统的
malloc
,而不是通过内存分配器,这可能会导致无法充分利用内部 FreeList 中的空闲内存。 - 线程安全问题: 在多线程并发场景下,由于有多个内存分配器,可能存在线程安全的问题。虽然可以通过加锁解决,但是锁竞争可能会影响分配和释放的效率。
注:这种设计和 STL 库的耳机空间配置器的设计完全一样。
5.了解 malloc 底层原理
5.1 动态存储器分配器
malloc 又称显示动态存储器分配器,动态存储器分配器维护着一个进程的虚拟存储器区域,称为堆。
我们假设堆紧接着未初始化.bss段后开始,并向上生长,对于每个进程,由内核维护着堆顶(brk —- break)
- 分配器将堆视为一组不同大小的块,每个块则是一个连续的虚拟存储器片,要么是已分配的,要么是空闲的。
- 已分配的块供应用程序使用,空闲块则可以用来分配。
- 已分配的块保持已分配的状态,直到它被释放。
5.2 malloc
我们在 UNIX 系统下讨论 malloc 如何分配空间
标准库函数:
#include <stdlib.h>
void* malloc(size_t size);
正如我们平时所使用一样,malloc 函数返回一个指针,指向大小(至少)为 size 字节的存储器块,这个块可能会包含在这个块内的任何数据对象类型做对齐。
(在 UNIX 系统上,malloc 返回一个 8 字节边界对齐的块)
特性 :
- 如果 malloc 出现错误,那么它返回 NULL,并设置 errno。
- malloc 不初始化它返回的存储器。
- 如果想要初始化可以使用 calloc 函数,calloc 是一个基于 malloc 的包装函数,他将分配的存储器初始化为 0。
- 如果想改变一个以前分配块的大小,可以使用 realloc 函数。
sbrk 函数 :
#include <unistd.h>
void *sbrk(intptr_t incr);
- sbrk() 函数通过将内核的 brk 指针增加 incr 来扩展和收缩堆。
- 如果成功,返回 brk 的旧值,否则返回 -1,并设置 errno。
- 用一个负值来调用 sbrk 函数是合法的,因为返回值指向距新堆顶向上 incr 字节处。
free 函数:
#include <stdlib.h>
void free(void *ptr);
- ptr 必须指向一个从 malloc / calloc / realloc 函数获得的已分配块的起始位置。
- 如果不是,那么 free 的行为将是未定义。这时就会产生一些运行时错误 。
现在我们展示 malloc free 是如何管理一个 C 程序的堆的,每个方框代表一个 4 字节的字。
5.3 分配器的特性
-
处理任意请求序列
一个应用可以有任意的分配请求和释放请求序列。 -
立即响应
分配器必须立即相应分配需求。 -
只使用堆
分配器使用的任何非标量数据都必须保存在堆里。 -
对齐
分配器必须对齐块,这是为了使得他们可以保存任何类型的数据对象。 -
不修改已分配块
分配器只能对空闲块进行操作。 -
最大化吞吐量
一个分配请求的最糟糕运行时间与空闲块的数量成线性关系,但释放请求的运行时间是个常数。 -
最大化存储器利用率
由于虚拟存储器的数量是受磁盘上交换空间的数量限制的,所以必须高效的使用。
而分配器则是在这两个要求之间找到一个合适的平衡。
5.4 碎片
碎片是造成堆利用率很低的一个主要原因。当有未使用的存储器但不能来满足分配请求时,就会发生这种现象。
碎片分为 :内部碎片和外部碎片
- 内部碎片 :
- 内部碎片是在分配一个已分配块比有效核载大时发生的。
- 例如 当一个分配器对已分配的块强加一个最小的大小值,而这个大小值比某个请求的有效核载大。
- 正如我们上面的例子,当 p2 申请 5 个字的空间时,由于要满足对齐约束,分配器就增加了块的大小为 6 个字,此时多出来的那一个字的大小就被称为内部碎片 。
- 外部碎片:
- 当空闲存储器合起来足够满足一个分配请求,但是没有一个单独的空闲块可以满足这个请求。
- 同样借鉴上面的例子,当 p4 申请了 2 个字之后,我们再想申请5个字,此时是可以满足的,但是如果申请 6个字节就会出现空闲块足够但是无法分配的情况。
- 外部碎片取决于请求的模式。
5.5 概念
在这里我们先思考 一个动态分配器需要做的事情,并且规划出一个蓝图。
由于外部碎片的难以量化和不可预测,所以分配器通常维持少量的大空闲块,而不是维持大量的小空闲块。
在实现时我们需要考虑:
- 我们如何记录空闲块
- 我们如何选择一个空闲块来放置一个新分配的快
- 在分配后,我们如何处理这个空闲块中的剩余部分
- 我们如何处理一个刚刚被释放的块
记录空闲块
隐式空闲链表
我们用一个数据结构来描述我们的空闲块,包括块的边界,以及区别已分配和空闲块。然后将这个数据结构用链表进行维护。
- 其中 a = 1 代表已分配 a = 0 代表未分配
- 块大小包括头部,有效核载和填充。
如果我们要强加一个双字的对齐约束条件,那么块的大小应该是 8 的倍数。
在头部后面就应该是调用 malloc 时请求的有效核载,有效核载后面是一片不使用的填充块(分配器策略或用于满足对其要求)。
这样我们就可以利用上述的头部来将堆组织为一个连续已分配和空闲块的序列,其中彩色块代表已分配。空白代表空闲 。
在这里我们并不需要一个前后指针来指向下一个空闲节点/分配节点,只需要读出头部的块大小并以当前地址为起始+块大小就可以计算出下一个空闲块/分配块的地址。
这样的结构就被称为隐式空闲链表。因为空闲块是通过头部中的大小字段隐含地连接着的 ,从而使得分配器通过遍历堆中的所有块,从而间接的遍历整个空闲块的集合。
但是隐式空闲链表也有一个明显的缺点就是,当我们要分配块时,空闲链表的搜索与堆中已分配块和空闲块的总数呈线性关系。
显示空闲链表
在隐式空闲链表中,由于块的分配与堆块的总数呈线性关系,所以对于通用分配器来说,隐式空闲链表是不合适的。
如果我们将空闲块组织为某种显示的数据结构,由于程序不需要一个空闲块的主题,所以我们将数据结构的指针存放在空闲块的主体里面,我们将堆组织为一个双向空闲链表,在每个空闲块中都包含一个 pred 前驱和 succ 后继指针。
使用双向链表后,使得首次适配的分配时间从块总数的线性时间减少到空闲块数量的线性时间。不过释放一个块的时间可以是线性的,也可以是常数的。
释放时间取决于放置策略
- 后进先出
- 将新释放的块放置在链表的开始处,释放和合并可以在常数时间内完成。
- 按地址放置
- 按照地址顺序来维护,每个块的地址都小于它的后继。具有更高的存储器利用率。
分离的空闲链表
在显示空闲链表中,一个使用单向空闲块链表的分配器需要与空闲块数量呈线性关系的时间来分配块。
而分离,就是维护多个空闲链,其中每个链表中的块有大致相等的大小,一般是将所有可能的块分成一些等价类。
分配器维护着一个空闲链表数组 ,每个大小类一个空闲链表,按照大小的升序排列。当分配器需要一个大小为 n 的块时,他就搜索相应的空闲链表,如不能找到则搜索下一个链表,以此类推。
简单的分离存储
每个大小类的空闲链表包含大小相等的块,每个块的大小就是这个大小类中最大元素的大小。
为了分配给一个给定大小的块,我们检查相应的空闲链表,如果链表为空,我们简单地分配其中第一块的全部。此时空闲块是不会分割以满足分配请求的。如果链表为空,分配器就向操作系统申请一个固定大小的额外存储器片,将这个片分成大小相等的块,并将这些块链接起来形成新的空闲链表。释放时,只需要简单的将这个块插入到相应的空闲链表前部。
这样的话,分配和释放都可以在常数时间内完成。由于我们不进行分割,那么也就没有合并,所以我们就不需要一个已分配/空闲标记,已分配块也就不需要头部,因为没有合并,同样也不需要脚部。
缺点 :容易造成外部碎片和内部碎片。
放置已分配块
当一个应用请求一个 k 字节的块时,分配器搜索空闲链表,查找一个可以放置所请求块的空闲块。这就和分配器的放置策略相关联了。
- 首次适配
从头开始搜索空闲链表,选择第一个合适的空闲块 。
优点 :总是将大的空闲块保留在链表的最后面。
缺点 :在靠近链表起始出会留下小空闲块,加大了对较大块的搜索时间。 - 下一次适配
与首次适配基本相似,只不过不是从头部开始搜索,而是从上一次查询结束的地方开始。
优点 :下一次适配比首次适配的运行时间更快。
缺点 :在存储器利用率方面比首次适配低得多。 - 最佳适配
检查每个空闲块 ,选择适合所需请求大小的最小空闲块。
优点 :存储器利用率最高。
缺点 :要求对堆进行彻底的搜索。
分割空闲块
一旦分配器找到一个匹配的空闲块,那么此时需要考虑的就是,分配这个空闲块中的多少空间。
如果选择使用整个空闲块,虽然速率较快,但是会造成内部碎片 。(但是如果趋向于产生好的匹配,那么内部碎片可以接受)。
如果匹配的不太好,分配器通常会选择将空闲块一分为二 ,第一部分变成分配块,而剩下的部分变成一个新的空闲块 。
合并空闲块
当分配器释放一个已分配块时,可能有其他空闲块与这个新释放的空闲块相邻。这些相邻的空闲块可能会造成一种 ‘假碎片’ 现象(有许多可用的空闲块被切割为小的无法使用的空闲块)。
为了解决这一个问题,任何分配器都必须执行合并相邻的空闲块,这个过程就被称为合并。
分配器可以选择立即合并,也可以选择推迟合并。
那么分配器如何实现合并?
以我们前面所设计的数据结构为例,当我们释放一个分配块时,合并下一个空闲块很简单且高效,但是如何合并前面的块就成了一个问题,所以我们需要对前面所设定的数据结构加以改进。
在这里我们添加了一个脚部,那么分配器就可以通过检查它的脚部来判断前一个块的起始位置。
但是这样会造成,我们的每个块都保持一个头部和一个脚部,如果一个应用程序大量的申请小块空间时,会产生显著的存储器开销。
所以我们需要对前面的头部+脚部的形式进行改进。
因为我们只有在合并的时候才会使用到脚部,所以对于已分配的块只需要一个头部而不需要脚部,但是空闲块依然需要脚部。
5.6 malloc 底层
C 标准库 函数 malloc 在底层使用的是 —– 分离适配
使用这种方法,分配器维护着一个空闲链表数组,每个空闲链表被组织成某种类型的显示/隐式链表。每个链表包含大小不同的块,这些块的大小是大小类的成员。
当要分配一个块时,我们确定了大小类之后,对适当的空闲链表做首次适配,查找一个合适的块,如果找到,那么可选地分割它,并将剩余的部分插入到适当的空闲链表中。如果没找到,那就搜索下一个更大的大小类的空闲链表,重复直到找到一个合适的块。如果空闲链表中没有合适的块,那么就向操作系统请求额外的堆存储器,从这个新的堆存储器中分配一个块,将剩余部分放置在适当的大小类中。
当释放一个块时,我们执行合并,并将结果放在相应的空闲链表中。
优点 :
- 存储器利用率高,分配效率高。减少了搜索时间。
- 对分离空闲链表的首次适配搜索,存储器利用率接近最佳适配搜索的存储器利用率。
- 这也就是 C 标准库中 malloc 采用的方法。
5.7 malloc 优缺点
- 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,如果找到则分裂成两个。比如:申请的是 4 page,4 page 后面没有挂 span,则向后面寻找更大的span,假设在 10 page 位置找到一个 span,则将 10 page span 分裂为一个 4 page span 和一个 6 page span。
- 如果找到 128 page 都没有合适的 span,则向系统使用 mmap、brk(Linux) 或者是VirtualAlloc(windows) 等方式申请 128 page 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
文章参考:
http://t.csdnimg.cn/1YSKk
http://t.csdnimg.cn/nRN4y