🔥 本文专栏:内存管理与高并发内存池
🌸作者主页:努力努力再努力wz



💪 今日博客励志语录 :
人最怕的不是走得慢,而是一直在原地解释自己为什么没走。
★★★ 本文前置知识:
定长内存池
思维导图

高并发内存池三级缓存架构图

引入
在此前的学习中,我们实现了一个定长内存池。为了理解为什么还需要进一步设计高并发内存池,我们可以先从多线程环境下的动态内存申请说起。
在多线程程序中,如果线程直接调用 malloc 申请内存,glibc 通常会优先尝试从当前线程的 tcache 中获取空闲内存块。
tcache 是一个线程私有的缓存结构,内部会按照不同的内存块规格维护多条自由链表。其中,entries 数组中的每一个元素都保存一条自由链表的头指针,对应一种特定大小的内存块;同时,counts 数组用于记录每条自由链表中当前缓存的空闲块数量。
由于 tcache 属于线程私有资源,因此线程访问自己的 tcache 时通常不需要加锁,可以较快地完成小块内存的申请和释放。
但是,tcache 的管理范围和缓存容量都是有限的。如果本次申请的内存大小不在 tcache 的管理范围内,或者对应规格的自由链表已经为空,那么 malloc 就需要继续访问当前线程所属的 arena。
arena 中维护了 fast bin、unsorted bin、small bin、large bin 等公共内存管理结构。由于一个进程中的 arena 数量是有限的,当线程数量较多时,多个线程可能会共享同一个 arena。此时,如果多个线程同时访问这个 arena,就需要通过加锁保证线程安全,从而产生一定的锁竞争和同步开销。
因此,一种直观的优化思路是:为每一个线程维护一份线程私有的内存池。
当线程需要申请内存时,可以优先从自己的内存池中获取空闲块,而不必频繁调用 malloc,也不必频繁访问多个线程共享的 arena。这样便可以减少线程之间的锁竞争,提高内存申请和释放的效率。
在此前实现的定长内存池中,我们会预先申请一片连续的内存空间,然后按照固定大小将其划分为一个个内存块,并在需要时将这些内存块返回给用户。
从整体上看,这片连续空间可以分为两部分:
text
已经划分的内存区域
尚未划分的连续内存区域
当用户申请内存时,定长内存池会优先检查自由链表中是否存在已经释放的空闲块。如果自由链表不为空,就直接取出其中的一个空闲块进行复用;如果自由链表为空,则从尚未划分的连续内存区域中继续向后切分出一个新的内存块。
之所以需要使用自由链表,是因为内存块的释放顺序通常与申请顺序并不一致。
例如,程序可能先后申请了三个内存块,但只释放了第一个和第三个内存块。此时,这些已经释放的空闲块在地址空间中并不是连续分布的,而是离散地分布在已经划分的内存区域中。因此,我们无法仅通过一段连续区间来描述所有空闲块,而是需要使用自由链表将这些离散的空闲块组织起来。
对于已经释放的内存块,我们可以复用内存块自身的头部空间,用于保存下一个空闲块的地址,从而将所有空闲块串联成一条链表:
text
free_list
|
v
+---------+ +---------+ +---------+
| next | --> | next | --> | nullptr |
+---------+ +---------+ +---------+
申请内存时,从自由链表头部取出一个空闲块;释放内存时,再将该内存块重新插入自由链表的头部。
不过,定长内存池也存在明显的局限性。
所谓"定长",指的是内存池中划分出来的每一个内存块大小都是相同的。因此,一个定长内存池通常只能服务于某一种固定大小的内存申请,或者用于创建某一种特定类型的对象。
但是在实际程序中,我们需要创建的对象类型通常并不相同,不同对象占用的内存大小也不相同。例如,程序可能需要申请一个几十字节的对象,也可能需要申请一个几百字节的对象。此时,单个定长内存池就无法同时满足这些不同大小的内存申请。
理论上,我们可以针对每一种对象类型,或者针对每一种内存块大小,分别创建一个定长内存池。但是这种方案会带来新的问题。
首先,程序中可能存在大量不同类型的对象。如果为每一种对象类型都维护一个独立的对象池,内存池的数量会非常多。
其次,定长内存池通常会预先申请一片连续空间。如果每一个线程都为每一种对象类型创建独立的定长内存池,就可能提前占用大量暂时不会使用的内存,造成严重的内存冗余和空间浪费。
此外,不同线程申请和释放内存的频率通常并不相同。某些线程的内存申请较为频繁,其私有内存池中的空间可能很快被耗尽;而另一些线程申请内存的频率较低,其私有内存池中可能仍然存在大量未划分区域或者已经释放的空闲块。
但是,如果每个线程的内存池完全独立,那么这些暂时没有被使用的内存仍然会被对应线程的私有内存池持续持有。其他线程即使自身的内存池已经耗尽,也无法直接使用这些空闲空间,只能继续向系统申请新的内存。
这样一来,虽然从整个进程的角度看,程序内部仍然存在大量空闲内存,但这些内存却分散在不同线程的私有内存池中,无法在线程之间进行统一调度和复用,最终导致内存利用率降低、内存分配不均衡以及进程整体内存占用增加。
因此,我们不能简单地为每一种对象类型、每一个线程都创建一个独立的定长内存池。
我们真正需要的是一套能够同时管理多种内存块规格的内存池系统。这套系统既要让每个线程能够优先从自己的局部缓存中申请和释放内存,减少线程之间的锁竞争;又要能够在局部缓存不足时,从公共结构中批量获取内存,并对底层的大块内存进行统一管理和复用。
这便是接下来需要学习和实现的------高并发内存池。
高并发内存池设计原理:三级缓存协作、批量调度与页级内存管理
从 malloc 锁竞争到三级缓存:高并发内存池的整体设计
接下来,我们便正式进入高并发内存池项目的学习。
对于这个项目,我们仍然按照此前的学习思路展开:先理解高并发内存池的整体设计原理,再逐步进入具体代码的实现。只有先明确每一层结构所解决的问题,后续在编写代码时,才能真正理解不同模块之间为什么要这样组织,以及它们之间是如何相互配合的。
首先需要明确的是,高并发内存池主要针对的是这样一种场景:多个线程并发、频繁地申请和释放小块内存。
在此前的学习中,我们已经了解了 glibc 的内存分配机制。glibc 提供的 malloc 和 free 本身是线程安全的,因此多个线程可以同时调用它们进行内存申请和释放,而不会破坏内存管理结构。
但是,线程安全并不代表在高并发场景下就一定具有很高的效率。
对于一次内存申请,malloc 通常会优先尝试从当前线程私有的 tcache 中获取内存。tcache 会按照不同的内存块规格维护多条自由链表,每一条自由链表中挂载的都是相同规格的空闲内存块。
由于 tcache 是线程私有的,因此线程访问自己的 tcache 时通常不需要加锁。如果对应规格的自由链表中存在空闲块,线程便可以直接取出一个内存块并返回,这条分配路径的效率通常较高。
但是,tcache 的管理范围和缓存数量都是有限的。
如果本次申请的内存大小不在 tcache 的管理范围内,或者 tcache 中对应规格的自由链表已经为空,那么线程就无法继续通过私有缓存完成本次分配。对于由 arena 管理的内存申请,malloc 会继续进入后续的公共分配路径,访问当前线程所绑定的 arena,并尝试从 fastbin、unsorted bin、small bin、large bin 或 top chunk 等结构中获取内存。
text
malloc
|
v
访问当前线程的 tcache
|
+-- 命中:直接取出空闲块并返回
|
+-- 未命中:进入 arena 的公共分配路径
需要注意的是,arena 的数量并不是无限的。当一个进程中的线程数量较多时,多个线程可能会共享同一个 arena。
arena 属于线程之间可能共享的公共内存管理结构,其关键分配路径需要通过互斥机制保证线程安全。因此,当多个线程同时进入同一个 arena 的分配路径时,就可能竞争该 arena 对应的锁。
虽然这些线程实际希望访问的结构可能并不相同,例如一个线程可能希望从 fastbin 中获取内存,另一个线程可能希望处理 unsorted bin 中的空闲块,但是它们仍然可能因为共享同一个 arena 而产生锁竞争。
没有立即获得锁的线程需要等待,从而增加同步开销。随着并发线程数量和内存申请频率不断增加,这类共享结构上的锁竞争就可能影响内存分配的整体效率和并发扩展能力。
因此,高并发内存池的核心目标并不是因为 malloc 和 free 不安全,而是希望在多线程频繁申请和释放小块内存的场景下。
高并发内存池的整体设计主要借鉴了 TCMalloc 的分层管理思想。同时,其中的线程私有缓存机制,也可以与 glibc 的 tcache 进行类比。
高并发内存池会为每一个线程维护一个线程私有的缓存结构,称为 ThreadCache。
当线程申请内存时,会优先访问自己的 ThreadCache。由于不同线程分别访问各自独立的 ThreadCache,因此这条分配路径通常不需要加锁,可以快速完成小块内存的申请和释放。
text
线程 1 ----> ThreadCache 1
线程 2 ----> ThreadCache 2
线程 3 ----> ThreadCache 3
ThreadCache 内部维护了一个自由链表数组。数组中的每一个位置都对应一种特定规格的内存块,而每个位置所管理的自由链表中,挂载的都是该规格下已经空闲的内存块。
例如:
text
free_lists[0] ----> 8 字节空闲块链表
free_lists[1] ----> 16 字节空闲块链表
free_lists[2] ----> 24 字节空闲块链表
free_lists[3] ----> 32 字节空闲块链表
...
当线程申请一块内存时,会先根据用户申请的字节数进行向上对齐,将其调整为内存池支持的某一种内存规格;随后再根据对齐后的大小计算对应的数组下标,从而定位到相应的自由链表。
其基本过程可以表示为:
text
用户申请的字节数
|
v
按照内存池的对齐规则向上对齐
|
v
计算对应的桶下标
|
v
定位 ThreadCache 中对应的自由链表
如果对应的自由链表不为空,就从链表头部取出一个空闲内存块并返回。由于这一过程只访问当前线程私有的 ThreadCache,不会与其他线程共同访问同一份数据,因此通常不需要加锁。
但是,ThreadCache 中缓存的空闲内存块是有限的。如果线程没有在自己的 ThreadCache 中找到对应规格的空闲块,就需要继续访问下一层公共缓存,也就是 CentralCache。
text
ThreadCache 中存在对应规格的空闲块
|
v
直接取出并返回
ThreadCache 中不存在对应规格的空闲块
|
v
向 CentralCache 请求内存
CentralCache 是一个由多个线程共同访问的公共结构,主要负责为各个线程的 ThreadCache 提供内存,同时接收 ThreadCache 归还的空闲内存。
与 ThreadCache 类似,CentralCache 也会按照不同的内存块规格进行分类。不同规格分别由不同的桶进行管理
例如:
text
8 字节规格桶 ----> mutex[0]
16 字节规格桶 ----> mutex[1]
24 字节规格桶 ----> mutex[2]
32 字节规格桶 ----> mutex[3]
因此,当 ThreadCache 中某个规格的空闲块不足时,就可以访问 CentralCache 中对应规格的桶,从中获取可用的内存。
由于 CentralCache 会被多个线程并发访问,因此需要通过加锁保证线程安全。不过,CentralCache 并不是使用一把锁保护整个结构,而是为每一个桶分别维护一把锁。
这样一来,不同线程访问不同规格的桶时,可以并发执行;只有多个线程同时访问同一个桶时,才会竞争同一把锁,从而降低公共缓存中的锁竞争。
text
线程 A 申请 16 字节内存
|
v
访问 16 字节规格桶
线程 B 申请 128 字节内存
|
v
访问 128 字节规格桶
如果 CentralCache 中也没有可用的内存,就需要继续向下一层的 PageCache 请求内存。
如果 CentralCache 中对应规格的桶也无法继续提供内存,就需要向更底层的 PageCache 请求内存。
PageCache 是高并发内存池中负责底层内存管理的公共结构。它会统一管理从操作系统申请到的内存,并在 CentralCache 无法继续提供内存时,为其补充新的内存资源。
如果 PageCache 中也没有足够的可用内存,就会通过底层的内存申请接口向操作系统申请新的内存,再交由高并发内存池继续管理。
因此,从整体上看,一次内存申请的基本路径可以表示为:
text
用户申请内存
|
v
访问当前线程的 ThreadCache
|
+-- 存在对应规格的空闲块
| |
| v
| 直接返回
|
+-- 不存在对应规格的空闲块
|
v
访问 CentralCache
|
+-- 能够提供内存
| |
| v
| 补充到 ThreadCache
|
+-- 无法提供内存
|
v
访问 PageCache
|
+-- 能够提供内存
| |
| v
| 补充到 CentralCache
|
+-- 无法提供内存
|
v
向操作系统申请内存
从整体结构上看,高并发内存池可以类比为一个多级缓存体系:
text
ThreadCache ----> 线程私有缓存
CentralCache ----> 多线程共享的公共缓存
PageCache ----> 底层内存管理层
其中,ThreadCache 最靠近线程,负责满足线程高频的内存申请和释放;CentralCache 位于中间,负责为各个线程的 ThreadCache 提供和回收内存;PageCache 位于最底层,负责统一管理底层内存,并为 CentralCache 提供内存资源。
通过这种分层结构,大部分内存申请都可以优先在当前线程私有的 ThreadCache 中完成。只有当当前层无法满足申请时,才会继续访问下一层,从而在减少锁竞争的同时,实现不同线程之间的内存统一管理和复用。
因此,接下来我们会首先从最靠近线程的 ThreadCache 开始学习,理解它如何按照不同内存规格组织自由链表,以及线程如何通过 ThreadCache 快速完成内存申请和释放。
在理解 ThreadCache 之后,再逐步引出 CentralCache 和 PageCache,最终将这三层结构串联起来,完整认识高并发内存池的申请、补给、回收和页级管理流程。
ThreadCache 设计原理:固定规格映射与线程私有自由链表
接下来,让我们正式认识高并发内存池中的第一级缓存结构------ThreadCache。
根据上文的介绍,我们已经知道,ThreadCache 是线程私有的。每一个线程都会维护自己的 ThreadCache,因此线程访问其中的空闲内存块时,不需要和其他线程竞争同一份数据,通常也就不需要加锁。
首先需要明确的是,ThreadCache 的核心职责是:缓存和组织当前可以被线程重新分配的空闲内存块。
从整个高并发内存池来看,底层内存最终来自操作系统。这些内存会经过底层结构的管理和划分,形成一个个可以分配给用户的小块内存。
对于已经划分出来的内存块来说,可以分为两种状态:
text
正在被用户使用的内存块
已经空闲、可以重新分配的内存块
除此之外,底层还可能存在尚未被划分的小块内存区域。不过,这些都不是 ThreadCache 需要直接管理的内容。
ThreadCache 不负责直接向操作系统申请连续内存,也不负责管理尚未划分的空间,更不会跟踪当前正在被用户使用的内存块。它只负责组织和缓存当前可以重新分配的空闲内存块。
这些空闲块既可能是用户释放以后重新归还到 ThreadCache 中的,也可能是由下一层缓存提供给 ThreadCache、但暂时还没有分配给用户的。
在此前学习 glibc 内存分配机制时,我们接触过类似的 tcache。tcache 会通过一个指针数组组织不同规格的空闲内存块:数组中的每一个元素都指向一条自由链表,而同一条自由链表上串联的,都是相同规格的空闲块。
ThreadCache 采用了类似的组织方式,其内部同样维护了一个桶数组。每一个桶都对应一种特定的内存块规格,并保存对应自由链表的头指针;同一条自由链表中挂载的,都是大小相同的空闲内存块。
例如:
text
ThreadCache
|
+-- 桶 0 ----> 某种规格的空闲块链表
|
+-- 桶 1 ----> 某种规格的空闲块链表
|
+-- 桶 2 ----> 某种规格的空闲块链表
|
...
接下来需要思考的是:为什么不能直接按照用户申请的原始字节数划分内存块,而是要预先设计一系列固定的内存规格?
对于内存申请来说,用户可以申请任意大小的内存块,并且大小的基本单位是字节。
假设高并发内存池允许用户申请 1~1024 字节的任意大小,如果我们完全按照用户申请的原始大小划分内存,并且为每一种大小分别维护一个桶,那么就需要建立长度为 1024 的桶数组:
text
桶 0 ----> 1 字节空闲块
桶 1 ----> 2 字节空闲块
桶 2 ----> 3 字节空闲块
...
桶 1023 ----> 1024 字节空闲块
但是,用户能够申请的内存大小显然不只 1024 字节,还可能申请更大的内存。
如果每一种原始字节数都对应一个独立的桶,那么随着可申请内存范围的扩大,桶数组的长度也会不断增加。这样不仅会增加管理结构本身占用的空间,而且当申请大小没有固定上限时,也很难提前确定桶数组究竟应该设计多大。
如果不为每一种字节大小分别设置一个桶,那么不同大小的空闲块就只能混合组织在同一条自由链表中。例如:
text
free_list
|
v
13B -> 71B -> 24B -> 160B -> 48B -> ...
此时,当用户申请一块内存时,就不能直接确定应该取出哪个空闲块,而是需要遍历链表,寻找一个大小能够满足本次申请的内存块。
如果找到的内存块小于用户申请的大小,后续使用时就会发生越界访问,因此肯定不能使用;如果选择的内存块远大于用户实际申请的大小,又会产生较多的内部碎片,造成内存浪费。
因此,在遍历过程中,不仅需要寻找大小足够的内存块,还需要尽量选择一个大小比较合适的内存块。其最坏时间复杂度为 O(N)。
而高并发内存池所面对的,正是多线程频繁申请和释放小块内存的场景。如果每一次内存申请都需要遍历一条自由链表,那么随着链表长度不断增加,查找成本也会越来越高,最终影响整体的分配效率。
因此,高并发内存池不会按照用户申请的每一种原始字节数,分别维护对应规格的空闲内存块。对于小于等于 256KB 的小块内存申请,内存池会预先划分出一系列固定的内存块规格,并将用户申请的每一种原始字节数映射到能够满足本次申请的某一种规格中。
例如,高并发内存池可以预先设置 8KB、16KB、32KB、64KB 等固定规格。当用户申请的原始大小并不完全等于这些规格时,就会将其向上映射到能够满足本次申请的最小规格。
例如,用户申请 13KB 的内存时,可以将其映射到 16KB 规格;用户申请 20KB 的内存时,则可以映射到 32KB 规格。
text
用户申请 13KB
|
v
映射到 16KB 规格
用户申请 20KB
|
v
映射到 32KB 规格
整体映射过程可以表示为:
text
用户申请的原始字节数
|
v
按照当前区间的对齐规则向上对齐
|
v
映射到能够满足申请的固定规格
|
v
定位该规格对应的 ThreadCache 桶
通过这种方式,原本数量庞大的任意字节大小,就被归并为有限数量的固定规格。ThreadCache 只需要为这些固定规格分别维护对应的桶和自由链表,而不需要为用户可能申请的每一种原始字节数都单独设置一个桶。
这样一来,多个相近的申请大小就可以由同一种规格的内存块满足,并由同一个桶进行管理。
虽然向上对齐可能会产生一定的内部碎片,例如用户只申请了 13 字节,但实际获得了 16 字节,多出的 3 字节暂时无法被其他申请使用,但是这种空间浪费是可控的。
通过适当的对齐规则,可以在空间利用率和分配效率之间取得平衡:
text
允许少量内部碎片
|
v
减少需要管理的内存规格数量
|
v
通过桶下标直接定位自由链表
|
v
快速完成内存申请和释放
因此,ThreadCache 内部会维护一个固定长度的桶数组。数组中的每一个位置都对应一种固定规格,而该位置上的自由链表中挂载的,都是这种规格的空闲内存块。
当线程申请内存时,基本过程如下:
text
用户申请 bytes 字节
|
v
按照内存池的规则向上对齐
|
v
计算对应的桶下标
|
v
访问 ThreadCache 中对应的自由链表
|
v
自由链表不为空时,取出头部空闲块
由于桶下标可以根据申请大小直接计算,因此不需要遍历其他规格的自由链表。定位到对应的桶以后,只需要从自由链表头部取出一个空闲块即可。
当用户释放内存时,同样会根据释放接口提供的内存大小,按照与申请时相同的规则进行对齐,并计算对应的桶下标,然后将释放的内存块插入对应自由链表的头部:
text
用户释放内存块
|
v
根据传入的大小进行向上对齐
|
v
计算对应的桶下标
|
v
将内存块头插到对应的自由链表
因此,ThreadCache 的核心设计可以概括为:
text
将任意大小的小块申请
映射到有限数量的固定规格
|
v
使用桶数组管理不同规格
|
v
每个桶维护一条自由链表
|
v
通过桶下标直接定位
|
v
在自由链表头部完成申请与释放
通过这种设计,线程对常见小块内存的申请和释放,只需要完成大小对齐、桶下标计算以及自由链表头部的插入或删除操作。
同时,由于每个线程访问的都是自己私有的 ThreadCache,因此这条高频分配路径通常不需要加锁,从而实现快速的小块内存申请和释放。
ThreadCache 与 CentralCache 的双向协作:Span 管理、批量补给与回收策略
从 ThreadCache 缓存未命中到 CentralCache 补给:Span 管理与分桶锁设计
根据上文,我们已经认识了高并发内存池中的第一级缓存结构------ThreadCache。
对于某一个线程来说,当其释放一块小内存时,会根据该内存块对应的规格计算桶下标,然后将这块内存挂载到当前线程所属 ThreadCache 的对应自由链表中。
由于 ThreadCache 是线程私有的,不同线程分别访问各自的 ThreadCache,因此在线程申请和释放内存时,通常不需要加锁,可以快速完成空闲块的获取和复用。
当线程申请任意大小的小块内存时,也会优先访问自己的 ThreadCache。不过,在真正访问对应桶之前,需要先按照高并发内存池规定的对齐规则,将用户申请的原始字节数向上映射到某一种固定规格,再根据该规格计算对应的桶下标。
text
用户申请的原始字节数
|
v
按照内存池规则向上对齐
|
v
映射到某一种固定规格
|
v
计算对应的桶下标
|
v
访问 ThreadCache 中对应的自由链表
如果对应的自由链表不为空,就可以从链表头部取出一个空闲块并返回给用户。
但是,ThreadCache 中对应规格的自由链表也可能为空。
ThreadCache 中缓存的空闲块主要有两个来源:一部分来自用户释放后重新归还的内存块,另一部分则来自下一层 CentralCache 对 ThreadCache 的内存补充。
如果某个线程频繁申请同一种规格的内存块,却没有及时释放,那么 ThreadCache 对应自由链表中的空闲块就会被不断取走,最终可能变为空链表。
除此之外,在高并发内存池刚刚开始运行时,线程还没有进行过任何内存释放,此时 ThreadCache 中的各个桶自然也是空的。线程第一次申请某种规格的内存时,同样无法直接从自己的 ThreadCache 中获得空闲块。
text
ThreadCache 初始状态
|
v
各个桶中的自由链表均为空
|
v
线程第一次申请内存
|
v
ThreadCache 无法直接提供空闲块
同时,根据前面的分析,ThreadCache 只负责缓存和组织已经切分完成、当前可以直接分配给用户的空闲小块。
它不会直接向操作系统申请连续内存,也不会负责从底层连续空间中切分新的小块,更不会管理正在被用户使用的内存块。
因此,当 ThreadCache 中对应规格的自由链表为空时,它无法依靠自身产生新的空闲块,而是需要向下一层公共缓存请求内存。
这一层公共缓存就是高并发内存池中的第二级缓存------CentralCache。
text
ThreadCache 中对应桶不为空
|
v
直接取出空闲块
ThreadCache 中对应桶为空
|
v
访问 CentralCache 补充内存
与线程私有的 ThreadCache 不同,CentralCache 是一个由多个线程共同访问的公共结构。它位于 ThreadCache 和更底层的 PageCache 之间,主要负责为不同线程的 ThreadCache 提供空闲内存,同时接收 ThreadCache 归还的内存。
CentralCache 的整体组织形式与 ThreadCache 类似,内部同样维护了一个桶数组,每一个桶都对应一种特定规格的内存块,并管理能够提供该规格内存块的链表。
同时,CentralCache 与 ThreadCache 采用相同的规格划分规则,二者桶数组的下标映射关系也是一致的。也就是说,相同下标的桶所对应的内存块规格相同:
text
ThreadCache[0] <----> CentralCache[0]
ThreadCache[1] <----> CentralCache[1]
ThreadCache[2] <----> CentralCache[2]
...
因此,当 ThreadCache 中某个桶为空时,就可以直接使用相同的桶下标,访问 CentralCache 中对应规格的桶,从中获取该规格的空闲内存块。
不过,虽然二者都按照固定规格维护桶数组,但每个桶内部组织的内容并不相同。
ThreadCache 的每个桶中直接维护一条自由链表,链表上挂载的是一个个相同规格的空闲小块:
text
ThreadCache 中某个桶
|
v
空闲块 -> 空闲块 -> 空闲块 -> ...
而 CentralCache 的每个桶中组织的并不是一个个独立的小块,而是能够提供该规格空闲块的 Span。
text
CentralCache 中某个桶
|
v
Span <-> Span <-> Span
每一个 Span 都管理着一段内存,这段内存已经按照当前桶所对应的固定规格进行了处理,能够向 ThreadCache 提供该规格的空闲块。
当前阶段只需要先明确:CentralCache 通过 Span 组织和管理不同规格的内存资源。至于 Span 的具体结构、内存如何切分以及空闲块如何从中取出,后面再继续展开。
由于 CentralCache 是多个线程共享的公共结构,因此多个线程可能会同时访问它。为了保证其内部结构的线程安全,访问 CentralCache 时就需要进行加锁保护。
但是,CentralCache 并不是使用一把大粒度的锁保护整个桶数组,而是让不同的桶分别维护自己的锁:
text
CentralCache
|
+-- 桶 0 + 锁 0
|
+-- 桶 1 + 锁 1
|
+-- 桶 2 + 锁 2
|
...
这样一来,如果两个线程访问的是不同规格的桶,它们就可以同时执行,不需要竞争同一把锁。
例如:
text
线程 A 访问 16B 规格桶
线程 B 访问 128B 规格桶
由于两个线程访问的是不同的桶,因此它们分别获取各自桶对应的锁,可以并发执行。
只有当多个线程同时访问同一个规格的桶时,才会竞争该桶所对应的锁:
text
线程 A 访问 16B 规格桶
线程 B 也访问 16B 规格桶
|
v
竞争 16B 桶对应的锁
这种按桶加锁的方式虽然不能完全消除锁竞争,但是可以将竞争限制在相同的内存规格内部,提高不同规格内存操作之间的并行度,减小公共结构中的锁竞争范围。
这与 glibc 中共享 arena 的分配路径有所不同。当多个线程共享同一个 arena,并同时进入需要获取 arena 锁的公共分配路径时,即使它们实际处理的是不同种类的空闲内存,也可能竞争同一个 arena 对应的锁。
而 CentralCache 将锁拆分到不同规格的桶上,使访问不同桶的线程可以并发执行,从而减少大粒度锁对整体分配效率的影响。
因此,当 ThreadCache 中某个桶的自由链表为空时,基本处理过程如下:
text
ThreadCache[index] 为空
|
v
访问 CentralCache[index]
|
v
获取该桶对应的锁
|
v
从该桶管理的 Span 中获取空闲块
|
v
为当前 ThreadCache 补充对应规格的内存
由此可以看出,ThreadCache 和 CentralCache 之间通过相同的桶下标建立了对应关系:
text
ThreadCache[index]
管理当前线程能够直接使用的空闲小块
|
| 空闲块不足
v
CentralCache[index]
管理能够提供该规格空闲块的 Span
CentralCache 的内存组织与分配机制
根据上文,我们已经认识了高并发内存池中的第二级缓存------CentralCache。
虽然 CentralCache 和 ThreadCache 都承担着缓存和管理内存资源的作用,但是二者的职责并不相同。
ThreadCache 是线程私有的,主要负责缓存当前线程可以直接使用的空闲小块。当其中某个规格的空闲块不足时,就需要向 CentralCache 请求补充。
而 CentralCache 是多个线程共享的公共缓存。它一方面需要为各个 ThreadCache 提供已经切分完成的空闲小块,另一方面还需要在自身资源不足时,向下一层的 PageCache 获取新的内存资源,并将其按照对应规格切分成一个个小块。
整体关系可以表示为:
text
PageCache
提供若干连续的虚拟内存页
|
v
CentralCache
将连续内存按固定规格切分成小块
|
v
ThreadCache
缓存线程可以直接使用的空闲小块
在介绍 CentralCache 时,我们提到过:它内部同样维护了一个桶数组,每一个桶对应一种固定的内存块规格。
但是,与 ThreadCache 不同,CentralCache 桶内链表挂载的节点并不是一个个独立的空闲小块,而是一个个 Span。
接下来,我们就来认识 Span 究竟是什么。
Span 是什么
从英文含义来看,span 表示"跨度"或者"一段范围"。
在高并发内存池中,一个 Span 用于关联和描述一段由一个或多个连续虚拟内存页组成的内存区域。
我们知道,操作系统对内存的管理通常以页为基本单位。在常见的 x86-64 Linux 环境中,一个基础内存页的大小通常为 4KB,也就是 4096 字节。
text
1 个内存页 = 4KB = 4096B
当高并发内存池从底层获得内存时,得到的通常不是一个任意字节大小的小块,而是一段由若干连续虚拟内存页组成的地址空间。
例如:
text
+---------+---------+---------+---------+
| 第 1 页 | 第 2 页 | 第 3 页 | 第 4 页 |
+---------+---------+---------+---------+
这里需要注意的是,这些页在进程的虚拟地址空间中是连续的,但它们所映射的物理页框并不一定连续。
高并发内存池在用户态进行地址切分和访问时,主要关心的是虚拟地址是否连续,因此 Span 所描述的是一段连续的虚拟内存页。
Span 是描述内存的元数据
这里需要特别强调:Span 只是关联和描述一段连续虚拟内存页,它本身并不直接包含这些内存页。
也就是说,我们不会在 Span 结构体中定义一个几 KB,甚至几 MB 大小的数组,用于真正保存用户数据。
如果这样设计,不仅会使 Span 本身变得非常庞大,而且完全没有必要。因为要访问一段虚拟内存,我们只需要知道这段内存的起始地址和长度即可。
因此,Span 本质上是一个元数据结构:
text
Span
|
+-- 描述内存从哪里开始
|
+-- 描述这段内存包含多少页
|
+-- 记录这段内存当前如何被管理
如果当前只考虑如何确定一段连续虚拟内存的地址范围,那么 Span 最核心的两个属性就是:
text
起始页号
包含的页数
什么是页号
假设一个内存页的大小为 4KB,也就是 4096 字节,那么可以将整个进程的虚拟地址空间抽象成一个巨大的页数组:
text
虚拟页 0
虚拟页 1
虚拟页 2
虚拟页 3
...
每一个数组元素表示一个虚拟内存页,每个元素的大小都是 4096 字节,而页号就相当于这个虚拟页数组的下标。
对于任意一个虚拟地址,其所在页的页号可以通过下面的方式计算:
text
页号 = 虚拟地址 / 4096
由于:
text
4096 = 2^12
因此在代码中,通常可以通过将虚拟地址右移 12 位得到页号:
cpp
page_id =
reinterpret_cast<std::uintptr_t>(address) >> 12;
这里右移掉的低 12 位表示页内偏移,剩余的高位部分就是该地址对应的虚拟页号。
由于底层分配给内存池的连续空间通常以页为单位,因此其起始地址也是页对齐的,也就是能够被 4096 整除。
假设一个 Span 的起始页号为 page_id,那么它所关联内存的起始地址为:
text
起始地址 = page_id × 4096
如果这个 Span 一共包含 page_count 个内存页,那么其结束边界为:
text
结束边界 = 起始地址 + page_count × 4096
因此,这个 Span 所描述的虚拟地址范围可以表示为:
text
[start, end)
也就是一个左闭右开的区间。
其中:
text
start = page_id × 4096
end = start + page_count × 4096
end 本身不属于这个 Span,最后一个有效字节的地址是 end - 1。
例如,一个 Span 从第 100 个虚拟页开始,一共包含 3 个页,那么其地址范围为:
text
[100 × 4096, 103 × 4096)
因此,只要记录起始页号和页数,就可以确定这个 Span 所关联的整段虚拟地址范围。
不过,起始页号和页数只是 Span 最核心的地址描述信息。在完整的高并发内存池中,为了支持小块切分、空闲块管理、使用计数以及链表组织,Span 还需要记录其他管理信息。
例如:
text
Span
|
+-- 起始页号
+-- 页数
+-- 当前切分的小块规格
+-- 空闲块链表
+-- 已经分配出去的小块数量
+-- 前驱 Span
+-- 后继 Span
这些具体字段后面进入代码实现时再逐步展开。当前只需要明确:Span 是一段连续虚拟内存页的描述结构,而不是内存本身。
为什么 CentralCache 的桶中挂载 Span
认识了 Span 之后,接下来需要思考一个问题:
为什么
CentralCache的桶中不直接挂载一个个空闲小块,而是挂载一个个Span?
理论上,CentralCache 当然也可以像 ThreadCache 一样,在桶中直接组织一个个空闲小块。但是这样设计会产生一些问题。
首先,一个 Span 通常包含多个连续虚拟内存页,而这些页又可以按照当前桶对应的规格切分成大量小块。
例如,某个 Span 包含若干连续页,当前 CentralCache 桶对应的是 16 字节规格,那么这段连续内存就可以被切分成大量 16 字节的小块:
text
一个 Span 关联的连续内存
+------------------------------------------+
| |
+------------------------------------------+
|
v
+----+----+----+----+----+----+----+----+
|16B |16B |16B |16B |16B |16B |16B |... |
+----+----+----+----+----+----+----+----+
因此,一个 Span 就可以向 ThreadCache 提供大量相同规格的空闲块。
如果 CentralCache 的桶中直接挂载所有独立的小块,那么桶中的链表可能会非常长;而通过 Span 进行组织,一个 Span 节点就可以统一管理一批属于同一段连续页的小块,从而缩短链表长度。
不过,缩短桶中链表的长度只是 CentralCache 使用 Span 进行组织所带来的一个好处,更核心的原因在于,CentralCache 还需要与下一层的 PageCache 进行内存资源的交换。
对于 ThreadCache 来说,其直接管理和使用的是一个个已经切分完成的小块内存;而对于 PageCache 来说,其管理的则是一段段连续的虚拟内存页,管理粒度是页,而不是一个个任意字节大小的小块。
text
ThreadCache
管理固定规格的小块
|
v
CentralCache
连接小块管理与页级管理
|
v
PageCache
管理连续的虚拟内存页
由于 CentralCache 需要从 PageCache 获取内存资源,而 PageCache 又是以连续虚拟内存页为单位组织和管理内存的,因此二者之间进行资源交换时,也需要使用能够描述一段连续虚拟页的结构。
这个结构就是 Span。
一个 Span 通过记录起始页号和页数,描述一段由一个或多个连续虚拟内存页组成的内存区域。因此,PageCache 管理和提供的是 Span,CentralCache 也必须以 Span 为单位接收和组织这些页级内存资源。
text
PageCache
以 Span 为单位管理连续虚拟页
|
v
CentralCache
以 Span 为单位接收和组织内存资源
所以,CentralCache 对应桶中的链表节点选择 Span,而不是直接挂载一个个独立的小块,其核心原因就是:
CentralCache需要与按页管理内存的PageCache进行资源交换,而Span正是用于描述和管理一段连续虚拟内存页的结构。
ThreadCache 的批量补给:从 CentralCache 的 Span 中获取空闲块
根据上文,我们已经对 Span 结构有了一定的认识。接下来,我们便可以重新回到内存申请路径,继续分析当 ThreadCache 中对应规格的空闲块不足时,线程如何从 CentralCache 中获取内存。
当线程申请一块小内存时,首先会将用户申请的原始字节数按照内存池规定的对齐规则向上对齐,映射到某一种固定规格;随后根据该规格计算对应的桶下标,并访问当前线程 ThreadCache 中对应的桶。
如果该桶中的自由链表不为空,就可以直接从链表头部取出一个空闲块并返回给用户。
但是,如果对应桶中的自由链表为空,说明当前线程的 ThreadCache 已经无法继续提供该规格的空闲块,此时就需要访问 CentralCache 中对应规格的桶,从中补充一批空闲块。
text
用户申请内存
|
v
对齐到某一种固定规格
|
v
计算对应的桶下标
|
v
访问 ThreadCache 对应桶
|
+-- 自由链表不为空:直接取出并返回
|
+-- 自由链表为空:访问 CentralCache 补充内存
这里需要注意,ThreadCache 向 CentralCache 请求内存时,通常不会只获取一个空闲块,而是会一次获取一批相同规格的空闲块。
原因也很直接:高并发内存池面对的是频繁申请和释放小块内存的场景。如果 ThreadCache 每次为空时,都只从 CentralCache 中获取一个内存块,那么这个内存块很快就会被当前线程申请取走。下一次申请相同规格的内存时,线程又需要重新访问 CentralCache。
但是,CentralCache 是一个由多个线程共享的公共结构。线程每次访问它,都需要获取对应桶的锁。如果每次只获取一个内存块,就会导致线程频繁进入共享结构,并反复执行加锁和解锁操作,从而增加锁竞争与同步开销。
因此,更合理的方式是:线程访问一次 CentralCache 时,就为自己的 ThreadCache 补充一批相同规格的空闲块。
text
ThreadCache 对应桶为空
|
v
访问 CentralCache 对应桶
|
v
一次获取一批相同规格的空闲块
|
+-- 一个满足当前申请
|
+-- 其余缓存在 ThreadCache 中
这样,当前线程后续再次申请相同规格的内存时,就可以直接从自己的 ThreadCache 中获取,而不需要频繁访问 CentralCache。
由于 CentralCache 与 ThreadCache 采用相同的规格划分方式,并且相同的桶下标对应相同的内存规格,因此,当 ThreadCache 中某个桶为空时,可以直接使用相同的下标访问 CentralCache 中对应规格的桶。
不过,CentralCache 桶中的链表与 ThreadCache 桶中的链表并不相同。
ThreadCache 对应桶中的自由链表直接挂载一个个相同规格的空闲块,而 CentralCache 对应桶中的链表挂载的则是一个个 Span:
text
ThreadCache 对应桶
|
v
空闲块 -> 空闲块 -> 空闲块 -> ...
CentralCache 对应桶
|
v
Span <-> Span <-> Span
挂载在 CentralCache 对应桶中的 Span,其所关联的连续虚拟内存已经按照该桶对应的固定规格切分成了一个个小块。
例如,某个桶对应 16 字节规格,那么该桶中的 Span 所关联的连续内存,就已经按照 16 字节切分成了多个小块。
这些当前还没有分配出去的空闲块,会通过 Span 内部的自由链表组织起来。因此,除了起始页号和页数等信息以外,Span 内部还会维护一个指向空闲块链表头节点的指针:
text
Span
|
+-- 起始页号
+-- 页数
+-- 空闲块链表头指针
+-- 其他管理信息
这里需要区分两层链表:
text
CentralCache 某个桶
|
v
Span <------> Span <------> Span
| | |
空闲块链表 空闲块链表 空闲块链表
外层链表用于组织一个个 Span,而每个 Span 内部的自由链表,则用于组织该 Span 中已经切分完成、但当前还没有分配出去的空闲小块。
如果某个 Span 内部的自由链表不为空,说明该 Span 当前仍然能够提供空闲块;如果其内部自由链表为空,则说明该 Span 切分出来的小块当前都已经从 CentralCache 中分配出去了。
需要注意的是,这里只能说明这些小块已经离开了 CentralCache。它们可能正缓存在某个线程的 ThreadCache 中,也可能已经进一步分配给了用户。
因此,当线程访问 CentralCache 对应的桶以后,会在该桶管理的 Span链表中寻找一个内部自由链表不为空的 Span。
找到以后,再从这个 Span 内部的自由链表中截取一批相同规格的空闲块:
text
访问 CentralCache 对应桶
|
v
寻找内部自由链表不为空的 Span
|
v
从 Span 内部截取一批空闲块
假设从某个 Span 中截取了一批空闲块:
text
obj1 -> obj2 -> obj3 -> obj4 -> obj5
其中一个空闲块用于满足线程当前的内存申请,直接返回给用户;其余空闲块则挂载到当前线程 ThreadCache 对应桶的自由链表中,供后续相同规格的内存申请复用。
text
从 Span 中获取一批空闲块
|
+-- obj1:返回给用户
|
+-- obj2~obj5:挂入 ThreadCache 对应桶
因此,整个补充过程可以表示为:
text
ThreadCache[index] 为空
|
v
获取 CentralCache[index] 对应的锁
|
v
寻找内部自由链表不为空的 Span
|
v
从该 Span 中截取一批空闲块
|
+-- 一个返回给用户
|
+-- 其余挂入 ThreadCache[index]
通过这种批量补充方式,可以将一次访问 CentralCache 时产生的加锁和公共结构访问开销,分摊到后续多次相同规格的内存申请中。
这样既可以使大部分内存申请继续在无锁的 ThreadCache 中完成,又能够减少线程频繁访问共享 CentralCache 所产生的锁竞争。
CentralCache 的批量回收:ThreadCache的自由链表长度限制以及Span 归属定位与 use_count 管理
根据上文,我们已经梳理了线程申请一块小内存时的大致路径。
线程首先根据用户申请的字节数进行向上对齐,将其映射到某一种固定规格,再计算对应的桶下标,并访问当前线程 ThreadCache 中对应的自由链表。
如果该自由链表为空,线程就需要访问 CentralCache 中对应规格的桶,从某个 Span 内部的自由链表中获取一批空闲块。其中一个空闲块用于满足当前的内存申请,其余空闲块则挂载到当前线程 ThreadCache 对应桶的自由链表中,供后续相同规格的申请复用。
text
用户申请内存
|
v
对齐到固定规格并计算桶下标
|
v
访问 ThreadCache 对应桶
|
+-- 不为空:直接取出一个空闲块
|
+-- 为空:访问 CentralCache
|
v
从 Span 中获取一批空闲块
|
+-- 一个满足当前申请
|
+-- 其余缓存到 ThreadCache
不过,这里所说的"一批空闲块"仍然比较抽象。
线程究竟应该一次从 CentralCache 获取多少个空闲块?是只获取一个,还是直接把某个 Span 内部的空闲块全部取走?
实际上,ThreadCache 与 CentralCache 之间交换的内存块数量,需要按照一定的规则进行控制。为了理解这个问题,我们首先需要认识 ThreadCache 中自由链表的长度限制。
为什么 ThreadCache 的自由链表需要长度上限
对于 ThreadCache 来说,每一个桶中的自由链表都不能无限增长,而是需要设置一个最大长度。
原因在于,ThreadCache 是线程私有的。一旦空闲块被放入某个线程的 ThreadCache,其他线程就无法直接访问和复用这些内存块。
虽然同一个进程中的多个线程共享进程的虚拟地址空间、页表以及文件描述符表等资源,但是每个线程的 ThreadCache 在逻辑上仍然彼此独立:
text
线程 A ----> ThreadCache A
线程 B ----> ThreadCache B
线程 C ----> ThreadCache C
线程 A 无法直接从线程 B 的 ThreadCache 中取出空闲块。
在实际程序中,内存的申请和释放并不一定发生在同一个线程中。既可以由同一个线程完成申请和释放,也可能由一个线程负责申请,再由另一个线程负责资源清理和释放。
例如,在网络服务器中,连接对象可以由负责建立或处理连接的线程创建,也可以在连接关闭后,由另一个线程统一完成对象销毁和资源清理。
假设线程 A 频繁申请连接对象,而线程 B 负责销毁这些连接对象:
text
线程 A:频繁申请连接对象
线程 B:负责销毁并释放连接对象
由于释放路径会优先将空闲块放入执行释放操作的线程所拥有的 ThreadCache,因此这些连接对象释放以后,会不断进入线程 B 对应规格的自由链表:
text
线程 A 的 ThreadCache:
空闲块不断被取走,需要继续向 CentralCache 获取
线程 B 的 ThreadCache:
不断积累已经释放的空闲块
如果线程 B 很少申请相同规格的对象,那么这些空闲块就会长期滞留在线程 B 的私有缓存中。
虽然这些内存块已经处于空闲状态,也仍然属于整个进程的虚拟地址空间,但是其他线程无法直接访问线程 B 的 ThreadCache,因此无法复用这些空闲块。
这会导致:
text
部分线程缺少空闲块
|
v
继续向 CentralCache 获取内存
其他线程持有大量空闲块
|
v
这些块却长期得不到复用
这种情况并不是内存泄漏,因为内存仍然受到内存池管理;但它会造成空闲内存在线程私有缓存中滞留,降低整个进程的内存利用率,并导致不同线程之间的内存资源分配不均衡。
因此,ThreadCache 中每个桶的自由链表都不能无限增长。当某个桶积累的空闲块超过一定上限时,就需要将其中一部分归还给公共的 CentralCache,使这些内存可以重新被其他线程获取和复用。
text
ThreadCache 中空闲块过多
|
v
将部分空闲块归还 CentralCache
|
v
其他线程可以再次获取并复用
由此可以看出,ThreadCache 与 CentralCache 之间的内存流动并不是单向的,而是双向的:
text
ThreadCache 空闲块不足
|
v
从 CentralCache 获取一批空闲块
ThreadCache 空闲块过多
|
v
向 CentralCache 归还一部分空闲块
ThreadCache 应该归还多少空闲块
当 ThreadCache 中某个桶的自由链表超过最大长度时,需要将其中一部分空闲块归还给 CentralCache。
但是,归还的数量同样不能过多或过少。
如果每次只归还一个空闲块,那么在内存频繁释放的情况下,自由链表很快又会超过最大长度,线程就需要再次访问 CentralCache:
text
一次只归还一个
|
v
自由链表很快再次超过上限
|
v
再次访问 CentralCache
|
v
频繁加锁和解锁
这样会导致线程频繁进入公共结构,增加锁竞争和同步开销。
但是,如果每次将自由链表中的所有空闲块全部归还,那么当前线程就无法保留足够的本地缓存:
text
一次归还全部空闲块
|
v
ThreadCache 对应桶变为空
|
v
后续再次申请相同规格内存
|
v
重新访问 CentralCache
这同样会导致线程频繁访问 CentralCache。
因此,归还数量需要在"保留本地缓存"和"释放过量资源"之间取得平衡。
在当前实现中,可以采用一种直接的折中方式:当自由链表长度超过上限时,保留大约一半的空闲块,并将剩余的一半归还给 CentralCache。
例如,自由链表中当前有 10 个空闲块:
text
ThreadCache 当前共有 10 个空闲块
|
+-- 保留 5 个
|
+-- 归还 5 个
这样既可以避免某个线程长期持有大量暂时不用的内存,又能让当前线程继续保留一部分空闲块,减少后续再次访问 CentralCache 的概率。
归还的空闲块可能来自不同的 Span
当 ThreadCache 需要向 CentralCache 归还一批空闲块时,还需要注意一个问题:同一个桶中的空闲块不一定都来自同一个 Span。
例如,某个 ThreadCache 曾经多次从 CentralCache 获取相同规格的内存块,而这些内存块可能分别来自不同的 Span。后续这些块按照不同顺序被释放以后,就可能同时出现在同一个自由链表中:
text
ThreadCache 某个桶
|
v
来自 Span A 的块
|
v
来自 Span B 的块
|
v
来自 Span A 的块
|
v
来自 Span C 的块
跨线程释放会让这种情况更加明显。
例如,多个线程分别从不同的 Span 中获得内存块,最后都由同一个清理线程释放,那么这些来自不同 Span 的空闲块,就会一起进入清理线程 ThreadCache 的同一个桶中。
因此,当这一批空闲块被归还给 CentralCache 时,不能简单地将它们全部放入同一个 Span,而是需要分别确定每个空闲块原本属于哪个 Span。
Span 的 use_count
为了记录一个 Span 中还有多少小块尚未归还到 CentralCache,Span 会维护一个 use_count。
当 CentralCache 从某个 Span 内部取出一批空闲块,并将它们提供给 ThreadCache 时,这些块就离开了该 Span 内部的自由链表,因此需要增加对应的 use_count:
text
从 Span 中取出 n 个空闲块
|
v
span->use_count += n
这里的 use_count 表示:
从该
Span中取出、目前尚未重新归还到CentralCache的小块数量。
这些小块既可能仍然缓存在某个线程的 ThreadCache 中,也可能已经进一步分配给用户使用。
当 ThreadCache 将一个空闲块归还给 CentralCache 时,需要将这个具体的内存块重新插入其所属 Span 的自由链表,同时将该 Span 的 use_count 减一:
text
空闲块归还 CentralCache
|
+-- 插回所属 Span 的自由链表
|
+-- span->use_count--
当:
text
span->use_count == 0
说明这个 Span 中此前分配出去的所有小块,都已经重新归还到了 CentralCache。此时,这个 Span 所关联的整段连续内存已经完全空闲,可以整体归还给下一层的 PageCache。
text
use_count > 0
|
v
仍有小块没有归还,继续由 CentralCache 管理
use_count == 0
|
v
所有小块均已归还,Span 可以归还 PageCache
之所以要将完全空闲的 Span 归还给 PageCache,是因为后续还会涉及相邻空闲 Span 的合并。至于具体的合并过程,等介绍 PageCache 时再详细展开。
如何根据空闲块地址找到所属 Span
既然同一个 ThreadCache 桶中的空闲块可能来自不同的 Span,那么在归还时,就需要根据每个空闲块的地址找到它所属的 Span。
每一个空闲块都有自己的起始虚拟地址。对于常见的 4KB 内存页来说:
text
4KB = 4096B = 2^12
因此,一个虚拟地址可以抽象为两部分:
text
高位:虚拟页号
低 12 位:页内偏移
计算某个地址所在的虚拟页号时,只需要将地址除以 4096。由于 4096 是 2 的整数次幂,因此在代码中可以通过右移 12 位完成:
cpp
page_id =
reinterpret_cast<std::uintptr_t>(ptr) >> 12;
这一操作相当于去掉虚拟地址低 12 位的页内偏移,只保留高位的虚拟页号:
text
空闲块的虚拟地址
|
v
右移 12 位
|
v
得到该地址所在的虚拟页号
得到虚拟页号以后,还需要通过一个映射结构查询这个虚拟页属于哪个 Span。
这个映射结构需要维护:
text
虚拟页号 ----> Span*
在实现上,可以使用哈希表,也可以使用基数树。
text
空闲块地址
|
v
计算所在页的页号
|
v
查询页号映射结构
|
v
获得对应的 Span*
找到对应的 Span 后,就可以将该空闲块重新插入这个 Span 内部的自由链表,并更新其 use_count。
完整的归还路径可以表示为:
text
ThreadCache 某个桶超过最大长度
|
v
摘取一部分空闲块
|
v
逐个根据地址计算虚拟页号
|
v
通过映射结构找到所属 Span
|
v
将具体空闲块插回 Span 的自由链表
|
v
span->use_count--
|
v
判断 use_count 是否等于 0
因此,页号到 Span 的映射结构,解决的是这样一个问题:
已知一个小块的虚拟地址,如何快速找到负责管理这块内存的
Span。
ThreadCache 的动态批量补给策略:规格控制与按需倍增
ThreadCache 批量补给数量的设计:规格反比与上下限控制
根据上文,我们已经知道,ThreadCache 中的每一个桶都会设置一个长度上限,用于限制当前线程可以缓存的空闲块数量。认识了这一点以后,我们就可以继续回答前文留下的问题:当 ThreadCache 对应桶的自由链表为空,需要从 CentralCache 中批量获取空闲块时,这一批空闲块的数量究竟应该是多少?
对于规格大小为 size 的内存块,可以先按照下面的方式计算批量获取数量:
cpp
num = 256KB / size;
这里的核心思想是:内存块的规格越小,一次可以多获取一些;内存块的规格越大,一次则应该少获取一些。
也就是说,批量获取数量与内存块规格之间大致呈反比关系:
text
规格越小
|
v
一次获取的数量越多
规格越大
|
v
一次获取的数量越少
之所以选择 256KB 作为计算基准,是为了让一次批量获取的内存总量大致维持在一个相对稳定的范围内。
因为:
text
获取数量 × 单块规格
≈ (256KB / size) × size
≈ 256KB
例如,当内存块规格为 1KB 时:
text
获取数量 = 256KB / 1KB = 256 个
总内存大小 = 256 × 1KB = 256KB
当内存块规格为 16KB 时:
text
获取数量 = 256KB / 16KB = 16 个
总内存大小 = 16 × 16KB = 256KB
因此,这个公式不仅实现了"小块多取、大块少取"的效果,也能够大致控制每次从 CentralCache 获取的内存总量。
不过,仅仅按照 256KB / size 计算仍然不够,还需要对结果设置一个合理的上限和下限。
对于规格非常小的内存块,直接计算得到的数量可能非常大。例如,当规格为 8B 时:
text
256KB / 8B = 32768 个
如果一次获取 32768 个空闲块,就可能导致当前线程的 ThreadCache 瞬间缓存大量暂时用不到的内存,从而降低内存利用率。
因此,需要为批量获取数量设置一个上限,例如最多获取 512 个:
text
计算结果超过 512
|
v
统一限制为 512
另一方面,对于规格较大的内存块,计算结果可能只有 1。
例如,当规格为 256KB 时:
text
256KB / 256KB = 1 个
如果每次只获取一个内存块,那么这个块会立即用于满足当前申请,ThreadCache 中仍然没有剩余的空闲块。下一次申请相同规格的内存时,线程又需要重新访问 CentralCache,再次进行加锁和解锁。
因此,还需要将批量获取数量的下限设置为 2:
text
计算结果小于 2
|
v
统一调整为 2
这样至少可以做到:
text
获取两个内存块
|
+-- 一个满足当前申请
|
+-- 一个缓存在 ThreadCache 中
最终,批量获取数量可以理解为:
cpp
size_t num = (256 * 1024) / size;
if (num < 2)
{
num = 2;
}
else if (num > 512)
{
num = 512;
}
其整体规则为:
text
先使用 256KB / size
计算当前规格的批量数量
|
v
小于 2 时调整为 2
|
v
大于 512 时限制为 512
例如:
text
规格为 8B
256KB / 8B = 32768
最终数量:512
规格为 1KB
256KB / 1KB = 256
最终数量:256
规格为 16KB
256KB / 16KB = 16
最终数量:16
规格为 128KB
256KB / 128KB = 2
最终数量:2
规格为 256KB
256KB / 256KB = 1
最终数量:2
因此,批量获取数量的设计本质上是在两个目标之间进行权衡:
text
一次获取过多
|
v
ThreadCache 容易囤积大量内存
一次获取过少
|
v
线程会频繁访问 CentralCache
通过使用 256KB / size 建立反比关系,并将结果限制在 [2, 512] 的范围内,可以让小规格内存块一次多获取一些,以减少访问 CentralCache 的次数;同时让大规格内存块一次少获取一些,避免线程私有缓存持有过多内存。
ThreadCache 的按需倍增策略:动态调整批量补给规模
根据上文,我们已经认识了不同规格内存块所对应的批量获取数量。对于规格大小为 size 的内存块,可以通过 256KB / size 计算一个批量获取数量,并将结果限制在合理的范围内。
不过,这个计算结果并不代表线程第一次申请某种规格的内存时,就要直接从 CentralCache 获取这么多内存块。
高并发内存池所面对的场景虽然包含频繁的内存申请和释放,但并不能保证线程会频繁申请每一种规格的内存。有些情况下,线程可能只申请一次某种规格的内存块,后续便不再申请。
假设某个规格计算出的批量获取数量较大,如果线程第一次申请时,就直接按照这个数量从 CentralCache 获取一整批内存块,那么除去当前使用的一个块,其余内存块都会被缓存在该线程的 ThreadCache 中。
但是,如果线程后续不再申请这种规格的内存,这些空闲块就会长期滞留在线程私有的 ThreadCache 中,其他线程无法直接复用,从而造成不必要的内存占用。
text
线程只申请一次某种规格
|
v
一次获取大量空闲块
|
v
其余空闲块缓存在 ThreadCache
|
v
后续不再申请,空闲块长期滞留
如果一个线程对大量不同规格都只进行少量申请,却在第一次申请时就为每一种规格获取大量空闲块,那么该线程就可能占用大量暂时无法得到有效复用的内存。
因此,ThreadCache 中每个桶的长度上限不能一开始就设置得很大,而是需要采用一种慢增长策略。
所谓慢增长,并不是每次只将阈值增加 1,而是让每个桶从一个较小的初始阈值开始,再根据线程对该规格内存块的实际需求逐步扩大。
对于某一种规格,可以维护两个数量:
text
批量上限:
根据内存块规格计算出的最大批量获取数量
当前阈值:
当前线程对该规格可以一次获取的数量
本次真正从 CentralCache 获取的数量,取当前阈值与批量上限中的较小值:
cpp
actual_num = std::min(current_threshold, batch_limit);
在桶的初始状态下,当前阈值可以设置为一个较小的值,例如 1。
这样,线程第一次申请该规格的内存时,只会从 CentralCache 获取少量内存块,而不会立即按照批量上限获取大量资源。
text
第一次申请某种规格
|
v
当前阈值较小
|
v
只获取少量内存块
|
v
避免一次缓存过多内存
如果线程后续不再申请这种规格,那么浪费的内存非常有限。
但是,如果线程确实对该规格存在频繁的申请需求,那么 ThreadCache 对应桶中的自由链表就会不断被取空,并再次触发对 CentralCache 的访问。
桶反复为空,说明当前线程对这种规格的内存存在持续需求。此时,就可以将当前阈值扩大,使下一次能够从 CentralCache 获取更多的空闲块。
为了避免阈值增长过慢,导致热点规格反复访问 CentralCache,这里可以采用倍增方式:
text
1 → 2 → 4 → 8 → 16 → 32 → ...
例如,某种规格计算出的批量上限为 32:
text
第一次桶为空:
当前阈值为 1
本次获取 1 个
阈值增长为 2
第二次桶为空:
当前阈值为 2
本次获取 2 个
阈值增长为 4
第三次桶为空:
当前阈值为 4
本次获取 4 个
阈值增长为 8
......
最终最多增长到批量上限 32
当然,阈值不能无限增长,而是最多增长到当前规格对应的批量上限:
cpp
if (current_threshold < batch_limit)
{
current_threshold *= 2;
if (current_threshold > batch_limit)
{
current_threshold = batch_limit;
}
}
整个过程可以表示为:
text
桶的初始阈值较小
|
v
实际获取数量 =
当前阈值与批量上限的较小值
|
v
如果桶再次为空
|
v
说明线程仍有持续申请需求
|
v
阈值扩大为原来的两倍
|
v
最多增长到批量上限
这种策略可以同时兼顾两种不同的使用场景。
对于只偶尔申请某种规格的线程:
text
初始阈值较小
|
v
只获取少量内存
|
v
避免 ThreadCache 囤积大量空闲块
对于频繁申请某种规格的线程:
text
对应桶反复为空
|
v
阈值快速倍增
|
v
一次获取更多空闲块
|
v
减少后续访问 CentralCache 的次数
如果每次只将阈值增加 1,例如:
text
1 → 2 → 3 → 4 → 5 → ...
那么对于真正的热点规格,阈值增长速度会比较慢。在增长到合适数量之前,线程仍然需要多次访问 CentralCache,反复进行加锁和解锁,从而增加公共结构上的锁开销。
因此,这里的慢增长更准确地说是一种按需倍增策略 :初始阶段只获取少量内存,避免线程为偶发申请囤积资源;如果线程表现出持续的申请需求,就通过阈值倍增快速扩大本地缓存,减少后续访问 CentralCache 的频率。
最终,本次实际获取数量可以表示为:
text
批量上限:
根据 256KB / size 计算,并限制在合理范围内
当前阈值:
从较小的初始值开始,随着桶反复耗尽而倍增
实际获取数量:
min(当前阈值, 批量上限)
这样,ThreadCache 就能够根据每个线程对不同内存规格的实际使用情况,动态调整本地缓存规模,在减少锁竞争和控制内存占用之间取得平衡。
PageCache 的页级内存管理:Span 分配、拆分、归还与合并
PageCache 的页级内存管理:Span 组织与页数计算
根据上文,我们已经认识了高并发内存池中的 ThreadCache 和 CentralCache。接下来,我们继续认识位于三级缓存最底层的 PageCache。
在了解 PageCache 的具体结构之前,首先需要明确它的职责。
PageCache 主要负责以页为单位管理底层内存。当内存池中的页级资源不足时,PageCache 会向操作系统申请一段连续的虚拟地址空间,并对已经申请到的内存进行统一组织和管理。
与 ThreadCache 和 CentralCache 类似,PageCache 内部同样采用桶数组和链表进行组织。不过,三者桶数组中每个桶所表示的含义并不相同。
对于 ThreadCache 和 CentralCache 来说,每一个桶对应的是一种固定规格的小块内存,例如 16 字节、32 字节或者 64 字节等。
而对于 PageCache 来说,每一个桶对应的则是特定页数的连续虚拟内存:
text
PageCache
|
+-- 1 页桶 ---> 空闲 Span 链表
|
+-- 2 页桶 ---> 空闲 Span 链表
|
+-- 3 页桶 ---> 空闲 Span 链表
|
...
|
+-- 128 页桶 ---> 空闲 Span 链表
PageCache 管理的是由 1 页到 128 页连续虚拟内存组成的空闲 Span。每个桶中的链表节点都是 Span,而 Span 通过起始页号和页数,描述一段连续的虚拟内存页。
当 PageCache 从操作系统获得一段连续虚拟内存以后,会使用一个 Span 对象描述这段内存,并根据该 Span 包含的页数,将其挂入对应页数的空闲桶中。
text
连续虚拟内存页
|
v
创建 Span 进行描述
|
v
根据 Span 的页数
挂入 PageCache 对应的桶中
认识了 PageCache 的基本职责以后,接下来再来看它与上一层 CentralCache 之间是如何联动的。
对于 CentralCache 来说,可能会出现这样一种情况:某个固定规格所对应的桶中没有可用的 Span,或者桶中虽然仍然存在 Span,但是这些 Span 内部的空闲块已经全部被分配出去,无法继续向 ThreadCache 提供该规格的小块。
此时,CentralCache 就需要向下一层的 PageCache 请求新的内存资源。
text
CentralCache 对应规格没有可用空闲块
|
v
访问 PageCache
|
v
获取一段连续虚拟内存页
这里需要注意,CentralCache 与 PageCache 管理内存时采用的粒度并不相同。
CentralCache 面向的是经过规格划分以后的小块内存,而 PageCache 管理的则是由若干连续虚拟页组成的 Span。
因此,CentralCache 不能直接对 PageCache 说:
text
我缺少一个 16 字节的空闲块
因为 PageCache 并不以 16 字节、32 字节这样的对象规格管理内存。CentralCache 必须先根据当前缺少的小块规格,计算需要向 PageCache 请求多少个连续页。
在前面的学习中,我们已经根据小块规格 size,计算出了该规格对应的标准批量数量:
cpp
num = (256 * 1024) / size;
if (num < 2)
{
num = 2;
}
else if (num > 512)
{
num = 512;
}
这个数量表示:对于当前规格,准备一批内存时,通常需要包含多少个大小为 size 的小块。
因此,这一批小块所需要的总字节数为:
text
总字节数 = size × num
再将总字节数向上换算成页数,就可以得到 CentralCache 应当向 PageCache 请求多少页:
text
需要的页数 =
(size × num) 向上取整到页大小
假设一个内存页的大小为 4KB,那么可以按照下面的方式计算:
cpp
size_t total_bytes = size * num;
size_t page_num =
(total_bytes + PAGE_SIZE - 1) / PAGE_SIZE;
这里还需要区分前面介绍的慢增长阈值。
每个 ThreadCache 桶维护的慢增长阈值,只用于控制当前线程这一次从 CentralCache 中实际获取多少个空闲块:
text
CentralCache → ThreadCache
本次转移多少个小块
|
v
受到当前线程桶阈值的限制
但是,CentralCache 向 PageCache 申请的 Span 并不是只服务于当前线程的这一次申请。
PageCache 提供的 Span 会由 CentralCache 持续管理,后续还可以继续为当前线程或者其他线程的 ThreadCache 提供相同规格的小块。
因此,CentralCache 向 PageCache 申请多少页,并不根据某一个线程当前的慢增长阈值计算,而是根据该规格的标准批量数量 num 计算。
text
慢增长阈值
|
v
控制 ThreadCache 本次实际获取多少个小块
标准批量数量 num
|
v
控制 CentralCache 准备多大的页级内存资源
例如,某个规格通过 256KB / size 计算出的标准批量数量为 32,但当前线程对应桶的慢增长阈值只有 4。
此时:
text
CentralCache 向 PageCache 申请内存时:
按照 32 个小块所需的总大小计算页数
CentralCache 向当前 ThreadCache 补充时:
本次只提供 4 个小块
剩余的小块继续保留在 CentralCache 所管理的 Span 中,后续还可以继续提供给其他 ThreadCache。
因此,二者之间的关系可以表示为:
text
PageCache
提供包含若干连续页的 Span
|
v
CentralCache
将 Span 切分成一批固定规格的小块
|
+-- 本次只向 ThreadCache 提供少量小块
|
+-- 其余小块继续由 CentralCache 管理
由此可以看出,慢增长策略只影响 ThreadCache 每次从 CentralCache 获取的小块数量,而不会改变 CentralCache 根据当前规格向 PageCache 申请页级内存资源的计算方式。
CentralCache 仍然需要根据小块规格和标准批量数量,计算一批小块所需的总字节数,再将其换算成页数,最后向 PageCache 请求对应页数的 Span。
从 Span 获取到小块切分:CentralCache 的内存补充流程
根据上文,我们已经知道,当 CentralCache 中某个规格对应桶的 Span 链表为空,或者链表中的所有 Span 都已经没有可用的空闲块时,CentralCache 就需要向下一层的 PageCache 获取新的内存资源。
在此前介绍 ThreadCache 与 CentralCache 的交互时,我们提到过:当 ThreadCache 对应桶为空时,并不会只从 CentralCache 中获取一个空闲块,而是会批量获取多个相同规格的空闲块。
那么,CentralCache 向 PageCache 获取内存资源时,是否也需要一次获取多个 Span 呢?
这里并不需要。
原因在于,Span 本身就关联着一段由一个或多个连续虚拟内存页组成的内存区域,而一个内存页通常为 4KB。对于小块内存来说,一个 Span 所关联的连续空间,通常已经能够切分出多个相同规格的小块。
同时,CentralCache 中的 Span 并不是只服务于某一个线程的 ThreadCache,而是可以持续向多个线程提供相同规格的空闲块。
因此,当 CentralCache 中某个规格的内存不足时,只需要向 PageCache 获取一个满足页数要求的 Span 即可。
text
CentralCache 中某个规格没有可用内存
|
v
向 PageCache 请求一个 Span
|
v
获取一段连续的虚拟内存页
这里申请的 Span 所包含的页数,按照前文介绍的方式,根据当前规格对应的标准批量数量计算即可。
当 PageCache 返回一个满足要求的 Span 后,这个 Span 所关联的仍然只是一段连续的虚拟内存页,还不能直接作为固定规格的小块提供给 ThreadCache。
因此,CentralCache 需要按照当前桶所对应的规格,将这段连续空间切分成一个个大小相同的小块,再通过 Span 内部的自由链表将这些空闲块组织起来。
text
PageCache 返回一个 Span
|
v
一段连续的虚拟内存页
|
v
CentralCache 按照当前规格切分
|
v
obj -> obj -> obj -> obj -> ...
完成切分以后,这个 Span 就可以挂载到 CentralCache 对应规格的 Span 链表中,并持续为不同线程的 ThreadCache 提供空闲块。
当某个线程的 ThreadCache 对应桶为空时,就可以从这个 Span 内部的自由链表中获取一批空闲块;而当某个线程的 ThreadCache 中积累了过多空闲块时,则会根据每个空闲块所属的 Span,将其分别归还到对应的自由链表中。
text
ThreadCache 空闲块不足
|
v
从 Span 内部获取一批空闲块
ThreadCache 空闲块过多
|
v
将空闲块归还到各自所属的 Span
由此可以看出,PageCache 向 CentralCache 提供一个 Span,而 CentralCache 再负责将其转换成能够直接提供给 ThreadCache 的固定规格小块。
PageCache 的全局锁设计:跨桶扫描、Span 拆分与锁粒度权衡
另外,还需要注意 PageCache 的线程安全问题。
和 CentralCache 一样,PageCache 也是一个由多个线程共享的公共结构。多个线程在访问各自的 ThreadCache 时,可能会同时进入 CentralCache,而不同的 CentralCache 桶又可能同时向同一个 PageCache 请求或者归还 Span。
因此,PageCache 同样需要通过加锁保证内部结构的线程安全。
不过,PageCache 和 CentralCache 的加锁粒度并不相同。
对于 CentralCache 来说,不同规格的桶之间相对独立,因此可以为每个桶分别设置一把锁:
text
CentralCache
|
+-- 桶 0 + 锁 0
+-- 桶 1 + 锁 1
+-- 桶 2 + 锁 2
...
这样,多个线程访问不同规格的桶时,可以并发执行。
而 PageCache 则使用一把全局锁保护整个页级内存管理结构:
text
PageCache
|
+-- 1 页桶
+-- 2 页桶
+-- 3 页桶
...
+-- 128 页桶
|
v
由同一把全局锁保护
读者看到这里可能会产生一个疑问:既然 CentralCache 可以为每个桶分别设置一把锁,那么 PageCache 为什么不采用相同的方式,而是使用一把全局锁保护整个桶数组呢?
从结构上来说,PageCache 完全可以为每个桶分别设置一把锁。它的桶数组长度固定,也不会像哈希表一样涉及扩容,运行过程中发生变化的主要是各个桶内部 Span 链表中的节点。
但是,PageCache 的一次内存申请并不一定只会访问一个桶。
假设 CentralCache 需要一个包含 k 页的 Span,PageCache 首先会访问 k 页所对应的桶。如果该桶不为空,就可以直接从中摘取一个 Span。
但如果该桶为空,PageCache 不会立即通过系统调用向操作系统申请新的虚拟内存。因为系统调用需要完成用户态与内核态之间的切换,并由内核执行虚拟内存映射等操作,具有一定的时间开销。因此,PageCache 会优先继续向后扫描页数更大的桶,尝试复用已有的空闲 Span:
text
请求 k 页的 Span
↓
检查 k 页桶
↓
目标桶为空
↓
继续扫描 k+1、k+2、k+3......页桶
↓
找到第一个非空桶
如果每个桶都使用独立的桶锁,那么为了安全地判断桶中的链表是否为空,扫描过程就需要反复获取和释放不同桶的锁:
text
获取 k 页桶的锁
↓
判断链表为空
↓
释放 k 页桶的锁
↓
获取 k+1 页桶的锁
↓
判断链表是否为空
↓
释放锁并继续扫描
直到找到一个非空桶后,还需要从该桶中摘出一个较大的 Span,将其拆分成当前需要的 k 页和剩余的若干页,再把剩余部分挂入对应页数的桶中。
假设最终从 m 页桶中取出了一个包含 m 页的 Span,而 CentralCache 当前只需要其中的 k 页,那么 PageCache 会对这个 Span 进行拆分。
具体来说,首先将原有 Span 所描述的页数从 m 修改为 k,使其继续关联原始连续内存中的前 k 页,并将这个 Span 返回给 CentralCache。
随后,再创建并初始化一个新的 Span,将其起始页号设置为原始起始页号加上 k,页数设置为 m-k,使其关联剩余的连续虚拟内存页。最后,再根据剩余页数,将这个新的 Span 挂入 PageCache 中对应的 m-k 页桶。
text
从 m 页桶中摘出原有 Span
↓
原有 Span:
起始页号 = page_id
页数由 m 修改为 k
↓
关联前 k 页,并返回给 CentralCache
↓
创建新的 Span
↓
新 Span:
起始页号 = page_id + k
页数 = m - k
↓
关联剩余的 m-k 页
↓
挂入 PageCache 的 m-k 页桶
拆分前后的地址关系可以表示为:
text
拆分前:
原有 Span
[ page_id ................................ page_id + m )
拆分后:
原有 Span 新 Span
[ page_id ........ page_id + k ) [ page_id + k ........ page_id + m )
k 页 m-k 页
这样,一段包含 m 个连续虚拟页的内存,就被拆分成了一个供 CentralCache 使用的 k 页 Span,以及一个重新留在 PageCache 中等待后续复用的 m-k 页 Span。
可以看到,一次 Span 获取过程可能需要连续扫描多个桶,并且最终还会涉及从一个桶中摘除 Span,再将拆分后的剩余部分插入另一个桶。
如果采用桶锁,虽然这种方式在理论上完全可以实现,但一次操作可能需要多次加锁和解锁。即使当前锁没有发生竞争,互斥锁本身的获取和释放也存在一定开销。
而对于 PageCache 来说,扫描桶时主要只是判断链表是否为空,找到合适的 Span 后进行链表摘除、元数据修改、拆分以及重新挂载。这些操作本身都比较轻量,并不会涉及复杂的计算。
因此,与其在一个较短的操作过程中反复获取和释放多个桶锁,不如直接在进入 PageCache 时获取一次全局锁,将整个扫描和拆分过程都放在同一个临界区内:
text
获取 PageCache 全局锁
↓
扫描后续桶
↓
摘出较大的 Span
↓
完成 Span 拆分
↓
将剩余 Span 挂入对应桶
↓
释放全局锁
这样,一次 PageCache 操作只需要进行一次加锁和一次解锁,避免了扫描多个桶时频繁切换锁。
因此,当前实现选择使用一把全局锁统一保护整个 PageCache。这样虽然会牺牲一定的并发度,使多个线程对 PageCache 的访问串行执行,但可以避免一次操作中频繁获取和释放多个桶锁,同时使跨桶的摘除、拆分、合并和重新挂载过程更加直接。
这里并不是说桶锁无法实现,也不能认为全局锁在任何情况下都一定具有更高的性能。它本质上是一种设计权衡:
text
桶锁:
不同桶之间的并发度更高
但跨桶操作需要多次加锁和解锁
全局锁:
所有 PageCache 操作串行执行
但一次申请只需加锁和解锁各一次
实现更加简单直接
考虑到 PageCache 位于三级缓存的最底层,正常情况下大部分小块申请都会优先由 ThreadCache 和 CentralCache 满足,真正访问 PageCache 的频率相对较低。因此,在当前实现中,使用全局锁保护整个桶数组是一种更简单且合理的选择。
空闲 Span 的归还与合并:use_count、相邻定位与页级复用
在前文中,我们提到过,Span 除了保存起始页号和页数等核心属性以外,还会维护一个 use_count 字段。
use_count 用来记录该 Span 中已经从自由链表取出、但尚未归还到 CentralCache 的小块数量。这些小块可能正在被用户使用,也可能暂时缓存在某个线程的 ThreadCache 中。
当 CentralCache 从某个 Span 中取出一批空闲块时,会增加该 Span 的 use_count;当 ThreadCache 将空闲块归还到所属 Span 的自由链表时,则会减少对应的 use_count。
因此,当一个 Span 的 use_count 变为 0 时,就意味着此前从该 Span 中取出的所有小块都已经归还,整个 Span 已经处于完全空闲的状态。
text
use_count > 0
↓
仍有部分小块位于 CentralCache 之外
use_count == 0
↓
所有小块均已归还
↓
整个 Span 完全空闲
此时,这个 Span 不应该继续停留在 CentralCache 对应规格的桶中。
假设这个 Span 此前按照 16B 的规格进行了切分,如果它在完全空闲以后仍然保留在 CentralCache 的 16B 桶中,那么这段连续虚拟内存就仍然被绑定在 16B 规格上,只能继续服务于该规格的内存申请。
如果后续线程对 16B 内存块的需求并不频繁,而对其他规格的内存块需求更加频繁,那么这个 Span 虽然已经完全空闲,却无法提供给其他规格使用,只能长期滞留在当前桶中。
text
完全空闲的 Span
↓
继续留在 CentralCache 的固定规格桶中
↓
只能服务于原有规格
↓
其他规格无法复用这段内存
因此,当 use_count 变为 0 时,需要将该 Span 从 CentralCache 对应的 Span 链表中摘除,并归还给 PageCache,使其重新回到页级内存的统一管理中。
text
Span 完全空闲
↓
从 CentralCache 对应桶中摘除
↓
归还给 PageCache
↓
解除与原有小块规格的绑定
归还以后,这段连续虚拟内存便可以在后续被重新分配给其他规格的 CentralCache 桶,并按照新的规格重新进行切分,从而提高底层内存跨规格复用的能力。
不过,Span 归还给 PageCache 后,并不是立即按照自身页数挂入对应的桶中,而是需要先尝试与其前后相邻的空闲 Span 进行合并。
为什么需要合并 Span
假设 PageCache 中存在两个地址连续的空闲 Span:
text
Span A:4 页
Span B:6 页
虽然它们一共包含 10 个空闲页,但是如果二者始终保持独立,那么它们只能分别提供最多 4 页和 6 页的连续内存,无法直接满足一个需要 8 个连续页的申请。
text
4 页 Span + 6 页 Span
总空闲页数 = 10 页
但是二者相互独立:
无法直接提供连续的 8 页
如果这两个 Span 在虚拟地址上相邻,就可以将其合并为一个包含 10 页的更大 Span:
text
4 页 Span + 6 页 Span
↓
合并为 10 页 Span
合并后的大 Span 不仅可以直接满足更大页数的申请,还可以在较小页数对应的桶为空时,被拆分成所需页数和剩余页数两部分。
因此,在地址连续且均处于空闲状态的前提下,将相邻的小 Span 合并成更大的 Span,可以减少页级内存的外部碎片,提高空闲连续页的组合与复用能力,同时降低因为缺少足够大的连续 Span 而再次向操作系统申请内存的概率。
text
多个相邻的小 Span
↓
合并成更大的 Span
↓
能够满足更多不同页数的申请
↓
提高空闲页的复用能力
如何找到前后相邻的空闲 Span
既然需要进行合并,那么首先就需要找到当前 Span 前后相邻的空闲 Span。
假设当前归还的 Span 包含以下信息:
text
起始页号 = page_id
页数 = n
那么它所覆盖的页号范围为:
text
[page_id, page_id + n)
因此,当前 Span 的最后一个页号为:
text
page_id + n - 1
而其前后相邻位置的页号分别为:
text
前一个相邻页号 = page_id - 1
后一个相邻页号 = page_id + n
虽然根据起始页号和页数可以直接计算出前后相邻位置的页号,但是只有页号还不够。我们还需要根据这个页号,快速找到负责描述该页的空闲 Span。
一种直接的方式,是遍历 PageCache 的所有桶,再遍历每个桶中挂载的全部 Span,逐个比较其起始页号和页数,从而判断是否与当前 Span 相邻。
但是,这种方式需要扫描大量链表节点,查询效率较低,显然不适合频繁执行的合并操作。
因此,PageCache 还需要维护一个页号到空闲 Span 的映射结构:
text
页号 → Span*
这个映射结构可以使用哈希表实现,其中键为页号,值为对应的 Span*。
对于 PageCache 当前管理的每一个空闲 Span,需要将其覆盖范围内的所有页号都注册到映射结构中,并让这些页号都指向同一个 Span。
例如,一个空闲 Span 的起始页号为 100,页数为 4:
text
Span:
起始页号 = 100
页数 = 4
那么需要建立以下映射关系:
text
100 → Span*
101 → Span*
102 → Span*
103 → Span*
这样,在合并时只需要根据当前 Span 的前后相邻页号查询哈希表,就可以快速定位到对应的空闲 Span:
text
查询 page_id - 1
↓
找到前方相邻的空闲 Span
查询 page_id + n
↓
找到后方相邻的空闲 Span
这里需要注意,这个映射结构中记录的本身就是 PageCache 当前管理的空闲 Span。因此,只要根据相邻页号查询成功,就说明该页号属于一个空闲 Span,可以将其从原来对应页数的桶中摘除,并与当前 Span 进行合并。
假设当前归还的 Span 前后都存在相邻的空闲 Span,那么整个过程可以表示为:
text
当前 Span 归还 PageCache
↓
查询前一个相邻页号
↓
找到前方空闲 Span
↓
从原桶中摘除并合并
↓
查询合并后 Span 的后一个相邻页号
↓
找到后方空闲 Span
↓
继续摘除并合并
↓
形成一个更大的空闲 Span
↓
挂入合并后页数对应的桶
完成合并以后,还需要根据合并后的起始页号和页数,重新更新页号到 Span* 的映射关系,确保新 Span 覆盖范围内的所有页号都能够正确指向合并后的 Span。
因此,这里的核心逻辑可以总结为:
text
Span 完全空闲
↓
从 CentralCache 归还给 PageCache
↓
解除与固定小块规格的绑定
↓
尝试与前后相邻的空闲 Span 合并
↓
通过页号映射结构快速定位相邻 Span
↓
形成更大的连续空闲内存区域
↓
重新挂入 PageCache 对应的桶
通过这种方式,PageCache 可以将分散但地址连续的空闲页重新组合成更大的 Span,从而提高页级内存的整体利用率。
高并发内存池的完整实现:三级缓存协作、页号映射与对象接口封装
至此,我们已经具备了实现高并发内存池所需的理论基础,不仅认识了三级缓存各自承担的职责,也梳理清楚了 ThreadCache、CentralCache 与 PageCache 之间的申请、补充、归还和回收流程。
接下来,我们正式进入代码实现阶段。按照内存申请时的访问顺序,首先从距离用户最近的第一级缓存 ThreadCache 开始实现。
ThreadCache 的完整实现:线程私有缓存、规格映射与批量补给回收
ThreadCache 类的基本骨架
接下来开始实现 ThreadCache 类。
对于 ThreadCache 来说,其内部最核心的成员就是一个自由链表数组。数组中的每一个位置都对应一种固定规格的小块内存,并通过自由链表组织当前线程缓存的空闲块。
cpp
FreeList free_Lists[NFreelists];
在当前实现中,自由链表数组的长度为 208:
cpp
NFreelists = 208;
至于为什么需要设置 208 个桶,以及不同大小的内存申请如何映射到具体的桶下标,后续在介绍内存对齐与桶下标计算时再详细说明。
除了维护自由链表数组以外,ThreadCache 还有一个重要的设计要求:
每个线程只能拥有一个属于自己的
ThreadCache对象。
需要注意,这里并不是整个进程只存在一个 ThreadCache,而是每个线程分别拥有一个独立的实例:
text
线程 A → ThreadCache A
线程 B → ThreadCache B
线程 C → ThreadCache C
不同线程之间的 ThreadCache 相互独立,因此线程访问自己的本地缓存时,不需要与其他线程竞争锁。
为了保证同一个线程内部不会随意创建、复制出多个 ThreadCache 对象,可以将 ThreadCache 设计成一个线程级单例。
禁止在类外直接创建 ThreadCache
首先,将构造函数设置为私有:
cpp
private:
ThreadCache() = default;
构造函数私有以后,类外就不能直接创建 ThreadCache 对象:
cpp
ThreadCache tc; // 无法通过编译
ThreadCache* ptr = new ThreadCache; // 无法通过编译
因为无论是在栈上创建对象,还是通过 new 在堆上创建对象,都需要调用构造函数,而类外无法访问私有构造函数。
同时,还需要删除拷贝构造函数和拷贝赋值运算符,防止通过已有对象复制出新的 ThreadCache:
cpp
ThreadCache(const ThreadCache&) = delete;
ThreadCache& operator=(const ThreadCache&) = delete;
移动操作同样没有必要,因此也一并删除:
cpp
ThreadCache(ThreadCache&&) = delete;
ThreadCache& operator=(ThreadCache&&) = delete;
这样,外部代码既不能直接创建 ThreadCache,也不能通过拷贝或者移动生成新的实例,只能通过类提供的统一接口获取当前线程对应的对象。
使用 thread_local 实现线程私有
为了让每个线程分别拥有自己的 ThreadCache,这里需要使用 thread_local 关键字:
cpp
static thread_local std::unique_ptr<ThreadCache> pTLSthreadcache;
这条声明中,static 和 thread_local 分别解决了两个不同的问题。
static:成员属于类本身
static 表示该变量是类的静态成员,与某一个具体的 ThreadCache 对象无关。
由于构造函数已经被设置为私有,类外一开始并不存在可供访问的 ThreadCache 对象,因此需要通过静态成员变量保存实例,再通过静态成员函数提供统一的获取入口。
thread_local:每个线程拥有独立副本
我们知道,一个线程在创建时,不仅需要为其准备独立的线程栈,还需要建立用于描述和管理线程状态的线程控制结构,并为线程局部数据准备相应的 TLS 存储区域。
其中,线程控制结构会记录线程运行所需的上下文和状态信息,而 TLS 区域则用于保存当前线程私有的数据。不同线程虽然可以访问名称相同的线程局部变量,但实际访问的是各自 TLS 区域中的独立副本。
当一个变量使用 thread_local 关键字声明后,它就具有线程存储期,每个线程都会拥有一份独立的变量实例:
text
线程 A 的 TLS:
pTLSthreadcache → ThreadCache A
线程 B 的 TLS:
pTLSthreadcache → ThreadCache B
线程 C 的 TLS:
pTLSthreadcache → ThreadCache C
在线程运行库的常见实现中,TCB 与 TLS 数据通常会被组织在相邻的线程局部存储区域中。每个线程都拥有独立的 FS Base,这里的 FS Base 可以理解为当前线程访问线程局部存储区域时所使用的基准地址。在 Linux x86-64 环境下,CPU 可以通过 FS 段基址配合变量偏移量,定位到当前线程 TLS 区域中的具体变量。
因此,编译器在访问 thread_local 变量时,通常不需要像访问普通局部变量一样,通过栈指针或栈帧指针进行定位,而是可以生成基于 FS 段和特定偏移量的访问指令:
text
当前线程的 FS Base
+
thread_local 变量对应的偏移量
↓
定位当前线程中的变量副本
这也正是 thread_local 能够实现线程私有数据的底层基础:虽然不同线程执行的是相同的访问代码,并且对于同一个 TLS 变量通常使用相同的相对偏移量,但由于每个线程拥有不同的 FS Base,最终计算得到的实际地址也不同,因此各个线程访问到的是各自 TLS 区域中的独立变量副本。
因此,虽然变量名都叫作 pTLSthreadcache,不同线程实际访问的并不是同一个智能指针:
text
线程 A 的 pTLSthreadcache → ThreadCache A
线程 B 的 pTLSthreadcache → ThreadCache B
线程 C 的 pTLSthreadcache → ThreadCache C
这样,每个线程就可以独立管理自己的 ThreadCache,不会直接访问其他线程的本地缓存。
ThreadCache 的延迟创建
在介绍当前实现之前,可以先认识单例对象常见的两种初始化方式:饿汉模式与懒汉模式。
对于具有静态存储期的全局变量、命名空间作用域变量以及类的静态成员变量,程序通常会在进入 main 函数之前为其分配存储空间,并完成必要的初始化。如果直接声明一个静态的自定义类型对象,那么该对象可能在程序启动阶段就调用构造函数完成创建。
例如:
cpp
ThreadCache ThreadCache::_instance;
这种在程序真正使用对象之前,就提前完成实例创建和初始化的方式,通常称为饿汉模式:
text
程序启动
↓
进入 main 函数之前
↓
为静态对象分配存储空间
↓
调用构造函数完成对象初始化
↓
进入 main 函数
饿汉模式的优点是对象在程序正式运行前就已经准备完成,后续可以直接使用。但它也存在一定的问题:即使程序运行期间从未使用过该对象,也会提前完成创建;如果对象的构造过程包含申请大量内存、读取文件或者建立连接等耗时操作,还可能增加程序的启动成本。
与饿汉模式相对,懒加载并不会提前创建真正需要使用的实例对象,而是将其创建过程推迟到第一次访问时。不过,懒加载也可以采用不同的实现方式:一种方式是提前创建并初始化一个轻量的空指针对象,等到第一次调用获取接口时,再动态创建其指向的实例;另一种方式是在获取函数内部定义局部静态对象,使该对象只有在程序第一次执行到其定义语句时,才完成存储空间分配、构造和初始化。
cpp
static ThreadCache& GetThreadCache()
{
// 当前线程第一次执行到该语句时, 才创建并初始化 ThreadCache 对象
static thread_local ThreadCache thread_cache;
return thread_cache;
}
在当前实现中,线程局部成员被声明为一个智能指针,而不是直接声明为 ThreadCache 对象:
cpp
static thread_local std::unique_ptr<ThreadCache> pTLSthreadcache;
thread_local 变量与普通的进程级静态变量有所不同:每个线程都会拥有一份独立的变量副本,并在对应线程的生命周期内完成初始化和销毁。
对于当前声明,线程拥有的是一个 unique_ptr<ThreadCache> 对象。该智能指针完成初始化后,初始状态为空:
text
pTLSthreadcache == nullptr
需要注意,此时只是创建并初始化了当前线程中的智能指针变量,并没有真正创建 ThreadCache 对象:
text
当前线程的 TLS 区域
↓
存在 pTLSthreadcache
↓
智能指针当前为空
↓
堆上还没有 ThreadCache 对象
只有当线程第一次调用 GetThreadCache() 时,程序才会在堆上创建一个 ThreadCache 对象,并交由当前线程的智能指针管理:
cpp
ThreadCache& ThreadCache::GetThreadCache()
{
if (pTLSthreadcache == nullptr)
{
pTLSthreadcache.reset(new ThreadCache);
}
return *pTLSthreadcache;
}
整个过程可以表示为:
text
线程开始运行
↓
初始化当前线程的 pTLSthreadcache
↓
pTLSthreadcache 为空
↓
第一次调用 GetThreadCache()
↓
在堆上创建 ThreadCache 对象
↓
pTLSthreadcache 管理该对象
↓
后续调用直接返回同一个对象
这就是一种线程级的懒加载方式。
如果某个线程从始至终都没有使用高并发内存池,那么它就不会调用 GetThreadCache(),也就不会真正创建属于该线程的 ThreadCache 对象,从而避免不必要的对象初始化和内存占用。
这里需要注意,真正实现延迟创建的并不是"使用了指针"这一点,而是获取函数中的判空与创建逻辑:
cpp
if (pTLSthreadcache == nullptr)
{
pTLSthreadcache.reset(new ThreadCache);
}
指针只是允许我们先用空值表示"对象尚未创建"。只有第一次调用获取函数时才执行 new ThreadCache,才是懒加载的核心。
因此,当前设计可以总结为:
text
饿汉模式:
对象提前创建
即使没有使用,也会完成初始化
当前线程级懒加载:
线程先拥有一个空智能指针
第一次使用时才创建 ThreadCache
未使用内存池的线程不会创建实例
为什么使用 unique_ptr
这里没有使用普通指针:
cpp
static thread_local ThreadCache* pTLSthreadcache;
而是使用了:
cpp
static thread_local std::unique_ptr<ThreadCache> pTLSthreadcache;
核心原因是需要在线程退出时自动销毁对应的 ThreadCache。
线程运行期间,其 ThreadCache 的各个桶中可能仍然缓存着一些空闲块。如果线程已经退出,这些空闲块就不会再被该线程使用。
如果不进行处理,这些内存块就会继续滞留在已经结束的线程对应的本地缓存中,其他仍在运行的线程也无法复用。
因此,在 ThreadCache 的析构函数中,需要遍历各个桶,将其中残留的空闲块归还给 CentralCache:
text
线程退出
↓
销毁线程对应的 ThreadCache
↓
遍历各个自由链表
↓
将残留的小块归还 CentralCache
↓
其他活跃线程可以继续复用
如果 TLS 中保存的是普通指针,那么线程退出时只会清理指针变量本身,并不会自动 delete 它指向的堆对象:
text
普通指针被清理
↓
堆上的 ThreadCache 不会自动释放
↓
ThreadCache 析构函数不会执行
↓
桶中的空闲块无法正常归还
当线程正常退出时,会清理当前线程所占用的相关资源,其中也包括 TLS 区域中的线程局部变量。对于 unique_ptr 这样的自定义类型对象,在线程退出时会调用其析构函数。随后,unique_ptr 会自动释放自己管理的 ThreadCache 对象,并进一步调用 ThreadCache 的析构函数。
text
线程正常退出
↓
清理当前线程中的 TLS 变量
↓
调用 unique_ptr 的析构函数
↓
自动 delete ThreadCache
↓
调用 ThreadCache 的析构函数
↓
归还桶中残留的空闲块
因此,unique_ptr 不仅避免了 ThreadCache 对象自身的内存泄漏,还将 ThreadCache 的生命周期与当前线程绑定起来。
为什么 GetThreadCache 返回引用
GetThreadCache() 最终返回的是:
cpp
return *pTLSthreadcache;
也就是当前线程 ThreadCache 对象的引用,而不是返回 unique_ptr 本身。
这是因为 unique_ptr 表示对资源的独占所有权,它不允许被拷贝:
text
拷贝构造:禁止
拷贝赋值:禁止
移动构造:转移所有权
移动赋值:转移所有权
如果将智能指针从获取函数中移动出去,就会导致当前线程的 pTLSthreadcache 失去对该对象的所有权,破坏线程级单例的管理关系。
而返回 ThreadCache&:
cpp
ThreadCache& ThreadCache::GetThreadCache();
只是在不改变所有权的前提下,为调用者提供当前线程实例的访问入口。
因此,每次调用:
cpp
ThreadCache::GetThreadCache()
获得的都是当前线程对应的同一个 ThreadCache 对象。
ThreadCache 的整体骨架
根据上面的设计,ThreadCache 类的基本结构如下:
cpp
#pragma once
#include "Common.h"
#include <cstddef>
#include <memory>
class ThreadCache
{
public:
~ThreadCache();
// 获取当前线程对应的 ThreadCache
static ThreadCache& GetThreadCache();
// 申请和释放小块内存
void* Allocate(size_t bytes);
void Deallocate(void* ptr, size_t bytes);
// 禁止拷贝
ThreadCache(const ThreadCache&) = delete;
ThreadCache& operator=(const ThreadCache&) = delete;
// 禁止移动
ThreadCache(ThreadCache&&) = delete;
ThreadCache& operator=(ThreadCache&&) = delete;
private:
ThreadCache() = default;
// 当前桶中的空闲块数量超过上限时,
// 将一部分内存块归还给 CentralCache
void ListTooLong(size_t index);
// 当前桶为空时,从 CentralCache 批量获取小块
void* FetchFromCentralCache(size_t index, size_t size);
private:
// 按照不同规格组织空闲块
FreeList free_Lists[NFreelists];
// 每个线程独立维护一份智能指针
static thread_local std::unique_ptr<ThreadCache> pTLSthreadcache;
};
静态线程局部成员变量还需要在源文件中进行定义:
cpp
thread_local std::unique_ptr<ThreadCache>
ThreadCache::pTLSthreadcache = nullptr;
获取当前线程实例的函数如下:
cpp
ThreadCache& ThreadCache::GetThreadCache()
{
if (pTLSthreadcache == nullptr)
{
pTLSthreadcache.reset(new ThreadCache);
}
return *pTLSthreadcache;
}
这里使用:
cpp
pTLSthreadcache.reset(new ThreadCache);
而没有直接使用:
cpp
std::make_unique<ThreadCache>();
是因为 ThreadCache 的构造函数被设置为了私有成员。
new ThreadCache 直接写在 ThreadCache 的成员函数内部,因此当前代码具有访问类中私有构造函数的权限。
而 std::make_unique 是标准库提供的函数模板,真正调用 ThreadCache 构造函数的代码位于标准库模板内部。标准库函数并不是 ThreadCache 的成员或友元,因此无法访问其私有构造函数,最终会导致编译失败。
至此,ThreadCache 类的基础骨架就已经搭建完成。它实现了以下几个目标:
text
每个线程拥有独立的 ThreadCache
↓
同一线程内只创建一个实例
↓
第一次访问时才进行初始化
↓
后续重复使用当前线程实例
↓
线程退出时自动析构并归还残留内存
后续只需要继续实现 Allocate、Deallocate、FetchFromCentralCache 和 ListTooLong 等接口,就可以逐步完成 ThreadCache 的内存申请、释放以及与 CentralCache 之间的联动。
FreeList 自由链表的实现
在正式介绍 ThreadCache 各个接口的实现之前,首先需要认识用于组织空闲内存块的 FreeList。
在 ThreadCache 中,每一个桶都需要通过一条自由链表管理当前规格下的空闲块。为了将链表长度维护、单个内存块插入与弹出、批量内存块插入与弹出等操作集中管理,这里专门将自由链表封装成一个 FreeList 类。
FreeList 的基本结构如下:
cpp
class FreeList
{
public:
FreeList();
void* pop();
void push(void* ptr);
void PushRange(void* start, void* end, size_t n);
void PopRange(void*& start, void*& end, size_t n);
bool empty() const;
static void*& NextObj(void* ptr);
size_t Size() const;
size_t GetMaxSize() const;
void SetMaxSize(size_t n);
private:
void* head;
size_t current_size;
size_t MaxSize;
};
其中,head 用来保存自由链表中第一个空闲块的地址。由于 FreeList 只负责组织和管理空闲内存块,并不知道这些内存块原本用于存储什么类型的数据,因此无法使用某一种具体类型的指针进行描述。这里使用通用指针 void* 保存空闲块的地址,从而可以统一管理不同数据类型所使用的内存空间。
cpp
void* head;
除了链表头指针以外,FreeList 还需要维护两个长度信息:
cpp
size_t current_size;
size_t MaxSize;
其中:
current_size记录当前自由链表中已经缓存的空闲块数量;MaxSize记录当前自由链表允许缓存的长度上限,同时也会参与前文介绍的慢增长策略。
根据前面的分析,ThreadCache 是线程私有的。如果某个桶中的自由链表长度不受限制,那么大量已经释放的内存块就可能一直滞留在当前线程中,其他线程无法直接复用。
特别是当该线程后续很少申请这种规格的内存时,这些空闲块就可能长期无法得到有效利用。因此,当链表长度超过当前阈值以后,需要将一部分空闲块归还给 CentralCache。
复用空闲块本身作为链表节点
这里的自由链表并不会额外创建一个个物理节点,再让这些节点保存空闲块地址,而是直接复用空闲块本身。
当一个内存块已经被释放后,其中原本保存的用户数据已经失去作用,因此可以直接覆盖这部分数据。这里将空闲块起始位置的前 sizeof(void*) 个字节用于保存下一个空闲块的地址,从而将多个空闲块串联起来。
在 64 位平台下,一个指针通常占用 8 字节,因此空闲块的头部可以表示为:
text
空闲块
┌──────────────────┬──────────────────────┐
│ 后继块地址 8 字节 │ 其余可复用空间 │
└──────────────────┴──────────────────────┘
^
当前空闲块的起始地址
例如,一条自由链表可以表示为:
text
head
↓
空闲块 A → 空闲块 B → 空闲块 C → nullptr
其中,每一个空闲块头部保存的都是下一个空闲块的地址。
这种设计不需要为自由链表额外申请节点内存,可以直接利用已经空闲的内存块完成链表组织。
不过,这种设计也有一个基本前提:
text
内存块大小 >= sizeof(void*)
也就是说,空闲块至少需要能够容纳一个指针。同时,空闲块的起始地址还需要满足指针的对齐要求。
NextObj:访问空闲块中的后继指针
为了方便访问和修改空闲块头部保存的后继地址,这里专门封装了一个 NextObj 函数:
cpp
void*& FreeList::NextObj(void* ptr)
{
return *(void**)ptr;
}
这里首先将 ptr 强制转换为 void**:
cpp
(void**)ptr
ptr 原本表示当前空闲块的起始地址。将其转换为 void**,相当于告诉编译器:
将当前内存块的起始位置解释为一个用于存放
void*指针的位置。
随后再进行解引用:
cpp
*(void**)ptr
得到的就是当前空闲块头部所保存的后继节点地址。
NextObj 的返回类型并不是普通的 void*,而是:
cpp
void*&
也就是一个指针引用。
这样,函数返回的结果不仅可以读取,还可以直接作为左值进行修改。例如:
cpp
NextObj(ptr) = head;
这条语句相当于直接将当前空闲块头部保存的后继地址修改为 head。
因此,NextObj 更准确的作用是:
返回当前空闲块头部用于保存后继地址的指针槽引用。
push:向自由链表头部插入一个空闲块
认识了 NextObj 后,push 的实现就比较直接了:
cpp
void FreeList::push(void* ptr)
{
NextObj(ptr) = head;
head = ptr;
++current_size;
}
假设插入前的自由链表为:
text
head
↓
A → B → C → nullptr
现在需要将空闲块 ptr 插入链表头部。
首先执行:
cpp
NextObj(ptr) = head;
将 ptr 头部保存的后继地址设置为原来的头节点:
text
ptr → A → B → C → nullptr
接着执行:
cpp
head = ptr;
让自由链表的头指针指向新插入的空闲块:
text
head
↓
ptr → A → B → C → nullptr
最后增加当前自由链表中的节点数量:
cpp
++current_size;
因此,push 只需要修改两个指针,时间复杂度为 O(1)。
实际实现中还可以增加断言:
cpp
assert(ptr != nullptr);
避免将空指针插入自由链表。
pop:从自由链表头部弹出一个空闲块
pop 的实现与普通单链表的头删操作类似。
首先保存当前头节点,然后让 head 指向原头节点的后继节点:
cpp
void* FreeList::pop()
{
assert(head != nullptr);
void* result = head;
head = NextObj(head);
--current_size;
return result;
}
假设原自由链表为:
text
head
↓
A → B → C → nullptr
执行:
cpp
void* result = head;
先保存原头节点 A。
随后:
cpp
head = NextObj(head);
让头指针移动到后继节点 B:
text
head
↓
B → C → nullptr
最终返回被摘下的空闲块 A。
该操作同样只涉及少量指针修改,时间复杂度为 O(1)。
PushRange:批量插入一段空闲块链表
除了单个内存块的插入以外,ThreadCache 和 CentralCache 之间还需要批量转移空闲块,因此 FreeList 还提供了 PushRange 接口。
cpp
void FreeList::PushRange(void* start, void* end, size_t n)
{
assert(start != nullptr);
assert(end != nullptr);
assert(n > 0);
NextObj(end) = head;
head = start;
current_size += n;
}
这里需要注意,PushRange 接收的并不是一段尚未组织的连续内存,而是一条已经完成串联的空闲块链表。
其中:
start指向这条链表的头节点;end指向这条链表的尾节点;n表示链表中的节点数量。
假设需要插入的链表为:
text
start
↓
A → B → C → nullptr
↑
end
原自由链表为:
text
head
↓
D → E → nullptr
首先执行:
cpp
NextObj(end) = head;
让待插入链表的尾节点 C 指向原自由链表的头节点 D:
text
A → B → C → D → E → nullptr
随后执行:
cpp
head = start;
让当前自由链表的头指针指向 start:
text
head
↓
A → B → C → D → E → nullptr
最后将当前节点数量增加 n:
cpp
current_size += n;
因此,PushRange 并不负责将 n 个节点依次串联,而只是将一条已经串联完成的链表整体插入当前自由链表的头部。
整个过程只修改两个指针,因此时间复杂度仍然为 O(1)。
PopRange:批量弹出一段空闲块链表
与 PushRange 对应,PopRange 用来从自由链表头部批量摘取一定数量的空闲块:
cpp
void FreeList::PopRange(
void*& start,
void*& end,
size_t n)
{
assert(head != nullptr);
assert(n > 0);
assert(current_size >= n);
start = head;
end = start;
for (size_t i = 0; i < n - 1; ++i)
{
end = NextObj(end);
}
head = NextObj(end);
NextObj(end) = nullptr;
current_size -= n;
}
这里的 start 和 end 是两个输出型参数,用于将摘取出的链表首尾地址返回给调用者:
cpp
void*& start;
void*& end;
假设原自由链表为:
text
head
↓
A → B → C → D → E → nullptr
现在需要弹出前 3 个节点。
首先:
cpp
start = head;
end = start;
此时:
text
start = A
end = A
随后通过循环向后移动 end:
cpp
for (size_t i = 0; i < n - 1; ++i)
{
end = NextObj(end);
}
循环结束后,end 指向第 n 个节点,也就是节点 C:
text
start = A
end = C
接着执行:
cpp
head = NextObj(end);
让原自由链表的头指针指向第 n+1 个节点 D:
text
head
↓
D → E → nullptr
此时,节点 C 仍然指向节点 D,因此还需要执行:
cpp
NextObj(end) = nullptr;
将被摘出的链表与原链表彻底断开:
text
摘出的链表:
start
↓
A → B → C → nullptr
↑
end
剩余链表:
head
↓
D → E → nullptr
最后,将自由链表的当前长度减少 n:
cpp
current_size -= n;
因此,PopRange 并不是逐个调用 pop 将节点取出,而是先找到第 n 个节点,再将前 n 个节点整体从原链表中截断。
由于需要遍历到第 n 个节点,所以该操作的时间复杂度为 O(n)。
至此,FreeList 的核心结构和操作就已经实现完成。
它并不会额外申请链表节点,而是直接复用空闲内存块本身,并通过块头部存储后继地址。在此基础上,FreeList 同时支持单个空闲块和批量空闲块的插入与弹出,为后续 ThreadCache 与 CentralCache 之间的内存转移提供了基础。
ThreadCache::Allocate:小块内存的申请流程
接下来,我们正式进入 ThreadCache 核心接口的实现,首先介绍小块内存申请接口 Allocate。
cpp
void* ThreadCache::Allocate(size_t bytes)
{
if (bytes == 0 || bytes > MaxBytes)
{
return nullptr;
}
size_t size = SizeClass::RoundUp(bytes);
size_t index = SizeClass::index(size);
FreeList& free_list = free_Lists[index];
if (!free_list.empty())
{
return free_list.pop();
}
return FetchFromCentralCache(index, size);
}
Allocate 接收用户申请的原始字节数 bytes,并尝试从当前线程所属的 ThreadCache 中获取一块可用内存。
其整体流程可以概括为:
text
校验申请大小
|
v
向上对齐到固定规格
|
v
计算对应的桶下标
|
v
访问对应的自由链表
|
+-- 不为空:弹出一个空闲块并返回
|
+-- 为空:向 CentralCache 请求批量补给
校验申请大小
首先需要判断用户传入的申请大小是否合法:
cpp
if (bytes == 0 || bytes > MaxBytes)
{
return nullptr;
}
在当前内存池的接口约定中,bytes == 0 表示本次申请无效,因此直接返回空指针。
同时,ThreadCache 只负责处理原始申请大小位于 (0, MaxBytes] 范围内的小块内存。其中:
text
MaxBytes = 256KB
需要注意,用户虽然可以申请 1 字节,但内存池中并不一定存在 1 字节规格。该申请后续会按照对齐规则映射到内存池支持的最小固定规格,例如 8B。
因此,更准确地说,ThreadCache 负责处理 1B~256KB 范围内的原始申请,并将其映射到对应的固定规格。
对于超过 256KB 的申请,需要由上层接口继续分流到 PageCache 或系统内存申请路径。
将原始申请映射到固定规格
参数校验通过以后,需要先将用户申请的原始字节数向上对齐:
cpp
size_t size = SizeClass::RoundUp(bytes);
例如:
text
用户申请 13B
|
v
向上对齐
|
v
映射到 16B 规格
这里得到的 size 不再是用户传入的原始申请大小,而是内存池实际用于分配和管理的固定规格。
随后,根据该固定规格计算对应的桶下标:
cpp
size_t index = SizeClass::index(size);
整个映射过程可以表示为:
text
原始申请大小 bytes
|
v
SizeClass::RoundUp(bytes)
|
v
得到固定规格 size
|
v
SizeClass::index(size)
|
v
得到桶下标 index
RoundUp 负责将任意字节数映射到固定规格,而 index 负责根据固定规格定位对应的桶,二者职责相互独立。
访问对应的自由链表
得到桶下标以后,就可以访问当前线程 ThreadCache 中对应的自由链表:
cpp
FreeList& free_list = free_Lists[index];
这里使用引用,是为了直接操作桶数组中真实存在的 FreeList,而不是创建一份副本。
由于当前访问的是当前线程私有的 ThreadCache,不会与其他线程同时操作同一条自由链表,因此这条本地申请路径通常不需要加锁。
自由链表命中
如果对应桶中的自由链表不为空:
cpp
if (!free_list.empty())
{
return free_list.pop();
}
说明当前线程已经缓存了该规格的空闲块。此时只需要从自由链表头部弹出一个空闲块,并将其起始地址返回给用户。
text
ThreadCache[index]
|
v
空闲块 A -> 空闲块 B -> 空闲块 C
|
v
弹出空闲块 A 并返回
pop 本质上是单链表的头删操作,只需要修改自由链表的头指针,因此时间复杂度为 O(1)。
这也是 ThreadCache 最核心的快速分配路径:
text
规格映射
+
桶下标计算
+
自由链表头删
整个过程只访问线程私有数据,不需要获取公共锁。
自由链表未命中
如果对应的自由链表为空,说明当前线程已经没有可以直接使用的该规格空闲块:
cpp
return FetchFromCentralCache(index, size);
此时,ThreadCache 无法依靠自身产生新的内存块,只能向下一层共享缓存 CentralCache 请求补充。
该函数需要接收两个参数:
cpp
FetchFromCentralCache(index, size);
其中:
index用于定位CentralCache中相同规格的桶;size用于确定当前内存块规格,并参与批量补给数量的计算。
经过补给以后,其中一个空闲块会作为本次 Allocate 的结果直接返回,其余空闲块则会缓存到当前线程的 ThreadCache 中,供后续相同规格的内存申请复用。
综上,ThreadCache::Allocate 实现了两条不同的申请路径:
text
本地自由链表命中
|
v
直接 pop 并返回
|
v
无锁快速路径
本地自由链表未命中
|
v
访问 CentralCache 批量补给
|
v
共享缓存慢速路径
大部分热点小块内存申请都可以通过第一条线程私有路径完成;只有对应桶中的空闲块耗尽时,才会进入 CentralCache 的公共补给路径。
SizeClass:内存规格对齐与桶下标计算
在继续分析后续接口的实现之前,还需要补充一个重要的工具类------SizeClass。
SizeClass 本身不负责保存或管理内存,它更接近一个用于封装规格计算逻辑的工具类,主要提供以下功能:
text
将用户申请的任意字节数
向上对齐到内存池支持的固定规格
|
v
根据对齐后的固定规格
计算对应的桶下标
其中,RoundUp 函数负责完成内存规格的向上对齐,而 index 函数负责根据对齐后的规格计算对应的桶下标。
cpp
class SizeClass
{
public:
static size_t RoundUp(size_t bytes);
static size_t index(size_t size);
private:
static size_t _RoundUp(size_t bytes, size_t align);
static size_t _index(size_t size, size_t align_shift);
};
由于 SizeClass 不需要创建对象,也不包含普通成员变量,因此这些接口都可以定义为静态成员函数。
如果希望进一步限制该类只能作为工具类使用,还可以删除它的构造函数:
cpp
SizeClass() = delete;
为什么需要划分不同的对齐档位
根据前面的分析,ThreadCache 并不会按照用户申请的每一种原始字节数分别维护一个桶,而是会将任意大小的小块申请映射到有限数量的固定规格中。
此时首先需要解决的问题是:
相邻两个固定规格之间的间隔,也就是对齐粒度,应该设置为多少?
假设整个小块内存范围都按照 8B 的粒度划分,那么固定规格将会是:
text
8B、16B、24B、32B、......
一直划分到 256KB。
这样一来,需要维护的规格数量为:
text
256KB / 8B = 32768
也就是说,ThreadCache 和 CentralCache 都需要维护三万多个桶。
如果每个桶中都要保存自由链表、当前长度、最大长度以及锁等管理信息,那么桶数组本身就会带来较大的额外内存开销。
text
对齐粒度过小
|
v
固定规格数量过多
|
v
桶数组长度过大
|
v
管理结构本身占用大量内存
但是,如果将整个范围的对齐粒度设置得很大,例如统一按照 2KB 对齐,虽然桶数量会显著减少,却会产生严重的内部碎片。
例如,用户只申请 2B,实际却获得一个 2KB 的内存块:
text
用户实际需要:2B
内存池实际分配:2048B
此时绝大部分空间都没有被有效使用。
text
对齐粒度过大
|
v
申请大小被大幅向上对齐
|
v
产生大量内部碎片
|
v
降低内存利用率
因此,对齐粒度既不能过小,也不能过大,需要在桶数量与内部碎片之间取得平衡。
小规格使用小粒度,大规格使用大粒度
对于较小的内存申请,对齐所增加的几个字节在整体申请中占比较高,因此应当使用较小的对齐粒度。
例如,用户申请 2B,若向上对齐到 8B:
text
实际使用:2B
实际分配:8B
内存利用率 = 2 / 8 = 25%
虽然只额外分配了 6B,但相对于用户真正需要的 2B 来说,浪费比例已经比较明显。
而对于较大的内存申请,即使向上对齐时多分配几百字节甚至几 KB,额外空间在整个内存块中的占比也相对较低。
因此,高并发内存池将 (0, 256KB] 的小块内存范围划分为五个档位,并为不同档位设置不同的对齐粒度:
text
申请大小范围 对齐粒度
(0, 128B] 8B
(128B, 1KB] 16B
(1KB, 8KB] 128B
(8KB, 64KB] 1KB
(64KB, 256KB] 8KB
整体规律是:
text
内存规格较小
|
v
使用较小的对齐粒度
|
v
减少内部碎片
内存规格逐渐增大
|
v
适当增大对齐粒度
|
v
减少需要维护的桶数量
通过分档设计,既可以控制小块内存的内部碎片,也可以避免在大规格范围内产生过多的桶。
RoundUp:将任意大小映射到固定规格
RoundUp 函数接收用户申请的原始字节数,并根据其所在的档位,选择相应的对齐粒度:
cpp
size_t SizeClass::RoundUp(size_t bytes)
{
size_t result = 0;
if (bytes <= 128)
{
result = _RoundUp(bytes, 8);
}
else if (bytes <= 1024)
{
result = _RoundUp(bytes, 16);
}
else if (bytes <= 8 * 1024)
{
result = _RoundUp(bytes, 128);
}
else if (bytes <= 64 * 1024)
{
result = _RoundUp(bytes, 1024);
}
else if (bytes <= 256 * 1024)
{
result = _RoundUp(bytes, 8 * 1024);
}
return result;
}
例如,当用户申请 13B 时,其位于第一档,对齐粒度为 8B:
text
13B
|
v
按照 8B 粒度向上对齐
|
v
16B
当用户申请 200B 时,其位于第二档,对齐粒度为 16B:
text
200B
|
v
按照 16B 粒度向上对齐
|
v
208B
因此,RoundUp 的作用可以表示为:
text
原始申请大小 bytes
|
v
判断所属档位
|
v
选择该档位的对齐粒度
|
v
向上对齐到最小的固定规格
所谓向上对齐,就是找到一个不小于 bytes,并且能够被 align 整除的最小值。其原始计算公式为:
cpp
(bytes + align - 1) / align * align
其中:
cpp
bytes + align - 1
用于完成向上取整所需的补偿。
例如,将 13B 按照 8B 对齐:
text
(13 + 8 - 1) / 8 × 8
=
20 / 8 × 8
=
2 × 8
=
16
因此,13B 最终会被向上对齐到 16B。
原始公式本身没有问题。不过,在当前内存池中,所有对齐数都是 2 的整数次幂:
text
8B = 2^3
16B = 2^4
128B = 2^7
1KB = 2^10
8KB = 2^13
假设:
text
align = 2^k
那么除以 align 就可以等价理解为右移 k 位,而乘以 align 则可以等价理解为左移 k 位。
因此,原始公式可以转换为:
cpp
((bytes + align - 1) >> k) << k
一个整数先右移 k 位,再左移 k 位,其整体效果就是丢弃原数值最低的 k 位,并在左移时将这 k 位重新补为 0。
也就是说,这一过程本质上是在将:
cpp
bytes + align - 1
的最低 k 位全部清零。
例如:
text
bytes = 13
align = 8 = 2^3
首先完成向上取整补偿:
text
bytes + align - 1
=
13 + 8 - 1
=
20
20 的二进制形式为:
text
00010100
将其右移 3 位:
text
00010100 >> 3
=
00000010
再左移 3 位:
text
00000010 << 3
=
00010000
最终得到:
text
00010000 = 16
由于右移后再左移的效果就是将最低 k 位清零,因此还可以通过掩码直接完成这一操作。
当:
text
align = 8 = 00001000
那么:
text
align - 1 = 7 = 00000111
align - 1 的最低 k 位全部为 1,将其按位取反后:
text
~(align - 1) = 11111000
便可以得到一个最低 k 位为 0、其余高位为 1 的掩码。
将补偿后的数值与该掩码进行按位与:
cpp
(bytes + align - 1) & ~(align - 1)
就可以保留高位,并将最低 k 位全部清零。
因此,向上对齐函数可以实现为:
cpp
size_t SizeClass::_RoundUp(size_t bytes, size_t align)
{
return (bytes + align - 1) & ~(align - 1);
}
整个推导过程可以概括为:
text
向上对齐的原始公式
(bytes + align - 1) / align * align
|
| align = 2^k
v
((bytes + align - 1) >> k) << k
|
| 右移再左移,相当于清零最低 k 位
v
(bytes + align - 1) & ~(align - 1)
因此,这种位运算写法并不是另一套独立的计算方式,而是将原始的除法、乘法公式,在对齐数为 2 的整数次幂这一前提下,转换成了等价的低位清零操作。
需要注意,该公式成立的前提是:
text
align 必须大于 0,并且是 2 的整数次幂
五个档位对应的固定规格数量
根据当前的档位设计,每个档位所包含的固定规格数量并不相同。
第一档的范围为 (0, 128B],对齐粒度为 8B:
text
128 / 8 = 16
因此第一档包含 16 个规格:
text
8B、16B、24B、......、128B
第二档的范围为 (128B, 1KB],对齐粒度为 16B:
text
(1024 - 128) / 16 = 56
因此第二档包含 56 个规格:
text
144B、160B、176B、......、1024B
第三档的范围为 (1KB, 8KB],对齐粒度为 128B:
text
(8192 - 1024) / 128 = 56
第四档的范围为 (8KB, 64KB],对齐粒度为 1KB:
text
(64KB - 8KB) / 1KB = 56
第五档的范围为 (64KB, 256KB],对齐粒度为 8KB:
text
(256KB - 64KB) / 8KB = 24
因此,内存池一共需要维护的固定规格数量为:
text
16 + 56 + 56 + 56 + 24 = 208
这也意味着 ThreadCache 和 CentralCache 中的桶数组都需要包含 208 个位置:
cpp
static constexpr size_t NFreelists = 208;
index:根据固定规格计算桶下标
完成向上对齐以后,还需要根据得到的固定规格计算对应的桶下标。
index 函数接收的不是用户申请的原始大小,而是已经经过 RoundUp 对齐后的固定规格:
cpp
size_t SizeClass::index(size_t size)
{
assert(size > 0 && size <= MaxBytes);
assert(size == RoundUp(size));
static constexpr size_t group[] = {
16, 56, 56, 56, 24
};
size_t index = 0;
if (size <= 128)
{
index = _index(size, 3);
}
else if (size <= 1024)
{
index = _index(size - 128, 4);
index += group[0];
}
else if (size <= 8 * 1024)
{
index = _index(size - 1024, 7);
index += group[0] + group[1];
}
else if (size <= 64 * 1024)
{
index = _index(size - 8 * 1024, 10);
index += group[0] + group[1] + group[2];
}
else if (size <= 256 * 1024)
{
index = _index(size - 64 * 1024, 13);
index += group[0] + group[1]
+ group[2] + group[3];
}
return index;
}
其整体计算过程可以表示为:
text
已经对齐的规格 size
|
v
判断 size 所在的档位
|
v
计算该规格在当前档位中的局部下标
|
v
加上前面所有档位的规格数量
|
v
得到全局桶下标
每个档位内部的固定规格都是按照相同粒度递增的,因此可以通过下面的函数计算局部下标:
cpp
size_t SizeClass::_index(
size_t size,
size_t align_shift)
{
return (size >> align_shift) - 1;
}
其中,align_shift 表示对齐粒度对应的二进制位数:
text
对齐粒度 align_shift
8B 3
16B 4
128B 7
1KB 10
8KB 13
例如,第一档按照 8B 对齐,因此需要右移 3 位。
对于 8B 规格:
text
8 >> 3 = 1
1 - 1 = 0
所以 8B 对应桶下标 0。
对于 16B 规格:
text
16 >> 3 = 2
2 - 1 = 1
所以 16B 对应桶下标 1。
这里减去 1,是因为数组下标从 0 开始,而右移后得到的是当前规格包含多少个对齐单位。
为什么后面的档位需要减去左边界
在当前的规格划分中,五个档位分别为:
text
第一档:(0, 128B]
第二档:(128B, 1KB]
第三档:(1KB, 8KB]
第四档:(8KB, 64KB]
第五档:(64KB, 256KB]
其中,第一档的左边界为 0,而后面四个档位都是左开右闭区间。也就是说,上一档的右边界属于上一档,并不属于当前档位。
例如,第一档的最后一个规格为 128B,因此第二档并不是从 128B 开始,而是从超过 128B 后能够按照 16B 粒度表示的第一个规格开始:
text
128B + 16B = 144B
所以,第二档中的规格为:
text
144B、160B、176B、......、1024B
计算某个规格在当前档位中的局部下标时,需要先将前面档位已经覆盖的大小范围排除掉,也就是减去当前档位的左边界。
因此,第二档需要先减去 128B:
cpp
_index(size - 128, 4);
第三档需要先减去 1KB:
cpp
_index(size - 1024, 7);
第四档需要先减去 8KB:
cpp
_index(size - 8 * 1024, 10);
第五档需要先减去 64KB:
cpp
_index(size - 64 * 1024, 13);
第一档则不需要额外减去左边界,因为它的左边界就是 0:
cpp
_index(size, 3);
因此,五个档位的局部下标计算可以表示为:
text
第一档:(0, 128B]
局部下标 = _index(size, 3)
第二档:(128B, 1KB]
局部下标 = _index(size - 128B, 4)
第三档:(1KB, 8KB]
局部下标 = _index(size - 1KB, 7)
第四档:(8KB, 64KB]
局部下标 = _index(size - 8KB, 10)
第五档:(64KB, 256KB]
局部下标 = _index(size - 64KB, 13)
因此,除了左边界为 0 的第一档以外,其余档位都需要先减去各自的左边界,再计算档位内部的局部下标。
根据前面各档位的规格数量,可以计算出每一档的起始全局下标:
text
第一档起始下标:
0
第二档起始下标:
16
第三档起始下标:
16 + 56 = 72
第四档起始下标:
16 + 56 + 56 = 128
第五档起始下标:
16 + 56 + 56 + 56 = 184
第五档共有 24 个规格,因此其下标范围为:
text
184 ~ 207
最终,整个桶数组的有效下标范围为:
text
0 ~ 207
总共包含 208 个桶。
至此,SizeClass 就完成了从用户原始申请大小到具体桶位置的映射:
text
用户申请 bytes 字节
|
v
RoundUp(bytes)
|
v
得到固定规格 size
|
v
index(size)
|
v
得到对应的桶下标
|
v
访问 ThreadCache 或 CentralCache
中的对应桶
通过分档对齐,内存池能够在控制内部碎片的同时,将任意大小的小块内存申请归并到有限数量的固定规格中;再通过桶下标计算,快速定位对应的自由链表,避免遍历查找。
ThreadCache::FetchFromCentralCache:批量补给与按需倍增
根据上文,我们已经认识了 SizeClass 类如何完成内存规格对齐、桶下标计算以及标准批量数量计算。
接下来继续分析 ThreadCache 中的 FetchFromCentralCache 函数。
当线程申请一块小内存时,首先会访问当前线程 ThreadCache 中对应规格的自由链表。如果自由链表不为空,就可以直接从链表头部取出一个空闲块;如果自由链表为空,则说明当前线程已经没有可用的该规格空闲块,需要向下一层的 CentralCache 请求补给。
text
ThreadCache 对应桶不为空
|
v
直接取出一个空闲块
ThreadCache 对应桶为空
|
v
调用 FetchFromCentralCache
|
v
向 CentralCache 批量获取空闲块
FetchFromCentralCache 接收两个参数:
cpp
void* FetchFromCentralCache(size_t index, size_t size);
其中:
text
index:当前规格对应的桶下标
size:经过 RoundUp 对齐后的固定规格
其实现如下:
cpp
void* ThreadCache::FetchFromCentralCache(size_t index,size_t size)
{
size_t BatchNum=SizeClass::NumMoveSize(size);
BatchNum=std::min(BatchNum,free_Lists[index].GetMaxSize());
void* start=nullptr;
void* end=nullptr;
size_t actual_num=CentralCache::GetInstance().FetchRangeObj(start,end,BatchNum,index,size);
if(actual_num==0)
{
return nullptr;
}
size_t maxSize = free_Lists[index].GetMaxSize();
size_t limit = SizeClass::NumMoveSize(size);
if(maxSize<limit)
{
free_Lists[index].SetMaxSize(std::min(maxSize*2,limit));
}
if(actual_num==1)
{
return start;
}
void* result=start;
void* next=FreeList::NextObj(start);
free_Lists[index].PushRange(next,end,actual_num-1);
return result;
}
该函数的整体流程可以表示为:
text
根据 size 计算标准批量上限
|
v
结合当前桶的慢增长阈值
确定本次请求数量
|
v
向 CentralCache 请求一批空闲块
|
v
判断是否获取成功
|
v
成功后扩大当前桶的补给阈值
|
v
一个空闲块返回给用户
|
v
其余空闲块缓存到 ThreadCache
计算当前规格的标准批量上限
在访问 CentralCache 之前,首先需要根据当前内存块规格,计算该规格对应的标准批量数量:
cpp
size_t batch_limit = SizeClass::NumMoveSize(size);
对应的实现为:
cpp
size_t SizeClass::NumMoveSize(size_t size)
{
assert(size > 0 && size <= MaxBytes);
size_t result = MaxBytes / size;
if (result < 2)
{
result = 2;
}
if (result > 512)
{
result = 512;
}
return result;
}
这里的 MaxBytes 为:
text
256KB
因此,标准批量数量首先按照下面的方式计算:
text
batch_limit = 256KB / size
这种计算方式体现了"小规格多取、大规格少取"的策略。
对于小规格内存块来说,单个块占用的空间较小,因此一次多缓存一些空闲块,所带来的空间成本相对可控,并且能够减少线程后续访问 CentralCache 的次数。
而对于大规格内存块来说,单个块本身就会占用较多空间。如果一次批量获取过多,很容易使线程私有的 ThreadCache 持有大量暂时无法得到复用的内存。
text
内存块规格较小
|
v
单个块占用空间较少
|
v
一次可以多获取一些
内存块规格较大
|
v
单个块占用空间较多
|
v
一次应该少获取一些
例如,当规格为 1KB 时:
text
256KB / 1KB = 256
其标准批量数量为 256。
当规格为 16KB 时:
text
256KB / 16KB = 16
其标准批量数量为 16。
因此,在没有触发上下限修正时:
text
批量数量 × 单块规格
≈ 256KB
这能够使不同规格一次批量准备的内存总量大致维持在相近范围内。
仅使用:
text
256KB / size
计算批量数量仍然不够,还需要设置上下限。
对于非常小的规格,计算结果可能过大。
例如,当规格为 8B 时:
text
256KB / 8B = 32768
如果一次向当前线程补充 32768 个空闲块,就可能导致 ThreadCache 瞬间缓存大量暂时用不到的内存。
因此,需要将批量数量的上限限制为:
text
512
另一方面,对于较大的规格,计算结果可能只有 1。
例如,当规格为 256KB 时:
text
256KB / 256KB = 1
如果只获取一个内存块,那么这个块会立即被当前申请取走,ThreadCache 中仍然没有剩余空闲块。下一次申请相同规格时,线程又需要重新访问 CentralCache。
因此,还需要将批量数量的下限设置为:
text
2
256KB 是计算标准批量数量时采用的基准值。在没有触发数量上下限修正时,通过 256KB / size 计算出的块数,可以使这一批内存块的总大小控制在 256KB 以内,并尽量接近 256KB。
不过,对于特别小的规格,批量数量会受到最大值 512 的限制;对于大于 128KB 的规格,计算结果为 1,又会被最小值 2 修正。因此,在这些边界规格下,实际批量总大小不一定接近或小于 256KB。
结合慢增长阈值计算本次请求数量
虽然 NumMoveSize 能够计算当前规格对应的标准批量上限,但线程第一次申请某种规格时,并不会立即按照这个上限从 CentralCache 获取大量空闲块。
原因在于,线程不一定会频繁使用这种规格。
如果线程只申请一次某种规格,却在第一次补给时就获取完整的一批空闲块,那么除去当前使用的一个块以外,其余空闲块都会被缓存在当前线程的 ThreadCache 中。
由于 ThreadCache 是线程私有的,其他线程无法直接复用这些空闲块,因此可能造成不必要的内存滞留。
text
线程只申请一次某种规格
|
v
第一次就获取完整批量
|
v
其余块缓存在当前 ThreadCache
|
v
后续不再申请
|
v
空闲块长期滞留在线程私有缓存
因此,每个 ThreadCache 桶还会维护一个当前最大长度,也就是慢增长阈值:
cpp
free_list.GetMaxSize();
这个阈值从一个较小的初始值开始,并随着该桶被反复耗尽而逐步增长。
本次真正向 CentralCache 请求的数量,需要取标准批量上限与当前阈值中的较小值:
cpp
BatchNum=std::min(BatchNum,free_Lists[index].GetMaxSize());
也就是:
text
本次请求数量 =
min(标准批量上限, 当前桶阈值)
这里存在两个不同的概念:
text
batch_limit
|
v
当前规格允许达到的标准批量上限
GetMaxSize()
|
v
当前线程、当前桶现阶段的补给阈值
例如,某个规格对应的标准批量上限为 32,但当前桶的阈值只有 4:
text
batch_limit = 32
current_max = 4
batch_num = min(32, 4) = 4
因此,本次只会向 CentralCache 请求 4 个空闲块,而不是直接请求 32 个。
确定本次请求数量以后,就可以调用 CentralCache 提供的批量获取接口:
cpp
size_t actual_num =
CentralCache::GetInstance()
.FetchRangeObj(
start,
end,
batch_num,
index,
size
);
其中:
text
start:
接收返回链表的头节点地址
end:
接收返回链表的尾节点地址
batch_num:
本次期望获取的空闲块数量
index:
当前规格对应的桶下标
size:
当前内存块的固定规格
FetchRangeObj 会访问 CentralCache 中对应规格的桶,从某个能够提供空闲块的 Span 中摘取一批节点,并通过 start 和 end 返回一条已经串联好的空闲块链表。
函数的返回值 actual_num 表示本次实际获取到的空闲块数量。
这里实际获取数量不一定始终等于期望数量 batch_num。如果当前 Span 中剩余的空闲块数量不足,CentralCache 可能只能返回更少的节点。
因此,需要使用 actual_num 作为后续链表处理的真实数量。
处理获取失败
如果 CentralCache 未能提供任何空闲块:
cpp
if (actual_num == 0)
{
return nullptr;
}
此时 FetchFromCentralCache 直接返回空指针,表示本次补给失败。
只有在实际获取成功以后,才能继续处理返回的链表并更新当前桶的慢增长阈值。
成功补给后更新慢增长阈值
如果本次成功从 CentralCache 获取到空闲块,说明当前线程确实已经耗尽了该规格在本地缓存中的资源。
如果后续该桶再次为空,就说明线程对这一规格仍然存在持续需求,因此可以扩大下一次的补给数量。
当前代码采用倍增方式更新阈值:
cpp
size_t current_max = free_list.GetMaxSize();
if (current_max < batch_limit)
{
size_t new_max =
std::min(current_max * 2, batch_limit);
free_list.SetMaxSize(new_max);
}
其增长过程可以表示为:
text
1 → 2 → 4 → 8 → 16 → 32 → ...
但阈值最多只能增长到当前规格对应的标准批量上限:
text
new_max =
min(current_max × 2, batch_limit)
例如:
text
当前阈值 = 4
标准批量上限 = 32
本次请求数量为:
text
min(4, 32) = 4
成功获取以后,阈值更新为:
text
min(4 × 2, 32) = 8
需要注意,更新后的阈值并不会影响本次已经完成的获取。
text
本次请求数量:4
成功后更新阈值:8
下一次桶再次为空时:
最多请求 8 个
因此,这里的阈值更新是为下一次补给做准备。
这种策略能够兼顾两种不同的使用场景。
对于偶尔申请某种规格的线程:
text
初始阈值较小
|
v
只获取少量空闲块
|
v
避免 ThreadCache 囤积过多内存
对于频繁申请某种规格的线程:
text
对应桶反复为空
|
v
补给阈值不断倍增
|
v
一次获取更多空闲块
|
v
减少访问 CentralCache 的次数
一个空闲块返回,其余缓存在 ThreadCache
如果本次实际只获取到一个空闲块:
cpp
if (actual_num == 1)
{
return start;
}
由于没有多余的空闲块可以缓存,因此直接将 start 返回,用于满足当前申请。
如果获取到了多个空闲块,则需要从返回链表中取出第一个节点:
cpp
void* result = start;
void* remain_start = FreeList::NextObj(result);
假设返回链表为:
text
start
|
v
obj1 → obj2 → obj3 → obj4 → nullptr
^
|
end
此时:
text
result = obj1
remain_start = obj2
随后,可以将返回节点与剩余链表断开:
cpp
FreeList::NextObj(result) = nullptr;
断开以后:
text
返回给用户:
obj1 → nullptr
剩余链表为:
text
obj2 → obj3 → obj4 → nullptr
^
|
end
最后,将剩余的 actual_num - 1 个空闲块整体挂入当前 ThreadCache 对应桶的自由链表:
cpp
free_list.PushRange(
remain_start,
end,
actual_num - 1
);
然后返回第一个空闲块:
cpp
return result;
整个过程可以表示为:
text
CentralCache 返回一批空闲块
|
v
取出第一个空闲块
|
+-- 返回给用户,满足当前申请
|
v
其余空闲块形成独立链表
|
v
整体挂入 ThreadCache 对应桶
因此,FetchFromCentralCache 的核心作用可以概括为:
当
ThreadCache某个桶为空时,根据当前规格的标准批量上限和桶的慢增长阈值,向CentralCache请求一批空闲块;成功获取后扩大下一次补给阈值,将其中一个块返回给当前申请,并将其余空闲块缓存到线程私有的自由链表中。
通过这种批量补给与按需倍增相结合的方式,可以将一次访问 CentralCache 所产生的加锁和公共结构访问开销,分摊到后续多次内存申请中,同时避免线程第一次使用某种规格时就缓存过多空闲内存。
ThreadCache::Deallocate:小块内存的释放与归还流程
认识了 ThreadCache::Allocate 的实现以后,接下来继续分析与其对应的内存释放接口 Deallocate。
cpp
void ThreadCache::Deallocate(void* ptr, size_t bytes)
{
if (ptr == nullptr)
{
return;
}
assert(bytes > 0 && bytes <= MaxBytes);
if (bytes == 0 || bytes > MaxBytes)
{
return;
}
size_t size = SizeClass::RoundUp(bytes);
size_t index = SizeClass::index(size);
FreeList& free_list = free_Lists[index];
free_list.push(ptr);
if (free_list.Size() > free_list.GetMaxSize())
{
ListTooLong(index);
}
}
Deallocate 接收两个参数:
cpp
void* ptr;
size_t bytes;
其中,ptr 表示待释放内存块的起始地址,bytes 表示申请这块内存时所使用的原始字节数。
该函数的整体处理流程可以表示为:
text
校验待释放的内存块
|
v
根据 bytes 进行规格对齐
|
v
计算对应的桶下标
|
v
将内存块头插到自由链表
|
v
判断链表长度是否超过上限
|
+-- 未超过:继续缓存在 ThreadCache
|
+-- 已超过:向 CentralCache 归还部分空闲块
首先需要判断待释放指针是否为空:
cpp
if (ptr == nullptr)
{
return;
}
释放空指针时不需要执行任何操作,因此可以直接返回。
这样既避免了后续对空地址的访问,也使当前接口的行为与标准 free(nullptr) 类似。
当 ptr 不为空时,还需要判断 bytes 是否位于 ThreadCache 的管理范围内:
cpp
assert(bytes > 0 && bytes <= MaxBytes);
其中:
text
MaxBytes = 256KB
因此,当前函数只负责释放原始申请大小位于 (0, 256KB] 范围内的小块内存。
这里的 bytes 表示调用者传入的原始申请大小,而不是经过对齐以后得到的固定规格。
而接下来需要按照与申请时相同的规则完成规格映射:
cpp
size_t size = SizeClass::RoundUp(bytes);
size_t index = SizeClass::index(size);
其过程为:
text
释放时传入的原始大小 bytes
|
v
SizeClass::RoundUp(bytes)
|
v
得到固定规格 size
|
v
SizeClass::index(size)
|
v
得到对应的桶下标 index
例如,释放一块申请大小为 13B 的内存:
text
13B
|
v
向上对齐到 16B
|
v
计算 16B 规格对应的桶下标
申请与释放必须采用完全一致的 RoundUp 和 index 规则,才能使内存块重新回到原本规格对应的自由链表。
得到桶下标以后,就可以获取当前线程中对应的自由链表:
cpp
FreeList& free_list = free_Lists[index];
随后将待释放的内存块插入链表头部:
cpp
free_list.push(ptr);
假设插入前的自由链表为:
text
head
↓
A → B → C → nullptr
将 ptr 插入以后:
text
head
↓
ptr → A → B → C → nullptr
push 本质上是单链表的头插操作,只需要修改少量指针,时间复杂度为 O(1)。
由于这里操作的是当前执行释放操作的线程所拥有的 ThreadCache,因此通常不需要加锁。
需要注意的是:
text
由哪个线程执行释放
|
v
内存块就进入哪个线程的 ThreadCache
也就是说,即使某块内存最初由线程 A 申请,只要最终由线程 B 调用释放接口,该内存块就会进入线程 B 的本地缓存。
这也是跨线程释放场景下,某些线程的 ThreadCache 可能不断积累空闲块的原因。
内存块插入自由链表以后,还需要判断该桶当前缓存的空闲块数量是否超过上限:
cpp
if (free_list.Size() > free_list.GetMaxSize())
{
ListTooLong(index);
}
这里的 GetMaxSize() 返回当前桶所允许缓存的最大长度。
假设:
text
MaxSize = 8
当插入前链表中有 7 个空闲块时:
text
插入前长度:7
插入后长度:8
此时链表长度没有超过上限,因此不会触发归还。
如果插入前链表中已经有 8 个空闲块:
text
插入前长度:8
插入后长度:9
此时链表长度已经超过上限,就会调用:
cpp
ListTooLong(index);
因此,这里使用:
cpp
free_list.Size() > free_list.GetMaxSize()
表达的是"链表长度真正超过上限后,再触发归还"。
当某个桶的自由链表超过长度上限时,说明当前线程已经缓存了过多的该规格空闲块。
由于 ThreadCache 是线程私有的,如果这些空闲块一直滞留在当前线程中,其他线程就无法直接获取和复用。因此,需要通过 ListTooLong 将其中一部分归还给公共的 CentralCache。
text
ThreadCache[index] 空闲块过多
|
v
调用 ListTooLong(index)
|
v
从当前自由链表中摘取一批空闲块
|
v
归还给 CentralCache
|
v
CentralCache 将每个块放回所属 Span
综上,ThreadCache::Deallocate 包含两条释放路径:
text
释放后自由链表未超过上限
|
v
继续缓存在当前 ThreadCache
|
v
供当前线程后续快速复用
释放后自由链表超过上限
|
v
调用 ListTooLong
|
v
向 CentralCache 归还部分空闲块
|
v
供其他线程重新获取和复用
因此,Deallocate 并不是简单地将内存块放入自由链表,而是在完成本地快速回收的同时,通过长度上限控制线程私有缓存的规模,并在缓存过量时将部分内存重新归还到公共缓存中。
ThreadCache::ListTooLong:批量归还过量空闲块
认识了 ThreadCache::Deallocate 的实现以后,接下来继续分析自由链表长度超过上限时调用的 ListTooLong 函数。
当线程释放一块小内存时,会先将该内存块插入当前 ThreadCache 对应桶的自由链表。插入完成以后,如果自由链表中的空闲块数量超过当前允许的最大长度,就会调用:
cpp
ListTooLong(index);
该函数的核心职责是:
从当前桶的自由链表中摘取一部分空闲块,并将其归还给
CentralCache,避免过多空闲内存长期滞留在线程私有缓存中。
其实现如下:
cpp
void ThreadCache::ListTooLong(size_t index)
{
FreeList& free_list = free_Lists[index];
size_t current_size = free_list.Size();
assert(current_size > free_list.GetMaxSize());
assert(current_size > 0);
size_t keep_num = current_size / 2;
size_t move_num = current_size - keep_num;
void* start = nullptr;
void* end = nullptr;
free_list.PopRange(start, end, move_num);
CentralCache::GetInstance()
.ReleaseListToSpans(start, move_num, index);
}
整个处理过程可以表示为:
text
ThreadCache 某个桶的长度超过上限
|
v
计算需要保留和归还的空闲块数量
|
v
从自由链表头部摘取一批空闲块
|
v
将摘取出的链表交给 CentralCache
|
v
CentralCache 将各个空闲块
重新放回其所属 Span
当自由链表长度超过上限以后,不能每次只归还一个空闲块。
如果归还数量过少,那么在线程继续频繁释放该规格内存的情况下,自由链表很快又会超过长度上限,进而再次访问 CentralCache:
text
每次只归还少量空闲块
|
v
自由链表很快再次超过上限
|
v
再次访问 CentralCache
|
v
频繁加锁和解锁
这样会增加对共享结构的访问频率以及锁开销。
但也不能将当前桶中的所有空闲块一次性全部归还。
如果全部归还,那么当前线程的本地缓存就会被清空。当线程后续再次申请相同规格的内存时,又需要访问 CentralCache 重新补充:
text
将空闲块全部归还
|
v
ThreadCache 对应桶变为空
|
v
后续再次申请相同规格
|
v
重新访问 CentralCache
因此,归还数量需要在两个目标之间取得平衡:
text
保留一定数量的本地缓存
+
释放线程暂时用不到的过量空闲块
在当前实现中,采用一种较为直接的折中方式:保留大约一半的空闲块,并将剩余部分归还给 CentralCache。
首先获取当前自由链表:
cpp
FreeList& free_list = free_Lists[index];
然后记录链表当前的空闲块数量:
cpp
size_t current_size = free_list.Size();
接下来计算需要保留的数量:
cpp
size_t keep_num = current_size / 2;
整数除法会向下取整,因此 keep_num 表示保留向下取整的一半。
需要归还的数量为:
cpp
size_t move_num = current_size - keep_num;
确定需要归还的数量以后,需要从当前自由链表中摘取 move_num 个空闲块:
cpp
void* start = nullptr;
void* end = nullptr;
free_list.PopRange(start, end, move_num);
其中:
start用于接收被摘取链表的头节点地址;end用于接收被摘取链表的尾节点地址;move_num表示需要摘取的节点数量。
假设原自由链表为:
text
head
↓
A → B → C → D → E → F → nullptr
如果需要摘取前 4 个节点,调用 PopRange 后会得到:
text
摘取出的链表:
start
↓
A → B → C → D → nullptr
↑
end
当前 ThreadCache 中剩余的链表为:
text
head
↓
E → F → nullptr
PopRange 会将摘取出的链表与原自由链表断开,因此从 start 开始的这批空闲块已经形成一条独立链表,可以整体交给 CentralCache。
完成链表摘取以后,通过下面的代码将这批空闲块归还给 CentralCache:
cpp
CentralCache::GetInstance()
.ReleaseListToSpans(start, move_num, index);
这里首先通过:
cpp
CentralCache::GetInstance()
获取 CentralCache 的单例对象,然后调用其公共成员函数:
cpp
ReleaseListToSpans(start, move_num, index);
之所以需要由 CentralCache 提供专门的归还接口,是因为 CentralCache 内部的桶数组、分桶锁以及 Span 链表都属于它自身的实现细节。
ThreadCache 不应该绕过类的封装,直接修改 CentralCache 内部的数据结构,而应该将待归还的空闲块交给 CentralCache,由其自行完成后续处理:
text
ThreadCache
摘取一批空闲块
|
v
调用 ReleaseListToSpans
|
v
CentralCache 根据 index
定位对应规格的桶
|
v
获取该桶对应的锁
|
v
逐个处理归还的空闲块
ListTooLong 的整体作用
ListTooLong 本身并不负责将每个空闲块插回所属 Span,它只负责完成 ThreadCache 这一侧的处理:
text
确定归还数量
|
v
从本地自由链表中批量摘取空闲块
|
v
将摘取出的链表交给 CentralCache
真正的 Span 定位、空闲块插入以及 use_count 更新,则由 CentralCache::ReleaseListToSpans 完成。
因此,ListTooLong 的核心作用可以概括为:
当
ThreadCache某个桶缓存的空闲块数量超过上限时,保留大约一半的本地空闲块,并将剩余部分批量归还给CentralCache,从而在线程本地命中率和进程整体内存复用之间取得平衡。
CentralCache 的完整实现:Span 管理、批量补给回收与 PageCache 协作
CentralCache 的整体骨架:Span、SpanLists 与全局单例
根据上文,我们已经完成了 ThreadCache 的整体实现。接下来,继续实现高并发内存池中的第二级缓存------CentralCache。
在进入各个接口的具体实现之前,仍然先从整体结构出发,梳理 CentralCache 由哪些部分组成,以及这些结构之间存在怎样的关系。
对于 CentralCache 来说,其内部最核心的成员同样是一个桶数组。
cpp
SpanLists Span_Lists[NFreelists];
CentralCache 与 ThreadCache 使用相同的内存规格划分规则,因此二者桶数组的长度相同,均为:
text
NFreelists = 208
相同下标的桶所对应的内存块规格也完全一致:
text
ThreadCache[0] <----> CentralCache[0]
ThreadCache[1] <----> CentralCache[1]
ThreadCache[2] <----> CentralCache[2]
...
ThreadCache[207] <----> CentralCache[207]
当 ThreadCache 中某个桶为空时,可以直接使用相同的桶下标访问 CentralCache 中对应规格的桶,从中获取一批空闲块。
不过,虽然二者的桶数组存在一一对应关系,但桶中真正组织的内容并不相同。
对于 ThreadCache 来说,每个桶中直接维护的是一条空闲块自由链表:
text
ThreadCache 某个桶
|
v
空闲块 → 空闲块 → 空闲块 → ...
而对于 CentralCache 来说,每个桶中挂载的节点并不是一个个独立的空闲块,而是一个个 Span 对象:
text
CentralCache 某个桶
|
v
Span <-> Span <-> Span
每个 Span 又会在内部维护一条自由链表,用于组织该 Span 所关联内存中已经切分完成、当前仍然处于空闲状态的小块:
text
CentralCache 某个桶
|
v
Span <----------> Span <----------> Span
| | |
空闲块链表 空闲块链表 空闲块链表
因此,CentralCache 的整体组织关系可以表示为:
text
CentralCache
|
v
长度为 208 的桶数组
|
v
每个桶对应一种固定规格
|
v
每个桶维护一条 Span 链表
|
v
每个 Span 内部维护一条空闲块链表
在实现 CentralCache 之前,首先需要定义作为桶内链表节点的 Span。
Span 的基本结构
Span 用于描述和管理一段由若干连续虚拟内存页组成的内存区域。
其基本结构如下:
cpp
class Span
{
public:
using PAGE_ID = size_t;
Span();
public:
Span* next;
Span* prev;
size_t usecount;
size_t object_size;
void* freelist;
PAGE_ID page_id;
size_t n;
};
其中,page_id 和 n 是 Span 最核心的地址描述信息:
cpp
PAGE_ID page_id;
size_t n;
page_id 表示当前 Span 所关联连续虚拟内存的起始页号,n 表示这段连续虚拟内存一共包含多少页。
因此,当前 Span 所覆盖的虚拟页范围为:
text
[page_id, page_id + n)
除了起始页号和页数以外,Span 还需要记录当前内存是按照什么规格进行切分的:
cpp
size_t object_size;
例如:
text
object_size = 16
表示当前 Span 所关联的连续内存已经按照 16B 规格切分成了多个小块。
这些切分出来、目前仍然没有被分配出去的小块,会通过 Span 内部的自由链表进行组织:
cpp
void* freelist;
freelist 保存该自由链表的头节点地址:
text
Span
|
+-- freelist
|
v
obj1 → obj2 → obj3 → nullptr
当 CentralCache 需要向 ThreadCache 提供空闲块时,就会从某个 Span 的 freelist 中摘取一批节点。
Span 还需要维护一个 usecount:
cpp
size_t usecount;
usecount 用来记录:
已经从当前
Span的自由链表中取出,但尚未重新归还到CentralCache的小块数量。
这些小块可能正由用户使用,也可能暂时缓存在某个线程的 ThreadCache 中。
text
CentralCache 从 Span 中取出 n 个空闲块
|
v
span->usecount += n
当 ThreadCache 将空闲块归还到其所属的 Span 时,则需要减少对应的 usecount:
text
空闲块归还到所属 Span
|
v
span->usecount--
当:
text
usecount == 0
说明当前 Span 中此前取出的所有小块都已经归还,整个 Span 已经处于完全空闲状态,可以从 CentralCache 中摘除并归还给 PageCache。
最后,next 和 prev 用于将当前 Span 挂入双向链表:
cpp
Span* next;
Span* prev;
因此,一个 Span 的核心职责可以概括为:
text
描述一段连续虚拟内存页
+
记录当前小块切分规格
+
维护内部空闲块自由链表
+
记录尚未归还的小块数量
+
作为节点挂入 Span 双向链表
SpanLists:管理桶中的 Span 链表
认识了 Span 以后,接下来还需要一个结构,用来管理 CentralCache 每个桶中的 Span 链表。
这里将该管理结构封装为 SpanLists 类:
cpp
class SpanLists
{
public:
SpanLists();
bool Empty();
Span* PopFront();
void PushFront(Span* span);
void Erase(Span* span);
Span* Begin();
Span* End();
std::mutex& GetMutex();
private:
Span head;
std::mutex lock;
};
每个 SpanLists 对象负责管理一条 Span 双向链表,也就对应 CentralCache 中的一个桶。
其内部首先包含一个链表头节点:
cpp
Span head;
后续可以通过该节点完成链表初始化、遍历、插入和删除等操作。
为了方便管理 Span 链表,SpanLists 对外提供了一组基础接口:
text
Empty
↓
判断链表是否为空
PopFront
↓
从链表头部摘取一个 Span
PushFront
↓
将一个 Span 插入链表头部
Erase
↓
将指定 Span 从链表中摘除
Begin / End
↓
提供链表遍历所需的起止位置
具体的双向链表操作,后续在实现 SpanLists 时再展开。
除此之外,每个 SpanLists 内部还维护一把互斥锁:
cpp
std::mutex lock;
由于 CentralCache 是多个线程共同访问的公共结构,因此不同线程可能同时访问同一个规格桶。
为了保证桶内 Span 链表及其管理状态不会被并发破坏,每个桶都需要通过一把独立的互斥锁进行保护。
text
CentralCache
|
+-- 桶 0:SpanLists + 锁 0
|
+-- 桶 1:SpanLists + 锁 1
|
+-- 桶 2:SpanLists + 锁 2
|
...
这样,不同线程访问不同规格的桶时,可以分别获取对应桶的锁并发执行;只有访问同一个规格桶时,才会竞争同一把锁。
GetMutex 用于向 CentralCache 的具体业务接口提供当前桶对应的互斥锁:
cpp
std::mutex& GetMutex();
至此,CentralCache 每个桶的基本组成就已经明确:
text
一个 CentralCache 桶
|
v
一个 SpanLists 对象
|
+-- 一条 Span 双向链表
|
+-- 一把桶锁
CentralCache 的类结构
完成 Span 和 SpanLists 的定义以后,就可以搭建 CentralCache 的整体骨架:
cpp
class CentralCache
{
public:
static CentralCache& GetInstance();
void ReleaseListToSpans(
void* start,
size_t num_move,
size_t index);
size_t FetchRangeObj(
void*& start,
void*& end,
size_t BatchNum,
size_t index,
size_t size);
CentralCache(const CentralCache&) = delete;
CentralCache& operator=(const CentralCache&) = delete;
CentralCache(CentralCache&&) = delete;
CentralCache& operator=(CentralCache&&) = delete;
private:
CentralCache() = default;
Span* FetchSpanFromPageCache(
SpanLists& span_list,
size_t size);
private:
SpanLists Span_Lists[NFreelists];
};
其内部最核心的成员为:
cpp
SpanLists Span_Lists[NFreelists];
该数组包含 208 个桶,每个桶对应一种固定规格,并通过 SpanLists 管理能够提供该规格空闲块的 Span。
text
Span_Lists[0]
↓
管理第一种规格对应的 Span
Span_Lists[1]
↓
管理第二种规格对应的 Span
...
Span_Lists[207]
↓
管理最后一种规格对应的 Span
CentralCache 对外主要提供两个接口。
第一个是:
cpp
size_t FetchRangeObj(
void*& start,
void*& end,
size_t BatchNum,
size_t index,
size_t size);
该接口用于在 ThreadCache 对应桶为空时,为其提供一批相同规格的空闲块:
text
ThreadCache 空闲块不足
|
v
调用 FetchRangeObj
|
v
从 CentralCache 对应桶的 Span 中
获取一批空闲块
第二个是:
cpp
void ReleaseListToSpans(
void* start,
size_t num_move,
size_t index);
该接口用于接收 ThreadCache 归还的一批空闲块,并将这些空闲块分别放回其所属的 Span:
text
ThreadCache 空闲块过多
|
v
调用 ReleaseListToSpans
|
v
将每个空闲块归还到所属 Span
此外,CentralCache 还包含一个私有接口:
cpp
Span* FetchSpanFromPageCache(
SpanLists& span_list,
size_t size);
当 CentralCache 对应规格的桶中不存在能够继续提供空闲块的 Span 时,就需要通过该接口向下一层的 PageCache 请求新的页级内存资源:
text
CentralCache 当前规格没有可用 Span
|
v
调用 FetchSpanFromPageCache
|
v
向 PageCache 请求新的 Span
|
v
按照当前规格切分成小块
这些接口的具体实现,后续再逐步展开。当前阶段只需要先明确它们各自的职责。
CentralCache 的单例设计
这里还需要注意,CentralCache 必须被设计成一个进程级单例。
与每个线程分别拥有一个实例的 ThreadCache 不同,CentralCache 是由进程中的所有线程共同访问的公共缓存。
text
线程 A ─┐
线程 B ─┼──> 同一个 CentralCache
线程 C ─┘
整个进程中只能存在一个 CentralCache 对象,由它统一维护内部的 208 个规格桶,以及各个 Span 的自由链表、usecount 和链表归属等管理状态。
之所以要求 CentralCache 全局唯一,并不只是为了方便不同线程访问,更重要的是保证整个内存池中只有一套权威的内存管理状态。
假设允许在类外创建或者拷贝多个 CentralCache 对象,那么不同对象就可能分别维护各自的桶数组和 Span 状态。
例如,初始情况下,两个 CentralCache 对象都可能认为某个 Span 中存在三个空闲块:
text
CentralCache A 中的 Span:
freelist:
obj1 → obj2 → obj3 → nullptr
usecount = 0
text
CentralCache B 中的 Span:
freelist:
obj1 → obj2 → obj3 → nullptr
usecount = 0
随后,CentralCache A 将这三个空闲块全部提供给某个 ThreadCache:
text
CentralCache A 中的 Span:
freelist = nullptr
usecount = 3
但是,CentralCache B 中保存的状态并不会自动发生相同变化,它仍然可能认为:
text
CentralCache B 中的 Span:
freelist:
obj1 → obj2 → obj3 → nullptr
usecount = 0
此时就出现了两套互相矛盾的管理状态:
text
CentralCache A 认为:
这些小块已经全部离开 Span,
不能继续分配。
CentralCache B 认为:
这些小块仍然位于自由链表中,
还可以继续分配。
如果 CentralCache B 再次取出 obj1,就可能将一块已经交给其他 ThreadCache 或用户使用的内存再次分配出去:
text
同一个 obj1
|
+-- 已经分配给线程 A
|
+-- 又被分配给线程 B
这会造成同一块内存被重复分配,破坏整个内存池的管理状态。
因此,整个进程中必须只有一个 CentralCache:
text
全局唯一的 CentralCache
|
v
唯一的一套规格桶数组
|
v
唯一的一套 Span 链表
|
v
唯一的 freelist 和 usecount 状态
|
v
保证底层内存管理状态统一
为了禁止类外直接创建 CentralCache 对象,需要将构造函数设置为私有:
cpp
private:
CentralCache() = default;
这样,类外就不能直接创建对象:
cpp
CentralCache cache; // 无法通过编译
CentralCache* ptr = new CentralCache; // 无法通过编译
同时,还需要删除拷贝构造函数和拷贝赋值运算符,防止通过已有对象复制出新的 CentralCache:
cpp
CentralCache(const CentralCache&) = delete;
CentralCache& operator=(const CentralCache&) = delete;
移动操作同样没有必要,因此也一并删除:
cpp
CentralCache(CentralCache&&) = delete;
CentralCache& operator=(CentralCache&&) = delete;
随后,对外提供一个公共静态成员函数,作为获取全局唯一实例的统一入口:
cpp
static CentralCache& GetInstance();
其实现如下:
cpp
CentralCache& CentralCache::GetInstance()
{
static CentralCache instance;
return instance;
}
这里的:
cpp
static CentralCache instance;
是一个函数局部静态对象。
该对象不会在程序启动时立即完成构造,而是在程序第一次执行到这条语句,也就是第一次调用 GetInstance() 时,才进行创建和初始化:
text
程序启动
|
v
CentralCache 尚未创建
|
v
第一次调用 GetInstance()
|
v
创建并初始化静态对象 instance
|
v
返回 instance 的引用
后续再次调用 GetInstance() 时,不会重新创建对象,而是继续返回之前已经创建的同一个实例:
text
第一次调用 GetInstance()
|
v
创建 CentralCache
后续调用 GetInstance()
|
v
返回同一个 CentralCache
因此,这种实现属于懒汉模式。
需要注意,这里单例设计保证的是:
整个进程中只有一套权威的
CentralCache桶数组和Span管理状态。
它所解决的重点,是防止多个 CentralCache 对象分别维护互相矛盾的 freelist、usecount 和桶链表状态。
至于多个线程同时访问同一个 CentralCache 时的线程安全问题,则由各个桶内部的互斥锁负责保证。
至此,CentralCache 的整体骨架便搭建完成:
text
CentralCache
|
+-- 全局唯一的进程级单例
|
+-- 长度为 208 的 SpanLists 桶数组
|
+-- 每个桶维护一条 Span 链表
|
+-- 每个 Span 管理一批固定规格的空闲块
|
+-- FetchRangeObj 向 ThreadCache 提供空闲块
|
+-- ReleaseListToSpans 接收 ThreadCache 归还的空闲块
|
+-- FetchSpanFromPageCache 从 PageCache 获取新的 Span
后续将在这一整体骨架的基础上,继续实现 CentralCache 的各个核心接口。
CentralCache::ReleaseListToSpans:空闲块归还与 Span 回收
根据上文,我们已经知道,当 ThreadCache 中某个桶的自由链表长度超过当前上限时,会保留大约一半的空闲块,并将剩余部分归还给 CentralCache。
在 ThreadCache::ListTooLong 中,会先通过 PopRange 从自由链表头部摘取一定数量的空闲块,并将这些节点组织成一条独立的链表:
text
start
|
v
obj1 → obj2 → obj3 → ... → nullptr
随后,再调用 CentralCache 提供的:
cpp
ReleaseListToSpans(start, num_move, index);
将这批空闲块归还到 CentralCache。
其中:
text
start:
待归还空闲块链表的头节点地址
num_move:
本次归还的空闲块数量
index:
这些空闲块对应的规格桶下标
需要注意,这些空闲块虽然来自同一个 ThreadCache 桶,因此具有相同的规格,但它们不一定属于同一个 Span。
例如:
text
ThreadCache 某个桶:
来自 Span A 的空闲块
|
v
来自 Span B 的空闲块
|
v
来自 Span A 的空闲块
|
v
来自 Span C 的空闲块
因此,ReleaseListToSpans 不能直接将整条链表挂入某一个 Span,而是需要逐个处理其中的空闲块,根据每个块的地址找到其所属的 Span,再分别归还。
其实现如下:
cpp
void CentralCache::ReleaseListToSpans(void* start,size_t num_move,size_t index)
{
assert(start != nullptr);
assert(num_move > 0);
assert(index < NFreelists);
SpanLists& span_list = Span_Lists[index];
std::lock_guard<std::mutex> lock(
span_list.GetMutex());
void* cur = start;
for(size_t i = 0; i < num_move; ++i)
{
assert(cur != nullptr);
void* next = FreeList::NextObj(cur);
Span* span =PageCache::GetInstance().MapObjectToSpan(cur);
assert(span != nullptr);
assert(span->usecount > 0);
FreeList::NextObj(cur) = span->freelist;
span->freelist = cur;
--span->usecount;
if(span->usecount == 0)
{
span_list.Erase(span);
PageCache::GetInstance().ReleaseSpanToPageCache(span);
}
cur = next;
}
// 检查实际链表长度是否正好等于 num_move
assert(cur == nullptr);
}
整个归还过程可以表示为:
text
ThreadCache 归还一批空闲块
|
v
获取 CentralCache 对应桶的锁
|
v
依次遍历待归还链表
|
v
根据每个块的地址找到所属 Span
|
v
将空闲块头插回 Span 的自由链表
|
v
递减对应 Span 的 usecount
|
v
usecount 是否等于 0
| |
| 否 | 是
v v
继续保留在 从 CentralCache
CentralCache 中 桶中摘除 Span
|
v
归还给 PageCache
函数首先通过断言检查三个参数:
cpp
assert(start != nullptr);
assert(num_move > 0);
assert(index < NFreelists);
分别用于保证:
text
start 不能为空
|
v
必须存在待归还的空闲块链表
num_move 必须大于 0
|
v
本次至少归还一个空闲块
index 必须小于 NFreelists
|
v
桶下标位于合法范围内
由于 ReleaseListToSpans 是内存池内部使用的接口,因此可以将这些条件视为调用者必须满足的前置条件,并通过断言及时发现错误。
参数校验通过以后,首先根据桶下标获取对应的 SpanLists:
cpp
SpanLists& span_list = Span_Lists[index];
随后获取该桶对应的互斥锁:
cpp
std::lock_guard<std::mutex> lock(
span_list.GetMutex());
虽然这些空闲块可能属于不同的 Span,但是它们都来自相同规格的 ThreadCache 桶,因此所属的 Span 都挂载在 CentralCache 的同一个规格桶中。
本次归还过程会修改:
text
Span 内部的空闲块自由链表
Span 的 usecount
CentralCache 桶中的 Span 链表
因此,需要用当前桶对应的互斥锁保护整个批量归还过程。
text
获取桶锁
|
v
逐个处理本次归还的所有空闲块
|
v
完成以后自动释放桶锁
lock_guard 会在当前函数作用域结束时自动释放锁,避免因为中途返回或者异常路径而遗漏解锁操作。
函数使用 cur 指向当前正在处理的空闲块:
cpp
void* cur = start;
然后按照 num_move 依次遍历:
cpp
for (size_t i = 0; i < num_move; ++i)
{
assert(cur != nullptr);
// 处理当前空闲块
}
在处理当前节点之前,需要先保存其在原归还链表中的后继节点:
cpp
void* next = FreeList::NextObj(cur);
这一步非常重要。
此时,cur 的头部保存的是原归还链表中的下一个节点地址:
text
cur → next → 后续待归还节点
但是,后续需要将 cur 插入其所属 Span 的自由链表:
cpp
FreeList::NextObj(cur) = span->freelist;
这条语句会覆盖 cur 原来保存的后继地址。
因此,必须在修改 cur 的后继指针之前,先将原链表中的 next 保存下来:
text
先保存原链表中的 next
|
v
再修改 cur 的后继指针
|
v
将 cur 插入 Span 自由链表
|
v
使用保存的 next 继续遍历
如果没有提前保存 next,一旦 cur 被插入另一个链表,就无法继续找到待归还链表中的后续节点。
得到当前空闲块以后,需要根据它的虚拟地址找到所属的 Span:
cpp
Span* span =
PageCache::GetInstance()
.MapObjectToSpan(cur);
MapObjectToSpan 会根据空闲块地址计算其所在的虚拟页号,再通过页号映射结构查询负责管理该页的 Span:
text
空闲块地址 cur
|
v
计算对应的虚拟页号
|
v
查询页号到 Span 的映射结构
|
v
得到所属 Span*
找到以后,通过断言检查:
cpp
assert(span != nullptr);
assert(span->usecount > 0);
span != nullptr 表示当前内存块确实能够映射到一个有效的 Span。
而:
cpp
span->usecount > 0
表示当前 Span 中确实还存在尚未归还到 CentralCache 的小块。
如果 usecount 已经是 0,却仍然有内存块试图归还,就说明前面的计数维护或者归还逻辑出现了错误。
定位到所属 Span 以后,就可以将当前空闲块头插到该 Span 的自由链表中:
cpp
FreeList::NextObj(cur) = span->freelist;
span->freelist = cur;
假设插入前的自由链表为:
text
span->freelist
|
v
A → B → C → nullptr
首先执行:
cpp
FreeList::NextObj(cur) = span->freelist;
使当前空闲块指向原来的链表头节点:
text
cur → A → B → C → nullptr
随后:
cpp
span->freelist = cur;
让 Span 的自由链表头指针指向当前节点:
text
span->freelist
|
v
cur → A → B → C → nullptr
至此,当前内存块便重新回到了所属 Span 的自由链表中。
空闲块重新回到 Span 以后,还需要更新:
cpp
--span->usecount;
usecount 记录的是:
已经从当前
Span的自由链表中取出,但尚未重新归还到CentralCache的小块数量。
此前,当 CentralCache 从某个 Span 中取出一批空闲块并提供给 ThreadCache 时,会增加对应的 usecount:
text
从 Span 中取出 n 个小块
|
v
span->usecount += n
现在,当前小块已经重新插回 Span 的自由链表,因此需要将 usecount 减一:
text
一个空闲块归还 Span
|
v
span->usecount--
如果减一以后:
text
usecount > 0
说明还有其他小块没有归还到 CentralCache。
这些小块可能仍然缓存在某个线程的 ThreadCache 中,也可能正在被用户使用,因此当前 Span 仍然需要继续保留在 CentralCache 中。
如果当前空闲块归还以后:
cpp
if (span->usecount == 0)
说明此前从这个 Span 中取出的所有小块都已经重新回到了其内部自由链表中。
text
usecount == 0
|
v
没有任何小块位于 CentralCache 之外
|
v
整个 Span 已经完全空闲
此时,这个 Span 已经没有必要继续停留在 CentralCache 当前规格的桶中。
因此,需要先将其从当前桶的 Span 链表中摘除:
cpp
span_list.Erase(span);
然后将这个完全空闲的 Span 归还给 PageCache:
cpp
PageCache::GetInstance()
.ReleaseSpanToPageCache(span);
text
从 CentralCache 当前规格桶中摘除
|
v
归还给 PageCache
PageCache 接收到这个 Span 后,会重新以页为单位管理这段连续虚拟内存,并尝试与前后相邻的空闲 Span 进行合并:
text
完全空闲的 Span
|
v
归还 PageCache
|
v
解除与原有小块规格的绑定
|
v
尝试与相邻空闲 Span 合并
|
v
挂入对应页数的空闲桶
这样,这段内存后续就可以重新提供给其他规格使用,从而提高内存的跨规格复用能力。
完成当前节点的归还以后:
cpp
cur = next;
让 cur 指向之前保存的原归还链表后继节点,继续处理下一个空闲块。
text
处理当前 cur
|
v
插回所属 Span
|
v
cur = next
|
v
继续处理下一节点
循环结束以后:
cpp
assert(cur == nullptr);
用于检查传入链表的实际长度是否正好等于 num_move。
如果链表实际长度小于 num_move,循环尚未结束时,cur 就会提前变成空指针,从而触发:
cpp
assert(cur != nullptr);
如果链表实际长度大于 num_move,循环处理完指定数量以后,cur 仍然会指向剩余节点,从而触发:
cpp
assert(cur == nullptr);
因此,这两个断言共同验证:
text
归还链表的实际节点数量
==
num_move
这也要求 ThreadCache::ListTooLong 中的 PopRange 正确地将摘取链表的尾节点后继设置为 nullptr。
综上,ReleaseListToSpans 的核心作用可以概括为:
接收
ThreadCache归还的一批同规格空闲块,在获取对应桶锁后,逐个根据地址找到所属Span,将其重新插回Span的自由链表并递减usecount;当某个Span的usecount归零时,将其从CentralCache中摘除并归还给PageCache。
整个过程实现了:
text
ThreadCache 中过量的空闲块
|
v
归还 CentralCache
|
v
重新进入所属 Span 的自由链表
|
v
完全空闲的 Span
|
v
继续归还 PageCache
通过这条回收路径,可以避免空闲内存长期滞留在线程私有缓存中,并使完全空闲的页级内存重新回到 PageCache 统一管理。
CentralCache::FetchRangeObj:从 Span 中批量获取空闲块
根据上文,我们已经知道,当线程申请某种规格的小块内存时,会先访问当前线程 ThreadCache 中对应的桶。
如果该桶中的自由链表不为空,就可以直接从链表头部取出一个空闲块;如果自由链表为空,则说明当前线程已经没有该规格的可用内存,需要向下一层的 CentralCache 请求批量补给。
text
ThreadCache 对应桶为空
|
v
调用 FetchRangeObj
|
v
从 CentralCache 对应桶中
批量获取一组空闲块
这个批量获取过程就是由 CentralCache::FetchRangeObj 完成的。
函数定义如下:
cpp
size_t CentralCache::FetchRangeObj(void*& start,void*& end,size_t BatchNum,size_t index,size_t size)
{
start = nullptr;
end = nullptr;
SpanLists& span_list = Span_Lists[index];
std::lock_guard<std::mutex> lock(span_list.GetMutex());
Span* cur = nullptr;
if (span_list.Empty())
{
cur = FetchSpanFromPageCache(span_list, size);
}
else
{
cur = span_list.Begin();
while (cur != span_list.End())
{
if (cur->freelist != nullptr)
{
break;
}
cur = cur->next;
}
if (cur == span_list.End())
{
cur = FetchSpanFromPageCache(span_list, size);
}
}
if (cur == nullptr || cur->freelist == nullptr)
{
return 0;
}
size_t actual_num=1;
start = cur->freelist;
end = start;
while(actual_num < BatchNum && FreeList::NextObj(end)!=nullptr)
{
end = FreeList::NextObj(end);
++actual_num;
}
void* next = FreeList::NextObj(end);
cur->freelist = next;
FreeList::NextObj(end) = nullptr;
cur->usecount+=actual_num;
return actual_num;
}
该接口的整体流程可以表示为:
text
根据 index 定位 CentralCache 桶
|
v
获取当前桶对应的互斥锁
|
v
寻找 freelist 非空的 Span
|
+-- 找到:直接使用
|
+-- 未找到:向 PageCache 获取新 Span
|
v
从 Span 的自由链表头部
摘取最多 BatchNum 个节点
|
v
将摘取出的链表与原链表断开
|
v
更新 Span 的 usecount
|
v
返回实际获取数量
FetchRangeObj 接收五个参数:
cpp
void*& start;
void*& end;
size_t BatchNum;
size_t index;
size_t size;
其中,start 和 end 是两个输出型参数。
函数会从某个 Span 的自由链表头部摘取一批空闲块,并通过它们返回这段链表的头节点和尾节点:
text
start
|
v
obj1 → obj2 → obj3 → ... → nullptr
^
|
end
BatchNum 表示 ThreadCache 本次期望获取的空闲块数量。
index 表示当前规格所对应的桶下标,用于定位 CentralCache 中相同规格的桶。
size 表示经过 SizeClass::RoundUp 对齐后的固定规格。当当前桶中没有可用的 Span 时,需要根据该规格向 PageCache 申请新的页级内存,并将其切分成相应大小的小块。
函数返回值表示本次实际获取到的空闲块数量:
text
返回值 = actual_num
实际数量不一定始终等于 BatchNum,因为当前 Span 的自由链表中可能没有足够多的节点。
函数首先将两个输出参数初始化为空:
cpp
start = nullptr;
end = nullptr;
这样,如果后续没有成功获取空闲块,调用者就不会拿到无效的链表地址。
接下来,根据桶下标获取对应的 SpanLists:
cpp
SpanLists& span_list = Span_Lists[index];
ThreadCache 和 CentralCache 使用相同的规格划分规则,因此相同的桶下标对应相同的内存块规格:
text
ThreadCache[index]
|
| 相同规格
v
CentralCache[index]
CentralCache 是多个线程共享的公共缓存,不同线程可能同时访问同一个规格桶。
因此,在遍历桶中的 Span、修改 Span 的自由链表以及更新 usecount 之前,需要先获取当前桶对应的互斥锁:
cpp
std::lock_guard<std::mutex> lock(
span_list.GetMutex());
本次获取过程中的以下操作都需要在桶锁的保护下完成:
text
遍历 Span 链表
寻找可用 Span
向桶中加入新的 Span
从 Span 的 freelist 中摘取节点
更新 Span 的 usecount
当函数执行结束后,lock_guard 会自动释放当前桶锁。
完成加锁以后,需要从当前桶中寻找一个内部自由链表不为空的 Span。
如果当前桶为空:
cpp
if (span_list.Empty())
{
cur = FetchSpanFromPageCache(
span_list, size);
}
说明 CentralCache 中还没有任何能够为当前规格提供内存的 Span,因此需要向下一层 PageCache 请求新的页级内存。
FetchSpanFromPageCache 会获取一个新的 Span,按照当前 size 将其关联的连续内存切分成小块,建立内部自由链表,并将该 Span 挂入当前桶中。
如果当前桶不为空,则从第一个 Span 开始遍历:
cpp
cur = span_list.Begin();
while (cur != span_list.End())
{
if (cur->freelist != nullptr)
{
break;
}
cur = cur->next;
}
遍历过程中依次检查每个 Span 的:
cpp
cur->freelist
如果自由链表不为空,说明当前 Span 还可以继续提供空闲块,因此结束遍历。
text
当前 Span 的 freelist 非空
|
v
找到可用 Span
如果自由链表为空,则继续访问下一个 Span:
text
当前 Span 的 freelist 为空
|
v
该 Span 暂时没有可分配的小块
|
v
继续检查下一个 Span
这里的 freelist == nullptr 并不表示当前 Span 已经可以被回收。
它可能只是所有小块都已经被提供给了 ThreadCache 或用户,此时通常具有:
text
freelist = nullptr
usecount > 0
该 Span 仍然需要保留在 CentralCache 中,等待此前分配出去的小块重新归还。
如果遍历到链表末尾,仍然没有找到自由链表非空的 Span:
cpp
if (cur == span_list.End())
{
cur = FetchSpanFromPageCache(
span_list, size);
}
说明当前桶中虽然存在 Span,但所有 Span 都已经暂时无法继续提供空闲块,因此同样需要向 PageCache 获取一个新的 Span。
所以,一共有两种情况需要向 PageCache 请求新的页级内存:
text
情况一:
当前 CentralCache 桶为空
情况二:
桶中存在 Span,
但所有 Span 的 freelist 都为空
调用 FetchSpanFromPageCache 后,仍然需要判断是否成功获得了可用的 Span:
cpp
if (cur == nullptr ||
cur->freelist == nullptr)
{
return 0;
}
如果 cur 为空,说明本次没有成功获取新的 Span。
如果 cur 不为空,但其 freelist 仍然为空,则说明当前 Span 中也没有可供分配的小块。
此时无法为 ThreadCache 提供内存,因此返回:
cpp
return 0;
同时,由于函数开始时已经将:
cpp
start = nullptr;
end = nullptr;
所以调用者也不会接收到无效的链表地址。
从 Span 的自由链表中摘取一批节点
找到可用的 Span 以后,就可以从其自由链表头部摘取一批空闲块。
由于已经保证:
cpp
cur->freelist != nullptr
因此当前至少可以获取一个空闲块。
首先,将 start 和 end 都指向自由链表的第一个节点:
cpp
size_t actual_num = 1;
start = cur->freelist;
end = start;
此时:
text
cur->freelist
|
v
obj1 → obj2 → obj3 → obj4 → nullptr
start = obj1
end = obj1
actual_num = 1
随后,从 end 开始向后遍历:
cpp
while (actual_num < BatchNum &&
FreeList::NextObj(end) != nullptr)
{
end = FreeList::NextObj(end);
++actual_num;
}
循环继续执行需要同时满足两个条件:
text
actual_num < BatchNum
|
v
尚未达到本次期望获取数量
NextObj(end) != nullptr
|
v
当前 Span 的自由链表中仍有后继节点
因此,该循环最多摘取 BatchNum 个节点。
如果当前 Span 中的空闲块数量小于 BatchNum,则会在到达链表尾部时停止,并将实际节点数量记录在:
cpp
actual_num
将返回链表与 Span 的自由链表分离
遍历结束后:
text
start
|
v
obj1 → obj2 → ... → end → 剩余节点
此时,start 指向本次摘取链表的第一个节点,end 指向最后一个节点。
但 end 仍然与 Span 自由链表中的剩余部分相连,因此需要先保存剩余链表的起始地址:
cpp
void* next = FreeList::NextObj(end);
例如,原自由链表为:
text
cur->freelist
|
v
obj1 → obj2 → obj3 → obj4 → obj5 → nullptr
本次摘取前三个节点,则此时:
text
start = obj1
end = obj3
next = obj4
随后,让 Span 的自由链表头指针指向剩余部分:
cpp
cur->freelist = next;
此时 Span 中剩余的空闲块为:
text
cur->freelist
|
v
obj4 → obj5 → nullptr
最后,将摘取链表的尾节点后继设置为空:
cpp
FreeList::NextObj(end) = nullptr;
这样,本次获取的节点就会形成一条完整且独立的自由链表:
text
start
|
v
obj1 → obj2 → obj3 → nullptr
^
|
end
而原 Span 中剩余的节点则继续由:
cpp
cur->freelist
进行管理。
整个切分过程可以表示为:
text
原 Span 自由链表:
obj1 → obj2 → obj3 → obj4 → obj5 → nullptr
摘取以后:
返回给 ThreadCache:
obj1 → obj2 → obj3 → nullptr
Span 中剩余:
obj4 → obj5 → nullptr
从 Span 的自由链表中摘取空闲块以后,还需要更新:
cpp
cur->usecount += actual_num;
usecount 记录的是:
已经从当前
Span的自由链表中取出,但尚未重新归还到CentralCache的小块数量。
本次共有 actual_num 个小块离开了当前 Span 的自由链表,因此需要将对应数量累加到 usecount 中:
text
从 Span 中取出 actual_num 个空闲块
|
v
span->usecount += actual_num
这些内存块回到 ThreadCache 后,其中一个会用于满足当前申请,其余部分会被缓存到 ThreadCache 对应桶的自由链表中。
text
CentralCache 返回一批空闲块
|
+-- 一个直接返回给当前申请
|
+-- 其余缓存到 ThreadCache
无论这些内存块当前位于用户手中,还是缓存在某个 ThreadCache 中,只要尚未重新归还到原 Span 的自由链表,就需要计入 usecount。
这里增加的是实际获取数量:
cpp
actual_num
而不是期望获取数量:
cpp
BatchNum
因为当前 Span 的自由链表可能不足以提供完整的一批节点。
完成链表切分和计数更新以后,函数返回:
cpp
return actual_num;
调用者可以根据返回值判断本次实际获取到多少个空闲块。
最终,start、end 和返回值之间的关系为:
text
start
|
v
第一个空闲块
|
v
... 共 actual_num 个节点 ...
|
v
最后一个空闲块
^
|
end
综上,FetchRangeObj 的核心作用可以概括为:
当
ThreadCache某个规格桶为空时,CentralCache根据桶下标找到对应的Span链表,从中选择一个内部自由链表不为空的Span;如果当前桶没有可用Span,则向PageCache获取新的页级内存。随后,从该Span的自由链表头部摘取最多BatchNum个空闲块,通过start和end返回摘取链表的首尾地址,并根据实际获取数量更新usecount。
整个过程实现了:
text
ThreadCache 对应桶为空
|
v
访问 CentralCache
|
v
寻找能够提供小块的 Span
|
v
批量摘取一段自由链表
|
v
一个块满足当前申请
|
v
其余块缓存到 ThreadCache
CentralCache::FetchSpanFromPageCache:向 PageCache 获取 Span 并构建小块自由链表
完成 FetchRangeObj 的分析以后,接下来实现 CentralCache 向 PageCache 获取内存的接口:
cpp
Span* FetchSpanFromPageCache(
SpanLists& span_list,
size_t size);
当 CentralCache 当前规格对应的桶为空,或者桶中虽然挂载了若干 Span,但所有 Span 的自由链表都已经为空时,现有的 Span 就无法继续为 ThreadCache 提供空闲块。
此时,CentralCache 需要向下一层的 PageCache 申请一段新的连续虚拟内存页,再按照当前固定规格将其切分成多个小块,并组织成自由链表。
整个过程可以表示为:
text
CentralCache 当前桶没有可用 Span
|
v
根据当前规格计算所需页数
|
v
向 PageCache 获取一个 Span
|
v
计算 Span 所关联的总字节数
|
v
按照当前固定规格切分小块
|
v
将小块串联成自由链表
|
v
将 Span 挂入 CentralCache 对应桶
接口实现如下:
cpp
Span* CentralCache::FetchSpanFromPageCache(SpanLists& span_list,size_t size)
{
size_t n = SizeClass::NumMovePages(size);
Span* span = PageCache::GetInstance().NewSpan(n);
if(span == nullptr)
{
return nullptr;
}
size_t span_bytes = span->n * PageSize;
size_t object_count = span_bytes / size;
char* start = reinterpret_cast<char*>(span->page_id << PageShift);
span->object_size = size;
span->usecount = 0;
span->freelist = start;
char* cur= start;
for(size_t i = 0; i < object_count-1; ++i)
{
char* next = cur + size;
FreeList::NextObj(cur) = next;
cur = next;
}
FreeList::NextObj(cur) = nullptr;
span_list.PushFront(span);
return span;
}
函数首先根据当前小块规格计算需要向 PageCache 申请多少页连续虚拟内存:
cpp
size_t n =
SizeClass::NumMovePages(size);
NumMovePages 会先根据当前固定规格计算标准批量数量,再得到这一批内存块所需的总字节数:
text
当前固定规格 size
|
v
计算标准批量数量
|
v
总字节数 = 批量数量 × size
随后,将总字节数除以页大小,并进行向上取整:
cpp
size_t npage =
(total_bytes + PageSize - 1) / PageSize;
得到所需页数以后,调用 PageCache 提供的 NewSpan 接口:
cpp
Span* span =
PageCache::GetInstance().NewSpan(n);
这里传入的 n 表示需要获取的连续虚拟页数量。
如果 PageCache 无法提供满足要求的 Span:
cpp
if (span == nullptr)
{
return nullptr;
}
则当前接口直接返回空指针,表示本次内存补给失败。
获取成功以后,span 中会记录这段连续虚拟内存的起始页号和实际页数:
cpp
span->page_id;
span->n;
其中:
text
page_id:
当前连续虚拟内存的起始页号
n:
当前 Span 实际关联的连续虚拟页数量
得到 Span 后,首先计算其所关联连续虚拟内存的总字节数:
cpp
size_t span_bytes =
span->n * PageSize;
例如,当前 Span 包含 64 页,每页大小为 4KB:
text
span_bytes
=
64 × 4KB
=
256KB
随后,根据当前固定规格计算这段内存能够完整切分出多少个小块:
cpp
size_t object_count =
span_bytes / size;
假设:
text
Span 总字节数 = 256KB
当前固定规格 = 1KB
则可以切分出:
text
object_count
=
256KB / 1KB
=
256 个
这里采用整数除法。如果 Span 总字节数不能被当前规格整除,那么尾部不足一个完整小块的空间不会参与切分。
text
Span 总内存
┌─────────────────────────────┬──────────┐
│ 可以切分出的完整内存块 │ 尾部剩余 │
└─────────────────────────────┴──────────┘
Span 中保存的是起始页号,而在切分小块时,需要得到这段连续虚拟内存的实际起始地址。
当前页大小为:
text
PageSize = 4096 = 2^12
因此:
text
PageShift = 12
页号与虚拟地址之间的关系为:
text
虚拟地址
=
页号 × 4096
乘以 2^12 可以通过左移 12 位完成:
cpp
span->page_id << PageShift
所以,起始虚拟地址可以还原为:
cpp
char* start =
reinterpret_cast<char*>(
span->page_id << PageShift);
这里使用 char*,主要是为了方便后续按照字节数进行地址偏移。
在 C++ 中,指针加上一个整数时,实际移动的字节数取决于指针所指向的数据类型:
text
指针实际移动的字节数
=
偏移量 × sizeof(指针所指向的类型)
例如:
cpp
int* ptr = ...;
ptr + 1;
由于 ptr 是 int*,因此 ptr + 1 表示向后移动一个 int 所占用的字节数,而不是只移动一个字节。
当前在切分 Span 所关联的连续内存时,需要根据固定规格 size,让地址准确地向后移动 size 个字节:
cpp
char* next = cur + size;
由于:
cpp
sizeof(char) == 1
所以对于 char* 来说:
text
cur + size
就表示从当前地址开始,准确地向后移动 size 个字节。
text
cur
|
| 向后移动 size 个字节
v
next
因此,这里先将起始虚拟地址转换为 char*:
cpp
char* start =
reinterpret_cast<char*>(
span->page_id << PageShift);
随后便可以通过:
cpp
char* next = cur + size;
按照当前固定规格依次定位下一个内存块的起始地址。
这里不能直接使用 void* 进行标准 C++ 指针运算,因为 void 没有确定的大小,编译器无法根据 sizeof(void) 计算地址移动的步长。
在按照固定规格切分连续虚拟内存时,可能会产生一个问题:
从这段内存中依次切分出来的小块,其起始地址是否满足对象存储和 CPU 访问所需的地址对齐要求?
这里不需要担心普通对象的地址对齐问题。
PageCache 以页为单位向系统申请内存,因此 Span 的起始地址天然按照页大小对齐:
text
PageSize = 4KB
也就是说:
text
start % 4096 == 0
因此,该起始地址同样能够满足常规对象所需的基础地址对齐要求。
同时,当前用于切分的 size 已经经过 SizeClass::RoundUp 处理,是内存池支持的固定规格,并能够满足当前对象所需的对齐关系。
假设对象所需的地址对齐数为 alignment,那么:
text
start % alignment == 0
size % alignment == 0
切分出的第 i 个内存块地址为:
text
block_address
=
start + i × size
由于 start 和 size 都能够被 alignment 整除,因此:
text
(start + i × size) % alignment == 0
所以,从页对齐的起始地址开始,按照已经对齐的固定规格依次切分后,每个小块的起始地址仍然能够满足相应的地址对齐要求。
例如:
text
start
|
v
块 1 起始地址
|
+ size
v
块 2 起始地址
|
+ size
v
块 3 起始地址
每次地址都增加一个满足对齐要求的固定规格,因此不会破坏后续内存块的地址对齐。
完成地址和块数量计算以后,需要设置当前 Span 的管理信息:
cpp
span->object_size = size;
span->usecount = 0;
span->freelist = start;
其中:
text
object_size
|
v
记录当前 Span 按照什么固定规格切分
usecount
|
v
记录已经从 Span 自由链表中取出、
但尚未归还的小块数量
freelist
|
v
指向切分后自由链表中的第一个小块
当前刚刚从 PageCache 获取 Span,尚未向任何 ThreadCache 提供小块,因此:
cpp
span->usecount = 0;
此时所有切分出来的小块都仍然位于 Span 自己的自由链表中。
将连续内存切分成小块
接下来,从起始地址开始,按照固定规格 size 依次定位每个小块:
cpp
char* cur = start;
当前 cur 指向第一个小块的起始地址。
循环中通过:
cpp
char* next = cur + size;
得到下一个小块的起始地址。
随后,将当前小块头部保存的后继地址设置为 next:
cpp
FreeList::NextObj(cur) = next;
再让 cur 移动到下一个小块:
cpp
cur = next;
完整循环如下:
cpp
for (size_t i = 0;
i < object_count - 1;
++i)
{
char* next = cur + size;
FreeList::NextObj(cur) = next;
cur = next;
}
假设当前 Span 能够切分出四个小块:
text
块 1 块 2 块 3 块 4
切分和串联完成以后,自由链表结构为:
text
span->freelist
|
v
块 1 → 块 2 → 块 3 → 块 4
循环只处理到倒数第二个小块,因为每次循环负责让当前节点指向下一个节点。
当循环结束以后,cur 指向最后一个小块,因此还需要将其后继地址设置为空:
cpp
FreeList::NextObj(cur) = nullptr;
最终得到一条完整的自由链表:
text
span->freelist
|
v
块 1 → 块 2 → 块 3 → 块 4 → nullptr
这里并没有额外创建链表节点,而是直接复用了每个空闲块本身,将块起始位置的前 sizeof(void*) 个字节用于保存下一个空闲块的地址。
将 Span 挂入 CentralCache 对应桶
完成小块切分和自由链表构建以后,需要将当前 Span 挂入 CentralCache 对应规格的桶中:
cpp
span_list.PushFront(span);
此时,该桶中就新增了一个能够提供当前固定规格空闲块的 Span:
text
CentralCache 对应桶
|
v
Span
|
+-- object_size = size
|
+-- usecount = 0
|
+-- freelist
|
v
块 1 → 块 2 → 块 3 → ...
最后返回该 Span:
cpp
return span;
上层的 FetchRangeObj 得到返回结果后,就可以继续从这个新 Span 的自由链表中摘取一批空闲块,并提供给 ThreadCache。
综上,FetchSpanFromPageCache 的核心作用可以概括为:
当
CentralCache当前规格桶中没有能够继续提供空闲块的Span时,根据该规格的标准批量需求计算所需页数,向PageCache获取一段连续虚拟内存页,并按照当前固定规格将其切分成多个小块,组织成Span内部的自由链表,最后将该Span挂入对应的CentralCache桶中。
整个过程完成了从页级内存到小块内存的转换:
text
PageCache 提供连续虚拟内存页
|
v
CentralCache 按照固定规格切分
|
v
形成小块自由链表
|
v
为 ThreadCache 提供批量补给
PageCache 的完整实现:页级内存管理、Span 拆分与相邻合并
PageCache 的整体骨架:页数桶、Span 映射与元数据管理
完成 CentralCache 的实现以后,接下来继续实现高并发内存池中的最后一级缓存------PageCache。
与 ThreadCache 和 CentralCache 主要管理固定规格的小块内存不同,PageCache 直接以虚拟内存页为单位管理内存,是整个内存池中负责页级资源分配、拆分、回收和合并的核心结构。
在进入各个接口的具体实现之前,仍然先从整体结构出发,梳理 PageCache 需要维护哪些成员,以及这些成员分别承担什么职责。
对于 PageCache 来说,最核心的成员仍然是一个桶数组:
cpp
SpanLists Span_List[NPageNum];
不过,PageCache 的桶数组与前两级缓存的组织方式不同。
ThreadCache 和 CentralCache 按照小块内存的固定规格划分桶:
text
ThreadCache / CentralCache
|
v
按照 8B、16B、24B......
等固定规格划分
|
v
共维护 208 个桶
而 PageCache 不再关心具体的小块规格,而是按照一个 Span 所包含的连续虚拟页数量划分桶:
text
PageCache
|
v
按照 Span 包含的页数划分桶
|
v
每个桶挂载相同页数的空闲 Span
在当前实现中,PageCache 管理的页数范围为 1~128 页,因此桶数组中需要对应 128 种不同的页数规格:
text
1 页桶
|
v
挂载所有包含 1 页的空闲 Span
2 页桶
|
v
挂载所有包含 2 页的空闲 Span
...
128 页桶
|
v
挂载所有包含 128 页的空闲 Span
如果桶数组长度正好为 128,那么有效下标范围为 0~127,通常可以使用:
text
桶下标 = Span 页数 - 1
完成页数与桶位置之间的映射。
因此,PageCache 桶数组的基本组织关系可以表示为:
text
PageCache
|
+-- Span_List[0]
| |
| v
| 1 页 Span 链表
|
+-- Span_List[1]
| |
| v
| 2 页 Span 链表
|
...
|
+-- Span_List[127]
|
v
128 页 Span 链表
这里挂载的 Span 都处于完全空闲状态,可以被重新拆分并提供给 CentralCache 使用。
除了桶数组以外,PageCache 还需要维护一把全局互斥锁:
cpp
std::mutex page_lock;
CentralCache 采用的是每个规格桶一把独立的锁,因为不同规格桶之间的大部分操作相对独立。
但对于 PageCache 来说,一次页级申请或者释放操作往往会同时修改多个桶。
因此,PageCache 中的拆分与合并并不是只修改某一个独立桶,而是可能同时影响多个桶、多个 Span 以及相关的页号映射关系。
为了保证一次完整的页级操作过程中,各个管理结构始终保持一致,这里使用一把全局锁保护整个 PageCache:
text
获取 PageCache 全局锁
|
v
查询页数桶
|
v
拆分或合并 Span
|
v
更新页号映射
|
v
重新挂入对应桶
|
v
释放全局锁
因此,这把全局锁不仅保护桶数组,还保护 Span 拆分、合并以及相关映射结构的更新过程。
空闲 Span 的页号映射
根据上文,当某个 Span 的 usecount 归零时,说明此前从该 Span 中取出的所有小块都已经归还,整个 Span 已经完全空闲。
此时,CentralCache 会将该 Span 从对应规格桶中摘除,并归还给 PageCache:
text
span->usecount == 0
|
v
从 CentralCache 桶中摘除
|
v
归还给 PageCache
PageCache 接收到这个 Span 后,需要尝试与前后相邻的空闲 Span 进行合并。
为了快速找到当前 Span 前后相邻虚拟内存页所属的空闲 Span,PageCache 需要维护一张页号到 Span 的映射表:
cpp
std::unordered_map<PAGE_ID, Span*> free_span_map;
这张哈希表只记录当前位于 PageCache 桶数组中的空闲 Span。
对于一个空闲 Span,其包含的所有页号都会注册到哈希表中。
例如,某个 Span 的信息为:
text
page_id = 100
n = 4
说明该 Span 覆盖的虚拟页范围为:
text
[100, 104)
因此,需要登记:
text
100 → Span
101 → Span
102 → Span
103 → Span
当另一个 Span 归还时,就可以根据它前后相邻的页号快速查询是否存在空闲区域。
假设当前归还的 Span 为:
text
起始页号:104
页数:3
那么其前一个相邻页号为:
text
104 - 1 = 103
通过查询:
text
free_span_map[103]
就可以找到前方相邻的空闲 Span。
同理,通过查询当前 Span 结束位置对应的页号,也可以找到后方相邻的空闲 Span。
因此,这张哈希表的主要作用是:
text
根据相邻页号
|
v
快速定位 PageCache 中的空闲 Span
|
v
支持 Span 的前后合并
这里不需要再为 free_span_map 单独准备一把互斥锁。
原因在于,对该哈希表的查询、插入、删除和更新,都发生在持有 PageCache 全局锁的场景中:
text
获取 page_lock
|
v
访问 free_span_map
|
v
修改桶数组和 Span 状态
|
v
更新 free_span_map
|
v
释放 page_lock
既然整个访问过程已经由 page_lock 统一保护,就不需要为哈希表再次加锁。
根据小块地址查询所属 Span
除了维护 PageCache 中空闲 Span 的映射关系以外,还需要维护已经提供给 CentralCache、并按照固定规格完成切分的 Span 映射关系。
根据上文,当 ThreadCache 中某个桶的自由链表长度超过上限,或者线程退出并执行 ThreadCache 析构函数时,会将缓存的空闲块归还给 CentralCache。
text
ThreadCache 归还一批空闲块
|
v
CentralCache 逐个处理空闲块
|
v
查询每个空闲块所属的 Span
虽然同一个 ThreadCache 桶中的空闲块规格相同,但这些空闲块可能来自不同的 Span。
因此,CentralCache 不能将整条链表直接挂回某一个 Span,而是必须根据每个空闲块的地址,找到其真正所属的 Span。
这一映射关系同样可以通过哈希表实现,但在当前项目中选择使用基数树:
cpp
PageRadixTree page_radix_tree;
基数树用于根据内存块所在的虚拟页号,定位负责管理该页的 Span:
text
空闲块地址
|
v
计算所在页号
|
v
查询基数树
|
v
得到所属 Span
至于这里为什么选择基数树而不是哈希表,以及基数树的具体结构和实现过程,后文再进行详细分析。
因此,当前 PageCache 中存在两套用途不同的映射结构:
text
free_span_map
|
v
记录 PageCache 中完全空闲的 Span
|
v
服务于相邻 Span 的查询与合并
page_radix_tree
|
v
记录已经交给 CentralCache 的 Span
|
v
服务于小块地址到所属 Span 的查询
两者虽然都建立页号与 Span 之间的关系,但服务的场景不同,并不是重复结构。
Span 元数据对象池
PageCache 还需要维护一个用于申请和回收 Span 元数据的定长对象池:
cpp
ObjectPool<Span> pool;
PageCache 在管理页级内存时,会频繁涉及 Span 对象的创建和回收。
例如,当所有页数桶中都没有能够满足要求的空闲 Span 时,PageCache 需要向系统申请一段新的连续虚拟内存:
text
现有桶中没有可用 Span
|
v
向系统申请连续虚拟内存页
|
v
创建一个 Span 描述这段内存
在拆分一个较大的 Span 时,同样可能需要创建新的 Span 元数据。
例如:
text
一个包含 100 页的 Span
|
v
切出前 40 页供 CentralCache 使用
|
v
剩余 60 页继续留在 PageCache
此时需要分别使用 Span 对象描述拆分后的两段连续虚拟内存。
如果每次都通过:
cpp
new Span;
创建元数据,又通过:
cpp
delete span;
进行释放,就会使高并发内存池自身频繁依赖普通动态内存分配器。
高并发内存池本身的目的就是减少频繁调用普通动态内存分配接口所带来的开销,因此不希望在内部管理 Span 时再次大量调用 new 和 delete。
所以,这里专门为 Span 元数据准备一个定长对象池:
text
需要新的 Span 元数据
|
v
从 ObjectPool<Span> 中获取
Span 合并后某个元数据不再使用
|
v
归还 ObjectPool<Span>
通过对象池复用 Span 对象,可以减少元数据频繁创建和销毁带来的动态内存分配开销。
PageCache 的整体类结构
根据上面的分析,PageCache 的整体骨架如下:
cpp
class PageCache
{
public:
using PAGE_ID = size_t;
static PageCache& GetInstance();
void ReleaseSpanToPageCache(Span* span);
Span* NewSpan(size_t K);
Span* MapObjectToSpan(void* obj);
void* SystemAllocate(
std::size_t page_count);
void SystemDeallocate(
void* ptr,
std::size_t page_count);
PageCache(const PageCache&) = delete;
PageCache& operator=(
const PageCache&) = delete;
PageCache(PageCache&&) = delete;
PageCache& operator=(
PageCache&&) = delete;
private:
PageCache();
void MergeSpan(
Span* span,
Span* other);
void RegisterFreeSpan(Span* span);
void RegisterSpan(Span* span);
void UnregisterFreeSpan(Span* span);
void UnregisterSpan(Span* span);
Span* SplitSpan(
Span* bigSpan,
size_t K);
Span* Allocate();
private:
std::mutex page_lock;
SpanLists Span_List[NPageNum];
ObjectPool<Span> pool;
PageRadixTree page_radix_tree;
std::unordered_map<PAGE_ID, Span*>
free_span_map;
};
其中,几个主要接口的职责如下。
cpp
Span* NewSpan(size_t K);
负责获取一个包含 K 个连续虚拟页的 Span。
cpp
void ReleaseSpanToPageCache(Span* span);
负责接收从 CentralCache 归还的完全空闲 Span,查询其前后相邻的空闲 Span,尝试完成页级合并,并将最终结果挂入对应页数的桶中。
cpp
Span* MapObjectToSpan(void* obj);
负责根据一个小块内存地址计算其所在的虚拟页号,再通过基数树找到所属的 Span。
cpp
Span* SplitSpan(
Span* bigSpan,
size_t K);
负责将一个较大的空闲 Span 拆分成两部分:
text
K 页 Span
+
剩余页 Span
并同步更新页号、页数、桶链表和相关映射关系。
cpp
void MergeSpan(
Span* span,
Span* other);
负责将两个虚拟地址连续的空闲 Span 合并成一个更大的 Span。
cpp
void RegisterFreeSpan(Span* span);
void UnregisterFreeSpan(Span* span);
负责维护 PageCache 空闲 Span 在:
cpp
free_span_map;
中的页号映射。
cpp
void RegisterSpan(Span* span);
void UnregisterSpan(Span* span);
负责维护已经提供给 CentralCache 的 Span 在:
cpp
page_radix_tree;
中的页号映射。
cpp
void* SystemAllocate(
std::size_t page_count);
void SystemDeallocate(
void* ptr,
std::size_t page_count);
负责与操作系统交互,按照页数申请和释放连续虚拟内存。
cpp
Span* Allocate();
负责从 ObjectPool<Span> 中获取一个可用的 Span 元数据对象。
PageCache::ReleaseSpanToPageCache:空闲 Span 的归还与相邻合并
根据上文,当 ThreadCache 中缓存的空闲块过多时,会将一部分空闲块归还给 CentralCache。
CentralCache 接收到这些空闲块以后,会根据每个内存块的地址找到其所属的 Span,将其重新插回对应 Span 的自由链表,并递减该 Span 的 usecount。
当:
text
usecount == 0
说明此前从该 Span 中取出的所有内存块都已经归还,整个 Span 已经完全空闲。
此时,CentralCache 会将这个 Span 从对应规格的桶中摘除,并调用:
cpp
void PageCache::ReleaseSpanToPageCache(Span* span)
{
std::lock_guard<std::mutex> guard(page_lock);
span->freelist = nullptr;
span->object_size = 0;
span->usecount = 0;
span->next = nullptr;
span->prev = nullptr;
UnregisterSpan(span);
size_t left_free_span = span->page_id - 1;
size_t right_free_span = span->page_id + span->n;
auto left = free_span_map.find(left_free_span);
auto right = free_span_map.find(right_free_span);
if(left != free_span_map.end())
{
Span* leftspan = left->second;
if(span->n + leftspan->n <= NPageNum)
{
Span_List[leftspan->n - 1].Erase(leftspan);
UnregisterFreeSpan(leftspan);
MergeSpan(span, leftspan);
pool.Delete(leftspan);
}
}
if(right != free_span_map.end())
{
Span* rightspan = right->second;
if(span->n + rightspan->n <= NPageNum)
{
Span_List[rightspan->n - 1].Erase(rightspan);
UnregisterFreeSpan(rightspan);
MergeSpan(span, rightspan);
pool.Delete(rightspan);
}
}
RegisterFreeSpan(span);
Span_List[span->n-1].PushFront(span);
}
该接口的整体流程可以表示为:
text
CentralCache 归还完全空闲的 Span
|
v
获取 PageCache 全局锁
|
v
清理原有的小块管理状态
|
v
解除基数树中的页号映射
|
v
查询左侧相邻空闲 Span
|
v
满足条件则摘桶、解除映射并合并
|
v
查询右侧相邻空闲 Span
|
v
满足条件则摘桶、解除映射并合并
|
v
注册合并后 Span 的空闲页号映射
|
v
挂入最终页数对应的桶
函数首先获取 PageCache 的全局互斥锁:
cpp
std::lock_guard<std::mutex> guard(
page_lock);
ReleaseSpanToPageCache 的执行过程会同时涉及:
text
Span 元数据的修改
页数桶中 Span 的摘除与插入
free_span_map 映射的删除与注册
基数树映射的解除
相邻 Span 的合并
Span 元数据对象的回收
这些操作共同组成一次完整的页级归还过程,因此需要在同一个全局锁的保护下完成。
当函数执行结束后,lock_guard 会自动释放 page_lock。
该 Span 此前由 CentralCache 管理,并按照某一种固定规格切分成了多个小块,因此内部仍然保存着与小块管理有关的信息。
cpp
span->freelist = nullptr;
span->object_size = 0;
span->usecount = 0;
span->next = nullptr;
span->prev = nullptr;
这里并不是重新初始化整个 Span,而是清理其在 CentralCache 阶段留下的管理状态。
text
freelist = nullptr
|
v
不再维护固定规格的小块自由链表
object_size = 0
|
v
解除与原有小块规格的绑定
usecount = 0
|
v
当前 Span 已经完全空闲
next、prev = nullptr
|
v
清除原 Span 链表中的连接关系
不过,下面两个字段仍然需要保留:
cpp
span->page_id;
span->n;
因为 PageCache 后续需要根据起始页号和页数查询相邻 Span、完成合并,并确定最终应该挂入哪个页数桶。
该 Span 被提供给 CentralCache 以后,其覆盖的页号会被注册到基数树中,用于根据任意小块地址查询所属的 Span。
现在,这个 Span 已经完全空闲,并重新交由 PageCache 管理,因此需要解除其在基数树中的页号映射:
cpp
UnregisterSpan(span);
整个状态转换可以表示为:
text
CentralCache 管理阶段
|
v
Span 页号注册在基数树中
|
v
根据小块地址定位所属 Span
Span 完全空闲
|
v
归还 PageCache
|
v
解除基数树中的原有映射
这里所说的"解除"或者"删除"映射,并不一定意味着真正删除基数树中的整条节点路径。在当前实现中,主要是将对应叶子位置保存的 Span* 进行清空或覆盖。
至于基数树具体如何完成注册和解除映射,后文再进行详细分析。
一个 Span 使用:
cpp
span->page_id;
span->n;
描述一段连续虚拟内存页。
假设当前 Span 覆盖的页号范围为:
text
[page_id, page_id + n)
那么,它左侧紧邻的虚拟页号为:
text
page_id - 1
右侧紧邻的虚拟页号为:
text
page_id + n
因此可以计算:
cpp
size_t left_page_id =
span->page_id - 1;
size_t right_page_id =
span->page_id + span->n;
通过查询这两个页号,就可以判断当前 Span 前后是否存在可合并的空闲 Span。
free_span_map 中记录的是当前由 PageCache 管理的空闲 Span。
每个空闲 Span 所包含的所有页号,都会映射到同一个 Span*。
因此,可以分别查询左右相邻页号:
cpp
auto left =
free_span_map.find(left_page_id);
auto right =
free_span_map.find(right_page_id);
如果查询成功:
text
free_span_map 中存在相邻页号
|
v
该页属于一个 PageCache 管理的空闲 Span
|
v
可以尝试进行合并
需要注意,这里查询的是相邻页号,而查询结果得到的则是包含该页的整个空闲 Span。
如果左侧相邻页号能够在 free_span_map 中找到:
cpp
if (left != free_span_map.end())
{
Span* leftspan = left->second;
}
说明当前 Span 左侧存在一个空闲 Span。
不过,在执行合并之前,还需要判断合并后的总页数是否超过 PageCache 桶数组能够管理的最大范围:
cpp
if (span->n + leftspan->n
<= NPageNum)
当前 PageCache 只管理 1~128 页的空闲 Span。如果合并后的页数超过 NPageNum,就无法挂入现有的页数桶,因此不执行本次合并。
如果满足合并条件,首先需要将 leftspan 从原页数桶中摘除:
cpp
Span_List[leftspan->n - 1]
.Erase(leftspan);
随后,解除 leftspan 在空闲哈希表中的页号映射:
cpp
UnregisterFreeSpan(leftspan);
因为合并以后,leftspan 将不再作为一个独立的空闲 Span 存在,其原来的映射关系也不再有效。
完成旧桶和旧映射的清理以后,就可以执行合并:
cpp
MergeSpan(span, leftspan);
合并操作会修改当前 span 的起始页号和页数,使其描述包含左右两段内存的完整连续虚拟页区域。
例如:
text
leftspan:
page_id = 96
n = 4
覆盖 [96, 100)
span:
page_id = 100
n = 6
覆盖 [100, 106)
合并以后:
text
span:
page_id = 96
n = 10
覆盖 [96, 106)
此时,原来由 leftspan 描述的虚拟内存页已经被纳入 span 的管理范围。
因此,leftspan 这个元数据对象不再需要,可以归还给 Span 对象池:
cpp
pool.Delete(leftspan);
右侧 Span 的合并过程与左侧基本一致:
cpp
if (right != free_span_map.end())
{
Span* rightspan = right->second;
if (span->n + rightspan->n
<= NPageNum)
{
Span_List[rightspan->n - 1]
.Erase(rightspan);
UnregisterFreeSpan(rightspan);
MergeSpan(span, rightspan);
pool.Delete(rightspan);
}
}
首先从原页数桶中摘除 rightspan:
cpp
Span_List[rightspan->n - 1]
.Erase(rightspan);
随后解除其在 free_span_map 中的页号映射:
cpp
UnregisterFreeSpan(rightspan);
再将其覆盖的连续虚拟页合并到当前 span 中:
cpp
MergeSpan(span, rightspan);
最后,将不再需要的 rightspan 元数据对象归还给对象池:
cpp
pool.Delete(rightspan);
需要注意,代码在执行左右合并之前,就已经计算出了右侧相邻页号:
cpp
size_t right_page_id =
span->page_id + span->n;
即使随后先完成左侧合并,当前 span 的起始页号会向左移动、页数会增加,但其原来的右边界并不会改变。
例如:
text
合并前 span:
[100, 110)
与左侧 [90, 100) 合并后:
[90, 110)
合并前后的右边界始终都是:
text
110
因此,前面计算出的 right_page_id 仍然可以继续用于查询右侧相邻 Span。
完成左右两侧的合并尝试以后,当前 span 的:
cpp
span->page_id;
span->n;
可能已经发生变化。
此时,需要根据最终的起始页号和页数,将该 Span 包含的所有页号重新注册到 free_span_map 中:
cpp
RegisterFreeSpan(span);
例如,最终合并结果为:
text
page_id = 90
n = 20
那么需要建立:
text
90 → span
91 → span
92 → span
...
109 → span
这些映射后续可以继续用于其他 Span 归还时的相邻查询和合并。
这里是在所有合并操作完成以后,才统一注册最终的 Span,避免在合并过程中反复登记和删除当前 span 的中间状态。
完成空闲页号映射注册以后,还需要将当前 span 挂入合并后页数对应的桶中:
cpp
Span_List[span->n - 1]
.PushFront(span);
页数与桶下标之间的关系为:
text
桶下标 = 页数 - 1
例如:
text
span->n = 1
↓
挂入 Span_List[0]
span->n = 8
↓
挂入 Span_List[7]
span->n = 128
↓
挂入 Span_List[127]
此时,当前 Span 就重新成为 PageCache 中一个可供后续拆分和分配的空闲页级资源。
整个状态转换过程可以表示为:
text
CentralCache 中完全空闲的 Span
|
v
解除小块管理状态
|
v
解除基数树映射
|
v
尝试与左右空闲 Span 合并
|
v
形成最终的连续页级 Span
|
v
注册到 free_span_map
|
v
挂入对应页数桶
综上,ReleaseSpanToPageCache 的核心作用可以概括为:
接收从
CentralCache归还的完全空闲Span,在获取PageCache全局锁后,清理其原有的小块管理状态并解除基数树映射;随后通过free_span_map查找左右相邻的空闲Span,在页数范围允许的情况下完成合并,回收不再使用的Span元数据对象;最后,将合并后的Span重新注册到空闲页号映射中,并挂入对应页数的桶。
通过相邻合并,可以将多个较小的连续空闲 Span 重新组合成更大的页级内存区域,减少外部碎片,并提高后续大页数内存申请成功复用现有空闲内存的可能性。
PageCache::NewSpan:页级内存的查找、拆分与系统补给
根据上文,当 CentralCache 中某个规格桶为空,或者该桶中虽然存在若干 Span,但所有 Span 的自由链表都已经为空时,现有内存便无法继续为 ThreadCache 提供空闲块。
此时,CentralCache 会根据当前固定规格计算所需的连续虚拟页数量,并调用:
cpp
Span* PageCache::NewSpan(size_t K);
向 PageCache 获取一个包含 K 个连续虚拟页的 Span。
NewSpan 获取内存时一共存在三种情况:
text
情况一:
K 页桶中存在空闲 Span
|
v
直接取出
情况二:
K 页桶为空,但后续更大的桶中存在空闲 Span
|
v
取出一个更大的 Span
|
v
拆分出 K 页
情况三:
所有页数桶都无法满足要求
|
v
向系统申请新的连续虚拟内存
|
v
再拆分出 K 页
接口实现如下:
cpp
Span* PageCache::NewSpan(size_t K)
{
assert(K>0 && K<=NPageNum);
std::lock_guard<std::mutex> guard(page_lock);
Span* ResultSpan = nullptr;
if(!Span_List[K-1].Empty())
{
ResultSpan = Span_List[K-1].PopFront();
UnregisterFreeSpan(ResultSpan);
ResultSpan->next = nullptr;
ResultSpan->prev = nullptr;
ResultSpan->usecount = 0;
ResultSpan->freelist = nullptr;
ResultSpan->object_size = 0;
}else{
for(size_t i = K; i < NPageNum; ++i)
{
if(!Span_List[i].Empty())
{
Span* BigSpan = Span_List[i].PopFront();
UnregisterFreeSpan(BigSpan);
ResultSpan = SplitSpan(BigSpan, K);
break;
}
}
if(ResultSpan == nullptr)
{
Span* BigSpan = Allocate();
if(BigSpan == nullptr)
{
return nullptr;
}
ResultSpan = SplitSpan(BigSpan, K);
}
}
RegisterSpan(ResultSpan);
return ResultSpan;
}
整个获取过程可以表示为:
text
CentralCache 请求 K 页 Span
|
v
校验 K 是否位于合法范围
|
v
获取 PageCache 全局锁
|
v
检查 K 页桶是否非空
| |
| 是 | 否
v v
直接取出 K 页 Span 扫描更大的页数桶
| |
| 是否找到可拆分 Span
| | |
| | 是 | 否
| v v
| 拆分 向系统申请内存
| |
| v
| 再进行拆分
|_______________________________|
|
v
注册到基数树
|
v
返回 K 页 Span
函数首先检查传入的页数是否合法:
cpp
assert(K > 0 && K <= NPageNum);
其中,K 表示本次需要获取的连续虚拟页数量。
当前 PageCache 的桶数组只管理 1~NPageNum 页的空闲 Span,因此必须满足:
text
1 <= K <= NPageNum
参数检查通过以后,获取 PageCache 的全局互斥锁:
cpp
std::lock_guard<std::mutex> guard(
page_lock);
NewSpan 的执行过程可能涉及:
text
从页数桶中摘除 Span
扫描多个页数桶
解除 free_span_map 映射
拆分较大的 Span
重新挂载拆分后的剩余 Span
申请新的页级内存
注册基数树映射
这些操作共同构成一次完整的页级分配过程,因此需要在同一把全局锁的保护下完成。
情况一:目标 K 页桶中存在空闲 Span
PageCache 按照 Span 所包含的页数划分桶,页数与桶下标之间的关系为:
text
桶下标 = 页数 - 1
因此,包含 K 页的空闲 Span 位于:
cpp
Span_List[K - 1];
函数首先判断目标桶是否为空:
cpp
if (!Span_List[K - 1].Empty())
如果目标桶不为空,说明已经存在一个页数恰好满足要求的空闲 Span,此时不需要再进行拆分,可以直接从链表头部取出:
cpp
ResultSpan =
Span_List[K - 1].PopFront();
当前 ResultSpan 原本位于 PageCache 的空闲桶中,因此其覆盖的所有页号都已经注册在:
cpp
free_span_map;
中。
现在,这个 Span 即将离开 PageCache,并被提供给 CentralCache,所以需要解除其原有的空闲页号映射:
cpp
UnregisterFreeSpan(ResultSpan);
其状态转换为:
text
PageCache 中的空闲 Span
|
v
从页数桶中摘除
|
v
解除 free_span_map 映射
|
v
准备交给 CentralCache
如果不解除原有映射,后续其他 Span 归还时,仍然可能通过 free_span_map 查询到这个已经被分配出去的 Span,并错误地将其视为空闲相邻区域进行合并。
从页数桶中取出以后,还需要清理该 Span 原有的链表关系和小块管理字段:
cpp
ResultSpan->next = nullptr;
ResultSpan->prev = nullptr;
ResultSpan->usecount = 0;
ResultSpan->freelist = nullptr;
ResultSpan->object_size = 0;
其中:
text
next、prev
|
v
清除原 PageCache 桶链表中的连接关系
usecount
|
v
当前尚未向 CentralCache 提供任何小块
freelist
|
v
当前还没有按照固定规格建立小块自由链表
object_size
|
v
当前尚未绑定具体的小块规格
这里保留:
cpp
ResultSpan->page_id;
ResultSpan->n;
因为这两个字段描述了该 Span 所关联连续虚拟内存的起始页号和页数,后续 CentralCache 仍然需要依靠它们还原起始地址并完成小块切分。
情况二:从更大的页数桶中寻找可拆分 Span
如果目标 K 页桶为空,就无法直接获得一个页数恰好满足要求的 Span。
此时,需要继续扫描后续更大的页数桶:
cpp
for (size_t i = K;
i < NPageNum;
++i)
这里从下标:
text
i = K
开始扫描。
由于下标 K - 1 对应的是 K 页桶,因此下标 K 实际对应:
text
K + 1 页桶
所以,循环扫描的页数范围为:
text
K + 1 页
K + 2 页
K + 3 页
...
NPageNum 页
函数会选择扫描过程中遇到的第一个非空桶:
cpp
if (!Span_List[i].Empty())
由于桶数组按照页数从小到大排列,因此这里找到的是当前能够满足要求的最小空闲 Span,可以尽量减少拆分后剩余的页数。
找到以后,从对应桶中取出一个较大的 Span:
cpp
Span* BigSpan =
Span_List[i].PopFront();
例如,本次需要:
text
K = 6 页
而扫描到的第一个非空桶中管理的是:
text
10 页 Span
那么:
text
BigSpan->n = 10
接下来需要从中拆分出一个 6 页的 Span。
BigSpan 此前同样位于 PageCache 的空闲桶中,其所有页号都登记在 free_span_map 中。
由于接下来要修改它的起始页号或页数,原有映射已经无法继续使用,因此必须在拆分前先解除:
cpp
UnregisterFreeSpan(BigSpan);
状态变化为:
text
较大的空闲 Span
|
v
从原页数桶中摘除
|
v
解除旧的 free_span_map 映射
|
v
进入拆分过程
随后调用:
cpp
ResultSpan =
SplitSpan(BigSpan, K);
将较大的 BigSpan 拆分为两部分。
而拆分后的剩余部分仍然处于完全空闲状态,需要继续由 PageCache 管理。
因此,SplitSpan 除了构造并返回 K 页的 ResultSpan 以外,还需要完成剩余 BigSpan 的处理:
text
修改 BigSpan 的 page_id 和 n
|
v
将剩余 BigSpan 注册到 free_span_map
|
v
挂入剩余页数对应的 PageCache 桶
例如,剩余部分包含 4 页,就需要挂入:
text
Span_List[3]
同时,为剩余部分覆盖的页号重新建立空闲映射:
text
106 → BigSpan
107 → BigSpan
108 → BigSpan
109 → BigSpan
这样,拆分后的剩余连续虚拟内存才不会脱离 PageCache 的管理。
找到并拆分出满足要求的 Span 后,当前扫描过程即可结束:
cpp
break;
情况三:向系统申请新的连续虚拟内存
如果目标桶为空,并且扫描完所有后续桶以后仍然没有获得结果:
cpp
if (ResultSpan == nullptr)
说明当前 PageCache 管理的所有空闲 Span 都无法满足本次 K 页申请。
此时,需要通过:
cpp
Span* BigSpan = Allocate();
向系统申请一段新的连续虚拟内存,并使用一个 Span 元数据对象描述这段区域。
整个过程可以表示为:
text
PageCache 所有桶均无法满足
|
v
调用 Allocate
|
v
向操作系统申请连续虚拟内存页
|
v
从 ObjectPool<Span> 获取元数据对象
|
v
初始化 BigSpan 的 page_id 和 n
如果系统内存申请失败:
cpp
if (BigSpan == nullptr)
{
return nullptr;
}
则当前接口无法继续提供所需内存,直接返回空指针。
申请成功以后,再调用:
cpp
ResultSpan =
SplitSpan(BigSpan, K);
从新申请的连续虚拟内存中切出本次需要的 K 页。
拆分出的 ResultSpan 将返回给 CentralCache,剩余部分则继续由 PageCache 管理,并由 SplitSpan 负责完成重新挂桶和空闲映射注册。
如果 BigSpan 本身的页数恰好等于 K,则不需要产生剩余部分。此时 SplitSpan 可以直接返回原来的 BigSpan,避免创建一个页数为 0 的无效 Span。
text
BigSpan->n == K
|
v
不需要真正拆分
|
v
直接返回 BigSpan
注册返回 Span 的基数树映射
无论 ResultSpan 来自哪条路径:
text
从目标 K 页桶直接取出
从更大的空闲 Span 中拆分
从系统新申请的内存中拆分
在返回给 CentralCache 之前,都需要执行:
cpp
RegisterSpan(ResultSpan);
将 ResultSpan 所覆盖的页号注册到基数树中。
因为这个 Span 接下来会被 CentralCache 按照某种固定规格切分成多个小块。
后续,当 ThreadCache 将小块归还给 CentralCache 时,需要根据小块地址计算所在页号,再通过基数树查询其所属的 Span:
text
小块地址
|
v
计算所在虚拟页号
|
v
查询基数树
|
v
找到 ResultSpan
因此,ResultSpan 离开 PageCache 时,需要完成如下状态转换:
text
PageCache 空闲状态
|
v
从页数桶中摘除
|
v
解除 free_span_map 映射
|
v
必要时进行拆分
|
v
注册到 page_radix_tree
|
v
交给 CentralCache
而拆分后的剩余 Span 则保持空闲状态:
text
拆分后的剩余 Span
|
v
注册到 free_span_map
|
v
挂回 PageCache 页数桶
二者进入了不同的管理阶段,因此分别维护在不同的映射结构中。
综上,NewSpan 的核心作用可以概括为:
根据
CentralCache请求的页数K,首先尝试从PageCache对应的页数桶中直接获取空闲Span;如果目标桶为空,则继续扫描更大的页数桶,并从较大的Span中拆分出所需的K页;如果所有空闲桶都无法满足要求,则向系统申请新的连续虚拟内存。最终,将返回的Span注册到基数树中,并交给CentralCache使用。
整个过程实现了:
text
优先复用相同页数的空闲 Span
|
v
其次拆分更大的空闲 Span
|
v
最后才向系统申请新内存
这种分配顺序能够尽可能复用 PageCache 中已有的空闲页级内存,减少直接向操作系统申请虚拟内存的次数。
PageRadixTree 的整体设计:页号映射、固定层级与无锁查询
根据上文,对于已经从 PageCache 提供给 CentralCache、并按照固定规格切分成小块的 Span,还需要维护一套页号到 Span 的映射关系。
当 ThreadCache 中某个桶缓存的空闲块数量超过上限,或者线程退出并将本地缓存统一归还时,CentralCache 会接收到一批规格相同的空闲块。
不过,这些空闲块虽然规格相同,却可能来自不同的 Span:
text
ThreadCache 某个规格桶:
来自 Span A 的空闲块
|
v
来自 Span B 的空闲块
|
v
来自 Span A 的空闲块
|
v
来自 Span C 的空闲块
因此,CentralCache 不能直接将整条链表挂入某一个 Span,而是需要根据每个空闲块的地址,找到其真正所属的 Span,再分别归还到对应的自由链表中。
整个查询过程可以表示为:
text
空闲块地址
|
v
计算所在的虚拟页号
|
v
根据页号查询映射结构
|
v
得到所属 Span*
这一映射关系可以通过哈希表实现,但在当前项目中选择使用基数树:
cpp
PageRadixTree page_radix_tree;
基数树同样是一种键值映射结构,特别适合键为整数、地址或页号的场景。
它会将整数键按照若干组二进制位进行划分,并使用每一组的值作为当前层数组的下标,从而逐层定位最终的数据。
与红黑树、AVL 树等比较树不同,基数树不需要根据键值大小进行左右比较,也不会因为插入和删除而进行旋转调整。它的查询路径和树高由键的位数以及每层处理的位数共同决定,因此具有固定层级的访问结构。
当前项目运行在 64 位平台上,指针和虚拟地址均使用 64 位整数表示。
不过,虚拟地址的低 12 位用于表示页内偏移,因为当前页大小为:
text
PageSize = 4096B = 2^12
因此:
text
虚拟地址:
┌──────────────────────────────┬──────────────┐
│ 虚拟页号 │ 12 位页内偏移 │
└──────────────────────────────┴──────────────┘
要根据一个小块地址查询其所属的 Span,首先需要将地址右移 12 位,去掉页内偏移:
cpp
PAGE_ID page_id =
reinterpret_cast<std::size_t>(obj)
>> PageShift;
这里得到的 page_id 就是当前地址所在的虚拟页号。
因此,基数树中真正建立的映射关系是:
text
虚拟页号 page_id
↓
Span*
而不是完整虚拟地址到 Span* 的映射。
对于一个包含多页的 Span,其覆盖的每一个页号都需要映射到同一个 Span*。
例如:
text
Span:
page_id = 100
n = 4
该 Span 覆盖的页号范围为:
text
[100, 104)
因此,需要建立:
text
100 → Span
101 → Span
102 → Span
103 → Span
这样,无论空闲块位于该 Span 中的哪一个虚拟页,都能够通过所在页号查询到同一个 Span。
用户地址位数与页号位数
虽然指针宽度为 64 位,但当前项目并不会直接使用完整的 64 位地址空间。
在本文采用的 x86-64 四级页表、低地址用户空间模型下,用户态虚拟地址可以使用低 47 位表示:
cpp
static constexpr std::size_t
UserAddressBits = 47;
其中低 12 位为页内偏移,因此虚拟页号真正需要处理的有效位数为:
cpp
static constexpr std::size_t
PageIdBits =
UserAddressBits - PageShift;
代入:
text
UserAddressBits = 47
PageShift = 12
得到:
text
PageIdBits
=
47 - 12
=
35
当前基数树每层使用 9 位作为索引:
cpp
static constexpr std::size_t
RadixBits = 9;
一组 9 位二进制数可以表示:
text
0~511
共计:
text
2^9 = 512
种取值,因此每个内部节点和叶子节点都维护长度为 512 的数组:
cpp
static constexpr std::size_t
RadixSize =
static_cast<std::size_t>(1)
<< RadixBits;
同时,可以使用:
cpp
static constexpr std::size_t
RadixMask = RadixSize - 1;
提取页号中某一组的低 9 位。
基数树所需高度为:
cpp
static constexpr std::size_t
RadixHeight =
(PageIdBits + RadixBits - 1)
/ RadixBits;
代入:
text
PageIdBits = 35
RadixBits = 9
得到:
text
RadixHeight
=
⌈35 / 9⌉
=
4
因此,需要将 35 位虚拟页号划分成四组。
不过:
text
4 × 9 = 36
所以最高一组实际只有 8 位,完整划分为:
text
35 位虚拟页号:
┌────────┬─────────┬─────────┬─────────┐
│ 8 位 │ 9 位 │ 9 位 │ 9 位 │
└────────┴─────────┴─────────┴─────────┘
对应的位区间可以表示为:
text
page_id[34:27] 8 位
page_id[26:18] 9 位
page_id[17:9] 9 位
page_id[8:0] 9 位
虽然最高一组实际只有 8 个有效位,但为了统一节点结构,根节点仍然使用长度为 512 的指针数组,只是实际最多使用前 256 个位置。
使用每组二进制位定位数组下标
基数树不会对键值进行大小比较,而是依次提取虚拟页号中的四组二进制位。
每一组都会被解释为一个整数,并作为当前层数组的下标。
例如,某一组 9 位二进制值为:
text
010101101
对应的十进制值为:
text
173
那么当前层就会访问:
cpp
children[173];
整个过程可以表示为:
text
提取页号当前层对应的一组位
|
v
转换为 0~511 的整数
|
v
作为当前节点数组的下标
|
v
访问下一层节点
对于 35 位页号,四组索引分别负责:
text
第一组
↓
定位根节点中的某个 children 元素
第二组
↓
定位第二层内部节点
第三组
↓
定位第三层内部节点
第四组
↓
定位叶子节点中的某个 data 元素
因此,当前基数树的节点层级为:
text
根内部节点
|
v
第二层内部节点
|
v
第三层内部节点
|
v
叶子节点
|
v
Span*
也就是说:
text
3 层内部节点
+
1 层叶子节点
RadixHeight = 4 表示虚拟页号被分成四组索引,而不是存在四层内部节点。
内部节点与叶子节点
基数树中的节点分为两种类型:
cpp
enum class NodeType
{
Internal,
Leaf
};
其中,内部节点负责保存下一层节点的地址,叶子节点负责保存最终的 Span*。
为了让两种节点能够通过统一的父类指针管理,这里先定义一个基础节点类型:
cpp
class RadixNode
{
public:
explicit RadixNode(NodeType node_type)
: type(node_type)
{
}
NodeType type;
};
type 用于标识当前节点究竟是内部节点还是叶子节点。
这在后续销毁整棵基数树时十分重要,因为内部节点和叶子节点来自不同的对象池,需要根据节点类型执行不同的回收操作。
内部节点
内部节点定义如下:
cpp
class RadixInternalNode
: public RadixNode
{
public:
RadixInternalNode();
std::atomic<RadixNode*>
children[RadixSize];
};
内部节点中包含一个长度为 512 的指针数组:
cpp
std::atomic<RadixNode*>
children[RadixSize];
数组中的每一个元素都指向下一层节点:
text
children[index]
|
v
下一层内部节点或叶子节点
内部节点构造时,需要将所有子指针初始化为空:
cpp
RadixInternalNode::RadixInternalNode()
: RadixNode(NodeType::Internal)
{
for (std::size_t i = 0;
i < RadixSize;
++i)
{
children[i].store(nullptr, std::memory_order_relaxed);
}
}
初始状态下,当前节点的所有路径都尚未创建:
text
children[0] = nullptr
children[1] = nullptr
...
children[511] = nullptr
叶子节点
叶子节点定义如下:
cpp
class RadixTreeLeafNode
: public RadixNode
{
public:
RadixTreeLeafNode();
std::atomic<Span*>
data[RadixSize];
};
叶子节点同样包含一个长度为 512 的数组,不过这里保存的不再是下一层节点,而是最终的 Span*:
text
data[index]
|
v
当前虚拟页号对应的 Span*
构造叶子节点时,也需要将所有数据槽位初始化为空:
cpp
RadixTreeLeafNode::RadixTreeLeafNode()
: RadixNode(NodeType::Leaf)
{
for (std::size_t i = 0;
i < RadixSize;
++i)
{
data[i].store(nullptr, std::memory_order_relaxed);
}
}
因此,内部节点和叶子节点的职责可以概括为:
text
RadixInternalNode
|
v
保存下一层节点地址
RadixTreeLeafNode
|
v
保存最终 Span*
基数树的懒创建
在基数树刚刚初始化时,并不会一次性创建完整的四层树结构。
如果直接创建所有可能节点,将会产生极其庞大的内存开销,因为虚拟地址空间中实际被内存池使用的页号通常只占很小一部分。
因此,基数树采用按需创建的方式。
初始状态下只创建一个根节点:
text
root
|
v
RadixInternalNode
根节点中的所有路径均为空:
text
root->children[i] == nullptr
当注册某个虚拟页号时,会根据页号对应的四组索引逐层向下访问。
如果发现当前路径尚未创建:
text
children[index] == nullptr
就创建对应的下一层节点:
text
当前路径不存在
|
v
申请新的内部节点或叶子节点
|
v
完成节点初始化
|
v
挂载到父节点的 children[index]
|
v
继续访问下一层
因此,只有真正使用到的页号路径才会创建节点,未使用的虚拟地址范围不会占用额外的基数树节点。
使用对象池申请树节点
基数树注册新的页号路径时,可能需要创建多个内部节点或者叶子节点。
如果每次都通过:
cpp
new RadixInternalNode;
new RadixTreeLeafNode;
进行申请,就会让高并发内存池内部再次频繁依赖普通动态内存分配器。
因此,这里分别为内部节点和叶子节点准备两个定长对象池:
cpp
ObjectPool<RadixInternalNode>
internal_node_pool;
ObjectPool<RadixTreeLeafNode>
leaf_node_pool;
之所以需要两个对象池,是因为内部节点和叶子节点的对象类型以及内存布局不同:
text
RadixInternalNode
|
v
保存 RadixNode* 数组
RadixTreeLeafNode
|
v
保存 Span* 数组
定长对象池中的每个槽位大小固定,因此不同类型的节点分别使用独立的对象池更加清晰。
节点申请过程可以表示为:
text
需要创建内部节点
|
v
从 internal_node_pool 获取
需要创建叶子节点
|
v
从 leaf_node_pool 获取
这样可以减少基数树路径扩展过程中频繁调用 new 和 delete 所带来的开销。
运行期间保持稳定的树结构
当前基数树的一个重要特点是:
节点一旦在运行期间被创建,就不会因为某个
Span解除映射而被删除。
当一个 Span 被 PageCache 分配给 CentralCache 时,会将该 Span 覆盖的所有页号注册到基数树中:
text
Span 页号范围
|
v
逐页执行映射注册
|
v
叶子节点 data[index] = span
当这个 Span 完全空闲并重新归还给 PageCache 时,需要解除原有映射。
不过,解除映射时并不会删除叶子节点,也不会向上回收内部节点,而只是将对应叶子槽位保存的指针清空:
text
leaf->data[index]
|
v
从 Span* 设置为 nullptr
也就是说:
text
节点路径继续保留
叶子槽位中的映射值被清空
因此,在程序运行期间:
text
节点一旦创建
|
v
不会因为映射解除而被回收
|
v
已经建立的树路径始终保持有效
只有在整个 PageRadixTree 对象析构时,才会通过:
cpp
DestroyRadixTree(root);
递归遍历并回收整棵树的所有内部节点和叶子节点。
因此,更准确地说:
基数树在运行期间不会因为单个
Span解除映射而删除路径节点,只有在整个基数树析构时才统一释放节点。
这种设计使基数树运行期间的结构保持稳定,避免读取线程访问到已经被其他线程释放的树节点。
为什么不直接使用哈希表
虚拟页号到 Span* 的映射同样可以使用:
cpp
std::unordered_map<PAGE_ID, Span*>
实现。
不过,普通哈希表在并发读写场景下需要额外的同步保护。
例如,一个线程可能正在通过页号查询所属 Span:
text
CentralCache 批量归还空闲块
|
v
查询页号映射
|
v
得到 Span*
与此同时,另一个线程可能正在通过 PageCache 注册或者解除某个 Span 的页号映射:
text
PageCache 分配或回收 Span
|
v
插入或删除映射条目
标准 unordered_map 不支持这种并发读写。
插入、删除以及扩容操作都可能改变哈希表内部的桶数组和链表结构,因此通常需要通过额外的互斥锁或者读写锁保护整张哈希表:
而当前页号映射的查询发生在 CentralCache 批量回收空闲块的路径中。
这条查询路径不会持有 PageCache 的全局锁,因为不会操作 PageCache 的页数桶,因此没有必要为了查询页号映射而额外获取 PageCache 的全局锁。
但如果使用普通哈希表,为了保证并发读写安全,就必须额外为哈希表加锁。
这样,ThreadCache 在归还空闲块并查询其所属 Span 时,就会重新承担访问共享结构所带来的锁开销,从而影响归还路径的执行效率。
而 ThreadCache 本身存在的重要目的,就是让线程优先访问本地缓存,减少对公共结构的访问以及由此产生的锁竞争。
如果在空闲块归还过程中,又因为页号映射查询而引入额外的锁,就会使这条原本用于回收本地缓存的路径重新受到锁竞争影响,增加归还过程的同步开销,并降低多线程场景下的执行效率。
基数树则具有以下特点:
text
树高固定
查询路径固定
运行期间不删除路径节点
不会发生整棵树扩容和桶迁移
节点指针与叶子槽位均使用原子变量
因此,在当前项目的并发访问模型下,基数树更加适合用来维护虚拟页号与 Span* 的映射关系。
基数树解决的是:
查询小块所属
Span时,不需要再额外获取PageCache全局锁或者单独的映射锁。
原子数组与无锁查询
内部节点中的子指针定义为:
cpp
std::atomic<RadixNode*>
children[RadixSize];
叶子节点中的映射值定义为:
cpp
std::atomic<Span*>
data[RadixSize];
因此,查询过程只需要按照页号的四组索引,执行固定次数的原子读取:
text
提取第一组索引
|
v
读取根节点 children[index]
提取第二组索引
|
v
读取第二层 children[index]
提取第三组索引
|
v
读取第三层 children[index]
提取第四组索引
|
v
读取叶子节点 data[index]
|
v
得到 Span*
因为运行期间已经创建的节点不会被回收,所以读取线程不需要担心:
text
刚读取到某个节点指针
|
v
节点立即被另一个线程释放
即使某个 Span 正在解除映射,也只会将最终叶子槽位设置为空,不会破坏整条访问路径。
因此,多个线程可以同时调用:
cpp
Span* GetSpan(PAGE_ID page_id) const;
完成无锁查询。
写操作仍然需要串行化
虽然读取过程可以不加锁,但基数树的写操作仍然不能直接并发执行。
注册一个新的页号时,可能需要完成:
text
检查当前路径是否存在
|
v
从对象池申请新节点
|
v
初始化新节点
|
v
将节点挂入父节点
|
v
继续访问下一层
|
v
设置最终 Span*
如果两个线程同时创建同一条路径,可能出现:
text
线程 A 发现 children[index] 为空
线程 B 也发现 children[index] 为空
线程 A 创建节点 A
线程 B 创建节点 B
两个线程同时尝试发布节点
由于基数树的注册和解除映射本来就发生在 PageCache 的页级状态变更过程中。
这些操作已经由:
cpp
std::mutex page_lock;
统一保护。
因此,基数树的写操作可以直接依赖 PageCache 全局锁完成串行化:
text
PageCache 获取全局锁
|
v
注册或解除基数树映射
|
v
必要时创建新的树节点
|
v
释放全局锁
不需要再为 PageRadixTree 单独维护一把写锁。
**这里的原子变量只能保证单次读取或写入操作本身是原子的,但注册映射时还会涉及路径检查、节点创建和节点挂载等多个连续步骤,无法仅靠原子变量保证整个过程的线程安全。**因此,基数树的写操作仍然需要在 PageCache 全局锁的保护下完成。
在发布新节点时,需要先完成节点初始化,再将节点指针写入父节点数组。
可以使用:
cpp
parent->children[index].store(
new_node,
std::memory_order_release);
查询时使用:
cpp
RadixNode* child =
parent->children[index].load(
std::memory_order_acquire);
这样可以保证读取线程观察到新节点指针时,该节点内部的数组已经完成初始化。
如果不显式指定内存序,std::atomic 默认使用顺序一致性,同样能够保证正确性,只是同步语义更强。
PageRadixTree 的整体类结构
根据上面的分析,PageRadixTree 的整体骨架如下:
cpp
class PageRadixTree
{
private:
enum class NodeType
{
Internal,
Leaf
};
using PAGE_ID = std::size_t;
static constexpr std::size_t
UserAddressBits = 47;
static constexpr std::size_t
PageIdBits =
UserAddressBits - PageShift;
static constexpr std::size_t
RadixBits = 9;
static constexpr std::size_t
RadixHeight =
(PageIdBits + RadixBits - 1)
/ RadixBits;
static constexpr std::size_t
RadixSize =
static_cast<std::size_t>(1)
<< RadixBits;
static constexpr std::size_t
RadixMask = RadixSize - 1;
static constexpr std::size_t
PoolSize = 32;
private:
class RadixNode
{
public:
explicit RadixNode(
NodeType node_type)
: type(node_type)
{
}
NodeType type;
};
class RadixTreeLeafNode
: public RadixNode
{
public:
RadixTreeLeafNode();
std::atomic<Span*>
data[RadixSize];
};
class RadixInternalNode
: public RadixNode
{
public:
RadixInternalNode();
std::atomic<RadixNode*>
children[RadixSize];
};
void DestroyRadixTree(
RadixNode* node);
using Node = RadixInternalNode;
private:
ObjectPool<RadixInternalNode>
internal_node_pool;
ObjectPool<RadixTreeLeafNode>
leaf_node_pool;
Node* root;
public:
PageRadixTree();
~PageRadixTree();
PageRadixTree(
const PageRadixTree&) = delete;
PageRadixTree& operator=(
const PageRadixTree&) = delete;
PageRadixTree(
PageRadixTree&&) = delete;
PageRadixTree& operator=(
PageRadixTree&&) = delete;
public:
Span* GetSpan(
PAGE_ID page_id) const;
void SetSpan(
PAGE_ID start_page_id,
std::size_t page_count,
Span* span);
void ClearSpan(
PAGE_ID start_page_id,
std::size_t page_count);
private:
void SetOnePage(
PAGE_ID page_id,
Span* span);
void ClearOnePage(
PAGE_ID page_id);
};
其中,对外主要提供三个接口。
cpp
Span* GetSpan(
PAGE_ID page_id) const;
用于根据单个虚拟页号查询其所属的 Span。
cpp
void SetSpan(
PAGE_ID start_page_id,
std::size_t page_count,
Span* span);
用于为一个 Span 覆盖的所有虚拟页建立映射。
内部可以循环调用:
cpp
SetOnePage(page_id, span);
逐页完成注册。
cpp
void ClearSpan(
PAGE_ID start_page_id,
std::size_t page_count);
用于解除一个 Span 覆盖范围内的所有页号映射。
内部可以逐页调用:
cpp
ClearOnePage(page_id);
将对应叶子槽位设置为空。
这里不会删除树节点,只会清空最终保存的 Span*。
cpp
void DestroyRadixTree(
RadixNode* node);
用于在 PageRadixTree 析构时递归释放整棵树。
该接口会根据:
cpp
node->type
判断当前节点是内部节点还是叶子节点,再将其归还到对应的对象池中。
至此,PageRadixTree 的整体结构便搭建完成。
其核心设计可以概括为:
text
虚拟页号作为整数键
|
v
35 位页号划分为 8 / 9 / 9 / 9 四组
|
v
三层内部节点 + 一层叶子节点
|
v
固定四组索引完成 Span 查询
|
v
运行期间不删除路径节点
|
v
原子数组支持无锁读取
|
v
写操作由 PageCache 全局锁保护
通过这种设计,可以在不额外获取 PageCache 全局锁或映射锁的情况下,根据虚拟页号快速查询所属的 Span,同时避免普通哈希表并发读写、扩容和结构变化带来的同步问题。
ConcurrentNew 与 ConcurrentDelete:统一对象申请与释放接口
完成 ThreadCache、CentralCache 和 PageCache 三级缓存以后,整个高并发内存池已经具备了底层内存申请与回收能力。
不过,前面实现的接口主要面向内存池内部,处理的都是未经构造的原始内存。为了方便外部程序使用,还需要在三级缓存之上提供一组统一的对象申请与释放接口。
为了对应 C++ 中的:
cpp
new
delete
这里实现两个模板接口:
cpp
ConcurrentNew
ConcurrentDelete
其基本职责可以表示为:
text
ConcurrentNew
|
+-- 从内存池申请原始内存
|
+-- 在原始内存上构造对象
|
+-- 返回完成构造的对象指针
ConcurrentDelete
|
+-- 调用对象析构函数
|
+-- 将原始内存归还到对应层级
整体类结构如下:
cpp
class Concurrent_Memory_Pool
{
public:
template <typename T, typename... Args>
static T* ConcurrentNew(Args&&... args)
{
void* ptr = ConcurrentAllocate(sizeof(T));
if (ptr == nullptr)
{
throw std::bad_alloc();
}
try
{
return ::new (ptr)
T(std::forward<Args>(args)...);
}
catch (...)
{
ConcurrentDeallocate(
ptr,
sizeof(T));
throw;
}
}
template <typename T>
static void ConcurrentDelete(T* ptr)
{
if (ptr == nullptr)
{
return;
}
ptr->~T();
ConcurrentDeallocate(
ptr,
sizeof(T));
}
private:
static void* ConcurrentAllocate(
std::size_t size);
static void ConcurrentDeallocate(
void* ptr,
std::size_t bytes);
};
ConcurrentNew:申请原始内存并构造对象
ConcurrentNew 是一个函数模板:
cpp
template <typename T, typename... Args>
static T* ConcurrentNew(Args&&... args);
其中:
text
T
|
v
需要创建的对象类型
Args...
|
v
传递给 T 构造函数的参数类型
例如:
cpp
int* p1 =
Concurrent_Memory_Pool::
ConcurrentNew<int>(10);
会申请一块能够容纳 int 的内存,并在该内存上构造一个值为 10 的 int 对象。
对于自定义类型:
cpp
class Test
{
public:
Test(int value, std::string name);
};
可以调用:
cpp
Test* p2 =
Concurrent_Memory_Pool::
ConcurrentNew<Test>(
10,
"memory pool");
可变参数模板使 ConcurrentNew 能够适配不同数量、不同类型的构造参数。
函数首先根据对象类型得到所需字节数:
cpp
sizeof(T)
随后调用:
cpp
void* ptr =
ConcurrentAllocate(sizeof(T));
申请一段尚未构造对象的原始内存。
整个过程可以表示为:
text
对象类型 T
|
v
计算 sizeof(T)
|
v
ConcurrentAllocate
|
v
获得原始内存地址
ConcurrentAllocate 的三级分流
ConcurrentAllocate 会根据本次申请的字节数,选择不同的内存申请路径。
当前内存池将申请划分为三类:
text
小块:
size <= 256KB
中块:
256KB < size <= 512KB
大块:
size > 512KB
完整分流关系为:
text
ConcurrentAllocate(size)
|
v
判断申请字节数
|
+-- size <= 256KB
| |
| v
| ThreadCache
| |
| v
| CentralCache
| |
| v
| PageCache
|
+-- 256KB < size <= 512KB
| |
| v
| 直接访问 PageCache
|
+-- size > 512KB
|
v
直接访问系统
小块申请
当:
text
size <= MaxBytes
也就是申请大小不超过 256KB 时,走三级缓存的常规小块申请路径:
text
当前线程的 ThreadCache
|
+-- 有空闲块:直接返回
|
+-- 没有空闲块
|
v
CentralCache
|
v
PageCache
这种路径优先访问线程私有的 ThreadCache,在本地桶存在空闲块时不需要访问公共结构,也不需要加锁。
中块申请
当:
text
MaxBytes < size <= MaxPageBytes
也就是申请大小位于 256KB~512KB 之间时,不再按照小块规格进行切分,而是直接向 PageCache 获取一段连续虚拟内存页。
首先将字节数换算成所需页数:
cpp
std::size_t page_count =
(size + PageSize - 1)
/ PageSize;
随后调用:
cpp
Span* span =
PageCache::GetInstance()
.NewSpan(page_count);
获取一个包含对应页数的 Span。
得到 Span 后,可以根据起始页号还原实际虚拟地址:
cpp
void* ptr =
reinterpret_cast<void*>(
span->page_id << PageShift);
中块内存不会再被切分成多个小块,而是整个 Span 直接用于满足当前申请:
text
一个中块对象
|
v
占用一个完整 Span
|
v
Span 包含若干连续虚拟页
由于该 Span 通过 PageCache::NewSpan 获取,其覆盖的页号会被注册到基数树中,因此后续释放时可以根据对象地址查询到对应的 Span。
大块申请
当:
text
size > MaxPageBytes
也就是申请大小超过 512KB 时,不再进入 PageCache 的桶数组,而是直接通过系统接口申请内存:
text
ConcurrentAllocate
|
v
SystemAllocate
|
v
mmap
同样需要先将字节数向上换算为完整页数:
cpp
std::size_t page_count =
(size + PageSize - 1)
/ PageSize;
再向操作系统申请对应数量的连续虚拟内存页。
因此,三级申请路径可以概括为:
text
小块
|
v
优先使用线程私有缓存
中块
|
v
直接从 PageCache 获取 Span
大块
|
v
直接通过 mmap 向系统申请
使用 placement new 构造对象
ConcurrentAllocate 返回的只是一段原始内存,此时对象的生命周期还没有开始。
因此,还需要通过 placement new 在这段内存上构造对象:
cpp
return ::new (ptr)
T(std::forward<Args>(args)...);
这里的:
cpp
::new (ptr)
表示不再重新申请内存,而是直接在 ptr 指向的已有内存上调用 T 的构造函数。
text
ConcurrentAllocate
|
v
获得原始内存 ptr
|
v
placement new
|
v
在 ptr 上构造 T 对象
|
v
返回 T*
std::forward 用于对构造参数进行完美转发:
cpp
std::forward<Args>(args)...
这样可以保留参数原有的左值或右值属性,并将其正确传递给 T 的构造函数。
构造异常时归还原始内存
原始内存申请成功,并不意味着对象一定能够构造成功。
如果 T 的构造函数抛出异常:
cpp
::new (ptr)
T(std::forward<Args>(args)...);
此时:
text
原始内存已经申请成功
但 T 对象没有完成构造
如果直接将异常向外抛出,已经申请的内存就会发生泄漏。
因此,构造过程放在 try 语句中:
cpp
try
{
return ::new (ptr)
T(std::forward<Args>(args)...);
}
catch (...)
{
ConcurrentDeallocate(
ptr,
sizeof(T));
throw;
}
当构造函数抛出异常时,先调用:
cpp
ConcurrentDeallocate(
ptr,
sizeof(T));
将原始内存归还到对应层级,再通过:
cpp
throw;
继续向上传播原异常。
需要注意,这里不能调用对象析构函数,因为对象并没有成功完成构造。
整个异常处理过程为:
text
原始内存申请成功
|
v
调用 T 的构造函数
|
+-- 构造成功
| |
| v
| 返回 T*
|
+-- 构造失败
|
v
归还原始内存
|
v
重新抛出异常
这样可以保证 ConcurrentNew 在对象构造失败时不会造成内存泄漏。
ConcurrentDelete:析构对象并归还内存
与 ConcurrentNew 对应,ConcurrentDelete 用于销毁对象并释放底层内存:
cpp
template <typename T>
static void ConcurrentDelete(T* ptr)
{
if (ptr == nullptr)
{
return;
}
ptr->~T();
ConcurrentDeallocate(
ptr,
sizeof(T));
}
函数首先处理空指针:
cpp
if (ptr == nullptr)
{
return;
}
这与标准 delete 对空指针的处理语义保持一致。
对于有效对象,需要先显式调用析构函数:
cpp
ptr->~T();
因为该对象是通过 placement new 构造的,不能直接依赖普通 delete 完成析构和内存释放。
对象析构完成以后,再调用:
cpp
ConcurrentDeallocate(
ptr,
sizeof(T));
将底层原始内存归还。
整个顺序必须是:
text
先调用析构函数
|
v
结束对象生命周期
|
v
再释放底层内存
如果先将内存归还给内存池,再调用析构函数,就可能访问已经被重新分配或失效的内存。
ConcurrentDeallocate 的三级释放路径
ConcurrentDeallocate 根据释放字节数,选择与申请过程对应的释放方式:
cpp
void Concurrent_Memory_Pool::ConcurrentDeallocate(
void* ptr,
std::size_t bytes)
{
if(ptr == nullptr)
{
return;
}
// 小块
if(bytes <= MaxBytes)
{
ThreadCache::GetThreadCache()
.Deallocate(ptr, bytes);
return;
}
// 中块:直接归还PageCache
if(bytes <= MaxPageBytes)
{
Span* span =PageCache::GetInstance().MapObjectToSpan(ptr);
assert(span != nullptr);
std::size_t page_count =
(bytes + PageSize - 1) / PageSize;
assert(span->n == page_count);
assert(reinterpret_cast<std::size_t>(ptr) == (span->page_id << PageShift));
PageCache::GetInstance().ReleaseSpanToPageCache(span);
return;
}
// 大块:直接munmap
std::size_t page_count = (bytes + PageSize - 1) / PageSize;
PageCache::GetInstance().SystemDeallocate(ptr, page_count);
}
小块释放:归还 ThreadCache
当:
text
bytes <= MaxBytes
时,当前内存属于小块申请路径,因此直接归还到当前线程的 ThreadCache:
cpp
ThreadCache::GetThreadCache()
.Deallocate(ptr, bytes);
text
小块对象析构
|
v
归还当前线程 ThreadCache
|
v
插入对应规格的自由链表
这里不需要根据地址查询基数树。
因为 ConcurrentDelete<T> 已经通过:
cpp
sizeof(T)
知道当前内存块的原始申请大小,可以直接通过规格映射定位 ThreadCache 对应的桶。
只有当该桶缓存的空闲块数量超过上限,需要向 CentralCache 批量归还时,才会根据每个空闲块的地址查询其所属 Span。
中块释放:将整个 Span 归还 PageCache
当:
text
MaxBytes < bytes <= MaxPageBytes
时,当前内存属于中块申请路径。
中块申请时没有将 Span 切分成多个小块,而是由当前对象独占整个 Span。因此,释放时需要根据对象地址查询对应的 Span,再将整个 Span 归还给 PageCache。
首先调用:
cpp
Span* span =
PageCache::GetInstance()
.MapObjectToSpan(ptr);
根据对象地址计算其所在页号,并通过基数树查询对应的 Span。
随后检查:
cpp
assert(span != nullptr);
保证该地址确实能够映射到有效的 Span。
接着,根据释放大小重新计算页数:
cpp
std::size_t page_count =
(bytes + PageSize - 1)
/ PageSize;
并检查:
cpp
assert(span->n == page_count);
保证查询得到的 Span 页数与申请大小一致。
检查通过以后,调用:
cpp
PageCache::GetInstance()
.ReleaseSpanToPageCache(span);
将整个 Span 归还给 PageCache。
随后,PageCache 会解除该 Span 在基数树中的映射,尝试与前后相邻的空闲 Span 合并,并将最终结果挂入对应页数的桶。
text
中块对象析构
|
v
根据地址查询 Span
|
v
整个 Span 归还 PageCache
|
v
尝试相邻合并
|
v
重新进入页数桶
大块释放:直接调用系统接口
当:
text
bytes > MaxPageBytes
时,当前内存属于大块申请路径。
大块内存没有进入 PageCache 的桶数组,也没有使用 Span 参与缓存管理,而是在申请时直接通过系统调用获取。
因此,释放时同样直接将字节数换算为页数:
cpp
std::size_t page_count =
(bytes + PageSize - 1)
/ PageSize;
然后调用:
cpp
PageCache::GetInstance()
.SystemDeallocate(
ptr,
page_count);
最终通过:
text
munmap
解除对应的虚拟内存映射。
text
大块对象析构
|
v
计算实际页数
|
v
SystemDeallocate
|
v
munmap
综上,这组对外接口可以概括为:
text
ConcurrentNew
|
+-- 根据 sizeof(T) 选择申请路径
|
+-- 获取原始内存
|
+-- placement new 构造对象
|
+-- 构造失败时自动归还内存
ConcurrentDelete
|
+-- 显式调用对象析构函数
|
+-- 根据 sizeof(T) 选择释放路径
|
+-- 小块归还 ThreadCache
|
+-- 中块归还 PageCache
|
+-- 大块直接 munmap
至此,三级缓存的底层内存管理能力就通过 ConcurrentNew 和 ConcurrentDelete 封装成了可以直接创建和销毁 C++ 对象的统一接口。
高并发内存池申请以及释放函数调用链

源码

结语
那么这就是本篇文章的全部内容,我会持续更新,希望你能够多多关注,如果本文有帮助到你的话,还请三连加关注,你的支持就是我创作的最大动力!
