Linux 文件描述符

最后更新: 2021-10-06 12:25:39 +0800 +0800
原文以英文撰写。您正在阅读AI翻译版本。

Linux 将进程的整数句柄与存储打开实例状态的内核对象分离。这个区别解释了偏移共享、复制、继承以及许多描述符泄漏。

描述符表和打开文件描述 #

文件描述符是一个非负整数,用于索引调用进程的描述符表。表项引用一个打开文件描述,在 Linux 内部由 struct file 表示。打开文件描述记录如下状态:

  • 当前文件偏移;
  • O_APPEND 和 O_NONBLOCK 等文件状态标志;
  • 打开的路径、访问模式、凭据和文件操作。

每次成功的 open() 通常都会创建一个新的打开文件描述,并在进程表中安装一个引用。因此,打开同一路径两次会创建独立偏移:

c
int a = open("events.log", O_RDONLY);
int b = open("events.log", O_RDONLY); /* independent description and offset */

inode 标识文件系统对象,而打开文件描述标识对它的一次打开。取消链接或重命名路径不会使现有描述符失效。

描述符标志属于表项,而不是共享的打开文件描述。主要描述符标志是 FD_CLOEXEC,通过 fcntl(fd, F_GETFD/F_SETFD) 读取或更改。应优先使用 O_CLOEXEC、pipe2(O_CLOEXEC) 和 dup3(..., O_CLOEXEC) 等原子创建标志,因为在后续 fcntl() 调用中设置标志,会与另一个线程中的 fork() 加 execve() 竞争。

复制、fork() 和 execve() #

dup()、dup2() 和 dup3() 创建另一个引用同一打开文件描述的描述符。因此,复制的描述符共享偏移和文件状态标志:

c
int input = open("input", O_RDONLY | O_CLOEXEC);
int stdin_copy = dup(input);

read(input, buf, 16);      /* advances the shared offset */
read(stdin_copy, buf, 16); /* starts after those 16 bytes */

新描述符拥有自己的描述符标志。特别是,dup() 和 dup2() 会清除新描述符上的 close-on-exec;dup3() 可以原子地设置它。dup2(oldfd, newfd) 会在必要时原子关闭 newfd 并复用该编号,从而避免先关闭再复制的竞争。

fork() 之后,父进程和子进程拥有独立的描述符表,但继承的表项引用相同的打开文件描述。这些引用之间仍共享偏移和状态标志变化。成功执行 execve() 后,除非表项设置了 FD_CLOEXEC,否则描述符仍保持打开;描述符表本身会保留到新程序中。

使用共享文件表创建的线程(如 POSIX 线程)会看到相同的描述符表项。因此,一个线程关闭或替换描述符可能影响另一个线程,且描述符编号可能立即被重用。

匿名管道 #

pipe() 创建一个内核字节流,并返回其读端和写端的描述符:

c
int fd[2];
if (pipe2(fd, O_CLOEXEC) == -1)
    /* handle error */;

/* fd[0] is readable; fd[1] is writable. */

传统的 fork() 管道必须在每个进程中关闭未使用的副本:

c
if (fork() == 0) {
    close(fd[1]);
    consume(fd[0]);
    close(fd[0]);
    _exit(0);
}

close(fd[0]);
produce(fd[1]);
close(fd[1]); /* the reader can observe EOF after every writer closes */

关闭纪律是协议的一部分:

  • 当管道仍有写入者时,空管道上的 read() 会阻塞,除非设置了 O_NONBLOCK。
  • 只有在缓冲区为空且引用写端的所有描述符都已关闭后,read() 才返回零。
  • 写满的管道上的 write() 会阻塞,或在 O_NONBLOCK 下失败/部分成功。
  • 所有读端关闭后执行 write() 会引发 SIGPIPE;如果该信号被忽略或处理,调用会失败并返回 EPIPE。

管道是字节流,不保留一般消息边界。POSIX 保证不超过 PIPE_BUF 字节的写入不会与其他进程的写入交错。Linux 将 PIPE_BUF 定义为 4096 字节。更大的写入可能被拆分或交错;非阻塞写入也可能部分成功,因此调用方必须检查返回值。

O_NONBLOCK 是打开文件描述上的文件状态标志。通过一个复制的描述符设置它,会改变共享该描述的所有描述符所观察到的行为。

FIFO 作为命名会合点 #

mkfifo() 创建一个类型为 FIFO 的文件系统项:

c
if (mkfifo("/run/example/events.fifo", 0600) == -1 && errno != EEXIST)
    /* handle error */;

目录项不存储流内容。FIFO 打开期间,内核维护一个管道对象,进程通过打开路径访问它。打开后,读取、写入、原子性、EOF 和 SIGPIPE 遵循管道语义。

打开操作提供会合行为:

  • 阻塞式 open(path, O_RDONLY) 会等待写入者。
  • 阻塞式 open(path, O_WRONLY) 会等待读取者。
  • 没有写入者时,非阻塞只读打开也会成功;没有写入者时读取返回零,或在存在写入者但没有可用数据时返回 EAGAIN。
  • 在读取者打开 FIFO 之前,非阻塞只写打开会失败并返回 ENXIO。

Linux 允许使用 O_RDWR 打开 FIFO,即使没有对端,但 POSIX 将该行为留作未定义。保持两端打开也可能抑制 EOF 或造成自死锁,因此可移植协议应使用明确的读取者和写入者角色。

由于 FIFO 名称可能在检查和 open() 之间被替换,应将其放在受控目录中;在信任边界要求时验证所有权和类型,并避免使用先检查再打开的授权逻辑。

参考资料 #

评论

在 GitHub 上阅读或评论(发表评论需要登录)