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 中的普通文件,并没有调用 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. ftruncatefstat

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. mmapmmap_obj

代码:

self.mmap_obj = mmap.mmap(
    self.fd,
    self.total_size_bytes,
    flags=mmap.MAP_SHARED,
    prot=mmap.PROT_READ | mmap.PROT_WRITE,
)

这里有三个容易混淆的名字:

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 做三件事:

  1. 要求 block 大小按 page 对齐;
  2. 对 mmap 的偏移和长度进行对齐计算;
  3. 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 的引用,可能无法安全解除映射。

最后记住四句话

  1. tmpfs 在接口上是文件系统,在存储上主要是内存。
  2. mmap_obj 是当前进程访问文件映射的窗口,不是一份复制数据。
  3. madvise 是给内核的内存管理建议;这里用它提前准备可写页面。
  4. unlink 删除文件名,不等于立即擦除底层数据。

参考:mmap Python 文档mmap(2)madvise(2)open(2)unlink(2)tmpfs(5)