本篇位置 持久化的接口篇。这一篇讲文件系统对上长什么样,第 26 篇讲它对下怎么实现
运行环境 容器里的 Linux,Python 3 + coreutils

一、一句反直觉的话:文件名不在文件里

先把结论摆出来,后面的实验都是在验证它:

文件名不是文件的属性。 它是目录里的一条记录。

一个文件真正的身份是 inode(索引节点)——一个数据结构,里面有:

  • 文件大小、权限、属主
  • 三个时间戳
  • 链接数(有几个名字指向我)
  • 数据块在磁盘上的位置

inode 里没有文件名。 名字在目录里。

目录本身也是一个文件,只不过它的内容是一张表:

名字            inode 号
------------  ---------
.                 140316
..                140301
a.txt             140316
b.txt             140316      ← 两个名字,同一个 inode

打个比方

图书馆。

  • inode 是书本身:有页数、有出版信息,放在某个书架的某个位置
  • 文件名是目录卡上的一行:写着"《操作系统导论》→ 3 排 2 架"。
  • 目录是一盒卡片。

⭐ 于是很多事一下就清楚了:同一本书可以有两张卡片(按书名一张、按作者一张)——这就是硬链接。抽掉一张卡片,书还在。

二、跑一遍看看:两张卡片,一本书

echo "机密数据" > a.txt
ln a.txt b.txt                    # 硬链接:加一张卡片
stat -c '  %n  inode=%i  链接数=%h  大小=%s' a.txt b.txt
  a.txt  inode=140316  链接数=2  大小=13
  b.txt  inode=140316  链接数=2  大小=13

同一个 inode,链接数是 2。 这不是"复制了一份"——磁盘上只有一份数据。

rm a.txt
stat -c '  %n  inode=%i  链接数=%h' b.txt
cat b.txt
  b.txt  inode=140316  链接数=1
  b.txt 的内容还在:机密数据

rm 干的事不是"删除文件",是"从目录里去掉一条记录,然后把链接数减 1"。 只有当链接数变成 0,文件系统才会去回收数据块。

它的系统调用名字就叫 unlink——“解除链接”,不叫 delete。这个命名是准确的。

软链接是完全不同的东西

ln -s b.txt c.txt
stat -c '  %n  inode=%i  类型=%F' b.txt c.txt
rm b.txt
cat c.txt
  b.txt  inode=140316  类型=regular file
  c.txt  inode=140317  类型=symbolic link
  删掉 b.txt 之后读 c.txt:cat: c.txt: No such file or directory

软链接有自己的 inode,它是一个独立的文件,内容就是一个路径字符串

所以目标没了它就断了——它存的是"卡片上写的那个位置",不是书本身。而硬链接是另一张指向同一本书的卡片

硬链接 软链接
有自己的 inode
存的是什么 目录里多一条指向同一 inode 的记录 一个路径字符串
目标被删了 没影响(链接数减 1) 断掉
能跨文件系统 不能(inode 号只在本文件系统内唯一)
能指向目录 不能(会造出环)

三、⭐ 第三层:打开的文件

上面讲了"名字"和"inode"两层。还有第三层,它解释了运维里最常见的那个困惑。

fd = os.open(PATH, os.O_RDWR | os.O_CREAT | os.O_TRUNC, 0o644)
os.write(fd, b"HELLO" + b"x" * ((200 << 20) - 5))
os.fsync(fd)
st = os.fstat(fd)
print(f"  写了 200 MB。inode={st.st_ino}  链接数={st.st_nlink}  占用磁盘 {st.st_blocks*512>>20} MB")

os.unlink(PATH)                       # 相当于 rm

st = os.fstat(fd)
print(f"    ls 看得到吗:{os.path.exists(PATH)}")
print(f"    inode={st.st_ino}  链接数={st.st_nlink}")
print(f"    但它仍然占着磁盘 {st.st_blocks*512>>20} MB")
print(f"    而且我这个 fd 还能读:{os.pread(fd, 5, 0)!r}")
print(f"    内核也还认它:/proc/self/fd/{fd} -> {os.readlink(f'/proc/self/fd/{fd}')}")
  写了 200 MB。inode=140526  链接数=1  占用磁盘 200 MB

  rm 之后:
    ls 看得到吗:False
    inode=140526  链接数=0   <-- 目录里的名字没了
    但它仍然占着磁盘 200 MB
    而且我这个 fd 还能读:b'HELLO'
    内核也还认它:/proc/self/fd/3 -> /tmp/ghost.bin (deleted)

链接数已经是 0,ls 里查无此文件,但那 200 MB 还占着,而且还能读。

因为回收的条件不是"链接数为 0",是"链接数为 0 没有任何进程打开着它"。 内核维护的是两个计数:

  • 链接数:磁盘上有几个名字指向它。rm 减这个。
  • 打开引用数:内存里有几个文件描述符指向它。close 减这个。

两个都归零,数据块才真的还回去。

⚠️ 这就是那个经典的运维事故:

磁盘满了,你 rm 掉了那个 50 GB 的日志文件,df 显示空间一点没少。

因为写日志的那个进程还开着它ls 看不到,du 也统计不到(它按目录树走),只有 dflsof 看得见。

正确做法是重启那个进程,或者让它 reopen 日志文件(这正是 logrotate 要给进程发 SIGHUP 的原因),或者干脆用 truncate 把内容清空而不是删文件。

⭐ 反过来,这个性质也是一个特性:程序可以创建一个临时文件、立刻 unlink 它、然后接着用。文件名从一开始就不存在,谁也看不到它,而进程一退出(不管是正常退出还是崩溃),内核自动回收,绝不留垃圾。很多数据库和编译器就这么干。

四、文件描述符:还有一层状态

open 返回的那个小整数,指向的其实是一条打开文件表项,里面有:

  • 指向 inode 的引用
  • 当前偏移量(读到哪了)
  • 打开时的标志(只读、追加……)

偏移量在这一层,不在 inode 上。 这解释了几件事:

  • 同一个文件 open 两次,得到两个独立的偏移量,各读各的
  • fork 出来的子进程共享父进程的打开文件表项——所以父子的偏移量是连着的,一个读了,另一个接着往下。(第 3 篇 shell 重定向那个演示就依赖这个。)
  • dup2 复制的是描述符,指向同一个表项,所以偏移量也共享。

⚠️ 这里有个并发陷阱:多个进程用 O_APPEND 打开同一个日志文件写,是安全的——内核保证"定位到末尾"和"写"这两步原子完成。但如果你自己 lseek 到末尾再 write那就是第 21 篇讲的原子性违反,两个进程会互相覆盖。

五、目录也是文件,但你不能随便写

目录是一个 inode,类型标着"目录",内容是那张"名字 → inode 号"的表。

你不能用 write 去写一个目录。想加一条记录,只能通过 creatmkdirlinkrename 这些系统调用。

为什么要限制? 因为目录结构的完整性关系到整个文件系统。让用户随便写,一个手滑就能造出指向随机 inode 的记录,或者造出一个环。⭐ 内核在这里做的事和第 4 篇一样:不让你直接操作,只给你一组有限的、保证不变式的操作。

也正因如此,硬链接不许指向目录——否则你可以造出一个环,而目录树"是一棵树"这个假设一旦破掉,rm -rfind、备份工具全都会陷进去。

... 是例外,它们是内核自己维护的,而且遍历工具都专门认得它们。)

六、代价与取舍

这套设计换来了什么:

  • 名字和内容分离,于是有了硬链接、有了"删了还能用"、有了原子的 rename(第 27 篇会重度使用它)。
  • 一个极简的接口open/read/write/close 五个动作管住了磁盘文件、管道、套接字、终端、/proc。⭐ “一切皆文件"的实际含义是:一切都能用这五个动作操作。

它花了什么:

  • 多一次间接。按路径找文件要一级一级查目录,每一级都是一次 inode 读取。(所以文件系统要缓存目录项——dentry cache。)
  • inode 数量是有限的。格式化时就定好了。⚠️ 一个分区可能"还有空间但创建不了文件”——inode 用光了。海量小文件的场景真的会遇到。

它放弃了什么: “删除"这个直觉。 rm 不删除任何东西,它只是解除一个链接。上面那个 df 事故就是这个抽象泄漏的代价。

七、小结

  • 文件名不在文件里。 inode 是文件的身份(大小、权限、时间、链接数、数据块位置),名字是目录里的一条记录
  • 硬链接是给同一个 inode 多加一张卡片;rm 其实是 unlink——去掉一条记录,链接数减 1。实测:删掉 a.txtb.txt 内容完好。
  • 软链接有自己的 inode,内容是一个路径字符串,目标没了就断。能跨文件系统、能指向目录,硬链接两样都不行。
  • 回收条件是"链接数为 0 没有进程打开着它”。 实测:rm 一个 200 MB 的打开文件,ls 里没了、链接数 0,但仍占 200 MB 且 fd 还能读
  • ⚠️ 这就是"磁盘满了,删了日志 df 却没变"——写日志的进程还开着它。du 看不到,lsof 看得到。反过来它也是个特性:创建后立刻 unlink,进程一退出内核自动回收,绝不留垃圾
  • 偏移量在"打开文件表项"上,不在 inode 上。所以 fork 出来的父子共享偏移量,open 两次则各读各的。⚠️ O_APPEND 是原子的,自己 lseek 再 write 不是
  • 目录是文件,但不能用 write——只能通过保证不变式的那组系统调用。硬链接不许指向目录,否则目录树不再是树。
  • 代价:多一次间接inode 数量有限(可能"有空间但建不了文件")、以及**“删除"这个直觉不成立**。

思考题

  1. mv a.txt b.txt 在同一个文件系统里是一个 rename 系统调用,几乎瞬间完成。跨文件系统 mv 呢?为什么会慢那么多?
  2. 第三节说"创建后立刻 unlink"是安全的临时文件做法。那 Linux 的 O_TMPFILE 又解决了什么这个做法解决不了的问题?(提示:想想 unlink 之前那一瞬间。)
  3. 一个目录里有一百万个文件,ls 会很慢。慢在哪一步?换成 ls -f(不排序)能快多少?为什么?
  4. 图书馆那个比方里,“打开引用数"对应什么?(提示:卡片被抽掉了,但书还在某个人手里。)

延伸