为什么分叉我的进程会导致文件被无限读取
- 2024-10-28 08:37:00
- admin 原创
- 46
问题描述:
我把整个程序简化为一个简短的主程序来复制这个问题,所以请原谅我它没有任何意义。
input.txt 是一个文本文件,其中包含几行文本。这个简化的程序应该会打印这些行。但是,如果调用 fork,程序就会进入无限循环,一遍又一遍地打印文件的内容。
据我了解,fork 在本代码片段中使用它的方式本质上是无操作。它 fork 后,父进程等待子进程继续,然后子进程立即被终止。
#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);
}
}
编辑:进一步调查只会让我的问题变得更加奇怪。如果文件包含<4 个空白行或<3 行文本,则不会中断。但是,如果超过这个数目,则会无限循环。
编辑2:如果文件包含3行数字,它将无限循环,但如果包含3行单词,则不会。
解决方案 1:
我很惊讶存在问题,但它似乎确实是 Linux 上的问题(我在 Mac 上的 VMWare Fusion VM 中运行的 Ubuntu 16.04 LTS 上进行了测试) - 但它不是运行 macOS 10.13.4(High Sierra)的 Mac 上的问题,而且我也不认为它在其他 Unix 变体上会是个问题。
正如我在评论中指出的那样:
每个流背后都有一个打开的文件描述和一个打开的文件描述符。当进程分叉时,子进程有自己的一组打开的文件描述符(和文件流),但子进程中的每个文件描述符都与父进程共享打开的文件描述。如果(这是一个很大的“如果”)关闭文件描述符的子进程首先执行了相当于的操作
lseek(fd, 0, SEEK_SET)
,那么这也会为父进程定位文件描述符,这可能会导致无限循环。但是,我从未听说过有库会执行这种搜索;没有理由这样做。
有关打开文件描述符和打开文件描述的更多信息open()
,请参阅 POSIX 。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
;第二个测试代码显示两者都不正确。
初步诊断
在我看来,这是一个错误。您不应该遇到这个问题。这很可能是 Linux(GNU C)库中的错误,而不是内核中的错误。它是由lseek()
子进程中的引起的。目前尚不清楚(因为我没有查看源代码)库在做什么或为什么这样做。
GLIBC 错误 23151
GLIBC Bug 23151 - 具有未关闭文件的分叉进程在退出之前执行 lseek,并可能导致父 I/O 中的无限循环。
该漏洞于 2018 年 5 月 8 日美国/太平洋时间创建,并于 2018 年 5 月 9 日作为无效漏洞关闭。给出的原因是:
请阅读
http://pubs.opengroup.org/onlinepubs/9699919799/functions/V2_chap02.html#tag_15_05_01,特别是这一段:
请注意,在 之后
fork()
,存在两个句柄,而之前只有一个句柄。[…]
POSIX
所提到的 POSIX 的完整部分(除了指出 C 标准未涵盖这一内容的措辞外)如下:
2.5.1 文件描述符和标准I/O流的交互
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()
、或 信号而终止时,该句柄被视为已关闭。当在文件描述符上设置了 FD_CLOEXEC 时,文件描述符由、或函数abort()
关闭。close()
`_exit()`exec()
(1) [原文如此] 使用“non-full”可能是“non-null”的拼写错误。
要使句柄成为活动句柄,应用程序应确保在最后一次使用该句柄(当前活动句柄)和第一次使用第二个句柄(未来活动句柄)之间执行以下操作。然后第二个句柄将成为活动句柄。应用程序影响第一个句柄上的文件偏移量的所有活动都应暂停,直到它再次成为活动文件句柄。(如果流函数具有影响文件偏移量的底层函数,则应认为该流函数影响文件偏移量。)
要应用这些规则,句柄不需要位于同一进程中。
请注意,在 之后
fork()
,两个句柄会同时存在,而之前只有一个句柄。应用程序应确保,如果两个句柄都可以访问,则它们都处于一个状态,即另一个可以先成为活动句柄。应用程序应为 做好准备,fork()
就像更改活动句柄一样。(如果其中一个进程执行的唯一操作是exec()
或_exit()
(不是exit()
)之一,则该进程永远不会访问该句柄。)对于第一个句柄,适用下面的第一个适用条件。在执行下面所需的操作后,如果句柄仍处于打开状态,则应用程序可以将其关闭。
如果它是一个文件描述符,则不需要采取任何行动。
如果对此打开文件描述符的任何句柄执行的唯一进一步操作是关闭它,则无需采取任何操作。
如果它是非缓冲的流,则不需要采取任何操作。
如果它是一个行缓冲的流,并且写入该流的最后一个字节是
<newline>
(也就是说,好像
`putc('
')`
是该流上的最近操作),则不需要采取任何操作。
如果它是一个为写入或附加而打开的流(但不为读取而打开),则应用程序应执行
fflush()
,或者应关闭该流。如果流已打开并可供读取,且位于文件末尾(
feof()
为真),则无需采取任何操作。如果流以允许读取的模式打开,并且底层打开的文件描述引用具有寻址能力的设备,则应用程序应执行
fflush()
,或者应关闭流。对于第二个句柄:
如果任何先前的活动句柄已被明确更改文件偏移量的函数使用,除了上面对第一个句柄的要求之外,应用程序应执行
lseek()
或fseek()
(根据句柄类型)到适当的位置。如果在满足上述第一个句柄的要求之前,活动句柄不再可访问,则打开文件描述的状态将变为未定义。这可能发生在诸如
fork()
或 之类的函数期间_exit()
。这些
exec()
函数使得调用时打开的所有流都无法访问,无论新进程映像可以使用哪些流或文件描述符。遵循这些规则后,无论使用句柄的顺序如何,实现都应确保应用程序(即使是由多个进程组成的应用程序)产生正确的结果:写入时不会丢失或重复任何数据,所有数据都应按顺序写入,除非搜索请求。是否以及在什么条件下所有输入都只被看到一次是由实现定义的。
每个对流进行操作的函数都被认为具有零个或多个“底层函数”。这意味着流函数与底层函数共享某些特征,但不要求流函数的实现与其底层函数之间存在任何关系。
注释
这很难读!如果你不清楚打开文件描述符和打开文件描述之间的区别,请阅读open()
和fork()
(和dup()
或)的规范。文件描述符和打开文件描述dup2()
的定义也相关,如果简洁的话。
在这个问题的代码上下文中(以及对于在读取文件时创建的不需要的子进程),我们有一个打开的文件流句柄,只用于读取,它还没有遇到EOF(所以feof()
不会返回true,即使读取位置在文件末尾)。
规范中一个至关重要的部分是:应用程序应该做好准备,fork()
就像是活动句柄的改变一样。
这意味着针对“第一个文件句柄”概述的步骤是相关的,并且逐步执行它们,第一个适用的条件是最后一个:
如果流以允许读取的模式打开,并且底层打开的文件描述引用具有寻址能力的设备,则应用程序应执行
fflush()
,或者应关闭流。
如果你看一下 的定义fflush()
,你会发现:
如果流指向一个输出流或一个未输入最近操作的更新流,
fflush()
则应导致该流的任何未写入数据写入文件,[CX] ⌦并且底层文件的最后数据修改和最后文件状态更改时间戳应标记为更新。对于使用底层文件描述打开以进行读取的流,如果文件尚未到达 EOF,并且文件能够查找,则底层打开文件描述的文件偏移量应设置为流的文件位置,并且任何被推回到流中的字符
ungetc()
或ungetwc()
随后尚未从流中读取的字符都应被丢弃(无需进一步更改文件偏移量)。⌫
目前还不清楚如果将 应用于fflush()
与不可查找文件关联的输入流会发生什么,但这不是我们当前关心的问题。但是,如果您正在编写通用库代码,那么您可能需要在fflush()
对流执行 之前知道底层文件描述符是否可查找。或者,使用fflush(NULL)
让系统对所有 I/O 流执行必要的操作,请注意,这将丢失任何推回的字符(通过ungetc()
等)。
lseek()
输出中显示的操作似乎strace
正在实现将fflush()
打开文件描述的文件偏移量与流的文件位置相关联的语义。
因此,对于本问题中的代码,似乎fflush(stdin)
在之前必须执行fork()
以确保一致性。不执行此操作会导致未定义的行为(“如果不执行此操作,则结果未定义”)——例如无限循环。
解决方案 2:
exit() 调用会关闭所有打开的文件句柄。在 fork 之后,子进程和父进程拥有相同的执行堆栈副本,包括 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);
}
}
解决方案 3:
正如 /u/visibleman 指出的那样,子线程正在关闭文件并在主线程中搞乱一切。
我可以通过检查程序是否处于终端模式来解决这个问题
!isatty(fileno(stdin))
如果 stdin 已被重定向,那么它会在进行任何处理或分叉之前将其全部读入链接列表。
解决方案 4:
将 exit(0) 替换为 _exit(0),一切就都好了。这是古老的 unix 传统,如果您使用 stdio,则分叉映像必须使用 _exit(),而不是 exit()。
- 2024年20款好用的项目管理软件推荐,项目管理提效的20个工具和技巧
- 2024年开源项目管理软件有哪些?推荐5款好用的项目管理工具
- 项目管理软件有哪些?推荐7款超好用的项目管理工具
- 项目管理软件哪个最好用?盘点推荐5款好用的项目管理工具
- 项目管理软件有哪些最好用?推荐6款好用的项目管理工具
- 项目管理软件有哪些,盘点推荐国内外超好用的7款项目管理工具
- 2024项目管理软件排行榜(10类常用的项目管理工具全推荐)
- 项目管理软件排行榜:2024年项目经理必备5款开源项目管理软件汇总
- 2024年常用的项目管理软件有哪些?推荐这10款国内外好用的项目管理工具
- 项目管理必备:盘点2024年13款好用的项目管理软件