4. Gestione dei processi e threads
Un processo è definito come un'istanza di programma in esecuzione. Un processo viene eseguito in modo sequenziale (nel senso una esecuzione alla volta, ma non per forza in ordine), ma in un sistema multiprogrammato i processi evolvono in modo concorrente.
Da cosa è formato un processo?
Un processo consiste di:
- Istruzioni (sezione Codice o Testo): è la parte statica del codice
- Dati (sezione Dati): è la parte di variabili globali
- Stack:
- le chiamate a procedura e parametri
- le variabili locali
- Heap
- Memoria allocata dinamicamente
- Attributi (id, stato, controllo)
Attributi (Process Control Block)
Vediamo ora in maggiore dettaglio la parte degli attributi.
All’interno del S.O. ogni processo è rappresentato dal Process Control Block (PCB), che rappresenta appunto la sezione "Attributi" nell'immagine sopra. Il PCB è una struttura dati con una tabella in memoria principale che conserva le seguenti informazioni:
- Stato: in esecuzione, in attesa, etc.
- Program Counter (PC): puntatore alla prossima istruzione da eseguire (in alcuni testi e chiamato Instruction Pointer)
- Valori dei registri: vengono conservati i valori dei registri CPU utilizzati dal processo
- Informazioni sulla memoria (es: registri limite, tabella pagine)
- Informazioni sullo stato dell’I/O (es: richieste pendenti, file)
- Informazioni sull’utilizzo del sistema (CPU)
- informazioni di scheduling (es: priorità)
Stati di un processo
Durante la sua esecuzione, un processo evolve attraverso diversi stati:
- Nuovo: il processo è appena stato avviato, deve essere ancora caricato in memoria dal long-term scheduler
- Pronto: il processo è in memoria, sta aspettando che lo short-term scheduler gli lasci l'utilizzo della CPU
- In Esecuzione: sta usando la CPU (ci può essere un solo processo in running alla volta)
- In Attesa: il processo è in attesa di un evento, o di soddisfare una richiesta di utilizzo di un dispositivo I/O non disponibile al momento (la separazione di waiting dagli altri stati è un passo fondamentale per migliorare l'utilizzo della CPU, infatti il processo torna in memoria se deve aspettare un evento)
- Terminated: il processo è terminato
Scheduling
Lo scheduler si occupa di controllare e selezionare il processo da eseguire nella CPU. Ce ne sono di 2 tipi:
- Long-term Scheduler: si occupa del caricamento dei processi da disco alla memoria principale (ready queue). Viene invocato con intervalli nell'ordine dei secondi/minuti. Controlla il grado di multiprogrammazione, ovvero il numero totale di processi nello stato ready.
- Short-term Scheduler: più veloce ed invocato con frequenze molto maggiori del precedente, si occupa del dispatching, ovvero il passaggio da Ready a Running. Short-term scheduler che eseguono cambi di contesto troppo spesso rischiano di causare un grande overhead al sistema, degradando notevolmente le prestazioni, mentre effettuare cambi di contesto troppo poco spesso porta a tempi di risposta ed attesa eccessivamente lunghi.
I processi vengono inseriti in delle code:
- coda dei processi pronti (ready queue): coda dei processi pronti per l'esecuzione
- coda di un dispositivo (waiting queue): coda dei processi in attesa che il dispositivo si liberi
I processi restano nella coda dei processi pronti fino a quando non ne viene selezionato uno per essere eseguito (dispatched).
I processi possono essere descritti come
- I/O bound: maggior parte del loro tempo eseguono operazioni di I/O, molti burst di CPU corti
- CPU bound: maggior parte del loro tempo eseguono computazioni, pochi burst di CPU lunghi
Come fa un processo a sapere a quale tipo appartiene? Bella domanda.
L'operazione più critica è quella fatta dal dispatcher, ovvero assegna la CPU ad un certo processo e fa passare il processo da ready a running. Come avviene nel dettaglio?
- Cambio di contesto: viene salvato il PCB del processo che esce e viene caricato il PCB (precedentemente registrato) del processo che entra
- Passaggio alla modalità utente (all’inizio della fase di dispatch il sistema si trova in modalità kernel)
- Salto all'istruzione da eseguire del processo appena arrivato nella CPU
Il tempo necessario al cambio di contesto è puro sovraccarico
- Il sistema non compie alcun lavoro utile durante la commutazione
- La durata del cambio di contesto dipende molto dall’architettura
Creazione di un processo
Per quanto riguarda la creazione dei processi, la maggior parte dei SO permette a processi di crearne di nuovi tramite apposite chiamate di sistema. Si parla in questo caso di processi padre e processi figlio; il figlio può ottenere le risorse dal padre o direttamente dall'SO.
A seconda delle politiche del SO si hanno 2 tipi di esecuzione:
- sincrona: il padre può aspettare che terminino i figli
- asincrona: continuare la propria esecuzione parallelamente ai figli (esecuzione concorrente)
Come si crea un processo in UNIX?
- System call fork(): Crea un figlio che è un duplicato esatto del padre
- il figlio deve essere più piccolo del padre per evitare di avere 2 processi completamente uguali
- il PCB del figlio è diverso
- System call exec(): Carica sul figlio un programma diverso da quello del padre
- System call wait(): Per esecuzione sincrona tra padre e figlio
Come si termina un processo?
Un processo può terminare in diversi modi:
- Il processo finisce la sua esecuzione
- Il processo viene terminato forzatamente dal padre
- Per eccesso nell’uso delle risorse
- Il compito richiesto al figlio non è più necessario
- Il padre termina (se il padre muore, muoiono anche i figli, i figli non possono rimanere senza padre)
- Il processo viene terminato forzatamente dal S.O.
- Quando l'utente chiude l'applicazione
- A causa di errori (aritmetici, di protezione, di memoria, …)
Threads
Qual è la differenza tra processo e thread?
Un thread è definito come l'unità minima di utilizzo della CPU, un processo come l'unità minima di possesso delle risorse. All'interno di un processo, i thread condividono codice, dati e risorse, ma ognuno può avere stati e registri diversi. La suddivisione di un processo in più thread permette che parte di essi siano in esecuzione mentre altri sono bloccati per l'attesa di un evento di I/O (questo è solo uno dei vantaggi). Dato che per definizione condividono codice e dati, la comunicazione e la condivisione di risorse tra thread è più semplice rispetto a quella tra processi; anche la creazione di thread è più veloce della creazione di processi.
Per riassumere, ad un processo sono associati:
- Spazio di indirizzamento
- Risorse del sistema
Invece, ad una singola thread sono associati:
- Stato di esecuzione
- Contatore di programma (program counter)
- Insieme di registri (della CPU)
- Stack
Inoltre le thread condividono:
- Spazio di indirizzamento
- Risorse e stato del processo
In un S.O. classico abbiamo che 1 processo = 1 thread , ma grazie al concetto di multithreading abbiamo la possibilità di supportare più thread per un singolo processo. Di conseguenza, abbiamo una separazione tra “flusso” di esecuzione (thread) e spazio di indirizzamento
- Processo con thread singola: Un flusso associato ad uno spazio di indirizzamento
- Processo con thread multiple: Più flussi associati ad un singolo spazio di indirizzamento
Vantaggi dei threads
Come accennato in precedenza, l'uso di threads presenta vari vantaggi:
- Riduzione tempo di risposta, più operazioni allo stesso tempo
- Mentre una thread è bloccata (I/O o elaborazione lunga), un’altra thread può continuare a interagire con l’utente
- Condivisione delle risorse
- Le thread di uno stesso processo condividono la memoria
- Più veloci dei processi
- Creazione/terminazione thread e context switch tra thread più veloce rispetto ai processi
- Scalabilità
- Grazie al multithreading aumenta il parallelismo, possiamo avere un thread in esecuzione su ogni processore
Stati di un thread
Gli stati di un thread sono gli stessi di un normale processo (pronto, in esecuzione, in attesa), ma la loro correlazione dipende dall'implementazione. Ad esempio un thread in wait potrebbe bloccare o meno altri thread dello stesso processo o anche l'intero processo.
Implementazione dei threads
Esistono essenzialmente due modi per implementare i thread in un SO, poi un terzo che è la combinazione dei primi due, di seguito riportati.
User-Level Thread
Implementati tramite apposite librerie (tipo pthread.h).
Vantaggi
- Tutti i thread sono gestiti da un unico processo nel kernel, il che rende il sistema molto economico.
- Non è necessario passare in modalità kernel per utilizzare le thread, quindi più efficienza
- Portabilità, girano su qualunque S.O. senza bisogno di modificare il kernel
- Meccanismo di scheduling variabile da applicazione ad applicazione
Svantaggi
- Il blocco di una thread blocca l’intero processo (anche se è superabile con accorgimenti specifici)
- Niente parallellismo, viene eseguito un solo thread alla volta per processo (praticamente il kernel ignora l’esistenza delle altre thread)
Kernel-Level Thread
Ad ogni thread a livello utente corrisponde un singolo thread a livello kernel. Vari esempi sono Win32, Linux, Native thread di Java.
Vantaggi
- Parallellismo, più thread dello stesso processo in esecuzione contemporanea su CPU diverse
- Il blocco di un thread non blocca il processo
Svantaggi
- Scarsa efficienza, per ogni thread a livello kernel c'è un thread a livello utente
Approccio misto
Ad
Libreria POSIX (pthread.h)
Per usare i pthreads in un programma C, è necessario includere la libreria <pthread.h>
. Per compilare un programma che usa i pthreads occorre linkare la libreria libpthread, usando l’opzione -lpthread:
$> gcc prog.c -o prog -lpthread
Creazione di un thread
Un thread ha vari attributi, che possono essere cambiati, ad esempio:
- la sua priorità (che influenza la frequenza con cui verrà schedulato)
- la dimensione del suo stack (che specifica la quantità massima di argomenti che gli si possono passare, la profondità delle chiamate ricorsive, e così via)
Gli attributi di un thread sono contenuti in un oggetto di tipo pthread_attr_t. La syscall per inizializzare un thread è la seguente:
int pthread_attr_init(pthread_attr_t *attr);
Questa funzione inizializza con i valori di default un “contenitore di attributi” *attr, che potrà poi essere passato alla system call che crea un nuovo thread.
Un nuovo thread viene creato con la syscall intera pthread_create che accetta quattro argomenti:
- una varibile di tipo pthread_t, che conterrà l’identificativo del thread che viene creato
- un oggetto attr, che conterrà gli attributi da dare al thread creato. Se si vuole creare un thread con attributi di default, si può anche semplicemente usare NULL
- un puntatore alla routine che contiene il codice che dovrà essere eseguito dal thread
- un puntatore all’eventuale argomento che si vuole passare alla routine stessa
Terminazione di un thread
Un thread termina quanto finisce il codice della routine specificata all’atto della creazione del thread stesso, oppure quando, nel codice della routine, si chiama la syscall di terminazione:
void pthread_exit(void *value_ptr);
Quando termina, il thread restituisce il “valore di return” specificato nella routine, oppure, se chiama la pthread_exit, il valore passato a questa syscall come argomento.
Sincronizzazione tra threads
Un thread può sospendersi, in attesa della terminazione di un altro thread chiamando la syscall:
int pthread_join(pthread_t thread, void **value_ptr);
- pthread_t thread è l'identificativo del thread di cui si attende la terminazione. Naturalmente deve essere un thread appartenente allo stesso processo.
- void **value_ptr è il valore restituito dal thread che termina.
Sono comunque disponibili diversi strumenti per sincronizzare fra loro i thread di un processo, fra questi anche i semafori. In realtà i semafori non fanno parte dell’ultima versione dello standard POSIX, ma della precedente. Tuttavia sono normalmente disponibili in tutte le versioni correnti dei pthread.
I pthread mettono anche a disposizione meccanismi di sincronizzazione strutturati, quali le variabili condizionali.
Esempio
#include <unistd.h>
#include <pthread.h>
#include <stdio.h>
void *tbody(void *arg) {
int j;
printf(" ciao sono un thread, mi hanno appena creato\n");
*(int *)arg = 10;
sleep(2); /* faccio aspettare un pò il mio creatore, poi termino */
pthread_exit((int *)50); /* oppure return ((int *)50); */
}
int main(int argc, char **argv) {
int i;
pthread_t mythread;
void *result;
printf("sono il primo thread, ora ne creo un altro \n");
pthread_create(&mythread, NULL, tbody, (void *) &i);
printf("ora aspetto la terminazione del thread che ho creato \n");
pthread_join(mythread, &result);
printf("Il thread creato ha assegnato %d ad i\n",i);
printf("Il thread ha restituito %d \n", result);
}
Il thread main ha generato un secondo thread tbody. Poi main si è messo in attesa della terminazione del thread creato (con pthread_join), ed è terminato lui stesso.
È importante notare che i due thread condividono lo stesso spazio di indirizzamento, e quindi vedono le stesse variabili: se uno dei due modifica una variabile, la modifica è vista anche dall’altro thread.
Nel codice di t1, il main passa al thread tbody il puntatore alla variabile i dichiarata nel main. il thread tbody modifica la variabile, e questa modifica è vista da main. Nel caso dei processi tradizionali, una cosa simile è ottenibile solo usando esplicitamente un segmento di memoria condivisa.
Ovviamente i thread di un processo possono condividere variabili in maniera ancora più semplice, usando variabili globali. Tuttavia un thread può anche avere variabili proprie, viste solo dal thread stesso usando la classe di variabili thread_specific_data.
SO come processo
Sappiamo che il sistema operativo è un programma a tutti gli effetti, quindi può essere considerato esso stesso un processo?
Questo varia a seconda dell'implementazione, in cui le parti del SO possono o meno essere considerate dei processi.
Vediamo dunque le alternative disponibili:
Kernel Separato
Il kernel esegue al di fuori dei processi utente, in uno spazio "riservato" in memoria ed in esecuzione privilegiata. Essendo appunto "separato" le cose vanno implementate due volte e in maniera diversa. Inoltre il S.O. prende il controllo dell'intero sistema. Il concetto di processo è quindi applicabile solo ai processi utente. Questa modalità è tipica dei primi SO.
Kernel nei processi utente
In questa implementazione i servizi del SO sono procedure chiamabili da programmi utente, accessibili però solo in modalità protetta (kernel mode).
Ogni processo ha un kernel stack per gestire le chiamate a funzione e una porzione di codice/dati condiviso tra tutti i processi. Ciò velocizza le chiamate di sistema, visto che in caso di interrupt o trap durante l’esecuzione di un processo utente è sufficiente solo un cambio di modalità (mode switch). Ossia, il sistema passa da user mode a kernel mode e viene eseguita la parte di codice relativa al S.O. senza context switch (da processo utente a processo di SO).
Kernel come processo
A differenza della precedente implementazione, qui solo una parte del kernel (solitamente lo scheduler) esegue separatamente, al di fuori di tutti i processi. Per il resto, ogni parte del SO è un processo separato, eseguito in modalità protetta (kernel mode). Questo è molto vantaggioso nei sistemi multicore, che di solito riservano dei processori ad esclusivo utilizzo del SO.