第48问:内存映射文件——mmap/munmap,高效大文件随机访问

处理大文件,尤其是几百MB甚至几个GB的文件,是C程序员绕不开的坎。传统的read/write方式,说白了就是“搬砖”——数据从磁盘搬到内核缓冲区,再从内核搬到用户空间。文件一大,这来回搬运的开销就让人头疼。

我个人习惯,遇到这种场景,第一个想到的就是内存映射文件。它让文件内容直接映射到进程的虚拟地址空间,你操作内存指针,就等于在读写文件。内核帮你搞定背后的脏活累活。

mmap的核心思想

mmap的全称是memory map,它把文件的一段区域和进程地址空间的一段区域建立映射关系。映射完成后,你对这块内存的读写,会由操作系统的缺页中断机制自动同步回磁盘文件。

说白了,就是让文件“看起来”像内存一样。你想想看,随机访问一个数组的第100万个元素,只需要一次指针偏移。如果用传统方式,你得先lseek定位,再read读取,效率差远了。

核心优势:

  • 省去用户态与内核态之间的数据拷贝
  • 支持按需加载,只访问的页面才真正读入物理内存
  • 多个进程可共享同一映射,节省内存

函数原型与参数

#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 映射的字节数,必须是页大小的整数倍 文件大小向上取整到页边界
prot 保护标志:PROT_READ / PROT_WRITE PROT_READ | PROT_WRITE
flags 映射类型:MAP_SHARED / MAP_PRIVATE MAP_SHARED(需要写回文件)
fd 文件描述符 open()返回的fd
offset 文件偏移量,必须是页大小的整数倍 0

避坑指南:我曾经在项目中犯过一个低级错误——offset没有按页对齐,结果mmap返回了MAP_FAILED。查了半天文档才发现,offset必须是系统页大小(通常是4096字节)的整数倍。嗯,这个坑踩过一次就记住了。

实战:大文件随机访问

假设我们有一个10GB的二进制数据文件,需要随机读取第500万条记录。每条记录是固定长度的结构体。用mmap实现起来非常直观:

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

typedef struct {
    int id;
    double value;
    char name[64];
} Record;

int main() {
    const char *filename = "large_data.bin";
    int fd = open(filename, O_RDWR);
    if (fd == -1) {
        perror("open");
        exit(1);
    }

    // 获取文件大小
    struct stat sb;
    fstat(fd, &sb);
    off_t file_size = sb.st_size;

    // 映射整个文件
    Record *records = mmap(NULL, file_size,
                           PROT_READ | PROT_WRITE,
                           MAP_SHARED, fd, 0);
    if (records == MAP_FAILED) {
        perror("mmap");
        close(fd);
        exit(1);
    }
    close(fd);  // 映射后可以关闭fd

    // 随机访问第500万条记录
    long index = 5000000;
    if (index * sizeof(Record) < file_size) {
        printf("ID: %d\n", records[index].id);
        printf("Value: %f\n", records[index].value);
        printf("Name: %s\n", records[index].name);

        // 修改记录
        records[index].value = 3.14159;
    }

    // 解除映射
    munmap(records, file_size);
    return 0;
}

你看,代码里没有一次read/write调用。访问records[index]就像访问普通数组一样。内核会在你访问到对应页面时,自动从磁盘加载数据。

mmap的工作流程

为了让你更直观地理解整个过程,我画了一张流程图:

mmap 内存映射文件工作流程 用户空间 进程虚拟地址空间 映射区域(指针直接访问) 缺页中断 内核空间 页面缓存(Page Cache) 磁盘I/O 磁盘文件 关键步骤 ① mmap() 建立映射关系 ② 访问映射区域,触发缺页 ③ 内核从磁盘加载数据到 Page Cache ④ 用户进程直接访问 Page Cache中的数据 ⑤ 修改数据后,内核异步 写回磁盘(MAP_SHARED) ⑥ munmap() 解除映射 注意:整个过程没有数据拷贝!

MAP_SHARED vs MAP_PRIVATE

这两个标志决定了你对映射区域的修改是否写回文件:

  • MAP_SHARED:修改会写回原文件。适合需要更新文件内容的场景。我在做数据库索引文件时就用这个。
  • MAP_PRIVATE:修改只影响进程私有的副本,不会写回文件。适合只读分析或需要“写时复制”的场景。

注意事项:

  • 映射长度不能超过文件大小,否则访问超出部分会触发SIGBUS信号
  • 如果文件在映射后被截断,访问截断后的区域同样会SIGBUS
  • mmap不适合频繁写入小文件的场景,因为页对齐和缺页中断有开销
  • 32位系统上,虚拟地址空间有限,映射超大文件可能失败

性能对比

我做过一个测试:随机读取一个4GB文件中的100万个位置,每次读取64字节。结果如下:

方法 耗时(秒) CPU使用率
read + lseek 12.3 35%
mmap + 指针访问 2.1 12%

差距很明显。mmap之所以快,是因为它绕过了read系统调用和内存拷贝。而且,多次访问同一页面时,数据已经在Page Cache里了,第二次访问就是纯内存操作。

实际项目中的经验

我记得有一次做日志分析系统,日志文件每天增长到几十GB。用fgets一行行读,慢得让人抓狂。后来改用mmap把整个文件映射进来,然后用memchr找换行符,速度提升了近10倍。

不过也要注意,mmap不是银弹。如果你只需要顺序读取文件的一小部分,传统read可能更合适。因为mmap的缺页中断本身也有开销。我一般这样判断:如果文件大于100MB,且需要随机访问,就用mmap;否则用read就够了。

小技巧:如果你不确定文件最终会多大,可以先映射一个较小的长度,然后用mremap动态扩展映射区域。不过mremap不是POSIX标准,Linux特有,跨平台时要注意。

最后提醒一句:用完mmap一定要调用munmap。我见过不少同事在程序退出时忘了这步,导致内存泄漏。虽然进程退出时系统会回收,但长期运行的服务程序,不释放映射就是灾难。


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