第四章:内存管理与缓存优化
内存管理,说白了就是跟延迟赛跑。我做了这么多年低延迟系统,最深的体会就是:内存访问的代价,往往比CPU计算高出一个数量级。你想想看,CPU一个时钟周期能处理好几条指令,但去内存里取个数据,可能要等几十甚至上百个周期。这就是所谓的「内存墙」。
今天咱们就聊聊怎么翻过这堵墙。我会从三个角度切入:内存池设计、伪共享的避免、大页内存的配置。这三个点,每一个我都踩过坑,也都有实战经验可以分享。
核心观点:高频拆单系统的延迟,70%以上出在内存访问上。优化内存,就是优化命脉。
4.1 内存池设计:告别频繁的malloc/free
我刚开始做交易系统的时候,犯过一个很蠢的错误——每个订单来了都去new一个对象,处理完了再delete。结果呢?系统跑了几分钟就开始卡顿。后来一分析,发现malloc和free的耗时占了总延迟的30%以上。这还得了?
内存池的思路其实很简单:提前分配好一大块内存,用的时候直接拿,用完放回去,不归还给操作系统。这样就避免了系统调用的开销。
4.1.1 固定大小内存池
对于拆单系统来说,订单对象的大小通常是固定的。这时候用固定大小内存池最合适。我给大家看一个我常用的实现框架:
// 固定大小内存池的核心结构
struct MemoryPool {
char* pool_start; // 内存池起始地址
char* pool_end; // 内存池结束地址
void* free_list; // 空闲链表头
size_t block_size; // 每个块的大小
size_t pool_size; // 总大小
};
// 分配一个块
void* pool_alloc(MemoryPool* pool) {
if (pool->free_list == NULL) {
return NULL; // 池已耗尽
}
void* block = pool->free_list;
pool->free_list = *(void**)block; // 空闲链表指针
return block;
}
// 回收一个块
void pool_free(MemoryPool* pool, void* block) {
*(void**)block = pool->free_list;
pool->free_list = block;
}
你看,这个实现里没有锁,没有系统调用。分配和回收都只是指针操作,耗时通常在10纳秒以内。我在项目中用这个模式,把订单处理延迟从微秒级降到了纳秒级。
我的经验:内存池的大小怎么定?我一般按峰值流量的1.5倍来预分配。比如系统峰值每秒处理10万笔订单,每笔订单对象占128字节,那内存池大小就是 10万 × 128 × 1.5 ≈ 19MB。这个量级的内存,在服务器上完全不是问题。
4.1.2 无锁回收与线程局部存储
多线程环境下,内存池的并发访问是个大问题。我见过有人用互斥锁保护内存池,结果锁竞争成了新的瓶颈。我的做法是:每个线程维护自己的内存池,用线程局部存储(Thread Local Storage)来实现。
// 线程局部内存池
__thread MemoryPool* tls_pool = NULL;
void* tls_alloc(size_t size) {
if (tls_pool == NULL) {
tls_pool = create_pool(size, 1024); // 每个线程预分配1024个块
}
return pool_alloc(tls_pool);
}
这样做的好处是:完全没有锁竞争。每个线程只管自己的池子,互不干扰。当然,缺点也有——内存利用率会低一些,但为了性能,这点代价完全可以接受。
注意:线程局部内存池在回收时要注意,不能把别的线程分配的内存还到自己的池子里。我一般会在对象头部记录所属线程ID,回收时做校验。曾经因为这个疏忽,导致内存泄漏,排查了好几天。
4.2 避免伪共享:Cache Line Padding
伪共享(False Sharing)是个很隐蔽的性能杀手。我打个比方:就像两个人共用一张桌子,虽然各干各的,但一个人动一下桌子,另一个人就得停下来。
在CPU里,Cache Line是缓存的最小单位,通常是64字节。如果两个线程各自修改的数据恰好落在同一个Cache Line里,即使它们修改的是不同的变量,也会导致缓存一致性协议频繁触发,性能急剧下降。
4.2.1 识别伪共享
我遇到过最典型的场景是:多个线程各自维护一个计数器,这些计数器被定义在一个结构体里。结果呢?每个线程更新自己的计数器时,都把别人的计数器也拖下水了。
// 错误的做法:伪共享
struct PerThreadCounter {
int64_t counter[32]; // 32个线程的计数器紧挨着
};
// 正确的做法:Cache Line对齐
struct alignas(64) PerThreadCounter {
int64_t counter;
char padding[56]; // 填充到64字节
};
你看,加上alignas(64)和padding之后,每个计数器独占一个Cache Line,互不干扰。这个改动,我曾经在一个项目中让性能提升了5倍。
4.2.2 实战中的Padding策略
我个人习惯用宏定义来控制Padding,这样代码更清晰:
#define CACHE_LINE_SIZE 64
#define PAD_CACHE_LINE char __pad[CACHE_LINE_SIZE - sizeof(/* 实际数据大小 */)]
struct alignas(CACHE_LINE_SIZE) OrderBook {
int64_t bid_price;
int64_t bid_volume;
PAD_CACHE_LINE; // 填充到64字节
int64_t ask_price;
int64_t ask_volume;
PAD_CACHE_LINE;
};
这里要注意,PAD_CACHE_LINE的计算要精确。我曾经因为算错大小,导致两个字段还是落在了同一个Cache Line里,性能优化了个寂寞。
核心原则:被不同线程频繁写入的变量,必须放在不同的Cache Line里。只读变量或者很少更新的变量,可以共享Cache Line。
4.3 大页内存配置:HugePages
说到大页内存,我得先讲个故事。有一次我在压测系统,发现内存访问延迟忽高忽低,很不稳定。排查了半天,最后发现是TLB(页表缓存)Miss导致的。普通4KB的页,TLB只能覆盖很小的内存范围,一旦工作集超过TLB容量,就会频繁触发缺页中断。
大页内存(HugePages)就是解决这个问题的。Linux默认支持2MB和1GB的大页。使用大页后,TLB能覆盖的内存范围扩大了512倍(2MB/4KB),TLB Miss率大幅降低。
4.3.1 配置大页内存
在Linux上配置大页内存,我一般这么操作:
# 查看当前大页配置
cat /proc/meminfo | grep Huge
# 配置2MB大页,分配1024个(共2GB)
echo 1024 > /proc/sys/vm/nr_hugepages
# 或者使用sysctl
sysctl -w vm.nr_hugepages=1024
配置完之后,应用程序需要显式使用大页内存。我常用的方式有两种:
| 方式 | 说明 | 适用场景 |
|---|---|---|
| mmap + MAP_HUGETLB | 通过内存映射直接分配大页 | 需要大块连续内存 |
| libhugetlbfs | 通过库拦截malloc,自动使用大页 | 现有代码改造 |
| 透明大页(THP) | 内核自动将普通页合并为大页 | 不想改代码,但性能不稳定 |
避坑指南:我曾经在生产环境用过透明大页(THP),结果发现延迟抖动反而更严重了。因为THP的合并和拆分操作是异步的,有时候会在关键路径上触发内存复制。我的建议是:关掉THP,手动管理大页。
4.3.2 代码中使用大页
下面是我在拆单系统中使用大页的典型代码:
#include <sys/mman.h>
void* alloc_hugepage(size_t size) {
// 对齐到大页边界
size_t aligned_size = (size + HUGE_PAGE_SIZE - 1) & ~(HUGE_PAGE_SIZE - 1);
void* addr = mmap(NULL, aligned_size,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB,
-1, 0);
if (addr == MAP_FAILED) {
// 回退到普通内存
addr = malloc(aligned_size);
}
return addr;
}
这里要注意,大页内存一旦分配,就不能被换出到磁盘。所以分配时要确保物理内存足够,否则mmap会失败。我一般会在系统启动时就分配好所有需要的大页内存,运行时不再动态分配。
我的经验:对于高频拆单系统,我建议把订单池、行情缓存、日志缓冲区这些热点数据都放在大页内存里。非热点数据用普通内存就行。这样既保证了性能,又不会浪费物理内存。
小结
内存管理与缓存优化,说白了就是三件事:减少分配开销、避免缓存冲突、提高TLB命中率。这三件事做好了,系统的延迟稳定性会有质的飞跃。
我见过太多团队,花大量精力优化业务逻辑,却忽略了内存层面的问题。结果呢?业务逻辑再快,也被内存访问拖了后腿。希望今天的分享能帮你少走一些弯路。
如果你在实际项目中遇到内存相关的问题,欢迎随时交流。毕竟,这些坑我都踩过,能帮你省点时间也是好的。