GNU/Linux >> LINUX-Kenntnisse >  >> Linux

Warum führt das Forking meines Prozesses dazu, dass die Datei unendlich gelesen wird?

Ich bin überrascht, dass es ein Problem gibt, aber es scheint ein Problem unter Linux zu sein (ich habe es auf Ubuntu 16.04 LTS getestet, das in einer VMWare Fusion-VM auf meinem Mac läuft) – aber es war kein Problem auf meinem Mac mit macOS 10.13. 4 (High Sierra), und ich würde auch nicht erwarten, dass es bei anderen Unix-Varianten ein Problem gibt.

Wie ich in einem Kommentar bemerkt habe:

Hinter jedem Stream gibt es eine offene Dateibeschreibung und einen offenen Dateideskriptor. Wenn sich der Prozess verzweigt, hat das Kind seinen eigenen Satz offener Dateideskriptoren (und Dateiströme), aber jeder Dateideskriptor im Kind teilt die offene Dateibeschreibung mit dem Elternteil. WENN (und das ist ein großes „wenn“) der untergeordnete Prozess, der die Dateideskriptoren schließt, hat zuerst das Äquivalent von lseek(fd, 0, SEEK_SET) ausgeführt , dann würde das auch den Dateideskriptor für den übergeordneten Prozess positionieren, was zu einer Endlosschleife führen könnte. Ich habe jedoch noch nie von einer Bibliothek gehört, die diese Suche durchführt; es gibt keinen Grund dafür.

Siehe POSIX open() und fork() für weitere Informationen über offene Dateideskriptoren und offene Dateibeschreibungen.

Die offenen Dateideskriptoren sind für einen Prozess privat; die offenen Dateibeschreibungen werden von allen Kopien des Dateideskriptors geteilt, die durch eine anfängliche "Datei öffnen"-Operation erzeugt wurden. Eine der Schlüsseleigenschaften der geöffneten Dateibeschreibung ist die aktuelle Suchposition. Das bedeutet, dass ein untergeordneter Prozess die aktuelle Suchposition für einen übergeordneten Prozess ändern kann, da sie sich in der Beschreibung der gemeinsam geöffneten Datei befindet.

neof97.c

Ich habe den folgenden Code verwendet – eine leicht angepasste Version des Originals, die sauber mit strengen Kompilierungsoptionen kompiliert wird:

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

Eine der Modifikationen begrenzt die Anzahl der Zyklen (Kinder) auf nur 30. Ich habe eine Datendatei mit 4 Zeilen mit 20 zufälligen Buchstaben plus einem Zeilenumbruch (insgesamt 84 Bytes) verwendet:

ywYaGKiRtAwzaBbuzvNb
eRsjPoBaIdxZZtJWfSty
uGnxGhSluywhlAEBIXNP
plRXLszVvPgZhAdTLlYe

Ich habe den Befehl unter strace ausgeführt auf Ubuntu:

$ strace -ff -o st-out -- neof97
ywYaGKiRtAwzaBbuzvNb
eRsjPoBaIdxZZtJWfSty
uGnxGhSluywhlAEBIXNP
plRXLszVvPgZhAdTLlYe
…
uGnxGhSluywhlAEBIXNP
plRXLszVvPgZhAdTLlYe
ywYaGKiRtAwzaBbuzvNb
eRsjPoBaIdxZZtJWfSty
$

Es gab 31 Dateien mit Namen der Form st-out.808## wobei die Hashes zweistellige Zahlen waren. Die Hauptprozessdatei war ziemlich groß; die anderen waren klein, mit einer der Größen 66, 110, 111 oder 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 +++
$

Es geschah einfach so, dass die ersten 4 Kinder jeweils eines der vier Verhaltensweisen zeigten – und jede weitere Gruppe von 4 Kindern zeigte das gleiche Muster.

Dies zeigt, dass drei von vier Kindern tatsächlich einen lseek() machten auf der Standardeingabe vor dem Beenden. Offensichtlich habe ich jetzt gesehen, wie eine Bibliothek dies getan hat. Ich habe zwar keine Ahnung, warum das für eine gute Idee gehalten wird, aber empirisch gesehen passiert genau das.

neof67.c

Diese Version des Codes verwendet einen separaten Dateistrom (und Dateideskriptor) und fopen() statt freopen() läuft auch auf das Problem.

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

Dies zeigt auch das gleiche Verhalten, außer dass der Dateideskriptor, auf dem die Suche erfolgt, 3 ist statt 0 . Zwei meiner Hypothesen sind also widerlegt – sie beziehen sich auf freopen() und stdin; beide werden vom zweiten Testcode falsch angezeigt.

Vorläufige Diagnose

IMO, das ist ein Bug. Sie sollten nicht in der Lage sein, auf dieses Problem zu stoßen. Es ist höchstwahrscheinlich eher ein Fehler in der Linux (GNU C)-Bibliothek als im Kernel. Es wird durch lseek() verursacht in den untergeordneten Prozessen. Es ist nicht klar (weil ich mir den Quellcode nicht angesehen habe), was die Bibliothek tut oder warum.

GLIBC-Fehler 23151

GLIBC Bug 23151 - Ein gegabelter Prozess mit nicht geschlossener Datei sucht vor dem Beenden und kann eine Endlosschleife in der übergeordneten E/A verursachen.

Der Fehler wurde am 08.05.2018 in den USA/Pazifik erstellt und am 09.05.2018 als UNGÜLTIG geschlossen. Als Grund wurde angegeben:

Bitte lesen Sie http://pubs.opengroup.org/onlinepubs/9699919799/functions/V2_chap02.html#tag_15_05_01, insbesondere diesen Absatz:

Beachten Sie, dass nach fork() , sind zwei Griffe vorhanden, wo zuvor einer vorhanden war. […]

POSIX

Der vollständige Abschnitt von POSIX, auf den verwiesen wird (abgesehen von dem Hinweis, dass dies nicht vom C-Standard abgedeckt wird), lautet wie folgt:

2.5.1 Interaktion von Dateideskriptoren und Standard-E/A-Strömen

Auf eine offene Dateibeschreibung kann über einen Dateideskriptor zugegriffen werden, der mit Funktionen wie open() erstellt wird oder pipe() , oder über einen Stream, der mit Funktionen wie fopen() erstellt wird oder popen() . Entweder ein Dateideskriptor oder ein Stream wird in der offenen Dateibeschreibung, auf die er sich bezieht, als "Handle" bezeichnet; eine offene Dateibeschreibung kann mehrere Handles haben.

Handles können durch explizite Benutzeraktionen erstellt oder zerstört werden, ohne die zugrunde liegende offene Dateibeschreibung zu beeinflussen. Einige der Möglichkeiten, sie zu erstellen, umfassen fcntl() , dup() , fdopen() , fileno() , und fork() . Sie können mindestens durch fclose() zerstört werden , close() , und der exec Funktionen.

Ein Dateideskriptor, der nie in einer Operation verwendet wird, die den Datei-Offset beeinflussen könnte (z. B. read() , write() , oder lseek() ) wird nicht als Handle für diese Diskussion angesehen, könnte aber zu einer führen (z. B. als Folge von fdopen()). , dup() , oder fork() ). Diese Ausnahme umfasst nicht den Dateideskriptor, der einem Stream zugrunde liegt, unabhängig davon, ob er mit fopen() erstellt wurde oder fdopen() , solange es nicht direkt von der Anwendung verwendet wird, um den Dateioffset zu beeinflussen. Der read() und write() Funktionen wirken sich implizit auf den Datei-Offset aus; lseek() ausdrücklich beeinflusst.

Das Ergebnis von Funktionsaufrufen, an denen ein beliebiges Handle (das „aktive Handle“) beteiligt ist, wird an anderer Stelle in diesem Band von POSIX.1-2017 definiert, aber wenn zwei oder mehr Handles verwendet werden und eines davon ein Stream ist, muss die Anwendung dies tun sicherstellen, dass ihre Maßnahmen wie nachstehend beschrieben koordiniert werden. Geschieht dies nicht, ist das Ergebnis undefiniert.

Ein Handle, das ein Stream ist, gilt als geschlossen, wenn entweder ein fclose() , oder freopen() mit nicht vollständigem Dateinamen, wird darauf ausgeführt (für freopen() mit einem Null-Dateinamen ist es implementierungsdefiniert, ob ein neues Handle erstellt oder das vorhandene wiederverwendet wird) oder wann der Prozess, der diesen Stream besitzt, mit exit() beendet wird , abort() , oder aufgrund eines Signals. Ein Dateideskriptor wird durch close() abgeschlossen , _exit() , oder die exec() funktioniert, wenn FD_CLOEXEC auf diesen Dateideskriptor gesetzt ist.

[sic] Die Verwendung von 'non-full' ist wahrscheinlich ein Tippfehler für 'non-null'.

Damit ein Handle zum aktiven Handle wird, muss die Anwendung sicherstellen, dass die folgenden Aktionen zwischen der letzten Verwendung des Handles (dem aktuellen aktiven Handle) und der ersten Verwendung des zweiten Handles (dem zukünftigen aktiven Handle) durchgeführt werden. Der zweite Griff wird dann zum aktiven Griff. Alle Aktivitäten der Anwendung, die sich auf den Datei-Offset auf dem ersten Handle auswirken, werden ausgesetzt, bis es wieder zum aktiven Datei-Handle wird. (Wenn eine Stream-Funktion als zugrunde liegende Funktion eine hat, die den Datei-Offset beeinflusst, wird davon ausgegangen, dass die Stream-Funktion den Datei-Offset beeinflusst.)

Die Handles müssen sich nicht im selben Prozess befinden, damit diese Regeln gelten.

Beachten Sie, dass nach einem fork() , sind zwei Griffe vorhanden, wo zuvor einer vorhanden war. Die Anwendung muss sicherstellen, dass, wenn jemals auf beide Handles zugegriffen werden kann, sie sich beide in einem Zustand befinden, in dem der andere zuerst zum aktiven Handle werden könnte. Der Antrag muss auf fork() vorbereitet werden genau so, als ob es sich um eine Änderung des aktiven Handles handeln würde. (Wenn die einzige Aktion, die von einem der Prozesse ausgeführt wird, eine der exec() ist Funktionen oder _exit() (nicht exit() ), wird in diesem Prozess niemals auf das Handle zugegriffen.)

Für den ersten Griff gilt die erste anwendbare Bedingung unten. Nachdem die unten erforderlichen Aktionen ausgeführt wurden, kann die Anwendung das Handle schließen, wenn es noch geöffnet ist.

  • Wenn es sich um einen Dateideskriptor handelt, ist keine Aktion erforderlich.

  • Wenn die einzige weitere Aktion, die an einem beliebigen Handle dieses offenen Dateideskriptors ausgeführt werden muss, darin besteht, ihn zu schließen, muss nichts unternommen werden.

  • Wenn es sich um einen ungepufferten Stream handelt, müssen keine Maßnahmen ergriffen werden.

  • Wenn es sich um einen zeilengepufferten Stream handelt und das letzte in den Stream geschriebene Byte ein <newline> war (also als ob ein putc('\n') der letzte Vorgang in diesem Stream war), müssen keine Maßnahmen ergriffen werden.

  • Wenn es sich um einen Stream handelt, der zum Schreiben oder Anhängen geöffnet ist (aber nicht auch zum Lesen geöffnet ist), muss die Anwendung entweder einen fflush() ausführen , oder der Stream wird geschlossen.

  • Wenn der Stream zum Lesen geöffnet ist und am Ende der Datei (feof() wahr ist), müssen keine Maßnahmen ergriffen werden.

  • Wenn der Stream in einem Modus geöffnet ist, der das Lesen ermöglicht, und die zugrunde liegende offene Dateibeschreibung auf ein Gerät verweist, das suchen kann, muss die Anwendung entweder einen fflush() ausführen , oder der Stream wird geschlossen.

Für das zweite Handle:

  • Wenn ein vorheriges aktives Handle von einer Funktion verwendet wurde, die den Datei-Offset explizit geändert hat, außer wie oben für das erste Handle erforderlich, muss die Anwendung einen lseek() ausführen oder fseek() (entsprechend dem Grifftyp) an einer geeigneten Stelle.

Wenn auf das aktive Handle nicht mehr zugegriffen werden kann, bevor die Anforderungen an das erste Handle oben erfüllt sind, wird der Zustand der offenen Dateibeschreibung undefiniert. Dies kann bei Funktionen wie fork() auftreten oder _exit() .

Die exec() Funktionen machen alle zum Zeitpunkt ihres Aufrufs geöffneten Streams unzugänglich, unabhängig davon, welche Streams oder Dateideskriptoren dem neuen Prozessabbild zur Verfügung stehen.

Wenn diese Regeln befolgt werden, müssen Implementierungen unabhängig von der Reihenfolge der verwendeten Handles sicherstellen, dass eine Anwendung, auch wenn sie aus mehreren Prozessen besteht, korrekte Ergebnisse liefert:Beim Schreiben dürfen keine Daten verloren gehen oder dupliziert werden, und alle Daten müssen eingeschrieben werden Reihenfolge, außer wie von seeks angefordert. Ob und unter welchen Bedingungen alle Eingaben genau einmal gesehen werden, ist implementierungsabhängig.

Jede Funktion, die auf einem Stream arbeitet, soll null oder mehr "zugrunde liegende Funktionen" haben. Das bedeutet, dass die Stream-Funktion bestimmte Merkmale mit den zugrunde liegenden Funktionen teilt, aber keine Beziehung zwischen den Implementierungen der Stream-Funktion und ihren zugrunde liegenden Funktionen bestehen muss.

Exegese

Das ist schwer zu lesen! Wenn Ihnen der Unterschied zwischen offenem Dateideskriptor und offener Dateibeschreibung nicht klar ist, lesen Sie die Spezifikation von open() und fork() (und dup() oder dup2() ). Die Definitionen für Dateideskriptor und offene Dateibeschreibung sind ebenfalls relevant, wenn auch knapp.

Im Kontext des Codes in dieser Frage (und auch für unerwünschte untergeordnete Prozesse, die beim Lesen von Dateien erstellt werden) haben wir ein Dateistream-Handle, das nur zum Lesen geöffnet ist und das noch nicht auf EOF gestoßen ist (also feof() würde nicht wahr zurückgeben, obwohl die Leseposition am Ende der Datei ist).

Einer der entscheidenden Teile der Spezifikation ist:Die Anwendung soll auf fork() vorbereiten genau so, als wäre es eine Änderung des aktiven Handles.

Das bedeutet, dass die für „erste Dateikennung“ beschriebenen Schritte relevant sind, und beim schrittweisen Durchlaufen ist die erste zutreffende Bedingung die letzte:

  • Wenn der Stream in einem Modus geöffnet ist, der das Lesen ermöglicht, und die zugrunde liegende offene Dateibeschreibung sich auf ein Gerät bezieht, das suchen kann, muss die Anwendung entweder einen fflush() ausführen , oder der Stream wird geschlossen.

Wenn Sie sich die Definition für fflush() ansehen , finden Sie:

Beim Streamen zeigt auf einen Ausgabestrom oder einen Aktualisierungsstrom, in den die letzte Operation nicht eingegeben wurde, fflush() bewirkt, dass alle ungeschriebenen Daten für diesen Stream in die Datei geschrieben werden, [CX] ⌦ und die Zeitstempel der letzten Datenänderung und der letzten Dateistatusänderung der zugrunde liegenden Datei werden zur Aktualisierung markiert.

Für einen zum Lesen geöffneten Strom mit einer zugrunde liegenden Dateibeschreibung, wenn sich die Datei nicht bereits am EOF befindet und die Datei suchfähig ist, wird der Datei-Offset der zugrunde liegenden offenen Dateibeschreibung auf die Dateiposition des Stroms gesetzt, und alle Zeichen, die von ungetc() in den Stream zurückgeschoben werden oder ungetwc() die anschließend nicht aus dem Stream gelesen wurden, werden verworfen (ohne den Datei-Offset weiter zu ändern). ⌫

Es ist nicht genau klar, was passiert, wenn Sie fflush() anwenden zu einem Eingabestream, der mit einer nicht durchsuchbaren Datei verknüpft ist, aber das ist nicht unsere unmittelbare Sorge. Wenn Sie jedoch generischen Bibliothekscode schreiben, müssen Sie möglicherweise wissen, ob der zugrunde liegende Dateideskriptor suchbar ist, bevor Sie fflush() ausführen auf dem Strom. Verwenden Sie alternativ fflush(NULL) um das System für alle E/A-Streams alles Notwendige tun zu lassen, wobei zu beachten ist, dass dadurch alle zurückgeschobenen Zeichen verloren gehen (über ungetc() usw.).

Die lseek() Operationen, die in strace gezeigt werden Ausgabe scheint den fflush() zu implementieren Semantik, die den Dateioffset der offenen Dateibeschreibung mit der Dateiposition des Streams verknüpft.

Für den Code in dieser Frage scheint also fflush(stdin) ist vor dem fork() erforderlich Konsistenz zu gewährleisten. Andernfalls führt dies zu undefiniertem Verhalten ('Wenn dies nicht getan wird, ist das Ergebnis undefiniert') — wie z. B. unendliche Schleifen.


Der Aufruf von exit() schließt alle geöffneten Dateihandles. Nach der Verzweigung haben das untergeordnete und das übergeordnete Element identische Kopien des Ausführungsstapels, einschließlich des FileHandle-Zeigers. Wenn das untergeordnete Element beendet wird, schließt es die Datei und setzt den Zeiger zurück.

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

Linux
  1. Liest Tail die ganze Datei?

  2. Was bedeutet in der Ausgabe von Ps?

  3. Warum wird der Dateideskriptor nur einmal geöffnet und gelesen?

  4. Warum wird select unter Linux verwendet

  5. Warum schlägt das Herunterfahren von Net RPC mit den richtigen Anmeldeinformationen fehl?

Warum enthält die Bash-Übersetzungsdatei nicht alle Fehlertexte?

Was ist die Linux-Prozesstabelle? Woraus besteht es?

Was bedeutet &am Ende eines Linux-Befehls?

Was bedeutet das 'rc' in `.bashrc` usw.?

Warum wird das Root-Verzeichnis durch ein /-Zeichen gekennzeichnet?

Welche Datei in /proc wird vom Kernel während des Bootvorgangs gelesen?