CAPITOLO 0 – INTRODUZIONE
• PROTEZIONE E SICUREZZA
Il sistema operativo deve effettuare delle distinzioni tra gli utenti che possono accedere alle risorse;
ogni utente può svolgere determinate operazioni e usufruire di un insieme ristretto di risorse. In
questo modo si garantisce la sicurezza dell'intero sistema, poiché soltanto gli utenti ed i processi che
ricevono autorizzazione possono lavorare con il calcolatore.
CAPITOLO 1 – STRUTTURA DEL SISTEMA OPERATIVO
• MACCHINA VIRTUALE
La suddivisione in livelli delle funzionalità del sistema operativo implica un concetto di astrazione che
trova la sua naturale conclusione nella macchina virtuale. L'idea di base è quindi quella di astrarre
l'hardware alla base del sistema, progettando per ogni unità fisica un software che ne simuli il
comportamento. Molto più semplicemente, si può dire che, partendo dall'hardware effettivamente
esistente, una parte di questo è virtualizzato, grazie ad applicazioni specifiche, e costituirà
l'hardware di base per un processo “ospite” (solitamente un sistema operativo). Le risorse saranno
quindi condivise dai diversi processi, ma sembrerà che ogni processo goda di un proprio processore,
o di una propria memoria, quando invece è sempre lo stesso hardware a fare da base a tutti questi.
La macchina virtuale fornisce una protezione completa delle risorse, poiché è isolata dal resto del
sistema e dalle altre macchine virtuali; inoltre, fornisce un comodo mezzo per il test dei sistemi
operativi, o delle applicazioni appena sviluppate. I programmi che permettono la virtualizzazione
sono eseguiti in kernel mode, mentre la macchina virtuale vera e propria è eseguita in user mode;
all'interno della macchina virtuale esisteranno ovviamente una kernel mode virtuale, ed una user
mode virtuale, che simulano il comportamento di un kernel reale, passando dall'una all'altra, per
mezzo di syscall virtuali. Nella kernel mode virtuale, vengono effettuate tutte le azioni che si
eseguirebbero nella kernel mode fisica, ma la loro estensione è limitata all'area della macchina
virtuale.
Un esempio di virtualizzazione è dato dall'applicazione VMware che consente di simulare più sistemi
operativi su una macchina Windows o Linux.
CAPITOLO 2 – PROCESSI
• DEFINIZIONE DI PROCESSO
Un processo può essere visto come un programma in esecuzione; il programma è infatti un'entità
passiva, mentre il processo riguarda l'esecuzione corrente, comprensiva di tutte le risorse associate
ad essa. Il processo, durante la sua esecuzione, è soggetto a cambiamenti di stato (nuovo,
esecuzione, attesa, pronto, terminato), ed è monitorato dal sistema operativo, grazie al blocco di
controllo di un processo (PCB). Il process control block contiene informazioni relative ad un
processo specifico, tra cui il suo stato, il contatore di programma, lo stato dei registri delle CPU, le
informazioni sullo scheduling della CPU, le informazioni sulla contabilizzazione delle risorse, e le
informazioni sullo stato dei dispositivi di I/O. Un processo prevede che il programma sia eseguito in
maniera sequenziale, quindi ci sono processi che seguono un unico percorso d'esecuzione (single
thread), e processi che eseguono più percorsi di esecuzione paralleli (multi thread).
• SCHEDULING
L'obiettivo comune di multiprogrammazione e multitasking è quello di minimizzare lo spreco di
risorse da parte della CPU, eseguendo in maniera “parallela” più processi. Come già visto, i processi
che possono essere eseguiti sono raggruppati in apposite aree di memoria, e da queste vengono
prelevati, uno alla volta, per l'elaborazione. Ogni processo del sistema è inserito all'interno di una
coda di processi, mentre i processi presenti in RAM, e pronti all'esecuzione, sono posti in una coda,
detta “coda dei processi pronti” (ready queue). Un'ulteriore coda, utilizzata dal sistema operativo, è
la coda dei dispositivi di I/O, che comprende tutti i processi che, nel corso dell'elaborazione, hanno
necessitato dell'accesso ad un dispositivo condiviso, il quale era però già occupato da un altro
processo; questi processi attenderanno nella coda, fino a quando il dispositivo non sarà disponibile,
e successivamente riprenderanno la loro normale esecuzione.
Generalmente un processo pronto per essere eseguito si trova nella coda dei processi pronti, dove
resta in attesa, fino alla sua esecuzione (dispatched). Durante l'esecuzione può accadere che il
processo richieda l'utilizzo di un dispositivo di I/O occupato, e che quindi venga messo nella coda di
I/O, e dopo in quella dei processi pronti; può accadere anche che il processo generi uno, o più,
processi figli e debba attenderne la terminazione per poi essere posto nuovamente nella coda dei
processi pronti; infine può accadere che il processo in esecuzione giunga in una fase di attesa, e
quindi venga posto nuovamente nella coda dei processi pronti. Il processo generico segue questo
schema di esecuzione, fino alla sua terminazione, momento in cui tutte le risorse assegnate al
processo sono liberate.
Lo scheduling, ovvero il passaggio dell'esecuzione da un processo all'altro all'interno di una coda, è
effettuato dal sistema operativo, che si serve di uno scheduler; esistono tre tipi di scheduler:
scheduler a lungo temine (job scheduler), scheduler a breve termine (CPU scheduler), scheduler a
medio termine.
Capita che i processi da eseguire siano di più di quelli che possono essere eseguiti effettivamente,
quindi i processi superflui vengono spostati sulla memoria di massa; lo scheduler a lungo termine
sceglie quali processi caricare dalla memoria di massa a quella centrale, affinché siano eseguiti.
Questo scheduler lavora con una bassa frequenza (secondi o minuti), perciò può scegliere in
maniera accurata quali processi caricare in RAM; l'obiettivo è quello di caricare lo stesso numero di
processi che effettuano elaborazioni (sfruttano la CPU), e di processi che lavorano con dispositivi di
I/O, in modo da non sbilanciare l'utilizzo delle risorse da parte del sistema.
Lo scheduler a breve termine, invece, si occupa della scelta dei processi, presenti in RAM, la cui
esecuzione deve cominciare; questo scheduler lavora con un altissima frequenza, quindi deve
lavorare a velocità altissime.
Lo scheduler a medio termine è utilizzato per scambiare processi presenti in RAM, con processi
presenti in memoria di massa, a seconda delle necessità; questo procedimento è detto swapping, e
permette di porre momentaneamente un processo in memoria secondaria, per poi riporlo in memoria
centrale, facendo ripartire l'esecuzione dal punto in cui era stata interrotta.
• CONTEXT SWITCH
Sia la multiprogrammazione che il multitasking consentono di gestire contemporaneamente più
processi, elaborandoli alternativamente in base ad un criterio temporale, o a seconda delle
necessità; il passaggio delle risorse della CPU da un processo all'altro è detto context switch.
Nel momento in cui si verifica un'interruzione il sistema deve salvare il contesto del processo
corrente (contenuto nel blocco di controllo di un processo), poiché il processo dovrà poi essere
ripreso, in un secondo momento. Il contesto comprende i valori dei registri della CPU, lo stato del
processo, e le informazioni relative alla memoria. Il cambio di contesto, però, oltre a richiedere la
sospensione di un processo attivo, richiede che sia ripristinato un processo in attesa, caricando il
suo stato, precedentemente salvato nel PCB.
Il context switch provoca un calo delle prestazioni, ed il tempo impiegato per effettuarlo varia a
seconda del sistema operativo e dell'hardware a disposizione.
• PROGRAMMAZIONE MULTITHREAD
L'utlizzo di più thread che lavorano in maniera concorrente è la principale innovazione apportata dai
thread, ma questo tipo di esecuzione dei processi è consentito soltanto se sistema operativo e
programmi applicativi sono programmati conformemente ad esso.
Distinguiamo i thread in due gruppi, ovvero i thread a livello utente (gestiti senza l'aiuto del kernel)
ed i thread a livello kernel (gestiti dal sistema operativo); esistono diversi modi per mettere in
relazione, all'interno del sistema, i due tipi di thread.
Il modello “molti a uno” fa corrispondere molti thread utente ad un unico thread kernel ma non è
vantaggioso, poiché i thread utente possono accedere singolarmente al thread kernel, senza
garantire il parallelismo.
Il modello “uno a uno”, invece, fa corrispondere ad ogni thread utente un thread kernel, ciò consente
il parallelismo, ma compromette il sistema nel caso in cui si creino troppi thread kernel.
Il modello “molti a molti”, infine, fa corrispondere più thread utente con un numero minore o uguale di
thread kernel; questo modello è il più conveniente, in quanto non presenta nessuno dei problemi
degli altri due modelli, quindi, consente l'esecuzione concorrente sui sistemi multicore.
• Program Counter
• Puntatore all'area stack del thread
• Stato del thread (running, ready, waiting, start, done)
• Valore dei registri del thread
• Puntatore al Process Control Block (PCB) del processo cui il thread è associato
CAPITOLO 4 – SCHEDULING DELLA CPU
• ALGORITMI DI SCHEDULING
Il primo, e più semplice, algoritmo di scheduling da prendere in considerazione è lo scheduling first
come-first served (FCFS); questo algoritmo prevede che la CPU sia assegnata al processo che la
richiede per primo, e che gli altri processi vengano ordinati in una coda (FIFO) e attendano il
completamento del processo in esecuzione. L'algoritmo FCFS è senza prelazione, e ciò comporta
un tempo medio di attesa abbastanza lungo.
Un altro algoritmo utilizzato è lo scheduling shortest job first (SJF), secondo questo algoritmo, ad
ogni processo è associata la lunghezza della successiva sequenza di operazioni della CPU e,
quando la CPU è disponibile, essa è assegnata al processo (tra quelli pronti) con la lunghezza
minore. Questo algoritmo è ottimale, poiché minimizza il tempo di attesa medio, infatti, eseguendo
prima i processi con lunghezza della successiva sequenza di operazioni della CPU minima, si riduce
il tempo di attesa per i processi brevi, più di quanto non si allunghi quello per i processi lunghi. Il
problema dell'algoritmo SJF sta nel fatto che è difficile conoscere la lunghezza relativa ad ogni
processo, al massimo è possibile stimarla, sulla base delle lunghezze precedenti appartenenti allo
stesso processo. La lunghezza della successiva sequenza di operazioni della CPU è stimata dalla
formula:
τ n+1 = α tn + (1 - α) τn
La lunghezza della prossima sequenza sarà data dalla sequenza attuale e dall'insieme delle
sequenze passate; il parametro α, in base al valore che assume (0 < α < 1), indica il peso che ha la
sequenza attuale, e quello che ha l'insieme di tutte le sequenze passate, sulla stima della prossima
sequenza. L'algoritmo SJF può essere preemptive o non preemptive, nel caso in cui sia preemptive,
viene valutata la lunghezza rimanente del processo in esecuzione e, se questa è superiore alla
lunghezza prevista di uno dei processi pronti, il processo in esecuzione è sostituito con quello che si
trova nella coda dei processi pronti; lo scheduling SJF con prelazione è detto scheduling shortest
remaining time first.
L'algoritmo SJF costituisce, però, soltanto un caso particolare dello scheduling per priorità; secondo
questo algoritmo, si associa ad ogni processo una priorità e, di volta in volta, si assegna la CPU al
processo con priorità maggiore (in SJF la priorità è assimilabile all'inverso della lunghezza). Lo
scheduling per priorità può essere anche esso preemptive, o non preemptive; nel caso di scheduling
a priorità con diritto di prelazione, se un processo all'interno della coda dei processi pronti ha priorità
superiore a quella del processo in esecuzione, la computazione di quest'ultimo viene interrotta, e si
assegna la CPU all'altro processo. Un difetto dello scheduling per priorità è dato dall'attesa indefinita
(starvation), che si presenta se un processo a bassa priorità non viene mai eseguito, poiché è
surclassato da processi a priorità maggiore, sempre in arrivo nella coda dei processi pronti. Una
soluzione allo starvation è data dall'invecchiamento (aging) dei processi, che consiste
nell'aumentare gradualmente la priorità dei processi che stanziano da troppo tempo nelle code.
Un ulteriore tipologia di scheduling è lo scheduling circolare (round robin); l'idea di base è quella di
assegnare la CPU per una piccola unità di tempo (quanto) ai processi, in maniera circolare. Il tempo
massimo che ogni processo dovrà attendere, prima di terminare la propria esecuzione, è
determinato dal quanto, infatti, supponendo di avere n processi, e che il quanto sia pari a q, si ha
che:
tmax = (n – 1) q
Naturalmente il valore di q dovrà essere grande rispetto al tempo impiegato dal context switch,
altrimenti si eccederebbe con l'overhead.
L'ultima tipologia di scheduling presa in considerazione è lo scheduling a code multiple. I processi
sono suddivisi in gruppi, in base a caratteristiche che li differenziano, che sono diverse, a seconda
delle necessità; ogni gruppo di processi avrà poi a disposizione una propria coda, gestita da uno
degli algoritmi di scheduling visti precedentemente. L'insieme delle code deve essere a sua volta
gestito da un algoritmo di scheduling, il quale può essere a priorità fissa (alcune code sono gestite
prima di altre), anche se ciò comporta problematiche come lo starvation, o a divisione di tempo. Non
è previsto che un processo possa spostarsi da una coda all'altra, ma una variante dello scheduling a
code multiple, ovvero lo scheduling a code multiple con feedback, consente anche questa
possibilità, infatti, vengono definiti dei metodi che regolano il passaggio dei processi da una coda ad
un'altra; questo tipo di scheduling elimina buona parte delle problematiche che potrebbero verificarsi
nel semplice scheduling a code multiple senza retroazione.
Delle varianti degli algoritmi visti sono costituite, ad esempio, dal fair share scheduling, che prevede
la suddivisione nel tempo della CPU tra i diversi utenti, per poi rimandare, ad ogni utente, la
suddivisione della CPU, nel proprio arco di tempo, tra i processi da eseguire.
Un'altra variazione, necessaria agli algoritmi di scheduling preemptive, è costituita dall'inversione di
priorità. Può capitare che un processo a priorità alta si trovi ad attendere il rilascio di una risorsa
detenuta da un processo a priorità bassa; onde evitare queste situazioni di stallo, il processo a
priorità bassa eredità la priorità dal processo che è in attesa della risorsa, se questa è più alta, fino a
quando la risorsa non risulta nuovamente disponibile.
<<<<VARIABILE CONDIVISA>>>>
int turn = 0;
<<<<THREAD i-esimo>>>>
do {
while (turn != i)
;
esegui la sezione critica;
turn = j;
esegui la sezione non critica;
} while(1);
<<<<VARIABILE CONDIVISA>>>>
int flag[2], flag[1], flag[0];
flag[1]=flag[0]=1;
<<<<THREAD i-esimo>>>>
do {
flag[i]=1;
while(flag[j]==1)
;
esegui la sezione critica;
flag[i]=0;
esegui la sezione non critica;
} while(1);
Anche in questo caso, l'algoritmo soddisfa la mutua esclusione, ma in determinate occasioni causa uno stato
di deadlock e non garantisce la scelta di un thread che deve accedere alla sezione critica.
L'evoluzione dei due algoritmi visti, conduce alla soluzione di Peterson, questa soluzione prevede che, dati
due processi, i quali condividono le variabili “turn” (indica di quale thread sia il turno per eseguire la sezione
critica) e “flag[i]” (array che indica se il thread i-esimo è pronto per eseguire la propria sezione critica), ogni
thread entrerà nella propria sezione critica, al massimo, dopo un'entrata da parte dell'altro thread; questo
significa che sono garantiti sia il criterio di mutua esclusione, sia quello di sicura scelta di uno tra i thread in
attesa. In forma algoritmica, la soluzione di Peterson è la seguente:
<<<<VARIABILI CONDIVISE>>>>
int turn = 0;
int flag[2], flag[1], flag[0];
flag[1]=flag[0]=0;
<<<<THREAD i-esimo>>>>
do {
flag[i]=1;
turn=j;
while(flag[j]==1 && turn==j)
;
esegui la sezione critica;
flag[i]=0;
esegui la sezione non critica;
} while(1);
• SINCRONIZZAZIONE HARDWARE
Il problema della sezione critica potrebbe essere facilmente evitato utilizzando un “lucchetto” (lock)
per proteggere le sezioni critiche. In altre parole, sarebbe consentito soltanto ai processi che hanno
acquisito il lock di accedere alla propria sezione critica. L'utilizzo del lock, assieme a quello di
semplici istruzioni hardware, può costituire una buona soluzione al problema della sezione critica.
Nei sistemi monoprocessore, impedendo prelazione ed interruzioni, si potrebbe ottenere una
modifica dei dati condivisi sicura, poiché fatta in modo sequenziale; d'altra parte, lo stesso
approccio, utilizzato su sistemi multiprocessore, ridurrebbe di molto le prestazioni del sistema,
aumentando gli sprechi di tempo. Le moderne architetture permettono l'utilizzo di istruzioni per
controllare o modificare il contenuto di una word, in maniera atomica (non possono essere
interrotte), e ciò costituirebbe una soluzione al problema della sezione critica. Una delle possibili
istruzioni è la TestAndSet(); se vengono eseguite due TestAndSet() su unità di elaborazione
differenti, queste sono eseguite in maniera sequenziale e garantiscono la mutua esclusione,
servendosi della variabile condivisa “lock”:
<<<<TESTANDSET>>>>
boolean TestAndSet(boolean *obiettivo) {
boolean valore=*obiettivo;
*obiettivo=true;
return valore;
}
<<<<PROCESSO>>>>
do{
while(TestAndSet(&lock)
;
esegui la sezione critica;
lock=false;
esegui la sezione non critica;
} while(1);
Un'altra istruzione è la Swap() che scambia il contenuto di due word in memoria; come la TestAndSet(),
anche queste istruzioni sono eseguite in maniera sequenziale e garantiscono la mutua esclusione,
servendosi della variabile condivisa “lock”:
<<<<SWAP>>>>
void Swap(boolean *a, boolean *b) {
boolean temp=*a;
*a=*b;
*b=temp; }
<<<<PROCESSO>>>>
do {
chiave=true;
while(chiave==true)
Swap(&lock, &chiave);
esegui la sezione critica;
lock=false;
esegui la sezione non critica;
} while(1);
Sia la TestAndSet() che la Swap() offrono una soluzione che soddisfa la mutua esclusione, ma nessuna delle
due soddisfa il criterio dell'attesa limitata definita per l'accesso alla sezione critica.
Un algoritmo che, sfruttando l'istruzione TestAndSet(), garantisce il soddisfacimento di tutti i criteri, è
realizzato utilizzando due strutture condivise, ovvero un array “attesa[n]” e il lucchetto “lock”, entrambe
inizializzate al valore “false”. Secondo questo algoritmo, il processo i-esimo potrà entrare nella propria
sezione critica soltanto se si verifica che attesa[i]==false, o chiave==false; il valore della chiave potrà essere
falso soltanto se si effettua la TestAndSet(), quindi il primo processo ad eseguire tale istruzione avrà
chiave=false, mentre tutti gli altri dovranno attendere. Naturalmente, ogni volta, soltanto un elemento
dell'array attesa avrà valore falso, garantendo così la mutua esclusione; per quanto riguarda la scelta del
processo, tra molteplici processi, che dovrà accedere alla propria sezione critica, anche questa è garantita,
in quanto un processo che termina l'esecuzione della propria sezione critica, imposta lock al valore falso, o
attesa[j] al valore falso, consentendo ad uno dei processi in attesa di accedere alla propria sezione critica.
Poi, oltre a garantire mutua esclusione e progresso, questo algoritmo garantisce anche attesa limitata, in
quanto l'array attesa è scandito ciclicamente, quindi ogni processo dovrà attendere al massimo n-1 turni
prima di poter eseguire la propria sezione critica (n è il numero di processi in attesa). L'algoritmo è il
seguente:
<<<PROCESSO>>>
do {
attesa[i]=true;
chiave=true;
while(attesa[i]&&chiave)
chiave=TestAndSet(&lock);
attesa[i]=false;
esegui la sezione critica;
j=(i+1)%n;
while((j != i) && !attesa[i])
j=(j+1)%n;
if(j==i)
lock=false;
else
attesa[j]=false;
esegui la sezione non critica;
} while(1);
• SEMAFORI
Le soluzioni proposte per evitare il problema della sezione critica, utilizzando le istruzioni
TestAndSet() e Swap(), complicano di molto il lavoro dei programmatori; è dunque molto più
semplice utilizzare uno strumento di sincronizzazione detto “semaforo”, ovvero una variabile
condivisa, cui i processi accedono tramite le due istruzioni atomiche di wait() e signal(). La variabile
semaforo può essere di tipo intero (semaforo contatore) o booleano (semaforo binario), ed è utile
nelle applicazioni che prevedono l'accesso ad una risorsa condivisa, presente in un numero finito di
esemplari. Tramite la funzione wait() il processo che la invoca decrementa il valore del semaforo di
un'unità; viceversa, tramite la funzione signal() il processo che la invoca incrementa il valore del
semaforo di un'unità; nel caso in cui la variabile semaforo risulti vuota o piena, allora, i processi che
chiamano le funzioni di wait() o signal(), dovranno attendere che il contenuto del semaforo sia
nuovamente conforme ad eseguire l'operazione richiesta. Il semaforo è così programmato:
do{ wait(mutex);
esegui la sezione critica;
signal(mutex);
esegui la sezione non critica;
} while(1);
Una delle problematiche principali presentata dall'utilizzo dei semafori è quella dell'attesa attiva; tale
inconveniente si presenta ogniqualvolta un processo vuole accedere alla propria sezione critica e, avendo
già un altro processo cominciato ad eseguire la propria sezione critica, questo stanzia nel ciclo di codice
della sezione di ingresso, sprecando risorse della CPU. Un tipo di semaforo che non sopperisce al problema
dell'attesa attiva è detto “spinlock”. Una possibile soluzione al problema è ottenuta modificando le istruzioni
wait() e signal(), facendo in modo che, nella wait(), quando un processo deve attendere il rilascio della
risorsa condivisa, invece di girare a vuoto, si auto-sospenda tramite l'istruzione block(); il bloccaggio pone il
processo in una coda di attesa associata al semaforo, mentre la CPU passa all'esecuzione di un altro
processo. Per quanto riguarda l'istruzione signal(), questa dovrà occuparsi di avvertire i processi in coda,
dell'avvenuto rilascio della risorsa, in modo da risvegliarli, tramite l'istruzione wakeup(). Il semaforo, a questo
punto, può essere visto come una struttura dati, a cui vengono associati un valore intero ed una lista per i
processi in attesa:
typedef struct {
int valore;
struct processo *lista;
} semaforo;
Il semaforo terrà conto dei processi in coda, salvando un riferimento al process control block di ogni
processo, all'interno di una lista gestita, solitamente, con politica FIFO.
Un secondo problema che potrebbe presentarsi, con l'utilizzo dei semafori, è dato dalle situazioni di stallo;
tali situazioni si verificano qualora un processo, acquisita una risorsa condivisa, per rilasciarla, abbia bisogno
di acquisire una seconda risorsa condivisa, già acquisita da un altro processo che, a sua volta, attende il
rilascio della risorsa acquisita dal primo processo. Oltre a queste situazioni di stallo (deadlock) potrebbe
presentarsi anche il problema dell'attesa attiva (starvasion), già analizzato in precedenza.
• MONITOR
Le problematiche di sincronizzazione incontrate possono essere solamente in parte risolte per
mezzo dell'utilizzo di semafori, senza contare che un'errata programmazione di un semaforo,
andrebbe a causare un gran numero di errori, difficilmente rilevabili. Un'alternativa al semaforo è
data da costrutto monitor, un tipo di dato astratto che presenta un insieme di meccanismi, efficaci ed
efficienti, ai fini della sincronizzazione dei processi. La struttura del costrutto monitor è la seguente:
monitor nome_monitor {
variabili condivise
procedura P1 (…) {…}
procedura P2 (…) {…}
…
procedura Pn (…) {…}
inizializzazione (…) {…}
}
All'interno della struttura monitor, quindi, sono definite sia le variabili che le procedure, la cui visibilità è
limitata al monitor stesso; inoltre, il costrutto monitor assicura che, al proprio interno, possa essere attivo
solo un processo alla volta.
• ESEMPI DI SINCRONIZZAZIONE
Solaris utilizza, a seconda dei casi, semafori mutex adattivi (classici semafori spinlock con problema
dall'attesa attiva, utilizzati solo per processi che acquisiscono il mutex per poche istruzioni), variabili
condizionali (semafori con possibilità di auto-sospensione dei processi, utilizzati per processi lunghi),
lock di lettura-scrittura (consentono la lettura contemporanea dei dati condivisi ma obbligano
all'acquisizione del mutex per la scrittura, utilizzati per processi brevi), tornelli (utilizzati per gestire i
processi in attesa del rilascio del mutex, si assegna un tornello ad ogni thread kernel).
WindowsXP, come Solaris, utilizza semafori spinlocks per i processi brevi, mentre utilizza degli
oggetti detti dispatcher per la sincronizzazione dei thread fuori dal kernel; i dispatcher possono
operare sia come mutex e semafori, che come variabili condizionali o timer. Il dispatcher può trovarsi
nello stato signaled, se è possibile ai thread accedere alla risorsa condivisa, oppure nello stato
unsignaled, se ciò non è concesso e la risorsa è occupata.
Linux utilizza due diverse modalità per la sincronizzazione dei processi: per sincronizzazioni che
richiedono un breve periodo di lock, se la macchina è multiprocessore, vengono usati gli spinlock
(attesa attiva); altrimenti, se è monoprocessore, per attivare un lock l viene disabilitata la prelazione
al livello di kernel e riabilitata quando bisogna disabilitare il lock per un determinato thread. Nel caso
vi sia necessità di mantenere un lock attivo più a lungo, Linux utilizza i semafori.
PThreads sono un’insieme di API indipendenti, cioè non fanno parte di alcun kernel specifico. In
ambiente PThread la tecnica principale è il lock mutex. È presente l’uso in costrutti monitor e, in
alcune estensioni di PThread anche gli spinlock, sebbene non siano totalmente portabili su tutte le
architetture.
CAPITOLO 6 – STALLO DEI PROCESSI
• SITUAZIONI DI STALLO
Abbiamo già analizzato, nel precedente capitolo, in cosa consista una situazione di stallo, dicendo
che un gruppo di processi è in stallo qualora si trovi in attesa di una risorsa detenuta da un altro
processo in attesa. In maniera più formale, possiamo dire che si perviene ad una situazione di stallo
soltanto se si verificano contemporaneamente quattro condizioni:
La prima condizione è la “mutua esclusione”, ovvero almeno una risorsa deve essere non
condivisibile, e quindi i processi dovranno accedervi in maniera esclusiva; la seconda condizione è
quella di “possesso e attesa”, ovvero almeno un processo, in possesso di una risorsa (non
condivisibile), si trova in attesa di una risorsa (anche questa non condivisibile), detenuta da un altro
processo. La terza condizione è “impossibilità di prelazione”, ovvero non deve essere possibile
avere diritto di prelazione sulle risorse tenute dai processi, in mutua esclusione. Infine abbiamo la
condizione di “attesa circolare”, che si verifica quando, dati n processi (P, P1, P2, Pn), P attende una
risorsa acquisita da P1, P1 attende una risorsa aquisita da P2, e così via fino a Pn che attende una
risorsa aquisita da P.
E' possibile descrivere le situazioni di stallo, in maniera più intuitiva, servendosi del grafo di
assegnazione delle risorse. Questo particolare grafo, mediante l'uso di vertici e di archi orientati,
consente di definire le relazioni che legano processi e risorse. Dati, ad esempio, n processi (P, P1,
P2, …, Pn) ed n tipi di risorsa (R, R1, R2, …, Rn), collegheremo con un arco orientato, uscente dal
processo, i processi richiedenti e le risorse che questi hanno richiesto; analogamente, collegheremo
con un arco orientato, uscente dalla risorsa, le risorse ed i processi che le hanno acquisite. I
processi sono rappresentati tramite cerchi, mentre le risorse tramite rettangoli, inoltre, ogni risorsa
ha un certo numero di istanze, ognuna rappresentata mediante un punto all'interno del rettangolo;
quando un processo ha acquisito una risorsa, l'arco orientato corrispondente collega l'istanza della
risorsa al processo, al contrario, quando un processo richiede una risorsa, l'arco orientato collega il
processo e la risorsa, non l'istanza.
Una volta tracciato il grafo di assegnazione delle risorse, è possibile capire immediatamente se ci si
trova in una situazione di stallo; se il grafo non contiene cicli, allora nessun processo può trovarsi in
una situazione di stallo. Se ogni risorsa ha esattamente un'istanza ed è presente un ciclo, allora
sicuramente si avrà una situazione di stallo che coinvolge ogni processo nel ciclo. Se ogni tipo di
risorsa ha più di un'istanza, l'esistenza di un ciclo non implica per forza una situazione di stallo. In
conclusione possiamo dire che la presenza di un ciclo, nel sistema, è una condizione necessaria,
ma non sufficiente, allo stallo, viceversa, l'assenza di un ciclo nel grafo implica necessariamente
l'assenza di situazioni di stallo.
• SISTEMI MULTIPROCESSORE
Esistono differenti tipologie di sistemi multiprocessore, in alcuni le CPU condividono la memoria o il
clock (UMA, NUMA), in altri le CPU condividono la memoria e comunicano tramite messaggi, in altri
ancora le CPU non condividono la memoria e comunicano tramite la rete.
Il primo tipo di architettura che andiamo ad esaminare è quella UMA (Uniform Memory Access), in
cui i processi condividono la memoria centrale, a cui sono collegati tramite bus, ed ogni CPU
impiega lo stesso tempo per accedervi. In un sistema così strutturato ogni processo può leggere o
scrivere (load o store) un dato utilizzando la memoria condivisa, anche se ciò può provocare un calo
delle prestazioni, dato che tutti i processi utilizzano lo stesso bus (collo di bottiglia). Un
miglioramento delle prestazioni può essere ottenuto introducendo, per ogni processore, una
memoria cache locale, ed eventualmente una memoria privata, per le computazioni che non
riguardano gli altri processori. L'introduzione di memorie cache conduce comunque ad un altro
problema, quello dell'incoerenza dei dati, che va risolto con lo snooping del bus (si analizzano le
richieste provenienti dalle CPU alla memoria e si utilizzano protocolli di coerenza della cache).
Nonostante queste precauzioni, il singolo bus e la memoria limitata limitano le possibilità
dell'architettura UMA, quindi bisogna passare ad un sistema che relazioni CPU e memoria in modo
diverso. Una possibile soluzione è il modello a commutatori incrociati (crossbar switch) in cui ogni
CPU è potenzialmente connessa, tramite degli switch, a tutti gli indirizzi di memoria ed il
collegamento tra le due entità può chiudersi (sono effettivamente collegate), o meno. Il numero degli
switch necessari cresce quadraticamente, al crescere del numero di CPU, quindi il modello è
consigliato solo per sistemi con un numero non elevato di CPU. Nel caso in cui si disponga di molti
processori, si può utilizzare il modello con commutatori a più stadi, che prevede l'utilizzo di switch
bidirezionali, con due ingressi e due uscite, in modo che ogni ingresso possa essere rediretto su
ciascuna uscita. Utilizzando questo modello, i messaggi inviati dalla CPU alla memoria saranno
costituiti da quattro campi (Module – codice locazione di memoria e codice CPU, Address – indirizzo
all'interno del modulo, Opcode – operazione da effettuare, Value – valore cui applicare l'operazione);
gli switch analizzano il campo Module, per capire dove instradare il messaggio. Rispetto al modello
crossbar switch, quello con commutatori a più stadi prevede un numero di switch pari a (n/2)log 2n (n
è il numero di CPU) e, inoltre, è di tipo bloccante, quindi non permette a più CPU di operare
contemporaneamente su una stessa locazione di memoria; bisogna utilizzare altre tecniche per
garantire un elevato numero di CPU ed il parallelismo, limitando i conflitti.
Ciò che si può evincere da quanto detto fino ad ora, è che non si possono avere sistemi UMA con un
elevato numero di processori (al massimo 256), quindi per avere più unità di elaborazione passiamo
ad un altro tipo di architetture: i multiprocessori NUMA (Not Uniform Memory Access). Nei sistemi
NUMA vi è comunque memoria centrale condivisa, ma ogni CPU dispone di una propria memoria
locale, anche questa visibile da tutti i processori; l'accesso alla memoria locale è più veloce rispetto
all'architettura UMA, ed infatti, i programmi scritti per sistemi UMA, possono girare anche su sistemi
NUMA, semplicemente impiegando un tempo differente di esecuzione.
• INTRODUZIONE
Più o meno tutte le istruzioni eseguite dalla CPU coinvolgono le memorie del sistema. Come
sappiamo, la CPU comunica direttamente con i registri di memoria e con la memoria centrale, infatti
le istruzioni accettano soltanto indirizzi relativi ad aree della RAM, e non quelli che fanno riferimento
ai dischi. Affinché un insieme di istruzioni possa essere eseguito, quindi, questo deve essere prima
caricato in memoria centrale, ma nonostante ciò l'accesso alla RAM risulta comunque lento, rispetto
all'accesso ai registri. Per velocizzare l'accesso alla memoria, dunque, si interpone una memoria
cache (molto veloce) tra la CPU e la RAM.
Oltre a dover garantire una certa velocità di accesso, bisogna garantire la protezione del sistema
operativo e dei processi utente; ciò è possibile grazie all'utilizzo di due registri: il registro base, che
contiene il più piccolo indirizzo legale della memoria fisica, ed il registro limite, che contiene la
dimensione dell'intervallo di memoria ammesso. Registro base e registro limite garantiscono che sia
definito, per ogni processo, uno spazio di memoria separato, infatti la CPU, ad ogni tentativo di
accesso alla memoria da parte di un processo utente, confronta l'indirizzo cui il processo vuole
accedere, con l'insieme di indirizzi identificato dai due registri e, qualora quell'indirizzo non
appartenga allo spazio del processo, viene lanciata un'eccezione.
Tutti i programmi utente risiedono sul disco, in particolare, l'insieme dei programmi pronti per essere
caricati in memoria viene posto in una coda d'ingresso (input queue). Ogni programma, prima di
essere eseguito, attraversa le tre fasi di compilazione, caricamento ed esecuzione, durante le quali i
suoi indirizzi sono rappresentati in modi differenti; in una delle tre fasi si compirà l'associazione di
istruzioni e dati con gli indirizzi di memoria. L'associazione si può avere in fase di compilazione, se si
conosce già dove il processo risiederà in memoria; nel caso in cui non sia possibile effettuare
l'associazione in compilazione, viene generato codice rilocabile, e si può avere l'associazione al
termine della fase di caricamento. Nel caso in cui, durante l'esecuzione il processo potrebbe essere
spostato da un'area di memoria ad un altra, allora l'associazione deve essere effettuata in fase di
esecuzione, servendosi del supporto MMU (Memory Management Unit). Tutti gli indirizzi generati
dalla CPU sono indirizzi logici, mentre gli indirizzi veri e propri, in memoria, sono gli indirizzi fisici.
L'assegnazione effettuata nelle fasi di compilazione e caricamento genera indirizzi logici e fisici
uguali, al contrario, l'associazione in fase di esecuzione genera indirizzi logici diversi dagli indirizzi
fisici; i due indirizzi differenti vengono poi fatti corrispondere per mezzo del MMU che somma
l'indirizzo logico, generato dalla CPU, con il contenuto del registro di rilocazione (registro base), per
ottenere l'indirizzo fisico cercato.
• ALLOCAZIONE CONTIGUA
La memoria centrale deve contenere sia i processi utente, che il sistema operativo, quindi è divisa in
due parti; nella parte dedicata ai processi utente possono essere caricati più processi
contemporaneamente e, solitamente, questa allocazione è contigua, ovvero ogni processo è visto
come un unico blocco non divisibile. La memoria viene divisa in partizioni di uguale dimensione, ed
ogni partizione deve contenere un processo; inizialmente la memoria è vuota e, di volta in volta, vi si
caricano i processi presenti nella input queue (uno per partizione), dopodiché la memoria risulterà
avere un processo per ogni partizione, ma presenterà anche dei buchi (hole) di dimensione inferiore
alla partizione. A questo punto è possibile caricare in memoria i processi, tra quelli in coda, che
necessitano di una memoria uguale o inferiore alla dimensione dei buchi rimasti; per ogni processo,
la scelta del buco avviene secondo uno di questi criteri: secondo il criterio first-fit si assegna al
processo il primo buco trovato, che sia abbastanza grande, vi è poi il criterio best-fit in cui si
scorrono i vari buchi, fino a trovare il più piccolo che possa contenere il processo; infine secondo il
criterio worst-fit si scorrono i buchi, per trovare il più grande che possa contenere il processo.
Questo tipo di allocazione presenta il problema della frammentazione, che può essere interna,
quando si crea un buco di piccole dimensioni tra due processi, oppure esterna, quando un processo
si trova tra due buchi. Una possibile soluzione alla frammentazione esterna è costituita dalla
compattazione, che prevede di riordinare la memoria, in modo da unire tutti i buchi; la
compattazione, però, è ammissibile solo nel caso in cui l'assegnamento degli indirizzi sia stato fatto
in fase di esecuzione. Un'altra possibile soluzione al problema sarebbe quella di non utilizzare un
tipo di allocazione contigua della memoria.
• PAGINAZIONE SU RICHIESTA
Il concetto su cui si basa la paginazione su richiesta è quello di caricare in memoria soltanto le
pagine che sono effettivamente necessarie, assegnando il compito di caricare di volta in volta le
pagine ad un elemento del sistema operativo, il paginatore (pager). Il paginatore, prima di caricare
un processo in memoria, individua le pagine che saranno utilizzate e le carica in memoria;
naturalmente, una gestione di questo tipo si serve di un supporto hardware costituito dall'utilizzo di
un bit di validità, assegnato ad ogni pagina, che specifica se la pagina è presente in memoria,
oppure non è valida per quel processo, oppure è valida ma non è presente in memoria. Una
problematica che potrebbe scaturire dalla paginazione su richiesta si ha quando un processo tenta
di accedere ad una pagina non valida. In questo caso si verifica un fage fault e viene lanciata
un'eccezione, che può portare a due conclusioni, infatti, o il riferimento non era effettivamente valido
ed il processo viene terminato, oppure il riferimento era valido e la pagina mancante viene caricata
in memoria, riavviando poi il processo interrotto. Riavviare delle istruzioni semplici non è affatto un
problema, ma riavviare istruzioni che sovrascrivono locazioni di memoria costa la perdita di dati,
tempo, e quindi efficienza. Ci sono due possibili soluzioni al problema che consistono o
nell'assicurarsi che tutte le pagine che il processo richiederà siano in memoria, oppure servirsi di
registri temporanei che effettuino il backup dei dati sovrascritti, per poter riportare il sistema allo
stato iniziale, in caso di page fault.
Le prestazioni di un sistema con paginazione su richiesta risulteranno cento volte inferiori di quelli di
un sistema che ne è privo, infatti indicando con 0<p<1 la probabilità di page fault, avremo che il
tempo di accesso effettivo ha valore
(1-p)*tempo di accesso alla memoria + p*tempo di gestione page fault
in conclusione, per avere prestazioni accettabili bisogna avere un page fault per ogni milione di
accessi alla memoria.
Una possibile alternativa alla paginazione su richiesta è costituita dalla tecnica di copiatura su
scrittura, utilizzata nel caso in cui ci si serva dell'istruzione fork(). Come sappiamo, la fork() consente
di duplicare un processo, effettuando una copia dello spazio degli indirizzi di tale processo; spesso,
però, capita che la fork() sia seguita da una exec(), e ciò rende la copiatura essenzialmente inutile.
Con la copiatura su scrittura, le pagine sono inizialmente condivise tra il processo padre e tutti i
processi figli, e si effettua la copia di una pagina soltanto se uno dei processi (genitore o figlio) vuole
modificarla; bisogna sottolineare che soltanto le pagine per cui è consentita la modifica possono
essere copiate. Per la copia di una o più pagine bisogna attingere da un pool di pagine libere, e
l'allocazione avviene tramite l'azzeramento su richiesta (zero fill on demand), secondo cui le pagine
sono riempite di zeri (si cancellano tutti i dati precedenti) prima di essere utilizzate per la copiatura.
La paginazione su richiesta presenta notevoli vantaggi dal punto di vista dell'efficienza, ma può
presentare, in alcuni casi, il problema della sovrallocazione. Si ha sovrallocazione nel momento in
cui, essendosi verificato un page fault, il sistema operativo individua la locazione della pagina sul
disco, ma non può caricarla in memoria poiché i frame liberi sono terminati. Sono molteplici le
possibilità di risoluzione, ma la più indicata è la sostituzione delle pagine; la sostituzione prevede
che una pagina in memoria venga copiata nell'area di avvicendamento, per poi essere sostituita in
memoria dalla pagina che aveva causato il page fault. Il metodo della sostituzione, così descritto,
prevede due trasferimenti di pagine in memoria per ogni page fault, peggiorando notevolmente le
prestazioni, ma può essere migliorato, servendosi di un bit di modifica (dirty bit), che consenta di
distinguere le pagine modificate, che quindi dovranno essere copiate in memoria prima della
cancellazione, da quelle non modificate, che potranno essere cancellate direttamente.
Esistono molteplici algoritmi di sostituzione delle pagine, confrontabili in base alla frequenza di page
fault, rispetto ad una successione di riferimenti alla memoria. Il primo algoritmo che andiamo ad
esaminare è quello FIFO, che associa ad ogni pagina l'istante in cui questa è stata caricata in
memoria, così da sostituire, in caso di page fault, la pagina caricata da più tempo. L'algoritmo FIFO
garantisce uno scarso rendimento, inoltre può verificare l'anomalia di Belady, ovvero, all'aumentare
dei frame, aumenta il numero di page fault (normalmente dovrebbe diminuire). Un algoritmo
efficiente che non presenta l'anomalia di Belady è quello di sostituzione ottimale delle pagine,
secondo cui viene sostituita la pagina che non verrà utilizzata per il periodo di tempo più lungo.
Purtroppo, però, è impossibile conoscere la successione futura dei riferimenti effettuati da una
pagina, quindi l'algoritmo ottimale non viene utilizzato direttamente, ma se ne possono ricavare delle
approssimazioni. Anziché sostituire la pagina che non verrà utilizzata per più tempo, si può sostituire
quella che non è utilizzata da più tempo, utilizzando l'algoritmo LRU (Last Recently Used); servirà
realizzare un ordine per i diversi frame, ottenibile attraverso contatori, incrementati ad ogni
riferimento al frame, oppure pile, alla cui sommità si pone, di volta in volta, il processo utilizzato più
recentemente, in modo da collocare il processo utilizzato meno recentemente in coda. L'algoritmo
LRU non è molto conveniente, in quanto deve aggiornare costantemente la pila, o il contatore,
sovraccaricando così la gestione della memoria. Un miglioramento dell'algoritmo LRU può essere
costituito dall'aggiunta di un bit di riferimento, associato ad ogni pagina in memoria, che viene settato
ad ogni utilizzo della pagina, in modo da individuare le pagine utilizzate, senza specificarne un
ordinamento temporale. L'introduzione del bit di riferimento genera due algoritmi di sostituzione,
ovvero l'algoritmo con bit supplementari di riferimento, in cui l'insieme di bit di riferimento delle
pagine è registrato ad intervalli regolari in registri a scorrimento, e l'algoritmo con seconda chance.
L'algoritmo con seconda chance è una variante dell'algoritmo con bit supplementari di riferimento,
che, anziché utilizzare un registro a scorrimento, utilizza un unico bit; nel decidere quali frame
eliminare per far spazio in memoria, si valuta il bit di riferimento e, se questo è pari a 1, si azzera e
viene data una seconda chance al frame, se invece è pari a 0, il frame può essere spostato dalla
memoria centrale. L'algoritmo con seconda chance può essere migliorato utilizzando, oltre al bit di
riferimento, anche un bit di modifica, infatti, la coppia di bit ottenuta identificherà quattro diverse
classi cui i frame possono appartenere: (0, 0) frame non usato né modificato recentemente, (0,1)
frame non usato ma modificato recentemente, (1, 0) frame usato ma non modificato recentemente,
(1, 1) frame usato e modificato recentemente; a questo punto, si analizzano tutti i frame presenti in
memoria e si sostituisce quello con la coppia riferimento-modifica minore.
L'algoritmo di sostituzione può essere anche basato sul conteggio, utilizzando un contatore che salvi
il numero di riferimenti effettuati ad ogni pagina e, alternativamente, sostituisca le pagine meno
utilizzate (LFU), o quelle più frequentemente usate (MFU).
Un'ultima tecnica di sostituzione è la bufferizzazione delle pagine, che prevede la presenza di un
pool di frame liberi in cui si carichino i frame che devono essere scritti in memoria secondaria,
quando vengono deallocati, in modo da garantire una veloce ripartenza del processo, che non deve
attendere la scrittura del frame. Un vantaggio offerto dalla bufferizzazione è che, nel caso in cui un
frame spostato nel pool si riveli nuovamente utile, non c'è bisogno di un accesso alla memoria per
ricaricarlo.
• THRASHING
Come già detto un processo necessita di un numero minimo di frame, senza il quale non può
continuare la propria esecuzione. Secondo una politica di sostituzioni globale, un processo potrebbe
trovarsi in una situazione in cui non ha più il numero minimo di frame in memoria e non può caricare
altre pagine, se non sostituendo proprio i frame cui egli stesso fa riferimento; ciò porta il processo in
un loop in cui carica ogni volta pagine “essenziali”, sostituendo altre pagine “essenziali”, che subito
dopo dovranno essere ricaricate. Trovandosi in una situazione come quella appena indicata, il
processo spende più tempo per la paginazione che per l'esecuzione, questo è il thrashing. Il
thrashing riduce l'utilizzo della CPU, cui il sistema operativo cerca di sopperire aumentando il livello
di multiprogrammazione (comincia ad eseguire nuovi processi), ma ciò provoca l'aumento dei page
fault e, di conseguenza, altro thrashing. Al fine di evitare il thrashing, si possono assegnare ai
processi tutti i frame di cui necessitano, utilizzando tecniche quali il modello dell'insieme di lavoro
(working-set). Il working-set si basa sull'ipotesi di località, la quale stabilisce che un processo,
durante l'esecuzione, si sposta da una località ad un'altra (con località si intende l'insieme di pagine
utilizzate attivamente da un processo), e quindi è formato da più località che possono sovrapporsi. Il
thrashing si verifica qualora la dimensione della località superi il numero di frame disponibili. Il
modello working-set si serve di una finestra dell'insieme di lavoro Δ, che contiene i più recenti
riferimenti alle pagine da parte del processo (è un'approssimazione della località del processo); se
una pagina è contenuta in Δ, allora è in uso attiva, altrimenti si ritiene come non usata. Calcolando
la dimensione dell'insieme di lavoro (WS), sarà possibile stimare il numero di frame necessari al
processo (D), infatti si ha che D = Σ WS*Si . Se D risulta superiore al numero di frame disponibili, il
processo degenera, quindi si comincerà ad eseguire un processo, solo se D risulta superiore al
numero di frame disponibili, inoltre, se durante l'esecuzione di più processi la somma delle D dei vari
processi superasse il numero di frame disponibili, totali, uno dei processi andrebbe terminato.
Invece di basarsi sul modello working-set, si può facilmente prevenire la paginazione degenere
osservando la frequenza di page fault: se il tasso di page fault di un processo risulta basso, allora si
possono togliere frame al processo, viceversa, se il tasso di page fault è alto, bisogna assegnare
altri frame al processo; nel caso in cui un processo avesse un'elevata frequenza di page fault, ma
non ci fossero frame disponibili da assegnargli, allora il processo verrebbe sospeso.
• PREPAGINAZIONE
La prepaginazione è una tecnica utilizzata per evitare i page fault che si presentano quando un
processo viene avviato. La tecnica consiste nel caricare in memoria tutte le pagine utili al processo,
prima che questo le referenzi; ma non è sempre conveniente. La prepaginazione è conveniente
quando, dato s il numero delle pagine caricate ed αs il numero delle pagine effettivamente utilizzate
(con 0<α<1), αs > (1-α)s . Se non risulta tale relazione, allora la prepaginazione non conviene.
CAPITOLO 10 – INTERFACCIA DEL FILE SYSTEM
• FILE E DIRECTORY
Tutte le informazioni gestite da un calcolatore vengono memorizzate su un supporto fisico di
memoria. Il sistema operativo fa in modo da offrire all'utente una visione logica uniforme delle
informazioni presenti in memoria, astraendole in “file”. Un file è dunque una collezione di
informazioni correlate, conservate in memoria, cui viene assegnato un nome. Generalmente un file è
formato da una sequenza di bit, che hanno significato soltanto per il creatore e l'utente del file
stesso.
Affinché possano essere gestiti, ai file vengono assegnati degli attributi, quali un nome, un
identificatore che identifichi il file nel file system, un tipo, una locazione, una dimensione, delle
informazioni di protezione e, infine, anche informazioni sulla data di creazione, o di ultimo utilizzo;
tutte le informazioni relative ad un file sono contenute in una struttura, detta directory, anche questa
presente in memoria secondaria. Una directory contiene un insieme di elementi, ognuno dei quali
relativo ad un file (nome, id, tipo, …).
Per definire effettivamente un file, bisogna indicare le possibili operazioni che si possono eseguire
su di esso e, a questo scopo, il sistema operativo offre chiamate di sistema per ogni operazione
possibile sui file. Le operazioni più importanti, messe a disposizione del sistema operativo, sono
quella di creazione file (trova lo spazio in memoria e aggiunge un elemento alla directory), quella di
scrittura o lettura di un file (trova la locazione del file in memoria e utilizza un puntatore alla
successiva locazione di memoria da leggere, o scrivere), quella di riposizionamento in un file (uguale
a lettura e scrittura, ma parte da un punto specifico all'interno del file), quella di cancellazione file
(individua la locazione di memoria, la libera ed elimina l'elemento dalla directory) e, infine, quella di
troncamento file (cancella i dati di un file, ma mantiene gli attributi). Queste operazioni basilari
possono essere combinate per ottenere operazioni più complesse. Dal momento che la maggior
parte delle operazioni effettuabili sui file richiedono una ricerca in memoria, per evirare di ripetere
continuamente l'accesso alla memoria, per uno stesso file, viene messa a disposizione la funzione
open(), che al primo utilizzo di un file, sposta l'elemento ad esso associato (nella directory) nella
tabella dei file aperti. Il sistema operativo utilizza due livelli di tabelle interne per gestire i file aperti,
ovvero la tabella dei file aperti dal processo, relativa ad un unico processo, in cui si memorizzano
tutti i file aperti da quel processo stesso, e la tabella dei file aperti, cui ogni elemento delle tabelle dei
file aperti dal processo punta, in cui si memorizzano le informazioni, indipendenti dai processi,
relative ai file aperti. La tabella dei file aperti può anche essere dotata di un contatore il cui valore
aumenta o diminuisce, a seconda del numero di processi che aprono o chiudono il file
corrispondente; quando il contatore arriva a 0, la riga viene rimossa dalla tabella. Per ogni file
aperto, le tabelle dei file aperti e dei file aperti da processo tengono conto delle seguenti
informazioni: contatore di file aperti e posizione del file nel disco (tabella di sistema), puntatore
all'interno del file e permessi (tabella dei processi).
Alcuni sistemi offrono anche la possibilità ai processi di accedere ad un file in maniera esclusiva,
utilizzando un lock. Il lock può essere esclusivo, quindi un unico processo alla volta potrà acquisire il
file, oppure condiviso, quindi più processi concorrenti possono appropriarsi del file (es lock lettura);
inoltre il lock può essere obbligatorio (es in Windows), quindi il sistema operativo assicura la mutua
esclusione, oppure consigliato (es in Unix), quindi è compito dei programmatori garantire la giusta
acquisizione del lock da parte dei processi.
Il sistema operativo deve essere in grado di riconoscere e gestire i diversi tipi di file. Ciò è reso
possibile specificando il tipo internamente al nome del file (estensione), cosicché il sistema operativo
possa stabilirne il tipo attraverso il nome, e possa stabilire le operazioni eseguibili. Le estensioni
sono un consiglio anche per le applicazioni utente che operano su un determinato file.
Esistono molteplici metodi di accesso alle informazioni contenute in un file; principalmente abbiamo
l'accesso di tipo sequenziale, secondo cui le informazioni si elaborano in maniera ordinata, e
l'accesso di tipo diretto, secondo cui si accede direttamente all'elemento cercato, all'interno del file.
E' anche possibile, essendo in presenza di un accesso diretto, simulare un accesso sequenziale,
servendosi di un variabile (es cp), che memorizzi la posizione corrente. In realtà, avendo a
disposizione un metodo di accesso diretto, si possono ricavare altri metodi di accesso, utilizzando un
indice. Infatti, se consideriamo un file come un insieme di blocchi, e salviamo in un indice un
puntatore all'indirizzo iniziale di ogni blocco, durante una ricerca (ad esempio) ci basterà accedere
all'indice per individuare il blocco in cui si trova il dato cercato, e accedervi direttamente.
• PROTEZIONE
Un ruolo importantissimo è rivestito dalla protezione delle informazioni, facile in un sistema
monoutente, più difficile da realizzare in un sistema multiutente. In presenza di più utenti, il
proprietario (o il creatore) di file e directory dovrebbe controllare che solo determinati utenti
(specificati tramite ID) possano effettuare una serie di operazione sui file. Si identificano
principalmente tre tipi di utenti: il proprietario, ovvero colui il quale crea il file, il gruppo, ovvero un
insieme di utenti specificati, e il pubblico, ovvero l'utente generico; per ogni file devono essere
specificate le azioni consentite ad ogni tipo di utente (in UNIX si utilizzano 9 bit, 3 per ogni utente,
ognuno dei quali specifica i permessi di lettura r, scrittura w, ed esecuzione x; in Windows gli accessi
si gestiscono tramite interfaccia grafica).
CAPITOLO 11 – REALIZZAZIONE DEL FILE SYSTEM
• METODI DI ALLOCAZIONE
I metodi di allocazione specificano come i file vengano allocati nei dischi; solitamente si utilizza un
metodo di allocazione per ogni file system. I metodi di allocazione più utilizzati sono tre, ovvero
l'allocazione contigua, l'allocazione concatenata e l'allocazione indicizzata.
L'allocazione contigua prevede che ogni file sia visto come un unico elemento, e quindi debba
essere allocato in blocchi di memoria adiacenti tra di loro. L'allocazione contigua di un file è definita
semplicemente tramite l'indirizzo del primo blocco del file, sul disco, e dal numero di blocchi che
compongono il file. L'accesso ad un file allocato in modo contiguo può essere di tipo sequenziale,
memorizzando l'ultimo blocco cui si è acceduti, ed eventualmente accedendo a quello successivo;
ma può essere anche di tipo diretto, specificando il blocco a cui si vuole accedere, a partire dal
blocco base. L'allocazione contigua presenta comunque alcuni svantaggi, primo fra tutti quello
relativo alla frammentazione esterna, che porta ad uno spreco dello spazio, seguito dall'impossibilità
di crescita da parte di un file. Una possibile soluzione è realizzata allocando, per ogni file, un numero
di blocchi superiore al necessario, ma è palese che questo metodo sia poco efficiente; un'altra
soluzione è data dall'utilizzo di estensioni, ovvero pezzi di spazio libero contiguo, che vengono
assegnati ai file, qualora sia necessario (allocazione contigua modificata).
Un secondo metodo di allocazione è l'allocazione concatenata, che risolve in buona parte i problemi
causati dall'allocazione contigua, poiché consente che blocchi differenti di uno stesso file vengano
allocati in parti non necessariamente contigue del disco. La directory contiene, per ogni elemento, un
puntatore al primo blocco di ogni file, il quale, a sua volta, contiene un puntatore al blocco
successivo. I vantaggi di questa politica di allocazione sono evidenti, in quanto si evita la
frammentazione, ed è possibile estendere la dimensione di un file, senza problemi. Un certo numero
di vantaggi, però, corrisponde ad un altrettanto importante numero di svantaggi, infatti, l'allocazione
concatenata è efficiente se si vuole accedere ad un file in maniera sequenziale, mentre risulta molto
sconveniente qualora si voglia effettuare un accesso diretto (bisogna attraversare tutti i blocchi). Un
altro inconveniente è costituito dallo spreco di spazio che ogni blocco deve riservare alla
memorizzazione del puntatore al blocco successivo, anche se questa problematica può essere
risolta raggruppando i blocchi in cluster (conseguente frammentazione). Un'ultima problematica
riguarda l'affidabilità, infatti, dato che ogni blocco contiene un puntatore al blocco successivo, si
potrebbero verificare seri problemi in caso di perdita di un puntatore. Una possibile soluzione
sarebbe l'utilizzo di liste doppiamente concatenate o, ancora meglio, l'utilizzo di tabelle di allocazione
dei file (FAT). Le tabelle FAT hanno un elemento per ogni blocco del volume, vengono indicizzate
tramite il numero di blocco, e contengono, per ogni elemento, il numero del blocco successivo; ciò
garantisce anche la possibilità di un accesso diretto, nonostante l'aumento del numero di
posizionamenti richiesti alla testina.
L'ultimo tipo di allocazione è l'allocazione indicizzata, che riesce a sopperire a tutte le problematiche
viste fino ad adesso. Questo tipo di allocazione utilizza un blocco (blocco indice) contenente i
puntatori a tutti i blocchi di un unico file; sarà quindi possibile effettuare, una volta acceduti al blocco,
sia accessi di tipo sequenziale, che accessi di tipo diretto, infatti, l'i-esimo elemento del blocco indice
punta all'i-esimo blocco del file. La dimensione del blocco indice può provocare spreco di spazio,
qualora un file si serva di un numero ridotto di blocchi, quindi la dimensione del blocco indice deve
essere limitata; per file molto grandi, invece, questo può risultare un problema, risolvibile, però, in
diversi modi. Una prima soluzione consiste nel far in modo che ogni blocco indice possa
concatenarsi ad un altro blocco indice (schema concatenato), oppure si possono aumentare i livelli
di indicizzazione, creando blocchi di indici, i quali, a loro volta, puntano a blocchi contenenti altri
indici (si possono avere fino a quattro livelli di indicizzazione). Un'altra alternativa è data dallo
schema combinato, utilizzato anche in UFS, che consiste nel tenere, per ogni file, i primi 15 puntatori
nell'inode del file, facendo in modo che i primi 12 puntatori puntino a blocchi diretti (contengono
puntatori diretti ai file), mentre gli altri tre puntatori siano, rispettivamente, puntatori di blocco indiretto
singolo, doppio e triplo.
• EFFICIENZA E PERFORMANCE
L'efficienza del sistema dipende molto dagli algoritmi utilizzati per l'allocazione sul disco, dalla
gestione delle directory e anche dai dati che si decide di mantenere, relativamente ad ogni file,
all'interno delle directory.
Una volta scelto l'algoritmo di allocazione, le prestazioni potranno essere ulteriormente migliorate,
introducendo le disk cache, ovvero memorie ad accesso rapido, interne al disco, destinate a
contenere i blocchi maggiormente utilizzati, in modo da non dover riposizionare ogni volta la testina,
diminuendo, di conseguenza, il tempo di latenza. Altri sistemi utilizzano, invece, una page cache,
cioè una cache che memorizza pagine, anziché blocchi, utilizzando indirizzi virtuali al posto di quelli
fisici. Altri sistemi utilizzano la buffer cache del disco, ma poiché la memoria virtuale non può
interagire direttamente con la buffer cache, le informazioni dovranno passare sia attraverso la buffer
cache, che attraverso la cache delle pagine (double caching). Altri sistemi consentono di ottimizzare
gli accessi sequenziali attraverso le tecniche del rilascio indietro (free-behind), che rimuove una
pagina dalla memoria quando si accede a quella successiva, e della lettura anticipata (read ahead),
che carica nella cache la pagina richiesta ed alcune pagine successive a questa.
CAPITOLO 12 – MEMORIA SECONDARIA E TERZIARIA
• STRUTTURA DEI DISPOSITIVI DI MEMORIZZAZIONE
Si è detto che il file system, da un punto di vista logico, si può considerare composto da tre parti:
l’interfaccia per il programmatore e l’utente, le strutture e gli algoritmi usati dal sistema operativo per
realizzare tale interfaccia e la struttura dei supporti di memorizzazione secondaria e terziaria. Dopo
aver già analizzate le prime due, passiamo a descrivere la terza parte.
I dischi magnetici sono il mezzo di memoria secondaria più diffuso. I piatti, di cui è composto il disco,
hanno una forma piana e rotonda (CD) e le due superfici sono ricoperte di materiale magnetico; le
informazioni si memorizzano magneticamente su piatti. Le testine di lettura e scrittura sono sospese
su ciascuna superficie d’ogni piatto e sono attaccate al braccio del disco che le muove in blocco. La
superficie di un piatto è suddivisa logicamente in tracce circolari, a loro volta suddivise in settori;
l’insieme delle tracce corrispondenti a una posizione del braccio costituisce un cilindro. Quando un
disco è in funzione, un motore lo fa ruotare ad alta velocità. L’efficienza di un disco è caratterizzata
da due valori: la velocità di trasferimento (cioè la velocità con cui i dati confluiscono dal disco al
calcolare) e il tempo di posizionamento, che consiste del tempo di ricerca (cioè il tempo necessario
a spostare il braccio in corrispondenza del cilindro desiderato) e del tempo di latenza di rotazione
(cioè il tempo necessario affinché il settore desiderato si porti, tramite rotazione, sotto la testina).
Esistono dischi rimovibili (es. floppy disk). Il trasferimento dei dati è gestito dai disk controller, che
comunicano tramite bus con il controller del computer (host controller).
I nastri magnetici sono stati i primi supporti di memorizzazione secondaria. Sono caratterizzati da un
elevato tempo d’accesso rispetto a quello dei dischi magnetici, ma possono contenere grandi
quantità di dati (anche TB). Il loro uso principale è quello di conservare copie di backup e trasferire
dati tra diversi sistemi. Il nastro è avvolto in bobine e scorre su una testina di lettura e scrittura. Essi
si classificano a seconda della larghezza del nastro.
Dal punto di vista logico, i dischi sono visti come grandi array monodimensionali di blocchi logici, la
cui dimensione è 512 byte per blocco (può essere maggiore). Viene trasferito un blocco alla volta.
L’associazione con un array monodimensionale fa pensare che sia possibile attribuire ogni settore di
ogni traccia del disco con un indice dell’array, al fine di gestire molto più rapidamente la
corrispondenza tra indirizzi fisici e logici, ma ciò non è possibile in pratica perché in un qualsiasi
disco sono sempre presenti molti settori difettosi.
• STRUTTURE RAID
I RAID (redondant array of indipendent [inexpensive] disks) sono strutture composte da più dischi
indipendenti (in passato costavano di meno di un solo disco più capiente, da cui inexpensive), nelle
quali è possibile memorizzare una grande quantità di dati. Vengono usati soprattutto per questioni di
affidabilità: è possibile infatti copiare i dati in più dischi; se un disco viene danneggiato, i dati
potranno essere recuperati sfruttando la loro ridondanza. Una di queste tecniche è la copia
speculare, la quale prevede di copiare ogni dato identicamente su due dischi diversi. Tale procedura
implica un tempo di scrittura doppio, se non si può accedere parallelamente ai dischi. Accedendo
parallelamente ai dischi si possono inoltre ridurre i tempi di lettura dei dati (l’accesso alle memorie
viene fatto in modo concorrente, ma devono essere letti e trasferiti la metà dati per disco). Al posto
della copia speculare, è possibile partizionare la scrittura (e la conseguenza lettura) dei dati su
diversi dischi. Quest’altra tecnica è detta sezionamento dei dati.
Sono stati pensati diversi schermi per l’affidabilità. Essi sono i livelli RAID. I più importanti sono il
livello 0, 1, 0+1 e 1+0, una variante del precedente.
LIVELLO 0: batterie di dischi con sezionamento a livello dei blocchi, ma senza ridondanza.
LIVELLO 1: batterie di dischi con copia speculare, quindi si necessità del doppio dei dischi.
LIVELLO 2: batterie di dischi dotate di controllo sugli errori con tecniche di parità sui bit (processo
lento).
LIVELLO 3: batterie di dischi dotate di controllo sugli errori con tecniche di parità intercalate sui bit
(migliorie rispetto al livello 2 ma il controllo sulla parità rimane comunque un processo lento).
LIVELLO 4: batterie di dischi dotate di controllo sugli errori con tecniche di parità intercalate sui bit e
tecniche di sezionamento a livello dei blocchi, che migliora le prestazioni in lettura.
LIVELLO 5: batterie di dischi dotate di controllo sugli errori con tecniche di parità intercalate
distribuite sui bit e tecniche di sezionamento a livello dei blocchi, che migliora le prestazioni in
lettura. Nei livelli 3 e 4 solo un disco è adibito al controllo di parità dei bit. In questo livello, che è
anche il più diffuso, tutti i dischi si occupano di effettuare controlli sulla parità, riducendo il carico di
lavoro su un solo disco.
LIVELLO 6: batterie di dischi dotate di controllo sugli errori con tecniche di parità intercalate
distribuite sui bit e tecniche di sezionamento a livello dei blocchi. Si usano anche delle informazioni
ridondanti in ogni disco, al fine di garantire la gestione dei dati nel caso di guasto di più dischi
contemporaneamente. Si avvale anche dei codici Reed-Salomon per la correzione degli errori.
LIVELLO 0+1: combina le prestazioni del livello 0 con l’affidabilità del livello 1; utilizza il doppio dei
dischi come il livello 1. Per questo motivo costa più del livello 5, ma offre prestazioni notevolmente
migliori.
LIVELLO 1+0: combina le prestazioni del livello 0 con l’affidabilità del livello 1; utilizza il doppio dei
dischi come il livello 1. Si effettuano prima le copie speculari e poi, avendo ottenuto 2n dischi si
procede al trasferimento dei dati tramite sezionamento a livello dei blocchi. Se un blocco si
danneggia è presente l’altra copia speculare. Offre quindi più affidabilità.
• INTERRUZIONI
La CPU ha un contatto, detto linea di richiesta dell’interruzione, del quale la CPU controlla lo stato
dopo l’esecuzione di ogni istruzione. Quando rileva il segnale di un controllore nella linea di richiesta
dell’interruzione, la CPU salva lo stato corrente e salta alla routine di gestione dell’interruzione, che
si trova a un indirizzo prefissato di memoria. Questa procedura determina le cause dell’interruzione,
porta a termine l’elaborazione necessaria e ritorna poi a completare la computazione del processo
interrotto per servire l’interrupt. Il meccanismo degli interrupt permette di gestire eventi asincroni, ma
per i sistemi operativi moderni sono necessari metodiche più raffinate: 1. Permettere la posposizione
dell’interruzione quando la CPU è in una fase critica di elaborazione (altre interruzioni, gestione
errori importanti, etc), 2. Non permettere il polling (interrogazione ciclica) per comprendere quale sia
il dispositivo causa dell’eccezione. 3. Introduzione del sistema delle interruzioni con priorità (le
interruzioni a priorità più bassa possono essere interrotte da quelle a priorità più alta).
Ai due livelli di priorità corrispondo due linee di richiesta delle interruzioni: interruzioni mascherabili
(priorità bassa) e interruzioni non mascherabili (priorità alta). Queste ultime, a differenza delle prime,
non possono essere interrotte. Le interruzioni vengono gestite tramite un vettore delle interruzioni, il
quale è una tabella che permette di riconoscere abbastanza velocemente il dispositivo che ha
generato l’interrupt.
Tale meccanismo viene inoltre usato per gestire errori come le eccezioni (come divisioni per zero,
accessi illegali alla memoria, etc) e in generale per notificare l’avvenuta di eventi asincroni, per i
quali la CPU deve eseguire urgentemente codice di gestione autonomo. Anche le chiamate di
sistema (trap) si avvalgono di questo sistema, anche se costituiscono interruzioni di bassa priorità.
Il DMA (Direct memory access) è uno speciale processo di I/O mappato in memoria per garantire, in
modo efficiente e veloce, il trasferimento di grandi quantità di dati in dispositivi di memorizzazione.
Tutto ciò viene eseguito senza l’ausilio del processore, impiegando, quindi, un processore special
purpose appositamente progettato per tale funzionalità.