APPUNTI DI SISTEMI OPERATIVI 1 PER LE ESERCITAZIONI IN LABORATORIO

CHIAMATE DI SISTEMA - PARTE 2

****************************************

SYSTEM CALLS PER GESTIONE PROCESSI

****************************************

La chiamata di sistema:

FORK:
     pid_t fork(void);
crea un nuovo processo (figlio) identico al processo chiamante (padre). Entrambi i processi proseguono eseguendo il codice del processo che ha effettuato la chiamata di fork() e si trovano all'uscita della chiamata di questa funzione, con la differenza che al nuovo processo viene restituito 0, al padre l'identificatore del processo figlio.
Il tipo pid_t, definito in un opportuno file header da includere (vedasi man fork) nasconde l'effettivo tipo numerico (es. int) utilizzato in ogni specifica versione del sistema Unix per rappresentare gli identificatori di processo.

GETPID, GETPPPID:
     pid_t getpid(void);
pid_t getppid(void);

restituiscono l'identificatore del processo corrente (quello che effettua la chiamata) e del processo 'padre' di quello corrente.

Un esempio "minimale" di uso della fork consiste in una banale "clonazione" del processo:
::::::::::::::
clona.c
::::::::::::::

#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>

int main(int argc, char *argv[])
{
fork();
printf("Hello World\n");
return 0; /* dovremmo verificare errore fork */
}

::::::::::::::
Come tutte le chiamate di sistema, fork() restituisce -1 in caso di errore e quindi è opportuno verificare se si tratta di questo caso.
Ma soprattutto, il risultato di fork può essere utilizzato per far eseguire parti diverse dello stesso codice al processo padre e al processo figlio, come nel seguente esempio, in cui due processi girano in pseudoparallelo (su una macchina con un solo processore single-core) o effettivamente in parallelo:
::::::::::::::
par.c
::::::::::::::

#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

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

int i,j;
pid_t n;

n=fork();
if (n==(pid_t)-1)
{perror("fork fallita");
exit(1);
};
if (n==(pid_t)0) {
for (j=0;j<20;j++) {
for (i=0; i< 100000000; i++);
printf(" Figlio %d di %d giro %d \n",getpid(),getppid(),j);
}
}
else {
for (j=0;j<20;j++) {
for (i=0; i< 100000000; i++);
printf("Padre %d di %d giro %d\n",getpid(),n,j);
}
}
return 0;
}

::::::::::::::
In questo esempio vediamo l'utilizzo del tipo pid_t per il valore restituito da fork: nei confronti fra tale valore e le costanti -1 e 0, tali costanti sono convertite in pid_t per garantire il corretto esito del confronto, facendo sì che i due numeri da confrontare siano rappresentati con lo stesso numero di bit.
Il codice eseguito solo dal processo figlio usa getpid() e getppid() per conoscere e stampare il proprio identificatore e quello del genitore.
Il codice eseguito solo dal processo padre usa il valore restituito da fork (contenuto nella variabile "n") per conoscere e stampare l'identificatore del processo figlio.

I valori risultanti per la variabile "j" evidenziano che i due processi hanno ciascuno una copia della variabile.

Le stampe "Padre" e "Figlio" a video possono alternarsi in modo diverso in esecuzioni diverse e contesti diversi.
Negli elaboratori comunemente in uso dopo il 2010 ci sono a disposizione più unità di elaborazione, quindi se non ci sono altri processi CPU bound, ci sono quasi sempre due unità di elaborazione disponibili per questi due processi, che girano effettivamente in parallelo, e i risultati delle stampe si alternano.
In generale, il numero medio di stampe consecutive da parte dello stesso processo dipende dal fatto che i processi girino effettivamente in parallelo o no, e nel secondo caso dalla velocità del processore.
In contesti diversi (diverso grado di parallelismo offerto dall'hardware e diverso "carico" cioè numero di processi o meglio thread pronti), si possono ottenere risultati diversi.


ESERCIZIO 2.1: 
Eseguire il programma ridirigendo l'output su file, es. chiamando "par  >  outpar" se "par" è il nome dell'eseguibile.
L'alternanza delle stringhe stampate dai due processi è diversa rispetto al caso di output su video? Perché?
Da questo si dovrebbe imparare che se abbiamo una applicazione costituita da diversi processi e vogliamo, attraverso stampe su uno stesso file, avere una traccia fedele degli eventi occorsi nei processi, è opportuno avere output non bufferizzato.

ESERCIZIO 2.2:
Aumentando il numero di giri nel figlio, si può verificare che quando il padre termina, il figlio viene 'adottato' da un processo di sistema che ha pid=1.
Se il numero è sufficientemente alto, vedendo il suo identificatore si ha tempo da un'altra finestra di terminarlo chiamando:
kill pid
che invia al processo un "segnale" che - per default - ne causa la terminazione (per i segnali si veda più avanti).


*********************************************************

Nell'esempio precedente può darsi che alcune stampe del processo figlio siano dopo il prompt stampato dalla shell per invitare l'utente a scrivere un altro comando.
Questa anomalia rispetto al solito è dovuta al fatto che la shell, prima di scrivere il prompt, quando esegue un comando (se non è stato chiesto con "&" di eseguirlo in background) attende la terminazione del processo "p" che ha creato (come proprio "figlio") per eseguire il comando. Ma nell'esempio precedente nessuno attendeva la fine di "q" figlio di "p" che quindi poteva avvenire dopo la stampa del prompt.

La terminazione di un processo figlio può essere attesa con:

WAIT:
     pid_t wait(int *status);
La chiamata di wait - semplificandone la descrizione - sospende il chiamante fino a che UNO dei figli termina, e restituisce il pid del processo terminato.
Se il puntatore passato come argomento non è zero, l'intero puntato viene valorizzato con informazioni sul modo di terminazione del  processo, compattate nei bit che costituiscono l'intero:
Esistono (vedere il man di wait) delle macro che permettono di estrarre da tale valore intero le informazioni codificate dalla wait, senza doversi ricordare come sono codificate (ad es. WIFEXITED è vera se il processo è terminato di sua iniziativa).
Le informazioni vengono (per default) mantenute nella tabella dei processi dopo la terminazione del processo, ma una volta lette da wait, l'elemento della tabella dei processi viene (per default) liberato. 

Esiste una variante di wait, più flessibile:

WAITPID:
     pid_t waitpid(pid_t pid, int *status, int options);
che permette di attendere (come da nome) anche solo un processo con un dato identificatore; inoltre permette di uscire anche quando il processo viene "fermato" (stopped, che differisce dal terminare perché un processo fermato può essere fatto ripartire), e di non sospendersi ma verificare soltanto se è terminato un processo figlio.

ESERCIZIO 2.3: inserire nel programma precedente l'attesa del figlio da parte del padre, e a verificare con WIFEXITED nei vari casi, incluso quello in cui il figlio viene terminato con kill, se il processo è terminato di sua iniziativa.


*********************************************************

GENERAZIONE DI PIÚ PROCESSI:

La fork può essere usata all'interno di programmi costruiti a piacimento anche con le strutture di controllo if, for, while etc, ad esempio per generare un certo numero di processi uniformi. In progammi del genere bisogna tuttavia fare attenzione all'effettivo ramo del programma in cui prosegue l'esecuzione di ciascun processo.

Ad esempio nel seguente programma vengono generati 5 processi figli che vanno ad eseguire una stessa funzione con valori diversi del parametro:
::::::::::::::
5figli.c
::::::::::::::

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>


void proc(int i)
{
int n;

printf("Processo %d con pid %d\n",i,getpid());
for (n=0;n<500000000;n++);
}

int main(int argc, char *argv[])
{
int i;
pid_t pid;

for(i=0;i<5;i++)
if (fork()==0)
{ proc(i); exit(0);};
for(i=0;i<5;i++)
{ pid=wait(0);
printf("Terminato processo %d\n",pid);
}
return 0; /* ... ma facciamo male a non verificare errori nelle system calls */
}
::::::::::::::
ESERCIZIO 2.4: che cosa succederebbe se non ci fosse la exit nel codice eseguito dai processi figli? Provare a rispondere, poi rimuovere la exit, far eseguire il programma e spiegare il risultato. Fra l'altro, si noti che in questo programma, molto imprudentemente, non si verifica se le chiamate di sistema - e in particolare la wait - comportano errori, motivo per cui otteniamo cose prive di senso come "Terminato processo -1".


*********************************************************

FORK ED EXEC

L'uso tipico di fork è in combinazione con una delle funzioni della "famiglia" exec:
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *filename, char *const argv[], char *const envp[]);

(in Linux la "vera" chiamata di sistema è l'ultima e le altre sono definite in base a quella).
L'effetto di una exec è il seguente: il processo va ad eseguire il programma contenuto nel file (eseguibile) contenuto nel file identificato dall'argomento "path",  (con gli argomenti passati come ulteriori parametri; le "execl" sono per il caso in cui il numero di argomenti è fissato: come ultimo argomento si passa NULL; alle "execv" si passa invece un vettore di argomenti).
Il processo (in particolare l'identificatore del processo) rimane lo stesso, ma quasi tutta l'immagine del processo (il codice e i dati) cambia.
NB come tutte le chiamate di sistema, ci sono dei casi di errore per le exec, ad esempio, il file indicato non viene trovato (dove? vedasi più avanti per execlp e execvp). Se però ci sono le condizioni affinché abbia successo, una volta che è completata la transizione e il processo è passato ad eseguire il nuovo programma, non c'è più nessuna relazione tra il processo e il programma che stava eseguendo prima (quello contenente la chiamata di exec).

Questo esempio illustra un semplice uso di execl; il secondo programma va ovviamente compilato in un file eseguibile "hello". Provare però ad eseguire provaexec sia prima di aver generato "hello", sia dopo.
::::::::::::::
provaexec.c
::::::::::::::
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[])
{
pid_t n,m;
int s;
if((n=fork())== (pid_t)-1)
{perror("fork fallita");
exit(1);
}

else if (n==(pid_t)0)
{/* processo figlio */
execl("hello","hello",NULL);
perror("exec fallita");
}
else
{/* processo padre */
m=wait(&s);
if (m==-1) perror("wait");
}
return 0;
}

::::::::::::::
hello.c
::::::::::::::
#include <stdio.h>

int main(int argc, char *argv[])
{
printf("Hello \n");
return 0;
}
::::::::::::::

Per fare exec di un comando che non si trova nella directory corrente, se non si vuole dare il pathname completo ma si vuole che il comando venga cercato nella lista di pathnames passata dalla shell nella variabile PATH del cosiddetto "ambiente" (che permette nella shell di scrivere semplicemente "ls" e non "/usr/bin/ls") bisogna usare le varianti execlp e execvp.

ESERCIZIO 2.5: modificare il programma precedente con execl("ls","ls",NULL); verificare che la exec fallisce, modificarlo in modo che funzioni.

ESERCIZIO 2.6: verificare i comportamenti indesiderati che si possono ottenere se si fa una stampa con printf che NON causa lo svuotamento del buffer e poi:
2.6.1) si chiama fork, oppure:
2.6.2) si chiama exec
e spiegare perché.
Suggerimento: per 2.6.1 usare fork ma non exec.

Si può ricavare come suggerimento: usare sempre le funzioni di libreria, perchè sono più comode delle system calls e ne fanno un uso efficiente; ma prima di una fork o di una exec chiamare fflush per i files su cui si  è scritto.