<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>OS | Alessandro Torcinovich</title><link>https://aretor.it/tag/os/</link><atom:link href="https://aretor.it/tag/os/index.xml" rel="self" type="application/rss+xml"/><description>OS</description><generator>Hugo Blox Builder (https://hugoblox.com)</generator><language>en-us</language><lastBuildDate>Tue, 15 Apr 2025 00:00:00 +0000</lastBuildDate><image><url>https://aretor.it/media/icon_huca2d213830e6c8c7000bb2ad6a60511a_285235_512x512_fill_lanczos_center_3.png</url><title>OS</title><link>https://aretor.it/tag/os/</link></image><item><title>1 Introduzione</title><link>https://aretor.it/courses/os2425/lessons/01_intro/</link><pubDate>Tue, 04 Mar 2025 00:00:00 +0000</pubDate><guid>https://aretor.it/courses/os2425/lessons/01_intro/</guid><description>&lt;p>In questa lezione forniremo una panoramica generale delle componenti di un sistema di elaborazione, di un calcolatore e di un sistema operativo. Verranno toccati molti argomenti e introdotti molti concetti, a cui dedicheremo approfondimenti nelle lezioni successive.&lt;/p>
&lt;h2 id="11---sistema-di-elaborazione">1.1 - Sistema di elaborazione&lt;/h2>
&lt;p>Un &lt;strong>sistema di elaborazione&lt;/strong> o &lt;strong>sistema elaborativo (s.e.)&lt;/strong> ruota attorno quattro concetti fondamentali:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>hardware:&lt;/strong> l&amp;rsquo;insieme delle componenti fisiche con cui il s.e. è
assemblato. Sono inclusi, tra gli altri:
&lt;ul>
&lt;li>La &lt;strong>Central Processing Unit (CPU)&lt;/strong> che esegue istruzioni a essa assegnate e produce dati.&lt;/li>
&lt;li>La &lt;strong>memoria&lt;/strong> che memorizza dati per utilizzo successivo.&lt;/li>
&lt;li>I &lt;strong>dispositivi Input/Output (I/O)&lt;/strong> che permettono l&amp;rsquo;interazione tra il mondo del computer e quello esterno (p.e. il mouse, la tastiera, il monitor, lo scanner, la stampante, etc.).&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>programmi applicativi:&lt;/strong> strutture logiche ad alto livello che svolgono dei servizi per l&amp;rsquo;utente (p.e. editor di testo, fogli di calcolo, compilatori, browser web, etc.).&lt;/li>
&lt;li>&lt;strong>utente:&lt;/strong> l&amp;rsquo;entità umana che interagisce con il s.e. attraverso i dispositivi I/O.&lt;/li>
&lt;li>&lt;strong>sistema operativo (s.o.):&lt;/strong> l&amp;rsquo;insieme delle componenti logiche che controlla l&amp;rsquo;utilizzo dell&amp;rsquo;hardware e dei programmi applicativi. Il s.o. può essere visto come l&amp;rsquo;insieme di regole necessarie al corretto funzionamento del s.e.&lt;/li>
&lt;/ul>
&lt;p>Hardware, sistema operativo e programmi applicativi saranno indicati con il nome &lt;strong>calcolatore/elaboratore&lt;/strong>, anche se esistono diverse definizioni.&lt;/p>
&lt;p>In sintesi:&lt;/p>
&lt;ul>
&lt;li>L&amp;rsquo;utente agisce sul s.e. tramite i programmi applicativi che offrono servizi all&amp;rsquo;utente.&lt;/li>
&lt;li>I programmi applicativi svolgono i servizi richiesti e sono regolati dal s.o.&lt;/li>
&lt;li>Il s.o. si occupa di gestire i programmi applicativi e il loro accesso all&amp;rsquo;hardware.&lt;/li>
&lt;li>l&amp;rsquo;hardware elabora i dati necessari ai programmi applicativi.&lt;/li>
&lt;/ul>
&lt;!-- {{figura componenti di un sistema di elaborazione}} -->
&lt;h2 id="12---i-sistemi-operativi">1.2 - I sistemi operativi&lt;/h2>
&lt;p>L&amp;rsquo;implementazione di un sistema operativo dipende dallo scopo del calcolatore per cui è stato progettato. Per esempio:&lt;/p>
&lt;ul>
&lt;li>I &lt;strong>Personal Computer (PC)&lt;/strong> si concentrano sulla facilità d&amp;rsquo;utilizzo da parte dell&amp;rsquo;utente, con qualche attenzione prestazioni ma nessuna all&amp;rsquo;utilizzo delle risorse.&lt;/li>
&lt;li>I &lt;strong>calcolatori integrati (embedded)&lt;/strong> hanno una scarsa facilità d&amp;rsquo;uso e forniscono meno servizi rispetto a un PC. Esempi di calcolatori integrati sono i sistemi che troviamo negli elettrodomestici (p.e. forno microonde o autoradio).&lt;/li>
&lt;/ul>
&lt;p>Non c&amp;rsquo;è accordo sulle componenti logiche che fanno parte di un s.o.&lt;/p>
&lt;ul>
&lt;li>Un s.o. comprende senz&amp;rsquo;altro il &lt;strong>kernel&lt;/strong>, l&amp;rsquo;unico programma che funziona sempre nel calcolatore.&lt;/li>
&lt;li>Possiamo includere anche i &lt;strong>programmi di sistema&lt;/strong> utili al funzionamento del calcolatore ma che non fanno parte del kernel.&lt;/li>
&lt;li>Quando si parla di s.o. si pensa comunemente a tutto il software incluso alla sua vendita, compresi i &lt;strong>programmi applicativi&lt;/strong> predefiniti dal venditore, che non regolano il s.e.&lt;/li>
&lt;/ul>
&lt;h2 id="13---panoramica-generale-del-calcolatore">1.3 - Panoramica generale del calcolatore&lt;/h2>
&lt;p>Un calcolatore è composto da una CPU, e vari dispositivi (p.e. unità disco, dispositivi audio, unità video, etc.). La comunicazione con la memoria avviene attraverso &lt;strong>bus di sistema&lt;/strong> che permette lo scambio di dati. Ciascun dispositivo è associato a un &lt;strong>controllore di dispositivo&lt;/strong> con una propria memoria interna (il &lt;strong>buffer&lt;/strong>) e un insieme di registri specializzati. Il controllore si occupa della comunicazione tra la memoria e il dispositivo. La componente logica che funge da interfaccia di comunicazione è chiamata &lt;strong>driver del dispositivo&lt;/strong>.&lt;/p>
&lt;h3 id="131---sistema-a-interruzioni">1.3.1 - Sistema a interruzioni&lt;/h3>
&lt;p>La comunicazione avviene tramite il &lt;strong>meccanismo a interruzioni&lt;/strong>. Riportiamo i vari passaggi di un&amp;rsquo;operazione di I/O:&lt;/p>
&lt;ol>
&lt;li>Il driver scrive nei registri del controllore la richiesta (p.e. &amp;ldquo;leggi un carattere dalla tastiera&amp;rdquo;, &amp;ldquo;scrivi un carattere sul monitor&amp;rdquo;, etc.).&lt;/li>
&lt;li>Il controllore legge i registri ed effettua l&amp;rsquo;operazione richiesta, trasferendo i dati dal dispositivo al buffer o viceversa. Infine, notifica il driver dell&amp;rsquo;avvenuta operazione.&lt;/li>
&lt;li>Il driver passa il controllo ad altre parti del s.o. attraverso un segnale di interruzione alla CPU.&lt;/li>
&lt;li>La CPU, che esegue istruzioni in sequenza, viene così &amp;ldquo;interrotta&amp;rdquo; e trasferisce l&amp;rsquo;esecuzione a una locazione fissa della memoria che contiene solitamente l&amp;rsquo;indirizzo iniziale della procedura di servizio dell&amp;rsquo;interruzione.&lt;/li>
&lt;li>Una volta completata la procedura, la CPU riprende l&amp;rsquo;elaborazione precedentemente interrotta.&lt;/li>
&lt;/ol>
&lt;p>La mappatura dell&amp;rsquo;interruzione alla corretta procedura di servizio dev&amp;rsquo;essere eseguita efficientemente. Anziché scorrere tutta la lista, per trovare quella corretta si utilizza un &lt;strong>vettore delle interruzioni&lt;/strong> contenente gli indirizzi di memoria (puntatori) delle procedure. Il vettore viene salvato negli indirizzi di memoria più bassi e il segnale di interruzione codifica l&amp;rsquo;indirizzo, così da permettere un accesso veloce. Quando i tipi di interruzioni sono molti, l&amp;rsquo;indirizzo punta a una &lt;strong>lista di procedure&lt;/strong> che verrà scorsa per cercare la giusta procedura.&lt;/p>
&lt;h4 id="1311---procedura-di-gestione-dellinterruzione">1.3.1.1 - Procedura di gestione dell&amp;rsquo;interruzione&lt;/h4>
&lt;p>La CPU dispone di una connessione chiamata &lt;strong>linea di richiesta di interruzione (interrupt-request line)&lt;/strong> che viene controllata dopo l&amp;rsquo;esecuzione di ogni istruzione. Di sotto riportiamo una schematizzazione della procedura di gestione dell&amp;rsquo;interruzione:&lt;/p>
&lt;ol>
&lt;li>La CPU controlla la linea di richiesta di interruzione.&lt;/li>
&lt;li>Se viene rilevata una richiesta la CPU interrompe l&amp;rsquo;esecuzione del programma e legge il codice di interruzione e salta alla procedura corretta.&lt;/li>
&lt;li>La procedura salva le informazioni di stato dell&amp;rsquo;operazione interrotta in CPU.&lt;/li>
&lt;li>La procedura esegue l&amp;rsquo;elaborazione necessaria ed esegue un&amp;rsquo;istruzione di ritorno (&lt;code>return_from_interrupt&lt;/code>) caricando l&amp;rsquo;indirizzo di ritorno nel &lt;strong>contatore di programma (program counter)&lt;/strong> della CPU.&lt;/li>
&lt;/ol>
&lt;p>In questo modo, la CPU è in grado di reagire a eventi asincroni, cioè non prestabiliti durante l&amp;rsquo;esecuzione (p.e. I/O).&lt;/p>
&lt;p>Esistono tuttavia casi in cui non vogliamo che questo accada, per esempio, quando la CPU sta eseguendo un&amp;rsquo;operazione critica. Per tale motivo la CPU possiede due linee di richiesta interruzione:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>non mascherabile (nonmaskable interrupt):&lt;/strong> è riservata a errori irreversibili (p.e. nella memoria)&lt;/li>
&lt;li>&lt;strong>mascherabile (maskable interrupt):&lt;/strong> può essere disattivata dalla CPU a seconda delle necessità.&lt;/li>
&lt;/ul>
&lt;p>In aggiunta, a ogni interruzione viene assegnato un livello di priorità in maniera da dare precedenza quelle critiche, senza la necessità di mascherare quelle meno critiche.&lt;/p>
&lt;p>Parlando di I/O, il meccanismo a interruzioni è ottimo per piccole moli di dati. Quando le operazioni di interruzione si intensificano, (p.e. grosse moli di dati), si genera un sovraccarico (overhead) della CPU con conseguente rallentamento dell&amp;rsquo;esecuzione. In questo caso, è possibile risolvere il problema con la tecnica di &lt;strong>accesso diretto alla memoria (DMA)&lt;/strong>, tramite il quale il controllore trasferisce un intero blocco di dati dal proprio buffer alla memoria, o viceversa, senza l&amp;rsquo;intervento della CPU.&lt;/p>
&lt;p>Infine, alcuni sistemi hanno abbandonato la configurazione a bus per introdurre gli &lt;strong>switch&lt;/strong>, con cui più dispositivi fisici possono interagire con varie parti del sistema concorrentemente, piuttosto che contendersi il bus condiviso.&lt;/p>
&lt;h2 id="14---struttura-della-memoria">1.4 - Struttura della memoria&lt;/h2>
&lt;p>Il calcolatore necessita di mantenere uno stato interno dell&amp;rsquo;esecuzione in maniera da poter gestire efficentemente l&amp;rsquo;elaborazione. Per questo motivo, è necessario dotarlo di &lt;strong>memoria&lt;/strong>. Con questo termine ci riferiamo a varie tecnologie preposte al salvataggio di informazioni. Il calcolatore dispone di una gerarchia di memorie. Le più veloci sono le più costose da produrre e perciò anche le più piccole. In aggiunta, alcune memorie sono volatili, ovvero perdono il loro stato quando viene a mancare la corrente. Di seguito riportiamo una categorizzazione:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Memorie volatili:&lt;/strong> le indichiamo con il termine generico &lt;strong>memoria&lt;/strong>. Troviamo:
&lt;ul>
&lt;li>&lt;strong>Registri:&lt;/strong> presenti all&amp;rsquo;interno della CPU, sono il tipo di memoria più veloce. La CPU accede solamente a questa memoria per l&amp;rsquo;esecuzione delle istruzioni.&lt;/li>
&lt;li>&lt;strong>Cache:&lt;/strong> una memoria estremamente veloce e piccola che è in grado di salvare dati recenti frequentemente usati per l&amp;rsquo;utilizzo in CPU. Può essere considerata come un&amp;rsquo;estensione dei registri.&lt;/li>
&lt;li>&lt;strong>Memoria ad accesso casuale (random access memory, RAM):&lt;/strong> il luogo in cui vengono caricati i programmi per la loro esecuzione. Calcolatori general-purpose come i PC utilizzano la RAM, solitamente realizzata con una tecnologia a semiconduttori chiamata &lt;strong>memoria dinamica ad accesso casuale (dynamic random access memory, DRAM)&lt;/strong>. Viene pensata come un vettore di byte associati a uno specifico indirizzo per il loro accesso. L&amp;rsquo;interazione con la CPU avviene attraverso l&amp;rsquo;operazione di &lt;code>load&lt;/code>, dalla RAM al registro, e &lt;code>store&lt;/code>, dal registro alla RAM.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>Memorie non-volatili:&lt;/strong> le indichiamo con &lt;strong>NVS (nonvolatile storage)&lt;/strong>. Si dividono generalmente in due tipologie, &lt;strong>meccanica&lt;/strong> ed &lt;strong>elettrica&lt;/strong>, chiamata in seguito &lt;strong>NVM&lt;/strong>. Troviamo:
&lt;ul>
&lt;li>&lt;strong>Memoria di sola lettura elettricamente cancellabile e programmabile (EEPROM):&lt;/strong> contiene il &lt;strong>programma di avviamento (bootstrap)&lt;/strong> che avvia il sistema operativo. È più lenta della RAM.&lt;/li>
&lt;li>&lt;strong>Memoria secondaria:&lt;/strong> contiene al suo interno i programmi eseguibili dal calcolatore e le informazioni elaborate da essi. Esistono vari tipi di memoria secondaria, come i &lt;strong>dischi magnetici (hard-disk drive, HDD)&lt;/strong> o i &lt;strong>dispositivi di memoria non volatile (NVM)&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Memoria terziaria:&lt;/strong> solitamente scollegabile dal calcolatore e portabile, per questo motivo la definiamo anche &amp;ldquo;esterna&amp;rdquo;. Esempi di memoria terziaria sono i CD-ROM, i blu-ray, i nastri magnetici, etc.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="15---struttura-del-processore">1.5 - Struttura del processore&lt;/h2>
&lt;p>Finora abbiamo parlato genericamente di CPU per indicare il componente principale preposto all&amp;rsquo;esecuzione di istruzioni macchina. È un termine comunemente utilizzato, ma in realtà il termine corretto è &lt;strong>processore&lt;/strong>, ovvero &lt;em>l&amp;rsquo;hardware che ospita al suo interno una o più CPU per il calcolo&lt;/em>.&lt;/p>
&lt;p>I calcolatori possono essere categorizzati in:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>sistema monoprocessore:&lt;/strong> un sistema con un solo processore contenente una sola CPU. In questo caso, il processore è solitamente coadiuvato da alcuni processori specializzati con scopi più limitati di quello principale.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>sistemi multiprocessore:&lt;/strong> contenenti più processori con una singola CPU (single-core). Questi sistemi sono più moderni e più efficienti, tuttavia con N processori il tempo di esecuzione non aumenta esattamente di N volte, ma meno. Ciò è dovuto alla necessità di coordinare i core e alla contesa per risorse condivise. I sistemi più comuni usano la &lt;strong>multielaborazione simmetrica (symmetric multiprocessing, SMP)&lt;/strong> in cui ogni processore può eseguire tutte le operazioni del sistema. Il vantaggio consiste nel poter eseguire molti processi contemporaneamente, ma può accadere che una CPU sia sovraccarica e l&amp;rsquo;altra vuota. Questo problema può essere risolto con la condivisione di alcune strutture dati tra i due processori.&lt;/p>
&lt;p>Il termine multiprocessore indica anche i &lt;strong>sistemi multicore&lt;/strong>, in cui più CPU (core) risiedono su un singolo processore (o più processori). La comunicazione è in questo caso più efficiente dal punto di vista della velocità e della potenza consumata, poiché è possibile effettivamente implementare una memoria cache condivisa dai core. Dal punto di vista logico, il s.o. si interfaccia come con un normale sistema multiprocessore.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>Come detto, con l&amp;rsquo;aumentare delle CPU l&amp;rsquo;aumento di potenza computazionale diminuisce poiché il bus di sistema condiviso diventa un collo di bottiglia. Per ovviare al problema è stato proposto un approccio alternativo chiamato &lt;strong>accesso non uniforme alla memoria (NUMA)&lt;/strong>. Le CPU sono dotate di una propria memoria e comunicano tramite un &lt;strong>interconnessione di sistema condivisa&lt;/strong>. Lo svantaggio è la latenza introdotta durante l&amp;rsquo;accesso di una CPU a un&amp;rsquo;altra memoria.&lt;/p>
&lt;h2 id="16---attività-del-sistema-operativo">1.6 - Attività del sistema operativo&lt;/h2>
&lt;p>Il s.o. si occupa di orchestrare i componenti di un calcolatore precedentemente descritti. In particolare, all&amp;rsquo;accensione del computer, viene lanciato il programma bootstrap, che si occupa di caricare in memoria il kernel del s.o. A sua volta il kernel inizia ad offrire servizi. L&amp;rsquo;utente interagirà col s.o. tramite eventi I/O che generano interruzioni, come spiegato sopra.&lt;/p>
&lt;h3 id="161---multiprogrammazione-e-multitasking">1.6.1 - Multiprogrammazione e multitasking&lt;/h3>
&lt;p>In questa sezione presentiamo due principali tecniche di orchestrazione dei programmi da parte del s.o.&lt;/p>
&lt;p>La &lt;strong>multiprogrammazione&lt;/strong> consiste nel tenere in memoria centrale diversi processi. Il s.o. lancia un processo in base alla priorità data e nei momenti di interrupt, quando la CPU sarebbe inattiva, vengono invece eseguiti gli altri processi, massimizzando così l&amp;rsquo;utilizzo della CPU.&lt;/p>
&lt;p>Il &lt;strong>multitasking&lt;/strong> è un&amp;rsquo;estensione logica della multiprogrammazione. Una CPU multitasking è in grado di eseguire più lavori (task) intervallando le loro esecuzioni per garantire tempi di risposta rapida a ogni processo. Un metodo per ottenere tale rapidità è quello di adottare una &lt;strong>memoria virtuale&lt;/strong> ovvero una sovrastruttura logica che astrae la memoria fisica ed emula una memoria potenzialmente più grande di quella centrale.&lt;/p>
&lt;h3 id="162---duplice-modalità-di-funzionamento">1.6.2 - Duplice modalità di funzionamento&lt;/h3>
&lt;p>Un s.o. dev&amp;rsquo;essere in grado di proteggersi da errori dovuti a esecuzioni di programmi errati, ma dev&amp;rsquo;essere anche in grado di prendere il controllo degli stessi per quando necessario.&lt;/p>
&lt;p>Per tale motivo il s.o. è in grado di distinguere le sue routine da quelle dell&amp;rsquo;utente attraverso la &lt;strong>dual mode&lt;/strong>, per la quale si utilizza un bit (&lt;strong>mode bit&lt;/strong>) che segnala l&amp;rsquo;appartenenza di un processo a una &lt;strong>modalità di sistema&lt;/strong> privilegiata (0) o a una &lt;strong>modalità utente&lt;/strong> non privilegiata (1).&lt;/p>
&lt;p>All&amp;rsquo;avvio del calcolatore la modalità sistema è attiva, poi il s.o. passa alla modalità utente, per l&amp;rsquo;esecuzione delle applicazioni utente. Ogni volta che un&amp;rsquo;applicazione fa una chiamata a sistema (p.e. con un&amp;rsquo;operazione I/O), questa viene gestita come un&amp;rsquo;interruzione software. Il s.o. passa alla modalità sistema e una volta eseguita la procedura si ritorna nuovamente alla modalità utente.&lt;/p>
&lt;p>La dual mode permette di definire istruzioni privilegiate (p.e. passaggio alla modalità kernel, etc.) che possono essere eseguite solo in modalità sistema. Se un programma utente causa un errore (p.e. accede a una zona di memoria proibita), l&amp;rsquo;hardware genera un&amp;rsquo;eccezione seguendo una condotta analoga a quella di un&amp;rsquo;interruzione. Il programma viene quindi terminato in maniera anomala.&lt;/p>
&lt;p>È possibile implementare diversi livelli di privilegio (p.e. Intel ne ha quattro, ARMv8 ne ha sette). Inoltre, le CPU che supportano la virtualizzazione hanno spesso una modalità distinta per la gestione della macchina virtuale.&lt;/p>
&lt;h3 id="163---timer">1.6.3 - Timer&lt;/h3>
&lt;p>A volte può accadere che un programma entri in un loop infinito. Questo comportamento pericoloso monopolizzerebbe la CPU a scapito del s.o. che non può più riprendere il controllo. Per evitare ciò si può utilizzare un &lt;strong>timer&lt;/strong> programmabile affinché invii un&amp;rsquo;interruzinoe a intervalli fissi o variabili.&lt;/p>
&lt;p>Il timer è implementato come un contatore che decresce a ogni impulso e quando raggiunge lo 0, genera un&amp;rsquo;interruzione. Il s.o. riprende dunque il controllo e decide se concedere altro tempo o interrompere il programma.&lt;/p>
&lt;h3 id="164---gestione-delle-risorse">1.6.4 - Gestione delle risorse&lt;/h3>
&lt;p>Riportiamo ora una panoramica sulle mansioni di gestione del s.o.&lt;/p>
&lt;h4 id="1641---gestione-dei-processi">1.6.4.1 - Gestione dei processi&lt;/h4>
&lt;p>Un &lt;strong>programma&lt;/strong> interagisce con il s.o. e l&amp;rsquo;utente solamente se è caricato in memoria e la CPU esegue le sue istruzioni. Nel momento in cui è in esecuzione, parliamo di &lt;strong>processo&lt;/strong>. Esempi di programmi sono il compilatore, un elaboratore di testi, o l&amp;rsquo;invio dati a una stampante. Un processo può anche avvalersi dell&amp;rsquo;utilizzo di vari sottoprocessi, detti jobs, parallelizzando così l&amp;rsquo;esecuzione.&lt;/p>
&lt;p>Ogni processo a singolo job (o thread singolo, single-threaded) possiede un &lt;strong>contatore di programma&lt;/strong> che indica quale istruzione eseguire. L&amp;rsquo;esecuzione avviene in maniera sequenziale. Nel caso di più thread il processo possiede più contatori di programma.&lt;/p>
&lt;p>Il s.o. è responsabile delle seguenti mansioni di gestione dei processi:&lt;/p>
&lt;ul>
&lt;li>creazione e cancellazione dei processi utenti e di sistema&lt;/li>
&lt;li>schedulazione di processi e thread sulle CPU&lt;/li>
&lt;li>sospensione e ripristino dei processi&lt;/li>
&lt;li>fornitura di meccanismi per la sincronizzazione dei processi&lt;/li>
&lt;li>fornitura di meccansimi per la comunicazione tra processi&lt;/li>
&lt;/ul>
&lt;h3 id="165-gestione-dei-file">1.6.5 Gestione dei file&lt;/h3>
&lt;p>Il &lt;strong>file&lt;/strong> è l&amp;rsquo;unità logica per l&amp;rsquo;organizzazione dell&amp;rsquo;informazione in un computer. Ogni programma è salvato in un file, ma un file può anche rappresentare una collezione di informazioni correlate, per esempio un documento di testo o un immagine. La struttura di un file può essere rigidamente codificata (p.e. file MP3) o più flessibile (p.e. file di testo). Il contenuto di un file può essere di tipo numerico, alfabetico o alfanumerico. I file possono essere raggruppati in una cartella (directory) e a sua volta le cartelle possono essere raggruppate in altre cartelle, formando una struttura ad albero.&lt;/p>
&lt;p>Il s.o. è responsabile delle seguenti attività connesse alla gestione dei file:&lt;/p>
&lt;ul>
&lt;li>creazione e cancellazione di file e directory&lt;/li>
&lt;li>fornitura delle funzioni fondamentali per la gestione di file e directory&lt;/li>
&lt;li>associazione dei file ai dispositivi di memoria secondaria&lt;/li>
&lt;li>creazione di copie di riserva (backup) dei file su dispositivi di memoriazzazione non volatili&lt;/li>
&lt;/ul>
&lt;h3 id="166---gestione-della-memoria">1.6.6 - Gestione della memoria&lt;/h3>
&lt;p>Per eseguire un programma, è necessario che questo sia associato a indirizzi assoluti e sia caricato nella memoria. Quando il programma termina, la parte di memoria a esso dedicata viene liberata. Per migliorare l&amp;rsquo;efficienza di questo sistema, è possibile mantenere più programmi contemporaneamente in memoria. La scelta della struttura della memoria dipende dal tipo di s.e.&lt;/p>
&lt;p>Il s.o. è responsabile delle seguenti mansioni di gestione dei processi:&lt;/p>
&lt;ul>
&lt;li>tenere traccia di quali parti di memoria sono attualmente usate e da chi&lt;/li>
&lt;li>assegnare e revocare lo spazio di memoria secondo le necessità&lt;/li>
&lt;li>decidere quali processi (o parti di essi) e dati debbano essere caricati in memoria centrale o trasferiti sulla memoria di massa&lt;/li>
&lt;/ul>
&lt;h4 id="1661---gestione-della-memoria-di-massa">1.6.6.1 - Gestione della memoria di massa&lt;/h4>
&lt;p>Il calcolatore ha bisogno di una memoria secondaria non volatile per mantenere il suo stato anche quando è spento. La maggior parte dei s.e. utilizza nastri magnetici e NVM per la memorizzazione.&lt;/p>
&lt;p>Il s.o. è responsabile delle seguenti attività connesse alla gestione della memoria di massa:&lt;/p>
&lt;ul>
&lt;li>montare/smontare (mounting/unmounting) le unità di memoria&lt;/li>
&lt;li>gestione dello spazio libero&lt;/li>
&lt;li>scheduling del disco&lt;/li>
&lt;li>partizionamento&lt;/li>
&lt;li>protezione&lt;/li>
&lt;/ul>
&lt;p>In alcuni casi è utile avere a disposizione anche delle memorie terziare, ad esempio per avere un backup dei dati.&lt;/p>
&lt;h4 id="1662---gestione-della-cache">1.6.6.2 - Gestione della cache&lt;/h4>
&lt;p>La &lt;strong>cache&lt;/strong> è un&amp;rsquo;unità di memoria che replica parte dello stato della memoria centrale. È pensata per fornire un accesso veloce ai dati da parte del calcolatore ed è più piccola della memoria centrale perché più costosa. Il s.o. si occupa solitamente dell&amp;rsquo;interazione tra cache e memoria centrale, mentre l&amp;rsquo;hardware di sistema dedicato si occupa dell&amp;rsquo;interazione tra cache e CPU. Un calcolatore può contenere più cache per aumentare l&amp;rsquo;efficienza nell&amp;rsquo;esecuzione delle istruzioni.&lt;/p>
&lt;h4 id="1663---coerenza-della-memoria">1.6.6.3 - Coerenza della memoria&lt;/h4>
&lt;p>Avendo a che fare con più unità di memoria diverse (registro, cache, memoria centrale, memoria secondaria), è importante mantenere lo stesso stato di un&amp;rsquo;applicazione in maniera da avere una coerenza tra i dati processati dalla CPU. Questo non rappresenta di per sé un problema nei sistemi single-threaded, ma richiede maggiore attenzione in quelli multitasking, dove, nel caso di memoria condivisa da più processi, è necessario garantire che ognuno di essi acceda alla copia più recentemente modificata dei dati. La situazione si complica ulteriormente nei sistemi multiprocessore, per i quali ciascuna CPU possiede anche una cache locale che va mantenuta aggiornata (e diventa ancora più complicata nei sistemi distribuiti, con più calcolatori).&lt;/p>
&lt;p>Questa situazione di &lt;strong>coerenza della cache&lt;/strong> si risolve solitamente a livello hardware, senza l&amp;rsquo;intervento diretto del s.o.&lt;/p>
&lt;h3 id="166---gestione-dellio">1.6.6 - Gestione dell&amp;rsquo;I/O&lt;/h3>
&lt;p>La varietà di dispositivi I/O che un calcolatore può ospitare richiede un&amp;rsquo;interfaccia di gestione per permettere un&amp;rsquo;interazione più semplice, gestita dal s.o. &lt;strong>sottosistema di I/O&lt;/strong>, composto dalle seguenti parti:&lt;/p>
&lt;ul>
&lt;li>un componente di gestione della memoria, buffer di I/O, cache e spooling&lt;/li>
&lt;li>un&amp;rsquo;interfaccia generale per i driver di dispositivi&lt;/li>
&lt;li>i driver dei dispositivi&lt;/li>
&lt;/ul>
&lt;h2 id="17---sicurezza-e-protezione">1.7 - Sicurezza e protezione&lt;/h2>
&lt;p>Per garantire un corretto funzionamento del s.e. è importante che il s.o. implementi dei sistemi di &lt;strong>protezione&lt;/strong> dei dati. Ad esempio in un sistema a più utenti, un utente deve avere la garanzia di poter decidere chi può accedere ai suoi file. Similmente, in un sistema a più processi, un processo non può scrivere in parti di memoria ad esso non allocate, previa autorizzazione del s.o. In questi casi è possibile prevedere degli identificativi (ID) dell&amp;rsquo;utente e del processo per decidere di quali privilegi dispongano.&lt;/p>
&lt;p>In aggiunta a ciò, pur essendo dotato di protezione adeguata, un sistema può rimanere esposto agli accessi abusivi rischiando malfunzionamenti. Se ad esempio a un utente vengono trafugate le informazioni di autenticazione, un soggetto malevolo può essere in grado di modificare i suoi file, anche se la protezione dati è attiva. È compito della &lt;strong>sicurezza&lt;/strong> non permettere che ciò accada, difendendo il s.e. da attacchi provenienti dall&amp;rsquo;interno o dall&amp;rsquo;esterno.&lt;/p>
&lt;!-- ## Virtualizzazione
La **virtualizzazione** è una tecnica che permette di astrarre l'hardware di un singolo computer in diversi ambienti di esecuzione.
L'**emulazione** è simile alla virtualizzazione e permette di utilizzare un s.o. diverso da quello nativo (p.e. si può virtualizzare un s.o. Linux su Windows), ma può essere utilizzato per rendere un s.o. compatibile con una CPU per cui non era stato progettato.
Con la virtualizzazione invece si può emulare un nuovo s.o. -->
&lt;h2 id="18---sistemi-embedded-real-time">1.8 - Sistemi embedded real-time&lt;/h2>
&lt;p>Tra i vari s.e., i sistemi embedded attualmente costituiscono la tipologia predominante di calcolatore. La loro interfaccia utente è poco elaborata o addirittura assente e i loro compiti sono piuttosto specifici (p.e. monitoraggio ambienti, gestione di dispositivi meccanici, etc.). I sistemi &lt;strong>embedded&lt;/strong> sono molto vari e possono essere implementati sia con sistemi general-purpose (di cui viene utilizzata solo una parte) sia con sistemi special-purpose.&lt;/p>
&lt;p>In generale, i loro s.o. sono &lt;strong>real-time&lt;/strong> ovvero hanno dei vincoli di tempo molto rigidi nell&amp;rsquo;esecuzione dei loro task, poiché vengono spesso utilizzati per processi critici (p.e. la produzione industriale) e se non riescono a completare in tempo le loro attività possono innescare una crisi. Ad esempio un braccio robotico dovrà necessariamente eseguire un&amp;rsquo;operazione di stop prima che vada a scontrarsi con l&amp;rsquo;automobile che era impegnato a costruire.&lt;/p>
&lt;div id="references-1784797996841971857" class="references" style="margin-top: 5rem;">
&lt;p>&lt;strong>References&lt;/strong>&lt;/p>
A. Silberschatz, P. B. Galvin, G. Gagne, &lt;em>Sistemi Operativi. Concetti ed Esempi&lt;/em>, Decima edizione. Ch. 1.
&lt;/div>
&lt;script>
document.addEventListener('DOMContentLoaded', function () {
var ref = document.getElementById('references-1784797996841971857');
var fn = document.querySelector('.footnotes');
if (ref &amp;&amp; fn &amp;&amp; fn.parentNode) {
fn.parentNode.insertBefore(ref, fn.nextSibling);
}
});
&lt;/script></description></item><item><title>Introduzione</title><link>https://aretor.it/courses/os2324/01_intro/</link><pubDate>Sat, 02 Mar 2024 00:00:00 +0000</pubDate><guid>https://aretor.it/courses/os2324/01_intro/</guid><description>&lt;p>In questa lezione forniremo una panoramica generale delle componenti di un sistema di elaborazione, di un calcolatore e di un sistema operativo. Verranno toccati molti argomenti e introdotti molti concetti, a cui dedicheremo approfondimenti nelle lezioni successive.&lt;/p>
&lt;h2 id="sistema-di-elaborazione">Sistema di elaborazione&lt;/h2>
&lt;p>Un &lt;strong>sistema di elaborazione&lt;/strong> o &lt;strong>sistema elaborativo (s.e.)&lt;/strong> ruota attorno quattro concetti fondamentali:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>hardware:&lt;/strong> l&amp;rsquo;insieme delle componenti fisiche con cui il s.e. è
assemblato. Sono inclusi, tra gli altri:
&lt;ul>
&lt;li>La &lt;strong>Central Processing Unit (CPU)&lt;/strong> che esegue istruzioni a essa assegnate e produce dati.&lt;/li>
&lt;li>La &lt;strong>memoria&lt;/strong> che memorizza dati per utilizzo successivo.&lt;/li>
&lt;li>I &lt;strong>dispositivi Input/Output (I/O)&lt;/strong> che permettono l&amp;rsquo;interazione tra il mondo del computer e quello esterno (p.e. il mouse, la tastiera, il monitor, lo scanner, la stampante, etc.).&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>programmi applicativi:&lt;/strong> strutture logiche ad alto livello che svolgono dei servizi per l&amp;rsquo;utente (p.e. editor di testo, fogli di calcolo, compilatori, browser web, etc.).&lt;/li>
&lt;li>&lt;strong>utente:&lt;/strong> l&amp;rsquo;entità umana che interagisce con il s.e. attraverso i dispositivi I/O.&lt;/li>
&lt;li>&lt;strong>sistema operativo (s.o.):&lt;/strong> l&amp;rsquo;insieme delle componenti logiche che controlla l&amp;rsquo;utilizzo dell&amp;rsquo;hardware e dei programmi applicativi. Il s.o. può essere visto come l&amp;rsquo;insieme di regole necessarie al corretto funzionamento del s.e.&lt;/li>
&lt;/ul>
&lt;p>Hardware, sistema operativo e programmi applicativi saranno indicati con il nome &lt;strong>calcolatore/elaboratore&lt;/strong>, anche se esistono diverse definizioni.&lt;/p>
&lt;p>In sintesi:&lt;/p>
&lt;ul>
&lt;li>L&amp;rsquo;utente agisce sul s.e. tramite i programmi applicativi che offrono servizi all&amp;rsquo;utente.&lt;/li>
&lt;li>I programmi applicativi svolgono i servizi richiesti e sono regolati dal s.o.&lt;/li>
&lt;li>Il s.o. si occupa di gestire i programmi applicativi e il loro accesso all&amp;rsquo;hardware.&lt;/li>
&lt;li>l&amp;rsquo;hardware elabora i dati necessari ai programmi applicativi.&lt;/li>
&lt;/ul>
&lt;!-- {{figura componenti di un sistema di elaborazione}} -->
&lt;h2 id="i-sistemi-operativi">I sistemi operativi&lt;/h2>
&lt;p>L&amp;rsquo;implementazione di un sistema operativo dipende dallo scopo del calcolatore per cui è stato progettato. Per esempio:&lt;/p>
&lt;ul>
&lt;li>I &lt;strong>Personal Computer (PC)&lt;/strong> si concentrano sulla facilità d&amp;rsquo;utilizzo da parte dell&amp;rsquo;utente, con qualche attenzione prestazioni ma nessuna all&amp;rsquo;utilizzo delle risorse.&lt;/li>
&lt;li>I &lt;strong>calcolatori integrati (embedded)&lt;/strong> hanno una scarsa facilità d&amp;rsquo;uso e forniscono meno servizi rispetto a un PC. Esempi di calcolatori integrati sono i sistemi che troviamo negli elettrodomestici (p.e. forno microonde o autoradio).&lt;/li>
&lt;/ul>
&lt;p>Non c&amp;rsquo;è accordo sulle componenti logiche che fanno parte di un s.o.&lt;/p>
&lt;ul>
&lt;li>Un s.o. comprende senz&amp;rsquo;altro il &lt;strong>kernel&lt;/strong>, l&amp;rsquo;unico programma che funziona sempre nel calcolatore.&lt;/li>
&lt;li>Possiamo includere anche i &lt;strong>programmi di sistema&lt;/strong> utili al funzionamento del calcolatore ma che non fanno parte del kernel.&lt;/li>
&lt;li>Quando si parla di s.o. si pensa comunemente a tutto il software incluso alla sua vendita, compresi i &lt;strong>programmi applicativi&lt;/strong> predefiniti dal venditore, che non regolano il s.e.&lt;/li>
&lt;/ul>
&lt;h2 id="panoramica-generale-del-calcolatore">Panoramica generale del calcolatore&lt;/h2>
&lt;p>Un calcolatore è composto da una CPU, e vari dispositivi (p.e. unità disco, dispositivi audio, unità video, etc.). La comunicazione con la memoria avviene attraverso &lt;strong>bus di sistema&lt;/strong> che permette lo scambio di dati. Ciascun dispositivo è associato a un &lt;strong>controllore di dispositivo&lt;/strong> con una propria memoria interna (il &lt;strong>buffer&lt;/strong>) e un insieme di registri specializzati. Il controllore si occupa della comunicazione tra la memoria e il dispositivo. La componente logica che funge da interfaccia di comunicazione è chiamata &lt;strong>driver del dispositivo&lt;/strong>.&lt;/p>
&lt;h3 id="sistema-a-interruzioni">Sistema a interruzioni&lt;/h3>
&lt;p>La comunicazione avviene tramite il &lt;strong>meccanismo a interruzioni&lt;/strong>. Riportiamo i vari passaggi di un&amp;rsquo;operazione di I/O:&lt;/p>
&lt;ol>
&lt;li>Il driver scrive nei registri del controllore la richiesta (p.e. &amp;ldquo;leggi un carattere dalla tastiera&amp;rdquo;, &amp;ldquo;scrivi un carattere sul monitor&amp;rdquo;, etc.).&lt;/li>
&lt;li>Il controllore legge i registri ed effettua l&amp;rsquo;operazione richiesta, trasferendo i dati dal dispositivo al buffer o viceversa. Infine, notifica il driver dell&amp;rsquo;avvenuta operazione.&lt;/li>
&lt;li>Il driver passa il controllo ad altre parti del s.o. attraverso un segnale di interruzione alla CPU.&lt;/li>
&lt;li>La CPU, che esegue istruzioni in sequenza, viene così &amp;ldquo;interrotta&amp;rdquo; e trasferisce l&amp;rsquo;esecuzione a una locazione fissa della memoria che contiene solitamente l&amp;rsquo;indirizzo iniziale della procedura di servizio dell&amp;rsquo;interruzione.&lt;/li>
&lt;li>Una volta completata la procedura, la CPU riprende l&amp;rsquo;elaborazione precedentemente interrotta.&lt;/li>
&lt;/ol>
&lt;p>La mappatura dell&amp;rsquo;interruzione alla corretta procedura di servizio dev&amp;rsquo;essere eseguita efficientemente. Anziché scorrere tutta la lista trovare quella corretta, si utilizza un &lt;strong>vettore delle interruzioni&lt;/strong> contenente gli indirizzi di memoria (puntatori) delle procedure. Il vettore viene salvato negli indirizzi di memoria più bassi e il segnale di interruzione codifica l&amp;rsquo;indirizzo, così da permettere un accesso veloce. Quando i tipi di interruzioni sono molti, l&amp;rsquo;indirizzo punta a una lista di procedure che verrà scorsa per cercare la giusta procedura.&lt;/p>
&lt;h4 id="procedura-di-gestione-dellinterruzione">Procedura di gestione dell&amp;rsquo;interruzione&lt;/h4>
&lt;p>La CPU dispone di una connessione chiamata &lt;strong>linea di richiesta di interruzione (interrupt-request line)&lt;/strong> che viene controllata dopo l&amp;rsquo;esecuzione di ogni istruzione. Di sotto riportiamo una schematizzazione della procedura di gestione dell&amp;rsquo;interruzione:&lt;/p>
&lt;ol>
&lt;li>La CPU controlla la linea di richiesta di interruzione.&lt;/li>
&lt;li>Se viene rilevata una richiesta la CPU interrompe l&amp;rsquo;esecuzione e legge il codice di interruzione e salta alla procedura corretta.&lt;/li>
&lt;li>La procedura salva le informazioni di stato dell&amp;rsquo;operazione interrotta in CPU.&lt;/li>
&lt;li>La procedura esegue l&amp;rsquo;elaborazione necessaria ed esegue un&amp;rsquo;istruzione di ritorno (&lt;code>return_from_interrupt&lt;/code>) caricando l&amp;rsquo;indirizzo di ritorno nel &lt;strong>contatore di programma (program counter)&lt;/strong> della CPU.&lt;/li>
&lt;/ol>
&lt;p>In questo modo, la CPU è in grado di reagire a eventi asincroni, cioè non prestabiliti durante l&amp;rsquo;esecuzione (p.e. I/O).&lt;/p>
&lt;p>Esistono tuttavia casi in cui non vogliamo che questo accada, per esempio, quando la CPU sta eseguendo un&amp;rsquo;operazione critica. Per tale motivo la CPU possiede due linee di richiesta interruzione:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>non mascherabile (nonmaskable interrupt):&lt;/strong> è riservata a errori irreversibili (p.e. nella memoria)&lt;/li>
&lt;li>&lt;strong>mascherabile (maskable interrupt):&lt;/strong> può essere disattivata dalla CPU a seconda delle necessità.&lt;/li>
&lt;/ul>
&lt;p>A ogni interruzione viene assegnato un livello di priorità in maniera da dare precedenza quelle più critiche, senza la necessità di mascheramento.&lt;/p>
&lt;p>Parlando di I/O, il meccanismo a interruzioni è ottimo per piccole moli di dati. Quando le operazioni di interruzione si intensificano, (p.e. grosse moli di dati), si genera un sovraccarico (overhead) della CPU con conseguente rallentamento dell&amp;rsquo;esecuzionie. In questo caso, è possibile risolvere il problema con la tecnica di &lt;strong>accesso diretto alla memoria (DMA)&lt;/strong>, tramite il quale il controllore trasferisce un intero blocco di dati dal proprio buffer alla memoria, o viceversa, senza l&amp;rsquo;intervento della CPU.&lt;/p>
&lt;p>Infine, alcuni sistemi hanno abbandonato la configurazione a bus per introdurre gli &lt;strong>switch&lt;/strong>, con cui più dispositivi fisici possono interagire con varie parti del sistema concorrentemente, piuttosto che contendersi il bus condiviso.&lt;/p>
&lt;h2 id="struttura-della-memoria">Struttura della memoria&lt;/h2>
&lt;p>Il calcolatore necessita di mantenere uno stato interno dell&amp;rsquo;esecuzione maniera da poter gestire efficentemente l&amp;rsquo;elaborazione. Per questo motivo, è necessario dotarlo di &lt;strong>memoria&lt;/strong>. Con questo termine ci riferiamo a varie tecnologie preposte al salvataggio di informazioni. Il calcolatore dispone di una gerarchia di memorie. Le più veloci sono le più costose da produrre e perciò anche le più piccole. In aggiunta, alcune memorie sono volatili, ovvero perdono il loro stato quando viene a mancare la corrente. Di seguito riportiamo una categorizzazione:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Memorie volatili:&lt;/strong> le indichiamo con il termine generico &lt;strong>memoria&lt;/strong>. Troviamo:
&lt;ul>
&lt;li>&lt;strong>Registri:&lt;/strong> presenti all&amp;rsquo;interno della CPU, sono il tipo di memoria più veloce. La CPU accede solamente a questa memoria per l&amp;rsquo;esecuzione delle istruzioni.&lt;/li>
&lt;li>&lt;strong>Cache:&lt;/strong> una memoria estremamente veloce e piccola che è in grado di salvare dati recenti frequentemente usati per l&amp;rsquo;utilizzo in CPU. Può essere considerata come un&amp;rsquo;estensione dei registri.&lt;/li>
&lt;li>&lt;strong>Memoria ad accesso casuale (random access memory, RAM):&lt;/strong> il luogo in cui vengono caricati i programmi per la loro esecuzione. Calcolatori general-purpose come i PC utilizzano la RAM, solitamente realizzata con una tecnologia a semiconduttori chiamata &lt;strong>memoria dinamica ad accesso casuale (dynamic random access memory, DRAM)&lt;/strong>. Viene pensata come un vettore di byte associati a uno specifico indirizzo per il loro accesso. L&amp;rsquo;interazione con la CPU avviene attraverso l&amp;rsquo;operazione di &lt;code>load&lt;/code>, dalla RAM al registro, e &lt;code>store&lt;/code>, dal registro alla RAM.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>Memorie non-volatili:&lt;/strong> le indichiamo con &lt;strong>NVS (nonvolatile storage)&lt;/strong>. Si dividono generalmente in due tipologie, &lt;strong>meccanica&lt;/strong> ed &lt;strong>elettrica&lt;/strong>, chiamata in seguito &lt;strong>NVM&lt;/strong>. Troviamo:
&lt;ul>
&lt;li>&lt;strong>Memoria di sola lettura elettricamente cancellabile e programmabile (EEPROM):&lt;/strong> contiene il &lt;strong>programma di avviamento (bootstrap)&lt;/strong> che avvia il sistema operativo. È più lenta della RAM.&lt;/li>
&lt;li>&lt;strong>Memoria secondaria:&lt;/strong> contiene al suo interno i programmi eseguibili dal calcolatore e le informazioni elaborate da essi. Esistono vari tipi di memoria secondaria, come i &lt;strong>dischi magnetici (hard-disk drive, HDD)&lt;/strong> o i &lt;strong>dispositivi di memoria non volatile (NVM)&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Memoria terziaria:&lt;/strong> solitamente scollegabile dal calcolatore e portabile, per questo motivo la definiamo anche &amp;ldquo;esterna&amp;rdquo;. Esempi di memoria terziaria sono i CD-ROM, i blu-ray, i nastri magnetici, etc.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h2 id="struttura-del-processore">Struttura del processore&lt;/h2>
&lt;p>Finora abbiamo parlato genericamente di CPU per indicare il componente principale preposto all&amp;rsquo;esecuzione di istruzioni macchina. È un termine comunemente utilizzato, ma in realtà il termine corretto è &lt;strong>processore&lt;/strong>, ovvero &lt;em>l&amp;rsquo;hardware che ospita al suo interno una o più CPU per il calcolo&lt;/em>.&lt;/p>
&lt;p>I calcolatori possono essere categorizzati in:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>sistema monoprocessore:&lt;/strong> un sistema con un solo processore contenente una sola CPU è chiamato &lt;strong>sistema monoprocessore&lt;/strong>. In questo caso, il processore è solitamente coadiuvato da alcuni processori specializzati con scopi più limitati di quello principale.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>sistemi multiprocessore:&lt;/strong> contenenti più processori con una singola CPU (single-core). Questi sistemi sono più moderni e più efficienti, tuttavia con N processori il tempo di esecuzione non aumenta esattamente di N volte, ma meno. Ciò è dovuto alla necessità di coordinare i core e alla contesa per risorse condivise. I sistemi più comuni usano la &lt;strong>multielaborazione simmetrica (symmetric multiprocessing, SMP)&lt;/strong> in cui ogni processore può eseguire tutte le operazioni del sistema. Il vantaggio consiste nel poter eseguire molti processi contemporaneamente, ma può accadere che una CPU sia sovraccarica e l&amp;rsquo;altra vuota. Questo problema può essere risolto con la condivisione di alcune strutture dati tra i due processori.&lt;/p>
&lt;p>Il termine multiprocessore indica anche i &lt;strong>sistemi multicore&lt;/strong>, in cui più CPU (core) risiedono su un singolo processore (o più processori). La comunicazione è in questo caso più efficiente dal punto di vista della velocità e della potenza consumata, poiché è possibile effettivamente implementare una memoria cache condivisa dai core. Dal punto di vista logico, il s.o. si interfaccia come con un normale sistema multiprocessore.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>Come detto, con l&amp;rsquo;aumentare delle CPU l&amp;rsquo;aumento di potenza computazionale diminuisce poiché il bus di sistema condiviso diventa un collo di bottiglia. Per ovviare al problema è stato proposto un approccio alternativo chiamato &lt;strong>accesso non uniforme alla memoria (NUMA)&lt;/strong>. Le CPU sono dotate di una propria memoria e vengono comunicano tramite un &lt;strong>interconnessione di sistema condivisa&lt;/strong>. Lo svantaggio è la latenza introdotta durante l&amp;rsquo;accesso di una CPU a un&amp;rsquo;altra memoria.&lt;/p>
&lt;!-- Infine, i **server blade** sono sistemi che contengono nello stesso contenitore fisico, le schede del processore, dell'I/O e della rete. Ogni scheda è dotata di un processore che esegue in maniera indipendente il proprio s.o. -->
&lt;!-- ### Cluster di elaboratori
I **cluster di elaboratori (clustered systems, cluster)** sono sistemi multiprocessore che utilizzano più calcolatori completi (nodi), anziché un'unica macchina. Per tale motivo vengono chiamati **debolmente accoppiati (loosely coupled)**. Vengono adottati per offrire un'elevata disponibilità del servizio, nel caso di sistemi in cui si possono connettere più utenti. Possono difatti continuare a fornire il servizio anche in caso di traffico elevato **(degrado controllato, graceful degradation)** e, in alcuni casi, anche in presenza di guasti **(tolleranza ai guasti, fault-tolerance)**.
I cluster si dividono in due categorie:
- **asimmetrici:** in questo caso un calcolatore rimane in stato di **attesa attiva (hot standby mode)**, monitorando l'altro che esegue le applicazioni. Nel caso di guasto, il calcolatore in standby sopperisce all'altro.
- **simmetrici:** in questo caso entrambi i calcolatori si monitorano reciprocamente ed eseguono applicazioni, migliorando l'efficienza dell'intero sistema. -->
&lt;h2 id="attività-del-sistema-operativo">Attività del sistema operativo&lt;/h2>
&lt;p>Il s.o. si occupa di orchestrare i componenti di un calcolatore precedentemente descritti. In particolare, all&amp;rsquo;accensione del computer, viene lanciato il programma bootstrap, che si occupa di caricare in memoria il kernel del s.o. A sua volta il kernel inizia ad offrire servizi. L&amp;rsquo;utente interagirà col s.o. tramite eventi I/O che generano interruzioni, come spiegato sopra.&lt;/p>
&lt;h3 id="multiprogrammazione-e-multitasking">Multiprogrammazione e multitasking&lt;/h3>
&lt;p>In questa sezione presentiamo due principali tecniche di orchestrazione dei programmi da parte del s.o.&lt;/p>
&lt;p>La &lt;strong>multiprogrammazione&lt;/strong> consiste nel tenere in memoria centrale diversi processi. Il s.o. lancia un processo in base alla priorità data e nei momenti di interrupt, quando la CPU sarebbe inattiva, vengono invece eseguiti gli altri processi, massimizzando così l&amp;rsquo;utilizzo della CPU.&lt;/p>
&lt;p>Il &lt;strong>multitasking&lt;/strong> è un&amp;rsquo;estensione logica della multiprogrammazione. Una CPU multitasking è in grado di eseguire più lavori (task) intervallando le loro esecuzioni per garantire tempi di risposta rapida a ogni processo. Un metodo per ottenere tale rapidità è quello di adottare una &lt;strong>memoria virtuale&lt;/strong> ovvero una sovrastruttura logica che astrae la memoria fisica ed emula una memoria potenzialmente più grande di quella centrale.&lt;/p>
&lt;h3 id="duplice-modalità-di-funzionamento">Duplice modalità di funzionamento&lt;/h3>
&lt;p>Un s.o. dev&amp;rsquo;essere in grado di proteggersi da errori dovuti a esecuzioni di programmi errati, ma dev&amp;rsquo;essere anche in grado di prendere il controllo degli stessi per quando necessario.&lt;/p>
&lt;p>Per tale motivo il s.o. è in grado di distinguere le sue routine da quelle dell&amp;rsquo;utente attraverso la &lt;strong>dual mode&lt;/strong>, per la quale si utilizza un bit che segnala l&amp;rsquo;appartenenza di un processo a una &lt;strong>modalità di sistema&lt;/strong> privilegiata (0) o a una &lt;strong>modalità utente&lt;/strong> non privilegiata (1).&lt;/p>
&lt;p>All&amp;rsquo;avvio del calcolatore la modalità sistema è attiva, poi il s.o. passa alla modalità utente, per l&amp;rsquo;esecuzione delle applicazioni. Ogni volta che viene fatta una chiamata a sistema (p.e. con un&amp;rsquo;operazione I/O), questa viene gestita come un&amp;rsquo;interruzione software. Il s.o. passa alla modalità sistema e una volta eseguita la procedura si ritorna nuovamente alla modalità utente.&lt;/p>
&lt;p>La dual mode permette di definire istruzioni privilegiate (p.e. passaggio alla modalità kernel, etc.) che possono essere eseguite solo in modalità sistema. Se un programma utente causa un errore (p.e. accede a una zona di memoria proibita), l&amp;rsquo;hardware genera un&amp;rsquo;eccezione seguendo una condotta analoga a quella di un&amp;rsquo;interruzione. Il programma viene quindi terminato in maniera anomala.&lt;/p>
&lt;p>È possibile implementare diversi livelli di privilegio (p.e. Intel ne ha quattro, ARMv8 ne ha sette). Inoltre, le CPU che supportano la virtualizzazione hanno spesso una modalità distinta per la gestione della macchina virtuale.&lt;/p>
&lt;h3 id="timer">Timer&lt;/h3>
&lt;p>A volte può accadere che un programma entri in un loop infinito. Questo comportamento pericoloso monopolizzerebbe la CPU a scapito del s.o. che non può più riprendere il controllo. Per evitare ciò si può utilizzare un &lt;strong>timer&lt;/strong> programmabile affinché invii un&amp;rsquo;interruzinoe a intervalli fissi o variabili.&lt;/p>
&lt;p>Il timer è implementato come un contatore che si decrementa a ogni impulso e quando raggiunge lo 0, genera un&amp;rsquo;interruzione. Il s.o. riprende dunque il controllo e decide se concedere altro tempo o interrompere il programma.&lt;/p>
&lt;h3 id="gestione-delle-risorse">Gestione delle risorse&lt;/h3>
&lt;p>Riportiamo ora una panoramica sulle mansioni di gestione del s.o.&lt;/p>
&lt;h4 id="gestione-dei-processi">Gestione dei processi&lt;/h4>
&lt;p>Un &lt;strong>programma&lt;/strong> interagisce con il s.o. e l&amp;rsquo;utente solamente se è caricato in memoria e la CPU esegue le sue istruzioni. Nel momento in cui è in esecuzione, parliamo di &lt;strong>processo&lt;/strong>. Esempi di programmi sono il compilatore, un elaboratore di testi, o l&amp;rsquo;invio dati a una stampante. Un processo può anche avvalersi dell&amp;rsquo;utilizzo di vari sottoprocessi, detti jobs, parallelizzando così l&amp;rsquo;esecuzione.&lt;/p>
&lt;p>Ogni processo a singolo job (o thread singolo, single-threaded) possiede un &lt;strong>contatore di programma&lt;/strong> che indica quale istruzione eseguire. L&amp;rsquo;esecuzione avviene in maniera sequenziale. Nel caso di più thread il processo possiede più contatori di programma.&lt;/p>
&lt;p>Il s.o. è responsabile delle seguenti mansioni di gestione dei processi:&lt;/p>
&lt;ul>
&lt;li>creazione e cancellazione dei processi utenti e di sistema&lt;/li>
&lt;li>schedulazione di processi e thread sulle CPU&lt;/li>
&lt;li>sospensione e ripristino dei processi&lt;/li>
&lt;li>fornitura di meccanismi per la sincronizzazione dei processi&lt;/li>
&lt;li>fornitura di meccansimi per la comunicazione tra processi&lt;/li>
&lt;/ul>
&lt;h3 id="gestione-dei-file">Gestione dei file&lt;/h3>
&lt;p>Il &lt;strong>file&lt;/strong> è l&amp;rsquo;unità logica per l&amp;rsquo;organizzazione dell&amp;rsquo;informazione in un computer. Ogni programma è salvato in un file, ma un file può anche rappresentare una collezione di informazioni correlate, per esempio un documento di testo o un immagine. La struttura di un file può essere rigidamente codificata (p.e. file MP3) o più flessibile (p.e. file di testo). Il contenuto di un file può essere di tipo numerico, alfabetico o alfanumerico. I file possono essere raggruppati in una cartella (directory) e a sua volta le cartelle possono essere raggruppate in altre cartelle, formando una struttura ad albero.&lt;/p>
&lt;p>Il s.o. è responsabile delle seguenti attività connesse alla gestione dei file:&lt;/p>
&lt;ul>
&lt;li>creazione e cancellazione di file e directory&lt;/li>
&lt;li>fornitura delle funzioni fondamentali per la gestione di file e directory&lt;/li>
&lt;li>associazione dei file ai dispositivi di memoria secondaria&lt;/li>
&lt;li>creazione di copie di riserva (backup) dei file su dispositivi di memoriazzazione non volatili&lt;/li>
&lt;/ul>
&lt;h3 id="gestione-della-memoria">Gestione della memoria&lt;/h3>
&lt;p>Per eseguire un programma, è necessario che questo sia associato a indirizzi assoluti e sia caricato nella memoria. Quando il programma termina, la parte di memoria a esso dedicata viene liberata. Per migliorare l&amp;rsquo;efficienza di questo sistema, è possibile mantenere più programmi contemporaneamente in memoria. La scelta della struttura della memoria dipende dal tipo di s.e.&lt;/p>
&lt;p>Il s.o. è responsabile delle seguenti mansioni di gestione dei processi:&lt;/p>
&lt;ul>
&lt;li>tenere traccia di quali parti di memoria sono attualmente usate e da chi&lt;/li>
&lt;li>assegnare e revocare lo spazio di memoria secondo le necessità&lt;/li>
&lt;li>decidere quali processi (o parti di essi) e dati debbano essere caricati in memoria centrale o trasferiti sulla memoria di massa&lt;/li>
&lt;/ul>
&lt;h4 id="gestione-della-memoria-di-massa">Gestione della memoria di massa&lt;/h4>
&lt;p>Il calcolatore ha bisogno di una memoria secondaria non volatile per mantenere il suo stato anche quando è spento. La maggior parte dei s.e. utilizza nastri magnetici e NVM per la memorizzazione.&lt;/p>
&lt;p>Il s.o. è responsabile delle seguenti attività connesse alla gestione della memoria di massa:&lt;/p>
&lt;ul>
&lt;li>montare/smontare (mounting/unmounting) le unità di memoria&lt;/li>
&lt;li>gestione dello spazio libero&lt;/li>
&lt;li>scheduling del disco&lt;/li>
&lt;li>partizionamento&lt;/li>
&lt;li>protezione&lt;/li>
&lt;/ul>
&lt;p>In alcuni casi è utile avere a disposizione anche delle memorie terziare, ad esempio per avere un backup dei dati.&lt;/p>
&lt;h4 id="gestione-della-cache">Gestione della cache&lt;/h4>
&lt;p>La &lt;strong>cache&lt;/strong> è un&amp;rsquo;unità di memoria che replica parte dello stato della memoria centrale. È pensata per fornire un accesso veloce ai dati da parte del calcolatore ed è più piccola della memoria centrale perché più costosa. Il s.o. si occupa solitamente dell&amp;rsquo;interazione tra cache e memoria centrale, mentre l&amp;rsquo;hardware di sistema dedicato si occupa dell&amp;rsquo;interazione tra cache e CPU. Un calcolatore può contenere più cache per aumentare l&amp;rsquo;efficienza nell&amp;rsquo;esecuzione delle istruzioni.&lt;/p>
&lt;h4 id="coerenza-della-memoria">Coerenza della memoria&lt;/h4>
&lt;p>Avendo a che fare con più unità di memoria diverse (registro, cache, memoria centrale, memoria secondaria), è importante mantenere lo stesso stato di un&amp;rsquo;applicazione in maniera da avere una coerenza tra i dati processati dalla CPU. Questo non rappresenta di per sé un problema nei sistemi single-threaded, ma richiede maggiore attenzione in quelli multitasking, dove è necessario garantire che ogni processo acceda alla copia più recente di un dato. La situazione si complica con i sistemi multiprocessore, per i quali ciascuna CPU possiede anche una cache locale (e diventa ancora più difficile nei sistemi distribuiti, con più calcolatori).&lt;/p>
&lt;p>Questa situazione di &lt;strong>coerenza della cache&lt;/strong> si risolve solitamente a livello hardware, senza l&amp;rsquo;intervento diretto del s.o.&lt;/p>
&lt;h3 id="gestione-dellio">Gestione dell&amp;rsquo;I/O&lt;/h3>
&lt;p>La varietà di dispositivi I/O che un calcolatore può ospitare richiede un&amp;rsquo;interfaccia di gestione per permettere un&amp;rsquo;interazione più semplice, gestita dal s.o. &lt;strong>sottosistema di I/O&lt;/strong>, composto dalle seguenti parti:&lt;/p>
&lt;ul>
&lt;li>un componente di gestione della memoria, buffer di I/O, cache e spooling&lt;/li>
&lt;li>un&amp;rsquo;interfaccia generale per i driver di dispositivi&lt;/li>
&lt;li>i driver dei dispositivi&lt;/li>
&lt;/ul>
&lt;h2 id="sicurezza-e-protezione">Sicurezza e protezione&lt;/h2>
&lt;p>Per garantire un corretto funzionamento del s.e. è importante che il s.o. implementi dei sistemi di &lt;strong>protezione&lt;/strong> dei dati. Ad esempio in un sistema a più utenti, un utente deve avere la garanzia di poter decidere chi può accedere ai suoi file. Similmente, in un sistema a più processi, un processo non può scrivere in parti di memoria ad esso non allocate, previa autorizzazione del s.o. In questi casi è possibile prevedere degli ID identificativi dell&amp;rsquo;utente e del processo per decidere di quali privilegi dispongano.&lt;/p>
&lt;p>In aggiunta a ciò, pur essendo dotato di protezione adeguata, un sistema può rimanere esposto agli accessi abusivi rischiando malfunzionamenti. Se ad esempio a un utente vengono trafugate le informazioni di autenticazione, un soggetto malevolo può essere in grado di modificare i suoi file, anche se la protezione dati è attiva. È compito della &lt;strong>sicurezza&lt;/strong> non permettere che ciò accada, difendendo il s.e. da attacchi provenienti dall&amp;rsquo;interno o dall&amp;rsquo;esterno.&lt;/p>
&lt;!-- ## Virtualizzazione
La **virtualizzazione** è una tecnica che permette di astrarre l'hardware di un singolo computer in diversi ambienti di esecuzione.
L'**emulazione** è simile alla virtualizzazione e permette di utilizzare un s.o. diverso da quello nativo (p.e. si può virtualizzare un s.o. Linux su Windows), ma può essere utilizzato per rendere un s.o. compatibile con una CPU per cui non era stato progettato.
Con la virtualizzazione invece si può emulare un nuovo s.o. -->
&lt;h2 id="sistemi-embedded-real-time">Sistemi embedded real-time&lt;/h2>
&lt;p>Tra i vari s.e. i sistemi embedded attualmente costituiscono la tipologia predominante di calcolatore. La loro interfaccia utente è poco elaborata o addirittura assente e i loro compiti sono piuttosto specifici (p.e. monitoraggio ambienti, gestione di dispositivi meccanici, etc.). I sistemi &lt;strong>embedded&lt;/strong> sono molto vari e possono essere implementati sia con sistemi general-purpose (di cui viene utilizzata solo una parte) sia con sistemi special-purpose.&lt;/p>
&lt;p>In generale, i loro s.o. sono &lt;strong>real-time&lt;/strong> ovvero hanno dei vincoli di tempo molto rigidi nell&amp;rsquo;esecuzione dei loro task, poiché vengono spesso utilizzati per processi critici (p.e. la produzione industriale) e se non riescono a completare in tempo le loro attività possono innescare una crisi. Ad esempio un braccio robotico dovrà necessariamente eseguire un&amp;rsquo;operazione di stop prima che vada a scontrarsi con l&amp;rsquo;automobile che era impegnato a costruire.&lt;/p>
&lt;div id="references-1784797996187486311" class="references" style="margin-top: 5rem;">
&lt;p>&lt;strong>References&lt;/strong>&lt;/p>
A. Silberschatz, P. B. Galvin, G. Gagne, &lt;em>Sistemi Operativi. Concetti ed Esempi&lt;/em>, Decima edizione. Ch. 1.
&lt;/div>
&lt;script>
document.addEventListener('DOMContentLoaded', function () {
var ref = document.getElementById('references-1784797996187486311');
var fn = document.querySelector('.footnotes');
if (ref &amp;&amp; fn &amp;&amp; fn.parentNode) {
fn.parentNode.insertBefore(ref, fn.nextSibling);
}
});
&lt;/script></description></item><item><title>2 Strutture dei sistemi operativi</title><link>https://aretor.it/courses/os2425/lessons/02_strutture/</link><pubDate>Thu, 06 Mar 2025 00:00:00 +0000</pubDate><guid>https://aretor.it/courses/os2425/lessons/02_strutture/</guid><description>&lt;p>In questa lezione andremo a vedere le caratteristiche di design da tenere in considerazione quando si sviluppa un sistema operativo. Parleremo dei principali servizi che un sistema operativo deve fornire, delle chiamate a sistema con le quali è chiamato a operare e delle varie tipologie di architetture di un s.o.&lt;/p>
&lt;h2 id="21-servizi-di-un-sistema-operativo">2.1 Servizi di un sistema operativo&lt;/h2>
&lt;p>Nel capitolo precedente abbiamo fatto una panoramica generale sui calcolatori e i sistemi operativi. Ricapitoliamo i servizi principali che un s.o. deve fornire:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Interfaccia con l&amp;rsquo;utente (user interface, UI):&lt;/strong> è il metodo di interazione con il calcolatore. Tipicamente si utilizza un&amp;rsquo;&lt;strong>interfaccia utente grafica (Graphical User Interface, GUI)&lt;/strong> con primitive grafice denominate &amp;ldquo;finestre&amp;rdquo;. Smartphone e tablet utilizzano &lt;strong>interfacce touch&lt;/strong> con le quali lo schermo funge da dispositivo di input e output. Infine, è possibile anche utilizzare un&amp;rsquo;&lt;strong>interfaccia a riga di comando (Command Line Interface, CLI)&lt;/strong> dalla grafica stilizzata, la cui interazione avviene con una tastiera.&lt;/li>
&lt;li>&lt;strong>Esecuzione di un programma&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Operazioni di I/O&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Gestione del file system&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Comunicazioni tra processi&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Rilevamento di errori&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Allocazione delle risorse&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Logging&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Protezione e sicurezza&lt;/strong>&lt;/li>
&lt;/ul>
&lt;h2 id="22-interfaccia-utente">2.2 Interfaccia utente&lt;/h2>
&lt;p>L&amp;rsquo;interfaccia è l&amp;rsquo;architettura logica per comunicare con il s.o.&lt;/p>
&lt;h3 id="221-interfaccia-a-riga-di-comando-cli">2.2.1 Interfaccia a riga di comando (CLI)&lt;/h3>
&lt;p>Una &lt;strong>command line interface (CLI)&lt;/strong> ruota attorno al concetto di &lt;strong>interprete dei comandi&lt;/strong>, un programma speciale che si avvia all&amp;rsquo;avvio di un job o al login dell&amp;rsquo;utente. Un s.o. può proporre diverse scelte chiamate &lt;strong>shell&lt;/strong> (p.e. la Bourne-Again Shell, BASH, nei sistemi UNIX/Linux).&lt;/p>
&lt;p>Il compito principale dell&amp;rsquo;interprete è di eseguire i comandi che vengono digitati dall&amp;rsquo;utente al suo interno. Esempi di comandi sono la creazione oppure la ricerca di un file. Il comando può fare riferimento a un programma nativo dell&amp;rsquo;interprete o a uno di sistema, esterno ad esso.&lt;/p>
&lt;p>Esempio: &lt;code>rm file.txt&lt;/code> rimuove il file chiamato &lt;code>file.txt&lt;/code>. L&amp;rsquo;interprete cerca un file chiamato &lt;code>rm&lt;/code>, lo carica in memoria e lo esegue con il parametro &lt;code>file.txt&lt;/code>.&lt;/p>
&lt;p>La CLI garantisce un&amp;rsquo;interazione a basso livello con il calcolatore.&lt;/p>
&lt;h3 id="222-interfaccia-grafica-con-lutente">2.2.2 Interfaccia grafica con l&amp;rsquo;utente&lt;/h3>
&lt;p>Una &lt;strong>Graphical User Interface (GUI)&lt;/strong> fornisce una grafica più elaborata e intuitiva. Invece di digitare i comandi, gli utenti possono selezionare elementi visuali presenti sulla schermata, come ad esempio le &lt;strong>icone&lt;/strong> di file. Un programma o una cartella verrà quindi rappresentato dalla propria icona con un nome sottostante.&lt;/p>
&lt;p>La GUI garantisce più facilità d&amp;rsquo;uso ma meno flessibilità rispetto a una CLI.&lt;/p>
&lt;h4 id="2221-interfaccia-touch-screen">2.2.2.1 Interfaccia touch screen&lt;/h4>
&lt;p>È una GUI che permette la trasmissione di input tattili. Molti dispositivi portatili odierni ne fanno uso.&lt;/p>
&lt;h2 id="23-chiamate-di-sistema">2.3 Chiamate di sistema&lt;/h2>
&lt;p>Le &lt;strong>chiamate di sistema (system call)&lt;/strong> sono routine invocate da un programma per chiedere al s.o. di fare qualcosa. Sono generalmente scritte in C/C++. L&amp;rsquo;esecuzione di un comando richiede solitamente più chiamate a sistema. Il comando &lt;code>cp in.txt out.txt&lt;/code>, che copia il contenuto del file &lt;code>in.txt&lt;/code> sul file &lt;code>out.txt&lt;/code>. richiede varie chiamate a sistema tra cui una per aprire il file di input e una per creare il file di output.&lt;/p>
&lt;h3 id="231-interfaccia-per-la-programmazione-di-applicazioni-api">2.3.1 Interfaccia per la programmazione di applicazioni (API)&lt;/h3>
&lt;p>Il comando &lt;code>cp&lt;/code> precedentemente illustrato è composto da una serie di comandi che vengono raggruppati per evitare all&amp;rsquo;utente di dover specificare passo per passo tutte le chiamate a sistema. L&amp;rsquo;astrazione e il raggruppamento dei comandi è uno dei concetti base dello sviluppo di codice.&lt;/p>
&lt;p>Per alleviare il carico cognitivo, dunque, gli sviluppatori predispongono una &lt;strong>application programming interface API&lt;/strong>, contenente una serie di funzioni documentate a disposizione del programmatore, in maniera da schermarlo dai dettagli a basso livello del codice. Il termine interfaccia è qui utilizzato con un significato diverso da quello visto precedentemente. Un&amp;rsquo;API garantisce anche la portabilità verso altri sistemi che garantiscono il supporto all&amp;rsquo;API. Esempi di API sono l&amp;rsquo;API Windows, l&amp;rsquo;API POSIX (utilizzata nei sistemi UNIX, Linux e macOS).&lt;/p>
&lt;p>Collegato all&amp;rsquo;API c&amp;rsquo;è l&amp;rsquo;&lt;strong>ambiente di esecuzione al run-time (RTE, run-time environment)&lt;/strong>. La suite completa di programmi necessaria per eseguire applicazioni scritte in un determinato linguaggio di programmazione, che include i compilatori o gli interpreti e altri software, come librerie e loader. L&amp;rsquo;RTE fornisce un&amp;rsquo;&lt;strong>interfaccia alle chiamate di sistema (system call interface)&lt;/strong> che collega il linguaggio alle system call del s.o.&lt;/p>
&lt;p>Le chiamate a funzione dell&amp;rsquo;API vengono intercettate dal RTE che invoca le relative chiamate a sistema e passa poi il risultato nuovamente all&amp;rsquo;API.&lt;/p>
&lt;p>Talvolta, le chiamate a sistema richiedono dei parametri. Questi possono essere passati direttamente nei registri della CPU. Se però ce ne sono troppi è necessario memorizzarli in un &lt;strong>blocco&lt;/strong> o &lt;strong>tabella di memoria&lt;/strong> passando poi il suo indirizzo. Alternativamente, è possibile utilizzare uno &lt;strong>stack&lt;/strong> in cui i parametri vengono inseriti (push) e poi successivamente prelevati (pop) dal s.o.&lt;/p>
&lt;h3 id="232-categorie-di-chiamate-a-sistema">2.3.2 Categorie di chiamate a sistema&lt;/h3>
&lt;p>Le classifichiamo in sei categorie:&lt;/p>
&lt;ol>
&lt;li>controllo dei processi&lt;/li>
&lt;li>gestione dei file&lt;/li>
&lt;li>gestione dei dispositivi/risorse&lt;/li>
&lt;li>gestione delle informazioni&lt;/li>
&lt;li>comunicazioni&lt;/li>
&lt;li>protezione&lt;/li>
&lt;/ol>
&lt;p>Utilizziamo denominazioni generiche, poiché le chiamate hanno nomi specifici in base al s.o. utilizzato.&lt;/p>
&lt;h4 id="2321-controllo-dei-processi">2.3.2.1 Controllo dei processi&lt;/h4>
&lt;p>Chiamate:&lt;/p>
&lt;ul>
&lt;li>Notificare la normale terminazione di un processo (&lt;code>end()&lt;/code>)&lt;/li>
&lt;li>Notificare la terminazione anomala di un processo (&lt;code>abort()&lt;/code>)&lt;/li>
&lt;li>Salvare copia dello stato (memory dump, &lt;code>dump()&lt;/code>) per debug&lt;/li>
&lt;li>Generare messaggi d&amp;rsquo;errore su schermo&lt;/li>
&lt;/ul>
&lt;p>In caso di esecuzione anomala, il s.o. passa il controllo all&amp;rsquo;interprete dei comandi
che continua a eseguire i comandi successivi. È compito dell&amp;rsquo;utente rispondere all&amp;rsquo;errore. È possibile stabilire un livello di errore ($0$ indica la terminazione normale).&lt;/p>
&lt;p>&lt;strong>Problema:&lt;/strong> a chi ritornare il controllo in caso p.e., di terminazione anomala o esecuzione parallela?&lt;/p>
&lt;ul>
&lt;li>Caricamento (&lt;code>load()&lt;/code>) ed esecuzione (&lt;code>execute()&lt;/code>) di un altro processo.&lt;/li>
&lt;li>Ispezione (&lt;code>get_process_attributes()&lt;/code>) e impostazione (&lt;code>set_process_attributes()&lt;/code>) degli attributi del processo (p.e. priorità, tempo max di esecuzione, etc.)&lt;/li>
&lt;li>Impostazione di criteri di terminazione, p.e. timer (&lt;code>wait_time()&lt;/code>) o attesa di un evento (&lt;code>wait_event()&lt;/code>) che dev&amp;rsquo;essere segnalato (&lt;code>signal_event()&lt;/code>).&lt;/li>
&lt;/ul>
&lt;p>Se due processi tentano di scrivere su una zona di memoria condivisa, c&amp;rsquo;è il rischio che venga rovinata l&amp;rsquo;integrità dei dati. Per ovviare al problema, un processo può bloccare il file prevenendo l&amp;rsquo;intervento di altri processi su di esso, acquisendo un &lt;strong>lock&lt;/strong>. Il lock sarà poi rilasciato a operazione conclusa. Le chiamate a sistema relative sono&lt;/p>
&lt;ul>
&lt;li>Acquisizione (&lt;code>acquire_lock()&lt;/code>) e rilascio (&lt;code>release_lock()&lt;/code>) del lock.&lt;/li>
&lt;/ul>
&lt;!-- Un processo deve potersi fermare sia normalmente (`end()`) sia in maniera anomala (`abort()`). Talvolta è necessario salvare una copia dello stato (memory dump) per investigare (debug) successivamente il problema, e un messaggio d'errore viene generato sullo schermo. In ogni caso, il s.o. trasferirà il controllo all'interprete dei commandi, che continuerà eseguendo il comando successivo. Sarà compito dell'utente eventualmente rispondere all'errore. È possibile anche stabilire un livello di errore: con $0$ viene indicata la terminazione normale, in maniera da standardizzare la procedura di terminazione. -->
&lt;!-- A volte un processo può richiedere il caricamento (`load()`) e l'esecuzione (`execute()`) di un altro processo. In questo caso, decidere a chi ritornare il controllo non è immediato. Può accadere ad esempio che il processo originale venga terminato o sospeso, o che il nuovo processo venga eseguito in maniera parallela. -->
&lt;!-- Un processo viene inoltre associato ad alcuni attributi quali la priorità o il tempo massimo di esecuzione. È necessario garantire l'ispezione (`get_process_attributes()`) e l'impostazione (`set_process_attributes()`) di questi attributi. -->
&lt;!-- Per garantire la terminazione si può impostare un timer (`wait_time()`) oppure attendere un evento (`wait_event()`) che dev'essere segnalato da un altro processo (`signal_event()`). -->
&lt;!-- In caso di condivisione dati, i processi possono organizzarsi per non intervenire contemporaneamente su essi, magari scrivendoci sopra nello stesso istante e rovinando la loro integrità. A tal proposito un processo può bloccare la scrittura su un file condiviso (`acquire_lock()`) e rilasciare il blocco una volta conclusa l'operazione con esso (`release_lock()`). -->
&lt;!-- ##### FreeBSD
FreeBSD (derivato da UNIX Berkeley) è un s.o. multitasking. All'avvio di una sessione, il sistema esegue l'interprete, ma essendo multitasking può continuare a eseguire altri processi di sistema. `fork()` crea un processo e `exec()` lo esegue. Una volta creato il processo, la shell attende il termine della sua esecuzione prima di concedere di nuovo il controllo all'utente. È possibile eseguire un processo in background: la shell avvia il processo e ritorna immediatamente il controllo all'utente. Non è possibile inviare altri parametri al processo in background poiché la shell riprende il controllo della tastiera. Completato il proprio compito, il processo esegue la chiamata di sistema `exit()`. -->
&lt;h4 id="2322-gestione-dei-file">2.3.2.2 Gestione dei file&lt;/h4>
&lt;p>Chiamate:&lt;/p>
&lt;ul>
&lt;li>Creare (&lt;code>create()&lt;/code>) e cancellare (&lt;code>delete()&lt;/code>) file&lt;/li>
&lt;li>Aprire (&lt;code>open()&lt;/code>), leggere (&lt;code>read()&lt;/code>), scrivere (&lt;code>write()&lt;/code>) e chiudere (&lt;code>close()&lt;/code>) file&lt;/li>
&lt;li>Spostare (&lt;code>move()&lt;/code>), copiare (&lt;code>copy()&lt;/code>) e incollare (&lt;code>paste()&lt;/code>) file&lt;/li>
&lt;/ul>
&lt;h4 id="2323-gestione-dei-dispositivirisorse">2.3.2.3 Gestione dei dispositivi/risorse&lt;/h4>
&lt;p>Essenziali per l&amp;rsquo;interazione con l&amp;rsquo;utente e per la gestione dei processi. Parliamo genericamente di risorse fisiche (p.e. memoria centrale e secondaria) virtuali (p.e. i file)
Chiamate:&lt;/p>
&lt;ul>
&lt;li>Richiedere (&lt;code>request()&lt;/code>) e rilasciare (&lt;code>release()&lt;/code>) una risorsa&lt;/li>
&lt;li>Leggere (&lt;code>read()&lt;/code>) e scrivere (&lt;code>write()&lt;/code>) una risorsa&lt;/li>
&lt;/ul>
&lt;!-- I dispositivi sono essenziali per l'interazione con l'utente, ma anche per la gestione dei processi (e.g. memoria centrale e secondaria). Parliamo genericamente di risorse utilizzate da un utente o un processo, che possono essere fisiche ma anche virtuali (p.e. i file). Similmente ai dati condivisi, il s.o. deve permettere la richiesta di risorse (`request()`) e il loro rilascio (`release()`). Come con i file le risorse possono essere lette e scritte. -->
&lt;h4 id="2324-gestione-delle-informazioni">2.3.2.4 Gestione delle informazioni&lt;/h4>
&lt;p>Chiamate:&lt;/p>
&lt;ul>
&lt;li>Ottenere l&amp;rsquo;ora (&lt;code>time()&lt;/code>) e la data (&lt;code>date()&lt;/code>)&lt;/li>
&lt;li>Ottenere il numero di utenti collegati&lt;/li>
&lt;li>Ottenere la versione del sistema operativo&lt;/li>
&lt;li>Ottenere informazioni per il debug, come il dump (&lt;code>dump()&lt;/code>).&lt;/li>
&lt;/ul>
&lt;h4 id="2325-comunicazione-tra-processi">2.3.2.5 Comunicazione tra processi&lt;/h4>
&lt;p>Esistono due modelli diffusi di comunicazione tra processi&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Modello a scambio di messaggi:&lt;/strong> i processi utilizzano una casella di posta (mailbox) condivisa in cui si scambiano i messaggi. È necessaria l&amp;rsquo;identificazione dei processi attraverso il &lt;strong>nome della macchina (host name)&lt;/strong> e l&amp;rsquo;&lt;strong>identificatore del processo (Process ID, PID)&lt;/strong>. Questo modello è utile per lo scambio di piccoli messaggi.&lt;/li>
&lt;/ol>
&lt;p>Le chiamate sono:&lt;/p>
&lt;ul>
&lt;li>Accesso all&amp;rsquo;id dell&amp;rsquo;host (&lt;code>get_hostid()&lt;/code>) del processo (&lt;code>get_processid()&lt;/code>)&lt;/li>
&lt;li>Apertura (&lt;code>open_connection()&lt;/code>) e chiusura della connessione (&lt;code>close_connection()&lt;/code>). Vengono chiamate &lt;code>open()&lt;/code>e &lt;code>close()&lt;/code> conseguentemente.&lt;/li>
&lt;li>Attesa (listening, &lt;code>wait_for_connection()&lt;/code>) e accettazione della connessione (&lt;code>accept_connection()&lt;/code>).&lt;/li>
&lt;li>Lettura (&lt;code>read_message()&lt;/code>) e scrittura (&lt;code>write_message()&lt;/code>) dei messaggi&lt;/li>
&lt;/ul>
&lt;ol start="2">
&lt;li>&lt;strong>Modello a memoria condivisa:&lt;/strong> i processi usano chiamate di sistema per creare e accedere alle aree di memoria possedute da altri processi. Occorre coordinamento con gli altri processi e con il s.o., che di solito impedisce questa pratica. Questo modello è utile per ottenere maggiore efficienza nello scambio di messaggi.&lt;/li>
&lt;/ol>
&lt;p>Le chiamate sono:&lt;/p>
&lt;ul>
&lt;li>Creare (&lt;code>shared_memory_create()&lt;/code>) e accedere (&lt;code>shared_memory_attach()&lt;/code>) ad aree di memoria possedute da altri processi&lt;/li>
&lt;/ul>
&lt;h4 id="2326-protezione">2.3.2.6 Protezione&lt;/h4>
&lt;p>Chiamate:&lt;/p>
&lt;ul>
&lt;li>Impostare (&lt;code>set_permission()&lt;/code>) e ottenere (&lt;code>get_permission()&lt;/code>) i permessi di accesso alle risorse (p.e. file e dischi)&lt;/li>
&lt;li>Permettere (&lt;code>allow_user()&lt;/code>) o negare (&lt;code>deny_user()&lt;/code>) l&amp;rsquo;accesso a una risorsa a un particolare utente.&lt;/li>
&lt;/ul>
&lt;h2 id="24-servizi-di-sistema">2.4 Servizi di sistema&lt;/h2>
&lt;p>I servizi di sistema sono a un livello di astrazione superiore rispetto alle chiamate e offrono un ambiente più conveniente per lo sviluppo e l&amp;rsquo;esecuzione dei programmi. Possono essere semplici interfacce per le chiamate di sistema o architetture logiche più complesse. Li classifichiamo in:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Gestione dei file:&lt;/strong> per operazioni su file e directory&lt;/li>
&lt;li>&lt;strong>Informazioni di stato:&lt;/strong> data, monitoraggio memoria o prestazioni, etc.&lt;/li>
&lt;li>&lt;strong>Modifica dei file:&lt;/strong> per modificare il contenuto dei file&lt;/li>
&lt;li>&lt;strong>Ambienti di supporto alla programmazione:&lt;/strong> compilatori, assemblatori, debugger, interpreti, etc.&lt;/li>
&lt;li>&lt;strong>Caricamento ed esecuzione di programmi:&lt;/strong> per il caricamento in memoria dei programmi&lt;/li>
&lt;li>&lt;strong>Comunicazioni:&lt;/strong> per la comunicazione tra processi in locale e remoto&lt;/li>
&lt;li>&lt;strong>Servizi in background:&lt;/strong> anche noti come &lt;strong>servizi, sottosistemi o demoni (daemon)&lt;/strong>. Sono sempre attivi e attendono eventi per iniziare le loro routine (p.e. rilevare connessioni di rete, scheduling dei processi, etc.).&lt;/li>
&lt;/ul>
&lt;h2 id="25-programmi-applicativi">2.5 Programmi applicativi&lt;/h2>
&lt;p>A un livello maggiore troviamo i programmi applicativi che risolvono problemi comuni. Sono inclusi, ad esempio, i browser web, i processori di testo, fogli di calcolo, sistemi di basi di dati, videogiochi 🎮, etc.&lt;/p>
&lt;h2 id="26-le-differenze-tra-i-sistemi-operativi">2.6 Le differenze tra i sistemi operativi&lt;/h2>
&lt;p>In generale, un&amp;rsquo;applicazione può essere utilizzata solo per il sistema operativo per cui è stata progettata. Le cause principali sono:&lt;/p>
&lt;ul>
&lt;li>Il s.o. ha un formato binario per le applicazioni con un header che dipende dal s.o. Certi metadati devono trovarsi esattamente nella posizione richiesta altrimenti il programma non può essere eseguito.&lt;/li>
&lt;li>Le CPU hanno set di istruzioni differenti.&lt;/li>
&lt;li>I diversi s.o. hanno chiamate di sistema differenti.&lt;/li>
&lt;/ul>
&lt;p>Una tecnica per mitigare questi problemi è accordarsi su uno standard da seguire. Similmente alle API è possibile definire un &lt;strong>Application Binary Interface (ABI)&lt;/strong> per stabilire il modo in cui diversi componenti di un codice binario possano interfacciarsi su un determinato s.o. e architettura. Un&amp;rsquo;ABI specifica dettagli come la dimensione degli indirizzi, i metodi di passaggio dei parametri, l&amp;rsquo;organizzazione dello stack runtime, etc.&lt;/p>
&lt;p>È comunque possibile creare delle applicazioni per più s.o. (cross-platform) in tre modi:&lt;/p>
&lt;ol>
&lt;li>L&amp;rsquo;applicazione è scritta in un linguaggio interpretato (p.e. Python o Ruby). Le istruzioni vengono interpretate e poi tradotte nel set di istruzioni nativo. Questo comporta un overhead nelle performance.&lt;/li>
&lt;li>L&amp;rsquo;applicazione è scritta in un linguaggio che supporta una macchina virtuale (p.e. Java). La macchina virtuale fa parte del RTE del linguaggio. Gli svantaggi sono simili a quelle dei linguaggi interpretati&lt;/li>
&lt;li>Lo sviluppatore di un&amp;rsquo;applicazione può utilizzare un linguaggio specifico in cui il compilatore genera binari nel linguaggio del s.o. e della macchina (p.e. API Posix per il mantenimento della compatibilità del source code tra le varianti di s.o. UNIX-like). Questo richiede di portare l&amp;rsquo;applicazione su ciascun sistema in cui verrà eseguito con un conseguente overhead nella parte di sviluppo e debug.&lt;/li>
&lt;/ol>
&lt;h2 id="27-progettazione-di-un-so">2.7 Progettazione di un s.o.&lt;/h2>
&lt;p>Nello sviluppo di un s.o. bisogna avere in mente gli &lt;strong>obiettivi degli utenti&lt;/strong> (utilità, facilità d&amp;rsquo;uso, velocità, etc.) e gli &lt;strong>obiettivi del sistema&lt;/strong> (facilità di progettazione, facilità di manutenzione, flessibilità, affidabilità, etc.). La disciplina del &lt;strong>software engineering&lt;/strong> si occupa anche di questi aspetti di progettazione.&lt;/p>
&lt;h3 id="271-meccanismi-e-politiche">2.7.1 Meccanismi e politiche&lt;/h3>
&lt;ul>
&lt;li>I meccanismi determinano come eseguire qualcosa. Un esempio è il timer di sistema che assicura la protezione della CPU. Un meccanismo mira a essere il più generico possibile per poterlo utilizzare con diverse politiche.&lt;/li>
&lt;li>Le politiche stabiliscono cosa si debba fare. La decisione del tempo da impostare nel timer di sistema è una politica.&lt;/li>
&lt;/ul>
&lt;p>I sistemi operativi basati su microkernel separano politche e meccanismi permettendo solamente una serie di funzioni fondamentali da utilizzare come elementi di base per la progettazione.&lt;/p>
&lt;h2 id="28-realizzazione-di-un-so">2.8 Realizzazione di un s.o.&lt;/h2>
&lt;p>La realizzazione di un s.o. richiede la scelta di un linguaggio adatto. Originariamente il linguaggio utilizzato era l&amp;rsquo;assembly. Adesso si tende a utilizzare linguaggi ad alto livello come C/C++. I vantaggi sono portabilità e facilità di lettura del codice, al costo di una minore velocità d&amp;rsquo;esecuzione e una maggiore occupazione di spazio di memoria. Il compilatore si occuperà di ottimizzare l&amp;rsquo;assembly generato per garantire maggiore efficienza.&lt;/p>
&lt;h2 id="29-struttura-di-un-sistema-operativo">2.9 Struttura di un sistema operativo&lt;/h2>
&lt;p>Nel corso degli anni, sono state proposte diverse architetture per un s.o. Vediamone alcune:&lt;/p>
&lt;h3 id="291-struttura-monolitica">2.9.1 Struttura monolitica&lt;/h3>
&lt;p>In questo caso, tutte le funzionalità del kernel vengono inserite in un singolo file binario statico che viene eseguito in un unico spazio d&amp;rsquo;indirizzamento. Esempi di kernel monolitico sono UNIX, diviso in kernel e programmi di sistema, e Linux che ha una struttura più modulare. Un s.o. monolitico ha il vantaggio della velocità di esecuzione ma è difficile da estendere. Per questo motivo viene chiamato anche &lt;strong>sistema strettamente accoppiato (tightly coupled)&lt;/strong>&lt;/p>
&lt;h3 id="292-struttura-stratificata">2.9.2 Struttura stratificata&lt;/h3>
&lt;p>In alternativa a un s.o. monolitico, possiamo progettare un &lt;strong>sistema debolmente accoppiato (loosely coupled)&lt;/strong>, suddiviso in componenti separate i cui cambiamenti agiscono solo su di esse. Questo permette uno sviluppo del s.o. più semplice. È possibile ad esempio stratificare i moduli: l&amp;rsquo;hardware corrisponderà allo strato più basso, mentre quello più alto sarà legato all&amp;rsquo;interfaccia con l&amp;rsquo;utente. Lo strato del s.o. è composto da strutture dati e routine utilizzabili da livelli più alti. L&amp;rsquo;architettura stratificata è stata utilizzata per le reti di computer (p.e. TCP/IP), ma molto poco per i s.o. per via della difficoltà di progettazione e degli overhead nella comunicazione tra strati.&lt;/p>
&lt;h3 id="293-struttura-a-microkernel">2.9.3 Struttura a microkernel&lt;/h3>
&lt;p>Con la crescita in dimensioni dei sistemi monolitici, venne proposto una nuova architettura per il s.o. che si propose di eliminare tutte le componenti non essenziali dal kernel e di trasformarle in programmi utente o di sistema. Questo approccio, chiamato microkernel, garantisce facile estendibilità dei servizi, ma incorre in overhead dovuti alla comunicazione tra i servzi del s.o. Il microkernel è stato adottato per il s.o. Mach. Darwin, il kernel di macOS, ha anch&amp;rsquo;esso una struttura a microkernel ed è utilizzato assieme a Mach (parliamo quindi più propriamente di un&amp;rsquo;architettura a due microkernel).&lt;/p>
&lt;h3 id="294-struttura-a-moduli">2.9.4 Struttura a moduli&lt;/h3>
&lt;p>L&amp;rsquo;utilizzo di &lt;strong>moduli del kernel caricabili dinamicamente&lt;/strong> è forse il miglior approccio disponibile per progettare un s.o. In questo caso, il kernel è costituito da componenti di base che vengono integrati dinamicamente con nuove funzionalità. Linux utilizza questo approccio con alcuni moduli del kernel caricabili.&lt;/p>
&lt;h3 id="295-struttura-ibrida">2.9.5 Struttura ibrida&lt;/h3>
&lt;p>I s.o. odierni utilizzano solitamente più strutture tra quelle descritte prima. Linux e Solaris sono monolitici, ma anche modulari. Windows è in gran parte monolitico ma con alcuni aspetti progettati secondo l&amp;rsquo;architettura a microkernel e quella modulare.&lt;/p>
&lt;h2 id="210-avvio-di-un-sistema-operativo">2.10 Avvio di un sistema operativo&lt;/h2>
&lt;p>Il processo di avvio di un calcolatore è noto come &lt;strong>boot&lt;/strong> e nella maggior parte dei casi funziona nel seguente modo:&lt;/p>
&lt;ol>
&lt;li>Il &lt;strong>programma di bootstrap&lt;/strong> o &lt;strong>bootloader&lt;/strong>, si occupa di caricare il kernel del s.o.&lt;/li>
&lt;li>Il kernel viene caricato in memoria ed eseguito&lt;/li>
&lt;li>Il kernel inizializza l&amp;rsquo;hardware&lt;/li>
&lt;li>Il file system principale (root file system) viene montato&lt;/li>
&lt;/ol>
&lt;p>Esistono vari programmi di avvio, di sotto ne riportiamo alcuni tra i più noti&lt;/p>
&lt;ul>
&lt;li>&lt;strong>multistage:&lt;/strong> All&amp;rsquo;accensione del calcolatore viene eseguito un bootloader situato nel firmware non volatile (&lt;strong>BIOS&lt;/strong>). Solitamente questo programma carica un secondo booloader, il &lt;strong>blocco di avvio (boot block)&lt;/strong>, contenuto spesso in un unico blocco che carica a sua volta il resto del programma di bootstrap.&lt;/li>
&lt;li>&lt;strong>(Unified Extensible Firmware Interface) UEFI:&lt;/strong> è un gestore d&amp;rsquo;avvio unificato rispetto a BIOS, ed è quindi più veloce. Offre un supporto migliore per sistemi a 64 bit e dischi grandi.&lt;/li>
&lt;li>&lt;strong>GRUB:&lt;/strong> è un bootloader open-source per sistem Linux/UNIX, flessibile. Può caricare diversi kernel in base ai parametri specificati. All&amp;rsquo;avvio, GRUB crea un file system temporaneao in RAM, &lt;code>initramfs&lt;/code>, che contiene driver e moduli da installare per l&amp;rsquo;inizializzazione. A seguire, Linux crea il processo iniziale di sistema, &lt;code>systemd&lt;/code>, e avvia altri servizi, presentando poi il prompt di login.&lt;/li>
&lt;/ul>
&lt;p>Il processo di avvio per sistemi mobili è leggermente diverso e si basa spesso sulle scelte dei distributori.&lt;/p>
&lt;p>Infine, il boot loader permette anche di avviare versioni più contenute del s.o., chiamate modalità di ripristino, o modalità a singolo utente, per la diagnostica.&lt;/p>
&lt;div id="references-1784797996832241460" class="references" style="margin-top: 5rem;">
&lt;p>&lt;strong>References&lt;/strong>&lt;/p>
A. Silberschatz, P. B. Galvin, G. Gagne, &lt;em>Sistemi Operativi. Concetti ed Esempi&lt;/em>, Decima edizione. Ch. 2.
&lt;/div>
&lt;script>
document.addEventListener('DOMContentLoaded', function () {
var ref = document.getElementById('references-1784797996832241460');
var fn = document.querySelector('.footnotes');
if (ref &amp;&amp; fn &amp;&amp; fn.parentNode) {
fn.parentNode.insertBefore(ref, fn.nextSibling);
}
});
&lt;/script></description></item><item><title>Strutture dei sistemi operativi</title><link>https://aretor.it/courses/os2324/02_strutture/</link><pubDate>Mon, 11 Mar 2024 00:00:00 +0000</pubDate><guid>https://aretor.it/courses/os2324/02_strutture/</guid><description>&lt;p>In questa lezione andremo a vedere le caratteristiche di design da tenere in considerazione quando si sviluppa un sistema operativo. Parleremo dei principali servizi che un sistema operativo deve fornire, delle chiamate a sistema con le quali è chiamato a operare e delle varie tipologie di architetture di un s.o.&lt;/p>
&lt;h2 id="servizi-di-un-sistema-operativo">Servizi di un sistema operativo&lt;/h2>
&lt;p>Nel capitolo precedente abbiamo fatto una panoramica generale sui calcolatori e i sistemi operativi. Ricapitoliamo i servizi principali che un s.o. deve fornire:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Interfaccia con l&amp;rsquo;utente (user interface, UI):&lt;/strong> è il metodo di interazione con il calcolatore. Tipicamente si utilizza un&amp;rsquo;&lt;strong>interfaccia utente grafica (Graphical User Interface, GUI)&lt;/strong> con primitive grafice denominate &amp;ldquo;finestre&amp;rdquo;. Smartphone e tablet utilizzano &lt;strong>interfacce touch&lt;/strong> con le quali lo schermo funge da dispositivo di input e output. Infine, è possibile anche utilizzare un&amp;rsquo;&lt;strong>interfaccia a riga di comando (Command Line Interface, CLI)&lt;/strong> dalla grafica stilizzata, la cui interazione avviene con una tastiera.&lt;/li>
&lt;li>&lt;strong>Esecuzione di un programma&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Operazioni di I/O&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Gestione del file system&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Comunicazioni tra processi&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Rilevamento di errori&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Allocazione delle risorse&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Logging&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Protezione e sicurezza&lt;/strong>&lt;/li>
&lt;/ul>
&lt;h2 id="interfaccia-utente">Interfaccia utente&lt;/h2>
&lt;p>L&amp;rsquo;interfaccia è l&amp;rsquo;architettura logica per comunicare con il s.o.&lt;/p>
&lt;h3 id="interfaccia-a-riga-di-comando-cli">Interfaccia a riga di comando (CLI)&lt;/h3>
&lt;p>Una &lt;strong>command line interface (CLI)&lt;/strong> ruota attorno al concetto di &lt;strong>interprete dei comandi&lt;/strong>, un programma speciale che si avvia all&amp;rsquo;avvio di un job o al login dell&amp;rsquo;utente. Un s.o. può proporre diverse scelte chiamate &lt;strong>shell&lt;/strong> (p.e. la Bourne-Again Shell, BASH, nei sistemi UNIX/Linux).&lt;/p>
&lt;p>Il compito principale dell&amp;rsquo;interprete è di eseguire i comandi che vengono digitati dall&amp;rsquo;utente al suo interno. Esempi di comandi sono la creazione oppure la ricerca di un file. Il comando può fare riferimento a un programma nativo dell&amp;rsquo;interprete o a uno di sistema, esterno ad esso.&lt;/p>
&lt;p>Esempio: &lt;code>rm file.txt&lt;/code> rimuove il file chiamato &lt;code>file.txt&lt;/code>. L&amp;rsquo;interprete cerca un file chiamato &lt;code>rm&lt;/code>, lo carica in memoria e lo esegue con il parametro &lt;code>file.txt&lt;/code>.&lt;/p>
&lt;p>La CLI garantisce un&amp;rsquo;interazione a basso livello con il calcolatore.&lt;/p>
&lt;h3 id="interfaccia-grafica-con-lutente">Interfaccia grafica con l&amp;rsquo;utente&lt;/h3>
&lt;p>Una &lt;strong>Graphical User Interface (GUI)&lt;/strong> fornisce una grafica più elaborata e intuitiva. Invece di digitare i comandi, gli utenti possono selezionare elementi visuali presenti sulla schermata, come ad esempio le &lt;strong>icone&lt;/strong> di file. Un programma o una cartella verrà quindi rappresentato dalla propria icona con un nome sottostante.&lt;/p>
&lt;p>La GUI garantisce più facilità d&amp;rsquo;uso ma meno flessibilità rispetto a una CLI.&lt;/p>
&lt;h4 id="interfaccia-touch-screen">Interfaccia touch screen&lt;/h4>
&lt;p>È una GUI che permette la trasmissione di input tattili. Molti dispositivi portatili odierni ne fanno uso.&lt;/p>
&lt;h2 id="chiamate-di-sistema">Chiamate di sistema&lt;/h2>
&lt;p>Le &lt;strong>chiamate di sistema (system call)&lt;/strong> sono routine invocate da un programma per chiedere al s.o. di fare qualcosa. Sono generalmente scitte in C/C++. L&amp;rsquo;esecuzione di un comando richiede solitamente più chiamate a sistema. Il comando &lt;code>cp in.txt out.txt&lt;/code>, che copia il contenuto del file &lt;code>in.txt&lt;/code> sul file &lt;code>out.txt&lt;/code>. richiede varie chiamate a sistema tra cui una per aprire il file di input e una per creare il file di output.&lt;/p>
&lt;h3 id="interfaccia-per-la-programmazione-di-applicazioni-api">Interfaccia per la programmazione di applicazioni (API)&lt;/h3>
&lt;p>Il comando &lt;code>cp&lt;/code> precedentemente illustrato è composto da una serie di comandi che vengono raggruppati per evitare all&amp;rsquo;utente di dover specificare passo per passo tutte le chiamate a sistema. L&amp;rsquo;astrazione e il raggruppamento dei comandi è uno dei concetti base dello sviluppo di codice.&lt;/p>
&lt;p>Per alleviare il carico cognitivo, dunque, gli sviluppatori predispongono una &lt;strong>application programming interface API&lt;/strong>, contenente una serie di funzioni documentate a disposizione del programmatore, in maniera da schermarlo dai dettagli a basso livello del codice. Il termine interfaccia è qui utilizzato con un significato diverso da quello visto precedentemente. Un&amp;rsquo;API garantisce anche la portabilità verso altri sistemi che garantiscono il supporto all&amp;rsquo;API. Esempi di API sono l&amp;rsquo;API Windows, l&amp;rsquo;API POSIX (utilizzata nei sistemi UNIX, Linux e macOS).&lt;/p>
&lt;p>Collegato all&amp;rsquo;API c&amp;rsquo;è l&amp;rsquo;&lt;strong>ambiente di esecuzione al run-time (RTE, run-time environment)&lt;/strong>. La suite completa di programmi necessaria per eseguire applicazioni scritte in un determinato linguaggio di programmazione, che include i compilatori o gli interpreti e altri software, come librerie e loader. L&amp;rsquo;RTE fornisce un&amp;rsquo;&lt;strong>interfaccia alle chiamate di sistema (system call interface)&lt;/strong> che collega il linguaggio alle system call del s.o.&lt;/p>
&lt;p>Le chiamate a funzione dell&amp;rsquo;API vengono intercettate dal RTE che invoca le relative chiamate a sistema e passa poi il risultato nuovamente all&amp;rsquo;API.&lt;/p>
&lt;p>Talvolta, le chiamate a sistema richiedono dei parametri. Questi possono essere passati direttamente nei registri della CPU. Se però ce ne sono troppi è necessario memorizzarli in un &lt;strong>blocco&lt;/strong> o &lt;strong>tabella di memoria&lt;/strong> passando poi il suo indirizzo. Alternativamente, è possibile utilizzare uno &lt;strong>stack&lt;/strong> in cui i parametri vengono inseriti (push) e poi successivamente prelevati (pop) dal s.o.&lt;/p>
&lt;h3 id="categorie-di-chiamate-a-sistema">Categorie di chiamate a sistema&lt;/h3>
&lt;p>Le classifichiamo in sei categorie:&lt;/p>
&lt;ol>
&lt;li>controllo dei processi&lt;/li>
&lt;li>gestione dei file&lt;/li>
&lt;li>gestione dei dispositivi/risorse&lt;/li>
&lt;li>gestione delle informazioni&lt;/li>
&lt;li>comunicazioni&lt;/li>
&lt;li>protezione&lt;/li>
&lt;/ol>
&lt;p>Utilizziamo denominazioni generiche, poiché le chiamate hanno nomi specifici in base al s.o. utilizzato.&lt;/p>
&lt;h4 id="controllo-dei-processi">Controllo dei processi&lt;/h4>
&lt;p>Chiamate:&lt;/p>
&lt;ul>
&lt;li>Notificare la normale terminazione di un processo (&lt;code>end()&lt;/code>)&lt;/li>
&lt;li>Notificare la terminazione anomala di un processo (&lt;code>abort()&lt;/code>)&lt;/li>
&lt;li>Salvare copia dello stato (memory dumpy, &lt;code>dump()&lt;/code>) per debug&lt;/li>
&lt;li>Generare messaggi d&amp;rsquo;errore su schermo&lt;/li>
&lt;/ul>
&lt;p>In caso di esecuzione anomala, il s.o. passa il controllo all&amp;rsquo;interprete dei comandi
che continua a eseguire i comandi successivi. È compito dell&amp;rsquo;utente rispondere all&amp;rsquo;errore. È possibile stabilire un livello di errore ($0$ indica la terminazione normale).&lt;/p>
&lt;p>&lt;strong>Problema:&lt;/strong> a chi ritornare il controllo in caso p.e., di terminazione anomala o esecuzione parallela?&lt;/p>
&lt;ul>
&lt;li>Caricamento (&lt;code>load()&lt;/code>) ed esecuzione (&lt;code>execute()&lt;/code>) di un altro processo.&lt;/li>
&lt;li>Ispezione (&lt;code>get_process_attributes()&lt;/code>) e impostazione (&lt;code>set_process_attributes()&lt;/code>) degli attributi del processo (p.e. priorità, tempo max di esecuzione, etc.)&lt;/li>
&lt;li>Impostazione di criteri di terminazione, p.e. timer (&lt;code>wait_time()&lt;/code>) o attesa di un evento (&lt;code>wait_event()&lt;/code>) che dev&amp;rsquo;essere segnalato (&lt;code>signal_event()&lt;/code>).&lt;/li>
&lt;/ul>
&lt;p>Se due processi tentano di scrivere su una zona di memoria condivisa, c&amp;rsquo;è il rischio che venga rovinata l&amp;rsquo;integrità dei dati. Per ovviare al problema, un processo può bloccare il file prevenendo l&amp;rsquo;intervento di altri processi su di esso, acquisendo un &lt;strong>lock&lt;/strong>. Il lock sarà poi rilasciato a operazione conclusa. Le chiamate a sistema relative sono&lt;/p>
&lt;ul>
&lt;li>Acquisizione (&lt;code>acquire_lock()&lt;/code>) e rilascio (&lt;code>release_lock()&lt;/code>) del lock.&lt;/li>
&lt;/ul>
&lt;!-- Un processo deve potersi fermare sia normalmente (`end()`) sia in maniera anomala (`abort()`). Talvolta è necessario salvare una copia dello stato (memory dump) per investigare (debug) successivamente il problema, e un messaggio d'errore viene generato sullo schermo. In ogni caso, il s.o. trasferirà il controllo all'interprete dei commandi, che continuerà eseguendo il comando successivo. Sarà compito dell'utente eventualmente rispondere all'errore. È possibile anche stabilire un livello di errore: con $0$ viene indicata la terminazione normale, in maniera da standardizzare la procedura di terminazione. -->
&lt;!-- A volte un processo può richiedere il caricamento (`load()`) e l'esecuzione (`execute()`) di un altro processo. In questo caso, decidere a chi ritornare il controllo non è immediato. Può accadere ad esempio che il processo originale venga terminato o sospeso, o che il nuovo processo venga eseguito in maniera parallela. -->
&lt;!-- Un processo viene inoltre associato ad alcuni attributi quali la priorità o il tempo massimo di esecuzione. È necessario garantire l'ispezione (`get_process_attributes()`) e l'impostazione (`set_process_attributes()`) di questi attributi. -->
&lt;!-- Per garantire la terminazione si può impostare un timer (`wait_time()`) oppure attendere un evento (`wait_event()`) che dev'essere segnalato da un altro processo (`signal_event()`). -->
&lt;!-- In caso di condivisione dati, i processi possono organizzarsi per non intervenire contemporaneamente su essi, magari scrivendoci sopra nello stesso istante e rovinando la loro integrità. A tal proposito un processo può bloccare la scrittura su un file condiviso (`acquire_lock()`) e rilasciare il blocco una volta conclusa l'operazione con esso (`release_lock()`). -->
&lt;!-- ##### FreeBSD
FreeBSD (derivato da UNIX Berkeley) è un s.o. multitasking. All'avvio di una sessione, il sistema esegue l'interprete, ma essendo multitasking può continuare a eseguire altri processi di sistema. `fork()` crea un processo e `exec()` lo esegue. Una volta creato il processo, la shell attende il termine della sua esecuzione prima di concedere di nuovo il controllo all'utente. È possibile eseguire un processo in background: la shell avvia il processo e ritorna immediatamente il controllo all'utente. Non è possibile inviare altri parametri al processo in background poiché la shell riprende il controllo della tastiera. Completato il proprio compito, il processo esegue la chiamata di sistema `exit()`. -->
&lt;h4 id="gestione-dei-file">Gestione dei file&lt;/h4>
&lt;p>Chiamate:&lt;/p>
&lt;ul>
&lt;li>Creare (&lt;code>create()&lt;/code>) e cancellare (&lt;code>delete()&lt;/code>) file&lt;/li>
&lt;li>Aprire (&lt;code>open()&lt;/code>), leggere (&lt;code>read()&lt;/code>), scrivere (&lt;code>write()&lt;/code>) e chiudere (&lt;code>close()&lt;/code>) file&lt;/li>
&lt;li>Spostare (&lt;code>move()&lt;/code>), copiare (&lt;code>copy()&lt;/code>) e incollare (&lt;code>paste()&lt;/code>) file&lt;/li>
&lt;/ul>
&lt;h4 id="gestione-dei-dispositivirisorse">Gestione dei dispositivi/risorse&lt;/h4>
&lt;p>Essenziali per l&amp;rsquo;interazione con l&amp;rsquo;utente e per la gestione dei processi. Parliamo genericamente di risorse fisiche (p.e. memoria centrale e secondaria) virtuali (p.e. i file)
Chiamate:&lt;/p>
&lt;ul>
&lt;li>Richiedere (&lt;code>request()&lt;/code>) e rilasciare (&lt;code>release()&lt;/code>) una risorsa&lt;/li>
&lt;li>Leggere (&lt;code>read()&lt;/code>) e scrivere (&lt;code>write()&lt;/code>) una risorsa&lt;/li>
&lt;/ul>
&lt;!-- I dispositivi sono essenziali per l'interazione con l'utente, ma anche per la gestione dei processi (e.g. memoria centrale e secondaria). Parliamo genericamente di risorse utilizzate da un utente o un processo, che possono essere fisiche ma anche virtuali (p.e. i file). Similmente ai dati condivisi, il s.o. deve permettere la richiesta di risorse (`request()`) e il loro rilascio (`release()`). Come con i file le risorse possono essere lette e scritte. -->
&lt;h4 id="gestione-delle-informazioni">Gestione delle informazioni&lt;/h4>
&lt;p>Chiamate:&lt;/p>
&lt;ul>
&lt;li>Ottenere l&amp;rsquo;ora (&lt;code>time()&lt;/code>) e la data (&lt;code>date()&lt;/code>)&lt;/li>
&lt;li>Ottenere il numero di utenti collegati&lt;/li>
&lt;li>Ottenere la versione del sistema operativo&lt;/li>
&lt;li>Ottenere informazioni per il debug, come il dump (&lt;code>dump()&lt;/code>).&lt;/li>
&lt;/ul>
&lt;h4 id="comunicazione-tra-processi">Comunicazione tra processi&lt;/h4>
&lt;p>Esistono due modelli diffusi di comunicazione tra processi&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Modello a scambio di messaggi:&lt;/strong> i processi utilizzano una casella di posta (mailbox) condivisa in cui si scambiano i messaggi. È necessaria l&amp;rsquo;identificazione dei processi attraverso il &lt;strong>nome della macchina (host name)&lt;/strong> e l&amp;rsquo;&lt;strong>identificatore del processo (Process ID, PID)&lt;/strong>. Questo modello è utile per lo scambio di piccoli messaggi.&lt;/li>
&lt;/ol>
&lt;p>Le chiamate sono:&lt;/p>
&lt;ul>
&lt;li>Accesso all&amp;rsquo;id dell&amp;rsquo;host (&lt;code>get_hostid()&lt;/code>) del processo (&lt;code>get_processid()&lt;/code>)&lt;/li>
&lt;li>Apertura (&lt;code>open_connection()&lt;/code>) e chiusura della connessione (&lt;code>close_connection()&lt;/code>). Vengono chiamate &lt;code>open()&lt;/code>e &lt;code>close()&lt;/code> conseguentemente.&lt;/li>
&lt;li>Attesa (listening, &lt;code>wait_for_connection()&lt;/code>) e accettazione della connessione (&lt;code>accept_connection()&lt;/code>).&lt;/li>
&lt;li>Lettura (&lt;code>read_message()&lt;/code>) e scrittura (&lt;code>write_message()&lt;/code>) dei messaggi&lt;/li>
&lt;/ul>
&lt;ol start="2">
&lt;li>&lt;strong>Modello a memoria condivisa:&lt;/strong> i processi usano chiamate di sistema per creare e accedere alle aree di memoria possedute da altri processi. Occorre coordinamento con gli altri processi e con il s.o., che di solito impedisce questa pratica. Questo modello è utile per ottenere maggiore efficienza nello scambio di messaggi.&lt;/li>
&lt;/ol>
&lt;p>Le chiamate sono:&lt;/p>
&lt;ul>
&lt;li>Creare (&lt;code>shared_memory_create()&lt;/code>) e accedere (&lt;code>shared_memory_attach()&lt;/code>) ad aree di memoria possedute da altri processi&lt;/li>
&lt;/ul>
&lt;h4 id="protezione">Protezione&lt;/h4>
&lt;p>Chiamate:&lt;/p>
&lt;ul>
&lt;li>Impostare (&lt;code>set_permission()&lt;/code>) e ottenere (&lt;code>get_permission()&lt;/code>) i permessi di accesso alle risorse (p.e. file e dischi)&lt;/li>
&lt;li>Permettere (&lt;code>allow_user()&lt;/code>) o negare (&lt;code>deny_user()&lt;/code>) l&amp;rsquo;accesso a una risorsa a un particolare utente.&lt;/li>
&lt;/ul>
&lt;h2 id="servizi-di-sistema">Servizi di sistema&lt;/h2>
&lt;p>I servizi di sistema sono a un livello di astrazione superiore rispetto alle chiamate e offrono un ambiente più conveniente per lo sviluppo e l&amp;rsquo;esecuzione dei programmi. Possono essere semplici interfacce per le chiamate di sistema o architetture logiche più complesse. Li classifichiamo in:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Gestione dei file:&lt;/strong> per operazioni su file e directory&lt;/li>
&lt;li>&lt;strong>Informazioni di stato:&lt;/strong> data, monitoraggio memoria o prestazioni, etc.&lt;/li>
&lt;li>&lt;strong>Modifica dei file:&lt;/strong> per modificare il contenuto dei file&lt;/li>
&lt;li>&lt;strong>Ambienti di supporto alla programmazione:&lt;/strong> compilatori, assemblatori, debugger, interpreti, etc.&lt;/li>
&lt;li>&lt;strong>Caricamento ed esecuzione di programmi:&lt;/strong> per il caricamento in memoria dei programmi&lt;/li>
&lt;li>&lt;strong>Comunicazioni:&lt;/strong> per la comunicazione tra processi in locale e remoto&lt;/li>
&lt;li>&lt;strong>Servizi in background:&lt;/strong> anche noti come &lt;strong>servizi, sottosistemi o demoni (daemon)&lt;/strong>. Sono sempre attivi e attendono eventi per iniziare le loro routine (p.e. rilevare connessioni di rete, scheduling dei processi, etc.).&lt;/li>
&lt;/ul>
&lt;h2 id="programmi-applicativi">Programmi applicativi&lt;/h2>
&lt;p>A un livello maggiore troviamo i programmi applicativi che risolvono problemi comuni. Sono inclusi, ad esempio, i browser web, i processori di testo, fogli di calcolo, sistemi di basi di dati, videogiochi 🎮, etc.&lt;/p>
&lt;h2 id="le-differenze-tra-i-sistemi-operativi">Le differenze tra i sistemi operativi&lt;/h2>
&lt;p>In generale, un&amp;rsquo;applicazione può essere utilizzata solo per il sistema operativo per cui è stata progettata. Le cause principali sono:&lt;/p>
&lt;ul>
&lt;li>Il s.o. ha un formato binario per le applicazioni con un header che dipende dal s.o. Certi metadati devono trovarsi esattamente nella posizione richiesta altrimenti il programma non può essere eseguito.&lt;/li>
&lt;li>Le CPU hanno set di istruzioni differenti.&lt;/li>
&lt;li>I diversi s.o. hanno chiamate di sistema differenti.
Una tecnica per mitigare questi problemi è accordarsi su uno standard da seguire. Similmente alle API è possibile definire un &lt;strong>Application Binary Interface (ABI)&lt;/strong> per stabilire il modo in cui diversi componenti di un codice binario possano interfacciarsi su undeterminato s.o. e architettura. Un&amp;rsquo;ABI specifica dettagli come la dimensione degli indirizzi, i metodi di passaggio dei parametri, l&amp;rsquo;organizzazione dello stack runtime, etc.&lt;/li>
&lt;/ul>
&lt;p>È comunque possibile creare delle applicazioni per più s.o. (cross-platform) in tre modi:&lt;/p>
&lt;ol>
&lt;li>L&amp;rsquo;applicazione è scritta in un linguaggio interpretato (p.e. Python o Ruby). Le istruzioni vengono interpretate e poi tradotte nel set di istruzioni nativo. Questo comporta un overhead nelle performance.&lt;/li>
&lt;li>L&amp;rsquo;applicazione è scritta in un linguaggio che supporta una macchina virtuale (p.e. Java). La macchina virtuale fa parte del RTE del linguaggio. Gli svantaggi sono simili a quelle dei linguaggi interpretati&lt;/li>
&lt;li>Lo sviluppatore di un&amp;rsquo;applicazione può utilizzare un linguaggio specifico in cui il compilatore genera binari nel linguaggio del s.o. e della macchina (p.e. API Posix per il mantenimento della compatibilità del source code tra le varianti di s.o. UNIX-like). Questo richiede di portare l&amp;rsquo;applicazione su ciascun sistema in cui verrà eseguito con un conseguente overhead nella parte di sviluppo e debug.&lt;/li>
&lt;/ol>
&lt;h2 id="progettazione-di-un-so">Progettazione di un s.o.&lt;/h2>
&lt;p>Nello sviluppo di un s.o. bisogna avere in mente gli &lt;strong>obiettivi degli utenti&lt;/strong> (utilità, facilità d&amp;rsquo;uso, velocità, etc.) e gli &lt;strong>obiettivi del sistema&lt;/strong> (facilità di progettazione, facilità di manutenzione, flessibilità, affidabilità, etc.). La disciplina del &lt;strong>software engineering&lt;/strong> si occupa anche di questi aspetti di progettazione.&lt;/p>
&lt;h3 id="meccanismi-e-politiche">Meccanismi e politiche&lt;/h3>
&lt;ul>
&lt;li>I meccanismi determinano come eseguire qualcosa. Un esempio è il timer di sistema che assicura la protezaione della CPU. Un meccanismo mira a essere il più generico possibile per poterlo utilizzare con diverse politiche.&lt;/li>
&lt;li>Le politiche stabiliscono cosa si debba fare. La decisione del tempo da impostare nel timer di sistema è una politica.&lt;/li>
&lt;/ul>
&lt;p>I sistemi operativi basati su microkernel separano politche e meccanismi permettendo solamente una serie di funzioni fondamentali da utilizzare come elementi di base per la progettazione.&lt;/p>
&lt;h2 id="realizzazione-di-un-so">Realizzazione di un s.o.&lt;/h2>
&lt;p>La realizzazione di un s.o. richiede la scelta di un linguaggio adatto. Originariamente il linguaggio utilizzato era l&amp;rsquo;assembly. Adesso si tende a utilizzare linguaggi ad alto livello come C/C++. I vantaggi sono portabilità e facilità di lettura del codice, al costo di una minore velocità d&amp;rsquo;esecuzione e una maggiore occupazione di spazio di memoria. Il compilatore si occuperà di ottimizzare l&amp;rsquo;assembly generato per garantire maggiore efficienza.&lt;/p>
&lt;h2 id="struttura-di-un-sistema-operativo">Struttura di un sistema operativo&lt;/h2>
&lt;p>Nel corso degli anni, sono state proposte diverse architetture per un s.o. Vediamone alcune:&lt;/p>
&lt;h3 id="struttura-monolitica">Struttura monolitica&lt;/h3>
&lt;p>In questo caso, tutte le funzionalità del kernel vengono inserite in un singolo file binario statico che viene eseguito in un unico spazio d&amp;rsquo;indirizzamento. Esempi di kernel monolitico sono UNIX, diviso in kernel e programmi di sistema, e Linux che ha una struttura più modulare. Un s.o. monolitico ha il vantaggio della velocità di esecuzione ma è difficile da estendere. Per questo motivo viene chiamato anche &lt;strong>sistema strettamente accoppiato (tightly coupled)&lt;/strong>&lt;/p>
&lt;h3 id="struttura-stratificata">Struttura stratificata&lt;/h3>
&lt;p>In alternativa a un s.o. monolitico, possiamo progettare un &lt;strong>sistema debolmente accoppiato (loosely coupled)&lt;/strong>, suddiviso in componenti separate i cui cambiamenti agiscono solo su di esse. Questo permette uno sviluppo del s.o. più semplice. È possibile ad esempio stratificare i moduli: l&amp;rsquo;hardware corrisponderà allo strato più basso, mentre quello più alto sarà legato all&amp;rsquo;interfaccia con l&amp;rsquo;utente. Lo strato del s.o. è composto da strutture dati e routine utilizzabili da livelli più alti. L&amp;rsquo;architettura stratificata è stata utilizzata per le reti di computer (p.e. TCP/IP), ma molto poco per i s.o. per via della difficoltà di progettazione e degli overhead nella comunicazione tra strati.&lt;/p>
&lt;h3 id="struttura-a-microkernel">Struttura a microkernel&lt;/h3>
&lt;p>Con la crescita in dimensioni dei sistemi monolitici, venne proposto una nuova architettura per il s.o. che si propose di eliminare tutte le componenti non essenziali dal kernel e di trasformarle in programmi utente o di sistema. Questo approccio, chiamato microkernel, garantisce facile estendibilità dei servizi, ma incorre in overhead dovuti alla comunicazione tra i servzi del s.o. Il microkernel è stato adottato per il s.o. Mach. Darwin, il kernel di macOS, ha anch&amp;rsquo;esso una struttura a microkernel ed è utilizzato assieme a Mach (parliamo quindi più propriamente di un&amp;rsquo;architettura a due microkernel).&lt;/p>
&lt;h3 id="struttura-a-moduli">Struttura a moduli&lt;/h3>
&lt;p>L&amp;rsquo;utilizzo di &lt;strong>moduli del kernel caricabili dinamicamente&lt;/strong> è forse il miglior approccio disponibile per progettare un s.o. In questo caso, il kernel è costituito da componenti di base che vengono integrati dinamicamente con nuove funzionalità. Linux utilizza questo approccio con alcuni moduli del kernel caricabili.&lt;/p>
&lt;h3 id="struttura-ibrida">Struttura ibrida&lt;/h3>
&lt;p>I s.o. odierni utilizzano solitamente più strutture tra quelle descritte prima. Linux e Solaris sono monolitici, ma anche modulari. Windows è in gran parte monolitico ma con alcuni aspetti progettati secondo l&amp;rsquo;architettura a microkernel e quella modulare.&lt;/p>
&lt;h2 id="avvio-di-un-sistema-operativo">Avvio di un sistema operativo&lt;/h2>
&lt;p>Il processo di avvio di un calcolatore è noto come &lt;strong>boot&lt;/strong> e nella maggior parte dei casi funziona nel seguente modo:&lt;/p>
&lt;ol>
&lt;li>Il &lt;strong>programma di bootstrap&lt;/strong> o &lt;strong>bootloader&lt;/strong>, si occupa di caricare il kernel del s.o.&lt;/li>
&lt;li>Il kernel viene caricato in memoria ed eseguito&lt;/li>
&lt;li>Il kernel inizializza l&amp;rsquo;hardware&lt;/li>
&lt;li>Il file system principale (root file system) viene montato&lt;/li>
&lt;/ol>
&lt;p>Esistono vari programmi di avvio, di sotto ne riportiamo alcuni tra i più noti&lt;/p>
&lt;ul>
&lt;li>&lt;strong>multistage:&lt;/strong> All&amp;rsquo;accensione del calcolatore viene eseguito un bootloader situato nel firmware non volatile (&lt;strong>BIOS&lt;/strong>). Solitamente questo programma carica un secondo booloader, il &lt;strong>blocco di avvio (boot block)&lt;/strong>, contenuto spesso in un unico blocco che carica a sua volta il resto del programma di bootstrap.&lt;/li>
&lt;li>&lt;strong>(Unified Extensible Firmware Interface) UEFI:&lt;/strong> è un gestore d&amp;rsquo;avvio unificato rispetto a BIOS, ed è quindi più veloce. Offre un supporto migliore per sistemi a 64 bit e dischi grandi.&lt;/li>
&lt;li>&lt;strong>GRUB:&lt;/strong> è un bootloader open-source per sistem Linux/UNIX, flessibile. Può caricare diversi kernel in base ai parametri specificati. All&amp;rsquo;avvio, GRUB crea un file system temporaneao in RAM, &lt;code>initramfs&lt;/code>, che contiene driver e moduli da installare per l&amp;rsquo;inizializzazione. A seguire, Linux crea il processo iniziale di sistema, &lt;code>systemd&lt;/code>, e avvia altri servizi, presentando poi il prompt di login.&lt;/li>
&lt;/ul>
&lt;p>Il processo di avvio per sistemi mobili è leggermente diverso e si basa spesso sulle scelte dei distributori.&lt;/p>
&lt;p>Infine, il boot loader permette anche di avviare versioni più contenute del s.o., chiamate modalità di ripristino, o modalità a singolo utente, per la diagnostica.&lt;/p>
&lt;div id="references-1784797996204269484" class="references" style="margin-top: 5rem;">
&lt;p>&lt;strong>References&lt;/strong>&lt;/p>
A. Silberschatz, P. B. Galvin, G. Gagne, &lt;em>Sistemi Operativi. Concetti ed Esempi&lt;/em>, Decima edizione. Ch. 2.
&lt;/div>
&lt;script>
document.addEventListener('DOMContentLoaded', function () {
var ref = document.getElementById('references-1784797996204269484');
var fn = document.querySelector('.footnotes');
if (ref &amp;&amp; fn &amp;&amp; fn.parentNode) {
fn.parentNode.insertBefore(ref, fn.nextSibling);
}
});
&lt;/script></description></item><item><title>3 Gestione dei processi</title><link>https://aretor.it/courses/os2425/lessons/03_processi/</link><pubDate>Tue, 11 Mar 2025 00:00:00 +0000</pubDate><guid>https://aretor.it/courses/os2425/lessons/03_processi/</guid><description>&lt;h2 id="31-concetto-di-processo">3.1 Concetto di processo&lt;/h2>
&lt;p>Un processo è un programma in esecuzione. Un nome alternativo è &lt;strong>job&lt;/strong>, storicamente utilizzato quando i calcolatori elaboravano code di processi (job queues) in sequenza. In seguito, si affermarono sistemi time-shared che potevano eseguire più programmi utente (task) in contemporanea.&lt;/p>
&lt;p>Lo stato di un processo è rappresentato dal suo contatore di programma (&lt;em>program counter&lt;/em>) e i valori presenti nei registri del processore. La struttura di un processo è composta di:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Sezione di testo:&lt;/strong> il codice eseguibile&lt;/li>
&lt;li>&lt;strong>Sezione dati:&lt;/strong> le variabili globali&lt;/li>
&lt;li>&lt;strong>Heap:&lt;/strong> la memoria allocata dinamicamente in esecuzione&lt;/li>
&lt;li>&lt;strong>Stack:&lt;/strong> una locazione temporanea per i dati usati nell&amp;rsquo;invocazione di funzioni (p.e. parametri, indirizzi di ritorno, variabili locali)&lt;/li>
&lt;/ul>
&lt;p>Solo lo heap e lo stack occupano uno spazio variabile, mentre testo e dati rimangono fissi ai valori di inizializzazione del processo. In particolare, ogni volta che viene chiamata una funzione un &lt;strong>record di attivazione&lt;/strong> contenente i parametri di funzione, le variabili locali e l&amp;rsquo;indirizzo di ritorno viene aggiunto allo stack, e una volta finita l&amp;rsquo;esecuzione della funzione, questo viene tolto. È importante far sì che le memorie di stack e heap non si sovrappongano.&lt;/p>
&lt;p>Ripetiamo che un programma è un&amp;rsquo;entità passiva e un processo è un&amp;rsquo;entità attiva generata al momento del caricamento di un programma in memoria, p.e. facendo doppio-click su un &lt;strong>file eseguibile&lt;/strong> in una GUI. Lo stesso programma può essere caricato più volte, generando più processi distinti. Un processo può anche generare altri processi, o essere un ambiente di esecuzione per altri processi (p.e. la virtual machine di Java).&lt;/p>
&lt;h3 id="311-stati-di-processo">3.1.1 Stati di processo&lt;/h3>
&lt;p>Un processo può trovarsi nei seguenti stati:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Nuovo:&lt;/strong> il processo è stato creato&lt;/li>
&lt;li>&lt;strong>Esecuzione (running):&lt;/strong> le istruzioni stanno venendo eseguite&lt;/li>
&lt;li>&lt;strong>Attesa (waiting):&lt;/strong> il processo sta attendendo un evento (p.e. I/O)&lt;/li>
&lt;li>&lt;strong>Pronto (ready):&lt;/strong> il processo è in attesa di essere assegnato a un processore&lt;/li>
&lt;li>&lt;strong>Terminato:&lt;/strong> il processo ha finito l&amp;rsquo;esecuzione&lt;/li>
&lt;/ul>
&lt;p>Questi nomi sono arbitrari e variano in base al s.o. Solamente un processo per core può essere in stato di esecuzione.&lt;/p>
&lt;h4 id="3111-blocco-di-controllo-del-processo">3.1.1.1 Blocco di controllo del processo&lt;/h4>
&lt;p>Ogni processo è rappresentato nel s.o. da un &lt;strong>blocco di controllo (Process Control Block, PCB, o Task Control Block, TCB)&lt;/strong>, contenente:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Stato del processo:&lt;/strong> uno degli stati sopra descritti&lt;/li>
&lt;li>&lt;strong>Contatore di programma:&lt;/strong> per tenere traccia dell&amp;rsquo;istruzione da eseguire&lt;/li>
&lt;li>&lt;strong>Registri della CPU:&lt;/strong> di molteplice natura (p.e. accumulatori, indici di registro, puntatori a stack, registri general purpose, informazioni sulle condizioni del codice, etc.). È informazione che va salvata in caso di interruzioni.&lt;/li>
&lt;li>&lt;strong>Informazioni sullo scheduling di CPU:&lt;/strong> per determinare quando il processo verrà eseguito (p.e. priorità del processo, puntatori a code di scheduling, etc.)&lt;/li>
&lt;li>&lt;strong>Informazioni sulla gestione della memoria:&lt;/strong> legate alle locazioni della memoria usate dal processo&lt;/li>
&lt;li>&lt;strong>Informazioni di accounting:&lt;/strong> p.e. il numero di CPU, i limiti di tempo, l&amp;rsquo;identificatore di job o processo, etc.&lt;/li>
&lt;li>&lt;strong>Informazioni sullo stato dell&amp;rsquo;I/O:&lt;/strong> p.e. la lista dei dispositivi I/O allocati al processo, la lista dei file in uso, etc.&lt;/li>
&lt;/ul>
&lt;h4 id="3112-thread">3.1.1.2 Thread&lt;/h4>
&lt;p>Un processo può utilizzare più core del processore, separando i task e eseguendoli in &lt;strong>thread&lt;/strong> diversi (p.e. un word processor può prendere in input caratteri e lanciare il controllo ortografico sul testo). In tal caso il processo ha un PCB per ogni thread.&lt;/p>
&lt;h2 id="32-scheduling-dei-processi">3.2 Scheduling dei processi&lt;/h2>
&lt;p>Due concetti fondamentali ruotano attorno alla schedulazione dei processi:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Il multiprogramming&lt;/strong> permette alla CPU di essere sempre occupata con altri programmi, anche durante gli interrupt.&lt;/li>
&lt;li>&lt;strong>Il time sharing&lt;/strong> permette di eseguire tutti i processi per brevi lassi di tempo, alternandoli, in maniera che l&amp;rsquo;utente possa interagirci senza ritardi. Per determinare quale processo eseguire in un determinato istante, è necessario un process scheduler.&lt;/li>
&lt;/ul>
&lt;p>Per accomodare queste due scelte di design, è bene trattare i processi distinguendoli in due categorie:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>I/O-bound&lt;/strong> per processi che fanno molte operazioni di I/O&lt;/li>
&lt;li>&lt;strong>CPU-bound&lt;/strong> per processi che utilizzano molto la CPU&lt;/li>
&lt;/ul>
&lt;p>Infine il numero di processi correntemente in memoria viene chiamato &lt;strong>grado di multiprogramming&lt;/strong>.&lt;/p>
&lt;h3 id="321-code-di-scheduling">3.2.1 Code di scheduling&lt;/h3>
&lt;p>Nel momento in cui un processo è pronto per essere eseguito, viene inserito nella &lt;strong>coda ready&lt;/strong>, generalmente implementata come una linked list. Una volta selezionato per l&amp;rsquo;esecuzione (dispatched), il processo può presentare tre comportamenti:&lt;/p>
&lt;ul>
&lt;li>In attesa di un evento (p.e. I/O). In questo caso viene messo nella &lt;strong>coda wait&lt;/strong> e, quando pronto, di nuovo nella coda ready.&lt;/li>
&lt;li>Creazione di un processo figlio (child process). Anche in questo caso il processo genitore (parent process) viene messo nella coda wait in attesa che il processo figlio finisca l&amp;rsquo;esecuzione, per poi venire rimesso nella coda ready.&lt;/li>
&lt;li>Rimozione dal core di esecuzione, che può avvenire per via di un interrupt o all&amp;rsquo;esaurimento del tempo di esecuzione (time slice) allocato al processo. In questo caso il processo viene spostato nella coda ready.&lt;/li>
&lt;/ul>
&lt;h3 id="322-scheduling-della-cpu">3.2.2 Scheduling della CPU&lt;/h3>
&lt;p>Il ruolo di un &lt;strong>CPU scheduler&lt;/strong> è di decidere quale processo allocare in un core, tra quelli disponibili nella ready queue. In aggiunta, deve sapere quando rimuovere un processo dalla CPU per far spazio agli altri. Nel caso di un processo I/O-bound, il processo può essere rimosso nel momento di un&amp;rsquo;interruzione. Nel caso di un processo CPU-bound, il processo viene tipicamente rimosso all&amp;rsquo;esaurimento di un limite di tempo programmato, per far eseguire anche gli altri processi.&lt;/p>
&lt;p>Alcuni sistemi utilizzano la tecnica dello &lt;strong>swapping&lt;/strong> per cui a volte è vantaggioso spostare un processo dalla memoria centrale a quella secondaria per ridurre il grado di multiprogramming. A seguire verrà re-inserito in memoria ed eseguito.&lt;/p>
&lt;h3 id="323-cambio-di-contesto">3.2.3 Cambio di contesto&lt;/h3>
&lt;p>Quando accade un&amp;rsquo;interruzione, il &lt;strong>contesto&lt;/strong> del processo in esecuzione viene salvato e rimosso dalla CPU. Il contesto è salvato nel PCB e un nuovo processo viene allocato nella CPU. Questo evento viene chiamato &lt;strong>cambio di contesto (context switch)&lt;/strong>. Il context switch, occupa tempo di CPU, generando un overhead. Per tale motivo dev&amp;rsquo;essere gestito in maniera efficiente e dipende dalla velocità di comunicazione con la memoria, dal numero di registri da copiare, etc. In generale, più il s.o. è complesso, più il context switch richiederà tempo.&lt;/p>
&lt;h2 id="33-operazioni-sui-processi">3.3 Operazioni sui processi&lt;/h2>
&lt;p>In questa sezione andremo a spiegare i meccanismi dietro le operazioni principali eseguibili sui processi.&lt;/p>
&lt;h3 id="331-creazione-di-processi">3.3.1 Creazione di processi&lt;/h3>
&lt;p>Come detto, un processo può creare altri processi, che possono a loro volta creare altri processi e così via, andando a formare un &lt;strong>albero di processi&lt;/strong>. Ogni processo è identificabile tramite un &lt;strong>identificatore di processo (Process Identifier, PID)&lt;/strong>, un numero intero. Nei sistemi UNIX solitamente il processo alla radice dell&amp;rsquo;albero è &lt;code>init()&lt;/code> mentre su Linux viene spesso rimpiazzato con &lt;code>systemd()&lt;/code>.&lt;/p>
&lt;p>Quando un processo crea un processo figlio, questo richiederà delle risorse per completare il proprio compito. Il processo genitore potrà quindi decidere di partizionare le risorse con il figlio o di condividerle. Il figlio può anche chiedere risorse direttamente al s.o. ma vincolare le risorse disponibili a quelle permesse al genitore permette di evitare un overload delle risorse in seguito all&amp;rsquo;eventuale creazione di numerosi processi figlio. In aggiunta, il processo genitore potrebbe dover passare dei parametri al processo figlio per eseguire il compito assegnato. Alternativamente, è il s.o. ad occuparsi di passare le risorse.&lt;/p>
&lt;p>Alla creazione del processo, il genitore può attendere la sua esecuzione o eseguire in parallelo col figlio. In aggiunta, il figlio può essere un duplicato del padre (stesso programma e dati), oppure avere un nuovo programma caricato.&lt;/p>
&lt;p>Nei sistemi UNIX un nuovo processo è creato usando &lt;code>fork()&lt;/code>. Il processo figlio è una copia dello spazio degli indirizzi del processo padre per facilitare la loro comunicazione. Il codice di ritorno dell&amp;rsquo;operazione è 0 per il figlio e il PID del figlio per il padre.&lt;/p>
&lt;p>Una volta effettuato il fork, uno dei due processi chiama &lt;code>exec()&lt;/code> per rimpiazzare la memoria del processo con un nuovo programma. Un nuovo file binario viene caricato in memoria e lo spazio di memoria che contiene l&amp;rsquo;exec viene eliminato. Utilizzando questo metodo i processi hanno prima la possibilità di comunicare e poi di essere indipendenti. Ovviamente, &lt;code>exec()&lt;/code> può non essere chiamato: in questo caso entrambi i processi eseguono lo stesso codice con copie dei dati separate.&lt;/p>
&lt;p>Qui sotto riportiamo un esempio di programma che crea un nuovo processo con &lt;code>fork()&lt;/code> (Fig. 3.8 del Silberschatz)&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="cp">#include&lt;/span> &lt;span class="cpf">&amp;lt;sys/types.h&amp;gt;&lt;/span>&lt;span class="cp">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cp">#include&lt;/span> &lt;span class="cpf">&amp;lt;stdio.h&amp;gt;&lt;/span>&lt;span class="cp">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cp">#include&lt;/span> &lt;span class="cpf">&amp;lt;unistd.h&amp;gt;&lt;/span>&lt;span class="cp">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cp">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kt">int&lt;/span> &lt;span class="nf">main&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kt">pid_t&lt;/span> &lt;span class="n">pid&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* fork a child process */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">pid&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nf">fork&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">pid&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="cm">/* error occurred */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">fprintf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">stderr&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;Fork Failed&amp;#34;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">else&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">pid&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="cm">/* child process */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">execlp&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;/bin/ls&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;ls&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">NULL&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">else&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="cm">/* parent process */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* parent will wait for the child to complete */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">wait&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">NULL&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">printf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;Child Complete&amp;#34;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="332-terminazione-dei-processi">3.3.2 Terminazione dei processi&lt;/h3>
&lt;p>Un processo termina quando esegue la chiamata di sistema &lt;code>exit()&lt;/code> e chiede al s.o. di eliminarlo. Solitamente il processo ritorna un valore al processo genitore, tramite la chiamata di sistema &lt;code>wait()&lt;/code>. Tutte le risorse del processo vengono deallocate.&lt;/p>
&lt;p>A volte è possibile che un processo termini un altro processo tramite una chiamata di sistema apposita. Ciò può avvenire per vari motivi:&lt;/p>
&lt;ul>
&lt;li>Il processo figlio ha superato la quota di risorse a esso assegnata&lt;/li>
&lt;li>Il compito del processo figlio non è più richiesto&lt;/li>
&lt;li>Il processo genitore sta terminando e il s.o. è stato istruito a terminare anche i processi figli a esso associati&lt;/li>
&lt;/ul>
&lt;p>La terminazione di processi figli a seguito di quella del genitore si chiama &lt;strong>terminazione a cascata&lt;/strong> ed è una scelta di design in alcuni s.o.&lt;/p>
&lt;p>Nei sistemi UNIX, &lt;code>exit()&lt;/code> viene chiamato di default, anche se non è presente nel programma. Quando un processo viene terminato, la sua entry nella tabella dei processi deve rimanere finché il processo genitore non chiama &lt;code>wait()&lt;/code> e recupera i valori di ritorno.&lt;/p>
&lt;ul>
&lt;li>Parliamo di processo figlio &lt;strong>zombie&lt;/strong> quando esso termina e attende che la &lt;code>wait()&lt;/code> del genitore venga eseguita. Solitamente il periodo di attesa è breve.&lt;/li>
&lt;li>Parliamo di processo figlio &lt;strong>orfano&lt;/strong> quando il genitore termina senza aver eseguito la &lt;code>wait()&lt;/code>. In questo caso i sistemi UNIX assegnano l&amp;rsquo;orfano al processo &lt;code>init&lt;/code> (&lt;code>systemd&lt;/code> nel caso di alcuni sistemi Linux), che si occupa di chiamare periodicamente &lt;code>wait()&lt;/code> per eseguire la terminazione corretta dei processi.&lt;/li>
&lt;/ul>
&lt;h2 id="34-comunicazione-tra-processi">3.4 Comunicazione tra processi&lt;/h2>
&lt;p>Un processo che non condivide dati con altri processi è detto &lt;strong>indipendente&lt;/strong>. Un processo che interagisce con altri processi è detto &lt;strong>cooperativo&lt;/strong>. Ci sono vari motivi per permettere ai processi di cooperare:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Condivisione delle informazioni&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Velocità computazionale:&lt;/strong> ottenuto partizionando un task in molteplici sottotask che possono essere eseguiti in parallelo&lt;/li>
&lt;li>&lt;strong>Modularità:&lt;/strong> che garantisce più controllo e facilità di modifica&lt;/li>
&lt;/ul>
&lt;p>I processi cooperativi richiedono un meccanismo di &lt;strong>comunicazione tra processi (InterProcess communication, IPC)&lt;/strong>. I due modelli fondamentali di comunicazione sono&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Memoria condivisa:&lt;/strong> una parte di memoria viene appunto condivisa tra i processi che sono liberi di scambiarsi le informazioni. È l&amp;rsquo;alternativa più veloce. Le memoria viene settata con una chiamata di sistema e poi tutti gli accessi vengono trattati come accessi alla memoria.&lt;/li>
&lt;li>&lt;strong>Passaggio di messaggi:&lt;/strong> attraverso chiamate di sistema, i processi si inviano messaggi con cui si scambiano i dati. È un metodo efficiente per inviare dati di piccola mole.&lt;/li>
&lt;/ul>
&lt;h3 id="341--ipc-nei-sistemi-a-memoria-condivisa">3.4.1 IPC nei sistemi a memoria condivisa&lt;/h3>
&lt;p>Nel modello di comunicazione a memoria condivisa i processi chiedono al s.o. di condividere parte della memoria. Il s.o. autorizza la richiesta e lascia ai processi la responsabilità di gestione della memoria condivisa. I processi devono occuparsi del tipo e della collocazione dei dati, inoltre sono responsabili di non scrivere nella stessa locazione contemporaneamente.&lt;/p>
&lt;p>Il paradigma comunemente utilizzato per introdurre il concetto è quello di &lt;em>consumatore/produttore&lt;/em>. Un processo produce dati che vengono poi consumati da un altro processo (p.e. un compilatore produce assembly, consumato da un assemblatore). Per il suo funzionamento, è necessario uno spazio di memoria condivisa (buffer) tra due processi e un meccanismo che prevenga il consumatore dal consumare dati non ancora prodotti.&lt;/p>
&lt;p>Si possono utilizzare due tipi di buffer:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Illimitato:&lt;/strong> il consumatore può dover attendere prodotti, ma il produttore può produrne quanti ne vuole&lt;/li>
&lt;li>&lt;strong>Limitato:&lt;/strong> sia il consumatore che il produttore devono attendere rispettivamente in caso di buffer vuoto o pieno&lt;/li>
&lt;/ul>
&lt;p>Può accadere talvolta che il produttore e il consumatore vogliano utilizzare contemporaneamente il buffer. In questo caso è necessario sincronizzare i processi.&lt;/p>
&lt;h3 id="342-ipc-in-sistemi-a-scambio-di-messaggi">3.4.2 IPC in sistemi a scambio di messaggi&lt;/h3>
&lt;p>Nel modello a scambio di messaggi i due processi comunicano senza la necessità di una memoria condivisa. È un meccanismo particolarmente utilizzato in ambienti distribuiti con più calcolatori, ad esempio per implementare una chat.&lt;/p>
&lt;p>Le due operazioni fondamentali sono l&amp;rsquo;invio (&lt;code>send(message)&lt;/code>) e la ricezione (&lt;code>receive(message)&lt;/code>). I messaggi possono avere lunghezza:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Fissa:&lt;/strong> elementare a livello di sistema, ma più complicato da gestire dal punto di vista applicativo&lt;/li>
&lt;li>&lt;strong>Variabile:&lt;/strong> complesso a livello di sistema, ma più semplice dal punto di vista applicativo.&lt;/li>
&lt;/ul>
&lt;p>Il componente logico fondamentale per implementare questo paradigma è il &lt;strong>canale di comunicazione&lt;/strong>. Ci sono almeno tre caratteristiche nello sviluppo del canale di comunicazione, che caratterizzano il canale: naming, sincronizzazione e buffering.&lt;/p>
&lt;h4 id="3421-naming">3.4.2.1 Naming&lt;/h4>
&lt;p>Per la comunicazione è necessario disporre che i processi possano essere nominati da altri processi. La comunicazione in base al naming si divide in:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Diretta:&lt;/strong> i processi comunicano tra di loro direttamente. Questo tipo di comunicazione avviene in due modi:
&lt;ul>
&lt;li>&lt;strong>simmetrica:&lt;/strong> comunicante deve esplicitamente indicare il ricevente o il trasmittente della comunicazione. Dato un processo ricevente P e un mittente Q le primitive sono &lt;code>send(P, msg)&lt;/code> &lt;code>receive(Q, msg)&lt;/code>. Il canale di comunicazione ha le seguenti proprietà:
&lt;ul>
&lt;li>Una connessione viene stabilita automaticamente tra ogni coppia di processi che vuole comunicare. I processi devono conoscere la loro reciproca identità per comunicare&lt;/li>
&lt;li>Un canale è associato esattamente a due processi&lt;/li>
&lt;li>Tra ogni coppia di processi esiste esattamente un canale&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>asimmetrica:&lt;/strong> solamente il mittente indica il ricevente. Le primitive sono &lt;code>send(P, msg)&lt;/code> e &lt;code>receive(id, msg)&lt;/code>, dove &lt;code>id&lt;/code> è l&amp;rsquo;identificatore del processo con cui la comunicazione ha avuto inizio.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>Lo svantaggio della comunicazione diretta è che gli identificatori devono essere esplicitamente definiti e il cambio di nome di un processo richiede di cercare tutte le sue occorrenze e di modificarle di conseguenza.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Indiretta:&lt;/strong> i processi inviano e ricevono messaggi da caselle di posta (mailbox) o porte (port). Una mailbox è essenzialmente un oggetto con un identificatore che riceve messaggi. Un processo può comunicare con un altro processo inviando messaggi nella mailbox apposita. Per una comunicazione reciproca è necessaria una mailbox condivisa. Data una mailbox A, le primitive sono &lt;code>send(A, msg)&lt;/code> e &lt;code>receive(A, msg)&lt;/code>. Il canale di comunicazione ha le seguenti proprietà:
&lt;ul>
&lt;li>Una connessione viene stabilita tra coppie di processi solo se hanno una mailbox condivisa&lt;/li>
&lt;li>Un canale può essere associato a più processi&lt;/li>
&lt;li>Tra ogni coppia di processi possono esistere più canali (uno per mailbox)&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>Supponiamo tre processi P1, P2, P3 che condividono una mailbox. P1 invia un messaggio. P2 e P3 eseguono la ricezione. Chi ottiene il messaggio. Ci sono tre possibilità per risolvere il problema:&lt;/p>
&lt;ul>
&lt;li>Permettere connessioni solo tra due processi al massimo&lt;/li>
&lt;li>Permettere di eseguire una receive solamente a un processo per volta&lt;/li>
&lt;li>Definire una policy per la gestione dei conflitti (p.e. alternare i processi riceventi)&lt;/li>
&lt;/ul>
&lt;p>Una mailbox può essere posseduta da un processo o dal s.o. Nel primo caso la mailbox è nello spazio di memoria del processo e distinguiamo tra proprietario (solo ricevente) e utente (solo mittente), in maniera da evitare confusione. Quando il processo termina, la mailbox sparisce e ogni processo che voglia ancora inviare messaggi dev&amp;rsquo;essere notificato dell&amp;rsquo;eliminazione della mailbox.&lt;/p>
&lt;p>La mailbox a livello di s.o. è indipendente e non associata ad alcun processo. Il s.o. deve essere in grado di creare ed eliminare una mailbox e inviare e ricevere messaggi tramite la mailbox.&lt;/p>
&lt;h4 id="3422-sincronizzazione">3.4.2.2 Sincronizzazione&lt;/h4>
&lt;p>La comunicazione tra processi avviene tramite le primitive &lt;code>send()&lt;/code> e &lt;code>receive()&lt;/code>. Lo scambio di messaggi può avvenire in due maniere:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Sincrono/bloccante:&lt;/strong> nel caso dell&amp;rsquo;invio, il processo mittente si blocca fino alla ricezione del messaggio da parte del ricevente. Nel caso della ricezione il processo ricevente si blocca fino a un nuovo messaggio in arrivo.&lt;/li>
&lt;li>&lt;strong>Asincrono/non-bloccante:&lt;/strong> nel caso dell&amp;rsquo;invio, il processo mittente invia il messaggio e continua l&amp;rsquo;esecuzione. Nel caso della ricezione il processo ricevente recupera il messaggio oppure null se non c&amp;rsquo;è alcun messaggio.&lt;/li>
&lt;/ul>
&lt;p>Se entrambi invio e ricezione sono bloccanti abbiamo un cosiddetto &lt;strong>rendezvous&lt;/strong> tra il mittente e il ricevente, che risolve il problema produttore/consumatore.&lt;/p>
&lt;h4 id="3423-buffering">3.4.2.3 Buffering&lt;/h4>
&lt;p>I messaggi scambiati dai processi risiedono in code temporanee. Tali code possono essere implementate in tre maniere:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Capacità zero:&lt;/strong> non avendo capacità i messaggi non possono essere accodati e il mittente deve bloccarsi finché il messaggio inviato non è stato ricevuto&lt;/li>
&lt;li>&lt;strong>Capacità limitata:&lt;/strong> permette al massimo &lt;em>n&lt;/em> messaggi nella coda, oltre i quali il mittente si blocca&lt;/li>
&lt;li>&lt;strong>Capacità illimitata:&lt;/strong> la coda ha lunghezza potenzialmente infinita e il mittente non si blocca mai&lt;/li>
&lt;/ul>
&lt;p>Il primo caso è a volte chiamato &lt;strong>senza buffering&lt;/strong> mentre gli altri due con &lt;strong>buffering automatico&lt;/strong>.&lt;/p>
&lt;h3 id="343-pipe">3.4.3 Pipe&lt;/h3>
&lt;p>Una &lt;strong>pipe&lt;/strong> è un meccanismo UNIX che agisce come canale di comunicazione tra processi. Le scelte di design per una pipe sono:&lt;/p>
&lt;ul>
&lt;li>La comunicazione è unidirezionale o bidirezionale?&lt;/li>
&lt;li>Se la comunicazione è bidirezionale, è &lt;strong>half duplex&lt;/strong> (i dati viaggiano solo in una direzione alla volta) o &lt;strong>full duplex&lt;/strong> (i dati viaggiano in entrambe le direzioni)?&lt;/li>
&lt;li>Deve esistere una relazione (del tipo genitore-figlio) tra i processi di comunicazione?&lt;/li>
&lt;li>Le pipe possono comunicare in rete o solo all&amp;rsquo;interno della macchina?&lt;/li>
&lt;/ul>
&lt;p>Esistono due tipi di pipe, &lt;strong>convenzionali&lt;/strong> e &lt;strong>named&lt;/strong>.&lt;/p>
&lt;h4 id="3431-pipe-convenzionali">3.4.3.1 Pipe convenzionali&lt;/h4>
&lt;p>Queste pipe unidirezionali utilizzano il paradigma produttore/consumatore e possiedono quindi un&amp;rsquo;&lt;strong>estremità dedicata alla scrittura (write-end)&lt;/strong> e un&amp;rsquo;&lt;strong>estremità dedicata alla lettura (read-end)&lt;/strong>. Per il caso bidirezionale servono due pipe. I sistemi UNIX permettono la creazione di due pipe alla volta (una per scrittura e una per lettura) che vengono gestite come un file (quindi con &lt;code>read&lt;/code> e &lt;code>write&lt;/code>). Queste pipe richiedono la distinzione tra genitore e figlio e quindi possono essere utilizzate solo all&amp;rsquo;interno della stessa macchina. Una volta che i processi finiscono di comunicare la pipe convenzionale viene eliminata.&lt;/p>
&lt;h4 id="3432-named-pipe">3.4.3.2 Named pipe&lt;/h4>
&lt;p>Queste pipe bidirezionali sono più potenti delle convenzionali e non richiedono la distinzione genitore figlio. Una volta creata una named pipe diversi processi possono utilizzarla per comunicare e la pipe non viene eliminata al termine delle comunicazioni. Nei sistemi UNIX
le named pipe sono dette FIFO e vengono trattate come file. Nonostante la bidirezionalità, la comunicazione è half duplex e quindi le FIFO vengono create a coppie come con le pipe convenzionali. I processi devono risiedere nella stessa macchina.&lt;/p>
&lt;p>Nel caso due processi risiedano su due macchine diverse è necessario creare una &lt;strong>socket&lt;/strong>.&lt;/p>
&lt;div id="references-1784797996845624410" class="references" style="margin-top: 5rem;">
&lt;p>&lt;strong>References&lt;/strong>&lt;/p>
A. Silberschatz, P. B. Galvin, G. Gagne, &lt;em>Sistemi Operativi. Concetti ed Esempi&lt;/em>, Decima edizione. Ch. 3.
&lt;/div>
&lt;script>
document.addEventListener('DOMContentLoaded', function () {
var ref = document.getElementById('references-1784797996845624410');
var fn = document.querySelector('.footnotes');
if (ref &amp;&amp; fn &amp;&amp; fn.parentNode) {
fn.parentNode.insertBefore(ref, fn.nextSibling);
}
});
&lt;/script></description></item><item><title>Gestione dei processi</title><link>https://aretor.it/courses/os2324/03_processi/</link><pubDate>Thu, 14 Mar 2024 00:00:00 +0000</pubDate><guid>https://aretor.it/courses/os2324/03_processi/</guid><description>&lt;h2 id="concetto-di-processo">Concetto di processo&lt;/h2>
&lt;p>Un processo è un programma in esecuzione. Un nome alternativo è &lt;strong>job&lt;/strong>, storicamente utilizzato quando i calcolatori elaboravano code di processi (job queues) in sequenza. In seguito, si affermarono sistemi time-shared che potevano eseguire più programmi utente (task) in contemporanea.&lt;/p>
&lt;p>Lo stato di un processo è rappresentato dal suo contatore di programma (&lt;em>program counter&lt;/em>) e i valori presenti nei registri del processore. La struttura di un processo è composta di:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Sezione di testo:&lt;/strong> il codice eseguibile&lt;/li>
&lt;li>&lt;strong>Sezione dati:&lt;/strong> le variabili globali&lt;/li>
&lt;li>&lt;strong>Heap:&lt;/strong> la memoria allocata dinamicamente in esecuzione&lt;/li>
&lt;li>&lt;strong>Stack:&lt;/strong> una locazione temporanea per i dati usati nell&amp;rsquo;invocazione di funzioni (p.e. parametri, indirizzi di ritorno, variabili locali)&lt;/li>
&lt;/ul>
&lt;p>Solo lo heap e lo stack occupano uno spazio variabile, mentre testo e dati rimangono fissi ai valori di inizializzazione del processo. In particolare, ogni volta che viene chiamata una funzione un &lt;strong>record di attivazione&lt;/strong> contenente i parametri di funzione, le variabili locali e l&amp;rsquo;indirizzo di ritorno viene aggiunto allo stack, e una volta finita l&amp;rsquo;esecuzione della funzione, questo viene tolto. È importante far sì che le memorie di stack e heap non si sovrappongano.&lt;/p>
&lt;p>Ripetiamo che un programma è un&amp;rsquo;entità passiva e un processo è un&amp;rsquo;entità attiva generata al momento del caricamento di un programma in memoria, p.e. facendo doppio-click su un &lt;strong>file eseguibile&lt;/strong> in una GUI. Lo stesso programma può essere caricato più volte, generando più processi distinti. Un processo può anche generare altri processi, o essere un ambiente di esecuzione per altri processi (p.e. la virtual machine di Java).&lt;/p>
&lt;h3 id="stati-di-processo">Stati di processo&lt;/h3>
&lt;p>Un processo può trovarsi nei seguenti stati:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Nuovo:&lt;/strong> il processo è stato creato&lt;/li>
&lt;li>&lt;strong>Esecuzione (running):&lt;/strong> le istruzioni stanno venendo eseguite&lt;/li>
&lt;li>&lt;strong>Attesa (waiting):&lt;/strong> il processo sta attendendo un evento (p.e. I/O)&lt;/li>
&lt;li>&lt;strong>Pronto (ready):&lt;/strong> il processo è in attesa di essere assegnato a un processore&lt;/li>
&lt;li>&lt;strong>Terminato:&lt;/strong> il processo ha finito l&amp;rsquo;esecuzione&lt;/li>
&lt;/ul>
&lt;p>Questi nomi sono arbitrari e variano in base al s.o. Solamente un processo per core può essere in stato di esecuzione.&lt;/p>
&lt;h4 id="blocco-di-controllo-del-processo">Blocco di controllo del processo&lt;/h4>
&lt;p>Ogni processo è rappresentato nel s.o. da un &lt;strong>blocco di controllo (Process Control Block, PCB, o Task Control Block, TCB)&lt;/strong>, contenente:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Stato del processo:&lt;/strong> uno degli stati sopra descritti&lt;/li>
&lt;li>&lt;strong>Contatore di programma:&lt;/strong> per tenere traccia dell&amp;rsquo;istruzione da eseguire&lt;/li>
&lt;li>&lt;strong>Registri della CPU:&lt;/strong> di molteplice natura (p.e. accumulatori, indici di registro, puntatori a stack, registri general purpose, informazioni sulle condizioni del codice, etc.). È informazione che va salvata in caso di interruzioni.&lt;/li>
&lt;li>&lt;strong>Informazioni sullo scheduling di CPU:&lt;/strong> per determinare quando il processo verrà eseguito (p.e. priorità del processo, puntatori a code di scheduling, etc.)&lt;/li>
&lt;li>&lt;strong>Informazioni sulla gestione della memoria:&lt;/strong> legate alle locazioni della memoria usate dal processo&lt;/li>
&lt;li>&lt;strong>Informazioni di accounting:&lt;/strong> p.e. il numero di CPU, i limiti di tempo, l&amp;rsquo;identificatore di job o processo, etc.&lt;/li>
&lt;li>&lt;strong>Informazioni sullo stato dell&amp;rsquo;I/O:&lt;/strong> p.e. la lista dei dispositivi I/O allocati al processo, la lista dei file in uso, etc.&lt;/li>
&lt;/ul>
&lt;h4 id="thread">Thread&lt;/h4>
&lt;p>Un processo può utilizzare più core del processore, separando i task e eseguendoli in &lt;strong>thread&lt;/strong> diversi (p.e. un word processor può prendere in input caratteri e lanciare il controllo ortografico sul testo). In tal caso il processo ha un PCB per ogni thread.&lt;/p>
&lt;h2 id="scheduling-dei-processi">Scheduling dei processi&lt;/h2>
&lt;p>Due concetti fondamentali ruotano attorno alla schedulazione dei processi:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Il multiprogramming&lt;/strong> permette alla CPU di essere sempre occupata con altri programmi, anche durante gli interrupt.&lt;/li>
&lt;li>&lt;strong>Il time sharing&lt;/strong> permette di eseguire tutti i processi per brevi lassi di tempo, alternandoli, in maniera che l&amp;rsquo;utente possa interagirci senza ritardi. Per determinare quale processo eseguire in un determinato istante, è necessario un process scheduler.&lt;/li>
&lt;/ul>
&lt;p>Per accomodare queste due scelte di design, è bene trattare i processi distinguendoli in due categorie:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>I/O-bound&lt;/strong> per processi che fanno molte operazioni di I/O&lt;/li>
&lt;li>&lt;strong>CPU-bound&lt;/strong> per processo che utilizzano molto la CPU&lt;/li>
&lt;/ul>
&lt;p>Infine il numero di processi correntemente in memoria viene chiamato &lt;strong>grado di multiprogramming&lt;/strong>.&lt;/p>
&lt;h4 id="code-di-scheduling">Code di scheduling&lt;/h4>
&lt;p>Nel momento in cui un processo è pronto per essere eseguito, viene inserito nella &lt;strong>coda ready&lt;/strong>, generalmente implementata come una linked list. Una volta selezionato per l&amp;rsquo;esecuzione (dispatched), il processo può presentare tre comportamenti:&lt;/p>
&lt;ul>
&lt;li>In attesa di un evento (p.e. I/O). In questo caso viene messo nella &lt;strong>coda wait&lt;/strong> e, quando pronto, di nuovo nella coda ready.&lt;/li>
&lt;li>Creazione di un processo figlio (child process). Anche in questo caso il processo genitore (parent process) viene messo nella coda wait in attesa che il processo figlio finisca l&amp;rsquo;esecuzione, per poi venire rimesso nella coda ready.&lt;/li>
&lt;li>Rimozione dal core di esecuzione, che può avvenire per via di un interrupt o all&amp;rsquo;esaurimento del tempo di esecuzione (time slice) allocato al processo. In questo caso il processo viene spostato nella coda ready.&lt;/li>
&lt;/ul>
&lt;h4 id="scheduling-della-cpu">Scheduling della CPU&lt;/h4>
&lt;p>Il ruolo di un &lt;strong>CPU scheduler&lt;/strong> è di decidere quale processo allocare in un core, tra quelli disponibili nella ready queue. In aggiunta, deve sapere quando rimuovere un processo dalla CPU per far spazio agli altri. Nel caso di un processo I/O-bound, il processo può essere rimosso nel momento di un&amp;rsquo;interruzione. Nel caso di un processo CPU-bound, il processo viene tipicamente rimosso all&amp;rsquo;esaurimento di un limite di tempo programmato, per far eseguire anche gli altri processi.&lt;/p>
&lt;p>Alcuni sistemi utilizzano la tecnica dello &lt;strong>swapping&lt;/strong> per cui a volte è vantaggioso spostare un processo dalla memoria centrale a quella secondaria per ridurre il grado di multiprogramming. A seguire verrà re-inserito in memoria ed eseguito.&lt;/p>
&lt;h4 id="cambio-di-contesto">Cambio di contesto&lt;/h4>
&lt;p>Quando accade un&amp;rsquo;interruzione, il &lt;strong>contesto&lt;/strong> del processo in esecuzione viene salvato e rimosso dalla CPU. Il contesto è salvato nel PCB e un nuovo processo viene allocato nella CPU. Questo evento viene chiamato &lt;strong>cambio di contesto (context switch)&lt;/strong>. Il context switch, occupa tempo di CPU, generando un overhead. Per tale motivo dev&amp;rsquo;essere gestito in maniera efficiente e dipende dalla velocità di comunicazione con la memoria, dal numero di registri da copiare, etc. In generale, più il s.o. è complesso, più il context switch richiederà tempo.&lt;/p>
&lt;h2 id="operazioni-sui-processi">Operazioni sui processi&lt;/h2>
&lt;p>In questa sezione andremo a spiegare i meccanismi dietro le operazioni principali eseguibili sui processi.&lt;/p>
&lt;h3 id="creazione-di-processi">Creazione di processi&lt;/h3>
&lt;p>Come detto, un processo può creare altri processi, che possono a loro volta creare altri processi e così via, andando a formare un &lt;strong>albero di processi&lt;/strong>. Ogni processo è identificabile tramite un &lt;strong>identificatore di processo (Process Identifier, PID)&lt;/strong>, un numero intero. Nei sistemi UNIX solitamente il processo alla radice dell&amp;rsquo;albero è &lt;code>init()&lt;/code> mentre su Linux viene spesso rimpiazzato con &lt;code>systemd()&lt;/code>.&lt;/p>
&lt;p>Quando un processo crea un processo figlio, questo richiederà delle risorse per completare il proprio compito. Il processo genitore potrà quindi decidere di partizionare le risorse con il figlio o di condividerle. Il figlio può anche chiedere risorse direttamente al s.o. ma vincolare le risorse disponibili a quelle permesse al genitore permette di evitare un overload delle risorse in seguito all&amp;rsquo;eventuale creazione di numerosi processi figlio. In aggiunta, il processo genitore potrebbe dover passare dei parametri al processo figlio per eseguire il compito assegnato. Alternativamente, è il s.o. ad occuparsi di passare le risorse.&lt;/p>
&lt;p>Alla creazione del processo, il genitore può attendere la sua esecuzione o eseguire in parallelo col figlio. In aggiunta, il figlio può essere un duplicato del padre (stesso programma e dati), oppure avere un nuovo programma caricato.&lt;/p>
&lt;p>Nei sistemi UNIX un nuovo processo è creato usando &lt;code>fork()&lt;/code>. Il processo figlio è una copia dello spazio degli indirizzi del processo padre per facilitare la loro comunicazione. Il codice di ritorno dell&amp;rsquo;operazione è 0 per il figlio e il PID del figlio per il padre.&lt;/p>
&lt;p>Una volta effettuato il fork, uno dei due processi chiama &lt;code>exec()&lt;/code> per rimpiazzare la memoria del processo con un nuovo programma. Un nuovo file binario viene caricato in memoria e lo spazio di memoria che contiene l&amp;rsquo;exec viene eliminato. Utilizzando questo metodo i processi hanno prima la possibilità di comunicare e poi di essere indipendenti. Ovviamente, &lt;code>exec()&lt;/code> può non essere chiamato: in questo caso entrambi i processi eseguono lo stesso codice con copie dei dati separate.&lt;/p>
&lt;p>Qui sotto riportiamo un esempio di programma che crea un nuovo processo con &lt;code>fork()&lt;/code> (Fig. 3.8 del Silberschatz)&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="cp">#include&lt;/span> &lt;span class="cpf">&amp;lt;sys/types.h&amp;gt;&lt;/span>&lt;span class="cp">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cp">#include&lt;/span> &lt;span class="cpf">&amp;lt;stdio.h&amp;gt;&lt;/span>&lt;span class="cp">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cp">#include&lt;/span> &lt;span class="cpf">&amp;lt;unistd.h&amp;gt;&lt;/span>&lt;span class="cp">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cp">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kt">int&lt;/span> &lt;span class="nf">main&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kt">pid_t&lt;/span> &lt;span class="n">pid&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* fork a child process */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">pid&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nf">fork&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">pid&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="cm">/* error occurred */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">fprintf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">stderr&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;Fork Failed&amp;#34;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">else&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">pid&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="cm">/* child process */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">execlp&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;/bin/ls&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;ls&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">NULL&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">else&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="cm">/* parent process */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* parent will wait for the child to complete */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">wait&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">NULL&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">printf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;Child Complete&amp;#34;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="terminazione-dei-processi">Terminazione dei processi&lt;/h3>
&lt;p>Un processo termina quando esegue la chiamata di sistema &lt;code>exit()&lt;/code> e chiede al s.o. di eliminarlo. Solitamente il processo ritorna un valore al processo genitore, tramite la chiamata di sistema &lt;code>wait()&lt;/code>. Tutte le risorse del processo vengono deallocate.&lt;/p>
&lt;p>A volte è possibile che un processo termini un altro processo tramite una chiamata di sistema apposita. Ciò può avvenire per varie motivi:&lt;/p>
&lt;ul>
&lt;li>Il processo figlio ha superato la quota di risorse a esso assegnata&lt;/li>
&lt;li>Il compito del processo figlio non è più richiesto&lt;/li>
&lt;li>Il processo genitore sta terminando e il s.o. è stato istruito a terminare anche i processi figli a esso associati&lt;/li>
&lt;/ul>
&lt;p>La terminazione di processi figli a seguito di quella del genitore si chiama &lt;strong>terminazione a cascata&lt;/strong> ed è una scelta di design in alcuni s.o.&lt;/p>
&lt;p>Nei sistemi UNIX, &lt;code>exit()&lt;/code> viene chiamato di default, anche se non è presente nel programma. Quando un processo viene terminato, la sua entry nella tabella dei processi deve rimanere finché il processo genitore non chiama &lt;code>wait()&lt;/code> e recupera i valori di ritorno.&lt;/p>
&lt;ul>
&lt;li>Parliamo di processo figlio &lt;strong>zombie&lt;/strong> quando esso termina e attende che la &lt;code>wait()&lt;/code> del genitore venga eseguita. Solitamente il periodo di attesa è breve.&lt;/li>
&lt;li>Parliamo di processo figlio &lt;strong>orfano&lt;/strong> quando il genitore termina senza aver eseguito la &lt;code>wait()&lt;/code>. In questo caso i sistemi UNIX assegnano l&amp;rsquo;orfano al processo &lt;code>init&lt;/code> (&lt;code>systemd&lt;/code> nel caso di alcuni sistemi Linux), che si occupa di chiamare periodicamente &lt;code>wait()&lt;/code> per eseguire la terminazione corretta dei processi.&lt;/li>
&lt;/ul>
&lt;h2 id="comunicazione-tra-processi">Comunicazione tra processi&lt;/h2>
&lt;p>Un processo che non condivide dati con altri processi è detto &lt;strong>indipendente&lt;/strong>. Un processo che interagisce con altri processi è detto &lt;strong>cooperativo&lt;/strong>. Ci sono vari motivi per permettere ai processi di cooperare:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Condivisione delle informazioni&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Velocità computazionale:&lt;/strong> ottenuto partizionando un task in molteplici sottotask che possono essere eseguiti in parallelo&lt;/li>
&lt;li>&lt;strong>Modularità:&lt;/strong> che garantisce più controllo e facilità di modifica&lt;/li>
&lt;/ul>
&lt;p>I processi cooperativi richiedono un meccanismo di &lt;strong>comunicazione tra processi (InterProcess communication, IPC)&lt;/strong>. I due modelli fondamentali di comunicazione sono&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Memoria condivisa:&lt;/strong> una parte di memoria viene appunto condivisa tra i processi che sono liberi di scambiarsi le informazioni. È l&amp;rsquo;alternativa più veloce. Le memoria viene settata con una chiamata di sistema e poi tutti gli accessi vengono trattati come accessi alla memoria.&lt;/li>
&lt;li>&lt;strong>Passaggio di messaggi:&lt;/strong> attraverso chiamate di sistema, i processi si inviano messaggi con cui si scambiano i dati. È un metodo efficiente per inviare dati di piccola mole.&lt;/li>
&lt;/ul>
&lt;h3 id="ipc-nei-sistemi-a-memoria-condivisa">IPC nei sistemi a memoria condivisa&lt;/h3>
&lt;p>Nel modello di comunicazione a memoria condivisa i processi chiedono al s.o. di condividere parte della memoria. Il s.o. autorizza la richiesta e lascia ai processi la responsabilità di gestione della memoria condivisa. I processi devono occuparsi del tipo e della collocazione dei dati, inoltre sono responsabili di non scrivere nella stessa locazione contemporaneamente.&lt;/p>
&lt;p>Il paradigma comunemente utilizzato per introdurre il concetto è quello di &lt;em>consumatore/produttore&lt;/em>. Un processo produce dati che vengono poi consumati da un altro processo (p.e. un compilatore produce assembly, consumato da un assemblatore). Per il suo funzionamento, è necessario uno spazio di memoria condivisa (buffer) tra due processi e un meccanismo che prevenga il consumatore dal consumare dati non ancora prodotti.&lt;/p>
&lt;p>Si possono utilizzare due tipi di buffer:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Illimitato:&lt;/strong> il consumatore può dover attendere prodotti, ma il produttore può produrne quanti ne vuole&lt;/li>
&lt;li>&lt;strong>Limitato:&lt;/strong> sia il consumatore che il produttore devono attendere rispettivamente in caso di buffer vuoto o pieno&lt;/li>
&lt;/ul>
&lt;p>Può accadere talvolta che il produttore e il consumatore vogliano utilizzare contemporaneamente il buffer. In questo caso è necessario sincronizzare i processi.&lt;/p>
&lt;h3 id="ipc-in-sistemi-a-scambio-di-messaggi">IPC in sistemi a scambio di messaggi&lt;/h3>
&lt;p>Nel modello a scambio di messaggi i due processi comunicano senza la necessità di una memoria condivisa. È un meccanismo particolarmente utilizzato in ambienti distribuiti con più calcolatori, ad esempio per implementare una chat.&lt;/p>
&lt;p>Le due operazioni fondamentali sono l&amp;rsquo;invio (&lt;code>send(message)&lt;/code>) e la ricezione (&lt;code>receive(message)&lt;/code>). I messaggi possono avere lunghezza:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Fissa:&lt;/strong> elementare a livello di sistema, ma più complicato da gestire dal punto di vista applicativo&lt;/li>
&lt;li>&lt;strong>Variabile:&lt;/strong> complesso a livello di sistema, ma più semplice dal punto di vista applicativo.&lt;/li>
&lt;/ul>
&lt;p>Il componente logico fondamentale per implementare questo paradigma è il &lt;strong>canale di comunicazione&lt;/strong>. Ci sono almeno tre caratteristiche nello sviluppo del canale di comunicazione, che caratterizzano il canale. Le vediamo nelle sezione seguenti&lt;/p>
&lt;h4 id="naming">Naming&lt;/h4>
&lt;p>Per comunicare è necessario disporre che i processi possano essere nominati da altri processi. La comunicazione in base al naming si divide in:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Diretta:&lt;/strong> i processi comunicano tra di loro direttamente. Questo tipo di comunicazione avviene in due modi:
&lt;ul>
&lt;li>&lt;strong>simmetrica:&lt;/strong> comunicante deve esplicitamente indicare il ricevente o il trasmittente della comunicazione. Dato un processo ricevente P e un mittente Q le primitive sono &lt;code>send(P, msg)&lt;/code> &lt;code>receive(Q, msg)&lt;/code>. Il canale di comunicazione ha le seguenti proprietà:
&lt;ul>
&lt;li>Una connessione viene stabilita automaticamente tra ogni coppia di processi che vuole comunicare. I processi devono conoscere la loro reciproca identità per comunicare&lt;/li>
&lt;li>Un canale è associato esattamente a due processi&lt;/li>
&lt;li>Tra ogni coppia di processi esiste esattamente un canale&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>asimmetrica:&lt;/strong> solamente il mittente indica il ricevente. Le primitive sono &lt;code>send(P, msg)&lt;/code> e &lt;code>receive(id, msg)&lt;/code>, dove &lt;code>id&lt;/code> è l&amp;rsquo;identificatore del processo con cui la comunicazione ha avuto inizio.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>Lo svantaggio della comunicazione diretta è che gli identificatori devono essere esplicitamente definiti e il cambio di nome di un processo richiede di cercare tutte le sue occorrenze e di modificarle di conseguenza.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Indiretta:&lt;/strong> i processi inviano e ricevono messaggi da caselle di posta (mailbox) o porte (port). Una mailbox è essenzialmente un oggetto con un identificatore che riceve messaggi. Un processo può comunicare con un altro processo inviando messaggi nella mailbox apposita. Per una comunicazione reciproca è necessaria una mailbox condivisa. Data una mailbox A, le primitive sono &lt;code>send(A, msg)&lt;/code> e &lt;code>receive(A, msg)&lt;/code>. Il canale di comunicazione ha le seguenti proprietà:
&lt;ul>
&lt;li>Una connessione viene stabilita tra coppie di processi solo se hanno una mailbox condivisa&lt;/li>
&lt;li>Un canale può essere associato a più processi&lt;/li>
&lt;li>Tra ogni coppia di processi possono esistere più canali (uno per mailbox)&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>Supponiamo tre processi P1, P2, P3 che condividono una mailbox. P1 invia un messaggio. P2 e P3 eseguono la ricezione. Chi ottiene il messaggio. Ci sono tre possibilità per risolvere il problema:&lt;/p>
&lt;ul>
&lt;li>Permettere connessioni solo tra due processi al massimo&lt;/li>
&lt;li>Permettere di eseguire una receive solamente a un processo per volta&lt;/li>
&lt;li>Definire una policy per la gestione dei conflitti (p.e. alternare i processi riceventi)&lt;/li>
&lt;/ul>
&lt;p>Una mailbox può essere posseduta da un processo o dal s.o. Nel primo caso la mailbox è nello spazio di memoria del processo e distinguiamo tra proprietario (solo ricevente) e utente (solo mittente), in maniera da evitare confusione. Quando il processo termina, la mailbox sparisce e ogni processo che voglia ancora inviare messaggi dev&amp;rsquo;essere notificato dell&amp;rsquo;eliminazione della mailbox.&lt;/p>
&lt;p>La mailbox a livello di s.o. è indipendente e non associata ad alcun processo. Il s.o. deve essere in grado di creare ed eliminare una mailbox e inviare e ricevere messaggi tramite la mailbox.&lt;/p>
&lt;h4 id="sincronizzazione">Sincronizzazione&lt;/h4>
&lt;p>La comunicazione tra processi avviene tramite le primitive &lt;code>send()&lt;/code> e &lt;code>receive()&lt;/code>. Lo scambio di messaggio può avvenire in due maniere:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Sincrono/bloccante:&lt;/strong> nel caso dell&amp;rsquo;invio, il processo mittente si blocca fino alla ricezione del messaggio da parte del ricevente. Nel caso della ricezione il processo ricevente si blocca fino a un nuovo messaggio in arrivo.&lt;/li>
&lt;li>&lt;strong>Asincrono/non-bloccante:&lt;/strong> nel caso dell&amp;rsquo;invio, il processo mittente invia il messaggio e continua l&amp;rsquo;esecuzione. Nel caso della ricezione il processo ricevente recupera il messaggio oppure null se non c&amp;rsquo;è alcun messaggio.&lt;/li>
&lt;/ul>
&lt;p>Se entrambi invio e ricezione sono bloccanti abbiamo un cosiddetto &lt;strong>rendezvous&lt;/strong> tra il mittente e il ricevente, che risolve il problema produttore/consumatore.&lt;/p>
&lt;h4 id="buffering">Buffering&lt;/h4>
&lt;p>I messaggi scambiati dai processi risiedono in code temporanee. Tali code possono essere implementate in tre maniere:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Capacità zero:&lt;/strong> non avendo capacità i messaggi non possono essere accodati e il mittente deve bloccarsi finché il messaggio inviato non è stato ricevuto&lt;/li>
&lt;li>&lt;strong>Capacità limitata:&lt;/strong> permette al massimo &lt;em>n&lt;/em> messaggi nella coda, oltre i quali il mittente si blocca&lt;/li>
&lt;li>&lt;strong>Capacità illimitata:&lt;/strong> la coda ha lunghezza potenzialmente infinita e il mittente non si blocca mai&lt;/li>
&lt;/ul>
&lt;p>Il primo caso è a volte chiamato &lt;strong>senza buffering&lt;/strong> mentre gli altri due con &lt;strong>buffering automatico&lt;/strong>.&lt;/p>
&lt;h3 id="pipe">Pipe&lt;/h3>
&lt;p>Una &lt;strong>pipe&lt;/strong> è un meccanismo UNIX che agisce come canale di comunicazione tra processi. Le scelte di design per una pipe sono:&lt;/p>
&lt;ul>
&lt;li>La comunicazione è unidirezionale o bidirezionale?&lt;/li>
&lt;li>Se la comunicazione è bidirezionale, è &lt;strong>half duplex&lt;/strong> (i dati viaggiano solo in una direzione alla volta) o &lt;strong>full duplex&lt;/strong> (i dati viaggiano in entrambe le direzioni)?&lt;/li>
&lt;li>Deve esistere una relazione (del tipo genitore-figlio) tra i processi di comunicazione?&lt;/li>
&lt;li>Le pipe possono comunicare in rete o solo all&amp;rsquo;interno della macchina?&lt;/li>
&lt;/ul>
&lt;p>Esistono due tipi di pipe, &lt;strong>convenzionali&lt;/strong> e &lt;strong>named&lt;/strong>.&lt;/p>
&lt;h4 id="pipe-convenzionali">Pipe convenzionali&lt;/h4>
&lt;p>Queste pipe unidirezionali utilizzano il paradigma produttore/consumatore e possiedono quindi un&amp;rsquo;&lt;strong>estremità dedicata alla scrittura (write-end)&lt;/strong> e un&amp;rsquo;&lt;strong>estremità dedicata alla lettura (read-end)&lt;/strong>. Per il caso bidirezionale servono due pipe. I sistemi UNIX permettono la creazione di due pipe alla volta (una per scrittura e una per lettura) che vengono gestite come un file (quindi con &lt;code>read&lt;/code> e &lt;code>write&lt;/code>). Queste pipe richiedono la distinzione tra genitore e figlio e quindi possono essere utilizzate solo all&amp;rsquo;interno della stessa macchina. Una volta che i processi finiscono di comunicare la pipe convenzionale viene eliminata.&lt;/p>
&lt;h4 id="named-pipe">Named pipe&lt;/h4>
&lt;p>Queste pipe bidirezionali sono più potenti delle convenzionali e non richiedono la distinzione genitore figlio. Una volta creata una named pipe diversi processi possono utilizzarla per comunicare e la pipe non viene eliminata al termine delle comunicazioni. Nei sistemi UNIX
le named pipe sono dette FIFO e vengono trattate come file. Nonostante la bidirezionalità, la comunicazione è half duplex e quindi le FIFO vengono create a coppie come con le pipe convenzionali. I processi devono risiedere nella stessa macchina.&lt;/p>
&lt;p>Nel caso due processi risiedano su due macchine diverse è necessario creare una &lt;strong>socket&lt;/strong>.&lt;/p>
&lt;div id="references-1784797996219320741" class="references" style="margin-top: 5rem;">
&lt;p>&lt;strong>References&lt;/strong>&lt;/p>
A. Silberschatz, P. B. Galvin, G. Gagne, &lt;em>Sistemi Operativi. Concetti ed Esempi&lt;/em>, Decima edizione. Ch. 3.
&lt;/div>
&lt;script>
document.addEventListener('DOMContentLoaded', function () {
var ref = document.getElementById('references-1784797996219320741');
var fn = document.querySelector('.footnotes');
if (ref &amp;&amp; fn &amp;&amp; fn.parentNode) {
fn.parentNode.insertBefore(ref, fn.nextSibling);
}
});
&lt;/script></description></item><item><title>4 Thread e concorrenza</title><link>https://aretor.it/courses/os2425/lessons/04_thread/</link><pubDate>Mon, 17 Mar 2025 00:00:00 +0000</pubDate><guid>https://aretor.it/courses/os2425/lessons/04_thread/</guid><description>&lt;p>In questa lezione andremo a parlare di come parallelizzare i processi per permettere un&amp;rsquo;esecuzione più efficiente dei task a esso associati. Discuteremo delle criticità principali nello sviluppo di codice parallelizzabile e introdurremo alcune API di programmazione parallela.&lt;/p>
&lt;h2 id="41-introduzione">4.1 Introduzione&lt;/h2>
&lt;p>Un thread è l&amp;rsquo;unità base d&amp;rsquo;uso della CPU e comprende:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Identificatore (ID)&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Contatore di programma&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Insieme di registri&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Stack&lt;/strong>&lt;/li>
&lt;/ul>
&lt;p>Condivide con gli altri thread:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Sezione di codice&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Sezione dati&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Risorse di sistema&lt;/strong>&lt;/li>
&lt;/ul>
&lt;p>Un processo tradizionale, viene chiamato &lt;strong>processo pesante (heavyweight process)&lt;/strong> ed è composto da un solo thread. Un &lt;strong>processo multithread&lt;/strong> svolge più compiti parallelamente.&lt;/p>
&lt;h3 id="411-motivazioni">4.1.1 Motivazioni&lt;/h3>
&lt;p>Di seguito forniamo alcuni esempi di applicazioni multithread:&lt;/p>
&lt;ul>
&lt;li>Un&amp;rsquo;applicazione che processa dei file in una cartella, può utilizzare i thread per processare più file contemporaneamente.&lt;/li>
&lt;li>Un browser può utilizzare un thread per rappresentare immagini e testo e un altro thread per scaricare i dati dalla rete&lt;/li>
&lt;li>Un word processor può avere un thread per la rappresentazione del testo e uno per ricevere l&amp;rsquo;input da tastiera&lt;/li>
&lt;li>Un server che deve gestire le interazioni di molti client. Invece di istanziare lo stesso programma per ogni cliente, creando un overhead, il server potrebbe creare un singolo processo che a ogni nuova connessione crea un thread per gestirla parallelamente.&lt;/li>
&lt;/ul>
&lt;p>La maggior parte dei kernel dei s.o. è multithread, con ogni thread preposto all&amp;rsquo;esecuzione di un&amp;rsquo;attività specifica (p.e. gestione dei dispositivi, memoria, interruzioni, etc.).&lt;/p>
&lt;p>Il multithreading può essere applicato anche su algoritmi per l&amp;rsquo;ordinamento, gli alberi o i grafi, oppure su problemi più specializzati di data mining, grafica o intelligenza artificiale 🤖&lt;/p>
&lt;h3 id="412-vantaggi">4.1.2 Vantaggi&lt;/h3>
&lt;p>I vantaggi principali del multithreading sono:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Tempo di risposta:&lt;/strong> il multithreading rende un&amp;rsquo;applicazione interattiva, anche se parte di essa è bloccata nell&amp;rsquo;esecuzione di alcuni task. Questa caratteristica è utile soprattutto nella programmazione di GUI.&lt;/li>
&lt;li>&lt;strong>Condivisione delle risorse:&lt;/strong> i processi possono condividere risorse solo attraverso IPC. I thread tuttavia condividono la memoria e le risorse di default.&lt;/li>
&lt;li>&lt;strong>Economia:&lt;/strong> assegnare memoria e risorse per la creazione di nuovi processi e eseguire context switch è costoso. Con i thread il costo viene abbattuto.&lt;/li>
&lt;li>&lt;strong>Scalabilità:&lt;/strong> I vantaggi della programmazione multithread sono ancora maggiori nelle architetture multiprocessore dove i thread possono essere eseguiti su diversi core di elaborazione.&lt;/li>
&lt;/ul>
&lt;h2 id="42-programmazione-multicore">4.2 Programmazione multicore&lt;/h2>
&lt;p>Il multithreading è stato sviluppato appositamente per sfruttare al meglio i sistemi multicore. Tuttavia, è possibile utilizzare il multithreading anche su sistemi a singola CPU, semplicemente i thread eseguono in maniera sequenziale. In questo senso possiamo distinguere due caratteristiche di un sistema:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Concorrenza (concurrency):&lt;/strong> il sistema supporta più di un compito, portando avanti tutti i compiti insieme (p.e. alternandoli o parallelizzandoli).&lt;/li>
&lt;li>&lt;strong>Parallelismo (parallelism):&lt;/strong> il sistema supporta l&amp;rsquo;esecuzione parallela di più task simultaneamente.&lt;/li>
&lt;/ul>
&lt;p>Di conseguenza un sistema parallelo è sempre concorrente, ma non vale necessariamente il contrario&lt;/p>
&lt;h2 id="43-sfide-nella-programmazione">4.3 Sfide nella programmazione&lt;/h2>
&lt;p>In generale, la programmazione parallela presenta cinque sfide:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Identificare i task:&lt;/strong> esaminare l&amp;rsquo;applicazione per comprendere quali parti del codice sono parallelizzabili.&lt;/li>
&lt;li>&lt;strong>Bilanciamento:&lt;/strong> garantire che il compito principale sia equamente diviso tra i task.&lt;/li>
&lt;li>&lt;strong>Suddivisione dei dati:&lt;/strong> similmente ai task, anche i dati devono essere equamente divisi tra i task.&lt;/li>
&lt;li>&lt;strong>Dipendenze dei dati:&lt;/strong> nel caso in cui i dati siano condivisi tra più task, bisogna garantire sincronizzazione durante la loro manipolazione.&lt;/li>
&lt;li>&lt;strong>Test e debugging:&lt;/strong> un algoritmo parallelo presenta molteplici sequenze di esecuzione, per tale motivo è richiesta particolare attenzione e cura nel testing e nel debugging.&lt;/li>
&lt;/ol>
&lt;h2 id="44-modello-multithreading">4.4 Modello multithreading&lt;/h2>
&lt;p>Il supporto ai thread può essere fornito a livello di &lt;strong>utente&lt;/strong> o di &lt;strong>kernel&lt;/strong>. Praticamente tutti i sistemi operativi moderni supportano i thread a livello di kernel. Ci possono essere tre relazioni tra thread utente e kernel.&lt;/p>
&lt;h3 id="441-modello-da-molti-a-uno">4.4.1 Modello da molti a uno&lt;/h3>
&lt;p>Questo modello mappa molteplici thread utente a un singolo thread kernel. La gestione dei thread viene effettuata a livello della libreria ed è efficiente. Non è possibile, tuttavia, eseguire i thread utente in parallelo poiché, ogni volta che un thread utente fa una chiamata di sistema bloccante, gli altri devono aspettare la sua esecuzione.&lt;/p>
&lt;h3 id="442-modello-da-uno-a-uno">4.4.2 Modello da uno a uno&lt;/h3>
&lt;p>Questo modello mappa un thread utente a un singolo thread kernel. Questo design permette più concorrenza del modello precedente dal momento che quando un thread fa una chiamata di sistema bloccante, gli altri thread possono comunque eseguire su thread kernel separati. È anche possibile eseguire i thread in parallelo. C&amp;rsquo;è tuttavia un overhead nella creazione del thread kernel e questo può peggiorare le performance quando devono essere creati molti thread utente. Per tale motivo alcuni s.o. limitano il numero massimo di thread kernel al numero di core del calcolatore.&lt;/p>
&lt;p>È un modello comunemente usato nei s.o. Linux e Windows.&lt;/p>
&lt;h3 id="443-modello-da-molti-a-molti">4.4.3 Modello da molti a molti&lt;/h3>
&lt;p>Questo modello mappa molteplici thread utente a molteplici thread kernel. In generale, i thread kernel sono in numero minore dei thread utente per garantire efficienza. Quando un thread utente fa un operazione bloccante, il s.o. può creare un nuovo thread kernel per far proseguire l&amp;rsquo;esecuzione.&lt;/p>
&lt;p>Una variante di questo modello è il modello &lt;strong>a due livelli&lt;/strong>, che permette anche la creazione di singole coppie utente-kernel, come nel modello uno-a-uno.&lt;/p>
&lt;h2 id="45-librerie-dei-thread">4.5 Librerie dei thread&lt;/h2>
&lt;p>Una &lt;strong>libreria dei thread&lt;/strong> fornisce un API per creare e gestire thread. Ci sono due approcci nella sua implementazione.&lt;/p>
&lt;p>Il primo consiste nel fornire una libreria interamente a livello utente, senza supporto del kernel. Il secondo consiste nel implementare una libreria che si interfaccia col kernel, con il codice, i dati e le strutture dati nello spazio del kernel.&lt;/p>
&lt;p>Le tre librerie principali di thread sono POSIX Pthreads (ilvello utente e sistema), Windows (sistema) e Java (utente ma tipicamente implementata con thread library a livello di sistema). Per le librerie POSIX e Windows ogni dato dichiarato a livello globale è a disposizione di tutti i thread.&lt;/p>
&lt;h3 id="451-threading-sincronoasincrono">4.5.1 Threading sincrono/asincrono&lt;/h3>
&lt;p>Secondo questa categorizzazione esistono due tipologie di threading:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Sincrono:&lt;/strong> quando un processo genitore genera uno o più thread figli, deve aspettare il termine della loro esecuzione prima di riprendere a eseguire.&lt;/li>
&lt;li>&lt;strong>Asincrono:&lt;/strong> quando un processo genitore genera un thread figlio, il genitore continua con l&amp;rsquo;esecuzione, in maniera indipendente. È una tecnica comunemente utilizzata per il design di interfacce responsive.&lt;/li>
&lt;/ul>
&lt;p>Ci concentreremo sul thread sincrono, che è più interessante e complesso da gestire.&lt;/p>
&lt;h3 id="452-pthreads">4.5.2 Pthreads&lt;/h3>
&lt;p>Qui sotto riportiamo un esempio (Fig. 4.13 del Silberschatz) di creazione di un singolo thread per l&amp;rsquo;esecuzione di una funzione &lt;code>runner&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="cp">#include&lt;/span> &lt;span class="cpf">&amp;lt;pthread.h&amp;gt;&lt;/span>&lt;span class="cp">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cp">#include&lt;/span> &lt;span class="cpf">&amp;lt;stdio.h&amp;gt;&lt;/span>&lt;span class="cp">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cp">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cp">#include&lt;/span> &lt;span class="cpf">&amp;lt;stdlib.h&amp;gt;&lt;/span>&lt;span class="cp">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cp">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kt">int&lt;/span> &lt;span class="n">sum&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="cm">/* this data is shared by the thread(s) */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kt">void&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nf">runner&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">void&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="n">param&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="cm">/* threads call this function */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kt">int&lt;/span> &lt;span class="nf">main&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">int&lt;/span> &lt;span class="n">argc&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">char&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="n">argv&lt;/span>&lt;span class="p">[])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kt">pthread_t&lt;/span> &lt;span class="n">tid&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="cm">/* the thread identifier */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kt">pthread_attr_t&lt;/span> &lt;span class="n">attr&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="cm">/* set of thread attributes */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* set the default attributes of the thread */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">pthread_attr_init&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="n">attr&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* create the thread */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">pthread_create&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="n">tid&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="n">attr&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">runner&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">argv&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">]);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* wait for the thread to exit */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">pthread_join&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tid&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">NULL&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">printf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;sum = %d∖n&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sum&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cm">/* The thread will execute in this function */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kt">void&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nf">runner&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">void&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="n">param&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kt">int&lt;/span> &lt;span class="n">i&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">upper&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nf">atoi&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">param&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">sum&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">i&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="n">i&lt;/span> &lt;span class="o">&amp;lt;=&lt;/span> &lt;span class="n">upper&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="n">i&lt;/span>&lt;span class="o">++&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">sum&lt;/span> &lt;span class="o">+=&lt;/span> &lt;span class="n">i&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">pthread_exit&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;ul>
&lt;li>&lt;code>pthread_t&lt;/code> e &lt;code>pthread_attr_t&lt;/code> sono i tipi per la creazione del thread e dei suoi attributi&lt;/li>
&lt;li>&lt;code>pthread_attr_init&lt;/code> si occupa dell&amp;rsquo;inizializzazione degli attributi del thread (in questo caso lasciati ai valori di default)&lt;/li>
&lt;li>&lt;code>pthread_create&lt;/code> crea il thread, gli assegna gli attributi, il codice da eseguire (&lt;code>runner&lt;/code>) e gli argomenti della funzione (&lt;code>argv[1]&lt;/code>)&lt;/li>
&lt;li>&lt;code>ptrhead_join&lt;/code> permette al processo genitore di attendere l&amp;rsquo;esecuzione del thread figlio&lt;/li>
&lt;/ul>
&lt;p>Notate che &lt;code>sum&lt;/code> è dichiarata a livello globale, e non è quindi necessario passarla al thread figlio. Per fare la join di diversi processi figlio il seguente snippet (Fig. 4.13 del Silberschatz) illustra come fare.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="cp">#define NUM THREADS 10
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cp">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cm">/* an array of threads to be joined upon */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">pthread&lt;/span> &lt;span class="n">t&lt;/span> &lt;span class="n">workers&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">NUM&lt;/span> &lt;span class="n">THREADS&lt;/span>&lt;span class="p">];&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kt">int&lt;/span> &lt;span class="n">i&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="n">i&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="n">NUM&lt;/span> &lt;span class="n">THREADS&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="n">i&lt;/span>&lt;span class="o">++&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">pthread_join&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">workers&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="nb">NULL&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="46-threading-implicito">4.6 Threading implicito&lt;/h2>
&lt;p>La crescita continua dei processori multicore, rende inevitabilmente più complesso lo sviluppo di codice parallelo e più difficile la verifica della correttezza di un programma, in quanto un codice parallelo può presentare molteplici path di esecuzione.&lt;/p>
&lt;p>Una soluzione al problema consiste nell&amp;rsquo;&lt;strong>threading implicito&lt;/strong>, ovvero il trasferimento della logica di threading e parallelismo dal lato sviluppatore a quello del compilatore e delle librerie run-time. Allo sviluppatore, in questo caso, è richiesto di definire dei &lt;strong>task&lt;/strong> che siano parallelizzabili e di utilizzare poi degli strumenti appositi per la loro parallelizzazione automatica.&lt;/p>
&lt;h3 id="461-gruppi-di-thread">4.6.1 Gruppi di thread&lt;/h3>
&lt;p>La creazione continuativa di molteplici thread per risolvere task può presentare due criticità. Innanzitutto, creare ed eliminare molti thread genera un overhead nel sistema. Inoltre il numero di thread potrebbe esplodere e deteriorare le performance generali del sistema.&lt;/p>
&lt;p>Per ovviare al problema è possibile stabilire un &lt;strong>gruppo di thread (thread pool)&lt;/strong>, ovvero una struttura dati che viene inizializzata solamente una volta e che contiene un numero fisso di thread. L&amp;rsquo;esecuzione avviene nel seguente modo:&lt;/p>
&lt;ol>
&lt;li>Un task che dev&amp;rsquo;essere eseguito viene sottomesso alla thread pool&lt;/li>
&lt;li>Un thread disponibile viene &amp;ldquo;svegliato&amp;rdquo; e assegnato all&amp;rsquo;esecuzione del task. Se non ci sono thread disponibili, il task viene accodato&lt;/li>
&lt;li>Il thread esegue il task e, una volta completato, ritorna nella pool in attesa di nuovi task da eseguire&lt;/li>
&lt;/ol>
&lt;p>I thread pool offrono alcuni vantaggi:&lt;/p>
&lt;ul>
&lt;li>Non c&amp;rsquo;è overhead dovuto alla continua creazione di thread.&lt;/li>
&lt;li>Il numero di thread disponibili è limitato e quindi controllato.&lt;/li>
&lt;li>È possibile pianificare l&amp;rsquo;esecuzione del task dopo un periodo di tempo o periodicamente.&lt;/li>
&lt;/ul>
&lt;p>Il numero di thread può essere impostato euristicamente, basandosi sul numero di core, sulla memoria disponibile, etc. Alcune thread pool più sofisticate possono regolare dinamicamente il numero massimo di thread.&lt;/p>
&lt;h3 id="462-fork-join">4.6.2 Fork join&lt;/h3>
&lt;p>Con questa strategia un processo genitore genera vari processi figli, attende la terminazione della loro esecuzione e poi combina i risultati parziali per ottenere il risultato finale. Questo modello è implementato nella librera Java di thread, ma è un ottimo candidato per fare implicit threading. Una libreria gestisce il numero di thread da creare ed è responsabile di assegnare i task ai thread. È simile al modello thread pool, ma è sincrono. È possibile sviluppare anche un approccio &lt;em>divide et impera&lt;/em>, in cui i task vengono spezzettati in ulteriori sottotask.&lt;/p>
&lt;h3 id="463-openmp">4.6.3 OpenMP&lt;/h3>
&lt;p>OpenMP è un&amp;rsquo;API in C/C++ per programmazione parallela in ambienti a memoria condivisa. Lo sviluppatore può identificare &lt;strong>regioni parallele&lt;/strong>, ovvero blocchi di codice che possono eseguire in parallelo, con direttive del compilatore che effettivamente parallelizzano il codice.&lt;/p>
&lt;h2 id="47-problemi-di-programmazione-multithreading">4.7 Problemi di programmazione multithreading&lt;/h2>
&lt;p>In questa sezione discutiamo di alcuni problemi che possono sorgere con i programmi multithreaded.&lt;/p>
&lt;h3 id="471-fork-e-exec">4.7.1 &lt;code>fork()&lt;/code> e &lt;code>exec()&lt;/code>&lt;/h3>
&lt;p>Se un thread chiama &lt;code>fork()&lt;/code>, il nuovo processo deve duplicare tutti i thread o solo uno? Alcuni sistemi UNIX utilizzano due versioni di &lt;code>fork()&lt;/code>.
Se &lt;code>exec()&lt;/code> è chiamata immediatamente dopo una fork, la duplicazione di tutti i thread non è necessaria poiché il programma in esecuzione sarà rimpiazzato con quello specificato da &lt;code>exec()&lt;/code>. In caso contrario, i thread saranno duplicati.&lt;/p>
&lt;h3 id="472-gestione-dei-segnali">4.7.2 Gestione dei segnali&lt;/h3>
&lt;p>I &lt;strong>segnali&lt;/strong> sono utilizzati in UNIX per notificare un processo che un particolare evento è accaduto. Il pattern è il seguente:&lt;/p>
&lt;ol>
&lt;li>Un segnale viene generato quando un evento accade&lt;/li>
&lt;li>Un segnale viene mandato al processo&lt;/li>
&lt;li>Una volta inviato, il segnale viene gestito&lt;/li>
&lt;/ol>
&lt;p>Esempi di segnali sincroni includono accessi illegali alla memoria e divisioni per 0. Vengono mandati allo stesso processo che ha fatto l&amp;rsquo;operazione che ha generato il segnale.&lt;/p>
&lt;p>Esempi di segnali asincroni includono la terminazione del processo con una specifica combinazione di tasti (p.e. &lt;code>ctrl + C&lt;/code> in UNIX) o per tempo limite raggiunto. Sono generati da eventi esterni al processo.&lt;/p>
&lt;p>Ogni segnale è associato a un &lt;strong>gestore del segnale di default&lt;/strong> che il kernel esegue quando gestisce il segnale. L&amp;rsquo;azione di default può essere sovrascritta con un &lt;strong>gestore del segnale definito dall&amp;rsquo;utente&lt;/strong>. Il segnale può essere gestito ignorandolo ma anche terminando il processo.&lt;/p>
&lt;p>Gestire segnali in programmi single-thread è semplice, mentre le cose si complicano con programmi multithreaded. Quando un segnale viene inviato a un thread, abbiamo quattro possibilità per la sua gestione:&lt;/p>
&lt;ol>
&lt;li>Inviare il segnale solamente al thread al quale il segnale si applica&lt;/li>
&lt;li>Inviare il segnale a ogni thread del processo&lt;/li>
&lt;li>Inviare il segnale a certi thread del processo&lt;/li>
&lt;li>Assegnare un thread specifico per ricevere tutti i segnali del processo&lt;/li>
&lt;/ol>
&lt;p>Nel caso di segnali sincroni, il segnale viene inviato solo al thread che lo ha generato. Il comportamento con i segnali asincroni è, invece, variabile. Per esempio nel caso di una terminazione con combinazione di tasti, il segnale va inviato a tutti i thread.&lt;/p>
&lt;p>In UNIX, i thread possono bloccare alcuni segnali. Quando viene emesso un segnale asincrono, questo può venire inviato a tutti i thread che non lo bloccano, tuttavia, siccome i segnali devono essere gestiti solo una volta, viene inviato solo al primo thread che non lo blocca.&lt;/p>
&lt;h3 id="473-cancellazione-dei-thread">4.7.3 Cancellazione dei thread&lt;/h3>
&lt;p>La &lt;strong>cancellazione dei thread&lt;/strong> consiste nel terminare un thread prima che questo abbia completato l&amp;rsquo;esecuzione (p.e. più thread concorrono alla ricerca di un file, uno lo trova e gli altri vengono cancellati).&lt;/p>
&lt;p>Un thread che va cancellato è spesso chiamato &lt;strong>thread target&lt;/strong>. La cancellazione avviene in due modi possibili:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Cancellazione asincrona:&lt;/strong> un thread termina immediatamente un altro thread&lt;/li>
&lt;li>&lt;strong>Cancellazione posticipata:&lt;/strong> un thread controlla periodicamente se deve terminare, permettendogli di terminare in maniera ordinata.&lt;/li>
&lt;/ol>
&lt;p>La difficoltà nella cancellazione avviene con la gestione delle risorse allocate al thread terminato, che vengono solo parzialmente liberate, specialmente nel caso di cancellazione asincrona. Un altro caso critico è la terminazione di un thread nel mezzo di un update della memoria condivisa. Con la cancellazione posticipata la terminazione è più facile da gestire e può essere effettuata in contesti di codice sicuri.&lt;/p>
&lt;p>In Pthreads, un thread può impostare il suo stato di cancellazione con tre opzioni:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Off:&lt;/strong> il thread non può essere cancellato&lt;/li>
&lt;li>&lt;strong>Deferred:&lt;/strong> cancellazione posticipata (default)&lt;/li>
&lt;li>&lt;strong>Asynchronous:&lt;/strong> cancellazione asincrona&lt;/li>
&lt;/ul>
&lt;p>In aggiunta Pthreads, ha una funzione di &lt;strong>gestione pulizia&lt;/strong> da invocare prima che un thread venga cancellato per rilasciare tutte le sue risorse. In Linux, la cancellazione è gestita attraverso segnali.&lt;/p>
&lt;h3 id="474-dati-locali-dei-thread">4.7.4 Dati locali dei thread&lt;/h3>
&lt;p>A volte può capitare che un thread necessiti di una copia privata di certi dati, chiamati &lt;strong>dati specifici dei thread (o TLS)&lt;/strong> (p.e. l&amp;rsquo;ID di una particolare richiesta che il thread sta svolgendo).&lt;/p>
&lt;p>La differenza tra variabili locali e TLS è che le prime risiedono nello scope di una funzione mentre i secondi sono visibili attraverso tutte le chiamate.&lt;/p>
&lt;h3 id="475-attivazione-dello-scheduler">4.7.5 Attivazione dello scheduler&lt;/h3>
&lt;p>Nel modello molti a molti, viene spesso implementato una sorta di scheduler per l&amp;rsquo;assegnazione dei thread utente ai thread kernel. Questa struttura intermedia viene chiamata &lt;strong>processo leggero (LWP)&lt;/strong> ed è una sorta di processore virtuale a cui l&amp;rsquo;applicazione può chiedere lo scheduling di un thread. Ciascun LWP è associato a un thread kernel e se quest&amp;rsquo;ultimo si blocca anche il relativo LWP si blocca con il conseguente blocco dei thread utente a esso associati.&lt;/p>
&lt;p>Il tipo di applicazione determina il numero di LWP necessari. Un processo CPU bound con esecuzione su singolo processore avrà bisogno di un solo LWP, mentre un processo I/O bound avrà bisogno di molteplici LWP.&lt;/p>
&lt;p>Uno dei modelli di comunicazione si chiama &lt;strong>attivazione dello scheduler&lt;/strong> che si basa sul meccanismo di &lt;strong>upcall&lt;/strong>. Il kernel usa le upcall per informare l&amp;rsquo;applicazione se si verificano determinati eventi come l&amp;rsquo;imminente blocco di un thread utente. Il procedimento è il seguente:&lt;/p>
&lt;ul>
&lt;li>Un thread sta per bloccarsi&lt;/li>
&lt;li>Il kernel invia un&amp;rsquo;upcall all&amp;rsquo;applicazione e identifica il thread bloccante&lt;/li>
&lt;li>Il kernel assegna all&amp;rsquo;applicazione un nuovo LWP&lt;/li>
&lt;li>L&amp;rsquo;applicazione esegue un gestore della upcall sul nuovo LWP&lt;/li>
&lt;li>Il gestore salva lo stato del thread bloccante e rilascia il processore virtuale su cui era stato eseguito&lt;/li>
&lt;li>Il gestore pianifica l&amp;rsquo;esecuzione di un altro thread sul nuovo LWP&lt;/li>
&lt;/ul>
&lt;p>Quando si verifica l&amp;rsquo;evento bloccante:&lt;/p>
&lt;ul>
&lt;li>Il kernel fa un&amp;rsquo;altra upcall alla libreria per comunicare che il thread bloccato è nuovamente attivo&lt;/li>
&lt;li>L&amp;rsquo;applicazione esegue un gestore della upcall&lt;/li>
&lt;li>Il kernel può decidere di creare un nuovo LWP per questa upcall o di sottrarne uno a un thread utente per prelazione&lt;/li>
&lt;li>L&amp;rsquo;applicazione contrassegna il thread precedentemente bloccato come pronto per l&amp;rsquo;esecuzione ed esegue l&amp;rsquo;algoritmo di scheduling&lt;/li>
&lt;/ul>
&lt;div id="references-1784797996860193725" class="references" style="margin-top: 5rem;">
&lt;p>&lt;strong>References&lt;/strong>&lt;/p>
A. Silberschatz, P. B. Galvin, G. Gagne, &lt;em>Sistemi Operativi. Concetti ed Esempi&lt;/em>, Decima edizione. Ch. 4.
&lt;/div>
&lt;script>
document.addEventListener('DOMContentLoaded', function () {
var ref = document.getElementById('references-1784797996860193725');
var fn = document.querySelector('.footnotes');
if (ref &amp;&amp; fn &amp;&amp; fn.parentNode) {
fn.parentNode.insertBefore(ref, fn.nextSibling);
}
});
&lt;/script></description></item><item><title>Thread e concorrenza</title><link>https://aretor.it/courses/os2324/04_thread/</link><pubDate>Mon, 18 Mar 2024 00:00:00 +0000</pubDate><guid>https://aretor.it/courses/os2324/04_thread/</guid><description>&lt;p>In questa lezione andremo a parlare di come parallelizzare i processi per permettere un&amp;rsquo;esecuzione più efficiente dei task a esso associati. Discuteremo delle criticità principali nello sviluppo di codice parallelizzabile e introdurremo alcune API di programmazione parallela.&lt;/p>
&lt;h2 id="introduzione">Introduzione&lt;/h2>
&lt;p>Un thread è l&amp;rsquo;unità base d&amp;rsquo;uso della CPU e comprende:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Identificatore (ID)&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Contatore di programma&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Insieme di registri&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Stack&lt;/strong>&lt;/li>
&lt;/ul>
&lt;p>Condivide con gli altri thread:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Sezione di codice&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Sezione dati&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Risorse di sistema&lt;/strong>&lt;/li>
&lt;/ul>
&lt;p>Un processo tradizionale, viene chiamato &lt;strong>processo pesante (heavyweight process)&lt;/strong> ed è composto da un solo thread. Un &lt;strong>processo multithread&lt;/strong> svolge più compiti parallelamente.&lt;/p>
&lt;h3 id="motivazioni">Motivazioni&lt;/h3>
&lt;p>Di seguito forniamo alcuni esempi di applicazioni multithread:&lt;/p>
&lt;ul>
&lt;li>Un&amp;rsquo;applicazione che crea processa i file in una cartella separatamente, può utilizzare i thread per processare più file contemporaneamente.&lt;/li>
&lt;li>Un browser può utilizzare un thread per rappresentare immagini e testo e un altro thread per scaricare i dati dalla rete&lt;/li>
&lt;li>Un word processor può avere un thread per la rappresentazione del testo e uno per ricevere l&amp;rsquo;input da tastiera&lt;/li>
&lt;li>Un server che deve gestire le interazioni di molti client. Invece di istanziare lo stesso programma per ogni cliente, creando un overhead, il server potrebbe creare un singolo processo che a ogni nuova connessione crea un thread per gestirla parallelamente.&lt;/li>
&lt;/ul>
&lt;p>La maggior parte dei kernel dei s.o. è multithread, con ogni thread preposto all&amp;rsquo;esecuzione di un&amp;rsquo;attività specifica (p.e. gestione dei dispositivi, memoria, interruzioni, etc.).&lt;/p>
&lt;p>Il multithreading può essere applicato anche su algoritmi per l&amp;rsquo;ordinamento, gli alberi o i grafi, oppure su problemi più specializzati di data mining, grafica o intelligenza artificiale 🤖&lt;/p>
&lt;h3 id="vantaggi">Vantaggi&lt;/h3>
&lt;p>I vantaggi principali del multithreading sono:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Tempo di risposta:&lt;/strong> il multithreading rende un&amp;rsquo;applicazione interattiva, anche se parte di essa è bloccata nell&amp;rsquo;esecuzione di alcuni task. Questa caratteristica è utile soprattutto nella programmazione di GUI.&lt;/li>
&lt;li>&lt;strong>Condivisione delle risorse:&lt;/strong> i processi possono condividere risorse solo attraverso IPC. I thread tuttavia condividono la memoria e le risorse di default.&lt;/li>
&lt;li>&lt;strong>Economia:&lt;/strong> assegnare memoria e risorse per la creazione di nuovi processi e eseguire context switch è costoso. Con i thread il costo viene abbattuto.&lt;/li>
&lt;li>&lt;strong>Scalabilità:&lt;/strong> I vantaggi della programmazione multithread sono ancora maggiori nelle architetture multiprocessore dove i thread possono essere eseguiti su diversi core di elaborazione.&lt;/li>
&lt;/ul>
&lt;h2 id="programmazione-multicore">Programmazione multicore&lt;/h2>
&lt;p>Il multithreading è stato sviluppato appositamente per sfruttare al meglio i sistemi multicore. Tuttavia, è possibile utilizzare il multithreading anche su sistemi a singola CPU, semplicemente i thread eseguono in maniera sequenziale. In questo senso possiamo distinguere due caratteristiche di un sistema:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Concorrenza (concurrency):&lt;/strong> il sistema supporta più di un compito, portando avanti tutti i compiti insieme (p.e. alternandoli o parallelizzandoli).&lt;/li>
&lt;li>&lt;strong>Parallelismo (parallelism):&lt;/strong> il sistema supporta l&amp;rsquo;esecuzione parallela di più task simultaneamente.&lt;/li>
&lt;/ul>
&lt;p>Di conseguenza un sistema parallelo è sempre concorrente, ma non vale necessariamente il contrario&lt;/p>
&lt;h2 id="sfide-nella-programmazione">Sfide nella programmazione&lt;/h2>
&lt;p>In generale, quando si programmazione parallela, abbiamo cinque sfide:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Identificare i task:&lt;/strong> esaminare l&amp;rsquo;applicazione per comprendere quali parti del codice sono parallelizzabili&lt;/li>
&lt;li>&lt;strong>Bilanciamento:&lt;/strong> garantire che il compito principale sia equamente diviso tra i task&lt;/li>
&lt;li>&lt;strong>Suddivisione dei dati:&lt;/strong> similmente ai task, anche i dati devono essere equamente divisi tra i task&lt;/li>
&lt;li>&lt;strong>Dipendenze dei dati:&lt;/strong> nel caso dei dati siano condivisi tra più task, bisogna garantire una sincronizzazione nella loro manipolazione&lt;/li>
&lt;li>&lt;strong>Test e debugging:&lt;/strong> un algoritmo parallelo presenta molteplici sequenze di esecuzione, per tale motivo è richiesta particolare attenzione e cura nel testing e nel debugging&lt;/li>
&lt;/ul>
&lt;h2 id="modello-multithreading">Modello multithreading&lt;/h2>
&lt;p>Il supporto ai thread può essere fornito a livello di &lt;strong>utente&lt;/strong> o di &lt;strong>kernel&lt;/strong>. Praticamente tutti i sistemi operativi moderni supportano i thread a livello di kernel. Ci possono essere tre relazioni tra thread utente e kernel.&lt;/p>
&lt;h3 id="modello-da-molti-a-uno">Modello da molti a uno&lt;/h3>
&lt;p>Questo modello mappa molteplici thread utente a un singolo thread kernel. La gestione dei thread viene effettuata a livello della libreria ed è efficiente. Non è possibile, tuttavia, eseguire i thread utente in parallelo poiché, ogni volta che un thread utente fa una chiamata di sistema bloccante, gli altri devono aspettare la sua esecuzione.&lt;/p>
&lt;h3 id="modello-da-uno-a-uno">Modello da uno a uno&lt;/h3>
&lt;p>Questo modello mappa un thread utente a un singolo thread kernel. Questo design permette più concorrenza del modello precedente dal momento che quando un thread fa una chiamata di sistema bloccante, gli altri thread possono comunque eseguire su thread kernel separati. È anche possibile eseguire i thread in parallelo. C&amp;rsquo;è tuttavia un overhead nella creazione del thread kernel e questo può peggiorare le performance quando devono essere creati molti thread utente. Per tale motivo alcuni s.o. limitano il numero massimo di thread kernel al numero di core del calcolatore.&lt;/p>
&lt;p>È un modello comunemente usato nei s.o. Linux e Windows.&lt;/p>
&lt;h3 id="modello-da-molti-a-molti">Modello da molti a molti&lt;/h3>
&lt;p>Questo modello mappa molteplici thread utente a molteplici thread kernel. In generale, i thread kernel sono in numero minore dei thread utente per garantire efficienza. Quando un thread utente fa un operazione bloccante, il s.o. può creare un nuovo thread kernel per far proseguire l&amp;rsquo;esecuzione.&lt;/p>
&lt;p>Una variante di questo modello è il modello &lt;strong>a due livelli&lt;/strong>, che permette anche la creazione di singole coppie utente-kernel, come nel modello uno-a-uno.&lt;/p>
&lt;h2 id="librerie-dei-thread">Librerie dei thread&lt;/h2>
&lt;p>Una &lt;strong>libreria dei thread&lt;/strong> fornisce un API per creare e gestire thread. Ci sono due approcci nella sua implementazione.&lt;/p>
&lt;p>Il primo consiste nel fornire una libreria interamente a livello utente, senza supporto del kernel. Il secondo consiste nel implementare una libreria che si interfaccia col kernel, con il codice, i dati e le strutture dati nello spazio del kernel.&lt;/p>
&lt;p>Le tre librerie principali di thread sono POSIX Pthreads (ilvello utente e sistema), Windows (sistema) e Java (utente ma tipicamente implementata con thread library a livello di sistema). Per le librerie POSIX e Windows ogni dato dichiarato a livello globale è a disposizione di tutti i thread.&lt;/p>
&lt;h3 id="threading-sincronoasincrono">Threading sincrono/asincrono&lt;/h3>
&lt;p>Secondo questa categorizzazione esistono due tipologie di threading:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Sincrono:&lt;/strong> quando un processo genitore genera uno o più thread figli, deve aspettare il termine della loro esecuzione prima di riprendere a eseguire.&lt;/li>
&lt;li>&lt;strong>Asincrono:&lt;/strong> quando un processo genitore genera un thread figlio, il genitore continua con l&amp;rsquo;esecuzione, in maniera indipendente. È una tecnica comunemente utilizzata per il design di interfacce responsive.&lt;/li>
&lt;/ul>
&lt;p>Ci concentreremo sul thread sincrono, che è più interessante e complesso da gestire.&lt;/p>
&lt;h3 id="pthreads">Pthreads&lt;/h3>
&lt;p>Qui sotto riportiamo un esempio (Fig. 4.13 del Silberschatz) di creazione di un singolo thread per l&amp;rsquo;esecuzione di una funzione &lt;code>runner&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="cp">#include&lt;/span> &lt;span class="cpf">&amp;lt;pthread.h&amp;gt;&lt;/span>&lt;span class="cp">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cp">#include&lt;/span> &lt;span class="cpf">&amp;lt;stdio.h&amp;gt;&lt;/span>&lt;span class="cp">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cp">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cp">#include&lt;/span> &lt;span class="cpf">&amp;lt;stdlib.h&amp;gt;&lt;/span>&lt;span class="cp">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cp">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kt">int&lt;/span> &lt;span class="n">sum&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="cm">/* this data is shared by the thread(s) */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kt">void&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nf">runner&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">void&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="n">param&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="cm">/* threads call this function */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kt">int&lt;/span> &lt;span class="nf">main&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">int&lt;/span> &lt;span class="n">argc&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">char&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="n">argv&lt;/span>&lt;span class="p">[])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kt">pthread_t&lt;/span> &lt;span class="n">tid&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="cm">/* the thread identifier */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kt">pthread_attr_t&lt;/span> &lt;span class="n">attr&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="cm">/* set of thread attributes */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* set the default attributes of the thread */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">pthread_attr_init&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="n">attr&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* create the thread */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">pthread_create&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="n">tid&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="n">attr&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">runner&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">argv&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">]);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* wait for the thread to exit */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">pthread_join&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tid&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="nb">NULL&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">printf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;sum = %d∖n&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="n">sum&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cm">/* The thread will execute in this function */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kt">void&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nf">runner&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">void&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="n">param&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kt">int&lt;/span> &lt;span class="n">i&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">upper&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nf">atoi&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">param&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">sum&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">i&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="n">i&lt;/span> &lt;span class="o">&amp;lt;=&lt;/span> &lt;span class="n">upper&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="n">i&lt;/span>&lt;span class="o">++&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">sum&lt;/span> &lt;span class="o">+=&lt;/span> &lt;span class="n">i&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">pthread_exit&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;ul>
&lt;li>&lt;code>pthread_t&lt;/code> e &lt;code>pthread_attr_t&lt;/code> sono i tipi per la creazione del thread e dei suoi attributi&lt;/li>
&lt;li>&lt;code>pthread_attr_init&lt;/code> si occupa dell&amp;rsquo;inizializzazione degli attributi del thread (in questo caso lasciati ai valori di default)&lt;/li>
&lt;li>&lt;code>pthread_create&lt;/code> crea il thread, gli assegna gli attributi, il codice da eseguire (&lt;code>runner&lt;/code>) e gli argmoenti della funzione (&lt;code>argv[1]&lt;/code>)&lt;/li>
&lt;li>&lt;code>ptrhead_join&lt;/code> permette al processo genitore di attendere l&amp;rsquo;esecuzione del thread figlio&lt;/li>
&lt;/ul>
&lt;p>Notate che &lt;code>sum&lt;/code> è dichiarata a livello globale, e non è quindi necessario passarla al thread figlio. Per fare la join di diversi processi figlio il seguente snippet (Fig. 4.13 del Silberschatz) illustra come fare.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="cp">#define NUM THREADS 10
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cp">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cm">/* an array of threads to be joined upon */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">pthread&lt;/span> &lt;span class="n">t&lt;/span> &lt;span class="n">workers&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">NUM&lt;/span> &lt;span class="n">THREADS&lt;/span>&lt;span class="p">];&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kt">int&lt;/span> &lt;span class="n">i&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="n">i&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="n">NUM&lt;/span> &lt;span class="n">THREADS&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="n">i&lt;/span>&lt;span class="o">++&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">pthread_join&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">workers&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="nb">NULL&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="threading-implicito">Threading implicito&lt;/h2>
&lt;p>La crescita continua dei processori multicore, rende inevitabilmente più complesso lo sviluppo di codice parallelo e più difficile la verifica della correttezza di un programma, in quanto un codice parallelo può presentare molteplici path di esecuzione.&lt;/p>
&lt;p>Una soluzione al problema consiste nell&amp;rsquo;&lt;strong>threading implicito&lt;/strong>, ovvero il trasferimento della logica di threading e parallelismo dal lato sviluppatore a quello del compilatore e delle librerie run-time. Alloo sviluppatore, in questo caso, è richiesto di definire dei &lt;strong>task&lt;/strong> che siano parallelizzabili e di utilizzare poi degli strumenti appositi per la loro parallelizzazione automatica.&lt;/p>
&lt;h3 id="gruppi-di-thread">Gruppi di thread&lt;/h3>
&lt;p>La creazione continuativa di molteplici thread per risolvere task può presentare due criticità. Innanzitutto, creare ed eliminare molti thread genera un overhead nel sistema. Inoltre il numero di thread potrebbe esplodere e deteriorare le performance generali del sistema.&lt;/p>
&lt;p>Per ovviare al problema è possibile stabilire un &lt;strong>gruppo di thread (thread pool)&lt;/strong>, ovvero una struttura dati che viene inizializzata solamente una volta e che contiene un numero fisso di thread. L&amp;rsquo;esecuzione avviene nel seguente modo:&lt;/p>
&lt;ol>
&lt;li>Un task che dev&amp;rsquo;essere eseguito viene sottomesso alla thread pool&lt;/li>
&lt;li>Un thread disponibile viene &amp;ldquo;svegliato&amp;rdquo; e assegnato all&amp;rsquo;esecuzione del task. Se non ci sono thread disponibili, il task viene accodato&lt;/li>
&lt;li>Il thread esegue il task e, una volta completato, ritorna nella pool in attesa di nuovi task da eseguire&lt;/li>
&lt;/ol>
&lt;p>I thread pool offrono i seguenti vantaggi:&lt;/p>
&lt;ul>
&lt;li>Non c&amp;rsquo;è overhead di creazione di thread&lt;/li>
&lt;li>Il numero di thread disponibili è limitato e quindi controllato&lt;/li>
&lt;li>È possibile pianificare l&amp;rsquo;esecuzione del task dopo un periodo di tempo o periodicamente&lt;/li>
&lt;/ul>
&lt;p>Il numero di thread può essere impostato euristicamente, basandosi sul numero di core, sulla memoria disponibile, etc. Alcune thread pool più sofisticate possono regolare dinamicamente il numero massimo di thread.&lt;/p>
&lt;h3 id="fork-join">Fork join&lt;/h3>
&lt;p>Con questa strategia un processo genitore genera vari processi figli, attende la terminazione della loro esecuzione e poi combina i risultati parziali per ottenere il risultato finale. Questo modello è implementato nella librera Java di thread, ma è un ottimo candidato per fare implicit threading. Una libreria gestisce il numero di thread da creare ed è responsabile di assegnare i task ai thread. È simile al modello thread pool, ma è sincrono. È possibile sviluppare anche un approccio &lt;em>divide et impera&lt;/em>, in cui i task vengono spezzettati in ulteriori sottotask.&lt;/p>
&lt;h3 id="openmp">OpenMP&lt;/h3>
&lt;p>OpenMP è un&amp;rsquo;API in C/C++ per programmazione parallela in ambienti a memoria condivisa. Lo sviluppatore può identificare &lt;strong>regioni parallele&lt;/strong>, ovvero blocchi di codice che possono eseguire in parallelo, con direttive del compilatore che effettivamente parallelizzano il codice.&lt;/p>
&lt;h2 id="problemi-di-programmazione-multithreading">Problemi di programmazione multithreading&lt;/h2>
&lt;p>In questa sezione discutiamo di alcuni problemi che possono sorgere con i programmi multithreaded.&lt;/p>
&lt;h3 id="fork-e-exec">fork() e exec()&lt;/h3>
&lt;p>Se un thread chiama &lt;code>fork()&lt;/code>, deve il nuovo processo duplicare tutti i thread o solo uno? Alcuni sistemi UNIX utilizzano due versioni di &lt;code>fork()&lt;/code>.
Se &lt;code>exec()&lt;/code> è chiamata immediatamente dopo una fork, la duplicazione di tutti i thread non è necessaria poiché il programma in esecuzione sarà rimpiazzato con quello specificato da &lt;code>exec()&lt;/code>. In caso contrario, i thread saranno duplicati.&lt;/p>
&lt;h3 id="gestione-dei-segnali">Gestione dei segnali&lt;/h3>
&lt;p>I &lt;strong>segnali&lt;/strong> sono utilizzati in UNIX per notificare un processo che un particolare evento è accaduto. Il pattern è il seguente:&lt;/p>
&lt;ol>
&lt;li>Un segnale viene generato quando un evento accade&lt;/li>
&lt;li>Un segnale viene mandato al processo&lt;/li>
&lt;li>Una volta inviato, il segnale viene gestito&lt;/li>
&lt;/ol>
&lt;p>Esempi di segnali sincroni includono accessi illegali alla memoria e divisioni per 0. Vengono mandati allo stesso processo che ha fatto l&amp;rsquo;operazione che ha generato il segnale.&lt;/p>
&lt;p>Esempi di segnali asincroni includono la terminazione del processo con una specifica combinazione di tasti (p.e. &lt;code>ctrl + C&lt;/code> in UNIX) o per tempo limite raggiunto. Sono generati da eventi esterni al processo.&lt;/p>
&lt;p>Ogni segnale è associato a un &lt;strong>gestore del segnale di default&lt;/strong> che il kernel esegue quando gestisce il segnale. L&amp;rsquo;azione di default può essere sovrascritta con un &lt;strong>gestore del segnale definito dall&amp;rsquo;utente&lt;/strong>. Il segnale può essere gestito ignorandolo ma anche terminando il processo.&lt;/p>
&lt;p>Gestire segnali in programmi single-thread è semplice, mentre le cose si complicano con programmi multithreaded. Quando un segnale viene inviato a un thread, abbiamo quattro possibilità per la sua gestione:&lt;/p>
&lt;ol>
&lt;li>Inviare il segnale solamente al thread al quale il segnale si applica&lt;/li>
&lt;li>Inviare il segnale a ogni thread del processo&lt;/li>
&lt;li>Inviare il segnale a certi thread del processo&lt;/li>
&lt;li>Assegnare un thread specifico per ricevere tutti i segnali del processo&lt;/li>
&lt;/ol>
&lt;p>Nel caso di segnali sincroni, il segnale viene inviato solo al thread che lo ha generato. Il comportamento con i segnali asincroni è, invece, variabile. Per esempio nel caso di una terminazione con combinazione di tasti, il segnale va inviato a tutti i thread.&lt;/p>
&lt;p>In UNIX, i thread possono bloccare alcuni segnali. Quando viene emesso un segnale asincrono, questo può venire inviato a tutti i thread che non lo bloccano, tuttavia, siccome i segnali devono essere gestiti solo una volta, viene inviato solo al primo thread che non lo blocca.&lt;/p>
&lt;h3 id="cancellazione-dei-thread">Cancellazione dei thread&lt;/h3>
&lt;p>La &lt;strong>cancellazione dei thread&lt;/strong> consiste nel terminare un thread prima che questo abbia completato l&amp;rsquo;esecuzione (p.e. più thread concorrono alla ricerca di un file, uno lo trova e gli altri vengono cancellati).&lt;/p>
&lt;p>Un thread che va cancellato è spesso chiamato &lt;strong>thread target&lt;/strong>. La cancellazione avviene in due modi possibili:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Cancellazione asincrona:&lt;/strong> un thread termina immediatamente un altro thread&lt;/li>
&lt;li>&lt;strong>Cancellazione posticipata:&lt;/strong> un thread controlla periodicamente se deve terminare, permettendogli di terminare in maniera ordinata.&lt;/li>
&lt;/ol>
&lt;p>La difficoltà nella cancellazione avviene con la gestione delle risorse allocate al thread terminato, che vengono solo parzialmente liberate, specialmente nel caso di cancellazione asincrona. Un altro caso critico è la terminazione di un thread nel mezzo di un update della memoria condivisa. Con la cancellazione posticipata la terminazione è più facile da gestire e può essere effettuata in contesti di codice sicuri.&lt;/p>
&lt;p>In Pthreads, un thread può impostare il suo stato di cancellazione con tre opzioni:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Off:&lt;/strong> il thread non può essere cancellato&lt;/li>
&lt;li>&lt;strong>Deferred:&lt;/strong> cancellazione posticipata (default)&lt;/li>
&lt;li>&lt;strong>Asynchronous:&lt;/strong> cancellazione asincrona&lt;/li>
&lt;/ul>
&lt;p>In aggiunta Pthreads, ha una funzione di &lt;strong>gestione pulizia&lt;/strong> da invocare prima che un thread venga cancellato per rilasciare tutte le sue risorse. In Linux, la cancellazione è gestita attraverso segnali.&lt;/p>
&lt;h3 id="dati-locali-dei-thread">Dati locali dei thread&lt;/h3>
&lt;p>A volte può capitare che un thread necessiti di una copia privata di certi dati, chiamati &lt;strong>dati specifici dei thread (o TLS)&lt;/strong> (p.e. l&amp;rsquo;ID di una particolare richiesta che il thread sta svolgendo).&lt;/p>
&lt;p>La differenza tra variabili locali e TLS è che le prime risiedono nello scope di una funzione mentre i secondi sono visibili attraverso tutte le chiamate.&lt;/p>
&lt;h3 id="attivazione-dello-scheduler">Attivazione dello scheduler&lt;/h3>
&lt;p>Nel modello molti a molti, viene spesso implementato una sorta di scheduler per l&amp;rsquo;assegnazione dei thread utente ai thread kernel. Questa struttura intermedia viene chiamata &lt;strong>processo leggero (LWP)&lt;/strong> ed è una sorta di processore virtuale a cui l&amp;rsquo;applicazione può chiedere lo scheduling di un thread. Ciascun LWP è associato a un thread kernel e se quest&amp;rsquo;ultimo si blocca anche il relativo LWP si blocca con il conseguente blocco dei thread utente a esso associati.&lt;/p>
&lt;p>Il tipo di applicazione determina il numero di LWP necessari. Un processo CPU bound con esecuzione su singolo processore avrà bisogno di un solo LWP, mentre un processo I/O bound avrà bisogno di molteplici LWP.&lt;/p>
&lt;p>Uno dei modelli comunicazione è conosciuto come &lt;strong>attivazione dello scheduler&lt;/strong> che si basa sul meccanismo di &lt;strong>upcall&lt;/strong>. Il kernel usa le upcall per informare l&amp;rsquo;applicazione se si verificano determinati eventi come l&amp;rsquo;imminente blocco di un thread utente. Il procedimento è il seguente:&lt;/p>
&lt;ul>
&lt;li>Un thread sta per bloccarsi&lt;/li>
&lt;li>Il kernel invia un&amp;rsquo;upcall all&amp;rsquo;applicazione e identifica il thread bloccante&lt;/li>
&lt;li>Il kernel assegna all&amp;rsquo;applicazione un nuovo LWP&lt;/li>
&lt;li>L&amp;rsquo;applicazione esegue un gestore della upcall sul nuovo LWP&lt;/li>
&lt;li>Il gestore salva lo stato del thread bloccante e rilascia il processore virtuale su cui era stato eseguito&lt;/li>
&lt;li>Il gestore pianifica l&amp;rsquo;esecuzione di un altro thread sul nuovo LWP&lt;/li>
&lt;/ul>
&lt;p>Quando si verifica l&amp;rsquo;evento bloccante:&lt;/p>
&lt;ul>
&lt;li>Il kernel fa un&amp;rsquo;altra upcall alla libreria per comunicare che il thread bloccato è nuovamente attivo&lt;/li>
&lt;li>L&amp;rsquo;applicazione esegue un gestore della upcall&lt;/li>
&lt;li>Il kernel può decidere di creare un nuovo LWP per questa upcall o di sottrarne uno a un thread utente per prelazione&lt;/li>
&lt;li>L&amp;rsquo;applicazione contrassegna il thread precedentemente bloccato come pronto per l&amp;rsquo;esecuzione ed esegue l&amp;rsquo;algoritmo di scheduling&lt;/li>
&lt;/ul>
&lt;div id="references-1784797996233989822" class="references" style="margin-top: 5rem;">
&lt;p>&lt;strong>References&lt;/strong>&lt;/p>
A. Silberschatz, P. B. Galvin, G. Gagne, &lt;em>Sistemi Operativi. Concetti ed Esempi&lt;/em>, Decima edizione. Ch. 4.
&lt;/div>
&lt;script>
document.addEventListener('DOMContentLoaded', function () {
var ref = document.getElementById('references-1784797996233989822');
var fn = document.querySelector('.footnotes');
if (ref &amp;&amp; fn &amp;&amp; fn.parentNode) {
fn.parentNode.insertBefore(ref, fn.nextSibling);
}
});
&lt;/script></description></item><item><title>5 Scheduling della CPU</title><link>https://aretor.it/courses/os2425/lessons/05_scheduling/</link><pubDate>Thu, 20 Mar 2025 00:00:00 +0000</pubDate><guid>https://aretor.it/courses/os2425/lessons/05_scheduling/</guid><description>&lt;p>In questa lezione andremo a vedere alcuni degli algoritmi di scheduling dei processi nella CPU, per rendere più efficiente e fluida l&amp;rsquo;esperienza utente nell&amp;rsquo;utilizzo del calcolatore. Prima di iniziare specifichiamo che:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Scheduling dei processi&lt;/strong> e &lt;strong>scheduling dei thread&lt;/strong> sono due locuzioni spesso considerate equivalenti. Le utilizzeremo tuttavia per identificare due concetti diversi&lt;/li>
&lt;li>L&amp;rsquo;unità di base di computazione è il core, che risiede in una CPU, tuttavia utilizzeremo la locuzione generica più comune &amp;ldquo;eseguire su CPU&amp;rdquo; per intendere l&amp;rsquo;esecuzione su un suo core&lt;/li>
&lt;/ul>
&lt;h2 id="51-concetti-fondamentali">5.1 Concetti fondamentali&lt;/h2>
&lt;p>Come detto in precedenza, l&amp;rsquo;obiettivo della multiprogrammazione è di avere sempre un processo in CPU, in modo da massimizzarne il suo utilizzo. Quando ad esempio un processo è in attesa di un evento o della gestione di una richiesta I/O, la CPU carica un altro processo e lo esegue nel frattempo. La scelta del processo da eseguire in CPU è chiamato scheduling della CPU.&lt;/p>
&lt;p>Lo scheduling è una funzione fonamentale dei s.o. e quasi tutte le risorse sono sottoposte a scheduling.&lt;/p>
&lt;h3 id="511-ciclicità-delle-fasi-delaborazione-e-di-io">5.1.1 Ciclicità delle fasi d&amp;rsquo;elaborazione e di I/O&lt;/h3>
&lt;p>L&amp;rsquo;esecuzione di un processo è composta di due stati:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Ciclo d&amp;rsquo;elaborazione:&lt;/strong> svolto dalla CPU&lt;/li>
&lt;li>&lt;strong>Attesa completamento operazioni di I/O&lt;/strong>&lt;/li>
&lt;/ul>
&lt;p>Il processo alterna sequenze di elaborazioni della CPU (&lt;strong>CPU burst&lt;/strong>) con sequenze di operazioni di I/O (&lt;strong>I/O burst&lt;/strong>). L&amp;rsquo;ultima CPU burst si conclude con una richiesta di terminazione del processo. Analisi sui processi hanno evidenziato che la maggior parte dei CPU burst sono brevi. Un processo I/O bound produce CPU burst relativamente brevi, mentre un processo CPU bound ne produce di molto lunghe. Questo è un principio da tenere a mente nello sviluppo di algoritmi di scheduling.&lt;/p>
&lt;h3 id="512-scheduler-di-cpu">5.1.2 Scheduler di CPU&lt;/h3>
&lt;p>Il &lt;strong>CPU scheduler&lt;/strong> si occupa della selezione di un processo da eseguire in CPU, prendendone uno dalla coda dei processi che sono in stato ready. I record nella coda sono generalmente i process control block dei processi. La coda non è necessariamente FIFO, come andremo a vedere.&lt;/p>
&lt;h3 id="513-scheduling-prioritario-e-non-prioritario">5.1.3 Scheduling prioritario e non-prioritario&lt;/h3>
&lt;p>Le decisioni di scheduling possono essere prese in una di queste quattro situazioni:&lt;/p>
&lt;ol>
&lt;li>Un processo passa dallo stato di esecuzione a quello di waiting&lt;/li>
&lt;li>Un processo passa dallo stato di esecuzione a quello di ready&lt;/li>
&lt;li>Un processo passa dallo stato di attesa a quello di ready&lt;/li>
&lt;li>Un processo termina&lt;/li>
&lt;/ol>
&lt;p>Per i casi 1. e 4. non c&amp;rsquo;è scelta, un nuovo processo dev&amp;rsquo;essere eseguito. In questo caso si parla di &lt;strong>scheduling non-prioritario o cooperativo&lt;/strong>. Il processo tiene la CPU finché non termina o passa allo stato di waiting.&lt;/p>
&lt;p>Per i casi 2. 3. si parla invece di &lt;strong>scheduling prioritario&lt;/strong>. Virtualmente, tutti i sistemi operativi moderni possiedono questa policy, che può risultare in race condition quando i dati sono condivisi tra più processi. &lt;strong>Esempio di race condition:&lt;/strong> Un processo che sta aggiornando dei dati viene soppiantato da un altro processo più prioritario che legge i dati.&lt;/p>
&lt;p>Un problema simile può accadere col kernel quando sta eseguendo una modifica alle sue strutture dati (p.e. la coda I/O) per conto di un processo. Se questo viene soppiantato nel mezzo della modifica e il kernel ha bisogno di modificare successivamente la struttura, otteniamo un&amp;rsquo;altra race condition.&lt;/p>
&lt;p>È possibile utilizzare un kernel non prioritario, tuttavia questa scelta non è adatta per i sistemi real-time. Un kernel prioritario, d&amp;rsquo;altro canto, richiede dei meccanismi di prevenzione delle race condition, come i mutex. I sistemi operativi moderni sono tutti prioritari.&lt;/p>
&lt;!-- Il s.o. deve accettare gli interrupt quasi sempre altrimenti l'input può essere perso e l'output sovrascritto. Dal momento che gli interrupt accadono senza preavviso, le sezioni di codice relative devono essere protette dall'uso simultaneo. Per tale motivo quando un processo concorrente esegue un interrupt, questo viene disabilitato per gli altri processi e riabilitato una volta concluso. -->
&lt;h3 id="514-dispatcher">5.1.4 Dispatcher&lt;/h3>
&lt;p>Il dispatcher è il componente preposto a dare il controllo della CPU al processo in corso. I suoi compiti sono:&lt;/p>
&lt;ul>
&lt;li>Context switching&lt;/li>
&lt;li>Cambio in modalità utente&lt;/li>
&lt;li>Saltare all&amp;rsquo;istruzione giusta quando viene ripresa l&amp;rsquo;esecuzione di un vecchio processo&lt;/li>
&lt;/ul>
&lt;p>Il tempo che trascorre tra il terminare un processo ed eseguirne un altro è chiamato &lt;strong>latenza di dispatch&lt;/strong>. È possibile monitorare i context switch, che si dividono in:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Volontari:&lt;/strong> quando un processo lascia la CPU perché attende delle risorse attualmente non disponibili&lt;/li>
&lt;li>&lt;strong>Non volontari:&lt;/strong> quando la CPU viene presa e assegnata a un altro processo, per esempio per scadenza tempo massimo o per prelazione di un processo verso il corrente.&lt;/li>
&lt;/ul>
&lt;h2 id="52-criteri-di-scheduling">5.2 Criteri di scheduling&lt;/h2>
&lt;p>I criteri per lo scheduling della CPU possono essere molteplici. Tra questi abbiamo:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Utilizzo della CPU:&lt;/strong> Vogliamo tenere la CPU più occupata possibile. L&amp;rsquo;utilizzo della CPU può variare tra $0%$ a $100%$, ma nella pratica sta nel range $40-90%$. Questa misura va massimizzata&lt;/li>
&lt;li>&lt;strong>Troughput:&lt;/strong> è la misura del numero di processi completati per unità di tempo. Un alto throughput indica che molti processi di breve durata sono stati completati. Questa misura va massimizzata&lt;/li>
&lt;li>&lt;strong>Tempo di turnaround:&lt;/strong> è il tempo di esecuzione di un processo, dalla sua sottomissione alla sua terminazione, compresi i tempi di attesa. Questa misura va minimizzata&lt;/li>
&lt;li>&lt;strong>Tempo di attesa:&lt;/strong> è il tempo speso da un processo in attesa nella coda di ready. Questa misura va minimizzata&lt;/li>
&lt;li>&lt;strong>Tempo di risposta:&lt;/strong> nei sistemi interattivi, dove un processo può continuare a produrre output mentre gli output precedenti sono resi disponibili all&amp;rsquo;utente, il tempo di turaround non è indicativo delle performance. Per tale motivo si può utilizzare il tempo di risposta dalla sottomissione di una richiesta al suo completamento. Questa misura va minimizzata&lt;/li>
&lt;/ul>
&lt;p>Nei sistemi interattivi, è più importante minimizzare la varianza del tempo di risposta rispetto alla media, dal momento che è più desiderabile avere un sistema con tempi di risposta prevedibili, rispetto a uno con tempi di risposta brevi in media ma più variabili. Purtroppo non è stato fatto molto lavoro a riguardo&lt;/p>
&lt;p>La nostra misura di riferimento sarà il tempo medio di attesa.&lt;/p>
&lt;h2 id="53-algoritmi-di-scheduling">5.3 Algoritmi di scheduling&lt;/h2>
&lt;p>In questa sezione iniziamo a parlare dei principali algoritmi di scheduling. Per semplicità li descriviamo in un contesto a CPU singola.&lt;/p>
&lt;h3 id="531-scheduling-first-come-first-served">5.3.1 Scheduling first-come, first-served&lt;/h3>
&lt;p>Con lo scheduling &lt;strong>First-Come, First-Served (FCFS)&lt;/strong> il processo che ha chiesto per primo la CPU verrà sempre eseguito per primo. L&amp;rsquo;implementazione è facilmente gestita con una coda FIFO. Il processo entra in coda e quando la CPU si libera, il processo in testa viene rimosso dalla coda e messo in esecuzione.&lt;/p>
&lt;p>Lo svantaggio di FCFS è che il tempo medio di attesa è lungo e molto vario. In aggiunta, consideriamo il caso in cui abbiamo molti processi I/O bound seguiti da un processo CPU bound:&lt;/p>
&lt;ul>
&lt;li>I processi I/O bound effettuano un&amp;rsquo;operazione di I/O e liberano la CPU&lt;/li>
&lt;li>Il processo CPU bound entra in CPU&lt;/li>
&lt;li>I processi I/O bound finiscono l&amp;rsquo;operazione e vanno in coda ready attendendo che la CPU si liberi (le prestazioni degradano)&lt;/li>
&lt;li>Il processo CPU bound fa una chiamata I/O&lt;/li>
&lt;li>I processi I/O accedono di nuovo alla CPU e finiscono in fretta&lt;/li>
&lt;li>Nel frattempo il processo CPU bound va in testa alla coda e riempie di nuovo la CPU (le prestazioni degradano)&lt;/li>
&lt;/ul>
&lt;p>Questo fenomeno è chiamato &lt;strong>effetto convoglio&lt;/strong>. FCFS è particolarmente inappropriato per sistemi interattivi.&lt;/p>
&lt;h3 id="532-scheduling-shortest-job-first">5.3.2 Scheduling shortest-job-first&lt;/h3>
&lt;p>Con lo scheduling &lt;strong>Shortest-Job-First (SJF)&lt;/strong>, ogni processo viene associato alla lunghezza della prossima CPU burst. Quando la CPU è disponibile il processo più corto in questi termini viene mandato in esecuzione. Un nome più corretto sarebbe &lt;em>shortest-next-CPU-burst&lt;/em>.&lt;/p>
&lt;p>Si può dimostrare che questo algoritmo è ottimale in termini di tempo medio di attesa, tuttavia non è implementabile a livello di scheduling CPU perché non è possibile conoscere a priori il tempo di esecuzione del prossimo CPU burst. A tal proposito, possamo predire il tempo di burst basandoci su quelli precedenti del processo. Si usa tipicamente una media esponenziale&lt;/p>
$$
\tau_{n + 1} = \alpha t_n + (1 - \alpha)\tau_n.
$$
&lt;p>$\tau_n$ contiene la storia precedente, mentre $t_n$ è il valore più recente. Se espandiamo l&amp;rsquo;espressione, diventa chiaro perché la media è chiamata &lt;em>esponenziale&lt;/em>&lt;/p>
$$
\tau_{n + 1} = \alpha t_n + (1 - \alpha)\alpha t_{n - 1} + \dots + (1 - \alpha)^j\alpha t_{n - j} + \dots + (1 - \alpha)^{n + 1} \tau_0
$$
&lt;p>L&amp;rsquo;algoritmo SJF può essere prioritario o non-prioritario. Nel caso arrivasse in coda un processo con CPU burst più breve di del tempo rimasto al processo correntemente in esecuzione, è possibile lasciare che quest&amp;rsquo;ultimo venga soppiantato dal nuovo arrivato oppure no. In caso di prelazione l&amp;rsquo;algoritmo viene chiamato anche &lt;strong>shortest-remaining-time first&lt;/strong>&lt;/p>
&lt;h3 id="533-scheduling-round-robin">5.3.3 Scheduling round-robin&lt;/h3>
&lt;p>Lo scheduling &lt;strong>round robin (RR)&lt;/strong> è simile a FCFS ma viene aggiunta la prelazione dei processi per poterli soppiantare in CPU. Lo scheduler di CPU prende il primo processo dalla coda ready e imposta un tempo limite di un quanto di tempo. Se il CPU burst del processo è più lungo del quanto, il processo viene tolto dalla CPU e messo in coda.&lt;/p>
&lt;p>Il tempo medio di attesa è generalmente lungo. Più il quanto di tempo è lungo più RR si comporterà come FCFS. Con quanti brevi, molto tempo di CPU verrà speso in context switch. Come regola, è bene che che l'80% del CPU burst sia più corto del quanto di tempo.&lt;/p>
&lt;h3 id="534-scheduling-con-priorità">5.3.4 Scheduling con priorità&lt;/h3>
&lt;p>L&amp;rsquo;algoritmo SJF è un caso speciale di &lt;strong>scheduling con priorità&lt;/strong>. Una priorità è associata al processo e la CPU è allocata al processo più prioritario. Alcuni s.o. definiscono l&amp;rsquo;ordine di priorità in maniera decrescente, altri in maniera crescente. Assegneremo la priorità più alta al valore 1.&lt;/p>
&lt;p>Le priorità possono essere definite internamente o esternamente. Le priorità interne sono quantità misurabili come i tempi limite, i requisiti di memoria, i numeri di file aperti o il rapporto tra I/O burst medio e CPU burst medio. Le priorità esterne sono impostate con criteri esterni al s.o. come l&amp;rsquo;importanza del processo, l&amp;rsquo;ammontare di fondi pagati per l&amp;rsquo;utilizzo del computer, il dipartimento che sponsorizza il lavoro e altri fattori, spesso politici.&lt;/p>
&lt;p>Lo scheduling con priorità può essere prioritario o non prioritario. Quando un processo arriva alla coda di ready, e la sua priorità è maggiore di quella del processo in esecuzione, il processo prioritario soppianta quello in CPU oppure viene messo semplicemente in testa alla coda, in base al tipo di scheduling.&lt;/p>
&lt;p>Un problema che può accadere è il &lt;strong>blocco infinito&lt;/strong> o &lt;strong>starvation&lt;/strong>, che accade quando un processo con priorità bassa potrebbe non riuscire ad accedere alla CPU. Per risolvere il problema è possibile ricorrere all&amp;rsquo;&lt;strong>invecchiamento&lt;/strong> che aumenta la priorità di un processo in base al tempo che trascorre in attesa.&lt;/p>
&lt;p>È possibile combinare round robin con priority scheduling, in modo che i processi con la stessa priorità vengano eseguiti col primo algoritmo.&lt;/p>
&lt;h3 id="535-scheduling-a-code-multilivello">5.3.5 Scheduling a code multilivello&lt;/h3>
&lt;p>Un’altra tecnica per fare scheduling è quella di creare una coda per ogni livello di priorità. Questo approccio, chiamato &lt;strong>a code multilivello&lt;/strong>, funziona bene anche quando priority scheduling è combinato con round robin. Questo modello può essere utilizzato anche per partizionare i processi in base al loro tipo, per esempio separando i processi foreground da quelli background.&lt;/p>
&lt;p>I primi sono interattivi e possono beneficiare di scheduling round robin. Per i secondi, FCFS è più adatto.&lt;/p>
&lt;p>In aggiunta ci dev’essere uno scheduling tra le code, che è comunemente implementato come prelazione a priorità fissa.&lt;/p>
&lt;p>Ecco un esempio:&lt;/p>
&lt;ol>
&lt;li>Processi real-time&lt;/li>
&lt;li>Processi di sistema&lt;/li>
&lt;li>Processi interattivi&lt;/li>
&lt;li>Processi batch&lt;/li>
&lt;/ol>
&lt;p>Un’altra possibilità è quella di assegnare dei time slice per ogni coda, così che tutti i processi possano eseguire. Per esempio, i processi foreground potrebbero ricevere un 80% del tempo e quelli background il restante 20%.&lt;/p>
&lt;h3 id="536-scheduling-a-code-multilivello-con-retroazione">5.3.6 Scheduling a code multilivello con retroazione&lt;/h3>
&lt;p>In questa variante, i processi possono cambiare la coda in cui sono stati inseriti, in base alle caratteristiche dei CPU burst.&lt;/p>
&lt;p>Un processo che aspetta troppo in una coda a bassa priorità potrebbe essere mosso in una coda a priorità maggiore. Con questo metodo d’invecchiamento è possibile prevenire la starvation. È possibile anche abbassare la priorità di un processo se questo non viene eseguito entro un tempo limite, favorendo così l’accesso alla CPU per i processi brevi.&lt;/p>
&lt;p>Una coda multilivello a feedback è definita dai seguenti parametri:&lt;/p>
&lt;ul>
&lt;li>Il numero di code&lt;/li>
&lt;li>L’algoritmo di scheduling per ogni coda&lt;/li>
&lt;li>I metodi utilizzati per aggiornare il processo a code diverse da quella di appartenenza&lt;/li>
&lt;li>Il metodo utilizzato per decidere in quale coda entra il processo&lt;/li>
&lt;/ul>
&lt;p>È l’algoritmo di scheduling più generico.&lt;/p>
&lt;h2 id="54-scheduling-dei-thread">5.4 Scheduling dei thread&lt;/h2>
&lt;p>Nei sistemi operativi moderni, ciò che viene mappato nella CPU è solitamente un thread kernel e non un processo. La mappatura thread utente a thread kernel viene invece lasciata agli LWP, assicurandosi di raggruppare tutti i thread dello stesso processo nella competizione per la CPU (&lt;strong>Process Contention Scope (PCS)&lt;/strong>). Per decidere invece quale thread kernel mappare nella CPU, il kernel utilizza il &lt;strong>system contention scope&lt;/strong>. Di solito PCS segue un meccanismo a priorità.&lt;/p>
&lt;h2 id="55-scheduling-multiprocesso">5.5 Scheduling multiprocesso&lt;/h2>
&lt;p>Con le architetture multiprocessore è possibile fare &lt;strong>condivisione del carico (load sharing)&lt;/strong> in cui molteplici thread possono essere eseguiti in parallelo, complicando tuttavia lo scheduling.&lt;/p>
&lt;h3 id="551-approcci-allo-scheduling-multiprocesso">5.5.1 Approcci allo scheduling multiprocesso&lt;/h3>
&lt;p>Il primo approccio allo scheduling multiprocesso è quello &lt;strong>asimmetrico&lt;/strong>, in cui lo scheduling viene affidato a un unico processore e gli altri eseguono solamente codice. Ciò semplifica il controllo ma il processore principale rischia di creare un bottleneck.&lt;/p>
&lt;p>L’approccio standard consiste nello scheduling &lt;strong>multiprocesso simmetrico (Symmetric Multiprocessing, SMP)&lt;/strong>, dove lo scheduler di ogni processore sceglie il proprio thread dalla coda di ready e lo esegue. Conseguentemente possiamo avere due casistiche:&lt;/p>
&lt;ul>
&lt;li>Tutti i thread stanno in un’unica coda. In questo caso bisogna evitare che si formino race condition, quando due processori selezionano contemporaneamente lo stesso thread.&lt;/li>
&lt;li>Ogni processore ha la sua coda privata. È l’approccio più comune nei sistemi SMP. È importante bilanciare la lunghezza delle code per ogni processore.&lt;/li>
&lt;/ul>
&lt;h3 id="552-processori-multicore">5.5.2 Processori multicore&lt;/h3>
&lt;p>I computer moderni sono sistemi multicore. In questo caso il processore ospita più core di computazione e il s.o. lo vede come una semplice CPU, senza avere accesso all’architettura interna. Questo complica gli algoritmi di scheduling. In particolare, è stato notato che le CPU spendono la maggior parte del loro tempo accedendo ai dati in memoria, che è più lenta. Il problema viene chiamato &lt;strong>stallo di memoria&lt;/strong> e può occupare fino al 50% del tempo di CPU.&lt;/p>
&lt;p>Per rimediare al problema i processori moderni hanno implementato sistemi multithreaded, in cui un core ha due thread kernel assegnati. Quando un thread kernel effettua un accesso alla memoria e attende risposta, il core può cambiare l&amp;rsquo;esecuzione con l&amp;rsquo;altro thread. In questo caso, il s.o. vede una CPU logica per thread (p.e. un sistema con quattro core, in cui ogni core gestisce due thread, viene considerato dal s.o. come un sistema con 8 CPU logiche).&lt;/p>
&lt;p>I processori Intel utilizzano il termine &lt;strong>hyper-threading&lt;/strong> (conosciuto anche come &lt;strong>simultaneous threading, SMT&lt;/strong>) per indicare il supporto a questa metodologia.&lt;/p>
&lt;p>In generale ci sono due modi per fare multithreading sui core:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Coarse-grained:&lt;/strong> il thread viene sostituito solo in caso di eventi a lunga latenza come gli stalli di memoria. Il costo dello scambio tra thread è alto.&lt;/li>
&lt;li>&lt;strong>Fine-grained:&lt;/strong> la sostituzione può avvenire potenzialmente a ogni ciclo di istruzione. Il costo dello scambio è più contenuto.&lt;/li>
&lt;/ul>
&lt;p>Lo scheduling nei sistemi multicore va fatto a due livelli:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Livello s.o.:&lt;/strong> il s.o. si occupa di assegnare thread software (utente) ai corrispondenti thread hardware (kernel). Gli algoritmi di schedulimg precedentemente spiegati sono applicabili a questo scopo.&lt;/li>
&lt;li>&lt;strong>Livello core:&lt;/strong> riguarda la decisione di quale thread hardware eseguire sul core.&lt;/li>
&lt;/ul>
&lt;p>È possibile permettere al s.o. di accedere alla logica a livello core, per permettere un bilanciamento migliore delle prestazioni.&lt;/p>
&lt;h3 id="553-bilanciamento-del-carico">5.5.3 Bilanciamento del carico&lt;/h3>
&lt;p>Nei sistemi SMP che fanno uso di code private è importante bilanciare il carico di ogni processore in maniera che non ci sia overloading su un singolo processore. Per fare ciò si adottano due strategie, che possono essere implementate insieme nello stesso s.o.:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Push migration:&lt;/strong> un task si occupa di monitorare i processori e quando trova un processore sovraccarico, ribilancia le code&lt;/li>
&lt;li>&lt;strong>Pull migration:&lt;/strong> il task si occupa di spostare dei processi sulle code dei processori idle&lt;/li>
&lt;/ul>
&lt;p>Il bilanciamento può essere fatto sul numero di processi per coda o sulle priorità per coda. È comunque un processo delicato che può portare a rallentamenti invece che a miglioramenti.&lt;/p>
&lt;h3 id="554-predilezione-per-il-processore">5.5.4 Predilezione per il processore&lt;/h3>
&lt;p>Dal momento che un processore ha la sua cache personale, spostare un thread tra processori invaliderebbe la cache e costringerebbe a un update della gerarchia della memoria, un’operazione molto costosa. Questo fenomeno è chiamato &lt;strong>predilezione per il processore (processor affinity)&lt;/strong>. Questo problema si verifica con la coda condivisa e non con le code private dove il thread rimane sempre nella stessa coda (a meno che non siano implementate strategie di bilanciamento del carico).&lt;/p>
&lt;p>Ci sono due tipi di predilezione:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Soft:&lt;/strong> un processo viene assegnato a un processore ma può essere spostato dal bilanciamento del carico.&lt;/li>
&lt;li>&lt;strong>Hard:&lt;/strong> un processo viene assegnato, tramite chiamata di sistema, a un sottoinsieme di processori sui quali può eseguire&lt;/li>
&lt;/ul>
&lt;p>Spesso i s.o. implementano entrambe le predilezioni. Anche l’architettura di memoria può influenzare la predilezione per il processore. Si noti infine come il bilanciamento di carico si contrappone ai vantaggi offerti dalla predilezione per il processore.&lt;/p>
&lt;h3 id="555-multiprocessing-eterogeneo">5.5.5 Multiprocessing eterogeneo&lt;/h3>
&lt;p>È possibile implementare architetture i cui core presentano caratteristiche diverse. Per esempio possiamo definire due tipi di core, uno grande che consuma molta energia e un altro piccolo che ne consuma poca. I task lenti potrebbero essere assegnati ai core piccoli per risparmiare sull’energia consumata, un aspetto fondamentale nei sistemi mobile. Questa pratica si chiama &lt;strong>Multiprocessing eterogeneo (Heterogeneous Multiprocessing).&lt;/strong>&lt;/p>
&lt;h2 id="56-scheduling-real-time-della-cpu">5.6 Scheduling real-time della CPU&lt;/h2>
&lt;p>Lo scheduling real-time può essere diviso in due strategie:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Soft:&lt;/strong> Un processo critico avrà precedenza rispetto ai non-critici, non vengono però date garanzie sul momento in cui questo verrà eseguito&lt;/li>
&lt;li>&lt;strong>Hard:&lt;/strong> Un processo critico verrà eseguito entro una scadenza prefissata, oltre la quale la sua esecuzione è inutile&lt;/li>
&lt;/ul>
&lt;h3 id="561-minimizzazione-della-latenza">5.6.1 Minimizzazione della latenza&lt;/h3>
&lt;p>Un sistema real-time è guidato dagli eventi in tempo reale, che possono accadere a livello sofrware (p.e. un timer) o hardware (p.e. un veicolo controllato a distanza si accorge di essere vicino a un ostacolo). Il sistema deve essere in grado di rispondere con la massima velocità possibile. Chiamiamo &lt;strong>latenza relativa all&amp;rsquo;evento (event latency)&lt;/strong> il periodo di tempo che intercorre tra l&amp;rsquo;occorrenza dell&amp;rsquo;evento e il momento in cui il sistema ne effettua la gestione.&lt;/p>
&lt;p>Eventi diversi hanno requisiti di latenza diversi. Le categorie di latenza sono due:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>Latenza relativa alle interruzioni:&lt;/strong> è il periodo di tempo compreso tra la notifica di un&amp;rsquo;interruzione alla CPU e l&amp;rsquo;avvio della routine che gestisce l&amp;rsquo;interruzione. Questa latenza è relativa al tempo complessivamente impiegato per le seguenti fasi:&lt;/p>
&lt;ol>
&lt;li>Notifica di un&amp;rsquo;interruzione&lt;/li>
&lt;li>La CPU porta a compimento l&amp;rsquo;operazione corrente&lt;/li>
&lt;li>La CPU determina il tipo di interruzione&lt;/li>
&lt;li>Lo stato del processo dev&amp;rsquo;essere salvato&lt;/li>
&lt;li>Avvio della procedura di gestione dell&amp;rsquo;interruzione&lt;/li>
&lt;/ol>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Latenza relativa al dispatch:&lt;/strong> è il periodo di tempo utilizzato dal dispatcher per la terminazione di un processo e l&amp;rsquo;avvio di un altro processo. Il metodo migliore per minimizzare questa latenza consiste nell&amp;rsquo;utilizzare un kernel prioritario. La &lt;strong>fase di conflitto&lt;/strong> della latenza relativa al dispatch ha due componenti:&lt;/p>
&lt;ol>
&lt;li>Prelazione di ogni processo che esegue nel kernel&lt;/li>
&lt;li>Rilascio delle risorse necessarie al processo critico da parte dei processi non critici&lt;/li>
&lt;/ol>
&lt;/li>
&lt;/ul>
&lt;p>A seguire, il dispatcher fa lo scheduling del processo critico su una CPU disponibile.&lt;/p>
&lt;h3 id="562-scheduling-basato-sulla-priorità">5.6.2 Scheduling basato sulla priorità&lt;/h3>
&lt;p>Gli s.o. real-time devono supportare uno scheduling di priorità con prelazione. Ciò è necessario per ottenere dei s.o. real-time soft. Per conseguire real-time hard è necessario eseguire i processi entro la loro scadenza prefissata.&lt;/p>
&lt;p>Per discutere di questi aspetti è necessario introdurre il concetto di &lt;strong>processo periodico&lt;/strong>, ovvero un processo che richiede la CPU a intervalli costanti (periodi). Una volta che un processo periodico, con periodo $p$ ottiene una CPU, esegue per un lasso di tempo fisso $t$ entro una deadline $d$ che dev&amp;rsquo;essere eseguita da una CPU. In sintesi, abbiamo che $0 \le t \le d \le p$. La frequenza di un task periodico è quindi $1 / p$.&lt;/p>
&lt;p>L&amp;rsquo;aspetto peculiare di un processo periodico è che deve annunciare la sua scadenza allo scheduler che, utilizzando una tecnica di &lt;strong>controllo dell&amp;rsquo;ammissione (admission control)&lt;/strong>, può ammettere il processo garantendo la sua esecuzione in tempo, o rifiutare la richiesta perché impossibile da eseguire entro la scadenza prefissata.&lt;/p>
&lt;h3 id="563-scheduling-con-priorità-proporzionale-alla-frequenza">5.6.3 Scheduling con priorità proporzionale alla frequenza&lt;/h3>
&lt;p>Questo algoritmo di scheduling organizza task periodici utilizzando una policy a priorità statica con prelazione. Per i task periodici, più il periodo è corto, più la priorità è alta è ciò consente di rispettare più deadlines. Questo scheduling è ottimale poiché se un insieme di processi non può essere pianificato da questo algoritmo, allora non può essere pianificato da nessun altro algoritmo &lt;em>che assegni priorità statiche&lt;/em>.&lt;/p>
&lt;p>L&amp;rsquo;algoritmo ha comunque una limitazione: l&amp;rsquo;utilizzo della CPU è vincolato e non è sempre possibile massimizzare le risorse CPU completamente rispettando le deadline. In particolar, il caso peggiore di utilizzo CPU per lo scheduling di $N$ processi è&lt;/p>
$$
N(2^{1/N} - 1).
$$
&lt;p>Se l&amp;rsquo;utilizzo reale della CPU supera questo limite teorico, non è possibile rispettare le scadenze dei processi. Il limite si assesta attorno al $69%$ per $N \rightarrow +\infty$ ($N$ tendente a infinito).&lt;/p>
&lt;h3 id="564-scheduling-edf">5.6.4 Scheduling EDF&lt;/h3>
&lt;p>Lo scheduling &lt;strong>Earliest-Deadline-First (EDF)&lt;/strong> assegna priorità dinamicamente secondo le scadenze dei processi. Più vicina è la scadenza, maggiore sarà la priorità assegnata al processo. Ogni volta che un processo entra nella coda di ready notifica la propria scadenza all&amp;rsquo;algoritmo che potrà assegnare la priorità al processo e modificare quelle degli altri processi.&lt;/p>
&lt;p>EDF non si preoccupa della periodicità dei processi e ha il vantaggio di essere teoricamente ottimo, garantendo, idealmente, l&amp;rsquo;esecuzione di tutti processi entro le proprie scadenze e sfruttando la CPU al 100%. Nella pratica, un rendimento così alto non è possibile per via dei cambi di contesto e della gestione della memoria.&lt;/p>
&lt;h3 id="565-scheduling-a-quote-proporzionali">5.6.5 Scheduling a quote proporzionali&lt;/h3>
&lt;p>Lo &lt;strong>scheduling a quote proporzionali&lt;/strong> opera distribuendo un certo numero di quote $T$ fra tutte le applicazioni. Un&amp;rsquo;applicazione può ricevere $N$ quote di tempo per avere una frazione $N/T$ del tempo di CPU. Questo scheduling ha bisogno di un meccanismo di controllo dell&amp;rsquo;ammissione per garantire che ogni applicazione riceva effettivamente le quote di tempo assegnate. Il meccanismo accetterà le richieste dei processi solo se effettivamente ci sono abbastanza quote disponibili.&lt;/p>
&lt;div id="references-1784797996868476379" class="references" style="margin-top: 5rem;">
&lt;p>&lt;strong>References&lt;/strong>&lt;/p>
A. Silberschatz, P. B. Galvin, G. Gagne, &lt;em>Sistemi Operativi. Concetti ed Esempi&lt;/em>, Decima edizione. Ch. 5.
&lt;/div>
&lt;script>
document.addEventListener('DOMContentLoaded', function () {
var ref = document.getElementById('references-1784797996868476379');
var fn = document.querySelector('.footnotes');
if (ref &amp;&amp; fn &amp;&amp; fn.parentNode) {
fn.parentNode.insertBefore(ref, fn.nextSibling);
}
});
&lt;/script></description></item><item><title>Scheduling della CPU</title><link>https://aretor.it/courses/os2324/05_scheduling/</link><pubDate>Thu, 28 Mar 2024 00:00:00 +0000</pubDate><guid>https://aretor.it/courses/os2324/05_scheduling/</guid><description>&lt;p>In questa lezione andremo a vedere alcuni degli algoritmi di scheduling dei processi nella CPU, per rendere più efficiente e fluida l&amp;rsquo;esperienza utente nell&amp;rsquo;utilizzo del calcolatore. Prima di iniziare specifichiamo che:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Scheduling dei processi&lt;/strong> e &lt;strong>scheduling dei thread&lt;/strong> sono due locuzioni spesso considerate equivalente. Le utilizzeremo tuttavia per identificare due concetti diversi&lt;/li>
&lt;li>L&amp;rsquo;unità di base di computazione è il core, che risiede in una CPU, tuttavia utilizzeremo la locuzione generica più comune &amp;ldquo;eseguire su CPU&amp;rdquo; per intendere l&amp;rsquo;esecuzione su un suo core&lt;/li>
&lt;/ul>
&lt;h2 id="concetti-fondamentali">Concetti fondamentali&lt;/h2>
&lt;p>Come detto in precedenza, l&amp;rsquo;obiettivo della mutliprogrammazione è di avere sempre un processo in CPU, in modo da massimizzarne il suo utilizzo. Quando ad esempio un processo è in attesa di un evento o della gestione di una richiesta I/O, la CPU carica un altro processo e lo esegue nel frattempo. La scelta del processo da eseguire in CPU è chiamato scheduling della CPU.&lt;/p>
&lt;p>Lo scheduling è una funzione fonamentale dei s.o. e quasi tutte le risorse sono sottoposte a scheduling.&lt;/p>
&lt;h3 id="ciclicità-delle-fasi-delaborazione-e-di-io">Ciclicità delle fasi d&amp;rsquo;elaborazione e di I/O&lt;/h3>
&lt;p>L&amp;rsquo;esecuzione di un processo è composta di due stati:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Ciclo d&amp;rsquo;elaborazione:&lt;/strong> svlto dalla CPU&lt;/li>
&lt;li>&lt;strong>Attesa completamento operazioni di I/O&lt;/strong>&lt;/li>
&lt;/ul>
&lt;p>Il processo alterna sequenze di elaborazioni della CPU (&lt;strong>CPU burst&lt;/strong>) con sequenze di operazioni di I/O (&lt;strong>I/O burst&lt;/strong>). L&amp;rsquo;ultima CPU burst si conclude con una richiesta di terminazione del processo. Analisi sui processi hanno evidenziato che la maggior parte dei CPU burst sono brevi. Un processo I/O bound produce CPU burst relativamente brevi, mentre un processo CPU bound ne produce di molto lunghe. Questo è un principio da tenere a mente nello sviluppo di algoritmi di scheduling.&lt;/p>
&lt;h3 id="scheduler-di-cpu">Scheduler di CPU&lt;/h3>
&lt;p>Il &lt;strong>CPU scheduler&lt;/strong> si occupa della selezione di un processo da eseguire in CPU, prendendone uno dalla coda dei processi che sono in stato ready. I record nella coda sono generalmente i process control block dei processi. La coda non è necessariamente FIFO, come andremo a vedere.&lt;/p>
&lt;h3 id="scheduling-prioritario-e-non-prioritario">Scheduling prioritario e non-prioritario&lt;/h3>
&lt;p>Le decisioni di scheduling possono essere prese in una di queste quattro situazioni:&lt;/p>
&lt;ol>
&lt;li>Un processo passa dallo stato di esecuzione a quello di waiting&lt;/li>
&lt;li>Un processo passa dallo stato di esecuzione a a quello di ready&lt;/li>
&lt;li>Un processo passa dallo stato di attesa a quello di ready&lt;/li>
&lt;li>Un processo termina&lt;/li>
&lt;/ol>
&lt;p>Per i casi 1. e 4. non c&amp;rsquo;è scelta, un nuovo processo dev&amp;rsquo;essere eseguito. In questo caso si parla di &lt;strong>scheduling non-prioritario o cooperativo&lt;/strong>. Il processo tiene la CPU finché non termina o passa allo stato di waiting.&lt;/p>
&lt;p>Per i casi 2. 3. si parla invece di &lt;strong>scheduling prioritario&lt;/strong>. Virtualmente, tutti i sistemi operativi moderni possiedono questa policy, che può risultare in race condition quando i dati sono condivisi tra più processi. &lt;strong>Esempio di race condition:&lt;/strong> Un processo che sta aggiornando dei dati viene soppiantato da un altro processo più prioritario che legge i dati.&lt;/p>
&lt;p>Un problema simile può accadere col kernel quando sta eseguendo una modifica alle sue strutture dati (p.e. la coda I/O) per conto di un processo. Se questo viene soppiantato nel mezzo della modifica e il kernel ha bisogno di modificare successivamente la struttura, otteniamo un&amp;rsquo;altra race condition.&lt;/p>
&lt;p>È possibile utilizzare un kernel non prioritario, tuttavia questa scelta non è adatta per i sistemi real-time. Un kernel prioritario, d&amp;rsquo;altro canto, richiede dei meccanismi di prevenzione delle race condition, come i mutex. I sistemi operativi moderni sono tutti prioritari.&lt;/p>
&lt;!-- Il s.o. deve accettare gli interrupt quasi sempre altrimenti l'input può essere perso e l'output sovrascritto. Dal momento che gli interrupt accadono senza preavviso, le sezioni di codice relative devono essere protette dall'uso simultaneo. Per tale motivo quando un processo concorrente esegue un interrupt, questo viene disabilitato per gli altri processi e riabilitato una volta concluso. -->
&lt;h3 id="dispatcher">Dispatcher&lt;/h3>
&lt;p>Il dispatcher è il componente preposto a dare il controllo della CPU al processo in corso. I suoi compiti sono:&lt;/p>
&lt;ul>
&lt;li>Context switching&lt;/li>
&lt;li>Cambio in modalità utente&lt;/li>
&lt;li>Saltare all&amp;rsquo;istruzione giusta quando viene ripresa l&amp;rsquo;esecuzione di un vecchio processo&lt;/li>
&lt;/ul>
&lt;p>Il tempo che trascorre tra il terminare un processo ed eseguirne un altro è chiamato &lt;strong>latenza di dispatch&lt;/strong>. È possibile monitorare i context switch, che si dividono in:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Volontari:&lt;/strong> quando un processo lascia la CPU perché attende delle risorse attualmente non disponibili&lt;/li>
&lt;li>&lt;strong>Non volontari:&lt;/strong> quando la CPU viene presa e assegnata a un altro processo, per esempio per scadenza tempo massimo o per prelazione di un processo verso il corrente.&lt;/li>
&lt;/ul>
&lt;h2 id="criteri-di-scheduling">Criteri di scheduling&lt;/h2>
&lt;p>I criteri per lo scheduling della CPU possono essere molteplici. Tra questi abbiamo:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Utilizzo della CPU:&lt;/strong> Vogliamo tenere la CPU più occupata possibile. L&amp;rsquo;utilizzo della CPU può variare tra $0%$ a $100%$, ma nella pratica sta nel range $40-90%$. Questa misura va massimizzata&lt;/li>
&lt;li>&lt;strong>Troughput:&lt;/strong> è la misura del numero di processi completati per unità di tempo. Un alto throughput indica che molti processi di breve durata sono stati completati. Questa misura va massimizzata&lt;/li>
&lt;li>&lt;strong>Tempo di turnaround:&lt;/strong> è il tempo di esecuzione di un processo, dalla sua sottomissione alla sua terminazione, compresi i tempi di attesa. Questa misura va minimizzata&lt;/li>
&lt;li>&lt;strong>Tempo di attesa:&lt;/strong> è il tempo speso da un processo in attesa nella coda di ready. Questa misura va minimizzata&lt;/li>
&lt;li>&lt;strong>Tempo di risposta:&lt;/strong> nei sistemi interattivi, dove un processo può continuare a produrre output mentre gli output precedenti sono resi disponibili all&amp;rsquo;utente, il tempo di turaround non è indicativo delle performance. Per tale motivo si può utilizzare il tempo di risposta dalla sottomissione di una richiesta al suo completamento. Questa misura va minimizzata&lt;/li>
&lt;/ul>
&lt;p>Nei sistemi interattivi, è più importante minimizzare la varianza del tempo di risposta rispetto alla media, dal momento che è più desiderabile avere un sistema con tempi di risposta prevedibili, rispetto a uno con tempi di risposta brevi in media ma più variabili. Purtroppo non è stato fatto molto lavoro a riguardo&lt;/p>
&lt;p>La nostra misura di riferimento sarà il tempo medio di attesa.&lt;/p>
&lt;h2 id="algoritmi-di-scheduling">Algoritmi di scheduling&lt;/h2>
&lt;p>In questa sezione iniziamo a parlare dei principali algoritmi di scheduling. Per semplicità li descriviamo in un contesto a CPU singola.&lt;/p>
&lt;h3 id="scheduling-first-come-first-served">Scheduling first-come, first-served&lt;/h3>
&lt;p>Con lo scheduling &lt;strong>First-Come, First-Served (FCFS)&lt;/strong> il processo che ha chiesto per primo la CPU verrà sempre eseguito per primo. L&amp;rsquo;implementazione è facilmente gestita con una coda FIFO. Il processo entra in coda e quando la CPU si libera, il processo in testa viene rimosso dalla coda e messo in esecuzione.&lt;/p>
&lt;p>Lo svantaggio di FCFS è che il tempo medio di attesa è lungo e molto vario. In aggiunta, consideriamo il caso in cui abbiamo molti processi I/O bound seguiti da un processo CPU bound:&lt;/p>
&lt;ul>
&lt;li>I processi I/O bound effettuano un&amp;rsquo;operazione di I/O e liberano la CPU&lt;/li>
&lt;li>Il processo CPU bound entra in CPU&lt;/li>
&lt;li>I processi I/O bound finiscono l&amp;rsquo;operazione e vanno in coda ready attendendo che la CPU si liberi (le prestazioni degradano)&lt;/li>
&lt;li>Il processo CPU bound fa una chiamata I/O&lt;/li>
&lt;li>I processi I/O accedono di nuovo alla CPU e finiscono in fretta&lt;/li>
&lt;li>Nel frattempo il processo CPU bound va in testa alla coda e riempie di nuovo la CPU (le prestazioni degradano)&lt;/li>
&lt;/ul>
&lt;p>Questo fenomeno è chiamato &lt;strong>effetto convoglio&lt;/strong>. FCFS è particolarmente inappropriato per sistemi interattivi.&lt;/p>
&lt;h3 id="scheduling-shortest-job-first">Scheduling shortest-job-first&lt;/h3>
&lt;p>Con lo scheduling &lt;strong>Shortest-Job-First (SJF)&lt;/strong>, ogni processo viene associato alla lunghezza della prossima CPU burst. Quando la CPU è disponibile il processo più corto in questi termini viene mandato in esecuzione. Un nome più corretto sarebbe &lt;em>shortest-next-CPU-burst&lt;/em>.&lt;/p>
&lt;p>Si può dimostrare che questo algoritmo è ottimale in termini di tempo medio di attesa, tuttavia non è implementabile a livello di scheduling CPU perché non è possibile conoscere a priori il tempo di esecuzione del prossimo CPU burst. A tal proposito, possamo predire il tempo di burst basandoci su quelli precedenti del processo. Si usa tipicamente una media esponenziale&lt;/p>
&lt;p>$\tau_{n + 1} = \alpha t_n + (1 - \alpha)\tau_n$.&lt;/p>
&lt;p>$\tau_n$ contiene la storia precedente, mentre $t_n$ è il valore più recente. Se espandiamo l&amp;rsquo;espressione, diventa chiaro perché la media è chiamata &lt;em>esponenziale&lt;/em>&lt;/p>
&lt;p>$\tau_{n + 1} = \alpha t_n + (1 - \alpha)\alpha t_{n - 1} + \dots + (1 - \alpha)^j\alpha t_{n - j} + \dots + (1 - \alpha)^{n + 1} \tau_0$&lt;/p>
&lt;p>L&amp;rsquo;algoritmo SJF può essere prioritario o non-prioritario. Nel caso arrivasse in coda un processo con CPU burst più breve di del tempo rimasto al processo correntemente in esecuzione, è possibile lasciare che quest&amp;rsquo;ultimo venga soppiantato dal nuovo arrivato oppure no. In caso di prelazione l&amp;rsquo;algoritmo viene chiamato anche &lt;strong>shortest-remaining-time first&lt;/strong>&lt;/p>
&lt;h3 id="scheduling-round-robin">Scheduling round-robin&lt;/h3>
&lt;p>Lo scheduling &lt;strong>round robin (RR)&lt;/strong> è simile a FCFS ma viene aggiunta la prelazione dei processi per poterli soppiantare in CPU. Lo scheduler di CPU prende il primo processo dalla coda ready e imposta un tempo limite di un quanto di tempo. Se il CPU burst del processo è più lungo del quanto, il processo viene tolto dalla CPU e messo in coda.&lt;/p>
&lt;p>Il tempo medio di attesa è generalmente lungo. Più il quanto di tempo è lungo più RR si comporterà come FCFS. Con quanti brevi, molto tempo di CPU verrà speso in context switch. Come regola, è bene che che l'80% del CPU burst sia più corto del quanto di tempo.&lt;/p>
&lt;h3 id="scheduling-con-priorità">Scheduling con priorità&lt;/h3>
&lt;p>L&amp;rsquo;algoritmo SJF è un caso speciale di &lt;strong>scheduling con priorità&lt;/strong>. Una priorità è associata al processo e la CPU è allocata al processo più prioritario. Alcuni s.o. definiscono l&amp;rsquo;ordine di priorità in maniera decrescente, altri in maniera crescente. Assegneremo la priorità più alta al valore 1.&lt;/p>
&lt;p>Le priorità possono essere definite internamente o esternamente. Le priorità interne sono quantità misurabili come i tempi limite, i requisiti di memoria, i numeri di file aperti o il rapporto tra I/O burst medio e CPU burst medio. Le priorità esterne sono impostate con criteri esterni al s.o. come l&amp;rsquo;importanza del processo, l&amp;rsquo;ammontare di fondi pagati per l&amp;rsquo;utilizzo del computer, il dipartimento che sponsorizza il lavoro e altri fattori, spesso politici.&lt;/p>
&lt;p>Lo scheduling con priorità può essere prioritario o non prioritario. Quando un processo arriva alla coda di ready, e la sua priorità è maggiore di quella del processo in esecuzione, il processo prioritario soppianta quello in CPU oppure viene messo semplicemente in testa alla coda, in base al tipo di scheduling.&lt;/p>
&lt;p>Un problema che può accadere è il &lt;strong>blocco infinito&lt;/strong> o &lt;strong>starvation&lt;/strong>, che accade quando un processo con priorità bassa potrebbe non riuscire ad accedere alla CPU. Per risolvere il problema è possibile ricorrere all&amp;rsquo;&lt;strong>invecchiamento&lt;/strong> che aumenta la priorità di un processo in base al tempo che trascorre in attesa.&lt;/p>
&lt;p>È possibile combinare round robin con priority scheduling, in modo che i processi con la stessa priorità vengano eseguiti col primo algoritmo.&lt;/p>
&lt;h3 id="scheduling-a-code-multilivello">Scheduling a code multilivello&lt;/h3>
&lt;p>Un’altra tecnica per fare scheduling è quella di creare una coda per ogni livello di priorità. Questo approccio, chiamato &lt;strong>a code multilivello&lt;/strong>, funziona bene anche quando priority scheduling è combinato con round robin. Questo modello può essere utilizzato anche per partizionare i processi in base al loro tipo, per esempio separando i processi foreground da quelli background.&lt;/p>
&lt;p>I primi sono interattivi e possono beneficiare di scheduling round robin. Per i secondi, FCFS è più adatto.&lt;/p>
&lt;p>In aggiunta ci dev’essere uno scheduling tra le code, che è comunemente implementato come prelazione a priorità fissa.&lt;/p>
&lt;p>Ecco un esempio:&lt;/p>
&lt;ul>
&lt;li>Processi real-time&lt;/li>
&lt;li>Processi di sistema&lt;/li>
&lt;li>Processi interattivi&lt;/li>
&lt;li>Processi batch&lt;/li>
&lt;/ul>
&lt;p>Un’altra possibilità è quella di assegnare dei time slice per ogni coda, così che tutti i processi possano eseguire. Per esempio, i processi foreground potrebbero ricevere un 80% del tempo e quelli background il restante 20%.&lt;/p>
&lt;h3 id="scheduling-a-code-multilivello-con-retroazione">Scheduling a code multilivello con retroazione&lt;/h3>
&lt;p>In questa variante, i processi possono cambiare la coda in cui sono stati inseriti, in base alle caratteristiche dei CPU burst.&lt;/p>
&lt;p>Un processo che aspetta troppo in una coda a bassa priorità potrebbe essere mosso in una coda a priorità maggiore. Con questo metodo d’invecchiamento è possibile prevenire la starvation. È possibile anche abbassare la priorità di un processo se questo non viene eseguito entro un tempo limite, favorendo così l’accesso alla CPU per i processi brevi.&lt;/p>
&lt;p>Una coda multilivello a feedback è definita dai seguenti parametri:&lt;/p>
&lt;ul>
&lt;li>Il numero di code&lt;/li>
&lt;li>L’algoritmo di scheduling per ogni coda&lt;/li>
&lt;li>I metodi utilizzati per aggiornare il processo a code diverse da quella di appartenenza&lt;/li>
&lt;li>Il metodo utilizzato per decidere in quale coda entra il processo&lt;/li>
&lt;/ul>
&lt;p>È l’algoritmo di scheduling più generico.&lt;/p>
&lt;h2 id="scheduling-dei-thread">Scheduling dei thread&lt;/h2>
&lt;p>Nei sistemi operativi moderni, ciò che viene mappato nella CPU è solitamente un thread kernel e non un processo. La mappatura thread utente a thread kernel viene invece lasciata agli LWP, assicurandosi di raggruppare tutti i thread dello stesso processo nella competizione per la CPU (&lt;strong>process contention scope (PCS)&lt;/strong>). Per decidere invece quale thread kernel mappare nella CPU, il kernel utilizza il &lt;strong>system contention scope&lt;/strong>. Di solito PCS segue un meccanismo a priorità.&lt;/p>
&lt;h2 id="scheduling-multiprocesso">Scheduling multiprocesso&lt;/h2>
&lt;p>Con le architetture multiprocessore è possibile fare &lt;strong>condivisione del carico (load sharing)&lt;/strong> in cui molteplici thread possono essere eseguiti in parallelo, complicando tuttavia lo scheduling.&lt;/p>
&lt;h3 id="approcci-allo-scheduling-multiprocesso">Approcci allo scheduling multiprocesso&lt;/h3>
&lt;p>Il primo approccio allo scheduling multiprocesso è quello &lt;strong>asimmetrico&lt;/strong>, in cui lo scheduling viene affidato a un unico processore e gli altri eseguono solamente codice. Ciò semplifica il controllo ma il processore principale rischia di creare un bottleneck.&lt;/p>
&lt;p>L’approccio standard consiste nello scheduling &lt;strong>multiprocesso simmetrico (Symmetric Multiprocessing, SMP)&lt;/strong>, dove lo scheduler di ogni processore sceglie il proprio thread dalla coda di ready e lo esegue. Conseguentemente possiamo avere due casistiche:&lt;/p>
&lt;ul>
&lt;li>Tutti i thread stanno in un’unica coda. In questo caso bisogna evitare che si formino race condition, quando due processori selezionano contemporaneamente lo stesso thread.&lt;/li>
&lt;li>Ogni processore ha la sua coda privata. È l’approccio più comune nei sistemi SMP. È importante bilanciare la lunghezza delle code per ogni processore.&lt;/li>
&lt;/ul>
&lt;h3 id="processori-multicore">Processori multicore&lt;/h3>
&lt;p>I computer moderni sono sistemi multicore. In questo caso il processore ospita più core di computazione e il s.o. lo vede come una semplice CPU, senza avere accesso all’architettura interna. Questo complica gli algoritmi di scheduling. In particolare, è stato notato che le CPU spendono la maggior parte del loro tempo accedendo ai dati in memoria, che è più lenta. Il problema viene chiamato &lt;strong>stallo di memoria&lt;/strong> e può occupare fino al 50% del tempo di CPU.&lt;/p>
&lt;p>Per rimediare al problema i processori moderni hanno implementato sistemi multithreaded, in cui un processore può cambiare thread hardware di esecuzione non appena un thread hardware effettua un accesso alla memoria. Il sistema operativo vede una CPU per thread (p.e. Un sistema con quattro core, in cui ogni core gestisce due thread, viene contato come un sistema a 8 CPU).&lt;/p>
&lt;p>I processori Intel utilizzano il termine &lt;strong>hyper-threading&lt;/strong> (conosciuto anche come &lt;strong>simultaneous threading, SMT&lt;/strong>) per indicare il supporto a questa metodologia. In generale ci sono due modi per&lt;/p>
&lt;p>In generale ci sono due modi per fare multithreading sui core:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Coarse-grained:&lt;/strong> il thread viene sostituito solo in caso di eventi a lunga latenza come gli stalli di memoria. Il costo dello scambio tra thread è alto.&lt;/li>
&lt;li>&lt;strong>Fine-grained:&lt;/strong> la sostituzione può avvenire potenzialmente a ogni ciclo di istruzione. Il costo dello scambio è più contenuto.&lt;/li>
&lt;/ul>
&lt;p>Lo scheduling nei sistemi multicore va fatto a due livelli:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Livello s.o.:&lt;/strong> il s.o. si occupa di assegnare thread software (utente) ai corrispondenti thread hardware (kernel). Gli algoritmi di schedulimg precedentemente spiegati sono applicabili a questo scopo.&lt;/li>
&lt;li>&lt;strong>Livello core:&lt;/strong> concerne la decisione di quale thread hardware eseguire sul core.&lt;/li>
&lt;/ul>
&lt;p>È possibile permettere al s.o. di accedere alla logica a livello core, per permettere un bilanciamento migliore delle prestazioni.&lt;/p>
&lt;h3 id="bilanciamento-del-carico">Bilanciamento del carico&lt;/h3>
&lt;p>Nei sistemi SMP che fanno uso di code private è importante bilanciare il carico di ogni processore in maniera che non ci sia overloading su un singolo processore. Per fare ciò si adottano due strategie, che possono essere implementate insieme nello stesso s.o.:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Push migration:&lt;/strong> un task si occupa di monitorare i processori e quando trova un processore sovraccarico, ribilancia le code&lt;/li>
&lt;li>&lt;strong>Pull migration:&lt;/strong> il task si occupa di spostare dei processi sulle code dei processori idle&lt;/li>
&lt;/ul>
&lt;p>Il bilanciamento può essere fatto sul numero di processi per coda o sulle priorità per coda. È comunque un processo delicato che può portare a rallentamenti invece che a miglioramenti.&lt;/p>
&lt;h3 id="predilezione-per-il-processore">Predilezione per il processore&lt;/h3>
&lt;p>Dal momento che un processore ha la sua cache personale, spostare un thread tra processori invaliderebbe la cache e costringerebbe a un update della gerarchia della memoria, un’operazione molto costosa. Questo fenomeno è chiamato &lt;strong>predilezione per il processore (processor affinity)&lt;/strong>. Questo problema si verifica con la coda condivisa e non con le code private dove il thread rimane sempre nella stessa coda (a meno che non siano implementate strategie di bilanciamento del carico).&lt;/p>
&lt;p>Ci sono due tipi di predilezione:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Soft:&lt;/strong> un processo viene assegnato a un processore ma può essere spostato dal bilanciamento del carico.&lt;/li>
&lt;li>&lt;strong>Hard:&lt;/strong> un processo viene assegnato, tramite chiamata di sistema, a un sottoinsieme di processori sui quali può eseguire&lt;/li>
&lt;/ul>
&lt;p>Spesso i s.o. implementano entrambe le predilezioni. Anche l’architettura di memoria può influenzare la predilezione per il processore. Si noti infine come il bilanciamento di carico si contrappone ai vantaggi offerti dalla predilezione per il processore.&lt;/p>
&lt;h3 id="multiprocessing-eterogeneo">Multiprocessing eterogeneo&lt;/h3>
&lt;p>È possibile implementare architetture i cui core presentano caratteristiche diverse. Per esempio possiamo definire due tipi di core, uno grande che consuma molta energia e un altro piccolo che ne consuma poca. I task lenti potrebbero essere assegnati ai core piccoli per risparmiare sull’energia consumata, un aspetto fondamentale nei sistemi mobile. Questa pratica si chiama &lt;strong>Multiprocessing eterogeneo (Heterogeneous Multiprocessing).&lt;/strong>&lt;/p>
&lt;h2 id="scheduling-real-time-della-cpu">Scheduling real-time della CPU&lt;/h2>
&lt;p>Lo scheduling real-time può essere diviso in due strategie:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Soft:&lt;/strong> Un processo critico avrà precedenza rispetto ai non-critici, non vengono però date garanzie sul momento in cui questo verrà eseguito&lt;/li>
&lt;li>&lt;strong>Hard:&lt;/strong> Un processo critico verrà eseguito entro una scadenza prefissata, oltre la quale la sua esecuzione è inutile&lt;/li>
&lt;/ul>
&lt;h3 id="minimizzazione-della-latenza">Minimizzazione della latenza&lt;/h3>
&lt;p>Un sistema real-time è guidato dagli eventi in tempo reale, che possono accadere a livello sofrware (p.e. un timer) o hardware (p.e. un veicolo controllato a distanza si accorge di essere vicino a un ostacolo). Il sistema deve essere in grado di rispondere con la massima velocità possibile. Chiamiamo &lt;strong>latenza relativa all&amp;rsquo;evento (event latency)&lt;/strong> il periodo di tempo che intercorre tra l&amp;rsquo;occorrenza dell&amp;rsquo;evento e il momento in cui il sistema ne effettua la gestione.&lt;/p>
&lt;p>Eventi diversi hanno requisiti di latenza diversi. Le categorie di latenza sono due:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>Latenza relativa alle interruzioni:&lt;/strong> è il periodo di tempo compreso tra la notifica di un&amp;rsquo;interruzione alla CPU e l&amp;rsquo;avvio della routine che gestisce l&amp;rsquo;interruzione. Questa latenza è relativa al tempo complessivamente impiegato per le seguenti fasi:&lt;/p>
&lt;ol>
&lt;li>Notifica di un&amp;rsquo;interruzione&lt;/li>
&lt;li>La CPU porta a compimento l&amp;rsquo;operazione corrente&lt;/li>
&lt;li>La CPU determina il tipo di interruzione&lt;/li>
&lt;li>Lo stato del processo dev&amp;rsquo;essere salvato&lt;/li>
&lt;li>Avvio della procedura di gestione dell&amp;rsquo;interruzione&lt;/li>
&lt;/ol>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Latenza relativa al dispatch:&lt;/strong> è il periodo di tempo utilizzato dal dispatcher per la terminazione di un processo e l&amp;rsquo;avvio di un altro processo. Il metodo migliore per minimizzare questa latenza consiste nell&amp;rsquo;utilizzare un kernel prioritario. La &lt;strong>fase di conflitto&lt;/strong> della latenza relativa al dispatch ha due componenti:&lt;/p>
&lt;ol>
&lt;li>Prelazione di ogni processo che esegue nel kernel&lt;/li>
&lt;li>Rilascio delle risorse necessarie al processo critico da parte dei processi non critici&lt;/li>
&lt;/ol>
&lt;/li>
&lt;/ul>
&lt;p>A seguire, il dispatcher fa lo scheduling del processo critico su una CPU disponibile.&lt;/p>
&lt;h3 id="scheduling-basato-sulla-priorità">Scheduling basato sulla priorità&lt;/h3>
&lt;p>Gli s.o. real-time devono supportare uno scheduling di priorità con prelazione. Ciò è necessario per ottenere dei s.o. real-time soft. Per conseguire real-time hard è necessario eseguire i processi entro la loro scadenza prefissata.&lt;/p>
&lt;p>Per discutere di questi aspetti è necessario introdurre il concetto di &lt;strong>processo periodico&lt;/strong>, ovvero un processo che richiede la CPU a intervalli costanti (periodi). Una volta che un processo periodico, con periodo $p$ ottiene una CPU, esegue per un lasso di tempo fisso $t$ entro una deadline $d$ che dev&amp;rsquo;essere eseguita da una CPU. In sintesi, abbiamo che $0 \le t \le d \le p$. La frequenza di un task periodico è quindi $1 / p$.&lt;/p>
&lt;p>L&amp;rsquo;aspetto peculiare di un processo periodico è che deve annunciare la sua scadenza allo scheduler che, utilizzando una tecnica di &lt;strong>controllo dell&amp;rsquo;ammissione (admission control)&lt;/strong>, può ammettere il processo garantendo la sua esecuzione in tempo, o rifiutare la richiesta perché impossibile da eseguire entro la scadenza prefissata.&lt;/p>
&lt;h3 id="scheduling-con-priorità-proporzionale-alla-frequenza">Scheduling con priorità proporzionale alla frequenza&lt;/h3>
&lt;p>Questo algoritmo di scheduling organizza task periodico utilizzando una policy a priorità statica con prelazione. Se un processo a bassa priorità sta eseguendo e un processo ad alta priorità è disponibile per essere eseguito, quest&amp;rsquo;ultimo soppianterà il processo in esecuzione. Per i task periodici, più il periodo è corto, più la priorità è alta è ciò consente di rispettare più deadlines. Questo scheduling è ottimale poiché se un insieme di processi non può essere pianificato da questo algoritmo, allora non può essere pianificato da nessun altro algoritmo che assegni priorità statiche.&lt;/p>
&lt;p>Questo algoritmo ha una limitazione: l&amp;rsquo;utilizzo della CPU è vincolato e non è sempre possibile massimizzare le risorse CPU completamente. Il caso peggiore di utilizzo CPU per lo scheduling di $N$ processi è&lt;/p>
&lt;p>$N(2^{1/N} - 1)$.&lt;/p>
&lt;p>Se l&amp;rsquo;utilizzo reale della CPU supera questo limite teorico, non è possibile rispettare le scadenze dei processi. Il limite si assesta attorno al $69%$ per $N \rightarrow +\infty$ ($N$ tendente a infinito).&lt;/p>
&lt;h3 id="scheduling-edf">Scheduling EDF&lt;/h3>
&lt;p>Lo scheduling &lt;strong>Earliest-Deadline-First (EDF)&lt;/strong> assegna priorità dinamicamente secondo le scadenze dei processi. Più vicina è la scadenza, maggiore sarà la priorità assegnata al processo. Ogni volta che un processo entra nella coda di ready notifica la propria scadenza all&amp;rsquo;algoritmo che potrà assegnare la priorità al processo e modificare quelle degli altri processi.&lt;/p>
&lt;p>EDF non si preoccupa della periodicità dei processi e ha il vantaggio di essere teoricamente ottimo, garantendo, idealmente, l&amp;rsquo;esecuzione di tutti processi entro le proprie scadenze e sfruttando la CPU al 100%. Nella pratica, un rendimento così alto non è possibile per via dei cambi di contesto e della gestione della memoria.&lt;/p>
&lt;h3 id="scheduling-a-quote-proporzionali">Scheduling a quote proporzionali&lt;/h3>
&lt;p>Lo &lt;strong>scheduling a quote proporzionali&lt;/strong> opera distribuendo un certo numero di quote $T$ fra tutte le applicazioni. Un&amp;rsquo;applicazione può ricevere $N$ quote di tempo per avere una frazione $N/T$ del tempo di CPU. Questo scheduling ha bisogno di un meccanismo di controllo dell&amp;rsquo;ammissione per garantire che ogni applicazione riceva effettivamente le quote di tempo assegnate. Il meccanismo accetterà le richieste dei processi solo se effettivamente ci sono abbastanze quote disponibili.&lt;/p>
&lt;div id="references-1784797996245958031" class="references" style="margin-top: 5rem;">
&lt;p>&lt;strong>References&lt;/strong>&lt;/p>
A. Silberschatz, P. B. Galvin, G. Gagne, &lt;em>Sistemi Operativi. Concetti ed Esempi&lt;/em>, Decima edizione. Ch. 5.
&lt;/div>
&lt;script>
document.addEventListener('DOMContentLoaded', function () {
var ref = document.getElementById('references-1784797996245958031');
var fn = document.querySelector('.footnotes');
if (ref &amp;&amp; fn &amp;&amp; fn.parentNode) {
fn.parentNode.insertBefore(ref, fn.nextSibling);
}
});
&lt;/script></description></item><item><title>6 Sincronizzazione dei processi</title><link>https://aretor.it/courses/os2425/lessons/06_proc_sync/</link><pubDate>Tue, 25 Mar 2025 00:00:00 +0000</pubDate><guid>https://aretor.it/courses/os2425/lessons/06_proc_sync/</guid><description>&lt;p>In questa lezione andremo a studiare i meccanismi per controllare l&amp;rsquo;accesso di
strutture condivise da parte dei thread, mantenendo l&amp;rsquo;integrità dei dati.&lt;/p>
&lt;h2 id="61-background">6.1 Background&lt;/h2>
&lt;p>Ricordiamo che un &lt;strong>processo cooperativo&lt;/strong> può essere influenzato da altri processi con i quali condivide codice o dati. Questo comportamento può contribuire a generare problemi relativi all&amp;rsquo;integrità dei dati.&lt;/p>
&lt;p>Presentiamo subito un esempio, con il paradigma produttore-consumatore.&lt;/p>
&lt;p>Questo è il codice del produttore.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nb">true&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* produce an item in next_produced */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">count&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="n">BUFFER_SIZE&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">;&lt;/span> &lt;span class="cm">/* do nothing */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">buffer&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">in&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">next_produced&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">in&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">in&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">%&lt;/span> &lt;span class="n">BUFFER_SIZE&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">count&lt;/span>&lt;span class="o">++&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Questo invece è il codice del consumatore.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nb">true&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">count&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">;&lt;/span> &lt;span class="cm">/* do nothing */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">next_consumed&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">buffer&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">out&lt;/span>&lt;span class="p">];&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">out&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">out&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">%&lt;/span> &lt;span class="n">BUFFER_SIZE&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">count&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* consume the item in next_consumed */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Se questi due snippet di codice vengono eseguiti concorrentemente, può accadere che venga eseguita l&amp;rsquo;operazione &lt;code>count++&lt;/code> e &lt;code>count--&lt;/code> contemporaneamente. Supponiamo che inizialmente il valore di &lt;code>count&lt;/code> sia &lt;code>5&lt;/code>. Possono sorgere due casistiche:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>Si verificano sequenzialmente le seguenti due istruzioni&lt;/p>
&lt;ul>
&lt;li>&lt;code>count&lt;/code> (valore &lt;code>5&lt;/code>) viene acceduto dal consumatore che esegue &lt;code>count--&lt;/code> (valore &lt;code>4&lt;/code>)&lt;/li>
&lt;li>&lt;code>count&lt;/code> (valore &lt;code>4&lt;/code>) viene acceduto dal produttore che esegue &lt;code>count++&lt;/code> (valore &lt;code>5&lt;/code>)&lt;/li>
&lt;/ul>
&lt;p>Il risultato finale (valore &lt;code>5&lt;/code>) è corretto. Notate come il produttore possa eseguire prima del consumatore, senza cambiare il risultato finale.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>Si verificano sequenzialmente queste istruzioni&lt;/p>
&lt;ul>
&lt;li>&lt;code>count&lt;/code> viene acceduto dal consumatore (valore &lt;code>5&lt;/code>)&lt;/li>
&lt;li>&lt;code>count&lt;/code> viene acceduto dal produttore (valore &lt;code>5&lt;/code>)&lt;/li>
&lt;li>Il consumatore esegue &lt;code>count--&lt;/code> (valore &lt;code>4&lt;/code>)&lt;/li>
&lt;li>Il produttore esegue &lt;code>count++&lt;/code> e sovrascrive il valore aggiornato dal consumatore, al quale non ha avuto accesso (valore &lt;code>6&lt;/code>)&lt;/li>
&lt;/ul>
&lt;p>Il risultato finale (valore &lt;code>6&lt;/code>) è sbagliato. Notate come possa accadere anche il caso opposto&lt;/p>
&lt;ul>
&lt;li>&lt;code>count&lt;/code> viene acceduto dal consumatore (valore &lt;code>5&lt;/code>)&lt;/li>
&lt;li>&lt;code>count&lt;/code> viene acceduto dal produttore (valore &lt;code>5&lt;/code>)&lt;/li>
&lt;li>Il produttore esegue &lt;code>count++&lt;/code> (valore &lt;code>6&lt;/code>)&lt;/li>
&lt;li>Il consumatore esegue &lt;code>count--&lt;/code> (valore &lt;code>4&lt;/code>)&lt;/li>
&lt;/ul>
&lt;p>Anche in questo caso il risultato finale (valore &lt;code>4&lt;/code>) è sbagliato.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;p>Le casisitiche sbagliate del punto 2, vengono chiamate &lt;strong>race conditions&lt;/strong>. Per proteggersi da tali fenomeni è necessario garantire che solo un processo alla volta modifichi la variabile condivisa &lt;code>count&lt;/code>, sincronizzando i processi.&lt;/p>
&lt;h2 id="62-il-problema-della-sezione-critica">6.2 Il problema della sezione critica&lt;/h2>
&lt;p>Iniziamo le nostre considerazioni discutendo del &lt;strong>problema della sezione critica&lt;/strong>. Consideriamo un sistema con $n$ processi $[P_0, \dots, P_{n - 1}]$. Ogni processo ha un segmento di codice chiamato &lt;strong>sezione critica&lt;/strong>, potenzialmente diverso tra i vari processi (p.e. produttore-consumatore), nel quale accede e aggiorna dei dati condivisi. Se vogliamo mantenere l&amp;rsquo;integrità dei dati, non è possibile lasciare eseguire una sezione critica a due processi concorrentemente.&lt;/p>
&lt;p>Per risolvere il problema è quindi necessario progettare un protocollo che sincronizzi l&amp;rsquo;attività dei processi. Ogni processo deve richiedere l&amp;rsquo;accesso alla sua sezione, e la parte di codice preposta a questo scopo viene chiamata &lt;strong>sezione d&amp;rsquo;entrata (entry section)&lt;/strong>. Quest&amp;rsquo;ultima è seguita da una &lt;strong>sezione di uscita (exit section)&lt;/strong>. Il codice rimanente è chiamato &lt;strong>sezione rimanente (remainder section)&lt;/strong>. Il codice di un processo deve quindi racchiudere la sua sezione critica tra la sezione d&amp;rsquo;entrata e quella d&amp;rsquo;uscita.&lt;/p>
&lt;p>Una soluzione al problema della sezione critica richiede che siano soddisfatti i seguenti tre requisiti:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Mutua esclusione:&lt;/strong> &lt;em>se il processo $P_i$ sta eseguendo la sua sezione critica, nessun altro processo può eseguirla in quel momento.&lt;/em>&lt;/li>
&lt;li>&lt;strong>Progresso:&lt;/strong> &lt;em>se nessun processo sta eseguendo la propria sezione critica (inclusa quella d&amp;rsquo;uscita) e alcuni processi vogliono entrarci, allora solamente quelli che non stanno eseguendo la sezione rimanente possono partecipare alla selezione in cui si decide chi entrerà. Questa selezione non può essere posticipata indefinitivamente.&lt;/em> In altre parole ci deve sempre un progresso fatto dell&amp;rsquo;algoritmo, o per via di un processo che esegue la sezione critica, o per via del meccanismo di selezione di un nuovo processo per entrare nella sezione critica.&lt;/li>
&lt;li>&lt;strong>Attesa limitata:&lt;/strong> &lt;em>esiste un limite al numero di volte in cui gli altri processi possono accedere alla loro sezione critica dopo che un processo ha fatto richiesta di accesso alla propria e prima che tale richiesta venga garantita.&lt;/em>&lt;/li>
&lt;/ol>
&lt;p>Non facciamo alcuna assunzione sulla velocità dei singoli processi, se non che questa sia non negativa.&lt;/p>
&lt;p>Riportiamo due esempi in cui il problema della sezione critica compare:&lt;/p>
&lt;ul>
&lt;li>Una struttura kernel con cui il sistema operativo tiene traccia dei file aperti. Tale struttura può essere modificata quando i processi aprono o chiudono file. Se due processi vogliono aprire lo stesso file, la lista dev&amp;rsquo;essere sincronizzata per poter evitare race condition.&lt;/li>
&lt;li>Due processi genitori eseguono &lt;code>fork()&lt;/code>. Il contatore pid da assegnare ai nuovi processi figli dev&amp;rsquo;essere sincronizzato per evitare che venga assegnato lo stesso pid a entrambi i processi&lt;/li>
&lt;/ul>
&lt;p>Il problema della sezione critica può essere risolto facilmente in contesti single-core, evitando gli interrupt quando una variabile condivisa sta venendo aggiornata. Sfortunatamente questa soluzione non è adatta a sistemi multicore dove la notifica della disabilitazione di interrupt a tutti i processori aggiunge un overhead.&lt;/p>
&lt;!-- Inoltre, si consideri l'effetto sul clock di sistema quando questo viene aggiornato con gli interrupt. -->
&lt;!-- Esistono due approcci generali al problema e consistono nello sviluppo di:
- **kernel non-prioritari:** non permettono che un processo che esegue in kernel mode venga soppiantato da un altro processo. Questo kernel non è soggetto a race condition
- **kernel prioritari:** permettono che un processo che esegue nel kernel venga soppiantato. La loro progettazione è particolarmente difficile in architetture SMP, tuttavia la loro interattività garantisce un'esperienza più fluida, specialmente in contesti real-time -->
&lt;h2 id="63-soluzione-di-peterson">6.3 Soluzione di Peterson&lt;/h2>
&lt;p>Ora illustriamo la classica soluzione al problema della sezione critica: la &lt;strong>soluzione di Peterson&lt;/strong>. Assumiamo due processi $P_i$ e $P_j$. La soluzione di Peterson richiede che i due processi condividano due dati:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="kt">int&lt;/span> &lt;span class="n">turn&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">boolean&lt;/span> &lt;span class="n">flag&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">];&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Se &lt;code>turn == i&lt;/code> allora $P_i$ &lt;em>ha il permesso&lt;/em> di entrare nella sezione critica. Se &lt;code>flag[i]&lt;/code> è impostato a &lt;code>true&lt;/code> $P_i$ &lt;em>è pronto&lt;/em> per entrare nella sezione critica.&lt;/p>
&lt;p>Per entrare nella sezione critica $P_i$ imposta &lt;code>flag[i] = true&lt;/code> e poi &lt;code>turn = j&lt;/code> indicando, così, che se l&amp;rsquo;altro processo vuole entrare nella sezione critica può farlo. Se entrambi i processi volessero entrare nella sezione critica nello stesso momento &lt;code>turn&lt;/code> conterrebbe il valore &lt;code>i&lt;/code> o &lt;code>j&lt;/code> e solo uno dei due processi potrebbe effettivamente entrare.&lt;/p>
&lt;p>Il codice per il processo $P_i$ è il seguente:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="k">while&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">true&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">flag&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">true&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">turn&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">j&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">flag&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">j&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="n">turn&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="n">j&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* critical section */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">flag&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">false&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* remainder section */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>La soluzione è corretta poiché rispetta le tre proprietà:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>Mutua esclusione:&lt;/strong> $P_i$ entra nella sua sezione critica solo se &lt;code>flag[j] == false&lt;/code> o &lt;code>turn == i&lt;/code>. Può accadere che &lt;code>flag[0] == flag[1] == true&lt;/code> e che &lt;code>turn&lt;/code> venga aggiornato contemporaneamente, tuttavia il suo valore sarà uno soltanto tra &lt;code>i&lt;/code> e &lt;code>j&lt;/code> garantendo l&amp;rsquo;accesso alla sezione critica solamente a un processo alla volta.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Progresso:&lt;/strong> supponiamo che $P_j$ sia nella sezione rimanente e che $P_i$ sia nel ciclo while. Se questa proprietà fosse verificata, solamente $P_i$ potrebbe accedere alla sezione critica, non importa quale processo venga eseguito prima tra $P_i$ e $P_j$. Vediamo le possibili casistiche di esecuzione&lt;/p>
&lt;ul>
&lt;li>$P_i$ esegue per primo il controllo della condizione nel ciclo while. &lt;code>flag[j] == false&lt;/code> quindi viene selezionato per entrare nella sezione critica.&lt;/li>
&lt;li>$P_j$ esegue per primo. Esce dalla sezione rimanente, imposta &lt;code>flag[j] == true&lt;/code> e &lt;code>turn = i&lt;/code> per poi entrare nel ciclo while. &lt;code>flag[i] == true&lt;/code>, poiché $P_i$ è anch&amp;rsquo;esso nel ciclo while e &lt;code>turn = i&lt;/code>, quindi $P_j$ non può entrare nella sezione critica.&lt;/li>
&lt;/ul>
&lt;p>È quindi garantito che $P_i$ sia l&amp;rsquo;unico candidato in selezione per entrare nella sezione critica.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Attesa limitata:&lt;/strong> dall&amp;rsquo;esempio precedente possiamo vedere che dopo ogni esecuzione di $P_j$ è garantito che $P_i$ possa eseguire. Quindi $P_i$ deve attendere al massimo un&amp;rsquo;esecuzione di $P_j$ prima di poter eseguire la sezione critica.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>Non è garantito che la soluzione di Peterson funzioni nelle architetture moderne, poiché, per ottimizzare l&amp;rsquo;esecuzione, i processori possono invertire le istruzioni presenti nel codice. Se le istruzioni &lt;code>flag[i] = true&lt;/code> e &lt;code>turn = j&lt;/code> fossero scambiate, potrebbe accadere la seguente esecuzione:&lt;/p>
&lt;ul>
&lt;li>$P_i$ imposta &lt;code>turn = j&lt;/code>&lt;/li>
&lt;li>$P_j$ imposta &lt;code>turn = i&lt;/code> e poi &lt;code>flag[j] == true&lt;/code>&lt;/li>
&lt;li>$P_j$ entra nel ciclo while. &lt;code>flag[i] == false&lt;/code> quindi esce dal ciclo while ed entra nella sezione critica&lt;/li>
&lt;li>$P_i$ imposta &lt;code>flag[i] == true&lt;/code> ed entra nel ciclo while. &lt;code>turn == i&lt;/code> quindi esce dal ciclo while ed entra nella sezione critica.&lt;/li>
&lt;/ul>
&lt;p>Entrambi i processi sono entrati nella sezione critica. È necessario quindi utilizzare delle API appropriate per gestire la sincronizzazione.&lt;/p>
&lt;h2 id="64-supporto-hardware-per-la-sincronizzazione">6.4 Supporto hardware per la sincronizzazione&lt;/h2>
&lt;p>Dopo aver descritto una soluzione software al problema della sezione critica, passiamo ora a discutere di una soluzione hardware.&lt;/p>
&lt;h3 id="barriere-di-memoria">Barriere di memoria&lt;/h3>
&lt;p>Un &lt;strong>modello di memoria&lt;/strong> specifica le garanzie della memoria sull&amp;rsquo;integrità dei dati, in una specifica architettura. In generale, abbiamo due categorie:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Fortemente ordinata:&lt;/strong> dove una modifica della memoria su un processore è immediatamente visibile agli altri processori&lt;/li>
&lt;li>&lt;strong>Debolmente ordinata:&lt;/strong> dove una modifica alla memoria su un processore può non essere immediatamente visibile agli altri processori&lt;/li>
&lt;/ul>
&lt;p>Dal momento che i modelli di memoria variano di processore in processore, gli sviluppatori di un kernel non possono fare assunzioni sulla visibilità delle modifiche alla memoria.&lt;/p>
&lt;p>Per ovviare al problema, le architetture forniscono istruzioni che possono forzare la notifica e l&amp;rsquo;aggiornamento di qualunque cambio nella memoria a tutti processori. Tali istruzioni si chiamano &lt;strong>barriere di memoria (memory barriers)&lt;/strong> o &lt;strong>recinti di memoria (memory fences)&lt;/strong>. Quando una barriera di memoria viene eseguita, il sistema garantisce che tutte le operazioni di load e store siano completate prima di ogni altra load o store successiva, permettendo una consistenza nel buffer anche in caso di riordinamento della memoria. Per esempio nel seguente codice&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="n">x&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">100&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nf">memory_barrier&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">flag&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>viene garantito che &lt;code>x&lt;/code> sia assegnato prima di &lt;code>flag&lt;/code>. Questa tecnica può essere utilizzata per garantire l&amp;rsquo;efficacia della soluzione di Peterson. Le barriere di memoria sono operazioni a basso livello, utilizzate solamente dagli sviluppatori di kernel.&lt;/p>
&lt;h3 id="641-istruzioni-hardware">6.4.1 Istruzioni hardware&lt;/h3>
&lt;p>Molti computer moderni forniscono speciali istruzioni hardware per eseguire operazioni di memoria &lt;strong>atomiche&lt;/strong>, il cui contenuto è visto come una singola istruzione non separabile. Facciamo due esempi. Il primo è con un&amp;rsquo;istruzione atomica &lt;code>test_and_set()&lt;/code>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="kt">bool&lt;/span> &lt;span class="nf">test_and_set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">bool&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="n">target&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kt">bool&lt;/span> &lt;span class="n">rv&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="n">target&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="o">*&lt;/span>&lt;span class="n">target&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">true&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">rv&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>che ritorna il valore originale di &lt;code>target&lt;/code> e poi lo imposta a &lt;code>true&lt;/code>. La caratteristica importante è che l&amp;rsquo;operazione viene eseguita atomicamente, quindi se due di queste istruzioni vengono eseguite simultaneamente su due core differenti, saranno eseguite sequenzialmente, in un qualche ordine arbitrario.&lt;/p>
&lt;p>Se il sistema supporta &lt;code>test_and_set()&lt;/code> possiamo ad esempio implementare un meccanismo di lock col seguente codice&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="k">do&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nf">test_and_set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="n">lock&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">;&lt;/span> &lt;span class="cm">/* do nothing */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* critical section */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">lock&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">false&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* remainder section */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nb">true&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Per fare un altro esempio, prendiamo l&amp;rsquo;istruzione &lt;code>compare_and_swap()&lt;/code>, definita nel seguente modo&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="kt">int&lt;/span> &lt;span class="nf">compare_and_swap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">int&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">int&lt;/span> &lt;span class="n">expected&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">int&lt;/span> &lt;span class="n">new_value&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kt">int&lt;/span> &lt;span class="n">temp&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="n">value&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="n">expected&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="o">*&lt;/span>&lt;span class="n">value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">new_value&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">temp&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Questa istruzione si occupa di impostare la variabile &lt;code>value&lt;/code> col valore contenuto in &lt;code>new_value&lt;/code>, solamente se &lt;code>value == expected&lt;/code>. Ancora una volta, l&amp;rsquo;operazione è considerata atomica dal sistema. La mutua esclusione può essere raggiunta col seguente codice&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nb">true&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nf">compare_and_swap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="n">lock&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">;&lt;/span> &lt;span class="cm">/* do nothing */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* critical section */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">lock&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* remainder section */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Una variabile globale &lt;code>lock&lt;/code> viene impostata a &lt;code>0&lt;/code>. Il primo processo che invoca &lt;code>compare_and_swap()&lt;/code> imposta &lt;code>lock&lt;/code> a &lt;code>1&lt;/code> e tutte le chiamate successive non modificheranno più il valore, finché il processo entrato nella sezione critica non ne esce ed esegue &lt;code>lock = 0&lt;/code>.&lt;/p>
&lt;p>Questo algoritmo non garantisce il requisito di attesa limitata, poiché un processo in attesa potrebbe potenzialmente non entrare mai nella sezione critica. Il seguente codice ovvia al problema&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nb">true&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">waiting&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">true&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">key&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span> &lt;span class="n">waiting&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="n">key&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">key&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nf">compare_and_swap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="n">lock&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">waiting&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">false&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* critical section */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">j&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">i&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">%&lt;/span> &lt;span class="n">n&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">((&lt;/span>&lt;span class="n">j&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="n">i&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="o">!&lt;/span>&lt;span class="n">waiting&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">j&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">j&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">j&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">%&lt;/span> &lt;span class="n">n&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">j&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="n">i&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">lock&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">else&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">waiting&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">j&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">false&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* remainder section */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>La sezione d&amp;rsquo;entrata permette solamente a un processo di entrare nella sezione critica, che imposterà &lt;code>key = 0&lt;/code> e &lt;code>waiting[i] = false&lt;/code>. Una volta eseguita la sezione critica il processo scorre il vettore &lt;code>waiting&lt;/code> finché non trova un altro processo con &lt;code>waiting[j] == true&lt;/code> oppure scorre tutto il vettore &lt;code>waiting&lt;/code> e imposta &lt;code>j = i&lt;/code>. In tal caso, esce dal ciclo while della sezione d&amp;rsquo;uscita e controlla quale delle due condizioni si è verificata. Se non ha trovato processi in attesa, allora &lt;code>j == i&lt;/code> e il processo imposta &lt;code>lock = 0&lt;/code>, altrimenti imposta &lt;code>waiting[j] = false&lt;/code> permettendo al processo &lt;code>j&lt;/code> di entrare nella sezione critica. In questo modo, un processo attenderà al massimo $n - 1$ turni prima di entrare nella sezione critica, garantendo il requisito di attesa limitata.&lt;/p>
&lt;h3 id="642-variabili-atomiche">6.4.2 Variabili atomiche&lt;/h3>
&lt;p>L&amp;rsquo;istruzione &lt;code>compare_and_swap()&lt;/code> viene tipicamente utilizzata per costruire degli strumenti di controllo più complessi, come una &lt;strong>variabile atomica&lt;/strong> che fornisce operazioni atomiche su tipi base di dati, p.e. interi e booleani.&lt;/p>
&lt;p>I sistemi che implementano le variabili atomiche, includono delle funzioni per manipolare tali tipi di dato. Riportiamo una funzione la funzione &lt;code>increment()&lt;/code>, utile per risolvere il problema dell&amp;rsquo;aggiornamento di un contatore, come nel paradigma produttore consumatore (è possibile definire una funzione &lt;code>decrement()&lt;/code> analoga)&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="kt">void&lt;/span> &lt;span class="nf">increment&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">atomic_int&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="n">v&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kt">int&lt;/span> &lt;span class="n">temp&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">do&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">temp&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="n">v&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">temp&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nf">compare_and_swap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">v&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">temp&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">temp&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In questo caso il valore in &lt;code>v&lt;/code> va incrementato. Si effettua l&amp;rsquo;assegnazione di &lt;code>temp = *v&lt;/code> e a seguire un aggiornamento di &lt;code>v&lt;/code> a &lt;code>temp + 1&lt;/code>. Se un altro processo concorrente esegue, invece, &lt;code>compare_and_swap&lt;/code> prima del processo corrente, &lt;code>*v&lt;/code> è pari a &lt;code>temp + 1&lt;/code>. Nel processo corrente, quindi, &lt;code>*v&lt;/code> è ora diverso da &lt;code>temp&lt;/code> e la condizione nel ciclo while fallisce. Il corpo del ciclo viene rieseguito e il valore di &lt;code>temp&lt;/code> viene riassegnato al nuovo valore dentro &lt;code>v&lt;/code>, per poi provare di nuovo a eseguire l&amp;rsquo;incremento.&lt;/p>
&lt;p>È importante notare che sebbene le variabili atomiche forniscano aggiornamenti atomici, non risolvono interamente le race condition in tutte le circostanze, Nel paradigma produttore potremmo avere due consumatori che attendono e una coda vuota. Un produttore produce un prodotto impostando una variabile atomica &lt;code>count&lt;/code> a &lt;code>1&lt;/code>. I due consumatori escono dal ciclo while di attesa ed entrambi procedono a consumare il prodotto, generando una race condition.&lt;/p>
&lt;p>Le variabili atomiche sono utilizzate spesso per singoli aggiornamenti di dati condivisi come i contatori e i generatori di sequenze.&lt;/p>
&lt;h3 id="643-lock-mutex">6.4.3 Lock mutex&lt;/h3>
&lt;p>Le soluzioni finora viste sono complicate e poco accessibili agli sviluppatori di applicazioni. Esistono alcuni strumenti più semplici per risolvere il problema della sezione critica nel software ad alto livello. Uno di questi è il cossiddetto &lt;strong>lock mutex&lt;/strong> (dove &lt;em>mutex&lt;/em> sta per &lt;em>mutual exclusion&lt;/em>). mutex mette a disposizione due funzioni:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="nf">acquire&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="n">available&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">;&lt;/span> &lt;span class="cm">/* busy wait */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">available&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">false&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>che acquisisce un lock.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="nf">release&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">available&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">true&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>che rilascia il lock. Le due call devono essere atomiche e possono essere quindi implementate con &lt;code>compare_and_swap()&lt;/code>.&lt;/p>
&lt;p>Lo svantaggio principale dell&amp;rsquo;implementazione sopracitata è che richiede il meccanismo di &lt;em>busy waiting&lt;/em> in cui i processi entrano per attendere il loro turno nell&amp;rsquo;acquisizione del lock. Ciò consuma cicli CPU senza che vengano eseguite realmente istruzioni e peggiora le prestazioni nei sistemi multiprogramming dove molti processi competono in un singolo core della CPU.&lt;/p>
&lt;p>Questo tipo di lock si chiama anche &lt;strong>spinlock&lt;/strong> poiché il processo &amp;ldquo;gira&amp;rdquo; (spins) finché il lock non diventa disponibile. Gli spinlock hanno il vantaggio di non richiedere context switch quando un processo attende il lock, nei sistemi multicore. Infatti, nei casi in cui il lock venga tenuto per poco tempo, un thread può &amp;ldquo;girare&amp;rdquo; su un core mentre un altro processo esegue la sezione critica su un altro core, senza la necessità di context switch. Per tale motivo gli spinlock sono comunemente usati in molti s.o.&lt;/p>
&lt;p>I lock mutex sono considerati i più semplici sistemi di sincronizzazione&lt;/p>
&lt;h2 id="65-semafori">6.5 Semafori&lt;/h2>
&lt;p>Un &lt;strong>semaforo&lt;/strong> è considerato una tecnica più robusta dei mutex per la sincronizzazione dei processi, introdotto dall&amp;rsquo;informatico olandese Edsger Dijkstra. Un semaforo $S$ è un intero che viene acceduto tramite due operazioni atomiche: &lt;code>wait()&lt;/code> e &lt;code>signal()&lt;/code>. Spesso le operazioni vengono chiamate anche P (dall&amp;rsquo;olandese &lt;em>proberen/testare&lt;/em>) e V (dall&amp;rsquo;olandese &lt;em>verhogen/incrementare&lt;/em>). Le definizioni delle funzioni sono le seguenti&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="nf">wait&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">S&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">S&lt;/span> &lt;span class="o">&amp;lt;=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">;&lt;/span> &lt;span class="c1">// busy wait
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="n">S&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="nf">signal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">S&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">S&lt;/span>&lt;span class="o">++&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In aggiunta all&amp;rsquo;atomicità, nel caso della &lt;code>wait()&lt;/code> il controllo della condizione deve avvenire senza interruzioni.&lt;/p>
&lt;h3 id="651-utilizzo-dei-semafori">6.5.1 Utilizzo dei semafori&lt;/h3>
&lt;p>Gli s.o. distinguono tra due tipi di semafori&lt;/p>
&lt;ul>
&lt;li>&lt;strong>semafori contatori:&lt;/strong> &lt;code>S&lt;/code> può essere maggiore di $1$&lt;/li>
&lt;li>&lt;strong>semafori binari:&lt;/strong> &lt;code>S&lt;/code> vale al massimo $1$. Il meccanismo è simile a quello dei mutex&lt;/li>
&lt;/ul>
&lt;p>I semafori contatori possono essere utilizzati per il controllo dell&amp;rsquo;accesso a risorse con un numero finito di istanze. Sono inizializzati al numero di risorse disponibili e ogni che viene chiamata &lt;code>wait()&lt;/code>, &lt;code>S&lt;/code> viene decrementato. Una volta raggiunto lo &lt;code>0&lt;/code>, la &lt;code>wait()&lt;/code> diviene bloccante.&lt;/p>
&lt;p>Possiamo utilizzare i semafori anche per problemi di sincronizzazione. Consideriamo due processi concorrenti: $P_1$ con codice &lt;code>S1&lt;/code> e $P_2$ con codice &lt;code>S2&lt;/code>. Supponiamo che &lt;code>S2&lt;/code> vada eseguito solamente dopo &lt;code>S1&lt;/code>. Possiamo implementare la sincronizzazione utilizzando un semaforo condiviso &lt;code>synch&lt;/code>, inizializzato a &lt;code>0&lt;/code>. il codice di $P_1$ è&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="n">S1&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nf">signal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">synch&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>mentre quello di $P_2$ è&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="nf">wait&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">synch&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">S2&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Poiché &lt;code>synch&lt;/code> è inizializzato a &lt;code>0&lt;/code>, $P_2$ eseguirà &lt;code>S2&lt;/code> solo dopo che $P_1$ avrà invocato &lt;code>signal(synch)&lt;/code>, ovvero dopo l&amp;rsquo;esecuzione di &lt;code>S1&lt;/code>.&lt;/p>
&lt;h3 id="652-implementazione-di-un-semaforo">6.5.2 Implementazione di un semaforo&lt;/h3>
&lt;p>Le definizioni di &lt;code>wait()&lt;/code> e &lt;code>signal()&lt;/code> sopra presentate, hanno lo stesso problema di busy waiting dei mutex. Per ovviare al problema possiamo modificare la definizione di &lt;code>wait()&lt;/code> facendo in modo che quando il valore del semaforo non è positivo, il processo si sospenda mettendosi nella coda di wait. Il processo viene poi risvegliato con una chiamata &lt;code>signal()&lt;/code> che esegue un&amp;rsquo;operazione di &lt;code>wakeup()&lt;/code>, che permette al processo di spostarsi nella coda di ready. L&amp;rsquo;implementazione del semaforo è la seguente:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="k">typedef&lt;/span> &lt;span class="k">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kt">int&lt;/span> &lt;span class="n">value&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">struct&lt;/span> &lt;span class="n">process&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="n">list&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span> &lt;span class="n">semaphore&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Ogni semaforo ha un valore intero e una lista di processi. Quando un processo attende un semaforo, questo viene aggiunto alla lista dei processi. L&amp;rsquo;operazione &lt;code>signal()&lt;/code> rimuove un processo dalla lista e lo risveglia.&lt;/p>
&lt;p>L&amp;rsquo;operazione di &lt;code>wait()&lt;/code> è implementata nel seguente modo:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="nf">wait&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">semaphore&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="n">S&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">S&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="n">value&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">S&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="n">value&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">add&lt;/span> &lt;span class="n">this&lt;/span> &lt;span class="n">process&lt;/span> &lt;span class="n">to&lt;/span> &lt;span class="n">S&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="n">list&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">sleep&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>e l&amp;rsquo;operazione di &lt;code>signal()&lt;/code> è invece:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="nf">signal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">semaphore&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="n">S&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">S&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="n">value&lt;/span>&lt;span class="o">++&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">S&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="n">value&lt;/span> &lt;span class="o">&amp;lt;=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">remove&lt;/span> &lt;span class="n">a&lt;/span> &lt;span class="n">process&lt;/span> &lt;span class="n">P&lt;/span> &lt;span class="n">from&lt;/span> &lt;span class="n">S&lt;/span>&lt;span class="o">-&amp;gt;&lt;/span>&lt;span class="n">list&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">wakeup&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">P&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>L&amp;rsquo;operazione &lt;code>sleep()&lt;/code> sospende il processo che lo invoca. L&amp;rsquo;operazione &lt;code>wakeup()&lt;/code> fa riprendere l&amp;rsquo;esecuzione.&lt;/p>
&lt;p>Con questa versione, un semaforo può assumere valori negativi, pari al numero di processi in attesa. La lista di processi può essere implementata con un campo link nel PCB del processo. Per assicurare un&amp;rsquo;attesa limitata, è possibile utilizzare una coda FIFO, ma qualunque strategia di accodamento va bene.&lt;/p>
&lt;p>Per garantire l&amp;rsquo;atomicità delle operazioni &lt;code>wait()&lt;/code> e &lt;code>signal()&lt;/code> è necessario inibire gli interrupt durante la loro esecuzione. Nei sistemi multicore, l&amp;rsquo;inibizione va fatta su tutti i processori, tuttavia ciò può comportare una diminuzione delle performance. Per tale motivo, i sistemi SMP devono fornire anche alternative come &lt;code>compare_and_swap()&lt;/code> o gli spinlock.&lt;/p>
&lt;h2 id="66-monitor">6.6 Monitor&lt;/h2>
&lt;p>I semafori e i mutex richiedono un’attenta progettazione del codice. È importante garantire che tutti i processi concorrenti chiamino prima la &lt;code>wait()&lt;/code> e poi la &lt;code>signal()&lt;/code>. Nel caso di sincronizzazioni complesse con molteplici processi in gioco, l’aderenza a questa regola può essere complicata. Citiamo a tal proposito, i &lt;strong>monitor&lt;/strong>, strumenti a più alto livello che tentano di risolvere questo problema (non li copriremo in questo corso).&lt;/p>
&lt;!-- ### Utilizzo dei monitor
Il tipo monitor è un **tipo di dato astratto (Abstract Data Type, ADT)**, ovvero un tipo di dato che incapsula dati e una serie di funzioni indipendentemente dal tipo di implementazione specifica dell`ADT. In particolare, un monitor contiene:
- Un insieme di operazioni per cui è garantita l’esecuzione con mutua esclusione
- Delle variabili condivise
- Delle funzioni sulle variabili condivise
L’implementazione di un monitor è la seguente:
```java
monitor monitor_name {
/* shared variable declarations */
function P1(...) {
...
}
function P2(...) {
...
}
.
.
.
function Pn(...) {
...
}
initialization_code(...) {
...
}
}
```
Tra le variabili condivise, è possibile definirne alcune di tipo `condition`, per esempio `condition x`, le cui uniche operazioni disponibili sono `wait()` e `signal()`. A differenza dei semafori, `x.signal()` non ha alcun effetto se non ci sono processi in attesa.
Supponiamo di avere un processo $Q$ in attesa e un processo $P$ che fa una chiamata `signal()` che risveglia $Q$. Non è possibile far eseguire entrambi i processi dentro il monitor, per cui bisogna progettare una strategia, tra le seguenti:
- **signal and wait:** il processo $P$ lascia che $Q$ esegua e attende che esca dal monitor oppure che un’altra condizione si verifichi
- **signal and continue:** il processo $Q$ attende che il processo $P$ esca dal monitor oppure che si verifichi un’altra condizione
Una terza soluzione potrebbe essere quella di progettare $P$ in maniera tale che esca dal monitor subito dopo che ha chiamato `signal()`.
### Implementare un monitor utilizzando i semafori
È possibile implementare un monitor utilizzando un semaforo binario `mutex`, inizializzato a `1`. Assumiamo una strategia signal and wait, e abbiamo bisogno quindi di un semaforo aggiuntivo `next` inizializzato a `0`, sul quale i processi possono autosospendersi con `signal()`. Ogni procedura esterna `F` viene sostituita col seguente codice:
```c
wait(mutex);
...
F
...
if (next_count > 0)
signal(next);
else
signal(mutex);
```
In questo modo abbiamo mutua esclusione all'interno del monitor. Per ogni variabile condition `x` abbiamo un semaforo `x_sem` e una variabile `x_count` entrambi inizializzati a `0`. L'operazione `x.wait()` è implementata come segue:
```c
x_count++;
if (next_count > 0)
signal(next);
else
signal(mutex);
wait(x_sem);
x_count--;
```
mentre la `signal()` viene implementata come:
```c
if (x_count > 0) {
next_count++;
signal(x_sem);
wait(next);
next_count--;
}
```
È possibile implementare delle versioni meno generali e più veloci di quella proposta.
### Risveglio dei processi all'interno di un monitor
Se un'operazione `x.signal()` viene eseguita e vari processi sono in stato di sleep, quale tra di essi va risvegliato? Una soluzione semplice potrebbe essere quella di utilizzare l'algoritmo FCFS.
Una soluzione più adeguata consiste nell'utilizzare la `conditional wait`. Questo costrutto viene eseguito tramite `x.wait(c)`, dove `c` è un intero chiamato `numero di priorità (priority number)` che viene salvato assieme al numero del processo che viene sospeso. Per illustrare il meccanismo consideriamo il seguente codice
```java
monitor ResourceAllocator
{
boolean busy;
condition x;
void acquire(int time) {
if (busy)
x.wait(time);
busy = true;
}
void release() {
busy = false;
x.signal();
}
initialization code() {
busy = false;
}
}
```
che controlla l'allocazione di una singola risorsa tra i vari processi che se la contendono. Il monitor la assegna al processo che ne ha fatto richiesta per il minor tempo possibile.
```java
R.acquire(t);
...
access the resource;
R.release();
```
Dove `R` è un `ResourceAllocator`.
Quest'implementazione presenta comunque dei problemi:
- Un processo potrebbe accedere alla risorsa senza averne il permesso
- Un processo potrebbe non rilasciare mai la risorsa, una volta che ci ha acceduto
- Un processo potrebbe provare a rilasciare una risorsa a cui non ha mai avuto accesso
- Un processo potrebbe richiedere la stessa risorsa due volte (senza rilasciarla)
È possibile spostare la logica di accesso alla risorsa all'interno del monitor ma questo significherebbe rimpiazzare lo scheduling dell'accesso con quello del monitor (invece che con quello sviluppato da noi). -->
&lt;h2 id="67-liveness">6.7 Liveness&lt;/h2>
&lt;p>Una possibile conseguenza dell&amp;rsquo;utilizzo di tool di sincronizzazione è quella che un processo attenda indefinitamente prima di accedere alla sezione critica. La &lt;strong>liveness&lt;/strong> si riferisce a un insieme di proprietà che un sistema deve garantire per far sì che i suoi processi procedano. Un &lt;strong>fallimento della liveness&lt;/strong> accade quando un processo non rispetta tali proprietà.&lt;/p>
&lt;p>Un semplice esempio è il loop infinito. Una busy wait può rappresentare una &lt;em>possibilità&lt;/em> di fallimento della liveness, se non è implementata correttamente.&lt;/p>
&lt;h3 id="671-deadlock">6.7.1 Deadlock&lt;/h3>
&lt;p>Un caso particolare di fallimento della liveness è il cosiddetto &lt;strong>deadlock&lt;/strong>, che accade quando in un semaforo due processi attendono un evento generato rispettivamente dall&amp;rsquo;altro processo. Consideriamo il seguente esempio:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl"> &lt;span class="n">P0&lt;/span> &lt;span class="n">P1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">wait&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">S&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nf">wait&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Q&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">wait&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Q&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nf">wait&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">S&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">.&lt;/span> &lt;span class="p">.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">.&lt;/span> &lt;span class="p">.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">.&lt;/span> &lt;span class="p">.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">signal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">S&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nf">signal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Q&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">signal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Q&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nf">signal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">S&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Se $P_0$ esegue &lt;code>wait(S)&lt;/code> e $P_1$ &lt;code>wait(Q)&lt;/code>.&lt;/p>
&lt;ul>
&lt;li>Se $P_0$ esegue &lt;code>wait(Q)&lt;/code> dovrà attendere che $P_1$ faccia &lt;code>signal(Q)&lt;/code>&lt;/li>
&lt;li>Se $P_1$ esegue &lt;code>wait(S)&lt;/code> dovrà attendere che $P_0$ faccia &lt;code>signal(S)&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>In questo modo i processi attendono un&amp;rsquo;operazione che non verrà mai eseguita comportando un deadlock. In generale, un insieme di processi è in deadlock, se ogni processo dell&amp;rsquo;insieme è in attesa di un evento generato da uno degli altri processi nell&amp;rsquo;insieme.&lt;/p>
&lt;h3 id="672-inversione-di-priorità">6.7.2 Inversione di priorità&lt;/h3>
&lt;p>Supponiamo di avere tre processi con priorità $L$, $M$, $H$, tali che $L &amp;lt; M &amp;lt; H$. Assumiamo che $H$ richieda una risorsa controllata da un semaforo $S$, attualmente acceduto da $L$. Supponiamo ora che $M$ diventi eseguibile e soppianti il processo $L$. Ora $H$ dovrà aspettare l&amp;rsquo;esecuzione di $M$ per poter accedere alla risorsa.&lt;/p>
&lt;p>Questo problema di liveness è chiamato &lt;strong>inversione di priorità (priority inversion)&lt;/strong> e può accadere soltanto in sistemi con più di due priorità. Tipicamente è possibile evitare questo problema implementando un &lt;strong>protocollo di ereditarietà della priorità (priority-inheritance protocol)&lt;/strong>. Con questo meccanismo, se un processo $H$ ad alta priorità richiede una risorsa detenuta da un processo $L$ a bassa priorità, quest&amp;rsquo;ultimo eredità la priorità del processo $H$ in maniera da prevenire altri processi con priorità media $M$ di poterla richiedere. Una volta concluso l&amp;rsquo;utilizzo, il processo a bassa priorità torna ad avere la sua priorità originale $L$&lt;/p>
&lt;h2 id="68-valutazione">6.8 Valutazione&lt;/h2>
&lt;p>Riportiamo alcune semplici strategie per determinare quale strumento di sincronizzazione usare, tra quelli che abbiamo presentato in questa lezione.&lt;/p>
&lt;p>Barriere di memoria e istruzioni atomiche sono tecniche di basso livello, spesso utilizzate per implementare meccanismi di sincronizzazione più sofisticati.&lt;/p>
&lt;p>L&amp;rsquo;approccio &lt;code>compare_and_swap()&lt;/code> (CAS) è un meccanismo ottimista in cui una variabile viene aggiornata e solo dopo viene controllato se un altro thread sta aggiornando la variabile. Al contrario il mutex è un approccio pessimistico in quanto si assume che un altro thread stia concorrentemente aggiornando la variabile per cui viene acquisito il lock prima di fare qualunque update. In generale:&lt;/p>
&lt;ul>
&lt;li>Se un lock non è conteso tra i processi, è preferibile una protezione CAS in quanto più veloce&lt;/li>
&lt;li>Se un lock viene conteso moderatamente tra i processi, CAS risulterà più veloce della sincronizzazione tradizionale&lt;/li>
&lt;li>Se un lock viene conteso spesso tra i processi, la sincronizzazione tradizionale risulterà essere più veloce di quella basata su CAS&lt;/li>
&lt;/ul>
&lt;p>In aggiunta, gli interi atomici, essendo molto leggeri, sono spesso utili per aggiornare variabili come contatori condivisi. In generale i lock mutex sono semplici da utilizzare e richiedono meno overhead dei semafori, che a loro volta sono meno pesanti dei monitor.&lt;/p>
&lt;div id="references-1784797996885332036" class="references" style="margin-top: 5rem;">
&lt;p>&lt;strong>References&lt;/strong>&lt;/p>
A. Silberschatz, P. B. Galvin, G. Gagne, &lt;em>Sistemi Operativi. Concetti ed Esempi&lt;/em>, Decima edizione. Ch. 6.
&lt;/div>
&lt;script>
document.addEventListener('DOMContentLoaded', function () {
var ref = document.getElementById('references-1784797996885332036');
var fn = document.querySelector('.footnotes');
if (ref &amp;&amp; fn &amp;&amp; fn.parentNode) {
fn.parentNode.insertBefore(ref, fn.nextSibling);
}
});
&lt;/script></description></item><item><title>Sincronizzazione dei processi</title><link>https://aretor.it/courses/os2324/06_proc_sync/</link><pubDate>Mon, 08 Apr 2024 00:00:00 +0000</pubDate><guid>https://aretor.it/courses/os2324/06_proc_sync/</guid><description>&lt;p>In questa lezione andremo a studiare i meccanismi per controllare l&amp;rsquo;accesso di
strutture condivise da parte dei thread, mantenendo l&amp;rsquo;integrità dei dati.&lt;/p>
&lt;h2 id="background">Background&lt;/h2>
&lt;p>Ricordiamo che un &lt;strong>processo cooperativo&lt;/strong> può essere influenzato da altri processi con i quali condivide codice o dati. Questo comportamento può contribuire a generare problemi relativi all&amp;rsquo;integrità dei dati.&lt;/p>
&lt;p>Presentiamo subito un esempio, con il paradigma produttore-consumatore.&lt;/p>
&lt;p>Questo è il codice del produttore.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nb">true&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* produce an item in next_produced */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">count&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="n">BUFFER_SIZE&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">;&lt;/span> &lt;span class="cm">/* do nothing */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">buffer&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">in&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">next_produced&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">in&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">in&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">%&lt;/span> &lt;span class="n">BUFFER_SIZE&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">count&lt;/span>&lt;span class="o">++&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Questo invece è il codice del consumatore.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nb">true&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">count&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">;&lt;/span> &lt;span class="cm">/* do nothing */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">next_consumed&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">buffer&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">out&lt;/span>&lt;span class="p">];&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">out&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">out&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">%&lt;/span> &lt;span class="n">BUFFER_SIZE&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">count&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* consume the item in next_consumed */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Se questi due snippet di codice vengono eseguiti concorrentemente, può accadere che venga eseguita l&amp;rsquo;operazione &lt;code>count++&lt;/code> e &lt;code>count--&lt;/code> contemporaneamente. Supponiamo che inizialmente il valore di &lt;code>count&lt;/code> sia &lt;code>5&lt;/code>. Possono sorgere due casistiche:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>Si verificano sequenzialmente le seguenti due istruzioni&lt;/p>
&lt;ul>
&lt;li>&lt;code>count&lt;/code> (valore &lt;code>5&lt;/code>) viene acceduto dal consumatore che esegue &lt;code>count--&lt;/code> (valore &lt;code>4&lt;/code>)&lt;/li>
&lt;li>&lt;code>count&lt;/code> (valore &lt;code>4&lt;/code>) viene acceduto dal produttore che esegue &lt;code>count++&lt;/code> (valore &lt;code>5&lt;/code>)&lt;/li>
&lt;/ul>
&lt;p>Il risultato finale (valore &lt;code>5&lt;/code>) è corretto. Notate come il produttore possa eseguire prima del consumatore, senza cambiare il risultato finale.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>Si verificano sequenzialmente queste istruzioni&lt;/p>
&lt;ul>
&lt;li>&lt;code>count&lt;/code> viene acceduto dal consumatore (valore &lt;code>5&lt;/code>)&lt;/li>
&lt;li>&lt;code>count&lt;/code> viene acceduto dal produttore (valore &lt;code>5&lt;/code>)&lt;/li>
&lt;li>Il consumatore esegue &lt;code>count--&lt;/code> (valore &lt;code>4&lt;/code>)&lt;/li>
&lt;li>Il produttore esegue &lt;code>count++&lt;/code> e sovrascrive il valore aggiornato dal consumatore, al quale non ha avuto accesso (valore &lt;code>6&lt;/code>)&lt;/li>
&lt;/ul>
&lt;p>Il risultato finale (valore &lt;code>6&lt;/code>) è sbagliato. Notate come possa accadere anche il caso opposto&lt;/p>
&lt;ul>
&lt;li>&lt;code>count&lt;/code> viene acceduto dal consumatore (valore &lt;code>5&lt;/code>)&lt;/li>
&lt;li>&lt;code>count&lt;/code> viene acceduto dal produttore (valore &lt;code>5&lt;/code>)&lt;/li>
&lt;li>Il produttore esegue &lt;code>count++&lt;/code> (valore &lt;code>6&lt;/code>)&lt;/li>
&lt;li>Il consumatore esegue &lt;code>count--&lt;/code> (valore &lt;code>4&lt;/code>)&lt;/li>
&lt;/ul>
&lt;p>Anche in questo caso il risultato finale (valore &lt;code>4&lt;/code>) è sbagliato.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;p>Le casisitiche sbagliate del punto 2, vengono chiamate &lt;strong>race conditions&lt;/strong>. Per proteggersi da tali fenomeni è necessario garantire che solo un processo alla volta modifichi la variabile condivisa &lt;code>count&lt;/code>, sincronizzando i processi.&lt;/p>
&lt;h2 id="il-problema-della-sezione-critica">Il problema della sezione critica&lt;/h2>
&lt;p>Iniziamo le nostre considerazioni discutendo del &lt;strong>problema della sezione critica&lt;/strong>. Consideriamo un sistema con $n$ processi $[P_0, \dots, P_{n - 1}]$. Ogni processo ha un segmento di codice chiamato &lt;strong>sezione critica&lt;/strong> nel quale accede e aggiorna dei dati condivisi. Se vogliamo mantenere l&amp;rsquo;integrità dei dati, non è possibile lasciare eseguire la sezione critica a due processi concorrentemente.&lt;/p>
&lt;p>Il problema della sezione critica consiste nel progettare un protocollo che sincronizzi l&amp;rsquo;attività dei processi per evitare ciò. Ogni processo deve richiedere l&amp;rsquo;accesso alla sua sezione, e la parte di codice preposta a questo scopo viene chiamata &lt;strong>sezione d&amp;rsquo;entrata (entry section)&lt;/strong>. Quest&amp;rsquo;ultima è seguita da una &lt;strong>sezione di uscita (exit section)&lt;/strong>. Il codice rimanente è chiamato &lt;strong>sezione rimanente (remainder section)&lt;/strong>. Il codice di un processo deve quindi racchiudere la sua sezione critica tra la sezione d&amp;rsquo;entrata e quella d&amp;rsquo;uscita.&lt;/p>
&lt;p>Una soluzione al problema della sezione critica richiede che siano soddisfatti i seguenti tre requisiti:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Mutua esclusione:&lt;/strong> se il processo $P_i$ sta eseguendo la sua sezione critica, nessun altro processo può eseguirla in quel momento&lt;/li>
&lt;li>&lt;strong>Progresso:&lt;/strong> se nessun processo sta eseguendo la propria sezione critica e alcuni processi vogliono entrarci, allora solamente quelli che non stanno eseguendo la sezione rimanente possono partecipare alla selezione in cui si decide chi entrerà. Questa selezione non può essere posticipata indefinitivamente.&lt;/li>
&lt;li>&lt;strong>Attesa limitata:&lt;/strong> esiste un limite al numero di volte in cui gli altri processi possono accedere alla loro sezione critica dopo che un processo ha fatto richiesta di accesso alla propria e prima che tale richiesta venga garantita.&lt;/li>
&lt;/ol>
&lt;p>Non facciamo alcuna assunzione sulla velocità dei singoli processi, se non che questa sia non negativa.&lt;/p>
&lt;p>Riportiamo due esempi in cui il problema della sezione critica compare:&lt;/p>
&lt;ul>
&lt;li>Una struttura kernel con cui il sistema operativo tiene traccia dei file aperti. Tale struttura può essere modificata quando i processi aprono o chiudono file. Se due processi vogliono aprire lo stesso file, la lista dev&amp;rsquo;essere sincronizzata per poter evitare race condition.&lt;/li>
&lt;li>Due processi genitori eseguono &lt;code>fork()&lt;/code>. Il contatore pid da assegnare ai nuovi processi figli dev&amp;rsquo;essere sincronizzato per evitare che venga assegnato lo stesso pid a entrambi i processi&lt;/li>
&lt;/ul>
&lt;p>Il problema della sezione critica può essere risolto facilmente in contesti single-core, evitando gli interrupt quando una variabile condivisa sta venendo aggiornata. Sfortunatamente questa soluzione non è adatta a sistemi multicore dove la notifica della disabilitazione di interrupt a tutti i processori aggiunge un overhead. Inoltre, si consideri l&amp;rsquo;effetto sul clock di sistema quando questo viene aggiornato con gli interrupt.&lt;/p>
&lt;p>Esistono due approcci generali al problema e consistono nello sviluppo di:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>kernel non-prioritari:&lt;/strong> non permettono che un processo che esegue nel kernel mode venga soppiantato da un altro processo. Questo kernel non è soggetto a race condition&lt;/li>
&lt;li>&lt;strong>kernel prioritari:&lt;/strong> permettono che un processo che esegue nel kernel venga soppiantato. La loro progettazione è particolarmente difficile in architetture SMP, tuttavia la loro interattività garantisce un&amp;rsquo;esperienza più fluida, specialmente in contesti real-time&lt;/li>
&lt;/ul>
&lt;h2 id="soluzione-di-peterson">Soluzione di Peterson&lt;/h2>
&lt;p>Ora illustriamo la classica soluzione al problema della sezione critica: la &lt;strong>soluzione di Peterson&lt;/strong>. Assumiamo due processi $P_i$ e $P_j$. La soluzione di Peterson richiede che i due processi condividano due dati:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="kt">int&lt;/span> &lt;span class="n">turn&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">boolean&lt;/span> &lt;span class="n">flag&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">];&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Se &lt;code>turn == i&lt;/code> allora $P_i$ &lt;em>ha il permesso&lt;/em> di entrare nella sezione critica. Se &lt;code>flag[i]&lt;/code> è impostato a &lt;code>true&lt;/code> $P_i$ &lt;em>è pronto&lt;/em> per entrare nella sezione critica.&lt;/p>
&lt;p>Per entrare nella sezione critica $P_i$ imposta &lt;code>flag[i] = true&lt;/code> e poi &lt;code>turn = j&lt;/code> asserendo che se l&amp;rsquo;altro processo vuole entrare nella sezione critica può farlo. Se entrambi i processi volessero entrare nella sezione critica nello stesso momento &lt;code>turn&lt;/code> conterrebbe il valore &lt;code>i&lt;/code> o &lt;code>j&lt;/code> e solo uno dei due processi potrebbe effettivamente entrare.&lt;/p>
&lt;p>Il codice per il processo $P_i$ è il seguente:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="k">while&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">true&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">flag&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">true&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">turn&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">j&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">flag&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">j&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="n">turn&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="n">j&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* critical section */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">flag&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">false&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* remainder section */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>La soluzione è corretta poiché rispetta le tre proprietà:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>Mutua esclusione:&lt;/strong> $P_i$ entra nella sua sezione critica solo se &lt;code>flag[j] == false&lt;/code> o &lt;code>turn == i&lt;/code>. Può accadere che &lt;code>flag[0] == flag[1] == true&lt;/code> e che &lt;code>turn&lt;/code> venga aggiornato contemporaneamente, tuttavia il suo valore sarà uno soltanto tra &lt;code>i&lt;/code> e &lt;code>j&lt;/code> garantendo l&amp;rsquo;accesso alla sezione critica solamente a un processo alla volta.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Progresso:&lt;/strong> supponiamo che $P_j$ sia nella sezione rimanente e che $P_i$ sia nel ciclo while. Se questa proprietà fosse verificata, solamente $P_i$ potrebbe accedere alla sezione critica, non importa quale processo venga eseguito prima tra $P_i$ e $P_j$. Vediamo le possibili casistiche di esecuzione&lt;/p>
&lt;ul>
&lt;li>$P_i$ esegue per primo il controllo della condizione nel ciclo while. &lt;code>flag[j] == false&lt;/code> quindi viene selezionato per entrare nella sezione critica.&lt;/li>
&lt;li>$P_j$ esegue per primo. Esce dalla sezione rimanente, imposta &lt;code>flag[j] == true&lt;/code> e &lt;code>turn = i&lt;/code> per poi entrare nel ciclo while. &lt;code>flag[i] == true&lt;/code>, poiché $P_i$ è anch&amp;rsquo;esso nel ciclo while e &lt;code>turn = i&lt;/code>, quindi $P_j$ non può entrare nella sezione critica.&lt;/li>
&lt;/ul>
&lt;p>È quindi garantito che $P_i$ sia l&amp;rsquo;unico candidato in selezione per entrare nella sezione critica.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Attesa limitata:&lt;/strong> dall&amp;rsquo;esempio precedente possiamo vedere che dopo ogni esecuzione di $P_j$ è garantito che $P_i$ possa eseguire. Quindi $P_i$ deve attendere al massimo un&amp;rsquo;esecuzione di $P_j$ prima di poter eseguire la sezione critica.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>Non è garantito che la soluzione di Peterson funzioni nelle architetture moderne, poiché, per ottimizzare l&amp;rsquo;esecuzione, i processori possono invertire le istruzioni presenti nel codice. Se le istruzioni &lt;code>flag[i] = true&lt;/code> e &lt;code>turn = j&lt;/code> fossero scambiate, potrebbe accadere la seguente esecuzione:&lt;/p>
&lt;ul>
&lt;li>$P_i$ imposta &lt;code>turn = j&lt;/code>&lt;/li>
&lt;li>$P_j$ imposta &lt;code>turn = i&lt;/code> e poi &lt;code>flag[j] == true&lt;/code>&lt;/li>
&lt;li>$P_j$ entra nel ciclo while. &lt;code>flag[i] == false&lt;/code> quindi esce dal ciclo while ed entra nella sezione critica&lt;/li>
&lt;li>$P_i$ imposta &lt;code>flag[i] == true&lt;/code> ed entra nel ciclo while. &lt;code>turn == i&lt;/code> quindi esce dal ciclo while ed entra nella sezione critica.&lt;/li>
&lt;/ul>
&lt;p>Entrambi i processi sono entrati nella sezione critica quindi si possono verificare delle race condition. È necessario quindi utilizzare delle API appropriate per gestire la sincronizzazione.&lt;/p>
&lt;h2 id="supporto-hardware-per-la-sincronizzazione">Supporto hardware per la sincronizzazione&lt;/h2>
&lt;p>Dopo aver descritto una soluzione software al problema della sezione critica, passiamo ora a discutere di una soluzione hardware.&lt;/p>
&lt;h3 id="barriere-di-memoria">Barriere di memoria&lt;/h3>
&lt;p>Un &lt;strong>modello di memoria&lt;/strong> specifica le garanzie della memoria sull&amp;rsquo;integrità dei dati, in una specifica architettura. In generale, abbiamo due categorie:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Fortemente ordinata:&lt;/strong> dove una modifica della memoria su un processore è immediatamente visibile agli altri processori&lt;/li>
&lt;li>&lt;strong>Debolmente ordinata:&lt;/strong> dove una modifica alla memoria su un processore può non essere immediatamente visibile agli altri processori&lt;/li>
&lt;/ul>
&lt;p>Dal momento che i modelli di memoria variano di processore in processore, gli sviluppatori di un kernel non possono fare assunzioni sulla visibilità delle modifiche alla memoria.&lt;/p>
&lt;p>Per ovviare al problema, le architetture forniscono istruzioni che possono forzare la notifica e l&amp;rsquo;aggiornamento di qualunque cambio nella memoria a tutti processori. Tali istruzioni si chiamano &lt;strong>barriere di memoria (memory barriers)&lt;/strong> o &lt;strong>recinti di memoria (memory fences)&lt;/strong>. Quando una barriera di memoria viene eseguita, il sistema garantisce che tutte le operazioni di load e store siano completate prima di ogni altra load o store successiva, permettendo una consistenza nel buffer anche in caso di riordinamento della memoria. Per esempio nel seguente codice&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="n">x&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">100&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nf">memory_barrier&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">flag&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>viene garantito che &lt;code>x&lt;/code> sia assegnato prima di &lt;code>flag&lt;/code>. Questa tecnica può essere utilizzata per garantire l&amp;rsquo;efficacia della soluzione di Peterson. Le barriere di memoria sono operazioni a basso livello, utilizzate solamente dagli sviluppatori di kernel.&lt;/p>
&lt;h3 id="istruzioni-hardware">Istruzioni hardware&lt;/h3>
&lt;p>Molti computer moderni forniscono speciali istruzioni hardware per eseguire operazioni di memoria &lt;strong>atomiche&lt;/strong>, il cui contenuto è visto come una singola istruzione non separabile. Facciamo due esempi. Il primo è con un&amp;rsquo;istruzione atomica &lt;code>test_and_set()&lt;/code>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="n">boolean&lt;/span> &lt;span class="nf">test_and_set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">boolean&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="n">target&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">boolean&lt;/span> &lt;span class="n">rv&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="n">target&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="o">*&lt;/span>&lt;span class="n">target&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">true&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">rv&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>che ritorna il valore originale di &lt;code>target&lt;/code> e poi lo imposta a &lt;code>true&lt;/code>. La caratteristica importante è che l&amp;rsquo;operazione viene eseguita atomicamente, quindi se due di queste istruzioni vengono eseguite simultaneamente su due core differenti, saranno eseguite sequenzialmente in un qualche ordine arbitrario.&lt;/p>
&lt;p>Se il sistema supporta &lt;code>test_and_set()&lt;/code> possiamo ad esempio implementare un meccanismo di lock col seguente codice&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="k">do&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nf">test_and_set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="n">lock&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">;&lt;/span> &lt;span class="cm">/* do nothing */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* critical section */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">lock&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">false&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* remainder section */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nb">true&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Per fare un altro esempio, prendiamo l&amp;rsquo;istruzione &lt;code>compare_and_swap()&lt;/code>, definita nel seguente modo&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="kt">int&lt;/span> &lt;span class="nf">compare_and_swap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">int&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">int&lt;/span> &lt;span class="n">expected&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">int&lt;/span> &lt;span class="n">new_value&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kt">int&lt;/span> &lt;span class="n">temp&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="n">value&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="n">expected&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="o">*&lt;/span>&lt;span class="n">value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">new_value&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">temp&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Questa istruzione si occupa di impostare la variabile &lt;code>value&lt;/code> col valore contenuto in &lt;code>new_value&lt;/code>, solamente se &lt;code>value == expected&lt;/code>. Ancora una volta, l&amp;rsquo;operazione è considerata atomica dal sistema. La mutua esclusione può essere raggiunta col seguente codice&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nb">true&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nf">compare_and_swap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="n">lock&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">;&lt;/span> &lt;span class="cm">/* do nothing */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* critical section */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">lock&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* remainder section */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Una variabile globale &lt;code>lock&lt;/code> viene impostata a &lt;code>0&lt;/code>. Il primo processo che invoca &lt;code>compare_and_swap()&lt;/code> imposta &lt;code>lock&lt;/code> a &lt;code>1&lt;/code> e tutte le chiamate successive non modificheranno più il valore, finché il processo entrato nella sezione critica non ne esce ed esegue &lt;code>lock = 0&lt;/code>.&lt;/p>
&lt;p>Questo algoritmo non garantisce il requisito di attesa limitata, poiché un processo in attesa potrebbe potenzialmente non entrare mai nella sezione critica. Il seguente codice ovvia al problema&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nb">true&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">waiting&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">true&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">key&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span> &lt;span class="n">waiting&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="n">key&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">key&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nf">compare_and_swap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="n">lock&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">waiting&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">false&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* critical section */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">j&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">i&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">%&lt;/span> &lt;span class="n">n&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">((&lt;/span>&lt;span class="n">j&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="n">i&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="o">!&lt;/span>&lt;span class="n">waiting&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">j&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">j&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">j&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">%&lt;/span> &lt;span class="n">n&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">j&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="n">i&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">lock&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">else&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">waiting&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">j&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">false&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="cm">/* remainder section */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>La sezione d&amp;rsquo;entrata permette solamente a un processo di entrare nella sezione critica, che imposterà &lt;code>key = 0&lt;/code> e &lt;code>waiting[i] = false&lt;/code>. Una volta eseguita la sezione critica il processo scorre il vettore &lt;code>waiting&lt;/code> finché non trova un altro processo con &lt;code>waiting[j] == true&lt;/code> oppure scorre tutto il vettore &lt;code>waiting&lt;/code> e imposta &lt;code>j = i&lt;/code>. In tal caso, esce dal ciclo while della sezione d&amp;rsquo;uscita e controlla quale delle due condizioni si è verificata. Se non ha trovato processi in attesa, allora &lt;code>j == i&lt;/code> e il processo imposta &lt;code>lock = 0&lt;/code>, altrimenti imposta &lt;code>waiting[j] = false&lt;/code> permettendo al processo &lt;code>j&lt;/code> di entrare nella sezione critica. In questo modo, un processo attenderà al massimo $n - 1$ turni prima di entrare nella sezione critica, garantendo il requisito di attesa limitata.&lt;/p>
&lt;h3 id="variabili-atomiche">Variabili atomiche&lt;/h3>
&lt;p>L&amp;rsquo;istruzione &lt;code>compare_and_swap()&lt;/code> viene tipicamente utilizzata per costruire degli strumenti di controllo più complessi, come una &lt;strong>variabile atomica&lt;/strong> che fornisce operazioni atomiche su tipi base di dati, p.e. interi e booleani.&lt;/p>
&lt;p>I sistemi che implementano le variabili atomiche, includono delle funzioni per manipolare tali tipi di dato, p.e. &lt;code>increment()&lt;/code>, definita come segue&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="kt">void&lt;/span> &lt;span class="nf">increment&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">atomic_int&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="n">v&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kt">int&lt;/span> &lt;span class="n">temp&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">do&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">temp&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="n">v&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">temp&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nf">compare_and_swap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">v&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">temp&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">temp&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>È importante notare che sebbene le variabili atomiche forniscano aggiornamenti atomici, non risolvono interamente le race condition in tutte le circostanze, Nel paradigma produttore potremmo avere due consumatori che attendono e una coda vuota. Un produttore produce un prodotto impostando una variabile atomica &lt;code>count&lt;/code> a &lt;code>1&lt;/code>. I due consumatori escono dal ciclo while di attesa ed entrambi procedono a consumare il prodotto, generando una race condition.&lt;/p>
&lt;p>Le variabili atomiche sono utilizzate spesso per singoli aggiornamenti di dati condivisi come i contatori e i generatori di sequenze.&lt;/p>
&lt;h3 id="lock-mutex">Lock mutex&lt;/h3>
&lt;p>Le soluzioni finora viste sono complicate e poco accessibili agli sviluppatori di applicazioni. Esistono alcuni strumenti più semplici per risolvere il problema della sezione critica nel software ad alto livello. Uno di questi è il cossiddetto &lt;strong>lock mutex&lt;/strong> (dove &lt;em>mutex&lt;/em> sta per &lt;em>mutual exclusion&lt;/em>). mutex mette a disposizione due funzioni:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="nf">acquire&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="n">available&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">;&lt;/span> &lt;span class="cm">/* busy wait */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">available&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">false&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>che acquisisce un lock.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="nf">release&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">available&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">true&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>che rilascia il lock. Le due call devono essere atomiche e possono essere quindi implementate con &lt;code>compare_and_swap()&lt;/code>.&lt;/p>
&lt;p>Lo svantaggio principale dell&amp;rsquo;implementazione sopracitata è che richiede il meccanismo di &lt;em>busy waiting&lt;/em> in cui i processi entrano per attendere il loro turno nell&amp;rsquo;acquisizione del lock. Ciò consuma cicli CPU senza che vengano eseguite realmente istruzioni e peggiora le prestazioni nei sistemi multiprogramming dove molti processi competono in un singolo core della CPU.&lt;/p>
&lt;p>Questo tipo di lock si chiama anche &lt;strong>spinlock&lt;/strong> poiché il processo &amp;ldquo;gira&amp;rdquo; (spins) finché il lock non diventa disponibile. Gli spinlock hanno il vantaggio di non richiedere context switch quando un processo attende il lock, nei sistemi multicore. Infatti, nei casi in cui il lock venga tenuto per poco tempo, un thread può &amp;ldquo;girare&amp;rdquo; su un core mentre un altro processo esegue la sezione critica su un altro core, senza la necessità di context switch. Per tale motivo gli spinlock sono comunemente usati in molti s.o.&lt;/p>
&lt;p>I lock mutex sono considerati i più semplici sistemi di sincronizzazione&lt;/p>
&lt;h2 id="semafori">Semafori&lt;/h2>
&lt;p>Un &lt;strong>semaforo&lt;/strong> è considerato una tecnica più robusta dei mutex per la sincronizzazione dei processi, introdotto dall&amp;rsquo;informatico olandese Edsger Dijkstra. Un semaforo $S$ è un intero che viene acceduto tramite due operazioni atomiche: &lt;code>wait()&lt;/code> e &lt;code>signal()&lt;/code>. Spesso le operazioni vengono chiamate anche P (dall&amp;rsquo;olandese &lt;em>proberen/testare&lt;/em>) e V (dall&amp;rsquo;olandese &lt;em>verhogen/incrementare&lt;/em>). Le definizioni delle funzioni sono le seguenti&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="nf">wait&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">S&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">S&lt;/span> &lt;span class="o">&amp;lt;=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">;&lt;/span> &lt;span class="c1">// busy wait
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="n">S&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="nf">signal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">S&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">S&lt;/span>&lt;span class="o">++&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In aggiunta all&amp;rsquo;atomicità, nel caso della &lt;code>wait()&lt;/code> il controllo della condizione deve avvenire senza interruzioni.&lt;/p>
&lt;h3 id="utilizzo-dei-semafori">Utilizzo dei semafori&lt;/h3>
&lt;p>Gli s.o. distinguono tra due tipi di semafori&lt;/p>
&lt;ul>
&lt;li>&lt;strong>semafori contatori:&lt;/strong> &lt;code>S&lt;/code> può essere maggiore di $1$&lt;/li>
&lt;li>&lt;strong>semafori binari:&lt;/strong> &lt;code>S&lt;/code> vale al massimo $1$. Il meccanismo è simile a quello dei mutex&lt;/li>
&lt;/ul>
&lt;p>I semafori contatori possono essere utilizzati per il controllo dell&amp;rsquo;accesso a risorse con un numero finito di istanze. Sono inizializzati al numero di risorse disponibili e ogni che viene chiamata &lt;code>wait()&lt;/code>, &lt;code>S&lt;/code> viene decrementato. Una volta raggiunto lo &lt;code>0&lt;/code>, la &lt;code>wait()&lt;/code> diviene bloccante.&lt;/p>
&lt;p>Possiamo utilizzare i semafori anche per problemi di sincronizzazione. Consideriamo due processi concorrenti: $P_1$ con codice $S_1$ e $P_2$ con codice $S_2$. Supponiamo che $S_2$ vada eseguito solamente dopo $S_1$. Possiamo implementare la sincronizzazione utilizzando un semaforo condiviso &lt;code>synch&lt;/code>, inizializzato a &lt;code>0&lt;/code>. il codice di $P_1$ è&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="n">S1&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nf">signal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">synch&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>mentre quello di $P_2$ è&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="nf">wait&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">synch&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">S2&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Poiché &lt;code>synch&lt;/code> è inizializzato a &lt;code>0&lt;/code>, $P_2$ eseguirà $S_2$ solo dopo che $P_1$ avrà invocato &lt;code>signal(synch)&lt;/code>, ovvero dopo l&amp;rsquo;esecuzione di $S_1$.&lt;/p>
&lt;h3 id="implementazione-di-un-semaforo">Implementazione di un semaforo&lt;/h3>
&lt;p>Le definizioni di &lt;code>wait()&lt;/code> e &lt;code>signal()&lt;/code> sopra presentate, presentano lo stesso problema di busy waiting dei mutex. Per ovviare al problema possiamo modificare la definizione di &lt;code>wait()&lt;/code> facendo in modo che quando il valore del semaforo non è positivo, il processo si sospende mettendosi nella coda di wait. Il processo viene poi risvegliato con una chiamata &lt;code>signal()&lt;/code> che esegue un&amp;rsquo;operazione di &lt;code>wakeup()&lt;/code>, che permette al processo di spostarsi nella coda di ready. L&amp;rsquo;implementazione del semaforo è la seguente:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="k">typedef&lt;/span> &lt;span class="k">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kt">int&lt;/span> &lt;span class="n">value&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">struct&lt;/span> &lt;span class="n">process&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="n">list&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span> &lt;span class="n">semaphore&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Ogni semaforo ha un valore intero e una lista di processi. Quando un processo attende un semaforo, questo viene aggiunto alla lista dei processi. L&amp;rsquo;operazione &lt;code>signal()&lt;/code> rimuove un processo dalla lista e lo risveglia.&lt;/p>
&lt;p>L&amp;rsquo;operazione di &lt;code>wait()&lt;/code> è implementata nel seguente modo:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="nf">wait&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">semaphore&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="n">S&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">S&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="n">value&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">S&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="n">value&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">add&lt;/span> &lt;span class="n">this&lt;/span> &lt;span class="n">process&lt;/span> &lt;span class="n">to&lt;/span> &lt;span class="n">S&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="n">list&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">sleep&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>e l&amp;rsquo;operazione di &lt;code>signal()&lt;/code> è invece:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="nf">signal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">semaphore&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="n">S&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">S&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="n">value&lt;/span>&lt;span class="o">++&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">S&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="n">value&lt;/span> &lt;span class="o">&amp;lt;=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">remove&lt;/span> &lt;span class="n">a&lt;/span> &lt;span class="n">process&lt;/span> &lt;span class="n">P&lt;/span> &lt;span class="n">from&lt;/span> &lt;span class="n">S&lt;/span>&lt;span class="o">-&amp;gt;&lt;/span>&lt;span class="n">list&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">wakeup&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">P&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>L&amp;rsquo;operazione &lt;code>sleep()&lt;/code> sospende il processo che lo invoca. L&amp;rsquo;operazione &lt;code>wakeup()&lt;/code> fa riprendere l&amp;rsquo;esecuzione.&lt;/p>
&lt;p>Con questa versione, un semaforo può assumere valori negativi, pari al numero di processi in attesa. La lista di processi può essere implementata con un campo link nel PCB del processo. Per assicurare un&amp;rsquo;attesa limitata, è possibile utilizzare una coda FIFO, ma qualunque strategia di accodamento va bene.&lt;/p>
&lt;p>Per garantire l&amp;rsquo;atomicità delle operazioni &lt;code>wait()&lt;/code> e &lt;code>signal()&lt;/code> è necessario inibire gli interrupt durante la loro esecuzione. Nei sistemi multicore, l&amp;rsquo;inibizione va fatta su tutti i processori, tuttavia ciò può comportare una diminuzione delle performance. Per tale motivo, i sistemi SMP devono fornire anche alternative come &lt;code>compare_and_swap()&lt;/code> o gli spinlock.&lt;/p>
&lt;h2 id="monitor">Monitor&lt;/h2>
&lt;p>I semafori e i mutex richiedono un’attenta progettazione del codice. È importante garantire che tutti i processi concorrenti chiamino prima la &lt;code>wait()&lt;/code> e poi la &lt;code>signal()&lt;/code>. Nel caso di sincronizzazioni complesse con molteplici processi in gioco, l’aderenza a questa regola può essere complicata. I &lt;strong>monitor&lt;/strong> sono strumenti a più alto livello che tentano di risolvere questo problema.&lt;/p>
&lt;h3 id="utilizzo-dei-monitor">Utilizzo dei monitor&lt;/h3>
&lt;p>Il tipo monitor è un &lt;strong>tipo di dato astratto (Abstract Data Type, ADT)&lt;/strong>, ovvero un tipo di dato che incapsula dati e una serie di funzioni indipendentemente dal tipo di implementazione specifica dell`ADT. In particolare, un monitor contiene:&lt;/p>
&lt;ul>
&lt;li>Un insieme di operazioni per cui è garantita l’esecuzione con mutua esclusione&lt;/li>
&lt;li>Delle variabili condivise&lt;/li>
&lt;li>Delle funzioni sulle variabili condivise&lt;/li>
&lt;/ul>
&lt;p>L’implementazione di un monitor è la seguente:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="n">monitor&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">monitor_name&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="cm">/* shared variable declarations */&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">function&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">P1&lt;/span>&lt;span class="p">(...)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">...&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">function&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">P2&lt;/span>&lt;span class="p">(...)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">...&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">function&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">Pn&lt;/span>&lt;span class="p">(...)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">...&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">initialization_code&lt;/span>&lt;span class="p">(...)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">...&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Tra le variabili condivise, è possibile definirne alcune di tipo &lt;code>condition&lt;/code>, per esempio &lt;code>condition x&lt;/code>, le cui uniche operazioni disponibili sono &lt;code>wait()&lt;/code> e &lt;code>signal()&lt;/code>. A differenza dei semafori, &lt;code>x.signal()&lt;/code> non ha alcun effetto se non ci sono processi in attesa.&lt;/p>
&lt;p>Supponiamo di avere un processo $Q$ in attesa e un processo $P$ che fa una chiamata &lt;code>signal()&lt;/code> che risveglia $Q$. Non è possibile far eseguire entrambi i processi dentro il monitor, per cui bisogna progettare una strategia, tra le seguenti:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>signal and wait:&lt;/strong> il processo $P$ lascia che $Q$ esegua e attende che esca dal monitor oppure che un’altra condizione si verifichi&lt;/li>
&lt;li>&lt;strong>signal and continue:&lt;/strong> il processo $Q$ attende che il processo $P$ esca dal monitor oppure che si verifichi un’altra condizione&lt;/li>
&lt;/ul>
&lt;p>Una terza soluzione potrebbe essere quella di progettare $P$ in maniera tale che esca dal monitor subito dopo che ha chiamato &lt;code>signal()&lt;/code>.&lt;/p>
&lt;h3 id="implementare-un-monitor-utilizzando-i-semafori">Implementare un monitor utilizzando i semafori&lt;/h3>
&lt;p>È possibile implementare un monitor utilizzando un semaforo binario &lt;code>mutex&lt;/code>, inizializzato a &lt;code>1&lt;/code>. Assumiamo una strategia signal and wait, e abbiamo bisogno quindi di un semaforo aggiuntivo &lt;code>next&lt;/code> inizializzato a &lt;code>0&lt;/code>, sul quale i processi possono autosospendersi con &lt;code>signal()&lt;/code>. Ogni procedura esterna &lt;code>F&lt;/code> viene sostituita col seguente codice:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="nf">wait&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">mutex&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">F&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">next_count&lt;/span> &lt;span class="o">&amp;gt;&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">signal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">next&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">else&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">signal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">mutex&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In questo modo abbiamo mutua esclusione all&amp;rsquo;interno del monitor. Per ogni variabile condition &lt;code>x&lt;/code> abbiamo un semaforo &lt;code>x_sem&lt;/code> e una variabile &lt;code>x_count&lt;/code> entrambi inizializzati a &lt;code>0&lt;/code>. L&amp;rsquo;operazione &lt;code>x.wait()&lt;/code> è implementata come segue:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="n">x_count&lt;/span>&lt;span class="o">++&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">next_count&lt;/span> &lt;span class="o">&amp;gt;&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">signal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">next&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">else&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">signal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">mutex&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nf">wait&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">x_sem&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">x_count&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>mentre la &lt;code>signal()&lt;/code> viene implementata come:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">x_count&lt;/span> &lt;span class="o">&amp;gt;&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">next_count&lt;/span>&lt;span class="o">++&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">signal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">x_sem&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">wait&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">next&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">next_count&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>È possibile implementare delle versioni meno generali e più veloci di quella proposta.&lt;/p>
&lt;h3 id="risveglio-dei-processi-allinterno-di-un-monitor">Risveglio dei processi all&amp;rsquo;interno di un monitor&lt;/h3>
&lt;p>Se un&amp;rsquo;operazione &lt;code>x.signal()&lt;/code> viene eseguita e vari processi sono in stato di sleep, quale tra di essi va risvegliato? Una soluzione semplice potrebbe essere quella di utilizzare l&amp;rsquo;algoritmo FCFS.&lt;/p>
&lt;p>Una soluzione più adeguata consiste nell&amp;rsquo;utilizzare la &lt;code>conditional wait&lt;/code>. Questo costrutto viene eseguito tramite &lt;code>x.wait(c)&lt;/code>, dove &lt;code>c&lt;/code> è un intero chiamato &lt;code>numero di priorità (priority number)&lt;/code> che viene salvato assieme al numero del processo che viene sospeso. Per illustrare il meccanismo consideriamo il seguente codice&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="n">monitor&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ResourceAllocator&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="kt">boolean&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">busy&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">condition&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">x&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="kt">void&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">acquire&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">time&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">if&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">busy&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">x&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">wait&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">time&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">busy&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="kt">void&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">release&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">busy&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">x&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">signal&lt;/span>&lt;span class="p">();&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">initialization&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nf">code&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">busy&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>che controlla l&amp;rsquo;allocazione di una singola risorsa tra i vari processi che se la contendono. Il monitor la assegna al processo che ne ha fatto richiesta per il minor tempo possibile.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-java" data-lang="java">&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">R&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">acquire&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">t&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">...&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">access&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">the&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">resource&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">R&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="na">release&lt;/span>&lt;span class="p">();&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Dove &lt;code>R&lt;/code> è un &lt;code>ResourceAllocator&lt;/code>.&lt;/p>
&lt;p>Quest&amp;rsquo;implementazione presenta comunque dei problemi:&lt;/p>
&lt;ul>
&lt;li>Un processo potrebbe accedere alla risorsa senza averne il permesso&lt;/li>
&lt;li>Un processo potrebbe non rilasciare mai la risorsa, una volta che ci ha acceduto&lt;/li>
&lt;li>Un processo potrebbe provare a rilasciare una risorsa a cui non ha mai avuto accesso&lt;/li>
&lt;li>Un processo potrebbe richiedere la stessa risorsa due volte (senza rilasciarla)&lt;/li>
&lt;/ul>
&lt;p>È possibile spostare la logica di accesso alla risorsa all&amp;rsquo;interno del monitor ma questo significherebbe rimpiazzare lo scheduling dell&amp;rsquo;accesso con quello del monitor (invece che con quello sviluppato da noi).&lt;/p>
&lt;h2 id="liveness">Liveness&lt;/h2>
&lt;p>Una possibile conseguenza dell&amp;rsquo;utilizzo di tool di sincronizzazione è quella che un processo attenda indefinitamente prima di accedere alla sezione critica. La &lt;strong>liveness&lt;/strong> si riferisce a un insieme di proprietà che un sistema deve garantire per far sì che i suoi processi procedano. Un &lt;strong>fallimento della liveness&lt;/strong> accade quando un processo non rispetta tali proprietà.&lt;/p>
&lt;p>Un semplice esempio è il loop infinito. Una busy wait può rappresentare una &lt;em>possibilità&lt;/em> di fallimento della liveness, se non è implementata correttamente.&lt;/p>
&lt;h3 id="deadlock">Deadlock&lt;/h3>
&lt;p>Un caso particolare di fallimento della livenuss è il cosiddetto &lt;strong>deadlock&lt;/strong>, che accade quando in un semaforo, due processi attendono un evento generato rispettivamente dall&amp;rsquo;altro processo. Supponiamo il seguente esempio:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-c" data-lang="c">&lt;span class="line">&lt;span class="cl"> &lt;span class="n">P0&lt;/span> &lt;span class="n">P1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">wait&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">S&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nf">wait&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Q&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">wait&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Q&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nf">wait&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">S&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">.&lt;/span> &lt;span class="p">.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">.&lt;/span> &lt;span class="p">.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">.&lt;/span> &lt;span class="p">.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">signal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">S&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nf">signal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Q&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nf">signal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Q&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nf">signal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">S&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Se $P_0$ esegue &lt;code>wait(S)&lt;/code> e $P_1$ &lt;code>wait(Q)&lt;/code>.&lt;/p>
&lt;ul>
&lt;li>Se $P_0$ esegue &lt;code>wait(Q)&lt;/code> dovrà attendere che $P_1$ faccia &lt;code>signal(Q)&lt;/code>&lt;/li>
&lt;li>Se $P_1$ esegue &lt;code>wait(S)&lt;/code> dovrà attendere che $P_0$ faccia &lt;code>signal(S)&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>In questo modo i processi attendono un&amp;rsquo;operazione che non verrà mai eseguita comportando un deadlock. In generale, un insieme di processi è in deadlock, se ogni processo dell&amp;rsquo;insieme è in attesa di un evento generato da uno degli altri processi nell&amp;rsquo;insieme.&lt;/p>
&lt;h3 id="inversione-di-priorità">Inversione di priorità&lt;/h3>
&lt;p>Supponiamo di avere tre processi con priorità $L$, $M$, $H$, tali che $L &amp;lt; M &amp;lt; H$. Assumiamo che $H$ richieda una risorsa controllata da un semaforo $S$, correntemente acceduto da $L$. Supponiamo ora che $M$ diventi eseguibile e soppianti il processo $L$. Ora $H$ dovrà aspettare l&amp;rsquo;esecuzione di $M$ per poter accedere alla risorsa.&lt;/p>
&lt;p>Questo problema di liveness è chiamato &lt;strong>inversione di priorità (priority inversion)&lt;/strong> e può accadere soltanto in sistemi con più di due priorità. Tipicamente è possibile evitare questo problema implementando un &lt;strong>protocollo di ererdità della priorità (priority-inheritance protocol)&lt;/strong>. Con questo meccanismo, se un processo $H$ ad alta priorità richiede una risorsa detenuta da un processo $L$ a bassa priorità, quest&amp;rsquo;ultimo eredità la priorità del processo $H$ in maniera da prevenire altri processi con priorità media $M$ di poterla richiedere. Una volta concluso l&amp;rsquo;utilizzo, il processo a bassa priorità torna ad avere la sua priorità originale $L$&lt;/p>
&lt;h2 id="valutazione">Valutazione&lt;/h2>
&lt;p>Riportiamo alcune semplici strategie per determinare quale strumento di sincronizzazione usare, tra quelli che abbiamo presentato in questa lezione.&lt;/p>
&lt;p>Barriere di memoria e istruzioni atomiche sono tecniche di basso livello, spesso utilizzate per implementare meccanismi di sincronizzazione più sofisticati.&lt;/p>
&lt;p>L&amp;rsquo;approccio &lt;code>compare_and_swap()&lt;/code> (CAS) è un meccanismo ottimista in cui una variabile viene aggiornata e solo dopo viene controllato se un altro thread sta aggiornando la variabile. Al contrario il mutex è un approccio pessimistico in quanto si assume che un altro thread stia concorrentemente aggiornando la variabile per cui viene acquisito il lock prima di fare qualunque update. In generale:&lt;/p>
&lt;ul>
&lt;li>Se un lock non è conteso tra i processi, è preferibile una protezione CAS in quanto più veloce&lt;/li>
&lt;li>Se un lock viene conteso moderatamente tra i processi, CAS risulterà più veloce della sincronizzazione tradizionale&lt;/li>
&lt;li>Se un lock viene conteso spesso tra i processi, la sincronizzazione tradizionale risulterà essere più veloce di quella basata su CAS&lt;/li>
&lt;/ul>
&lt;p>In aggiunta, gli interi atocimi, essendo molto leggeri, sono spesso utili per aggiornare variabili come contatori condivisi. In generale i lock mutex sono semplici da utilizzare e richiedono meno overhead dei semafori, che a loro volta sono meno pesanti dei monitor.&lt;/p>
&lt;div id="references-1784797996273225713" class="references" style="margin-top: 5rem;">
&lt;p>&lt;strong>References&lt;/strong>&lt;/p>
A. Silberschatz, P. B. Galvin, G. Gagne, &lt;em>Sistemi Operativi. Concetti ed Esempi&lt;/em>, Decima edizione. Ch. 6.
&lt;/div>
&lt;script>
document.addEventListener('DOMContentLoaded', function () {
var ref = document.getElementById('references-1784797996273225713');
var fn = document.querySelector('.footnotes');
if (ref &amp;&amp; fn &amp;&amp; fn.parentNode) {
fn.parentNode.insertBefore(ref, fn.nextSibling);
}
});
&lt;/script></description></item><item><title>7 Gestione della memoria</title><link>https://aretor.it/courses/os2425/lessons/07_memory/</link><pubDate>Tue, 01 Apr 2025 00:00:00 +0000</pubDate><guid>https://aretor.it/courses/os2425/lessons/07_memory/</guid><description>&lt;p>La memoria è una componente fondamentale che, assieme alla CPU, dev&amp;rsquo;essere gestita in maniera efficiente per garantire che le prestazioni di un computer non peggiorino. In questa lezione introdurremo i concetti alla base della sua progettazione logica.&lt;/p>
&lt;h2 id="71-background">7.1 Background&lt;/h2>
&lt;p>La memoria consiste in un grande array di byte ciascuno con il proprio indirizzo. Un programma viene caricato in memoria tramite un programma speciale chiamato &lt;strong>program loader&lt;/strong>. La CPU legge quindi le istruzioni dalla memoria basandosi sul contatore di programma. Queste istruzioni possono causare dei load o dei store aggiuntivi da e verso la memoria.&lt;/p>
&lt;h2 id="72-hardware-di-base">7.2 Hardware di base&lt;/h2>
&lt;p>I core CPU possono accedere solamente ai registri e alla memoria centrale. Se un dato si trova in una memoria secondaria o terziaria, questo va spostato in memoria centrale per essere acceduto. I registri CPU possono essere tipicamente acceduti in un ciclo di CPU, mentre la memoria centrale, connessa tramite un bus di sistema, richiede molti cicli. Il suo accesso può generare uno &lt;strong>stallo&lt;/strong> per l&amp;rsquo;attesa dei dati, peggiorando le prestazioni del sistema. Per ovviare al problema è possibile implementare una memoria per accesso veloce, chiamata &lt;strong>cache&lt;/strong>, dentro il chip della CPU.&lt;/p>
&lt;p>In aggiunta a ciò, è importante proteggere gli spazi di memoria dedicati ai processi kernel da parte dei processi utente, oltre che proteggere i processi utente l&amp;rsquo;uno con l&amp;rsquo;altro da accessi a zone di memoria non consentite. Per fare ciò quando un programma viene caricato, il s.o. imposta i valori di due registri, &lt;strong>un registro base&lt;/strong> che indica l&amp;rsquo;inizio dello spazio degli indirizzi del processo, e un &lt;strong>registro limite&lt;/strong> che indica la dimensione dello spazio degli indirizzi a cui il processo può accedere (l&amp;rsquo;indirizzo finale viene calcolato con base + limite). A ogni istruzione la CPU controlla che il processo utente acceda alla sezione di memoria a esso associata. Se ciò non avviene il s.o. emette un errore. Il s.o. esegue in modalità kernel ed è quindi libero di caricare ed eliminare i programmi dalla memoria.&lt;/p>
&lt;h2 id="73-mappatura-degli-indirizzi">7.3 Mappatura degli indirizzi&lt;/h2>
&lt;p>Un programma può essere caricato in qualunque parte della memoria fisica, tuttavia gli indirizzi in un codice sorgente sono tipicamente simbolici (p.e. una variabile &lt;code>a&lt;/code>, con una zona di memoria ancora non specificata). La &lt;strong>mappatura (binding)&lt;/strong> da indirizzi logici a indirizzi fisici, può avvenire nei seguenti tre modi:&lt;/p>
&lt;!-- - Il compilatore mappa gli indirizzi simbolici a degli indirizzi rilocabili (p.e. "14 byte dall'inizio del modulo") -->
&lt;!-- - Il linker o il loader mappano gli indirizzi rilocabili a quelli assoluti (p.e. "74014"). -->
&lt;ul>
&lt;li>&lt;strong>Compile time binding:&lt;/strong> in questo caso il compilatore sa già dove il programma deve risiedere in memoria e può mappare direttamente gli indirizzi simbolici a indirizzi fisici (assoluti), generando &lt;strong>codice assoluto (absolute code)&lt;/strong>. Se la locazione del processo cambia sarà necessario ricompilare il programma.&lt;/li>
&lt;li>&lt;strong>Load time binding:&lt;/strong> in questo caso non è noto dove il programma risiederà in memoria, ma solamente che una volta caricato non cambierà posizione. È necessario dunque che il compilatore generi &lt;strong>codice rilocabile&lt;/strong> (p.e. &lt;code>a&lt;/code> diventa &amp;ldquo;14 byte dall&amp;rsquo;inizio del programma&amp;rdquo;). In questo caso la mappatura agli indirizzi fisici è demandata al program linker o loader (p.e. l&amp;rsquo;indirizzo rilocabile precedente diventa 74014). Se l&amp;rsquo;indirizzo di partenza cambia, è necessario eseguire nuovamente il loading del codice utente.&lt;/li>
&lt;li>&lt;strong>Execution time binding:&lt;/strong> se il processo può essere spostato in memoria durante la sua esecuzione, la mappatura deve avvenire a tempo di esecuzione. Il linker o il loader generano indirizzi simbolici (chiamati virtuali) e un hardware speciale si occupa della mappatura a indirizzi fisici (lo vedremo a breve). La maggior parte dei sistemi operativi utilizza questo metodo.&lt;/li>
&lt;/ul>
&lt;h2 id="74-spazio-degli-indirizzi-logico-e-fisico">7.4 Spazio degli indirizzi logico e fisico&lt;/h2>
&lt;p>Ogni programma e la CPU operano generalmente con &lt;strong>indirizzi logici&lt;/strong>, mentre la memoria opera con &lt;strong>indirizzo fisici&lt;/strong>. In caso di mappatura a compile e load time gli indirizzi logici e fisici coincidono. Con la mappatura a execution time l&amp;rsquo;indirizzo logico, chiamato &lt;strong>indirizzo virtuale&lt;/strong> (utilizzeremo tuttavia entrambi i termini logico e virtuale allo stesso modo), va invece convertito in indirizzo fisico tramite dell&amp;rsquo;hardware specializzato, l&amp;rsquo;&lt;strong>unità di gestione della memoria (memory-management unit, MMU)&lt;/strong>. Ogni volta che la CPU effettua una store o una load sulla memoria, l&amp;rsquo;indirizzo virtuale viene quindi convertito dalla MMU in indirizzo fisico e viceversa. Introduciamo un semplice schema di mappatura che generalizza quello del registro base. Tale registro viene chiamato, in questo contesto, &lt;strong>registro di rilocazione (relocation register)&lt;/strong>. Questo valore è aggiunto a ogni indirizzo da un processo utente nel momento in cui l&amp;rsquo;indirizzo è inviato in memoria. Se il registro rilocabile è 14000, allora 0 sarà mappato a 14000 e 346 a 14346. Sottolineiamo di nuovo che il programma non ha accesso all&amp;rsquo;indirizzo fisico, solo a quello rilocabile.&lt;/p>
&lt;h2 id="75-linking-e-loading">7.5 Linking e Loading&lt;/h2>
&lt;p>Un programma è tipicamente composto da librerie di routine. Le librerie sono simili a programmi e hanno il loro spazio degli indirizzi personale. Per l&amp;rsquo;integrazione delle librerie nell&amp;rsquo;eseguibile è necessario rintracciare le librerie e rimappare tutti i loro indirizzi perché non ci siano overlap. Per fare ciò, esistono tre diverse modalità: &lt;strong>static linking&lt;/strong>, &lt;strong>dynamic linking&lt;/strong> e &lt;strong>dynamic loading&lt;/strong>.&lt;/p>
&lt;h3 id="751-static-linking">7.5.1 Static Linking&lt;/h3>
&lt;p>In questo caso, il linker tratta le librerie come moduli oggetto e le combina all&amp;rsquo;interno dell&amp;rsquo;eseguibile del programma, a compile time. L&amp;rsquo;eseguibile contiene quindi tutto ciò che serve per eseguire ma incrementa in dimensioni poiché porta con sé anche le librerie, che vengono chiamate &lt;strong>librerie statiche (static libraries)&lt;/strong>. In aggiunta, le librerie vengono caricate in memoria, una volta per programma, anche se sono già presenti, occupando maggiore spazio. In Windows le librerie statiche vengono definite con l&amp;rsquo;estensione &lt;code>.lib&lt;/code>, in Linux con &lt;code>.a&lt;/code>.&lt;/p>
&lt;h3 id="752-dynamic-linking">7.5.2 Dynamic Linking&lt;/h3>
&lt;p>In questo caso, il linking delle librerie avviene a load time. In particolare, il loader chiama il linker che si occupa di controllare se le librerie richieste sono già in memoria, altrimenti le carica, aggiornando gli indirizzi. Le librerie possono essere quindi condivise tra vari programmi e per tale motivo si chiamano anche &lt;strong>librerie condivise (shared libraries)&lt;/strong>. Inoltre, col dynamic linking è possibile tenere in memoria secondaria degli eseguibili più piccoli, ed è possibile aggiornare le librerie condivise senza dover ricompilare gli altri programmi. Sono molto utilizzate nei sistemi Windows, dove vengono chiamate dinamically linked libraries (&lt;code>.dll&lt;/code>), e Linux (shared object, &lt;code>.so&lt;/code>). Per evitare che vengano inavvertitamente utilizzate delle librerie di una versione incompatibile, viene solitamente incluso un numero di versione che controllato a linking time. Il dynamic linking richiede l&amp;rsquo;intervento del s.o. per permettere la condivisione di librerie a più processi.&lt;/p>
&lt;h3 id="753-dynamic-loading">7.5.3 Dynamic Loading&lt;/h3>
&lt;p>In questo caso il caricamento della libreria avviene a run time, al momento del suo effettivo utilizzo, tramite la chiamata di una funzione nel codice dell&amp;rsquo;eseguibile (p.e. &lt;code>load_library(&amp;lt;nome libreria&amp;gt;&lt;/code>)). Le routine vengono salvate in uno speciale formato rilocabile e quando un&amp;rsquo;altra routine viene chiamata, il chiamante controlla se il chiamato è già presente in memoria. Se non lo trova, il loader lo carica dalla memoria secondaria, aggiorna la tabella degli indirizzi del programma e passa il controllo alla routine chiamata. Le routine posso anche essere rilasciate, liberando così memoria. Questo metodo è efficace nel caso di routine molto grandi che vengono chiamate poco frequentemente, come ad esempio quelle per la gestione degli errori. Il loading dinamico non richiede speciale supporto da parte del s.o. ed è di responsabilità dello sviluppatore. Il s.o. può tuttavia fornire delle routine di libreria per implementare il loading dinamico.&lt;/p>
&lt;h2 id="76-allocazione-contigua-della-memoria">7.6 Allocazione contigua della memoria&lt;/h2>
&lt;p>La memoria centrale deve accomodare sia il s.o. che i vari processi utente. Abbiamo quindi bisogno di allocare lo spazio nella migliore maniera possibile. La memoria viene divisa generalmente in due partizioni: una per il s.o. e una per i processi utente. È possibile allocare la memoria s.o. negli indirizzi più bassi o più alti della memoria, tuttavia molti s.o. (tra cui Windows e Linux) utilizzano gli indirizzi alti. Nell&amp;rsquo;&lt;strong>allocazione di memoria contigua&lt;/strong> ogni processo utente viene allocato in una singola sezione di memoria contigua a quella del processo successivo.&lt;/p>
&lt;h2 id="77-protezione-della-memoria">7.7 Protezione della memoria&lt;/h2>
&lt;p>Possiamo prevenire che un processo acceda alla memoria di un altro processo combinando la tecnica del registro base e del registro di rilocazione precedentemente visto. Dal momento che gli indirizzi rilocabili vanno da 0 (incluso) al valore specificato dal registro di rilocazione limite (escluso), quando una CPU esegue un&amp;rsquo;istruzione di accesso alla memoria è necessario solamente che controlli che &lt;code>&amp;lt;indirizzo rilocabile&amp;gt; &amp;lt; &amp;lt;registro limite&amp;gt;&lt;/code>. In tal caso, la CPU computerà &lt;code>&amp;lt;indirizzo fisico&amp;gt; = &amp;lt;registro base&amp;gt; + &amp;lt;indirizzo rilocabile&amp;gt;&lt;/code> per ottenere l&amp;rsquo;indirizzo fisico.&lt;/p>
&lt;h2 id="78-allocazione-della-memoria">7.8 Allocazione della memoria&lt;/h2>
&lt;p>Tra le sfide nella gestione dei processi in memoria, troviamo il &lt;strong>problema dell&amp;rsquo;allocazione dinamica di spazio&lt;/strong>. Uno dei metodi più semplici per allocazione della memoria consiste nell&amp;rsquo;allocare, per ciascun processo, una partizione di memoria di dimensione variabile. Inizialmente tutta la memoria è disponibile ed è considerata un &lt;strong>buco (hole)&lt;/strong>. Progredendo col caricamento e la terminazione dei processi, la memoria conterrà vari buchi inutilizzati. Nel momento in cui un processo entra in memoria il s.o. tiene in conto dei requisiti di memoria necessari. Se non c&amp;rsquo;è memoria sufficiente è possibile rifiutare il processo o piazzarlo in una coda di attesa. Se invece un processo è di dimensione minore rispetto a un buco contenuto nell&amp;rsquo;insieme dei buchi, può venire piazzato lì. In tal caso, se c&amp;rsquo;è molto spazio all&amp;rsquo;interno del buco, solo una parte viene allocata al processo e la rimanente ritorna nell&amp;rsquo;insieme dei buchi. Quando un processo termina lasciando un buco, se questo è adiacente a un altro buco entrambi vengono uniti in un buco soltanto. La scelta tra i buchi disponibili in cui allocare un nuovo processo è basata su una strategia di allocazione. Tra le più note troviamo:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>First-fit:&lt;/strong> alloca il primo buco che è grande abbastanza per contenere il processo. La ricerca può cominciare all&amp;rsquo;inizio dell&amp;rsquo;insieme dei buchi o nella locazione dove la ricerca si era precedentemente conclusa. La ricerca si ferma una volta trovato il buco.&lt;/li>
&lt;li>&lt;strong>Best-fit:&lt;/strong> alloca il buco più piccolo che può contenere il processo. La ricerca richiede di controllare tutti i buchi disponibili (se non è ordinata per dimensione).&lt;/li>
&lt;li>&lt;strong>Worst-fit:&lt;/strong> alloca il buco più grande che può contenere il processo. La ricerca richiede di controllare tutti i buchi disponibili (se non è ordinata per dimensione). Questa strategia lascia più spazio rimanente nel buco e può essere più utile della strategia best-fit.&lt;/li>
&lt;/ul>
&lt;p>Le simulazioni dimostrano che first-fit e best-fit sono i migliori in termini di utilizzo della memoria e del tempo. Tra questi due first-fit è il più veloce in termini di tempo, mentre non c&amp;rsquo;è una differenza sostanziale in termini di memoria occupata.&lt;/p>
&lt;h2 id="79-frammentazione">7.9 Frammentazione&lt;/h2>
&lt;p>Con gli algoritmi first-fit e best-fit, l&amp;rsquo;allocazione e la rimozione dei processi può generare problemi di &lt;strong>frammentazione&lt;/strong>. Esistono due tipologie di frammentazione:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Frammentazione esterna:&lt;/strong> in seguito all&amp;rsquo;allocazione e rimozione dei processi si creano molti piccoli buchi all&amp;rsquo;interno della memoria. Questo fenomeno accade quando ci sarebbe memoria sufficiente per ospitare un nuovo processo ma questa è frammentata in molteplici buchi non contigui. Analisi statistiche sull&amp;rsquo;algoritmo first-fit confermano che allocando $N$ blocchi, altri $0.5N$ blocchi vengono persi per via della frammentazione (&lt;strong>$50$-percent rule&lt;/strong>). Una soluzione al problema è il &lt;strong>compattamento (compaction)&lt;/strong> per cui la memoria viene riorganizzata per accostare i buchi e unirli insieme. Questa metodologia non è possibile se la rilocazione è stata fatta a compile o load time. Bisogna inoltre calcolare il costo della rilocazione, quando questa è possibile. La strategia più semplice, ma anche costosa, è quella di spostare tutti i processi da una parte e i buchi dall&amp;rsquo;altra. È anche possibile permettere che lo spazio degli indirizzi di un processo non sia contiguo. Questa strategia è chiamata &lt;strong>paging&lt;/strong> ed è la più comune nei s.o.&lt;/li>
&lt;li>&lt;strong>Frammentazione interna:&lt;/strong> supponiamo di avere un processo da $18,462$ bytes allocato in un buco da $18,464$ bytes. Il peso della struttura usata per tracciare i due byte rimanenti sarebbe maggiore dei due byte stessi comportando uno spreco di memoria. Per ovviare a questa situazione è possibile allocare un blocco fisso di memoria, maggiore della memoria richiesta dal processo. La rimanenza tra la memoria del blocco e quella effettivamente occupata dal processo viene chiamata frammentazione interna.&lt;/li>
&lt;/ul>
&lt;h2 id="710-paging">7.10 Paging&lt;/h2>
&lt;p>Immaginate di avere una memoria da 1GiB indicizzabile a livello di byte. Se vogliamo mappare ogni byte abbiamo in totale $1Gi = 2^{30}$ indirizzi da gestire. La mappatura richiede quindi che un indice sia lungo 30 bit, ma dal momento che lavoriamo a livello di byte dobbiamo utilizzare 32 bit (4B). L&amp;rsquo;indicizzazione dei byte pesa quindi $4B \cdot 2^{30} = 2^{32}B = 4GiB$, molto più della memoria stessa! Questo approccio di mappatura è dunque infattibile, e per ovviare al problema utilizziamo il &lt;strong>paging&lt;/strong>.&lt;/p>
&lt;p>Il paging è una tecnica che consiste nell&amp;rsquo;assegnare un indirizzo a un insieme di zone di memoria indicizzabili (tipicamente i byte), contigue tra loro. Gli insieme così indicizzati, di dimensione fissa, vengono chiamati &lt;strong>frame&lt;/strong> nella memoria fisica, e &lt;strong>pagine (pages)&lt;/strong> in quella logica. Lo spazio logico degli indirizzi di un programma viene quindi diviso in pagine, e quando un processo viene eseguito, le sue pagine vengono caricate in memoria. La corrispondenza tra frame e pagine è arbitraria. In altre parole, una pagina con indirizzo 3 può essere mappata nel frame 9.&lt;/p>
&lt;p>Il paging permette anche di caricare solo una parte delle pagine programma in maniera da ottimizzare l&amp;rsquo;utilizzo della memoria fisica. Inoltre, l&amp;rsquo;idea di separare la memoria fisica da quella logica ci permette di avere indirizzi logici di, per esempio, $64$ bit senza avere una memoria fisica da $2^{64}$ bit.&lt;/p>
&lt;p>Ogni indirizzo generato dalla CPU viene diviso in due parti:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Numero di pagina ($p$):&lt;/strong> è usato come un indice dentro una &lt;strong>tabella di pagine&lt;/strong>, relativa a un singolo processo e contenente l&amp;rsquo;indirizzo fisico di ogni frame.&lt;/li>
&lt;li>&lt;strong>Offset di pagina ($d$):&lt;/strong> è l&amp;rsquo;offset utilizzato per riferirsi alla locazione esatta nel frame.&lt;/li>
&lt;/ul>
&lt;p>L&amp;rsquo;MMU esegue i seguenti step per tradurre un indirizzo logico in quello fisico:&lt;/p>
&lt;ol>
&lt;li>Estrarre il numero di pagina $p$ e utilizzarlo per cercare nella tabella delle pagine.&lt;/li>
&lt;li>Estrarre il corrispondente numero di frame $f$ dalla tabella delle pagine.&lt;/li>
&lt;li>Rimpiazzare il numero di pagina $p$ nell&amp;rsquo;indirizzo logico col numero di frame $f$.&lt;/li>
&lt;li>Aggiungere l&amp;rsquo;offset $d$ al numero di frame $f$ per ottenere l&amp;rsquo;indirizzo fisico.&lt;/li>
&lt;/ol>
&lt;p>La dimensione di una pagina è definita a livello hardware ed è una potenza di $2$ (tipicamente tra $4$KB e $1$GB). In questo modo, se la dimensione della pagina è $2^n$ e la dimensione dello spazio logico è $2^m$ ($m &amp;gt; n$), allora un indirizzo logico contiene $m$ bit, gli $n$ bit meno significativi indicano l&amp;rsquo;offset di pagina e gli $m - n$ bit più significativi sono riservati al numero di pagina.&lt;/p>
&lt;p>Il paging è un sistema di rilocazione simile a quelli visti in precedenza. Non crea frammentazione esterna poiché la memoria fisica viene partizionata in anticipo, tuttavia può creare frammentazione interna, poiché un processo potrebbe non occupare completamente l&amp;rsquo;ultima pagina a esso allocata. Nel peggiore dei casi potremmo avere l&amp;rsquo;allocazione di un solo byte nell&amp;rsquo;ultima pagina risultando in una frammentazione interna di quasi un intero frame. Possiamo stimare una frammentazione interna media di mezza pagina, quindi è bene avere una dimensione di pagina piccola, tuttavia avere molte pagine implica un overhead nella gestione delle struttura delle entry di pagina. Inoltre, dal punto di vista del I/O su disco (memoria secondaria) è meglio avere grandi quantità di dati da trasferire. In generale le pagine oggi sono di $4$K o $8$K. Molti s.o. supportano più dimensioni di pagina.&lt;/p>
&lt;p>Un programma, prima dell&amp;rsquo;esecuzione esprime la sua dimensione in pagine. Se richiede $n$ pagine, è necessario disporre di $n$ frame per poterlo allocare. La mappatura è gestita dal s.o., che tiene traccia dei frame liberi attraverso una &lt;strong>tabella dei frame (frame table)&lt;/strong>. Questa struttura indica quali frame sono liberi, quali sono occupati e da che processi sono occupati.&lt;/p>
&lt;h2 id="711-supporto-hardware">7.11 Supporto hardware&lt;/h2>
&lt;p>Ogni PCB del processo contiene un puntatore alla tabella delle pagine a esso associate, e questa tabella viene caricata dalla CPU ogni volta che un processo entra in esecuzione. Vediamo due modi per fare l&amp;rsquo;implementazione hardware della tabella delle pagine:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Insieme di registri CPU dedicati&lt;/strong>: In questo caso viene utilizzato un set di $N$ registri specializzati cosicché il registro $p$ contiene l&amp;rsquo;indirizzo del frame associato alla pagina $p$ del processo in CPU. Questa implementazione è utile per fare velocemente traduzioni pagina-frame, ma aumenta i tempi di context switch e non può essere utilizzata in caso di page table grandi.&lt;/li>
&lt;li>&lt;strong>Puntatore in memoria&lt;/strong>: nella maggior parte dei s.o. moderni, è necessario salvare la page table in memoria e utilizzare un singolo registro specializzato, il &lt;strong>Page-Table Base Register, PTBR&lt;/strong>, che contiene l&amp;rsquo;indirizzo iniziale della page table del processo. Cambiare la page table richiede solo di cambiare il valore di questo registro riducendo sostanzialmente il tempo di context switch e l&amp;rsquo;utilizzo di page table più grandi.&lt;/li>
&lt;/ul>
&lt;h3 id="7111-buffer-di-traduzione-look-aside">7.11.1 buffer di traduzione look-aside&lt;/h3>
&lt;p>Se salviamo la tabella delle pagine in memoria risparmiamo tempo nel context switch ma l&amp;rsquo;accesso alla tabella situata nella memoria peggiora le performance poiché dobbiamo accedere alla PTBR ricavare l&amp;rsquo;indirizzo e accedere poi alla pagina cercata. È possibile implementare una piccola e veloce cache di ricerca chiamata &lt;strong>buffer di traduzione Look-aside (Translation Look-aside Buffer, TLB)&lt;/strong>. Ogni entry del TLB consiste in una chiave e un valore. Quando arriva un oggetto, questo viene confrontato con tutte le chiavi finché non avviene un match e viene ritornato il corrispondente valore. Il TLB è piccolo in dimensioni, da $32$ a $1024$ entry. Alcune CPU hanno una TLB per le istruzioni e una per i dati.&lt;/p>
&lt;p>Relativamente al paging, una TLB contiene solamente una piccola parte delle entry della tabella di pagine. Quando un&amp;rsquo;indirizzo logico viene generato, l&amp;rsquo;MMU controlla se il numero di pagina (la chiave) è nella TLB e ritorna immediatamente il rispettivo numero di frame (il valore) a esso associato. Se il numero di pagina non è presente si verifica un &lt;strong>TLB miss&lt;/strong>. In questo caso, viene eseguita la traduzione da pagina a frame e poi la mappatura viene salvata nella TLB. Se la TLB è piena, è necessario eliminare una entry. Varie strategie possono essere applicate, dal round-robin a politiche che selezionano la pagina recentemente meno utilizzata (Least Recently Used, LRU). Alcune entry possono essere &lt;strong>fissate (wired down)&lt;/strong>, ad esempio le entry relative a codice kernel fondamentale.&lt;/p>
&lt;p>Alcuni TLB salvano anche un &lt;strong>identificatore dello spazio degli indirizzi (Adress-Space Identifier, ASID)&lt;/strong> che determina l&amp;rsquo;identità del processo per garantire protezione. Quando un TLB prova a risolvere un numero di pagina virtuale, controlla che l&amp;rsquo;ASID della mappatura corrisponda a quello del processo, in caso contrario restituisce un TLB miss. Senza ASID, ogni volta che una nuova tabella delle pagine viene selezionata (p.e. durante un context switch) il TLB deve assicurarsi che il suo contenuto venga resettato (flushed), per garantire che la precedente tabella non crei conflitti puntando a indirizzi sbagliati.&lt;/p>
&lt;p>Per misurare le prestazioni di una TLB è possibile calcolare la, &lt;strong>hit ratio&lt;/strong>, ovvero la percentuale di volte in cui l&amp;rsquo;informazione richiesta si trova nella TLB. Per calcolare il tempo di accesso effettivo in memoria si può utilizzare la seguente formula:&lt;/p>
$$
\begin{align}
&amp; \text{effective access time} = \underbrace{\text{hit ratio} \times \text{page access time}}_{TLB\ hit} + \nonumber\\
&amp; + (1 - \text{hit ratio}) \times \underbrace{(\text{page table access time} + \text{page access time})}_{TLB\ miss}
\end{align}
$$
&lt;p>Con $page\ table\ access\ time = page\ access\ time$. In altre parole, se l&amp;rsquo;entry si trova nella TLB dovrò accedere solamente alla pagina, in caso contrario bisogna consultare la memoria anche per accedere alla page table. In questo esempio, assumiamo che sia richiesto solo un accesso in caso di TLB miss e che i tempi di accesso al TLB siano trascurabili.&lt;/p>
&lt;h2 id="712-protezione">7.12 Protezione&lt;/h2>
&lt;p>È possibile implementare degli approcci di sicurezza per evitare che alcune pagine siano read-only o read-write, o temporaneamente non disponibili (p.e. per swapping del frame in memoria secondaria), e così via. Tipicamente vengono associati dei &lt;strong>bit di validità&lt;/strong> alla pagina, che indicano quali operazioni sono disponibili.&lt;/p>
&lt;!-- Per evitare che un processo acceda a dei frame in memoria a esso non assegnati è possibile inserire un bit di validità per entry nella tabella delle pagine, il cosiddetto **bit valido-invalido (valid-invalid bit)**. Ogni volta che un processo vuol fare un accesso a una pagina virtuale, il rispettivo bit di validità viene controllato e se è impostato a falso, l'accesso viene catturato dal s.o.
Questo approccio presenta un problema: se l'ultima pagina riservata al processo è utilizzata solo parzialmente, il processo ha la possibilità di accedere anche ai byte della sezione di frammentazione interna. Più in generale, è raro che un processo utilizzi tutto lo spazio degli indirizzi che gli è stato reso disponibile. Alcuni sistemi utilizzano un **registro di lunghezza della tabella delle pagine (Page-Table Length Register, PTLR)** che indica la dimensione della tabella delle pagine per controllare il range di validità degli indirizzi a cui un processo può accedere. -->
&lt;!-- È possibile estendere l'approccio del bit di validità per includere altri bit read-only, write-only, etc. -->
&lt;h2 id="713-pagine-condivise">7.13 Pagine condivise&lt;/h2>
&lt;p>Uno dei vantaggi del paging è la possibilità di condividere pagine tra processi. Supponiamo ad esempio il caso di alcuni processi che utilizzano la stessa libreria standard, con cui è possibile risparmiare spazio creando alcune tabelle di pagine che si riferiscono alla stessa zona di memoria fisica contenente la libreria. Il codice in oggetto è chiamato &lt;strong>reentrant code&lt;/strong> e dev&amp;rsquo;essere non modificabile, in maniera tale da garantire l&amp;rsquo;integrità dei suoi dati.&lt;/p>
&lt;h2 id="714-struttura-di-una-tabella-di-pagine">7.14 Struttura di una tabella di pagine&lt;/h2>
&lt;p>In questa sezione esploriamo tecniche comuni per strutturare una tabella di pagine.&lt;/p>
&lt;h3 id="7141-paging-gerarchico">7.14.1 Paging gerarchico&lt;/h3>
&lt;p>Con i sistemi moderni, una tabella di pagine può diventare molto grande e occupare molto spazio in memoria. Per esempio, su un sistema a $32$-bit con pagine di dimensione $4$K ($2^{12}$) una tabella delle pagine consisterebbe di $2^{32} / 2^{12}$ entry. Se la dimensione di un&amp;rsquo;entry è, ad esempio di 4 byte, consumeremmo 4MB di memoria a processo solo per la tabella delle pagine.&lt;/p>
&lt;p>È possibile ovviare al problema inserendo una struttura di paging gerarchica in cui anche la tabella delle pagine viene paginata. Ad esempio nel precedente sistema, se associamo $20$ bit al numero di pagina e $12$ bit all&amp;rsquo;offset, potremmo &amp;ldquo;paginare&amp;rdquo; la tabella, dividendo i $20$ bit in $10$ per un primo livello e $10$ per un secondo livello di paging. Il livello più interno si riferirebbe sempre al numero di pagina, mentre quello esterno sarebbe relativo a una tabella delle pagine esterna, che indicizza la tabella delle pagine interna.&lt;/p>
&lt;p>Il processo può essere esteso a più livell di gerarchia, per caricare meno dati in memoria. Questa strategia richiede tuttavia più accessi alla memoria, uno per livello, peggiorando le prestazioni in termini di efficienza computazionale.&lt;/p>
&lt;h3 id="7142-tabelle-hash-di-pagine">7.14.2 Tabelle hash di pagine&lt;/h3>
&lt;p>Un approccio per gestire gli spazi di indirizzi maggiori di 32 bit è quello di usare una &lt;strong>tabella hash di pagine (hashed page table)&lt;/strong> con il valore di hash corrispondente al numero virtuale di pagina. Ogni entry della tabella contiene una lista concatenata. Ogni elemento consiste in tre campi:&lt;/p>
&lt;ul>
&lt;li>Il numero di pagina virtuale $p$&lt;/li>
&lt;li>Il valore del frame mappato $s$&lt;/li>
&lt;li>Un puntatore all&amp;rsquo;elemento successivo nella lista concatenata&lt;/li>
&lt;/ul>
&lt;p>$p$ viene convertito nel valore hash e l&amp;rsquo;entry corrispondente viene cercata nella tabella. $p$ viene quindi confrontato con gli elementi della lista per trovare il frame $s$ corrispondente.&lt;/p>
&lt;!-- Una variazione utile per indirizzi a $64$bit è quella a **tabella di pagine raggruppate (clustered page table)** in cui un valore hash si può riferire a diverse pagine piuttosto che a una soltanto. In questo caso, una singola entry nella tabella si può riferire a diversi frame. Questa variante è utile in presenza di spazi degli indirizzi **sparsi** -->
&lt;h3 id="7143-tabella-di-pagine-invertita">7.14.3 Tabella di pagine invertita&lt;/h3>
&lt;p>Tutte le tabelle di pagine puntano ai frame della memoria fisica. Per risparmiare spazio possiamo salvarci una tabella invertita che, a ogni frame, mappa la pagina virtuale a esso associato. Per garantire protezione da accessi non voluti, è necessario salvare anche il PID del processo associato alle pagine virtuali. In questo modo è possibile salvarsi un&amp;rsquo;unica tabella anziché una tabella per processo, salvando molta memoria.&lt;/p>
&lt;p>Questo metodo è costoso in termini di ricerca, poiché per fare una mappatura da pagina a frame bisogna potenzialmente scorrere tutti i frame uno a uno finché non viene trovato il riferimento. Per accelerare il processo è possibile utilizzare una tabella hash. Inoltre, un grosso svantaggio è costituito dal fatto che è possibile salvare solo un riferimento per frame e quindi un frame non può essere condiviso tra processi (sono necessarie tecniche alternative per farlo).&lt;/p>
&lt;h2 id="715-swapping">7.15 Swapping&lt;/h2>
&lt;p>Lo swapping è una tecnica per ampliare la spazio degli indirizzi fisici, che consiste nello spostare &lt;strong>(swapping)&lt;/strong> processi, o porzioni di un processo in un &lt;strong>backing store&lt;/strong> temporaneo che risiede tipicamente nella memoria secondaria.&lt;/p>
&lt;h3 id="7151-standard-swapping">7.15.1 Standard swapping&lt;/h3>
&lt;p>Questa tecnica prevede di spostare un processo intero tra la memoria e il backing store. Il vantaggio principale consiste nel permettere alla memoria fisica di essere sovrascritta permettendo di gestire più processi. I processi in attesa sono buoni candidati per lo swapping.&lt;/p>
&lt;h3 id="7152-swapping-con-paging">7.15.2 Swapping con paging&lt;/h3>
&lt;p>Il swapping standard richiede molto tempo per lo spostamento dei processi ed è stato quasi sempre sostituito con il swapping di una parte delle pagine di un processo. Il termine &lt;em>swapping&lt;/em> si riferisce allo swapping standard mentre il termine &lt;em>paging&lt;/em> allo swapping con le pagine. Un&amp;rsquo;operazione di &lt;strong>page out&lt;/strong> sposta una pagina dalla memoria al backing store, mentre un&amp;rsquo;operazione di &lt;strong>page in&lt;/strong> fa il contrario.&lt;/p>
&lt;div id="references-1784797996895776590" class="references" style="margin-top: 5rem;">
&lt;p>&lt;strong>References&lt;/strong>&lt;/p>
A. Silberschatz, P. B. Galvin, G. Gagne, &lt;em>Sistemi Operativi. Concetti ed Esempi&lt;/em>, Decima edizione. Ch. 9.
&lt;/div>
&lt;script>
document.addEventListener('DOMContentLoaded', function () {
var ref = document.getElementById('references-1784797996895776590');
var fn = document.querySelector('.footnotes');
if (ref &amp;&amp; fn &amp;&amp; fn.parentNode) {
fn.parentNode.insertBefore(ref, fn.nextSibling);
}
});
&lt;/script></description></item><item><title>8 Gestione della memoria virtuale</title><link>https://aretor.it/courses/os2425/lessons/08_virt_mem/</link><pubDate>Tue, 08 Apr 2025 00:00:00 +0000</pubDate><guid>https://aretor.it/courses/os2425/lessons/08_virt_mem/</guid><description>&lt;p>In questa lezione approfondiremo i concetti sottostanti la memoria virtuale, la sua implementazione e alcune considerazioni sulla sua efficenza computazionale&lt;/p>
&lt;h2 id="81-background">8.1 Background&lt;/h2>
&lt;p>Ci sono varie casistiche da considerare per poter risparmiare memoria:&lt;/p>
&lt;ul>
&lt;li>Durante la sua esecuzione, un programma non utilizza tipicamente tutto il codice della sua implementazione. Le routine di errore, per esempio, vengono chiamate solo all&amp;rsquo;occorrenza.&lt;/li>
&lt;li>Alcune strutture dati (p.e. array) possono venire allocate con maggiore memoria di quella effettivamente richiesta&lt;/li>
&lt;/ul>
&lt;p>Per tali motivi, è utile caricare solo parte di un programma in memoria, utilizzando una memoria virtuale. I vantaggi sono:&lt;/p>
&lt;ul>
&lt;li>Un programma può accedere a uno spazio virtuale molto più grande di quello fisico&lt;/li>
&lt;li>Molti programmi possono eseguiti contemporaneamente&lt;/li>
&lt;li>Sono necessarie meno operazioni di I/O per lo swapping completo dei programmi&lt;/li>
&lt;li>Il programmatore non si deve preoccupare di gestire la memoria permettendogli di sviluppare codice più facilmente&lt;/li>
&lt;/ul>
&lt;p>Nella gestione della memoria virtuale, possiamo allocare negli indirizzi iniziali le istruzioni del programma, seguite dai dati e dall&amp;rsquo;heap dinamico. Negli indirizzi finali, in coda allo spazio degli indirizzi allochiamo lo stack, lasciando così un buco intermedio tra stack e heap che può essere utilizzato per estendere queste strutture o caricare le librerie dinamiche.&lt;/p>
&lt;p>La memoria virtuale ci permette anche di:&lt;/p>
&lt;ul>
&lt;li>Condividere librerie di sistema&lt;/li>
&lt;li>Condividere codice in comune (p.e. a seguito di una &lt;code>fork&lt;/code>)&lt;/li>
&lt;li>Avviare una comunicazione tramite memoria condivisa&lt;/li>
&lt;/ul>
&lt;h2 id="82-paging-su-richiesta">8.2 Paging su richiesta&lt;/h2>
&lt;p>Come detto, è possibile caricare solo parzialmente le parti di un programma, posticipando il caricamento di pagine specifiche solamente quando queste sono richieste dal processo per continuare a eseguire. Questa tecnica si chiama &lt;strong>paging su richiesta (demand paging)&lt;/strong>.&lt;/p>
&lt;h3 id="821-concetti-base">8.2.1 Concetti base&lt;/h3>
&lt;p>Il paging su richiesta necessita di un meccanismo per indicare quali pagine nella tabella delle pagine sono state caricate in memoria. È possibile, a tal proposito, utilizzare un bit di validità. Quando il bit è impostato a &lt;code>true&lt;/code>, la pagina è valida, altrimenti la pagina non è valida oppure non è stata caricata in memoria centrale. Nel caso si provi ad accedere una pagina con bit a &lt;code>false&lt;/code>, viene emesso un &lt;strong>page fault&lt;/strong> che viene catturato dal s.o. La procedura di gestione dell&amp;rsquo;errore è la seguente:&lt;/p>
&lt;ul>
&lt;li>Viene controllata una tabella interna al processo per vedere se la pagina non è valida&lt;/li>
&lt;li>Se la pagina non è valida il processo termina, se è valida la pagina viene caricata&lt;/li>
&lt;li>Viene cercato un frame libero per l&amp;rsquo;allocazione della pagina&lt;/li>
&lt;li>La pagina viene letta dalla memoria secondaria e allocata nel nuovo frame&lt;/li>
&lt;li>Viene aggiornata la tabella delle pagine per indicare che la pagina è ora valida&lt;/li>
&lt;li>L&amp;rsquo;esecuzione del processo riprende&lt;/li>
&lt;/ul>
&lt;p>Nel caso estremo, un processo può essere inizializzato con zero pagine in memoria (&lt;strong>pure demand paging&lt;/strong>).&lt;/p>
&lt;p>Può accadere che un processo richieda molte pagine il cui bit è impostato a false una di seguito all&amp;rsquo;altra comportando un peggioramento delle performance inaccettabile. Quest&amp;rsquo;eventualità è molto rara poiché i programmi tendono ad avere una &lt;strong>località dei riferimenti&lt;/strong>, per cui molti accessi consecutivi avvengono spesso in locazioni vicine tra loro.&lt;/p>
&lt;p>Quando un processo tenta di accedere a una pagina non caricata in memoria, si interrompe e riprende l&amp;rsquo;esecuzione al suo caricamento. Questo può comportare la ripetizione dell&amp;rsquo;istruzione che stava eseguendo. Per esempio, nel caso di una operazione di somma:&lt;/p>
&lt;ul>
&lt;li>Gli addendi &lt;code>A&lt;/code> e &lt;code>B&lt;/code> vengono letti&lt;/li>
&lt;li>Il risultato &lt;code>A + B&lt;/code> viene computato&lt;/li>
&lt;li>Il risultato viene salvato in &lt;code>C&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>Nel caso in cui &lt;code>C&lt;/code> corrispondesse a una locazione di memoria in una pagina invalida, il processo va in interruzione per caricare la pagina corrispondente, e alla sua ripresa deve ricomputare la somma. Nel caso di istruzioni che implicano lo spostamento di grossi moli di dati, questo può peggiorare le performance. Esistono varie soluzioni a questo problema.&lt;/p>
&lt;h3 id="822-lista-di-frame-liberi">8.2.2 Lista di frame liberi&lt;/h3>
&lt;p>Molti sistemi utilizzano una &lt;strong>lista di frame liberi (free-frame list)&lt;/strong> per trovare subito un frame disponibile a ospitare una pagina dopo un page fault. I s.o. allocano tipicamente frame utilizzando una tecnica chiamata &lt;strong>zero-fill on demand&lt;/strong> che resetta il frame prima di eseguirne l&amp;rsquo;allocazione, per evitare problemi di sicurezza dovuti a un potenziale leakage di dati.&lt;/p>
&lt;h3 id="823-performance-del-paging-su-richiesta">8.2.3 Performance del paging su richiesta&lt;/h3>
&lt;p>Calcoliamo ora il &lt;strong>tempo effettivo di accesso (effective access time)&lt;/strong> con il paging su richiesta. Sia $p$ la probabilità di page fault ($0 \le p \le 1$) ed $ma$ il tempo di accesso alla memoria. Il tempo effettivo di accesso è&lt;/p>
$$
\text{effective access time} = (1 - p) \times ma + p \times \text{page fault time}
$$
&lt;p>Il &lt;strong>tempo di page fault (page fault time)&lt;/strong> è il più complicato da stimare, ed è composto da tre operazioni principali:&lt;/p>
&lt;ol>
&lt;li>Servire l&amp;rsquo;interrupt del page fault&lt;/li>
&lt;li>Leggere la pagina&lt;/li>
&lt;li>Riprendere l&amp;rsquo;esecuzione del processo&lt;/li>
&lt;/ol>
&lt;h2 id="83-copy-on-write">8.3 Copy-on-write&lt;/h2>
&lt;p>Quando un processo genitore chiama la &lt;code>fork()&lt;/code> e genera un processo figlio, questo ultimo riceve, di norma, anche una copia dello spazio degli indirizzi su cui operare. Tuttavia considerando che molti figli invocano &lt;code>exec()&lt;/code> immediatamente dopo la loro creazione, è possibile minimizzare il consumo di memoria utilizzando la tecnica di &lt;strong>copy-on-write&lt;/strong>. In particolare, tutte le pagine condivise vengono etichettate come copy-on-write e vengono duplicate solamente all&amp;rsquo;occorrenza, quando uno dei due processi ci esegue un&amp;rsquo;operazione di scrittura. Solamente le pagine modificabili vengono etichettate, quelle non-modificabili (p.e. quelle contenenti il codice eseguibile) no.&lt;/p>
&lt;h2 id="84-sostituzione-delle-pagine">8.4 Sostituzione delle pagine&lt;/h2>
&lt;p>Col paging su richiesta è possibile eseguire molti processi anche se il loro spazio degli indirizzi complessivo fosse più grande della memoria disponibile, poiché solamente una parte delle pagine viene caricata.&lt;/p>
&lt;p>Può accadere tuttavia che i processi richiedano contemporaneamente molte pagine in memoria e che lo spazio fisico sia effettivamente insufficiente. In questo caso, incorriamo nel &lt;strong>sovrallocamento&lt;/strong> della memoria. Nel momento in cui una nuova pagina non può essere allocata, possiamo terminare il processo che l&amp;rsquo;ha richiesta. Questa soluzione è tuttavia non ottimale, ed è meglio fare swapping di altre pagine.&lt;/p>
&lt;h3 id="841-sostituzione-delle-pagine-standard">8.4.1 Sostituzione delle pagine standard&lt;/h3>
&lt;p>Quando un processo registra un page fault, l&amp;rsquo;algoritmo standard per la sostituzione di pagine funziona nel seguente modo:&lt;/p>
&lt;ol>
&lt;li>Trova la locazione della pagina da caricare nella memoria secondaria.&lt;/li>
&lt;li>Trova un frame libero.
&lt;ol>
&lt;li>Se c&amp;rsquo;è un frame libero, usalo.&lt;/li>
&lt;li>Se non c&amp;rsquo;è, usa un algoritmo di sostituzione pagina per selezionare un &lt;strong>victim frame&lt;/strong>.&lt;/li>
&lt;li>Salva il victim frame nella memoria secondaria (se necessario), cambia la pagina e il frame.&lt;/li>
&lt;/ol>
&lt;/li>
&lt;li>Carica la pagina desiderata nel frame liberato, aggiorna le tabelle delle pagine e dei frame.&lt;/li>
&lt;li>Continua a eseguire il processo dal momento del page fault.&lt;/li>
&lt;/ol>
&lt;p>In caso non ci siano frame liberi, sono richiesti normalmente due trasferimenti, uno per la pagina in entrata e uno per la pagina in uscita. Si può ridurre l&amp;rsquo;overhead utilizzando un &lt;strong>modify bit&lt;/strong> (o &lt;strong>dirty bit&lt;/strong>), che specifica se il victim frame è stato modificato e va quindi salvato in memoria o può essere semplicemente sovrascritto.&lt;/p>
&lt;p>All&amp;rsquo;aumentare del numero di frame disponibili il numero di page fault decrementa, assumendo un andamento simil-esponenziale.&lt;/p>
&lt;p>Per implementare correttamente il paging su richiesta è necessario trovare un algoritmo di sostituzione pagine e uno di allocazione dei frame.&lt;/p>
&lt;h3 id="842-sostituzione-pagine-fifo">8.4.2 Sostituzione pagine FIFO&lt;/h3>
&lt;p>L&amp;rsquo;algoritmo più semplice è il First-In, First-Out (FIFO) che seleziona la pagina più vecchia in memoria per la sostituzione. Non serve salvare il tempo in cui le pagine sono state allocate, basta utilizzare una coda FIFO. Se una pagina in uso viene sostituita, questa verrà riallocata in memoria al prossimo page fault, garantendo comunque la correttezza dell&amp;rsquo;algoritmo.&lt;/p>
&lt;h4 id="8421-anomalia-di-belady">8.4.2.1 Anomalia di Belady&lt;/h4>
&lt;p>L&amp;rsquo;algoritmo FIFO può incorrere in un particolare fenomeno noto come &lt;strong>anomalia di Belady&lt;/strong>, per il quale con l&amp;rsquo;aumentare del numero di frame disponibili, aumenta il numero di page fault. Consideriamo la sequenza 1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5. Simulando l&amp;rsquo;algoritmo FIFO su una memoria fisica da 3 frame otteniamo 9 page fault, mentre con una memoria da 4 frame otteniamo 10 page fault!&lt;/p>
&lt;h3 id="843-sostituzione-pagine-ottimale">8.4.3 Sostituzione pagine ottimale&lt;/h3>
&lt;p>Esiste un algoritmo ottimale di sostituzione pagine, chiamato OPT o MIN. La regola è semplice e consiste nel sostituire la pagina che non verrà utilizzata per il periodo più lungo di tempo. Sfortunatamente l&amp;rsquo;algoritmo è difficile da implementare perché richiede di stimare l&amp;rsquo;utilizzo delle pagine nel futuro. Per tale motivo viene utilizzato solo come riferimento. Questo algoritmo non soffre dell&amp;rsquo;anomalia di Belady.&lt;/p>
&lt;h3 id="844-sostituzione-pagine-lru">8.4.4 Sostituzione pagine LRU&lt;/h3>
&lt;p>Un&amp;rsquo;altro algoritmo di sostituzione pagine è il cosiddetto &lt;strong>Least Recently Used (LRU)&lt;/strong>. LRU associa a ogni pagina il tempo in cui è stata utilizzata l&amp;rsquo;ultima volta e, quando è necessaria una sostituzione, viene eliminata la pagina utilizzata meno recentemente.&lt;/p>
&lt;p>È un algoritmo comunemente utilizzato. Il problema principale è l&amp;rsquo;implementazione del meccanismo di memoria, che viene generalmente eseguito in due modi:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Contatori:&lt;/strong> Associamo a ogni entry della tabella delle pagine un tempo di utilizzo e aggiungiamo alla CPU un clock logico o un contatore. Ogni volta che avviene l&amp;rsquo;accesso a una pagina, il suo tempo viene registrato nella entry di riferimento. In caso di sostituzione, la pagina con il tempo minore viene scelta per la sostituzione. Questo metodo richiede lo scorrimento delle pagine per la ricerca della pagina LRU e un&amp;rsquo;operazione di scrittura per l&amp;rsquo;aggiornamento delle pagine a ogni accesso in memoria.&lt;/li>
&lt;li>&lt;strong>Stack:&lt;/strong> Un altro approccio è uello di utilizzare una pila di numeri di pagina. Quando una pagina viene acceduta, viene messa sopra la pila. In questa maniera le pagine utilizzate più recentemente sono mantenute sopra lo stack. La struttura dati da utilizzare è una lista doppiamente concatenata con un puntatore alla testa e alla coda. Questo approccio è particolarmente vantaggioso.&lt;/li>
&lt;/ul>
&lt;!-- Rimuovere una pagina e metterla sopra la pila richiede la modifica di sei puntatori nel peggiore dei casi. Non è necessaria la ricerca, anche se l'update è più costoso. -->
&lt;p>Anche questo algoritmo non soffre dell&amp;rsquo;anomalia di Belady. Sia l&amp;rsquo;algoritmo ottimale che questo algoritmo vengono chiamati &lt;strong>algoritmi stack&lt;/strong> poiché si può dimostrare che l&amp;rsquo;insieme di pagine in memoria su $n$ frame è sempre il sottoinsieme di pagine che sarebbe in memoria con $n + 1$ frame. Per la sostituzione LRU, l&amp;rsquo;insieme di pagine in memoria consisterebbe nelle $n$ pagine utilizzate più recentemente.&lt;/p>
&lt;h4 id="8441-sostituzione-pagine-lru-approssimata">8.4.4.1 Sostituzione pagine LRU approssimata&lt;/h4>
&lt;p>Non tutti i sistemi forniscono hardware sufficiente per supportare LRU. Molti sistemi forniscono qualche aiuto, nella forma di &lt;strong>bit di riferimento (reference bit)&lt;/strong>. Il bit di riferimento per una pagina è impostato dall&amp;rsquo;hardware a $1$ ogni volta che la pagina viene acceduta. È possibile esaminare e selezionare la pagina da sostituire tra quelle che non sono state utilizzate, anche se non conosciamo l&amp;rsquo;ordine di utilizzo.&lt;/p>
&lt;p>È possibile estendere l&amp;rsquo;algoritmo includendo diversi bit di riferimento. A intervalli regolari (p.e. ogni $100$ millisecondi), avviene un interrupt che trasferisce il controllo al s.o. che si occupa di fare lo shift dei bit, scartando quello meno significativo e impostando il bit più significativo a $1$ nel caso la pagina sia stata utilizzata, durante l&amp;rsquo;intervallo precedente. La sequenza di bit così formata è un intero (p.e. $10111111$, con $8$ bit, indica che la pagina non è stata acceduta nel penultimo intervallo soltanto). Possiamo quindi selezionare tutte le pagine corrispondenti all&amp;rsquo;intero minimo e rimuoverle tutte oppure utilizzare un metodo FIFO per sceglierne una soltanto.&lt;/p>
&lt;p>L&amp;rsquo;algoritmo LRU con un solo bit di riferimento viene chiamato &lt;strong>algoritmo di sostituzione pagine con seconda possibilità (second-chance page-replacement algorithm)&lt;/strong>. È possibile utilizzare il bit di riferimento assieme a quello di modifica per ottenere un&amp;rsquo;analisi più completa dell&amp;rsquo;utilizzo della pagina:&lt;/p>
&lt;ul>
&lt;li>(0, 0) pagina non recentemente usata, né modifica. Rappresenta il candidato migliore per la sostituzione&lt;/li>
&lt;li>(0, 1) pagina non recentemente usata, ma modificata. La sua sostituzione comporta una scrittura in memoria secondaria&lt;/li>
&lt;li>(1, 0) pagina recentemente usata, ma non modificata. Probabilmente verrà usata di nuovo&lt;/li>
&lt;li>(1, 1) pagina recentemente usata e modificata. Verrà probabilmente riusata e in ogni caso dovrebbe venire scritta nella memoria secondaria&lt;/li>
&lt;/ul>
&lt;h3 id="845-sostituzione-pagine-basata-su-conteggio">8.4.5 Sostituzione pagine basata su conteggio&lt;/h3>
&lt;p>Ci sono altri algoritmi che possono essere usati per la sostituzione delle pagine. Per esempio possiamo tenere un riferimento al numero di volte in cui una pagina è stata acceduta.&lt;/p>
&lt;h3 id="846-algoritmi-di-buffering-di-pagina">8.4.6 Algoritmi di buffering di pagina&lt;/h3>
&lt;p>È possibile implementare dei buffer di frame liberi, in maniera tale che quando accade un page fault, la pagina richiesta possa essere caricata subito nel buffer, mentre il victim frame viene liberato.&lt;/p>
&lt;h2 id="85-allocazione-dei-frame">8.5 Allocazione dei frame&lt;/h2>
&lt;p>Passiamo ora al problema dell&amp;rsquo;allocazione dei frame ai vari processi. In particolare, ci chiediamo quanti frame dobbiamo assegnare a ciascun processo.&lt;/p>
&lt;h3 id="851-numero-di-frame-minimo">8.5.1 Numero di frame minimo&lt;/h3>
&lt;p>È importante allocare un numero minimo di frame per processo. Le motivazioni di questa scelta sono due:&lt;/p>
&lt;ol>
&lt;li>Col diminuire di frame disponibili per un processo, i suoi page fault aumentano, diminuendo le prestazioni.&lt;/li>
&lt;li>Quando avviene un page fault prima che un&amp;rsquo;istruzione sia completata, l&amp;rsquo;istruzione va ri-eseguita da capo.&lt;/li>
&lt;/ol>
&lt;p>Solitamente un&amp;rsquo;architettura stabilisce il numero minimo di frame e imposta il numero massimo con il numero di frame totali nella memoria fisica.&lt;/p>
&lt;h3 id="852-algoritmi-di-allocazione">8.5.2 Algoritmi di allocazione&lt;/h3>
&lt;p>La maniera più semplice per allocare $m$ frame a $n$ processi è di dare a ciascuno un ammontare uguale di $m/n$ frame. Per esempio, con $93$ frame e $5$ processi daremmo $18$ frame a ciascuno, tenendo i rimanenti $3$ come buffer. Questo schema è chiamato &lt;strong>allocazione uniforme&lt;/strong>.&lt;/p>
&lt;p>Un&amp;rsquo;alternativa è quella di riconoscere che i vari processi richiederanno un differente ammontare di memoria. Se abbiamo un sistema con frame a dimensione $s$ e $n$ processi $P_1, \dots, P_n$ che pesano $s_1, \dots, s_n$, possiamo calcolare&lt;/p>
$$
S = \sum_{i = 1}^n s_i
$$
&lt;p>e calcolare quanti frame allocare a ogni processo utilizzando la seguente formula&lt;/p>
$$
a_i = s_i / S \times m.
$$
&lt;p>Dobbiamo poi regolare il numero affinché sia un intero maggiore del numero minimo di frame e minore di $m$. Questo algoritmo si chiama di &lt;strong>allocazione proporzionale&lt;/strong>.&lt;/p>
&lt;p>Lo svantaggio di questo metodo è che non fa distinzioni tra processi a bassa e alta priorità. Questi ultimi potrebbero avere delle maggiori esigenze in termini di memoria per garantire la loro esecuzione in tempi stretti. È possibile quindi calcolare lo spazio di allocazione anche in base alla priorità del processo.&lt;/p>
&lt;h3 id="853-allocazione-globale-vs-locale">8.5.3 Allocazione globale vs locale&lt;/h3>
&lt;p>Possiamo classifiare gli algoritmi di sostituzione pagine in due macro categorie:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Rimpiazzamento globale:&lt;/strong> un processo può selezionare un qualunque frame di sostituzione, anche tra i frame posseduti da altri processi. È utile per esempio per permettere a processi ad alta priorità di selezionare all&amp;rsquo;occorrenza frame da processi a bassa priorità. Il problema principale è che il comportamento di paging di ogni processo varia e non è quindi facile prevedere quali pagine selezionare. In ogni caso, il rimpiazzamento globale è la scelta più comunemente adottata.&lt;/li>
&lt;li>&lt;strong>Rimpiazzamento locale:&lt;/strong> un processo può selezionare un frame di sostituzione solo tra quelli a esso allocati. Il problema principale è che il processo non può accedere a frame di altri processi che potrebbe essere sottoutilizzati.&lt;/li>
&lt;/ul>
&lt;p>È possibile implementare il rimpiazzamento globale utilizzando una lista di frame liberi che soddisfi i page fault dei processi. La lista viene utilizzata fino a che la sua dimensione scende fino a una certa soglia minima, positiva. Raggiunta la soglia i processi smettono di richiedere frame liberi e avviano l&amp;rsquo;algoritmo di sostituzione pagine. In questo modo, vengono conservati dei frame liberi utilizzabili per nuove richieste (p.e. un nuovo processo viene caricato in memoria). Raggiunta la soglia minima, inoltre, il kernel avvia un processo &lt;strong>reaper&lt;/strong>, che inizia a reclamare le pagine assegnate agli altri processi (kernel escluso) utilizzando un algoritmo di rimpiazzamento pagina. Il reaper non utilizza effettivamente i frame, ma li libera soltanto, incrementando la lista di frame liberi. L&amp;rsquo;operazione continua finché non viene raggiunta una soglia superiore e i processi possono tornare a richiedere frame dalla lista di frame liberi.&lt;/p>
&lt;div id="references-1784797996921047419" class="references" style="margin-top: 5rem;">
&lt;p>&lt;strong>References&lt;/strong>&lt;/p>
A. Silberschatz, P. B. Galvin, G. Gagne, &lt;em>Sistemi Operativi. Concetti ed Esempi&lt;/em>, Decima edizione. Ch. 10.
&lt;/div>
&lt;script>
document.addEventListener('DOMContentLoaded', function () {
var ref = document.getElementById('references-1784797996921047419');
var fn = document.querySelector('.footnotes');
if (ref &amp;&amp; fn &amp;&amp; fn.parentNode) {
fn.parentNode.insertBefore(ref, fn.nextSibling);
}
});
&lt;/script></description></item><item><title>9 Memoria di massa</title><link>https://aretor.it/courses/os2425/lessons/09_sec_mem/</link><pubDate>Wed, 09 Apr 2025 00:00:00 +0000</pubDate><guid>https://aretor.it/courses/os2425/lessons/09_sec_mem/</guid><description>&lt;p>In questa lezione vedremo i meccanismi di base e il modo in cui i s.o. traducono le loro proprietà fisiche in memoria logica tramite la mappatura degli indirizzi.&lt;/p>
&lt;h2 id="91-struttura-dei-dispositivi-di-memorizzazione">9.1 Struttura dei dispositivi di memorizzazione&lt;/h2>
&lt;p>I &lt;strong>dischi magnetici (o rigidi)&lt;/strong> e i &lt;strong>dispositivi NVM&lt;/strong> cosituiscono i supporti fondamentali di memoria secondaria dei computer. Di seguito riportiamo le loro caratteristiche principali.&lt;/p>
&lt;h3 id="911-dischi-rigidi">9.1.1 Dischi rigidi&lt;/h3>
&lt;p>I dischi rigidi sono costituiti da piatti che hanno una forma piana e rotonda, come quella dei Compact Disc (CD). Le loro superfici sono ricoperte di materiale magnetico la cui configurazione consente la memorizzazione delle informazioni. Sulla superficie di ogni piatto stanno, sospese, le testine di lettura e scrittura, attaccate al &lt;strong>braccio del disco&lt;/strong>. La superficie di ogni piatto è divisa logicamente in &lt;strong>tracce&lt;/strong> circolari, che a loro volta sono suddivise in &lt;strong>settori&lt;/strong>. L&amp;rsquo;insieme delle tracce corrispondenti a una posizione del braccio costituisce un &lt;strong>cilindro&lt;/strong>.&lt;/p>
&lt;!-- **N.B ale vedi figura 11.1 perché secondo me è utile** -->
&lt;p>Il disco in funzione ruota tra velocità comprese tra i $60$ e $250$ giri al secondo. Questa velocità di rotazione è in relazione con la &lt;strong>velocità di trasferimento (transfer rate)&lt;/strong>, ovvero la velocità con cui i dati vengono trasferiti dall&amp;rsquo;unità a disco al calcolatore. La transfer rate è uno dei fattori che determina le prestazioni. Il secondo fattore è il cosiddetto &lt;strong>tempo di posizionamento/tempo d&amp;rsquo;accesso casuale&lt;/strong>, che consta di due componenti: il tempo necessario a spostare il braccio del disco in corrispondenza del cilindro desiderato, detto &lt;strong>tempo di ricerca&lt;/strong> e il tempo necessario affinché il settore desiderato si porti, tramite la rotazione del disco, sotto la testina, detto &lt;strong>latenza di rotazione&lt;/strong>. Questi tempi sono nell&amp;rsquo;ordine dei millisecondi.&lt;/p>
&lt;h3 id="912-dispositivi-nvm">9.1.2 Dispositivi NVM&lt;/h3>
&lt;p>Gli NVM sono dispositivi elettrici composti da un controllore e da diversi chip di memoria a semiconduttore NAND flash utilizzati per memorizzare i dati.&lt;/p>
&lt;p>Questi dispositivi&lt;/p>
&lt;ul>
&lt;li>Vengono spesso inseriti in contenitori simili a unità disco, e dunque vengono chiamati &lt;strong>dischi a stato solido&lt;/strong>, o &lt;strong>SSD&lt;/strong>. Possiamo però trovare dispositivi NVM anche in formato di &lt;strong>unità USB&lt;/strong>&lt;/li>
&lt;li>Possono essere più affidabili dei dischi rigidi, perché non hanno parti mobili e sono in genere più veloci, perché non hanno tempo di posizionamento o latenza di rotazione&lt;/li>
&lt;li>Consumano meno energia, però per contro sono più costosi e hanno una capacità inferiore rispetto ai dischi rigidi tradizionali. Nel corso del tempo, la capacità dei dispostivi NVM è aumentata più rapidamente rispetto a quella dei dischi rigidi, e il loro prezzo è diminuito, motivo per cui il loro utilizzo è in aumento&lt;/li>
&lt;/ul>
&lt;p>Poiché i dispositivi NVM possono essere molto più veloci rispetto ai dischi rigidi, le interfacce bus standard possono costituire un limite importante al throughpput e alcuni dispositivi NVM sono progettati per connettersi direttamente al bus di sistema.&lt;/p>
&lt;p>Un dispositivo NVM è composto da moltissimi &lt;strong>die&lt;/strong>, delle piastrine di circuiti integrati atte alla memorizzazione dei dati. Ogni die contiene &lt;strong>blocchi&lt;/strong> che a loro volta contengono molte &lt;strong>pagine&lt;/strong>. La scrittura in un NVM viene effettuata a livello di pagina, ma richiede più tempo della lettura. È necessario infatti cancellare completamente il &lt;em>blocco&lt;/em> corrispondente e riscriverlo, con la pagina modificata. In altre parole, non è possibile sovrascrivere i dati. Il problema è alleviato dal fatto che i &lt;strong>die&lt;/strong> hanno molteplici &lt;strong>datapath&lt;/strong> che garantiscono l&amp;rsquo;esecuzione di operazioni in parallelo. A differenza di altri dispositivi inoltre, la durata di un NVM non si misura in anni, bensì in numero di scritture per giorno sull&amp;rsquo;unità (Drive Writes Per Day, DWPD).&lt;/p>
&lt;h4 id="9121-algoritmi-del-controllore-delle-nand-flash">9.1.2.1 Algoritmi del controllore delle NAND Flash&lt;/h4>
&lt;p>Poiché i dispositivi NAND non possono essere sovrascritti, e vanno prima cancellati, quando una pagina viene modificata, viene semplicmente scritta in una nuova locazione di memoria libera per risparmiare tempo e le versioni precedenti della pagina, con dati non più validi, rimangono in memoria. Per tenerne traccia, il controllore mantiene una &lt;strong>tabella di traduzione flash&lt;/strong> (Flash Translation Layer, FTL) così da poter sapere quali blocchi contengono solo pagine non valide e pertanto cancellabili. Per non esaurire lo spazio, un &lt;strong>garbage collector&lt;/strong> accorpa le pagine con dati validi in altre posizioni, liberando completamente blocchi che possono poi essere cancellati e riutilizzati per nuove scritture. Per migliorare il coordinamento tra nuove scritture dati e garbage collection, il dispositivo utilizza una tecnica di &lt;strong>over-provisioning&lt;/strong>. Il produttore mette da parte un certo numero di blocchi che risulta invisibile all&amp;rsquo;utente. Tali blocchi sono utilizzati per:&lt;/p>
&lt;ul>
&lt;li>Permettere la scrittura istantanea di nuove pagine da parte del controllore.&lt;/li>
&lt;li>Permettere al garbage collector di svolgere il suo compito di accorpamento pagine più efficientemente, utilizzando l&amp;rsquo;area di over-provisionig come buffer.&lt;/li>
&lt;li>Garantire un&amp;rsquo;area sempre disponibile per la scrittura in caso di malfunzionamenti futuri di altri blocchi.&lt;/li>
&lt;/ul>
&lt;!--https://www.kingston.com/en/blog/pc-performance/overprovisioning-->
&lt;p>In aggiunta, se alcuni blocchi vengono cancellati ripetutamente, mentre altri non lo sono, i blocchi frequentemente cancellati si logorano più velocemente degli altri e l&amp;rsquo;intero dispositivo avrà una vita più breve. Per questo motivo, il controllore di dispositivo cerca di alleviare questo problema con degli algoritmi che favoriscono la memorizzazione di dati su blocchi meno cancellati.&lt;/p>
&lt;h3 id="913-memoria-volatile">9.1.3 Memoria volatile&lt;/h3>
&lt;p>La DRAM, l&amp;rsquo;hardware per la memoria principale, viene spesso utilizzata come dispositivo di archivazione di massa. Più precisamente, una sezione della DRAM viene ritagliata e presentata al resto del sistema come se fosse una memoria di massa (con un file system associato). In questa maniera, dati in memoria principale possono essere salvati temporanemanete come file temporanei in memoria utilizzando le normali operazioni sui file. Infatti, sebbene i dispositivi NVM siano veloci, la DRAM è molto più veloce e le operazioni di I/O su unità RAM sono il modo piu veloce per creare, leggere, scrivere ed eliminare file.&lt;/p>
&lt;h3 id="914-mappatura-degli-indirizzi">9.1.4 Mappatura degli indirizzi&lt;/h3>
&lt;p>I dispositivi di archivazione sono indicizzati come vettori unidimensionali di &lt;strong>blocchi logici&lt;/strong>, in cui un blocco logico rappresenta la più piccola unità di trasferimento. Ogni blocco logico viene mappato su un settore fisico o su una pagina di un dispositivo a semiconduttore.&lt;/p>
&lt;p>Nei dischi rigidi, una zona di memoria viene identificata attraverso una tupla &lt;code>&amp;lt;settore, cilindro, testina&amp;gt;&lt;/code>. Nei dispositivi NVM una pagina viene invece identificata attraverso una tupla &lt;code>&amp;lt;chip, blocco, pagina&amp;gt;&lt;/code>. Per semplificare le operazioni, le tuple sono convertite in un &lt;strong>indirizzo di blocco logico (Logic Block Address, LBA)&lt;/strong> (anche per il disco rigido, nonostante il nome). In un disco rigido, la mappatura da indirizzo a logico a fisico è difficile da effettuare nella pratica, principalmente per tre motivi:&lt;/p>
&lt;!-- Usando questa mappatura su un disco rigido possiamo, almeno in teoria, convertire un numero di blocco logico in un indirizzo *vecchio stile* del disco formato da un numero di cilindro, un numero di traccia all'interno di quel cilindro e un numero di settore all'interno di quella traccia. Nella pratica però è difficile effettuare una traduzione 1:1, principalmente per tre motivi: -->
&lt;ul>
&lt;li>La maggior parte delle unità presenta alcuni settori difettosi che la mappatura nasconde sostituendoli con settori di riserva collocati in altre posizioni sull&amp;rsquo;unità.&lt;/li>
&lt;/ul>
&lt;!-- In questo caso, l'indirizzo del blocco logico rimane sequenziale, ma la posizione del settore fisico cambia -->
&lt;ul>
&lt;li>Il numero di settori per traccia non è sempre costante.&lt;/li>
&lt;li>I produttori di dischi gestiscono la mappatura di LBA su indirizzi fisici internamente.&lt;/li>
&lt;/ul>
&lt;h2 id="92-scheduling-dei-dischi-rigidi">9.2 Scheduling dei dischi rigidi&lt;/h2>
&lt;p>Il sistema operativo ha, tra le varie responsabilità, quella di fare un uso efficiente dell&amp;rsquo;hardware. Nel caso dei dischi rigidi, far fronte a questa responsabilità significa garantire tempi di accesso contenuti e ampiezze di banda elevate. Nel caso dei dischi, il tempo d&amp;rsquo;accesso è composto da:&lt;/p>
&lt;ol>
&lt;li>Il &lt;strong>tempo di ricerca&lt;/strong> (seek time): ossia il tempo richiesto affinché il braccio dell&amp;rsquo;unità a disco sposti le testine fino al cilindro contenente il settore desiderato&lt;/li>
&lt;li>La &lt;strong>latenza di rotazione&lt;/strong> (rotational latency): il tempo necessario affinché il disco ruoti finché il settore desiderato si trovi sotto la testina.&lt;/li>
&lt;/ol>
&lt;p>L&amp;rsquo;&lt;strong>ampiezza di banda (bandwidth)&lt;/strong> è definita come il numero totale di byte trasferiti diviso il tempo totale intercorso fra la prima richiesta e il completamento dell&amp;rsquo;ultimo trasferimento. Gestendo l&amp;rsquo;ordine delle richieste di I/O relative al disco si possono migliorare sia il tempo d&amp;rsquo;accesso sia l&amp;rsquo;ampiezza di banda. Se l&amp;rsquo;unità a disco desiderata e il controllore sono disponibili, la richiesta si può immediatamente soddisfare. In caso contrario le nuove richieste si aggiungono alla coda, che dovrà essere opportunamente gestita. La presenza di una coda di richieste verso un dispositivo su cui è possibile minimizzare i tempi di ricerca (seek) consente ai driver del dipostivo di migliorare le prestazioni ordinando opportunamente la coda.&lt;/p>
&lt;h3 id="921-scheduling-in-ordine-di-arrivo--fcfs">9.2.1 Scheduling in ordine di arrivo – FCFS&lt;/h3>
&lt;p>Questo scheduling rappresenta la forma più semplice dove il servizio viene effettuato secondo l&amp;rsquo;ordine di arrivo (FCFS). Si tratta di un algoritmo &lt;em>equo&lt;/em>, ma che in generale non garantisce la massima velocità di servizio. Vediamolo con un esempio:
Data la coda di richieste nei cilindri $98$, $183$, $37$, $122$, $14$, $124$, $65$, $67$, la testina, una volta arrivata al cilindro $122$, deve poi effettuare un grande salto per andare al cilindro $14$, e tornare successivamente al $124$, con conseguente inefficienza di utilizzo. Se invece le richieste per i cilindri $37$ e $14$ si potessero effettuare in sequenza, la distanza totale percorsa diminuirebbe notevolmente e le prestazioni migliorerebbero di conseguenza.&lt;/p>
&lt;h3 id="922-scheduling---scan">9.2.2 Scheduling - SCAN&lt;/h3>
&lt;p>Questo algoritmo prevede che il braccio dell&amp;rsquo;unità a disco parta da un estremo del disco, si sposti verso l&amp;rsquo;altro estremo, e serva le richieste mentre attraversa i cilindri. Arrivato all&amp;rsquo;estremità, il braccio inverte la marcia e la procedura continua. Per questo motivo, viene anche chiamato &lt;strong>algoritmo dell&amp;rsquo;ascensore&lt;/strong>. Vediamo il funzionamento ancora con l&amp;rsquo;esempio precedente. Data la coda $98$, $183$, $37$, $122$, $14$, $124$, $65$ e $67$, sappiamo che la posizione corrente è $53$, e che la direzione del movimento della testina è nella direzione del cilindro $0$. Dunque, l&amp;rsquo;unità a disco serve per prima la richiesta $37$, e poi la $14$. Una volta arrivato al cilindro $0$, inverte in senso di marcia servendo le richieste $65$, $67$, $98$, $122$, $124$ e $183$. Se arriva una nuova richiesta riferita a uno dei cilindri posti davanti alla testina essa sarà quasi immediatamente soddisfatta. In caso contrario, dovrà attendere fino a che la testina non giunga alla fine del disco e inverta la direzione di moto.&lt;/p>
&lt;h3 id="923-scheduling--c-scan">9.2.3 Scheduling – C-SCAN&lt;/h3>
&lt;p>L&amp;rsquo;algoritmo &lt;strong>SCAN circolare&lt;/strong> è una variante dello scheduling SCAN concepita per garantire un tempo d&amp;rsquo;accesso meno variabile. Anche questo algoritmo, come il precedente, sposta la testina da un estremo all&amp;rsquo;altro del disco servendo le richieste lungo il percorso. Tuttavia, quando la testina giunge all&amp;rsquo;altro estremo del disco, ritorna immediatamente all&amp;rsquo;inizio del disco stesso, senza servire richieste durante il viaggio di ritorno.&lt;/p>
&lt;!-- vedi esempio fine pag. 497 se vuoi -->
&lt;!-- ### Scelta di un algoritmo di scheduling
La scelta di un algoritmo di scheduling dipende dalle prestazioni. Per tutti gli algoritmi di scheduling le prestazioni dipendono dal numero e dal tipo di richieste. Supponiamo per esempio che la coda sia costitutita di una sola richiesta inevasa: in questo caso è evidente che tutti gli algoritmi abbiano lo stesso comportamento, avendo una sola scelta possibile, e tutti dunque si comportano come un FCFS. Ci sono anche casi con più richieste, con una possibiltà di attesa indefinita. Per questo motivo, Linux è dotato di uno **scheduler con scadenza (deadline scheduler)** che conserva code di lettura e scrittura separate, e dà priorità alla lettura, poiché i processi hanno più probabilità di bloccarsi in lettura che in scrittura. Le code vengono ordinate secondo LBA, implementando C-SCAN, e tutte le richieste di I/O vengono inviate secondo questo ordine. Lo scheduler con scadenza mantiene quattro code: due in lettura e due in scrittura, una in ordine di LBA e l'altra in ordine FCFS. Dopo ogni lotto di operazioni, l'algoritmo controlla se ci sono richieste nelle code FCFS più vecchie di un tempo preconfigurato. In tal caso, viene selezionata la coda LBA (di lettura o scrittura) contenente tale richiesta per il successivo lotto di I/O. -->
&lt;h3 id="924-scheduling-di-dispositivi-nvm">9.2.4 Scheduling di dispositivi NVM&lt;/h3>
&lt;p>Gli algoritmi appena discussi si applicano a memorie basate su dischi meccanici e hanno come obiettivo principale la riduzione della quantità di movimento della testina del disco. I dispositivi NVM, che invece non contengono testine in movimento, usano generalemnte una semplice politica FCFS. Inoltre, siccome nei dispositivi NVM il tempo richiesto per le letture è uniforme, ma il tempo richiesto per le scritture no, alcuni scheduler per gli SSD sfruttano questa proprietà e accorpano soltanto le richieste di scrittura adiacenti, mentre le richieste di lettura sono servite in ordine FCFS.&lt;/p>
&lt;p>Questa pecularità evidienza anche differenze in termini di prestazioni. Infatti, come abbiamo visto l&amp;rsquo;I/O può essere sequenziale o diretto.&lt;/p>
&lt;ul>
&lt;li>L&amp;rsquo;accesso sequenziale è ottimale per i dispositivi meccanici come gli HDD e i nastri, poiché i dati da leggere o scrivere si trovano vicino alla testina di lettura/scrittura.&lt;/li>
&lt;li>L&amp;rsquo;accesso casuale/diretto invece, misurato in &lt;strong>operazioni di input/output al secondo (IOPS)&lt;/strong> provoca il movimento della testina del disco, e dunque è ottimale nei dispostivi NVM.&lt;/li>
&lt;/ul>
&lt;p>Dunque, il vantaggio dei dispositivi NVM è molto inferiore nel caso di trasferimenti sequenziali, dove la ricerca sui dischi rigidi è ridotta al minimo e vengono enfatizzate la lettura e la scrittura dei dati. In questi casi, per le letture, le prestazioni dei due tipi di dispositivi possono essere equivalenti o differire di un ordine di grandezza in favore dei dispositivi NVM. La scrittura su NVM è più lenta della lettura, riducendo ulteriormente il vantaggio rispetto agli HDD. Inoltre, mentre le prestazioni di scrittura su HDD restano costanti durante il ciclo di vita del del dispostivo, le prestazioni in scrittura su dispositivi NVM variano a seconda di quanto il dispositivo è pieno (ricordiamo la necessità di garbage collection e over-provisioning) e di quanto è usurato.&lt;/p>
&lt;h2 id="93-rilevamento-e-correzione-di-errori">9.3 Rilevamento e correzione di errori&lt;/h2>
&lt;p>Il rilevamento e la correzione degli errori sono fondamentali per la gestione della memoria, delle reti e dei dispostivi di massa. Il &lt;strong>rilevamento degli errori&lt;/strong> permette di determinare se si è verificato un problema in modo da arrestare un&amp;rsquo;operazione prima che essa venga propagata. Per molto tempo i sistemi di memoria hanno rilevato gli errori utilizzando i bit di parità. In questo scenario, a ogni byte in un sistema di memoria viene associato un bit di parità. Se uno dei bit nel byte è corrotto (un $1$ diventa $0$ o viceversa) la parità del byte cambia e non corrisponde piu alla parità memorizzata. Ciò permette al sistema di memoria di rilevare tutti gli errori su un singolo bit, ma non è detto che ne rilevi uno su due bit.&lt;/p>
&lt;p>Un altro metodo di rilevamento degli errori è il &lt;strong>controllo di ridondanza ciclica (Ciclic Redundancy Check, CRC)&lt;/strong>, che utilizza una funzione di hash per rilevare errori su più bit. Un &lt;strong>codice di correzione degli errori (Error Correcting Code))&lt;/strong> è in grado di correggere il problema oltre a rilevarlo. La correzione viene eseguita utilizzando opportuni algoritmi e una certa quantità di spazio di memorizzazione aggiuntivo. Il funzionamento di ECC prevede che quando un controllore scrive un settore di dati durante il normale I/O, viene anche scritto l&amp;rsquo;ECC, calcolato a partire da tutti i byte dei dati che vengono scritti. Quando il settore viene letto, l&amp;rsquo;ECC viene ricalcolato e confrontato con il valore memorizzato. Un valore diverso da quello calcolato indica che i dati sono corrotti e che il supporto di memorizzazione potrebbe essere danneggiato.&lt;/p>
&lt;p>L&amp;rsquo;ECC è in grado di correggere gli errori solo quando pochi bit sono corrotti, ossia nei casi dei cosiddetti &lt;strong>errori reversibili&lt;/strong> (soft), mentre fallisce con gli &lt;strong>errori irreversibili&lt;/strong> (hard).&lt;/p>
&lt;h2 id="95-gestione-delle-unità-di-memoria-secondaria">9.5 Gestione delle unità di memoria secondaria&lt;/h2>
&lt;p>Tra le varie responsabilità del s.o. rientrano anche:&lt;/p>
&lt;ul>
&lt;li>L&amp;rsquo;inizializzazione del dispositivo.&lt;/li>
&lt;li>L&amp;rsquo;avviamento del sistema del dispositivo.&lt;/li>
&lt;li>La gestione del blocchi difettosi.&lt;/li>
&lt;/ul>
&lt;p>Andiamo a vedere nel dettaglio questi aspetti.&lt;/p>
&lt;h3 id="951-formattazione-del-disco-partizioni-e-volumi">9.5.1 Formattazione del disco, partizioni e volumi&lt;/h3>
&lt;p>Prima che un dispositivo nuovo di storage possa memorizzare dati, deve essere diviso in settori che possono essere letti o scritti dal controllore. Nel caso dei dispotivi NVM, devono essere inizializzate le pagine. Questo processo si chiama &lt;strong>formattazione di basso livello/formattazione fisica&lt;/strong>, e consiste nel riempire il dispositivo con una speciale struttura dati per ogni locazione di memorizzazione. Tale struttura tipicamente consiste di un&amp;rsquo;intestazione, un&amp;rsquo;area per i dati e una coda. L&amp;rsquo;intestazione e la coda contengono informazioni usate dal controllore del disco e l&amp;rsquo;ECC.&lt;/p>
&lt;p>Prima di usare un dispostivo come contenitore di file, il sistema operativo deve compiere tre passi:&lt;/p>
&lt;ol>
&lt;li>Suddividere il dispositivo in uno o più gruppi di blocchi o pagine, dette &lt;strong>partizioni&lt;/strong>. Alcuni s.o. e file system eseguono automaticamnte il partizionamento nel caso in cui un intero dispositivo debba essere gestito dal file system.&lt;/li>
&lt;/ol>
&lt;!-- Le informazioni sulle partizioni sono scritte in un formato stabilito e in una posizione fissa sul dispositivo, vengono lette le varie informazioni sulle partizioni e il s.o. crea delle descrizioni per le partizioni stesse. -->
&lt;ol start="2">
&lt;li>Creare e gestire il volume. A volte questo passaggio è implicito, come quando un file system viene inserito direttamente all&amp;rsquo;interno di una partizione. Altre volte invece è esplicito, per esempio quando più partizioni o dispositivi vengono utilizzanti insieme&lt;/li>
&lt;li>Eseguire la &lt;strong>formattazione logica&lt;/strong>, cioè la creazione di un file system. Il s.o. registra nel dispostivo le strutture dati iniziali relative al file system.&lt;/li>
&lt;/ol>
&lt;!-- Le strutture dati in questione possono includere descrizioni dello spazio libero e dello spazio allocato e una directory iniziale vuota. Le informazioni sulle partizioni indicano anche se una partizione contiene un file system -->
&lt;!-- Alcuni s.o. permettono a certi programmi speciali di impiegare una partizione del disco come un grande array sequenziale di blocchi logici, non contenente alcuna struttura dati relativa al file system. Questo array è detto a volte **disco di basso livello**. L'I/O relativo si chiama **I/O di basso livello** (raw I/O) e in questi casi il s.o. può essere utilizzato come spazio di swap. Il raw I/O comprende tutti i servizi del file system: gestione delle cache, locking dei file, prefetching, allocazione dello spazio, gestione dei nomi dei file, e le directory. È possibile rendere certe applciazioni più efficienti permettondo di implementare servizi di memorizzazione specializzati che usino una partizione di basso livello -->
&lt;!-- ma la maggior parte delle applicazioni preferisce un file system gia' fornito piuttosto che prendersi carico della gestione dei dati. -->
&lt;h3 id="952-blocco-davviamento">9.5.2 Blocco d&amp;rsquo;avviamento&lt;/h3>
&lt;p>affinché un calcolatore possa entrare in funzione, per esempio quando viene acceso o riavviato, è necessario che esegua un programma iniziale; di solito, questo &lt;strong>programma di avviamento iniziale (bootstrap)&lt;/strong> è piuttosto semplice. Nella maggior parte dei calcolatori il &lt;strong>bootstrap&lt;/strong> è memorizzato nel &lt;strong>firmware&lt;/strong> (una memoria flash NVM) sulla scheda madre del sistema e mappato su una posizione di memoria nota. Il piccolo programma che carica il bootstrap, detto &lt;strong>bootstrap loader&lt;/strong> è anche in grado di caricare dalla memoria secondaria un programma di bootstrap completo, che è memorizzato nei blocchi di avvio in una posizione fissa del dispositivo.&lt;/p>
&lt;!-- Il bootstrap loader predefinito di Linux e' **grub2**. -->
&lt;h3 id="953-blocchi-difettosi">9.5.3 Blocchi difettosi&lt;/h3>
&lt;p>Le unità a disco sono strutturalmente soggette a malfunzionamenti perché sono costituite da parti mobili con basse tolleranze (si ricordi che una testina è sospesa appena sopra la superficie del disco). A volte si può verificare un guasto irreparabile, e l&amp;rsquo;unità a disco deve essere sostituita. Il suo contenuto dev&amp;rsquo;essere recuperato da una copia di backup e trasferito nella nuova unità a disco. Più di frequente, uno o più settori diventano malfunzionanti. In effetti, la maggior parte dei dischi messi in commercio contiene gia &lt;strong>blocchi difettosi&lt;/strong>.&lt;/p>
&lt;p>Nel caso di dischi semplici come quelli gestiti da un controllore IDE, i blocchi difettosi sono gestiti &lt;em>manualmente&lt;/em>. Una possibile strategia consiste nell&amp;rsquo;effettuare la scansione del disco durante la formattazione, per rilevare la presenza di blocchi difettosi. Un blocco difettoso viene marcato come inutilizzabile e viene segnalato alle procedure di allocazione del file system per non utilizzarlo. Se qualche blocco diventa malfunzionante nel corso dell&amp;rsquo;ordinario uso del sistema, un programma speciale viene lanciato manualmente per individuare i blocchi difettosi e isolarli.&lt;/p>
&lt;p>Unità a disco più complesse hanno strategie di recupero dei blocchi difettosi più raffinate. Il controllore mantiene una lista dei blocchi malfunzionanti dell&amp;rsquo;unità a disco che è inizializzata durante la formattazione fisica eseguita dal produttore, ed è aggiornata per tutto il periodo in cui l&amp;rsquo;unità a disco è operativa. La formazione fisica mette anche da parte dei settori di riserva non visibili al s.o.: si può istruire il controllore affinché sostituisca da un punto di vista logico un settore difettoso con uno dei settori di riserva inutilizzati. Questa strategia è nota come &lt;strong>sector sparing/sector forwarding&lt;/strong>.&lt;/p>
&lt;!-- vedi esempio pg. 506 (lista puntata) -->
&lt;p>Un&amp;rsquo;alternativa al sector sparing è data da quei controllori capaci di sostituire i settori difettosi tramite la traslazione dei settori, detta &lt;strong>sector slipping&lt;/strong>.&lt;/p>
&lt;!-- Si supponga per esempio che il blocco logico 17 divenga malfunzionante e che il primo settore di riserva disponibile sia quello successivo al settore 202. La traslazione dei settori sposta in avanti di una posizione tutti I settori dal 17 al 202: quindi, il settore 202 viene copiato sul settore di riserva, il settore 201 sul 202, il 200 sul 201 e cosi via fino a che il settore 18 non viene copiato sul 19. Questa traslazione di settori libera lo spazio del settore 18, e il settore 17 puo essere mappato su quest'ultimo. -->
&lt;p>Anche i dispositivi NVM possono contenere bit, byte e persino pagine che sono difettose già al momento della produzione o che si degradano nel tempo. La gestione di queste aree difettose è più semplice rispetto agli HDD, perché non incide sul tempo di ricerca. È possibile in questi casi mettere da parte più pagine da utilizzare in sostituzione di quelle difettose, opure utilizzare spazio dell&amp;rsquo;area di over-provisioning.&lt;/p>
&lt;h2 id="96-gestione-dellarea-di-swapping">9.6 Gestione dell&amp;rsquo;area di swapping&lt;/h2>
&lt;!-- Se ricordato abbiamo introdotto lo swapping qualche lezione fa, dove abbiamo trattato lo spostamento di interi processi tra disco e memoria centrale. In quel contesto, lo swapping interviene quando l'ammontare della memoria fisica si abbassa fino al punto di raggiungere la soglia critica e i processi vengono traferiti alla memoria all'area di swapping, per liberare memoria. Nella pratica, pochissimi sistemi realizzano lo swapping nel modo descritto: più frequentemente, combinano lo swapping con tecniche di memoria virtuale, ed effettuano lo swapping di pagine e non necessariamente di interi processi. -->
&lt;p>La &lt;strong>gestione dell&amp;rsquo;area di swapping&lt;/strong> è un altro compito di basso livello del sistema operativo. La memoria virtuale usa lo spazio dei dischi come estensione della memoria centrale: poiché l&amp;rsquo;accesso alle unità a disco è molto più lento dell&amp;rsquo;accesso alla memoria centrale, l&amp;rsquo;uso dell&amp;rsquo;area di swapping ridure notevolmente le prestazioni del sistema. L&amp;rsquo;obiettivo principale nella progettazione e realizzazione di un&amp;rsquo;area di swapping è di fornire il migliore throughput per il sistema di memoria virtuale.&lt;/p>
&lt;h3 id="961-uso-dellarea-di-swapping">9.6.1 Uso dell&amp;rsquo;area di swapping&lt;/h3>
&lt;p>L&amp;rsquo;area di swapping è usata in modi diversi da sistemi operativi diversi, in funzione degli algoritmi di gestione della memoria utilizzati. I sistemi che adottano lo swapping dei processi nella memoria, per esempio, possono usare l&amp;rsquo;area di swapping per mantenere l&amp;rsquo;intera imamgine del processo, inclusi i segmenti dei dati e del codice. I sistemi a paginazione, invece, possono semplicemente memorizzarvi pagine non contenute nella memoria centrale. Lo spazio richiesto da quest&amp;rsquo;area per un sistema può quindi variare da pochi megabyte ad alcuni gigabyte, a seconda della quantità di memoria fisica, della quantità di memoria virtuale che esso deve sostenere, e del modo in cui quest&amp;rsquo;ultima è usata. Alcuni sistemi consigliano la quantità da riservare all&amp;rsquo;area di swapping.&lt;/p>
&lt;!-- Linux in passato ha suggerito di raddoppiare l'area di swapping rispetto alla memroia fisica, ma oggi questa limitazione non c'è più e la maggior parte dei sistemi Linux usa un'area decisamente minore. -->
&lt;h3 id="962-collocazione-dellarea-di-swapping">9.6.2 Collocazione dell&amp;rsquo;area di swapping&lt;/h3>
&lt;p>Le possibili collocazioni sono due: all&amp;rsquo;interno del normale file system, o in una partizione del disco a sé stante. Se l&amp;rsquo;area di swapping è semplicemente un grande file all&amp;rsquo;interno del file system, si possono usare le ordinarie funzioni del file system per crearla, assegnargli un nome e allocare spazio per essa. In alternativa, l&amp;rsquo;area di swapping si può creare in un&amp;rsquo;apposita &lt;strong>partizione del disco non formattata&lt;/strong>: in essa non è presente alcuna struttura relativa al file system e alle directory, ma si usa uno speciale gestore dell&amp;rsquo;area di swapping per allocare e rimuovere i blocchi. Esso adotta algoritmi ottimizzati rispetto alla velocità di accesso, e non rispetto allo spazio occupato, dato che all&amp;rsquo;area di swapping si accede molto più frequentemente che al file system. Il metodo delle raw partition assegna una dimensione fissa all&amp;rsquo;area di swapping al momento della creazione delle partizioni del disco, e l&amp;rsquo;aumento delle dimensioni dell&amp;rsquo;area deve quindi passare attraverso il ripartizionamento del disco, oppure attraverso la creazione di un&amp;rsquo;altra area di swapping.&lt;/p>
&lt;!-- Infine, vi basti sapere che i calcolatori accedono alla memoria secondaria in tre modi: tramite un dispositivo collegato alla macchina, tramite un dispositivo connesso alla rete o tramite un dispositivo cloud, che però non andremo ad analizzare. -->
&lt;div id="references-1784797996934493628" class="references" style="margin-top: 5rem;">
&lt;p>&lt;strong>References&lt;/strong>&lt;/p>
A. Silberschatz, P. B. Galvin, G. Gagne, &lt;em>Sistemi Operativi. Concetti ed Esempi&lt;/em>, Decima edizione. Ch. 11.
&lt;/div>
&lt;script>
document.addEventListener('DOMContentLoaded', function () {
var ref = document.getElementById('references-1784797996934493628');
var fn = document.querySelector('.footnotes');
if (ref &amp;&amp; fn &amp;&amp; fn.parentNode) {
fn.parentNode.insertBefore(ref, fn.nextSibling);
}
});
&lt;/script></description></item><item><title>Gestione della memoria</title><link>https://aretor.it/courses/os2324/09_memory/</link><pubDate>Mon, 15 Apr 2024 00:00:00 +0000</pubDate><guid>https://aretor.it/courses/os2324/09_memory/</guid><description>&lt;p>La memoria è una componente fondamentale che, assieme alla CPU, dev&amp;rsquo;essere gestita in maniera efficiente per garantire che le prestazioni di un computer non peggiorino. In questa lezione introdurremo i concetti alla base della sua progettazione logica.&lt;/p>
&lt;h2 id="background">Background&lt;/h2>
&lt;p>La memoria consiste in un grande array di byte ciascuno con il proprio indirizzo. La CPU legge le istruzioni dalla memoria basandosi sul contatore di programma. Queste istruzioni possono causare dei load o dei store aggiuntivi da e verso la memoria.&lt;/p>
&lt;h3 id="hardware-di-base">Hardware di base&lt;/h3>
&lt;p>I core CPU possono accedere solamente ai registri e alla memoria centrale. Se un dato si trova in una memoria secondaria o terziaria, questo va spostato in memoria centrale per essere acceduto. I registri CPU possono essere tipicamente acceduti in un ciclo di CPU, mentre la memoria centrale, connessa tramite un bus di sistema, richiede molti cicli. Il suo accesso può generare uno &lt;strong>stallo&lt;/strong> per l&amp;rsquo;attesa dei dati, peggiorando le prestazioni del sistema. Per ovviare al problema è possibile implementare una memoria per accesso veloce, chiamata &lt;strong>cache&lt;/strong>, dentro il chip della CPU.&lt;/p>
&lt;p>In aggiunta a ciò, è importante proteggere gli spazi di memoria dedicati ai processi kernel da parte dei processi utente, oltre che proteggere i processi utente l&amp;rsquo;uno con l&amp;rsquo;altro da accessi a zone di memoria non consentite. Per fare ciò quando un programma viene caricato, il s.o. imposta i valori di due registri, &lt;strong>un registro base&lt;/strong> che indica l&amp;rsquo;inizio dello spazio degli indirizzi del processo, e un &lt;strong>registro limite&lt;/strong> che indica la dimensione dello spazio degli indirizzi a cui il processo può accedere (l&amp;rsquo;indirizzo finale viene calcolato con base + limite). A ogni istruzione la CPU controlla che il processo utente acceda alla sezione di memoria a esso associata. Se ciò non avviene il s.o. emette un errore. Il s.o. esegue in modalità kernel ed è quindi libero di caricare ed eliminare i programmi dalla memoria.&lt;/p>
&lt;h3 id="mappatura-degli-indirizzi">Mappatura degli indirizzi&lt;/h3>
&lt;p>Un programma può essere caricato in qualunque parte della memoria fisica e gli indirizzi dal punto di vista del programma sono simbolici. La &lt;strong>mappatura (binding)&lt;/strong> tra istruzioni e dati a indirizzi di memoria può avvenire con la seguente gerarchia:&lt;/p>
&lt;ul>
&lt;li>Il compilatore mappa gli indirizzi simbolici a degli indirizzi rilocabili (p.e. &amp;ldquo;14 byte dall&amp;rsquo;inizio del modulo&amp;rdquo;)&lt;/li>
&lt;li>Il linker o il loader mappano gli indirizzi rilocabili a quelli assoluti (p.e. &amp;ldquo;74014&amp;rdquo;).&lt;/li>
&lt;/ul>
&lt;p>Più in generale, la mappatura può avvenire a qualunque livello:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Compile time:&lt;/strong> se, a tempo di compilazione, si sa dove il programma risiederà in memoria, il compilatore può mappare direttamente gli indirizzi simbolici a indirizzi assoluti, generando &lt;strong>codice assoluto (absolute code)&lt;/strong>. Se la locazione del processo cambia sarà necessario ricompilare il programma&lt;/li>
&lt;li>&lt;strong>Load time:&lt;/strong> se non si sa dove il programma risiederà, è necessario generare &lt;strong>codice rilocabile&lt;/strong>. In questo caso la mappatura agli indirizzi fisici è demandata a tempo di loading. Se l&amp;rsquo;indirizzo di partenza cambia, è necessario eseguire il loading del codice utente di nuovo&lt;/li>
&lt;li>&lt;strong>Execution time:&lt;/strong> se il processo può essere spostato in memoria durante la sua esecuzione, la mappatura avverrà a tempo di esecuzione. È necessario un hardware speciale per questo tipo di mappatura. La maggior parte dei sistemi operativi utilizza questo metodo&lt;/li>
&lt;/ul>
&lt;h3 id="spazio-degli-indirizzi-logico-e-fisico">Spazio degli indirizzi logico e fisico&lt;/h3>
&lt;p>Un indirizzo comunemente generato dalla CPU è chiamato &lt;strong>indirizzo logico&lt;/strong>, mentre un indirizzo visto dalla memoria, caricato nel &lt;strong>registro degli indirizzi di memoria (memory-address register)&lt;/strong> è chiamato &lt;strong>indirizzo fisico&lt;/strong>. La mappatura a compile e load time genera gli stessi indirizzi logici e fisici, tuttavia la mappatura a execution time no. In quest&amp;rsquo;ultimo caso, l&amp;rsquo;indirizzo logico viene chiamato &lt;strong>indirizzo virtuale&lt;/strong> (utilizzeremo tuttavia entrambi i termini logico e virtuale allo stesso modo). La mappatura run-time da indirizzi virtuali a quelli fisici è operata con dell&amp;rsquo;hardware chiamato &lt;strong>unità di gestione della memoria (memory-management unit, MMU)&lt;/strong>. Introduciamo un semplice schema di mappatura che generalizza quello del registro base. Tale registro viene chiamato ora &lt;strong>registro di rilocazione (relocation register)&lt;/strong>. Questo valore è aggiunto a ogni indirizzo da un processo utente nel momento in cui l&amp;rsquo;indirizzo è inviato in memoria. Se il registro rilocabile è 14000, allora 0 sarà mappato a 14000 e 346 a 14346. Il programma non ha accesso all&amp;rsquo;indirizzo fisico, solo a quello rilocabile.&lt;/p>
&lt;h3 id="loading-dinamico">Loading dinamico&lt;/h3>
&lt;p>È possibile demandare il caricamento delle routine utilizzate da un programma al momento effettivo in cui esse vengono chiamate. Questa pratica si chiama &lt;strong>loading dinamico (dynamic loading)&lt;/strong>. In particolare, le routine vengono salvate in uno speciale formato rilocabile. Quando una routine esegue e chiama un&amp;rsquo;altra routine, la routine chiamante controlla se la routine chiamata è già presente nella memoria. Se non la trova, la carica dalla memoria secondaria, aggiorna la tabella degli indirizzi del programma e passa il controllo alla routine chiamata. Questo metodo è efficace nel caso di routine molto grandi che vengono chiamate poco frequentemente, come ad esempio le routine in caso di errore.&lt;/p>
&lt;p>Il loading dinamico non richiede speciale supporto da parte del s.o. ed è di responsabilità dello sviluppatore. Il s.o. può tuttavia fornire delle routine di libreria per implementare il loading dinamico.&lt;/p>
&lt;h3 id="linking-dinamico-e-librerie-condivise">Linking dinamico e librerie condivise&lt;/h3>
&lt;p>Esistono due modi per mappare un programma alle librerie di routine di cui ha bisogno per eseguire:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Static linking:&lt;/strong> il loader tratta le librerie di routine come ogni altro modulo oggetto e le combina all&amp;rsquo;interno dell&amp;rsquo;eseguibile del programma. L&amp;rsquo;eseguibile contiene tutto ciò che gli serve per eseguire ma incrementa in dimensioni poiché trasporta con sé le librerie di routine (o perlomeno le routine di cui ha bisogno per eseguire). In aggiunta, le librerie di routine vengono caricate in memoria centrale sprecando memoria&lt;/li>
&lt;li>&lt;strong>Dynamic linking:&lt;/strong> il linker cerca le librerie di routine tra quelle di sistema e fa il linking col programma utente quando questo viene eseguito. Il programma è più leggero e le librerie, chiamate &lt;strong>Dynamically Linked Libraries (DLL)&lt;/strong>, possono essere condivise anche da altri programmi. Per tale motivo vengono anche chiamate &lt;strong>librerie condivise (shared libraries)&lt;/strong>. Sono molto utilizzate nei sistemi Windows e Linux e il linguaggio C fa largo uso di questo sistema. Quando un programma richiede una routine di una libreria dinamica il loader cerca la DLL e la carica in memoria aggiornando gli indirizzi. In questo modo è possibile aggiornare le DLL senza dover ricompilare tutto il programma, tuttavia per evitare che un programma utilizzi inavvertitamente delle DLL di una versione non compatibile, solitamente viene incluso un numero di versione che viene controllato prima del linking. Questa alternativa al dynamic loading richiede aiuto da parte del s.o. per permettere al processo che ha richiesto la libreria di accederci&lt;/li>
&lt;/ul>
&lt;h3 id="allocazione-contigua-della-memoria">Allocazione contigua della memoria&lt;/h3>
&lt;p>La memoria centrale deve accomodare sia il s.o. che i vari processi utente. Abbiamo quindi bisogno di allocare lo spazio nella migliore maniera possibile. La memoria viene divisa generalmente in due partizioni: una per il s.o. e una per i processi utente. È possibile allocare la memoria s.o. negli indirizzi più bassi o più alti della memoria, tuttavia molti s.o. (tra cui Windows e Linux) utilizzano gli indirizzi alti, quindi tratteremo questo caso. Nell&amp;rsquo;&lt;strong>allocazione di memoria contigua&lt;/strong> ogni processo utente viene allocato in una singola sezione di memoria contigua a quella del processo successivo.&lt;/p>
&lt;h3 id="protezione-della-memoria">Protezione della memoria&lt;/h3>
&lt;p>Possiamo prevenire che un processo acceda alla memoria di un altro processo combinando la tecnica del registro base e del registro di rilocazione precedentemente visto. Dal momento che gli indirizzi rilocabili vanno da 0 (incluso) al valore specificato dal registro di rilocazione limite (escluso), quando una CPU esegue un&amp;rsquo;istruzione di accesso alla memoria è necessario solamente che controlli che l&amp;rsquo;indirizzo rilocabile sia minore del registro limite. In tal caso, la CPU aggiungerà il registro base per ottenere l&amp;rsquo;indirizzo fisico.&lt;/p>
&lt;h3 id="allocazione-della-memoria">Allocazione della memoria&lt;/h3>
&lt;p>Uno dei metodi più semplici di allocazione della memoria consiste nell&amp;rsquo;allocare, per ciascun processo, una partizione di memoria di dimensione variabile. Inizialmente tutta la memoria è disponibile ed è considerata un &lt;strong>buco (hole)&lt;/strong>. Progredendo col caricamento la memoria conterrà più buchi vuoti. Nel momento in cui un processo entra in memoria il s.o. tiene in conto dei requisiti di memoria di cui necessita. Se non c&amp;rsquo;è memoria sufficiente è possibile rifiutare il processo o piazzarlo in una coda di attesa. Se un processo è di dimensione minore rispetto a un buco contenuto nell&amp;rsquo;insieme dei buchi, può venire piazzato lì. In tal caso, se c&amp;rsquo;è molto spazio all&amp;rsquo;interno del buco, solo una parte viene allocata al processo e la rimanente ritorna nell&amp;rsquo;insieme dei buchi. Quando un processo finisce di eseguire lasciando un buco, se questo è adiacente a un altro buco entrambi vengono uniti in un buco soltanto. Questa è una singola istanza di strategia per risolvere il &lt;strong>problema dell&amp;rsquo;allocazione dinamica di spazio&lt;/strong>. Tra le strategie più utilizzate troviamo&lt;/p>
&lt;ul>
&lt;li>&lt;strong>First-fit:&lt;/strong> alloca il primo buco che è grande abbastanza per contenere il processo. La ricerca può cominciare all&amp;rsquo;inizio dell&amp;rsquo;insieme dei buchi o nella locazione dove la ricerca si era precedentemente conclusa. La ricerca si ferma una volta trovato il buco&lt;/li>
&lt;li>&lt;strong>Best-fit:&lt;/strong> alloca il buco più piccolo che può contenere il processo. La ricerca richiede di controllare tutti i buchi disponibili (se non è ordinata per dimensione)&lt;/li>
&lt;li>&lt;strong>Worst-fit:&lt;/strong> alloca il buco più grande che può contenere il processo. La ricerca richiede di contrallare tutti i buchi disponibili (se non è ordinata per dimensione). Questa strategia lascia più spazio rimanente nel buco e può essere più utile della strategia best-fit&lt;/li>
&lt;/ul>
&lt;p>Le simulazioni dimostrano che first-fit e best-fit sono i migliori in termini di utilizzo della memoria e del tempo. Tra questi due first-fit è il più veloce in termini di tempo, mentre non c&amp;rsquo;è una differenza sostanziale in termini di memoria occupata.&lt;/p>
&lt;h3 id="frammentazione">Frammentazione&lt;/h3>
&lt;p>Con gli algoritmi first-fit e best-fit, l&amp;rsquo;allocazione e la rimozione dei processi può generare problemi di &lt;strong>frammentazione&lt;/strong>. Esistono due tipologie di frammentazione:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Frammentazione esterna:&lt;/strong> in seguito all&amp;rsquo;allocazione e rimozione dei processi si creano molti piccoli buchi all&amp;rsquo;interno della memoria. Questo fenomeno accade quando ci sarebbe memoria sufficiente per ospitare un nuovo processo ma questa è frammentata in molteplici buchi non contigui. Analisi statistiche sull&amp;rsquo;algoritmo first-fit confermano che allocando $N$ blocchi, altri $0.5N$ blocchi vengono persi per via della frammentazione (&lt;strong>$50$-percent rule&lt;/strong>). Una soluzione al problema è il &lt;strong>compattamento (compaction)&lt;/strong> per cui la memoria viene riorganizzata per accostare i buchi e unirli insieme. Questa metodologia non è possibile se la rilocazione è stata fatta a compile o load time. Bisogna inoltre calcolare il costo della rilocazione, quando questa è possibile. La strategia più semplice, ma anche costosa, è quella di spostare tutti i processi da una parte e i buchi dall&amp;rsquo;altra. È anche possibile permettere che lo spazio degli indirizzi di un processo non sia contiguo. Questa strategia è chiamata &lt;strong>paging&lt;/strong> ed è la più comune nei s.o.&lt;/li>
&lt;li>&lt;strong>Frammentazione interna:&lt;/strong> supponiamo di avere un processo da $18,462$ bytes allocato in un buco da $18,464$ bytes. Il peso della struttura usata per tracciare i due byte rimanenti sarebbe maggiore dei due byte stessi comportando uno spreco di memoria. Per ovviare a questa situazione è possibile allocare un blocco fisso di memoria, maggiore della memoria richiesta dal processo. La rimanenza tra la memoria del blocco e quella effettivamente occupata dal processo viene chiamata frammentazione interna.&lt;/li>
&lt;/ul>
&lt;h3 id="paging">Paging&lt;/h3>
&lt;p>Il paging è una tecnica che consiste nel dividere la memoria fisica in blocchi di dimensione fissa chiamati &lt;strong>frame&lt;/strong>, e quella logica in &lt;strong>pagine (pages)&lt;/strong>. Quando un processo viene eseguito, le sue pagine vengono caricate in memoria. L&amp;rsquo;idea di separare la memoria fisica da quella logica ci permette di avere indirizzi logici di, per esempio, $64$ bit senza avere una memoria fisica da $2^{64}$ bit.&lt;/p>
&lt;p>Ogni indirizzo generato dalla CPU viene diviso in due parti:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Numero di pagina ($p$):&lt;/strong> è usato come un indice dentro una &lt;strong>tabella di pagine&lt;/strong>, relativa a un singolo processo e contenente l&amp;rsquo;indirizzo fisico di ogni frame&lt;/li>
&lt;li>&lt;strong>Offset di pagina ($d$):&lt;/strong> è l&amp;rsquo;offset utilizzato per riferirsi alla locazione esatta nel frame&lt;/li>
&lt;/ul>
&lt;p>L&amp;rsquo;MMU esegue i seguenti step per tradurre un indirizzo logico in quello fisico&lt;/p>
&lt;ol>
&lt;li>Estrarre il numero di pagina $p$ e utilizzarlo per cercare nella tabella delle pagine&lt;/li>
&lt;li>Estrarre il corrispondente numero di frame $f$ dalla tabella delle pagine&lt;/li>
&lt;li>Rimpiazzare il numero di pagina $p$ nell&amp;rsquo;indirizzo logico col numero di frame $f$&lt;/li>
&lt;li>Aggiungere l&amp;rsquo;offset $d$ al numero di frame $f$ per ottenere l&amp;rsquo;indirizzo fisico&lt;/li>
&lt;/ol>
&lt;p>La dimensione di una pagina è definita a livello hardware ed è una potenza di $2$ (tipicamente tra $4$KB e $1$GB). In questo modo, se la dimensione della pagina è $2^n$ e la dimensione dello spazio logico è $2^m$ ($m &amp;gt; n$), allora un indirizzo logico contiene $m$ bit, gli $n$ bit meno significativi indicano l&amp;rsquo;offset di pagina e gli $m - n$ bit più significativi sono riservati al numero di pagina.&lt;/p>
&lt;p>Il paging è un sistema di rilocazione simile a quelli visti in precedenza. Non crea frammentazione esterna poiché la memoria fisica viene partizionata in anticipo, tuttavia può creare frammentazione interna, poiché un processo potrebbe non occupare completamente l&amp;rsquo;ultima pagina a esso allocata. Nel peggiore dei casi potremmo avere l&amp;rsquo;allocazione di un solo byte nell&amp;rsquo;ultima pagina risultando in una frammentazione interna di quasi un intero frame. Possiamo stimare una frammentazione interna media di mezza pagina, quindi è bene avere una dimensione di pagina piccola, tuttavia avere molte pagine implica un overhead nella gestione delle struttura delle entry di pagina. Inoltre, dal punto di vista del I/O su disco (memoria secondaria) è meglio avere grandi quantità di dati da trasferire. In generale le pagine oggi sono di $4$K o $8$K. Molti s.o. supportano più dimensioni di pagina.&lt;/p>
&lt;p>Un programma, prima dell&amp;rsquo;esecuzione esprime la sua dimensione in pagine. Se richiede $n$ pagine, è necessario disporre di $n$ frame per poterlo allocare. La mappatura è gestita dal s.o., che tiene traccia dei frame liberi attraverso una &lt;strong>tabella di frame (frame table)&lt;/strong>. Questa struttura indica quali frame sono liberi, quali sono occupati e da che processi sono occupati.&lt;/p>
&lt;h3 id="supporto-hardware">Supporto hardware&lt;/h3>
&lt;p>Ogni PCB del processo contiene un puntatore alla tabella delle pagine a esso associate, e questa tabella viene caricata dalla CPU ogni volta che un processo entra in esecuzione. L&amp;rsquo;implementazione hardware della tabella può essere fatta in vari modi e di solito consiste in una serie di registri dedicati che rendono la traduzione efficiente. L&amp;rsquo;uso dei registri è efficiente se la tabella è piccola. Per tabelle più grosse, i registri non bastano ed è necessario ricorrere a un &lt;strong>registro base di tabelle di pagine (Page-Table Base Register, PTBR)&lt;/strong> situato in memoria centrale. Cambiare le tabelle di pagine richiede solo di cambiare il valore di questo registro riducendo sostanzialmente il tempo di context switch.&lt;/p>
&lt;h4 id="buffer-di-traduzione-look-aside">buffer di traduzione look-aside&lt;/h4>
&lt;p>Se salviamo la tabella delle pagine in memoria risparmiamo tempo nel context switch ma l&amp;rsquo;accesso alla tabella situata nella memoria peggiora le performance poiché dobbiamo accedere alla PTBR ricavare l&amp;rsquo;indirizzo e accedere poi alla pagina cercata. È possibile implementare una piccola e veloce cache di ricerca chiamata &lt;strong>buffer di traduzione Look-aside (Translation Look-aside Buffer, TLB)&lt;/strong>. Ogni entry del TLB consiste di una chiave e di un valore. Quando arriva un oggetto, questo viene confrontato con tutte le chiavi finché non avviene un match e viene ritornato il corrispondente valore. Il TLB è piccolo in dimensioni, da $32$ a $1024$ entry. Alcune CPU hanno una TLB per le istruzioni e una per i dati.&lt;/p>
&lt;p>Relativamente al paging, una TLB contiene solamente una piccola parte delle entry della tabella di pagine. Quando un&amp;rsquo;indirizzo logico viene generato, l&amp;rsquo;MMU controlla se il numero di pagina (la chiave) è nella TLB e ritorna immediatamente il rispettivo numero di frame (il valore) a esso associato. Se il numero di pagina non è presente si verifica un &lt;strong>TLB miss&lt;/strong>. In questo caso, viene eseguita la traduzione da pagina a frame e poi la mappatura viene salvata nella TLB. Se la TLB è piena, è necessario eliminare una entry. Varie strategie possono essere applicate, dal round-robin a politiche che selezionano la pagina recentemente meno utilizzata (Least Recently Used, LRU). Alcune entry possono essere &lt;strong>fissate (wired down)&lt;/strong>, ad esempio le entry relative a codice kernel fondamentale.&lt;/p>
&lt;p>Alcuni TLB salvano anche un &lt;strong>identificatore dello spazio degli indirizzi (Adress-Space Identifier, ASID)&lt;/strong> che determina l&amp;rsquo;identità del processo per garantire protezione. Quando un TLB prova a risolvere un numero di pagina virtuale, controlla che l&amp;rsquo;ASID della mappatura corrisponda a quello del processo, in caso contrario restituisce un TLB miss. Senza ASID, ogni volta che una nuova tabella delle pagine viene selezionata (p.e. durante un context switch) il TLB deve assicurarsi che il suo contenuto venga resettato (flushed), per garantire che la precedente tabella non crei conflitti puntando a indirizzi sbagliati.&lt;/p>
&lt;p>Per misurare le prestazioni di una TLB è possibile calcolare la, &lt;strong>hit ratio&lt;/strong>, ovvero la percentuale di volte in cui l&amp;rsquo;informazione richiesta si trova nella TLB. Per calcolare il tempo di accesso effettivo in memoria si può utilizzare la seguente formula:&lt;/p>
&lt;p>$$effective\ access\ time = \underbrace{hit\ ratio \times page\ access\ time}_{TLB\ hit} + (1 - hit\ ratio) \times \underbrace{(page\ table\ access\ time + page\ access\ time)}_{TLB\ miss}$$&lt;/p>
&lt;p>Con $page\ table\ access\ time = page\ access\ time$. In altre parole, se l&amp;rsquo;entry si trova nella TLB dovrò accedere solamente alla pagina, in caso contrario bisogna consultare la memoria anche per accedere alla page table. In questo esempio, assumiamo che sia richiesto solo un accesso in caso di TLB miss e che i tempi di accesso al TLB siano trascurabili.&lt;/p>
&lt;h3 id="protezione">Protezione&lt;/h3>
&lt;p>Per evitare che un processo acceda a dei frame in memoria a esso non assegnati è possibile inserire un bit di validità per entry nella tabella delle pagine, il cosiddetto &lt;strong>bit valido-invalido (valid-invalid bit)&lt;/strong>. Ogni volta che un processo vuol fare un accesso a una pagina virtuale, il rispettivo bit di validità viene controllato e se è impostato a falso, l&amp;rsquo;accesso viene catturato dal s.o.&lt;/p>
&lt;p>Questo approccio presenta un problema: se l&amp;rsquo;ultima pagina riservata al processo è utilizzata solo parzialmente, il processo ha la possibilità di accedere anche ai byte della sezione di frammentazione interna. Più in generale, è raro che un processo utilizzi tutto lo spazio degli indirizzi che gli è stato reso disponibile. Alcuni sistemi utilizzano un &lt;strong>registro di lunghezza della tabella delle pagine (Page-Table Length Register, PTLR)&lt;/strong> che indica la dimensione della tabella delle pagine per controllare il range di validità degli indirizzi a cui un processo può accedere.&lt;/p>
&lt;p>È possibile estendere l&amp;rsquo;approccio del bit di validità per includere altri bit read-only, write-only, etc.&lt;/p>
&lt;h3 id="pagine-condivise">Pagine condivise&lt;/h3>
&lt;p>Uno dei vantaggi del paging è la possibilità di condividere pagine tra processi. Supponiamo ad esempio il caso di alcuni processi che utilizzano la stessa libreria standard, con cui è possibile risparmiare spazio creando alcune tabelle di pagine che si riferiscono alla stessa zona di memoria fisica contenente la libreria. Il codice in oggetto è chiamato &lt;strong>reentrant code&lt;/strong> e dev&amp;rsquo;essere non modificabile, in maniera tale da garantire l&amp;rsquo;integrità dei suoi dati.&lt;/p>
&lt;h2 id="struttura-di-una-tabella-di-pagine">Struttura di una tabella di pagine&lt;/h2>
&lt;p>In questa sezione esploriamo tecniche comuni per strutturare una tabella di pagine.&lt;/p>
&lt;h3 id="paging-gerarchico">Paging gerarchico&lt;/h3>
&lt;p>Con i sistemi moderni, una tabella di pagine può diventare molto grande e occupare molto spazio in memoria. Per esempio, su un sistema a $32$-bit con pagine di dimensione $4$K ($2^{12}$) una tabella delle pagine consisterebbe di $2^{32} / 2^{12}$ entry. Se la dimensione di un&amp;rsquo;entry è, ad esempio di $4$ byte, consumeremmo $4$MB di memoria a processo solo per la tabella delle pagine.&lt;/p>
&lt;p>È possibile ovviare al problema inserendo una struttura di paging gerarchica in cui anche la tabella delle pagine viene spezzettata. Ad esempio nel precedente sistema, se associamo $20$ bit al numero di pagina e $12$ bit all&amp;rsquo;offset, potremmo &amp;ldquo;paginare&amp;rdquo; le pagine, dividendo i $20$ bit in $10$ per un primo livello e $10$ per un secondo livello di paging. Il livello più interno si riferirebbe sempre al numero di pagina, mentre quello esterno sarebbe relativo a una tabella delle pagine esterna, che indicizza la tabella delle pagine interna.&lt;/p>
&lt;p>Il processo può essere esteso a più livell di gerarchia, per caricare meno dati in memoria. Questa strategia richiede tuttavia più accessi alla memoria, uno per livello, peggiorando le prestazioni in termini di efficienza computazionale.&lt;/p>
&lt;h3 id="tabelle-hash-di-pagine">Tabelle hash di pagine&lt;/h3>
&lt;p>Un approccio per gestire gli spazi degli indirizzi molto grandi ($32$bit) è quello di utare una &lt;strong>tabella hash di pagine (hashed page table)&lt;/strong> con il valore di hash corrispondente al numero virtuale di pagina. Ogni entry della tabella contiene una lista concatenata. Ogni elemento consiste in tre campi:&lt;/p>
&lt;ul>
&lt;li>Il numero di pagina virtuale&lt;/li>
&lt;li>Il valore del frame mappato&lt;/li>
&lt;li>Un puntatore all&amp;rsquo;elemento successivo nella lista concatenata&lt;/li>
&lt;/ul>
&lt;p>Il numero di pagina virtuale nell&amp;rsquo;indirizzo virtuale viene salvato nella tabella hash. Viene quindi confrontato con il primo campo, la lista corrispondente viene selezionata e si cerca il frame corrispondente. Una variazione utile per indirizzi a $64$bit è quella a &lt;strong>tabella di pagine raggruppate (clustered page table)&lt;/strong> in cui un valore hash si può riferire a diverse pagine piuttosto che a una soltanto. In questo caso, una singola entry nella tabella si può riferire a diversi frame. Questa variante è utile in presenza di spazi degli indirizzi &lt;strong>sparsi&lt;/strong>&lt;/p>
&lt;h3 id="tabella-di-pagine-invertita">Tabella di pagine invertita&lt;/h3>
&lt;p>Tutte le tabelle di pagine puntano ai frame della memoria fisica. Per risparmiare spazio possiamo salvarci una tabella invertita che, a ogni frame, mappa la pagina virtuale a esso associato. Per garantire protezione da accessi non voluti, è necessario salvare anche il PID del processo associato alle pagine virtuali. In questo modo è possibile salvarsi un&amp;rsquo;unica tabella anziché una tabella per processo, salvando molta memoria.&lt;/p>
&lt;p>Questo metodo è costoso in termini di ricerca, poiché per fare una mappatura da pagina a frame bisogna potenzialmente scorrere tutti i frame uno a uno finché non viene trovato il riferimento. Per accelerare il processo è possibile utilizzare una tabella hash. Inoltre, un grosso svantaggio è costituito dal fatto che è possibile salvare solo un riferimento per frame e quindi un frame non può essere condiviso tra processi (sono necessarie tecniche alternative per farlo).&lt;/p>
&lt;h2 id="swapping">Swapping&lt;/h2>
&lt;p>Lo swapping è una tecnica per ampliare la spazio degli indirizzi fisici, che consiste nello spostare &lt;strong>(swapping)&lt;/strong> processi, o porzioni di un processo in un &lt;strong>backing store&lt;/strong> temporaneo che risiede tipicamente nella memoria secondaria.&lt;/p>
&lt;h3 id="standard-swapping">Standard swapping&lt;/h3>
&lt;p>Questa tecnica prevede di spostare un processo intero tra la memoria e il backing store. Il vantaggio principale consiste nel permettere alla memoria fisica di essere sovrascritta permettendo di gestire più processi. I processi in attesa sono buoni candidati per lo swapping.&lt;/p>
&lt;h3 id="swapping-con-paging">Swapping con paging&lt;/h3>
&lt;p>Il swapping standard richiede molto tempo per lo spostamento dei processi ed è stato quasi sempre sostituito con il swapping di una parte delle pagine di un processo. Il termine &lt;em>swapping&lt;/em> si riferisce allo swapping standard mentre il termine &lt;em>paging&lt;/em> allo swapping con le pagine. Un&amp;rsquo;operazione di &lt;strong>page out&lt;/strong> sposta una pagina dalla memoria al backing store, mentre un&amp;rsquo;operazione di &lt;strong>page in&lt;/strong> fa il contrario.&lt;/p>
&lt;div id="references-1784797996328395235" class="references" style="margin-top: 5rem;">
&lt;p>&lt;strong>References&lt;/strong>&lt;/p>
A. Silberschatz, P. B. Galvin, G. Gagne, &lt;em>Sistemi Operativi. Concetti ed Esempi&lt;/em>, Decima edizione. Ch. 9.
&lt;/div>
&lt;script>
document.addEventListener('DOMContentLoaded', function () {
var ref = document.getElementById('references-1784797996328395235');
var fn = document.querySelector('.footnotes');
if (ref &amp;&amp; fn &amp;&amp; fn.parentNode) {
fn.parentNode.insertBefore(ref, fn.nextSibling);
}
});
&lt;/script></description></item><item><title>Gestione della memoria virtuale</title><link>https://aretor.it/courses/os2324/10_virt_mem/</link><pubDate>Thu, 18 Apr 2024 00:00:00 +0000</pubDate><guid>https://aretor.it/courses/os2324/10_virt_mem/</guid><description>&lt;p>In questa lezione andremo ad approfondire i concetti sottostanti la memoria virtuale, la sua implementazione e alcune considerazioni sulla sua efficenza computazionale&lt;/p>
&lt;h2 id="background">Background&lt;/h2>
&lt;p>Ci sono varie casistiche da considerare per poter risparmiare memoria:&lt;/p>
&lt;ul>
&lt;li>Un programma non utilizza tipicamente tutto il codice con cui è stato implementato. Le routine di errore, per esempio, vengono chiamate solo all&amp;rsquo;occorrenza.&lt;/li>
&lt;li>Alcune strutture dati (p.e. array) possono venire allocate con maggiore memoria di quella effettivamente richiesta&lt;/li>
&lt;/ul>
&lt;p>Per tali motivi, è utile caricare solo parte di un programma in memoria, utilizzando una memoria virtuale. I vantaggi sono:&lt;/p>
&lt;ul>
&lt;li>Un programma può accedere a uno spazio virtuale molto più grande di quello fisico&lt;/li>
&lt;li>Molti programmi possono eseguiti contemporaneamente&lt;/li>
&lt;li>Sono necessarie meno operazioni di I/O per lo swapping completo dei programmi&lt;/li>
&lt;li>Il programmatore non si deve preoccupare di gestire la memoria permettendogli di sviluppare codice più facilmente&lt;/li>
&lt;/ul>
&lt;p>Nella gestione della memoria virtuale, possiamo allocare negli indirizzi iniziali le istruzioni del programma, seguite dai dati e dall&amp;rsquo;heap dinamico. Negli indirizzi finali, in coda allo spazio degli indirizzi allochiamo lo stack, lasciando così un buco intermedio tra stack e heap che può essere utilizzato per estendere queste strutture o caricare le librerie dinamiche.&lt;/p>
&lt;p>La memoria virtuale ci permette anche di:&lt;/p>
&lt;ul>
&lt;li>Condividere librerie di sistema&lt;/li>
&lt;li>Condividere codice in comune (p.e. a seguito di una fork)&lt;/li>
&lt;li>Avviare una comunicazione tramite memoria condivisa&lt;/li>
&lt;/ul>
&lt;h2 id="paging-su-richiesta">Paging su richiesta&lt;/h2>
&lt;p>Come detto, è possibile caricare solo parzialmente le parti di un processo, demandando il caricamento di pagine specifiche solamente quando queste sono richieste dal processo per continuare a eseguire. Questa tecnica si chiama &lt;strong>paging su richiesta (demand paging)&lt;/strong>.&lt;/p>
&lt;h3 id="concetti-base">Concetti base&lt;/h3>
&lt;p>Il paging su richiesta necessita di un meccanismo per indicare quali pagine nella tabella delle pagine sono state caricate in memoria. È possibile, a tal proposito, utilizzare il bit di validità. Quando il bit è impostato a true, la pagina è valida, altrimenti la pagina non è valida oppure non è stata caricata in memoria centrale. Nel caso si provi ad accedere una pagina con bit a false, viene emesso un errore &lt;strong>page fault&lt;/strong> che viene catturato dal s.o. La procedura di gestione dell&amp;rsquo;errore è la seguente:&lt;/p>
&lt;ul>
&lt;li>Viene controllata una tabella interna al processo per vedere se la pagina non è valida&lt;/li>
&lt;li>Se la pagina non è valida il processo termina, se è valida la pagina viene caricata&lt;/li>
&lt;li>Viene cercato un frame libero per l&amp;rsquo;allocazione della pagina&lt;/li>
&lt;li>La pagina viene letta dalla memoria secondaria e allocata nel nuovo frame&lt;/li>
&lt;li>Viene aggiornata la tabella delle pagine per indicare che la pagina è ora valida&lt;/li>
&lt;li>L&amp;rsquo;esecuzione del processo riprende&lt;/li>
&lt;/ul>
&lt;p>Nel caso estremo, un processo può essere inizializzato con zero pagine in memoria (&lt;strong>pure demand paging&lt;/strong>).&lt;/p>
&lt;p>Può accadere che un processo richieda molte pagine il cui bit è impostato a false una di seguito all&amp;rsquo;altra comportando un peggioramento delle performance inaccettabile. Quest&amp;rsquo;eventualità è molto rara poiché i programmi tendono ad avere una &lt;strong>località dei riferimenti&lt;/strong>, per cui molti accessi consecutivi avvengono spesso in locazioni vicine tra loro.&lt;/p>
&lt;p>Quando un processo tenta di accedere a una pagina non caricata in memoria, si interrompe e riprende l&amp;rsquo;esecuzione al suo caricamento. Questo può comportare la ripetizione dell&amp;rsquo;istruzione che stava eseguendo. Per esempio, nel caso di una operazione ADD:&lt;/p>
&lt;ul>
&lt;li>Gli addendi &lt;code>A&lt;/code> e &lt;code>B&lt;/code> vengono letti&lt;/li>
&lt;li>Il risultato &lt;code>A + B&lt;/code> viene computato&lt;/li>
&lt;li>Il risultato viene salvato in &lt;code>C&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>Nel caso in cui &lt;code>C&lt;/code> corrispondesse a una locazione di memoria in una pagina invalida, il processo va in interruzione e alla sua ripresa deve ricomputare la somma. Nel caso di istruzioni che implicano lo spostamento di grossi moli di dati, questo può peggiorare le performance. Esistono varie soluzioni a questo problema.&lt;/p>
&lt;h3 id="lista-di-frame-liberi">Lista di frame liberi&lt;/h3>
&lt;p>Molti sistemi utilizzano una &lt;strong>lista di frame liberi (free-frame list)&lt;/strong> per trovare subito un frame disponibile a ospitare una pagina dopo un page fault. I s.o. allocano tipicamente frame utilizzando una tecnica chiamata &lt;strong>zero-fill on demand&lt;/strong> che resetta il frame prima di eseguirne l&amp;rsquo;allocazione, per evitare problemi di sicurezza dovuti a un potenziale leakage di dati.&lt;/p>
&lt;h3 id="performance-del-paging-su-richiesta">Performance del paging su richiesta&lt;/h3>
&lt;p>Calcoliamo ora il &lt;strong>tempo effettivo di accesso (effective access time)&lt;/strong> con il paging su richiesta. Sia $p$ la probabilità di page fault ($0 \le p \le 1$) ed $ma$ il tempo di accesso alla memoria. Il tempo effettivo di accesso è&lt;/p>
&lt;p>$$effective\ access\ time = (1 - p) \times ma + p \times page\ fault\ time$$&lt;/p>
&lt;p>Il &lt;strong>tempo di page fault (page fault time)&lt;/strong> è il più complicato da stimare, ed è composto da tre operazioni principali:&lt;/p>
&lt;ol>
&lt;li>Servire l&amp;rsquo;interrupt del page fault&lt;/li>
&lt;li>Leggere la pagina&lt;/li>
&lt;li>Riprendere l&amp;rsquo;esecuzione del processo&lt;/li>
&lt;/ol>
&lt;h2 id="copy-on-write">Copy-on-write&lt;/h2>
&lt;p>Quando un processo genitore chiama la &lt;code>fork()&lt;/code> e genera un processo figlio, questo ultimo riceverebbe anche una copia dello spazio degli indirizzi su cui operare. Tuttavia considerando che molti figli invocano &lt;code>exec()&lt;/code> immediatamente dopo la loro creazione, è possibile minimizzare il consumo di memoria utilizzando la tecnica di &lt;strong>copy-on-write&lt;/strong>. In particolare, tutte le pagine condivise vengono etichettate come copy-on-write e vengono duplicate solamente all&amp;rsquo;occorrenza, quando uno dei due processi ci esegue un&amp;rsquo;operazione di scrittura. Solamente le pagine modificabili vengono etichettate, quelle non-modificabili (p.e. quelle contenenti il codice eseguibile) no.&lt;/p>
&lt;h2 id="sostituzione-delle-pagine">Sostituzione delle pagine&lt;/h2>
&lt;p>Col paging su richiesta è possibile eseguire molti processi anche se il loro spazio degli indirizzi complessivo fosse più grande della memoria disponibile, poiché solamente una parte delle pagine viene caricata.&lt;/p>
&lt;p>Può accadere tuttavia che i processi richiedano contemporaneamente molte pagine in memoria e che lo spazio fisico sia effettivamente insufficiente. In questo caso, incorriamo nel &lt;strong>sovrallocamento&lt;/strong> della memoria. Nel momento in cui una nuova pagina non può essere allocata, possiamo terminare il processo che l&amp;rsquo;ha richiesta. Questa soluzione è tuttavia non ottimale, ed è meglio fare il swapping di altre pagine.&lt;/p>
&lt;h3 id="sostituzione-delle-pagine-standard">Sostituzione delle pagine standard&lt;/h3>
&lt;p>Quando un processo registra un page fault, l&amp;rsquo;algoritmo standard per la sostituzione di pagine funziona nel seguente modo:&lt;/p>
&lt;ol>
&lt;li>Trova la locazione della pagina da caricare nella memoria secondaria&lt;/li>
&lt;li>Trova un frame libero
&lt;ol>
&lt;li>Se c&amp;rsquo;è un frame libero usalo&lt;/li>
&lt;li>Se non c&amp;rsquo;è usa un algoritmo di sostituzione pagina per selezionare un &lt;strong>victim frame&lt;/strong>&lt;/li>
&lt;li>Salva il victim frame nella memoria secondaria (se necessario), cambia la pagina e il frame&lt;/li>
&lt;/ol>
&lt;/li>
&lt;li>Carica la pagina desiderata nel frame liberato, aggiorna le tabelle delle pagine e dei frame&lt;/li>
&lt;li>Continua a eseguire il processo dal momento del page fault&lt;/li>
&lt;/ol>
&lt;p>Due trasferimenti sono richiesti, uno per la pagina in entrata e uno per la pagina in uscita. Si può ridurre l&amp;rsquo;overhead utilizzando un &lt;strong>modify bit&lt;/strong> (o &lt;strong>dirty bit&lt;/strong>). In caso positivo, viene prima copiata nella memoria secondaria, altrimenti viene semplicemente sovrascritta.&lt;/p>
&lt;p>Ovviamente, all&amp;rsquo;aumentare del numero di frame disponibili il numero di page fault decrementa, assumendo un andamento simil-esponenziale.&lt;/p>
&lt;p>Per implementare correttamente il paging su richiesta è necessario trovare un algoritmo di allocazione dei frame e uno di sostituzione pagine.&lt;/p>
&lt;h3 id="sostituzione-pagine-fifo">Sostituzione pagine FIFO&lt;/h3>
&lt;p>L&amp;rsquo;algoritmo più semplice è il first-in, first-out (FIFO) che seleziona la pagina più vecchia in memoria per la sostizione. Non serve salvare il tempo in cui le pagine sono state allocate, basta utilizzare una coda FIFO. Se una pagina in uso viene sostituita, questa verrà riallocata in memoria al prossimo page fault, garantendo comunque la correttezza dell&amp;rsquo;algoritmo.&lt;/p>
&lt;p>Questo algoritmo può incorrere in un fenomeno strano noto come l&amp;rsquo;&lt;strong>anomalia di Belady&lt;/strong>, per il quale aumentando il numero di frame disponibili, aumenta il numero di page fault.&lt;/p>
&lt;h3 id="sostituzione-pagine-ottimale">Sostituzione pagine ottimale&lt;/h3>
&lt;p>Esiste un algoritmo ottime di sostituzione pagine, chiamato OPT o MIN. La regola è semplice e consiste nel sostituire la pagina che non verrà utilizzata per il periodo più lungo di tempo. Sfortunatamente l&amp;rsquo;algoritmo è difficile da implementare perché richiede di stimare l&amp;rsquo;utilizzo delle pagine nel futuro. Per tale motivo l&amp;rsquo;algoritmo viene utilizzato solo come riferimento. Questo algoritmo non soffre dell&amp;rsquo;anomalia di Belady.&lt;/p>
&lt;h3 id="sostituzione-pagine-lru">Sostituzione pagine LRU&lt;/h3>
&lt;p>Un&amp;rsquo;altro algoritmo di sostituzione pagine è il cosiddetto &lt;strong>Least Recently Used (LRU)&lt;/strong>. LRU associa a ogni pagina il tempo in cui è stata utilizzata l&amp;rsquo;ultima volta e, quando è necessaria una sostituzione, viene eliminata la pagina utilizzata meno recentemente.&lt;/p>
&lt;p>Questo algoritmo è spesso utilizzato. Il problema principale è l&amp;rsquo;implementazione del meccanismo di memoria, che viene generalmente fatta in due modi:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Contatori:&lt;/strong> Associamo a ogni entry della tabella delle pagine un tempo di utilizzo e aggiungiamo alla CPU un clock logico o un contatore. Ogni volta che avviene l&amp;rsquo;accesso a una pagina, il suo tempo viene registrato nella entry di riferimento. In caso di sostituzione, la pagina con il tempo minore viene scelta per la sostituzione. Questo metodo richiede lo scorrimento delle pagine per la ricerca della pagina LRU e un&amp;rsquo;operazione di scrittura per l&amp;rsquo;aggiornamento delle pagine a ogni accesso in memoria.&lt;/li>
&lt;li>&lt;strong>Stack:&lt;/strong> Un altro approccio è uello di utilizzare una pila di numeri di pagina. Quando una pagina viene acceduta e messa sopra la pila. In questa maniera le pagine utilizzate più recentemente sono mantenute sopra lo stack. La struttura dati da utilizzare è una lista doppiamente concatenata con un pontatore alla testa e alla coda. Rimuovere una pagina e metterla sopra la pila richiede la modifica di sei puntatori nel peggiore dei casi. Non è necessaria la ricerca, anche se l&amp;rsquo;update è più costoso. Questo approccio è particolarmente vantaggioso.&lt;/li>
&lt;/ul>
&lt;p>Anche questo algoritmo non soffre dell&amp;rsquo;anomalia di Belady. Sia l&amp;rsquo;algoritmo ottimale che questo algoritmo vengono chiamati &lt;strong>algoritmi stack&lt;/strong> poiché si può dimostrare che l&amp;rsquo;insieme di pagine in memoria su $n$ frame è sempre il sottoinsieme di pagine che sarebbe in memoria con $n + 1$ frame. Per la sostituzione LRU, l&amp;rsquo;insieme di pagine in memoriea consisterebbe nelle $n$ pagine utilizzate più recentemente.&lt;/p>
&lt;h3 id="sostituzione-pagine-lru-approssimata">Sostituzione pagine LRU approssimata&lt;/h3>
&lt;p>Non tutti i sistemi forniscono hardware sufficiente per supportare LRU. Molti sistemi forniscono qualche aiuto, ma nella forma di &lt;strong>bit di riferimento (reference bit)&lt;/strong>. Il bit di riferimento per una pagina è impostato dall&amp;rsquo;hardware a $1$ ogni volta che la pagina viene acceduta. È possibile esaminare e selezionare la pagina da sostituire tra quelle che non sono state utilizzate, anche se non conosciamo l&amp;rsquo;ordine di utilizzo.&lt;/p>
&lt;h4 id="algoritmo-con-i-bit-di-riferimento-aggiuntivi">Algoritmo con i bit di riferimento aggiuntivi&lt;/h4>
&lt;p>È possibile estendere l&amp;rsquo;algoritmo includendo diversi bit di riferimento. A intervalli regolari (p.e. ogni $100$ millisecondi), avviene un interrupt che trasferisce il controllo al s.o. che si occupa di fare lo shift dei bit, scartando quello meno significativo e impostando il bit più significativo a $1$ nel caso la pagina sia stata utilizzata, durante l&amp;rsquo;intervallo precedente. La sequenza di bit così formata è un intero (p.e. $10111111$, con $8$ bit, indica che la pagina non è stata acceduta nel penultimo intervallo soltanto). Possiamo quindi selezionare tutte le pagine corrispondenti all&amp;rsquo;intero minimo e rimuoverle tutte oppure utilizzare un metodo FIFO per sceglierne una soltanto.&lt;/p>
&lt;p>L&amp;rsquo;algoritmo FIFO con un solo bit di riferimento viene chiamato &lt;strong>algoritmo di sostituzione pagine con seconda possibilità (second-chance page-replacement algorithm)&lt;/strong>. È possibile utilizzare il bit di riferimento assieme a quello di modifica per ottenere un&amp;rsquo;analisi più completa dell&amp;rsquo;utilizzo della pagina:&lt;/p>
&lt;ul>
&lt;li>(0, 0) pagina non recentemente usata, né modifica. Rappresenta il candidato migliore per la sostituzione&lt;/li>
&lt;li>(0, 1) pagina non recentemente usata, ma modificata. La sua sostituzione comporta una scrittura in memoria secondaria&lt;/li>
&lt;li>(1, 0) pagina recentemente usata, ma non modificata. Probabilmente verrà usata di nuovo&lt;/li>
&lt;li>(1, 1) pagina recentemente usata e modificata. Verrà probabilmente riusata e in ogni caso dovrebbe venire scritta nella memoria secondaria&lt;/li>
&lt;/ul>
&lt;h3 id="sostituzione-pagine-basata-su-conteggio">Sostituzione pagine basata su conteggio&lt;/h3>
&lt;p>Ci sono altri algoritmi che possono essere usati per la sostituzione delle pagine. Per esempio possiamo tenere un riferimento al numero di volte in cui una pagina è stata acceduta.&lt;/p>
&lt;h3 id="algoritmi-di-buffering-di-pagina">Algoritmi di buffering di pagina&lt;/h3>
&lt;p>È possibile implementare dei buffer di frame liberi, in maniera tale che quando accade un page fault, la pagina richiesta possa essere caricata subito nel buffer, mentre il victim frame viene liberato.&lt;/p>
&lt;h2 id="allocazione-dei-frame">Allocazione dei frame&lt;/h2>
&lt;p>Passiamo ora al problema dell&amp;rsquo;allocazione dei frame ai vari processi. In particolare, ci chiediamo quanti frame dobbiamo assegnare a ciascun processo? è&lt;/p>
&lt;h3 id="numero-di-frame-minimo">Numero di frame minimo&lt;/h3>
&lt;p>È importante allocare un numero minimo di frame per processo. Le motivazioni di questa scelta sono due:&lt;/p>
&lt;ol>
&lt;li>Col diminuire di frame disponibili per un processo, i suoi page fault aumentano, diminuendo le prestazioni.&lt;/li>
&lt;li>Quando avviene un page fault prima che un&amp;rsquo;istruzione sia completata, l&amp;rsquo;istruzione va ri-eseguita da capo.&lt;/li>
&lt;/ol>
&lt;p>Solitamente un&amp;rsquo;architettura stabilisce il numero minimo di frame e imposta il numero massimo con il numero di frame totali nella memoria fisica.&lt;/p>
&lt;h3 id="algoritmi-di-allocazione">Algoritmi di allocazione&lt;/h3>
&lt;p>La maniera più semplice per allocare $m$ frame a $n$ processi è di dare a ciascuno un ammontare uguale di $m/n$ frame. Per esempio, con $93$ frame e $5$ processi daremmo $18$ frame a ciascuno, tenendo i rimanenti $3$ come buffer. Questo schema è chiamato &lt;strong>allocazione uniforme&lt;/strong>.&lt;/p>
&lt;p>Un&amp;rsquo;alternativa è quella di riconoscere che i vari processi richiederanno un differente ammontare di memoria. Se abbiamo un sistema con frame a dimensione $s$ e $n$ processi $P_1, \dots, P_n$ che pesano $s_1, \dots, s_n$, possiamo calcolare&lt;/p>
&lt;p>$S = \sum_{i = 1}^n s_i$&lt;/p>
&lt;p>e calcolare quanti frame allocare a ogni processo utilizzando la seguente formula&lt;/p>
&lt;p>$a_i = s_i / S \times m$.&lt;/p>
&lt;p>Dobbiamo poi regolare il numero affinché sia un intero maggiore del numero minimo di frame e minore di $m$. Questo algoritmo si chiama di &lt;strong>allocazione proporzionale&lt;/strong>.&lt;/p>
&lt;p>Lo svantaggio è che non fa distinzioni tra processi a bassa e ad alta priorità. Questi ultimi potrebbero avere delle maggiori esigenze in termini di memoria per garantire la loro esecuzione in tempi stretti. È possibile quindi calcolare lo spazio di allocazione anche in base alla priorità del processo.&lt;/p>
&lt;h3 id="allocazione-globale-vs-locale">Allocazione globale vs locale&lt;/h3>
&lt;p>Possiamo classifiare gli algoritmi di sostituzione pagine in due macro categorie:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Rimpiazzamento globale:&lt;/strong> un processo può selezionare un qualunque frame di sostituzione, anche tra i frame posseduti da altri processi. È utile per esempio per permettere a processi ad alta priorità di selezionare all&amp;rsquo;occorrenza frame da processi a bassa priorità. Il problema principale è che il comportamento di paging di ogni processo varia e non è quindi facile gestire prevedere quali pagine selezionare. In ogni caso, il rimpiazzamento globale è la scelta più comunemente adottata.&lt;/li>
&lt;li>&lt;strong>Rimpiazzamento locale:&lt;/strong> un processo può selezionare un frame di sostituzione solo tra quelli a esso allocati. Il problema principale è che il processo non può accedere a frame di altri processi che potrebbe essere sottoutilizzati.&lt;/li>
&lt;/ul>
&lt;p>È possibile implementare il rimpiazzamento globale utilizzando una lista di frame liberi che soddisfi i page fault dei processi. La lista viene utilizzata fino a che la sua dimensione scende fino a una certa soglia minima, positiva. Raggiunta la soglia i processi smettono di richiedere frame liberi e avviano l&amp;rsquo;algoritmo di sostituzione pagine. In questo modo, vengono conservati dei frame liberi utilizzabili per nuove richieste (p.e. un nuovo processo viene caricato in memoria). Raggiunta la soglia minima, inoltre, il kernel avvia un processo &lt;strong>reaper&lt;/strong>, che inizia a reclamare le pagine assegnate agli altri processi (kernel escluso) utilizzando un algoritmo di rimpiazzamento pagina. Il reaper non utilizza effettivamente i frame, ma li libera soltanto, incrementando la lista di frame liberi. L&amp;rsquo;operazione continua finché non viene raggiunta una soglia superiore e i processi possono tornare a richiedere frame dalla lista di frame liberi.&lt;/p>
&lt;div id="references-1784797996353985593" class="references" style="margin-top: 5rem;">
&lt;p>&lt;strong>References&lt;/strong>&lt;/p>
A. Silberschatz, P. B. Galvin, G. Gagne, &lt;em>Sistemi Operativi. Concetti ed Esempi&lt;/em>, Decima edizione. Ch. 10.
&lt;/div>
&lt;script>
document.addEventListener('DOMContentLoaded', function () {
var ref = document.getElementById('references-1784797996353985593');
var fn = document.querySelector('.footnotes');
if (ref &amp;&amp; fn &amp;&amp; fn.parentNode) {
fn.parentNode.insertBefore(ref, fn.nextSibling);
}
});
&lt;/script></description></item><item><title>Memoria di massa</title><link>https://aretor.it/courses/os2324/11_sec_mem/</link><pubDate>Thu, 25 Apr 2024 00:00:00 +0000</pubDate><guid>https://aretor.it/courses/os2324/11_sec_mem/</guid><description>&lt;p>In questa lezione vedremo i meccanismi di base e il modo in cui i s.o. traducono le loro proprietà fisiche in memoria logica tramite la mappatura degli indirizzi.&lt;/p>
&lt;h2 id="struttura-dei-dispositivi-di-memorizzazione">Struttura dei dispositivi di memorizzazione&lt;/h2>
&lt;p>I &lt;strong>dischi magnetici (o rigidi)&lt;/strong> e i &lt;strong>dispositivi NVM&lt;/strong> cosituiscono i supporti fondamentali di memoria secondaria dei computer. Di seguito riportiamo le loro caratteristiche principali.&lt;/p>
&lt;h3 id="dischi-rigidi">Dischi rigidi&lt;/h3>
&lt;p>I dischi rigidi sono costituiti da piatti che hanno una forma piana e rotonda, come quella dei Compact Disc (CD). Le loro superfici sono ricoperte di materiale magnetico la cui configurazione consente la memorizzazione delle informazioni. Sulla superficie di ogni piatto stanno, sospese, le testine di lettura e scrittura, attaccate al &lt;strong>braccio del disco&lt;/strong>. La superficie di ogni piatto è divisa logicamente in &lt;strong>tracce&lt;/strong> circolari, che a loro volta sono suddivise in &lt;strong>settori&lt;/strong>. L&amp;rsquo;insieme delle tracce corrispondenti a una posizione del braccio costituisce un &lt;strong>cilindro&lt;/strong>.&lt;/p>
&lt;!-- **N.B ale vedi figura 11.1 perché secondo me è utile** -->
&lt;p>Il disco in funzione ruota tra velocità comprese tra i $60$ e $250$ giri al secondo. Questa velocità di rotazione è in relazione con la &lt;strong>velocità di trasferimento (transfer rate)&lt;/strong>, ovvero la velocità con cui i dati vengono trasferiti dall&amp;rsquo;unità a disco al calcolatore. La transfer rate è uno dei fattori che determina le prestazioni. Il secondo fattore è il cosiddetto &lt;strong>tempo di posizionamento/tempo d&amp;rsquo;accesso casuale&lt;/strong>, che consta di due componenti: il tempo necessario a spostare il braccio del disco in corrispondenza del cilindro desiderato, detto &lt;strong>tempo di ricerca&lt;/strong> e il tempo necessario affinché il settore desiderato si porti, tramite la rotazione del disco, sotto la testina, detto &lt;strong>latenza di rotazione&lt;/strong>. Questi tempi sono nell&amp;rsquo;ordine dei millisecondi.&lt;/p>
&lt;h3 id="dispositivi-nvm">Dispositivi NVM&lt;/h3>
&lt;p>Gli NVM sono dispositivi elettrici composti da un controllore e da diversi chip di memoria a semiconduttore NAND flash utilizzati per memorizzare i dati.&lt;/p>
&lt;p>Questi dispositivi&lt;/p>
&lt;ul>
&lt;li>Vengono spesso inseriti in contenitori simili a unità disco, e dunque vengono chiamati &lt;strong>dischi a stato solido&lt;/strong>, o &lt;strong>SSD&lt;/strong>. Possiamo però trovare dispositivi NVM anche in formato di &lt;strong>unità USB&lt;/strong>&lt;/li>
&lt;li>Possono essere più affidabili dei dischi rigidi, perché non hanno parti mobili e sono in genere più veloci, perché non hanno tempo di posizionamento o latenza di rotazione&lt;/li>
&lt;li>Consumano meno energia, però per contro sono più costosi e hanno una capacità inferiore rispetto ai dischi rigidi tradizionali. Bisogna dire però che nel corso del tempo, la capacità dei dispostivi NVM è aumentata più rapidamente rispetto a quella dei dischi rigidi, e il loro prezzo è diminuito, motivo per cui il loro utilizzo è in aumento&lt;/li>
&lt;/ul>
&lt;p>Poiché i dispositivi NVM possono essere molto più veloci rispetto ai dischi fissi, le interfacce bus standard possono costituire un limite importante al throughpput e alcuni dispositivi NVM sono progettati per connettersi direttamente al bus di sistema.&lt;/p>
&lt;p>Un dispositivo NVM è composto da moltissimi &lt;strong>die&lt;/strong>, delle piastrine di circuiti integrati atte alla memorizzazione dei dati. Ogni die contiene blocchi che a loro volta contengono molte pagine. La scrittura in un NVM viene effettuata a livello di pagina, ma richiede più tempo della lettura. È necessario infatti cancellare completamente il &lt;em>blocco&lt;/em> corrispondente e riscriverlo, con la pagina modificata. In altre parole, non è possibile sovrascrivere i dati. Il problema è alleviato dal fatto che i &lt;strong>die&lt;/strong> hanno molteplici &lt;strong>datapath&lt;/strong> per garantire l&amp;rsquo;esecuzione di operazioni in parallelo. A differenza di altri dispositivi inoltre, la durata di un NVM non si misura in anni, bensì in numero di scritture per giorno sull&amp;rsquo;unità (Drive Writes Per Day, DWPD).&lt;/p>
&lt;h4 id="algoritmi-del-controllore-delle-nand-flash">Algoritmi del controllore delle NAND Flash&lt;/h4>
&lt;p>Poiché i dispositivi NAND non possono essere sovrascritti, e vanno prima cancellati, quando una pagina viene modificata, viene semplicmente scritta in una nuova locazione di memoria libera per risparmiare tempo e le versioni precedenti della pagina, con dati non più validi, rimangono in memoria. Per tenerne traccia, il controllore mantiene una &lt;strong>tabella di traduzione flash&lt;/strong> (Flash Translation Layer, FTL) così da poter sapere quali blocchi contengono solo pagine non valide e pertanto cancellabili. Per non esaurire lo spazio, un &lt;strong>garbage collector&lt;/strong> accorpa le pagine con dati validi in altre posizioni, liberando completamente blocchi che possono poi essere cancellati e riutilizzati per nuove scritture. Per migliorare il coordinamento tra nuove scritture dati e garbage collection, il dispositivo utilizza una tecnica di &lt;strong>over-provisioning&lt;/strong>. Il produttore mette da parte un certo numero di blocchi che risulta invisibile all&amp;rsquo;utente. Tali blocchi sono utilizzati per:&lt;/p>
&lt;ul>
&lt;li>Permettere la scrittura istantanea di nuove pagine da parte del controllore&lt;/li>
&lt;li>Permettere al garbage collector di svolgere il suo compito di accorpamento pagine più efficientemente, utilizzando l&amp;rsquo;area di over-provisionig come buffer&lt;/li>
&lt;li>Garantire un&amp;rsquo;area sempre disponibile per la scrittura in caso di malfunzionamenti futuri di altri blocchi.&lt;/li>
&lt;/ul>
&lt;!--https://www.kingston.com/en/blog/pc-performance/overprovisioning-->
&lt;p>In aggiunta, se alcuni blocchi vengono cancellati ripetutamente, mentre altri non lo sono, i blocchi frequentemente cancellati si logorano più velocemente degli altri e l&amp;rsquo;intero dispositivo avrà una vita più breve. Per questo motivo, il controllore di dispositivo cerca di alleviare questo problema con degli algoritmi che favoriscono la memorizzazione di dati su blocchi meno cancellati.&lt;/p>
&lt;h3 id="memoria-volatile">Memoria volatile&lt;/h3>
&lt;p>La DRAM, o memoria volatile, viene spesso utilizzata come dispositivo di archivazione di massa. Più precisamente, le RAM agiscono come memoria secondaria, ma sono create da driver di periferica che ritagliano una sezione della DRAM di sistema e la presentano al resto del sistema come se fosse una memoria di massa. Sono utilizzate perché, mentre cache e buffer sono allocati dal programmatore o dal sistema operativo, le unità RAM consentono all&amp;rsquo;utente di salvare temporanemanete i dati in memoria utilizzando le normali operazioni sui file. Inoltre, sebbene i dispositivi NVM siano veloci, la DRAM è molto più veloce e le operazioni di I/O su unità RAM sono il modo piu veloce per creare, leggere, scrivere ed eliminare i file e il loro contenuto.&lt;/p>
&lt;h3 id="mappatura-degli-indirizzi">Mappatura degli indirizzi&lt;/h3>
&lt;p>I dispositivi di archivazione sono indicizzati come vettori unidimensionali di &lt;strong>blocchi logici&lt;/strong>, in cui un blocco logico rappresenta la più piccola unità di trasferimento. Ogni blocco logico viene mappato su un settore fisico o su una pagina di un dispositivo a semiconduttore e l&amp;rsquo;intero vettore di blocchi logici viene mappato sui settori o sulle pagine del dispositivo.&lt;/p>
&lt;p>Nei dispositivi NVM una tupla del tipo &lt;em>&amp;lt;chip, blocco, pagina&amp;gt;&lt;/em> viene mappata su un vettore di blocchi logici. Un &lt;strong>indirizzo di blocco logico (Logic Based Address, LBA)&lt;/strong> è più semplice da utilizzare rispetto a una tupla &lt;em>&amp;lt;settore, cilindro, testina&amp;gt;&lt;/em> o &lt;em>&amp;lt;chip, blocco, pagina&amp;gt;&lt;/em>.
La mappatura da indirizzo a logico a fisico è difficile da effettuare nella pratica, principalmente per tre motivi.&lt;/p>
&lt;!-- Usando questa mappatura su un disco rigido possiamo, almeno in teoria, convertire un numero di blocco logico in un indirizzo *vecchio stile* del disco formato da un numero di cilindro, un numero di traccia all'interno di quel cilindro e un numero di settore all'interno di quella traccia. Nella pratica però è difficile effettuare una traduzione 1:1, principalmente per tre motivi: -->
&lt;ul>
&lt;li>La maggior parte delle unità presenta alcuni settori difettosi che la mappatura nasconde sostituendoli con settori di riserva collocati in altre posizioni sull&amp;rsquo;unità. In questo caso, l&amp;rsquo;indirizzo del blocco logico rimane sequenzialie, ma la posizione del settore fisico cambia&lt;/li>
&lt;li>Il numero di settori per traccia non è sempre costante&lt;/li>
&lt;li>I produttori di dischi gestiscono la mappatura di LBA su indirizzi fisici internamente&lt;/li>
&lt;/ul>
&lt;p>Gli algoritmi che gestiscono gli HDD prediligono comunque indirizzi logici correlati a quelli fisici.&lt;/p>
&lt;h2 id="scheduling-dei-dischi-rigidi">Scheduling dei dischi rigidi&lt;/h2>
&lt;p>Il sistema operativo ha, tra le varie responsabilità, quella di fare un uso efficiente dell&amp;rsquo;hardware. Nel caso dei dischi rigidi, far fronte a questa responsabilità significa garantire tempi di accesso contenuti e ampiezze di banda elevate. Nel caso dei dischi, il tempo d&amp;rsquo;accesso è composto da:&lt;/p>
&lt;ol>
&lt;li>Il &lt;strong>tempo di ricerca&lt;/strong> (seek time): ossia il tempo richiesto affinché il braccio dell&amp;rsquo;unità a disco sposti le testine fino al cilindro contenente il settore desiderato&lt;/li>
&lt;li>La &lt;strong>latenza di rotazione&lt;/strong> (rotational latency): il tempo necessario affinché il disco ruoti finché il settore desiderato si trovi sotto la testina.&lt;/li>
&lt;/ol>
&lt;p>L&amp;rsquo;&lt;strong>ampiezza di banda (bandwidth)&lt;/strong> è definita come il numero totale di byte trasferiti diviso il tempo totale intercorso fra la prima richiesta e il completamento dell&amp;rsquo;ultimo trasferimento. Gestendo l&amp;rsquo;ordine delle richieste di I/O relative al disco si possono migliorare sia il tempo d&amp;rsquo;accesso sia l&amp;rsquo;ampiezza di banda. Se l&amp;rsquo;unità a disco desiderata e il controllore sono disponibili, la richiesta si può immediatamente soddisfare. In caso contrario le nuove richieste si aggiungono alla coda, che dovrà essere opportunamente gestita. La presenza di una coda di richieste verso un dispositivo su cui è possibile minimizzare i tempi di ricerca (seek) consente ai driver del dipostivo di migliorare le prestazioni ordinando opportunamente la coda.&lt;/p>
&lt;h3 id="scheduling-in-ordine-di-arrivo--fcfs">Scheduling in ordine di arrivo – FCFS&lt;/h3>
&lt;p>Questo scheduling rappresenta la forma più semplice dove il servizio viene effettuato secondo l&amp;rsquo;ordine di arrivo (FCFS). Si tratta di un algoritmo &lt;em>equo&lt;/em>, ma che in generale non garantisce la massima velocità di servizio. Vediamolo con un esempio:
Data la coda di richieste nei cilindri $98$, $183$, $37$, $122$, $14$, $124$, $65$, $67$, la testina, una volta arrivata al cilindro $122$, deve poi effettuare un grande salto per andare al cilindro $14$, e tornare successivamente al $124$, con conseguente inefficienza di utilizzo. Se invece le richieste per I cilindri $37$ e $14$ si potessero effettuare in sequenza, la distanza totale percorsa diminuirebbe notevolmente e le prestazioni migliorerebbero di conseguenza.&lt;/p>
&lt;h3 id="scheduling---scan">Scheduling - SCAN&lt;/h3>
&lt;p>Questo algoritmo prevede che il braccio dell&amp;rsquo;unità a disco parta da un estremo del disco, si sposti verso l&amp;rsquo;altro estremo, e serva le richieste mentre attraversa i cilindri. Arrivato all&amp;rsquo;estremità, il braccio inverte la marcia e la procedura continua. Per questo motivo, viene anche chiamato &lt;strong>algoritmo dell&amp;rsquo;ascensore&lt;/strong>. Vediamo il funzionamento ancora con l&amp;rsquo;esempio precedente. Data la coda $98$, $183$, $37$, $122$, $14$, $124$, $65$ e $67$, sappiamo che la posizione corrente è $53$, e che la direzione del movimento della testina è nella direzione del cilindro $0$. Dunque, l&amp;rsquo;unità a disco serve per prima la richiesta $37$, e poi la $14$. Una volta arrivato al cilindro $0$, inverte in senso di marcia servendo le richieste $65$, $67$, $98$, $122$, $124$ e $183$. Se arriva una nuova richiesta riferita a uno dei cilindri posti davanti alla testina essa sarà quasi immediatamente soddisfatta. In caso contrario, dovrà attendere fino a che la testina non giunga alla fine del disco e inverta la direzione di moto.&lt;/p>
&lt;h3 id="scheduling--c-scan">Scheduling – C-SCAN&lt;/h3>
&lt;p>L&amp;rsquo;algoritmo &lt;strong>SCAN circolare&lt;/strong> è una variante dello scheduling SCAN concepita per garantire un tempo d&amp;rsquo;accesso meno variabile. Anche questo algoritmo, come il precedente, sposta la testina da un estremo all&amp;rsquo;altro del disco servendo le richieste lungo il percorso. Tuttavia, quando la testina giunge all&amp;rsquo;altro estremo del disco, ritorna immediatamente all&amp;rsquo;inizio del disco stesso, senza servire richieste durante il viaggio di ritorno.&lt;/p>
&lt;!-- Ale vedi esempio fine pag. 497 se vuoi -->
&lt;!-- ### Scelta di un algoritmo di scheduling
La scelta di un algoritmo di scheduling dipende dalle prestazioni. Per tutti gli algoritmi di scheduling le prestazioni dipendono dal numero e dal tipo di richieste. Supponiamo per esempio che la coda sia costitutita di una sola richiesta inevasa: in questo caso è evidente che tutti gli algoritmi abbiano lo stesso comportamento, avendo una sola scelta possibile, e tutti dunque si comportano come un FCFS. Ci sono anche casi con più richieste, con una possibiltà di attesa indefinita. Per questo motivo, Linux è dotato di uno **scheduler con scadenza (deadline scheduler)** che conserva code di lettura e scrittura separate, e dà priorità alla lettura, poiché i processi hanno più probabilità di bloccarsi in lettura che in scrittura. Le code vengono ordinate secondo LBA, implementando C-SCAN, e tutte le richieste di I/O vengono inviate secondo questo ordine. Lo scheduler con scadenza mantiene quattro code: due in lettura e due in scrittura, una in ordine di LBA e l'altra in ordine FCFS. Dopo ogni lotto di operazioni, l'algoritmo controlla se ci sono richieste nelle code FCFS più vecchie di un tempo preconfigurato. In tal caso, viene selezionata la coda LBA (di lettura o scrittura) contenente tale richiesta per il successivo lotto di I/O. -->
&lt;h3 id="scheduling-di-dispositivi-nvm">Scheduling di dispositivi NVM&lt;/h3>
&lt;p>Gli algoritmi appena discussi si applicano a memorie basate su dischi meccanici e hanno come obiettivo principale la riduzione della quantità di movimento della testina del disco. I dispositivi NVM, che invece non contengono testine in movimento, usano generalemnte una semplice politica FCFS. Inoltre, siccome nei dispositivi NVM il tempo richiesto per le letture è uniforme, ma il tempo richiesto per le scritture no, alcuni scheduler per gli SSD sfruttano questa proprietà e accorpano soltanto le richieste di scrittura adiacenti, mentre le richieste di lettura sono servite in ordine FCFS.&lt;/p>
&lt;p>Questa pecularità evidienza anche differenze in termini di prestazioni. Infatti, come abbiamo visto l&amp;rsquo;I/O può essere sequenziale o diretto.&lt;/p>
&lt;ul>
&lt;li>L&amp;rsquo;accesso sequenziale è ottimale per i dispositivi meccanici come gli HDD e i nastri, poiché i dati da leggere o scrivere si trovano vicino alla testina di lettura/scrittura.&lt;/li>
&lt;li>L&amp;rsquo;accesso casuale/diretto invece, misurato in &lt;strong>operazioni di input/output al secondo (IOPS)&lt;/strong> provoca il movimento della testina del disco, e dunque è ottimale nei dispostivi NVM.&lt;/li>
&lt;/ul>
&lt;p>Dunque, il vantaggio dei dispositivi NVM è molto inferiore nel caso di trasferimenti sequenziali, dove la ricerca sui dischi rigidi è ridotta al minimo e vengono enfatizzate la lettura e la scrittura dei dati. In questi casi, per le letture, le prestazioni dei due tipi di dispositivi possono essere equivalenti o differire di un ordine di grandezza in favore dei dispositivi NVM. La scrittura su NVM è più lenta della lettura, riducendo ulteriormente il vantaggio rispetto agli HDD. Inoltre, mentre le prestazioni di scrittura su HDD restano costanti durante il ciclo di vita del del dispostivo, le prestazioni in scrittura su dispositivi NVM variano a seconda di quanto il dispositivo è pieno (ricordiamo la necessità di garbage collection e over-provisioning), e di quanto è usurato.&lt;/p>
&lt;h2 id="rilevamento-e-correzione-di-errori">Rilevamento e correzione di errori&lt;/h2>
&lt;p>Il rilevamento e la correzione degli errori sono fondamentali per la gestione della memoria, delle reti e dei dispostivi di massa. Il &lt;strong>rilevamento degli errori&lt;/strong> permette di determinare se si è verificato un problema in modo da arrestare un&amp;rsquo;operazione prima che essa venga propagata. Per molto tempo i sistemi di memoria hanno rilevato gli errori utilizzando i bit di parità. In questo scenario, a ogni byte in un sistema di memoria viene associato un bit di parità. Se uno dei bit nel byte è corrotto (un $1$ diventa $0$ o viceversa) la parità del byte cambia e non corrisponde piu alla parità memorizzata. Ciò permette al sistema di memoria di rilevare tutti gli errori su un singolo bit, ma non è detto che ne rilevi uno su due bit.&lt;/p>
&lt;p>Un altro metodo di rilevamento degli errori è il &lt;strong>controllo di ridondanza ciclica (Ciclic Redundancy Check, CRC)&lt;/strong>, che utilizza una funzione di hash per rilevare errori su più bit. Un &lt;strong>codice di correzione degli errori (Error Correcting Code))&lt;/strong> è in grado di correggere il problema oltre a rilevarlo. La correzione viene eseguita utilizzando opportuni algoritmi e una certa quantità di spazio di memorizzazione aggiuntivo. Il funzionamento di ECC prevede che quando un controllore scrive un settore di dati durante il normale I/O, viene anche scritto l&amp;rsquo;ECC, calcolato a partire da tutti i byte dei dati che vengono scritti. Quando il settore viene letto, l&amp;rsquo;ECC viene ricalcolato e confrontato con il valore memorizzato. Un valore diverso da quello calcolato indica che i dati sono corrotti e che il supporto di memorizzazione potrebbe essere danneggiato.&lt;/p>
&lt;p>L&amp;rsquo;ECC è in grado di correggere gli errori solo quando pochi bit sono corrotti, ossia nei casi dei cosiddetti &lt;strong>errori reversibili&lt;/strong> (soft), mentre fallisce con gli &lt;strong>errori irreversibili&lt;/strong> (hard).&lt;/p>
&lt;h2 id="gestione-delle-unità-di-memoria-secondaria">Gestione delle unità di memoria secondaria&lt;/h2>
&lt;p>Tra le varie responsabilità del s.o. rientrano anche:&lt;/p>
&lt;ol>
&lt;li>L&amp;rsquo;inizializzazione del dispositivo&lt;/li>
&lt;li>L&amp;rsquo;avviamento del sistema del dispositivo&lt;/li>
&lt;li>La gestione del blocchi difettosi&lt;/li>
&lt;/ol>
&lt;p>Andiamo a vedere nel dettaglio questi aspetti.&lt;/p>
&lt;h3 id="formattazione-del-disco-partizioni-e-volumi">Formattazione del disco, partizioni e volumi&lt;/h3>
&lt;p>Prima che un dispositivo nuovo di storage possa memorizzare dati, deve essere diviso in settori che possono essere letti o scritti dal controllore. Nel caso dei dispotivi NVM, devono essere inizializzate le pagine. Questo processo si chiama &lt;strong>formattazione di basso livello/formattazione fisica&lt;/strong>, e consiste nel riempire il dispositivo con una speciale struttura dati per ogni locazione di memorizzazione. Tale struttura tipicamente consiste di un&amp;rsquo;intestazione, un&amp;rsquo;area per i dati e una coda. L&amp;rsquo;intestazione e la coda contengono informazioni usate dal controllore del disco e l&amp;rsquo;ECC.&lt;/p>
&lt;p>Prima di usare un dispostivo come contenitore di file, il sistema operativo deve compiere tre passi:&lt;/p>
&lt;ol>
&lt;li>Suddividere il dispositivo in uno o più gruppi di blocchi o pagine, dette &lt;strong>partizioni&lt;/strong>. Alcuni s.o. e file system eseguono automaticamnte il partizionamento nel caso in cui un intero dispositivo debba essere gestito dal file system.&lt;/li>
&lt;/ol>
&lt;!-- Le informazioni sulle partizioni sono scritte in un formato stabilito e in una posizione fissa sul dispositivo, vengono lette le varie informazioni sulle partizioni e il s.o. crea delle descrizioni per le partizioni stesse. -->
&lt;ol start="2">
&lt;li>Creare e gestire il volume. A volte questo passaggio è implicito, come quando un file system viene inserito direttamente all&amp;rsquo;interno di una partizione. Altre volte invece è espliciito, per esempio quando più partizioni o dispositivi vengono utilizzanti insieme&lt;/li>
&lt;li>Eseguire la &lt;strong>formattazione logica&lt;/strong>, cioè la creazione di un file system. Il s.o. registra nel dispostivo le strutture dati iniziali relative al file system.&lt;/li>
&lt;/ol>
&lt;!-- Le strutture dati in questione possono includere descrizioni dello spazio libero e dello spazio allocato e una directory iniziale vuota. Le informazioni sulle partizioni indicano anche se una partizione contiene un file system -->
&lt;!-- Alcuni s.o. permettono a certi programmi speciali di impiegare una partizione del disco come un grande array sequenziale di blocchi logici, non contenente alcuna struttura dati relativa al file system. Questo array è detto a volte **disco di basso livello**. L'I/O relativo si chiama **I/O di basso livello** (raw I/O) e in questi casi il s.o. può essere utilizzato come spazio di swap. Il raw I/O comprende tutti i servizi del file system: gestione delle cache, locking dei file, prefetching, allocazione dello spazio, gestione dei nomi dei file, e le directory. È possibile rendere certe applciazioni più efficienti permettondo di implementare servizi di memorizzazione specializzati che usino una partizione di basso livello -->
&lt;!-- ma la maggior parte delle applicazioni preferisce un file system gia' fornito piuttosto che prendersi carico della gestione dei dati. -->
&lt;h3 id="blocco-davviamento">Blocco d&amp;rsquo;avviamento&lt;/h3>
&lt;p>affinché un calcolatore possa entrare in funzione, per esempio quando viene acceso o riavviato, è necessario che esegua un programma iniziale; di solito, questo &lt;strong>programma di avviamento iniziale (bootstrap)&lt;/strong> è piuttosto semplice. Nella maggior parte dei calcolatori il &lt;strong>bootstrap&lt;/strong> è memorizzato nel &lt;strong>firmware&lt;/strong> (una memoria flash NVM) sulla scheda madre del sistema e mappato su una posizione di memoria nota. Il piccolo programma che carica il bootstrap, detto &lt;strong>bootstrap loader&lt;/strong> è anche in grado di caricare dalla memoria secondaria un programma di bootstrap completo, che è memorizzato nei blocchi di avvio in una posizione fissa del dispositivo.&lt;/p>
&lt;!-- Il bootstrao loader predefinito di Linux e' **grub2**. -->
&lt;h3 id="blocchi-difettosi">Blocchi difettosi&lt;/h3>
&lt;p>Le unità a disco sono strutturalmente soggette a malfunzionamenti perché sono costituite da parti mobili con basse tolleranze (si ricordi che una testina è sospesa appena sopra la superficie del disco). A volte si può verificare un guasto irreparabile, e l&amp;rsquo;unità a disco deve essere sostituita. Il suo contenuto dev&amp;rsquo;essere recuperato da una copia di backup e trasferito nella nuova unità a disco. Più di frequente, uno o più settori diventano malfunzionanti. In effetti, la maggior parte dei dischi messi in commercio contiene gia &lt;strong>blocchi difettosi&lt;/strong>.&lt;/p>
&lt;p>Nel caso di dischi semplici come quelli gestiti da un controllore IDE, i blocchi difettosi sono gestiti &lt;em>manualmente&lt;/em>. Una possibile strategia consiste nell&amp;rsquo;effettuare la scansione del disco durante la formattazione, per rilevare la presenza di blocchi difettosi. Un blocco difettoso viene marcato come inutilizzabile e viene segnalato alle procedure di allocazione del file system per non utilizzarlo. Se qualche blocco diventa malfunzionante nel corso dell&amp;rsquo;ordinario uso del sistema, un programma speciale viene lanciato manualmente per individuare i blocchi difettosi e isolarli.&lt;/p>
&lt;p>Unità a disco più complesse hanno strategie di recupero dei blocchi difettosi più raffinate. Il controllore mantiene una lista dei blocchi malfunzionanti dell&amp;rsquo;unità a disco che è inizializzata durante la formattazione fisica eseguita dal produttore, ed è aggiornata per tutto il periodo in cui l&amp;rsquo;unità a disco è operativa. La formazione fisica mette anche da parte dei settori di riserva non visibili al s.o.: si può istruire il controllore affinché sostituisca da un punto di vista logico un settore difettoso con uno dei settori di riserva inutilizzati. Questa strategia è nota come &lt;strong>sector sparing/sector forwarding&lt;/strong>.&lt;/p>
&lt;!-- Ale vedi esempio pg. 506 (lista puntata) -->
&lt;p>Un&amp;rsquo;alternativa al sector sparing è data da quei controllori capaci di sostituire i settori difettosi tramite la traslazione dei settori, detta &lt;strong>sector slipping&lt;/strong>.&lt;/p>
&lt;!-- Si supponga per esempio che il blocco logico 17 divenga malfunzionante e che il primo settore di riserva disponibile sia quello successivo al settore 202. La traslazione dei settori sposta in avanti di una posizione tutti I settori dal 17 al 202: quindi, il settore 202 viene copiato sul settore di riserva, il settore 201 sul 202, il 200 sul 201 e cosi via fino a che il settore 18 non viene copiato sul 19. Questa traslazione di settori libera lo spazio del settore 18, e il settore 17 puo essere mappato su quest'ultimo. -->
&lt;p>Anche i dispositivi NVM possono contenere bit, byte e persino pagine che sono difettose già al momento della produzione o che si degradano nel tempo. La gestione di queste aree difettose è più semplice rispetto agli HDD, perché non incide sul tempo di ricerca. È possibile in questi casi mettere da parte più pagine da utilizzare in sostituzione di quelle difettose, opure utilizzare spazio dell&amp;rsquo;area di over-provisioning.&lt;/p>
&lt;h2 id="gestione-dellarea-di-swapping">Gestione dell&amp;rsquo;area di swapping&lt;/h2>
&lt;!-- Se ricordato abbiamo introdotto lo swapping qualche lezione fa, dove abbiamo trattato lo spostamento di interi processi tra disco e memoria centrale. In quel contesto, lo swapping interviene quando l'ammontare della memoria fisica si abbassa fino al punto di raggiungere la soglia critica e i processi vengono traferiti alla memoria all'area di swapping, per liberare memoria. Nella pratica, pochissimi sistemi realizzano lo swapping nel modo descritto: più frequentemente, combinano lo swapping con tecniche di memoria virtuale, ed effettuano lo swapping di pagine e non necessariamente di interi processi. -->
&lt;p>La &lt;strong>gestione dell&amp;rsquo;area di swapping&lt;/strong> e&amp;rsquo; un altro compito di basso livello del sistema operativo. La memoria virtuale usa lo spazio dei dischi come estensione della memoria centrale: poiché l&amp;rsquo;accesso alle unità a disco è molto più lento dell&amp;rsquo;accesso alla memoria centrale, l&amp;rsquo;uso dell&amp;rsquo;area di swapping ridure notevolmente le prestazioni del sistema. L&amp;rsquo;obiettivo principale nella progettazione e realizzazione di un&amp;rsquo;area di swapping è di fornire il migliore throughput per il sistema di memoria virtuale.&lt;/p>
&lt;h3 id="uso-dellarea-di-swapping">Uso dell&amp;rsquo;area di swapping&lt;/h3>
&lt;p>L&amp;rsquo;area di swapping è usata in modi diversi da sistemi operativi diversi, in funzione degli algoritmi di gestione della memoria utilizzati. I sistemi che adottano lo swapping dei processi nella memoria, per esempio, possono usare l&amp;rsquo;area di swapping per mantenere l&amp;rsquo;intera imamgine del processo, inclusi i segmenti dei dati e del codice. I sistemi a paginazione, invece, possono semplicemente memorizzarvi pagine non contenute nella memoria centrale. Lo spazio rihiesto da quest&amp;rsquo;area per un sistema può quindi variare da pochi megabyte ad alcuni gigabyte, a seconda della quantità di memoria fisica, della quantità di memoria virtuale che esso deve sostenere, e del modo in cui quest&amp;rsquo;ultima è usata. Alcuni sistemi consigliano la quantità da riservare all&amp;rsquo;area di swapping.&lt;/p>
&lt;!-- Linux in passato ha suggerito di raddoppiare l'area di swapping rispetto alla memroia fisica, ma oggi questa limitazione non c'è più e la maggior parte dei sistemi Linux usa un'area decisamente minore. -->
&lt;h3 id="collocazione-dellarea-di-swapping">Collocazione dell&amp;rsquo;area di swapping&lt;/h3>
&lt;p>Le possibili collocazioni sono due: all&amp;rsquo;interno del normale file system, o in una partizione del disco a sé stante. Se l&amp;rsquo;area di swapping è semplicemente un grande file all&amp;rsquo;interno del file system, si possono usare le ordinarie funzioni del file system per crearla, assegnarli un nome e allocare spazio per essa. In alternativa, l&amp;rsquo;area di swapping si può creare in un&amp;rsquo;apposita &lt;strong>partizione del disco non formattata&lt;/strong>: in essa non è presente alcuna struttura relativa al file system e alle directory, ma si usa uno speciale gestore dell&amp;rsquo;area di swapping per allocare e rimuovere i blocchi. Esso adotta algoritmi ottimizzati rispetto alla velocità di accesso, e non rispetto allo spazio impiegato su disco, dato che all&amp;rsquo;area di swapping si accede molto più frequentemente che al file system. Il metodo delle raw partition assegna una dimensione fissa all&amp;rsquo;area di swapping al moemnto della creazione delle partizioni del disco, e l&amp;rsquo;aumento delle dimensioni dell&amp;rsquo;area deve quindi passare attraverso il ripartizionamento del disco, oppure attraverso la creazione di un&amp;rsquo;altra area di swapping in qualche altra unità a disco del sistema.&lt;/p>
&lt;!-- Infine, vi basti sapere che i calcolatori accedono alla memoria secondaria in tre modi: tramite un dispositivo collegato alla macchina, tramite un dispositivo connesso alla rete o tramite un dispositivo cloud, che però non andremo ad analizzare. -->
&lt;div id="references-1784797996375236258" class="references" style="margin-top: 5rem;">
&lt;p>&lt;strong>References&lt;/strong>&lt;/p>
A. Silberschatz, P. B. Galvin, G. Gagne, &lt;em>Sistemi Operativi. Concetti ed Esempi&lt;/em>, Decima edizione. Ch. 11.
&lt;/div>
&lt;script>
document.addEventListener('DOMContentLoaded', function () {
var ref = document.getElementById('references-1784797996375236258');
var fn = document.querySelector('.footnotes');
if (ref &amp;&amp; fn &amp;&amp; fn.parentNode) {
fn.parentNode.insertBefore(ref, fn.nextSibling);
}
});
&lt;/script></description></item><item><title>Sistemi I/O</title><link>https://aretor.it/courses/os2324/12_dispositivi_io/</link><pubDate>Wed, 15 May 2024 00:00:00 +0000</pubDate><guid>https://aretor.it/courses/os2324/12_dispositivi_io/</guid><description>&lt;p>I compiti principali di un calcolatore sono l&amp;rsquo;I/O e l&amp;rsquo;elaborazione. L&amp;rsquo;I/O è preponderante in attività come la navigazione in Internet o la modifica di un file. Il ruolo del s.o. nell&amp;rsquo;I/O è quello di gestire e controllare le sue operazioni di base. In questa sezione discutiamo dei principi di funzionamento dei dispositivi I/O.&lt;/p>
&lt;p>I progettisti dei s.o. si focalizzano molto sulla gestione dei dispositivi a causa della loro natura variegata e delle velocità dei dispositivi di I/O. Il sottosistema I/O gestisce questa complessità, separandola dal kernel. Nonostante l&amp;rsquo;aumento della standardizzazione nelle interfacce hardware e software, la proliferazione di dispositivi I/O diversificati presenta sfide di integrazione. Per affrontare questo problema, il kernel utilizza moduli di &lt;strong>driver di dispositivo&lt;/strong> per incapsulare i dettagli del dispositivo, garantendo un&amp;rsquo;interfaccia di accesso uniforme all&amp;rsquo;interno del sottosistema di I/O.&lt;/p>
&lt;h2 id="hardware-di-io">Hardware di I/O&lt;/h2>
&lt;p>I computer interagiscono con dispositivi diversi categorizzati in tipi di archiviazione, trasmissione e interfaccia uomo-macchina. Nonostante questa varietà, sono sufficienti pochi concetti per comprendere come siano connessi e come il s.o. li controlli. I dispositivi comunicano con i computer tramite segnali, sia cablati che wireless, attraverso dei punti di connessione, o &lt;strong>porte&lt;/strong>.&lt;/p>
&lt;p>Le connessioni fisiche possono formare bus, che sono un insieme di fili con un protocollo definito che specifica l’insieme dei messaggi che si possono inviare. I bus sono caratterizzati da vari metodi di segnalazione, velocità, larghezze di banda e metodi di connessione. Una struttura tipica del bus PC include un &lt;strong>bus PCIe&lt;/strong> che collega il sottosistema processore-memoria ai dispositivi veloci e un bus di espansione che collega dispositivi più lenti come tastiere e porte USB. PCIe trasmette dati su uno o più &amp;ldquo;corsie&amp;rdquo;, delle connessioni full-duplex per la trasmissione dei dati.&lt;/p>
&lt;p>Un &lt;strong>controller&lt;/strong> è una collezione di componenti elettronici che gestisce una porta, un bus o un dispositivo.&lt;/p>
&lt;h3 id="memory-mapped-io">Memory-Mapped I/O&lt;/h3>
&lt;p>Per facilitare i trasferimenti I/O, il processore comunica con un controller che contiene registri per dati e segnali di controllo. Questa comunicazione coinvolge tipicamente istruzioni speciali per I/O che attivano il trasferimento di byte o parole a specifici indirizzi di porta I/O. Queste istruzioni attivano linee di bus per selezionare il dispositivo appropriato e trasferire dati da o verso i registri del dispositivo. In alternativa, i dispositivi possono supportare l&amp;rsquo;I/O mappato in memoria, dove i registri di controllo del dispositivo sono nella memoria principale. Qui, la CPU esegue richieste di I/O utilizzando istruzioni standard di trasferimento dati per accedere a questi registri presso le loro posizioni mappate nella memoria fisica. Il controllo del dispositivo I/O di solito coinvolge quattro registri: dati-in e dati-out, stato, controllo.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Registro di dati-in:&lt;/strong> letto dall&amp;rsquo;host per ricevere input&lt;/li>
&lt;li>&lt;strong>Registro di dati-out:&lt;/strong> scritto dall&amp;rsquo;host per inviare output&lt;/li>
&lt;li>&lt;strong>Registro di stato:&lt;/strong> contiene bit che indicano stati come il completamento del comando, la disponibilità dei dati e gli errori del dispositivo&lt;/li>
&lt;li>&lt;strong>Registro di controllo:&lt;/strong> scritto dall&amp;rsquo;host per avviare comandi o modificare modalità del dispositivo, come tipo di comunicazione, controllo di parità, lunghezza della parola e selezione della velocità. I registri di dati sono solitamente di dimensioni comprese tra $1$ e $4$ byte. Alcuni controller incorporano chip FIFO, che estendono la capacità del controller memorizzando più byte di dati di input o output. Questi chip FIFO possono temporaneamente contenere una piccola raffica di dati fino a quando il dispositivo o l&amp;rsquo;host non è pronto per riceverli&lt;/li>
&lt;/ul>
&lt;h3 id="polling">Polling&lt;/h3>
&lt;p>Il protocollo di interazione tra un host e un controller può essere complesso, ma il concetto base è semplice. Ad esempio, consideriamo uno scenario in cui vengono utilizzati $2$ bit per gestire la relazione produttore-consumatore. Il controller comunica il suo stato tramite il bit di busy nello status register, impostandolo quando è occupato e cancellandolo quando è pronto per il prossimo comando. Al contrario, l&amp;rsquo;host esprime le sue intenzioni attraverso il bit di command-ready nel command register, impostandolo quando un comando è pronto per l&amp;rsquo;esecuzione. Questo consente una comunicazione coordinata tra l&amp;rsquo;host e il controller come segue:&lt;/p>
&lt;ol>
&lt;li>L&amp;rsquo;host controlla continuamente il bit di busy finché non diventa $0$&lt;/li>
&lt;li>L&amp;rsquo;host imposta il bit di scrittura nel command register e invia un byte al data-out register&lt;/li>
&lt;li>L&amp;rsquo;host imposta il bit di command-ready&lt;/li>
&lt;li>Dopo aver rilevato il bit di command-ready impostato, il controller imposta il bit di busy&lt;/li>
&lt;li>Il controller esamina il command register, identifica il comando di scrittura, recupera il byte dal data-out register e esegue l&amp;rsquo;operazione di I/O sul dispositivo.&lt;/li>
&lt;li>Successivamente, il controller cancella il bit di command-ready, reimposta il bit di errore nello status register per indicare un&amp;rsquo;operazione di I/O del dispositivo riuscita e cancella il bit di busy per indicare il completamento&lt;/li>
&lt;/ol>
&lt;p>L&amp;rsquo;host monitora costantemente lo status register finché il bit di busy non si cancella, un metodo noto come attesa attiva (busy-waiting) o polling.&lt;/p>
&lt;!-- Per controller e dispositivi veloci, questo approccio è ragionevole, ma per attese più lunghe, l'host potrebbe dover passare a compiti diversi. È cruciale per l'host discernere quando il controller è inattivo per evitare la perdita di dati, specialmente con dati in streaming. Tipicamente, il polling comporta tre cicli di istruzioni CPU, ma può diventare inefficiente se il dispositivo è raramente pronto per il servizio. In tali casi, l'uso di interruzioni, dove il controller hardware notifica alla CPU quando il dispositivo è pronto per il servizio, potrebbe offrire un'alternativa più efficiente. -->
&lt;h3 id="interrupt">Interrupt&lt;/h3>
&lt;p>L&amp;rsquo;hardware della CPU dispone di una &lt;strong>linea di richiesta di interrupt&lt;/strong> che si attiva dopo ogni istruzione. Quando viene rilevato un segnale di interrupt, la CPU salva il suo stato e salta a un indirizzo di memoria fisso per una routine di gestione degli interrupt. Questa routine identifica la causa dell&amp;rsquo;interrupt, la gestisce, ripristina lo stato della CPU e ritorna all&amp;rsquo;esecuzione normale.&lt;/p>
&lt;p>I controller dei dispositivi generano interrupt inviando un segnale sulla linea di interrupt, che la CPU gestisce tramite il gestore di interrupt. La gestione è cruciale nei sistemi moderni e il meccanismo di interrupt di base consente alle CPU di reagire a eventi asincroni, ma i moderni s.o. richiedono capacità di gestione più avanzate.&lt;/p>
&lt;!-- 1. È necessario poter posticipare la gestione delle interruzioni durante le fasi critiche dell’elaborazione
2. È necessario un meccanismo efficiente per passare il controllo al giusto gestore degli interrupt senza interrogare tutti i dispositivi
3. Sono richiesti interrupt multilivello per distinguere tra interrupt ad alta e bassa priorità e rispondere di conseguenza
4. Le istruzioni devono essere in grado di richiamare direttamente l'attenzione del s.o. per attività come i fault di pagina e gli errori, che vengono gestiti tramite trap -->
&lt;p>In un calcolatore moderno, le funzioni di interrupt sono gestite dalla CPU e dal controller hardware degli interrupt. Le CPU di solito dispongono di due linee di richiesta di interrupt: una &lt;strong>non mascherabile&lt;/strong> per eventi critici come gli errori di memoria, e una &lt;strong>mascherabile&lt;/strong> per le richieste dei dispositivi.&lt;/p>
&lt;p>Gli interrupt sono gestiti tramite un vettore di interruzioni, che contiene gli indirizzi dei gestori delle interruzioni specializzati. Quando esistono più dispositivi rispetto agli indirizzi del vettore, viene utilizzato il chaining delle interruzioni, dove ogni elemento del vettore punta a un elenco di gestori. Questa struttura bilancia il sovraccarico di una grande tabella di interruzioni e l&amp;rsquo;efficienza del dispatch a un singolo gestore.&lt;/p>
&lt;p>Il meccanismo di interrupt include livelli di priorità che consentono alla CPU di posticipare gli interrupt a bassa priorità senza bloccarle tutte, consentendo a quelli ad alta priorità di interrompere quelli a bassa priorità.&lt;/p>
&lt;p>Durante l&amp;rsquo;avvio, un s.o. identifica i dispositivi hardware e installa i relativi gestori di interrupt. I controller dei dispositivi generano interrupt durante le operazioni di I/O per vari eventi come l&amp;rsquo;output completato o l&amp;rsquo;input disponibile. Gli interrupt gestiscono anche eccezioni come errori di divisione per zero o accesso a memoria protetta.&lt;/p>
&lt;!-- Per gestire efficientemente la complessità interrupt, i sistemi spesso suddividono tra un gestore di interrupt di primo livello (FLIH) e un gestore di interrupt di secondo livello (SLIH). -->
&lt;!-- I s.o. utilizzano gli interrupt per compiti come il paging della memoria virtuale, dove un fault di pagina genera un interrupt, sospendendo il processo corrente ed eseguendo un gestore di fault di pagina. -->
&lt;p>Nei system call, gli interrupt software, o trap, facilitano la comunicazione tra le applicazioni utente e il kernel. Questi interrupt hanno una priorità inferiore rispetto agli interrupt dei dispositivi.Inoltre, gli interrupt gestiscono il flusso di controllo del kernel, dando priorità a compiti come il completamento delle letture dal disco.&lt;/p>
&lt;p>I gestori ad alta priorità gestiscono compiti critici, come l&amp;rsquo;avvio di operazioni di I/O in sospeso, mentre i gestori a bassa priorità gestiscono compiti meno urgenti, garantendo un utilizzo efficiente del disco. La gestione degli interrupt nei moderni sistemi operativi coinvolge il dispacciamento degli interrupt a bassa priorità quando la CPU è disponibile. Questi interrupt completano i compiti di I/O a livello utente prima di programmare le applicazioni per l&amp;rsquo;esecuzione.&lt;/p>
&lt;!-- Le architetture kernel threaded gestiscono efficientemente le priorità degli interrupt. Questa architettura consente di prelazionare i gestori di interrupt a bassa priorità per compiti di priorità più alta, migliorando la reattività del sistema. Gli interrupt sono fondamentali -->
&lt;h3 id="accesso-diretto-alla-memoria">Accesso diretto alla memoria&lt;/h3>
&lt;p>Per trasferimenti efficienti di grandi quantità di dati, come quelli dei dischi rigidi è possibile utilizzare i controller di accesso diretto alla memoria (Direct Memory Access, DMA) che gestiscono autonomamente i trasferimenti di dati, senza l&amp;rsquo;utilizzo della CPU. L&amp;rsquo;host inizia il DMA scrivendo un blocco di comandi specificando sorgente, destinazione e conteggio dei byte. La tecnica di scatter-gather consente trasferimenti complessi tramite un singolo comando.&lt;/p>
&lt;p>Gli attuali sistemi operativi impiegano la mappatura della memoria per trasferimenti I/O diretti tra dispositivi e spazio degli indirizzi utente, migliorando le prestazioni.&lt;/p>
&lt;p>La comunicazione tra il controller DMA e il controller del dispositivo avviene tramite i cavi DMA-request e DMA-acknowledge. Il controller del dispositivo segnala la disponibilità dei dati tramite DMA-request, inducendo il controller DMA ad accedere alla memoria. Una volta riconosciuto, i dati vengono trasferiti e i segnali vengono rimossi. Al termine, il controller DMA interrompe la CPU. Sebbene il &amp;ldquo;cycle stealing&amp;rdquo; possa rallentare brevemente il calcolo della CPU, il trasferimento dei dati generalmente migliora le prestazioni del sistema. Alcune architetture utilizzano l&amp;rsquo;accesso diretto alla memoria virtuale (Direct Virtual Memory Access, DVMA), bypassando l&amp;rsquo;intervento della CPU e l&amp;rsquo;uso della memoria principale. I kernel in modalità protetta impediscono i comandi diretti ai dispositivi per proteggere dati e stabilità del sistema.&lt;/p>
&lt;!-- Invece, il s.o. fornisce ai processi privilegiati funzioni per l'accesso all'hardware. Al contrario, i kernel privi di protezione della memoria consentono l'accesso diretto ai dispositivi per ottenere prestazioni potenzialmente più elevate ma comportano rischi per la sicurezza. -->
&lt;h2 id="interfaccia-di-applicazione-io">Interfaccia di applicazione I/O&lt;/h2>
&lt;p>In questa sezione discutiamo delle interfacce e delle schematizzazioni utilizzate dai s.o. per trattare i dispositivi I/O in maniera uniforme.&lt;/p>
&lt;p>L&amp;rsquo;idea generale nello sviluppo degli algoritmi di funzionamento dei dispositivi I/O è quella di comunicare col dispositivo attraverso un&amp;rsquo;interfaccia di funzioni standard e poi incapsulare le differenze in moduli kernel chiamati driver di dispositivo. Lo scopo dei driver è di nascondere le differenze tra i controller di dispositivo al sottosistema I/O. In altre parole, rendere il sottosistema I/O indipendente dall&amp;rsquo;hardware semplifica il lavoro degli sviluppatori del s.o.&lt;/p>
&lt;p>I produttori di dispositivi possono invece sviluppare un dispositivo per essere compatibile con un&amp;rsquo;interfaccia di controllore già presente (p.e. SATA) oppure possono scrivere driver di dispositivo per interfacciarsi con i s.o. più popolari. Sfortunatamente ogni s.o. ha i propri standard per i driver di interfaccia.&lt;/p>
&lt;p>Alcune scelte nel protocollo di comunicazione dei dispositivi:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Trasferimento a flusso di caratteri o a blocchi:&lt;/strong> un dispositivo può trasferire i byte uno ad uno oppure a gruppi&lt;/li>
&lt;li>&lt;strong>Accesso sequenziale o diretto:&lt;/strong> un dispositivo può trasferire i dati seguendo un ordine fisso oppure puntando a posizioni non necessariamente contigue&lt;/li>
&lt;li>&lt;strong>Sincrono o asincrono:&lt;/strong> un dispositivo può trasferire i dati in accordo ad altri aspetti del sistema oppure con tempi di risposta variabili, non coordinati agli eventi del s.o.&lt;/li>
&lt;li>&lt;strong>Condiviso o dedicato:&lt;/strong> un dispositivo può essere utilizzato da molti processi contemporaneamente oppure da un solo processo alla volta&lt;/li>
&lt;li>&lt;strong>Velocità di esecuzione:&lt;/strong> i dispositivi hanno velocità che spaziano da pochi byte al secondo a diversi gigabyte al secondo&lt;/li>
&lt;li>&lt;strong>Read-write, read only o write once:&lt;/strong> alcuni dispositivi supportano la scrittura e la lettura, mentre altri solo una delle due operazioni. Inoltre in alcuni dispositivi la scrittura può essere effettuata solo una volta e il dato diventa read only, dopo.&lt;/li>
&lt;/ul>
&lt;p>Molti s.o. hanno un meccanismo di &lt;strong>escape&lt;/strong> (o &lt;strong>backdoor&lt;/strong>) che permette alle applicazioni di passare comandi arbitrari direttamente ai driver.&lt;/p>
&lt;h3 id="dispositivi-con-trasferimento-a-blocchi-e-a-caratteri">Dispositivi con trasferimento a blocchi e a caratteri&lt;/h3>
&lt;p>L&amp;rsquo;&lt;strong>interfaccia per dispositivi a blocchi (block-device interface)&lt;/strong> è pensata per dispositivi che gestiscono il trasferimento dei dati a unità chiamate blocchi. Il s.o. preferisce a volte accedere al dispositivo considerandolo come un semplice array di blocchi. Questo modo di accesso è chiamato &lt;strong>raw I/O&lt;/strong>. È possibile che un&amp;rsquo;applicazione che utilizza il dispositivo si occupi direttamente di gestire il buffering e il locking di regioni condivise. In questo caso, i s.o. moderni possono passare a una modalità in cui tali funzionalità sono disabilitate per il processo. Nel mondo UNIX, questa modalità è chiamata &lt;strong>direct I/O&lt;/strong>. Un esempio di dispositivo a blocchi è il file.&lt;/p>
&lt;p>La tastiera, il mouse o il modem sono un esempi di &lt;strong>interfaccia per dispositivi a caratteri (character-device interface)&lt;/strong>. Le chiamate di sistema leggono o inseriscono un carattere alla volta.&lt;/p>
&lt;h3 id="i-dispositivi-di-rete">I dispositivi di rete&lt;/h3>
&lt;p>Nel caso di dispositivi di rete utilizziamo delle interfacce chiamate &lt;strong>socket (prese)&lt;/strong>. Il meccanismo è simile a quello di una presa elettrica. L&amp;rsquo;applicazione genera una socket che permette a un indirizzo remoto di connettersi. A sua volta l&amp;rsquo;applicazione si connette alla socket generata dall&amp;rsquo;applicazione in remoto.&lt;/p>
&lt;h3 id="clock-e-timer">Clock e timer&lt;/h3>
&lt;p>I clock e i timer forniscono le seguenti tre funzioni:&lt;/p>
&lt;ul>
&lt;li>Danno il tempo corrente&lt;/li>
&lt;li>Danno il tempo rimasto&lt;/li>
&lt;li>Impostano delle deadline per emettere un evento in un istante di tempo prefissato&lt;/li>
&lt;/ul>
&lt;p>L&amp;rsquo;hardware preposto si chiama &lt;strong>timer di intervallo programmabile&lt;/strong>. Viene impostato a un determinato istante di tempo per generare un interrupt. Si può impostare per emettere l&amp;rsquo;evento una volta o periodicamente. Un clock/timer può essere soggetto a deviazioni (drift) dal tempo che deve tracciare. Tali deviazioni possono essere corrette attraverso l&amp;rsquo;utilizzo di protocolli specifici come &lt;strong>Network Time Protocol (NTP)&lt;/strong> che mantiene la precisione di clock di sistema a livelli paragonabili a quelli degli orologi atomici.&lt;/p>
&lt;h3 id="io-asincrono-e-non-bloccante">I/O asincrono e non bloccante&lt;/h3>
&lt;p>Possiamo dividere le chiamate a sistema in tre categorie:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>I/O bloccante:&lt;/strong> una volta effettuata la chiamata, il thread chiamante attende la sua esecuzione e riceve il risultato dell&amp;rsquo;operazione di I/O. Spesso le chiamate I/O non sono bloccanti, poiché i dispositivi I/O hanno tempi imprevedibili, tuttavia il s.o. fornisce anche la possibilità di avere chiamate bloccanti poiché sono più semplici da gestire&lt;/li>
&lt;li>&lt;strong>I/O non bloccante:&lt;/strong> una volta effettuata la chiamata, questa ritorna velocemente. Il risultato è il numero di byte che sono stati trasferiti (tutti, una parte o nessuno). Un esempio di processo che richiede I/O non bloccante è un&amp;rsquo;interfaccia utente che processa indipendentemente l&amp;rsquo;input da mouse e da tastiera mentre visualizza gli output sul monitor&lt;/li>
&lt;li>&lt;strong>I/O asincrono:&lt;/strong> una volta effettuata la chiamata, questa ritorna immediatamente, senza il risultato, che viene computato completamente ma in futuro&lt;/li>
&lt;/ul>
&lt;h3 id="io-vettorizzato">I/O vettorizzato&lt;/h3>
&lt;p>Alcuni s.o. permettono di effettuare molteplici operazioni I/O attraverso un&amp;rsquo;unica chiamata a sistema.&lt;/p>
&lt;h2 id="sottosistema-di-io-del-kernel">Sottosistema di I/O del kernel&lt;/h2>
&lt;p>I kernel forniscono vari servizi relativi all&amp;rsquo;I/O, raggruppati nel sottosistema I/O.&lt;/p>
&lt;h3 id="scheduling-io">Scheduling I/O&lt;/h3>
&lt;p>Per programmare un insieme di richieste I/O è importante stabilire un ordine di esecuzione che massimizzi le performance del dispositivo. Gli sviluppatori implementano gli scheduler I/O utilizzando una coda d&amp;rsquo;attesa che può essere riordinata all&amp;rsquo;occorrenza, sulla base delle performance ma anche mantenendo un&amp;rsquo;equità d&amp;rsquo;utilizzo da parte delle applicazioni sulla base delle priorità. Vediamo ora alcuni aspetti dello scheduling I/O.&lt;/p>
&lt;h3 id="buffering">Buffering&lt;/h3>
&lt;p>Un &lt;strong>buffer&lt;/strong> è un&amp;rsquo;area di memoria che salva i dati in trasferimento tra due dispositivi e o tra un dispositivo e un&amp;rsquo;applicazione. Il buffering viene effettuato per tre ragioni:&lt;/p>
&lt;ul>
&lt;li>Gestire lo squilibrio tra la velocità di produzione e di consumo dei dati. Per esempio, quando è necessario scrivere in una memoria secondaria un messaggio proveniente da Internet, è possibile programmare due buffer nella memoria principale. La rete inizia a trasmettere dati in un buffer. Quando questo è pieno la rete scrive sul secondo buffer, mentre il primo viene svuotato in memoria secondaria, e così via, finché il messaggio completo non viene salvato. Questo metodo è chiamato &lt;strong>double buffering&lt;/strong>&lt;/li>
&lt;li>Gestire lo squilibrio tra le dimensioni delle unità di trasferimento. Per esempio, in una rete, un messaggio da inviare viene frammentato in pacchetti piccoli che vengono ricevuti in buffer di destinazione per poi essere riassemblati&lt;/li>
&lt;li>Gestire la semantica di copia per applicazioni I/O. Nel caso un&amp;rsquo;applicazione voglia scrivere nella memoria secondaria un buffer di dati prodotti, deve assicurarsi che il buffer non cambi durante la copia. Per questo motivo, è possibile creare un buffer kernel che operi la copia mentre il buffer dell&amp;rsquo;applicazione continua a produrre dati.&lt;/li>
&lt;/ul>
&lt;h3 id="caching">Caching&lt;/h3>
&lt;p>Una &lt;strong>cache&lt;/strong> è una regione di memoria che mantiene copie di dati e garantisce un accesso più efficiente della memoria normale. La differenza con il concetto di buffer è che la cache mantiene &lt;em>una copia&lt;/em> per definizione, mentre un buffer può contenere dati originali.&lt;/p>
&lt;p>A volte una regione di memoria può essere utilizzata sia per buffering che per caching. Per esempio, il s.o. utilizza dei buffer per mantenere nella memoria principale alcune informazioni presenti in memoria secondaria utili a un processo. Se un altro processo vuole accedere agli stessi dati in sola lettura il s.o. può utilizzare il buffer in memoria come una cache e condividere i dati, minimizzando così lo spazio consumato in memoria.&lt;/p>
&lt;h3 id="spooling">Spooling&lt;/h3>
&lt;p>Uno &lt;strong>spool&lt;/strong> è un buffer che mantiene l&amp;rsquo;input inviato a un dispositivo in attesa che venga processato. Per esempio, è possibile inviare più input contemporaneamente a una stampante. In questo caso il primo input viene processato e gli altri vengono salvati in file temporanei. Il sistema di spooling si occupa di gestire la coda degli input.&lt;/p>
&lt;h3 id="gestione-degli-errori">Gestione degli errori&lt;/h3>
&lt;p>Un s.o. che utiliza meccanismi di protezione della memoria si può difendere contro molto tipi di errori hardware e d&amp;rsquo;applicazione evitando un generale malfunzionamento di sistema. In generale, un s.o. ritornerà un bit di informazione che indica lo stato della chiamata (successo o fallimento). In UNIX, viene anche ritornato un intero indicante il tipo dell&amp;rsquo;errore.&lt;/p>
&lt;h3 id="protezione-io">Protezione I/O&lt;/h3>
&lt;p>Un utente potrebbe accidentalmente o volontariamente alterare la normale operatività di un sistema chiedendo di eseguire istruzioni I/O illegali. Per tale motivo tutte le operazioni I/O sono considerate privilegiate e l&amp;rsquo;utente non le può avviare direttamente, ma deve invece chiedere al s.o. di eseguirle. Il s.o., in modalità monitor, controlla che la richiesta sia valida ed esegue l&amp;rsquo;I/O richiesta.&lt;/p>
&lt;div id="references-1784797996403614401" class="references" style="margin-top: 5rem;">
&lt;p>&lt;strong>References&lt;/strong>&lt;/p>
A. Silberschatz, P. B. Galvin, G. Gagne, &lt;em>Sistemi Operativi. Concetti ed Esempi&lt;/em>, Decima edizione. Ch. 12.
&lt;/div>
&lt;script>
document.addEventListener('DOMContentLoaded', function () {
var ref = document.getElementById('references-1784797996403614401');
var fn = document.querySelector('.footnotes');
if (ref &amp;&amp; fn &amp;&amp; fn.parentNode) {
fn.parentNode.insertBefore(ref, fn.nextSibling);
}
});
&lt;/script></description></item><item><title>10 Interfaccia del file system</title><link>https://aretor.it/courses/os2425/lessons/10_file_system/</link><pubDate>Tue, 15 Apr 2025 00:00:00 +0000</pubDate><guid>https://aretor.it/courses/os2425/lessons/10_file_system/</guid><description>&lt;p>Il file system è essenziale nei s.o. per l&amp;rsquo;archiviazione e l&amp;rsquo;accesso a dati e programmi. È composto da file che memorizzano informazioni e da una struttura logica a directory che organizza i file. La maggior parte dei file system risiede su dispositivi di archiviazione. In questa lezione discuteremo degli aspetti dei file, delle strutture dei file system, della condivisione dei file tra processi e utenti, e delle modalità di protezione dei file.&lt;/p>
&lt;h2 id="101-aspetti-base-dei-file">10.1 Aspetti base dei file&lt;/h2>
&lt;p>Il &lt;strong>file&lt;/strong> è un&amp;rsquo;unità di memorizzazione logica delle informazioni che fornisce un&amp;rsquo;astrazione delle caratteristiche fisiche di archiviazione dei dati.&lt;/p>
&lt;h3 id="1011-concetto-di-file">10.1.1 Concetto di file&lt;/h3>
&lt;p>I computer archiviano dati su diversi supporti, astratti dal s.o. per una visione uniforme. Dal punto di vista dell&amp;rsquo;utente, un file è la più piccola porzione di archiviazione logica secondaria. I dati non possono essere scritti su archiviazione secondaria a meno che non siano contenuti in un file. I &lt;strong>file system&lt;/strong> gestiscono le informazioni sui file e permettono il loro accesso. Le strutture dei file dipendono dal loro tipo e ogni tipo di file ha una struttura distinta, organizzata secondo criteri di usabilità ed efficienza del dato. I file sono quindi il metodo principale per gli utenti e le applicazioni per memorizzare e accedere ai dati. Il concetto di file è ampio, e comprende sequenze di bit, byte, linee o record (elementi logici). Tra i vari tipi di file, abbiamo:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>file di testo:&lt;/strong> sequenza di caratteri organizzati in linee (e possibilmente pagine).&lt;/li>
&lt;li>&lt;strong>file sorgente:&lt;/strong> sequenza di funzioni, ognuna delle quali è ulteriormente organizzata come dichiarazioni seguite da istruzioni eseguibili.&lt;/li>
&lt;li>&lt;strong>file eseguibile:&lt;/strong> sequenza di sezioni di codice che il loader può caricare in memoria ed eseguire.&lt;/li>
&lt;/ul>
&lt;h3 id="1012-attributi-dei-file">10.1.2 Attributi dei file&lt;/h3>
&lt;p>I file sono denominati, tipicamente per comodità, con stringhe come &lt;code>example.c&lt;/code>. La denominazione rende i file indipendenti dagli utenti e dai sistemi, consentendo una facile condivisione e manipolazione. I s.o. possono o meno differenziare tra caratteri maiuscoli e minuscoli nei nomi. Con i trasferimenti o le modifiche interne, i file mantengono i loro nomi, per garantire coerenza nel loro accesso e nella loro gestione. Alcune informazioni che caratterizzano i file:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Nome:&lt;/strong> il nome simbolico del file è l&amp;rsquo;unica informazione conservata in forma user-readable.&lt;/li>
&lt;li>&lt;strong>Identificatore:&lt;/strong> tag unico, di solito un numero, che identifica il file all&amp;rsquo;interno del file system. È il nome non-user-readable del il file.&lt;/li>
&lt;li>&lt;strong>Tipo:&lt;/strong> necessario per i sistemi che supportano diversi tipi di file.&lt;/li>
&lt;li>&lt;strong>Locazione:&lt;/strong> un puntatore a un dispositivo e alla posizione del file su tale dispositivo.&lt;/li>
&lt;li>&lt;strong>Dimensione:&lt;/strong> la dimensione attuale del file (in byte, parole o blocchi) e eventualmente la dimensione massima consentita sono incluse in questo attributo.&lt;/li>
&lt;li>&lt;strong>Protezione:&lt;/strong> le informazioni sul controllo degli accessi determinano chi può fare lettura, scrittura, esecuzione, e così via.&lt;/li>
&lt;li>&lt;strong>Timestamp e identificazione dell&amp;rsquo;utente:&lt;/strong> queste informazioni possono essere conservate per la creazione, l&amp;rsquo;ultima modifica e l&amp;rsquo;ultimo utilizzo. Possono essere inoltre utili per la protezione, la sicurezza e il monitoraggio dell&amp;rsquo;uso.&lt;/li>
&lt;/ul>
&lt;p>I nuovi file system offrono &lt;strong>attributi estesi&lt;/strong> come la codifica dei caratteri e i checksum dei file. Le informazioni sui file, compresi gli attributi, sono memorizzate nelle directory sullo stesso dispositivo. Le voci di directory includono tipicamente il nome del file e un identificatore unico, con la dimensione della directory.&lt;/p>
&lt;h3 id="1013-operazioni-sui-file">10.1.3 Operazioni sui file&lt;/h3>
&lt;p>Un file è un &lt;strong>tipo di dato astratto&lt;/strong> con operazioni definite come creare, scrivere, leggere, etc. I sistemi operativi offrono chiamate di sistema per eseguire queste operazioni. Comprendere queste operazioni di base facilita l&amp;rsquo;implementazione di funzioni simili come rinominare i file.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Creare un file:&lt;/strong> consiste nel trovare spazio nel file system e fare un&amp;rsquo;entrata in una directory.&lt;/li>
&lt;li>&lt;strong>Aprire un file:&lt;/strong> varie operazioni (tranne creare e eliminare) richiedono una chiamata &lt;code>open()&lt;/code> di file, che restituisce un handle di file per il suo utilizzo.&lt;/li>
&lt;li>&lt;strong>Scrivere un file:&lt;/strong> richiede di specificare un handle di file aperto e i dati da scrivere, aggiornando un puntatore di scrittura sequenziale.&lt;/li>
&lt;li>&lt;strong>Leggere un file:&lt;/strong> richiede di specificare l&amp;rsquo;handle del file e la locazione di memoria per il blocco successivo, aggiornando un puntatore di lettura per letture sequenziali.&lt;/li>
&lt;li>&lt;strong>Rilocamento all&amp;rsquo;interno di un file&lt;/strong> consiste nel riposizionamento del puntatore di locazione corrente del file, senza eseguire operazioni I/O. Questa operazione è nota anche come &lt;strong>file seek&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Eliminare un file:&lt;/strong> coinvolge la ricerca della directory per il file, il rilascio del suo spazio e la cancellazione o marcatura della sua voce di directory.&lt;/li>
&lt;li>&lt;strong>Troncare un file:&lt;/strong> consente di cancellare i contenuti del file mantenendo gli attributi reimpostando la lunghezza del file a zero e rilasciando il suo spazio.&lt;/li>
&lt;/ul>
&lt;p>Le operazioni di base sui file includono creazione, lettura, scrittura, riposizionamento, eliminazione e troncamento dei file, coinvolgendo spesso ricerche nelle directory. I sistemi richiedono spesso una chiamata &lt;code>open()&lt;/code> prima dell&amp;rsquo;uso del file per evitare costanti ricerche nelle directory. I file possono essere aperti implicitamente o esplicitamente con la chiamata di sistema &lt;code>open()&lt;/code>, specificando modalità di accesso come sola lettura o lettura-scrittura. Ogni processo mantiene una tabella per-processo per tracciare i file aperti (&lt;strong>tabella dei file aperti&lt;/strong>), con tabelle a livello di sistema contenenti informazioni sulla posizione e sui metadati dei file. Le operazioni &lt;code>open()&lt;/code> e &lt;code>close()&lt;/code> sono più complesse negli ambienti multi-processo, coinvolgendo tabelle per-processo e a livello di sistema per tracciare l&amp;rsquo;uso dei file e mantenere la coerenza.&lt;/p>
&lt;p>Le operazioni di creazione, lettura, scrittura, eliminazione costituiscono la base dei sistemi di gestione dei file. Tali operazioni servono per l&amp;rsquo;esecuzione di compiti più complessi come la copia o la rinomina dei file. Attributi come la lunghezza e la proprietà di un file possono essere direttamente accessibili e modificati attraverso operazioni specifiche. In molti sistemi, è richiesta una chiamata &lt;code>open()&lt;/code> esplicita prima di utilizzare un file per ottimizzare le prestazioni del sistema ed eliminare ricerche ridondanti nelle directory. All&amp;rsquo;apertura, le informazioni sul file sono memorizzate in una tabella a livello di sistema, mentre ogni processo mantiene la propria tabella per tracciare i file aperti. Negli ambienti con processi multipli, la gestione dell&amp;rsquo;accesso ai file diventa più intricata, richiedendo una sincronizzazione accurata tra processi e tabelle a livello di sistema. Questo coordinamento garantisce coerenza e previene conflitti tra gli accessi simultanei ai file. Inoltre, alcuni sistemi aprono automaticamente i file al loro primo riferimento e li chiudono al termine del programma per semplificare la gestione dei file per i programmatori. Nonostante le variazioni nell&amp;rsquo;implementazione, una gestione efficiente dei file è essenziale per garantire l&amp;rsquo;integrità dei dati e l&amp;rsquo;affidabilità del sistema.&lt;/p>
&lt;h3 id="1014-tipi-di-file">10.1.4 Tipi di file&lt;/h3>
&lt;p>I tipi di file sono un aspetto cruciale per il corretto funzionamento di un s.o., dal momento che i s.o. riconoscono solo i tipi di file per i quali sono programmati. I tipi sono spesso indicati da estensioni nei nomi dei file, che aiutano nel loro riconoscimento. Estensioni come &lt;code>.exe&lt;/code> o &lt;code>.sh&lt;/code> indicano file che contengono eseguibili. I programmi ricorrono alle estensioni per il riconoscimento dei tipi di file supportati, come &lt;code>.java&lt;/code> per i file sorgente Java o &lt;code>.docx&lt;/code> per i documenti di Microsoft Word.&lt;/p>
&lt;p>UNIX utilizza un semplice codice noto come &lt;strong>magic number&lt;/strong> all&amp;rsquo;inizio dei file per indicare il tipo di dati. Le estensioni dei nomi dei file sono utili all&amp;rsquo;utente e non al s.o., fornendo dunque suggerimenti sui contenuti dei file piuttosto che regole per il loro utilizzo.&lt;/p>
&lt;h3 id="1015-struttura-dei-file">10.1.5 Struttura dei file&lt;/h3>
&lt;p>Il tipo di un file può anche indicare l&amp;rsquo;organizzazione dei suoi dati, ovvero la struttura prevista dal programma che legge il file. Inoltre i s.o. stessi possono richiedere che i file aderiscano ad alcune strutture per la loro corretta elaborazione. Il supporto a più strutture di file può rendere il codice del s.o. più ingombrante e complesso. D&amp;rsquo;altro canto, quando un file viene codificato in una struttura non supportata dal s.o., si possono verificare problemi. Per esempio, supponiamo che un s.o. supporti file di testo ed eseguibili, e che vogliamo implementare un tipo di file crittografato. Questo tipo non è interpretabile dal s.o. ed è quindi necessario istruirlo a non utilizzare il meccanismo di riconoscimento tipi oppure abbandonare l&amp;rsquo;idea di implementare un meccanismo crittografico. Per risolvere il problema, alcuni s.o. come UNIX e Windows impongono requisiti minimi per le strutture di file, interpretandoli come sequenze di 8 bit e supportando solo la struttura di file eseguibile per le funzionalità di base del sistema. È possibile così definire altre strutture di file e sarà compito delle applicazioni fornire il codice per interpretarle.&lt;/p>
&lt;h3 id="1016-struttura-interna-dei-file">10.1.6 Struttura interna dei file&lt;/h3>
&lt;p>Per l&amp;rsquo;indirizzamento a una specifica locazione in un file, il s.o. utilizza spesso un approccio basato su blocchi. L&amp;rsquo;I/O della memoria di massa lavora con blocchi di dimensioni fisse, tipicamente non sono allineati con i record logici. Per risolvere il problema, più record logici vengono compattati in blocchi fisici. Ciò garantisce efficienza nell&amp;rsquo;archiviazione e nel recupero dei dati. Così facendo, si verifica &lt;strong>frammentazione interna&lt;/strong> a livello di blocco, poiché le dimensioni dei file spesso non sono multipli della dimensione del blocco. I diversi file system gestiscono la frammentazione interna in modo diverso, con dimensioni di blocco più grandi che portano a una maggiore frammentazione.&lt;/p>
&lt;h2 id="102-metodi-daccesso">10.2 Metodi d’accesso&lt;/h2>
&lt;p>I file memorizzano dati, accessibili e leggibili in memoria attraverso vari metodi. I s.o. offrono diversi metodi di accesso ai dati, ponendo una sfida progettuale nella scelta del metodo più adatto per applicazioni specifiche. Di seguito, vediamo due esempi di accesso.&lt;/p>
&lt;h3 id="1021-accesso-sequenziale">10.2.1 Accesso sequenziale&lt;/h3>
&lt;p>Con l&amp;rsquo;&lt;strong>accesso sequenziale&lt;/strong> i dati vengono letti e scritti in ordine, record per record, ed è comunemente utilizzato da editor e compilatori. Le operazioni di lettura fanno avanzare un puntatore di file e leggono la porzione successiva di dati, mentre le operazioni di scrittura aggiungono dati in coda al file e modificano il puntatore alla fine. Un file può essere reimpostato all&amp;rsquo;inizio, e alcuni sistemi consentono di saltare avanti o indietro di un numero definito di record. Questo metodo di accesso funziona universalmente su dispositivi sequenziali e ad accesso casuale.&lt;/p>
&lt;h3 id="1022-accesso-diretto">10.2.2 Accesso diretto&lt;/h3>
&lt;p>Con l&amp;rsquo;&lt;strong>accesso diretto&lt;/strong> un file viene diviso in &lt;strong>record logici&lt;/strong> di dimensione fissa e i programmi possono leggere o scrivere in un record. Questo metodo è fondamentale per database e grandi set di dati. Le operazioni sui file includono &lt;code>read(n)&lt;/code> e &lt;code>write(n)&lt;/code> laddove &lt;code>n&lt;/code> è il numero di blocco. I numeri di blocco sono codificati in maniera relativa rispetto all&amp;rsquo;inizio del file e vengono poi tradotti in indirizzi fisici. Assumendo una lunghezza del record di $L$ byte La richiesta per un record $N$ si traduce in richieste di I/O per $L$ byte in posizione $L \cdot N$. Alcuni s.o. supportano solo un tipo di accesso ai file.&lt;/p>
&lt;h2 id="103-struttura-delle-directory">10.3 Struttura delle directory&lt;/h2>
&lt;p>Le directory fungono da tabelle di simboli, che traducono i nomi dei file nei loro control block (analoghi ai process control block) e offrono vari schemi organizzativi per facilitare l&amp;rsquo;inserimento, la cancellazione, la ricerca e le operazioni di elenco. Quando si progetta un sistema di directory, è cruciale allineare la struttura con le operazioni previste:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Ricerca di un file:&lt;/strong> la ricerca dev&amp;rsquo;essere efficiente di file specifici, potenzialmente includendo il pattern matching con sottostringhe che compongono i nomi di file simili.&lt;/li>
&lt;li>&lt;strong>Creazione di un file:&lt;/strong> è essenziale poter creare nuovi file e incorporarli nella struttura delle directory.&lt;/li>
&lt;li>&lt;strong>Eliminazione di un file:&lt;/strong> i file dovrebbero essere eliminabili dalla directory quando non sono più necessari. Il file system dovrebbe disporre di metodi di deframmentazione della directory.&lt;/li>
&lt;li>&lt;strong>Elencamento di una directory:&lt;/strong> è necessario elencare tutti i file all&amp;rsquo;interno di una directory e la directory a cui un file è associato.&lt;/li>
&lt;li>&lt;strong>Rinominazione di un file:&lt;/strong> i nomi dei file dovrebbero poter essere modificabili per riflettere le modifiche nei contenuti o nell&amp;rsquo;uso del file. La rinominazione può anche permettere il cambiamento della sua posizione all&amp;rsquo;interno della directory.&lt;/li>
&lt;li>&lt;strong>Attraversamento del file system:&lt;/strong> accedere a tutte le directory e ai file al loro interno può essere necessario per scopi di backup, spesso raggiunto copiando i dati su supporti di archiviazione secondari o sul cloud per proteggersi da guasti del sistema.&lt;/li>
&lt;/ul>
&lt;h3 id="1031-directory-a-un-livello">10.3.1 Directory a un livello&lt;/h3>
&lt;p>In una struttura delle directory a un livello tutti i file vengono memorizzati in una sola directory, semplificando la gestione ma ponendo limitazioni quando il numero dei file aumenta o quando ci sono più utenti. Ogni file deve avere un nome univoco. Anche per un singolo utente, gestire molti file su una singola directory può risultare complicato, evidenziando la necessità di strutture di directory più complesse.&lt;/p>
&lt;h3 id="1032-directory-a-due-livelli">10.3.2 Directory a due livelli&lt;/h3>
&lt;p>Nella struttura delle directory a due livelli, ogni utente ha una &lt;strong>directory dei file utente (User File Directory, UFD)&lt;/strong>, mentre la &lt;strong>directory dei file principale (Master File Directory, MFD)&lt;/strong> indicizza le UFD degli utenti. Gli utenti possono avere file con nomi identici all&amp;rsquo;interno delle loro UFD. Un programma di sistema separato viene utilizzato per creare ed eliminare le UFD, di solito eseguibile dagli amministratori. Sebbene risolva i problemi di collisione dei nomi, isola gli utenti e complica l&amp;rsquo;accesso ai file tra di loro. Per accedere ai file in un&amp;rsquo;altra directory dell&amp;rsquo;utente, si deve specificare sia il nome utente che il nome del file.&lt;/p>
&lt;h3 id="1033-directory-con-struttura-ad-albero">10.3.3 Directory con struttura ad albero&lt;/h3>
&lt;p>Estendere la struttura delle directory a un albero di altezza arbitraria consente agli utenti di creare sotto-directory, organizzando i file in modo più efficiente. Ogni file nel sistema ha un nome del percorso univoco all&amp;rsquo;interno di questa struttura ad albero. Le directory (o le sotto-directory) contengono insiemi di file (o altre sotto-directory), ed è presente una &lt;strong>directory radice (root directory)&lt;/strong> in cima. In molte implementazioni, i file e le directory vengono distinti attraverso l&amp;rsquo;utilizzo di un bit e il s.o. implementa il formato di file &amp;ldquo;directory&amp;rdquo; che viene gestito attraverso chiamate di sistema apposite. Di solito, ogni processo ha la propria directory corrente, contenente file rilevanti, ma è possibile usare una chiamata di sistema per cambiare directory. La directory iniziale di un utente viene impostata all&amp;rsquo;accesso. I sottoprocessi ereditano di solito la directory corrente del genitore.&lt;/p>
&lt;p>I nomi dei percorsi possono essere &lt;strong>assoluti&lt;/strong>, a partire dalla radice, o &lt;strong>relativi&lt;/strong>, originati a partire dalla directory corrente. In UNIX e Linux, i percorsi assoluti iniziano con &lt;code>/&lt;/code>, mentre i percorsi relativi si basano sulla directory corrente.&lt;/p>
&lt;p>Consentire agli utenti di creare sotto-directory permette un organizzazione logica dei propri file, ad esempio per argomento o tipo di file. La cancellazione delle directory viene implementata seguendo una tra due politiche principali. Alcuni s.o. permettono di eliminare solamente directory vuote, mentre altri offrono un&amp;rsquo;opzione per eliminare le directory insieme ai loro contenuti. Anche se la seconda politica è più conveniente, essa comporta rischi di eliminazione accidentale. Nelle directory strutturate ad albero, gli utenti possono accedere ai file di altri utenti utilizzando nomi di percorso assoluti o relativi, o cambiando la loro directory corrente. Questa flessibilità consente una gestione efficiente dei file e la collaborazione tra gli utenti.&lt;/p>
&lt;h3 id="1034-directory-con-grafo-aciclico">10.3.4 Directory con grafo aciclico&lt;/h3>
&lt;p>Nei progetti collaborativi, le directory condivise facilitano il lavoro congiunto consentendo a più utenti di accedere e modificare i file contemporaneamente. A differenza delle copie, le directory condivise contengono una singola istanza di ciascun file, garantendo che le modifiche apportate da un utente siano immediatamente visibili agli altri. La capacità di condividere sotto-directory assicura un accesso coerente ai nuovi file in tutte le posizioni condivise, semplificando la collaborazione.&lt;/p>
&lt;p>La struttura a grafo aciclico, a differenza delle strutture ad albero, consente directory condivise, permettendo a file o sotto-directory di essere associati a più posizioni all&amp;rsquo;interno del file system. Questo meccanismo di condivisione migliora l&amp;rsquo;efficienza e il coordinamento nei progetti collaborativi. L&amp;rsquo;implementazione di file (o sotto-directory) condivisi può essere realizzata tramite:&lt;/p>
&lt;ul>
&lt;li>La creazione di &lt;strong>link&lt;/strong>, ovvero puntatori al file&lt;/li>
&lt;li>La duplicazione del file. È necessario mantenere l&amp;rsquo;integrità dei dati tra le due copie&lt;/li>
&lt;/ul>
&lt;p>L&amp;rsquo;eliminazione di file condivisi in strutture a grafi aciclici è complessa e presenta tre problemi:&lt;/p>
&lt;ul>
&lt;li>Quando un link viene eliminato bisogna decidere se rimuovere il file a esso associato&lt;/li>
&lt;li>Quando un file viene eliminato gli eventuali link relativi a esso diventano &lt;strong>pendenti (dangling)&lt;/strong> e puntano a una posizione vuota&lt;/li>
&lt;li>Se dei nuovi dati vanno a occupare una posizione puntata da link pendenti, tali link punteranno a dei dati non corretti.&lt;/li>
&lt;/ul>
&lt;p>Per risolvere questi problemi, esistono due approcci:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>Rimuovere il link senza eliminare il file a esso associato. Se il file stesso viene eliminato, possiamo:&lt;/p>
&lt;ul>
&lt;li>Cercare tutti i suoi link ed eliminarli. Questo approccio richiede di mantenere una struttura che elenchi i link al file&lt;/li>
&lt;li>Non eliminare i link. Nel momento in cui uno di questi link viene usato, si controlla che il file a cui puntano sia quello corretto. In caso contrario, viene lanciato un errore di accesso illegale a file.&lt;/li>
&lt;/ul>
&lt;p>In UNIX e Windows, i link vengono lasciati e sta all&amp;rsquo;utente controllare se il file originale è stato eliminato o sostituito.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>Mantenere il file finché tutti i suoi riferimenti non vengono cancellati. È possibile associare al file un contatore ai suoi riferimenti. Quando il contatore scende a $0$, il file può venire eliminato. Un garbage collector entra in funzione a intervalli periodici e controlla tutti i file, eliminandoli in caso il contatore sia a $0$. UNIX utilizza questo approccio con link non-simbolici (hard).&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>Infine, alcuni sistemi optano per vietare del tutto le directory o i collegamenti condivisi.&lt;/p>
&lt;h3 id="1035-directory-con-grafo-generale">10.3.5 Directory con grafo generale&lt;/h3>
&lt;p>Con una struttura a grafo aciclico, prevenire i cicli diventa fondamentale per mantenere l&amp;rsquo;integrità del file system. Sebbene inizialmente si parta con una struttura ad albero, l&amp;rsquo;introduzione di collegamenti può rompere la gerarchia, portando a una struttura a grafo generica. Gli algoritmi di attraversamento devono garantire una ricerca efficiente senza ripassare sezioni condivise per evitare blocchi delle prestazioni o cicli infiniti causati.&lt;/p>
&lt;p>Determinare quando un file può essere rimosso in modo sicuro diventa difficile. In presenza di cicli, infatti, la reference count di un file potrebbe non scendere mai a $0$ anche quando il &amp;ldquo;ciclo&amp;rdquo; non è più accessibile. Sono necessari algoritmi di garbage collection specializzati per rintracciare i file che non possono essere acceduti. Tali algoritmi sono tuttavia dispendiosi in termini di tempo e raramente utilizzati. Rintracciare cicli è ugualmente dispendioso, per cui la soluzione più semplice consiste nel non seguire i link durante lo scorrimento del file system.&lt;/p>
&lt;h2 id="104-protezione">10.4 Protezione&lt;/h2>
&lt;p>Garantire la sicurezza delle informazioni in un sistema informatico comporta di occuparsi di aspetti quali &lt;em>affidabilità&lt;/em> e &lt;em>protezione&lt;/em>.&lt;/p>
&lt;p>L&amp;rsquo;affidabilità è tipicamente ottenuta tramite copie ridondanti dei file, spesso salvate a intervalli regolari per mitigare i rischi di perdita dati da guasti hardware, errori o cancellazioni accidentali.&lt;/p>
&lt;p>Le misure di protezione variano, dalle autenticazioni degli utenti tramite password sui laptop alla crittografia dello storage secondario per prevenire l&amp;rsquo;accesso non autorizzato e l&amp;rsquo;implementazione di firewall per proteggersi dalle violazioni di rete. Nei sistemi multiutente, sono necessari meccanismi più sofisticati per regolare l&amp;rsquo;accesso e garantire che solo gli utenti autorizzati possano interagire con i dati.&lt;/p>
&lt;h3 id="1041-tipo-di-accesso">10.4.1 Tipo di Accesso&lt;/h3>
&lt;p>Nei sistemi multiutente è necessario garantire la protezione dei file da accessi indesiderati, p.e., da utenti non autorizzati (nei sistemi senza accesso multiutente invece la protezione può non essere richiesta).&lt;/p>
&lt;p>L&amp;rsquo;&lt;strong>accesso controllato&lt;/strong> regola varie operazioni sui file in base al tipo di permessi. Operazioni come lettura, scrittura, esecuzione, aggiunta, cancellazione, elenco e modifica degli attributi possono essere regolate. Operazioni ad alto livello come il cambio nome, la copia e la modifica dei file possono essere implementate tramite chiamate di sistema a livello più basso e quindi ereditare la protezione di questi livelli. Ad esempio, una copia può essere realizzata con chiamate di &lt;code>read()&lt;/code> quindi l&amp;rsquo;accesso in sola lettura garantisce la copia. È bene differenziare anche i livelli di protezione in base al contesto. Per esempio, un piccolo gruppo di ricerca potrebbe non richiedere gli stessi livelli di protezione di una grande rete aziendale.&lt;/p>
&lt;h3 id="1042-controllo-degli-accessi">10.4.2 Controllo degli accessi&lt;/h3>
&lt;p>L&amp;rsquo;approccio comune alla protezione è quello di implementare diversi livelli di accesso a un file in base all&amp;rsquo;identità dell&amp;rsquo;utente. Tipicamente, ogni file/directory è associato a una &lt;strong>lista di controllo degli accessi (Access Control List, ACL)&lt;/strong> che specifica gli utenti e il tipo di accesso permesso. Quando un utente richiede accesso, il sistema controlla l&amp;rsquo;ACL. Se è consentito, l&amp;rsquo;accesso viene garantito, altrimenti viene negato.&lt;/p>
&lt;p>Esistono due problemi con questo approccio:&lt;/p>
&lt;ul>
&lt;li>Mantenere la lista per ogni file è faticoso, specialmente se non si conoscono gli utenti in anticipo&lt;/li>
&lt;li>La dimensione di una entry diventa è ora di size variabile e la gestione dello spazio su disco diventa più complicata&lt;/li>
&lt;/ul>
&lt;p>Per semplificare la gestione, molti sistemi condensano la lunghezza della ACL implementando tre classificazioni degli utenti:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Proprietario:&lt;/strong> l&amp;rsquo;utente che ha creato il file è il proprietario&lt;/li>
&lt;li>&lt;strong>Gruppo:&lt;/strong> un insieme di utenti che condividono il file e necessitano di accessi simili&lt;/li>
&lt;li>&lt;strong>Altro:&lt;/strong> tutti gli altri utenti nel sistema&lt;/li>
&lt;/ul>
&lt;p>Il gruppo altro può anche essere sostituito col gruppo universo (tutti gli utenti). Quest&amp;rsquo;ultimo approccio, può essere combinato con ACL per fornire, quando necessario, un controllo degli accessi più specifico. Per far funzionare questo schema, i permessi vanno controllati. Ad esempio, nel sistema UNIX, i gruppi possono essere creati e modificati solo dal responsabile della struttura o da qualsiasi superutente e il controllo è garantito attraverso l&amp;rsquo;interazione umana.&lt;/p>
&lt;p>Con questo tipo di protezione sono richiesti solo tre campi, organizzati tipicamente come collezioni di bit, ognuno che controlla diversi tipi di accesso (lettura, scrittura, esecuzione, per utente, gruppo, altro).&lt;/p>
&lt;h2 id="105-file-mappati-in-memoria">10.5 File mappati in memoria&lt;/h2>
&lt;p>La mappatura in memoria di un file consiste nel caricare blocchi di file nelle pagine della memoria centrale, evitando di accedere riga per riga al file attraverso le chiamate &lt;code>open()&lt;/code>, &lt;code>read()&lt;/code> and &lt;code>write()&lt;/code>. Questo metodo può migliorare significativamente le prestazioni rispetto ai metodi tradizionali di accesso al disco.&lt;/p>
&lt;p>La scrittura su disco di una pagina modificata non avviene subito, ma solamente quando il file viene chiuso, oppure regime di scarsità di spazio nella memoria centrale.&lt;/p>
&lt;!-- ### Meccanismo di base
La mappatura in memoria di un file coinvolge l'associazione di un blocco del disco con le pagine di memoria, inizialmente accessibili attraverso il paging su richiesta. Gli accessi successivi ai file sono trattati come operazioni in memoria, semplificando e accelerando la gestione dei file. Tuttavia, le scritture nei file mappati in memoria potrebbero non riflettere immediatamente le modifiche su disco. Alcuni sistemi, mappano in memoria i file indipendentemente dal metodo utilizzato per l'accesso ai file. Più processi possono mappare contemporaneamente lo stesso file, consentendo la condivisione dei dati. La mappatura in memoria può supportare la funzionalità di copia-su-scrittura, consentendo l'accesso condiviso in sola lettura con modifiche individuali. La memoria condivisa è spesso implementata tramite file mappati in memoria, facilitando la comunicazione inter-processo. -->
&lt;div id="references-1784797996954271944" class="references" style="margin-top: 5rem;">
&lt;p>&lt;strong>References&lt;/strong>&lt;/p>
A. Silberschatz, P. B. Galvin, G. Gagne, &lt;em>Sistemi Operativi. Concetti ed Esempi&lt;/em>, Decima edizione. Ch. 13.
&lt;/div>
&lt;script>
document.addEventListener('DOMContentLoaded', function () {
var ref = document.getElementById('references-1784797996954271944');
var fn = document.querySelector('.footnotes');
if (ref &amp;&amp; fn &amp;&amp; fn.parentNode) {
fn.parentNode.insertBefore(ref, fn.nextSibling);
}
});
&lt;/script></description></item><item><title>Interfaccia del file system</title><link>https://aretor.it/courses/os2324/13_file_system/</link><pubDate>Fri, 03 May 2024 00:00:00 +0000</pubDate><guid>https://aretor.it/courses/os2324/13_file_system/</guid><description>&lt;p>Il file system è essenziale nei s.o., fornendo archiviazione e accesso ai dati e ai programmi. È composto da file che memorizzano dati correlati e da una struttura a directory che organizza le informazioni sui file. La maggior parte dei file system risiede su dispositivi di archiviazione. In questa lezione discutiamo degli aspetti dei file, delle strutture delle directory, della condivisione dei file tra processi e utenti, e delle modalità di protezione dei file.&lt;/p>
&lt;h2 id="aspetti-base-dei-file">Aspetti base dei file&lt;/h2>
&lt;p>I &lt;strong>file&lt;/strong> sono unità di memorizzazione logica delle informazioni che forniscono un&amp;rsquo;astrazione delle caratteristiche fisiche di archiviazione dei dati.&lt;/p>
&lt;h3 id="concetto-di-file">Concetto di file&lt;/h3>
&lt;p>I computer archiviano dati su diversi supporti, astratti dal s.o. per una visione uniforme. Dal punto di vista dell&amp;rsquo;utente, un file è la più piccola porzione di archiviazione logica secondaria. I dati non possono essere scritti su archiviazione secondaria a meno che non siano contenuti in un file. Essi comprendono programmi e informazioni, strutturati secondo le esigenze dell&amp;rsquo;utente. Le strutture dei file variano, adattandosi a tipi di contenuto come testo, codice sorgente o eseguibili. UNIX e s.o. simili sfruttano i file system per accedere ai dati di sistema. Le &lt;strong>strutture dei file&lt;/strong> sono determinate dal loro tipo, facilitando l&amp;rsquo;uso da parte delle applicazioni. Consentono di memorizzare informazioni come programmi, numeri, testi, multimediali e altro ancora. Ogni tipo di file ha una struttura distinta, organizzata per usabilità ed efficienza. I file servono come metodo principale per gli utenti e le applicazioni per memorizzare e accedere ai dati. Il concetto di file è ampio, comprendendo sequenze di bit, byte, linee o record. I file system gestiscono le informazioni sui file e forniscono accesso attraverso processi, utenti e computer. Tra i vari tipi di file, abbiamo:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>file di testo:&lt;/strong> sequenza di caratteri organizzati in linee (e eventualmente pagine).&lt;/li>
&lt;li>&lt;strong>file sorgente:&lt;/strong> sequenza di funzioni, ognuna delle quali è ulteriormente organizzata come dichiarazioni seguite da istruzioni eseguibili.&lt;/li>
&lt;li>&lt;strong>file eseguibile:&lt;/strong> sequenza di sezioni di codice che il loader può caricare in memoria ed eseguire.&lt;/li>
&lt;/ul>
&lt;h3 id="attributi-dei-file">Attributi dei file&lt;/h3>
&lt;p>I file sono denominati, tipicamente per comodità, con stringhe come &lt;code>example.c&lt;/code>. La denominazione rende i file indipendenti dagli utenti e dai sistemi, consentendo una facile condivisione e manipolazione. I s.o. possono o meno differenziare tra caratteri maiuscoli e minuscoli nei nomi. Nonostante trasferimenti o modifiche, i file mantengono i loro nomi, facilitando la coerenza nell&amp;rsquo;accesso e gestione. Alcune informazioni che caraterizzano i file:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Nome:&lt;/strong> il nome simbolico del file è l&amp;rsquo;unica informazione conservata in forma user-readable&lt;/li>
&lt;li>&lt;strong>Identificatore:&lt;/strong> tag unico, di solito un numero, che identifica il file all&amp;rsquo;interno del file system. È il nome non-user-readable del il file&lt;/li>
&lt;li>&lt;strong>Tipo:&lt;/strong> necessario per i sistemi che supportano diversi tipi di file&lt;/li>
&lt;li>&lt;strong>Locazione:&lt;/strong> un puntatore a un dispositivo e alla posizione del file su tale dispositivo&lt;/li>
&lt;li>&lt;strong>Dimensione:&lt;/strong> la dimensione attuale del file (in byte, parole o blocchi) e eventualmente la dimensione massima consentita sono incluse in questo attributo&lt;/li>
&lt;li>&lt;strong>Protezione:&lt;/strong> le informazioni sul controllo degli accessi determinano chi può fare lettura, scrittura, esecuzione, e così via&lt;/li>
&lt;li>&lt;strong>Timestamp e identificazione dell&amp;rsquo;utente:&lt;/strong> queste informazioni possono essere conservate per la creazione, l&amp;rsquo;ultima modifica e l&amp;rsquo;ultimo utilizzo. Possono essere inoltre utili per la protezione, la sicurezza e il monitoraggio dell&amp;rsquo;uso&lt;/li>
&lt;/ul>
&lt;p>I nuovi file system offrono &lt;strong>attributi estesi&lt;/strong> come la codifica dei caratteri e i checksum dei file. Le informazioni sui file, compresi gli attributi, sono memorizzate nelle directory sullo stesso dispositivo. Le voci di directory includono tipicamente il nome del file e un identificatore unico, con la dimensione della directory che corrisponde alla volatilità del file.&lt;/p>
&lt;h3 id="operazioni-sui-file">Operazioni sui file&lt;/h3>
&lt;p>Un file è un &lt;strong>tipo di dato astratto&lt;/strong> con operazioni definite come creare, scrivere, leggere, etc. I sistemi operativi offrono chiamate di sistema per eseguire queste operazioni. Comprendere queste operazioni di base facilita l&amp;rsquo;implementazione di funzioni simili come rinominare i file.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Creare un file:&lt;/strong> consiste nel trovare spazio nel file system e fare un&amp;rsquo;entrata in una directory&lt;/li>
&lt;li>&lt;strong>Aprire un file:&lt;/strong> varie operazioni (tranne creare e eliminare) richiedono una chiamata &lt;code>open()&lt;/code> di file, che restituisce un handle di file per il suo utilizzo&lt;/li>
&lt;li>&lt;strong>Scrivere un file:&lt;/strong> richiede di specificare un handle di file aperto e i dati da scrivere, aggiornando un puntatore di scrittura sequenziale&lt;/li>
&lt;li>&lt;strong>Leggere un file:&lt;/strong> richiede di specificare l&amp;rsquo;handle del file e la locazione di memoria per il blocco successivo, aggiornando un puntatore di lettura per letture sequenziali&lt;/li>
&lt;li>&lt;strong>Rilocamento all&amp;rsquo;interno di un file&lt;/strong> consiste nel riposizionamento del puntatore di locazione corrente del file, senza eseguire operazioni I/O. Questa operazione è nota anche come &lt;strong>file seek&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Eliminare un file:&lt;/strong> coinvolge la ricerca della directory per il file, il rilascio del suo spazio e la cancellazione o marcatura della sua voce di directory&lt;/li>
&lt;li>&lt;strong>Troncare un file:&lt;/strong> consente di cancellare i contenuti del file mantenendo gli attributi reimpostando la lunghezza del file a zero e rilasciando il suo spazio&lt;/li>
&lt;/ul>
&lt;p>Le operazioni di base sui file includono creazione, lettura, scrittura, riposizionamento, eliminazione e troncamento dei file, coinvolgendo spesso ricerche nelle directory. I sistemi richiedono spesso una chiamata &lt;code>open()&lt;/code> prima dell&amp;rsquo;uso del file per evitare costanti ricerche nelle directory. I file possono essere aperti implicitamente o esplicitamente con la chiamata di sistema &lt;code>open()&lt;/code>, specificando modalità di accesso come sola lettura o lettura-scrittura. Ogni processo mantiene una tabella per-processo per tracciare i file aperti (&lt;strong>tabella dei file aperti&lt;/strong>), con tabelle a livello di sistema contenenti informazioni sulla posizione e sui metadati dei file. Le operazioni &lt;code>open()&lt;/code> e &lt;code>close()&lt;/code> sono più complesse negli ambienti multi-processo, coinvolgendo tabelle per-processo e a livello di sistema per tracciare l&amp;rsquo;uso dei file e mantenere la coerenza.&lt;/p>
&lt;p>Le operazioni di creazione, lettura, scrittura, eliminazione costituiscono la base dei sistemi di gestione dei file. Tali operazioni servono per l&amp;rsquo;esecuzione di compiti più complessi come la copia o la rinomina dei file. Attributi come la lunghezza e la proprietà di un file possono essere direttamente accessibili e modificati attraverso operazioni specifiche. In molti sistemi, è richiesta una chiamata &lt;code>open()&lt;/code> esplicita prima di utilizzare un file per ottimizzare le prestazioni del sistema ed eliminare ricerche ridondanti nelle directory. All&amp;rsquo;apertura, le informazioni sul file sono memorizzate in una tabella a livello di sistema, mentre ogni processo mantiene la propria tabella per tracciare i file aperti. Negli ambienti con processi multipli, la gestione dell&amp;rsquo;accesso ai file diventa più intricata, richiedendo una sincronizzazione accurata tra processi e tabelle a livello di sistema. Questo coordinamento garantisce coerenza e previene conflitti tra gli accessi simultanei ai file. Inoltre, alcuni sistemi aprono automaticamente i file al loro primo riferimento e li chiudono al termine del programma per semplificare la gestione dei file per i programmatori. Nonostante le variazioni nell&amp;rsquo;implementazione, una gestione efficiente dei file è essenziale per garantire l&amp;rsquo;integrità dei dati e l&amp;rsquo;affidabilità del sistema.&lt;/p>
&lt;h3 id="tipi-di-file">Tipi di file&lt;/h3>
&lt;p>Quando si progetta un file system per un s.o., i tipi di file sono un aspetto cruciale per il suo corretto funzionamento. I s.o. riconoscono solo alcuni tipi di file che possono elaborare di conseguenza. I tipi di file sono spesso indicati da estensioni nei nomi dei file, aiutando gli utenti e i sistemi a identificare i tipi. Estensioni come &lt;code>.exe&lt;/code> o &lt;code>.sh&lt;/code> dettano le operazioni sui file (p.e. esecuzione). I programmi ricorrono alle estensioni per il riconoscimento dei tipi di file supportati, come &lt;code>.java&lt;/code> per i file sorgente Java o &lt;code>.docx&lt;/code> per i documenti di Microsoft Word.&lt;/p>
&lt;p>UNIX utilizza un semplice codice noto come &lt;strong>magic number&lt;/strong> all&amp;rsquo;inizio dei file per indicare il tipo di dati. Le estensioni dei nomi dei file in sono per convenienza dell&amp;rsquo;utente e non sono applicate dal sistema operativo, fornendo quindi suggerimenti sui contenuti dei file piuttosto che regole per le applicazioni.&lt;/p>
&lt;h3 id="struttura-dei-file">Struttura dei file&lt;/h3>
&lt;p>I tipi di file possono anche indicare la struttura interna di un file e sono essenziali affinché il s.o. li comprenda e li elabori correttamente. Tuttavia, il supporto a più strutture di file può rendere il codice del s.o. ingombrante e complesso. D&amp;rsquo;altro canto quando un file viene codificato in una struttura non supportata dal s.o., si possono verificare problemi ed è necessario istruire il s.o. a non utilizzar il file incorrettamente. Alcuni s.o., come UNIX e Windows, impongono requisiti minimi per le strutture di file, supportando solo la struttura di file eseguibile per le funzionalità di base del sistema. È possibile definire altre strutture di file ed è compito delle applicazioni fornire il codice per interpretarle.&lt;/p>
&lt;h3 id="struttura-interna-dei-file">Struttura interna dei file&lt;/h3>
&lt;p>Il sistema operativo gestisce internamente le strutture dei file, spesso utilizzando un approccio basato su blocchi. L&amp;rsquo;I/O del disco funziona in blocchi di dimensioni fisse, che potrebbero non allinearsi perfettamente con i record logici. Per affrontare questo problema, i record logici vengono compattati in blocchi fisici, sia dal s.o. che dall&amp;rsquo;applicazione dell&amp;rsquo;utente. Ciò garantisce efficienza nello storage e recupero dei dati. Si può verificare, tuttavia, &lt;strong>frammentazione interna&lt;/strong> a causa dello spazio sprecato nell&amp;rsquo;ultimo blocco, poiché i file vengono allocati in blocchi di dimensioni fisse. I diversi file system gestiscono la frammentazione interna in modo diverso, con dimensioni di blocco più grandi che portano a una maggiore frammentazione.&lt;/p>
&lt;h2 id="metodi-daccesso">Metodi d’accesso&lt;/h2>
&lt;p>I file memorizzano dati, accessibili e leggibili in memoria attraverso vari metodi. I s.o. offrono diversi metodi di accesso ai dati, ponendo una sfida progettuale nella scelta del metodo più adatto per applicazioni specifiche. Di seguito, vediamo due esempi di accesso.&lt;/p>
&lt;h3 id="accesso-sequenziale">Accesso sequenziale&lt;/h3>
&lt;p>L&amp;rsquo;&lt;strong>accesso sequenziale&lt;/strong> legge e scrive i dati in ordine, ed è comunemente utilizzato da editor e compilatori. Le operazioni di lettura fanno avanzare un puntatore di file e leggono la porzione successiva di dati, mentre le operazioni di scrittura aggiungono dati alla fine e regolano di conseguenza il puntatore di file. I file possono essere reimpostati all&amp;rsquo;inizio, e alcuni sistemi consentono di saltare avanti o indietro di un numero definito di record. Questo metodo di accesso funziona universalmente su dispositivi sequenziali e ad accesso casuale.&lt;/p>
&lt;h3 id="accesso-diretto">Accesso diretto&lt;/h3>
&lt;p>L&amp;rsquo;&lt;strong>accesso diretto&lt;/strong> facilita la rapidità di lettura e scrittura di &lt;strong>record&lt;/strong> (elementi logici) di lunghezza fissa, fondamentale per database e grandi set di dati. Le operazioni sui file includono &lt;code>read(n)&lt;/code> e &lt;code>write(n)&lt;/code> laddove &lt;code>n&lt;/code> è il numero di blocco. I numeri di blocco sono codificati relativamente all&amp;rsquo;inizio del file e vengono tradotti in indirizzi fisici. Assumendo una lunghezza del record di $L$ byte La richiesta per un record $N$ si traduce in richieste di I/O per $L$ byte in posizione $L \cdot N$. Alcuni sistemi limitano l&amp;rsquo;accesso ai file sia sequenziale che diretto, determinato durante la creazione del file. La simulazione dell&amp;rsquo;accesso diretto su un file sequenziale è inefficiente a causa della sua struttura intrinseca.&lt;/p>
&lt;h2 id="struttura-delle-directory">Struttura delle directory&lt;/h2>
&lt;p>Le directory fungono da tabelle di simboli, traducendo i nomi dei file nei loro blocchi di controllo e offrendo vari schemi organizzativi. Queste strutture devono facilitare l&amp;rsquo;inserimento, la cancellazione, la ricerca e le operazioni di elenco delle voci. Quando si progetta un sistema di directory, è cruciale allineare la struttura con le operazioni di directory previste:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Ricerca di un file:&lt;/strong> le strutture delle directory dovrebbero supportare la ricerca efficiente di file specifici, potenzialmente includendo la corrispondenza di modelli per nomi di file correlati&lt;/li>
&lt;li>&lt;strong>Creazione di un file:&lt;/strong> è essenziale poter creare nuovi file e incorporarli nella struttura delle directory&lt;/li>
&lt;li>&lt;strong>Eliminazione di un file:&lt;/strong> i file dovrebbero essere eliminabili dalla directory quando non sono più necessari, potenzialmente portando a metodi di deframmentazione della directory&lt;/li>
&lt;li>&lt;strong>Elencamento di una directory:&lt;/strong> è necessario elencare tutti i file all&amp;rsquo;interno di una directory&lt;/li>
&lt;li>&lt;strong>Rinominazione di un file:&lt;/strong> i nomi dei file dovrebbero essere modificabili per riflettere le modifiche nei contenuti o nell&amp;rsquo;uso del file, potenzialmente influenzando la posizione nella struttura della directory&lt;/li>
&lt;li>&lt;strong>Attraversamento del file system:&lt;/strong> accedere a tutte le directory e i file all&amp;rsquo;interno di una struttura può essere necessario per scopi di backup, spesso raggiunto copiando i dati su supporti di archiviazione secondari o sul cloud per proteggersi da guasti del sistema&lt;/li>
&lt;/ul>
&lt;h3 id="directory-a-un-livello">Directory a un livello&lt;/h3>
&lt;p>La struttura delle directory a livello singolo memorizza tutti i file in una sola directory, semplificando la gestione ma ponendo limitazioni quando il numero dei file aumenta o con più utenti. Ogni file deve avere un nome univoco. Anche per un singolo utente, gestire molti file su diversi sistemi può essere scoraggiante, evidenziando la necessità di strutture di directory più complesse.&lt;/p>
&lt;h3 id="directory-a-due-livelli">Directory a due livelli&lt;/h3>
&lt;p>Nella struttura delle directory a due livelli, ogni utente ha una &lt;strong>directory dei file utente (User File Directory, UFD)&lt;/strong>, mentre la &lt;strong>directory dei file principale (Master File Direcoty, MFD)&lt;/strong> indicizza le UFD degli utenti. Gli utenti possono avere file con nomi identici all&amp;rsquo;interno delle loro UFD. Un programma di sistema separato viene utilizzato per creare ed eliminare le UFD, di solito eseguibile dagli amministratori. Sebbene risolva i problemi di collisione dei nomi, isola gli utenti e complica l&amp;rsquo;accesso ai file tra loro. Per accedere ai file in un&amp;rsquo;altra directory dell&amp;rsquo;utente, si deve specificare sia il nome utente che il nome del file.&lt;/p>
&lt;h3 id="directory-con-struttura-ad-albero">Directory con struttura ad albero&lt;/h3>
&lt;p>Estendere la struttura delle directory a un albero di altezza arbitraria consente agli utenti di creare sotto-directory, organizzando i file in modo più efficiente. Ogni file nel sistema ha un nome del percorso univoco all&amp;rsquo;interno di questa struttura ad albero. Le directory o le sotto-directory contengono insiemi di file o sotto-directory, con una &lt;strong>root directory&lt;/strong> in cima. In molte implementazioni, i file e le directory vengono distinti attraverso l&amp;rsquo;utilizzo di un bit e il s.o. implementa il formato di file &amp;ldquo;directory&amp;rdquo; che viene gestito attraverso chiamate di sistema apposite. Di solito, ogni processo ha la propria directory corrente, contenente file rilevanti, ma è possibile usare una chiamata di sistema per cambiare directory. La directory iniziale di un utente viene impostata all&amp;rsquo;accesso. I sottoprocessi ereditano di solito la directory corrente del genitore.&lt;/p>
&lt;p>I nomi dei percorsi possono essere &lt;strong>assoluti&lt;/strong>, a partire dalla radice, o &lt;strong>relativi&lt;/strong>, originati a partire dalla directory corrente. In UNIX e Linux, i percorsi assoluti iniziano con &lt;code>/&lt;/code>, mentre i percorsi relativi no e si basano sulla directory corrente.&lt;/p>
&lt;p>Consentire agli utenti di creare sotto-directory permette un organizzazione logica dei propri file, ad esempio per argomento o tipo di file. La cancellazione delle directory viene implementata seguendo una tra due politiche principali. Alcuni s.o. permettono di eliminare solamente directory vuote, mentre altri offrono un&amp;rsquo;opzione per eliminare le directory insieme ai loro contenuti. Anche se la seconda politica è più conveniente, essa comporta rischi di eliminazione accidentale. Nelle directory strutturate ad albero, gli utenti possono accedere ai file di altri utenti utilizzando nomi di percorso assoluti o relativi, o cambiando la loro directory corrente. Questa flessibilità consente una gestione efficiente dei file e la collaborazione tra gli utenti.&lt;/p>
&lt;h3 id="directory-con-grafo-aciclico">Directory con grafo aciclico&lt;/h3>
&lt;p>Nei progetti collaborativi, le directory condivise facilitano il lavoro congiunto consentendo a più utenti di accedere e modificare i file contemporaneamente. A differenza delle copie, le directory condivise contengono una singola istanza di ciascun file, garantendo che le modifiche apportate da un utente siano immediatamente visibili agli altri. La capacità di condividere sotto-directory assicura un accesso coerente ai nuovi file in tutte le posizioni condivise, semplificando la collaborazione.&lt;/p>
&lt;p>La struttura a grafo aciclico, a differenza delle strutture ad albero, consente directory condivise, permettendo a file o sotto-directory di essere associati a più posizioni all&amp;rsquo;interno del file system. Questo meccanismo di condivisione migliora l&amp;rsquo;efficienza e il coordinamento nei progetti collaborativi. L&amp;rsquo;implementazione di file (o sotto-directory) condivisi può essere realizzata tramite:&lt;/p>
&lt;ul>
&lt;li>La creazione di &lt;strong>link&lt;/strong>, ovvero puntatori al file&lt;/li>
&lt;li>La duplicazione del file. È necessario mantenere l&amp;rsquo;integrità dei dati tra le due copie&lt;/li>
&lt;/ul>
&lt;p>L&amp;rsquo;eliminazione di file condivisi in strutture a grafi aciclici è complessa e presenta tre problemi:&lt;/p>
&lt;ul>
&lt;li>Quando un link viene eliminato bisogna decidere se rimuovere il file a esso associato&lt;/li>
&lt;li>Quando un file viene eliminato gli eventuali link relativi a esso diventano &lt;strong>pendenti (dangling)&lt;/strong> e puntano a una posizione vuota&lt;/li>
&lt;li>Se dei nuovi dati vanno a occupare una posizione puntata da link pendenti, tali link punteranno a dei dati non corretti.&lt;/li>
&lt;/ul>
&lt;p>Per risolvere questi problemi, esistono due approcci:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>Rimuovere il link senza eliminare il file a esso associato. Se il file stesso viene eliminato, possiamo:&lt;/p>
&lt;ul>
&lt;li>Cercare tutti i suoi link ed eliminarli. Questo approccio richiede di mantenere una struttura che elenchi i link al file&lt;/li>
&lt;li>Non eliminare i link. Nel momento in cui uno di questi link viene usato, si controlla che il file a cui puntano sia quello corretto. In caso contrario, viene lanciato un errore di accesso illegale a file.&lt;/li>
&lt;/ul>
&lt;p>In UNIX e Windows, i link vengono lasciati e sta all&amp;rsquo;utente controllare che se il file originale è stato eliminato o sostituito.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>Mantenere il file finché tutti i suoi riferimenti non vengono cancellati. È possibile associare al file un contatore ai suoi riferimenti. Quando il contatore scende a $0$, il file può venire eliminato. Un garbage collector entra in funzione a intervalli periodici e controlla tutti i file, eliminandoli in caso il contatore sia a $0$. UNIX utilizza questo approccio con link non-simbolici (hard).&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>Infine, alcuni sistemi optano per vietare del tutto le directory o i collegamenti condivisi.&lt;/p>
&lt;h3 id="directory-con-grafo-generale">Directory con grafo generale&lt;/h3>
&lt;p>Con una struttura a grafo aciclico, prevenire i cicli diventa fondamentale per mantenere l&amp;rsquo;integrità del file system. Sebbene inizialmente si parta con una struttura ad albero, l&amp;rsquo;introduzione di collegamenti può rompere la gerarchia, portando a una struttura a grafo generica. Gli algoritmi di attraversamento devono garantire una ricerca efficiente senza ripassare sezioni condivise per evitare blocchi delle prestazioni o cicli infiniti causati.&lt;/p>
&lt;p>Determinare quando un file può essere rimosso in modo sicuro diventa difficile. In presenza di cicli, infatti, la reference count di un file potrebbe non scendere mai a $0$ anche quando il &amp;ldquo;ciclo&amp;rdquo; non è più accessibile. Sono necessari algoritmi di garbage collection specializzati per rintracciare i file che non possono essere acceduti. Tali algoritmi sono tuttavia dispendiosi in termini di tempo e raramente utilizzati. Rintracciare cicli è ugualmente dispendioso, per cui la soluzione più semplice consiste nel non seguire i link durante lo scorrimento del file system.&lt;/p>
&lt;h2 id="protezione">Protezione&lt;/h2>
&lt;p>Garantire la sicurezza delle informazioni in un sistema informatico comporta di occuparsi di aspetti quali &lt;em>affidabilità&lt;/em> e &lt;em>protezione&lt;/em>.&lt;/p>
&lt;p>L&amp;rsquo;affidabilità è tipicamente ottenuta tramite copie ridondanti dei file, spesso salvate a intervalli regolari per mitigare i rischi di perdita dati da guasti hardware, errori o cancellazioni accidentali.&lt;/p>
&lt;p>Le misure di protezione variano, dalle autenticazioni degli utenti tramite password sui laptop alla crittografia dello storage secondario per prevenire l&amp;rsquo;accesso non autorizzato e l&amp;rsquo;implementazione di firewall per proteggersi dalle violazioni di rete. Nei sistemi multiutente, sono necessari meccanismi più sofisticati per regolare l&amp;rsquo;accesso e garantire che solo gli utenti autorizzati possano interagire con i dati.&lt;/p>
&lt;h3 id="tipo-di-accesso">Tipo di Accesso&lt;/h3>
&lt;p>Nei sistemi multiutente è necessario garantire la protezione dei file da accessi indesiderati, p.e., da utenti non autorizzati (nei sistemi senza accesso multiutente invece la protezione può non essere richiesta).&lt;/p>
&lt;p>L&amp;rsquo;&lt;strong>accesso controllato&lt;/strong> regola varie operazioni sui file in base al tipo di permessi. Operazioni come lettura, scrittura, esecuzione, aggiunta, cancellazione, elenco e modifica degli attributi possono essere regolate. Operazioni ad alto livello come il cambio nome, la copia e la modifica dei file possono essere implementate tramite chiamate di sistema a livello più basso e quindi ereditare la protezione di questi livelli. Ad esempio, una copia può essere realizzata con chiamate di &lt;code>read()&lt;/code> quindi l&amp;rsquo;accesso in sola lettura garantisce la copia. È bene differenziare anche i livelli di protezione in base al contesto. Per esempio, un piccolo gruppo di ricerca potrebbe non richiedere gli stessi livelli di protezione di una grande rete aziendale.&lt;/p>
&lt;h3 id="controllo-degli-accessi">Controllo degli accessi&lt;/h3>
&lt;p>L&amp;rsquo;approccio comune alla protezione è quello di implementare diversi livelli l&amp;rsquo;accesso un file in base all&amp;rsquo;identità dell&amp;rsquo;utente. Tipicamente, ogni file/directory è associato a una &lt;strong>lista di controllo degli accessi (Access Control List, ACL)&lt;/strong> che specifica gli utenti e il tipo di accesso permesso. Quando un utente richiede accesso, il sistema controlla l&amp;rsquo;ACL. Se è consentito, l&amp;rsquo;accesso viene garantito, altrimenti viene negato.&lt;/p>
&lt;p>Esistono due problemi con questo approccio:&lt;/p>
&lt;ul>
&lt;li>Mantenere la lista per ogni file è faticoso, specialmente se non si conoscono gli utenti in anticipo&lt;/li>
&lt;li>La dimensione di una entry diventa è ora di size variabile e la gestione dello spazio su disco diventa più complicata&lt;/li>
&lt;/ul>
&lt;p>Per semplificare la gestione, molti sistemi condensano la lunghezza della ACL implementando tre classificazioni degli utenti:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Proprietario:&lt;/strong> l&amp;rsquo;utente che ha creato il file è il proprietario&lt;/li>
&lt;li>&lt;strong>Gruppo:&lt;/strong> un insieme di utenti che condividono il file e necessitano di accessi simili&lt;/li>
&lt;li>&lt;strong>Altro:&lt;/strong> tutti gli altri utenti nel sistema&lt;/li>
&lt;/ul>
&lt;p>Il gruppo altro può anche essere sostituito col gruppo universo (tutti gli utenti). Quest&amp;rsquo;ultimo approccio, può essere combinato con ACL per fornire, quando necessario, un controllo degli accessi più specifico. Per far funzionare questo schema, i permessi vanno controllati. Ad esempio, nel sistema UNIX, i gruppi possono essere creati e modificati solo dal responsabile della struttura o da qualsiasi superutente e il controllo è garantito attraverso l&amp;rsquo;interazione umana.&lt;/p>
&lt;p>Con questo tipo di protezione sono richiesti solo tre campi, organizzati tipicamente come collezioni di bit, ognuno che controlla diversi tipi di accesso (lettura, scrittura, esecuzione, per utente, gruppo, altro).&lt;/p>
&lt;h2 id="file-mappati-in-memoria">File mappati in memoria&lt;/h2>
&lt;p>La mappatura in memoria di un file consiste nel caricare blocchi di file nelle pagine della memoria centrale, evitando di accedere riga per riga al file attraverso le chiamate &lt;code>open()&lt;/code>, &lt;code>read()&lt;/code> and &lt;code>write()&lt;/code>. Questo metodo può migliorare significativamente le prestazioni rispetto ai metodi tradizionali di accesso al disco.&lt;/p>
&lt;p>La scrittura su disco di una pagina modificata non avviene subito, ma solamente quando il file viene chiuso, oppure regime di scarsità di spazio nella memoria centrale.&lt;/p>
&lt;!-- ### Meccanismo di base
La mappatura in memoria di un file coinvolge l'associazione di un blocco del disco con le pagine di memoria, inizialmente accessibili attraverso il paging su richiesta. Gli accessi successivi ai file sono trattati come operazioni in memoria, semplificando e accelerando la gestione dei file. Tuttavia, le scritture nei file mappati in memoria potrebbero non riflettere immediatamente le modifiche su disco. Alcuni sistemi, mappano in memoria i file indipendentemente dal metodo utilizzato per l'accesso ai file. Più processi possono mappare contemporaneamente lo stesso file, consentendo la condivisione dei dati. La mappatura in memoria può supportare la funzionalità di copia-su-scrittura, consentendo l'accesso condiviso in sola lettura con modifiche individuali. La memoria condivisa è spesso implementata tramite file mappati in memoria, facilitando la comunicazione inter-processo. -->
&lt;div id="references-1784797996447775923" class="references" style="margin-top: 5rem;">
&lt;p>&lt;strong>References&lt;/strong>&lt;/p>
A. Silberschatz, P. B. Galvin, G. Gagne, &lt;em>Sistemi Operativi. Concetti ed Esempi&lt;/em>, Decima edizione. Ch. 13.
&lt;/div>
&lt;script>
document.addEventListener('DOMContentLoaded', function () {
var ref = document.getElementById('references-1784797996447775923');
var fn = document.querySelector('.footnotes');
if (ref &amp;&amp; fn &amp;&amp; fn.parentNode) {
fn.parentNode.insertBefore(ref, fn.nextSibling);
}
});
&lt;/script></description></item><item><title>Implementazione del file system</title><link>https://aretor.it/courses/os2324/14_fs_impl/</link><pubDate>Wed, 08 May 2024 00:00:00 +0000</pubDate><guid>https://aretor.it/courses/os2324/14_fs_impl/</guid><description>&lt;p>In questa lezione tratteremo gli aspetti comuni condivisi dai molteplici file system presenti nel mercato.&lt;/p>
&lt;!-- Nella scorsa lezione abbiamo visto i file system, i quali gestiscono lo storage online e l'accesso ai contenuti dei file come dati e programmi. Abbiamo trattato lo storage e l'accesso dei file su comuni dispositivi di storage secondario come hard disk e dispositivi di memoria non volatile. Abbiamo visto la strutturazione dell'uso dei file, l'allocazione dello spazio di archiviazione, il recupero dello spazio liberato, il tracciamento delle posizioni dei dati e l'interfacciamento con altre parti del sistema operativo. Esistono vari file system con differenze nelle caratteristiche, nelle prestazioni, nella affidabilità e negli obiettivi di progettazione, che si adattano a diverse esigenze. Ad esempio, i file system temporanei privilegiano lo storage veloce e il recupero dei file non persistenti, mentre i file system di storage secondario predefiniti come Linux ext4 privilegiano l'affidabilità e le funzionalità rispetto alle prestazioni. Nonostante la diversità, questa lezione si concentra sugli aspetti comuni condivisi dai file system. -->
&lt;h2 id="struttura-di-un-file-system">Struttura di un file system&lt;/h2>
&lt;p>Un &lt;strong>file system&lt;/strong> fornisce un accesso conveniente ed efficiente allo storage. Il suo design richiede di:&lt;/p>
&lt;ul>
&lt;li>Definire la struttura di un file&lt;/li>
&lt;li>Creare algoritmi e strutture dati per la gestione efficiente di un file system&lt;/li>
&lt;/ul>
&lt;p>La struttura di un file system può essere definita a strati. Dal livello più alto (software) al più basso (hardware) abbiamo:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Programmi:&lt;/strong> richiedono ed elaborano dati (tramite la CPU)&lt;/li>
&lt;li>&lt;strong>File system logico:&lt;/strong> gestisce i metadati del file system, ovvero tutte le sue informazioni ad esclusione dei dati stessi. Include:
&lt;ul>
&lt;li>La struttura a directory del file system&lt;/li>
&lt;li>I metadati dei file raggruppati in &lt;strong>File Control Block (FCB)&lt;/strong> (&lt;strong>inode&lt;/strong> nei file system UNIX). Nei FCB troviamo informazioni come la proprietà, i permessi e la locazione del file&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>Modulo di organizzazione file:&lt;/strong> gestisce i file e i loro blocchi logici (numerati da $0$/$1$ a $N$). Ne fa parte anche il gestore dello spazio libero che tiene traccia dei blocchi liberi e li fornisce all&amp;rsquo;occorrenza&lt;/li>
&lt;li>&lt;strong>File system di base:&lt;/strong> si occupa di inviare generiche istruzioni di lettura o scrittura ai dispositivi di memoria secondaria e di gestire cache e buffer per una gestione più efficiente dei trasferimenti di dati&lt;/li>
&lt;li>&lt;strong>Controllo I/O:&lt;/strong> consiste di driver di dispositivo e gestori di interrupt per trasferire l&amp;rsquo;informazione tra la memoria principale e quella secondaria. Il driver è un &amp;ldquo;traduttore&amp;rdquo; da istruzioni ad alto livello (p.e. recupera il blocco 123) a istruzioni per il controllore hardware.&lt;/li>
&lt;li>&lt;strong>Dispositivi di memoria secondaria:&lt;/strong> l&amp;rsquo;hardware preposto a salvare i dati permanentemente&lt;/li>
&lt;/ol>
&lt;p>Con una struttura a strati il codice viene minimizzato poiché, ad esempio, il codice del file system di base può essere utilizzato con altri file system.&lt;/p>
&lt;!-- Nei sistemi informatici, i dischi sono cruciali per lo storage secondario, dove sono memorizzati i file system. Offrono due vantaggi chiave:
- I dischi consentono la **riscrittura in loco**, permettendo la modifica dei blocchi di dati direttamente sul disco.
- Offrono **accesso diretto** a qualsiasi blocco di informazioni che contengono, semplificando sia l'accesso sequenziale che quello diretto ai file.
I dispositivi di NVM stanno guadagnando popolarità per lo storage dei file, ma differiscono dai dischi nel senso che non possono essere riscritti in loco e presentano diverse caratteristiche di prestazione. L'unità di trasferimento I/O tra memoria e storage è tipicamente il blocco per migliorare l'efficienza. I dischi rigidi hanno blocchi con uno o più settori solitamente di $512$ o $4.096$ byte, mentre i dispositivi NVM hanno tipicamente blocchi da $4.096$ byte, con trasferimenti simili ai dischi rigidi.
forniscono un accesso efficiente ai dispositivi di storage mediante il design dell'interfaccia utente e il mapping dello storage logico-fisico. Impiegano strutture stratificate, con ciascun livello che sfrutta le caratteristiche dei livelli inferiori per migliorare la funzionalità.
Il livello di **controllo I/O** gestisce il trasferimento dei dati tra memoria e dischi attraverso driver del dispositivo e gestori delle interruzioni. I driver del dispositivo traducono i comandi di alto livello in istruzioni specifiche dell'hardware.
Il **basic file system** interagisce con i driver per supervisionare la lettura, scrittura e pianificazione delle richieste di I/O a blocchi, così come i buffer di memoria e le cache. Le cache memorizzano i metadati utilizzati frequentemente per aumentare le prestazioni, richiedendo una gestione attenta. Una gestione efficace della cache è vitale per l'ottimizzazione del sistema.
Il **modulo di organizzazione dei file** gestisce i blocchi logici dei file e include un gestore dello spazio libero per l'allocazione dei blocchi. I metadati, escludendo i dati effettivi, sono gestiti dal file system logico, che gestisce le strutture delle directory e gli attributi dei file attraverso i **blocchi di controllo dei file (File Control Block, FCB)**. I FCB contengono informazioni come proprietà, permessi e posizione del contenuto del file. Il file system logico garantisce anche la protezione dei file. Le implementazioni stratificate dei file system minimizzano la duplicazione del codice, consentendo l'uso condiviso del controllo I/O e del codice di base del file system tra più file system. Tuttavia, la stratificazione può aumentare l'overhead del sistema operativo, potenzialmente influenzando le prestazioni e rappresentando una sfida nel design del sistema.
Oggi vengono utilizzati vari file system, con la maggior parte dei sistemi operativi che supportano formati multipli. UNIX per esempio impiega il file system UNIX (*Unix File System*, UFS), basato sul Berkeley Fast File System (FFS). -->
&lt;h2 id="operazioni-del-file-system">Operazioni del file system&lt;/h2>
&lt;p>In questa sezione esploriamo le strutture e le operazioni coinvolte nell&amp;rsquo;implementazione delle operazioni del file system di &lt;code>open()&lt;/code> e &lt;code>close()&lt;/code>.&lt;/p>
&lt;h3 id="panoramica">Panoramica&lt;/h3>
&lt;p>Nell&amp;rsquo;implementazione del file system vengono utilizzate diverse strutture, che comprendono informazioni relative all&amp;rsquo;avvio del s.o., al conteggio totale dei blocchi, al conteggio e alle posizioni dei blocchi liberi, alla struttura delle directory e ai singoli file. Vediamole:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Blocco di controllo di avvio (boot control block):&lt;/strong> contiene informazioni necessarie all&amp;rsquo;avvio del s.o. in un volume. Se il disco non contiene un sistema operativo, tale blocco può essere vuoto&lt;/li>
&lt;li>&lt;strong>Blocco di controllo del volume (volume control block):&lt;/strong> memorizza dettagli del volume come conteggio e dimensione dei blocchi, conteggio e puntatori dei blocchi liberi&lt;/li>
&lt;li>&lt;strong>Struttura delle directory:&lt;/strong> usata per organizzare i file&lt;/li>
&lt;li>&lt;strong>Blocco di controllo del file (File Control Block, FCB):&lt;/strong> contiene dettagli del file e un identificatore univoco per l&amp;rsquo;associazione alla directory&lt;/li>
&lt;/ul>
&lt;p>L&amp;rsquo;informazione in-memoria serve sia per la gestione del file system che per il miglioramento delle prestazioni tramite caching. Viene caricata al momento del mount ed eliminata al dismount del volume. Può includere:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Tabella di mount:&lt;/strong> contiene dettagli su ciascun volume montato&lt;/li>
&lt;li>&lt;strong>Cache della struttura delle directory:&lt;/strong> memorizza informazioni sulle directory a cui si è avuto recentemente accesso&lt;/li>
&lt;li>&lt;strong>Tabella di sistema dei file aperti:&lt;/strong> contiene copie di FCB per file aperti&lt;/li>
&lt;li>&lt;strong>Tabella dei file aperti (per processo):&lt;/strong> contiene puntatori alle entry nella tabella dei file aperti dal processo&lt;/li>
&lt;li>&lt;strong>Buffer:&lt;/strong> memorizza temporaneamente blocchi del file system durante le operazioni di trasferimento dati&lt;/li>
&lt;/ul>
&lt;p>Per creare un file, il file system logico assegna un nuovo FCB e aggiorna di conseguenza la struttura della directory che lo conterrà. Alcuni s.o. trattano le directory in modo simile ai file, mentre altri le trattano separatamente. Il file system logico gestisce il mapping I/O alle posizioni dei blocchi di archiviazione, interagendo con il modulo di organizzazione dei file e il sistema di controllo I/O.&lt;/p>
&lt;h3 id="utilizzo">Utilizzo&lt;/h3>
&lt;p>Prima di poter eseguire operazioni di I/O, i file devono essere aperti. La chiamata &lt;code>open()&lt;/code> passa il nome del file al file system logico, che verifica se il file è già aperto da un altro processo. In tal caso, crea una entry che punti alla tabella già esistente per ridurre l&amp;rsquo;overhead. Se il file non è già aperto, viene cercato nella struttura delle directory, spesso memorizzata nella cache in-memoria. Una volta trovato, il FCB viene copiato nella tabella di sistema dei file aperti e viene creato una entry per il processo. Quando un processo chiude il file, la sua entry viene rimossa e il conteggio delle aperture del file viene decrementato. I metadati aggiornati vengono scritti nuovamente sul disco quando tutti gli utenti chiudono il file. Le strutture del file system memorizzano informazioni nella cache in-memoria, ad eccezione dei blocchi di dati, per l&amp;rsquo;ottimizzazione delle prestazioni.&lt;/p>
&lt;h2 id="realizzazione-delle-directory">Realizzazione delle directory&lt;/h2>
&lt;p>In questa sezione esploriamo gli algoritmi di allocazione e gestione delle directory e il loro impatto sull&amp;rsquo;efficienza, sulle prestazioni e sulla affidabilità del file system.&lt;/p>
&lt;h3 id="lista-lineare">Lista lineare&lt;/h3>
&lt;p>Un approccio di base all&amp;rsquo;implementazione delle directory prevede una lista lineare di nomi di file con puntatori ai blocchi di dati, che è semplice da programmare ma lenta nell&amp;rsquo;esecuzione. La creazione di un nuovo file richiede la ricerca nella directory per possibili file con lo stesso nome e l&amp;rsquo;aggiunta di una nuova entry alla fine. L&amp;rsquo;eliminazione di un file comporta la ricerca e il rilascio dello spazio allocato.&lt;/p>
&lt;!-- Le entry non utilizzate nella directory possono essere contrassegnate come tali o aggiunte a un elenco di entry libere. In alternativa, l'ultima entry può sostituire quella liberata, riducendo la lunghezza della directory. -->
&lt;p>La ricerca lineare necessaria per trovare i file in questa configurazione può essere lenta. Molti s.o. utilizzano una cache per memorizzare le informazioni sulle directory che vengono accedute frequentemente. Utilizzare una lista ordinata consente una ricerca binaria, riducendo il tempo di ricerca, ma mantenere l&amp;rsquo;ordine può complicare la creazione e l&amp;rsquo;eliminazione dei file. Strutture dati più complesse come gli alberi bilanciati potrebbero alleviare questo problema.&lt;/p>
&lt;h3 id="tabella-hash">Tabella hash&lt;/h3>
&lt;p>Un&amp;rsquo;altra struttura dati utilizzata per le directory dei file è la &lt;strong>tabella hash&lt;/strong>, che combina una lista lineare con una struttura dati hash. La tabella hash calcola un valore dal nome del file, utilizzato per l&amp;rsquo;indicizzazione nella lista, riducendo significativamente il tempo di ricerca nella directory. L&amp;rsquo;inserimento e l&amp;rsquo;eliminazione sono relativamente semplici, tuttavia quando c&amp;rsquo;è una collisione (due file generano lo stesso hash), è necessario salvare la nuova entry nella prima posizione successiva libera (&lt;strong>open hash&lt;/strong>). Le tabelle hash spesso hanno dimensioni fisse, con funzioni hash dipendenti da questa dimensione. Ad esempio, se una tabella contiene $64$ entry e viene aggiunto un nuovo file, la tabella deve essere ingrandita, rendendo necessaria una nuova funzione hash e la riorganizzazione delle entry. In alternativa, si può utilizzare una tabella hash con &lt;strong>overflow concatenato&lt;/strong>, dove ogni entry hash contiene una lista per ospitare le collisioni. Anche se le ricerche possono essere più lente a causa dell’attraversamento delle liste, questo metodo è generalmente più veloce di una ricerca lineare attraverso l&amp;rsquo;intera directory.&lt;/p>
&lt;h2 id="metodi-di-allocazioni">Metodi di allocazioni&lt;/h2>
&lt;p>La natura ad accesso diretto dello storage secondario consente un&amp;rsquo;implementazione flessibile dei file. È cruciale allocare lo spazio in modo efficiente per un accesso rapido quando sono memorizzati più file su un singolo dispositivo. Tre metodi principali - contiguo, collegato e indicizzato - sono comunemente utilizzati, ciascuno con vantaggi e svantaggi unici.&lt;/p>
&lt;h3 id="allocazione-contigua">Allocazione contigua&lt;/h3>
&lt;p>Con l&amp;rsquo;&lt;strong>allocazione contigua&lt;/strong> i blocchi di un file vengono salvati uno vicino all&amp;rsquo;altro. Salvare un file richiede quindi la definizione dell&amp;rsquo;indirizzo di partenza e della sua lunghezza, facilitandone l&amp;rsquo;accesso. Questo metodo è semplice da implementare ma richiede attenzione per via della frammentazione esterna. Diverse strategie, come il first-fit e il best-fit, cercano di alleviare il problema.&lt;/p>
&lt;!-- Anche se il first-fit tende ad essere più veloce, nessun approccio è universalmente superiore in termini di efficienza di archiviazione. -->
&lt;!-- Nonostante i suoi limiti, l'allocazione contigua fornisce spunti sui problemi dinamici di allocazione di memoria e rimane rilevante per comprendere i principi di progettazione dei file system. -->
&lt;!-- La frammentazione esterna si verifica quando lo spazio libero viene diviso in tanti blocchi piccoli, portando a inefficienze quando il blocco contiguo più grande è insufficiente per memorizzare i dati. -->
&lt;p>Per mitigare ulteriormente il problema della frammentazione, un approccio consiste nel copiare l&amp;rsquo;intero file system su un altro dispositivo, e ricopiarlo sul dispositivo originale compattandolo. Il processo può tuttavia richiedere molto tempo, soprattutto per dispositivi di grandi dimensioni. Alcuni s.o. richiedono che l&amp;rsquo;operazione sia fatta offline quando il dispositivo non è montato. Inoltre, determinare lo spazio richiesto per un file può essere difficile, portando potenzialmente a una sottostima e successivi errori di allocazione. Se lo spazio allocato è insufficiente, i programmi possono terminare o richiedere esecuzioni ripetute. In alternativa, trovare un buco contiguo più grande e spostare i contenuti del file può alleviare il problema, anche se con una complessità temporale aumentata. Nonostante queste sfide, i sistemi moderni mirano a eseguire la deframmentazione online durante le operazioni regolari, anche se con potenziali penalità sulle prestazioni.&lt;/p>
&lt;p>L&amp;rsquo;allocazione contigua richiede inoltre di sapere in anticipo la dimensione di un file, per preallocare lo spazio richiesto. La preallocazione può portare a frammentazione interna. Per mitigare questo problema, i s.o. possono adottare un approccio di allocazione contigua modificato, allocando inizialmente un blocco di spazio e aggiungendo estensioni se necessario. Ogni estensione viene registrata e collegata alla precedente. Anche se a volte gli utenti possono impostare le dimensioni dell&amp;rsquo;estensione, impostazioni errate possono causare inefficienze. Estensioni grandi possono comunque causare frammentazione interna, mentre dimensioni variabili nell&amp;rsquo;estensione possono portare a frammentazione esterna.&lt;/p>
&lt;h3 id="allocazione-concatenata">Allocazione concatenata&lt;/h3>
&lt;p>Con questo tipo di allocazione i blocchi di cui è composto un file non sono necessariamente contigui e formano una lista concatenata. La directory si salva il puntatore al blocco iniziale e finale del file. Questo metodo presenta tre svantaggi principali:&lt;/p>
&lt;ul>
&lt;li>L&amp;rsquo;accesso al file risulta essere sequenziale, poiché è necessario seguire i puntatori per raggiungere la posizione di lettura richiesta&lt;/li>
&lt;li>Un altro svantaggio è il maggiore peso di un file in memoria, dovuto al salvataggio dei puntatori. Per ovviare a questo problema i blocchi vengono spesso raggruppati in cluster e l&amp;rsquo;allocazione avviene per cluster anziché per blocco. Questa tecnica aumenta la frammentazione interna.&lt;/li>
&lt;li>Se un pointer viene danneggiato o perso il s.o. può avere dei comportamenti imprevedibili. Per alleviare il problema è possibile utilizzare liste doppiamente concatenate o salvare il nome del file e il numero di blocco in ogni file per identificare la sua appartenenza.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>File Allocation Table (FAT)&lt;/strong> di MS-DOS utilizza una variante di questo sistema in cui una tabella (FAT appunto) mappa il numero di un blocco al puntatore del blocco successivo.&lt;/p>
&lt;!-- L'allocazione collegata consente flessibilità trattando ogni file come una lista collegata di blocchi di archiviazione sparsi. La directory punta ai primi e ultimi blocchi del file, facilitando l'accesso sequenziale. Ogni blocco contiene un puntatore al blocco successivo, gestito internamente dal sistema. Questo metodo elimina la frammentazione esterna e consente ai file di crescere dinamicamente. Tuttavia, l'accesso diretto può essere inefficiente a causa dello scorrimento sequenziale dei puntatori. Il sovraccarico dei puntatori consuma parte dello spazio su disco, spesso mitigato raggruppando i blocchi in cluster. Tuttavia, l'allocazione collegata presenta preoccupazioni per l'affidabilità poiché i puntatori sparsi sul dispositivo possono causare errori. Varianti come la **tabella di allocazione dei file** (*File Allocation Table*, FAT) affrontano questi problemi, utilizzate da sistemi come MS-DOS, organizzando i blocchi in una tabella indicizzata dai numeri di blocco. Anche se l'allocazione FAT può richiedere significativi spostamenti delle testine del disco, la memorizzazione nella cache può migliorare le prestazioni, offrendo un miglioramento del tempo di accesso diretto. -->
&lt;h3 id="allocazione-indicizzata">Allocazione indicizzata&lt;/h3>
&lt;p>Con questo tipo di allocazione viene riservato un &lt;strong>blocco indice&lt;/strong> che contiene tutti i puntatori ai blocchi. Questo metodo fornisce indicizzazione diretta senza soffrire di frammentazione esterna, tuttavia è necessario allocare un intero blocco per contenere l&amp;rsquo;indice. Nel caso di file piccoli, lo spazio allocato per l&amp;rsquo;indice è eccessivo. Tra i vari modi di gestire l&amp;rsquo;indice troviamo:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Schema concatenato:&lt;/strong> un blocco indice corrisponde a un blocco dello storage. In caso di grandi file, l&amp;rsquo;indice può essere spezzettato in vari blocchi collegati da puntatori&lt;/li>
&lt;li>&lt;strong>Indice multilivello:&lt;/strong> l&amp;rsquo;indice viene spezzato in più livelli&lt;/li>
&lt;li>&lt;strong>Schema combinato:&lt;/strong> un&amp;rsquo;alternativa utilizzata nei sistemi UNIX. La maggior parte dei puntatori punta a dei blocchi di dati, mentre una parte più piccola punta a blocchi contenenti puntatori&lt;/li>
&lt;/ul>
&lt;h2 id="gestione-dello-spazio-libero">Gestione dello spazio libero&lt;/h2>
&lt;p>Per gestire lo spazio libero viene utilizzata una struttura chiamata &lt;strong>lista dello spazio libero&lt;/strong>. Tale struttura tiene traccia di tutti i blocchi liberi e li fornisce su richiesta del s.o. Nonostante il nome non è sempre implementata come una lista. Vediamo alcune implementazioni.&lt;/p>
&lt;h3 id="vettore-di-bit">Vettore di bit&lt;/h3>
&lt;p>Spesso, viene utilizzata questa implementazione che consiste nel gestire una lista di bit, uno per blocco. Un bit a $0$ indica che il blocco corrispondente è libero, mentre un bit a $1$ indica un blocco occupato. Lo svantaggio principale è che l&amp;rsquo;accesso alla struttura può essere dispendioso a meno che l&amp;rsquo;intero vettore non venga tenuto in memoria, con conseguenti difficoltà nel caso di dispositivi di grandi dimensioni.&lt;/p>
&lt;h3 id="lista-concatenata">Lista concatenata&lt;/h3>
&lt;p>Ogni blocco libero contiene il puntatore al blocco libero successivo. Inoltre, è possibile fare il caching dei primi blocchi per avere un accesso veloce. L&amp;rsquo;approccio è inefficiente poiché lo scorrimento della lista richiede molte operazioni I/O, ma fortunatamente è un&amp;rsquo;operazione poco frequente.&lt;/p>
&lt;h3 id="raggruppamento">Raggruppamento&lt;/h3>
&lt;p>In questo caso, il primo blocco libero contiene gli indirizzi dei primi $n$ blocchi liberi. L&amp;rsquo;ultimo blocco di questo primo gruppo, contiene gli indici degli $n$ blocchi liberi successivi e così via.&lt;/p>
&lt;h3 id="conteggio">Conteggio&lt;/h3>
&lt;p>È una variante del caso precedente in cui un blocco libero punta a un blocco successivo, garantendo che gli $n$ blocchi contigui siano liberi anch&amp;rsquo;essi.&lt;/p>
&lt;h2 id="recupero-dati">Recupero dati&lt;/h2>
&lt;p>Un disastro di sistema può causare una perdita di dati o interrompere un processo di modifica del file system, generando così inconsistenze. Per tali motivi è bene adottare delle misure di prevenzione dell&amp;rsquo;integrità dei dati.&lt;/p>
&lt;h3 id="controllo-della-consistenza">Controllo della consistenza&lt;/h3>
&lt;p>Prima di tutto, è importante monitorare la consistenza dei dati. Per fare ciò viene avviato un programma che controlla un bit di stato che viene impostato a uno ogniqualvolta è in corso un&amp;rsquo;operazione di modifica dei metadati di un file. Se tale operazione viene per qualche motivo interrotta prima di completare, il bit rimane impostato a $1$ e il programma di monitoraggio è così in grado di identificare un problema di integrità dei dati. A seguire, confronta i metadati con lo stato del programma per provare a correggere eventuali problemi.&lt;/p>
&lt;h3 id="file-system-basati-su-log">File system basati su log&lt;/h3>
&lt;p>Questo sistema consiste nell&amp;rsquo;utilizzare un &lt;strong>log&lt;/strong>, ovvero un buffer circolare in cui il s.o. scrive le operazioni sui metadati da svolgere, chiamate &lt;strong>transazioni&lt;/strong>. Le transazioni vengono completate in maniera asincrona dal file system che si occupa di portarle a termine. Se il sistema si interrompe bruscamente, al suo riavvio, viene ripresa la lettura delle istruzioni dal buffer, minimizzando così i potenziali problemi di integrità derivanti.&lt;/p>
&lt;div id="references-1784797996474860842" class="references" style="margin-top: 5rem;">
&lt;p>&lt;strong>References&lt;/strong>&lt;/p>
A. Silberschatz, P. B. Galvin, G. Gagne, &lt;em>Sistemi Operativi. Concetti ed Esempi&lt;/em>, Decima edizione. Ch. 14.
&lt;/div>
&lt;script>
document.addEventListener('DOMContentLoaded', function () {
var ref = document.getElementById('references-1784797996474860842');
var fn = document.querySelector('.footnotes');
if (ref &amp;&amp; fn &amp;&amp; fn.parentNode) {
fn.parentNode.insertBefore(ref, fn.nextSibling);
}
});
&lt;/script></description></item><item><title>11 Implementazione del file system</title><link>https://aretor.it/courses/os2425/lessons/11_fs_impl/</link><pubDate>Wed, 17 Apr 2024 00:00:00 +0000</pubDate><guid>https://aretor.it/courses/os2425/lessons/11_fs_impl/</guid><description>&lt;p>In questa lezione tratteremo gli aspetti comuni condivisi dai molteplici file system presenti nel mercato.&lt;/p>
&lt;!-- Nella scorsa lezione abbiamo visto i file system, i quali gestiscono lo storage online e l'accesso ai contenuti dei file come dati e programmi. Abbiamo trattato lo storage e l'accesso dei file su comuni dispositivi di storage secondario come hard disk e dispositivi di memoria non volatile. Abbiamo visto la strutturazione dell'uso dei file, l'allocazione dello spazio di archiviazione, il recupero dello spazio liberato, il tracciamento delle posizioni dei dati e l'interfacciamento con altre parti del sistema operativo. Esistono vari file system con differenze nelle caratteristiche, nelle prestazioni, nella affidabilità e negli obiettivi di progettazione, che si adattano a diverse esigenze. Ad esempio, i file system temporanei privilegiano lo storage veloce e il recupero dei file non persistenti, mentre i file system di storage secondario predefiniti come Linux ext4 privilegiano l'affidabilità e le funzionalità rispetto alle prestazioni. Nonostante la diversità, questa lezione si concentra sugli aspetti comuni condivisi dai file system. -->
&lt;h2 id="111-struttura-di-un-file-system">11.1 Struttura di un file system&lt;/h2>
&lt;p>Un &lt;strong>file system&lt;/strong> fornisce un accesso conveniente ed efficiente allo storage. Il suo design richiede di:&lt;/p>
&lt;ul>
&lt;li>Definire la struttura di un file&lt;/li>
&lt;li>Creare algoritmi e strutture dati per la gestione efficiente di un file system&lt;/li>
&lt;/ul>
&lt;p>La struttura di un file system può essere definita a strati. Dal livello più alto (software) al più basso (hardware) abbiamo:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Programmi:&lt;/strong> richiedono ed elaborano dati (tramite la CPU)&lt;/li>
&lt;li>&lt;strong>File system logico:&lt;/strong> gestisce i metadati del file system, ovvero tutte le sue informazioni ad esclusione dei dati stessi. Include:
&lt;ul>
&lt;li>La struttura a directory del file system&lt;/li>
&lt;li>I metadati dei file raggruppati in &lt;strong>File Control Block (FCB)&lt;/strong> (&lt;strong>inode&lt;/strong> nei file system UNIX). Nei FCB troviamo informazioni come la proprietà, i permessi e la locazione del file&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>Modulo di organizzazione file:&lt;/strong> gestisce i file e i loro blocchi logici (numerati da $0$/$1$ a $N$). Ne fa parte anche il gestore dello spazio libero che tiene traccia dei blocchi liberi e li fornisce all&amp;rsquo;occorrenza&lt;/li>
&lt;li>&lt;strong>File system di base:&lt;/strong> si occupa di inviare generiche istruzioni di lettura o scrittura ai dispositivi di memoria secondaria e di gestire cache e buffer per una gestione più efficiente dei trasferimenti di dati&lt;/li>
&lt;li>&lt;strong>Controllo I/O:&lt;/strong> consiste di driver di dispositivo e gestori di interrupt per trasferire l&amp;rsquo;informazione tra la memoria principale e quella secondaria. Il driver è un &amp;ldquo;traduttore&amp;rdquo; da istruzioni ad alto livello (p.e. recupera il blocco 123) a istruzioni per il controllore hardware.&lt;/li>
&lt;li>&lt;strong>Dispositivi di memoria secondaria:&lt;/strong> l&amp;rsquo;hardware preposto a salvare i dati permanentemente&lt;/li>
&lt;/ol>
&lt;p>Con una struttura a strati il codice viene minimizzato poiché, ad esempio, il codice del file system di base può essere utilizzato con altri file system.&lt;/p>
&lt;!-- Nei sistemi informatici, i dischi sono cruciali per lo storage secondario, dove sono memorizzati i file system. Offrono due vantaggi chiave:
- I dischi consentono la **riscrittura in loco**, permettendo la modifica dei blocchi di dati direttamente sul disco.
- Offrono **accesso diretto** a qualsiasi blocco di informazioni che contengono, semplificando sia l'accesso sequenziale che quello diretto ai file.
I dispositivi di NVM stanno guadagnando popolarità per lo storage dei file, ma differiscono dai dischi nel senso che non possono essere riscritti in loco e presentano diverse caratteristiche di prestazione. L'unità di trasferimento I/O tra memoria e storage è tipicamente il blocco per migliorare l'efficienza. I dischi rigidi hanno blocchi con uno o più settori solitamente di $512$ o $4.096$ byte, mentre i dispositivi NVM hanno tipicamente blocchi da $4.096$ byte, con trasferimenti simili ai dischi rigidi.
forniscono un accesso efficiente ai dispositivi di storage mediante il design dell'interfaccia utente e il mapping dello storage logico-fisico. Impiegano strutture stratificate, con ciascun livello che sfrutta le caratteristiche dei livelli inferiori per migliorare la funzionalità.
Il livello di **controllo I/O** gestisce il trasferimento dei dati tra memoria e dischi attraverso driver del dispositivo e gestori delle interruzioni. I driver del dispositivo traducono i comandi di alto livello in istruzioni specifiche dell'hardware.
Il **basic file system** interagisce con i driver per supervisionare la lettura, scrittura e pianificazione delle richieste di I/O a blocchi, così come i buffer di memoria e le cache. Le cache memorizzano i metadati utilizzati frequentemente per aumentare le prestazioni, richiedendo una gestione attenta. Una gestione efficace della cache è vitale per l'ottimizzazione del sistema.
Il **modulo di organizzazione dei file** gestisce i blocchi logici dei file e include un gestore dello spazio libero per l'allocazione dei blocchi. I metadati, escludendo i dati effettivi, sono gestiti dal file system logico, che gestisce le strutture delle directory e gli attributi dei file attraverso i **blocchi di controllo dei file (File Control Block, FCB)**. I FCB contengono informazioni come proprietà, permessi e posizione del contenuto del file. Il file system logico garantisce anche la protezione dei file. Le implementazioni stratificate dei file system minimizzano la duplicazione del codice, consentendo l'uso condiviso del controllo I/O e del codice di base del file system tra più file system. Tuttavia, la stratificazione può aumentare l'overhead del sistema operativo, potenzialmente influenzando le prestazioni e rappresentando una sfida nel design del sistema.
Oggi vengono utilizzati vari file system, con la maggior parte dei sistemi operativi che supportano formati multipli. UNIX per esempio impiega il file system UNIX (*Unix File System*, UFS), basato sul Berkeley Fast File System (FFS). -->
&lt;h2 id="112-strutture-e-operazioni-del-file-system">11.2 Strutture e operazioni del file system&lt;/h2>
&lt;p>In questa sezione esploriamo le strutture e le operazioni coinvolte nell&amp;rsquo;implementazione delle operazioni del file system di &lt;code>open()&lt;/code> e &lt;code>close()&lt;/code>.&lt;/p>
&lt;h3 id="1121-strutture">11.2.1 Strutture&lt;/h3>
&lt;p>Nell&amp;rsquo;implementazione del file system vengono utilizzate diverse strutture per il suo avvio e la sua gestione. Vediamole:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Blocco di controllo di avvio (boot control block):&lt;/strong> contiene informazioni necessarie all&amp;rsquo;avvio del s.o. in un volume. Tipicamente il programma di bootstrap, situato in una ROM, istruisce di leggere il programma di bootstrap completo nel boot control block. Se il disco non contiene un sistema operativo, tale blocco può essere vuoto.&lt;/li>
&lt;li>&lt;strong>Blocco di controllo del volume (volume control block):&lt;/strong> memorizza dettagli del volume come il conteggi/dimensione dei blocchi, e il conteggio/puntatori dei blocchi liberi.&lt;/li>
&lt;li>&lt;strong>Struttura delle directory:&lt;/strong> usata per organizzare logicamente i file.&lt;/li>
&lt;li>&lt;strong>Blocco di controllo del file (File Control Block, FCB):&lt;/strong> contiene dettagli del file e un identificatore univoco per l&amp;rsquo;associazione alla directory.&lt;/li>
&lt;/ul>
&lt;p>L&amp;rsquo;informazione in-memoria serve sia per la gestione del file system che per il miglioramento delle prestazioni tramite caching. Viene caricata al momento del mount ed eliminata al dismount del volume. Può includere:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Tabella di mount:&lt;/strong> contiene dettagli su ciascun volume montato.&lt;/li>
&lt;li>&lt;strong>Cache della struttura delle directory:&lt;/strong> memorizza informazioni sulle directory a cui si è avuto recentemente accesso.&lt;/li>
&lt;li>&lt;strong>Tabella di sistema dei file aperti:&lt;/strong> contiene copie di FCB per file aperti.&lt;/li>
&lt;li>&lt;strong>Tabella dei file aperti (per processo):&lt;/strong> contiene puntatori alle entry nella tabella dei file aperti dal processo.&lt;/li>
&lt;li>&lt;strong>Buffer:&lt;/strong> memorizza temporaneamente blocchi del file system durante le operazioni di trasferimento dati.&lt;/li>
&lt;/ul>
&lt;p>Per creare un file, il file system logico assegna un nuovo FCB e aggiorna di conseguenza la struttura della directory che lo conterrà. Alcuni s.o. trattano le directory in modo simile ai file, mentre altri le trattano separatamente. Il file system logico gestisce il mapping I/O alle posizioni dei blocchi di archiviazione, interagendo con il modulo di organizzazione dei file e il sistema di controllo I/O.&lt;/p>
&lt;h3 id="1122-utilizzo">11.2.2 Utilizzo&lt;/h3>
&lt;p>Prima di poter eseguire operazioni di I/O, i file devono essere aperti. La chiamata &lt;code>open()&lt;/code> passa il nome del file al file system logico, che verifica se il file è già aperto da un altro processo. In tal caso, crea una entry che punti alla tabella già esistente per ridurre l&amp;rsquo;overhead. Se il file non è già aperto, viene cercato nella struttura delle directory, spesso memorizzata nella cache in-memoria. Una volta trovato, il FCB viene copiato nella tabella di sistema dei file aperti e viene creato una entry per il processo. Quando un processo chiude il file, la sua entry viene rimossa e il conteggio delle aperture del file viene decrementato. I metadati aggiornati vengono scritti nuovamente sul disco quando tutti gli utenti chiudono il file. Le strutture del file system memorizzano informazioni nella cache in-memoria, ad eccezione dei blocchi di dati, per l&amp;rsquo;ottimizzazione delle prestazioni.&lt;/p>
&lt;h2 id="113-realizzazione-delle-directory">11.3 Realizzazione delle directory&lt;/h2>
&lt;p>In questa sezione esploriamo gli algoritmi di allocazione e gestione delle directory e il loro impatto sull&amp;rsquo;efficienza, sulle prestazioni e sulla affidabilità del file system.&lt;/p>
&lt;h3 id="1131-lista-lineare">11.3.1 Lista lineare&lt;/h3>
&lt;p>Un approccio di base all&amp;rsquo;implementazione delle directory prevede una lista lineare di nomi di file con puntatori ai blocchi di dati, che è semplice da programmare ma lenta nell&amp;rsquo;esecuzione. La creazione di un nuovo file richiede la ricerca nella directory per possibili file con lo stesso nome e l&amp;rsquo;aggiunta di una nuova entry al fine. L&amp;rsquo;eliminazione di un file comporta la ricerca e il rilascio dello spazio allocato.&lt;/p>
&lt;!-- Le entry non utilizzate nella directory possono essere contrassegnate come tali o aggiunte a un elenco di entry libere. In alternativa, l'ultima entry può sostituire quella liberata, riducendo la lunghezza della directory. -->
&lt;p>La ricerca lineare necessaria per trovare i file in questa configurazione può essere lenta. Molti s.o. utilizzano una cache per memorizzare le informazioni sulle directory che vengono accedute frequentemente. Utilizzare una lista ordinata consente una ricerca binaria, riducendo il tempo di ricerca, ma mantenere l&amp;rsquo;ordine può complicare la creazione e l&amp;rsquo;eliminazione dei file. Strutture dati più complesse come gli alberi bilanciati potrebbero alleviare questo problema.&lt;/p>
&lt;h3 id="1132-tabella-hash">11.3.2 Tabella hash&lt;/h3>
&lt;p>Un&amp;rsquo;altra struttura dati utilizzata per le directory dei file è la &lt;strong>tabella hash&lt;/strong>, che combina una lista lineare con una struttura dati hash. La tabella hash calcola un valore dal nome del file, utilizzato per l&amp;rsquo;indicizzazione nella lista, riducendo significativamente il tempo di ricerca nella directory. L&amp;rsquo;inserimento e l&amp;rsquo;eliminazione sono relativamente semplici, tuttavia quando c&amp;rsquo;è una collisione (due file generano lo stesso hash), è necessario salvare la nuova entry nella prima posizione successiva libera (&lt;strong>open hash&lt;/strong>). Le tabelle hash spesso hanno dimensioni fisse, con funzioni hash dipendenti da questa dimensione. Ad esempio, se una tabella contiene 64 entry e viene aggiunto un nuovo file, la tabella deve essere ingrandita, rendendo necessaria una nuova funzione hash e la riorganizzazione delle entry. In alternativa, si può utilizzare una tabella hash con &lt;strong>overflow concatenato&lt;/strong>, dove ogni entry hash contiene una lista per ospitare le collisioni. Anche se le ricerche possono essere più lente a causa dell’attraversamento delle liste, questo metodo è generalmente più veloce di una ricerca lineare attraverso l&amp;rsquo;intera directory.&lt;/p>
&lt;h2 id="114-metodi-di-allocazioni">11.4 Metodi di allocazioni&lt;/h2>
&lt;p>La natura ad accesso diretto dello storage secondario consente un&amp;rsquo;implementazione flessibile dei file. È cruciale allocare lo spazio in modo efficiente per un accesso rapido quando sono memorizzati più file su un singolo dispositivo. Tre metodi principali - contiguo, collegato e indicizzato - sono comunemente utilizzati, ciascuno con vantaggi e svantaggi unici.&lt;/p>
&lt;h3 id="1141-allocazione-contigua">11.4.1 Allocazione contigua&lt;/h3>
&lt;p>Con l&amp;rsquo;&lt;strong>allocazione contigua&lt;/strong> i blocchi di un file vengono salvati uno vicino all&amp;rsquo;altro. Salvare un file richiede quindi la definizione dell&amp;rsquo;indirizzo di partenza e della sua lunghezza, facilitandone l&amp;rsquo;accesso. Questo metodo è semplice da implementare ma richiede attenzione per via della frammentazione esterna. Diverse strategie, come first-fit e il best-fit, possono alleviare il problema.&lt;/p>
&lt;!-- Anche se il first-fit tende ad essere più veloce, nessun approccio è universalmente superiore in termini di efficienza di archiviazione. -->
&lt;!-- Nonostante i suoi limiti, l'allocazione contigua fornisce spunti sui problemi dinamici di allocazione di memoria e rimane rilevante per comprendere i principi di progettazione dei file system. -->
&lt;!-- La frammentazione esterna si verifica quando lo spazio libero viene diviso in tanti blocchi piccoli, portando a inefficienze quando il blocco contiguo più grande è insufficiente per memorizzare i dati. -->
&lt;p>Per mitigare ulteriormente il problema della frammentazione, un approccio consiste nell&amp;rsquo;eseguire la &lt;strong>deframmentazione&lt;/strong> ovvero copiare l&amp;rsquo;intero file system su un&amp;rsquo;altra zona di memoria, e ricopiarlo sul dispositivo originale compattandolo. Il processo può tuttavia richiedere molto tempo, soprattutto per dispositivi di grandi dimensioni. Alcuni s.o. richiedono che l&amp;rsquo;operazione sia fatta offline quando il dispositivo non è montato. Inoltre, determinare con accuratezza lo spazio richiesto per un file può essere difficile, portando potenzialmente a una sottostima e successivi errori di allocazione. Se lo spazio allocato è insufficiente, i programmi possono dover terminare o richiedere esecuzioni ripetute. In alternativa, è possibile trovare un buco contiguo più grande e spostare i contenuti del file, aumentando tuttavia la complessità temporale. Nonostante queste sfide, i sistemi moderni mirano a eseguire la deframmentazione online durante le operazioni regolari, anche se ciò può portare a un peggioramento delle prestazioni complessive.&lt;/p>
&lt;p>L&amp;rsquo;allocazione contigua richiede inoltre di sapere in anticipo la dimensione di un file, per preallocare lo spazio richiesto. La preallocazione può portare a frammentazione interna. Per mitigare questo problema, i s.o. possono adottare un approccio di allocazione contigua modificato, allocando inizialmente un blocco di spazio e aggiungendo estensioni se necessario. Ogni estensione viene registrata e collegata alla precedente. Anche se a volte gli utenti possono impostare le dimensioni dell&amp;rsquo;estensione, impostazioni errate possono causare inefficienze. Estensioni grandi possono comunque causare frammentazione interna, mentre dimensioni variabili nell&amp;rsquo;estensione possono portare a frammentazione esterna.&lt;/p>
&lt;h3 id="1142-allocazione-concatenata">11.4.2 Allocazione concatenata&lt;/h3>
&lt;p>Con questo tipo di allocazione i blocchi di cui è composto un file non sono necessariamente contigui e formano una lista concatenata. La directory si salva il puntatore al blocco iniziale e finale del file. Questo metodo presenta tre svantaggi principali:&lt;/p>
&lt;ul>
&lt;li>L&amp;rsquo;accesso al file è sequenziale, poiché è necessario seguire i puntatori per raggiungere la posizione di lettura richiesta&lt;/li>
&lt;li>Il file ha maggiori dimensioni in memoria, per via del salvataggio dei puntatori. Per ovviare a questo problema i blocchi vengono spesso raggruppati in cluster e l&amp;rsquo;allocazione avviene per cluster anziché per blocco. Questa tecnica aumenta la frammentazione interna.&lt;/li>
&lt;li>Se un pointer viene danneggiato o il s.o. viene perso può avere dei comportamenti imprevedibili. Per alleviare il problema, è possibile utilizzare liste doppiamente concatenate o salvare il nome del file e il numero di blocco in ogni blocco per identificare la sua appartenenza.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>File Allocation Table (FAT)&lt;/strong> di MS-DOS utilizza una variante di questo sistema in cui una tabella (FAT appunto) mappa il numero di un blocco al puntatore del blocco successivo.&lt;/p>
&lt;!-- L'allocazione collegata consente flessibilità trattando ogni file come una lista collegata di blocchi di archiviazione sparsi. La directory punta ai primi e ultimi blocchi del file, facilitando l'accesso sequenziale. Ogni blocco contiene un puntatore al blocco successivo, gestito internamente dal sistema. Questo metodo elimina la frammentazione esterna e consente ai file di crescere dinamicamente. Tuttavia, l'accesso diretto può essere inefficiente a causa dello scorrimento sequenziale dei puntatori. Il sovraccarico dei puntatori consuma parte dello spazio su disco, spesso mitigato raggruppando i blocchi in cluster. Tuttavia, l'allocazione collegata presenta preoccupazioni per l'affidabilità poiché i puntatori sparsi sul dispositivo possono causare errori. Varianti come la **tabella di allocazione dei file** (*File Allocation Table*, FAT) affrontano questi problemi, utilizzate da sistemi come MS-DOS, organizzando i blocchi in una tabella indicizzata dai numeri di blocco. Anche se l'allocazione FAT può richiedere significativi spostamenti delle testine del disco, la memorizzazione nella cache può migliorare le prestazioni, offrendo un miglioramento del tempo di accesso diretto. -->
&lt;h3 id="1143-allocazione-indicizzata">11.4.3 Allocazione indicizzata&lt;/h3>
&lt;p>Con questo tipo di allocazione viene riservato un &lt;strong>blocco indice&lt;/strong> che contiene tutti i puntatori ai blocchi. Questo metodo fornisce indicizzazione diretta senza soffrire di frammentazione esterna, tuttavia è necessario allocare un intero blocco per contenere l&amp;rsquo;indice. Nel caso di file piccoli, lo spazio allocato per l&amp;rsquo;indice è eccessivo. Tra i vari modi di gestire l&amp;rsquo;indice troviamo:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Schema concatenato:&lt;/strong> un blocco indice corrisponde a un blocco dello storage. In caso di grandi file, l&amp;rsquo;indice può essere spezzettato in vari blocchi collegati da puntatori.&lt;/li>
&lt;li>&lt;strong>Indice multilivello:&lt;/strong> l&amp;rsquo;indice viene spezzato in più livelli.&lt;/li>
&lt;li>&lt;strong>Schema combinato:&lt;/strong> un&amp;rsquo;alternativa utilizzata nei sistemi UNIX. La maggior parte dei puntatori punta a dei blocchi di dati, mentre una parte più piccola punta a blocchi contenenti puntatori.&lt;/li>
&lt;/ul>
&lt;h2 id="115-gestione-dello-spazio-libero">11.5 Gestione dello spazio libero&lt;/h2>
&lt;p>Per gestire lo spazio libero viene utilizzata una struttura chiamata &lt;strong>lista dello spazio libero&lt;/strong>. Tale struttura tiene traccia di tutti i blocchi liberi e li fornisce su richiesta del s.o. Nonostante il nome non è sempre implementata come una lista. Vediamo alcune implementazioni.&lt;/p>
&lt;h3 id="1151-vettore-di-bit">11.5.1 Vettore di bit&lt;/h3>
&lt;p>Spesso, viene utilizzata questa implementazione che consiste nel gestire una lista di bit, uno per blocco. Un bit a $0$ indica che il blocco corrispondente è libero, mentre un bit a $1$ indica un blocco occupato. Lo svantaggio principale è che l&amp;rsquo;accesso alla struttura può essere dispendioso a meno che l&amp;rsquo;intero vettore non venga tenuto in memoria, con conseguenti difficoltà nel caso di dispositivi di grandi dimensioni.&lt;/p>
&lt;h3 id="1152-lista-concatenata">11.5.2 Lista concatenata&lt;/h3>
&lt;p>Ogni blocco libero contiene il puntatore al blocco libero successivo. Inoltre, è possibile fare il caching dei primi blocchi per avere un accesso veloce. L&amp;rsquo;approccio presenta inefficienze nello scorrimento della lista poiché richiede molte operazioni I/O, tuttavia tale operazione è poco frequente.&lt;/p>
&lt;h3 id="1153-raggruppamento">11.5.3 Raggruppamento&lt;/h3>
&lt;p>In questo caso, il primo blocco libero contiene gli indirizzi dei primi $n$ blocchi liberi. L&amp;rsquo;ultimo blocco di questo primo gruppo, contiene gli indici degli $n$ blocchi liberi successivi e così via.&lt;/p>
&lt;h3 id="1154-conteggio">11.5.4 Conteggio&lt;/h3>
&lt;p>È una variante che assume che spesso i blocchi liberi siano contigui tra loro. In questo caso, è possibile salvare l&amp;rsquo;indirizzo del primo blocco libero e un intero $n$ che indica i blocchi liberi contigui a esso.&lt;/p>
&lt;h2 id="116-recupero-dati">11.6 Recupero dati&lt;/h2>
&lt;p>Un disastro di sistema può causare una perdita di dati o interrompere un processo di modifica del file system, generando così inconsistenze. Per tali motivi è bene adottare delle misure di prevenzione dell&amp;rsquo;integrità dei dati.&lt;/p>
&lt;h3 id="1161-controllo-della-consistenza">11.6.1 Controllo della consistenza&lt;/h3>
&lt;p>Prima di tutto, è importante monitorare la consistenza dei dati. Per fare ciò viene avviato un programma che controlla un bit di stato che viene impostato a uno ogniqualvolta è in corso un&amp;rsquo;operazione di modifica dei metadati di un file. Se tale operazione viene per qualche motivo interrotta prima di completare, il bit rimane impostato a $1$ e il programma di monitoraggio è così in grado di identificare un problema di integrità dei dati. A seguire, confronta i metadati con lo stato del programma per provare a correggere eventuali problemi.&lt;/p>
&lt;h3 id="1162-file-system-basati-su-log">11.6.2 File system basati su log&lt;/h3>
&lt;p>Questo sistema consiste nell&amp;rsquo;utilizzare un &lt;strong>log&lt;/strong>, ovvero un buffer circolare in cui il s.o. scrive le operazioni sui metadati da svolgere, chiamate &lt;strong>transazioni&lt;/strong>. Le transazioni vengono completate in maniera asincrona dal file system che si occupa di portarle a termine. Se il sistema si interrompe bruscamente, al suo riavvio, viene ripresa la lettura delle istruzioni dal buffer, minimizzando così i potenziali problemi di integrità derivanti.&lt;/p>
&lt;div id="references-1784797996971838866" class="references" style="margin-top: 5rem;">
&lt;p>&lt;strong>References&lt;/strong>&lt;/p>
A. Silberschatz, P. B. Galvin, G. Gagne, &lt;em>Sistemi Operativi. Concetti ed Esempi&lt;/em>, Decima edizione. Ch. 14.
&lt;/div>
&lt;script>
document.addEventListener('DOMContentLoaded', function () {
var ref = document.getElementById('references-1784797996971838866');
var fn = document.querySelector('.footnotes');
if (ref &amp;&amp; fn &amp;&amp; fn.parentNode) {
fn.parentNode.insertBefore(ref, fn.nextSibling);
}
});
&lt;/script></description></item><item><title>12 Sistemi I/O</title><link>https://aretor.it/courses/os2425/lessons/12_dispositivi_io/</link><pubDate>Mon, 22 Apr 2024 00:00:00 +0000</pubDate><guid>https://aretor.it/courses/os2425/lessons/12_dispositivi_io/</guid><description>&lt;p>Oltre all&amp;rsquo;elaborazione tramite CPU, l&amp;rsquo;altro compito principale di un elaboratore è l&amp;rsquo;&lt;strong>Input/Output (I/O)&lt;/strong>. L&amp;rsquo;I/O è essenziale per attività come la navigazione in Internet o la modifica di un file. Il ruolo del s.o. nell&amp;rsquo;I/O è quello di gestire e controllare i dispositive le loro operazioni. In questa sezione discutiamo dei principi di funzionamento dei dispositivi I/O.&lt;/p>
&lt;p>La varietà dei dispositivi I/O nella loro funzione e nella loro velocità richiede a sua volta una varietà di metodi per la loro gestione, chiamata &lt;strong>sottosistema I/O&lt;/strong>. Il sottosistema I/O gestisce la complessità dei dispositivi, separandola dal kernel. Se da un lato c&amp;rsquo;è una progressiva standardizzazione delle interfacce hardware e software, dall&amp;rsquo;altro c&amp;rsquo;è una sempre crescente varietà di dispositivi I/O che porta a sfide nella loro integrazione. Per affrontare questo problema, il kernel si interfaccia a un dispositivo I/O tramite un &lt;strong>driver&lt;/strong>, ovvero un&amp;rsquo;interfaccia di accesso uniforme che incapsula i dettagli del dispositivo stesso.&lt;/p>
&lt;h2 id="121-hardware-di-io">12.1 Hardware di I/O&lt;/h2>
&lt;p>Introduciamo alcune definizioni legate all&amp;rsquo;hardware I/O:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Porta:&lt;/strong> il punto di connessione attraverso il quale un dispositivo comunica con l&amp;rsquo;hardware I/O tramite segnali, via-cavo o wireless.&lt;/li>
&lt;li>&lt;strong>Bus:&lt;/strong> un insieme di cavi di connessione condiviso da più dispositivi. L&amp;rsquo;utilizzo di un bus richiede un protocollo di utilizzo dei cavi e dei messaggi che possono essere inviati. I bus variano in base ai metodi di segnalazione, velocità, throughput e metodi di connessione.
&lt;ul>
&lt;li>&lt;strong>Bus PCIe:&lt;/strong> il bus che collega il sottosistema processore-memoria a dispositivi veloci (p.e. il monitor). PCIe trasmette dati su uno o più &amp;ldquo;corsie (lanes)&amp;rdquo;, delle connessioni full-duplex per la trasmissione dei dati.&lt;/li>
&lt;li>&lt;strong>Bus di espansione:&lt;/strong> il bus che collega tra di loro dispositivi più lenti come tastiere e porte USB. Questo bus può essere poi connesso al bus PCIe tramite un&amp;rsquo;interfaccia apposita.&lt;/li>
&lt;li>&lt;strong>Bus SAS:&lt;/strong> un bus che può essere usato per la connessione di memorie di massa. SAS sta per serial-attached SCSI (SCSI, è un&amp;rsquo;interfaccia di connessione). Il bus in questo caso viene connesso tramite un controller SAS.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>Controller:&lt;/strong> è un insieme di componenti elettronici che gestisce una porta, un bus o un dispositivo.
&lt;ul>
&lt;li>&lt;strong>Serial-port controller:&lt;/strong> un controller piuttosto semplice costituito da un chip che gestisce i segnali inviati alla porta di un dispositivo.&lt;/li>
&lt;li>&lt;strong>Fibre Channel (FC) bus controller:&lt;/strong> un controller complesso usato spesso nei data center. Viene implementato spesso con un circuito stampato (host bus adapter) che si connette a un bus del computer.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;h3 id="1211-comunicazione-con-lio">12.1.1 Comunicazione con l&amp;rsquo;I/O&lt;/h3>
&lt;p>Esistono due approcci principali per comunicare con l&amp;rsquo;I/O.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Registri specializzati:&lt;/strong> Il controller I/O possiede dei registri per la comunicazione col processore di dati e segnali di controllo (comandi).&lt;/li>
&lt;li>&lt;strong>I/O mappato in memoria:&lt;/strong> I registri corrispondono a indirizzi nella memoria principale. Il processore utilizza le normali routine di trasferimento dati.&lt;/li>
&lt;/ul>
&lt;p>Un dispositivo può implementare entrambi i metodi. In passato, un controller per la grafica a schermo possedeva dei registri specializzati per operazione di gestione di base e una zona in memoria per tenere l&amp;rsquo;output dello schermo. Oggigiorno, molte operazioni I/O sono mappate in memoria.&lt;/p>
&lt;!-- Questa comunicazione coinvolge tipicamente istruzioni speciali per l'I/O che attivano il trasferimento di byte o parole a specifici indirizzi di porta I/O. Queste istruzioni attivano linee di bus per selezionare il dispositivo appropriato e trasferire dati da o verso i registri del dispositivo. In alternativa, i dispositivi possono supportare l'I/O mappato in memoria, dove i registri di controllo del dispositivo sono nella memoria principale. Qui, la CPU esegue richieste di I/O utilizzando istruzioni standard di trasferimento dati per accedere a questi registri presso le loro posizioni mappate nella memoria fisica. -->
&lt;p>Il controllo del dispositivo I/O di solito coinvolge quattro registri: dati-in e dati-out, stato, controllo.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Registro di dati-in:&lt;/strong> letto dall&amp;rsquo;host per ricevere input.&lt;/li>
&lt;li>&lt;strong>Registro di dati-out:&lt;/strong> scritto dall&amp;rsquo;host per inviare output.&lt;/li>
&lt;li>&lt;strong>Registro di stato:&lt;/strong> contiene bit che indicano stati come il completamento di un comando, la disponibilità di dati e gli errori del dispositivo.&lt;/li>
&lt;li>&lt;strong>Registro di controllo:&lt;/strong> scritto dall&amp;rsquo;host per avviare comandi o modificare modalità del dispositivo, come il tipo di comunicazione, il controllo di parità, lunghezza della parola, o selezione della velocità di comunicazione.&lt;/li>
&lt;/ul>
&lt;p>I registri di dati sono solitamente di dimensioni comprese tra 1-4 byte. Alcuni controller incorporano chip FIFO, che estendono la capacità del controller memorizzando più byte di dati di input o output.&lt;/p>
&lt;h3 id="1212-polling">12.1.2 Polling&lt;/h3>
&lt;p>Il protocollo di interazione tra un host e un controller può essere complesso, ma il concetto base è semplice. Per esempio, consideriamo uno scenario in cui vengono utilizzati 2 bit per gestire la relazione produttore-consumatore. Il controller comunica il suo stato tramite un bit &lt;code>busy&lt;/code> in un registro &lt;code>status&lt;/code>, impostandolo quando è occupato e cancellandolo quando è pronto per il prossimo comando. L&amp;rsquo;host interagisce attraverso un bit &lt;code>command-ready&lt;/code> in un registro &lt;code>command&lt;/code>, impostandolo quando un comando è pronto per l&amp;rsquo;esecuzione. Questo consente una comunicazione coordinata tra l&amp;rsquo;host e il controller come segue:&lt;/p>
&lt;ol>
&lt;li>L&amp;rsquo;host controlla continuamente il bit di busy finché non diventa 0.&lt;/li>
&lt;li>L&amp;rsquo;host imposta il bit di scrittura in &lt;code>command&lt;/code> e invia un byte al registro &lt;code>data-out&lt;/code>.&lt;/li>
&lt;li>L&amp;rsquo;host imposta il bit &lt;code>command-ready&lt;/code>.&lt;/li>
&lt;li>Dopo aver rilevato che &lt;code>command-ready&lt;/code> è stato impostato, il controller imposta il bit &lt;code>busy&lt;/code>.&lt;/li>
&lt;li>Il controller esamina &lt;code>command&lt;/code>, identifica il comando di scrittura, recupera il byte da &lt;code>data-out&lt;/code> ed esegue l&amp;rsquo;operazione di I/O sul dispositivo.&lt;/li>
&lt;li>Successivamente, il controller cancella &lt;code>command-ready&lt;/code>, imposta a 0 il bit &lt;code>errore&lt;/code> dentro &lt;code>status&lt;/code> per indicare un&amp;rsquo;operazione di I/O del dispositivo riuscita e cancella il bit &lt;code>busy&lt;/code> per indicare il completamento.&lt;/li>
&lt;/ol>
&lt;p>L&amp;rsquo;host monitora costantemente lo status register finché il bit &lt;code>busy&lt;/code> non viene re-impostato, un metodo noto come &lt;strong>attesa attiva (busy-waiting)&lt;/strong> o &lt;strong>polling&lt;/strong>. È un approccio utile per operazioni veloci, ma se il polling va a vuoto per molti cicli di CPU, è preferibile il meccanismo di interrupt.&lt;/p>
&lt;!-- Per controller e dispositivi veloci, questo approccio è ragionevole, ma per attese più lunghe, l'host potrebbe dover passare a compiti diversi. È cruciale per l'host discernere quando il controller è inattivo per evitare la perdita di dati, specialmente con dati in streaming. Tipicamente, il polling comporta tre cicli di istruzioni CPU, ma può diventare inefficiente se il dispositivo è raramente pronto per il servizio. In tali casi, l'uso di interruzioni, dove il controller hardware notifica alla CPU quando il dispositivo è pronto per il servizio, potrebbe offrire un'alternativa più efficiente. -->
&lt;h3 id="1213-interrupt">12.1.3 Interrupt&lt;/h3>
&lt;p>L&amp;rsquo;hardware della CPU dispone di una &lt;strong>linea di richiesta di interrupt&lt;/strong> che si attiva dopo ogni istruzione. Quando viene rilevato un segnale di interrupt, la CPU salva il suo stato e salta a un indirizzo di memoria fisso per una routine di gestione degli interrupt. Questa routine identifica la causa dell&amp;rsquo;interrupt, la gestisce, ripristina lo stato della CPU e ritorna all&amp;rsquo;esecuzione normale.&lt;/p>
&lt;p>I controller dei dispositivi generano interrupt inviando un segnale sulla linea di interrupt, che la CPU gestisce tramite il gestore di interrupt. La gestione è cruciale nei sistemi moderni e il meccanismo di interrupt di base consente alle CPU di reagire a eventi asincroni, ma i moderni s.o. richiedono capacità di gestione più avanzate.&lt;/p>
&lt;ol>
&lt;li>È necessario poter posticipare la gestione delle interruzioni durante fasi critiche di elaborazione.&lt;/li>
&lt;li>È necessario un meccanismo efficiente per passare il controllo al giusto gestore degli interrupt senza interrogare tutti i dispositivi.&lt;/li>
&lt;li>Sono richiesti interrupt multilivello per distinguere tra interrupt ad alta e bassa priorità e rispondere di conseguenza.&lt;/li>
&lt;li>Le istruzioni devono essere in grado di richiamare direttamente l&amp;rsquo;attenzione del s.o. per attività come i fault di pagina e gli errori, che vengono gestiti tramite trap.&lt;/li>
&lt;/ol>
&lt;p>In un calcolatore moderno, le funzioni di interrupt sono gestite dalla CPU e dal controller hardware degli interrupt. Le CPU di solito dispongono di due linee di richiesta di interrupt: una &lt;strong>non mascherabile&lt;/strong> per eventi critici come gli errori di memoria, e una &lt;strong>mascherabile&lt;/strong> per le richieste dei dispositivi.&lt;/p>
&lt;p>Gli interrupt sono gestiti tramite un vettore di interruzioni, che contiene gli indirizzi dei gestori delle interruzioni specializzati. Quando esistono più dispositivi rispetto agli indirizzi del vettore, viene utilizzato il chaining delle interruzioni, dove ogni elemento del vettore punta a un elenco di gestori. Questa struttura bilancia il sovraccarico di una grande tabella di interruzioni e l&amp;rsquo;efficienza del dispatch a un singolo gestore.&lt;/p>
&lt;p>Il meccanismo di interrupt include livelli di priorità che consentono alla CPU di posticipare gli interrupt a bassa priorità senza bloccarle tutte, consentendo a quelli ad alta priorità di interrompere quelli a bassa priorità.&lt;/p>
&lt;p>Durante l&amp;rsquo;avvio, un s.o. identifica i dispositivi hardware e installa i relativi gestori di interrupt. I controller dei dispositivi generano interrupt durante le operazioni di I/O per vari eventi come l&amp;rsquo;output completato o l&amp;rsquo;input disponibile. Gli interrupt gestiscono anche eccezioni come errori di divisione per zero o accesso a memoria protetta.&lt;/p>
&lt;!-- Per gestire efficientemente la complessità interrupt, i sistemi spesso suddividono tra un gestore di interrupt di primo livello (FLIH) e un gestore di interrupt di secondo livello (SLIH). -->
&lt;!-- I s.o. utilizzano gli interrupt per compiti come il paging della memoria virtuale, dove un fault di pagina genera un interrupt, sospendendo il processo corrente ed eseguendo un gestore di fault di pagina. -->
&lt;p>Nei system call, gli interrupt software, o trap, facilitano la comunicazione tra le applicazioni utente e il kernel. Questi interrupt hanno una priorità inferiore rispetto agli interrupt dei dispositivi. Inoltre, gli interrupt gestiscono il flusso di controllo del kernel, dando priorità a compiti come il completamento delle letture dal disco.&lt;/p>
&lt;p>I gestori ad alta priorità gestiscono compiti critici, come l&amp;rsquo;avvio di operazioni di I/O in sospeso, mentre i gestori a bassa priorità gestiscono compiti meno urgenti, garantendo un utilizzo efficiente del disco. La gestione degli interrupt nei moderni sistemi operativi coinvolge il dispacciamento degli interrupt a bassa priorità quando la CPU è disponibile. Questi interrupt completano i compiti di I/O a livello utente prima di programmare le applicazioni per l&amp;rsquo;esecuzione.&lt;/p>
&lt;!-- Le architetture kernel threaded gestiscono efficientemente le priorità degli interrupt. Questa architettura consente di prelazionare i gestori di interrupt a bassa priorità per compiti di priorità più alta, migliorando la reattività del sistema. Gli interrupt sono fondamentali -->
&lt;h3 id="1214-accesso-diretto-alla-memoria">12.1.4 Accesso diretto alla memoria&lt;/h3>
&lt;p>Per trasferimenti efficienti di grandi quantità di dati, come quelli dei dischi rigidi è possibile utilizzare i controller di &lt;strong>accesso diretto alla memoria (Direct Memory Access, DMA)&lt;/strong> che gestiscono autonomamente i trasferimenti di dati, senza l&amp;rsquo;utilizzo della CPU. L&amp;rsquo;host inizializza il DMA scrivendo un blocco di comandi in memoria, dove viene specificata la sorgente, e la destinazione dei dati e il conteggio dei byte da trasferire. La tecnica di scatter-gather consente trasferimenti complessi tramite un singolo comando.&lt;/p>
&lt;p>Gli attuali sistemi operativi impiegano la mappatura della memoria per trasferimenti I/O diretti tra dispositivi e spazio degli indirizzi utente, migliorando le prestazioni.&lt;/p>
&lt;!-- La comunicazione tra il controller DMA e il controller del dispositivo avviene tramite i cavi DMA-request e DMA-acknowledge. Il controller del dispositivo segnala la disponibilità dei dati tramite DMA-request, inducendo il controller DMA ad accedere alla memoria. Una volta riconosciuto, i dati vengono trasferiti e i segnali vengono rimossi. Al termine, il controller DMA interrompe la CPU. Sebbene il "cycle stealing" possa rallentare brevemente il calcolo della CPU, il trasferimento dei dati generalmente migliora le prestazioni del sistema. Alcune architetture utilizzano l'accesso diretto alla memoria virtuale (Direct Virtual Memory Access, DVMA), bypassando l'intervento della CPU e l'uso della memoria principale. I kernel in modalità protetta impediscono i comandi diretti ai dispositivi per proteggere dati e stabilità del sistema. -->
&lt;!-- Invece, il s.o. fornisce ai processi privilegiati funzioni per l'accesso all'hardware. Al contrario, i kernel privi di protezione della memoria consentono l'accesso diretto ai dispositivi per ottenere prestazioni potenzialmente più elevate ma comportano rischi per la sicurezza. -->
&lt;h2 id="122-interfaccia-di-applicazione-io">12.2 Interfaccia di applicazione I/O&lt;/h2>
&lt;p>In questa sezione discutiamo delle interfacce e delle schematizzazioni utilizzate dai s.o. per trattare i dispositivi I/O in maniera uniforme.&lt;/p>
&lt;p>L&amp;rsquo;idea generale nello sviluppo degli algoritmi di funzionamento dei dispositivi I/O è quella di comunicare col dispositivo attraverso un&amp;rsquo;interfaccia di funzioni standard e poi incapsulare le differenze in moduli kernel chiamati driver di dispositivo. Lo scopo dei driver è di nascondere le differenze tra i controller di dispositivo al sottosistema I/O. In altre parole, rendere il sottosistema I/O indipendente dall&amp;rsquo;hardware semplifica il lavoro degli sviluppatori del s.o.&lt;/p>
&lt;p>I produttori di dispositivi possono invece:&lt;/p>
&lt;ul>
&lt;li>Sviluppare un dispositivo per essere compatibile con un&amp;rsquo;interfaccia di controllore già presente (p.e. SATA, per i dischi)&lt;/li>
&lt;li>Scrivere driver di dispositivo per interfacciarsi con i s.o. più popolari. Sfortunatamente ogni s.o. ha i propri standard per i driver di interfaccia.&lt;/li>
&lt;/ul>
&lt;p>Alcune scelte nel protocollo di comunicazione dei dispositivi:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Trasferimento a flusso di caratteri o a blocchi:&lt;/strong> un dispositivo può trasferire i byte uno ad uno oppure a gruppi.&lt;/li>
&lt;li>&lt;strong>Accesso sequenziale o diretto:&lt;/strong> un dispositivo può trasferire i dati seguendo un ordine fisso oppure puntando a posizioni non necessariamente contigue.&lt;/li>
&lt;li>&lt;strong>Sincrono o asincrono:&lt;/strong> un dispositivo può trasferire i dati in coordinamento con altri aspetti del sistema oppure con tempi di risposta variabili, non sincronizzati agli eventi del s.o.&lt;/li>
&lt;li>&lt;strong>Condiviso o dedicato:&lt;/strong> un dispositivo può essere utilizzato da molti processi contemporaneamente oppure da un solo processo alla volta.&lt;/li>
&lt;li>&lt;strong>Velocità di esecuzione:&lt;/strong> i dispositivi hanno velocità che spaziano da pochi byte al secondo a diversi gigabyte al secondo.&lt;/li>
&lt;li>&lt;strong>Read-write, read-only o write-once:&lt;/strong> alcuni dispositivi supportano la scrittura e la lettura, mentre altri solo una delle due operazioni. Inoltre in alcuni dispositivi la scrittura può essere effettuata solo una volta e il dato diventa read only, dopo.&lt;/li>
&lt;/ul>
&lt;p>Molti s.o. hanno un meccanismo di &lt;strong>escape&lt;/strong> (o &lt;strong>backdoor&lt;/strong>) che permette alle applicazioni di passare comandi arbitrari direttamente ai driver.&lt;/p>
&lt;h3 id="1221-dispositivi-con-trasferimento-a-blocchi-e-a-caratteri">12.2.1 Dispositivi con trasferimento a blocchi e a caratteri&lt;/h3>
&lt;p>L&amp;rsquo;&lt;strong>interfaccia per dispositivi a blocchi (block-device interface)&lt;/strong> è pensata per dispositivi che gestiscono il trasferimento dei dati a unità chiamate blocchi. Il s.o. preferisce a volte accedere al dispositivo considerandolo come un semplice array di blocchi. Questo modo di accesso è chiamato &lt;strong>raw I/O&lt;/strong>. È possibile che un&amp;rsquo;applicazione che utilizza il dispositivo si occupi direttamente di gestire il buffering e il locking di regioni condivise. In questo caso, i s.o. moderni possono passare a una modalità in cui tali funzionalità sono disabilitate per il processo. Nel mondo UNIX, questa modalità è chiamata &lt;strong>direct I/O&lt;/strong>. Un esempio di dispositivo a blocchi è il file.&lt;/p>
&lt;p>La tastiera, il mouse o il modem sono un esempi di &lt;strong>interfaccia per dispositivi a caratteri (character-device interface)&lt;/strong>. Le chiamate di sistema leggono o inseriscono un carattere alla volta.&lt;/p>
&lt;h3 id="1222-i-dispositivi-di-rete">12.2.2 I dispositivi di rete&lt;/h3>
&lt;p>Nel caso di dispositivi di rete utilizziamo delle interfacce chiamate &lt;strong>socket (prese)&lt;/strong>. Il meccanismo è simile a quello di una presa elettrica. L&amp;rsquo;applicazione genera una socket che permette a un indirizzo remoto di connettersi. A sua volta l&amp;rsquo;applicazione si connette alla socket generata dall&amp;rsquo;applicazione in remoto.&lt;/p>
&lt;h3 id="1223-clock-e-timer">12.2.3 Clock e timer&lt;/h3>
&lt;p>I clock e i timer forniscono le seguenti tre funzioni:&lt;/p>
&lt;ul>
&lt;li>Danno il tempo corrente&lt;/li>
&lt;li>Danno il tempo rimasto&lt;/li>
&lt;li>Impostano delle deadline per emettere un evento in un istante di tempo prefissato&lt;/li>
&lt;/ul>
&lt;p>L&amp;rsquo;hardware preposto si chiama &lt;strong>timer di intervallo programmabile&lt;/strong>. Viene impostato a un determinato istante di tempo per generare un interrupt. Si può impostare per emettere l&amp;rsquo;evento una volta o periodicamente. Un clock/timer può essere soggetto a deviazioni (drift) dal tempo che deve tracciare. Tali deviazioni possono essere corrette attraverso l&amp;rsquo;utilizzo di protocolli specifici come &lt;strong>Network Time Protocol (NTP)&lt;/strong> che mantiene la precisione di clock di sistema a livelli paragonabili a quelli degli orologi atomici.&lt;/p>
&lt;h3 id="1224-io-asincrono-e-non-bloccante">12.2.4 I/O asincrono e non bloccante&lt;/h3>
&lt;p>Possiamo dividere le chiamate a sistema in tre categorie:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>I/O bloccante:&lt;/strong> una volta effettuata la chiamata, il thread chiamante attende la sua esecuzione e riceve il risultato dell&amp;rsquo;operazione di I/O. Spesso le chiamate I/O non sono bloccanti, poiché i dispositivi I/O hanno tempi imprevedibili, tuttavia il s.o. fornisce anche la possibilità di avere chiamate bloccanti poiché sono più semplici da gestire&lt;/li>
&lt;li>&lt;strong>I/O non bloccante:&lt;/strong> una volta effettuata la chiamata, questa ritorna velocemente. Il risultato è il numero di byte che sono stati trasferiti (tutti, una parte o nessuno). Un esempio di processo che richiede I/O non bloccante è un&amp;rsquo;interfaccia utente che processa indipendentemente l&amp;rsquo;input da mouse e da tastiera mentre visualizza gli output sul monitor&lt;/li>
&lt;li>&lt;strong>I/O asincrono:&lt;/strong> una volta effettuata la chiamata, questa ritorna immediatamente, senza il risultato, che viene computato completamente ma in futuro&lt;/li>
&lt;/ul>
&lt;h3 id="1225-io-vettorizzato">12.2.5 I/O vettorizzato&lt;/h3>
&lt;p>Alcuni s.o. permettono di effettuare molteplici operazioni I/O attraverso un&amp;rsquo;unica chiamata a sistema.&lt;/p>
&lt;h2 id="123-sottosistema-di-io-del-kernel">12.3 Sottosistema di I/O del kernel&lt;/h2>
&lt;p>I kernel forniscono vari servizi relativi all&amp;rsquo;I/O, raggruppati nel sottosistema I/O. Vediamoli.&lt;/p>
&lt;h3 id="1231-scheduling-io">12.3.1 Scheduling I/O&lt;/h3>
&lt;p>Per programmare un insieme di richieste I/O è importante stabilire un ordine di esecuzione che massimizzi le performance del dispositivo. Gli sviluppatori implementano gli scheduler I/O utilizzando una coda d&amp;rsquo;attesa che può essere riordinata all&amp;rsquo;occorrenza, sulla base delle performance ma anche mantenendo un&amp;rsquo;equità d&amp;rsquo;utilizzo da parte delle applicazioni sulla base delle priorità. Vediamo alcuni aspetti dello scheduling I/O.&lt;/p>
&lt;h3 id="1232-buffering">12.3.2 Buffering&lt;/h3>
&lt;p>Un &lt;strong>buffer&lt;/strong> è un&amp;rsquo;area di memoria che salva i dati in trasferimento tra due dispositivi e o tra un dispositivo e un&amp;rsquo;applicazione. Il buffering viene effettuato per tre ragioni:&lt;/p>
&lt;ul>
&lt;li>Gestire lo squilibrio tra la velocità di produzione e di consumo dei dati. Per esempio, quando è necessario scrivere in una memoria secondaria un messaggio proveniente da Internet, è possibile programmare due buffer nella memoria principale. La rete inizia a trasmettere dati in un buffer. Quando questo è pieno la rete scrive sul secondo buffer, mentre il primo viene svuotato in memoria secondaria, e così via, finché il messaggio completo non viene salvato. Questo metodo è chiamato &lt;strong>double buffering&lt;/strong>&lt;/li>
&lt;li>Gestire lo squilibrio tra le dimensioni delle unità di trasferimento. Per esempio, in una rete, un messaggio da inviare viene frammentato in pacchetti piccoli che vengono ricevuti in buffer di destinazione per poi essere riassemblati&lt;/li>
&lt;li>Gestire la semantica di copia per applicazioni I/O. Nel caso un&amp;rsquo;applicazione voglia scrivere nella memoria secondaria un buffer di dati prodotti, deve assicurarsi che il buffer non cambi durante la copia. Per questo motivo, è possibile creare un buffer kernel che operi la copia mentre il buffer dell&amp;rsquo;applicazione continua a produrre dati.&lt;/li>
&lt;/ul>
&lt;h3 id="1233-caching">12.3.3 Caching&lt;/h3>
&lt;p>Una &lt;strong>cache&lt;/strong> è una regione di memoria che mantiene copie di dati e garantisce un accesso più efficiente della memoria normale. La differenza con il concetto di buffer è che la cache mantiene &lt;em>una copia&lt;/em> per definizione, mentre un buffer può contenere dati originali.&lt;/p>
&lt;p>A volte una regione di memoria può essere utilizzata sia per buffering che per caching. Per esempio, il s.o. utilizza dei buffer per mantenere nella memoria principale alcune informazioni presenti in memoria secondaria utili a un processo. Se un altro processo vuole accedere agli stessi dati in sola lettura il s.o. può utilizzare il buffer in memoria come una cache e condividere i dati, minimizzando così lo spazio consumato in memoria.&lt;/p>
&lt;h3 id="1234-spooling">12.3.4 Spooling&lt;/h3>
&lt;p>Uno &lt;strong>spool&lt;/strong> è un buffer che mantiene l&amp;rsquo;input inviato a un dispositivo in attesa che venga processato. Per esempio, è possibile inviare più input contemporaneamente a una stampante. In questo caso il primo input viene processato e gli altri vengono salvati in file temporanei. Il sistema di spooling si occupa di gestire la coda degli input.&lt;/p>
&lt;h3 id="1235-gestione-degli-errori">12.3.5 Gestione degli errori&lt;/h3>
&lt;p>Un s.o. che utiliza meccanismi di protezione della memoria si può difendere contro molto tipi di errori hardware e d&amp;rsquo;applicazione evitando un generale malfunzionamento di sistema. In generale, un s.o. ritornerà un bit di informazione che indica lo stato della chiamata (successo o fallimento). In UNIX, viene anche ritornato un intero indicante il tipo dell&amp;rsquo;errore.&lt;/p>
&lt;h3 id="1236-protezione-io">12.3.6 Protezione I/O&lt;/h3>
&lt;p>Un utente potrebbe accidentalmente o volontariamente alterare la normale operatività di un sistema chiedendo di eseguire istruzioni I/O illegali. Per tale motivo tutte le operazioni I/O sono considerate privilegiate e l&amp;rsquo;utente non le può avviare direttamente, ma deve invece chiedere al s.o. di eseguirle. Il s.o., in modalità monitor, controlla che la richiesta sia valida ed esegue l&amp;rsquo;I/O richiesta.&lt;/p>
&lt;div id="references-1784797997021292546" class="references" style="margin-top: 5rem;">
&lt;p>&lt;strong>References&lt;/strong>&lt;/p>
A. Silberschatz, P. B. Galvin, G. Gagne, &lt;em>Sistemi Operativi. Concetti ed Esempi&lt;/em>, Decima edizione. Ch. 12.
&lt;/div>
&lt;script>
document.addEventListener('DOMContentLoaded', function () {
var ref = document.getElementById('references-1784797997021292546');
var fn = document.querySelector('.footnotes');
if (ref &amp;&amp; fn &amp;&amp; fn.parentNode) {
fn.parentNode.insertBefore(ref, fn.nextSibling);
}
});
&lt;/script></description></item></channel></rss>