Definizione e classificazioni Il sistema operativo di un computer è quel software di base che permette di gestire le risorse del computer, in pratica soprattutto le parti hardware di un computer: CPU, memoria centrale e periferiche. Verrebbe naturale oggi dire che il sistema operativo è quel software che permette di utilizzare il nostro computer, se non fosse che i primi programmatori non avevano nessun sistema operativo a disposizione, anzi ai tempi pionieristici dell’informatica non esistevano neanche i linguaggi di programmazione e chi scriveva programmi lo faceva direttamente conoscendo il linguaggio della macchina che utilizzavano e tutte le caratteristiche hardware della macchina stessa. Dal punto di vista dell’utente il sistema operativo è quel software che permette l’esecuzione dei programmi lanciati; dal punto di vista del computer il sistema operativo permette la gestione delle sue risorse, attraverso la virtualizzazione delle stesse. Per virtualizzazione intendiamo quel processo che mette a disposizione di ogni programma in esecuzione una macchina virtuale composta da CPU, memoria centrale e periferiche dedicate a quel programma, quando in effetti esiste una sola CPU all’interno del computer e la memoria centrale, per quanto estesa, è comunque limitata. I primi sistemi operativi (vedi DOS) avevano una interfaccia a caratteri per dialogare con l’utente, mentre ora i sistemi operativi mettono a disposizione una interfaccia grafica (GUI) che facilita l’utilizzo del computer da parte dell’utente. Si possono avere diverse classificazioni dei sistemi operativi basati sul loro modo di lavorare piuttosto che sull’utilizzo che se ne fa. Una prima classificazione basata sul modo di lavorare dei sistemi operativi è la seguente: • sistemi monoprogrammati • sistemi multiprogrammati • sistemi distribuiti I sistemi monoprogrammati sono quei sistemi operativi che permettono l’esecuzione di un programma per volta. Quindi tutte le risorse dell’elaboratore sono a disposizione dell’unico programma in esecuzione, le quali rimangono inutilizzate, se il programma non ne necessita. I sistemi multiprogrammati sono di due tipologie differenti a seconda del fatto che sono monoutente o multiutente. I primi, come gli attuali PC, permettono l’esecuzione di più programmi contemporaneamente eseguiti da un singolo utente, attraverso l’apertura e la gestione di differenti finestre di lavoro, in ognuno delle quali lavora una applicazione. Il secondo tipo di sistemi multiprogrammati, quelli multiutente, sono quelli chiamati “centralizzati” in cui l’elaboratore è composto da un “mainframe”, un’unità dotata di capacità elaborativi (CPU e memoria centrale) e da tanti terminali, postazioni composte da tastiera e video, senza capacità elaborativi. Gli utenti attraverso i terminali mandano in esecuzione i programmi verso il mainframe e nello stesso istante il mainframe esegue contemporaneamente programmi di diversi utenti. I sistemi distribuiti sono le reti di calcolatori, ovvero sono composti da elaboratori che hanno capacità di elaborazione autonoma, connessi fra loro ed in grado quindi di utilizzare risorse fra loro condivise (file, cartelle, dischi, stampanti). Una seconda classificazione è basata sul modo di utilizzo del sistema operativo. Mettiamo fra questi in evidenza i seguenti tipi di sistemi operativi: • batch • time sharing • real time I sistemi batch sono quelli che non hanno interazioni fra utente e macchina. L’utente deposita il suo programma in ingresso al sistema (compresi gli input per la sua esecuzione) e in tempi successivi potrà vedere il risultato dell’elaborazione. I sistemi time-sharing sono quelli che suddividono il tempo di lavoro dell’elaboratore in modi equo fra i vari programmi che sono in esecuzione. Ad ogni programma viene assegnato un quanto di tempo fisso della CPU, chiamato time-slice. Il time-slice assegnato ad ognuno dei programma è lo stesso. L’utente in questo modo vede avanzare il proprio programma in continuazione e ha l’impressione di avere l’intero sistema a disposizione, quando in realtà in ogni piccolo istante solo un programma alla volta è realmente in esecuzione. I sistemi real-time sono quelli che hanno continue interazioni fra utente e macchina con tempi di risposta della macchina che devono essere molto brevi. Gli aspetti grafici di questi sistemi non sono normalmente molto curati per non appesantire il sistema e rallentarne i suoi tempi di risposta. Struttura gerarchica di un sistema operativo (struttura a cipolla) Un sistema operativo è un sostare molto complesso e come tale è suddiviso in moduli ognuno dei quali realizza una differente funzione. Una delle più note strutture dei sistemi operativi è la cosiddetta “struttura a cipolla”, una struttura gerarchica composta da vari livelli, ognuno dei quali gestisce una delle risorse hardware dell’elaboratore: • • • • • livello livello livello livello livello 0: 1: 2: 3: 4: hardware nucleo (gestione del processore) gestione della memoria centrale gestione delle periferiche file system (gestione delle memorie di massa e delle informazioni ivi contenute) Il livello 0 non fa parte del sistema operativo e serve solo ad indicare che il sistema operativo si appoggia sull’hardware per gestirne le risorse. Ognuno di questi livelli gestisce una risorsa hardware virtualizzandola, cioè mettendo a disposizione di ogni programma una risorsa dedicata. Il livello 1 mette a disposizione del programma un processore virtuale dedicato al programma; il livello 2 una memoria virtuale dedicata al programma e così via per i livelli successivi. Concetto di processo e processore Un processo è un programma in esecuzione. Mentre il programma è un’entità statica (è un codice scritto e salvato in un file), che descrive la sequenza di esecuzione delle istruzioni, il processo è un’entità dinamica rappresentante il programma lanciato in esecuzione che evolve attraverso degli stati. Il processore (o CPU) è quel dispositivo che permette l’evoluzione dei processi. In sistemi a singolo processore solo un processo per volta può essere in esecuzione in un singolo istante. Cicli di vita di un processo (diagramma degli stati) Dal momento in cui un programma viene mandato in esecuzione, il processo evolve attraverso diversi stati fino alla sua conclusione. Il processo quindi subisce varie transizioni di stato in base agli eventi che si verificano. In seguito alle transizioni di stato il processo può trovarsi in vari stati di avanzamento passando per un certo stato anche diverse volte. Gli stati in cui si può trovare un processo sono: • • • • pronto esecuzione attesa terminazione Lo stato di pronto è lo stato in cui un processo di trova quando è stato caricato in memoria centrale e quindi è pronto per essere eseguito appena ad esso verrà assegnata la CPU. In questo stato si può quindi trovare una coda di processi (ready-list) Lo stato di esecuzione è lo stato in cui un processo si trova quando ad esso viene assegnata la CPU e quindi sta effettivamente evolvendo. Solo un processo per volta si può trovare in questo stato nei sistemi a singolo processore. Lo stato di attesa è lo stato in cui un processo si trova quando esso sta richiedendo una risorsa o un servizio al sistema operativo (generalmente l’esecuzione di una operazione di I/O) e aspetta che la risorsa gli sia assegnata o il servizio richiesto sia completato. Lo stato di terminazione è lo stato in cui il processo si trova quando termina e il sistema operativo può rilasciare le risorse ad esso assegnate. Il diagramma degli stati illustra le transizioni di stato che un processo può avere. • da pronto ad esecuzione: quando al processo (già caricato in memoria centrale) viene assegnata la CPU • da esecuzione a pronto: quando il tempo di CPU assegnato al processo scade senza che il processo abbia terminato. Il processo viene riaccodato fra i processi in stato di pronto che aspettano la CPU per poter continuare l’elaborazione • da esecuzione a terminazione: quando il processo termina durante il tempo di CPU che il sistema operativo gli ha assegnato • da esecuzione ad attesa: quando il processo in esecuzione richiede al sistema operativo una risorsa o un servizio (generalmente l’esecuzione di un’operazione di I/O). da attesa a pronto: quando la risorsa richiesta dal processo è disponibile o il sistema operativo è in grado di fornire il servizio richiesto dal processo (per esempio è pronto l’input richiesto dal processo). In questo caso il processo non va subito in esecuzione ma nello stato di pronto nel quale aspetta che gli venga assegnata di nuovo la CPU per evolvere. • Compiti del nucleo Il nucleo è il primo livello del sistema operativo e quindi quello più vicino all’hardware e le cui funzioni sono di supporto ai livelli superiori. Il compito principale del nucleo è quello di generare un processore virtuale per ognuno dei processi attivi del sistema, in modo tale che l’assegnazione del processore fisico ad un processo piuttosto che ad un altro non influenzino il comportamento logico del sistema. Le funzioni principali del nucleo sono: • scelta del processo cha passa dallo stato di pronto a quello di esecuzione (schedulazione dei processi) • gestione delle interruzioni • salvataggio e ripristino dello stato dei processi • sincronizzazione dei processi • gestione del deadlock Gestione delle interruzioni (interrupt) Una interruzione è un meccanismo attraverso cui un processo interrompe la sua esecuzione. Si distinguono interruzioni hardware e software. Le prime sono meccanismi hardware attraverso i quali una periferica avverte la CPU che la periferica è pronta per scambiare dei dati nelle operazioni di I/O di un programma. Per esempio in una operazioni di input, l’utente immette un valore nella tastiera in modo asincrono rispetto al funzionamento di un programma. Il programma rimane fermo fino alla ricezione del valore inserito in input dall’utente che lo esegue. Per quanto l’utente possa essere veloce nell’inserimento del dato, (per esempio svolge l’operazione in un secondo) in quel frangente la CPU potrebbe eseguire circa un miliardo di istruzioni. Per questo l’esecuzione del processo viene interrotta e la CPU viene assegnata ad un altro processo. Quando il dato è stato inserito dall’utente attraverso la tastiera, la periferica avverte la CPU di questo fatto e il processo può così essere in grado di proseguire. L’arrivo di un interrupt hardware causa il passaggio di stato da attesa a pronto del processo. Ci sono due tipi di interrupt: mascherabili e non mascherabili. La gestione dei primi può essere differita nel tempo, quando ad esempio il sistema sta gestendo un altro interrupt di priorità maggiore. I secondi tipi di interruzione hanno priorità massima e devono essere immediatamente gestiti. Vengono generati quindi in situazioni di emergenza del sistema. Per quanto riguarda le interruzioni software ci si riferisce a chiamate del processo a routine (primitive) del sistema operativo. Si distinguono due tipi di interruzioni software: da programma e da eccezione. Queste ultime (chiamate anche trap) sono scatenate quando avviene un errore durante l’esecuzione di un programma (divisione per zero, indice di un vettore al di fuori dell’intervallo ecc.). Le interruzioni da programma vengono generate dal programma stesso quando ad esempio il processo esegue una operazioni di I/O, proprio per evitare che al processo resti assegnata la CPU nel periodo in cui la periferica, molto più lenta del processore, esegue l’operazione di I/O. Nell’esempio precedente di un processo che esegue un’operazione di input da tastiera, il processo richiama il sistema operativo attraverso una interruzione per interrompersi, per poi essere riattivato dopo che l’input verrà inserito dall’utente. Questa tipo di interruzione software causa il passaggio di stato del processo da esecuzione ad attesa. Salvataggio e ripristino dello stato di un processo Quando un processo viene interrotto a causa di una interruzione – ma anche quando per esempio è finito il tempo di CPU assegnatogli dal sistema operativo – il suo stato deve essere memorizzato per poter essere poi ripristinato e far ripartire il processo dal punto in cui era stato interrotto esattamente nella stessa situazione in cui era il processo al momento della interruzione. Lo stato del processo viene chiamato contesto. Il contesto del processo deve innanzitutto contenere il valore del registro PC (indicante l’indirizzo di memoria della prossima istruzione da eseguire) in modo da far riprendere l’esecuzione del programma proprio a partire dall’istruzione successiva all’ultima eseguita. Interrompendo l’esecuzione del processo e attivando l’esecuzione di un altro processo in coda nello stato di pronto, il valore del PC viene cambiato in modo che contenga l’indirizzo della prossima istruzione del nuovo processo in esecuzione. Non è necessario invece salvare il valore del registro IR in quanto l’istruzione in corso viene portata sempre a termine (cioè fino al termine della fase di execute) prima che avvenga l’interruzione del processo. Inoltre il processore ha al suo interno degli altri registri che servono a contenere valori temporanei ottenuti durante l’esecuzione del processo (per esempio il risultato di una operazione aritmetico-logica) appena eseguita dalla ALU. Anche questi valori fanno parte del contesto del processo e devono essere salvati in modo che possano essere rimessi nei registri nel momento in cui riprenderà l’esecuzione del processo precedentemente interrotto. Il contesto del processo, formato dal valore del PC e degli altri registri della CPU vengono salvati in un’area della memoria centrale chiamata stack, una struttura a pila gestita in modo LIFO (last in first out). Schedulazione dei processi Questa funzione del nucleo, la parte del sistema operativo più frequentemente richiamata, permette di scegliere quale dei processi in stato di pronto andrà in esecuzione. In particolare attraverso la schedulazione dei processi viene scelto il processo che a cui verrà assegnata la CPU e per quanto tempo avrà a disposizione il processore per evolvere. Esistono varie tecniche di schedulazione dei processi, alcune molto semplici e altre più complesse. Occorre ricordare che il sistema operativo è un software e come tale concorre con gli altri software applicativi all’acquisizione delle risorse. Questo vuol dire che spesso tecniche di schedulazione o algoritmi di risoluzione di problemi, per quanto sofisticati possono essere meno validi di altri più semplici perché richiedono tempi di esecuzione più lunghi e quindi uso di risorse per più lungo tempo, togliendole così ai programmi applicativi. Nessun sistema può essere efficiente se serve per far funzionare solo il sistema operativo e non gli applicativi. Quindi una parte di sistema operativo così frequentemente richiamata, come la schedulazione dei processi, deve utilizzare algoritmi non troppo complessi. Le più note tecniche di schedulazione dei processi sono: FIFO o FCFS (First Come First Served). Al primo processo nella coda di pronto viene assegnata la CPU fino al suo termine o finché non effettua un’operazione di I/O (che come abbiamo già visto fa sospendere immediatamente il processo) Round Robin: ogni processo in stato di pronto avrà a disposizione un quanto di tempo (chiamato time slice) per evolvere. Il tempo di CPU cioè viene suddiviso in time slice della stessa dimensione(dell’ordine dei millisecondi) e in ogni time slice verrà schedulato uno dei processi in stato di pronto. Allo scadere del time slice il processo viene posto in fondo alla coda dei processi in stato di pronto e il processore viene allora assegnato al primo processo nella coda di pronto, a meno che durante il time slice il processo non sia terminato (in questo caso passa nello stato di terminato) oppure esegue un’operazione di I/O (in questo caso viene interrotto e passa nello stato di attesa); anche in questi casi il processore viene assegnato al primo processo nella coda di pronto. Inverso del time slice: questa tecnica si basa sui concetti di I/O Bound e CPU Bound. I primi sono i processi che eseguono molte operazioni di I/O e quindi sfruttano meglio le altre risorse hardware del sistema; i secondi sono quelli che fanno molti calcoli utilizzando quindi molto la CPU e poco le periferiche. Questa tecnica privilegia i processi del primo tipo, utilizzando il concetto di time slice della tecnica precedente. I processi che utilizzano meno il time slice vengono messi in testa alla coda dei processi in pronto e saranno schedulati prima. Se abbiamo tre processi, che hanno utilizzano rispettivamente 20,10,30 millisecondi prima di essere interrotti da un’operazione di I/O, nel giro successivo verranno riordinati con il secondo processo che verrà schedulato per primo, poi il primo e infine il terzo. Ci sono altre tecniche più complesse che usano più liste (code) nello stato di pronto con differenti priorità. Una di queste tecniche assegna una priorità predefinita ad ogni processo, in base al fatto che il processo sia di sistema operativo, oppure di un utente con maggiore priorità (amministratore) o di utente “normale”. Ogni coda viene gestita attraverso le tecniche FCFS o Round Robin e finché queste liste sono piene vengono schedulati i processi di queste code. Quando la coda di priorità maggiore è vuota si passa a gestire la coda di priorità successiva e così via, ma quando una coda ritorna ad avere qualche processo si riprende a gestire la lista con priorità maggiore. Un’altra tecnica multilista, favorisce processi più corti rispetto a quelli più lunghi. Ogni processo quando parte viene accodato nella lista con la massima priorità, ma ogni volta che viene interrotto o scade il time slice perde un livello di priorità e viene accodato nella lista con una priorità minore. Le code vengono gestire come nella tecnica precedente. Entrambe le tecniche con liste multiple potrebbero portare a situazione in cui alcuni processi con bassa priorità possano non ottenere mai la CPU: per evitare questo (blocco individuale) periodicamente vengono gestite comunque le code con bassa priorità dando un tempo di CPU più alto per portarli a termine. Sincronizzazione dei processi Il problema della sincronizzazione dei processi è legata al fatto che i processi (compresi quelli di sistema operativo) sono in competizione per ottenere le stesse risorse nello stesso istante. Le risorse possono essere fisiche (quelle HW relative al processore, la memoria centrale e le periferiche) e quelle logiche (buffer, file). Esistono diversi tipi di relazione fra processi concorrenti: • cooperazione • competizione • interferenza Due processi sono in cooperazione se sono legati logicamente fra loro in modo che ognuno ha bisogno dell’altro per evolvere. Il caso tipico è lo schema dei processi produttore/ consumatore, nel quale un processo produttore P genera dei dati e li inserisce in un buffer (comune al consumatore) e un processo consumatore C che legge i dati dal buffer e li manipola. C può leggere i dati dal buffer solo dopo che P l’ha riempito e P può riempire il buffer solo dopo che C l’ha svuotato. Non è possibile avere una sequenza non ordinata P-C-P-C…, perché se avessimo una sequenza P-P-C-C si perderebbe il primo blocco di dati inseriti nel buffer e con una sequenza P-C-C-P il consumatore leggerebbe due volte i dati del primo blocco. Due processi sono in competizione se sono indipendenti dal punto di vista logico ma hanno in comune la necessità di lavorare sulla stessa risorsa. L’acquisizione di tale risorsa e i tempi di completamento del lavoro possono dipendere sensibilmente da questa “competizione”. La questione diventa stringente quando è associata al problema della mutua esclusione, per cui l’acquisizione di una risorsa è esclusiva di un processo con conseguente attesa di tutti gli altri processi che necessitano della stessa risorsa. Si ha una interferenza fra processi quando il risultato degli stessi dipende dal momento in cui vengono eseguiti, cioè dalle sequenze temporali di utilizzo delle risorse del sistema da parte di tutti i processi che le richiedono. Un caso tipico è quello di due processi che richiedono una stampa: non si possono intervallare i blocchi mandati in stampa da un processo con quelli dell’altro processo. Alcune risorse infatti non possono essere sottratte ad un processo che le ha acquisite ed assegnarle ad un altro, altrimenti si possono avere risultati che variano da esecuzione ad esecuzione, cioè causare errori dipendenti dal tempo, difficili da trovare perché dipendenti dalla sequenza di esecuzione e non facilmente ripetibili. Il meccanismo più semplice di sincronizzazione sarebbe quello di assegnare una risorsa ad un processo per tutto il tempo necessario per farlo terminare, ma questo penalizzerebbe troppo gli altri processi. In realtà nelle situazioni sopra introdotte spesso il problema è legato ad alcuni passi limitati dell’esecuzione dei programmi, nei quali l’esecuzione del processo che sta utilizzando una determinata risorsa non deve essere interrotto. Queste parti del programma sono chiamate “zone critiche” (per esempio operazioni di stampa o di scrittura su un file). Queste operazioni devono essere rese indivisibili, cioè non interrompibili. Quando un processo acquisisce una risorsa non deve essere interrotto finché non ha terminato l’operazione a rischio, cioè ha eseguito la zona critica. Un meccanismo tipico per gestire queste zone critiche e la sincronizzazione dei processi è quello del semaforo. Quando un processo acquisisce una risorsa e sta eseguendo una zona critica il semaforo diventa “rosso” e gli altri processi rimangono bloccati in attesa che il semaforo diventi “verde”. Quando il primo processo ha finito di eseguire la zona critica e quindi di lavorare su quella risorsa sblocca il semaforo e un altro processo precedentemente bloccato può acquisirla. Lo schema dei processi produttore/consumatore precedentemente descritto si risolve con due semafori, il primo che serva a bloccare il consumatore quando il produttore sta scrivendo i dati sul buffer e il secondo per bloccare il produttore quando il consumatore sta leggendo i dati dal buffer. Questo meccanismo permette ai due processi di alternarsi nell’accesso del buffer. Deadlock Un sistema è in stato di deadlock (o stallo) quando nessuno dei processi attivi riesce a terminare. Un tipico caso di deadlock è quello di due processi e di due risorse: un processo ha acquisito una risorsa che è richiesta dall’altro processo che ha acquisito la seconda risorsa, richiesta dal primo processo (attesa circolare). Seguono esempi. Il deadlock viene generato quando si hanno contemporaneamente le seguenti situazioni: o o o allocazione parziale delle risorse: le risorse sono allocate ad un processo non all’inizio dell’esecuzione di una zona critica ma man mano che le utilizza negazione del prerilascio forzato: il sistema non può sottrarre forzatamente le risorse già allocate ai processi attesa circolare: lista di processi, ognuno dei quali è in attesa di una risorsa già allocata al processo successivo. Il deadlock può essere gestito o attraverso meccanismi di prevenzione o attraverso meccanismi di riconoscimento e recovery. Nella progettazione di un sistema operativo occorre tenere in considerazione sia che il deadlock è una situazione grave che porta ad un blocco totale del sistema e quindi alla necessità di farlo ripartire abortendo tutti i processi che erano in esecuzione in quel momento, sia che è comunque un fenomeno che accade raramente e quindi non sempre la scelta di adottare meccanismi di prevenzione è quella migliore. Infatti l’allocazione delle risorse è l’attività che viene eseguita continuamente da un sistema operativo e il dover controllare ogni volta se una allocazione di una risorsa porti ad uno stato di deadlock può essere molto gravosa. Le principali tecniche di prevenzione sono: • • • allocazione globale delle risorse allocazione gerarchica delle risorse algoritmo del banchiere L’allocazione globale delle risorse prevede che ad un processo vengono assegnate le risorse che richiede solo se sono tutte disponibile, altrimenti rimane in attesa. La controindicazione di questa tecnica è che ad un processo possono venire allocate delle risorse che utilizzerà solo dopo molto tempo e che verranno quindi rese indisponibili ad altri processi che ne hanno bisogno, peggiorando le prestazioni del sistema. L’allocazione gerarchica delle risorse prevede che alle risorse vengano assegnati dei livelli di priorità e che un processo deve ottenere le risorse partendo da quelle di priorità più bassa fino a quelle di priorità a più alta. In questo modo si impedisce che un processo possa avere acquisito risorse più rare - e di tenerle quindi bloccate ad altri processi - in attesa che gli vengano assegnate risorse più comuni. L’algoritmo del banchiere è applicabile solo se è noto a priori il numero massimo di risorse che ogni processo necessita, informazione non sempre conosciuta. Il sistema operativo tramite questo algoritmo effettua sempre una scelta sicura: ogni qualvolta i processi richiedono l’allocazione di risorse l’algoritmo sceglie la richiesta per cui almeno un processo può terminare. Seguono esempi Per quanto riguarda il riconoscimento del deadlock in realtà il sistema operativo non può mai essere sicuro che il deadlock si sia effettivamente verificato. Ci si può basare solo su metodi statistici basati sul superamento di alcune soglie temporali: tempo massimo di attesa di una risorsa da parte di un processo o tempo massimo di possesso di una risorsa da parte di un processo. Se vengono superate tali soglie il sistema considera che si sia verificata una situazione di deadlock e cerca di effettuare il recovery, ovvero recuperare lo stato del sistema precedente al deadlock. Ovviamente non sempre si può essere certi che il superamento di queste soglie sia realmente causato da un deadlock. Le principali tecniche di recovery sono: o o o interruzione di tutti i processi interruzione di un processo alla volta fino a quando il deadlock non sia rimosso rilascio forzato di una risorsa per volta ad un processo prescelto fino a quando il deadlock non sia rimosso Il primo metodo è sicuro ma è anche il meno efficiente perché prevede che i processi siano tutti abortiti e occorre farli ripartire tutti quanti. Inoltre dovendo far ripartire tutti i processo di nuovo, si potrebbero ripetere le condizioni che avevano portato al deadlock. Il secondo metodo prevede che si scelga un processo da eliminare in modo da riallocare le sue risorse agli altri processi bloccati. Se il deadlock è eliminato il procedimento termina, altrimenti si sceglie un altro processo da eliminare e così via. Il terzo metodo prevede che si selezioni un processo al quale viene tolta una risorsa già assegnata per allocarla ad un altro processo in attesa della stessa. Se il deadlock è eliminato il procedimento termina, altrimenti la stessa risorsa viene tolta ad un altro processo e rassegnata e così via. I criteri per scegliere i processi da eliminare si basano o su una priorità più bassa, piuttosto che su quanto il processo ha già lavorato e su quanto manca alla sua terminazione, scegliendo i processi che hanno lavorato di meno e su quelli a cui manca più tempo per terminare. La situazione di blocco individuale si ha quando solo alcuni processi sono bloccati mentre il sistema nella sostanza lavora. Può accadere quando alcuni processi hanno bassa priorità e sono molto penalizzati nella competizione con altri processi sull’assegnazione delle risorse. E’ il caso ad esempio delle tecniche di schedulazione dei processi basate su liste multiple, alcune delle quali con priorità molto bassa. I processi delle liste con bassa priorità potrebbero non essere mai schedulati e quindi essere di fatto bloccati. Livello 2: gestione della memoria centrale Un programma per essere eseguito deve prima essere caricato in memoria centrale. Il compito del secondo livello del sistema operativo è quello di gestire la memoria centrale in modo da rendere massimo il numero di processi allocati e aumentare quindi il grado di multiprogrammazione. Ovviamente per un sistema operativo monoprogrammato la gestione della memoria è molto semplice ed è dedicata all’unico processo in esecuzione in quel momento (a parte l’area riservata al sistema operativo stesso). Prima di vedere le varie tecniche utilizzate per gestire la memoria da parte del sistema operativo occorre fare alcune premesse. In un programma (scritto in un linguaggio di programmazione ad alto livello) i riferimenti e gli indirizzi di memoria vengono effettuati tramite nomi simbolici assegnati a delle variabili. Per poter eseguire il programma occorre che venga stabilita una corrispondenza fra lo spazio logico (nomi simbolici delle variabili) e lo spazio fisico (indirizzi di locazioni di memoria). Questa corrispondenza può essere svolta in vari modi. Quando il traduttore genera il codice binario del programma tradotto, gli indirizzi sono relativi ad un indirizzo base (offset o piazzamento rispetto ad un indirizzo base). Per quanto riguarda gli indirizzi delle istruzioni del programma l’indirizzo base è quello a partire dal quale verrà poi caricato il programma; per quanto riguarda le variabili, l’indirizzo base è quello a partire dal quale verrà caricato poi la parte dati del programma. Per mandare il programma in esecuzione dovranno intervenire altri due software – linker e loader – col compito rispettivamente di risolvere i riferimenti esterni presenti nel programma (per esempio le chiamate alle funzioni di I/O) e di caricare il programma effettivamente in memoria centrale. Il loader ha anche il compito di risolvere i riferimenti agli indirizzi in termini di indirizzi fisici assoluti, operazione che prende il nome di rilocazione. Un modo più efficiente per risolvere la corrispondenza fra spazio logico e spazio fisico della memoria può essere risolta attraverso una rilocazione dinamica effettuata a livello hardware da un dispositivo intelligente chiamato MMU (memory management unit) come vedremo più avanti nella trattazione delle tecniche di gestione della memoria virtuale. E’ importante sottolineare e rammentare che se si hanno a disposizione N bit per indirizzare una locazione di memoria (per esempio il registro PC per indirizzare la locazione di memoria che contiene la prossima istruzione da eseguire), lo spazio logico di indirizzamento del processore sarà di 2N locazioni di memoria. Lo spazio fisico della memoria è invece composto dalle locazioni fisiche realmente presenti nella memoria fisica. Di seguito si descriveranno le tecniche di gestione della memoria che si sono susseguite a partire dalle più antiche a quelle più recenti. Partizionamento statico Il partizionamento statico prevede che la memoria centrale fisica sia suddivisa in aree (partizioni) di dimensione fissate a priori, in ognuna delle quali può essere caricato un programma di dimensioni minore o uguale alla partizione. Per gestire il partizionamento statico il sistema operativo necessita di una tabella delle partizioni contenente il numero della partizione, la sua dimensione, l’indirizzo iniziale della partizione e un bit di stato per indicare se la partizione in quel momento contiene o meno un programma. Ad esempio per una memoria fisica di 1024KB numero partizione dimensione indirizzo iniziale bit di stato 1 8K 312K 1 2 32K 320K 1 3 32K 352K 0 4 120K 384K 0 5 520K 504K 1 dove il valore 1 nel bit di stato indica che la partizione è occupata, 0 che è libera. Nell’esempio precedente se c’è da caricare un programma di 30KB può essere caricato sia nella partizione 3 che nella 4. Un programma di 50KB verrà caricato nella partizione 4, mentre un programma di 150KB non può essere caricato finché non si libera la partizione 5. Se nella partizione 5 è stato caricato un programma di 200KB gli altri 320KB non utilizzati dal programma non verranno utilizzati, creando quelli che vengono chiamati frammenti, cioè aree di memoria non utilizzate. Partizionamento dinamico o variabile Il partizionamento dinamico prevede che la memoria centrale fisica sia suddivisa in partizioni la cui dimensione coincide esattamente con quella del programma che vi sarà caricato. Per gestire il partizionamento dinamico il sistema operativo dovrà gestire due tabelle, una per le partizioni libere, una per quelle occupate. Ad esempio Tabelle delle partizioni occupate numero partizione dimensione indirizzo iniziale 1 8K 312K 2 32K 320K 3 24K 352K 4 120K 384K Tabelle delle partizioni libere numero partizione dimensione indirizzo iniziale 1 8K 376K 2 520K 504K Se a questo punto dovrà essere caricato un programma di 120K la partizione 2 delle partizioni libere verrà spezzata in due, formando una partizione occupata di 120K in cui verrà caricato il programma, e una seconda partizione libera di 400K. Tabelle delle partizioni occupate numero partizione dimensione indirizzo iniziale 1 8K 312K 2 32K 320K 3 24K 352K 4 120K 384K 5 120K 504K Tabelle delle partizioni libere numero partizione dimensione indirizzo iniziale 1 8K 376K 2 400K 624K Questa tecnica migliora la situazione della frammentazione della memoria rispetto al partizionamento statico, nel quale le parti non utilizzate dal programma allocato in una partizione non erano disponibili per nessun altro programma, ma a lungo andare nella memoria fisica si potranno venire a creare dei piccoli frammenti non sufficienti a caricare ulteriori programmi, cioè la memoria fisica sarà piena di “buchi” non sfruttabili per ulteriori allocazioni di programmi. La scelta da parte del sistema operativo delle partizioni da utilizzare per caricare un programma si basano su tre distinte tecniche: • • • first fit: si sceglie la prima partizione libera in grado di contenere il programma da caricare best fit: si sceglie la partizione che meglio si adatta al programma da caricare. In pratica viene scelta la partizione più piccola fra quelle in grado di contenere il programma. Più efficiente della tecnica first fit ma alla lunga crea frammenti piccoli non più riutilizzabili worst fit: si sceglie la partizione che peggio si adatta al programma da caricare. In pratica viene scelta la partizione libera più grande. Questa tecnica apparentemente poco efficiente invece permette di creare nuove partizioni libere grandi ed adatte a contenere altri programmi. Attività del S.O. in connessione con la gestione della memoria. Rilocazione • Il S.O. deve associare agli indirizzi logici di un programma gli indirizzi fisici. Allocazione • Il S.O. deve tener traccia di quali parti della memoria sono correntemente usate e da quali processi. Protezione e Condivisione • Il S.O. deve proteggere ogni processo da interferenze (accesso da parte di un processo allo spazio di indirizzamento di un altro processo) indesiderate da parte di altri processi e deve permettere a diversi processi di accedere alla stessa porzione di memoria. Gestione degli scambi • Il S.O. deve gestire gli scambi di informazioni tra memoria centrale e disco quando la prima non è abbastanza grande per contenere i processi (swapping). Rilocazione Per rilocazione si intende l’ associazione(Mapping) tra lo spazio di indirizzi logici del programma e lo spazio di indirizzi fisici della memoria. L’ associazione degli indirizzi nella fase di esecuzione è svolta da un dispositivo detto unità di gestione della memoria (MMU-Memory Management Unit) attraverso il registro di rilocazione o registro base La possibilità di caricare un programma a partire da una posizione arbitraria della memoria presuppone che sul codice venga effettuata una rilocazione di tutti gli indirizzi logici presenti nel programma in indirizzi fisici. Rilocazione effettuata mediante un registro base, registro di rilocazione, che contiene l’indirizzo di caricamento del programma. Quando un processo utente genera un indirizzo, si somma a tale indirizzo, chiamato OFFSET il valore contenuto nel registro di rilocazione. Partizionamento rilocabile Per risolvere il problema della frammentazione creato dalla tecnica del partizionamento dinamico, la soluzione più evidente è quella di ricompattare tutti i frammenti di memoria in un'unica partizione, di solito in fondo alla memoria fisica. Questo è ciò che fa la tecnica del partizionamento rilocabile. Questa soluzione ovvia da un punto di vista concettuale comporta però problemi pratici, ad iniziare dal cosa fare quando occorre spostare i programmi già allocati. E’ inaccettabile pensare di far ripartire daccapo il programma stesso, sia perché si perderebbe tutto il lavoro svolto fino a quel momento, sia perché i programmi potrebbero avere svolto delle operazioni irreversibili (come la scrittura su un file). Per evitare questi problemi occorre prevedere che il sistema operativo gestisca una coppia di registri per ogni partizione: un registro base di rilocazione e un registro limite. Quando un processo viene schedulato per l’esecuzione, il registro base viene caricato con l’indirizzo di partenza della partizione e quello limite con la lunghezza della partizione. Ogni indirizzo di memoria viene sommato al registro base prima di essere spedito alla memoria. Ad esempio se prima del ricompattamento la situazione della memoria fisica era la seguente: SO 0K libero 100K prog. 1 150K libero 200K prog. 2 220K libero 250K prog. 3 300K libero 400K 500K dopo il ricompattamento la nuova situazione sarà: SO 0K prog. 1 100K prog. 2 150K prog. 3 180K libero 280K 500K Nell’esempio il registro base del progr 1 ad esempio verrà cambiato e caricato con l’indirizzo di partenza dopo il ricompattamento, cioè 100k invece che 150k, come era prima del ricompattamento. Il registro limite per ogni partizione ha la funzione di protezione degli accessi non consentiti in memoria anche dopo il ricompattamento per evitare di accedere ad un indirizzo non facente parte del programma in questione. Per sapere se l’indirizzo ricalcolato è un indirizzo consentito per quel programma basterà che non superi la somma dei valori del registro base e del registro limite. Il ricompattamento può avvenire ogni qualvolta si libererà una partizione alla fine dell’esecuzione di un processo (efficiente per avere sempre un’unica area libera ma con grandi costi di gestione) oppure periodicamente o quando non c’è spazio sufficiente in memoria per caricare un nuovo programma. Memoria virtuale Il partizionamento rilocabile supera il problema della frammentazione. Non risolve il problema di poter caricare in memoria un programma più grande dell’area libera a disposizione o addirittura più grande della memoria fisica stessa. Il concetto di memoria virtuale permette di superare il limite della dimensione fisica della memoria, mettendo a disposizione dell’elaboratore una memoria illimitata, aumentando di gran lunga il grado di multiprogrammazione. Il concetto di memoria virtuale si basa su due principi statistici, chiamati principi di località spaziale e temporale. Il principio di località spaziale afferma che quando è stato utilizzato un indirizzo di memoria è probabile che vengano utilizzati presto quelli adiacenti ad esso (sequenzialità dei programmi e di strutture dati quali i vettori) Il principio di località temporale afferma che se è appena stato utilizzato un indirizzo è probabile che possa essere riutilizzato di nuovo (istruzioni cicliche, sottoprogrammi, dati strutturati). Dai principi di località si deduce che non è necessario caricare tutto il programma in memoria nello stesso istante per eseguirlo, ma che per un certo periodo si utilizzeranno le stesse locazioni o locazioni adiacenti. Per cui ad ogni istante saranno necessarie in memoria solo porzioni del programma. Inoltre le porzioni di programma caricate in memoria non dovranno necessariamente caricate in area fisiche contigue: basterà garantire la contiguità logica del programma. Le parti non caricate dei programmi saranno comunque presenti in un’area del disco fisso chiamata area di swap. Memoria virtuale paginata Nella gestione della memoria virtuale paginata la memoria fisica sarà suddivisa in blocchi (pagine fisiche) tutte della stessa dimensione; il programma è suddiviso in pagine logiche tutte della stessa dimensione. La dimensione delle pagine logiche e fisiche è la stessa, per cui una pagina logica è esattamente contenuta in una pagina fisica, eliminando completamente il problema della frammentazione. Le pagine logiche e fisiche sono normalmente di dimensioni limitate e la dimensione delle pagine è una potenza di 2 (2K, 4K). Di ogni programma verranno caricate in memoria solo alcune pagine, quelle che serviranno in un determinato momento, le altre si trovano nell’area di swap. Quando si fa riferimento ad un indirizzo logico di una pagina questo sarà composto da un numero di pagina e da uno spiazzamento (offset). La conversione dell’indirizzo logico in indirizzo fisico è fatta dalla MMU attraverso la tabella delle pagine, una tabella che associa ad ogni pagina logica di un processo la corrispondente pagina fisica (se allocata in memoria). All’indirizzo della pagina fisica verrà sommato lo spiazzamento. Esiste quindi per ogni processo una tabella delle pagine per quel processo. Nel caso in cui si fa riferimento ad una locazione di una pagina logica che non sia caricata in memoria, la pagina dovrà essere copiata dall’area di swap alla memoria centrale. Se non ci fossero pagine fisiche libere in memoria occorre scaricare una delle pagine caricate. Nel caso in cui la pagina da scaricare fosse stata modificata (per esempio una pagina contenente l’area dati di un processo) dovrà essere salvata nell’area di swap. Questa informazione è memorizzata per ogni pagina in un bit della tabella delle pagine, chiamato change bit, che indica se la pagina è stata modificata dall’ultima volta che è stata caricata in memoria. Un altro bit, chiamato reference bit, indica se la pagina è stata utilizzata dall’ultima volta che è stata caricata in memoria centrale e servirà per decidere l’eventuale pagina fisica da scaricare (vedi in seguito). L’algoritmo può essere schematizzato così: calcolo del numero di pagina logica da utilizzare SE la pagina logica è in memoria ALLORA eseguire l’istruzione ALTRIMENTI SE c’è una pagina fisica libera ALLORA carica la pagina logica nella pagina fisica libera ALTRIMENTI scegliere la pagina fisica da scaricare SE change bit=1 ALLORA salvala nell’area di swap FINE SE carica la pagina logica nella pagina fisica libera FINE SE aggiorna la tabella delle pagine eseguire l’istruzione FINE SE Quando una pagina logica non si trova nella memoria centrale viene generato un page fault che indica un fallimento nella ricerca della pagina. Se non ci sono pagine fisiche libere occorre scegliere una pagina da scaricare. Le tecniche utilizzate dal sistema operativo per scegliere la pagina da scaricare sono le seguenti: o FIFO: viene eliminata dalla memoria la pagina fisica che sta da più tempo in memoria. Tecnica semplice ma poco efficiente e genera molti page fault, perché se una pagina è presente in memoria da molto tempo è probabile che sia una pagina usata molto frequentemente o LRU (last recently used): viene scaricata la pagina fisica che non è stata utilizzata da più tempo. Occorre salvare per ogni pagina l’ultimo tempo di accesso. Tecnica molto efficiente ma che comporta uno spreco di memoria o NUR (not used recently): viene scaricata una delle pagine fisiche non usate di recente. Periodicamente il reference bit viene resettato. Quando occorre scaricare una pagina si sceglie una di quelle con il reference bit a 0, cioè una di quelle che non è stata utilizzata dopo l’azzeramento periodico. Infatti se una pagina viene utilizzata dopo l’azzeramento il reference bit verrà posto a 1. Un po’ meno efficiente della tecnica LRU ma che non comporta spreco di memoria e di tempo. In ogni caso quando occorre scaricare una pagina fisica si evita di scegliere se possibile una di quelle che hanno il change bit a 1 (pagina modificata) perché per queste occorre salvarle nell’area di swap, operazione questa che richiede del tempo. Memoria virtuale segmentata Nella gestione della memoria virtuale segmentazione la memoria fisica sarà suddivisa in segmenti di differenti dimensioni; il programma è suddiviso in segmenti logici di differenti dimensioni in base alla struttura logica del programma (segmento codice, segmento dati, segmento stack ecc.) La dimensione dei segmenti logici e fisici sono del tutto indipendenti fra loro. Di ogni programma verranno caricate in memoria solo alcuni segmenti, quelli che serviranno in un determinato momento, gli altri si trovano nell’area di swap. Quando si fa riferimento ad un indirizzo logico di un segmento questo sarà composto da un indirizzo base del segmento e da uno spiazzamento (offset). La conversione dell’indirizzo logico in indirizzo fisico è fatta dalla MMU attraverso la tabella dei segmenti, una tabella che associa al numero del segmento logico di un processo l’indirizzo iniziale del segmento fisico in cui è allocato (se allocato in memoria). All’indirizzo iniziale del segmento fisico verrà sommato lo spiazzamento. Esiste quindi per ogni processo una tabella dei segmenti per quel processo. Nel caso in cui si fa riferimento ad una locazione di un segmento logico non sia caricata in memoria, il segmento dovrà essere copiata dall’area di swap alla memoria centrale. Se non ci fossero segmenti fisici liberi in memoria occorre scaricare una dei segmenti già caricati. Nel caso in cui il segmento da scaricare fosse stata modificato (per esempio un segmento contenente l’area dati di un processo) dovrà essere salvato nell’area di swap. Questa informazione è memorizzata per ogni segmento in un bit della tabella dei segmenti, chiamato change bit, che indica se il segmento è stata modificato dall’ultima volta che è stata caricato in memoria. Un altro bit, chiamato reference bit, indica se il segmento è stata utilizzato dall’ultima volta che è stata caricato in memoria centrale e servirà per decidere l’eventuale segmento fisico da scaricare. L’algoritmo è analogo a quello già descritto per la memoria virtuale paginata Quando un segmento logico non si trova nella memoria centrale viene generato un segment fault che indica un fallimento nella ricerca del segmento. Se non ci sono segmenti fisici liberi occorre scegliere un segmento da scaricare. Le tecniche di rimozione dei segmenti fisici da rimuovere sono simili a quelle utilizzati per le pagine con una ulteriore avvertenza che il segmento da scaricare deve essere di dimensioni maggiori o uguali a quelle del segmento logico da caricare. Questo è uno degli svantaggi della gestione della memoria virtuale segmentata rispetto a quella paginata. Un altro aspetto più grave della memoria virtuale segmentata è il problema della frammentazione, eliminabile però con un ricompattamento periodico dei frammenti, a fronte comunque di un costo di gestione che non esiste nel caso della memoria virtuale paginata. Un chiaro vantaggio della gestione della memoria virtuale paginata segmentata rispetto a quella paginata è il fatto che ad ogni segmento logico si può associare un tipo (codice, dati ecc.) e che quindi si possono definire chiaramente le operazioni che si possono effettuare su quel segmento: lettura, scrittura per i dati, esecuzione per il codice. Inoltre il compilatore può creare segmenti ulteriori (per ogni sottoprogramma, per ogni variabile strutturata) rendendo più alti i controlli di accesso ad ogni parte della memoria. Nella memoria virtuale paginata non è invece possibile effettuare alcun controllo sugli accessi perché ogni programma è suddiviso a “fettine” della stessa dimensione, senza alcuna accortezza che si tratti di parti di codice, di dati o altro. Memoria virtuale segmentata-paginata Nella gestione della memoria virtuale segmentata-paginata la memoria fisica sarà suddivisa in blocchi (pagine fisiche) tutte della stessa dimensione; il programma è suddiviso in segmenti logici a loro volta suddivisi in pagine logiche tutte della stessa dimensione. La dimensione delle pagine logiche e fisiche è la stessa, per cui una pagina logica è esattamente contenuta in una pagina fisica, eliminando completamente il problema della frammentazione presente nella gestione della memoria virtuale segmentata. Anche il problema della gestione della memoria virtuale paginata di non aver alcun controllo sugli accessi ad ogni pagina logica viene eliminato perché ogni processo è suddiviso in segmento a cui è associato il tipo (codice, dati ecc.). E’ una tecnica che elimina quindi i difetti di entrambe le tecniche da cui derivano ma è più complessa da realizzare. L’indirizzo logico è composto da tre parti: numero del segmento, numero della pagina e spiazzamento e si dovranno gestire sia la tabella delle pagine che quella dei segmenti.