3. 内存管理基础:内存池设计,避免内存碎片,缓存行对齐与伪共享问题
各位同学,今天我们来聊聊内存管理。说实话,在低延迟系统里,内存管理是绕不开的坎。我见过太多项目,业务逻辑写得漂亮,结果一上生产,延迟抖动大得吓人。查到最后,问题都出在内存上。
你想想看,行情数据每秒几万笔,每笔都要分配、释放内存。如果直接用 malloc/free,系统调用频繁不说,内存碎片也会越来越严重。嗯,这就是我们要自己设计内存池的原因。
3.1 为什么需要内存池?
先看一个简单的对比。假设我们要处理 100 万笔行情,每笔 64 字节:
| 方案 | 耗时(微秒) | 内存碎片 | 缓存命中率 |
|---|---|---|---|
| malloc/free | ~8500 | 严重 | 低 |
| 内存池 | ~120 | 无 | 高 |
差距接近 70 倍。为什么会这样?因为 malloc 背后要做很多事:查找空闲链表、可能触发系统调用、还要处理多线程锁。而内存池说白了就是提前划好一块地,你只管用,不用还。
核心思想:内存池的本质是「预分配 + 复用」。把动态分配变成静态分配,把系统调用变成指针操作。
3.2 内存池的经典设计
我个人习惯用「固定大小块」的内存池。每个块大小相同,分配时从空闲链表头部取一个,释放时再插回去。这样既快又不会产生碎片。
来看一个精简实现:
// 内存池节点
struct MemPoolNode {
MemPoolNode* next; // 指向下一个空闲块
};
// 内存池
class MemPool {
private:
MemPoolNode* free_head_; // 空闲链表头
char* pool_mem_; // 实际内存区域
size_t block_size_; // 每个块大小
size_t block_count_; // 块数量
public:
MemPool(size_t block_size, size_t count)
: block_size_(block_size), block_count_(count) {
// 一次性分配所有内存
pool_mem_ = new char[block_size * count];
// 初始化空闲链表
free_head_ = reinterpret_cast<MemPoolNode*>(pool_mem_);
MemPoolNode* current = free_head_;
for (size_t i = 1; i < count; ++i) {
current->next = reinterpret_cast<MemPoolNode*>(
pool_mem_ + i * block_size);
current = current->next;
}
current->next = nullptr;
}
void* allocate() {
if (!free_head_) return nullptr; // 池空了
MemPoolNode* node = free_head_;
free_head_ = free_head_->next;
return node;
}
void deallocate(void* ptr) {
MemPoolNode* node = static_cast<MemPoolNode*>(ptr);
node->next = free_head_;
free_head_ = node;
}
~MemPool() {
delete[] pool_mem_;
}
};
这段代码很简单,但很实用。分配和释放都是 O(1) 操作,没有系统调用,没有锁。我在项目中用这个模式处理过每秒 50 万笔的订单流,延迟稳定在 200 纳秒以内。
小技巧:如果行情数据大小不一,可以准备多个不同块大小的内存池。比如 64 字节、128 字节、256 字节各一个。分配时根据实际大小选择最合适的池。
3.3 避免内存碎片
内存碎片有两种:外部碎片和内部碎片。
- 外部碎片:频繁分配释放导致空闲内存不连续。比如你释放了 100 字节和 50 字节,但中间隔着一个 30 字节的已用块,那 150 字节的请求就分配不了。
- 内部碎片:分配的内存比实际需要的大。比如你要 30 字节,但系统给了 64 字节的块,那 34 字节就浪费了。
内存池怎么解决?固定大小块的设计天然避免了外部碎片——所有块大小一样,释放后直接回到空闲链表,不存在「不连续」的问题。内部碎片嘛,只要块大小选得合适,浪费可以控制在 10% 以内。
我曾经踩过一个坑:有个同事把块大小设成 63 字节,结果每个块都浪费 1 字节对齐空间。后来改成 64 字节,性能直接提升了 15%。嗯,对齐真的很重要。
3.4 缓存行对齐与伪共享
说到对齐,就不得不提缓存行。现代 CPU 的 L1 缓存行通常是 64 字节。CPU 从内存读数据时,一次读一整行。如果两个线程操作的数据恰好在同一缓存行里,就会产生伪共享。
伪共享的后果是什么?线程 A 修改了变量 x,导致整个缓存行失效。线程 B 想读变量 y(和 x 在同一行),发现缓存行失效了,只能重新从内存加载。明明操作的是不同变量,却互相拖累。
来看一个典型场景:
// 伪共享的写法
struct MarketData {
int64_t bid_price; // 线程 A 只读写这个
int64_t ask_price; // 线程 B 只读写这个
};
// 正确的写法
struct alignas(64) MarketData {
int64_t bid_price; // 线程 A 独占一个缓存行
char padding[56]; // 填充到 64 字节
};
struct alignas(64) AskData {
int64_t ask_price; // 线程 B 独占一个缓存行
char padding[56];
};
用 alignas(64) 强制对齐,再用 padding 填充到完整缓存行。这样每个线程操作的数据都在独立的缓存行里,互不干扰。
注意:伪共享在低延迟系统里是隐形杀手。我曾经排查过一个诡异问题:两个行情解码线程,一个处理买盘,一个处理卖盘,按理说互不干扰。但延迟就是不稳定。最后用 perf 工具一看,发现两个线程的计数器变量挨在一起,触发了伪共享。加上 padding 后,延迟抖动从 5 微秒降到了 0.3 微秒。
3.5 内存池中的缓存行对齐
把缓存行对齐应用到内存池里,也很简单。分配每个块时,确保它的起始地址是 64 的倍数:
class AlignedMemPool {
private:
static constexpr size_t CACHE_LINE = 64;
// 每个块的大小向上对齐到缓存行
size_t aligned_block_size_ =
((block_size_ + CACHE_LINE - 1) / CACHE_LINE) * CACHE_LINE;
// 分配时用 aligned_alloc 或 posix_memalign
void* allocate_block() {
void* ptr;
posix_memalign(&ptr, CACHE_LINE, aligned_block_size_);
return ptr;
}
};
这样每个行情数据块都对齐到缓存行边界。多线程访问时,不会因为地址错位而触发额外的缓存行加载。
3.6 知识体系总览
下面这张图总结了本章的核心逻辑:
3.7 实战建议
最后,给你几个我在实战中总结的建议:
- 先测再优化:不要一上来就搞复杂的内存池。先用
malloc跑通,然后用 perf 工具看瓶颈在哪里。如果分配次数不多,malloc也够用。 - 线程局部存储:每个线程一个内存池,彻底避免锁竞争。我见过一个项目,用全局内存池加锁,性能还不如
malloc。 - 监控池使用率:加个计数器,记录分配和释放次数。如果池子频繁空,说明块数不够;如果长期用不满,说明块数太多浪费内存。
- 小心内存泄漏:内存池虽然快,但释放不干净会导致内存泄漏。建议在析构函数里检查空闲链表长度,和初始块数对比,不一致就打印警告。
避坑指南:我曾经在内存池里用 reinterpret_cast 把空闲链表指针存在用户数据区。结果用户数据区只有 8 字节,链表指针占了 8 字节,用户数据根本没地方放。嗯,后来我改成在块头部预留 8 字节给链表用,用户数据从偏移 8 开始。这个教训告诉我:内存池的块大小一定要考虑链表指针的开销。
好了,内存管理的基础就讲到这里。记住一句话:在低延迟系统里,内存不是用来「管理」的,是用来「规划」的。提前规划好,后面就省心。
公众号:蓝海数据掘金营,微信deep3321