Me sorprende que haya un problema, pero parece ser un problema en Linux (probé en Ubuntu 16.04 LTS ejecutándose en una máquina virtual VMWare Fusion en mi Mac), pero no fue un problema en mi Mac con macOS 10.13. 4 (High Sierra), y tampoco esperaría que fuera un problema en otras variantes de Unix.
Como señalé en un comentario:
Hay una descripción de archivo abierto y un descriptor de archivo abierto detrás de cada transmisión. Cuando el proceso se bifurca, el hijo tiene su propio conjunto de descriptores de archivos abiertos (y secuencias de archivos), pero cada descriptor de archivos en el hijo comparte la descripción del archivo abierto con el padre. SI (y eso es un gran 'si') el proceso secundario que cerró los descriptores de archivo primero hizo el equivalente a lseek(fd, 0, SEEK_SET)
, entonces eso también posicionaría el descriptor de archivo para el proceso principal, y eso podría conducir a un bucle infinito. Sin embargo, nunca he oído hablar de una biblioteca que haga esa búsqueda; no hay razón para hacerlo.
Ver POSIX open()
y fork()
para obtener más información sobre descriptores de archivos abiertos y descripciones de archivos abiertos.
Los descriptores de archivos abiertos son privados para un proceso; las descripciones de archivo abierto son compartidas por todas las copias del descriptor de archivo creado por una operación inicial de 'archivo abierto'. Una de las propiedades clave de la descripción del archivo abierto es la posición de búsqueda actual. Eso significa que un proceso hijo puede cambiar la posición de búsqueda actual para un padre, porque está en la descripción del archivo abierto compartido.
neof97.c
Usé el siguiente código, una versión levemente adaptada del original que se compila limpiamente con opciones de compilación rigurosas:
#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;
}
Una de las modificaciones limita la cantidad de ciclos (hijos) a solo 30. Usé un archivo de datos con 4 líneas de 20 letras aleatorias más una nueva línea (84 bytes en total):
ywYaGKiRtAwzaBbuzvNb
eRsjPoBaIdxZZtJWfSty
uGnxGhSluywhlAEBIXNP
plRXLszVvPgZhAdTLlYe
Ejecuté el comando bajo strace
en Ubuntu:
$ strace -ff -o st-out -- neof97
ywYaGKiRtAwzaBbuzvNb
eRsjPoBaIdxZZtJWfSty
uGnxGhSluywhlAEBIXNP
plRXLszVvPgZhAdTLlYe
…
uGnxGhSluywhlAEBIXNP
plRXLszVvPgZhAdTLlYe
ywYaGKiRtAwzaBbuzvNb
eRsjPoBaIdxZZtJWfSty
$
Había 31 archivos con nombres de la forma st-out.808##
donde los hashes eran números de 2 dígitos. El archivo del proceso principal era bastante grande; los otros eran pequeños, con uno de los tamaños 66, 110, 111 o 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 +++
$
Dio la casualidad de que los primeros 4 niños exhibieron cada uno de los cuatro comportamientos, y cada grupo adicional de 4 niños exhibió el mismo patrón.
Esto muestra que tres de cada cuatro de los niños estaban haciendo un lseek()
en la entrada estándar antes de salir. Obviamente, ahora he visto una biblioteca hacerlo. Sin embargo, no tengo idea de por qué se cree que es una buena idea, pero empíricamente, eso es lo que está sucediendo.
neof67.c
Esta versión del código, usando un flujo de archivo separado (y un descriptor de archivo) y fopen()
en lugar de freopen()
también se encuentra con el problema.
#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;
}
Esto también exhibe el mismo comportamiento, excepto que el descriptor de archivo en el que ocurre la búsqueda es 3
en lugar de 0
. Entonces, dos de mis hipótesis están refutadas:está relacionado con freopen()
y stdin
; ambos se muestran incorrectos por el segundo código de prueba.
Diagnóstico preliminar
En mi opinión, esto es un error. No debería poder encontrarse con este problema. Lo más probable es que se trate de un error en la biblioteca de Linux (GNU C) en lugar del kernel. Es causado por el lseek()
en los procesos hijo. No está claro (porque no he ido a mirar el código fuente) qué está haciendo la biblioteca o por qué.
Error GLIBC 23151
Error de GLIBC 23151:un proceso bifurcado con un archivo sin cerrar busca antes de salir y puede provocar un bucle infinito en la E/S principal.
El error se creó el 2018-05-08 EE. UU./Pacífico y se cerró como NO VÁLIDO el 2018-05-09. La razón dada fue:
Lea http://pubs.opengroup.org/onlinepubs/9699919799/functions/V2_chap02.html#tag_15_05_01, especialmente este párrafo:
Tenga en cuenta que después de un fork()
, existen dos identificadores donde antes existía uno. […]
POSIX
La sección completa de POSIX a la que se hace referencia (aparte de la palabrería que señala que esto no está cubierto por el estándar C) es esta:
2.5.1 Interacción de descriptores de archivo y flujos de E/S estándar
Se puede acceder a una descripción de archivo abierto a través de un descriptor de archivo, que se crea usando funciones como open()
o pipe()
, o a través de una secuencia, que se crea mediante funciones como fopen()
o popen()
. Un descriptor de archivo o una secuencia se denomina "identificador" en la descripción del archivo abierto al que se refiere; una descripción de archivo abierto puede tener varios identificadores.
Los identificadores se pueden crear o destruir mediante una acción explícita del usuario, sin afectar la descripción del archivo abierto subyacente. Algunas de las formas de crearlos incluyen fcntl()
, dup()
, fdopen()
, fileno()
y fork()
. Pueden ser destruidos por al menos fclose()
, close()
, y el exec
funciones.
Un descriptor de archivo que nunca se usa en una operación que podría afectar el desplazamiento del archivo (por ejemplo, read()
, write()
, o lseek()
) no se considera un identificador para esta discusión, pero podría dar lugar a uno (por ejemplo, como consecuencia de fdopen()
, dup()
o fork()
). Esta excepción no incluye el descriptor de archivo subyacente a una transmisión, ya sea que se haya creado con fopen()
o fdopen()
, siempre que la aplicación no lo use directamente para afectar el desplazamiento del archivo. El read()
y write()
las funciones afectan implícitamente el desplazamiento del archivo; lseek()
lo afecta explícitamente.
El resultado de las llamadas a funciones que involucran cualquier identificador (el "identificador activo") se define en otra parte de este volumen de POSIX.1-2017, pero si se utilizan dos o más identificadores, y cualquiera de ellos es un flujo, la aplicación deberá asegurarse de que sus acciones se coordinen como se describe a continuación. Si esto no se hace, el resultado es indefinido.
Un identificador que es un flujo se considera cerrado cuando un fclose()
o freopen()
con un nombre de archivo no completo, se ejecuta en él (para freopen()
con un nombre de archivo nulo, está definido por la implementación si se crea un nuevo identificador o se reutiliza el existente), o cuando el proceso que posee ese flujo termina con exit()
, abort()
, o debido a una señal. Un descriptor de archivo se cierra con close()
, _exit()
, o el exec()
funciona cuando FD_CLOEXEC está configurado en ese descriptor de archivo.
[sic] Usar 'no completo' es probablemente un error tipográfico para 'no nulo'.
Para que un identificador se convierta en identificador activo, la aplicación debe asegurarse de que las acciones siguientes se realicen entre el último uso del identificador (el identificador activo actual) y el primer uso del segundo identificador (el identificador activo futuro). El segundo identificador se convierte entonces en el identificador activo. Toda actividad de la aplicación que afecte el desplazamiento del archivo en el primer identificador se suspenderá hasta que vuelva a ser el identificador de archivo activo. (Si una función de flujo tiene como función subyacente una que afecta el desplazamiento del archivo, se considerará que la función de flujo afecta el desplazamiento del archivo).
No es necesario que los identificadores estén en el mismo proceso para que se apliquen estas reglas.
Tenga en cuenta que después de un fork()
, existen dos identificadores donde antes existía uno. La aplicación debe garantizar que, si se puede acceder a ambos identificadores, ambos se encuentran en un estado en el que el otro podría convertirse en el identificador activo primero. La aplicación se preparará para un fork()
exactamente como si se tratara de un cambio de controlador activo. (Si la única acción realizada por uno de los procesos es uno de los exec()
funciones o _exit()
(no exit()
), nunca se accede al identificador en ese proceso.)
Para el primer identificador, se aplica la primera condición aplicable a continuación. Después de realizar las acciones requeridas a continuación, si el identificador aún está abierto, la aplicación puede cerrarlo.
-
Si es un descriptor de archivo, no se requiere ninguna acción.
-
Si la única acción adicional a realizar en cualquier identificador de este descriptor de archivo abierto es cerrarlo, no es necesario realizar ninguna acción.
-
Si se trata de una transmisión sin búfer, no es necesario realizar ninguna acción.
-
Si se trata de una transmisión que tiene un búfer de línea y el último byte escrito en la transmisión fue un
<newline>
(es decir, como si unputc('\n')
fue la operación más reciente en ese flujo), no es necesario realizar ninguna acción. -
Si se trata de un flujo que está abierto para escribir o agregar (pero no también para leer), la aplicación deberá realizar un
fflush()
, o la transmisión se cerrará. -
Si la secuencia está abierta para lectura y está al final del archivo (
feof()
es cierto), no es necesario tomar ninguna medida. -
Si la transmisión está abierta con un modo que permite la lectura y la descripción del archivo abierto subyacente se refiere a un dispositivo que es capaz de buscar, la aplicación deberá realizar un
fflush()
, o la transmisión se cerrará.
Para el segundo mango:
- Si cualquier identificador activo anterior ha sido utilizado por una función que cambió explícitamente el desplazamiento del archivo, excepto lo requerido anteriormente para el primer identificador, la aplicación realizará un
lseek()
ofseek()
(según corresponda al tipo de manija) a una ubicación apropiada.
Si el identificador activo deja de ser accesible antes de que se cumplan los requisitos del primer identificador anterior, el estado de la descripción del archivo abierto se vuelve indefinido. Esto puede ocurrir durante funciones como fork()
o _exit()
.
El exec()
Las funciones hacen inaccesibles todos los flujos que están abiertos en el momento en que se llaman, independientemente de qué flujos o descriptores de archivos estén disponibles para la nueva imagen del proceso.
Cuando se siguen estas reglas, independientemente de la secuencia de identificadores utilizados, las implementaciones deben garantizar que una aplicación, incluso una que consta de varios procesos, arroje resultados correctos:no se perderán ni duplicarán datos al escribir, y todos los datos se escribirán en orden, excepto lo solicitado por la búsqueda. Está definido por la implementación si, y bajo qué condiciones, todas las entradas se ven exactamente una vez.
Se dice que cada función que opera en un flujo tiene cero o más "funciones subyacentes". Esto significa que la función de flujo comparte ciertos rasgos con las funciones subyacentes, pero no requiere que haya ninguna relación entre las implementaciones de la función de flujo y sus funciones subyacentes.
Exégesis
¡Esa es una lectura difícil! Si no tiene clara la distinción entre el descriptor de archivo abierto y la descripción de archivo abierto, lea la especificación de open()
y fork()
(y dup()
o dup2()
). Las definiciones para el descriptor de archivo y la descripción de archivo abierto también son relevantes, aunque breves.
En el contexto del código de esta pregunta (y también para los procesos secundarios no deseados que se crean durante la lectura de archivos), tenemos un identificador de flujo de archivos abierto solo para lectura que aún no ha encontrado EOF (por lo tanto, feof()
no devolvería verdadero, aunque la posición de lectura esté al final del archivo).
Una de las partes cruciales de la especificación es:La aplicación debe prepararse para un fork()
exactamente como si se tratara de un cambio de identificador activo.
Esto significa que los pasos descritos para el "tratamiento del primer archivo" son relevantes y, al seguirlos, la primera condición aplicable es la última:
- Si la secuencia está abierta con un modo que permite la lectura y la descripción del archivo abierto subyacente se refiere a un dispositivo que es capaz de buscar, la aplicación deberá realizar un
fflush()
, o la transmisión se cerrará.
Si observa la definición de fflush()
, encuentras:
Si transmitir apunta a un flujo de salida o un flujo de actualización en el que no se ingresó la operación más reciente, fflush()
hará que cualquier dato no escrito para ese flujo se escriba en el archivo, [CX] ⌦ y las marcas de tiempo de la última modificación de datos y el último cambio de estado del archivo del archivo subyacente se marcarán para su actualización.
Para un flujo abierto para lectura con una descripción de archivo subyacente, si el archivo aún no está en EOF, y el archivo es capaz de buscar, el desplazamiento del archivo de la descripción de archivo abierta subyacente se establecerá en la posición del archivo del flujo. y cualquier carácter empujado de vuelta a la transmisión por ungetc()
o ungetwc()
que no se hayan leído posteriormente del flujo se descartarán (sin cambiar más el desplazamiento del archivo). ⌫
No está exactamente claro qué sucede si aplica fflush()
a un flujo de entrada asociado con un archivo que no se puede buscar, pero esa no es nuestra preocupación inmediata. Sin embargo, si está escribiendo código de biblioteca genérico, es posible que necesite saber si el descriptor de archivo subyacente se puede buscar antes de hacer un fflush()
en la corriente Alternativamente, use fflush(NULL)
para que el sistema haga lo que sea necesario para todos los flujos de E/S, teniendo en cuenta que esto perderá cualquier carácter retrocedido (a través de ungetc()
etc.).
El lseek()
operaciones mostradas en el strace
la salida parece estar implementando el fflush()
semántica que asocia el desplazamiento del archivo de la descripción del archivo abierto con la posición del archivo de la transmisión.
Entonces, para el código de esta pregunta, parece que fflush(stdin)
es necesario antes del fork()
para asegurar la consistencia. No hacerlo conduce a un comportamiento indefinido ('si esto no se hace, el resultado es indefinido'), como repetir indefinidamente.
La llamada exit() cierra todos los identificadores de archivos abiertos. Después de la bifurcación, el hijo y el padre tienen copias idénticas de la pila de ejecución, incluido el puntero FileHandle. Cuando el niño sale, cierra el archivo y restablece el puntero.
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);
}
}