第四章:内存分配与缓存优化

内存,在高频交易系统里,从来都不是「存数据的地方」那么简单。它更像是整个系统的命脉。我见过太多团队,算法写得漂亮,网络延迟压到微秒级,结果一跑起来,卡在内存访问上——嗯,那种感觉,就像赛车装了个自行车轮胎。

这一章,我们来聊聊内存分配与缓存优化。说白了,就是让数据在正确的时间、正确的位置,以最快的速度被CPU拿到。

4.1 内存池设计:别再让malloc拖后腿

先问个问题:你的交易系统里,每笔订单进来,是不是都调一次 malloc?如果是,那你可能已经在亏钱了。

malloc 本身不慢,但它不可控。你不知道它什么时候会触发系统调用,什么时候会去内核里翻找空闲内存。在高频场景下,这种不确定性就是灾难。

我个人的习惯是:预分配,然后复用。这就是内存池的核心思想。

内存池设计要点:

  • 固定大小块:把内存切成固定大小的块,比如64字节、128字节。避免碎片化。
  • 无锁分配:用原子操作代替互斥锁。每个线程有自己的本地缓存。
  • 回收不释放:对象用完了,放回池子里,而不是还给操作系统。

我曾经在一个做市商项目里,把订单对象的内存分配从 malloc 改成内存池。结果呢?单笔订单处理时间从800纳秒降到了120纳秒。你没看错,差了将近7倍。

// 一个简单的无锁内存池示例
struct MemoryPool {
    char* pool;          // 预分配的大块内存
    std::atomic<int> free_index;  // 空闲块索引

    void* allocate() {
        int idx = free_index.fetch_add(1);
        return pool + idx * BLOCK_SIZE;
    }

    void deallocate(void* ptr) {
        // 什么都不做,或者放回一个回收队列
        // 注意:这里不能真的释放,否则就失去了内存池的意义
    }
};

我的小技巧:内存池的大小,建议按2的幂次来定。比如64、128、256。这样地址对齐更友好,CPU cache line利用率更高。

4.2 预分配策略:把痛苦留在启动时

你有没有遇到过这种情况:系统跑得好好的,突然某个时刻,延迟飙了上去?查了半天,发现是内存分配触发了缺页中断。

为什么会这样?因为操作系统是「懒」的。你申请了100MB内存,它只是给你记了个账,并没有真的给你物理页。等你真正去读写的时候,才触发缺页中断,去分配物理内存。这个过程,少说几十微秒,多则上百微秒。在高频交易里,这就是天塌了。

我建议的做法是:启动时一次性分配,然后全部触摸一遍

// 预分配并触摸所有页面
void* prealloc_and_touch(size_t size) {
    void* ptr = mmap(NULL, size, PROT_READ | PROT_WRITE,
                     MAP_PRIVATE | MAP_ANONYMOUS | MAP_POPULATE, -1, 0);
    // 或者手动触摸
    volatile char* p = (volatile char*)ptr;
    for (size_t i = 0; i < size; i += 4096) {
        p[i] = 0;  // 每个页面写一个字节,触发缺页
    }
    return ptr;
}

注意:预分配不是越多越好。你分配了10GB,但系统物理内存只有8GB,那就会触发swap。嗯,那比缺页中断还可怕。我曾经在一个项目里吃过这个亏,启动时预分配了太多,结果系统开始疯狂换页,延迟直接崩到毫秒级。

4.3 避免缺页中断:让数据「常驻」

缺页中断,说白了就是CPU要找的数据不在物理内存里,得去磁盘上搬。这个代价,你想想看,内存访问是纳秒级,磁盘访问是毫秒级,差了百万倍。

怎么避免?除了上面说的预分配和触摸,还有一个利器:mlock

mlock 可以把指定的内存页锁定在物理内存中,防止被换出到swap。我个人的习惯是,把关键数据结构和热数据路径上的所有内存都锁住。

// 锁定内存页
void* data = malloc(1024 * 1024);  // 1MB
if (mlock(data, 1024 * 1024) != 0) {
    perror("mlock failed");
    // 嗯,这里要处理权限问题,通常需要root或者CAP_IPC_LOCK
}

避坑指南:我曾经在一个生产环境里,忘了给进程加 CAP_IPC_LOCK 权限,结果 mlock 静默失败,返回了-1。系统跑了三天,突然延迟飙升。排查了一整天,才发现是内存被换出了。从那以后,我每次部署都会检查 mlock 的返回值。

4.4 大页内存(HugePages):减少TLB miss

TLB,全称是Translation Lookaside Buffer,用来缓存虚拟地址到物理地址的映射。默认的页面大小是4KB。如果你的工作集很大,TLB很快就会装满,然后每次内存访问都要去查页表——这又是一次延迟。

大页内存,就是把页面大小从4KB变成2MB甚至1GB。这样一来,同样的TLB条目数,能覆盖的内存范围大了512倍甚至更多。

我建议在高频交易系统里,必须使用大页。尤其是订单簿、行情数据这些频繁访问的数据结构。

页面大小 TLB覆盖范围(64条目) 适用场景
4KB 256KB 小数据、栈
2MB 128MB 订单簿、缓存
1GB 64GB 全内存数据库
// 使用大页分配内存
#include <sys/mman.h>

void* alloc_hugepages(size_t size) {
    // 注意:需要先配置系统的大页池
    void* ptr = mmap(NULL, size, PROT_READ | PROT_WRITE,
                     MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB,
                     -1, 0);
    if (ptr == MAP_FAILED) {
        perror("HugePages allocation failed");
        // 回退到普通分配
        ptr = malloc(size);
    }
    return ptr;
}

配置大页:在Linux上,可以通过 /proc/sys/vm/nr_hugepages 来设置大页数量。比如 echo 1024 > /proc/sys/vm/nr_hugepages 会分配1024个2MB的大页,总共2GB。

4.5 缓存行对齐与伪共享:别让两个核打架

现代CPU的缓存,是以「缓存行」为单位的,通常是64字节。当两个线程分别修改两个不同的变量,但这两个变量恰好在同一个缓存行里——嗯,问题就来了。

CPU为了保证缓存一致性,会让这两个核不断地互相通知:「嘿,你改了我的缓存行!」结果就是,明明两个线程操作的是不同的数据,却因为共享了同一个缓存行,性能急剧下降。这就是伪共享。

我遇到过最夸张的一次,一个多线程程序,加了缓存行对齐之后,吞吐量直接翻了3倍。你想想看,只是加了个 alignas,什么都没改。

// 缓存行对齐示例
struct alignas(64) AlignedCounter {
    std::atomic<uint64_t> value;
    // 填充到64字节,避免和其他变量共享缓存行
    char padding[64 - sizeof(std::atomic<uint64_t>)];
};

// 每个线程操作自己的计数器
AlignedCounter counters[8];  // 每个计数器独占一个缓存行

核心原则

  • 读多写少的数据:可以共享缓存行,因为读操作不会触发缓存一致性协议。
  • 写频繁的数据:必须隔离到不同的缓存行,每个变量至少64字节对齐。
  • 热点数据:放在同一个缓存行里,利用空间局部性。

注意:缓存行对齐会增加内存占用。如果你有100万个计数器,每个都对齐到64字节,那光填充就浪费了将近60MB。所以,只在真正热点的数据结构上使用对齐。

知识体系总览

下面这张图,是我自己总结的内存优化知识体系。你可以把它当作一个检查清单,每次做性能调优时,对照着看一遍。

内存分配与缓存优化知识体系 内存性能优化 内存池设计 预分配策略 避免缺页中断 大页内存 HugePages 缓存行对齐与伪共享 固定大小块 无锁分配 启动时分配 触摸所有页 mlock锁定 MAP_POPULATE 2MB/1GB页 减少TLB miss alignas(64) 避免伪共享 目标:可预测的低延迟,零抖动

这张图里,五个分支其实是有顺序的。我个人建议的调优路径是:先搞定内存池和预分配,这是基础;然后处理缺页中断和大页,这是进阶;最后才是缓存行对齐,这是精细活。别一上来就搞缓存行对齐,结果发现内存分配本身都在抖动——那等于白干。

好了,这一章的内容就到这里。记住一句话:内存不是无限的,缓存不是透明的,延迟不是免费的。每一纳秒,都值得你去争取。


公众号:蓝海资料掘金营,微信deep3321