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(或页对齐) |
我个人习惯用MAP_SHARED。为什么?因为交易系统里多个进程需要共享同一份行情数据。MAP_SHARED保证了一个进程的修改,其他进程立即可见。这个特性在行情分发场景下太重要了。
举个例子,我们做行情网关时,就用mmap把内存中的行情快照映射给多个交易策略进程。每个策略进程直接读这块内存,延迟从微秒级降到纳秒级。
3.2 虚拟内存与物理内存映射
好,现在说说mmap背后的核心机制——虚拟内存和物理内存的映射关系。
你想想看,每个进程都有独立的虚拟地址空间。32位系统是4GB,64位系统更大。但物理内存是有限的。内核怎么管理这个矛盾?
答案就是页表。
mmap做的事情,说白了就是在进程的虚拟地址空间里划出一块区域,然后通过页表,把这区域映射到物理内存或者文件。
这里有个关键点:mmap不立即分配物理内存。它只是建立了虚拟地址到物理地址的映射关系。真正的物理内存分配,发生在你第一次访问这块内存的时候。
嗯,这就是我们接下来要说的缺页中断。
3.3 缺页中断机制
缺页中断,英文叫Page Fault。这是Linux内核内存管理的核心机制之一。
流程是这样的:
- 进程访问mmap映射的某个地址
- CPU发现页表中该地址没有对应的物理页框
- CPU触发缺页中断,进入内核态
- 内核的缺页中断处理函数开始工作
- 如果是文件映射,内核从磁盘读取数据到物理内存
- 更新页表,建立映射关系
- 返回用户态,进程继续执行
我曾经在优化行情系统时,用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); // 锁定物理内存
为了让你更直观地理解整个流程,我画了一张图:
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);
}
场景二:历史数据回放
做策略回测时,需要快速读取大量历史行情数据。传统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