文件描述符是一个进程内部用于引用已打开资源的小整数。进程的描述符表把这个数字映射到内核维护的打开文件描述;对于普通文件,打开描述包含当前偏移和文件状态标志。另一个进程可以把相同整数用于完全不同的资源,因此数字本身不是全局文件身份。理解描述符表、打开描述与底层文件之间的关系,才能正确判断复制、关闭和继承的效果。

坏掉的样子

dup 分配另一个描述符,让它引用同一份打开文件描述;它不是简单复制整数,也不是重新按路径打开文件。因此,两份描述符读取普通文件时共享偏移。相反,再次打开相同路径,通常会得到具有独立偏移的新打开描述。设备与 inode 身份相同,不代表打开状态也相同,排查读取位置异常时必须区分这两个层面。

状态标志和描述符标志也属于不同层次。通过 F_SETFL 改变共享打开描述的状态,复制出来的引用都会看到;close-on-exec 标志则分别属于每个描述符。把这些层次混在一起,可能改变另一个消费者的行为,也可能让描述符意外进入后续执行的程序。选择复制引用之前,需要知道自己想独立控制的是寿命、偏移,还是继承行为。

关闭描述符会释放表项,整数可能立即被再次使用。只保留旧数字的代码,之后可能操作新资源,而不是稳定得到“无效描述符”错误。当描述符在回调、线程或库之间传递时,需要明确所有权和寿命,不能仅检查一个整数非负,就认定它仍然代表原来那个对象。数字看起来没有变化,引用关系却可能已经完全不同。

快速验证

在一次性 Linux 环境中运行下面的 Python 脚本。它创建两份无害临时文件,并关闭自己打开的所有描述符。实测环境为 Linux 6.12.76、Python 3.11.2。实验没有后台线程,因此可以观察分配与复用,不会有另一个线程同时抢占刚释放的编号:

from pathlib import Path
from tempfile import TemporaryDirectory
import fcntl,os

with TemporaryDirectory(prefix='xbcatm-fds-') as directory:
    root=Path(directory);original=root/'original';other=root/'other'
    original.write_bytes(b'abcdef');other.write_bytes(b'replacement')
    handles=set()
    try:
        first=os.open(original,os.O_RDONLY);handles.add(first)
        duplicate=os.dup(first);handles.add(duplicate)
        independent=os.open(original,os.O_RDONLY);handles.add(independent)
        assert first!=duplicate
        assert os.read(first,2)==b'ab' and os.read(duplicate,2)==b'cd'
        assert os.read(independent,2)==b'ab'
        assert os.lseek(first,0,os.SEEK_CUR)==4
        print('dup uses a different descriptor but shares offset; a separate open has its own offset')
        flags=fcntl.fcntl(duplicate,fcntl.F_GETFL)
        fcntl.fcntl(duplicate,fcntl.F_SETFL,flags|os.O_NONBLOCK)
        assert fcntl.fcntl(first,fcntl.F_GETFL)&os.O_NONBLOCK
        assert not fcntl.fcntl(independent,fcntl.F_GETFL)&os.O_NONBLOCK
        os.set_inheritable(first,False);os.set_inheritable(duplicate,True)
        assert not os.get_inheritable(first) and os.get_inheritable(duplicate)
        print('file status flags shared by dup; close-on-exec flags remain descriptor-specific')
        os.close(first);handles.remove(first)
        reused=os.open(other,os.O_RDONLY);handles.add(reused)
        assert reused==first and os.read(first,32)==b'replacement'
        assert os.read(duplicate,2)==b'ef'
        print('closed descriptor number is reused for another file; surviving duplicate still reads original')
        original.unlink();os.lseek(duplicate,0,os.SEEK_SET)
        assert os.fstat(duplicate).st_nlink==0 and os.read(duplicate,32)==b'abcdef'
        print('unlink removes the pathname while an open descriptor retains access')
    finally:
        for handle in handles:os.close(handle)

第一次打开读取 ab,复制描述符接着读取 cd,两者此时都看到偏移四。对同一个文件独立执行 open,则仍从自己的起始位置读到 ab。这些精确字节证明哪份状态被共享;仅仅看到两个入口都能读取相同内容,并不足以区分复制引用和独立打开,因为它们本来就可以指向同一个底层文件。

脚本通过复制描述符设置 O_NONBLOCK,从原描述符读到该标志,而独立打开仍保留此前状态。这里使用普通文件,验证的是标志归属,不是声称普通磁盘读取会因此变成异步操作。随后给复制描述符设置不同的可继承属性,确认原描述符的 close-on-exec 行为没有改变,从而把两类标志的共享范围实际区分开。

关闭第一份描述符后,再打开另一文件会复用相同整数。通过保存旧编号的变量读取,此时得到的是 replacement;仍存活的复制描述符却继续读到原文件末尾的 ef。这直接说明,一个已经关闭的整数并不是对原对象的持久引用,甚至不能指望误用时总是马上报错。重新分配之后,它可能成为完全合法但对象错误的句柄。

取消原路径的链接会删除目录项。复制描述符的文件状态报告链接数为零,但重新定位并读取,仍能得到 abcdef。样本中的其他打开引用,也在关闭前继续有效。删除名字、关闭某个描述符和释放最后一份打开引用,是不同操作,因此不能根据某个路径消失,就推断文件内容已经没有任何使用者。

脚本用集合记录当前真正拥有的描述符,在编号复用前先移除已经关闭的条目,避免把同一个复用整数当成两份需要分别关闭的资源。真实程序在转移所有权,或用包装对象管理底层描述符时,也需要这种清晰关系。否则一个迟到的清理动作,就可能关闭另一个模块刚刚打开的新对象。

在交接处定义所有权

函数应说明自己只是借用描述符,还是接管关闭责任。适合时使用具有明确寿命的包装对象,避免两个独立所有者都去关闭同一个整数。复制引用可以分离描述符本身的寿命,但仍然共享打开描述状态;如果需要独立偏移,就应明确采用符合需求的独立打开,而不是把 dup 当成完整状态复制。

启动另一个程序时,应显式选择继承。语言运行时可能默认创建不可继承描述符,子进程接口也可能关闭允许集合以外的全部句柄。应验证真实启动行为,不能假定 fork、exec 和高级进程接口会暴露完全相同的描述符。继承规则既影响功能,也影响哪个进程仍然持有资源。

最后,操作要匹配资源类型。管道和套接字同样通过描述符访问,但不会因此具有普通文件的可定位偏移契约。正确管理描述符,需要同时考虑资源语义、打开描述状态,以及哪个组件可以关闭每一份进程内句柄,而不是把所有整数都当成可以互换操作的文件编号。

来源

  • open(2),进程描述符表、打开文件描述、偏移与标志,已核验 2026-09-12。
  • dup(2),新描述符分配、共享打开描述与描述符标志,已核验 2026-09-12。
  • unlink(2),删除名字后通过打开引用保留文件,已核验 2026-09-12。