mmap 不是普通字节数组:映射边界、SIGBUS 与安全更新

内存映射文件的虚拟内存与磁盘边界

mmap 能让程序像访问内存一样读取大文件,省去显式的 read 与缓冲区搬运,常见于索引、模型和进程间共享数据。但映射只是把一段虚拟地址关联到文件,并没有把文件变成永远有效的数组;忽视长度、并发截断和落盘边界,轻则读到旧数据,重则进程直接收到 SIGBUS。

映射建立后发生了什么

创建映射时,内核只建立地址区间与文件偏移的关系。程序首次访问某一页时触发缺页,内核再把对应文件页装入页缓存。映射长度由创建时的参数决定:文件后来变长,旧映射不会自动扩展;文件若被同一 inode 上的 truncate 缩短,映射尾部仍有虚拟地址,却已没有文件内容可支撑,访问那里可能触发 SIGBUS。

映射范围、文件页与截断危险区

两种常见映射的语义也不同:

模式 写入去向 典型用途
MAP_PRIVATE 写时复制,不回写原文件 只读解析、进程私有修改
MAP_SHARED 修改共享页,可回写文件 共享状态、原地更新

MAP_SHARED 只说明修改能够反映到文件,不等于一次多字段更新具有事务性,也不保证掉电后已经持久化。其他进程还可能看到更新到一半的数据。

用一个实验观察 SIGBUS

下面在临时目录创建两页文件并映射;子进程把原文件截短后,父进程访问第二页。在 Linux 上,这通常会让 Python 进程因总线错误退出,所以请只在测试环境运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
workdir=$(mktemp -d)
python3 - "$workdir/data.bin" <<'PY'
import mmap
import os
import sys
import time

path = sys.argv[1]
page = mmap.PAGESIZE
with open(path, "wb") as file:
file.truncate(page * 2)

fd = os.open(path, os.O_RDWR)
view = mmap.mmap(fd, page * 2, access=mmap.ACCESS_WRITE)
if os.fork() == 0:
os.truncate(path, page)
os._exit(0)

os.wait()
time.sleep(0.1)
print(view[page]) # 访问已超出新文件末尾的映射页
PY
rm -rf "$workdir"

try/except 无法可靠兜住这类硬件级故障。真正的防线是让发布者不要原地截断仍可能被映射的文件。

读者与发布者怎样配合

读取端应先打开文件描述符,再用 fstat 获取这个描述符所指对象的长度,并拒绝空文件、异常长度和不完整头部;不要先对路径 stat 再打开,因为路径可能在两步之间被替换。文件格式最好包含魔数、版本、正文长度和校验和,映射后先验证头部声明没有越过实际边界。

发布端处理不可变快照时,优先采用“新文件 + 原子替换”:在同目录写完临时文件,校验并同步内容,再用 rename/os.replace 切换名称。已经打开或映射旧文件的读者仍持有旧 inode,新读者则打开完整的新版本,因此无需对正在使用的对象执行 truncate。若还要求掉电持久性,替换前同步临时文件,替换后同步父目录。

必须原地共享写入时,则需要额外协议,例如:

  1. 固定文件大小,运行期间禁止缩短;
  2. 用锁或序列号协调写者和读者;
  3. 先写正文,最后发布长度与版本;
  4. 读取前后检查序列号,变化则重试;
  5. 持久化场景执行 flush/msync,并按故障承诺评估 fsync。

这些步骤仍不是数据库事务。崩溃恢复、跨页撕裂和硬件持久性都需要单独设计与测试。

上线前的检查清单

  • 映射长度来自已打开文件的 fstat,并设置合理上限;
  • 映射对象不会被其他进程原地缩短;
  • 文件增长后会关闭并重新建立映射;
  • 共享写入有明确的并发协议与完整性校验;
  • 测试覆盖空文件、短文件、替换、截断和进程崩溃;
  • 资源退出路径会关闭映射与文件描述符。

mmap 优化的是访问路径,不会替应用补上边界检查、并发控制和持久化协议。把映射当成带生命周期的文件视图,并用不可变文件加原子替换发布新版本,才能在享受按需分页的同时避开最危险的故障。