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

18

我已将整个程序简化为一个短的主函数来重现问题,因此请谅解如果它没有任何意义。

input.txt 是一个文本文件,其中有几行文本。这个简化的程序应该打印这些行。但是,如果调用了 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行单词,则不会。


将fork放到循环外面。每一行你都在创建一个新的进程。等等,这没意义...如果你立即退出它,为什么要创建一个子进程? - Tony
阅读我的帖子,它解释了为什么这段代码毫无意义。 - lbenedetto
你在哪个平台上工作?我在运行macOS 10.13.4(High Sierra)的Mac上编译了代码,并将其自己的源代码用作“input.txt”,它可以正常工作 - 这是我所期望的。 - Jonathan Leffler
最新的Linux Mint。同时在一个Ubuntu 16虚拟机上进行了测试。 - lbenedetto
有趣的是,在Mac上运行良好的相同源代码在Ubuntu 16.04 LTS(在Mac上运行的VM中)下也会出现问题。我认为这是Linux库中的一个错误,而不是内核的问题。你不应该遇到这个问题。 - Jonathan Leffler
文件大小(以字节为单位)也很重要,尽管对于从stdin缓冲区解析的fgets行计数来说,作为一个因素是有意义的,因为fgets在fork / exit之前注意到EOF会发生任何奇怪的事情。 - Peter Cordes
4个回答

21
我很惊讶出现了问题,但似乎是在Linux上出现的问题(我在我的Mac上运行VMWare Fusion VM中测试了Ubuntu 16.04 LTS),但在运行macOS 10.13.4(High Sierra)的Mac上没有问题,我也不希望其他Unix变体出现问题。
正如我在注释中所指出的:

每个流后面都有一个打开的文件描述符和打开的文件描述符。当进程分叉时,子进程有自己的一组打开的文件描述符(和文件流),但子进程中的每个文件描述符都与父进程共享打开的文件描述符。 如果(那是一个大的“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##,其中哈希值是两位数。主进程文件非常大,其他文件很小,其中一个大小为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;
}

这也表现出相同的行为,只是发生seek的文件描述符是3而不是0。因此,我的两个假设都被证明是错误的——它与freopen()和stdin有关;第二个测试代码显示了两者都是不正确的。
初步诊断
在我看来,这是一个bug。你不应该遇到这个问题。这很可能是Linux(GNU C)库中的一个bug,而不是内核。它是由子进程中的lseek()引起的。目前还不清楚(因为我没有查看源代码)库正在做什么或为什么。

GLIBC Bug 23151

GLIBC Bug 23151 - 当一个未关闭文件的forked进程在退出前执行lseek操作,可能会导致父进程I/O陷入无限循环。

该漏洞于2018-05-08 US/Pacific被发现,并于2018-05-09被关闭为INVALID。关闭原因如下:

请参阅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()或具有非完整(1)文件名的freopen()时(对于具有空文件名的freopen(),实现定义是否创建新句柄或重用现有句柄),或者进程由于exit()abort()或信号而终止时,被视为关闭流的句柄。当FD_CLOEXEC在文件描述符上设置时,close()_exit()exec()函数会关闭文件描述符。

(1) [sic] 使用“non-full”可能是“non-null”的拼写错误。

为了使一个句柄成为活动句柄,应用程序必须确保在最后使用句柄(当前活动句柄)和第二个句柄的第一次使用(未来活动句柄)之间执行以下操作。然后第二个句柄成为活动句柄。应用程序对影响第一个句柄上的文件偏移量的所有活动都将被挂起,直到它再次成为活动文件句柄。(如果流函数具有影响文件偏移量的底层函数,则应将流函数视为影响文件偏移量。)
这些规则适用于不同进程中的句柄。
请注意,在fork()之后,存在两个曾经不存在的句柄。如果两个句柄都可以被访问,应用程序必须确保它们都处于另一个句柄首先成为活动句柄的状态。应用程序必须像处理更改活动句柄一样准备fork()。(如果一个进程执行的唯一操作是exec()函数或_exit()(而不是exit()),则该句柄永远不会在该进程中被访问。)
对于第一个句柄,应用以下第一个适用条件。在执行所需操作后,如果句柄仍然打开,则应用程序可以关闭它。
  • 如果它是文件描述符,则不需要采取任何操作。

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

  • 如果它是未缓冲的流,则无需采取任何操作。

  • 如果它是行缓冲流,并且最后写入流的字节是<newline>(也就是说,如果putc('\n')是该流上最近的操作),则无需采取任何操作。

  • 如果它是打开以进行写入或追加(但不是打开以进行读取)的流,则应用程序应执行fflush()或关闭流。

  • 如果该流是打开以进行读取并且位于文件末尾(feof()为true),则无需采取任何操作。

  • 如果流以允许读取的模式打开,并且底层打开文件描述符引用能够寻址的设备,则应用程序应执行fflush()或关闭流。

对于第二个句柄:
如果之前的活动句柄已被函数使用,该函数显式更改了文件偏移量(除了上述第一个句柄所需的操作),则应用程序应执行lseek()fseek()(适用于该类型的句柄)到适当位置。
如果在满足上述第一个句柄的要求之前,活动句柄停止可访问,则打开文件描述符的状态变得未定义。这可能发生在fork()_exit()等函数中。 exec()函数在调用它们时使所有打开的流都无法访问,而不管新进程映像可用哪些流或文件描述符。
遵循这

解释

这是一篇难懂的文章! 如果你不清楚打开文件描述符和打开文件描述符之间的区别,请阅读open()fork()(以及dup()dup2())的规范。如果简洁明了,文件描述符打开文件描述符的定义也是相关的。

在这个问题的代码上下文中(以及在读取文件时创建不需要的子进程),我们有一个仅用于读取的文件流句柄,尚未遇到EOF(因此feof()不会返回true,即使读取位置已经到达文件末尾)。

规范的关键部分之一是:应用程序应该像更改活动句柄一样准备好fork()

这意味着“第一个文件句柄”的概述步骤是相关的,并且依次通过它们,最后一个适用的条件是:

如果流以允许读取的模式打开,并且底层的打开文件描述符引用了一个能够寻址的设备,应用程序应该执行fflush(),或者关闭该流。查看fflush()的定义,您会发现: 如果stream指向输出流或最近的操作不是输入的更新流,则fflush()将导致该流的任何未写入数据被写入文件,并标记底层文件的最后数据修改和最后文件状态更改时间戳进行更新。对于使用底层文件描述符打开的读取流,如果文件尚未到达EOF,并且文件能够寻址,则底层打开文件描述符的文件偏移量将设置为流的文件位置,并且通过ungetc()ungetwc()从流中推回的任何字符(尚未从流中读取)都将被丢弃(而不会进一步更改文件偏移量)。
如果您将fflush()应用于与非可寻址文件相关联的输入流,那么发生了什么并不清楚,但这不是我们的直接关注点。然而,如果您正在编写通用库代码,则可能需要知道在对流进行fflush()之前底层文件描述符是否可寻址。或者,使用fflush(NULL)使系统执行所有I/O流所需的任何操作,注意这会丢失任何通过ungetc()等推回的字符。

strace输出中显示的lseek()操作似乎实现了将打开文件描述符的文件偏移量与流的文件位置相关联的fflush()语义。

因此,对于此问题中的代码,在fork()之前似乎需要fflush(stdin)以确保一致性。不这样做会导致未定义行为('如果不这样做,则结果是未定义的'),例如无限循环。

好的写作。我认为自从我记事以来,Linux 已经一直在这样工作了。我认为 C 标准规定 close/fclose 负责释放 FILE 结构的缓冲区。但正如你所说,可能没有关于重置 fpos 的内容。让子进程 fclose 并进入睡眠状态而不是退出,看看会发生什么,这将是有趣的。 - visibleman
@visibleman:谢谢。我承认,基于对Unix系统的广泛先前经验,我对它可能是一个真正的问题持怀疑态度。然而,实证证据表明,在Linux上存在一些奇特的问题。(你的记忆能回溯多久?我使用Unix已经超过30年了,自90年代末以来就一直在使用Linux,但我以前从未注意到这一点。)我注意到,在“neof67.c”的子进程中显式使用fclose(fp);可以避免这个问题;在“neof97.c”中显式使用fclose(stdin);也可以避免这个问题。尽管如此,在我看来这些都不应该是必要的。 - Jonathan Leffler
自90年代以来,我一直在使用Linux...但是对于看到这种特殊效果的记忆可能要追溯到10多年前了吧?我猜想显式关闭会导致某些子进程返回错误和条件?如果我在阅读您的写作后提出一个理论的话 - 也许exit->close检测到对已经关闭的结构体进行close()调用的错误,并执行lseek到开头? - visibleman
你有没有考虑用你的示例向 GNU glibc 提交一个错误报告呢? - Basile Starynkevitch
1
@BasileStarynkevitch:我已经创建了GLIBC Bug 23151,并在答案中引用了它,在回答“另一个问题”(在读取文件时创建不需要的子进程)中也进行了引用。 - Jonathan Leffler

4

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);
        }
    }

但是我认为子线程有进程镜像的副本,所以它只应该关闭文件句柄的副本。这就是允许使用dup2进行管道传输的原因。 - lbenedetto
首先,我认为在从文件读取时最好不要使用stdin。其次,freopen的fstream指针参数只是一个指针,指向某个内存区域中的结构体。因此,父进程和子进程都有相同指针的副本,但其背后的内存区域是共享的。 - visibleman
1
好的,这样就更有意义了。在我的实际程序中,我没有用文件替换标准输入,但是有人从命令行运行我的程序并将文件重定向到它(这将使用文件替换标准输入)。 - lbenedetto
当您使用重定向的输入运行程序时,是否出现相同的症状? - visibleman
1
如果我运行程序并粘贴整个文件的内容,它可以工作。但如果我尝试将文件重定向到我的程序中,它会出现问题。 - lbenedetto
显示剩余4条评论

1
正如 /u/visibleman 指出的那样,子线程正在关闭文件并混乱主线程。
我能够通过检查程序是否处于终端模式来解决这个问题。
!isatty(fileno(stdin))

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

1

将exit(0)替换为_exit(0),一切都会好起来。这是一个古老的Unix传统,如果你使用stdio,则fork出的镜像必须使用_exit()而不是exit()。


不是很老;_exit()是一个相对较新的发明。 - Jonathan Leffler
它是为了这个特定目的而在V7 UNIX中开发的,因此至少从1979年以来存在。 - mevets

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接