第四章:内存管理与缓存优化

内存管理,说白了就是跟延迟赛跑。我做了这么多年低延迟系统,最深的体会就是:内存访问的代价,往往比CPU计算高出一个数量级。你想想看,CPU一个时钟周期能处理好几条指令,但去内存里取个数据,可能要等几十甚至上百个周期。这就是所谓的「内存墙」。

今天咱们就聊聊怎么翻过这堵墙。我会从三个角度切入:内存池设计、伪共享的避免、大页内存的配置。这三个点,每一个我都踩过坑,也都有实战经验可以分享。

核心观点:高频拆单系统的延迟,70%以上出在内存访问上。优化内存,就是优化命脉。

内存管理与缓存优化 内存池设计 避免伪共享 大页内存配置 预分配策略 无锁回收 Cache Line对齐 填充(Padding) TLB命中率 2MB/1GB页 目标:减少TLB Miss + 避免Cache颠簸 + 降低分配开销

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命中率。这三件事做好了,系统的延迟稳定性会有质的飞跃。

我见过太多团队,花大量精力优化业务逻辑,却忽略了内存层面的问题。结果呢?业务逻辑再快,也被内存访问拖了后腿。希望今天的分享能帮你少走一些弯路。

如果你在实际项目中遇到内存相关的问题,欢迎随时交流。毕竟,这些坑我都踩过,能帮你省点时间也是好的。


专注资料整理