卷 IV · 万物CH 16深度 16/24

一切皆文件:fd 到底是什么

你以为 open() 给了你一个文件。它没有。它给了你一个数组下标。这个下标本身不携带任何信息——同一个 3,在你的进程里和我的进程里指向完全不同的东西。这一章拆开那三层表。

fd 三层表文件偏移dup一切皆文件

「一切皆文件」到底是什么意思

这句 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 已经被占了——标准输入、标准输出、标准错误。内核返回的永远是当前最小的可用下标。

而这个下标指向的东西,中间隔着三层:

◆ 三层表
  1. fd 表——每个进程一张。就是个数组,fd 是下标,内容是指向第二层的指针。
  2. 打开文件表——全机器一张。每一项代表「一次打开」,文件偏移量住在这里,还有打开模式和引用计数。
  3. inode 表——全机器一张。代表文件本体:数据在哪、多大、权限、修改时间。

为什么要三层?因为「偏移量」既不属于进程,也不属于文件。它属于「这一次打开」。两个进程各自 open 同一个文件,它们该有各自的读写位置;但 dup 出来的两个 fd 应该共享位置。中间这一层就是为此存在的。

▶ 动手 · fd 的三层表

下面这台机器的三层表是真的在增删改、引用计数是真的在加减。请按 demo 里给出的顺序操作——它会带你撞见一个非常经典的坑。

两次 open 和一次 dup,差别在哪

这是三层结构最重要的推论:

操作fd 表打开文件表偏移量
open 两次同一文件2 个 fd2 个表项各自独立
dup(3)2 个 fd1 个表项共享
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,之后的每次 readwrite不再检查文件权限——内核只看这个 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 用完了会怎样

fd 表不是无限大的。每个进程有个上限(ulimit -n,常见默认 1024 或 65536),系统还有个全局上限(/proc/sys/fs/file-max)。

用完了,openacceptsocket 全部返回 -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 还是某个文件,往往一眼就能定位到代码。

¤ 价目表 · 关于 close 的一个提醒

很多语言的 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,所以这个数字涨得比你想的快。用 StrictModedetectLeakedClosableObjects() 可以在开发期就抓到。

这一章的一句话

fd 不是文件,是你进程私有的一个数组下标;它背后有三层表,而文件偏移住在中间那层——这解释了为什么 dup 和 fork 会共享偏移量,也解释了 O_APPEND 为什么不可替代。更进一步,fd 是一种能力:权限只在 open 时检查一次,之后它可以被降权保留、甚至传给别的进程。

下一章往下走一层:那第三层的 inode 到底是什么?我们会看到「名字」和「数据」是彻底分开的两件事——以及这为什么让 rm 掉的日志还占着你的磁盘。