从零手写 C++ 内存池 ------ 从 freelist 到无锁并发
一个下午,10 个功能,从固定大小分配器到 CAS 无锁栈。这篇文章记录每一层的设计思路和核心代码。适合想理解"malloc 内部怎么工作"的 C++ 初学者。
为什么要手写内存池
malloc/free 是通用目的的内存分配器------它要处理各种大小、多线程竞争、碎片回收,内部逻辑极其复杂。但很多场景不需要通用的 malloc------你的 muduo 在处理几十万个 TCP 连接时,每个连接的 Buffer 大小固定。每来一个新连接都要 new Buffer(4096),频繁的系统调用拖死性能。
内存池做一件事:预分配一大块内存,自己管理分配和回收,系统调用减少 90% 以上。
这篇文章从最简单的 freelist 开始,一步步加上多尺寸管理、线程本地缓存、全局调剂、CAS 无锁、内存对齐、越界检测。代码在 GitHub 上,MIT 协议:
memory-pool :
https://github.com/ch0sen1pm/memory-pool
第 1 层:FixedAllocator --- freelist 复用
核心思路: 预分配一块连续内存,切成等大块。空闲块的前 8 字节存指针指向下一个空闲块------这就是 freelist。分配/回收都是 O(1)。
ini
预分配内存: [block][block][block][block]...
freelist: head → block3 → block2 → block1 → block0 → nullptr
为什么 freelist 没有额外内存开销? 空闲块的前 8 字节正好存 next 指针。分配出去后用户数据覆盖这块内存------不需要链表指针了。回收时重新写回 next。复用同一块内存存指针,零额外开销。
cpp
template <size_t BlockSize, size_t Align, bool Debug>
class FixedAllocator {
struct Block { Block* next; }; // 只用 8 字节存链表
Block* freeList_; // 空闲块链表头
char* pool_; // 预分配的大块内存
void* allocate() {
if (!freeList_) return nullptr;
Block* b = freeList_;
freeList_ = b->next; // head 后移
return b;
}
void deallocate(void* p) {
Block* b = static_cast<Block*>(p);
b->next = freeList_; // 归还的块指向原来的 head
freeList_ = b; // 自己成为新 head
}
};
构造时建 freelist: new char[BlockSize * numBlocks] → 切成等大块 → 每块 b->next = freeList_; freeList_ = b → 插到头。栈式释放------LIFO,最后归还的块被最先取走。
第 2 层:SlabAllocator --- 多尺寸管理
FixedAllocator 只管理一种大小。实际需要支持多种大小------8 字节、16 字节、32 字节、64 字节... SlabAllocator 管理多个 FixedAllocator,根据请求大小分到合适的 slab:
cpp
class SlabAllocator {
FixedAllocator<8> slab8;
FixedAllocator<16> slab16;
FixedAllocator<32> slab32;
FixedAllocator<64> slab64;
// ...
void* allocate(size_t size) {
size_t rounded = roundUp(size); // 向上取整到 2 的幂次
if (rounded <= 8) return slab8.allocate();
if (rounded <= 16) return slab16.allocate();
// ...
return ::malloc(size); // 超大 → 直接 malloc
}
};
roundUp 位运算: n--; n |= n>>1; ...; return n+1 ------最高位的 1 扩散到所有低位,+1 得 2 的幂次。比如 30 → 32,60 → 64。经典算法。
第 3 层:ThreadCache --- thread_local 无锁
多线程下多个线程共用一个 FixedAllocator 需要加锁------每个 allocate/deallocate 都要争锁。但如果每个线程有自己独立的 freelist,就不需要锁了。
cpp
template <size_t BlockSize>
class ThreadCache {
static thread_local FixedAllocator<BlockSize> alloc_;
void* allocate() { return alloc_.allocate(); } // 无锁!
void deallocate(void* p) { alloc_.deallocate(p); }
};
thread_local 关键字做了什么: 每个线程有自己独立的 alloc_ 副本------物理上是两块不同的内存。线程 A 跟线程 B 同时调 allocate(),各自操作自己的 freelist,互不干扰。线程安全不是"加了锁"------是"根本没有共享数据"。
static + thread_local 组合:static = 所有 ThreadCache 对象共享一份 alloc_,thread_local = 但不同线程的副本独立。两个关键字各管各的。
第 4 层:CentralCache --- 全局调剂
ThreadCache 容量有限(默认 1024 块)。用完了返回 nullptr------需要从全局池补货。CentralCache 是线程间的"块交换中心":
css
线程 A: allocate() → ThreadCache 空了 → 找 CentralCache 批量要
线程 B: deallocate() → ThreadCache 太多了 → 批量还给 CentralCache
CentralCache: 持有 FixedAllocator + mutex。只在批量转移时加锁
cpp
template <size_t BlockSize>
class CentralCache {
FixedAllocator<BlockSize> alloc_;
std::mutex mutex_;
void* allocate() {
std::lock_guard<std::mutex> lock(mutex_);
return alloc_.allocate();
}
};
ThreadCache 99% 的请求不碰锁------只在补货/回收时锁一次。跟 jemalloc 的 tcache + ecache 两层缓存一个道理。
第 5 层:LockFreeStack --- CAS 无锁 freelist
CentralCache 的互斥锁在竞争激烈时成为瓶颈。CAS(Compare And Swap)原子指令可以替代锁------多线程并发 push/pop 不需要任何锁。
CAS 原理: "如果这块内存还是原来的值,就把新值写进去;如果被别的线程改了,重试。"
cpp
void push(Node* node) {
node->next = head_.load(); // 新节点指向当前头
while (!head_.compare_exchange_weak(
node->next, node)) { // CAS: 如果 head 没变 → 改成 node
// CAS 失败 → 其他线程抢先了 → 重试
}
}
Node* pop() {
Node* node = head_.load(); // 记住当前头
while (node && !head_.compare_exchange_weak(
node, node->next)) {
// CAS 失败 → 重试
}
return node;
}
为什么不会死锁: CAS 失败不阻塞------原地重试。其他线程的修改帮你的链表维护好了,你重读一次新的头就行。"合作式无锁"------每个线程的修改对其他线程有利。
内存序: push 用 release------保证写入后对其他线程可见。pop 用 acquire------保证读到前一个 push 的完整数据。失败都用 relaxed------反正是重试,无所谓。这是 CPU 的内存模型------先记住固定搭配,以后看 OSTEP 理解。
第 6 层:内存对齐
SIMD 指令(AVX/SSE)要求数据地址是 16/32/64 的倍数。alignas 关键字让编译器保证对齐:
cpp
struct alignas(64) Block {
Block* next;
};
// 每个块的起始地址必须是 64 的倍数
// stride = (BlockSize + Align - 1) & ~(Align - 1) --- 向上取整 BlockSize 到 Align 倍数
(addr + Align - 1) & ~(Align - 1) 向上取整地址到 Align 的倍数。比如 addr=0x1003, Align=64:(0x1003+63) & ~63 = 0x1040 ✅。位运算比 ceil(a/64)*64 快。
第 7 层:Debug 模式 --- guard bytes 检测越界
每个块前后放哨兵字节(0xDEADBEEFCAFEBABE)。deallocate 时检查哨兵是否被改------被改了说明有越界写。
less
分配: [next 8B] [front guard 8B] [用户数据 BlockSize] [back guard 8B]
↑ ↑ ↑ ↑
b b+8 b+16 b+stride-8
allocate 返回 b+16
cpp
void deallocate(void* p) {
Block* b = reinterpret_cast<Block*>(reinterpret_cast<char*>(p) - 16);
uint64_t* front = reinterpret_cast<uint64_t*>(reinterpret_cast<char*>(b) + 8);
assert(*front == GUARD && "front guard corrupted!");
// 检查 back guard...
}
if constexpr (Debug) 决定是否编译 guard 代码。Debug=false 时全部跳过------零运行时开销。
性能数据
多线程 4 核,100K 次 alloc+free per thread:
bash
FixedAllocator + mutex: 18M ops/s
malloc + mutex: 13M ops/s
ThreadCache no lock: 33M ops/s --- 2.5x faster than malloc
单线程 SlabAllocator vs malloc 基本持平------微基准的差异在毫秒级。多线程场景 thread_local 无锁的优势明显。
项目结构
arduino
memory-pool/
├── fixed_allocator.h --- freelist 单链表 + 对齐 + Debug guard
├── slab_allocator.h --- 多尺寸管理 + roundUp 位运算
├── thread_cache.h --- thread_local 无锁分配
├── central_cache.h --- mutex 全局调剂
├── lockfree_stack.h --- CAS 原子无锁栈
├── bench.cpp --- 单线程 benchmark
├── bench_mt.cpp --- 多线程 benchmark
├── main.cpp --- 综合测试
└── README.md
6 个头文件,2000+ 行代码,零外部依赖。
更新记录
- 7/19 FixedAllocator --- freelist 固定大小分配器
- 7/19 SlabAllocator --- 多尺寸 + roundUp
- 7/19 Benchmark vs malloc(单线程)
- 7/19 ThreadCache --- thread_local 无锁
- 7/19 CentralCache --- mutex 全局池
- 7/19 LockFreeStack --- CAS 原子无锁栈
- 7/19 内存对齐 --- alignas + stride 向上取整
- 7/19 Debug 模式 --- guard bytes 越界检测
- 7/19 Benchmark vs malloc(多线程)--- ThreadCache 2.5x 更快
项目已完结。一个下午从 freelist 到 CAS 无锁。代码在 GitHub 上,MIT 协议,欢迎 star ⭐
memory-pool :
https://github.com/ch0sen1pm/memory-pool配套项目:my_muduo (Reactor 网络库)+ my_logger(日志库)