Skip to content

bugkeep/-tcmalloc-

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

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

它提供两类操作:

  • 单对象操作:pushpop
  • 批量操作:PushRangePopRange

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

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

No releases published

Packages

 
 
 

Contributors