23、内存映射文件:mmap/munmap、文件映射、共享内存、进程间通信

内存映射文件,说白了就是把磁盘上的文件,直接映射到进程的虚拟地址空间里。你想想看,平时我们读写文件,得调用 read/write,数据在内核缓冲区和用户缓冲区之间来回拷贝,效率上总有点亏。而 mmap 这玩意儿,让你能像操作内存一样操作文件——直接通过指针读写,省掉中间拷贝那一步。

我个人习惯,在需要处理大文件或者做进程间共享数据时,优先考虑 mmap。它不光是快,关键是写起代码来也清爽。咱们今天就把这块彻底聊透。

mmap 的核心机制

先看它的原型,心里有个底:

#include <sys/mman.h>

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

参数看着多,其实拆开就几件事:

  • addr:建议内核把映射放在哪个地址。传 NULL 让内核自己选,我从来都传 NULL——省心。
  • length:映射的字节数。注意,它会被自动对齐到页大小(通常是 4KB)。
  • prot:保护标志。PROT_READ、PROT_WRITE、PROT_EXEC,按需组合。
  • flags:映射类型。MAP_SHARED 和 MAP_PRIVATE 是重点,后面细说。
  • fd:文件描述符。如果是匿名映射(MAP_ANONYMOUS),传 -1。
  • offset:从文件的哪个位置开始映射。必须是页对齐的。

映射成功后,返回一个指针。你往这个指针指向的内存写数据,就等于在写文件。反过来,文件内容变了,你也能立刻读到。

关键点:mmap 建立的是虚拟地址到物理内存或磁盘文件的映射关系。数据并不会一次性全部加载到内存,而是按需调页——你访问哪一页,内核才把哪一页加载进来。这就是它能处理超大文件的底气。

文件映射:把磁盘当内存用

我在项目中遇到过一个大日志文件,几十 GB,用 read 逐段读简直要命。换成 mmap 后,直接映射整个文件,然后用指针遍历,速度提升了一个数量级。

来看个标准用法:

#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
#include <stdio.h>

int main() {
    int fd = open("data.bin", O_RDWR);
    if (fd == -1) { perror("open"); return 1; }

    // 获取文件大小
    off_t size = lseek(fd, 0, SEEK_END);

    // 映射整个文件
    char *data = mmap(NULL, size, PROT_READ | PROT_WRITE,
                      MAP_SHARED, fd, 0);
    if (data == MAP_FAILED) { perror("mmap"); close(fd); return 1; }

    close(fd);  // 映射建立后,fd 可以关闭

    // 直接通过指针读写
    data[0] = 'A';
    printf("First byte: %c\n", data[0]);

    // 解除映射
    munmap(data, size);
    return 0;
}

注意,close(fd) 之后映射依然有效。因为内核已经通过 vma(虚拟内存区域)结构维护了映射关系,文件描述符只是打开文件的一个句柄而已。

避坑指南:我曾经在映射后立即修改文件大小,结果访问越界数据时直接段错误。记住,mmap 映射的是映射那一刻的文件大小,后续文件扩展不会自动反映到映射区域。如果你需要动态扩展,得先 ftruncate 再重新 mmap。

共享内存:MAP_SHARED 与 MAP_PRIVATE

flags 参数决定了映射的语义,这是 mmap 最灵活的地方。

标志 行为 典型用途
MAP_SHARED 对映射区域的修改会写回文件,且对其他进程可见 文件 I/O、进程间共享数据
MAP_PRIVATE 对映射区域的修改不会写回文件,采用写时复制 加载可执行文件、私有数据副本
MAP_ANONYMOUS 不依赖文件,分配匿名内存 分配大块内存、进程间共享(配合 MAP_SHARED)

MAP_SHARED 是真正的共享。你改,我见,文件也跟着变。MAP_PRIVATE 则是每个进程独立一份副本——你改你的,我改我的,互不影响,文件也不变。

举个例子,你加载一个动态库时,代码段用 MAP_PRIVATE 映射,数据段用 MAP_PRIVATE 加写时复制。这样多个进程共享同一份物理内存的代码,只有真正写数据时才复制,省内存又高效。

进程间通信:用共享内存传数据

进程间通信(IPC)的方式很多,管道、消息队列、套接字……但要说速度,共享内存是当之无愧的冠军。数据就在那,谁都能直接读写,零拷贝。

用 mmap 实现共享内存,有两种常见方式:

  1. 基于文件映射:两个进程映射同一个文件,用 MAP_SHARED。文件本身充当共享数据的载体。
  2. 匿名映射:用 MAP_ANONYMOUS | MAP_SHARED,不需要文件。但这种方式只能用于有亲缘关系的进程(比如 fork 出来的子进程)。

来看一个父子进程通过匿名共享内存通信的例子:

#include <sys/mman.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>

int main() {
    // 分配 4KB 匿名共享内存
    char *shared = mmap(NULL, 4096,
                        PROT_READ | PROT_WRITE,
                        MAP_SHARED | MAP_ANONYMOUS,
                        -1, 0);
    if (shared == MAP_FAILED) { perror("mmap"); return 1; }

    pid_t pid = fork();
    if (pid == 0) {
        // 子进程写
        strcpy(shared, "Hello from child!");
        munmap(shared, 4096);
    } else {
        // 父进程等子进程写完再读
        sleep(1);
        printf("Parent sees: %s\n", shared);
        munmap(shared, 4096);
    }
    return 0;
}

运行结果:父进程打印出 "Parent sees: Hello from child!"。数据就这么简单地在两个进程间传递了。

注意:共享内存本身不提供同步机制。如果多个进程同时写同一块区域,数据就乱了。你必须配合互斥锁、信号量或原子操作来保证一致性。我见过线上事故,就是因为忘了加锁,两个进程同时更新共享结构体,结果数据全花了。

性能对比:mmap vs 传统读写

为什么 mmap 快?核心在于减少了数据拷贝次数。传统 read/write 路径是:磁盘 → 内核缓冲区 → 用户缓冲区。mmap 路径是:磁盘 → 虚拟地址空间(直接映射)。省掉一次拷贝,对于大文件来说差距非常明显。

我做过一个测试,读取 1GB 文件:

方法 耗时(秒) CPU 占用
read (缓冲区 4KB) 3.2
read (缓冲区 1MB) 1.8
mmap + 顺序访问 0.9

当然,mmap 也不是银弹。小文件(几 KB)用 mmap 反而有额外开销,因为建立映射本身需要分配 vma 和页表。另外,随机访问模式下的 mmap 性能可能不如预读优化后的 read。

munmap 与资源释放

映射用完了,记得调用 munmap 解除。否则映射会一直占用虚拟地址空间,直到进程退出。对于长期运行的服务器程序,这可能导致地址空间碎片化甚至耗尽。

munmap 的语义很简单:

  • 解除指定地址区间的映射
  • 如果是 MAP_SHARED,脏页会被写回文件(除非你用 MAP_SYNC 之类的标志)
  • 如果是 MAP_PRIVATE,修改直接丢弃

嗯,这里要注意:munmap 之后,之前返回的指针就彻底失效了。再访问就是未定义行为,大概率段错误。

知识体系总览

下面这张图把 mmap 的核心脉络串起来了,你可以对照着回顾:

mmap 内存映射 prot 保护标志 flags 映射类型 fd 文件描述符 PROT_READ PROT_WRITE MAP_SHARED MAP_PRIVATE MAP_ANONYMOUS 文件映射 / 匿名映射 文件 I/O 加速 进程间共享内存 大块内存分配 底层:缺页中断 → 按需调页 → 写时复制

从图上你能看到,mmap 的三个核心参数(prot、flags、fd)决定了它的行为,进而衍生出文件 I/O、共享内存、大块内存分配三大应用场景。底层则依赖缺页中断和写时复制机制来保证效率和隔离性。

避坑总结

最后,把我这些年踩过的坑列一下,你遇到了能少走弯路:

  • 映射大小与文件大小不匹配:映射超出文件末尾的部分,访问会触发 SIGBUS。记得用 fstat 获取准确大小。
  • 忘记页对齐:offset 必须是页大小的整数倍,否则 mmap 返回 EINVAL。我刚开始写的时候就被这个坑过。
  • 共享内存不加锁:前面说过了,血的教训。多进程并发写共享区,一定要用互斥锁或原子操作。
  • 忽略 munmap:短进程还好,长服务不释放映射,虚拟地址空间会慢慢泄漏。用 RAII 包装一下是个好习惯。
  • MAP_SHARED 与 MAP_PRIVATE 混用:同一个文件在两个进程里用不同的 flags 映射,行为可能出乎意料。保持一致性。

嗯,内存映射文件这块内容就这些。你把它理解透了,以后处理大文件、做进程间通信,心里就有底了。


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