:: Sistemi operativi - Lezione del 10 marzo 2008 ::
Torna alla pagina di Sistemi Operativi
Stavamo parlando dei processi. Possiamo vederli come un flusso di esecuzione di computazione. Due processi separati daranno luogo a 2 flussi diversi.
Tra di loro, questi 2 flussi possono essere sincronizzati o indipendenti.
Questo piccolo ragionamento ci introduce ad una nuova visione della computazione, ovvero considerarla come una serie di attività che si possono spezzare in più processi, ognuno che si occupa di un determinato aspetto.
Rispetto a questa visione, il processo può essere:
- monolitico: un programma che parte e va fino alla fine dritto come un treno;
- cooperante: un programma che attiva più processi, alcuni indipendenti, altri no, e tutti con lo stesso obiettivo.
Per realizzare un processo monolitico si scrive un programma monolitico.
Per realizzare processi cooperanti posso ancora scrivere opportunamente un programma monolitico, oppure scrivere programmi diversi eseguiti come processi diversi. Un processo può generarne altri, e ogni processo generato può generare ancora.
Generare un processo
Il processo che ne vuole generare una copia di se stesso deve chiamare una funzione di sistema che sulle macchine Unix si chiama fork(). Il generante si chiama padre, il generato figlio.
Padre e figlio possono usare lo stesso codice, ma due contesti diversi (ovvero lavorare su istanze diverse dei dati), oppure avere codice diverso per ognuno.
Quest'ultima affermazione è interessante: quando un processo crea un processo figlio, quest'ultimo a che risorse ha accesso?
- padre e figlio possono usare le stesse risorse, coi permessi giusti, e soprattutto opportunamente sincronizzati;
- possono condividere solo alcune delle risorse;
- non condividono niente.
Quale che sia l'atteggiamento del figlo verso il padre, un processo ha sempre il suo proprio spazio di indirizzamento intoccabile da parte degli altri.
Quindi, all'attivazione del figlio il padre potrebbe decidere di copiare tutto o in parte il suo spazio di indirizzamento in quello del figlio, per ottenere uno dei primi due punti visti qui sopra.
Nell'ambito della programmazione, la fork() restituisce un valore. Se il valore è 0, vuol dire che si tratta del processo figlio. Se il valore non è 0, allora è il pid (process id) del padre.
int valore = fork();
if (valore == 0) {
printf("Sono il figlio!\n");
return (0);
}
else {
printf("Sono il padre!\n");
return (0);
}
In genere, un figlio generato va per la sua strada indipentemente dal padre. Se il padre vuole aspettare la terminazione dell'esecuzione del figlio, deve chiamare la funzione di sistema wait().
Se un processo vuole sì generarne un altro, ma non uguale a se stesso, non basta chiamare la fork(). Occorre chiamare anche la exec() e avviare un programma del tutto nuovo. La exec() sostituisce interamente il codice ed i dati del processo figlio, che la fork() ha reso uguale al padre, con codice e dati del programma indicato alla exec(). Si possono anche passarci dei parametri per copiare dati etc. etc.
Quando un processo termina graziosamente, restituisce un valore di stato a seconda che abbia avuto successo o no. Se invece termina ex abrupto, accade quella che si dice abort. Il padre muore improvvisamente a metà computazione e fa morire a cascata tutti i figli.
Lezione 3: sospensione e riattivazione dei processi
Sospendere e riattivare i processi è l'anima del multitasking. Ricordiamolo ancora una volta:
- multiprogrammazione = più programmi contemporaneamente in memoria;
- multitasking = più processi eseguti in "parallelo".
Dividiamo i processi in due classi astratte:
- i processi CPU-bound, che usano principalmente la CPU;
- i processi I/O-bound, che usano principalmente l'I/O del sistema.
Si tratta di due classi estreme, i processi nella realtà non sono così manichei nella divisione.
Per ottenere il multitasking, devo comportarmi in modo diverso a seconda che un processo sia CPU- o I/O-bound.
Nel caso del CPU-bound, se l'obiettivo è utilizzare al massimo il mio processore, allora non mi interessa nemmeno QUALE processo lo stia usando, purché ce ne sia uno che lo faccia. Posso far partire il mio processo CPU-bound, e questo si mette a macinare calcoli continuamente. Il processore viene sfruttato.
Ma c'è un'altra cosa da considerare: all'utente tutto ciò come appare? Se un processo monopolizza il processore, vuol dire che gli altri non fanno niente, e la macchina apparirà immobile all'utente.
Quindi, non basta occupare il processore, occorre anche che tutto appaia fluido e in esecuzione parallela all'utente. E allora, per ottenere del vero multitasking, occorre che io sappia fermare un processo, attivarne (dispatching) un altro in base a qualche criterio (scheduling), e poi tornare al processo di prima.
Come al solito, possiamo distinguere tra politiche e meccanismi. Decidere CHI sospendere è politica, COME farlo è un meccanismo.
Politiche di sospensione dei processi
Sono le regole in base alle quali si stabilisce di interrompere un processo.
Un processo viene sospeso implicitamente quando:
- esegue un'operazione I/O;
- fa una fork();
Un processo si autosospende esplicitamente quando è lui stesso a volerlo.
Quale che sia l'occasione, queste sospensioni sono tutte sincrone con lo stato dell'evoluzione della computazione, e avvengono sempre all'interno di chiaamte di sistema. Infatti le chiamate I/O sono chiamate di sistema, così come le fork e le richieste esplicite di sospensione.
Time Sharing
Per ottenere la fluidità e l'illusione che diverse cose avvengano contemporaneamente in un processore si usa la tecnica del time sharing. Il processo in esecuzione viene obbligato a ad andarsene: questo si chiama preemption.
Il principio è che un processo può utilizzare la CPU per una certa quantità di tempo detta time slice, o quanto di tempo. Finito il tempo a sua disposizione, volente o nolente il SO obbliga il processo a menare le tolle, lo sospende e ne prende un altro.
Così, ho i processo che sono forzatamente obbligati a farsi strada l'un l'altro. Certo che se una certo evento da cui il processo dipende avviene proprio nell'istante in cui il SO lo preemptiva, possono nascere casini. Ricordiamoci quindi che il time sharing non è indolore.
Anche se c'è il time sharing, le recole implicite ed esplicite del paragrafo precedente rimangono comunque valide.
Domanda da un milione di banconote del Paninopoli: se un processo è in esecuzione, e sta usando la CPU, come fa il SO a sospenderlo?
La risposta sta nel RTC, il real time clock, che è un orologietto al quarzo che ad intervalli regolari invia delle interruzioni. Il SO le gestisce, e decide che è ora di cambiare processo.
Certo che se il periodo tra un'interruzione e un'altra è troppo piccolo, il SO perde tempo a gestire le interruzioni più che ad eseguire effettivamente i processi. E il RTC lavora ad alte frequenze. Quindi nella pratica si usa come intervallo di interruzione non tanto la frequenza del RTC, ma un suo multiplo.
Come al solito durante le interruzioni, quando il processore ne gestisce una, disabilita la ricezione delle altre. Quindi è possibile che durante la gestione dell'interruzione di altre periferiche, arrivi il segnale RTC e il processore non se ne accorga! Ecco quindi che si imposta il valore di interruzione dell'RTC un po' più basso di quello che si vorrebbe, così che statisticamente si ottiene il valore desiderato.
Meccanismi di sospensione dei processi
Quando avvengono le sospensioni, il SO è in modalità supervisor, ovvero è uno di quei moemnti in cui è proprio il SO ad avere il controllo del sistema.
Il contesto dei singoli processi viene salvato: i registri nello stack del processo, lo stack pointer nel Process Control Block del sistema operativo. Per ripristinare, faccio l'inverso: copio i dati dal PCB alla cima dello stack, e ripristino i registri.
Il cambiamento di contesto si chiama context switchig: sospensione e riattivazione di un processo.
Da notare che alcuno processori (eg gli x86) possono farlo anche in hardware (c'è un'istruzione apposita che salva il contesto dove indicato), ma a volte farlo in software risulta più veloce.
Modulo 4 - Lezione 1: i Thread
I processi sono realtà autonome, con il loro spazio nella memoria toccale intangibile agli altri. Bella cosa; un po' meno però quando ho la necessità di scambiare dati fra processi: sincronizzarli non è così semplice, e soprattutto è una cosa lenta.
Poi, prendiamo il caso di un web server, a cui viene richiesta 2 volte di fila la stessa pagina: un processo la legge e la invia al client; poi arriva il secondo processo che la rilegge e la invia al secondo client. Ho quindi letto 1 volta di troppo la stessa pagina, e questo perché non ho potuto comunicare tra i processi.
L'idea sarebbe quindi che i processi possano avere uno spazio condiviso a cui accedere in modo nativo e naturale. In particolare, se ho un'applicazione che rende disponibili dei dati, vorrei che tutti i suoi dati siano accessibili ai processi figli in modo semplice, senza dover copiare i dati dallo spazio di indirizzamento di un processo allo spazio di un altro processo, perché è inefficiente.
Allora inventiamo il thread, che è un flusso di controllo indipendente dell'esecuzione di istruzioni di 1 programma, ma NON autonomo rispetto al sistema, bensì autonomo solo all'interno del programma.
Detto in altre parole, è un mini-processo che vive all'interno di un processo vero, autonomo dal punto di vista dell'esecuzione, ma con la stessa "base di dati" a cui attingere, ovvero quella del processo che lo ha generato.
Il processo tradizionale, detto pesante, ha 1 solo thread che fa tutto.
Un processo multi-thread ha invece più thread, ognuno dei quali fa cose differenti, e che possono comunicare in modo più semplice perché hanno accesso alla stesso spazio di indirizzamento, quello del processo. Ovvio che vanno sincronizzati opportunamente, ma questa è un'altra storia:)
Benefici dell'uso dei thread:
- prontezza di risposta rispetto a più processi;
- condivisione delle risorse tra di loro;
- economia nell'occupazione di risorse proprio per via della condivisione;
- sfruttabili dai sistemi multiprocessore.
Il supporto ai thread può essere realizzato nello spazio utente, come una libreria, oppure essere implementato direttamente dal SO nello spazio kernel.
Se è implementato nello spazio utente, è il mio processo che gestisce tutti i suoi thread interni, e il SO non ne sa niente. Il SO vede solo un processo; quello che poi il processo fa al suo interno non lo sa.
Se invece è il kernel ad implementarli, allora il SO è a conoscenza dei miei thread.
Lezione 2: modelli multi-thread
All'interno del SO, posso vedere i thread di livello utente in molti modi:
- molti a uno;
- uno a uno;
- molti a molti;
- 2 livelli.
Certo, se il SO NON supporta i thread, lui vede solo un processo, e all'interno del processo c'è una libreria che si occupa dei thread.
Se invece li supporta, ogni thread del processo può essere visto dal SO in uno dei modi elencati sopra.
Uno a uno vuol dire che 1 thread del processo equivale ad 1 thread del processore. Molti a uno vuol dire che più thread del processo sono visti come 1 thread dal processore. Molti a molti vuol dire che i thread del processo sono raggruppati in vario modo in diversi thread (di numero inferiore) del processore.
Se i thread sono mappati molti a molti o molti a uno, è il programma utente che deve far sì che i suoi thread interni siano opportunamente sincronizzati in modo che il thread di livello kernel che li gestise li veda come uno solo. Se invece sono mappati uno a uno, è il kernel che si prende la briga di sincronizzarli tutti.
Ognuno di questi approcci ha i suoi svantaggi.
Lo svantaggio di avere una mappatura molti a uno vuol dire che se uno degli x si blocca eg per un I/O, allora blocchera tutti gli altri thread che fanno parte del suo stesso gruppo.
Per fare un esempio, il thread kernel A gestisce i thread utente Alfa, Beta e Gamma. Se Beta fa un'I/O, si blocca in attesa del risultato. Bloccandosi Beta, anche A si blocca perché è proprio A a smazzare l'I/O di Beta. Ma se A smazza l'I/O di Beta, allora Alfa e Gamma non possono fare altro che dormire, perché A, che dovrebbe gestirli, non può ascoltarli in quanto impegnato a dare retta all'I/O di Beta!
Lo svantaggio invece della mappatura uno a uno è che l'efficienza complessiva del SO cala, perché il SO deve, oltre che a gestire i processi, gestire anche la miriade di thread da questi creati.
Il miglior compromesso è quindi fare una mappatura molti a molti, in cui creo diversi kernel thread, i quali si occupano ciascuno di diversi userspace thread. Magari anche ben raggruppati in base alle funzionalità. Così cerco di evitare gli svantaggi delle 2 situazioni qui sopra illustrate.
Cooperazione tra thread
Per far cooperare tra di loro i thread, ho 3 modelli di comportamento:
- thread simmetrici;
- thread gerarchici;
- strutture in pipeline.
Nel modello dei thread simmetrici tutti i thread sono equipotenti, e quando mi arriva una richiesta scelgo uno qualsiasi dei thread per attivarlo (o ne creo uno per l'occasione).
Nel caso del server web, se mi arriva un client, io creo un thread che si occupi di ascoltare la richiesta, pescare la pagina da disco e reinviargliela.
Nel sistema dei thread gerarchici, ho un thread capo che coordina i worker thread, e i worker eseguono. In questo caso è conveniente mappare il thread capo nel kernel, e i worker nell'user space.
Il sistema a pipeline invece divide il lavoro come in catena di montaggio. Ogni thread fa un solo aspetto del lavoro. Uno riceve tutte le richieste. Uno fa tutto l'I/O dal disco. Uno risponde alle richieste. Quindi, ognuno dei thread lavora poco ma spesso, perché è subito pronto a gestire nuove richieste.
Lezione 3 - Gestione dei Thread
Creazione dei thread
Uso una fork() più o meno come per i processi. La domandona però è: se ho un processo multithread, ne verrà creato uno nuovo con la copia di tutti i suoi thread, o un processo pesante composto da quell'unico thread che aveva lanciato la fork? La risposta è: dipende. Ad esempio nei sistemi UNIX viene data la possibilità di scegliere tra le due alternative fornendo due funzioni fork() distinte.
Esecuzione in thread
Uso ancora la exec() per eseguire un programma all'interno del thread, diverso dal chiamante.
Cancellazione
Posso uccidere un thread quando mi pare, anche in modo asincrono rispetto al suo stato di evoluzione della computazione.
Ma posso anche aspettare: quando un thread comunica di aver finito la sua carriera, viene messo nella lista nera, e quando avrà tempo il processo libererà le sue risorse.
Sincronizzazione e comunicazione
Il SO permette la comunicazione di info tra tutti i thread di 1 processo, oppure può limitarla in vario modo.
Processo leggero
Il processo leggero, altrimento noto come LWP = Light Weight Process, è una via di mezzo tra il processo ed i thread.
Del processo ha tutto ciò che lo renderebbe autonomo e passibile di context-switching, al pari degli altri processi. Però gran parte della sua memoria viene condivisa con quella di altri processi.
È un modo per realizzare i thread nello spazio kernel, in quanto è sì schedulato dal kernel come un processo, ma dispone della memoria assegnata ad un altro processo.
Torna alla pagina di Sistemi Operativi