Linux内核分析之进程间通信-04

This language version is unavailable; showing the other language.

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——用户态锁的最后一块内核拼图。