一切皆文件:fd 到底是什么
你以为 open() 给了你一个文件。它没有。它给了你一个数组下标。这个下标本身不携带任何信息——同一个 3,在你的进程里和我的进程里指向完全不同的东西。这一章拆开那三层表。
「一切皆文件」到底是什么意思
这句 Unix 口号常被误解成「所有东西都存在磁盘上」。它的真正含义是:
不管你面对的是磁盘文件、网络连接、管道、终端、键盘、还是内核的运行状态,你都用同一组系统调用操作它:
open / read / write / close / lseek / ioctl
这意味着一个只会读文件的程序,不用改一行代码就能读网络、读管道、读设备。这就是为什么 cat 可以 cat /dev/urandom、可以 cat < /dev/tcp/host/80、可以被管道喂数据。
Unix 最深刻的设计不是某个机制,而是这种「用少数几个动词统一大量名词」的品味。
看看这些「文件」都是什么:
| 路径 | 实际是 | 读它会得到 |
|---|---|---|
/dev/null | 字符设备 | 永远是 EOF。写进去的一律丢弃 |
/dev/urandom | 字符设备 | 无穷无尽的随机字节 |
/proc/self/maps | 内核虚拟文件 | ★ 你自己的地址空间布局(第 10 章)——它不存在于任何磁盘上,是读的时候现生成的 |
/sys/fs/cgroup/cpu.max | 内核虚拟文件 | CPU 配额(第 8 章)。写它就能改配额 |
| 一个 socket | 网络连接 | 对端发来的字节 |
| 一个 pipe | 内核缓冲区 | 另一个进程写进去的字节 |
/proc 和 /sys 是这个理念最漂亮的应用:内核把自己的内部状态伪装成文件系统,于是你用 cat 就能看内核数据、用 echo 就能改内核参数。不需要专门的 API,不需要专门的工具。
那个数字,和它背后的三层
open() 返回 3。为什么是 3?因为 0、1、2 已经被占了——标准输入、标准输出、标准错误。内核返回的永远是当前最小的可用下标。
而这个下标指向的东西,中间隔着三层:
- fd 表——每个进程一张。就是个数组,
fd是下标,内容是指向第二层的指针。 - 打开文件表——全机器一张。每一项代表「一次打开」,文件偏移量住在这里,还有打开模式和引用计数。
- inode 表——全机器一张。代表文件本体:数据在哪、多大、权限、修改时间。
为什么要三层?因为「偏移量」既不属于进程,也不属于文件。它属于「这一次打开」。两个进程各自 open 同一个文件,它们该有各自的读写位置;但 dup 出来的两个 fd 应该共享位置。中间这一层就是为此存在的。
下面这台机器的三层表是真的在增删改、引用计数是真的在加减。请按 demo 里给出的顺序操作——它会带你撞见一个非常经典的坑。
两次 open 和一次 dup,差别在哪
这是三层结构最重要的推论:
| 操作 | fd 表 | 打开文件表 | 偏移量 |
|---|---|---|---|
open 两次同一文件 | 2 个 fd | ★ 2 个表项 | 各自独立 |
dup(3) | 2 个 fd | ★ 1 个表项 | 共享 |
fork() | 2 张表(复制) | ★ 还是原来那些表项 | ★ 共享 |
第三行是最容易出事的。fork 复制了 fd 表,但表里的内容(指向哪个打开文件)是照抄的——所以父子进程共享同一个偏移量。
int fd = open("app.log", O_WRONLY | O_CREAT, 0644);
if (fork() == 0) {
write(fd, "子进程的日志\n", 20); // 从偏移 0 开始写
} else {
write(fd, "父进程的日志\n", 20); // 也从偏移 0 开始写 —— 覆盖了!
}
两边共享偏移量,但「读偏移 + 写 + 更新偏移」这三步不是原子的,于是互相覆盖、内容错乱。
解法是 O_APPEND:
int fd = open("app.log", O_WRONLY | O_CREAT | O_APPEND, 0644);
带上它之后,内核会在每次 write 时原子地「定位到文件末尾 + 写入」。这个原子性是内核在锁的保护下保证的,你在用户态用 lseek(fd, 0, SEEK_END) 加 write 模拟不出来——那两步之间有窗口。
所有多进程写同一个日志文件的场景(nginx 的 worker、gunicorn 的 worker、任何 prefork 模型)都依赖这个标志。这也是为什么日志库总是用追加模式打开文件。
fd 是一种能力,不只是一个编号
有件事值得单独指出:权限检查只发生在 open() 的那一刻。
一旦你拿到了 fd,之后的每次 read/write 都不再检查文件权限——内核只看这个 fd 在你的表里、以及它的打开模式对不对。
这个设计有很实际的后果:
// 程序以 root 启动
int fd = open("/etc/shadow", O_RDONLY); // ← 权限在这里检查,通过
setuid(1000); // 降权成普通用户
read(fd, buf, 100); // ← 仍然读得到!不再检查
这不是漏洞,是特性。它让「特权进程打开资源,然后降权继续运行」成为可能——这是安全编程的一个标准模式(第 6 章那个 fork 与 exec 之间的窗口期,做的就是这类事)。
更进一步:fd 可以通过 Unix domain socket 传给另一个进程(SCM_RIGHTS)。接收方拿到的是一个可用的 fd,哪怕它自己根本没有权限打开那个文件。
这就是所谓的能力(capability)模型:不问「你是谁」,而是「你手上有没有这个东西」。Android 的 Binder 传 ParcelFileDescriptor、systemd 的 socket 激活、Chrome 的沙箱渲染进程拿到文件、Docker 的很多机制——底下都是这一招。
《开门》讲插件系统时反复出现一个主题:怎么把一个受限的能力交给不受信任的代码。fd 传递就是操作系统层面最干净的答案——你不给它权限,你给它一个已经开好的口子,它只能用这个口子,做不了别的。
Chrome 的渲染进程就是这么被关起来的:它的沙箱里几乎什么系统调用都不让用,需要读文件时由主进程打开好、把 fd 递过去。能力比权限更容易收紧,因为它天然是最小化的。
fd 用完了会怎样
fd 表不是无限大的。每个进程有个上限(ulimit -n,常见默认 1024 或 65536),系统还有个全局上限(/proc/sys/fs/file-max)。
用完了,open/accept/socket 全部返回 -EMFILE(Too many open files)。对服务器来说这基本等于宕机——它还活着,但接不了新连接。
常见原因就是 fd 泄漏:打开了忘了关。检查方法:
$ ls -l /proc/1234/fd | head # 看这个进程开了什么 lrwx------ 1 app app 64 ... 0 -> /dev/pts/0 lrwx------ 1 app app 64 ... 3 -> socket:[184729] lrwx------ 1 app app 64 ... 4 -> /var/log/app.log $ ls /proc/1234/fd | wc -l # 开了多少个 $ cat /proc/1234/limits | grep files # 上限是多少
如果这个数字随时间单调增长,那就是泄漏。而且 /proc/PID/fd 会直接告诉你泄漏的是什么类型——是 socket 还是某个文件,往往一眼就能定位到代码。
很多语言的 GC 会在对象被回收时关闭 fd(Java 的 finalizer、Python 的引用计数)。但 GC 的时机由内存压力决定,与 fd 压力无关。
于是会出现这种情况:内存很充裕,GC 迟迟不跑,fd 却已经耗尽了。两种资源,一套回收机制,管不过来。
所以所有现代语言都提供了确定性释放的语法,并且强烈建议你用:Java 的 try-with-resources、Kotlin 的 use { }、Python 的 with、C# 的 using、Rust 的 Drop(编译期保证,最强的一档)。
// 别依赖 GC 关 fd
FileInputStream(f).use { ins ->
ins.readBytes()
} // ← 出了这个块立刻 close,不等 GC
「Android 上『Too many open files』崩溃怎么查?」
Android 的 fd 上限通常是 1024(有些设备 32768)。崩溃时的 log 会告诉你 EMFILE,但不会告诉你泄漏的是什么。
调试手段就是上面那个 /proc 目录:
$ adb shell ls -l /proc/$(adb shell pidof com.example.app)/fd
常见的泄漏源,按出现频率排:
- Cursor 没关——查数据库之后没
close()。这是第一名。 - Bitmap / InputStream 没关——尤其是异常路径上漏掉的。
- OkHttp 的 Response body 没消费也没关——连接一直挂着。
- 每次都 new 一个 OkHttpClient——每个都带自己的连接池和线程池。应该全局共用一个。
注意 Android 上 socket、数据库连接、匿名共享内存(ashmem)、甚至 Binder 引用都占 fd,所以这个数字涨得比你想的快。用 StrictMode 的 detectLeakedClosableObjects() 可以在开发期就抓到。
这一章的一句话
fd 不是文件,是你进程私有的一个数组下标;它背后有三层表,而文件偏移住在中间那层——这解释了为什么 dup 和 fork 会共享偏移量,也解释了 O_APPEND 为什么不可替代。更进一步,fd 是一种能力:权限只在 open 时检查一次,之后它可以被降权保留、甚至传给别的进程。
下一章往下走一层:那第三层的 inode 到底是什么?我们会看到「名字」和「数据」是彻底分开的两件事——以及这为什么让 rm 掉的日志还占着你的磁盘。