24: Processi padre/figlio

Anche se i corsi che affrontano questo argomento non sono molti, pensavamo fosse utile per i “pochi eletti” e interessante per gli appassionati parlare dei processi. Dunque, iniziamo.

Un programma esegue le sue istruzioni in maniera sequenziale, una dopo l’altra. Anche l’esecuzione di N programmi dovrebbe avvenire in maniera sequenziale, ovvero dopo la prima istruzione di un programma dovrebbe avvenire l’esecuzione di tutte le istruzioni di quel programma, prima di iniziare quelle del programma successivo. Ciò è molto utile per il programmatore, che sa che non ci saranno conflitti tra i programmi, tuttavia il modello di esecuzione sequenziale ha molti svantaggi: non permette, per esempio, di tenere più applicazioni aperte contemporaneamente, oppure di far accedere più utenti allo stesso calcolatore nello stesso istante. Per sovvenire alla necessità del parallelismo (esecuzione di più programmi contemporaneamente, senza aspettare che gli altri in esecuzione terminino), Linux (e molti altri) ha creato tanti esecutori, chiamati processi, quanti sono i programmi da eseguire: essi sono una sorta di “macchine virtuali” (perché realizzati dal sistema operativo, e non hanno hardware) che vengono create dinamicamente per eseguire i programmi in parallelo e sono indipendenti tra loro, ovvero non creano conflitti tra i vari programmi.

Ogni processo possiede delle risorse, come una memoria, dei file aperti e un terminale di controllo. La memoria dei processi si divide in 3 parti:

  • segmento codice: contiene il codice eseguibile del programma riferito al processo;
  • segmento dati: contiene i dati statici e i dati dinamici, questi ultimi a loro volta si dividono in dati (record di attivazione delle funzioni) allocati automaticamente nella stack (pila) e dati allocati esplicitamente con la malloc in una zona di memoria detta heap
  • segmento di sistema: contiene dati del processo stesso (es. tabella file aperti) gestiti più propriamente dal sistema operativo

Tutti i processi vengono identificati da un PID (Process Identifier) e tutti, tranne il processo iniziale, hanno un processo padre, che li ha creati, e tutti possono generare un processo figlio. I seguenti servizi che vediamo sono solo alcuni messi a disposizione da Linux : si può generare un processo figlio che è la copia del padre in esecuzione (nel senso che è relativo allo stesso processo del padre); il padre può attendere la fine dell’esecuzione del figlio; il figlio può mandare un codice di terminazione al padre; si può sostituire il programma (segmento codice e segmento dati) del processo con (segmento codice e segmento dati di) un altro programma.

Come si genera un processo? Come si fa terminare?

Per creare un processo figlio (che sarà identico al processo padre) si utilizza la funzione fork: i segmenti codice e dati (variabili e codici) e il segmento di sistema (file aperti) del padre saranno duplicati nel figlio. Viene ereditata anche la posizione del PC del padre, quindi entrambi si ritroveranno a eseguire la stessa istruzione del programma. Tuttavia, l’elemento di fondamentale importanza per distinguere padre e figlio, è il valore di ritorno della fork: nel processo padre la funzione fork ritorna un valore diverso da zero, che generalmente è il PID del processo figlio (oppure ritorna -1 se la fork è fallita); nel processo figlio il valore di ritorno della fork è zero. Quindi dopo la fork potremo sapere se ci troviamo in un processo padre o figlio interrogando tale valore all’interno dei processi.

Per terminare un processo, invece, usiamo la funzione exit: questa causa la distruzione immediata del processo che la invoca. (Non è sempre obbligatoria, in alcuni casi può essere implicita).

Inoltre, esiste un funzione molto utile, la getpid, che restituisce al processo che la invoca il valore del suo pid.

Vediamo la loro rappresentazione formale mediante un esempio:

 #include <stdio.h>
#include <sys/types.h> //libreria necessaria per i processi
void main( )
{ pid_t pid, miopid; //pid_t è un tipo predefinito utilizzato per i pid dei processi
pid=fork( ); //chiamo la fork
if (pid==-1) //se pid=-1 vuol dire che la fork non è andata a buon fine

{printf(“errore esecuzione fork”); exit();}
else
if (pid==0) //se il pid è uguale a zero allora siamo nel processo figlio
{ miopid=getpid(); //prendo il pid del processo corrente (figlio)
printf("sono il processo figlio con pid %i\n", miopid); //%i serve per stampare i pid
exit( ); //fine del processo
}
else //se il pid è diverso da zero sono nel processo padre

{
printf("sono il processo padre e ho creato un processo figlio con pid %i\n",pid);
miopid=getpid(); printf("Il mio pid invece è %i\n",miopid);
exit( ); //non necessaria 
} }

IMPORTANTE: Dopo che viene eseguita la fork, il processo padre e figlio sono totalmente indipendenti l’uno dall’altro, e noi non siamo in grado di stabilire chi fa prima le proprie istruzioni (il figlio potrebbe essere eseguito prima del padre o viceversa): NON CI SARÀ UN PRECISO ORDINE DI ESECUZIONE PERCHÈ EVOLVONO IN PARALLELO.

Siccome ogni processo può creare un figlio e tutti i processi eccetto quello iniziale hanno un padre, quella che si viene a creare è una struttura gerarchica. Dato che, però, essi procedono autonomamente, un processo padre potrebbe terminare prima del processo figlio: in tal caso avremo un processo orfano. Secondo la convenzione adottata da Linux questi processi vengono “adottati” dal processo iniziale (sistema operativo).

Abbiamo detto che i processi padre/figlio sono indipendenti l’uno dall’altro, quindi non conosciamo il loro l’ordine di esecuzione. Tuttavia è possibile sincronizzarli attraverso la funzione wait: una volta invocata (dal padre), il padre si mette in attesa del processo figlio, il quale, terminando con una exit, informa il processo padre di aver terminato la sua esecuzione. Vediamo un esempio:

 #include <stdio.h>
#include <sys/types.h>
void main( )
{ pid_t pid, miopid; int statoExit, statoWait; //per usare la wait e la exit mi servono le variabili di stato
pid=fork( ); //genero processo figlio
if (pid==0)  //sono nel processo figlio
{ miopid=getpid( );
printf("sono il processo figlio con pid %i \n", miopid);
printf("termino \n");
statoExit=1; //pongo lo stato di uscita uguale a 1
exit(statoExit); //la exit passa la variabile di stato a un eventuale processo in attesa
}
else //sono nel processo padre
{ printf("ho creato un processo figlio \n");
pid=wait (&statoWait); //mi metto in attesa del processo figlio, il quale quando terminerà restituirà il statoExit *256
printf("terminato il processo figlio \n");
printf("il pid del figlio e' %i, lo stato e' %i\n",pid,statoWait/256);
}
} 

Il processo figlio termina la sua esecuzione e restituisce il valore 1. Il padre, dopo la terminazione del figlio, riprende la sua esecuzione: per stampare correttamente il valore restituito dal figlio bisogna dividerlo per 256 (è una regola).Abbiamo “sincronizzato” i due processi, nel senso che sarà per forza il processo figlio a finire per primo, perché il padre è in attesa.

Un’alternativa alla wait è la waitpid: in questo caso però il processo padre si mette in attesa di un preciso processo figlio (usando il pid come parametro).

Se il figlio ha già eseguito la exit ma il padre non ha ancora eseguito la wait, il figlio chiude i suoi file aperti e distrugge i suoi segmenti, e passa da uno stato attivo a uno stato zombie: in questo modo mantiene pid e variabile di stato che saranno reperibili per il padre. Se, invece, il padre si mette in attesa del figlio, ma il figlio non esegue mai una exit, allora avremo un processo padre in sospeso all’infinito: viene richiesto quindi un intervento esterno che forzi la terminazione dei processi.

Un’ultima funzione utile è la exec. Questa consente di sostituire il programma in esecuzione in un
processo con un altro programma indicato dai parametri della funzione exec. Formalmente (anche se esistono molte varianti):

 int execl (char *nome_programma, char *arg0, char *arg1, …NULL ); 

Il nome del programma deve contenere l’identificazione completa (con pathname) di un file eseguibile contenente il nuovo programma da lanciare. Gli argomenti sono puntatori a stringhe passate al main del nuovo programma (l’ultimo deve essere NULL, mentre l’arg0 per convenzione è il nome del programma senza pathname). In caso di errore la funzione ritorna -1. Per ricevere i parametri la funzione main del programma da eseguire è definita
con l’intestazione:

 int main(int argc, char * argv[ ]) 

il parametro argc è un intero che indica il numero di parametri ricevuti e il parametro argv[ ] è un vettore di puntatori a stringhe. Vediamo un esempio:

 #include <stdio.h>
void main (int argc, char *argv[ ] ) //il programma main1 con i parametri di intestazione
{ int i;
printf(" Sono il programma main1\n");
printf("Ho ricevuto %i parametri\n", argc);
for (i=0; i<argc; i++)
printf("il parametro %i è: %s \n", i, argv[i]); //uso %s perché sono stringhe
}

//programma exec1

#include <stdio.h>
#include <sys/types.h>
void main( )
{
char P0[ ]="main1"; //nome del programma
char P1[ ]="parametro 1"; //vari parametri che voglio passare ai programmi
char P2[ ]="parametro 2";
printf("sono il programma exec1\n");
execl("/home/pelagatt/esempi/main1", P0, P1, P2, NULL); //passo al programma main1
printf("errore di exec"); //se l'execl è eseguita correttamente, non si arriverà a questa istruzione
}

Questo è quanto. Se non vi è chiaro qualcosa siamo disponibili a risolvere ogni dubbio.

 

Please follow and like us: