「文件描述符就是个 int」——这句话没错,但把它当成全部理解,会解释不了三类高频线上问题:为什么 fork 出来的子进程和父进程写同一个文件会互相顶位置(offset 共享),为什么 rm 掉一个大日志文件磁盘空间却不释放(fd 持有 inode),为什么 nginx 改完 worker_rlimit_nofile 重启后 ulimit -n 还是老值(两层上限)。这三个问题的答案都在同一个地方:fd 不只是整数,它是一张三层结构的第一层索引——进程 fd 表 → open file description(struct file)→ inode。这一篇把三层逐一拆开,你会看到 shell 重定向、nohup、日志轮转、rm 不释放空间这些「日常魔法」全都是这三层结构上几种基本操作的不同组合。
一、三层结构:每一层管什么
第一层是进程级的 fd 表:数组下标是 fd,槽位指向一个 open file description。进程独享这张表,ulimit -n 限制的就是它的长度上限。第二层是系统级的 open file description(OFD,内核里的 struct file):记录打开时的标志(O_RDWR、O_APPEND、O_NONBLOCK)、当前读写偏移量 offset、以及指向第三层的指针。它由 open() 创建,同一次 open 的所有使用共享同一个 OFD。第三层是文件系统级的 inode:一个物理文件的元数据本体——大小、权限、时间戳、数据块指针,以及引用计数(链接数)。inode 由文件系统管理,ls -i 显示的就是它:
$ ls -li /var/log/app.log
268501 -rw-r----- 1 app app 2147483648 Nov 5 10:24 /var/log/app.log
^链接数=1
第一列 268501 是 inode 号,第三列的 1 是硬链接计数。三层的引用关系可以这样直观理解:fd 表的槽位是「进程对这个打开实例的引用」,OFD 是「打开实例本身」,inode 是「文件本体」——多个打开实例可以指向同一个文件,多个 fd 可以指向同一个打开实例。看 /proc/self/fdinfo 就能同时看到三层的状态:
$ ls -l /proc/self/fd/1 # fd 1 指向哪个打开实例/文件
l-wx------ 1 root root 64 Nov 5 10:31 /proc/self/fd/1 -> /var/log/app.log
$ cat /proc/self/fdinfo/1
pos: 2147483904 # 该 OFD 的当前 offset
flags: 0100001 # O_WRONLY|O_APPEND(0x80000 = O_APPEND)
mnt_id: 252
pos 和 flags 都属于第二层 OFD——这是理解 dup 与 fork 行为的钥匙。
二、三层结构与共享关系
三种「共享」路径对应三类完全不同的行为,画成图后所有困惑都消失了:
flowchart TD
subgraph P1["进程 A"]
FD1["fd 3"] --> OFD_A
FD2["fd 4 (dup自3)"] --> OFD_A
end
subgraph P2["进程 B (fork自A)"]
FD3["fd 3 (继承)"] --> OFD_A
end
OFD_A["OFA: 同一个 open file description<br/>offset=1024, flags=O_APPEND"] --> INO["inode 268501<br/>app.log, 链接数 1"]
subgraph P3["进程 C (独立 open 同一文件)"]
FD4["fd 3 (自己open)"] --> OFD_B
end
OFD_B["OFB: 另一个 open file description<br/>offset=0, flags=O_RDONLY"] --> INO
三个要点读出来:dup/dup2/fork 产生的 fd 指向同一个 OFD,所以共享 offset 与标志;进程 C 自己 open 同一个文件,得到独立的 OFD,offset 各走各的;但两个 OFD 最终都落到同一个 inode。这一个模型直接解释三类现象。其一,shell 重定向 cmd > file 2>&1:> file 把 fd 1 重定向(dup2 打开 file 的 fd 覆盖 fd 1),2>&1 再把 fd 2 dup 到 fd 1——此后 stdout 与 stderr 是同一个 OFD,共享同一个 offset,这就是为什么 stderr 的输出不会覆盖 stdout 已写的位置(若各自独立 open,两个 offset 独立推进,反而会互相覆盖——正是 2>&1 存在的意义)。其二,fork 后父子共享 OFD:父进程 lseek(fd, 0, SEEK_END) 写到末尾后 fork,子进程继续 write 会从父进程停下的位置接着写——Kafka、Redis 的持久化子进程依赖这个语义。反过来,Nginx 的多个 worker 故意不共享:master 先 open 日志再 fork,所有 worker 共享同一个 OFD 与 offset,日志行首尾相接;如果每个 worker 自己 open,offset 互不知晓,写入会相互穿插覆盖(除非用 O_APPEND)。其三,O_APPEND 是 inode 层面的原子写保证:每次 write 前内核自动把 offset 定位到文件当前末尾(在 inode 的锁保护下),多个 OFD、多个进程并发 append 也不会互相覆盖——这是多进程写同一日志文件的标准解法,但注意它是「单次 write 调用」的原子性,不是「一次日志语句」的原子性:一条日志若分两次 write 仍可能被别人的 write 插队,所以单条日志要在一次 write 里写完(且长度不超过 PIPE_BUF 无关的那个 write() 原子性界限——对普通文件,内核在 append 路径上持有 inode 锁,整段 write 是原子的)。
三、硬链接与 unlink:inode 的引用计数
inode 上有一个容易被误解的计数器——硬链接数(ls -l 第三列)。它的语义不是「多少人打开」,而是「目录树里有多少个目录项(dirent)指向这个 inode」。ln a b 不复制数据,只是在目录表里加一条「文件名 b → inode 268501」的记录并把链接数加一;rm(底层 unlink())则是删掉一个目录项并把链接数减一。只有链接数降到 0 且没有任何打开实例时,inode 才真正被释放——注意「且」:打开着的 fd 也是 inode 的一种引用(内核在 OFD 上持有 struct inode 引用)。这就是「rm 掉大文件空间不释放」的完整机制:目录项没了(ls 看不见文件了),但 nginx 的日志 fd 还开着,inode 的数据块全部保留,df -h 继续显示空间被占。
排查方法是把「谁还开着它」找出来:
$ df -h / | tail -1
/dev/sda1 40G 38G 2.1G 95% /
$ rm /var/log/app.log && df -h / | tail -1 # 95% 依旧
$ lsof +L1 /var/log # 列出链接数为 0 但仍打开的文件
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME
nginx 9120 root 4w REG 8,1 2.1G 0 268501 (deleted)
$ systemctl reload nginx # 经典解法:让进程重新打开日志文件
这也是日志轮转工具(logrotate)发 HUP/USR1 信号给服务的原因:服务收到信号后关闭旧 fd、重新 open 新文件,旧 inode 的引用才清零、空间才释放。另一个相关语义是「目录不能建硬链接」(只有内核允许的 .、.. 例外),防的是目录环——文件系统遍历会死循环;符号链接(软链接)是另一种存在:它是独立 inode,内容是路径字符串,可以指目录、可以跨文件系统、可以悬空,与硬链接是两套完全不同的对象。顺手把 mv 也放进 inode 视角:同一文件系统内 mv 只是 rename()——改一个目录项,inode 号不变、数据不动、打开它的 fd 完全无感(mv big.log renamed.log 后 lsof 里进程还写着原来的 inode);跨文件系统的 mv 才是「复制 + unlink」——先在新文件系统创建新 inode 写数据,成功后删源,inode 号必然变化。这解释了两个日常现象:正在写的日志被 rename 后,进程继续写到旧 inode(logrotate 语境里的「copytruncate 与 create 两种模式之争」正源于此);以及跨盘 mv 大文件时有进度条、同盘 mv 瞬间完成。
四、O_CLOEXEC:exec 瞬间的 fd 泄漏防线
fd 的继承规则里藏着一个安全与稳定性双重敏感的行为:fork 之后 exec,fd 表原样保留。也就是说,一个进程打开了密码文件、数据库连接、内部日志 fd,随后调用 exec 启动子程序(system()、popen()、调用外部工具),这些 fd 会被原封不动地带进新程序——新程序根本不知道它们的存在,更不会关闭。后果有两级:轻则资源泄漏(每个 exec 出来的子进程都攥着一把无用 fd,配合长寿命工具进程就是 fd 耗尽),重则信息泄漏(带权限的 fd,比如一个已打开的 /etc/shadow fd 或 unix 域套接字,被子进程恶意读取——这是经典的安全审计项,2019 年 systemd 修过的若干 fd 泄漏 CVE 都属于这类)。内核给出的机制是 close-on-exec 标志:open(path, O_RDWR | O_CLOEXEC) 打开时就带上,或事后 fcntl(fd, F_SETFD, FD_CLOEXEC);fd 正常使用一切如常,唯独 exec 成功的瞬间被内核自动关闭。这个标志的价值在于把「清理责任」从应用代码挪进了内核语义——否则你要在 exec 之前遍历 fd 表逐个 close,而这个窗口在多线程程序里是天然的竞态(线程 A 刚 open 完还没来得及设 CLOEXEC,线程 B 已经 exec 了——泄漏就在这个缝隙发生;O_CLOEXEC 让 open 与设标志成为一次原子调用,竞态从根上消失)。现代系统的惯例由此而来:所有库代码 open fd 一律带 O_CLOEXEC(glibc、Go、Java 的 NIO 都这么做),只有明确要跨 exec 传递的 fd 才手动摘标志——systemd 的套接字激活(socket activation)正是这个「明确例外」的正规用法:systemd 先监听端口,把 listen fd 标注后传给服务进程(环境变量 LISTEN_FDS),服务启动即可用,无需自己在竞态窗口里抢端口。相关的新系统调用是 close_range(first, last, 0)(5.9+):一条指令关闭一段 fd 范围,daemon 化第 6 步里那个「for 循环 close 到 _SC_OPEN_MAX」在现代内核上的正确替代品就是它——一次系统调用完成全表清理,还支持 CLOSE_RANGE_UNSHARE 处理与子进程共享的表。排查 fd 泄漏给子进程的工具链:ls -l /proc/<child_pid>/fd 逐个看指向(不认识的 fd 就是嫌疑对象),配合 strace -f -e trace=openat,fcntl 观察打开时是否带 O_CLOEXEC 位(strace 显示的 flags 里 02000000 即 O_CLOEXEC)。
五、fd 泄漏与上限:file-max、nr_open 与 ulimit 的层级
fd 是进程资源,但整个系统的 fd 总量也有上限,而且是三层限流,排查「Too many open files」时必须分清是哪一层在报错:
$ cat /proc/sys/fs/file-max # 系统级:全机所有进程 fd 总量上限
9223372036854775807 # 新内核默认极大(64-bit 系统几乎不限)
$ cat /proc/sys/fs/nr_open # 单进程硬上限:ulimit -n 永远不能超过它
1048576
$ cat /proc/sys/fs/file-nr # 当前已分配/已分配上限/历史最大(当前==0表示无上限自由分配)
34288 0 9223372036854775807
$ ulimit -n # 进程级:当前 shell 及其子进程的软限制
1024
$ ulimit -Hn # 硬限制(软限制只能调到硬限制之下)
1048576
三层各自的堵点不同:ulimit -n(软限制)是最常见的报错源——systemd 服务要配 LimitNOFILE=,容器要配 docker 的 --ulimit nofile=,它们各自独立,改了 shell 的 ulimit 不影响 systemd 服务;nr_open 是单个进程的天花板,长连接网关(单进程几十万连接)经常需要把它从默认 1048576 调大,同时把 ulimit -n 也拉上去;file-max 在新内核默认 64-bit 极大值(LONG_MAX),几乎不再堵,但老内核(或某些发行版裁剪)默认值较小,高并发机器要检查 file-nr 第一列与它的差距。报错定位的经验法则:单进程报 EMFILE(Too many open files)查 ulimit/nr_open;全系统报 ENFILE 查 file-max。注意还有一个容易混淆的点:fd 泄漏的根因几乎总是「打开后不 close」或「异常路径跳过 close」——lsof -p <pid> | wc -l 超过千级就该警惕,配合 cat /proc/<pid>/fd | awk -F'->' '{print $2}' | sort | uniq -c | sort -rn | head 能快速看出泄漏的是哪类资源(socket、文件还是 eventfd)。
小结
fd 的完整图景是三层结构:进程 fd 表是索引层(ulimit 限它的长度),open file description 是打开实例层(offset 与 flags 属于它),inode 是文件本体层(链接计数与数据块属于它)。dup/fork 产生的 fd 共享同一个 OFD——offset 与标志同步推进,这是 shell 重定向与 nginx 多 worker 共写日志的实现基础;独立 open 的两个 OFD 互不知晓 offset,并发写需要 O_APPEND 在 inode 锁保护下提供单次 write 的原子追加。硬链接计数与打开引用共同决定 inode 的生死:rm 只删目录项,链接数归零且无人打开才真正释放——「rm 后空间不释放」与 logrotate 的 HUP 信号都在这个机制上。fd 总量有三层限流(ulimit -n / nr_open / file-max),EMFILE 查进程层、ENFILE 查系统层。这个三层模型是文件系统一切行为的骨架——下一篇的文件锁、以及之前讲过的 page cache,都要落到这三层上才能看懂。
读者留言
COMMENTS 暂无还没有留言,来说第一句?