VFS:从路径名到打开的文件

本页目录

删除一个文件后,已经打开的 fd 仍能读取;两次打开同一路径,读位置又未必共享。沿着名字、对象、打开实例这三层,VFS 的接口关系就能串起来。

VFS 统一入口,具体读写、等待条件和能力仍由文件、管道、设备等对象实现。

要解释的现象阅读位置
两个 fd 的读位置为何互相影响?第 1 节:打开实例
路径刚检查过,打开为何还是不对?第 2~3 节:查找与操作分发
unlink、close 何时真正释放资源?第 4 节:引用实验
同一路径在容器里为何内容不同?第 5~6 节:挂载视图与定位

1. 先区分名字、对象、打开实例

对象回答的问题关键边界
super_block哪一个文件系统实例?不与挂载点一一对应;多个挂载可共享它
inode哪一个文件系统对象?包含类型、权限、大小等,不以路径字符串为身份
dentry父目录中的这个名字对应谁?是内存中的名字查找对象,不是磁盘目录项原样复制
struct file这次打开具有什么状态?对应 open file description,保存偏移、状态标志和操作表
fd进程通过哪一个表项访问打开实例?小整数可复用;FD_CLOEXEC 等属于描述符层
mount + dentry在哪一个挂载视图中定位对象?两者组成 struct path,仅有 dentry 不足以描述完整位置

硬链接为同一 inode 提供多个名字;符号链接本身是另一个 inode,解析时按其内容继续查找。常规本地文件的 (st_dev, st_ino) 可用于比较当时的文件身份,但 inode 号可能在删除后复用,不能直接当作永久全局 ID。叠加文件系统等环境还应考虑其身份映射规则。

两个 open 与一次 dup:共享到哪一层? fd A 第一次 open fd B dup(A) fd C 第二次 open struct file ① A、B 共享偏移与状态 struct file ② C 有独立偏移与状态 同一个 inode 同一文件内容与元数据 两个 file 各持有 path:mount + dentry fd 是表项;file 是打开实例;inode 不是文件名,多个名字可指向同一 inode。

按图中的引用关系读偏移变化:

获得 fd 的方式打开实例后续读取位置
独立 open 同一路径通常各自一个 struct file各自推进
dup,或 fork 继承可以引用同一 struct file一方读取会推进共享偏移
pread使用已有打开实例,但显式指定偏移不推进共享位置

图中 A、B 共享打开实例,所以 A 读取后 B 从新偏移继续;C 独立打开,不受这个偏移变化影响。见 open(2)。

2. 路径查找为什么需要 dentry 和 mount

解析 /srv/data/a 时,需要逐分量处理目录权限、目录项、符号链接及挂载边界。相对路径从工作目录或 openat() 的目录 fd 开始;绝对路径从调用者的根目录视图开始。最后一个分量还受创建、独占、跟随符号链接等标志影响。

Negative dentry 缓存“该父目录下的这个名字没有关联 inode”的查找结果。它可以减少重复失败查找,但不是永久不存在的证明:创建、重命名、缓存失效及远程文件系统重新验证都可能改变结果。

快路径:缓存关系稳定时,减少逐项协调

RCU-walk 在 RCU 保护和序列检查下读取稳定关系,减少引用计数与锁操作。遇到并发变化、需要阻塞式验证等情况,可以转为 REF-walk 或重新查找。

所以 dcache 命中不保证整个 open 无锁、无等待:权限检查和最终打开仍有自己的同步要求。见 路径查找设计。

查找到存在,再打开,为什么仍可能错?

假设程序先检查 a 不存在,另一个进程随即创建 a,随后第一个程序普通 open(O_CREAT) 打开了对方的文件。两个系统调用之间的事实已经改变。创建新文件应把排他要求交给 O_CREAT | O_EXCL;限制路径逃出指定目录、禁止某类符号链接等需求,可按威胁模型使用 openat2() 的解析约束,而不是仅做字符串前缀检查。

O_TMPFILE 是在指定目录所在的文件系统中创建无名 inode 的独立打开方式,需要文件系统支持;它不是先随便起一个名字再立刻删除的语法糖。后续能否链接到目录还受打开标志、权限与链接方式约束。

3. 操作表负责分工,不保证所有能力存在

操作表常见职责阅读源码时的入口
super_operationsinode 分配/回收、文件系统同步等文件系统实例的管理
inode_operations目录查找、创建、链接、属性操作名称与元数据操作
file_operations读写、定位、映射、poll、ioctl、fsync已打开对象的操作
address_space_operations页缓存填充、预读、写回等缓存与后端存储协作
dentry_operations重新验证、名字比较等文件系统特有的名字语义

内核提供通用辅助实现,文件系统可选择复用;不能理解为缺少任何回调都会自动补齐。未实现某项能力时,调用可能失败或具有接口规定的默认行为。例如管道不支持常规文件式的随机定位,设备 read() 也不必进入页缓存。操作契约见 VFS 文档,具体签名应以目标内核头文件为准。

对常规文件,缓冲读取通常查页缓存,未就绪时等待或发起后端读取;后端可以是块设备,也可以是网络服务。缓冲写入通常先修改缓存,但可能因内存分配、旧数据读取或脏页限流而等待。O_SYNC、显式同步和直接 I/O 又改变完成条件,因此“write 只改内存,绝不在此等待存储”是错误概括。

4. unlink、close 与最终释放是三件事

unlink() 删除一个目录中的名字。若还有硬链接,仍能通过其他名字打开;即使链接数降到零,已有的打开引用通常仍可访问内容。磁盘空间何时回收,还取决于打开引用、映射、文件系统回收机制及快照等因素。

操作直接改变的关系仍可能保留内容的引用
unlink删除一个名字到对象的绑定其他硬链接、打开引用、映射等
close(fd)移除当前描述符引用dup、继承或映射等其他引用
munmap撤销该映射关系其他映射、打开引用等

驱动的 release 应按最后打开引用释放理解,不能按每次 close 计数;flush 等回调有不同触发语义。文件映射也可在原 fd 关闭后继续有效。

先在图中分别操作名字和 fd:独立打开、dup、读取、unlink 与 close 会改变不同的引用。最后一条引用消失之前,内容与偏移仍有明确的所有者。

正在呈现知识画面
名字删掉了,引用还指向谁?

unlink 撤销名字,close 撤销描述符。dup 共享打开实例和偏移;独立 open 使用独立偏移。

下面只在临时目录内验证共享偏移和“名字已删,打开引用仍可读”:

import os
import tempfile
from pathlib import Path

with tempfile.TemporaryDirectory(prefix="vfs-demo-") as directory:
    path = Path(directory) / "a"
    path.write_bytes(b"abcdef")
    fds = []
    try:
        a = os.open(path, os.O_RDONLY)
        fds.append(a)
        b = os.dup(a)
        fds.append(b)
        c = os.open(path, os.O_RDONLY)
        fds.append(c)
        assert os.read(a, 2) == b"ab"
        assert os.read(b, 2) == b"cd"   # 与 a 共享偏移
        assert os.read(c, 2) == b"ab"   # 第二次 open 的偏移独立
        assert os.pread(a, 2, 0) == b"ab"
        assert os.lseek(b, 0, os.SEEK_CUR) == 4
        renamed = path.with_name("renamed")
        path.rename(renamed)
        renamed.unlink()
        assert not renamed.exists()
        assert os.fstat(a).st_nlink == 0
        assert os.pread(c, 6, 0) == b"abcdef"
        print("共享偏移、独立打开和删除后读取:通过")
    finally:
        for fd in reversed(fds):
            os.close(fd)

这验证的是本地常规文件语义,不是断电持久性实验,也不能直接推导所有网络文件系统的删除后行为。

5. 挂载是在名字空间中接入一棵树

挂载使路径遍历在某个位置进入另一棵目录树。原来该位置的内容通常被遮住,并未删除。bind mount 可以把已有子树接到另一处,继续共享底层对象,因此“每个挂载都读取磁盘并创建独立 superblock”不成立。

mount namespace 隔离的是挂载视图,不是文件数据副本。在两个 namespace 中通过共享文件系统访问同一文件,仍可看到相互写入;挂载事件是否传播还取决于 shared/private/slave 等传播关系。不能仅凭创建 namespace 就断言所有挂载变化完全隔离。

源码入口:先配置文件系统,再挂接视图

新的挂载接口把配置文件系统上下文、取得文件系统树、把挂载接入位置等步骤分开,便于用 fd 管理对象和报告参数错误。内核侧 fs_context 与用户侧 fsopen/fsconfig/fsmount/move_mount 属于相关但不同层次的接口;读源码时应分别追踪配置和挂接阶段。见 挂载 API。

6. 从现象定位到层次

现象优先区分什么
两个 fd 的读位置互相影响是否引用同一个打开实例,是否由 dup/fork 得到
删除大文件后空间未明显减少是否仍被打开、映射或被快照保留;不要只查文件名
路径存在却打开失败搜索权限、挂载视图、符号链接、最终打开权限与并发变化
相同路径在容器中内容不同root、mount namespace 与挂载覆盖关系
open 很慢但后续 read 快名字/权限重新验证与数据缓存是不同路径

只读观测可从 findmnt、/proc/self/mountinfo、/proc/self/fd 开始。/proc/sys/fs/dentry-state 是系统级缓存统计,字段应对照当前内核文档,不代表某个目录的命中率。进一步读 fs/namei.c、fs/dcache.c、fs/namespace.c,数据路径接 Page Cache 与 I/O。