3、mmap内存映射:从磁盘到内存的零拷贝之路

各位同学,今天我们来聊聊mmap。说实话,这个技术我在量化系统里用了快十年了。记得刚入行那会儿,我还在用传统的read()读行情文件,结果每次开盘前加载快照数据都要等好几秒。后来一位老前辈跟我说:「试试mmap吧。」这一试,就再也没回头。

3.1 mmap工作原理:它到底干了什么?

mmap,全称memory map,内存映射文件。说白了,就是把一个文件或者其它对象映射到进程的地址空间。你想想看,传统方式读文件,你得先申请一块用户态缓冲区,然后调用read()让内核把数据从磁盘拷到内核缓冲区,再拷到你的用户缓冲区——这中间至少两次拷贝。

但mmap不一样。它直接在进程的虚拟地址空间中开辟一块区域,然后让这块区域和文件内容建立映射关系。之后你操作这块内存,就相当于直接操作文件。嗯,这里要注意:mmap并没有真正把文件内容加载到内存,它只是建立了一个映射关系。

核心要点:mmap建立的是虚拟地址与文件内容的映射关系,而非立即加载数据。真正的数据加载发生在你第一次访问映射区域时——通过缺页中断完成。

3.2 虚拟内存与物理内存映射:一张图说清楚

我画了一张图,帮你理解mmap的整个数据流。这张图我每次培训都会拿出来讲,因为它真的能说清楚问题。

mmap内存映射数据流 磁盘文件 Level-2行情快照 snapshot_20240101.dat 缺页中断时 按页加载(4KB) 物理内存(页缓存) Page Cache 4KB页框 页表映射 虚拟地址→物理地址 进程 虚拟地址空间 mmap区域 页表(Page Table) 虚拟页号 → 物理页框号 | 权限位 | 存在位 | 脏位 存在位=0 → 触发缺页中断 → 从磁盘加载 缺页中断处理流程 1. CPU检测存在位=0 2. 触发缺页异常 3. 内核从磁盘加载数据到物理内存 mmap vs read:read需要两次拷贝(磁盘→内核→用户),mmap只需要一次(磁盘→物理内存,通过页表直接访问) ✅ mmap优势:减少一次内存拷贝 + 按需加载 + 多进程共享物理内存

这张图你看懂了吗?我来解释一下关键流程:

  1. 建立映射:调用mmap()后,内核在进程的虚拟地址空间中分配一段区域,并建立虚拟地址到文件偏移的映射关系。此时物理内存中还没有数据。
  2. 首次访问:当你第一次读取映射区域中的某个地址时,CPU发现该虚拟页对应的页表项中「存在位」为0,于是触发缺页中断
  3. 加载数据:内核在缺页中断处理中,从磁盘读取对应的文件页到物理内存的页缓存中,然后更新页表,将虚拟页映射到刚加载的物理页框。
  4. 后续访问:再次访问同一页时,页表已经建立好映射,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,它在网络传输场景下能发挥巨大作用。


专注资料整理