2. 内存池核心设计:固定大小块分配器与线程安全设计
好,咱们接着聊内存池。上一章我讲了为什么需要内存池,说白了就是对抗碎片和分配开销。这一章,咱们直接动手,看看最核心的设计——固定大小块分配器,以及怎么让它线程安全。
我个人习惯,在设计任何系统组件之前,先画一张图,把核心逻辑理清楚。下面这张图,就是固定大小块分配器的核心骨架。
2.1 固定大小块分配器:最朴素的思路
固定大小块分配器,名字听着唬人,其实思路特别简单。你想想看,既然频繁的 malloc/free 会导致碎片,那我干脆提前把一大块内存切成很多个大小相同的小块,用链表串起来。谁要内存,我就从链表头上摘一个给他;谁还回来,我就再挂回链表头上。
这就是所谓的空闲链表(free list)。核心数据结构就两个东西:一个是指向第一个空闲块的指针,另一个是每个块内部隐藏的 next 指针。
核心要点:固定大小块分配器的本质,就是把「分配/释放」操作,简化为「链表头节点的摘取与插入」。时间复杂度 O(1),没有内存碎片。
我在项目中遇到过这样一个场景:一个实时音频处理系统,需要频繁分配和释放 64 字节的小缓冲区。用系统自带的 malloc,跑个几分钟就开始出现卡顿。后来换成固定大小块分配器,卡顿问题直接消失。说白了,就是减少了系统调用和内存管理的开销。
2.2 代码实现:一个极简版本
光说不练假把式。咱们直接看代码。这个版本虽然简单,但五脏俱全。
// 固定大小块分配器
class FixedBlockAllocator {
private:
struct Block {
Block* next; // 空闲链表指针
};
Block* free_list_head_; // 空闲链表头
size_t block_size_; // 每个块的大小(用户数据区)
size_t pool_size_; // 池中总块数
void* pool_memory_; // 整块内存的起始地址
std::mutex mtx_; // 互斥锁
public:
FixedBlockAllocator(size_t block_size, size_t block_count)
: block_size_(block_size), pool_size_(block_count), free_list_head_(nullptr) {
// 1. 一次性申请大块内存
pool_memory_ = std::malloc(block_size_ * pool_size_);
if (!pool_memory_) throw std::bad_alloc();
// 2. 初始化空闲链表
// 把每个块的首地址,当作 Block 结构体来用
char* mem = static_cast<char*>(pool_memory_);
for (size_t i = 0; i < pool_size_; ++i) {
Block* block = reinterpret_cast<Block*>(mem + i * block_size_);
block->next = free_list_head_;
free_list_head_ = block;
}
}
~FixedBlockAllocator() {
std::free(pool_memory_);
}
void* allocate() {
std::lock_guard<std::mutex> lock(mtx_);
if (!free_list_head_) {
return nullptr; // 内存耗尽
}
Block* block = free_list_head_;
free_list_head_ = free_list_head_->next;
return block;
}
void deallocate(void* ptr) {
if (!ptr) return;
std::lock_guard<std::mutex> lock(mtx_);
Block* block = static_cast<Block*>(ptr);
block->next = free_list_head_;
free_list_head_ = block;
}
};
设计技巧:注意看,这里把用户释放回来的内存块,直接当作链表节点来用。也就是说,空闲块内部的前几个字节,存储的是 next 指针。当这个块被分配出去后,这块内存完全归用户使用,next 指针被覆盖也没关系。这就是「零额外开销」的秘诀。
2.3 线程安全设计:加锁还是无锁?
单线程环境下,上面的代码已经够用了。但现实世界是多线程的。多个线程同时 allocate 或 deallocate,就会出问题——链表头被多个线程同时修改,数据就乱了。
解决线程安全,通常有两条路:
- 加锁(互斥锁):简单粗暴,性能一般。
- 无锁(原子操作):性能高,但实现复杂。
我个人习惯,在项目初期先用加锁方案。为什么?因为快。你想想看,一个 bug 难找的无锁队列,和一把简单的 mutex,哪个更容易维护?
避坑指南:我曾经在一个高并发网络库中,一开始就上了无锁队列。结果调试了整整三天,发现一个 ABA 问题导致内存错乱。后来老老实实换成带锁的版本,性能只下降了 5%,但代码稳定多了。所以,不要过早优化,先保证正确性。
2.4 加锁方案的性能考量
加锁就一定慢吗?不一定。关键看锁的粒度。
上面的代码中,每次 allocate 或 deallocate 都加锁解锁。如果线程数不多(比如 4-8 个),这个开销完全可以接受。我做过测试,在 8 核机器上,每个线程分配释放 100 万次,带锁版本比无锁版本慢大约 10%-15%。
但如果线程数很多(比如 64 个以上),锁竞争就会成为瓶颈。这时候可以考虑线程本地缓存(Thread Local Cache)的思路:每个线程先从一个全局池中拿一批块到自己的本地缓存,然后优先从本地缓存分配。本地缓存不够了,再去全局池拿。释放时,先放回本地缓存,本地缓存满了再批量归还全局池。
| 方案 | 实现复杂度 | 性能(低竞争) | 性能(高竞争) | 适用场景 |
|---|---|---|---|---|
| 全局锁 | 低 | 高 | 低 | 线程数少(< 16) |
| 线程本地缓存 + 全局锁 | 中 | 很高 | 高 | 线程数多(> 16) |
| 无锁(CAS) | 高 | 高 | 高 | 极端性能要求 |
2.5 无锁方案:CAS 实现
如果你确实需要无锁方案,可以用 C++11 的 std::atomic 配合 CAS(Compare-And-Swap)操作。核心思路是把 free_list_head_ 声明为 std::atomic<Block*>,然后 allocate 和 deallocate 都用 CAS 来更新。
class LockFreeFixedBlockAllocator {
private:
struct Block {
Block* next;
};
std::atomic<Block*> free_list_head_;
// ... 其他成员
public:
void* allocate() {
Block* old_head = free_list_head_.load();
while (old_head) {
Block* new_head = old_head->next;
if (free_list_head_.compare_exchange_weak(old_head, new_head)) {
return old_head;
}
// CAS 失败,说明其他线程修改了 head,重试
}
return nullptr; // 内存耗尽
}
void deallocate(void* ptr) {
Block* block = static_cast<Block*>(ptr);
Block* old_head = free_list_head_.load();
do {
block->next = old_head;
} while (!free_list_head_.compare_exchange_weak(old_head, block));
}
};
注意:无锁方案虽然避免了锁的开销,但引入了 ABA 问题。简单说就是:线程 A 读取 head 为 X,然后被挂起;线程 B 把 X 分配出去,又释放回来,head 还是 X;线程 A 醒来后 CAS 成功,但 X 的内容已经被线程 B 修改过了。解决 ABA 问题,通常需要引入版本号或使用 hazard pointer。
2.6 我的建议:从简单开始
嗯,说了这么多,最后给个实在的建议。如果你刚开始设计内存池,先用带全局锁的固定大小块分配器。它简单、正确、容易调试。等系统跑起来后,用性能分析工具看看锁竞争是不是瓶颈。如果是,再考虑线程本地缓存或无锁方案。
我在一个游戏服务器项目中就是这么干的。一开始用全局锁,压测发现锁竞争占 CPU 的 3%,完全不是问题。后来业务量涨了 10 倍,锁竞争到了 15%,才改成线程本地缓存。改完之后,锁竞争降到了 2% 以下。你看,过早优化是万恶之源,这句话在内存池设计上同样适用。
好,这一章就到这里。固定大小块分配器是内存池的基石,线程安全设计是让它能在多线程环境下稳定工作的关键。下一章,咱们聊聊更灵活的设计——变长内存池与 slab 分配器。