Skip to content

Latest commit

 

History

History
293 lines (193 loc) · 13.8 KB

File metadata and controls

293 lines (193 loc) · 13.8 KB

🛠️ 项目架构复盘:

1. 核心架构设计理念

该内存池采用了 三层分级架构,目的是最大限度地减少锁竞争(Lock Contention)并提高缓存局部性(Cache Locality)。

层级 组件名称 存储位置 并发控制 速度 职责
L1 Thread Local Cache (TLFL) 线程栈/TLS 无锁 (Lock-Free) 极快 处理 95%+ 的分配/释放请求
L2 Page (内存页) 堆 (Heap) 细粒度互斥锁 (Page Mutex) 为 L1 提供批量填充/回收
L3 Pool Manager (池管理) 堆 (Heap) 全局大锁 (Big Mutex) 较慢 管理 Page 集合、扩容、销毁

2. 静态数据结构分析

2.1. 内存块 (Node)

codeC++

struct Node { Node* next; };
  • 嵌入式链表:直接利用空闲内存块的前 8 字节(64位系统)存储下一个块的地址。不占用额外的管理内存(除了正在使用时)。

2.2. 内存页 (Page) —— 细粒度锁的核心

codeC++

struct alignas(64) Page {
    std::mutex mtx;           // 【关键】每页一把锁,实现并发隔离
    Node* free_list;          // 页内的空闲链表头
    char* base;               // 页的起始物理地址(用于计算块归属)
    size_t inuse;             // 引用计数
    // ...
};
  • alignas(64):防止 伪共享 (False Sharing)。确保每个 Page 对象的元数据都在独立的缓存行(Cache Line)中,避免多线程操作不同 Page 时导致 CPU 缓存失效。
  • inuse 计数:用于判断页是否全空,从而触发惰性回收(Decommission)。

2.3. 主类 (FixedSizeMemory)

  • vector<shared_ptr> pages_
    • 使用 shared_ptr 是为了生命周期安全。当一个线程正在操作某个 Page 时,即使主线程触发了扩容或缩容导致 vector 重新分配,该 Page 的内存也不会被错误释放。
  • thread_local 变量 (在 cpp 中)
    • thread_local_free_list:线程私有的空闲链表。
    • tl_janitor:线程生命周期看门人,负责线程退出时的资源回收。

3. 高并发运行逻辑与工作流

这是本项目的精华部分,解决了从“能用”到“高性能”的关键问题。

3.1. 内存分配流程 (allocate)

这是一个 “快路径优先,慢路径兜底” 的策略:

  1. L1 极速路径 (TLFL)
    • 检查当前线程的 thread_local_free_list 是否有空闲块。
    • 如果有:直接弹出(Pop),调整 thread_local_count。全程无锁,仅涉及几个指针操作,纳秒级响应。
    • 如果无:进入 L2 慢路径。
  2. L2 填充路径 (refill_local_list)
    • 策略:一次性从全局 Page 拿走一批块(例如 16 个),而不是 1 个。这叫 “批量移动 (Batching)”,极大地摊薄了锁的开销。
    • 查找逻辑
      1. 持有全局锁 big_mtx_ 快速遍历 pages_。
      2. 使用 try_lock() 尝试锁定 Page。如果锁住了且有足够空闲块,则选中该 Page。这避免了在某一个热门 Page 上排队等待
      3. 如果没找到可用页,调用 expand() 扩容。
    • 重试机制 (While-True Loop)
      • 如果 expand() 后新页被别人抢了,或者选中的页瞬间被掏空,循环重试。这是解决并发竞态条件(Race Condition)的关键。
    • 数据迁移:持有 Page 锁,切断 Page 链表的一部分,将其嫁接到线程本地链表。

3.2. 内存释放流程 (deallocate)

  1. L1 极速路径 (TLFL)
    • 检查本地缓存是否已满(thread_local_count < LOCAL_CACHE_SIZE)。
    • 未满:直接将块压入(Push)本地链表。全程无锁
    • 已满:缓存太多了,需要归还一部分给全局,进入 L2。
  2. L2 归还路径 (drain_local_list)
    • 定位归属:拿到一个内存地址,必须知道它属于哪个 Page。
      • 通过 big_mtx_ 遍历 pages_,利用 ptr >= base && ptr < end 判断归属。
    • 批量归还:将本地链表的一半截断,归还给对应的 Page。
    • 锁竞争优化:归还操作也只在“缓存满”时触发,频率远低于单次释放。

4. 关键技术点总结

4.1. 解决内存泄漏的 "Janitor" (看门人)

在使用了 thread_local 的系统中,最容易发生泄漏的时间点是 线程退出时

  • 问题:线程死掉了,但它的 thread_local_free_list 里还挂着 10 个内存块。这些块永远不会被分配,也永远不会回到 Page。
  • 解法:ThreadCacheJanitor 利用 RAII 机制。当线程结束,TLS 变量析构,Janitor 的析构函数自动调用 flush_thread_cache(),将遗产归还给公家。

4.2. 解决段错误的 "Shared_ptr" 与 "Safe Logic"

  • 问题:refill 过程中,刚找到一个 Page,主线程可能把这个 Page 删了(缩容),或者 vector 扩容导致指针失效。
  • 解法:pages_ 存储 shared_ptr。只要当前线程持有这个指针,Page 的物理内存就不会被释放。

4.3. 解决 ABA 问题与死锁

  • 单向流动:内存块只在 Head 进行操作,且使用了锁(在 Page 层)或 线程私有(在 TLS 层),避开了传统无锁队列复杂的 CAS/ABA 问题。
  • 锁顺序:总是先 big_mtx_ (可选) -> page.mtx。expand() 函数虽然会释放全局锁再加页锁,但通过状态检查和重试循环避免了逻辑死锁。

5. 性能瓶颈与未来优化方向 (进阶思考)

虽然目前通过了测试,但作为架构师,你需要知道它的极限在哪里:

  1. 地址定位开销 (deallocate -> drain)
    • 现状:释放时,如果要归还给 Page,需要遍历 pages_ 向量来对比地址范围。如果池子很大(几万个页),这个遍历是 O(N) 的,且持有全局锁,性能会下降。
    • 优化:引入 Radix Tree (基数树)PageMap。通过内存地址的高位直接索引到对应的 Page*,将查找复杂度降为 O(1)。(类似 tcmalloc/jemalloc 的做法)。
  2. 扩容策略
    • 现状:每次只扩 1 页。
    • 优化:可以指数级扩容(1页 -> 2页 -> 4页),减少调用 new 和争抢全局锁的频率。
  3. False Sharing (伪共享) 的隐患
    • 虽然 Page 结构体对齐了,但如果两个线程频繁操作相邻的内存块(用户拿到的内存),而这些块非常小(例如 8 字节),可能会导致用户数据的伪共享。但这属于使用者的问题,内存池本身已做到最好。

6. 总结

这个内存池项目是一个标准的 "从教科书到工业界" 的跨越练习:

  • v1.0 可能只是一个带一把大锁的链表。
  • v2.0 引入了 Page 锁,减小了锁粒度。
  • v3.0 (当前版本) 引入了 Thread Local CacheBatching,这是现代高性能内存分配器(如 tcmalloc, jemalloc, mimalloc)的核心特征。

你成功实现了一个线程安全、抗高并发、无内存泄漏的内存池。这是一个非常有价值的 C++ 系统编程实战经验。

追踪一个内存块

全景图:内存流动的三个层级

  1. OS Heap (操作系统堆):源头,由 operator new 分配。
  2. Page (页):中转站,由 FixedSizeMemory::Page 管理,受 shared_ptr 和 mutex 保护。
  3. TLS (线程本地缓存):终端,由 thread_local 指针管理,无锁。

第一阶段:内存的诞生与分配 (Allocation)

假设 线程 A 请求分配一个内存块。

1. 入口:FixedSizeMemory::allocate()

这是用户调用的主入口。

  • 步骤 1.1 (L1 查缓存)
    • 检查 thread_local_free_list (TLS变量)。
    • Hit (命中):如果指针不为空,直接修改 next 指针弹出第一个节点。thread_local_count--。函数返回
    • Miss (未命中):缓存为空,进入 步骤 1.2
  • 步骤 1.2 (L2 进货)
    • 调用 refill_local_list(REFILL_COUNT)。这是并发冲突最激烈的区域。

2. 核心深潜:refill_local_list()

这个函数负责从全局池“批发”一批内存块到线程本地。

  • 步骤 2.1 (寻找可用页)
    • 进入 while(true) 重试循环。
    • 加 big_mtx_ 锁,遍历 pages_ 向量。
    • 关键并发逻辑:使用 pg->mtx.try_lock()。
      • 为什么? 防止线程 A 在一个被线程 B 锁住的热点页上死等。如果锁不住,立刻找下一页。
    • 如果找到有空闲块的页,标记 target_page,保持锁住状态,跳出遍历。
  • 步骤 2.2 (扩容 - 如果没找到)
    • 如果所有页都满了或锁不住,调用 expand()
    • expand() 内部
      • 调用 ::operator new 向操作系统申请一大块物理内存(比如 64KB)。
      • 构建 Page 对象,初始化 free_list 链表。
      • 将 shared_ptr 放入 pages_ 向量。
    • 生命周期诞生:此时,物理内存和 Page 管理对象正式诞生。
  • 步骤 2.3 (批发)
    • 持有 target_page->mtx。
    • 从 target_page->free_list 切割下 REFILL_COUNT (比如 16个) 节点。
    • 修改 target_page->inuse 增加计数。
    • 解锁 Page。
  • 步骤 2.4 (填充 TLS)
    • 将切割下来的链表挂到 thread_local_free_list 上。
    • 函数返回

回到 allocate(),此时 TLS 里有货了,再次执行 步骤 1.1,分配成功。


第二阶段:内存的使用与释放 (Deallocation)

假设 线程 A 用完了这个块 p,调用 deallocate(p)。

1. 入口:FixedSizeMemory::deallocate(void* p)

  • 步骤 1.1 (L1 归还缓存)
    • 检查 thread_local_count < LOCAL_CACHE_SIZE。
    • 未满:将 p 插入 thread_local_free_list 头部。thread_local_count++。函数返回
    • 已满:缓存太多了(超过 32 个),需要“卸货”。进入 步骤 1.2
  • 步骤 1.2 (L2 卸货)
    • 先把 p 放进缓存(此时缓存有 33 个)。
    • 计算需要卸载的一半数量(比如 16 个)。
    • 调用 drain_local_list(head, count)

2. 核心深潜:drain_local_list()

这个函数负责把线程私有内存归还给共享的 Page。这是生命周期管理最容易出 Bug 的地方。

  • 步骤 2.1 (寻址与保命)
    • 我们需要知道内存块 head 属于哪个 Page。
    • 加 big_mtx_ 锁,遍历 pages_。
    • 判断标准:block_addr >= page->base && block_addr < page->base + capacity。
    • 生命周期关键点:找到后,拷贝一份 std::shared_ptr 到本地变量 target_spg
      • 为什么? 即使下一毫秒其他线程触发了缩容把这个 Page 从 pages_ 删除了,只要 线程 A 手里还捏着这个 shared_ptr,这个 Page 对象和它的物理内存就不会被析构。这是防止 Segfault 的核心。
  • 步骤 2.2 (归还)
    • 加 target_spg->mtx 锁。
    • 把 head 链表接到 Page 的 free_list 上。
    • target_spg->inuse -= count。
  • 步骤 2.3 (标记空闲)
    • 如果 inuse == 0(页空了),将该 Page 标记为 is_empty_candidate。
    • 将 Page 指针放入 empty_pages_ 列表(供后续惰性回收)。

第三阶段:物理内存的消亡 (Decommission / Exit)

内存有两种死亡方式:被动回收(Page销毁)被动归还(Thread销毁)

方式 A:惰性回收 (Decommissioning)

当空闲页太多时,系统会物理释放内存给 OS。

  1. 触发:在 deallocate 或其他维护点调用 decommission_pages()。
  2. 操作
    • 从 pages_ 向量中移除某个 Page 的 shared_ptr。
    • 从 empty_pages_ 中移除记录。
  3. 生命周期终结
    • 当 pages_ 删除了指针,且没有任何线程正在操作该页(没有持有临时的 shared_ptr)。
    • 引用计数降为 0。
    • 触发 Page::~Page()
    • 析构函数调用 ::operator delete(base)。
    • 物理内存归还给操作系统

方式 B:线程退出时的强制归还 (Thread Cache Janitor)

这是防止内存泄漏的最后一道防线。

  1. 场景:线程 A 结束运行(main 退出或 join)。
  2. 触发:thread_local 变量开始销毁。
  3. Janitor 介入
    • tl_janitor 对象析构,调用 ~ThreadCacheJanitor()。
    • 内部调用 flush_thread_cache()
  4. 流程复用
    • flush_thread_cache 内部直接调用 drain_local_list,把该线程手里所有剩余的缓存块,统统还给对应的 Page。
    • 如果没有这个 Janitor,这些缓存在 TLS 里的块就会随着线程栈的销毁而永久泄露(因为 Page 以为这些块还在被使用,引用计数 inuse 永远不归零,Page 永远无法释放)。

总结:高并发下的生命周期保护机制

资源对象 所在位置 谁负责分配 谁负责释放 保护机制 (高并发)
内存块 (Node) TLS / Page allocate() deallocate() TLS 无锁 (L1)
Page 互斥锁 (L2)
内存页 (Page) 全局 Vector expand() decommission() std::shared_ptr
(防止在使用中被删除)
物理内存 OS Heap Page构造 Page析构 引用计数 (inuse)
(inuse==0 才能被回收)
TLS 缓存 线程栈 首次访问自动创建 Janitor (RAII) flush_thread_cache
(线程死前强制归还)

这个架构通过 分层设计(TLS 避开锁,Page 分散锁,Shared_ptr 保护生命周期,Janitor 防止泄露),完美闭环了从分配到释放的全部逻辑。