Sei sulla pagina 1di 38

ARCHITETTURE E SISTEMI OPERATIVI

CAPITOLO 0 – INTRODUZIONE

• DEFINIZIONE DI SISTEMA OPERATIVO


Sono quattro le principali componenti che hanno rilevanza quando si parla di calcolatori elettronici,
ovvero l'insieme di componenti hardware, i programmi applicativi, gli utenti ed il sistema operativo. I
programmi applicativi, per mezzo dell'hardware, consentono agli utenti di risolvere problematiche di
tipo computazionale; il sistema operativo si occupa di coordinare e gestire le risorse hardware
disponibili, assegnandole in maniera efficiente ai diversi programmi applicativi.
Le funzionalità del sistema operativo variano a seconda delle necessità; sistemi operativi presenti su
dispositivi mobili implementeranno aspetti diversi, rispetto a sistemi operativi presenti su personal
computer (ad esempio il risparmio della batteria).
Non esiste una definizione universalmente condivisa di “Sistema Operativo”, ma si può dire che
questo sia l'anello di congiunzione tra l'hardware ed i programmi applicativi; alcuni sostengono,
invece, che il sistema operativo sia il programma che è sempre in esecuzione sul calcolatore (in
realtà solo una parte del sistema operativo è sempre in esecuzione, il kernel).

• ORGANIZZAZIONE DEL SISTEMA OPERATIVO


Le unità che fanno parte del calcolatore elettronico sono la CPU, la memoria, il bus di sistema ed i
dispositivi di I/O; ogni dispositivo di I/O è collegato ad un controllore, ogni controllore può gestire uno
o più dispositivi e lavora in maniera concorrente con la CPU e con gli altri controllori.
Al momento dell'avvio del sistema (o del riavvio) viene lanciato un programma di avviamento detto
“Bootstrap”, il quale carica in memoria il sistema operativo e ne avvia l'esecuzione; il sistema
operativo, quindi, lancia il primo processo (init) e si pone in attesa di un evento, che può essere
un'interruzione, un'eccezione o una chiamata di sistema. Una volta ricevuta l'interruzione, la CPU
abbandona l'elaborazione corrente e, identificato l'evento, scorre una tabella che associa ad ogni
segnale di interruzione l'indirizzo a cui si trova il codice da eseguire in risposta a questo, quindi,
esegue il codice e ritorna all'elaborazione interrotta precedentemente, il cui indirizzo era stato
salvato momentaneamente in area stack.
Tutti i programmi da eseguire devono essere contenuti in memoria centrale, con la quale la CPU si
relaziona tramite operazioni di load e store; poiché la RAM non gode di grande capienza e di
persistenza, non tutti i programmi possono esservi presenti contemporaneamente, infatti, tutti i
programmi risiedono sulla memoria fissa del calcolatore (disco), che ne garantisce la persistenza e
carica di volta in volta le informazioni in memoria centrale. Esiste in realtà una gerarchia molto più
fitta per quanto riguarda le memorie che può comprendere diversi livelli in più, oltre alla RAM e al
disco fisso.
Per quanto riguarda i dispositivi di I/O, abbiamo già detto che sono collegati a controllori, i quali si
occupano di una parte dell'elaborazione e memorizzano i dati in aree di memoria proprie dette
buffer; quando ci sono informazioni da trasferire al calcolatore, viene generata un'interruzione, quindi
il sistema operativo, interfacciandosi tramite i driver con il controllore, esegue le operazioni
richieste. Viene generata un'interruzione ogni volta che ci sono informazioni da trasferire, ma se la
quantità di informazioni è ingente, viene generato un unico interrupt e si effettua un accesso diretto
alla memoria (DMA), senza passare per la CPU.

• ARCHITETTURA DEI CALCOLATORI


I calcolatori si differenziano per molti aspetti, uno di questi è il numero di unità di elaborazione
presenti nel sistema.
Esistono, infatti, sistemi monoprocessore, che utilizzano un'unica unità di elaborazione principale
(esegue istruzioni di natura generale), e un insieme di sottoprocessori (eseguono operazioni
specifiche, legate ai processi utente); ciò alleggerisce il carico di lavoro della CPU e migliora
l'efficienza.
Troviamo, poi, anche sistemi multiprocessore, che utilizzano più processori, i quali comunicano tra
loro e condividono le risorse del sistema (bus, memoria, clock, I/O); ciò comporta un notevole
miglioramento del throughput, un risparmio di risorse, e una maggiore affidabilità. Il sistema
operativo, in questo caso, si preoccupa di gestire la concorrenza tra le diverse unità di elaborazione,
che sicuramente proveranno ad accedere contemporaneamente alle risorse condivise. I sistemi
multiprocessore possono essere suddivisi tra simmetrici, ovvero i processori si trovano tutti allo
stesso livello e sono gestiti dal sistema operativo (ciò permette di eseguire molte azioni in maniera
concorrente), e asimmetrici, ovvero i processori si trovano in una condizione di subordinazione
gerarchica, rispetto ad un processore “capo”, che impartisce i diversi compiti e si preoccupa della
gestione dell'intera elaborazione.
Un'ultima tipologia è costituita dai sistemi multi-core, che utilizzano più unità di elaborazione,
montate su un unico chip; ciò aumenta le prestazioni, in quanto le informazioni viaggiano ad una
velocità maggiore, trovandosi sulla stessa circuiteria, ed inoltre diminuisce il consumo di energia, dal
momento che a necessitare di alimentazione non sono più molti processori, ma soltanto uno che li
ingloba tutti. La necessità di ideare sistemi multi-core deriva dalle difficoltà incontrate dai progettisti
nel voler migliorare le prestazioni, aumentando la frequenza di clock; questo comportava, infatti, un
elevato dispendio di energia, ed una conseguente impossibilità di dissipare l'eccessivo calore.
Altre innovazioni, degne di nota, sono i “Server Blade”, ovvero sistemi che alloggiano in un'unica
cella la scheda madre, la scheda di rete e le schede di I/O; ogni insieme, così composto, esegue le
proprie elaborazioni, indipendentemente dagli altri server blade che compongono il sistema. Oltre ai
server blade, troviamo anche i sistemi “Cluster”, ovvero sistemi composti da due o più calcolatori che
lavorano allo stesso problema; i calcolatori sono collegati da una rete veloce e possono essere
collegati in maniera simmetrica (tutti allo stesso livello, si scambiano informazioni) o asimmetrica (un
calcolatore controlla il lavoro degli altri, ed interviene in caso di malfunzionamento). I sistemi cluster
costituiscono l'architettura più efficiente, tra quelle presentate, ma è necessario che un programma
sia appositamente scritto per questi sistemi, per poter essere elaborato.

• STRUTTURA SISTEMA OPERATIVO


Concetti chiave che bisogna specificare quando si parla di sistema operativo sono la
“Multiprogrammazione” ed il “Multitasking”. La multiprogrammazione parte dall'idea secondo cui la
CPU non è mai costantemente impegnata in attività di elaborazione, perciò, qualora durante
un'elaborazione il sistema dovesse trovarsi davanti ad un qualunque tipo di attesa, questo passerà
all'elaborazione successiva, ritornando ad occuparsi del problema “abbandonato” nel momento in cui
l'attesa sia terminata. A questo scopo, il sistema operativo istanzia un'apposita area in memoria fissa
detta “job pool”, nella quale risiedono tutti i programmi in attesa di esecuzione; una parte di questi
programmi viene caricata in memoria centrale, e la CPU, coordinata dal sistema operativo, si
alterna, per il principio di multiprogrammazione, nell'esecuzione di questi programmi.
Il multitasking è invece la suddivisione del tempo di elaborazione dedicato ad ogni singolo
programma; secondo questo principio, la CPU dedica un tempo prefissato all'esecuzione di ogni
processo in memoria, ma la frequenza di elaborazione è talmente veloce da consentire all'utente di
interagire in tempo reale con i programmi, senza che questo si accorga di nulla. Il sistema operativo
si occupa dello scheduling dei processi e dello swapping (scambiare programmi tra la memoria RAM
ed il disco fisso, a seconda delle necessità di elaborazione).

• ATTIVITA' SISTEMA OPERATIVO


Come già detto in precedenza, il sistema operativo, in assenza di interruzioni, eccezioni, o chiamate
di sistema, risiede in uno stato di quiete; al verificarsi di uno di questi tre eventi, il sistema operativo
risponde con l'azione adeguata. Bisogna assicurarsi, però, che un errore provocato da un
programma utente non arrechi alcun danno agli altri programmi, ed il sistema operativo deve
occuparsi anche di questo. Vengono definite per il sistema due diverse modalità di funzionamento, la
“modalità kernel” e la “modalità utente”; per indicare quale della due modalità sia attiva, la CPU è
dotata di un bit (bit mode) che specifica se l'istruzione corrente sia eseguita dal sistema operativo (bit
0), o da un programma applicativo (bit 1). Inizialmente il bit mode è posto a 0, il sistema operativo lo
setta nel caso si debbano eseguire dei processi e, ogni volta che si verifica un'interruzione o una
syscall, il bit mode è resettato ed il controllo è trasferito di nuovo al sistema operativo, il quale si
occupa della gestione dell'interruzione.
Questa modalità di funzionamento, detta “dual mode” riesce a tenere separate le azioni ammissibili
per il solo sistema operativo (istruzioni privilegiate), e quelle ammissibili per l'utente, onde evitare
alcun tipo di problema.
Un altra problematica, che può facilmente verificarsi, si ha quando il programma utente entra in un
ciclo infinito (loop infinito) e non richiede più servizi al sistema operativo; se ciò accade, il sistema
operativo predispone un timer (hardware) e, trascorso un determinato lasso di tempo, viene
generata un'interruzione che restituisce il controllo al sistema operativo, il quale, a sua volta, può
interrompere il processo in questione, o gestire il problema in modo diverso.

• GESTIONE RISORSE DA PARTE DEL SISTEMA OPERATIVO


Analizziamo per prima cosa la gestione dei processi da parte del sistema operativo; il sistema
operativo si occupa di creazione e cancellazione dei processi, quindi assegna le risorse necessarie
all'esecuzione di un determinato processo, quando questo viene creato, e le rilascia nel momento in
cui la sua esecuzione termina. Si occupa poi della sospensione e del ripristino dei processi,
conformemente alla multiprogrammazione e al multitasking; sincronizza e mette in comunicazione
tra di loro i processi e fornisce meccanismi per risolvere situazioni di deadlock.
Analizziamo poi la gestione della memoria; il sistema operativo si occupa di tener traccia delle parti
di memoria centrale occupate dai programmi e, al termine di un'esecuzione, segnala che quell'area
di memoria è disponibile per caricarvi il programma successivo. Decide poi quali processi caricare in
memoria e quali trasferire, e alloca, o dealloca, lo spazio a seconda delle necessità.
Per quanto riguarda la gestione dei file, il sistema operativo astrae le proprietà fisiche dei dispositivi
hardware in unità logiche dette “file”. Il sistema operativo deve essere in grado di creare e cancellare
file su cui memorizzare informazioni; creare e cancellare le directory, in cui i file sono organizzati e
fornire le funzioni fondamentali per gestirle. Deve, inoltre, creare copie di riserva dei file (file di
backup) e memorizzarle su dispositivi di memorizzazione non volatili.
Passiamo ora alla gestione della memoria di massa; il sistema operativo deve tenere traccia dei dati
che occupano la memoria e deve effettuare lo scheduling del disco.
Il sistema operativo si trova a dover gestire anche la memoria cache, ovvero una memoria alla quale
si accede molto più velocemente, rispetto alla RAM e alla memoria centrale; questa contiene le
informazioni che (presumibilmente) saranno utilizzate immediatamente, senza doverle andare a
caricare nuovamente dalla memoria centrale (o peggio dal disco fisso). Il sistema operativo si
occupa del passaggio delle informazioni dalla RAM alla cache, ma non si occupa del passaggio delle
informazioni dalla cahce ai registri della CPU.
In relazione ai dispositivi di I/O, il sistema operativo deve nascondere le caratteristiche dei dispositivi
fisici all'utente finale; deve gestire la memoria (buffer), la cache ed i driver.

• 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

• SERVIZI DEL SISTEMA OPERATIVO (UTENTE GENERICO)


I servizi che il sistema operativo mette a disposizione sono molteplici, si parte dall'interfaccia con
l'utente, la quale può essere un'interfaccia a riga di comando (CLI), oppure un'interfaccia grafica
(GUI); un'altra funzionalità disponibile è quella che permette di eseguire programmi, dopo averli
caricati in memoria, tenendo conto di possibili errori, o eccezioni. Inoltre, il sistema operativo deve
fare da intermediario tra l'utente ed i dispositivi I/O, rilevare errori per risolverli, e fare in modo che i
diversi processi siano in grado di comunicare tra loro per mezzo della rete. Una delle funzionalità
principali più importanti che il sistema operativo deve svolgere è, però, la gestione del filesystem; i
programmi, infatti, lavorano facendo largo uso di file e cartelle (lettura, scrittura, ricerca, etc.), quindi
il sistema operativo mette a disposizione diverse modalità per gestire ed organizzare questi dati
(filesystem).
Ulteriori servizi che il sistema operativo offre, non legati all'utente, ma al funzionamento generale del
calcolatore sono ad esempio l'assegnamento delle risorse (già visto), che assegna in maniera
efficiente le risorse condivise a processi che vengono eseguiti contemporaneamente; la
contabilizzazione delle risorse, ovvero il tener traccia delle operazioni effettuate da ogni processo, o
da ogni utente, e infine i servizi di protezione e sicurezza.
Esaminando più nello specifico i tipi di interfaccia utente, si può dire che l'interfaccia a riga di
comando consente all'utente di comporre comandi testuali, interpretati da un programma compreso
nel kernel; quando i programmi interpreti sono molteplici, e quindi è possibile scegliere quale
utilizzare, questi vengono detti “shell”. Solitamente è lo stesso interprete a contenere il codice per
l'esecuzione dei comandi, quindi la sua dimensione è proporzionale al numero di comandi ammessi;
un altro tipo di interprete, invece, accede ad un file caricato in memoria, per ottenere il codice
corrispondente ad un determinato comando (non ne capisce il significato, utilizza soltanto il nome).
L'interfaccia grafica, a differenza della CLI, è uno strumento user-friendly e presenta all'utente, in
modo grafico, l'insieme delle risorse presenti sul calcolatore, affinché questo possa gestirle,
utilizzando strumenti quali il puntatore mouse.

• SERVIZI DEL SISTEMA OPERATIVO (PROGRAMMATORE)


L'interfaccia tra il sistema operativo ed i processi (programmi) è data dalle sole chiamate di sistema
(syscall). Anche per operazioni semplicissime, quali ad esempio la trascrizione di alcuni dati da un
file ad un altro, le chiamate di sistema sono numerosissime e costituiscono uno strumento basilare
per il funzionamento dei programmi utente. I programmatori, però, non sempre utilizzano in maniera
diretta le chiamate di sistema nel progettare un'applicazione; essi sfruttano un'interfaccia per la
programmazione, detta API (Application Programming Interface), che mette a disposizione del
programmatore un insieme di funzioni, le quali invocano a loro volta le chiamate di sistema. Le API
maggiormente utilizzate sono la API Win32 (Windows), la API POSIX (POSIX), e la API Java (JVM).
E' preferibile, per il programmatore, utilizzare l'intermediazione delle API, poiché queste
garantiscono una maggiore portabilità dei programmi; senza contare che eliminano del tutto le
difficoltà legate alla gestione diretta delle chiamate di sistema. Il sistema operativo si serve di un
sistema di supporto all'esecuzione (run-time support system), che traduce le funzioni delle API nelle
syscall corrispondenti, per poi restituire al programma utente il risultato delle elaborazioni del
sistema operativo. I parametri sono solitamente passati dalle API al sistema operativo, per mezzo di
registri; se i parametri eccedono la dimensione dei registri, viene passato un indirizzo di memoria,
indicante la locazione in cui sono contenuti i parametri, o ancora si utilizza una pila in cui il
programma immette i parametri, che saranno poi prelevati dal sistema operativo.
Le principali tipologie di chiamate di sistema sono per il controllo dei processi, per la gestione dei
file, per la gestione dei dispositivi, per la gestione delle informazioni e per le comunicazioni.

• REALIZZAZIONE DEL SISTEMA OPERATIVO


Non esiste una maniera univoca di progettare un sistema operativo; tutto dipende dalla struttura
hardware di base, e dalle finalità che il sistema operativo deve avere. Dal punto di vista dell'utente, il
sistema operativo deve essere facile da utilizzare, da imparare, sicuro e veloce; dal punto di vista del
progettista, invece, il sistema operativo deve essere facile da progettare e manutenere, flessibile,
affidabile, efficiente, e privo di errori.
Un principio essenziale, in fase di progettazione, prevede la separazione tra i concetti di
“meccanismo” e “criterio”; un meccanismo determina come eseguire qualcosa, un criterio indica
cosa eseguire. Questa separazione apporta notevoli vantaggi, primo fra tutti la flessibilità, infatti,
avendo un insieme di meccanismi generali, non legati al singolo criterio, è possibile modificare
quest'ultimo, senza dover andare a modificare il meccanismo stesso.
Il più semplice esempio di sistema operativo è MS-DOS, progettato senza una netta separazione tra
i diversi livelli (funzionalità), in quanto l'obiettivo principale dei progettisti era offrire la massima
funzionalità in uno spazio minimo. In MS-DOS non vi è quindi una struttura modulare e le
applicazioni possono accedere direttamente alle funzioni del sistema operativo, senza effettuare
syscall. Come si può notare, il sistema è molto semplice e non dà modo di evitare errori, o situazioni
di stallo.
Un secondo approccio, utilizzato nella progettazione di un sistema operativo, è quello a layer (a
livelli), secondo il quale, in presenza di un hardware adeguato, la struttura del sistema può essere
partizionata in moduli; ogni modulo può accedere a funzioni e servizi, definiti al livello sottostante, e
si relaziona soltanto con i livelli immediatamente inferiore ed immediatamente superiore. Solitamente
questo tipo di sistema operativo viene realizzato con una progettazione top-down, secondo cui il
livello più alto è costituito dalle applicazioni utente, mentre quello più basso è costituito
dall'hardware. I vantaggi che questa suddivisione offre possono essere racchiusi tutti sotto la parola
semplicità; il sistema è più semplice non solo da progettare, ma risulta facilmente modificabile e,
anche in fase di debugging, si riesce velocemente a risalire alla porzione di codice che ha generato
l'errore. Un esempio di sistema semplice, strutturato su più livelli, è dato dalla versione originale di
UNIX; questa versione presenta due livelli distinti, ovvero i programmi di sistema ed il kernel. In
UNIX il kernel è poi suddiviso in un insieme di interfacce e driver dei dispositivi di I/O e comprende
filesystem, scheduling della CPU, gestione della memoria, etc.
Un ulteriore approccio è quello dei sistemi a microkernel, che alleggeriscono il kernel da tutte le
funzionalità “non essenziali”, lasciando quindi poche azioni da poter eseguire in kernel mode, e
aggiungendo alla user mode tutte le altre. In caso di modifica delle funzionalità, il kernel rimarrà
invariato, mentre dovranno essere modificate soltanto le applicazioni. Le architetture a microkernel
garantiscono un'alta portabilità, maggiore sicurezza , e affidabilità, nonostante si possa registrare un
calo delle prestazioni, dovuto all'eccessiva esecuzione di processi di sistema come processi utente.
Il migliore approccio, che supera per flessibilità e semplicità tutti quelli visti fino ad ora, è quello
modulare; il sistema operativo è programmato per mezzo di un linguaggio orientato agli oggetti, il
kernel è quindi costituito da un insieme di componenti basilari, a cui sono aggiunte funzioni caricate
in maniera dinamica. Questo sistema si struttura, più o meno, come quello a livelli, con la differenza
che un livello (modulo) può comunicare anche con livelli che non sono conseguenti ad esso
(superiore o inferiore), inoltre, i moduli possono comunicare anche senza invocare funzioni di
trasmissione dei messaggi. Un esempio di sistema operativo strutturato in questo modo è dato dal
sistema Solaris.

• 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.

• OPERAZIONI SUI PROCESSI


Un processo, durante la propria esecuzione, può creare altri processi, detti processi figli, i quali, a
loro volta, potranno dare vita ad altri sottoprocessi (albero dei processi); all'interno del sistema
operativo, i processi sono identificati per mezzo di un numero univoco, detto identificatore di
processo (PID). Se un processo genera uno o più processi figli, questi utilizzeranno un insieme di
risorse fornitegli dal sistema operativo, oppure, utilizzeranno un sottinsieme delle risorse proprie del
processo padre. Per quanto riguarda l'esecuzione, processo padre e processi figli possono essere
eseguiti concorrentemente, oppure il processo padre può rimanere in attesa che i processi figli
abbiano terminato la loro esecuzione. Esistono due alternative anche per quanto riguarda lo spazio
degli indirizzi, infatti, il processo figlio può essere un duplicato del processo padre, oppure nel
processo figlio si carica un nuovo programma.
In UNIX le principali funzioni per lavorare con i processi sono fork(), utilizzata per la creazione di un
nuovo processo a cui viene assegnato il PID più basso, assegnando al processo padre il PID
immediatamente successivo. Poi troviamo la funzione exec(), utilizzata dopo fork(), che sostituisce lo
spazio di memoria del processo appena generato con un nuovo programma. Infine troviamo la
funzione wait(), utilizzata dal processo padre, quando questo non è eseguito contemporaneamente
ai processi figli, e rimane in attesa della loro terminazione.
Un processo termina quando finisce l'esecuzione della sua ultima istruzione ed effettua una richiesta
di cancellazione al sistema operativo, tramite il comando exit(); se il processo terminato è un
processo figlio, questo può inviare dati al processo genitore, il quale li riceve grazie al comando
wait(); per mezzo della terminazione, tutte le risorse assegnate ad un processo sono liberate dal
sistema operativo.
Solitamente i processi possono interrompere altri processi, per mezzo di chiamate di sistema, ma ciò
è consentito solo se i processi si trovano in una relazione di genitore-figlio. Le motivazioni per cui un
processo genitore possa voler terminare un processo figlio sono, ad esempio, un eccessivo utilizzo
delle risorse da parte del processo figlio, oppure i servizi del processo figlio non sono più richiesti,
etc. Naturalmente, se un processo padre termina, termineranno, di conseguenza, tutti i processi da
esso generati.

• STRUTTURA DEL PCB


• Program Counter
• Area per il salvataggio dei registri general purpose, di indirizzamento
• Area salvataggio registro di stato
• Area di salvataggio per i flag
• Stato corrente di avanzamento del processo (Pronto, In Esecuzione, Bloccato)
• Identificatore unico del processo (PID)
• Un puntatore al processo padre
• Puntatore ai processi figli se esistenti
• Livello di priorità
• Informazioni per il memory management (Gestione della memoria) (in particolare memoria virtuale)
del processo
• Identificatore della CPU su cui è in esecuzione
• Informazioni per lo scheduling (gestione) del processo, come il tempo di run (esecuzione) o wait
(attesa) accumulato o tempo stimato di esecuzione rimanente
• Informazioni di accounting di un processo
• Segnali pendenti
• Informazioni sullo stato di I/O del processo
• Registro nel quale è presente un puntatore alla page table
CAPITOLO 3 – THREAD

• RELAZIONE TRA THREAD E PROCESSO


Come abbiamo potuto comprendere, un processo è un programma in esecuzione, le cui istruzioni
vengono svolte in maniera sequenziale, quindi, è possibile assegnare ad ogni processo un thread,
ovvero, un percorso d'esecuzione. A questo punto, il sistema operativo non dovrà più effettuare lo
scheduling sui processi, ma dovrà farlo sui thread. Analogamente ai processi, anche i thread
possono trovarsi in stati differenti, ai fini dello scheduling; ad esempio, si troveranno nello stato di init
quando sono stati appena creati, in quello di ready quando sono pronti all'esecuzione, in quello di
running quando sono effettivamente eseguiti, in quello di waiting quando sono in attesa di un evento,
e nello stato terminated quando avranno completato l'esecuzione. Il sistema operativo si occupa
dello scheduling dei thread, servendosi del thread control block (TCB), il cui funzionamento è
analogo a quello del blocco di controllo di un processo (PCB); il TCB contiene informazioni relative ai
vari thread, per comprendere come gestirli al meglio. Come per i processi, anche per i thread è
ammesso il context switch, quindi, quando la CPU deve passare alla gestione di un nuovo thread,
mette in attesa il thread precedente, salvandole le relative informazioni, e carica lo stato relativo al
nuovo thread. Un'altra analogia con la gestione dei processi, si trova nel fatto che anche i thread
possono essere inseriti in code di attesa, come le code per i dispositivi di I/O, o quelle per i thread
pronti all'esecuzione. Lo scheduling per i thread viene effettuato con gli stessi criteri visti per lo
scheduling dei precessi, infatti, si cerca sempre di bilanciare le tipologie di operazioni svolte dai
diversi thread eseguiti contemporaneamente, al fine di sfruttare al massimo le risorse della CPU,
senza che ci siano periodi morti.

• UTILIZZO DEI THREAD


L'utilizzo dei thread, sempre, più diffusi nel panorama informatico, è dovuto ad una serie di vantaggi
che essi apportano, quali un notevole miglioramento dei tempi di risposta, o la condivisione delle
risorse, che consente ad un'applicazione di avere più thread, che svolgono attività diverse, nello
stesso spazio di indirizzi. Un altro vantaggio, da non trascurare, è il risparmio ottenuto dall'utilizzo dei
thread al posto dei processi, poiché è dispendioso creare processi, mentre non costa nulla creare
molti thread, infatti questi utilizzano le stesse risorse. L'ultimo, ma forse più importante pregio dei
thread, sta nel fatto che, nel caso di programmazione multithread, questi sfruttano al massimo le
prestazioni dei processori multicore, in quanto più thread possono essere eseguiti
contemporaneamente dai diversi core, riuscendo a garantire il parallelismo.

• 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.

• LIBRERIE DEI THREAD


Le librerie forniscono al programmatore API per la gestione e la creazione dei thread; in base alle
librerie, si distinguono due tipi di thread, ovvero quelli a livello utente, per i quali la libreria è collocata
fuori dal kernel (invocare una funzione della libreria non implica una syscall), e quelli a livello kernel,
la cui libreria è implementata a livello del sistema operativo (invocare una funzione della libreria
comporta una syscall). Le librerie più utilizzate sono la libreria Pthreads (utente e kernel), la libreria
Win32 (kernel), e la libreria Java (implementeta tramite Pthreads o Win32).
La libreria Pthreads definisce la API per la creazione e la sincronizzazione dei thread; la API non si
occupa della realizzazione dei thread, ma di una definizione del loro comportamento.
La libreria Win32 è utilizzata nei sistemi Windows; ad esempio, in Windows XP si utilizza la API
Win32 che implementa il modello uno a uno. Secondo la Win32 i thread sono costituiti da un
identificatore univoco, da un insieme di registri del processore, una pila (utente o kernel), ed un'area
di memoria per l'allocazione dinamica dei dati della libreria. Tutto ciò costituisce il contesto del
thread e i dati possono essere organizzati in strutture dati differenti, quali, ETHREAD, KTHREAD e
TEB. A differenza di Windows, Linux non distingue i processi dai thread, quindi utilizza il concetto
unico di task; la creazione di un task è ottenuta per mezzo della funzione clone(), la quale, in base ai
diversi flag attribuibili, consente ad un task figlio di condividere, o meno, delle informazioni con il task
padre.
La libreria Java, infine, utilizza due tecniche per la generazione dei thread; la prima consiste nel
creare una classe derivata da Thread, e fare l'override del metodo run(), la seconda consisnte nel
creare una classe che implementa runnable.

• PROBLEMATICHE DEI THREAD


Una delle principali problematiche legate alla gestione dei thread, si riferisce alla funzione fork(),
utilizzata per la duplicazione di un processo; nel caso di processi gestiti da più di un thread, la fork()
dovrà duplicare, a seconda dei casi, tutti i thread che gestiscono il processo, o soltatno uno. In
relazione alla funzione exec(), bisognerà duplicare soltanto un thread se la fork() è seguita
immediatamente dalla exec(), in caso contrario, bisognerà duplicare tutti i thread.
Un'altra problematica è legata alla cancellazione dei thread; cancellare un thread (target thread) vuol
dire terminare un thread, prima della sua conclusione naturale. Un thread può essere cancellato in
modo asincrono, quando si richiede la cancellazione e questa avviene immediatamente; o in modo
sincrono, quando i thread sono programmati per verificare, in determinate occasioni, se è stata
richiesta la loro terminazione o meno; la cancellazione sincrona è preferita a quella asincrona,
specialmente perché, in caso di cancellazione sincrona il thread verrà terminato in un punto
prestabilito, per cui la sua cancellazione non creerà problemi.
Un'altra problematica riguarda la gestione dei segnali; con il termine segnale si intende la notifica
conseguente al verificarsi di un determinato evento. I segnali possono essere sincroni o asincroni,
essi vengono generati, inviati ad un processo e infine gestiti; le problematiche riguardano l'invio dei
segnali al processo quando questo è gestito da più thread, poiché bisogna capire se inviare la
segnalazione a tutti i thread, o soltatno ad alcuni.
In un server, il quale genera un thread per ogni richiesta effettuata dagli utenti, è possibile che si
presentino delle difficoltà, legate alla generazione di troppi thread; un elevato numero di thread,
provoca uno spreco di tempo (per la creazione e la cancellazione), e potrebbe esaurire le risorse
messe a disposizione del sistema stesso. Una soluzione è data dalla creazione di gruppi di thread
(thread pool), che consentono di definire un gruppo di thread iniziale (fisso), i cui thread sono
impiegati per soddisfare le diverse richieste in arrivo; se nessun thread è disponibile, la richiesta
rimarrà in attesa. In questo modo il numero di thread è limitato e tutti i problemi presi in
considerazione prima non sono più rilevanti.
Nei modelli molti a molti e uno a uno, è possibile che si ecceda nel numero di thread utilizzato; per
sopperire a questa problematica, lo scheduler utilizza le upcalls, in modo che i thread del kernel
possano comunicare con la libreria, in modo da mantenere il giusto numero di thread.
Ogni thread, inoltre, può mantenere, oltre alle risorse condivise con gli altri thread, anche delle
risorse private, dette “dati specifici” del thread.

• STRUTTURA DEL TCB

• 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

• INTRODUZIONE ALLO SCHEDULING


Alla base della multiprogrammazione vi è l'obiettivo di sfruttare al massimo le risorse di calcolo della
CPU, riducendo al minimo le situazioni di inattività; quindi, un processo in attesa (di operazioni di
I/O) verrà privato della CPU, affinché questa venga assegnata ad un altro dei processi pronti.
Solitamente un processo alterna periodi in cui sfrutta la CPU (CPU burst), a periodi in cui è in attesa,
per poter interagire con i dispositivi di I/O (I/O burst), e permane in questa evoluzione ciclica, fino a
che non termina.
E' il sistema operativo, per mezzo dello scheduler, a decidere quale tra i molteplici processi pronti
verrà eseguito, quando un processo in esecuzione viene interrotto; il processo di scheduling si attiva
in quattro occasioni, ovvero quando un processo passa dallo stato di esecuzione a quello di attesa,
quando passa dallo stato di esecuzione a pronto, quando passa dallo stato di attesa a pronto, e
quando termina. Nel primo caso, come nel quarto, si dice che lo scheduling è senza diritto di
prelazione, mentre nel secondo, e nel terzo caso, si dice che è con diritto di prelazione; con il
termine diritto di prelazione si intende dire che i processi possono essere interrotti anche se non
hanno terminato la loro esecuzione.
Un elemento fondamentale nelle operazioni di scheduling è senza dubbio il dispatcher, ovvero colui
il quale assegna effettivamente le risorse della CPU ad un determinato processo; il dispatcher è
impiegato in occasione di context switch, nel passaggio da kernel mode a user mode, e quando
bisogna riprendere l'esecuzione di un programma, a partire da una locazione specifica. Il dispatcher
necessita di rapidità, ed il tempo che perde tra l'interruzione di un processo e l'avvio del processo
successivo, prende il nome di “latenza di dispatch”.
Esiste comunque una grande varietà di algoritmi di scheduling, ognuno derivante dallo studio di un
insieme di fattori, quali l'utilizzo della CPU, il throughput, il tempo di completamento (tempo che
intercorre tra l'inserimento di un processo nel sistema, e la fine della sua esecuzione), il tempo
d'attesa (tempo totale trascorso dal processo nella coda dei processi pronti), ed il tempo di risposta
(tempo che intercorre tra l'inserimento di un processo nel sistema, e la prima risposta ricevuta).
Solitamente, si tende a massimizzare l'utilizzo della CPU, e a ridurre al minimo i tempi di
completamento, attesa e risposta; in molti casi, si cerca di ottimizzare i valori medi di queste
grandezze.

• 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.

• ESEMPI REALI DI SCHEDULING


L'algoritmo di scheduling utilizzato su Solaris è a priorità, infatti, si suddividono i processi (o i thread)
in classi e ad ogni classe è assegnata una priorità differente, ed un algoritmo differente; il criterio di
scheduling predefinito per i processi è il round robin, con la particolarità che ai processi con priorità
più alta è asseganto un quanto di tempo minore.
In Windows XP, invece, si utilizza un algoritmo di scheduling a priorità, preemptive, in cui i processi
sono classificati secondo 32 livelli di priorità ed il dispatcher esamina tutti e 32 i livelli, al fine di
trovare il processo da eseguire.
Linux suddivide i processi in due gruppi (il primo per i processi con priorità da 0 a 99, ed il secondo
per i processi con priorità da 100 a 140), ai processi con priorità maggiore (più è basso il numero,
più è alta la priorità) è assegnato un tempo maggiore per l'esecuzione, a quelli con priorità minore è
assegnato un tempo minore. Il kernel gestisce i processi presenti nella coda di esecuzione, dove i
processi sono suddivisi in due array, quello dei processi da sfruttare, e quello dei processi scaduti;
una volta che il processo è stato eseguito per il quanto di tempo assegnatogli, viene interrotto e
spostato tra i processi scaduti...il prossimo processo che dovrà essere eseguito sarà quello a priorità
maggiore, tra quelli da sfruttare. Una volta terminato l'array dei processi da sfruttare, i processi
scaduti diventeranno nuovamente processi da sfruttare, e così via.
In conclusione non è facile scegliere l'algoritmo giusto per un determinato sistema operativo, la
scelta dipende da un insieme di fattori aleatori, e ci sono diversi metodi per stimare quale sia
l'algoritmo più adatto. Il metodo deterministico, attraverso una formula matematica, stima l'efficienza
di ogni algoritmo su un carico di lavoro dato a priori; il metodo delle reti di code conoscendo due
parametri tra le sequenze di CPU burst, la frequenza di arrivo nel sistema di nuovi processi ed il
tempo medio di attesa, calcola il terzo, valutando l'effetto di ogni algoritmo; infine, la simulazione
consente, a partire da dati casuali, di valutare una possibile evoluzione reale del lavoro del sistema,
secondo i diversi algoritmi.
L'unico metodo certo per conoscere effettivamente quale sia l'algoritmo migliore è l'esecuzione dei
diversi algoritmi, seguita dal confronto; ciò provoca però moltissimi problemi agli utenti.
CAPITOLO 5 – SINCRONIZZAZIONE DEI PROCESSI

• UTILITA' DELLA SINCRONIZZAZIONE


La sincronizzazione tra processi risulta fondamentale, nel caso di processi che cooperano tra di loro
utilizzando dati in comune; infatti, accedendo contemporaneamente a risorse condivise, possono
produrre dati inconsistenti. Onde evitare problemi, più processi che lavorano assieme, devono
essere gestiti da meccanismi che non ne compromettano l'esecuzione. Prendendo in considerazione
il problema del produttore-consumatore, con buffer condiviso, e utilizzando una variabile ausiliaria
che definisca il riempimento del buffer, è evidente che soltanto un processo alla volta potrà
modificare la variabile ausiliaria; se così non fosse, e più processi la modificassero
contemporaneamente, il valore della variabile non rispecchierebbe il riempimento effettivo del buffer.
I meccanismi di sincronizzazione ci consentono di evitare queste situazioni.

• PROBLEMA DELLA SEZIONE CRITICA E SOLUZIONE DI PETERSON


Ogni processo può essere suddiviso in quattro parti:
La prima parte comprende le righe di codice che vanno a modificare, o ad utilizzare, risorse
condivise anche da altri processi, ed è detta sezione critica; ad un processo è consentito eseguire
istruzioni appartenenti alla sua sezione critica, solo se nessun altro processo si trova nella propria
sezione critica.
La seconda parte precede la sezione critica, e comprende istruzioni per controllare l'accesso
mutuamente esclusivo alla sezione critica; questa parte è detta sezione d'ingresso.
La terza parte segue la sezione critica, e comprende istruzioni per comunicare agli altri processi la
terminazione della sezione critica; è detta sezione di uscita.
L'ultima parte, infine, comprende tutte le istruzioni che non fanno parte della sezione critica, di quella
d'ingresso e di quella di uscita.
Una soluzione al problema della sezione critica deve soddisfare la mutua esclusione, deve scegliere
tra più processi concorrenti quale debba accedere per primo alla sezione critica, e deve garantire un
tempo limitato che ogni processo possa attendere, prima di accedere alla propria sezione critica.
Una prima soluzione al problema della sezione critica, prevede che il thread i-esimo possa eseguire
la propria sezione critica nel turno stabilito (quando turn==i):

<<<<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);

Questa soluzione soddisfa il criterio di mutua esclusione, ma rischia di posticipare in maniera


indefinita la scelta del processo che, tra molti, deve entrare nella propria sezione critica.
Una seconda soluzione a questo problema prevede che, dati due processi, utilizzando dei flag,
quando il flag corrispondente ad un determinato thread viene settato, allora quel thread può eseguire
la propria sezione critica (quando flag[i]==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.

• GESTIONE DELLE SITUAZIONI DI STALLO


Sono tre gli approcci esistenti, relativamente alla gestione dei deadlock: il primo consente di
prevenire o evitare la creazione dei deadlock; il secondo consente al sistema di pervenire in
situazioni di stallo, per poi individuarle ed effettuare un ripristino del sistema; l'ultimo approccio
consiste nell'ignorare il problema, assumendo che non ci sia possibilità che si vengano a creare
situazioni di stallo. L'ultimo approccio è quello meno efficiente, in quanto, al verificarsi di un
deadlock, questo non viene riconosciuto e gestito, causando, di conseguenza, un notevole calo delle
prestazioni. Molti sistemi operativi ignorano i deadlock, poiché questo approccio è molto conveniente
da un punto di vista economico.
Naturalmente, la gestione migliore è quella fatta al fine di prevenire o evitare le situazioni di stallo del
sistema. Prevenire i deadlock significa fare in modo che non si verifichi almeno una delle condizioni
necessarie alla loro creazione. Sappiamo che è impossibile impedire la mutua esclusione, in quanto
non si possono avere a disposizione solamente risorse condivisibili (alcune sono intrinsecamente
non condivisibili); è possibile, invece, scardinare la condizione di possesso e attesa, facendo in
modo che i processi possano acquisire risorse, in esclusiva, soltanto se non ne hanno acquisite altr
(semrpe in esclusiva). Ci sono due metodi per impedire la condizione di possesso e attesa; uno
prevede che ogni processo acquisisca all'inizio tutte le risorse di cui avrà bisogno durante la propria
esecuzione, un altro prevede che quando un processo vuole acquisire una risorsa, per poterlo fare,
deve rilasciare tutte le risorse acquisite in precedenza. Andare ad inibire il possesso e attesa è
dunque sconveniente, poiché si rischia che vengano acquisite risorse che non verranno utilizzate
nell'immediato, o peggio, possono verificarsi situazioni di starvation. Vista le difficoltà incontrate nel
tentativo di impedire i primi due criteri, si può provare a concedere la prelazione sui processi
acquisiti; è possibile farlo imponendo che ogni processo che richieda una risorsa non disponibile,
liberi tutte le risorse acquisite in precedenza, posticipando la propria esecuzione al momento in cui
tutte le risorse (vecchie e nuove) saranno disponibili. Una variante della soluzione appena proposta
consiste nell'imporre che il processo richiedente possa acquisire la risorsa occupata, qualora il
processo detentore della risorsa sia a sua volta in attesa dell'acquisizione di un'altra risorsa; nel
caso in cui non fosse possibile al processo richiedente acquisire immediatamente la risorsa, allora
questo dovrebbe portarsi in uno stato di attesa, durante il quale potrà essere privato delle risorse
acquisite in precedenza, se un altro processo le richiedesse. In ultima ipotesi è anche possibile
evitare situazioni di attesa circolare, ordinando l'insieme delle risorse, e numerando in maniera
ascendente, le risorse richieste da ogni processo. In questo modo, confrontando le risorse, secondo
criteri di precedenza, sarà possibile imporre che ogni processo possa acquisire risorse, soltanto
seguendo un ordine di numerazione crescente. Quanto appena illustrato consente di eliminare
l'attesa circolare (è possibile dimostrarlo per assurdo).
Evitare i deadlock, invece, significa raccogliere in anticipo informazioni relative ai processi del
sistema, analizzando ogni volta se è possibile soddisfare una richiesta da parte di un processo o
meno (in base alle risorse libere, a quelle occupate, ed in base alle risorse che il processo chiederà
di acquisire in seguito); nel caso più semplice, c'è bisogno che ogni processo indichi al sistema il
numero massimo di risorse di cui necessita per l'esecuzione.
Un altra politica per evitare le situazioni di stallo si ottiene attraverso la definizione di “stato sicuro”:
diciamo che il sistema si trova in uno stato sicuro quando esiste una “sequenza sicura”. Una
sequenza sicura è una sequenza di processi, tale che si possono soddisfare le richieste che ogni
processo può ancora fare, impiegando le risorse disponibili e le risorse acquisite dai processi
precedenti al processo richiedente; se non è possibile, con tali risorse, soddisfare la richiesta, il
processo resterà in attesa, fino al rilascio delle risorse necessarie. Se c'è la possibilità, in seguito ad
una richiesta, che si passi da una sequenza sicura ad una sequenza non sicura, allora quella
richiesta non deve essere soddisfatta, affinché il sistema non pervenga ad uno stato non sicuro.
CAPITOLO 7 – ARCHITETTURE MULTIPROCESSORE

• 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.

• SISTEMI MULTIPROCESSORE IN PRATICA


I primi sistemi multiprocessore prevedevano che ogni processore lavorasse con una copia del
sistema operativo, non condivisa, e che gestisse dei processi propri, salvando le informazioni su una
memoria interna (problemi in incoerenza).
Un altra realizzazione dei sistemi multiprocessore è quella master-slave, in cui un processore master
gestisce una copia del sistema operativo, distribuendo il lavoro ai processori slave; ciò risolve gran
parte dei problemi presentati dal modello precedente, ma può provocare un sovraccarico della CPU
master.
Un altro possibile modello di sistema multiprocessore è quello SMP (Symmetric Multiprocessor),
secondo cui ogni processore gestisce una copia condivisa del sistema operativo (i processori
condividono le stesse risorse), ed i problemi di conflittualità sono risolti per mezzo dell'utilizzo di
semafori. I sistemi SMP, per diminuire lo spreco di tempo, devono bloccare il bus di sistema, durante
le fasi di letture.

• SCHEDULING PER SISTEMI MULTIPROCESSORE


Lo scheduler utilizzato nei sistemi multiprocessore, oltre a scegliere il thread da eseguire, deve
scegliere la CPU che deve eseguirlo. Ci sono diversi algoritmi di scheduling possibili: il primo è
l'algoritmo time-sharing, che prevede una sola coda di processi, acquisita tramite mutex dalle CPU,
le quali prelevano il thread da eseguire e la rilasciano. Il secondo algoritmo di scheduling è lo space-
sharing, in cui le CPU sono divise in gruppi logici, in funzione dei thread; si effettua, quindi, lo
scheduling di thread correlati in maniera parallela, su CPU diverse dello stesso gruppo. I thread di
un unico processo vengono assegnati ad un unico gruppo, sono creati assieme e cominciano
l'elaborazione assieme, a meno che non siano disponibili abbastanza CPU, in quel caso
l'elaborazione di tutto il processo è posticipata.
L'ultimo tipo di scheduling è il gang-scheduling in cui thread correlati sono visti come un blocco unico
(gang) e sono eseguiti simultaneamente su CPU diverse, in time-sharing; il tempo è diviso in quanti
di tempo (slice) e, all'inizio di una nuova time-slice, si effettua lo scheduling di tutte le CPU,
assegnando nuovi thread.

• DIFFERENZA TRA MULTIPROCESSORE E MULTICORE


I sistemi multicore sono un caso particolare dei sistemi multiprocessore, infatti presentano diverse
unità di elaborazione su un singolo chip; core diversi eseguono thread diversi, che operano su aree
di memoria diverse. La tecnica SMT (Simultaneous Multithreading) o Hyperthreading consente di
massimizzare le prestazioni dei processori multicore, in quanto consente di eseguire thread in
parallelo, minimizzando così il tempo di inattività, e sia il sistema operativo che le applicazioni
vedono SMT come un processore virtuale.
CAPITOLO 8 – GESTIONE DELLA MEMORIA CENTRALE

• 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.

• CARICAMENTO E COLLEGAMENTO DINAMICO E SWAPPING


L'utilizzo della memoria è reso più efficiente ricorrendo al caricamento dinamico. Questo espediente
prevede che si carichi una procedura in memoria centrale, soltanto quando questa venga
effettivamente chiamata, conservandola, altrimenti, in memoria secondaria.
Il collegamento dinamico è analogo al caricamento dinamico, ma riguarda le librerie. Le librerie non
sono caricate in memoria assieme alle procedure, e si inserisce in ogni procedura del codice (stub)
che indica come individuare la libreria in memoria.
Abbiamo già analizzato la tecnica dello swapping, ma non in relazione alla memoria; da questo
punto di vista, per i processi temporaneamente spostati sul disco, bisogna salvare delle copie,
contenute in una memoria ausiliaria, cui si può accedere in maniera diretta. Le principali
problematiche dello swapping sono il tempo impiegato al trasferimento dei processi, il
riposizionamento in memoria (i processi devono essere ricaricati agli stessi indirizzi), ed il fatto che
processi apparentemente inattivi potrebbero essere occupati nell'utilizzo di dispositivi di I/O.

• 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.

• ALLOCAZIONE NON CONTIGUA


L'allocazione non contigua permette di non vedere i processi come un unico blocco, affinché questi
possano essere allocati dovunque vi sia memoria disponibile. Sono due le tecniche che consentono
l'allocazione non contigua, ovvero la paginazione e la segmentazione.
La paginazione consiste nel suddividere la memoria fisica in frame di uguale dimensione (pagine
fisiche) e la memoria logica in blocchi detti pagine, che hanno dimensione uguale ai frame. Ogni
indirizzo che la CPU genera sarà costituito da un numero di pagina e da un offset; il numero di
pagina serve da indice alla tabella delle pagine che contiene associazioni tra i numeri di pagina e gli
effettivi indirizzi base in memoria fisica; l'offset, sommato all'indirizzo base ricavato, consente di
accedere alla locazione di memoria esatta. Solitamente la dimensione di una pagina è pari ad una
potenza di 2, e ciò permette di evitare la frammentazione esterna, ma non quella interna, in quanto i
programmi utente non avranno mai dimensione pari ad una potenza di 2.
La tabella della pagine è contenuta in memoria centrale e può essere gestita in diversi modi; uno di
questo è per mezzo del PTRB (registro base della tabella delle pagine), il quale punta alla tabella
stessa. L'utilizzo del PTRB implica che ogni accesso alla memoria sia duplicato, in quanto, oltre
all'accesso vero e proprio, si dovrà effettuare anche un accesso per ottenere l'elemento della tabella
delle pagine. Si può introdurre, dunque, una TLB, ovvero una memoria associativa, ad accesso
molto rapido, contenente coppie chiave-valore (indirizzo logico – frame), ricavate da un piccolo
sottoinsieme della tabella delle pagine. Ogni volta che la CPU genera un indirizzo logico, ne viene
controllata la presenza nella TLB e, se l'indirizzo è presente (hit), si ottiene il frame senza accedere
alla memoria, se l'indirizzo non è presente (TLB miss) bisognerà necessariamente effettuare un
accesso in più alla memoria. Ipotizzando che il tempo di accesso alla memoria sia pari ad un'unità di
tempo ε, e che il tasso di hit (hit ratio) sia pari ad α, allora il tempo di accesso effettivo sarà pari a
(1+ ε) α + (2+ ε)(1- α) = 2 + ε - α.
Utilizzando la tecnica di paginazione, la protezione dei processi del sistema operativo e di quelli
utente è garantita grazie all'utilizzo dei bit di protezione, associati ad ogni frame, che si trovano nella
tabella delle pagine. I bit di protezione indicano se la pagina ha permessi di sola lettura, o permessi
di lettura scrittura, cosicché in fase di calcolo dell'indirizzo fisico, vengano valutati anche questi
permessi. E' di frequente uso anche il bit di validità che, associato ad ogni elemento della tabella
delle pagine, indica se la pagina appartiene allo spazio degli indirizzi del processo, oppure no.
Un ulteriore risparmio di risorse è consentito per mezzo dell'utilizzo di pagine condivise, ovvero,
quando un codice è rientrante (può essere eseguito da più processi contemporaneamente), se ne
conserva un'unica copia in memoria, facendo sì che la tabella delle pagine faccia corrispondere i
frame relativi a quel codice sempre alla stessa locazione di memoria; i dati relativi ai singoli processi,
invece, saranno collocati in aree differenti, anche se fanno riferimento a quel codice condiviso.
Bisogna però sottolineare che l'utilizzo di tabelle delle pagine molto grandi implica un grande
dispendio di memoria e tempo. Una possibile soluzione al problema potrebbe essere quella di
paginare la tabella della pagine stessa, ottenendo così più livelli di paginazione; questo risultato si
può ottenere suddividendo il numero di pagina (all'interno dell'indirizzo logico) in due o più numeri di
pagina, dove il primo numero di pagina dell'indirizzo farà riferimento alla tabella delle pagine più
esterna, mentre l'ultimo numero di pagina, farà riferimento alla tabella delle pagine più interna.
Questa soluzione è comunque utilizzabile solo in caso di sistemi a 32 bit, mentre per i sistemi
superiori a 32 bit converrebbe utilizzare una tabella delle pagine con hashing; in questo caso l'input
della funzione di hash sarà il numero di pagina virtuale, che restituirà l'indice di una tabella che
contiene, per ogni elemento, una lista di coppie del tipo numero pagina – frame. L'indice ricavato
indica il primo elemento di una lista, la quale va esaminata, fino a trovare una corrispondenza con il
numero di pagina virtuale utilizzato come input.
Per diminuire ulteriormente la dimensione della tabella delle pagine in memoria possiamo utilizzare
una tabella delle pagine invertita, in cui ad ogni pagina fisica corrisponde un elemento che, a sua
volta, contiene il numero di pagina logica corrispondente al frame, e le informazioni relative al
processo cui appartiene la pagina. Questo approccio riduce la memoria impiegata, ma provoca un
aumento del tempo necessario alla ricerca di un elemento della tabella; una soluzione a questo
problema può essere costituita dall'utilizzo di tabelle delle pagine invertite, gestite con hashing.
La tecnica alternativa alla paginazione è la segmentazione, secondo cui la memoria è divisa in
segmenti, ciascuno identificato da un nome ed una lunghezza. Gli indirizzi sono costituiti, dunque,
da un nome (o un numero) che identifica il segmento, ed uno scostamento, che identifica la parte di
codice cui ci si riferisce; il compilatore prende il programma sorgente e struttura i segmenti, il
caricatore preleva i segmenti e assegna i numeri di segmento. Questa visione della memoria è,
però, significativa soltanto dal punto di vista dell'utente, dunque, gli indirizzi logici dovranno essere
tradotti in indirizzi fisici, utilizzando una tabella dei segmenti che contiene coppie di segmenti base e
segmenti limite, dove il segmento base contiene l'indirizzo fisico base della memoria che contiene il
segmento, mentre il segmento limite contiene la lunghezza del segmento. Se lo scostamento
specificato nell'indirizzo logico non eccede le dimensioni del segmento, specificate nel segmento
limite, allora è possibile accedere all'area di memoria richiesta, altrimenti viene generata
un'eccezione.
CAPITOLO 9 – GESTIONE DELLA MEMORIA VIRTUALE

• INTRODUZIONE ALLA GESTIONE DELLA MEMORIA VIRTUALE


E' giusto affermare che, per poter essere eseguiti, i programmi debbano essere contenuti in
memoria fisica, ma, come abbiamo già potuto osservare, non sempre si necessita dell'intero
programma, o almeno, di tutte le parti del programma contemporaneamente.
L'utilizzo di una memoria virtuale completa il processo di separazione tra la memoria logica e quella
fisica, in quanto è una memoria molto ampia, talvolta anche di più della memoria fisica, che
consente ai processi di condividere risorse tramite la condivisione delle pagine, e migliora l'efficienza
nella creazione di processi.
La memoria virtuale si può implementare tramite la paginazione su richiesta e la segmentazione su
richiesta.

• 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.

• ALLOCAZIONE DEI FRAME


Ogni processo necessita di un numero minimo di frame, che consenta di contenere tutte le pagine
cui ogni istruzione può fare riferimento, e che generalmente è definito dall'hardware del sistema; ad
esempio, l'istruzione mvc in IBM-370 nel caso peggiore richiede otto frame. L'ipotesi peggiore si fa
per i sistemi che consentono riferimenti indiretti a più livelli di memoria, che al massimo possono
richiedere un numero di frame pari all'intera memoria virtuale.
Esistono due principali schemi per l'allocazione dei frame ai processi: l'allocazione uniforme,
secondo cui, dati m frame ed n processi, si assegna ad ogni processo un numero di frame pari a
m/n; e poi c'è l'allocazione proporzionale, secondo cui si assegna, ad ogni processo, un numero di
frame proporzionale alla sua dimensione. Essendo si la dimensione della memoria virtuale di un
processo, dati m frame disponibili, al processo verrà assegnato un numero di frame pari a
ai = si / (Σsi *m)
In entrambi gli approcci, il numero di frame varia, a seconda del livello di multiprogrammazione.
Anche la sostituzione delle pagine influisce molto sull'assegnazione dei frame ai processi, infatti,
dividiamo gli algoritmi di sostituzione in due categorie: a sostituzione globale, in cui il frame da
sostituire, per far posto ad una pagina richiesta da un processo, può essere scelto tra i frame che
fanno riferimento anche ad altri processi; o a sostituzione locale, in cui ogni processo, al verificarsi di
un page fault, può sostituire soltanto uno dei propri frame, per fare posto alla pagina richiesta. Con la
sostituzione globale non è possibile controllare la propria frequenza di page fault, in quanto anche gli
altri processi potrebbero deallocare le sue pagine, nonostante ciò la sostituzione globale è
comunque preferita a quella locale, poiché garantisce migliori prestazioni.

• 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.

• ALLOCAZIONE DI MEMORIA DEL KERNEL


L'allocazione di memoria per il kernel avviene in modo sostanzialmente differente rispetto a come
avviene per i processi utente, infatti, attinge ad una riserva di memoria differente, che non è l'insieme
dei frame disponibili. Il kernel fa questo principalmente perché richiede quantità di memoria variabile,
e deve ridurre al minimo gli sprechi dovuti alla frammentazione, inoltre, necessita che le pagine cui i
processi fanno riferimento siano allocate in modo contiguo. Vi sono in sostanza due strategie
utilizzate per l'allocazione della memoria del kernel. La prima strategia è costituita dal sistema
Buddy, un sistema gemellare che utilizza segmenti di grandezza fissa (potenze di 2) per l'allocazione
della memoria; inizialmente si ha un unico segmento di memoria e, giunta una richiesta, questo
viene diviso a metà e, ogni metà, viene a sua volta divisa, fino ad ottenere il segmento più piccolo
che possa contenere il processo da allocare. Il sistema Buddy consente di congiungere rapidamente
segmenti vuoti adiacenti, tramite la fusione, ma presenta lo svantaggio di una possibile
frammentazione interna, visto che i segmenti sono necessariamente di dimensione uguale ad una
potenza di 2.
L'allocazione a lastre consente di evitare la frammentazione interna, ed è basata sul seguente
principio: ogni lastra è composta da una o più pagine fisicamente contigue, vi sono poi delle cache
(una per ogni struttura dati del kernel) contenenti una o più lastre. L'algoritmo di allocazione utilizza
le cache per memorizzare oggetti nel kernel, ovvero, quando si verifica una richiesta, l'allocatore
seleziona dalla cache, all'interno di una lastra, l'oggetto corrispondente alla richiesta e lo etichetta
come occupato (all'inizio tutti gli oggetti sono liberi). Si avranno dunque lastre in cui tutti gli oggetti
sono liberi, lastre in cui tutti gli oggetti sono occupati, e lastre in cui vi sono sia oggetti liberi che
oggetti occupati. Il vantaggio offerto dall'allocazione a lastre è una maggiore efficienza, in quanto,
come già detto, elimina la frammentazione (alloca esattamente lo spazio necessario ad un oggetto),
e soddisfa rapidamente le richieste di memoria (gli oggetti sono creati in anticipo e si trovano in
cache).

• 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.

• STRUTTURA DEL FILE SYSTEM


L'insieme di file e directory costituisce il file system. Il file system deve quindi contenere tutte le
informazioni sui file presenti nel sistema, le quali possono essere organizzate in modo differente, a
seconda delle necessità. I dispositivi di memorizzazione possono presentare anche più di un file
system (divisione in partizioni), effettuando una divisione della memoria in volumi. Le directory sono
l'elemento posto alla base della realizzazione di un fila system, per queste deve essere definito un
insieme di attributi (molto simile a quello dei file), ed un insieme di operazioni eseguibili per la
directory, quali la ricerca di un file, la creazione o la cancellazione di un file, la possibilità di elencare
tutti i file, la ridenominazione di un file, e l'attraversamento del file system.
Il filesystem può essere strutturato in diversi modi, il più semplice dei quali è costituito dal file system
a livello singolo. Nell'organizzazione a livello singolo, tutti i file sono contenuti in un'unica directory,
anche per più utenti, e questo presenta la problematica dei nomi unici (ogni file deve avere un nome
unico). Una struttura migliorata è quella utilizzata dal file system a due livelli, che propone la
creazione di una directory per ogni utente del sistema; ciò consente di eliminare il problema di
unicità dei nomi, inoltre, le azioni prima specificate avranno visibilità limitata alla sola directory
dell'utente che le ha invocate. Un utente che voglia accedere ad alcuni file, che si trovano nella
directory di un altro utente, può farlo (se ha i permessi) specificando il percorso (path name)
assoluto del file, che molto probabilmente conterrà il nome dell'utente che detiene il file. Ogni file
presente nel sistema ha un path name.
Un'altra struttura possibile si ottiene generalizzando la struttura a due livelli, ottenendo così una
struttura ad albero, in cui ogni utente può creare, a partire dalla propria directory, varie sottodirectory
o file. Quando un utente effettua un'operazione (tra quelle viste per le directory), questa si riferisce
alla directory corrente; se invece si vuole che l'operazione si riferisca ad una directory, si effettua una
chiamata di sistema (change directory) che sostituisce la directory corrente con il percorso della
directory specificata. I nomi di percorso possono essere di due tipi, ovvero assoluti, quando il
percorso è indicato partendo dalla radice dell'albero, fino al file, oppure relativi, quando il percorso è
indicato partendo dalla directory corrente. Alcuni sistemi permettono di cancellare una directory,
anche se questa non è vuota, altri obbligano a cancellare prima tutti i file e le sottodirectory presenti.
Per quanto riguarda la ricerca, affinché questa venga facilitata, si utilizza il Desktop file, che contiene
il nome e la locazione di tutti i programmi eseguibili del sistema.
Ancora un'altra struttura possibile è data dal file system a grafo aciclico, sostanzialmente un file
system ad albero che consente la condivisione di sottodirectory e file, permettendo ad un file, o ad
una sottodirectory, di trovarsi in più directory diverse. Ci sono due modi per implementare questa
struttura, o effettuare più copie dello stesso elemento condiviso, anche se ciò provocherebbe
problemi di incoerenza, oppure, attraverso l'utilizzo di collegamenti (link), che sono puntatori ad altri
file o cartelle. Quando si effettua un operazione che ha come oggetto il collegamento, questo
riconduce all'elemento reale cui punta. I link presentano, però, alcuni inconvenienti, quali l'aliasing
(collegamenti diversi allo stesso oggetto hanno nomi diversi), o i problemi di cancellazione, risolvibili
comunque utilizzando un contatore che, quando arriva a 0, genera l'automatica cancellazione del
file. La gestione delle cancellazioni con contatore è utilizzata per i collegamenti non simbolici (hard
link).
L'ultima struttura che andiamo ad analizzare è quella dei file system con grafo generale, una
struttura potenzialmente pericolosa, che potrebbe essere generata a partire dalla struttura con grafo
aciclico, qualora venissero creati collegamenti che, a loro volta, generino un ciclo nel grafo. La
presenza di un ciclo, durante la visita del file system, potrebbe provocare un loop infinito; è quindi
necessario che non vi siano cicli, o almeno, che in presenza di cicli, si evitino le visite multiple di uno
stesso elemento (es tramite marcatori). Un secondo problema nasce quando si vuole cancellare un
collegamento che punta alla directory padre, in questo caso il contatore dei riferimenti è pari a 0, ma
il collegamento non può essere cancellato.
Per poter essere visibile da parte dei processi, un file system deve essere montato. La locazione in
cui il file system è montato è detta punto di montaggio e, di solito, è una directory vuota cui verrà
agganciato il file system; nel caso in cui la directory di montaggio non sia vuota, allora i file presenti
saranno nascosti quando il file system risulta montato, mentre torneranno visibili una volta smontato.

• 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

• STRUTTURA DEL FILE SYSTEM


Tutti i dati di un sistema sono memorizzati in memoria secondaria e, solitamente, i dispositivi
utilizzati per la memorizzazione sono i dischi. I dischi memorizzano le informazioni in settori e
consentono, mediante l'utilizzo di una testina, di accedere direttamente al settore desiderato; per
migliorare l'efficienza degli accessi in memoria, lo scambio di informazioni con i dischi avviene
tramite l'utilizzo di blocchi, ovvero insiemi di più settori.
Per fornire una modalità più efficiente di accesso al disco, poi, il sistema operativo utilizza più file
system, quindi ci si dovrà preoccupare degli aspetti realizzativi dei file system stessi. Nella fase di
realizzazione di un file system bisogna preoccuparsi di due aspetti, ovvero l'aspetto della struttura
agli occhi dell'utente, e la creazione di una corrispondenza tra il file system logico e la memoria fisica
(i dischi).
Una possibilità è quella di strutturare il file system secondo il seguente modello a strati, partendo dal
basso, verso l'altro:
Livello di controllo dell'input e dell'output. A questo livello si trovano i driver per i vari dispositivi di I/O,
che comunicano con i controllori dei dispositivi stessi; si gestiscono i segnali di interruzione, e ci si
occupa anche del trasferimento dei dati dalla memoria secondaria a quella centrale.
Livello del file system di base. Questo livello comunica con il driver, inviandogli indirizzi di memoria
fisica da cui leggere, o su cui scrivere.
Livello di organizzazione dei file. Questo livello è a conoscenza sia della struttura logica che di quella
fisica dei blocchi di file, infatti effettua la traduzione degli indirizzi. A questo livello viene gestita anche
la memoria libera.
Livello del file system logico. Questo livello gestisce le informazioni relative i file, e gestisce
protezione e sicurezza.
In uno stesso sistema è possibile che più file system condividano i livelli dell'Input-Output e del file
system di base, gestendo in modo indipendente i livelli di organizzazione dei file e logico.

• REALIZZAZIONE DEL FILE SYSTEM


Ai fini della realizzazione di un file system, si necessita di un insieme di strutture dati, che possono
essere presenti all'interno dei dischi, oppure all'interno della memoria centrale. All'interno dei dischi
troviamo strutture quali il blocco di controllo dell'avviamento (boot control block), contenente
informazioni necessarie all'avviamento di un sistema operativo dal disco stesso; il blocco di controllo
del volume, contenente informazioni relative ai blocchi del volume; le strutture delle directory, che
indicano l'organizzazione dei file; i blocchi di controllo dei file, che memorizzano le informazioni
relative ai singoli file presenti sul disco. All'interno della memoria troviamo, invece, strutture quali la
tabella di montaggio, che contiene informazioni sui volumi montati; informazioni sulla struttura delle
directory recentemente accedute; le tabelle dei file aperti e dei file aperti da processo.
All'atto della creazione di un nuovo file, si alloca un nuovo file control block (FCB) e poi si aggiunge
alla directory corrispondente un elemento che faccia riferimento al FCB; a questo punto è possibile
aprire il file. Quando un file viene aperto (funzione open()) il file system controlla se il file sia già
presente nella tabella dei file aperti quindi, se lo è, inserisce un elemento nella tabella dei file aperti
da processo che fa riferimento al FCB nella tabella dei file aperti, altrimenti scorre la directory per
individuare il file, ne prende il relativo FCB e crea un nuovo elemento sia nella tabella dei file aperti,
che in quella dei file aperti da processo.
Uno stesso disco può essere suddiviso in più partizioni, inizialmente prive di una struttura logica (raw
partition), poiché non contengono alcun file system; i dischi privi di struttura logica sono detti raw
disk, ad esempio, in UNIX l'area di avvicendamento dei processi è priva di struttura. In ogni
partizione può essere presente un'area destinata a contenere il kernel del sistema operativo, e deve
essere posta in una posizione specificata, in modo che venga caricata in fase di avviamento. Il
montaggio delle partizioni avviene nella fase di caricamento del sistema operativo e si tiene traccia
di ogni file system montato, attraverso la tabella di montaggio.
I sistemi operativi moderni possono supportare file system differenti. Ciò è reso possibile attraverso
l'utilizzo di un Virtual File System (VFS), che può essere definito come un'interfaccia che separa le
generiche chiamate di sistema (uguali per tutti i file system) dall'effettiva implementazione (diversa
per ogni file system). Il VFS vede tutti gli elementi come v-node, e consente di gestirli,
indipendentemente dalla loro reale struttura; inoltre, è in grado di riconoscere file locali e file remoti
(in rete), e rappresentarli all'utente, ugualmente, come v-node.

• 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.

• GESTIONE DELLO SPAZIO LIBERO


Anche la gestione della memoria libera riveste una notevole importanza; infatti, si tiene traccia dello
spazio lasciato libero da file cancellati, affinché questo si possa riassegnare. Il sistema conserva
informazioni sullo spazio libero in memoria, servendosi di strutture dati differenti. Nella maggior parte
dei casi, si tiene conto dello spazio libero utilizzando un vettore di bit in cui ogni blocco in memoria è
rappresentato da un bit, di valore 0 se il blocco è occupato, di valore 1 se è libero. Questo risulta
molto semplice ed anche molto efficiente, qualora il vettore di bit sia contenuto in memoria centrale;
ciò non è però possibile, infatti, soltanto in caso di un vettore di piccole dimensioni sarà possibile
contenerlo in memoria, negli altri casi, questo risulterà occupare troppo spazio. Una struttura
alternativa, per tenere traccia dei blocchi liberi, è costituita dalla lista concatenata dei blocchi liberi,
che consente di memorizzare l'indirizzo del primo blocco libero, il quale, a sua volta, punterà al
blocco libero successivo; in questo caso non c'è spreco di spazio, anche se risulta poco efficiente
scorrere la lista concatenata. Nei sistemi FAT, analizzati in precedenza, la tabella FAT conteneva
informazioni relative ai blocchi liberi, quindi in quel caso non c'era necessita di un'altra struttura dati
che li gestisse. Altri metodi per gestire i blocchi liberi sono il raggruppamento (ogni blocco contiene
n-1 puntatori a blocchi liberi, ed un puntatore ad un altro blocco di puntatori) ed il conteggio (per ogni
blocco libero si tiene conto del suo indirizzo e del numero di blocchi liberi che lo seguono).

• 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.

• CONNESSIONE DEI DISCHI


I calcolatori possono accedere alla memoria secondaria in due modi: tramite porte di I/O
(connessione diretta con il disco), oppure in modo remoto per mezzo di un file system distribuito.
Nel primo caso, per le porte sono disponibili diverse tecnologie: per i PC si usa IDE o ATA, mentre
per i server SCSI e FC.
L’architettura SCSI è un architettura a bus, fatto da un cavo piatto, che può supportare 16 dispositivi,
dei quali uno è il controllore (SCSI initiator) che stabilisce le operazioni che i 15 SCSI target devono
eseguire. Il protocollo permette di accedere fino a 8 unità logiche per ciascun dispositivo.
L’FC (Fiber Channel) è un’architettura seriale ad alta velocità e la si può trovare in due varianti. La
prima variante è una grande struttura a commutazione con uno spazio d’indirizzi a 24 bit (base per il
futuro e per le reti SAN storage area network). LA seconda si chiama FC-AL (arbitrated loop) e può
accedere fino a 126 dispositivi.
Per quanto riguarda il caso della connessione in remoto,si usa solitamente la rete internet con i
protocolli TCP/IP. I client accedono alla memoria connessa alla rete (NAS network attached storage)
tramite un’interfaccia RPC (chiamate di procedura remota). È un protocollo molto efficace per
condividere un dispositivo di memorizzazione in una rete LAN, ma offre prestazioni scadenti. Un
metodo più efficace è il protocollo iSCSI, il cui compito è riprodurre il protocollo offerto dall’architet-
tura SCSI, ma attraverso la rete internet. Offre prestazioni molto alte e le macchine sono in grado di
trattare i dispositivi di memorizzazione come se fossero collegati direttamente alla macchina.
Un grande svantaggio di queste ultime soluzioni è che il collegamento in rete (attraverso internet) dei
dispositivi di memorizzazione occupa banda, aumentando la latenza delle comunicazioni in rete.
Una soluzione è data dalle reti SAN, che utilizzano protocolli specifici per la condivisione di memorie
secondarie in rete. Il mezzo principale per i collegamenti è la fibra ottica.

• SCHEDULING DEL DISCO


Una delle responsabilità del sistema operativo è quella di massimizzare l’utilizzo delle risorse. Nel
caso delle unità a disco bisogna migliorare: 1. 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) e 2. l’ampiezza di banda, ovvero la quantità di dati trasferiti per unità di
tempo. Le richieste di trasferimento sono richieste di I/O, per questo, in un sistema multiprogramma-
to, è possibile che vi siano parecchi processi in attesa di effettuare operazioni di I/O e scrittura sul
disco. Lo scheduling provvede a ottimizzare la gestione di tali richieste. Queste sono caratterizzate
da diverse informazioni: 1. Se l’operazione è di lettura/scrittura, 2. L’indirizzo nel disco al quale
effettuare l’operazione, 3. L’indirizzo di memoria rispetto al quale eseguire l’operazione, 4. Il numero
di byte da trasferire.
Il primo e più banale algoritmo di scheduling è il FCFS (First Come First Served). Si tratta di un
algoritmo equo, ma non garantisce in generale la massima velocità di trasferimento possibile.
Un altro è il SSTF (Shortesh seek time first), che è un algoritmo abbastanza efficiente e che si basa
sul servire prima le richieste che si trovano vicine (immaginando i settori di un disco attraverso un
ordinamento numerico 1..2…1000 in modo crescente dall’esterno verso l’interno). È molto simile al
SJF (algoritmo di brevità per i processi) e proprio come quest’ultimo può provocare problemi di
starvation, ovvero attesa indefinita per alcune richieste. Una differenza si ha invece nel fatto che
SSTF non è ottimale per quanto riguarda il tempo di attesa.
Vi è poi lo scheduling per scansione SCAN (o algoritmo dell’ascensore): la testina si muove da un
estremo all’altro e viceversa, asservendo tutte le richieste che trova al suo passaggio. Si è notato,
però, che assumendo una distribuzione normale per le richieste, dopo aver scasionato il disco,
passando dall’estremo 1 all’estremo 2, è difficile che nel passaggio di ritorno, dall’estremo 2
all’estremo 1, si incontrino subito molte nuove richieste; è infatti, più probabile che esse siano
concentrate sull’estremo opposto, ovvero nelle vicinanze dell’estremo 1. Quest’idea dà origine
all’algoritmo di scheduling per scansione circolare C-SCAN: la testina passa dall’estremo 1
all’estremo 2 del disco e ricomincia. Queste versioni di SCAN scheduling presuppongono
l’attraversamento di tutto il disco, in modo ciclico. Tale procedura implica un spreco di tempo, se
arrivati a un certo punto non vi sono più richieste, pertanto è inutile continuare a scansionare fino
all’estremo del disco. È più conveniente che giunti all’ultima richiesta da asservire in quella
direzione, la testina, una volta soddisfatta la richiesta, cambi verso e proceda nella direzione
opposta. Gli algoritmi così strutturati sono detti SCAN LOOK e C-SCAN LOOK.
La scelta di un algoritmo di scheduling per il disco non è banale; le prestazioni dipendono soprattutto
dal numero e dai tipi di richieste. Tuttavia la scelta più naturale risulta essere il SSTF, ma per sistemi
che richiedono un utilizzo gravoso del disco sono migliori SCAN LOOK e C-SCAN LOOK. Inoltre le
richieste possono essere influenzate dal metodo di allocazione dei file (contiguo o concatenata) e
pertanto sarebbe meglio che tali procedure di scheduling costituiscano un modulo a parte del
sistema operativo, in modo che possa essere sostituito in situazioni diverse da quelle ordinarie.
Quando si opera in questo modo gli algoritmi predefiniti sono il SSTF e lo SCAN LOOK.

• GESTIONE DELL’UNITÀ A DISCO


Sono fondamentali tre procedure: inizializzazione del disco, avviamento del sistema basato sull’unità
a disco e la gestione dei blocchi difettosi.
Prima che un disco magnetico nuovo possa memorizzare dati, deve essere diviso in settori che
possano essere letti o scritti dal controllore. Questo processo si chiama formattazione fisica del
disco, o formattazione di basso livello. Quest’ultima riempie il disco con una speciale struttura dati
per ogni settore, tipicamente consistente di un’intestazione, un area per i dati (da 512 byte) e una
coda. L’intestazione e la coda contengono informazioni necessarie al controllore, come ad esempio
l’ECC (error-correcting code), che permette di determinare se un settore è danneggiato o incoerente.
Nel caso di presenza di dati danneggiati, se i bit sbagliati non sono eccessivi, è possibile correggere
l’errore. Per usare un disco come contenitore d’informazioni, il sistema operativo deve registrare le
proprie strutture dati, cosa che fa in due passi. Il primo consiste nel suddividere il disco in uno o più
gruppi di cilindri, detti partizioni. Il passo successivo è la formattazione logica, che consiste nella
creazione di un file system sul disco.
Affinché un calcolatore possa entrare in funzione, è necessario che esegua un programma iniziale
(bootstrap). Esso inizializza il sistema in tutti i suoi aspetti. Per fare ciò il programma di avviamento
trova il kernel del sistema operativo nei dischi, lo carica in memoria e salta all’indirizzo corretto per
l’esecuzione. Di solito tale programma è caricato in una memoria a sola lettura (ROM), che lo
protegge da attacchi informatici. Se però il programma deve essere cambiato, c’è la necessità di
cambiare tutto il circuito integrato ROM. Per questo motivo, molti sistemi operativi caricano nelle
memorie ROM solo un caricatore d’avviamento (bootstrap loader), il cui compito è andare a cercare
in memoria il programma d’avviamento; quest’ultimo può facilmente essere modificato.
Un altro compito importante è la gestione dei blocchi (settori) difettosi. Infatti un disco magnetico,
all’atto dell’acquisto, presenta già dei settori difettosi. Il sistema li gestisce in modo differente, a
seconda che siano memorie per bassa utenza o per aziende. Nel primo caso, durante la
formattazione logica, si individuano i blocchi difettosi e li si etichettano, in modo tale che quest’ultimi
non possano essere visitati. Un metodo usato da dischi più complessi è l’accantonamento di settori:
il controller del disco tiene conto dei settori difettosi attraverso una lista, aggiornata continuamente, e
mette da parte dei settori di riserva, al fine di effettuare una sostituzione logica. In questo modo
qualora il sistema volesse accedere al settore danneggiato, accede invece a quello di riserva. Tale
meccanismo però, inficia le ottimizzazioni fornite dallo scheduling del disco. Un alternativa è la
traslazione di settori: se vi è un settore danneggiato, il suo contenuto e quello di tutti i settori
successivi, fino al ragiungimento di quello di riserva, vengono traslati, riscrivendo i dati nel settore
immediatamente successivo.

• GESTIONE DELL’AREA DI AVVICENDAMENTO (SWAPPING)


L’area di swap viene utilizzata dai sistemi come estensione della memoria fisica, al fine di spostare i
processi in background sul disco così da non doverli terminare e riprenderli all’occorrenza. L’area di
swap può essere collocata all’interno del file system, scelta non consigliabile dal punto di vista delle
prestazioni, oppure può essere collocata in un’area riservata (non formattata) del disco: in questo
modo l’accesso è più veloce, perché non si devono attraversare le strutture del file system. La
seconda soluzione crea situazioni di frammentazione interna, ma non sono problematiche.

• 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à.

• DISPOSITIVI PER LA MEMORIZZAZIONE TERZIARIA


Fanno parte dei dispositivi di memoria terziaria i CD-ROM, le USB-Drive, dischi rimovibili, etc. Sono
caratterizzati dall’avere un basso costo. I dischi magnetici rimovibili sono ricoperti da materiale
magnetico racchiuso in un involucro protettivo di plastica. I floppy sono un esempio, anche se ormai
in disuso. Possono raggiungere la velocità dei dischi magnetici, ma hanno un tasso di danneggia-
mento più elevato. I dischi magneto-ottici rimovibili sfruttano i principi del campo magnetico per la
scrittura e la lettura; la temperatura (per incrementare l’effetto del campo magnetico) viene innalzata
da un puntatore laser che illumina il settore di memoria designato. Vi sono poi i dischi ottici rimovibili,
i quali utilizzano solo i laser, che colpendo speciali superfici, possono illuminarle di un colore chiaro o
scuro a seconda dei bit contenuti in tale locazione di memoria. I supporti appena descritti, possono
essere riutilizzati. Un tipo di supporto che invece non gode di tale caratteristica è il disco WORM
(write once – read many). Questi dischi sono realizzati interponendo una pellicola di alluminio tra
due piatti di plastica o vetro. Effettuando dei fori sulla superficie di alluminio si registrano i dati. Dato
che il processo è irreversibile, non c’è possibilità di riscrivere sul supporto. Essi sono considerati
molto affidabili, dal momento che non è facile distruggere l’informazione in essi contenuta. CD e
DVD sono esempi di WORM. I nastri rimovibili sono più economici dei dischi ottici o magnetici
rimovibili, anche se il loro accesso alla memoria è molto più lento, a causa del fatto che, a lettura
completata, bisogna riavvolgere il nastro. Si sta da poco sviluppando l’uso di memorie a stato solido
SSD, che hanno le caratteristiche dei nastri magnetici, ma sono più affidabili e veloci, consumando
meno energia; in compenso il loro ciclo di vita è inferiore e possono conservare una minor quantità
di dati.

• COMPITI DEL SISTEMA OPERATIVO


La maggior parte dei sistemi operativi gestisce i dischi rimovibili quasi come i dischi fissi. La gestione
dei nastri è invece differente: il sistema operativo lo tratta come un supporto di memorizzazione di
basso livello, pertanto se ne garantisce la mutua esclusione ad una sola applicazione, essendo un
dispositivo molto lento. Non si forniscono servizi di file system, l’applicazione decide come usare il
nastro.
CAPITOLO 13 – DISPOSITIVI DI I/O
• ARCHITETTURA E DISPOSITIVI DI I/O
Una delle funzioni principali di un elaboratore è l’interfacciamento input output. Data l’esistenza di
dispositivi di I/O con caratteristiche molto diverse, per velocità, funzioni, etc, bisogna contemplare
numerosi metodi di gestione; per questo motivo nel kernel la parte di codice riservato all’I/O è
separato dal resto e costituisce il cosiddetto sottosistema di I/O. Tra i dispositivi di I/O si annoverano:
schede di rete, memorie secondarie e terziarie, tastiere, etc. Essi possono essere collegati al
sistema di calcolo tramite etere o cavi e sono connesse attraverso un punto di connessione, ad
esempio una porta seriale. Se si usano cavi o fili, il metodo di connessione è detto bus. Un bus è
l’insieme di fili e il protocollo atti alla comunicazione del dispositivo con il calcolatore. Un controllore
è un insieme di componenti elettronici che può far funzionare una porta di un bus o dispositivo. La
CPU dà comandi e fornisce dati al controllore per portare a termine trasferimenti di I/O tramite uno o
più registri per dati e segnali di controllo. Un modo in cui questa comunicazione può avvenire è
tramite l’uso di speciali istruzioni di I/O che specificano il trasferimento di un byte o una parola a un
indirizzo di porta di I/O. In alternativa, il controllore di dispositivo può disporre dell’I/O mappato in
memoria: in questo caso i registri di controllo del dispositivo si fanno corrispondere a un
sottoinsieme dello spazio d’indirizzi della CPU, che esegue le richieste di I/O usando le ordinarie
istruzioni di trasferimento di dati per leggere e scrivere i registri di controllo del dispositivo. L’I/O
mappato in memoria è notevolmente più veloce del metodo delle istruzioni di I/O, ma presenta lo
svantaggio che un errore di programmazione nell’associare le locazioni di memoria può provocare
gravi incoerenze.
Una porta di I/O consiste, in genere, di 4 registri: 1. Status, nel quale è definito lo stato della porta
(disponibile, occupato, rotto, errore) 2. Control, in cui si può scrivere per attivare un comando o
abilitare il controllo sulla parità, impostare velocità di trasmissione etc, 3. Data-in, dove la CPU legge
per ricevere dati, 4. Data-out, in cui la CPU scrivi dati affinché siano letti dal controllore di I/O.
Come si è visto, CPU e controllore di I/O interagiscono tra loro per portare a termine determinate
operazioni; l’inizio della comunicazione avviene tramite un handshaking. Essa avviene in questo
modo: 1. La CPU legge ripetutamente il bit busy del dispostivo (che se è 1 indica che è occupato),
finchè non valga 0. 2. La CPU pone a 1 il bit write del registro dei comandi e scrive nel registro data-
out. 3. La CPU pone a 1 il registro command-ready. 4. Quando il controllore nota che il registro
command-ready è posto a 1, pone a 1 il bit busy, per etichettarsi come occupato. 5. Il controllore
legge il registro dei comandi e trovando il comando write, legge i dati dal registro data-out; compie,
infine, l’operazione di scrittura all’interno del dispositivo. 6. Il controllore pone a 0 il bit command-
ready, pone a 0 il bit error nel registro status per indicare che l’operazione di I/O ha avuto esito
positivo e pone a 0 il bit busy per indicare che l’operazione è terminata.
Come si nota, durante l’esecuzione del passo 1 la CPU è in attesa attiva, o in interrogazione ciclica
(polling): itera la lettura del registro status finché il bit busy non assume il valore 0. Questo metodo è
efficiente solo se effettuata per pochi cicli, quindi per operazioni di I/O che hanno durata breve; in
caso contrario, risulta più conveniente ricorrere al metodo delle interruzioni, meccanismo che fa sì
che sia il controllore del dispositivo a notificare la CPU della propria disponibilità.

• 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à.

• INTERFACCIA DI I/O PER LE APPLICAZIONI


È di fondamentale importanza definire interfacce di I/O, tali che permettano un trattamento uniforme
dei dispositivi di I/O, nonostante la loro diversità strutturale e progettuale (anche tra dispositivi dello
stesso produttore). Ognuno di essi ha un driver specifico, ma comunica con il calcolatore e le sue
applicazioni tramite le suddette interfacce. Lo scopo di quest’ultime è di nascondere al sottosistema
di I/O del kernel le differenze tra i controllori dei dispositivi, in modo simile a quello che avviene con
le chiamate di sistema di I/O, che eseguono l’operazione richiesta, prescindendo dal tipo di
dispositivo utilizzato (copia file su USB-DRIVE, floppy, SSD, etc). Questo risulta un vantaggio sia per
il sistema operativo che per i produttori di dispositivi: devono semplicemente adeguare il proprio
dispositivo ai sistemi operativi esistenti. Sfortunatamente, sistemi operativi diversi gestiscono l’I/O
diversamente, per cui per ogni dispositivo di I/O sono necessari diversi driver, affinché sia garantita
la compatibilità con tutti i sistemi esistenti.
I dispositivi possono differire in molti aspetti:
1. Trasferimento a flusso di caratteri o a blocchi: i dispositivi del 1° tipo trasferiscono un byte
(carattere) alla volta, quelli del 2° tipo interi blocchi di dati in’unico comando (memorie secondarie).
2. Accesso sequenziale o diretto: i dispositivi del 1° tipo trasferiscono dati secondo un ordine preciso
e invariabile, quelli del 2° tipo possno accedere a una qualsiasi locazione di memoria.
3. Dispositivi sincroni o asincroni: gli apparecchi del 1° tipo trasferiscono dati con un tempo di
risposta prevedibili, a differenza di quelli del 2° tipo.
4. Condivisibili o riservati: la riservatezza di un dispositivo garantisce la sua mutua esclusione per un
dato processo, cosa che non avviene se il mezzo è condivisibile tra più processi.
5. Velocità di funzionamento
6. Modalità di accesso ai dati: lettura/scrittura, solo lettura, solo scrittura.
L’interfaccia per i dispositivi a blocchi sintetizza tutti gli aspetti necessari per accedere alle unità a
disco o simili. I dispositivi di questo tipo interagiscono con instruzioni tipo read(), write(), etc. A volte,
alcuni di essi possono essere trattati come dispositivi di basso livello (raw I/O): in queste situazioni il
sistema operativo è ridondante, dato che l’applicazione deve definire precisi termini per la gestioni di
questi apparecchi particolari. Altre volte, i normali dispositivi di I/O sono trattati, non come unità di
trasferimento a blocchi, ma come dispositivi di I/O mappato in memoria. Le operazioni gestite in
questo modo sono più efficienti. Apparecchi a flusso di caratteri sono, ad esempio le tastiere o
mouse, che lavorano con byte e devono rispondere a chiamate di sistema del tipo put(), get(). Sono
di tipo asincrono, dal momento che un utente può, attraverso di essi, interagire in modo del tutto
irregolare. I modem e i dispositivi di rete in generale, hanno un’interfaccia diversa (dal momento che
svolgono operazioni diverse): essa è detta socket.
Tra i tanti dispositivi, è possibile trovare in qualsiasi sistema di calcolo anche orologi e timer, i quali
forniscono 3 funzioni essenziali: 1. Segnare l’ora corrente, 2. Segnalare il tempo trascorso, 3. Rego-
lare un timer in modo da avviare l’operazione x al tempo t. I timer vengono usati dappertutto: lo
scheduler lo utilizza per determinare i processi che hanno esaurito il loro quanto di tempo, il
sottosistema di rete per misurare la lentezza delle richieste (e quindi possibilità di congestione della
rete), etc.
Un’altra distinzione importante da tenere in considerazione, riguarda le chiamate di I/O bloccanti e
quelle non bloccanti. Le prime richiedono che il processo vada nella coda dei processi in attesa dopo
avere effettuato un’interrogazione verso i dispositivi di I/O.

• SOTTOSISTEMA PER L’I/O DEL KERNEL


Il kernel fornisce molti servizi riguardanti l’I/O, come lo scheduling del’I/O, la gestione del buffer, delle
cache e delle code, l’uso esclusivo dei dispositivi e la gestione degli errori. Compie anche compiti di
autoprotezione da processi errati o utenti malintenzionati.
Per quanto riguarda lo scheduling, la scelta migliore non è di certo l’algoritmo FCFS. Il sistema
operativo si serve di strutture dati come le code dei dispositivi di I/O, che accolgono i processi in
attesa di operazioni di I/O. A questo proposito si vedano gli algoritmi di scheduling del capitolo 12. I
kernel che mettono a disposizione l’I/O sincrono devono essere in grado di tenere traccia di più
richieste di I/O contemporaneamente; si effettua tale operazione tramite una tabella dello stato dei
dispositivi, in comunicazione con la coda dei dispositivi in attesa.
Il buffer è un’area di memoria che si interpone, al fine della comunicazione, tra due dispositivi o tra
un’applicazione e un dispositivo, ed è molto importante per 3 ragioni: 1. Necessità di gestire diverse
velocità tra il produttore e il consumatore di dati (modem più lento di un disco, ad esempio). È
necessario impiegare due buffer: uno, che quando pieno si deve apprestare all’invio di dati al
consumatore, e un altro per accogliere i nuovi dati emessi dal produttore.
2. Necessità di far fronte a blocchi di dati di lunghezza differente.
3. Realizzazione della semantica delle copie, che garantisce che la versione dei dati scritta nel disco
sia conforme a quella contenuta nel buffer al momento della chiamata di sistema,
indipendentemente da ogni successiva modifica.
La cache è un’area di memoria “aggiuntiva”, nel senso che il suo solo scopo è quello di garantire
prestazioni maggiori conservando copie di dati in una memoria ad accesso più veloce. Per questo
motivo è diversa dal buffer: la cache contiene copie.
Le code servono a tenere traccia dei processi che ambiscono a compiere un’operazione di I/O: molti
dispositivi (come le stampanti) non possono accettare flussi di dati intercalati, ma hanno l’obbligo di
servire un solo processi per volta. L’accodamento è un metodo efficace per garantire tale funziona-
lità. Inoltre, il sistema operativo mette a disposizione funzionalità per gestire l’accesso esclusivo a
taluni dispositivi.
Per evitare errori o attacchi informatici, i dispositivi di I/O non sono gestiti dall’utente, bensì dal
sistema operativo, tramite istruzioni privilegiate, ovvero le chiamate di sistema.
Per quanto riguarda la gestione degli errori, essi sono una costante nell’I/O (possono essere
generati perennemente da controllori difettosi). Solitamente il sistema gestisce gli errori attraverso
un bit d’informazione riguardo al successo (1) o all’insuccesso (0) della chiamata di sistema per l’I/O.
Unix usa la variabile intera errno per codificare genericamente il tipo di errore.
Il kernel tiene conto di tutto ciò servendosi di speciali strutture dati che memorizzano ogni dettaglio
del dispositivo.

Potrebbero piacerti anche