
mmap 能让程序像访问内存一样读取大文件,省去显式的 read 与缓冲区搬运,常见于索引、模型和进程间共享数据。但映射只是把一段虚拟地址关联到文件,并没有把文件变成永远有效的数组;忽视长度、并发截断和落盘边界,轻则读到旧数据,重则进程直接收到 SIGBUS。
映射建立后发生了什么
创建映射时,内核只建立地址区间与文件偏移的关系。程序首次访问某一页时触发缺页,内核再把对应文件页装入页缓存。映射长度由创建时的参数决定:文件后来变长,旧映射不会自动扩展;文件若被同一 inode 上的 truncate 缩短,映射尾部仍有虚拟地址,却已没有文件内容可支撑,访问那里可能触发 SIGBUS。

两种常见映射的语义也不同:
| 模式 | 写入去向 | 典型用途 |
|---|---|---|
MAP_PRIVATE |
写时复制,不回写原文件 | 只读解析、进程私有修改 |
MAP_SHARED |
修改共享页,可回写文件 | 共享状态、原地更新 |
MAP_SHARED 只说明修改能够反映到文件,不等于一次多字段更新具有事务性,也不保证掉电后已经持久化。其他进程还可能看到更新到一半的数据。
用一个实验观察 SIGBUS
下面在临时目录创建两页文件并映射;子进程把原文件截短后,父进程访问第二页。在 Linux 上,这通常会让 Python 进程因总线错误退出,所以请只在测试环境运行:
1 | workdir=$(mktemp -d) |
try/except 无法可靠兜住这类硬件级故障。真正的防线是让发布者不要原地截断仍可能被映射的文件。
读者与发布者怎样配合
读取端应先打开文件描述符,再用 fstat 获取这个描述符所指对象的长度,并拒绝空文件、异常长度和不完整头部;不要先对路径 stat 再打开,因为路径可能在两步之间被替换。文件格式最好包含魔数、版本、正文长度和校验和,映射后先验证头部声明没有越过实际边界。
发布端处理不可变快照时,优先采用“新文件 + 原子替换”:在同目录写完临时文件,校验并同步内容,再用 rename/os.replace 切换名称。已经打开或映射旧文件的读者仍持有旧 inode,新读者则打开完整的新版本,因此无需对正在使用的对象执行 truncate。若还要求掉电持久性,替换前同步临时文件,替换后同步父目录。
必须原地共享写入时,则需要额外协议,例如:
- 固定文件大小,运行期间禁止缩短;
- 用锁或序列号协调写者和读者;
- 先写正文,最后发布长度与版本;
- 读取前后检查序列号,变化则重试;
- 持久化场景执行
flush/msync,并按故障承诺评估fsync。
这些步骤仍不是数据库事务。崩溃恢复、跨页撕裂和硬件持久性都需要单独设计与测试。
上线前的检查清单
- 映射长度来自已打开文件的
fstat,并设置合理上限; - 映射对象不会被其他进程原地缩短;
- 文件增长后会关闭并重新建立映射;
- 共享写入有明确的并发协议与完整性校验;
- 测试覆盖空文件、短文件、替换、截断和进程崩溃;
- 资源退出路径会关闭映射与文件描述符。
mmap 优化的是访问路径,不会替应用补上边界检查、并发控制和持久化协议。把映射当成带生命周期的文件视图,并用不可变文件加原子替换发布新版本,才能在享受按需分页的同时避开最危险的故障。