删除一个文件后,已经打开的 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 使用独立偏移。
{"id":"vfs-name-and-open-lifetime","title":"名字删掉了,引用还指向谁?","summary":"unlink 撤销名字,close 撤销描述符。dup 共享打开实例和偏移;独立 open 使用独立偏移。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"track-row\"><span data-i18n=\"names\"></span><div id=\"names\" class=\"track\"></div></div><div class=\"actions\"><button id=\"link\" data-i18n=\"link\"></button><button id=\"unlinkA\" data-i18n=\"unlinkA\"></button><button id=\"unlinkB\" data-i18n=\"unlinkB\"></button></div><label for=\"fd\" data-i18n=\"choose\"></label><select id=\"fd\"></select><div class=\"track-row\"><span data-i18n=\"fds\"></span><div id=\"fds\" class=\"track\"></div></div><div class=\"actions\"><button id=\"open\" data-i18n=\"open\"></button><button id=\"dup\" data-i18n=\"dup\"></button></div><div class=\"actions\"><button id=\"read\" data-i18n=\"read\"></button><button id=\"close\" data-i18n=\"close\"></button></div><div class=\"track-row\"><span data-i18n=\"content\"></span><div id=\"content\" class=\"track\"></div></div><div class=\"metrics\"><div><span data-i18n=\"links\"></span><strong id=\"links\"></strong></div><div><span data-i18n=\"refs\"></span><strong id=\"refs\"></strong></div></div><div class=\"readout\"><span data-i18n=\"last\"></span><strong id=\"last\"></strong></div><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"note\"></p></div><div class=\"actions\"><button id=\"reset\" data-i18n=\"reset\"></button></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n.track#fds{flex-direction:column}.readout strong{display:block;font-size:24px}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}let names=[true,false],fds=[0,null,null],files=[{offset:0}],chosen=0,last='—';function draw(){cells('names',names.map((n,i)=>({text:(i?'B':'A')+(n?' → inode 7':' ×'),cls:n?'on':'empty'})));cells('fds',fds.map((f,i)=>({text:'fd '+i+' → '+(f===null?'—':'F'+f+' [offset '+files[f].offset+'] → inode 7'),cls:f===null?'empty':i===chosen?'on':''})));const refs=new Set(fds.filter(x=>x!==null)).size,alive=names.some(Boolean)||refs;cells('content',[{text:alive?'A B C D':t('gone'),cls:alive?'gold':'empty'}]);set('links',names.filter(Boolean).length);set('refs',refs);set('last',last);$('#link').disabled=names[1]||!names[0];$('#unlinkA').disabled=!names[0];$('#unlinkB').disabled=!names[1];$('#open').disabled=!names.some(Boolean)||!fds.includes(null);$('#dup').disabled=fds[chosen]===null||!fds.includes(null);$('#read').disabled=$('#close').disabled=fds[chosen]===null;set('explanation',t('note'));}for(let i=0;i<3;i++){const e=document.createElement('option');e.value=i;e.textContent='fd '+i;$('#fd').append(e);}$('#fd').onchange=()=>{chosen=+$('#fd').value;draw();};$('#link').onclick=()=>{names[1]=true;draw();};$('#unlinkA').onclick=()=>{names[0]=false;draw();};$('#unlinkB').onclick=()=>{names[1]=false;draw();};$('#open').onclick=()=>{const slot=fds.indexOf(null);if(slot<0||!names.some(Boolean))return;fds[slot]=files.length;files.push({offset:0});draw();};$('#dup').onclick=()=>{const slot=fds.indexOf(null);if(slot>=0&&fds[chosen]!==null)fds[slot]=fds[chosen];draw();};$('#read').onclick=()=>{const f=files[fds[chosen]];if(!f)return;last='fd '+chosen+' → '+('ABCD'[f.offset]??'EOF');if(f.offset<4)f.offset++;draw();};$('#close').onclick=()=>{fds[chosen]=null;draw();};$('#reset').onclick=()=>{names=[true,false];fds=[0,null,null];files=[{offset:0}];last='—';draw();};draw();","audio":false,"strings":{"choose":"操作哪个 fd","close":"关闭选中 fd","content":"仍被引用的内容","dup":"复制选中 fd","fds":"fd → 打开实例 → inode 7","gone":"无引用;内容可回收","heading":"名字删掉了,引用还指向谁?","intro":"分别操作名字和 fd。复制 fd 后轮流读取,再删除全部名字,观察打开实例和内容是否仍在。","last":"最近一次读取","link":"增加硬链接 B","links":"名字数量","names":"名字 → inode 7","note":"先看箭头是否还在,再看读位置。fd 的编号会复用,不能用它代表文件身份。","open":"从现存名字 open","read":"读取一个字节","refs":"打开实例数量","reset":"重置","scope":"一个普通文件、两个名字、三个 fd,内容 ABCD。无映射、快照和并发;最后引用消失只表示模型不再需要内容,不模拟文件系统实际回收时机。","scopeLabel":"模型范围与约定","unlinkA":"删除名字 A","unlinkB":"删除名字 B"}}
下面只在临时目录内验证共享偏移和“名字已删,打开引用仍可读”:
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" assert os . read ( c , 2 ) == b " ab" 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 。