La creazione di un processo richiede alcune operazioni da parte del Sistema Operativo:
- Creazione nuovo ID (PID – Process Identifier),
- Allocazione Memoria (Codice, Dati),
- Allocazione altre risorse (stdin, stdout, dispositivi I/O in genere),
- Gestione informazioni sul nuovo processo (es. priorità),
- Creazione PCB (Process Control Block) contenente le informazioni precedenti.
Processi in Unix
Un processo è sempre creato da un altro processo tramite un’opportuna chiamata a sistema. Fa eccezione init
(pid = 1) che viene creato al momento del boot.
Il processo creante è detto parent (genitore), mentre il processo creato child (figlio). Si genera una struttura di “parentela” ad albero.
Relazioni Dinamiche
Cosa accade dopo la creazione?
- Il processo genitore attende il processo figlio.
Esempio: Esecuzione di un programma da shell$ sleep 5 ( ... 5 secondi di attesa ... )
La shell è genitore del nuovo processo
sleep
NOTA: il terminale è associato al nuovo programma:ctrl-c
, ad esempio, terminasleep
. - Il processo genitore continua.
Esempio: Esecuzione in background di un programma da shell:$ sleep 5 & [1] 17589 ps PID TTY TIME CMD 14695 pts/1 00:00:00 bash 17589 pts/1 00:00:00 sleep 17590 pts/1 00:00:00 ps $
In questo caso i due processi procedono in modo concorrente. Tramite
ps
possiamo osservare i processi in esecuzione.NOTA:
ps
di default mostra solo i processi associati al terminale da cui viene lanciato. In questo caso abbiamo la shellbash
, il programmasleep
e lo stessops
.Anche in esecuzione rimane un legame genitore-figlio. Ad esempio, quando
sleep
termina la shell viene notificata:$ [1]+ Done sleep 5 $
Può essere utile che un processo si dissoci dal processo genitore. Ad esempio i Daemon sono processi che restano attivi fino allo shutdown del sistema: devono, ad esempio, dissociarsi dalla shell per non essere terminati alla chiusura del terminale stesso. Quando si dissociano diventano figli del processo
init
(vedremo come questo sia possibile).
Per visualizzare il PID del genitore basta passare gli opportuni parametri al programma ps (PPID significa Parent process ID):
$ sleep 5 &
[1] 23
$ ps -o pid,ppid,comm
PID PPID COMMAND
11 10 bash
23 11 sleep
24 11 ps
$
Si osservi che sleep
e ps
sono entrambi figli di bash
.
Relazioni di contenuto
Due possibilità:
- Il figlio è un duplicato del genitore (ad esempio in UNIX)
- Il figlio esegue un programma differente (ad esempio nei sistemi Windows)
Questo è il comportamento standard ma ovviamente è possibile anche l’altra modalità in entrambi i sistemi.
System Call “fork”
La chiamata a sistema fork
permette di creare un processo duplicato del processo genitore.
NOTA fork
, come la maggior parte delle chiamate a sistema che discuteremo, appartiene allo standard POSIX (Portable Operating System Interface) di IEEE (Institute of Electrical and Electronics Engineers). È utilizzabile in qualsiasi sistema che supporti POSIX.
La fork
crea un nuovo processo che
- condivide l’area codice del processo genitore
- utilizza una copia dell’area dati del processo genitore
Come fanno i processi a differenziarsi? Si utilizza il valore di ritorno della fork
:
- =0 Processo figlio
- >0 Processo genitore: il valore di ritorno è il PID del figlio.
Schema di base di utilizzo della fork:
pid = fork(); if ( pid < 0 ) perror("fork error"); // stampa la descrizione dell'errore else if (pid == 0) { // codice figlio } else { // codice genitore, (pid > 0) } // codice del genitore e del figlio: da usare con cautela!
NOTA: pid_t
è un signed integer, cioè int
in molti sistemi ma potrebbe essere di dimensioni differenti, es. long
, ed è quindi bene usare sempre il tipo pid_t
Un esempio concreto:
// Esempio di utilizzo della fork. #include <sys/types.h> #include <unistd.h> #include <stdio.h> int main() { pid_t pid; printf("Prima della fork. pid = %d, pid del genitore = %d\n",getpid(), getppid()); pid = fork(); if ( pid < 0 ) perror("fork error"); // stampa la descrizione dell'errore else if (pid == 0) { // figlio printf("[Figlio] pid = %d, pid del genitore = %d\n",getpid(), getppid()); } else { // genitore printf("[Genitore] pid = %d, pid del mio genitore = %d\n",getpid(), getppid()); printf("[Genitore] Mio figlio ha pid = %d\n",pid); sleep(1); // attende 1 secondo } // entrambi i processi printf("PID %d termina.\n", getpid()); }
che dà il seguente output:
$ ./fork
Prima della fork. pid = 25267, pid del genitore = 329
[Genitore] pid = 25267, pid del mio genitore = 329
[Genitore] Mio figlio ha pid = 25268
[Figlio] pid = 25268, pid del genitore = 25267
PID 25268 termina.
PID 25267 termina.
$
Esempio Come possiamo visualizzare l’alternanza nell’esecuzione dei processi genitore e figlio? Un modo è mettere le printf dentro un while(1)
:
// visualizza l'esecuzione concorrente di genitore e figlio #include <sys/types.h> #include <unistd.h> #include <stdio.h> int main() { pid_t pid; int i; pid = fork(); if ( pid < 0 ) perror("fork error"); // stampa la descrizione dell'errore else if (pid == 0) { while(1) { for (i=0;i<10000;i++) {} // riduce il numero di printf printf("Figlio: pid = %d, pid del genitore = %d\n",getpid(), getppid()); } } else { while(1) { for (i=0;i<10000;i++) {} // riduce il numero di printf printf("genitore: pid = %d, pid di mio genitore = %d\n",getpid(), getppid()); } } }
In output avremo un’alternanza (non stretta a causa dell’output bufferizzato!) degli output dei due processi. Il ciclo for “vuoto” prima della printf
riduce il numero di stampe e permette di visualizzare meglio l’alternanza (ed evita di “saturare” il terminale di output). Variare, se necessario, il limite.
NOTA Su computer a più core i processi vengono eseguiti in parallelo su core differenti. In tal caso non è necessario rallentarli con il ciclo for per vedere l’alternanza nell’output. Per vedere il carico sui vari core si può lanciare ‘top’ da un altro terminale. Il Linux, premendo 1, viene visualizzato il carico delle differenti CPU. Provare per esercizio.
Fallimento della fork
Quando fallisce una fork
? Quando non è possibile creare un processo e tipicamente questo accade quando non c’è memoria per il processo o per il kernel. Ecco un piccolo test.
NOTA BENE. Il test potrebbe bloccare tutto il sistema perché i processi generati vanno ad occupare tutte le risorse e il sistema non può più prendere il controllo. È necessario limitare in numero di processi utenti tramite il comando ulimit -u num
(al massimo num
processi sono ammessi per l’utente sul presente terminale). Attenzione che il limite è per terminale quindi il test va eseguito dalla stessa finestra su cui avete impostato il limite. Nei sistemi Linux moderni il limite è preimpostato ed è solo possible abbassarlo. Verificare con ulimit -u
che ci sia un valore massimo (che non sia unlimited
) prima di eseguire il test. Nei kernel linux moderni cgroups limita comunque il numero massimo di risorse evitando che la “fork bomb” blocchi il sistema.
int main() { while(1) if (fork() < 0) perror ("errore fork"); }
Esercizio. Quanti processi eseguono la fork
al loop i-esimo? (pensare a quanti processi ci sono alla prima fork, quanti alla seconda, e cosi’ via).
Processi orfani e processi zombie
Se metto una sleep
subito prima della printf nel figlio lo rendo orfano perché termina il genitore prima di lui: viene adottato da init (genitore di tutti i processi di sistema) o da upstart nei moderni sistemi Linux:
// figlio sleep(5); printf("[Figlio] pid = %d, pid del genitore = %d\n",getpid(), getppid());
ottengo:
$ ./fork
Prima della fork. pid = 396, pid del genitore = 354
[Genitore] pid = 396, pid del mio genitore = 354
[Genitore] Mio figlio ha pid = 397
PID 396 termina.
<===== 5 secondi
$ [Figlio] pid = 397, pid del genitore = 1 <===== processo adottato!
PID 397 termina.
dove si nota, appunto, l’adozione da parte di init (1).
NOTA 1: se utilizzate testbed in docker, 1 non è init
bensì un processo sh
in quanto docker utilizza una virtualizzazione a container.
NOTA 2: Un processo orfano non viene più terminato da ctrl-c (provare). Se invece il genitore è ancora in esecuzione tutti i processi figli vengono terminati quando il genitore viene terminato.
Gli zombie sono processi terminati ma in attesa che il genitore rilevi il loro stato di terminazione (vedremo come fare). Per osservare la generazione di un processo zombie ci basta porre la sleep prima della printf del processo genitore:
// genitore sleep(5); printf("[Genitore] pid = %d, pid di mio genitore = %d\n",getpid(),getppid());
che dà il seguente output:
$ ./fork & <== notare l'esecuzione in background
[1] 8270
Prima della fork. pid = 408, pid del genitore = 354
[Figlio] pid = 409, pid del genitore = 408
PID 409 termina. <== termina figlio e diventa zombie
$ ps <== andiamo ad osservare i processi
PID TTY TIME CMD
354 pts/2 00:00:00 bash
408 pts/1 00:00:00 ./fork <== genitore in esecuzione
409 pts/1 00:00:00 ./fork <defunct> <== zombie
410 pts/1 00:00:00 ps
$ <== 5 secondi
[Genitore] pid = 408, pid del mio genitore = 354
[Genitore] Mio figlio ha pid = 409
PID 408 termina.
[1]+ Done ./fork