GNU/Linux >> Linux Esercitazione >  >> Linux

Perché il fork del mio processo fa sì che il file venga letto all'infinito

Sono sorpreso che ci sia un problema, ma sembra essere un problema su Linux (ho provato su Ubuntu 16.04 LTS in esecuzione in una VMWare Fusion VM sul mio Mac) - ma non era un problema sul mio Mac con macOS 10.13. 4 (High Sierra), e non mi aspetto che sia un problema nemmeno con altre varianti di Unix.

Come ho notato in un commento:

C'è una descrizione di file aperto e un descrittore di file aperto dietro ogni flusso. Quando il processo esegue il fork, il figlio ha il proprio set di descrittori di file aperti (e flussi di file), ma ogni descrittore di file nel figlio condivide la descrizione del file aperto con il genitore. SE (e questo è un grande 'se') il processo figlio chiudendo prima i descrittori di file ha fatto l'equivalente di lseek(fd, 0, SEEK_SET) , quindi posizionerebbe anche il descrittore di file per il processo genitore e ciò potrebbe portare a un ciclo infinito. Tuttavia, non ho mai sentito parlare di una biblioteca che fa quella ricerca; non c'è motivo per farlo.

Vedi POSIX open() e fork() per ulteriori informazioni sui descrittori di file aperti e sulle descrizioni dei file aperti.

I descrittori di file aperti sono privati ​​di un processo; le descrizioni dei file aperti sono condivise da tutte le copie del descrittore di file creato da un'operazione iniziale di 'apri file'. Una delle proprietà chiave della descrizione del file aperto è la posizione di ricerca corrente. Ciò significa che un processo figlio può modificare la posizione di ricerca corrente per un genitore, poiché si trova nella descrizione del file aperto condiviso.

neof97.c

Ho utilizzato il seguente codice, una versione leggermente adattata dell'originale che si compila in modo pulito con rigorose opzioni di compilazione:

#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 delle modifiche limita il numero di cicli (figli) a soli 30. Ho utilizzato un file di dati con 4 righe di 20 lettere casuali più una nuova riga (84 byte in totale):

ywYaGKiRtAwzaBbuzvNb
eRsjPoBaIdxZZtJWfSty
uGnxGhSluywhlAEBIXNP
plRXLszVvPgZhAdTLlYe

Ho eseguito il comando sotto strace su Ubuntu:

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

C'erano 31 file con nomi nella forma st-out.808## dove gli hash erano numeri a 2 cifre. Il file di processo principale era piuttosto grande; gli altri erano piccoli, con una delle taglie 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 +++
$

È successo così che i primi 4 bambini hanno mostrato ciascuno uno dei quattro comportamenti e ogni ulteriore gruppo di 4 bambini ha mostrato lo stesso schema.

Ciò dimostra che tre bambini su quattro stavano effettivamente facendo un lseek() sullo standard input prima di uscire. Ovviamente, ora ho visto una biblioteca farlo. Non ho idea del motivo per cui si pensa sia una buona idea, ma empiricamente, questo è ciò che sta accadendo.

neof67.c

Questa versione del codice, utilizza un flusso di file separato (e un descrittore di file) e fopen() invece di freopen() incontra anche il 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;
}

Anche questo mostra lo stesso comportamento, tranne per il fatto che il descrittore di file su cui si verifica la ricerca è 3 invece di 0 . Quindi, due delle mie ipotesi sono smentite:è correlato a freopen() e stdin; entrambi sono mostrati errati dal secondo codice di prova.

Diagnosi preliminare

IMO, questo è un bug. Non dovresti essere in grado di incorrere in questo problema. Molto probabilmente è un bug nella libreria Linux (GNU C) piuttosto che nel kernel. È causato dal lseek() nei processi figlio. Non è chiaro (perché non sono andato a vedere il codice sorgente) cosa stia facendo la libreria o perché.

GLIBC Bug 23151

GLIBC Bug 23151 - Un processo biforcuto con file non chiuso esegue la ricerca prima dell'uscita e può causare un ciclo infinito nell'I/O principale.

Il bug è stato creato il 2018-05-08 US/Pacific ed è stato chiuso come INVALID entro il 2018-05-09. Il motivo addotto era:

Si prega di leggere http://pubs.opengroup.org/onlinepubs/9699919799/functions/V2_chap02.html#tag_15_05_01, in particolare questo paragrafo:

Nota che dopo un fork() , esistono due maniglie dove ne esisteva una prima. […]

POSIX

La sezione completa di POSIX a cui si fa riferimento (a parte la verbosità che fa notare che questo non è coperto dallo standard C) è questa:

2.5.1 Interazione tra descrittori di file e flussi I/O standard

È possibile accedere alla descrizione di un file aperto tramite un descrittore di file, che viene creato utilizzando funzioni come open() o pipe() , o tramite uno stream, che viene creato utilizzando funzioni come fopen() o popen() . Un descrittore di file o un flusso è chiamato "handle" sulla descrizione del file aperto a cui si riferisce; una descrizione di file aperta può avere diversi handle.

Gli handle possono essere creati o distrutti da un'azione esplicita dell'utente, senza influire sulla descrizione del file aperto sottostante. Alcuni dei modi per crearli includono fcntl() , dup() , fdopen() , fileno() e fork() . Possono essere distrutti almeno da fclose() , close() e exec funzioni.

Un descrittore di file che non viene mai utilizzato in un'operazione che potrebbe influire sull'offset del file (ad esempio, read() , write() o lseek() ) non è considerato un handle per questa discussione, ma potrebbe darne origine (ad esempio, come conseguenza di fdopen() , dup() o fork() ). Questa eccezione non include il descrittore di file alla base di uno stream, se creato con fopen() o fdopen() , purché non venga utilizzato direttamente dall'applicazione per influire sull'offset del file. Il read() e write() le funzioni influenzano implicitamente l'offset del file; lseek() lo influenza esplicitamente.

Il risultato delle chiamate di funzione che coinvolgono un qualsiasi handle (l'"handle attivo") è definito altrove in questo volume di POSIX.1-2017, ma se vengono utilizzati due o più handle e uno di essi è uno stream, l'applicazione deve garantire che le loro azioni siano coordinate come descritto di seguito. Se ciò non viene fatto, il risultato è indefinito.

Un handle che è uno stream è considerato chiuso quando si verifica un fclose() o freopen() con nome file non completo, viene eseguito su di esso (per freopen() con un nome file nullo, è definito dall'implementazione se viene creato un nuovo handle o riutilizzato quello esistente) o quando il processo che possiede quel flusso termina con exit() , abort() , o a causa di un segnale. Un descrittore di file è chiuso da close() , _exit() o il exec() funziona quando FD_CLOEXEC è impostato su quel descrittore di file.

[sic] Usare 'non-full' è probabilmente un errore di battitura per 'non-null'.

Affinché un handle diventi l'handle attivo, l'applicazione deve garantire che le azioni seguenti vengano eseguite tra l'ultimo utilizzo dell'handle (l'attuale handle attivo) e il primo utilizzo del secondo handle (il futuro handle attivo). La seconda maniglia diventa quindi la maniglia attiva. Tutte le attività dell'applicazione che interessano l'offset del file sul primo handle devono essere sospese finché non diventa nuovamente l'handle del file attivo. (Se una funzione di flusso ha come funzione sottostante una funzione che influenza l'offset del file, si considera che la funzione di flusso influisca sull'offset del file.)

Non è necessario che gli handle si trovino nello stesso processo affinché queste regole vengano applicate.

Nota che dopo un fork() , esistono due maniglie dove ne esisteva una prima. L'applicazione deve garantire che, se è possibile accedere a entrambi gli handle, si trovino entrambi in uno stato in cui l'altro potrebbe diventare per primo l'handle attivo. L'applicazione deve preparare un fork() esattamente come se si trattasse di un cambio di maniglia attivo. (Se l'unica azione eseguita da uno dei processi è uno dei exec() funzioni o _exit() (non exit() ), l'handle non è mai accessibile in quel processo.)

Per la prima maniglia, si applica la prima condizione applicabile di seguito. Dopo aver eseguito le azioni richieste di seguito, se l'handle è ancora aperto, l'applicazione può chiuderlo.

  • Se si tratta di un descrittore di file, non è richiesta alcuna azione.

  • Se l'unica ulteriore azione da eseguire su qualsiasi handle di questo descrittore di file aperto è chiuderlo, non è necessario intraprendere alcuna azione.

  • Se si tratta di un flusso senza buffer, non è necessario intraprendere alcuna azione.

  • Se si tratta di un flusso con buffer di riga e l'ultimo byte scritto nel flusso era un <newline> (ovvero, come se un putc('\n') era l'operazione più recente su quel flusso), non è necessario intraprendere alcuna azione.

  • Se si tratta di un flusso aperto per la scrittura o l'aggiunta (ma non aperto anche per la lettura), l'applicazione deve eseguire un fflush() , o lo stream verrà chiuso.

  • Se lo stream è aperto per la lettura e si trova alla fine del file (feof() è vero), non è necessario intraprendere alcuna azione.

  • Se il flusso è aperto con una modalità che consente la lettura e la sottostante descrizione del file aperto si riferisce a un dispositivo in grado di cercare, l'applicazione deve eseguire un fflush() , o lo stream verrà chiuso.

Per la seconda maniglia:

  • Se un handle attivo precedente è stato utilizzato da una funzione che ha modificato in modo esplicito l'offset del file, ad eccezione di quanto richiesto sopra per il primo handle, l'applicazione deve eseguire un lseek() o fseek() (a seconda del tipo di maniglia) in una posizione appropriata.

Se l'handle attivo cessa di essere accessibile prima che siano stati soddisfatti i requisiti del primo handle, lo stato della descrizione del file aperto diventa indefinito. Ciò potrebbe verificarsi durante funzioni come fork() o _exit() .

Il exec() le funzioni rendono inaccessibili tutti i flussi aperti nel momento in cui vengono richiamati, indipendentemente da quali flussi o descrittori di file potrebbero essere disponibili per la nuova immagine di processo.

Quando queste regole vengono seguite, indipendentemente dalla sequenza di handle utilizzata, le implementazioni devono garantire che un'applicazione, anche se composta da più processi, fornisca risultati corretti:nessun dato deve essere perso o duplicato durante la scrittura e tutti i dati devono essere scritti in ordine, ad eccezione di quanto richiesto da seeks. È definito dall'implementazione se, e in quali condizioni, tutti gli input vengono visti esattamente una volta.

Si dice che ogni funzione che opera su un flusso abbia zero o più "funzioni sottostanti". Ciò significa che la funzione stream condivide alcuni tratti con le funzioni sottostanti, ma non richiede che vi sia alcuna relazione tra le implementazioni della funzione stream e le sue funzioni sottostanti.

Esegesi

È una lettura difficile! Se non ti è chiara la distinzione tra descrittore di file aperto e descrizione di file aperto, leggi la specifica di open() e fork() (e dup() o dup2() ). Anche le definizioni per il descrittore di file e la descrizione del file aperto sono rilevanti, se concise.

Nel contesto del codice in questa domanda (e anche per i processi figlio indesiderati creati durante la lettura del file), abbiamo un handle di flusso di file aperto solo per la lettura che non ha ancora incontrato EOF (quindi feof() non restituirebbe true, anche se la posizione di lettura è alla fine del file).

Una delle parti cruciali della specifica è:L'applicazione deve preparare per un fork() esattamente come se si trattasse di un cambio di handle attivo.

Ciò significa che i passaggi delineati per il "primo handle di file" sono rilevanti e, procedendo attraverso di essi, la prima condizione applicabile è l'ultima:

  • Se il flusso è aperto con una modalità che consente la lettura e la descrizione sottostante del file aperto si riferisce a un dispositivo in grado di cercare, l'applicazione deve eseguire un fflush() , o lo stream verrà chiuso.

Se guardi la definizione di fflush() , troverai:

Se trasmetti in streaming punta a un flusso di output o a un flusso di aggiornamento in cui non è stata immessa l'operazione più recente, fflush() farà in modo che tutti i dati non scritti per quel flusso vengano scritti nel file, [CX] ⌦ e i timestamp dell'ultima modifica dei dati e dell'ultima modifica dello stato del file del file sottostante devono essere contrassegnati per l'aggiornamento.

Per uno stream aperto per la lettura con una descrizione del file sottostante, se il file non è già in EOF e il file è in grado di cercare, l'offset del file della descrizione del file aperto sottostante deve essere impostato sulla posizione del file dello stream, e tutti i caratteri rimandati nello stream da ungetc() o ungetwc() che non sono stati successivamente letti dallo stream devono essere scartati (senza modificare ulteriormente l'offset del file). ⌫

Non è esattamente chiaro cosa succede se applichi fflush() a un flusso di input associato a un file non ricercabile, ma questa non è la nostra preoccupazione immediata. Tuttavia, se stai scrivendo un codice di libreria generico, potresti aver bisogno di sapere se il descrittore di file sottostante è ricercabile prima di eseguire un fflush() sul flusso. In alternativa, usa fflush(NULL) fare in modo che il sistema esegua tutto il necessario per tutti i flussi di I/O, notando che questo perderà tutti i caratteri respinti (tramite ungetc() ecc.).

Il lseek() operazioni mostrate nel strace output sembra implementare il fflush() semantica che associa l'offset del file della descrizione del file aperto con la posizione del file dello stream.

Quindi, per il codice in questa domanda, sembra che fflush(stdin) è necessario prima di fork() per garantire la coerenza. Non farlo porta a un comportamento indefinito ('se ciò non viene fatto, il risultato non è definito') — come un ciclo indefinito.


La chiamata exit() chiude tutti gli handle di file aperti. Dopo il fork, il figlio e il genitore hanno copie identiche dello stack di esecuzione, incluso il puntatore FileHandle. Quando il figlio esce, chiude il file e reimposta il puntatore.

  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. Perché il modo seguente non cambia la dimensione del limite del file principale?

  2. Tail legge l'intero file?

  3. Perché il meccanismo di creazione del processo predefinito è fork?

  4. Perché il file di traduzione Bash non contiene tutti i testi di errore?

  5. Cosa significa nell'output di Ps?

Qual è la tabella dei processi Linux? In cosa consiste?

Perché select è usato in Linux

Perché l'arresto di net rpc fallisce con le giuste credenziali?

Perché wget'ing un'immagine mi dà un file, non un'immagine?

Perché rsync non riesce a copiare i file da /sys in Linux?

Perché la directory principale è indicata da un segno /?