Skip to content

Repository files navigation

仿 tcmalloc 的三级缓存内存池

这是一个参考 tcmalloc 分层思想实现的并发内存池项目,核心目标是降低多线程场景下频繁申请/释放小块内存时的锁竞争与系统调用开销。

当前版本重点覆盖 256KB 以内的小对象分配,整体采用 ThreadCache -> CentralCache -> PageCache 的三级缓存架构:线程先尽量在本地拿内存,本地不够再向中心缓存批量申请,中心缓存不够再向页缓存拿页,页缓存没有合适页块时再向系统申请大块内存。

项目特点

  • 参考 tcmalloc 的三级缓存思路,结构清晰,职责分层明确
  • 线程本地缓存减少锁竞争,适合高频小对象分配
  • 中心缓存按大小分类管理 Span,支持批量搬运对象
  • 页缓存按页管理大块内存,支持切分与相邻 Span 合并
  • 使用对象内存本身维护自由链表,额外元数据开销小
  • 提供基础测试代码,便于直接运行验证

整体架构

项目的核心不是“单纯存一堆空闲内存”,而是把不同粒度的内存管理拆成三层:

  • ThreadCache:线程私有的小对象缓存,优先命中本地自由链表,速度最快
  • CentralCache:所有线程共享的中间层,负责把一个 Span 切好的对象批量发给线程,也负责接收线程批量归还的对象
  • PageCache:页级别的大块内存管理层,负责按页分配、拆分、合并 Span
flowchart TD
    A["业务线程"] --> B["ConcurrentAlloc / ConcurrentFree"]
    B --> C["ThreadCache<br/>线程私有自由链表"]
    C -->|本地命中| D["直接返回对象"]
    C -->|本地不足或本地过长| E["CentralCache<br/>按大小分类的 SpanList"]
    E -->|Span 中还有对象| C
    E -->|没有可用 Span| F["PageCache<br/>按页管理 Span"]
    F -->|拆分现有大 Span| E
    F -->|没有合适页块| G["SystemAlloc<br/>VirtualAlloc"]
Loading

这套结构的核心收益有两个:

  • 大多数小对象请求都在 ThreadCache 内完成,不需要加锁
  • 即使需要共享层,也尽量通过“批量取、批量还”来摊薄锁成本

内存池的核心设计

1. 大小对齐与桶映射

项目不是把每个字节大小都单独建一个链表,而是先做“对齐”,再把同一类大小映射到同一个桶中。

SizeClass::Roundup 的实现思路是按区间采用不同对齐粒度:

对象大小区间 对齐粒度
1 ~ 128 字节 8 字节对齐
129 ~ 1024 字节 16 字节对齐
1025 ~ 8KB 128 字节对齐
8KB ~ 64KB 1024 字节对齐
64KB ~ 256KB 8192 字节对齐

这样做的好处是:

  • 小对象分得更细,内部碎片更少
  • 大对象分得更粗,桶数量不会爆炸
  • 同一对齐后的大小一定进入同一个桶,后续管理简单稳定

SizeClass::index 则负责把“对齐后的大小”映射到具体下标。整个项目一共准备了 208 个自由链表桶,用来覆盖 256KB 以内的小对象范围。

2. 三级缓存分工

ThreadCache 管对象,CentralCache 管批量流转,PageCache 管页和 Span。三层关注点不同,因此实现上既能保持速度,也能控制碎片。

3. Span 作为中间抽象

项目里真正把三层串起来的关键结构是 Span。它表示“一段连续页组成的大块内存”,主要记录:

  • 起始页号 _pageId
  • 页数量 _n
  • 当前小块自由链表 _freelist
  • 已分配给线程缓存的对象数 _usecount
  • 当前 Span 是否正在被 CentralCache 使用的标记 _isUse

有了 Span,页缓存可以按页切大块,中心缓存可以按对象粒度继续切小块,线程缓存则只关心拿到的对象链表。

分配与回收流程

分配流程

  1. 用户调用 ConcurrentAlloc(size)
  2. 进入当前线程的 ThreadCache
  3. 对请求大小执行 Roundup
  4. 用 index 找到对应桶
  5. 如果线程本地自由链表非空,直接返回
  6. 如果本地为空,则从 CentralCache 批量申请一段对象
  7. 如果中心缓存里也没有可用对象,就向 PageCache 要一个合适的 Span
  8. 如果页缓存没有合适页块,则调用 VirtualAlloc 申请更大的连续页,再切分后使用
sequenceDiagram
    participant T as 线程
    participant TC as ThreadCache
    participant CC as CentralCache
    participant PC as PageCache
    participant OS as 系统

    T->>TC: allocate(size)
    TC->>TC: Roundup + index
    alt 本地链表非空
        TC-->>T: 直接返回对象
    else 本地链表为空
        TC->>CC: FetchRangeObj(batch)
        alt 中心缓存有可用对象
            CC-->>TC: 返回一段对象链
        else 中心缓存无可用 Span
            CC->>PC: newSpan(k)
            alt 页缓存有合适 Span
                PC-->>CC: 返回 Span
            else 页缓存无可用页块
                PC->>OS: VirtualAlloc
                OS-->>PC: 连续页内存
                PC-->>CC: 切分后的 Span
            end
            CC-->>TC: 返回一段对象链
        end
        TC-->>T: 返回首对象,剩余对象挂回本地链表
    end
Loading

回收流程

  1. 用户调用 ConcurrentFree(ptr, size)
  2. 对应对象先回收到当前线程的 ThreadCache
  3. 如果本地链表过长,就批量归还给 CentralCache
  4. CentralCache 通过地址反查对象属于哪个 Span
  5. 当某个 Span 的 _usecount 降为 0,说明这个 Span 对应的小块都回来了
  6. 该 Span 退回 PageCache
  7. PageCache 尝试与左右相邻空闲 Span 合并,减轻外部碎片

关键数据结构

Freelist

这是最底层的小对象自由链表,实现上直接把对象内存的前几个字节当作“下一个节点指针”来用,所以不需要额外节点结构。

它提供两类操作:

  • 单对象操作:push、pop
  • 批量操作:PushRange、PopRange

批量接口很重要,因为线程缓存和中心缓存之间不是一个对象一个对象搬,而是一段链表整体搬运,这样能明显减少链表操作次数。

SpanList

SpanList 是一个带哨兵位的双向循环链表,用来挂接同一类 Span。它的特点是:

  • 头插、删除都是常数时间
  • 带头结点后,边界情况少,逻辑更稳
  • 非常适合做桶内 Span 管理

_idSpanMap

这是页缓存里非常关键的一张映射表,用页号快速定位对象属于哪个 Span。

它承担两个任务:

  • 对正在使用中的 Span,支持对象地址反查所属 Span
  • 对空闲 Span,保留边界页映射,方便后续向左、向右做合并

这也是整个回收路径能闭环的关键。

关键函数说明

下面不按代码逐行解释,而是按“函数在架构中的职责”来说明。

ThreadCache 相关

  • ConcurrentAlloc 对外分配入口。先确保当前线程有自己的 ThreadCache,然后把请求交给线程本地缓存处理。

  • ConcurrentFree 对外释放入口。当前实现要求调用者传入正确的对象大小,释放时同样走线程本地缓存。

  • ThreadCache::allocate 小对象分配主流程。先对齐,再算桶下标,本地有就直接取;没有就进入共享层拿一批回来。

  • ThreadCache::deallocate 小对象释放主流程。对象先回收到本地自由链表,本地链表达到阈值后再批量吐回中心缓存,避免共享层频繁加锁。

  • ThreadCache::FetchFromCentralCache 线程缓存向中心缓存取对象的关键函数。它一次不是只拿一个,而是按 batchNum 批量获取,自己留一个返回给用户,剩余对象挂回本地链表。这样后续相同大小请求大概率都能在本地命中。

  • ThreadCache::ListTooLong 当某个桶的本地链表过长时,把一整段对象批量归还给 CentralCache,防止线程私有缓存无限膨胀。

SizeClass 相关

  • SizeClass::Roundup 核心作用是做分段对齐。它不是统一按固定粒度对齐,而是“小对象细对齐、大对象粗对齐”,这样在碎片和桶数量之间做了平衡。

  • SizeClass::index 核心作用是把“对齐后的大小”映射到唯一桶编号。这样线程缓存和中心缓存都能按同一套规则找到自己的链表位置。

  • SizeClass::NumMoveSize 用来决定一次从中心缓存搬多少个对象。对象越小,单次搬运数量越大;对象越大,单次搬运数量越小。这是典型的批量摊销思路。

CentralCache 相关

  • CentralCache::FetchRangeObj 中心缓存给线程缓存发对象的主函数。它从某个大小类对应的 SpanList 中找到可用 Span,摘下一段对象链表返回,并增加该 Span 的 _usecount。

  • CentralCache::GetOneSpan 当当前大小类下没有可分配对象时,这个函数负责向下层 PageCache 要一个新的 Span,再把这个 Span 按固定对象大小切成自由链表,挂回对应桶。

  • CentralCache::ReleaseListToSpans 线程缓存归还对象时,中心缓存通过地址找到对象所属 Span,把对象重新挂回 Span 的小块链表。当 _usecount 归零时,说明这个 Span 已经完全空闲,可以继续交回页缓存。

PageCache 相关

  • PageCache::NumMovePage 根据对象大小和单次批量搬运数量,算出向页缓存申请多少页更合适。它本质上是在“批量分配效率”和“页级浪费”之间取平衡。

  • PageCache::newSpan 页缓存的主分配函数。优先看当前页数桶里有没有现成 Span,没有就向更大的桶借,再切出目标页数;如果还是没有,就直接向系统申请一大块连续页。

  • PageCache::_newSpan 这个函数在当前版本里声明了但没有实际使用,说明后续还能继续重构或扩展页分配逻辑。

  • PageCache::MapObjectToSpan 根据对象地址快速定位所属 Span。这是释放路径必须依赖的能力,否则中心缓存无法知道对象应该回到哪一段页内存。

  • PageCache::ReleaseSpanToPageCache 页缓存回收 Span 的关键函数。它会尝试向左、向右查找相邻空闲 Span 并合并,减少外部碎片。这也是页级管理层最重要的价值之一。

目录结构

memory_pool_improvement
├─ ConcurrentAlloc.h       对外分配与释放接口
├─ ThreadCache.h
├─ ThreadCache.cpp         线程级缓存与大小分类
├─ CentralCache.h
├─ CentralCache.cpp        中心缓存与 Span 中转
├─ PageCache.h
├─ PageCache.cpp           页缓存、页切分与合并
├─ main.cpp                基础测试代码
└─ CMakeLists.txt          构建脚本

编译与运行

当前实现使用了 Windows 下的 VirtualAlloc,因此更适合在 Windows 环境下直接编译运行。

使用 CMake 构建

cmake -S . -B build
cmake --build build --config Debug

运行测试

./build/Debug/memory_pool_improvement.exe

main.cpp 目前包含几类基础测试:

  • 基本申请与释放
  • 1000 次相同大小对象申请与释放
  • 多种大小混合申请与释放
  • 简单压力测试

图文说明

图 1:三级缓存协作关系

上图展示的是整个内存池的职责划分。可以把它理解成“本地快速通道 + 共享中转层 + 底层页管理层”。

  • 线程访问优先走本地,追求速度
  • 本地不够时走共享层,追求批量周转
  • 共享层不够时走页层,追求大块管理和合并能力

图 2:一次完整分配路径

第二张图展示的是一次对象申请如何逐层下探,又如何把多余对象留在本地缓存中,以提高下一次命中率。

如果把这个项目和普通 new/delete 的频繁系统调用相比,最大的区别就在于:

  • 不把每次请求都下沉到系统层
  • 不把每次释放都立即归还给系统
  • 通过缓存和批量搬运,把频繁的小操作收敛成较少的大操作

当前实现边界

为了让项目定位更清楚,下面这些点直接说明:

  • 当前主要覆盖 256KB 以内的小对象分配
  • ConcurrentFree 需要由调用者传入正确的对象大小
  • 系统申请部分当前基于 Windows 的 VirtualAlloc
  • 目前已经有基础测试,但还没有加入更系统的多线程性能对比测试
  • 代码里部分注释存在编码问题,不影响逻辑,但后续可以统一整理

总结

这个项目最有价值的地方,不只是“实现了一个能跑的内存池”,而是把高频内存分配问题拆成了三层,并且通过对齐、桶映射、批量搬运、页切分和页合并,把性能和空间管理串成了一条完整链路。

如果继续往下扩展,这个项目还可以继续补:

  • 大对象直通系统分配路径
  • 更完整的多线程压测
  • 统计信息与监控接口
  • 更细致的回收策略与慢启动阈值调优

作为一个仿 tcmalloc 思路的练手项目,这一版已经把核心骨架搭起来了,而且主线非常完整。

Releases

Packages

Contributors

Languages