4、内存映射文件:mmap系统调用详解、MAP_SHARED与MAP_PRIVATE区别、大文件映射策略

好,咱们今天聊聊内存映射文件。说实话,这是多进程架构里我最喜欢的一个机制。为什么?因为它太巧妙了——把磁盘文件和进程虚拟地址空间直接挂钩,省去了传统read/write那套繁琐的拷贝流程。

我刚开始接触mmap时,其实挺困惑的。心想这不就是另一种读写文件的方式吗?后来在做一个日志分析系统时,需要同时处理几十个进程读取同一个GB级别的日志文件,传统IO方式下磁盘都快被抡冒烟了。换成mmap后,性能直接翻了三倍。嗯,从那以后我就彻底服了。

4.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、PROT_EXEC,按需组合。
  • flags:映射类型。MAP_SHARED还是MAP_PRIVATE,这是核心区别,下面细说。
  • fd:文件描述符。映射哪个文件就传哪个fd。
  • offset:从文件的哪个位置开始映射。必须是页对齐的。

调用成功后,返回映射区的起始地址。之后你就能像操作内存一样操作文件了。比如:

// 映射一个文件到内存
int fd = open("data.bin", O_RDWR);
size_t len = 4096;
char *mapped = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);

// 直接读写内存,就是在读写文件
mapped[0] = 'A';  // 修改立即同步到文件
printf("%c\n", mapped[0]);

// 用完记得解除映射
munmap(mapped, len);
close(fd);

你想想看,少了多少中间步骤?不用malloc申请缓冲区,不用read/write系统调用,数据直接在内存和磁盘之间「共享」。这就是mmap的精髓。

我的小技巧: 我习惯在mmap后立即检查返回值是不是MAP_FAILED。很多新手忘了这步,结果操作非法地址直接段错误。血的教训啊。

4.2 MAP_SHARED与MAP_PRIVATE区别

这两个标志,说白了就是「你的修改要不要让别人看见」。

特性 MAP_SHARED MAP_PRIVATE
修改是否写回文件 是,立即或延迟写回 否,只修改内存中的副本
其他进程是否可见 是,共享同一物理页 否,每个进程有独立副本
典型用途 进程间共享数据、大文件读写 私有数据、代码加载、COW机制
内存开销 低,共享物理页 高,写时复制产生新页

MAP_SHARED:多个进程映射同一个文件,修改立即可见。我在做分布式缓存时就用这个,多个worker进程共享一个内存映射区,数据更新后所有进程都能读到最新值,比用消息队列快多了。

MAP_PRIVATE:每个进程看到的是文件的「快照」。你修改了,只改自己的副本,不影响别人,也不写回文件。这其实利用了写时复制(Copy-on-Write)技术。我记得有一次调试一个诡异的bug,一个进程改了数据另一个进程却看不到,查了半天才发现是用了MAP_PRIVATE。嗯,这种坑踩过一次就记住了。

注意: 用MAP_PRIVATE时,修改不会写回磁盘。如果你以为改了内存就等于改了文件,那数据就丢了。我曾经见过有人用MAP_PRIVATE做持久化存储,结果程序崩溃后所有修改都消失了。别犯这种错。

4.3 大文件映射策略

大文件映射,这里的大是指超过物理内存甚至超过虚拟地址空间的文件。比如几十GB的日志文件、视频文件。直接整个映射?不行,32位系统虚拟地址空间才4GB,64位虽然大,但物理内存扛不住。

我的策略是:分片映射,按需加载

具体做法:

  1. 先获取文件大小,确定总长度。
  2. 设定一个窗口大小,比如256MB或1GB,根据你的物理内存来定。
  3. 只映射当前需要的那个窗口区域。
  4. 访问完一个窗口,munmap掉,再mmap下一个窗口。

代码示例:

#define WINDOW_SIZE (256 * 1024 * 1024)  // 256MB窗口

void process_large_file(const char *path) {
    int fd = open(path, O_RDONLY);
    off_t file_size = lseek(fd, 0, SEEK_END);
    
    off_t offset = 0;
    while (offset < file_size) {
        size_t map_len = min(WINDOW_SIZE, file_size - offset);
        char *data = mmap(NULL, map_len, PROT_READ, MAP_PRIVATE, fd, offset);
        
        // 处理这个窗口的数据
        process_data(data, map_len);
        
        munmap(data, map_len);
        offset += map_len;
    }
    close(fd);
}

这里有几个要点:

  • 窗口大小要合理:太小了频繁mmap/munmap,性能差;太大了内存压力大。我一般取物理内存的1/4到1/2。
  • 注意页对齐:offset必须是页大小的整数倍。如果不对齐,mmap会失败。我习惯用offset & ~(pagesize - 1)来对齐。
  • 考虑预读:如果访问模式是顺序的,可以用madvise告诉内核「我要顺序访问」,内核会帮你预取数据。

避坑指南: 我曾经处理一个200GB的日志文件,直接mmap整个文件,结果系统直接OOM killer把进程杀了。后来改成256MB窗口分片映射,稳得很。记住:不要贪心,分片才是王道

另外,如果你需要随机访问大文件的不同位置,可以维护一个「窗口缓存」,把最近访问的几个窗口映射保留下来,避免频繁切换。这有点像操作系统的页面缓存,但你自己控制粒度,更灵活。

最后提一句,mmap不是银弹。对于小文件(几KB那种),传统read/write的开销可以忽略,用mmap反而因为页对齐浪费空间。我一般文件超过1MB才考虑mmap。你想想看,是不是这个理?