为什么分叉我的进程会导致文件被无限读取

2024-10-28 08:37:00
admin
原创
46
摘要:问题描述:我把整个程序简化为一个简短的主程序来复制这个问题,所以请原谅我它没有任何意义。input.txt 是一个文本文件,其中包含几行文本。这个简化的程序应该会打印这些行。但是,如果调用 fork,程序就会进入无限循环,一遍又一遍地打印文件的内容。据我了解,fork 在本代码片段中使用它的方式本质上是无操作...

问题描述:

我把整个程序简化为一个简短的主程序来复制这个问题,所以请原谅我它没有任何意义。

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 &quot;posixver.h&quot;
#include &lt;stdio.h>
#include &lt;stdlib.h>
#include &lt;sys/wait.h>
#include &lt;unistd.h>

enum { MAX = 100 };

int main(void)
{
    if (freopen(&quot;input.txt&quot;, &quot;r&quot;, stdin) == 0)
        return 1;
    char s[MAX];
    for (int i = 0; i &lt; 30 &amp;&amp; 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, &amp;status, 0);
        }
        // End region
        printf(&quot;%s&quot;, 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 &quot;posixver.h&quot;
#include &lt;stdio.h>
#include &lt;stdlib.h>
#include &lt;sys/wait.h>
#include &lt;unistd.h>

enum { MAX = 100 };

int main(void)
{
    FILE *fp = fopen(&quot;input.txt&quot;, &quot;r&quot;);
    if (fp == 0)
        return 1;
    char s[MAX];
    for (int i = 0; i &lt; 30 &amp;&amp; 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, &amp;status, 0);
        }
        // End region
        printf(&quot;%s&quot;, 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())之一,则该进程永远不会访问该句柄。)

对于第一个句柄,适用下面的第一个适用条件。在执行下面所需的操作后,如果句柄仍处于打开状态,则应用程序可以将其关闭。

  • 如果它是一个文件描述符,则不需要采取任何行动。

  • 如果对此打开文件描述符的任何句柄执行的唯一进一步操作是关闭它,则无需采取任何操作。

  • 如果它是非缓冲的流,则不需要采取任何操作。

  • 如果它是一个行缓冲的流,并且写入该流的最后一个字节是&lt;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(&quot;input.txt&quot;, &quot;r&quot;, 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, &amp;status, 0);
            }
            //End region
            printf(&quot;%s&quot;, s);
            ret = fgets(s, MAX, stdin);
        }
    }

解决方案 3:

正如 /u/visibleman 指出的那样,子线程正在关闭文件并在主线程中搞乱一切。

我可以通过检查程序是否处于终端模式来解决这个问题

!isatty(fileno(stdin))

如果 stdin 已被重定向,那么它会在进行任何处理或分叉之前将其全部读入链接列表。

解决方案 4:

将 exit(0) 替换为 _exit(0),一切就都好了。这是古老的 unix 传统,如果您使用 stdio,则分叉映像必须使用 _exit(),而不是 exit()。

相关推荐
  为什么项目管理通常仍然耗时且低效?您是否还在反复更新电子表格、淹没在便利贴中并参加每周更新会议?这确实是耗费时间和精力。借助软件工具的帮助,您可以一目了然地全面了解您的项目。如今,国内外有足够多优秀的项目管理软件可以帮助您掌控每个项目。什么是项目管理软件?项目管理软件是广泛行业用于项目规划、资源分配和调度的软件。它使项...
项目管理软件   601  
  华为IPD与传统研发模式的8大差异在快速变化的商业环境中,产品研发模式的选择直接决定了企业的市场响应速度和竞争力。华为作为全球领先的通信技术解决方案供应商,其成功在很大程度上得益于对产品研发模式的持续创新。华为引入并深度定制的集成产品开发(IPD)体系,相较于传统的研发模式,展现出了显著的差异和优势。本文将详细探讨华为...
IPD流程是谁发明的   7  
  如何通过IPD流程缩短产品上市时间?在快速变化的市场环境中,产品上市时间成为企业竞争力的关键因素之一。集成产品开发(IPD, Integrated Product Development)作为一种先进的产品研发管理方法,通过其结构化的流程设计和跨部门协作机制,显著缩短了产品上市时间,提高了市场响应速度。本文将深入探讨如...
华为IPD流程   9  
  在项目管理领域,IPD(Integrated Product Development,集成产品开发)流程图是连接创意、设计与市场成功的桥梁。它不仅是一个视觉工具,更是一种战略思维方式的体现,帮助团队高效协同,确保产品按时、按质、按量推向市场。尽管IPD流程图可能初看之下显得错综复杂,但只需掌握几个关键点,你便能轻松驾驭...
IPD开发流程管理   8  
  在项目管理领域,集成产品开发(IPD)流程被视为提升产品上市速度、增强团队协作与创新能力的重要工具。然而,尽管IPD流程拥有诸多优势,其实施过程中仍可能遭遇多种挑战,导致项目失败。本文旨在深入探讨八个常见的IPD流程失败原因,并提出相应的解决方法,以帮助项目管理者规避风险,确保项目成功。缺乏明确的项目目标与战略对齐IP...
IPD流程图   8  
热门文章
项目管理软件有哪些?
云禅道AD
禅道项目管理软件

云端的项目管理软件

尊享禅道项目软件收费版功能

无需维护,随时随地协同办公

内置subversion和git源码管理

每天备份,随时转为私有部署

免费试用