文件描述符:一个整数背后的三层跳转

文件描述符:一个整数背后的三层跳转
隔壁老康难度 ★★☆☆☆ · 前置:一次命令执行:用户态、内核态与系统调用 | 一句话结论:fd 是进程私有的一张表的下标,顺着它能走到内核的
struct file,再走到 inode。dup共享偏移、open不共享,fork继承、exec保留——五个问句的答案都在这一条链上。
ls > out.txt 这条命令我用得很早,早到已经想不起来第一次是在哪篇教程里看到的,反正是把输出写进文件,敲完就完事,从来没觉得这行字里有什么值得多看一眼的地方。它太顺了,顺到你根本不会去想中间到底发生了什么,我甚至还产生过一个更省事的念头,觉得重定向多半是 shell 的一个小语法糖,把文件名字符串塞给了程序,程序自己打开自己写。真正的疑问是后来才冒出来的,而且冒出来之后很久都没散掉,ls 这个程序 1971 年就存在了,它怎么可能知道重定向这回事,> 又不是它的参数,它连 out.txt 这个字符串都收不到,凭什么呢。
我还专门去翻过 coreutils 的源码,想找找 out.txt 到底是从哪一段读进来的。
翻了半天,没找到。
因为这个东西压根不存在,ls 从头到尾都不知道自己写去了哪儿,它的代码里只有一句话,往 1 号写,写完就把结果交给内核,后面的事它一概不管。
就这一句。
一本贴了号码的册子
可以直接把内核想成发遥控器的,每个进程出生的时候领到一本小册子,册子上贴着一排号码,1 号默认对着屏幕,2 号对着错误输出,0 号对着键盘,程序只管按 1 号,从来不掀开看一眼它此刻对着哪儿。shell 干的事就是在启动 ls 之前,把 1 号那个遥控器从屏幕前面拔下来,插到 out.txt 上,然后才 exec,这本册子叫文件描述符表,册子上的号码就是文件描述符,中间没有任何魔法,也没有任何进程间通信,你能想到的所有重定向花样,全是在这一行命令之前就准备好的。这个分工的好处是责任特别清楚,换插头的人不需要懂程序,写程序的人也不需要懂设备,两边只要认那本册子上的号码就行,谁也不用打听对面是谁。
这个设计妙就妙在这儿。
程序可以一行不改,输出就去向了别处,ls、cat、grep、ps 这几十年里从没为重定向改过一次代码,它们只是一直按 1 号键,至于 1 号背后是屏幕还是文件,它们既不知道也不关心。反过来看也一样,你今天自己写一个程序,只要老老实实往 1 号写,重定向、管道、tee、日志收集这些能力全是白送的,不用你实现任何一样。
三层,不是一层
上一篇的 strace 输出里 openat 返回过 3,这个 3 就是一个 fd。它的本质朴素得有点伤人,内核给每个进程维护一张表,表里存着一堆指向 struct file 的指针,fd 就是这张表的下标,所以它永远是当前进程内最小的可用非负整数,不是全局编号,也不是某种句柄对象。我当初以为是反过来的,以为 fd 是内核统一发下来的号,全球唯一,后来同时开两个终端,看见两边都有 fd 3,而且指向两个毫不相干的文件,才把脑子里那个模型推翻,推翻之后还得回头重读一遍自己写过的代码,看看有没有哪一处是依赖那个错模型的。
graph LR P["进程<br/>files_struct"] --> FD["fd 表<br/>0 1 2 3 4 …"] FD -->|"fd 3"| F1["struct file<br/>offset=4<br/>flags=O_RDWR"] FD -->|"fd 4"| F2["struct file<br/>offset=0<br/>flags=O_RDWR"] FD -->|"fd 5"| F1 F1 --> I["inode<br/>属主 / 权限 / 大小 / 数据块位置"] F2 --> I I --> D["磁盘上的数据块"]
图里有一处值得多看两眼,3 号和 5 号指向同一个 struct file,这正是 dup 的结果,两个号码,一份状态。
把这张图记在脑子里,后面的内容全是从它身上长出来的。第一,fd 表是进程私有的,你的 fd 3 和别人的 fd 3 指向完全不同的 struct file,同名不同命,一个进程的号码对另一个进程没有任何含义,拿不到也没法转给谁。第二,struct file 才代表一次打开,偏移量、打开标志、文件操作函数表全在这里,同一个 inode 可以被打开很多次,每次产生一个互相独立的 struct file,读到了哪儿、能不能写、要不要缓冲,这些问题都该问 struct file 而不是问 inode。第三,inode 才是文件本身,它描述数据块在哪、多大、属主是谁,文件名不在 inode 里,那是目录项的事,讲硬链接那篇会说透。
0、1、2 是约定,不是特权
进程启动时,只要父进程没干什么奇怪的事,都会带着三个已经打开的 fd 进场。
| fd | 名字 | 通常指向 |
|---|---|---|
| 0 | stdin | 终端、管道,或者某个文件 |
| 1 | stdout | 同上 |
| 2 | stderr | 同上,但一般不做缓冲 |
通常这两个字很重要,cmd > out.txt 2>&1 之后,1 和 2 都指向 out.txt,cmd </dev/null 之后,0 指向 /dev/null,它们没有任何特殊地位,只是数字小,小到你写代码的时候懒得去判,程序也不会因为判断错了而报错,只是日志不知道去了哪儿(我第一次给 systemd 写服务,就是靠 ls -l /proc/PID/fd 才发现 1 号根本不是我以为的屏幕,那会儿还在奇怪 journalctl 为什么一条日志都收不到)。
ls 打开目录时拿到 3,纯粹因为 0、1、2 被占了,不是因为 3 这个数字有什么讲究。
最小可用,以及它埋的坑
分配规则只有一条,从 0 开始找,返回第一个空着的。
这条规则让重定向变得极其简单,dup2(fd, 1) 的意思是关掉 1,再把 1 指向 fd 指向的那个 struct file,shell 执行 cmd > out.txt 干的就是先 open("out.txt") 拿到某个 fd,dup2(那个fd, 1),最后 close(那个fd),三步,没有第四步。这三步的顺序也不能反,反了会变成先把 1 号关掉再打开文件,结果文件自己先占住 1 号,后面的 dup2 就成了自己复制自己,看着像成功,其实什么都没搬。
但同一条规则也坑过不少人,经典的守护进程写法长这样,看着很合理。
这一篇的 C 例子都用 gcc -O0 -o x x.c && ./x 编译,头文件要 #include <fcntl.h>、#include <unistd.h>、#include <stdio.h>。
1 | /* 有 bug 的版本 */ |
此时 open 返回的是 1,不是 3,因为这三个位置刚刚被腾空,最小可用的规则立刻把 1 号发出去了,后面任何一句 printf 都会直接写进日志文件,而写日志的代码本身毫无察觉。
我遇到这事的现场是这样的,日志里冒出一堆调试输出,但代码里明明是 printf 到屏幕的,查了很久以为是某处漏了重定向,把启动脚本翻来覆去看了三遍,逐行对着 systemd 的 unit 文件比对,还是没找到能把 stdout 换掉的地方,甚至怀疑过是不是哪个库偷偷改了。最后把 /proc/PID/fd 打出来才发现,进程自己把 stdout 换成了日志文件。那次之后我养成一个顺序,先看 fd,再看代码,因为前者三十秒就有答案,后者可能要看一小时,而且看完也不一定有结论。
正确做法是打开 /dev/null 再 dup2 上去,把三个缺口先堵住。
1 | int null_fd = open("/dev/null", O_RDWR); |
这样 0、1、2 都指向 /dev/null,后面的 open 才从 3 开始,日志文件不会再被调试输出污染,最后那句 if 是为了防止 null_fd 本身恰好落在 0 到 2 之间被误关。
dup 共享偏移,open 不共享
这是最容易混淆的一点,也是排障时最常用的区分。
dup(fd) 复制的是指针,新旧 fd 指向同一个 struct file,共享偏移量、共享打开标志,一个读完另一个接着读,位置是接上的。再调一次 open 打开同一个路径,会创建一个新的 struct file,偏移量从 0 开始,互不影响,两边各读各的。
为什么要在意这个,日志切割工具就靠它活着。
如果进程用 >> 追加日志,等价于 O_APPEND,每条 write 都重新定位到文件末尾,所以就算日志被 rename 走了,写入还是会落到新文件里,因为路径名变了,inode 没变,而 O_APPEND 每次定位到的是文件末尾这个语义位置。但如果进程是 O_RDWR 加手写 lseek,切割之后它还在往老 inode 里写,于是 du 显示磁盘没释放,df 那边一点没降。这就是删了文件空间不下降的一个成因,inode 与硬链接那篇把这条链路拆开讲了。
fork 继承,exec 保留
fork 出的子进程复制了父进程的 fd 表,但表里的指针指向同一批 struct file,所以父子进程共享偏移量,父进程读了 100 字节,子进程接着读就是从第 100 字节开始,两个进程像在同一根水管上轮流接水,谁先接谁就往前推。
顺带说一个复制的特殊形态,fork 和 dup 都是在同一个进程或它的子进程之间复制 fd,而 Unix domain socket 的 SCM_RIGHTS 能把一个 fd 传给完全无关的另一个进程,传过去之后,接收方拿到的 fd 号可能和发送方不一样,但两者指向同一个 struct file,同一个文件、同一个偏移量。systemd 就是靠这个把监听 socket 交给服务进程的(LISTEN_FDS 那套,我看了两遍文档才明白服务端那个数字是从哪来的),细节在IPC 选型那篇。这条路径也有代价,发送方在传的那一刻不能关掉自己的副本,关早了接收方拿到的是一个已经失效的号码,而且这个错误不会立刻报出来。
execve 不一样,它换掉整个地址空间,但 fd 表会保留,除非那个 fd 带 FD_CLOEXEC。
这就是 O_CLOEXEC 存在的理由,管道、socket、临时文件这些不该泄漏给子进程的 fd,在打开时就标记上 exec 时自动关闭,避免子进程悄悄持有它们导致管道关不掉、wait 卡死。
从 Linux 2.6.23 起 O_CLOEXEC 是原子的,open 一次性完成创建 fd 和打标记两件事,中间没有能被 fork 插进来的窗口,这是它相对 open 加 fcntl(FD_CLOEXEC) 的关键优势,多线程下后者有竞态。
麻烦在于,不是所有造 fd 的调用都叫 open,管道、socket、连接、收来的 fd,各有各的入口,也各有各的原子 flag。
| 造 fd 的调用 | 用哪个 flag | 起于 |
|---|---|---|
open/openat |
O_CLOEXEC |
2.6.23 |
socket |
SOCK_CLOEXEC |
2.6.27 |
pipe2 |
O_CLOEXEC |
2.6.27 |
dup3 |
O_CLOEXEC |
2.6.27 |
accept4 |
SOCK_CLOEXEC |
2.6.28 |
fcntl 复制 |
F_DUPFD_CLOEXEC |
2.6.24 |
recvmsg 收 fd |
MSG_CMSG_CLOEXEC |
2.6.23 |
epoll_create1 |
EPOLL_CLOEXEC |
2.6.27 |
eventfd/signalfd/inotify_init1 |
EFD_CLOEXEC 等 |
2.6.27 起 |
记住这张表的现实意义,一个多线程服务里,如果用的是 accept 加 fcntl(FD_CLOEXEC),那中间那个窗口足以让另一个线程 fork 出子进程、把这条连接带进子进程,然后父进程关了 fd、子进程还开着,端口永远不释放,重启也起不来,报错还只说 Address already in use。
这也是为什么现代代码几乎不用 pipe、accept、dup 这些老版本,不是老版本不能用,是它们的原子版才是安全的,而安全这件事,写的时候看不见,出事的时候才看得见。
上限有四个层次
限制不是一个数,是四个数,挨着看一遍就明白了。
1 | ulimit -n # shell 的软限制 |
现代发行版上 ulimit -n 常见 1024,硬限制 ulimit -Hn 是 524288。这个 524288 不是内核定的,是 systemd 替所有进程抬上去的,内核自己的默认硬限制只有 4096。至于 1048576,那是 fs.nr_open 的值,它管的是 setrlimit 最多能要到多高,不是你手里已经拿到了多少,两个数经常被混着说,其实是三个层次(systemd 那层留到进程那一阶再讲)。这四个数是嵌套的,外面的管着里面的,谁也越不过谁,把其中一个改大不等于另外三个跟着变大,改完没生效,多半是撞在了更外面那一层上。
1024 这个值是被 systemd 按服务单元继承下来的历史默认,很多 too many open files 的报错就来自它,跟系统能力没关系(我第一次碰到的时候去翻内核参数,翻了半晚上,最后发现只是 session 的软限制,那种感觉挺没面子的)。
把三个进程的 fd 表摊开
一个进程打开了什么
光看代码不如直接问内核,下面这段先起一个把三个标准 fd 全重定向掉的进程,再去看它的 fd 目录。
1 | sleep 1000 < /etc/hostname > /tmp/out.txt 2> /tmp/err.txt & |
1 | total 0 |
/proc/PID/fd 下每一项都是一个符号链接,链接目标就是它最终指向的路径。权限位第一个字符告诉你打开模式,r 只读、w 只写、u 读写。
管道和 socket 也会出现在这里,但目标是 pipe:[12345] 或 socket:[12345] 这种形式,方括号里是 inode 号。这个号很有用,lsof | grep 12345 能顺着它找到另一头是谁。
验证最小可用
1 | exec 7>/tmp/seven.txt # 在 shell 里开一个 fd 7 |
1 | 0 -> /dev/pts/0 |
这里直接跳到 7,因为 3 到 6 明明空着却没被分配,exec 7>... 是你指定了号码,不走分配逻辑。
想看自动分配,得用程序测。
1 |
|
1 | a=3 b=4 c=5 |
三行输出各说一件事,c 是 5 是因为 dup 拿到的是下一个空位,a 和 c 的偏移都是 4 是因为 write 之后走到第 4 字节而两者共享同一个 struct file,b 是 0 是因为它是另一次 open,close(a) 之后新 open 又拿到 3 是因为 fd 号会被回收复用。
最后那条最容易忽略,号码会回收,所以 lsof 里看到的号码一直在变,但总数在涨,这也是 fd 泄漏难查的原因。
数一数漏没漏
1 | ls /proc/$PID/fd | wc -l |
一个正常的长跑服务,这个数应该长期稳定。如果它隔几分钟涨几个、从不下降,就是有 open 没有对应的 close,这时候别急着翻代码,先确认是不是某个错误分支上提前 return 了,那种路径平时跑不到,出事了才跑。
定位办法是间隔采样两次,把 ls -l 出来的链接目标做 diff,新增的那批就是泄漏点。
fd 的两个错觉
fd 就是文件?它可能指向管道、socket、设备、eventfd、epoll 实例、内存文件,凡是可以 read、write 的东西都行,/proc/PID/fd 里出现 socket:[...] 是常态,所以把它叫文件句柄只说对了一半。fd 是全局的?完全进程私有,同一个数字在两个进程里毫无关联,这也是 pidfd 这个新接口要先拿到进程句柄的原因,光有 PID 不够,PID 会被回收,句柄不会。
同一个文件打开两次,偏移共享?不共享,只有 dup 和 fork 才共享,这个区别决定了多线程各写各的文件要不要加锁,两个独立 fd 各自 write,偏移互不干扰,共用一个 fd 就必然交错。close 之后用旧 fd 会报错?不一定,fd 号会被立刻回收,下次 open 可能拿到同一个号,于是旧代码里那个已经关掉的 fd 又指向了一个新文件,写入静默成功(这个坑我踩过两次,第二次是半年后,写法和第一次几乎一样,说明知道和做到是两回事)。
0/1/2 一定连着终端?systemd 托管的服务默认 0 指向 /dev/null,1、2 指向 journal,想知道真相就 ls -l /proc/PID/fd,别猜。
两个区别,一个后果
fd 是进程私有数组的下标,最小可用,会被回收复用。
dup 共享偏移,open 不共享,这一个区别能解释很多写文件串了的怪现象。
fork 继承、exec 保留,除非显式标了 O_CLOEXEC。
下一个问题换了个角度,程序到底怎么按这批遥控器,read、write、lseek 背后各有一层缓冲在偷偷攒批,攒得好省一次系统调用,攒不好就丢数据,而这两件事在代码里长得一模一样。












