为啥分叉我的进程会导致文件被无限读取
Posted
技术标签:
【中文标题】为啥分叉我的进程会导致文件被无限读取【英文标题】:Why does forking my process cause the file to be read infinitely为什么分叉我的进程会导致文件被无限读取 【发布时间】:2018-10-11 04:01:07 【问题描述】:我已经将我的整个程序简化为一个简短的主程序来复制这个问题,所以请原谅我没有任何意义。
input.txt 是一个文本文件,其中包含几行文本。这个简化的程序应该打印这些行。但是,如果调用了 fork,程序会进入一个无限循环,并在其中一遍又一遍地打印文件的内容。
据我了解 fork,我在这个 sn-p 中使用它的方式本质上是无操作的。它分叉,父母在继续之前等待孩子,孩子立即被杀死。
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
enum MAX = 100 ;
int main()
freopen("input.txt", "r", stdin);
char s[MAX];
int i = 0;
char* ret = fgets(s, MAX, stdin);
while (ret != NULL)
//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);
ret = fgets(s, MAX, stdin);
编辑:进一步调查只会让我的问题变得陌生。如果文件包含
Edit2:如果文件包含数字 3 行数字,它将无限循环,但如果它包含 3 行单词,则不会。
【问题讨论】:
在循环之外获取分叉。您正在创建一个新流程的每一行。等等,这没有任何意义.. 如果您立即退出子进程,为什么还要创建子进程? 阅读我的帖子,它解释了为什么这段代码没有意义。 您在哪个平台上工作?我在运行 macOS 10.13.4 (High Sierra) 的 Mac 上编译了代码,并使用了它自己的源代码input.txt
,它运行良好——这是我所期望的。
最新的 Linux Mint。还在 Ubuntu 16 VM 上进行了测试。
耐人寻味——在 Ubuntu 16.04 LTS(在 Mac 上运行的 VM 中)下,问题确实重现,源代码在 Mac 上运行良好。我认为 Linux 上的库中存在一个错误 - 库而不是内核更有可能有问题。你不应该遇到这个问题。
【参考方案1】:
我很惊讶有一个问题,但这似乎是 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 标准未涵盖的冗长部分)是这样的:
2.5.1 Interaction of File Descriptors and Standard I/O Streams
可以通过使用
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()
之前是必要的,以确保一致性。不这样做会导致未定义的行为('如果不这样做,结果是未定义的')——例如无限循环。
【讨论】:
好文章。我想从我记事起,它就一直在 Linux 中这样工作。我认为 C 标准说 close/fclose 负责释放 FILE 结构 e 的缓冲区。但是正如您所说,重置fpos可能没有任何意义。看看让孩子关闭并进入睡眠而不是退出时会发生什么会很有趣。 @visibleman:谢谢。我承认,基于之前对 Unix 系统的广泛经验,我怀疑这可能是一个真正的问题。然而,经验证据表明 Linux 存在一个特殊的问题。 (你的记忆有多远?我使用 Unix 已经 30 多年了,自 90 年代末以来一直使用 Linux,但我以前从未注意到这一点。)我注意到 @ 中的孩子中有一个明确的fclose(fp);
987654467@避免问题; neof97.c
中的显式 fclose(stdin);
也是如此。不过,这些都不是必需的,IMO。
自 90 年代以来我一直在使用 Linux ...但是看到这种特殊效果的记忆可能要追溯到 10 多年前?我假设显式关闭会在某些孩子身上返回错误条件?如果我在阅读您的文章后抛出一个理论。 - 也许 exit->close 检测到对已关闭的结构的 close() 调用的错误,而是执行 lseek to head?
您是否考虑过以您的示例发布针对 GNU glibc
的错误报告?
@BasileStarynkevitch:我创建了 GLIBC 错误 23151 并在答案和“另一个问题”(Unwanted child processes being created while reading file) 的答案中引用了它。【参考方案2】:
exit() 调用关闭所有打开的文件句柄。在分叉之后,子节点和父节点拥有相同的执行堆栈副本,包括 FileHandle 指针。当子进程退出时,它会关闭文件并重置指针。
int main()
freopen("input.txt", "r", stdin);
char s[MAX];
prompt(s);
int i = 0;
char* ret = fgets(s, MAX, stdin);
while (ret != NULL)
//Commenting out this region fixes the issue
int status;
pid_t pid = fork(); // At this point both processes has a copy of the filehandle
if (pid == 0)
exit(0); // At this point the child closes the filehandle
else
waitpid(pid, &status, 0);
//End region
printf("%s", s);
ret = fgets(s, MAX, stdin);
【讨论】:
但我认为子线程有进程映像的副本,所以它应该只关闭文件句柄的副本。这就是允许使用 dup2 进行管道工作的原因。 首先,我认为你最好不要在读取文件时使用标准输入。其次,freopen 的 fstream 指针参数就是一个指针,指向某个内存区域中的结构。所以父子都拥有同一个指针的副本,但是后面的内存区域是共享的。 好吧,这更有意义。在我的实际程序中,我不是用文件替换标准输入,而是有人从命令行运行我的程序并将文件重定向到其中(用文件替换标准输入) 使用重定向输入按预期运行程序时是否会出现相同的症状? 如果我运行程序并粘贴整个文件的内容,它就可以工作。只有当我尝试将文件重定向到我的程序时它才会中断。【参考方案3】:正如 /u/visibleman 所指出的,子线程正在关闭文件并在 main 中搞砸了。
我可以通过检查程序是否处于终端模式来解决它
!isatty(fileno(stdin))
如果标准输入已被重定向,那么它会在进行任何处理或分叉之前将其全部读入链表。
【讨论】:
【参考方案4】:将exit(0) 替换为_exit(0),一切正常。这是一个古老的 unix 传统,如果你使用 stdio,你的分叉图像必须使用 _exit(),而不是 exit()。
【讨论】:
不是很老;_exit()
是一个相对较新的发明。
它在 V7 UNIX 中就是为了这个目的,所以至少从 1979 年开始。以上是关于为啥分叉我的进程会导致文件被无限读取的主要内容,如果未能解决你的问题,请参考以下文章