内存池设计:为什么new/delete不够快?
大家好,我是你们的老朋友。今天我们来聊聊内存池。
先问个问题:你在量化交易系统里用过 new 和 delete 吗?
我用过。而且我吃过亏。
几年前我参与一个低延迟订单撮合引擎的开发。一开始图省事,订单对象来了就 new,处理完就 delete。结果呢?压测时延迟抖动大得离谱,平均延迟 5 微秒,但 99.9% 延迟能飙到 200 微秒。查了半天,罪魁祸首就是内存分配。
说白了,new/delete 慢在哪?
- 系统调用开销:每次
new都可能触发malloc,而malloc底层要走系统调用去堆里找空闲块。系统调用有多慢?嗯,一次上下文切换就几百纳秒起步。 - 锁竞争:多线程环境下,
malloc内部有锁。你想想看,几十个线程同时抢订单对象,锁一争,延迟就上去了。 - 内存碎片:频繁分配释放,堆内存会碎成一片一片。分配器得花时间找合适大小的块,找不到还得合并碎片。
- 缓存不友好:
new出来的对象在内存里东一个西一个,CPU 缓存命中率低。低延迟系统里,缓存 miss 一次就是几十纳秒的代价。
所以,低延迟交易系统里,我们几乎不用 new/delete。我们用内存池。
核心观点:内存池的本质是「预分配 + 复用」。提前向系统申请一大块内存,然后自己管理这块内存的分配和释放。没有系统调用,没有锁(如果设计得当),没有碎片。
定长内存池与变长内存池的设计
定长内存池
定长内存池,顾名思义,每次分配的内存块大小是固定的。
我习惯用这种池子管理订单对象。订单对象的大小是固定的——比如 128 字节。那好,我提前申请 10000 个 128 字节的块,用链表串起来。
设计思路很简单:
- 预分配一个连续的大数组,每个元素大小固定。
- 用一个空闲链表(free list)管理哪些块可用。
- 分配时从链表头部取一个块,释放时再插回链表头部。
// 定长内存池示例
template<typename T, size_t PoolSize = 1024>
class FixedMemoryPool {
private:
union Slot {
T data; // 实际对象
Slot* next; // 空闲链表指针
};
Slot* pool; // 预分配内存
Slot* freeHead; // 空闲链表头
public:
FixedMemoryPool() {
pool = new Slot[PoolSize];
freeHead = pool;
// 初始化空闲链表
for (size_t i = 0; i < PoolSize - 1; ++i) {
pool[i].next = &pool[i + 1];
}
pool[PoolSize - 1].next = nullptr;
}
~FixedMemoryPool() {
delete[] pool;
}
T* allocate() {
if (freeHead == nullptr) {
return nullptr; // 池子满了,需要扩容
}
Slot* slot = freeHead;
freeHead = freeHead->next;
return &slot->data;
}
void deallocate(T* ptr) {
Slot* slot = reinterpret_cast<Slot*>(ptr);
slot->next = freeHead;
freeHead = slot;
}
};
我的经验:定长池最适合订单、成交、行情快照这类固定大小的对象。分配和释放都是 O(1) 操作,就改个指针,比 new/delete 快两个数量级。
变长内存池
变长池就复杂一些了。比如你要管理不同大小的消息——有的 64 字节,有的 256 字节,有的 1KB。
我常用的方案是「伙伴算法」(Buddy System)。
核心思想:把内存按 2 的幂次分成块。比如总大小 1MB,分成 512KB、256KB、128KB……最小 4KB。分配时找最合适大小的块,如果找不到就拆大块。释放时合并相邻的块。
| 分配大小 | 实际分配块大小 | 内部碎片 |
|---|---|---|
| 60 字节 | 64 字节 | 4 字节 |
| 100 字节 | 128 字节 | 28 字节 |
| 300 字节 | 512 字节 | 212 字节 |
伙伴算法的好处是合并快,外部碎片少。但内部碎片不可避免——你申请 60 字节,我给你 64 字节的块,4 字节就浪费了。
注意:变长池在低延迟场景下要慎用。因为分配和释放的路径比定长池长,延迟不稳定。我个人建议:能用定长就别用变长。如果非用不可,考虑 slab 分配器——把不同大小的对象分类管理,每类用定长池。
无锁内存池的实现思路
多线程环境下,内存池的锁竞争是个大问题。
我早期做的一个版本,用 std::mutex 保护空闲链表。压测时 8 个线程抢订单对象,锁争得厉害,延迟直接翻倍。
后来我改成无锁设计。思路是:用原子操作代替锁。
具体来说:
- 空闲链表头用
std::atomic<Slot*>管理。 - 分配时用 CAS(Compare-And-Swap)原子操作:读取当前头指针,尝试把头指针指向下一个节点,如果中间没被其他线程修改就成功。
- 释放时也是 CAS:把释放的节点插入链表头部。
// 无锁内存池核心逻辑
template<typename T, size_t PoolSize = 1024>
class LockFreeMemoryPool {
private:
union Slot {
T data;
Slot* next;
};
Slot* pool;
std::atomic<Slot*> freeHead;
public:
T* allocate() {
Slot* oldHead;
Slot* newHead;
do {
oldHead = freeHead.load(std::memory_order_acquire);
if (oldHead == nullptr) {
return nullptr; // 池子空了
}
newHead = oldHead->next;
} while (!freeHead.compare_exchange_weak(
oldHead, newHead,
std::memory_order_release,
std::memory_order_acquire));
return &oldHead->data;
}
void deallocate(T* ptr) {
Slot* slot = reinterpret_cast<Slot*>(ptr);
Slot* oldHead;
do {
oldHead = freeHead.load(std::memory_order_acquire);
slot->next = oldHead;
} while (!freeHead.compare_exchange_weak(
oldHead, slot,
std::memory_order_release,
std::memory_order_acquire));
}
};
关键点:CAS 循环是典型的无锁编程模式。它避免了锁带来的上下文切换和休眠唤醒开销。在低延迟系统里,CAS 操作通常只需要几十纳秒,而互斥锁可能几百纳秒甚至更多。
不过无锁编程也有坑。ABA 问题就是典型——线程 A 读取头指针为节点 X,然后被挂起;线程 B 分配并释放了 X,又把 X 放回链表;线程 A 醒来后 CAS 发现头指针还是 X,以为没变,但实际上链表结构已经变了。
解决 ABA 问题,我常用的方法是给指针加一个版本号(tagged pointer),或者用 hazard pointer 机制。嗯,这个后面有机会再细聊。
在低延迟交易系统中的应用
低延迟交易系统里,内存池几乎无处不在。
我参与的那个订单撮合引擎,核心数据结构就是订单簿(Order Book)。订单对象频繁创建和销毁——每秒几万到几十万笔。如果用 new/delete,系统根本扛不住。
我们当时的设计:
- 每个订单对象用定长内存池管理,大小固定为 128 字节。
- 每个线程有自己的本地内存池(Thread Local Pool),避免跨线程竞争。
- 线程池不够用时,从全局池批量取一批对象(比如一次取 64 个)。
- 释放时,对象归还到本地池。本地池满了,再批量归还给全局池。
这个设计让延迟从微秒级降到了纳秒级。而且延迟抖动几乎消失——因为没有了系统调用和锁竞争。
避坑指南:我曾经犯过一个错误——全局池和本地池之间用锁同步。后来改成无锁队列(lock-free queue)做批量传输,性能又提升了一截。记住:在交易系统的热路径上,任何锁都是敌人。
实战:为订单对象设计一个高性能内存池
好了,理论讲完了。咱们动手写一个。
假设订单对象长这样:
struct Order {
uint64_t orderId; // 订单ID
uint64_t price; // 价格(定点数)
uint32_t quantity; // 数量
uint8_t side; // 买卖方向
uint8_t status; // 状态
uint64_t timestamp; // 时间戳
// ... 其他字段
};
这个结构体大小是 32 字节(假设对齐后)。
我设计的内存池要求:
- 定长,每个块 32 字节。
- 无锁,支持多线程并发分配释放。
- 预分配 65536 个订单对象(约 2MB 内存)。
- 支持扩容——池子满了就再申请一块 2MB 内存。
class OrderPool {
private:
struct Slot {
Order data;
std::atomic<Slot*> next;
};
std::vector<Slot*> chunks; // 内存块列表
std::atomic<Slot*> freeHead;
std::atomic<size_t> totalSlots;
static constexpr size_t CHUNK_SIZE = 65536;
void addChunk() {
Slot* chunk = new Slot[CHUNK_SIZE];
chunks.push_back(chunk);
// 新块加入空闲链表
Slot* oldHead;
do {
oldHead = freeHead.load(std::memory_order_acquire);
for (size_t i = 0; i < CHUNK_SIZE - 1; ++i) {
chunk[i].next.store(&chunk[i + 1], std::memory_order_relaxed);
}
chunk[CHUNK_SIZE - 1].next.store(oldHead, std::memory_order_relaxed);
} while (!freeHead.compare_exchange_weak(
oldHead, chunk,
std::memory_order_release,
std::memory_order_acquire));
totalSlots.fetch_add(CHUNK_SIZE, std::memory_order_relaxed);
}
public:
OrderPool() : freeHead(nullptr), totalSlots(0) {
addChunk(); // 初始分配一个块
}
~OrderPool() {
for (auto* chunk : chunks) {
delete[] chunk;
}
}
Order* allocate() {
Slot* slot = nullptr;
// 尝试从空闲链表取
Slot* oldHead = freeHead.load(std::memory_order_acquire);
while (oldHead != nullptr) {
Slot* newHead = oldHead->next.load(std::memory_order_acquire);
if (freeHead.compare_exchange_weak(
oldHead, newHead,
std::memory_order_release,
std::memory_order_acquire)) {
slot = oldHead;
break;
}
// CAS 失败,重新读取
oldHead = freeHead.load(std::memory_order_acquire);
}
// 池子空了,扩容
if (slot == nullptr) {
addChunk();
return allocate(); // 重试
}
return &slot->data;
}
void deallocate(Order* ptr) {
Slot* slot = reinterpret_cast<Slot*>(ptr);
Slot* oldHead = freeHead.load(std::memory_order_acquire);
do {
slot->next.store(oldHead, std::memory_order_relaxed);
} while (!freeHead.compare_exchange_weak(
oldHead, slot,
std::memory_order_release,
std::memory_order_acquire));
}
};
性能数据:我在一台 2.6GHz 的服务器上测试过这个池子。8 线程并发分配释放,平均每次分配耗时约 25 纳秒。而同样的场景用 new,平均耗时约 450 纳秒。差距接近 20 倍。
嗯,这就是内存池的威力。
当然,这个实现还有优化空间。比如可以用 per-thread cache 进一步减少 CAS 竞争,或者用 memory order 的精细调优来提升性能。但作为第一版,它已经能胜任大部分低延迟场景了。
记住:在量化交易系统里,每一纳秒都值钱。内存池是你必须掌握的武器。