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 实现共享内存,有两种常见方式:
- 基于文件映射:两个进程映射同一个文件,用 MAP_SHARED。文件本身充当共享数据的载体。
- 匿名映射:用 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)决定了它的行为,进而衍生出文件 I/O、共享内存、大块内存分配三大应用场景。底层则依赖缺页中断和写时复制机制来保证效率和隔离性。
避坑总结
最后,把我这些年踩过的坑列一下,你遇到了能少走弯路:
- 映射大小与文件大小不匹配:映射超出文件末尾的部分,访问会触发 SIGBUS。记得用 fstat 获取准确大小。
- 忘记页对齐:offset 必须是页大小的整数倍,否则 mmap 返回 EINVAL。我刚开始写的时候就被这个坑过。
- 共享内存不加锁:前面说过了,血的教训。多进程并发写共享区,一定要用互斥锁或原子操作。
- 忽略 munmap:短进程还好,长服务不释放映射,虚拟地址空间会慢慢泄漏。用 RAII 包装一下是个好习惯。
- MAP_SHARED 与 MAP_PRIVATE 混用:同一个文件在两个进程里用不同的 flags 映射,行为可能出乎意料。保持一致性。
嗯,内存映射文件这块内容就这些。你把它理解透了,以后处理大文件、做进程间通信,心里就有底了。