| 本篇位置 | 持久化的接口篇。这一篇讲文件系统对上长什么样,第 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 也统计不到(它按目录树走),只有 df 和 lsof 看得见。
正确做法是重启那个进程,或者让它 reopen 日志文件(这正是 logrotate 要给进程发 SIGHUP 的原因),或者干脆用 truncate 把内容清空而不是删文件。
⭐ 反过来,这个性质也是一个特性:程序可以创建一个临时文件、立刻 unlink 它、然后接着用。文件名从一开始就不存在,谁也看不到它,而进程一退出(不管是正常退出还是崩溃),内核自动回收,绝不留垃圾。很多数据库和编译器就这么干。
四、文件描述符:还有一层状态
open 返回的那个小整数,指向的其实是一条打开文件表项,里面有:
- 指向 inode 的引用
- 当前偏移量(读到哪了)
- 打开时的标志(只读、追加……)
偏移量在这一层,不在 inode 上。 这解释了几件事:
- 同一个文件
open两次,得到两个独立的偏移量,各读各的。 fork出来的子进程共享父进程的打开文件表项——所以父子的偏移量是连着的,一个读了,另一个接着往下。(第 3 篇 shell 重定向那个演示就依赖这个。)dup2复制的是描述符,指向同一个表项,所以偏移量也共享。
⚠️ 这里有个并发陷阱:多个进程用 O_APPEND 打开同一个日志文件写,是安全的——内核保证"定位到末尾"和"写"这两步原子完成。但如果你自己 lseek 到末尾再 write,那就是第 21 篇讲的原子性违反,两个进程会互相覆盖。
五、目录也是文件,但你不能随便写
目录是一个 inode,类型标着"目录",内容是那张"名字 → inode 号"的表。
但你不能用 write 去写一个目录。想加一条记录,只能通过 creat、mkdir、link、rename 这些系统调用。
为什么要限制? 因为目录结构的完整性关系到整个文件系统。让用户随便写,一个手滑就能造出指向随机 inode 的记录,或者造出一个环。⭐ 内核在这里做的事和第 4 篇一样:不让你直接操作,只给你一组有限的、保证不变式的操作。
也正因如此,硬链接不许指向目录——否则你可以造出一个环,而目录树"是一棵树"这个假设一旦破掉,rm -r、find、备份工具全都会陷进去。
(. 和 .. 是例外,它们是内核自己维护的,而且遍历工具都专门认得它们。)
六、代价与取舍
这套设计换来了什么:
- 名字和内容分离,于是有了硬链接、有了"删了还能用"、有了原子的
rename(第 27 篇会重度使用它)。 - 一个极简的接口:
open/read/write/close五个动作管住了磁盘文件、管道、套接字、终端、/proc。⭐ “一切皆文件"的实际含义是:一切都能用这五个动作操作。
它花了什么:
- 多一次间接。按路径找文件要一级一级查目录,每一级都是一次 inode 读取。(所以文件系统要缓存目录项——
dentry cache。) - inode 数量是有限的。格式化时就定好了。⚠️ 一个分区可能"还有空间但创建不了文件”——inode 用光了。海量小文件的场景真的会遇到。
它放弃了什么: “删除"这个直觉。 rm 不删除任何东西,它只是解除一个链接。上面那个 df 事故就是这个抽象泄漏的代价。
七、小结
- ⭐ 文件名不在文件里。 inode 是文件的身份(大小、权限、时间、链接数、数据块位置),名字是目录里的一条记录。
- 硬链接是给同一个 inode 多加一张卡片;
rm其实是unlink——去掉一条记录,链接数减 1。实测:删掉a.txt,b.txt内容完好。 - 软链接有自己的 inode,内容是一个路径字符串,目标没了就断。能跨文件系统、能指向目录,硬链接两样都不行。
- ⭐ 回收条件是"链接数为 0 且 没有进程打开着它”。 实测:
rm一个 200 MB 的打开文件,ls里没了、链接数 0,但仍占 200 MB 且 fd 还能读。 - ⚠️ 这就是"磁盘满了,删了日志
df却没变"——写日志的进程还开着它。du看不到,lsof看得到。反过来它也是个特性:创建后立刻 unlink,进程一退出内核自动回收,绝不留垃圾。 - 偏移量在"打开文件表项"上,不在 inode 上。所以
fork出来的父子共享偏移量,open两次则各读各的。⚠️O_APPEND是原子的,自己 lseek 再 write 不是。 - 目录是文件,但不能用
write写——只能通过保证不变式的那组系统调用。硬链接不许指向目录,否则目录树不再是树。 - 代价:多一次间接、inode 数量有限(可能"有空间但建不了文件")、以及**“删除"这个直觉不成立**。
思考题
mv a.txt b.txt在同一个文件系统里是一个rename系统调用,几乎瞬间完成。跨文件系统mv呢?为什么会慢那么多?- 第三节说"创建后立刻 unlink"是安全的临时文件做法。那 Linux 的
O_TMPFILE又解决了什么这个做法解决不了的问题?(提示:想想 unlink 之前那一瞬间。) - 一个目录里有一百万个文件,
ls会很慢。慢在哪一步?换成ls -f(不排序)能快多少?为什么? - 图书馆那个比方里,“打开引用数"对应什么?(提示:卡片被抽掉了,但书还在某个人手里。)