3. mmap内存映射原理:从系统调用到交易实战

大家好,我是你们的讲师。今天我们来聊聊mmap——这个在交易系统里几乎绕不开的零拷贝技术。

说实话,我最早接触mmap是在做高频交易系统的时候。当时团队为了降低行情数据的读取延迟,试了各种办法。最后发现,mmap这玩意儿,用好了是真香。但用不好,坑也不少。

咱们今天就把mmap的底裤扒干净。从系统调用开始,一路深入到内核的缺页中断,最后落到交易系统的实战场景。

3.1 mmap系统调用详解

先看最基础的东西。mmap是个系统调用,原型长这样:

#include <sys/mman.h>

void *mmap(void *addr, size_t length, int prot, int flags,
           int fd, off_t offset);

参数看着多,其实核心就三件事:映射什么、怎么映射、映射到哪

参数 含义 我常用的值
addr 建议的起始地址 NULL(让内核选)
length 映射长度(字节) 文件大小或页对齐
prot 保护标志 PROT_READ | PROT_WRITE
flags 映射类型 MAP_SHARED(交易系统必备)
fd 文件描述符 open()返回的fd
offset 文件偏移 0(或页对齐)
⚠️ 注意:offset必须是页大小的整数倍。我见过新手直接传个非对齐偏移,结果mmap返回MAP_FAILED,一脸懵逼。

我个人习惯用MAP_SHARED。为什么?因为交易系统里多个进程需要共享同一份行情数据。MAP_SHARED保证了一个进程的修改,其他进程立即可见。这个特性在行情分发场景下太重要了。

举个例子,我们做行情网关时,就用mmap把内存中的行情快照映射给多个交易策略进程。每个策略进程直接读这块内存,延迟从微秒级降到纳秒级。

3.2 虚拟内存与物理内存映射

好,现在说说mmap背后的核心机制——虚拟内存和物理内存的映射关系。

你想想看,每个进程都有独立的虚拟地址空间。32位系统是4GB,64位系统更大。但物理内存是有限的。内核怎么管理这个矛盾?

答案就是页表

mmap做的事情,说白了就是在进程的虚拟地址空间里划出一块区域,然后通过页表,把这区域映射到物理内存或者文件。

这里有个关键点:mmap不立即分配物理内存。它只是建立了虚拟地址到物理地址的映射关系。真正的物理内存分配,发生在你第一次访问这块内存的时候。

嗯,这就是我们接下来要说的缺页中断。

核心理解:mmap是"懒加载"的。它只搭桥,不过河。数据真正从磁盘到内存的搬运,是在缺页中断里完成的。

3.3 缺页中断机制

缺页中断,英文叫Page Fault。这是Linux内核内存管理的核心机制之一。

流程是这样的:

  1. 进程访问mmap映射的某个地址
  2. CPU发现页表中该地址没有对应的物理页框
  3. CPU触发缺页中断,进入内核态
  4. 内核的缺页中断处理函数开始工作
  5. 如果是文件映射,内核从磁盘读取数据到物理内存
  6. 更新页表,建立映射关系
  7. 返回用户态,进程继续执行

我曾经在优化行情系统时,用perf工具发现缺页中断占了CPU的15%。当时吓了一跳。后来分析发现,是因为行情数据文件太大,首次访问时触发了大量缺页。

解决方案是什么?预缺页。用mlock()或者MAP_POPULATE标志,让内核在mmap时就提前把数据加载到物理内存。

// 预加载方式1:使用MAP_POPULATE
void *addr = mmap(NULL, file_size, PROT_READ, 
                  MAP_SHARED | MAP_POPULATE, fd, 0);

// 预加载方式2:使用mlock
void *addr = mmap(NULL, file_size, PROT_READ, 
                  MAP_SHARED, fd, 0);
mlock(addr, file_size);  // 锁定物理内存
💡 经验之谈:交易系统里,我建议在初始化阶段就完成所有mmap内存的预加载。别等到交易时段再触发缺页,那延迟你扛不住。

为了让你更直观地理解整个流程,我画了一张图:

mmap内存映射与缺页中断流程 用户空间 进程虚拟地址空间 mmap映射区域 (0x7f...) 用户态访问:读/写映射地址 缺页中断 内核空间 缺页中断处理函数 检查页表 → 分配物理页框 磁盘I/O:读取文件数据 物理内存 物理页框 1 物理页框 2 物理页框 3 物理页框 4 磁盘文件 行情数据文件 (tick.dat) 历史K线数据文件 (kline.dat) 缺页时读取 页表映射

3.4 mmap在交易系统中的应用场景

理论说完了,咱们来点实在的。mmap在交易系统里到底怎么用?我总结了三个核心场景。

场景一:行情数据共享

这是最常见的用法。多个交易策略进程需要同时读取最新的行情数据。

传统做法:每个进程自己读文件,或者通过Socket通信。延迟高,CPU浪费。

mmap做法:行情网关进程把数据写入mmap共享内存,策略进程直接读。零拷贝,零延迟。

// 行情网关(写入端)
int fd = open("tick.shm", O_RDWR | O_CREAT, 0666);
ftruncate(fd, SHM_SIZE);
struct tick_data *tick = mmap(NULL, SHM_SIZE, 
    PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);

while (1) {
    // 从交易所接收行情
    recv_tick_from_exchange(&new_tick);
    // 直接写入共享内存
    memcpy(tick, &new_tick, sizeof(struct tick_data));
    // 其他策略进程立即可见
}

// 策略进程(读取端)
int fd = open("tick.shm", O_RDONLY);
struct tick_data *tick = mmap(NULL, SHM_SIZE, 
    PROT_READ, MAP_SHARED, fd, 0);

while (1) {
    // 直接读取,延迟极低
    process_tick(tick);
}
性能对比:我实测过,mmap共享内存的读取延迟约50-100ns,而Socket通信至少1-2μs。差了20倍。

场景二:历史数据回放

做策略回测时,需要快速读取大量历史行情数据。传统read()系统调用每次都要从磁盘拷贝到用户缓冲区,效率低。

mmap直接把文件映射到内存,回测程序像访问数组一样访问历史数据。配合madvise()给内核访问模式提示,性能还能再提升。

// 回测系统加载历史数据
int fd = open("history.dat", O_RDONLY);
struct kline *data = mmap(NULL, file_size, 
    PROT_READ, MAP_SHARED, fd, 0);

// 告诉内核:我们将顺序访问
madvise(data, file_size, MADV_SEQUENTIAL);

// 像数组一样遍历
for (int i = 0; i < kline_count; i++) {
    process_kline(&data[i]);
}

场景三:进程间状态同步

交易系统里,多个组件需要共享状态信息。比如风控模块的开关状态、订单簿的快照等。

用mmap共享内存做状态同步,比信号量、消息队列都快。而且支持原子操作,保证数据一致性。

⚠️ 避坑指南:我曾经在共享内存里放了一个结构体,里面有个指针。结果不同进程的虚拟地址空间不同,指针指向的内容完全不对。记住:共享内存里不要放指针,用偏移量

3.5 性能调优建议

最后,分享几个我在实战中总结的调优经验:

  • 页大小对齐:mmap的offset和length最好按页对齐(通常是4KB)。不对齐会导致内核做额外处理,影响性能。
  • 使用Huge Pages:对于大块内存映射(比如几百MB的行情数据),开启2MB或1GB的大页,能减少TLB miss,提升访问速度。
  • 避免频繁munmap:mmap/munmap是 heavyweight 操作。我建议在初始化阶段一次性映射好,运行期间不要反复映射。
  • 监控缺页中断:用perf stat -e page-faults监控缺页次数。如果太高,考虑预加载。
💡 我的习惯:每次上线新系统前,我都会用strace跟踪mmap相关的系统调用,确认参数正确。这个习惯帮我避免了好几次线上事故。

好了,mmap的原理和应用就讲到这里。记住一句话:mmap是交易系统零拷贝的基石。理解透了它,后面的共享内存、RDMA等技术学起来就轻松多了。


公众号:蓝海数据掘金营,微信deep3321