第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的工作流程
为了让你更直观地理解整个过程,我画了一张流程图:
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