Creazione di processi

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, termina sleep.

  • 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 shell bash, il programma sleep e lo stesso ps.

    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