Esercizio sulla fork
Lo scopo del seguente esercizio è la comprensione approfondita del funzionamento della fork
e, in particolare, del fatto che dopo ogni fork
esiste un processo identico al processo genitore (tranne che per il valore di ritorno della fork
) in esecuzione nello stesso punto del programma.
Considerare il seguente programma:
#include <unistd.h>
#include <stdio.h>
int main() {
pid_t f1,f2,f3;
f1=fork();
f2=fork();
f3=fork();
printf("%i%i%i ", (f1 > 0),(f2 > 0),(f3 > 0));
}
Domanda: che output dà? Perché?
Guardare la soluzione dopo aver provato a risolvere l’esercizio da soli!
System call exec
Come si fa ad eseguire un programma diverso da quello che ha effettuato la fork
? Esiste una chiamata a sistema apposita: exec
. Tale chiamata a sistema sostituisce codice e dati di un processo con quelli di un programma differente.
Lo schema seguente mostra fork
ed exec
assieme.
Copy-on-write
Notare che la exec
“butta via” la copia dei dati creata dalla fork
. Questo è chiaramente inefficiente, soprattutto quando la exec
viene eseguita immediatamente dopo la fork
. Per ovviare a questo problema, viene copiata solamente la page-table, e le pagine (quelle contenenti i dati, che dovrebbero essere state copiate) sono invece etichettate come read-only. Un tentativo di scrittura, quindi, genera un errore che viene gestito dal kernel:
- copiando al volo (copy-on-write, appunto) la pagina fisica e aggiornando opportunamente la page-table in modo che punti alla nuova copia;
- impostando la modalità a read-write: da quel momento in poi le due copie sono indipendenti.
Quindi se si fa fork
e subito exec
non avviene nessuna scrittura e quindi nessuna pagina viene effettivamente copiata.
Nota storica: Precedentemente alla tecnica Copy-on-write veniva utilizzata la vfork
, che condivideva i dati con il processo genitore in attesa di eseguire la exec
. Eseguire man vfork
per maggiori informazioni.
Sintassi
La exec
ha diverse varianti che si differenziano in base al
- formato degli argomenti (lista o array
argv[]
) - utilizzo o meno del path della shell
execl("/bin/ls", arg0, arg1, ..., NULL);
execlp("ls", arg0, arg1, ..., NULL);
execv("/bin/ls", argv);
execvp("ls", argv);
Le prime due varianti prendono una lista di argomenti terminata da NULL. Le altre due, invece, prendono i parametri sotto forma di un array di stringhe (puntatori a char), sempre terminato da NULL. La presenza della ‘p’ nel nome della exec indica che viene utilizzato il path della shell (quindi, ad esempio, non è necessario specificare /bin perché già nel path).
NOTA: Per convenzione, il primo argomento contiene il nome del file associato al programma da eseguire. Ad esempio:
#include <stdio.h> int main(int argc, char * argv[]) { int i; for(i=0;i<argc;i++) { printf("arg %d: %s\n",i,argv[i]); } }
Dà il seguente output:
$ ./argv prova 1 2 3 arg 0: ./argv arg 1: prova arg 2: 1 arg 3: 2 arg 4: 3
Valore di ritorno
La exec ritorna solamente in caso di errore (valore -1). In caso di successo il vecchio codice è completamente sostituito dal nuovo e non è più possibile tornare al programma originale. È estremamente importante capire questo punto. L’esempio successivo lo evidenzia e permette di fare alcuni test interessanti.
#include <stdio.h>
#include <unistd.h>
int main() {
printf("provo a eseguire ls\n");
execl("/bin/ls","/bin/ls","-l",NULL);
// oppure : execlp("ls","ls","-l",NULL);
printf("non scrivo questo! \n");
// questa printf non viene eseguita, se la exec va a buon fine
}
Dà il seguente output
provo a eseguire ls
total 16
-rwxr-xr-x 1 focardi focardi 6619 2008-03-05 17:02 a.out
-rw-r--r-- 1 focardi focardi 226 2008-03-05 17:02 exec1.c
-rw-r--r-- 1 focardi focardi 225 2008-03-05 17:02 exec1.c~
Si può quindi osservare che in effetti la exec non ritorna se il comando viene eseguito correttamente! Se sostituiamo il seguente comando alla exec del programma precedente generiamo un errore, in quanto ls2 non esiste:
execlp("ls2","ls2","-l",NULL);
L’esecuzione in questo caso dà il seguente risultato:
provo a eseguire ls
non scrivo questo!
In questo caso la exec è andata in errore e il controllo ritorna al programma. Di conseguenza l’ultima printf
viene eseguita stampando la stringa “non scrivo questo!”
È quindi buona norma verificare se la exec
ritorna -1 e in tal caso gestire l’errore (in generale in C è sempre consigliabile testare il valore di ritorno delle chiamate a libreria e a sistema e stampare un messaggio di errore, se necessario, altrimenti diventa complesso capire dove il programma sta fallendo)
if (execlp("ls2","ls2","-l",NULL) == -1) {
perror("errore durante la exec");
// eventualmente si esce: exit(EXIT_FAILURE);
}
In questo caso otteniamo:
provo a eseguire ls
errore durante la exec: No such file or directory
non scrivo questo!
che ci fornisce informazioni utili per il debug del programma.
Errori nei programmi eseguiti
Vediamo ora cosa accade se si prova ad invocare ls
con un parametro errato. Questa situazione è nuova: il programma esiste e dovrebbe essere quindi eseguito dalla exec
ma poi gli si passa un parametro che lo manda in errore.
if (execlp("ls","ls","-z",NULL) == -1) {
perror("errore durante la exec");
// eventualmente si esce: exit(EXIT_FAILURE);
}
Il programma dà il seguente output
provo a eseguire ls
ls: invalid option -- z
Try `ls --help' for more information.
Nota: la exec
non fallisce (non ritorna e non esegue nessuna istruzione del programma che l’ha invocata). L’errore a terminale è prodotto dalla ls
. Quindi non è la exec
a fallire ma il programma ls
il quale ha già sovrascritto codice e dati del programma originale. Non c’è modo di gestire l’errore della ls
dal programma chiamante.
Un esempio completo: simulare una shell
Vediamo come si può simulare il comportamento di una shell per quanto riguarda la generazione del nuovo processo e la sua esecuzione:
#include <sys/types.h> #include <unistd.h> #include <stdlib.h> #include <stdio.h> #include <string.h> int main() { pid_t esito=1,i; char comando[128], *argv[128], *pch; while(1) { printf("myshell# "); // il prompt dei comandi // il codice che segue separa gli argomenti e // salva i puntatori in argv[] fgets(comando, 128, stdin); // legge l'input dell'utente pch = strtok (comando," \n"); // "parsa" il primo argomento for (i=0; pch != NULL && i < 127; i++) { argv[i] = pch; pch = strtok (NULL, " \n"); // "parsa" gli argomenti successivi } argv[i] = NULL; // termina l'array argv con NULL if (argv[0] != 0) { // comando vuoto, ignora esito=fork(); // crea un processo figlio if (esito < 0) perror("fallimento fork"); else if (esito == 0) { execvp(argv[0],argv); // esegue il comando! perror("Errore esecuzione"); exit(EXIT_FAILURE); } } } // il processo genitore (shell) torna a leggere un altro comando }
Proviamo a compilarlo e eseguirlo:
$ ./shell
myshell# ls
myshell# EsempioExec EsempioExec.c EsercizioFork EsercizioFork.c argomenti argomenti.c shell shell.c shell2.c
Cosa si nota di anomalo?
Non attende la terminazione del processo figlio: è come avere un &
implicito. Lo si nota anche dal prompt myshell#
immediatamente prima della lista dei file generata da ls
: la shell chiede subito il comando successivo senza aspettare il risultato di quello precedente.
Notiamo, inoltre, che eseguendo ps
i programmi eseguiti divcentano processi zombie:
ps myshell# PID TTY TIME CMD 14 pts/0 00:00:00 bash 256 pts/0 00:00:00 shell 257 pts/0 00:00:00 ls <defunct> 258 pts/0 00:00:00 ps ps myshell# PID TTY TIME CMD 14 pts/0 00:00:00 bash 256 pts/0 00:00:00 shell 257 pts/0 00:00:00 ls <defunct> 258 pts/0 00:00:00 ps <defunct> 259 pts/0 00:00:00 ps
Per poter attendere e gestire la terminazione di un processo figlio dobbiamo vedere un po’ più in dettaglio cosa accade quando un processo termina.
Terminazione di un processo
La terminazione di un processo rilascia le risorse allocate dal SO al momento della creazione (ad esempio la memoria e i file aperti) e “segnala” la terminazione al genitore: alcune informazioni di stato vengono messe a disposizione al processo genitore e devono rimanere memorizzate finché non vengono processate. Parte della informazioni contenute nella PCB vengono quindi mantenute dopo la terminazione, finché il processo genitore non ha eventualmente letto tali informazioni.
Il sistema mantiene almeno:
- il PID,
- lo stato di terminazione;
- il tempo di CPU utilizzato dal processo.
Esistono due chiamate a sistema:
exit
: termina il processo (già usata negli esempi per i casi di errore);wait
: attende la terminazione di un figlio (se uno dei figli è uno zombie ritorna subito senza bloccarsi).
NOTA. Un processo può anche terminare in modo anomalo a causa di un errore o perché terminato dal SO o da altri processi.
Sintassi
exit(int stato)
: termina il processo ritornando lostato
al genitore; Si usano le costantiEXIT_FAILURE
eEXIT_SUCCESS
che normalmente sono uguali ad 1 e 0 rispettivamente;pid = wait(int &stato)
: ritorna ilpid
e lostato
del figlio che ha terminato. Si invocawait(NULL)
se non interessa lo stato. Se non ci sono figli ritorna -1.
Valore di ritorno della wait
Lo stato titornato da wait
va gestito con opportune macro:
WIFEXITED(status)==true
se il figlio è uscito normalmente con unaexit
.WEXITSTATUS(status)
ritorna gli 8 bit di stato passati dallaexit
.Esempio di codice:if (WIFEXITED(status)) printf("OK: status = %d\n",WEXITSTATUS(status));
WIFSIGNALED(status)==true
se il figlio è stato terminato in maniera anomala.WTERMSIG(status)
ritorna il “segnale” che ha causato la terminazione.Esempio di codice:if (WIFSIGNALED(status)) printf("ANOMALO: status = %d\n",WTERMSIG(status));
- macro analoghe per stop/resume, utili per il tracing dei processi (
WIFSTOPPED
,WSTOPSIG
,WIFCONTINUED
).
Variante per attendere un processo particolare
Se si vuole attendere un processo particolare (o processi appartenenti a un gruppo particolare), si può utilizzare la chiamata a sistema pid = waitpid(pid2,&stato,options)
- attende il processo
pid2
(valori dipid2
minori o uguali a zero permettono di attendere gruppi di processi, vedere il manuale per maggiori dettagli); - se
pid2 == -1
attende un qualsiasi figlio: diventa uguale allawait
;
Esempio completo con exit e wait
Vediamo le chiamate a sistema exit
e wait
in azione nel seguente codice. Vengono creati 2 figli: il primo termina normalmente restituendo un valore al genitore, il secondo dereferenzia l’indirizzo 0 generando un segmentation fault. Il processo genitore attende la terminazione dei figli e stampa le relative informazioni.
#include <unistd.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> int main() { int pid,status; pid = fork(); if ( pid < 0 ) { perror("errore fork"); exit(EXIT_FAILURE); } /* figlio 1: esce normalmente inviando al genitore lo stato "42" */ if (pid == 0) { printf("Sono il figlio1! pid=%d ppid=%d\n",getpid(), getppid()); sleep(3); exit(42);} pid = fork(); if ( pid < 0 ) { perror("errore fork"); exit(EXIT_FAILURE); } /* figlio 2: segfault, cerca di accedere alla locazione 0 */ if (pid == 0) { int *tmp=0; int a; printf("Sono il figlio2! pid=%d ppid=%d\n",getpid(), getppid()); sleep(5); a = *tmp; } // segfault /* solo il genitore continua e attende tutti i figli ... */ while((pid=wait(&status)) >= 0) { printf("ricevuta terminazione di pid=%d\n",pid); if (WIFEXITED(status)) printf("OK: status = %d\n",WEXITSTATUS(status)); else if (WIFSIGNALED(status)) printf("ANOMALO: status = %d\n",WTERMSIG(status)); } }
Il programma dà il seguente output:
$ ./ExecWait
Sono il figlio1! pid=304 ppid=303
Sono il figlio2! pid=305 ppid=303
ricevuta terminazione di pid=304
OK: status = 42
ricevuta terminazione di pid=305
ANOMALO: status = 11
Il codice 11 corrisponde al ‘segnale’ di violazione di segmento (vedremo più in dettaglio i segnali nella prossima lezione).
Esercizio: shell con wait
Aggiungere le opportune wait
, con relativa gestione dello stato, al codice della shell visto precedentemente. Provare anche ad eseguire, tramite tale shell, programmi che effettuano errori run-time (come divisioni per zero).
Guardare la soluzione solo dopo aver provato a risolvere l’esercizio da soli!
Esercizio: demoni
Una tecnica per creare demoni (programmi in esecuzione che non sono sotto il controllo degli utenti, come i servizi di sistema) è quella di eseguire una doppia fork
e far terminare il primo figlio: in questo modo il processo “nipote” viene adottato da init
e si distacca dal processo “nonno” definitivamente.
Scrivere il codice che realizzi questa tecnica facendo attenzione che
- la seconda
fork
venga effettuata solo dal primo figlio e non dal genitore, altrimenti si generano due nuovi processi; - la terminazione del primo figlio venga correttamente processata dal genitore tramite una opportuna
wait
(meglio ancora unawaitpid
), onde evitare processi zombie.