内存池设计:固定大小分配器、无锁内存池、线程本地缓存、伙伴系统
大家好,我是你们的讲师。今天我们来聊聊内存池设计。说实话,在低延迟交易系统里,内存管理是绕不开的硬骨头。标准库的 malloc 和 new 虽然方便,但它们的性能波动和锁竞争,在高频交易场景下简直就是灾难。我自己就踩过这个坑——有一次系统延迟突然飙升,排查了半天,最后发现是内存分配器在搞鬼。
所以,这一章我们专门来攻克内存池。我会从最简单的固定大小分配器讲起,再到无锁设计、线程本地缓存,最后是伙伴系统。每个方案都有它的适用场景,咱们一个一个来看。
1. 固定大小分配器:最朴素的方案
先说说固定大小分配器。说白了,就是预先分配一大块内存,然后切成固定大小的块。每次分配就取一个块,释放就还回去。这玩意儿实现起来特别简单,但效率极高。
我个人的习惯是,在交易引擎里,像订单、成交回报这类对象,它们的大小是固定的。用固定大小分配器,分配和释放都是 O(1) 的复杂度,而且没有内存碎片。
来看一个简单的实现:
class FixedAllocator {
char* pool;
size_t blockSize;
size_t blockCount;
void* freeList; // 空闲链表头
public:
FixedAllocator(size_t blockSize, size_t blockCount)
: blockSize(blockSize), blockCount(blockCount) {
pool = (char*)malloc(blockSize * blockCount);
// 初始化空闲链表
freeList = pool;
for (size_t i = 0; i < blockCount - 1; ++i) {
char* current = pool + i * blockSize;
char* next = pool + (i + 1) * blockSize;
*(void**)current = next;
}
*(void**)(pool + (blockCount - 1) * blockSize) = nullptr;
}
void* allocate() {
if (!freeList) return nullptr;
void* ptr = freeList;
freeList = *(void**)freeList;
return ptr;
}
void deallocate(void* ptr) {
*(void**)ptr = freeList;
freeList = ptr;
}
};
嗯,这里要注意一点:空闲链表里每个块的前几个字节被用来存放下一个块的地址。所以块大小至少得能放得下一个指针。我在项目中遇到过有人把块设成 4 字节,结果链表指针都存不下,直接崩了。
2. 无锁内存池:告别锁竞争
固定大小分配器虽然快,但如果有多个线程同时分配,还是得加锁。在高频交易里,锁的代价太大了。你想想看,一个锁争抢可能就让你的延迟从微秒级跳到毫秒级。
无锁内存池的核心思路,就是利用原子操作来管理空闲链表。我常用的方案是基于 CAS(Compare-And-Swap)的无锁栈。
class LockFreePool {
struct Node {
Node* next;
};
std::atomic<Node*> head;
public:
LockFreePool() : head(nullptr) {}
void* allocate() {
Node* oldHead = head.load();
while (oldHead) {
if (head.compare_exchange_weak(oldHead, oldHead->next)) {
return oldHead;
}
}
return nullptr;
}
void deallocate(void* ptr) {
Node* node = (Node*)ptr;
Node* oldHead = head.load();
do {
node->next = oldHead;
} while (!head.compare_exchange_weak(oldHead, node));
}
};
这里有个坑,我必须要提醒你:ABA 问题。CAS 操作里,如果某个线程把 A 取出来,另一个线程又把 A 放回去,第一个线程的 CAS 还是会成功,但此时 A 的内容可能已经变了。解决办法?加一个版本号标签,或者用 hazard pointer。
3. 线程本地缓存:减少跨线程交互
无锁方案虽然避免了锁,但原子操作本身还是有开销的。而且多个线程竞争同一个空闲链表,CAS 失败重试的次数会增多。这时候,线程本地缓存(Thread Local Cache)就派上用场了。
思路很简单:每个线程维护一个本地的小缓存。分配时先从本地拿,本地不够了再去全局池取一批。释放时先放回本地,本地满了再还给全局池。
我一般这样设计:
- 每个线程有一个
thread_local的缓存,容量大概 64 或 128 个块 - 分配时,如果本地缓存不为空,直接取;否则从全局池批量取 32 个
- 释放时,如果本地缓存未满,直接放回;否则把本地的一半还给全局池
这样做的好处是,大部分分配和释放操作都在线程内部完成,完全不需要同步。只有跨线程的批量操作才需要加锁或使用原子操作。
我记得有一次优化一个订单簿模块,用了线程本地缓存后,延迟直接降了 40%。效果立竿见影。
4. 伙伴系统:应对可变大小分配
前面讲的都是固定大小分配。但实际场景中,我们有时需要分配不同大小的内存块。比如网络缓冲区,消息大小可能从几十字节到几 KB 不等。这时候伙伴系统就登场了。
伙伴系统的核心思想是:把内存分成 2 的幂次大小的块。分配时,找到最小的满足需求的块;释放时,如果相邻的块也是空闲的,就合并成更大的块。
我画了一张图,帮你理解伙伴系统的结构:
伙伴系统的实现要点:
- 用数组管理不同大小的空闲链表,每个链表对应一种 2 的幂次大小
- 分配时,从最小满足需求的链表取块;如果链表为空,从更大一级的链表分裂
- 释放时,检查伙伴是否空闲,如果是则合并,递归向上
伙伴系统的优点是内存利用率高,外部碎片少。但缺点是内部碎片依然存在——比如你申请 10 KB,它给你 16 KB 的块,浪费了 6 KB。另外,合并操作可能比较耗时。
5. 四种方案的对比与选择
最后,我整理了一个表格,方便你对比这四种方案:
| 方案 | 分配/释放复杂度 | 线程安全 | 适用场景 | 缺点 |
|---|---|---|---|---|
| 固定大小分配器 | O(1) | 需加锁 | 对象大小固定、生命周期短 | 无法处理变长分配 |
| 无锁内存池 | O(1)(CAS 可能重试) | 无锁 | 多线程高频分配 | ABA 问题、实现复杂 |
| 线程本地缓存 | O(1)(本地操作) | 线程局部 | 线程间隔离、减少竞争 | 内存占用略高 |
| 伙伴系统 | O(log N) | 需加锁 | 变长分配、内存利用率高 | 内部碎片、合并开销 |
说实话,没有银弹。实际项目中,我经常是组合使用:固定大小分配器处理订单对象,线程本地缓存减少竞争,伙伴系统处理网络缓冲区。每种方案各司其职,才能把性能压榨到极致。
好了,这一章的内容就到这里。内存池设计是低延迟系统的基石,希望你能动手实现一遍,踩踩坑,才能真正理解其中的门道。