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:
- se il processo termina con exit, gli 8 bit meno significativi del
valore passato a exit vengono inseriti nell'intero spostati di 8 bit a
sinistra;
- se é stato terminato da un "segnale", l'intero identifica
il tipo del segnale, aumentato di 128 se é stato salvato un file
"core" come avviene per alcuni tipi di segnali. Il file "core" contiene
l'immagine di memoria del processo al momento della terminazione e
può essere utilizzato in un debugger qualora la terminazione
sia, per l'utente, inattesa e si ritenga sia stata causata dal processo
stesso che ha ricevuto il segnale dal sistema operativo come risposta
al tentativo di operare ad es. su un
indirizzo non ammesso
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.