我很惊讶出现了问题,但似乎是在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++)
{
int status;
pid_t pid = fork();
if (pid == 0)
{
exit(0);
}
else
{
waitpid(pid, &status, 0);
}
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++)
{
int status;
pid_t pid = fork();
if (pid == 0)
{
exit(0);
}
else
{
waitpid(pid, &status, 0);
}
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标准中的措辞)如下:
可以使用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)
以确保一致性。不这样做会导致未定义行为('如果不这样做,则结果是未定义的'),例如无限循环。