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的精髓。
4.2 MAP_SHARED与MAP_PRIVATE区别
这两个标志,说白了就是「你的修改要不要让别人看见」。
| 特性 | MAP_SHARED | MAP_PRIVATE |
|---|---|---|
| 修改是否写回文件 | 是,立即或延迟写回 | 否,只修改内存中的副本 |
| 其他进程是否可见 | 是,共享同一物理页 | 否,每个进程有独立副本 |
| 典型用途 | 进程间共享数据、大文件读写 | 私有数据、代码加载、COW机制 |
| 内存开销 | 低,共享物理页 | 高,写时复制产生新页 |
MAP_SHARED:多个进程映射同一个文件,修改立即可见。我在做分布式缓存时就用这个,多个worker进程共享一个内存映射区,数据更新后所有进程都能读到最新值,比用消息队列快多了。
MAP_PRIVATE:每个进程看到的是文件的「快照」。你修改了,只改自己的副本,不影响别人,也不写回文件。这其实利用了写时复制(Copy-on-Write)技术。我记得有一次调试一个诡异的bug,一个进程改了数据另一个进程却看不到,查了半天才发现是用了MAP_PRIVATE。嗯,这种坑踩过一次就记住了。
4.3 大文件映射策略
大文件映射,这里的大是指超过物理内存甚至超过虚拟地址空间的文件。比如几十GB的日志文件、视频文件。直接整个映射?不行,32位系统虚拟地址空间才4GB,64位虽然大,但物理内存扛不住。
我的策略是:分片映射,按需加载。
具体做法:
- 先获取文件大小,确定总长度。
- 设定一个窗口大小,比如256MB或1GB,根据你的物理内存来定。
- 只映射当前需要的那个窗口区域。
- 访问完一个窗口,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。你想想看,是不是这个理?