该内存池采用了 三层分级架构,目的是最大限度地减少锁竞争(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 集合、扩容、销毁 |
codeC++
struct Node { Node* next; };- 嵌入式链表:直接利用空闲内存块的前 8 字节(64位系统)存储下一个块的地址。不占用额外的管理内存(除了正在使用时)。
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)。
- vector<shared_ptr> pages_:
- 使用 shared_ptr 是为了生命周期安全。当一个线程正在操作某个 Page 时,即使主线程触发了扩容或缩容导致 vector 重新分配,该 Page 的内存也不会被错误释放。
- thread_local 变量 (在 cpp 中):
- thread_local_free_list:线程私有的空闲链表。
- tl_janitor:线程生命周期看门人,负责线程退出时的资源回收。
这是本项目的精华部分,解决了从“能用”到“高性能”的关键问题。
这是一个 “快路径优先,慢路径兜底” 的策略:
- L1 极速路径 (TLFL):
- 检查当前线程的 thread_local_free_list 是否有空闲块。
- 如果有:直接弹出(Pop),调整 thread_local_count。全程无锁,仅涉及几个指针操作,纳秒级响应。
- 如果无:进入 L2 慢路径。
- L2 填充路径 (refill_local_list):
- 策略:一次性从全局 Page 拿走一批块(例如 16 个),而不是 1 个。这叫 “批量移动 (Batching)”,极大地摊薄了锁的开销。
- 查找逻辑:
- 持有全局锁 big_mtx_ 快速遍历 pages_。
- 使用 try_lock() 尝试锁定 Page。如果锁住了且有足够空闲块,则选中该 Page。这避免了在某一个热门 Page 上排队等待。
- 如果没找到可用页,调用 expand() 扩容。
- 重试机制 (While-True Loop):
- 如果 expand() 后新页被别人抢了,或者选中的页瞬间被掏空,循环重试。这是解决并发竞态条件(Race Condition)的关键。
- 数据迁移:持有 Page 锁,切断 Page 链表的一部分,将其嫁接到线程本地链表。
- L1 极速路径 (TLFL):
- 检查本地缓存是否已满(thread_local_count < LOCAL_CACHE_SIZE)。
- 未满:直接将块压入(Push)本地链表。全程无锁。
- 已满:缓存太多了,需要归还一部分给全局,进入 L2。
- L2 归还路径 (drain_local_list):
- 定位归属:拿到一个内存地址,必须知道它属于哪个 Page。
- 通过 big_mtx_ 遍历 pages_,利用 ptr >= base && ptr < end 判断归属。
- 批量归还:将本地链表的一半截断,归还给对应的 Page。
- 锁竞争优化:归还操作也只在“缓存满”时触发,频率远低于单次释放。
- 定位归属:拿到一个内存地址,必须知道它属于哪个 Page。
在使用了 thread_local 的系统中,最容易发生泄漏的时间点是 线程退出时。
- 问题:线程死掉了,但它的 thread_local_free_list 里还挂着 10 个内存块。这些块永远不会被分配,也永远不会回到 Page。
- 解法:ThreadCacheJanitor 利用 RAII 机制。当线程结束,TLS 变量析构,Janitor 的析构函数自动调用 flush_thread_cache(),将遗产归还给公家。
- 问题:refill 过程中,刚找到一个 Page,主线程可能把这个 Page 删了(缩容),或者 vector 扩容导致指针失效。
- 解法:pages_ 存储 shared_ptr。只要当前线程持有这个指针,Page 的物理内存就不会被释放。
- 单向流动:内存块只在 Head 进行操作,且使用了锁(在 Page 层)或 线程私有(在 TLS 层),避开了传统无锁队列复杂的 CAS/ABA 问题。
- 锁顺序:总是先 big_mtx_ (可选) -> page.mtx。expand() 函数虽然会释放全局锁再加页锁,但通过状态检查和重试循环避免了逻辑死锁。
虽然目前通过了测试,但作为架构师,你需要知道它的极限在哪里:
- 地址定位开销 (deallocate -> drain):
- 现状:释放时,如果要归还给 Page,需要遍历 pages_ 向量来对比地址范围。如果池子很大(几万个页),这个遍历是 O(N) 的,且持有全局锁,性能会下降。
- 优化:引入 Radix Tree (基数树) 或 PageMap。通过内存地址的高位直接索引到对应的 Page*,将查找复杂度降为 O(1)。(类似 tcmalloc/jemalloc 的做法)。
- 扩容策略:
- 现状:每次只扩 1 页。
- 优化:可以指数级扩容(1页 -> 2页 -> 4页),减少调用 new 和争抢全局锁的频率。
- False Sharing (伪共享) 的隐患:
- 虽然 Page 结构体对齐了,但如果两个线程频繁操作相邻的内存块(用户拿到的内存),而这些块非常小(例如 8 字节),可能会导致用户数据的伪共享。但这属于使用者的问题,内存池本身已做到最好。
这个内存池项目是一个标准的 "从教科书到工业界" 的跨越练习:
- v1.0 可能只是一个带一把大锁的链表。
- v2.0 引入了 Page 锁,减小了锁粒度。
- v3.0 (当前版本) 引入了 Thread Local Cache 和 Batching,这是现代高性能内存分配器(如 tcmalloc, jemalloc, mimalloc)的核心特征。
你成功实现了一个线程安全、抗高并发、无内存泄漏的内存池。这是一个非常有价值的 C++ 系统编程实战经验。
- OS Heap (操作系统堆):源头,由 operator new 分配。
- Page (页):中转站,由 FixedSizeMemory::Page 管理,受 shared_ptr 和 mutex 保护。
- TLS (线程本地缓存):终端,由 thread_local 指针管理,无锁。
假设 线程 A 请求分配一个内存块。
这是用户调用的主入口。
- 步骤 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.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,分配成功。
假设 线程 A 用完了这个块 p,调用 deallocate(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)。
这个函数负责把线程私有内存归还给共享的 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_ 列表(供后续惰性回收)。
内存有两种死亡方式:被动回收(Page销毁) 和 被动归还(Thread销毁)。
当空闲页太多时,系统会物理释放内存给 OS。
- 触发:在 deallocate 或其他维护点调用 decommission_pages()。
- 操作:
- 从 pages_ 向量中移除某个 Page 的 shared_ptr。
- 从 empty_pages_ 中移除记录。
- 生命周期终结:
- 当 pages_ 删除了指针,且没有任何线程正在操作该页(没有持有临时的 shared_ptr)。
- 引用计数降为 0。
- 触发 Page::~Page()。
- 析构函数调用 ::operator delete(base)。
- 物理内存归还给操作系统。
这是防止内存泄漏的最后一道防线。
- 场景:线程 A 结束运行(main 退出或 join)。
- 触发:thread_local 变量开始销毁。
- Janitor 介入:
- tl_janitor 对象析构,调用 ~ThreadCacheJanitor()。
- 内部调用 flush_thread_cache()。
- 流程复用:
- 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 防止泄露),完美闭环了从分配到释放的全部逻辑。