Nei moderni sistemi operativi, un processo, è una istanza di un programma in esecuzione.
Il processo è, quindi, un’entità dinamica che include, oltre al codice del programma, anche lo stato dell’esecuzione, identificato dal contenuto della memoria del processo.
Esistono sistemi operativi “moderni” che non supportano alcune (o nessuna) delle proprietà appena descritte. Esempi classici sono i sistemi operativi “embedded” che gestiscono device con bassa capacità di calcolo, e.g., lettori di smart card.
La maggior parte dei sistemi operativi “general purpose” moderni sono:
Dal punto di vista dell’utente, quindi, l’elemento centrale dei moderni sistemi operativi è il concetto di task.
Un task è implementato da uno o più processi cooperanti. Ci occuperemo della programmazione multi-thread dalla lezione n° 22.
Nei sistemi Unix ad ogni processo è assegnato un identificativo univoco denominato Process Identifier o PID.
Il PID è univoco, indipendentemente dal numero di CPU presenti.
Il PID non è altro che un intero, compreso tra 0 e 32767.
Nei sistemi Linux il valore massimo del PID è pari al numero di bit in una pagina fisica (page frame) ed è configurabile all’atto della compilazione del kernel.
I PID 0 ed 1 sono riservati al sistema operativo e sono associati, nei sistemi Linux, ai processi denominati “swapper” (o “cpu_idle”) ed “init”.
All’atto della creazione di un processo, il sistema operativo assegna al nuovo processo un PID non utilizzato. Il processo mantiene il proprio PID fino alla sua terminazione.
Attenzione: Il sistema operativo assegna al nuovo processo il valore dell’ultimo PID assegnato +1. Quando questo valore eccede il massimo, il sistema operativo riutilizza i PID dei processi terminati.
Come già accennato in precedenza, ad ogni utente il sistema operativo assegna un identificativo univoco detto User Identifier (o UID).
È possibile definire gruppi di utenti, ad ognuno dei quali viene assegnato un identificativo univoco detto Group Identifier (o GID).
I sistemi Unix forniscono una serie di system call per ottenere informazioni sull’identità dl processo.
Sebbene PID, UID e GID siano interi, per questioni di portabilità il sistema operativo fornisce tipi specifici per ognuno di questi oggetti. Rispettivamente, un PID, UID e GID sono memorizzati in una variabile di tipo pid_t, uid_t e gid_t.
Lo standard POSIX definisce le seguenti system call:
Si noti che nessuna di queste funzioni ritorna mai un errore.
L’unico modo per un processo utente di creare nuovi processi è attraverso la system call fork() o sue varianti.
pid_t fork(void)
Il nuovo processo creato è detto processo “figlio” mentre il processo che ha invocato la fork è detto processo “padre”.
La funzione è invocata una volta ma ritorna due volte:
In entrambi i casi, viene eseguita l’istruzione successiva alla fork.
La fork crea un nuovo processo “copiando” l’intera memoria (testo, dati statici, dati dinamici e stack) utilizzata dal processo padre. In realtà, per questioni di efficienza, il S.O. NON esegue una copia immediatamente ma solo quando effettivamente necessario (copy on write).
Dal momento della sua creazione, però, I processi padre e figlio sono:
Identici (a meno del valore ritornato da fork)
Indipendenti: NON condividono aree di memoria
Esempio di utilizzo della fork: Mostra codice
Il programma:
Da questo momento in poi I due processi, padre e figlio, sono indipendenti. Difatti il figlio modifica le variabili "globale" e "locale" e poi visualizza il loro valore attraverso la write.
Il padre attende due secondi ed esegue la stessa printf del figlio, ovviamente su dati diversi.
L'output del programma: Mostra codice
Di nuovo, sebbene il figlio modifichi la sua copia delle variabili, le variabili del padre NON vengono modificate.
NON ha senso utilizzare variabili globali e "sperare" che la loro modifica possa "trasferirsi" tra padre e figlio.
Se redirigiamo l’output dello stesso programma su un file e ne visualizziamo il contenuto, otteniamo un comportamento difforme dal precedente. Compare nel file: Mostra codice
Difatti la stringa "prima della fork" visualizzata attraverso l'I/O bufferizzato, compare nel file DUE volte, una volta per il padre ed una volta per il figlio ed, in entrambi I casi, successivamente alla stringa visualizzata dalla write a fine file. In figura:
Dopo l’esecuzione della system call fork, il processo figlio eredita dal padre le seguenti proprietà:
Proprietà ereditata dal processo padre dopo l’esecuzione della system call fork:
La system call fork ritorna due volte, una volta nel padre ed una volta nel figlio.
I processi padre e figlio sono due processi:
La fork può fallire (valore di ritorno -1) se:
Verificare sempre il valore di ritorno della system call.
In molti casi è necessario che un processo crei più processi figli.
Di seguito un programma in cui il processo “padre” crea 5 processi figli e termina: Mostra codice
Ogni processo figlio esegue la funzione fun_figlio che:
Si noti che l'esecuzione delle istruzioni all'interno dell'if vengono eseguite SOLO dal processo figlio. Inoltre, la presenza dell'exit provoca la terminazione del processo figli dopo l'esecuzione della funzione.
Come già evidenziato nell'esempio precedente, la modifica di una variabile (locale o globale) nel processo padre NON influenza il valore della variabile con lo stesso nome del processo padre o viceversa.
Di seguito un possibile output del programma: Mostra codice. Sono da rimarcare:
È necessario prestare la massima attenzione quando la system call fork viene inserita all’interno di cicli. Ecco l’output di una variante (SBAGLIATA) del programma precedente in cui è stata eliminata l’invocazione della system call exit e l’indice i assume i valori 0,1,2 (invece di 0,1,2,3,4): Mostra codice. L'output può essere compreso osservando che, a causa dell'eliminazione della exit, in realtà il programma crea l'albero dei processi (vedi figura). Infatti, per I=0, il processo padre (la radice dell'albero), crea il primo processo figlio (il nodo rosso), il quale visualizza una riga dell'output. Il processo figlio possiede una copia locale della variabile I con valore pari a 0. Non essendo più presente la exit, nessuno dei due termina, entrambi continuano l'esecuzione del ciclo e, quindi:
- entrambi incrementeranno la (copia locale della) variabile I al valore 1;
- entrambi eseguono la system call fork, creando DUE processi (I nodi in giallo), ognuno dei quali visualizza una riga dell'output.
Ora tutti I processi attivi hanno una copia locale della variabile I pari ad 1. Ne consegue che, di nuovo, che ognuno di essi:
- incrementa la (copia locale della) variabile I al valore 2;
- Esegue la system call fork creando, in totale quattro nuovi processi (i nodi in blu).
A questo punto, tutti gli otto processi attivi, incrementano la (copia locale della) variabile I al valore tre ed terminando il ciclo.
La creazione di un processo figlio può avere, sostanzialmente, due motivazioni.
Si tenga conto che l’operazione di start-up di un sistema operativo parte da un unico processo che utilizza la fork per creare tutti gli altri processi del sistema. Quindi TUTTI I processi, sono, in realtà, “discendenti” del processo init.
Quando un processo esegue una fork, il sistema operativo crea idealmente una “copia” (testo, dati statici, dati dinamici e stack) del padre prima di eseguire il figlio.
In realtà NON è esattamente così.
I moderni sistemi NON eseguono una copia del contenuto della memoria fisica se non è strettamente necessario.
Viene creata una copia dello spazio di indirizzamento del processo padre, in modo che il figlio abbia la stessa “visione della memori fisica del padre”, (i.e., abbia accesso alle “stesse variabili”, allo “stesso codice”, allo “stesso stack”) pur non avendone una copia propria.
Se il processo figlio deve eseguire una applicazione scritta da terzi, la copia dello spazio di indirizzamento del processo padre è inutile.
La exec “sovrascrive” l’intera area di memoria del processo che la esegue.
In questo caso è possibile usare la vfork().
vfork() ritorna gli stessi valori della fork().
il padre attende che il figlio esegua la exec() o exit().
NON esegue la “copia” del processo padre. Il figlio usa lo stesso address space del padre.
Se il processo figlio (per errore del programmatore) modifica la memoria, la vfork si comporta come una fork , duplicando sia lo spazio di indirizzamento del padre che l’area di memoria fisica da modificare.
Il sistema operativo informa il processo padre quando uno dei suoi processi figli termina.
L’informazione che sopravvive alla terminazione di un processo è il suo exit status. Quando un processo (figlio) termina, il sistema operativo:
Nei sistemi Linux, quando un processo termina:
Se un processo termina prima dei suoi figli, I processi orfani diventano figli del processo init che si occupa di leggere il loro exit status per consentire il rilascio dei descrittori.
Il processo terminato viene definitivamente eliminato dalla memoria quando il padre:
La consapevolezza che la terminazione di un figlio implica la ricezione di un segnale SIGCHLD consente al processo padre di continuare le proprie attività indipendentemente dall’operato dei figli.
Come vedremo nella Lezione 18, è possibile progettare “indipendentemente” l’applicazione e le funzioni che gestiscono i segnali.
In alternativa il padre può attendere esplicitamente la terminazione di un figlio tramite l’invocazione di una system call wait o waitpid ed ottenere l’exit status del figlio.
Le system call wait ha a seguente firma:
pid_t wait(int *status);
Riceve come parametro un puntatore ad un intero, allocato dal processo che la invoca, o NULL;
Il comportamento del processo è il seguente:
Se la system call wait viene eseguita una volta all’interno del’handler di SIGCHLD, non risulta bloccante visto che vi sarà almeno un processo terminato.
Le system call waitpid ha la seguente firma:
pid_t waitpid(pid_t pid, int *status, int options);
waitpid consente di attendere la terminazione di un processo (figlio) specifico. Il parametro pid è interpretato come segue:
waitpid ritorna un errore se il pid specificato non identifica nessun processo o process group (e se la system call è interrotta).
Come la wait, riceve come parametro un puntatore ad un intero, allocato dal processo che la invoca o NULL;
Il parametro options è zero o composto come OR delle seguenti costanti:
Riproponiamo l’esempio visto precedentemente in cui il processo padre, questa volta, attende la terminazione dei figli: Mostra codice
Si noti che il padre esegue una invocazione della system call per ogni figlio creato.
A differenza dell'esecuzione precedente, il pid del processo padre visualizzato su ogni riga dell'output del programma è sempre lo stesso.
Difatti, la presenza della wait blocca il processo fino alla terminazione del figlio.
Quando un processo figlio invoca la system call getppid(), quindi, il padre è ancora attivo ed il suo pid viene ritornato al figlio che lo visualizza.
Output della funzione: Mostra codice
L’analisi dell’exit status deve essere effettuato tramite l’utilizzo di macro specifiche:
L’esempio: Mostra codice, estende ancora l'esempio precedente analizzando il valore dell'exit status.
Il processo padre, dopo aver creato i tre processi figli esegue la system call wait passando come parametro un puntatore ad un intero.
La system call ritorna il pid del processo figlio terminato.
L'analisi del valore di status consente di ottenere l'exit value del processo figlio.
Nell'esempio, tutti I processi terminano normalmente eseguendo la system call exit a cui passano come parametro il valore corrente della variabile I.
È da notare che:
Output della programma: Mostra codice
Esempio di programma che utilizza la system call waitpid: Mostra codice
A differenza dell'esempio appena visto, il pid ritornato dalla fork viene memorizzato in un array di interi, in modo da poter essere referenziato successivamente.
Ad ogni iterazione del secondo ciclo, il programma invoca la waitpid passando, alla iterazione I-esima il valore del pid dell'I-esimo processo creato.
L'effetto di questa variazione al codice è: Mostra codice
Difatti, indipendentemente dall'ordine di esecuzione dei processi padre e figli, il processo padre visualizza sempre (in quest'ordine) l'exit status del primo processo figlio, seguito dall'exit status del secondo processo figlio, seguito dall'exit status del terzo processo figlio.
La famiglia delle system call exec è utilizzata per sostituire il processo corrente con un nuovo “programma”.
Il codice del programma da eseguire viene letto dal disco l’intera memoria associata al processo viene riscritta (testo, dati, heap e stack). In particolare:
Chiaramente, affinché sia possibile eseguire il codice, è necessario che:
Le system call della famiglia exec sono le seguenti:
int execl(const char *path, const char *arg, ...);
int execv(const char *path, char *const argv[]);
int execle(const char *path, const char *arg , ..., char * const envp[]);
int execve(const char *file, char *const argv [], char *const envp[]);
int execlp(const char *file, const char *arg, ...);
int execvp(const char *file, char *const argv[]);
Tutte le system call sono front-end della system call execve.
Il parametro path indica il pathname dell’eseguibile da caricare (e.g., /bin/ls)
Il parametro file:
Le system call execl, execle, execlp prendono come parametri la lista di stringhe (array di caratteri terminati dal carattere nullo) che assumeranno il valore di “parametri su riga di comando” per il programma da eseguire;
L’ultimo elemento della lista DEVE essere NULL.
Le system call execv, execve, execvp prendono come parametro il vettore di puntatori agli argomenti da passare all’eseguibile.
L’ultimo elemento dell’array DEVE essere NULL.
Le system call execle, execve prendono come ulteriore parametro un descrittore (lista o array) del “environment”
PATH, SHELL, HOME, PWD,….
Utile per specificare un ambiente specifico per un figlio.
La altre system call utilizzano la variabile environ del processo padre.
Le system call exec ritornano SOLO in caso di errore. In tal caso, il valore di ritorno è -1 e la variabile globale errno contiene il codice d’errore. Nel caso in cui la exec venga eseguita con successo, la system call NON ritorna. Il processo originario viene sostituito dal programma richiesto ed il “nuovo processo” termina quando questi termina.
L’esecuzione di una system call exec sostituisce il codice del processo corrente con un altro ma eredita dal processo che originario:
Il processo, NON eredita dal processo originario:
Di seguito un programma in cui il processo padre, crea un figlio ed attende la sua terminazione: Mostra codice
Il processo figlio esegue lo script test.sh, riportato nella figura in basso.
La execl prende come parametro il pathname del file "test.sh". In questo caso, mancando "/" nel primo parametro, il file viene ricercato nelle directory indicate dalla variabile PATH.
I successivi tre parametri identificano i "parametri su riga" di comando visibili al uovo programma in esecuzione, arg0, arg1.... Per convenzione, arg0 punta al nome del file.
La lista di parametri della execl DEVE terminare con un puntatore NULL.
Si noti che l'istruzione "perror" NON viene eseguita.
L'output del programma: Mostra codice. Il programma in C esegue lo script che visualizza il "nome dello script" $0, l'elenco dei suoi parametri "par 1 par 2", il primo parametro "par 1", e l'elenco dei file nella directory corrente.
È da rimarcare che è (ovviamente):
Si noti, inoltre, che non indicando l’interprete all’interno dello script, la exec ritorna con errore.
Nel seguente codice: Mostra codice, dallo script originario è stata eliminata la prima riga. L'esecuzione del codice porta alla esecuzione della funzione perror.
Output della programma: Mostra codice
Riportiamo un esempio di utilizzo della system call execvp: Mostra codice
Questa system call prende come parametri il nome del file da eseguire ed un array di puntatori a stringhe, in cui l'ultimo elemento è un puntatore nullo.
L'array contiene, come nel caso della lista, I parametri per il programma da eseguire. Il primo elemento dell'array contiene, per convenzione, il nome del file da eseguire.
Il programma, mostrato nell'esempio, crea un processo figlio che esegue il comando "ls" passando l'opzione "-l".
Il processo padre attende la terminazione del figlio, (I.e., del comando ls), visualizza il messaggio "Fine ls" e poi termina.
1. Introduzione ai sistemi Unix
2. Principi di programmazione Shell
3. Esercitazioni su shell scripting - parte prima
5. Esercitazioni su shell scripting - parte seconda
6. Espressioni Regolari ed Introduzione ad AWK
7. Esercitazioni su espressioni regolari ed awk scripting
9. Esercitazioni su awk scripting - parte seconda
10. Programmazione in linguaggio C: Input/Output di basso livello
11. Esercitazioni su I/O di basso livello
12. Interazione con file di sistema e variabili d'ambiente
13. Esercitazioni sulla gestione dei file di sistema e le variabili...
14. System call per la gestione di file e directory
15. Esercitazioni su gestione file e directory
16. La programmazione multi-processo
17. Esercitazioni su programmazione multi-processo
18. I Segnali
20. I Socket
W.R. Stevens, S.A. Rago - Advanced Programming in the Unix Environment - Capitolo 8 (8.1-8.7/8.10)