Linux内核分析之进程间通信-04
30.1 POSIX 共享内存 (shm_open)
POSIX 共享内存把 28.4 节的内核事实——"共享内存就是 tmpfs 文件"——直接暴露为文件系统对象:shm_open("/data") 在 /dev/shm/ 下创建/打开一个真实文件,之后 ftruncate 定大小、mmap 映射使用。生命周期由引用计数而非 IPC_RMID 管理,配合 memfd_create 的匿名变体,构成了容器/虚拟机时代的标准内存共享栈。本节拆解内核侧的文件来源、生命周期与三种建段路径的统一。
30.1.1 shm_open 的内核路径:一个 tmpfs 文件
shm_open("/data", O_CREAT|O_RDWR, 0600) 的内核视图:
glibc: snprintf("/dev/shm/data"); open(...)
│
v
/dev/shm 是 tmpfs 挂载点 (37.4 节内存文件系统)
open 即普通文件操作: 建 inode / 查 dcache
│
v
ftruncate(fd, size): shmem_setattr → 页数定调 (不分配页!)
│
v
mmap(NULL, size, PROT_READ|WRITE, MAP_SHARED, fd, 0)
│
v
22.3 节 mmap 全流程 → VMA 建立后缺页时
shmem_fault 供货 (页即 tmpfs 页缓存页)
内核侧没有独立的 "posix shm 子系统"——它就是 tmpfs + open/ftruncate/mmap 的组合。28.4 节的 SysV shm 段(shmem_file_setup 建在 shm_mnt 上,mm/shmem.c:46 的全局唯一挂载)与 shm_open 的文件(/dev/shm 挂载点)本质是同一个文件系统的两种打开方式:前者内部建文件+IPC 记账,后者用户态走路径名。
生命周期对比是两者的核心差异:
| 维度 | SysV shm (28.4) | POSIX shm (本节) |
|---|---|---|
| 命名 | key(应用层约定) | 文件系统路径(可见 ls /dev/shm/) |
| 持久 | namespace 级:进程全退仍存活,RMID+空才回收 | 引用计数:最后 close/unlink 条件满足即释放 |
| 撤销 | IPC_RMID 显式 | shm_unlink(与文件 unlink 同语义:名字消失,已映射者继续用) |
| 权限 | mode 位 + IPC_SET | 文件权限位 + 进程凭证 |
shm_unlink 后"已映射者继续用、新 open 者找不到"的语义正是文件引用计数的自然结果——inode 随 dentry 摘除而进入"无名但被引用"状态,与 22.3 节 munmap 后 VMA 的生命周期哲学一致。
30.1.2 memfd_create:匿名化的共享内存
shm_open 时代的问题: /dev/shm 的名字是全局攻击面
(可被猜测/抢先创建/pre-open 泄漏), 容器隔离需要
"连名字都不存在"的共享内存。
memfd_create("heap", MFD_CLOEXEC) 的答案:
[1] mm/memfd.c: 在 shmem 上建匿名文件 (无路径!),
返回一个普通 fd
[2] 传递: 经 SCM_RIGHTS (unix 域 socket 辅助消息)
把 fd 发给无关进程 — 48 章套接字层的"能力传递"
[3] 权限即 fd: 拿到 fd 才能映射, 无名不可猜
(MFD_CLOEXEC 防 exec 泄漏, MFD_HUGETLB 大页变体)
消费者: QEMU 虚机内存后端 (23.4.3 节 memfd+THP),
Chromium 的共享渲染缓冲, io_uring 的注册缓冲 (37 章)
SEAL 机制 (MFD_ALLOW_SEALING):
fcntl(fd, F_ADD_SEALS, F_SEAL_SHRINK|F_SEAL_WRITE...)
让 fd 持有者"冻结"文件的大小/写性 —
共享给不可信方后禁止其截断/改写 (防 TOCTOU)
memfd 与 O_TMPFILE 一样体现现代内核的能力化(capability-ize)趋势:把"路径名"这一全局坐标替换为"fd 这一可传递凭据"。sealing 是 fd 化后必须补上的控制面——共享对象失去路径权限模型后,以原子封印约束对方行为。
30.1.3 三条建段路径的统一
建段入口 内核事实
┌─────────────────────────────────────────────────────────┐
│ shm_open("/x") + ftruncate + mmap │
│ /dev/shm 的 tmpfs 文件 ──────┐ │
│ │ │
│ memfd_create → SCM_RIGHTS ─────┼──> shmem 文件的页缓存页 │
│ │ 缺页时 shmem_fault │
│ SysV shmget + shmat (28.4) │ 供货 (22.4 节矩阵) │
│ shm_mnt 上的内部文件 ─────────┘ │
└─────────────────────────────────────────────────────────┘
三个 API, 一个真相: 共享 = 映射同一个文件
性能上限 = 内存带宽; 语义差异只在命名与生命周期
选型速查:容器内多进程大数据交换 → memfd(无名字攻击面)+ SCM_RIGHTS;传统多进程服务共享配置/缓冲 → shm_open(运维可见 /dev/shm);遗留数据库 → SysV(28.4,需管理 ipcs 残留段)。
小结
POSIX 共享内存没有专属内核子系统——shm_open 就是打开 tmpfs 文件,shm_unlink 就是文件 unlink,生命周期由引用计数自然管理;memfd_create 进一步去掉名字、以 fd 凭据传递共享对象并用 sealing 补上控制面。三条建段路径(shm_open/memfd/SysV)最终都收敛到"映射同一个 shmem 文件"的内核事实。下一节把镜头对准 mmap 的一般形态——文件映射 I/O 与 msync 的落盘控制。
30.2 内存映射 I/O —— mmap 与 msync
mmap 文件映射把 read/write 的"两次拷贝 + 显式定位"替换为"指针直取":文件内容经页缓存(36 章)映入进程地址空间,读写退化成访存。代价是可见性与落盘的时序变得隐式——msync 是拿回控制权的显式接口。本节沿 mmap 公共路径到 msync 的落盘循环展开。
30.2.1 mmap 公共路径:ksys_mmap_pgoff
// mm/mmap.c:567-608(节选)
unsigned long ksys_mmap_pgoff(unsigned long addr, unsigned long len,
unsigned long prot, unsigned long flags,
unsigned long fd, unsigned long pgoff)
{
struct file *file = NULL;
unsigned long retval;
if (!(flags & MAP_ANONYMOUS)) {
audit_mmap_fd(fd, flags);
file = fget(fd);
if (!file)
return -EBADF;
if (is_file_hugepages(file)) {
len = ALIGN(len, huge_page_size(hstate_file(file)));
} else if (unlikely(flags & MAP_HUGETLB)) {
retval = -EINVAL;
goto out_fput;
}
} else if (flags & MAP_HUGETLB) {
...
/*
* VM_NORESERVE is used because the reservations will be
* taken when vm_ops->mmap() is called
*/
file = hugetlb_file_setup(HUGETLB_ANON_FILE, len, ...); /* :597 */
if (IS_ERR(file))
return PTR_ERR(file);
}
retval = vm_mmap_pgoff(file, addr, len, prot, flags, pgoff); /* :605 */
out_fput:
if (file)
fput(file);
return retval;
}
入口的三分岔:文件映射(fget 后进入 22.3 节全流程)、匿名映射(file=NULL)、匿名大页(伪造一个 hugetlbfs 匿名文件——MAP_ANONYMOUS|MAP_HUGETLB 在内核里也是"文件映射",:597 的注释解释 reservations 延迟到 mmap 钩子)。vm_mmap_pgoff(mm/util.c:565)包上安全钩子(LSM/记账)与 mmap_lock 后进入 22.3.2 节的 mmap_region。
30.2.2 文件映射 I/O 的执行流
mmap 后读写的真实路径:
读 (指针访问):
缺页 (22.4 节) → filemap_fault → 页缓存命中/读盘
→ pte 指向页缓存页 → 之后每次访问 = 纯访存 (无系统调用!)
与 read() 对照: read = 页缓存 → 用户缓冲 (一次拷贝)
写 (MAP_SHARED):
pte 可写 → 直接写页缓存页 → PG_dirty (19.2.2 节)
落盘交给回写系统 (36.3 节 flusher, 秒级延迟)
msync/fsync 才有确定性
写 (MAP_PRIVATE):
COW (25.3 节) → 私有匿名副本, 文件永不被改
页缓存的一致性税 (30.2.4 节展开):
mmap 写与 write() 写、DMS 副路径的同页视图
由同一页缓存保证 — 这是"映射 I/O"的天然优势
性能账的适用边界:随机访问大文件(数据库页) mmap 完胜 read(省拷贝省系统调用);顺序流式读 read 常更快(可预测预读 + 免缺页异常税 + madvise 不可控性);MAP_POPULATE 可预付缺页成本把整个区间一次触满。
30.2.3 msync:落盘的控制点
// mm/msync.c:32-114(节选)
SYSCALL_DEFINE3(msync, unsigned long, start, size_t, len, int flags)
{
...
if (offset_in_page(start))
goto out;
if ((flags & MS_ASYNC) && (flags & MS_SYNC))
goto out; /* 两态互斥 */
...
/*
* If the interval [start,end) covers some unmapped address ranges,
* just ignore them, but return -ENOMEM at the end...
*/
mmap_read_lock(mm);
vma = find_vma(mm, start);
for (;;) {
...
file = vma->vm_file;
fstart = (start - vma->vm_start) +
((loff_t)vma->vm_pgoff << PAGE_SHIFT); /* 虚→文件偏移换算 */
fend = fstart + (min(end, vma->vm_end) - start) - 1;
start = vma->vm_end;
if ((flags & MS_SYNC) && file &&
(vma->vm_flags & VM_SHARED)) {
get_file(file);
mmap_read_unlock(mm); /* 落盘是长操作: 放锁! */
error = vfs_fsync_range(file, fstart, fend, 1); /* :96 */
fput(file);
if (error || start >= end)
goto out;
mmap_read_lock(mm); /* 回锁继续下一 VMA */
vma = find_vma(mm, start);
}
...
}
}
三个标志的真实语义:
| 标志 | 行为 | 现实注记 |
|---|---|---|
MS_SYNC |
逐 VMA vfs_fsync_range 同步落盘后返回 |
跨 VMA 时放锁-落盘-回锁循环(:90-99),长区间等于多段 fsync |
MS_ASYNC |
Linux 上什么都不做即返回 0 | 内核回写系统本来就会清脏页,历史 ABI 兼容位 |
MS_INVALIDATE |
使缓存失效(配合 VM_LOCKED 检查 :77-81) | 多进程场景的可见性刷新 |
MS_ASYNC 是名不副实的 API——注释明言该结果"would be -ENOMEM anyway"的短路分支,内核把它实现为空操作。用户态要"异步落盘"的正确姿势是 sync_file_range() 或依赖回写子系统。msync 的真实价值集中在 MS_SYNC:把"页缓存脏页 → 设备"的确定性交给调用者——数据库 WAL、事务提交点的标准动作。
30.2.4 一致性陷阱清单
mmap I/O 的四大坑 (全部源于"页缓存单副本"的另一面):
[1] write() 与 mmap 混用同一文件:
共享同一页缓存, 数据视图一致 ✓ — 但 msync/fsync
责任不自动转移: mmap 写的页需要 msync, write 写的
页需要 fsync, 元数据都需要后者
[2] MAP_SHARED 写后 truncate:
另一进程把文件截短 → 映射区间触达洞 → SIGBUS
(36 章页缓存的 i_size 裁剪) — memfd sealing
(30.1.2 节) 正是为此而生的防御
[3] 越界写不越权:
尾页部分映射时, 写"文件尾之后的页内字节"合法且
会写进页缓存但不落盘 — 检查不出越界却丢数据
[4] fork 的映射继承:
MAP_SHARED 映射随 fork 继承 (父子继续共享),
MAP_PRIVATE 的 COW 父子分家 — 审计内存语义时
必须区分"继承映射"与"继承页"
小结
ksys_mmap_pgoff 把文件/匿名/匿名大页三分岔收敛进 22.3 节的 VMA 流程,文件映射以页缓存为单副本中心:读省一次拷贝、写直达页缓存,代价是落盘时序交给回写系统;msync 的 MS_SYNC 以"放锁-落盘-回锁"的循环提供确定性 fsync,而 MS_ASYNC 在 Linux 上是空操作。mmap I/O 的四大一致性陷阱——混用、truncate、尾页、fork——全部源自"页缓存单副本"这枚硬币的另一面。第五部分的通信与映射机制至此完整;下一章进入 futex——用户态锁的最后一块内核拼图。