我很惊讶有一个问题,但这似乎是 Linux 上的问题(我在我的 Mac 上的 VMWare Fusion VM 中运行的 Ubuntu 16.04 LTS 上进行了测试)——但在我运行的 Mac 上不是问题macOS 10.13.4 (High Sierra),我也不认为它会成为其他 Unix 变体的问题。
正如我在comment 中指出的:
每个流后面都有一个打开的文件描述和一个打开的文件描述符。当进程分叉时,子进程拥有自己的一组打开文件描述符(和文件流),但子进程中的每个文件描述符与父进程共享打开文件描述。 IF(这是一个很大的“如果”)子进程关闭文件描述符首先执行相当于lseek(fd, 0, SEEK_SET),然后这也将定位文件描述符为父进程,这可能导致无限循环。但是,我从未听说过有这样的图书馆。没有理由这样做。
有关打开文件描述符和打开文件描述的更多信息,请参阅 POSIX open() 和 fork()。
打开的文件描述符是进程私有的;打开文件描述由初始“打开文件”操作创建的文件描述符的所有副本共享。打开文件描述的关键属性之一是当前查找位置。这意味着子进程可以更改父进程的当前查找位置——因为它位于共享打开文件描述中。
neof97.c
我使用了以下代码 - 对原始代码进行了适度修改,并使用严格的编译选项进行干净编译:
#include "posixver.h"
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
enum { MAX = 100 };
int main(void)
{
if (freopen("input.txt", "r", stdin) == 0)
return 1;
char s[MAX];
for (int i = 0; i < 30 && fgets(s, MAX, stdin) != NULL; i++)
{
// Commenting out this region fixes the issue
int status;
pid_t pid = fork();
if (pid == 0)
{
exit(0);
}
else
{
waitpid(pid, &status, 0);
}
// End region
printf("%s", s);
}
return 0;
}
其中一项修改将周期(子)的数量限制为 30 个。
我使用了一个包含 4 行 20 个随机字母加上一个换行符(总共 84 个字节)的数据文件:
ywYaGKiRtAwzaBbuzvNb
eRsjPoBaIdxZZtJWfSty
uGnxGhSluywhlAEBIXNP
plRXLszVvPgZhAdTLlYe
我在 Ubuntu 上运行strace 下的命令:
$ strace -ff -o st-out -- neof97
ywYaGKiRtAwzaBbuzvNb
eRsjPoBaIdxZZtJWfSty
uGnxGhSluywhlAEBIXNP
plRXLszVvPgZhAdTLlYe
…
uGnxGhSluywhlAEBIXNP
plRXLszVvPgZhAdTLlYe
ywYaGKiRtAwzaBbuzvNb
eRsjPoBaIdxZZtJWfSty
$
有 31 个名称格式为 st-out.808## 的文件,其中哈希值是 2 位数字。主进程文件很大;其他的都很小,尺寸为 66、110、111 或 137:
$ cat st-out.80833
lseek(0, -63, SEEK_CUR) = 21
exit_group(0) = ?
+++ exited with 0 +++
$ cat st-out.80834
lseek(0, -42, SEEK_CUR) = -1 EINVAL (Invalid argument)
exit_group(0) = ?
+++ exited with 0 +++
$ cat st-out.80835
lseek(0, -21, SEEK_CUR) = 0
exit_group(0) = ?
+++ exited with 0 +++
$ cat st-out.80836
exit_group(0) = ?
+++ exited with 0 +++
$
碰巧的是,前 4 个孩子各自表现出四种行为中的一种——而后面的每组 4 个孩子表现出相同的模式。
这表明四分之三的孩子在退出之前确实在标准输入上执行了lseek()。显然,我现在已经看到一个图书馆这样做了。我不知道为什么它被认为是一个好主意,但根据经验,这就是正在发生的事情。
neof67.c
这个版本的代码,使用单独的文件流(和文件描述符)和fopen()而不是freopen()也会遇到问题。
#include "posixver.h"
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
enum { MAX = 100 };
int main(void)
{
FILE *fp = fopen("input.txt", "r");
if (fp == 0)
return 1;
char s[MAX];
for (int i = 0; i < 30 && fgets(s, MAX, fp) != NULL; i++)
{
// Commenting out this region fixes the issue
int status;
pid_t pid = fork();
if (pid == 0)
{
exit(0);
}
else
{
waitpid(pid, &status, 0);
}
// End region
printf("%s", s);
}
return 0;
}
这也表现出相同的行为,除了发生搜索的文件描述符是3而不是0。所以,我的两个假设被推翻了——它与freopen() 和stdin 有关;第二个测试代码都显示不正确。
初步诊断
IMO,这是一个错误。你应该不会遇到这个问题。
它很可能是 Linux (GNU C) 库而不是内核中的错误。它是由子进程中的lseek() 引起的。目前还不清楚(因为我没有去看源代码)库在做什么或为什么。
GLIBC 错误 23151
GLIBC Bug 23151 - 具有未关闭文件的分叉进程在退出前执行 lseek,并可能导致父 I/O 中的无限循环。
该错误创建于 2019 年 5 月 8 日美国/太平洋地区,并于 2018 年 5 月 9 日以 INVALID 的形式关闭。给出的理由是:
请阅读
http://pubs.opengroup.org/onlinepubs/9699919799/functions/V2_chap02.html#tag_15_05_01,
尤其是这一段:
请注意,在fork() 之后,存在两个句柄,而之前存在一个句柄。 […]
POSIX
引用的 POSIX 的完整部分(除了注意到 C 标准未涵盖的冗长部分)是这样的:
可以通过使用open() 或pipe() 等函数创建的文件描述符或使用fopen() 或popen() 等函数创建的流来访问打开的文件描述。文件描述符或流在它所指的打开文件描述中被称为“句柄”;一个打开的文件描述可能有多个句柄。
句柄可以通过明确的用户操作来创建或销毁,而不会影响底层打开文件的描述。创建它们的一些方法包括fcntl()、dup()、fdopen()、fileno() 和fork()。它们至少可以被fclose()、close() 和exec 函数销毁。
从未在可能影响文件偏移量的操作中使用的文件描述符(例如,read()、write() 或 lseek())不被视为此讨论的句柄,但可能会导致一个(例如,作为fdopen()、dup() 或fork() 的结果)。此异常不包括流底层的文件描述符,无论是使用fopen() 还是fdopen() 创建的,只要应用程序不直接使用它来影响文件偏移即可。 read() 和 write() 函数隐式影响文件偏移量; lseek() 明确影响它。
涉及任何一个句柄(“活动句柄”)的函数调用的结果在本卷 POSIX.1-2017 的其他地方定义,但如果使用两个或多个句柄,并且其中任何一个是流,应用程序应确保它们的操作按如下所述进行协调。如果不这样做,结果是不确定的。
当fclose() 或freopen() 具有非完整(1) 文件名在其上执行时,作为流的句柄被认为已关闭(对于freopen()对于空文件名,实现定义是创建新句柄还是重用现有句柄),或者当拥有该流的进程以exit()、abort() 或由于信号终止时。当文件描述符上设置了 FD_CLOEXEC 时,文件描述符由 close()、_exit() 或 exec() 函数关闭。
(1)[原文如此] 使用 'non-full' 可能是 'non-null' 的拼写错误。
为了使句柄成为活动句柄,应用程序应确保在最后一次使用句柄(当前活动句柄)和第一次使用第二个句柄(未来活动句柄)之间执行以下操作。然后第二个句柄成为活动句柄。应用程序影响第一个句柄上的文件偏移量的所有活动都应暂停,直到它再次成为活动文件句柄。 (如果流函数具有影响文件偏移量的底层函数,则应认为流函数影响文件偏移量。)
要应用这些规则,句柄不必在同一个进程中。
请注意,在fork() 之后,存在两个句柄,而之前存在一个句柄。应用程序应确保,如果两个句柄都可以访问,它们都处于另一个可以首先成为活动句柄的状态。应用程序应为fork() 做好准备,就像它是活动句柄的更改一样。 (如果其中一个进程执行的唯一操作是exec() 函数或_exit()(不是exit())之一,则永远不会在该进程中访问句柄。)
对于第一个句柄,以下第一个适用条件适用。采取以下要求的操作后,如果句柄仍处于打开状态,则应用程序可以将其关闭。
如果是文件描述符,则无需操作。
如果对该打开的文件描述符的任何句柄执行的唯一进一步操作是关闭它,则无需执行任何操作。
如果是未缓冲的流,则无需执行任何操作。
-
如果它是一个行缓冲的流,并且写入流的最后一个字节是<newline>(也就是说,就像一个:
putc('\n')
是对该流的最新操作),无需采取任何措施。
-
如果它是一个可写入或追加的流(但也不是可读取的),应用程序要么执行fflush(),要么关闭流。
李>
如果流已打开以供读取并且位于文件末尾(feof() 为真),则无需执行任何操作。
如果流以允许读取的模式打开,并且底层打开文件描述指的是能够查找的设备,则应用程序应执行fflush(),或者应关闭流。
对于第二个句柄:
- 如果任何先前的活动句柄已被显式更改文件偏移量的函数使用,除了上面对第一个句柄的要求外,应用程序应执行
lseek() 或fseek()(根据句柄类型而定) ) 到适当的位置。
如果在满足上述第一个句柄的要求之前无法访问活动句柄,则打开文件描述的状态变为未定义。这可能发生在诸如fork() 或_exit() 之类的函数期间。
exec() 函数使在调用它们时打开的所有流都无法访问,而与新进程映像可以使用哪些流或文件描述符无关。
当遵循这些规则时,无论使用的句柄顺序如何,实现都应确保应用程序,即使是由多个进程组成的应用程序,也应产生正确的结果:写入时不会丢失或重复数据,并且所有数据都应按顺序书写,除非寻求者要求。它是由实现定义的,是否以及在什么条件下,所有输入都只能看到一次。
对流进行操作的每个函数都被称为具有零个或多个“底层函数”。这意味着流函数与底层函数共享某些特征,但不要求流函数的实现与其底层函数之间存在任何关系。
释经
这很难阅读!如果您不清楚打开文件描述符和打开文件描述之间的区别,请阅读open() 和fork()(以及dup() 或dup2())的规范。 file descriptor 和 open file description 的定义也是相关的,如果简洁的话。
在这个问题的代码上下文中(也适用于Unwanted child processes being created while file reading),我们有一个文件流句柄以只读方式打开,它还没有遇到 EOF(所以feof() 不会返回 true,即使读取位置在文件末尾)。
规范的关键部分之一是:应用程序应准备fork(),就像它是活动句柄的更改一样。
这意味着为“第一个文件句柄”概述的步骤是相关的,并且单步执行它们,第一个适用的条件是最后一个:
- 如果流以允许读取的模式打开,并且底层打开文件描述指的是能够搜索的设备,则应用程序应执行
fflush(),或者应关闭流。
如果您查看fflush() 的定义,您会发现:
如果 stream 指向未输入最新操作的输出流或更新流,fflush() 将导致该流的任何未写入数据写入文件,[ CX] ⌦ 并标记底层文件的最后一次数据修改和最后一次文件状态变化时间戳。
对于带有底层文件描述的打开读取的流,如果文件尚未处于EOF,并且该文件能够查找,则应将底层打开文件描述的文件偏移设置为文件位置流,以及由ungetc() 或ungetwc() 推回流中且随后未从流中读取的任何字符都将被丢弃(无需进一步更改文件偏移量)。 ⌫
如果您将fflush() 应用于与不可搜索文件关联的输入流会发生什么,目前尚不清楚,但这不是我们最关心的问题。但是,如果您正在编写通用库代码,那么在对流执行 fflush() 之前,您可能需要知道底层文件描述符是否可搜索。或者,使用 fflush(NULL) 让系统对所有 I/O 流执行任何必要的操作,注意这将丢失所有推回的字符(通过 ungetc() 等)。
strace 输出中显示的lseek() 操作似乎实现了fflush() 语义,将打开文件描述的文件偏移量与流的文件位置相关联。
所以,对于这个问题中的代码,似乎fflush(stdin) 在fork() 之前是必要的,以确保一致性。不这样做会导致未定义的行为('如果不这样做,结果是未定义的')——例如无限循环。