3、mmap内存映射:从磁盘到内存的零拷贝之路
各位同学,今天我们来聊聊mmap。说实话,这个技术我在量化系统里用了快十年了。记得刚入行那会儿,我还在用传统的read()读行情文件,结果每次开盘前加载快照数据都要等好几秒。后来一位老前辈跟我说:「试试mmap吧。」这一试,就再也没回头。
3.1 mmap工作原理:它到底干了什么?
mmap,全称memory map,内存映射文件。说白了,就是把一个文件或者其它对象映射到进程的地址空间。你想想看,传统方式读文件,你得先申请一块用户态缓冲区,然后调用read()让内核把数据从磁盘拷到内核缓冲区,再拷到你的用户缓冲区——这中间至少两次拷贝。
但mmap不一样。它直接在进程的虚拟地址空间中开辟一块区域,然后让这块区域和文件内容建立映射关系。之后你操作这块内存,就相当于直接操作文件。嗯,这里要注意:mmap并没有真正把文件内容加载到内存,它只是建立了一个映射关系。
核心要点:mmap建立的是虚拟地址与文件内容的映射关系,而非立即加载数据。真正的数据加载发生在你第一次访问映射区域时——通过缺页中断完成。
3.2 虚拟内存与物理内存映射:一张图说清楚
我画了一张图,帮你理解mmap的整个数据流。这张图我每次培训都会拿出来讲,因为它真的能说清楚问题。
这张图你看懂了吗?我来解释一下关键流程:
- 建立映射:调用mmap()后,内核在进程的虚拟地址空间中分配一段区域,并建立虚拟地址到文件偏移的映射关系。此时物理内存中还没有数据。
- 首次访问:当你第一次读取映射区域中的某个地址时,CPU发现该虚拟页对应的页表项中「存在位」为0,于是触发缺页中断。
- 加载数据:内核在缺页中断处理中,从磁盘读取对应的文件页到物理内存的页缓存中,然后更新页表,将虚拟页映射到刚加载的物理页框。
- 后续访问:再次访问同一页时,页表已经建立好映射,CPU直接通过TLB(快表)找到物理地址,无需中断。
我的经验:在行情系统中,快照文件通常几百MB到几个GB。如果用read()一次性读完,不仅慢,还浪费内存——因为很多数据你可能根本用不到。mmap的按需加载特性,说白了就是「用多少加载多少」,这在处理海量行情数据时特别有用。
3.3 页表与缺页中断:深入内核看看
页表是CPU和操作系统共同维护的一个数据结构。每个进程都有自己的页表,记录了虚拟页号到物理页框号的映射关系。嗯,这里有个关键点:页表项里除了物理地址,还包含权限位、存在位、脏位、访问位等控制信息。
缺页中断,就是当CPU访问一个虚拟地址时,发现对应的页表项中「存在位」为0,于是触发一个异常。内核捕获这个异常后,会执行以下步骤:
- 检查访问是否合法(比如是不是越界了)
- 找到一个空闲的物理页框
- 从磁盘读取数据到该页框
- 更新页表项,设置存在位为1
- 返回用户态,重新执行触发缺页的指令
我曾经在调试一个行情系统时遇到过一个问题:开盘后行情数据更新很快,mmap映射的文件频繁触发缺页中断,导致系统响应变慢。后来我用了mlockall()把映射区域锁定在物理内存中,避免了缺页中断的开销。但要注意,这招不能乱用——锁太多内存会导致系统内存紧张。
避坑指南:mmap的缺页中断是在用户态触发的,但处理过程在内核态。如果频繁访问未映射的页面,会导致大量上下文切换。我曾经见过一个同事,用mmap映射了一个10GB的文件,然后逐字节遍历——结果系统卡成狗。正确的做法是按页(4KB)访问,或者用madvise()给内核一些访问模式的提示。
3.4 mmap vs read:性能对比
我们来做个对比。假设你要读取一个1GB的行情快照文件:
| 对比维度 | 传统read() | mmap |
|---|---|---|
| 数据拷贝次数 | 2次(磁盘→内核→用户) | 1次(磁盘→物理内存,用户直接访问) |
| 内存占用 | 需要用户缓冲区 + 内核缓冲区 | 仅内核页缓存(可共享) |
| 加载方式 | 一次性全部加载 | 按需加载(缺页时加载) |
| 多进程共享 | 每个进程有独立缓冲区,浪费内存 | 共享同一份物理内存页 |
| 系统调用次数 | 每次read()都需要系统调用 | 仅mmap()一次系统调用,后续访问无系统调用 |
| 适合场景 | 小文件、顺序读取 | 大文件、随机访问、多进程共享 |
你看这个表格就明白了。read()每次调用都要陷入内核,然后数据从磁盘到内核缓冲区,再从内核缓冲区拷贝到用户缓冲区。而mmap只在建立映射时有一次系统调用,之后访问数据就像访问普通内存一样,完全不需要系统调用。
我做过一个测试:用read()读取一个500MB的Level-2行情文件,耗时约1.2秒;用mmap映射后首次访问耗时约0.8秒(包含缺页中断时间),后续访问几乎零延迟。在量化交易中,这零点几秒的差距可能就是盈利和亏损的分水岭。
3.5 mmap在行情快照中的应用
在量化系统中,行情快照通常以固定格式存储在文件中。比如深交所的Level-2行情快照,每秒钟会生成一个快照文件,包含所有股票的十档行情、逐笔成交等信息。这些文件的特点是:
- 体积大:一个快照文件可能几十到几百MB
- 结构固定:每条记录的长度和偏移是确定的
- 随机访问:策略程序可能只关心某几只股票的数据
- 多进程共享:多个策略进程可能需要同时读取同一份快照
mmap完美契合这些需求。你可以把整个快照文件映射到内存,然后通过指针直接访问任意位置的记录。而且多个进程映射同一个文件时,内核会共享物理内存页,节省了大量内存。
核心优势:在行情快照场景中,mmap让程序能以「内存访问」的速度读取磁盘文件,同时享受操作系统的页缓存管理和多进程共享特性。说白了,就是既快又省内存。
3.6 实战:用mmap读取Level-2行情文件
好了,理论讲完了,我们来写点真东西。下面是一个用C++实现的mmap读取Level-2行情快照的示例:
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstdio>
#include <cstring>
#include <cstdint>
// Level-2行情快照结构(简化版)
#pragma pack(push, 1)
struct SnapshotHeader {
uint32_t magic; // 魔数,用于校验
uint32_t timestamp; // 时间戳
uint32_t stock_count; // 股票数量
uint32_t record_size; // 每条记录大小
};
struct StockSnapshot {
char code[6]; // 股票代码
uint64_t volume; // 成交量
double price; // 最新价
double bid[10]; // 十档买价
double ask[10]; // 十档卖价
uint64_t bid_vol[10]; // 十档买量
uint64_t ask_vol[10]; // 十档卖量
};
#pragma pack(pop)
class MmapSnapshotReader {
private:
int fd_;
void* mapped_;
size_t file_size_;
SnapshotHeader* header_;
StockSnapshot* records_;
public:
MmapSnapshotReader() : fd_(-1), mapped_(nullptr),
file_size_(0), header_(nullptr), records_(nullptr) {}
bool open(const char* filename) {
// 1. 打开文件
fd_ = ::open(filename, O_RDONLY);
if (fd_ == -1) {
perror("open failed");
return false;
}
// 2. 获取文件大小
struct stat st;
if (fstat(fd_, &st) == -1) {
perror("fstat failed");
::close(fd_);
return false;
}
file_size_ = st.st_size;
// 3. mmap映射整个文件
mapped_ = mmap(nullptr, file_size_, PROT_READ,
MAP_SHARED, fd_, 0);
if (mapped_ == MAP_FAILED) {
perror("mmap failed");
::close(fd_);
return false;
}
// 4. 关闭文件描述符(映射已建立,fd可以关闭)
::close(fd_);
fd_ = -1;
// 5. 解析头部
header_ = static_cast<SnapshotHeader*>(mapped_);
if (header_->magic != 0x4C32584D) { // "L2XM" 魔数校验
fprintf(stderr, "Invalid magic number: 0x%X\n", header_->magic);
munmap(mapped_, file_size_);
return false;
}
// 6. 计算记录起始位置
records_ = reinterpret_cast<StockSnapshot*>(
static_cast<char*>(mapped_) + sizeof(SnapshotHeader)
);
printf("Snapshot loaded: %u stocks, %u bytes each\n",
header_->stock_count, header_->record_size);
return true;
}
// 按索引访问股票快照
const StockSnapshot* get_stock(uint32_t index) const {
if (index >= header_->stock_count) return nullptr;
return &records_[index];
}
// 按股票代码查找(二分查找,假设已排序)
const StockSnapshot* find_stock(const char* code) const {
// 这里简化处理,实际应用可以用哈希表或二分查找
for (uint32_t i = 0; i < header_->stock_count; ++i) {
if (strncmp(records_[i].code, code, 6) == 0) {
return &records_[i];
}
}
return nullptr;
}
void close() {
if (mapped_ != nullptr && mapped_ != MAP_FAILED) {
munmap(mapped_, file_size_);
mapped_ = nullptr;
}
if (fd_ != -1) {
::close(fd_);
fd_ = -1;
}
}
~MmapSnapshotReader() {
close();
}
};
// 使用示例
int main() {
MmapSnapshotReader reader;
if (!reader.open("snapshot_20240101.dat")) {
return 1;
}
// 直接通过指针访问,零拷贝!
const StockSnapshot* stock = reader.find_stock("000001");
if (stock) {
printf("Stock: %s, Price: %.2f, Volume: %lu\n",
stock->code, stock->price, stock->volume);
// 访问十档行情
for (int i = 0; i < 10; ++i) {
printf("Bid[%d]: %.2f @ %lu | Ask[%d]: %.2f @ %lu\n",
i, stock->bid[i], stock->bid_vol[i],
i, stock->ask[i], stock->ask_vol[i]);
}
}
reader.close();
return 0;
}
这段代码的核心逻辑很简单:
- 用
mmap()把整个快照文件映射到进程地址空间 - 通过指针直接访问文件内容,就像访问普通内存一样
- 映射完成后立即关闭文件描述符——因为映射已经建立,fd不再需要
- 使用
MAP_SHARED标志,这样多个进程映射同一文件时共享物理内存
我的建议:在实际的量化系统中,我通常会在开盘前把所有快照文件mmap好,然后策略程序直接通过指针访问。这样开盘后数据读取的延迟几乎为零。另外,记得用madvise(mapped_, file_size_, MADV_WILLNEED)提前告诉内核你即将访问这些页面,让内核提前加载,避免运行时缺页中断。
嗯,关于mmap的内容就讲到这里。记住一句话:在行情数据处理中,能用mmap就别用read。它带来的性能提升是实实在在的,而且代码写起来也更简洁。下一章我们会聊聊零拷贝技术的另一个重要成员——sendfile,它在网络传输场景下能发挥巨大作用。