我并不完全了解 Linux 做出的选择,但其他答案中 Linux 内核的评论指出了我 13 年前在 OpenBSD 中所做的工作,所以我在这里尝试记住到底是什么继续。
由于open的实现方式,它首先分配一个文件描述符,然后它实际上尝试在解锁文件描述符表的情况下完成打开操作。一个原因可能是我们实际上不想引起 open 的副作用(最简单的方法是更改文件的时间,但例如打开设备可能会产生更严重的副作用),因为我们退出了文件描述符。这同样适用于分配文件描述符的所有其他操作,当您阅读下面的文本时,只需将 open 替换为“分配文件描述符的任何系统调用”即可。我不记得这是 POSIX 规定的还是只是事情一直以来的做法。
open 可以分配内存,进入文件系统并做一堆可能长时间阻塞的事情。对于像 fuse 这样的文件系统,在最坏的情况下,它甚至可能会回到用户态。由于这个原因(和其他原因),我们实际上不想在整个打开操作期间锁定文件描述符表。内核中的锁在睡眠时很难保持,如果完成锁定操作可能需要与用户空间进行交互[1]。
当有人在一个线程(或共享相同文件描述符表的进程)中调用 open 时,会出现问题,它分配了一个文件描述符但尚未完成,而同时另一个线程执行 @ 987654329@ 指向与open 刚刚获得的相同文件描述符。由于未完成的文件描述符仍然无效(例如,read 和 write 在您尝试使用它时会返回 EBADF),我们实际上还不能关闭它。
在 OpenBSD 中,这是通过使用复杂的引用计数跟踪已分配但尚未打开的文件描述符来解决的。大多数操作会假装文件描述符不存在(但它也不能分配)并且只会返回EBADF。但是对于dup2,我们不能假装它不存在,因为它存在。最终结果是,如果两个线程同时调用open和dup2,open实际上会对文件执行完全打开操作,但是由于dup2赢得了文件描述符的竞争,最后一件事open做是减少它刚刚分配的文件的引用计数并再次关闭它。与此同时,dup2 赢得了比赛并假装关闭了open 得到的文件描述符(它实际上并没有这样做,实际上是open 做到了)。内核选择哪种行为并不重要,因为在这两种情况下,这都会导致open 或dup2 出现意外行为。充其量,返回 EBUSY 的 Linux 只是缩小了比赛的窗口,但比赛仍然存在,没有什么可以阻止 dup2 调用的发生,就像open 在另一个线程中返回并替换之前的文件描述符一样open 的调用者有机会使用它。
当您参加这场比赛时,您问题中的错误很可能会发生。为避免这种情况,请不要dup2 指向您不知道其状态的文件描述符,除非您确定没有其他人将同时访问文件描述符表。唯一可以确定的方法是成为唯一运行的线程(文件描述符一直由库在你背后打开)或者确切地知道你正在覆盖什么文件描述符。首先允许dup2 覆盖未分配的文件描述符的原因是关闭 fds 0、1 和 2 以及 dup2 /dev/null 进入它们是一种常见的习惯用法。
另一方面,在dup2 之前不关闭文件描述符将丢失来自close 的错误返回。不过我不担心,因为来自close 的错误很愚蠢,一开始就不应该存在:Handling C Read Only File Close Errors 另一个线程意外行为的例子以及文件描述符如何奇怪地表现,因为我一直在谈论这里看到这个问题:Socket descriptor not getting released on doing 'close ()' for a multi-threaded UDP client
这里有一些示例代码来触发这个:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <err.h>
#include <pthread.h>
static void *
do_bad_things(void *v)
{
int *ip = v;
int fd;
sleep(2); /* pretend this is proper synchronization. */
if ((fd = open("/dev/null", O_RDONLY)) == -1)
err(1, "open 2");
if (dup2(fd, *ip))
warn("dup2");
return NULL;
}
int
main(int argc, char **argv)
{
pthread_t t;
int fd;
/* This will be our next fd. */
if ((fd = open("/dev/null", O_RDONLY)) == -1)
err(1, "open");
close(fd);
if (mkfifo("xxx", 0644))
err(1, "mkfifo");
if (pthread_create(&t, NULL, do_bad_things, &fd))
err(1, "pthread_create");
if (open("xxx", O_RDONLY) == -1)
err(1, "open fifo");
return 0;
}
FIFO 是导致open 阻塞的标准方法,只要您愿意。正如预期的那样,这在 OpenBSD 上静默运行,在 Linux 上 dup2 返回 EBUSY。在 MacOS 上,由于某种原因,它会杀死我执行“echo foo > xxx”的 shell,而打开它进行编写的普通程序工作正常,我不知道为什么。
[1] 这里有一个轶事。我一直参与编写用于 AFS 实现的类似熔断器的文件系统。我们遇到的一个错误是我们在调用用户空间时持有文件对象锁。目录项查找的锁定协议要求您持有目录锁,然后查找目录项,锁定该目录项下的对象,然后释放目录锁。由于我们持有文件对象锁,其他一些进程进来并试图查找文件,这导致该进程在仍然持有目录锁的同时为文件锁休眠。另一个进程进来了,试图查找目录,并最终持有父目录的锁。长话短说,我们最终得到了一连串锁,直到我们到达根目录。与此同时,文件系统守护进程仍在通过网络与服务器通信。由于某种原因,网络操作失败,文件系统守护进程需要记录错误消息。为此,它必须读取一些语言环境数据库。为此,它需要使用完整路径打开文件。但由于根目录被其他人锁定,守护进程等待该锁定。我们有一个 8 锁长的死锁链。这就是为什么内核经常执行复杂的柔术体操以避免在长时间操作期间持有锁,尤其是文件系统操作。