shared_offload_region.py 中的 Linux 知识
这段代码的核心是:多个同一主机上的进程,通过同一个 /dev/shm 文件,把同一份内存映射到各自的虚拟地址空间。
进程 A 的虚拟地址 ─┐
进程 B 的虚拟地址 ─┼─ mmap(MAP_SHARED) ── tmpfs 内存页
进程 C 的虚拟地址 ─┘ │
/dev/shm/xxx.mmap
1. /dev/shm:为什么既像文件又像内存
/dev/shm 通常是 Linux tmpfs 的挂载点。它有文件系统的接口:路径、文件名、权限、文件大小;但文件内容主要存放在内存中,内存压力下也可能被 swap 到磁盘。tmpfs 不是持久化磁盘,卸载或重启后内容会消失。
因此:
/dev/shm/xxx在接口上是一个文件;- 文件内容在实现上是内核管理的内存页;
mmap后,这些页又可以表现为进程内存。
这里使用的是 /dev/shm 中的普通文件,并没有调用 POSIX shm_open。
如果进程在 unlink 前被中断,Linux 会自动释放该进程的 FD 和 mmap,但不会自动删除文件名,所以仍能在 /dev/shm 中看到残留文件。SIGKILL 无法被程序捕获,更不会执行 cleanup。
findmnt /dev/shm
df -hT /dev/shm
2. open、FD 和 O_* 标志
代码:
fd = os.open(
path,
os.O_CREAT | os.O_EXCL | os.O_RDWR,
0o600,
)
os.open 是 Linux open(2) 的 Python 包装,返回一个整数 file descriptor(FD)。FD 是当前进程文件描述符表中的索引,内核通过它找到对应的文件对象。
| 是按位或,用于组合多个 bit flag:
| 标志 | 含义 |
|---|---|
O_CREAT |
路径不存在时创建文件;已存在时打开已有文件 |
O_EXCL |
通常与 O_CREAT 一起使用;文件已存在则失败并返回 EEXIST |
O_RDWR |
以读写方式打开 |
0o600 |
新文件的初始权限为 owner 可读写;最终权限还受 umask 影响 |
多个进程同时执行 O_CREAT | O_EXCL 时,只有一个进程能创建成功,其他进程得到 EEXIST。这可以用来选出 creator,避免“先检查不存在、再创建”带来的 TOCTOU race。
但 O_EXCL 只保护文件的创建,不保护文件内容的并发读写,也不是完整的进程间锁。
3. ftruncate 和 fstat
creator 创建文件后,文件初始长度通常是 0,随后执行:
os.ftruncate(fd, total_size)
ftruncate(2) 将文件的逻辑长度设置为 total_size。它解决的是“文件可以映射多大”,不代表所有物理内存页已经立即分配。
其他进程成为 joiner 后执行:
os.fstat(fd).st_size
fstat(2) 通过 FD 查询文件元数据。代码不断检查 st_size,直到文件达到预期长度,避免对尚未完成扩展的 0 字节文件执行 mmap。
creator: open → ftruncate
joiner: open → fstat 轮询 → mmap
4. mmap 和 mmap_obj
代码:
self.mmap_obj = mmap.mmap(
self.fd,
self.total_size_bytes,
flags=mmap.MAP_SHARED,
prot=mmap.PROT_READ | mmap.PROT_WRITE,
)
这里有三个容易混淆的名字:
mmap:Python 模块;mmap.mmap(...):创建内存映射的构造器;mmap_obj:构造器返回的 Python 映射对象。
mmap_obj 不是文件副本,也不是一个普通的 Python bytes 对象。它是当前进程访问“文件映射区域”的窗口,可以像字节数组一样读写,也可以调用 madvise() 和 close()。
MAP_SHARED 表示:其他进程把同一个文件区域以 MAP_SHARED 映射后,可以看到相同的修改。每个进程的虚拟地址可能不同,但底层数据相同。
进程 A: mmap_obj_A ─┐
进程 B: mmap_obj_B ─┼─ 同一个 /dev/shm 文件的 backing pages
进程 C: mmap_obj_C ─┘
MAP_SHARED 只解决“共享哪一份数据”,不解决“谁先写、谁后写”。并发写同一地址仍然需要锁、原子操作或不重叠的所有权设计。
5. mmap.PAGESIZE
mmap.PAGESIZE
它表示当前系统的内存页大小,单位是字节。Linux 上通常为 4096,但程序不应硬编码这个值。
Linux 虚拟内存以 page 为基本管理单位:
[ page 0 ][ page 1 ][ page 2 ]
4 KiB 4 KiB 4 KiB
这个文件使用 PAGESIZE 做三件事:
- 要求 block 大小按 page 对齐;
- 对 mmap 的偏移和长度进行对齐计算;
- fallback 路径中每隔一个 page 访问一次。
原因是 mmap、page fault 和 madvise 都以 page 为重要的管理单位。
6. madvise
madvise(2) 用于向 Linux 内核说明一段内存接下来会如何使用。Python 中的:
mmap_obj.madvise(option, start, length)
就是对当前 mmap 区域调用 madvise。
这个文件使用:
MADV_POPULATE_WRITE
其作用是提前建立可写页表并预触页面,把原本可能发生在第一次访问时的 page fault 提前到初始化阶段:
没有 madvise:第一次访问 → page fault → 内核准备页面 → 继续执行
有 MADV_POPULATE_WRITE:初始化时准备页面 → 后续访问更平滑
该选项需要 Linux 5.14 或更新版本,并且要求映射可写。如果不支持,代码退化为每隔一个 page 写入 0:
arr[offset : offset + length : mmap.PAGESIZE] |= 0
|= 0 不改变原数据,但会触发对页面的访问。
madvise 不是锁,也不负责进程间同步;它主要影响内存页的准备和管理方式。
7. unlink:删除名字,不是立即擦除数据
Linux 中可以把文件理解为:
目录项中的文件名 → inode → 文件数据
执行:
os.unlink(path)
主要是删除目录项中的文件名。如果其他进程已经持有 FD 或 mmap,底层对象仍可以继续使用;但新进程再通过这个路径打开时会失败。
所以:
unlink(path)
├─ 删除文件名
├─ 不等于把内容改成 0
├─ 不会立即让已有 mmap 失效
└─ 最后一个引用释放后,底层资源才可回收
对普通文件来说,命令行的:
rm file
通常就是调用 unlink(2)。它不是安全擦除,也不是简单的内存 free。
8. 资源生命周期
这个文件的清理顺序是:
释放 Tensor / buffer 引用
↓
mmap_obj.close() # 解除映射
↓
os.close(fd) # 关闭 FD
↓
os.unlink(path) # creator 删除文件名
如果先关闭 mmap,但仍有 Python、NumPy 或 PyTorch 对这段 buffer 的引用,可能无法安全解除映射。
最后记住四句话
tmpfs在接口上是文件系统,在存储上主要是内存。mmap_obj是当前进程访问文件映射的窗口,不是一份复制数据。madvise是给内核的内存管理建议;这里用它提前准备可写页面。unlink删除文件名,不等于立即擦除底层数据。
参考:mmap Python 文档、mmap(2)、madvise(2)、open(2)、unlink(2)、tmpfs(5)。