


















Studia grazie alle numerose risorse presenti su Docsity
Guadagna punti aiutando altri studenti oppure acquistali con un piano Premium
Prepara i tuoi esami
Studia grazie alle numerose risorse presenti su Docsity
Prepara i tuoi esami con i documenti condivisi da studenti come te su Docsity
Trova i documenti specifici per gli esami della tua università
Preparati con lezioni e prove svolte basate sui programmi universitari!
Rispondi a reali domande d’esame e scopri la tua preparazione
Riassumi i tuoi documenti, fagli domande, convertili in quiz e mappe concettuali
Studia con prove svolte, tesine e consigli utili
Togliti ogni dubbio leggendo le risposte alle domande fatte da altri studenti come te
Esplora i documenti più scaricati per gli argomenti di studio più popolari
Ottieni i punti per scaricare
Guadagna punti aiutando altri studenti oppure acquistali con un piano Premium
Libro di Michael Dahlin, Thomas Anderson tradotto in italiano (primi 13 capitoli)
Tipologia: Appunti
1 / 26
Questa pagina non è visibile nell’anteprima
Non perderti parti importanti!



















Lezione 13/03/2018 (Capitolo 5)
Conosciamo due ambienti:
Alla lunga si è pensato di avere sia ambiente globale che locale come nella figura sottostante:
Codice
Sincronizzazione:
I compilatori attuali utilizzano tecniche di ottimizzazione riordinando le istruzioni, quindi non ho nessun controllo sull’ordine in cui esse vengono eseguite.
Problema del troppo latte:
In un appartamento vi sono 2 studenti fuori sede, tutti e due fanno colazione con il latte, quindi nel frigorifero ci deve essere una e una sola confezione di latte. Quando finisce il latte cosa potrebbe accadere?
Proprietà fondamentali:
La soluzione sarebbe quella di lasciare un post-it prima di andare a comprare il latte. Come nella figura sottostante:
Questa soluzione potrebbe non funzionare, poiché non dico chi ha lasciato il post-it. Quindi inseriamo la firma:
Questa soluzione non funziona poiché se A lascia il post-it e viene rimosso il processo A, passa il Thread B che lascia il suo post-it controlla che ci sia il post-it di A (la condizione dall’assunzione precedente è verificata) quindi il thread B viene deschedulato (lasciando la nota) => Ne il thread A, Ne il thread B comprano il latte.
La soluzione corretta è la sottostante:
Problemi:
Aspetto fin quando la lock è disponibile (ATTESA ATTIVA).
Sarebbe più intelligente: Che il thread venga sospeso fin quando la lock non è disponibile su una variabile di condizionamento
Variabili di condizionamento: Alle lock quindi è ben associare una condizione di blocco (attraverso le code) in cui i thread aspettano che la lock possa essere ottenuta. È un blocco da software (a differenza di una lettura del disco che è da hardware).
API Variabile di condizionamento:
LEZIONE (14/03/18)
Soluzione finale al TTM
Lock.acquire();
if (!milk) buy it;
Lock.release();
Chi arriva prima nella lock.acquire() la ottiene dopo che faccio la broadcast, gli altri si sospendono nella lock e non sulla variabile condizione!
Problema produttore consumatore
Un problema che ci poniamo è: Che succede se volessi scrivere qualcosa ma il buffer è pieno? Non posso buttare via l’oggetto prodotto, lo stesso ragionamento è quando voglio prelevare un oggetto ma il buffer è vuoto! Non possono ignorare la richiesta, al contrario devo aspettare fin quando c’è un oggetto da prendere.
Variabili Condizione
Chiamate quando si è ottenuta la chiave.
Avendo un meccanismo del genere, potrei spostare il TCB dalla running -> waiting list sospesi su una certa variabile condizione associate a delle lock.
Una soluzione potrebbe essere quella a fianco.
Regole affinché le VC funzionano bene:
OSS: Assunzioni di base
Regole generale per la sincronizzazione: (Sincronizzazione strutturata)
SEMANTICA DI VC
OSS: Sempre se non arriva un’interruzione, e viene scombinato tutto all’interno delle nostre strutture dati.
OSS: Se volessi svegliarmi in ordine FIFO? Non avrò una coda unica per tutti i processi poiché lo scheduler deciderebbe chi far partire. Mi conviene avere una coda single-thread (quindi una variabile condizione per ogni thread, quindi una coda privata per thread ), quindi avrò una lista di VC e attraverso il metodo first prenderei il primo della coda(quindi potrei realizzarlo in modo FIFO).
Normalmente (non è una gestione fifo)
Ti Tj Tk Tl Ts Tu
VC empty per ogni thread
1.ii. Lock_release: abilito interruzioni
1.b. OSS TMM: Veniva risolto perché riuscivamo ad eseguire il tutto in una sola istruzione al linguaggio macchina
Realizzazione delle lock
waiting.add(current TCB) è la coda della lock che sto cercando di prendere.
Multiprocessore
Le interruzioni le disabilito solo sul processore in cui sono in esecuzione. Ma questo non basta, devo prevedere un’istruzione macchina che mi guarda il contenuto di una variabile e in una istruzione macchina me la modifica.
Cioè questo qui a fianco deve essere fatto in un'unica istruzione macchina. Per esempio, test_and_set legge un valore in memoria, e lo modifica in un'unica istruzione in modo da realizzare le lock e le unlock.
Affrontiamo anche il problema della sospensione (nel caso di uniprocessore le interruzioni sono disabilitate) nel sistema multiprocessore potrei anche fare un’attesa attiva.
SpinLock (solo per multiprocessore)
Se era Busy non faccio nulla, altrimenti se fosse stato free testAndSet mi cambia il valore in Busy.
La spinlock mi da garanzia sugli altri processori ma non su me stesso.
LEZIONE 15/03/
Le vc devono essere utilizzate insieme alle lock per poter garantire una concorrenza corretta.
La condizione deve essere verificata all’interno di un ciclo while.
Conviene utilizzare pos_libere e pos_occupate al posto di confronti di posizioni.
Abbiamo una condizione booleana associata strettamente ad una coda.
Esiste un meccanismo che mi tiene appiccicata il test sulla condizione e l’eventuale accodamento, tale meccanismo è chiamato semaforo. Serve per gestire l’accesso concorrente alla risorsa (ovvero l’incrocio nel caso dei semafori, nel caso di memoria è il buffer), in modo tale che solo uno possa accederci. Ha sia il valore della condizione che vado a testare ( valore intero ) + una coda (gestita FIFO).
Operazioni (syscall): Se è > 0 decremento di 1! Ma quando entro?
■ Potrei perdere l’uso del processore quando accedo, quindi la P prenota la risorsa.
Sem:
Op:
Capitolo 5 (DAL LIBRO)
Accesso Sincronizzato agli oggetti condivisi
I programmi multi-thread estendono il tradizionale modello di programmazione single-thread, ciascuno dei quali fornisce un singolo stream di esecuzione sequenziale composto da istruzioni familiari. Se avessimo soltanto thread indipendenti che operano su sottoinsiemi di stato completamente separati, allora possiamo trattare ogni thread separatamente. In questo caso la scrittura e il ragionamento su thread indipendenti differiscono poco dallo scrivere e ragionare su una serie indipendente di programmi single-thread.
Comunque, la maggior parte dei programmi multi-thread hanno sia uno stato del thread che uno stato condiviso. La cooperazione tra thread permette di leggere e scrivere su uno stato condiviso.
Lo stato condiviso tra i thread è molto utile poiché permette ai thread di comunicare, coordinare il lavoro e condividere informazioni.
Sfortunatamente, quando i thread cooperano utilizzano uno stato condiviso, quindi scrivere un programma multi-thread corretto è molto più complicato. La maggior parte dei programmatori sono abituati a pensare sequenzialmente quando scrivono un programma. Questo tipo di modello non è possibile utilizzarlo nei programmi con cooperazione tra thread per 3 ragioni
1.a. Per esempio, se due thread volessero scrivere un valore ad una certa variabile, la variabile avrà il valore dell’ultimo thread che ha scritto.
Q: Come possiamo ragionare su tutti i possibili interleaving dei thread?
1.b.Differenti esecuzioni dello stesso programma producono risultati differenti. Nell’esempio 1.a l’ultimo thread a scrivere potrebbe essere il primo thread o il secondo.
1.c. Jim Gray nel 1998 coniò il termine Heisenbugs per i bug che scompaiono o cambiano il loro comportamento quando provi ad esaminarlo.
Q: Come possiamo debuggare un programma se il suo comportamento cambia durante l’esecuzione?
1.d.I moderni hardware e compilatori riorganizzeranno le istruzioni per migliorare la performance. Questa organizzazione è generalmente invisibile per i programmi single- thread; I compilatori e i processori hanno cura di assicurarsi che le dipendenze tra sequenze di istruzioni sono preservate. Comunque, questo riordinamento può diventare visibile quando thread multipli interagiscono e osservano gli stati intermedi. Osservando il seguente codice:
Anche se potrebbe sembrare che questo codice assicura che p è inizializzato prima di anotherComputation(p), questo potrebbe non accadere. Per massimizzare il livello di
parallelismo tra le istruzioni, l’hardware o il compilatore dovrebbe prima inizializzare pIsInitialized = true prima che la computazione di p sia completata, e anotherComputation (p) potrebbe essere eseguito con un valore inaspettato.
Come possiamo ragionare sull’interleaving dei thread quando i compilatori e l’hardware riorganizzano le operazioni dei thread?
Strutture di sincronizzazione
Dopo aver analizzato queste sfide, il codice multi-thread può introdurre bug sottili, non deterministici, e non replicabili. Questo capitolo descrive l’approccio per strutture di sincronizzazione per lo stato condiviso nei programmi multi-thread. Piuttosto che spargere l’accesso allo stato condiviso per tutto il tempo e ragionamenti ad hoc sui modi possibili di interleaving degli accessi dei thread, strutturiamo il programma per facilitare il ragionamento su esso e usiamo un insieme di primitive di sincronizzazione standard per controllare l’accesso allo stato condiviso.
La prima parte di questa sezione si focalizza sulle sfide affrontate dai programmatori multi-thread e sul perché sia inutile cercare di ragionare su tutti i possibili interleaving dei thread nel caso generale, non strutturato.
Il resto del capitolo descrivere le strutture degli oggetti condivisi nei programmi multi-thread in modo tale da poter ragionarci sopra. Descriveremo gli aspetti di questa struttura. Prima, struttureremo uno stato condiviso per i programmi multi-thread come un insieme di oggetti condivisi che incapsulano lo stato condiviso, definendo e limitando come esso può essere acceduto. In secondo, per evitare ragionamenti ad hoc sui possibili interleaving di accessi alle variabili di stato all’interno di un oggetto condiviso, descriveremo come gli oggetti condivisi includono un piccolo insieme di primitive di sincronizzazione come lock e variabili di sincronizzazione per coordinare gli accessi al loro stato da thread differenti. Infine, per semplificare il ragionamento sul codice degli oggetti condivisi, descriveremo un insieme di metodi per scrivere il codice che implementa lo stato condiviso. Poiché le prime due ragioni sono strettamente correlate tra di loro, indirizziamo questi 2 in due sezioni principali:
1.a. Come utilizziamo le lock e le variabili di sincronizzazione per incapsulare lo stato condiviso?
Infine, è importante per capire come lavorano i tool che usiamo attualmente, quindi esamineremo i dettagli di come le primitive di sincronizzazione sono implementate.
I programmi multi-thread hanno la reputazione di essere complicati. Questo capitolo fornisce un insieme di semplici regole che ognuno può seguire per implementare oggetti che possono essere condivisi da thread multipli.
5.1 Sfide
L’inizio di questa sezione definisce le sfide principali della programmazione multi-thread: un’esecuzione di un programma multi-thread dipende dai differenti modi di accedere alla memoria condivisa in presenza di interleaving, per il quale può risultare difficile ragionare o fare il debug. In particolare, l’esecuzione dei thread che cooperano potrebbe essere affetta da race condition.
Possiamo definire ogni coinquilino come un thread, quindi possiamo modellare il numero di bottiglie di latte con una variabile in memoria. La domanda è, se le sole operazioni atomiche nello stato condiviso, possono assicurarci sia la sicurezza che la vitalità (liviness).
Assunzioni semplificate. Per tutta la nostra analisi in questa sezione, assumeremo che le istruzioni sono eseguiti esattamente nell’ordine in cui sono scritte. Questa assunzione è cruciale per ragionare sull’ordine delle operazioni atomiche (load, store), ma molti moderni compilatori e architetture violano questa linea di pensiero, quindi bisogna stare molto attenti ad utilizzare questo approccio.
Soluzione 1.
L’idea di base è quella di lasciare una nota prima di andare a compare il latte. Questa via può essere realizzata tramite flag. Si setta ad 1 quando si sta andando a comprare il latte e controllare tale flag prima di andare a comprarlo.
Sfortunatamente, l’implementazione può violare la sicurezza. Per esempio, il primo thread potrebbe eseguire fino al controllo del post-it, poi quest’ultimo viene deschedulato (prima di mettere note=1). Il secondo thread esegue interamente il suo codice, quindi compra la bottiglia di latte e lascia il note=0. Il primo thread controllerà se esiste un post-it, questo non esiste poiché il thread precedente ha settato note=0, allora andrà a comprare un'altra bottiglia di latte violando quella che è la sicurezza, poiché avremmo 2 bottiglie di latte.
Questa soluzione peggiora il problema. Il codice menzionato prima potrebbe funzionare in certi casi, ovvero quando lo scheduler fa la cosa giusta. Creando così un Heisenbug che causerà qualche volta la violazione della sicurezza.
Soluzione 2.
Nella soluzione precedente, prima di poter settare il note dovevamo controllarlo, il quale ha causato un interleaving non corretto. Se utilizzassimo due variabili, un coinquilino può lasciare una nota prima di guardare l’altra poi successivamente controllare la “variabile del latte” e in tal caso prendere la decisione di comprarlo.
Se il primo thread eseguisse il Path A, e il secondo il Path B, il protocollo sarebbe “safe”. Sapendo che ogni thread scrivere una nota prima di decidere se compare il latte, ci assicuriamo che entrambi i thread non possono mai comprare il latte insieme.
Sebbene l’intuizione potrebbe sembrare giusta, dimostrare la sicurezza enumerando tutti i possibili interleaving richiede un po’ di attenzione.
Prova di sicurezza. Assumendo per contraddizione che l’algoritmo sia “unsafe” – ovvero che A e B comprano il latte. Consideriamo lo stato delle due variabili (noteB, milk) quando il thread A sta eseguendo atomicamente l’istruzione A1 nel momento in cui la load atomica del noteB dalla memoria condivisa del registro di A occorre. Ci sono 3 casi da considerare:
Liviness. Sfortunatamente, la soluzione 2 non ci assicura la “ LIVINESS”. In particolare, è possibile per entrambi i thread settare i loro corrispondenti post-it, per ogni thread controllare il post-it dell’altro, e per entrambi i thread decidere di non compare il latte.
Soluzione 3.
La soluzione 2 era sicura poiché un thread che avrebbe voluto comprare il latte avrebbe evitato se ci fosse stato la possibilità che qualcun altro lo stava già facendo.
Per mostrare che la soluzione 3 garantisce la “liviness”, osserviamo che il path B non ha nessun loop, così se eventualmente il thread B finische di eseguire il suo codice NoteB == 0 diventa vero e rimane tale. Perciò, eventualmente il thread A deve raggiungere la linea M e decidere se compare il latte. Se trova M==1, allora il latte è stato comprato, garantendo la “liviness”. Se trovasse M==0, comprerà il latte, garantendo anche stavolta la “liviness”.
5.1.4 (Discussione sulle prestazioni)
5.1.5 Una soluzione migliore
La prossima sezione descriverà un approccio migliore per scrivere programmi nei quali thread multipli accedono ad uno stato condiviso. Descriveremo oggetti condivisi che usano oggetti di sincronizzazione per coordinare tali accessi.
Supporremo, per esempio, di avere una primitiva chiamata lock che assicura che un thread alla volta può ottenere la chiave. Poi, definiremo una classe Kitchen con il suo metodo per risolvere il problema TMM.
Le variabili di sincronizzazione coordinano l’accesso alle variabili di stato (semplici variabili come le istanze di un oggetto).
Usando le variabili di sincronizzazione semplificheremo l’implementazione degli oggetti condivisi.
Piuttosto che implementare le variabili di sincronizzazione come lock e variabili di condizionamento utilizzando solo load e store atomiche come visto per il problema del TMM, le moderne implementazioni costruiscono le variabili di sincronizzazione utilizzando istruzioni read-modify- write atomiche. Un’istruzione di questo tipo permette ad un thread di avere l’accesso esclusivo di una locazione di memoria in modo tale che non venga interrotto durante la sua operazione.
Scopo principale: Come illustrato nella figura 5.1, programmi concorrenti sono costruiti su oggetti condivisi. Il resto di questo capitolo si focalizzerà sui 3 principali livelli della figura.
5.3 LOCK: Mutua Esclusione
DEF: è una variabile di sincronizzazione che fornisce una mutua esclusione – quando un thread ottiene “la lock”, nessun altro può prenderla.
Un programma associa ogni lock con qualche sotto-insieme di stati condivisi e richiede che un thread ottenga la lock prima di accedere ad uno di questi stati. Infine, solo un thread alla volta può accedere allo stato.
La mutua esclusione semplifica il modo di ragionare sui programmi poiché un thread può eseguire un arbitrario insieme di operazione fin tanto che esso ha la lock, e queste operazioni appaiono in modo atomico dal punto di vista dei thread. In particolare, poiché le lock forzano la mutua esclusione e poiché i thread devono ottenere la lock prima di accedere allo stato condiviso, nessuno altro thread può accedere a quel determinato stato.
È molto più semplice ragionare sull’interleaving di gruppi atomici di operazioni piuttosto che sull’interleaving di ciascun’operazione per due ragioni:
In particolare, con gli oggetti condivisi utilizzeremo di solito una lock per “assicurare” tutto l’intero stato dell’oggetto, e avremo un metodo pubblico per acquisire/rilasciare la lock
5.3.1 Lock: API e proprietà
Una lock fornisce la mutua esclusione attraverso due metodi: acquire() e release(). Queste operazioni sono definite nel seguente modo:
Acquire: Aspetta fin quando la lock è libera, poi atomicamente setta la lock a BUSY.
Controllando lo stato e settare il nuovo stato viene fatto in modo atomico. In modo tale che se thread multipli tentano di acquisirla, solo uno ci riesce.
Release(): La lock è libera (FREE). Se ci fossero richieste di acquire() pendenti, cambiando lo stato permetteremo a uno dei thread di procedere.
Proprietà formali: Le precedenti definizione descrivono le operazioni di base di una lock. Una lock può essere definita più precisamente come segue:
Abbiamo detto che un thread ottiene la lock dopo aver fatto un acquire() e la rilascia attraverso la release(). Abbiamo detto che un thread aspetta una lock se viene fatta un acquire ma questa non viene ottenuta.
Una lock dovrebbe assicurare le seguenti 3 proprietà:
Non-proprietà: Ordinamento dei thread: Il limite d’attesa per la “fariness propriety” garantisce che eventualmente un thread otterrà una chance di acquisire la lock ma, per esempio, nessuno promette che il thread acquisirà tale chiave in modo FIFO.
5.3.2 Lock e stati condivisi
Ogni stato condiviso è un istanza di una classe che definisce lo stato della classe e i metodi per operare con essa.
Questo stato include le variabili di stato e le variabili di sincronizzazione. Sebbene, ogni volta che utilizziamo un costruttore della classe per produrre un'altra istanza dell’oggetto condiviso, allochiamo una nuova lock e una nuova istanza dello stato che viene protetta da tale lock.
Nel codice soprastante è definito un semplice oggetto condiviso,
Le variabili condizione forniscono un modo per un thread di aspettarne un altro per fare qualche azione. Per esempio, nella coda-“safe” vista in precedenza, piuttosto che ritornare un errore quando si tentava di rimuovere un elemento da una coda vuota, avremmo potuto aspettare fin quando la coda non aveva almeno un elemento da estrarre.
Similmente, un web server dovrebbe aspettare fin tanto che una nuova richiesta arrivi.
Un modo per far si che un thread aspetti un altro che agisca sarebbe quello di verificare ripetutamente lo stato di interesse ( poll-repeatedly). Nell’esempio della TSQueue, potremmo inserire la tryRemove all’interno di un loop per fornire un metodo remove() che restituisce sempre un elemento come nel seguente esempio:
Sfortunatamente, usare questo tipo di approccio può essere inefficiente poiché aspettare un thread all’interno di un loop, consumerebbe cicli macchina della CPU senza che esso faccia alcun progresso. Peggio ancora, potrebbe ritardare lo scheduling di altri thread – probabilmente quelli per i quali thread in loop sono in attesa.
Un possibile soluzione all’approccio polling-based è quello di aggiungere un ritardo, Per esempio, nell’esempio precedente potremmo aspettare, con una yield (quindi lasciando il processore), per 100ms dopo che non si è potuto rimuovere un elemento dalla coda con la chiamata alla tryRemove() in modo tale da introdurre un ritardo dopo che si è verificato un fallimento.
Questo tipo di approccio ha 2 problemi. Il primo, sebbene riduce l’inefficienza del polling, non la elimina. Sospendere e schedulare un thread impone spese generale non banali, e se un programma avesse una vasta collezione di thread polling ogni 10-100 millisecondi, questo potrebbe consumare risorse significative. In secondo luogo, un polling periodico introduce latenza.
5.4.1 Definizione di variabile di condizionamento
Una variabile di condizionamento è un oggetto di sincronizzazione che abilita i thread di aspettare efficientemente una modifica allo stato condiviso protetto da una lock. Una variabile di condizionamento ha 3 metodi:
Come possiamo notare una variabile-condizione è sempre associata ad una lock. Una usa una variabile- condizione per attendere una modifica allo stato condiviso e aggiorna lo stato condiviso che è protetto dalla lock. Così, le API della variabili-condizione sono costruite accuratamente per lavorare in mutua esclusione.
In particolare, il pattern standard per un oggetto condiviso è quello di includere una o più lock o più variabili di condizionamento. Quindi, un metodo che usa le variabili di condizionamento può essere scritto nel modo seguente:
In questo codice, la chiamata del thread prima acquisisce la lock in modo tale che può leggere e scrivere lo stato condiviso della variabile. Per aspettare fin quando il testOnSharedState () è verificato, il thread chiama la wait () sulla variabile di condizione “cv” dello stato condiviso. Poi, una volta che il thread viene eseguito di nuovo e vede che il testOnSharedState () è verificato, può fare ciò che vuole, infine rilascia la lock e termina.
Il codice seguente invece, è stato modificato in modo tale da permettere un thread in attesa di fare progressi e utilizzare la signal sulla variabile di condizionamento in modo tale da far partire tale thread.
Variabili di condizionamento integrate con le lock. Da notare che un thread in attesa è sempre in attesa che lo stato di un oggetto condiviso cambi, quindi deve verificare tale stato all’interno di un loop. Allora il metodo wait della variabile di condizionamento rilascia la lock (per lasciare che altri thread modifichino lo stato) per poi riprenderla (per verificare lo stato di nuovo).
Similmente, la sola ragione per la thread signal () o broadcast () è quella di cambiare lo stato in un modo che potrebbe interessare un thread in attesa. Per poter far tutto ciò, il thread deve acquisire la lock, quindi sia la signal che la broadcast sono sempre chiamate nel frattempo che si ha la lock dello stato che è stato cambiato.
( Tra il 5.4.1 e il 5.4.2 sono presenti delle note al programmatore)
5.4.2 Rivisitazione del ciclo di vita del thread.
Nel capitolo 4, abbiamo discusso su come un thread poteva cambiare il suo stato in Ready, Waiting, Running. Possiamo spiegare adesso lo stato di Waiting ancora più in dettaglio.
Un thread in running chiama la wait e mette tale thread nello stato di Waiting. Questo è tipicamente implementato spostando il TCB di tate thread dalla coda di ready nella coda di waiting associata ad una variabile di condizione, da parte dello scheduler. Poi, quando qualcun altro in running chiama la signal o broadcast su quella variabile condizione, un TCB nel caso della signal o tutti nel caso della broadcast viene/ vengono spostato/spostati dallo scheduler nella ready list. Questo cambia lo stato da Waiting -> Ready. Poco tempo dopo lo scheduler decide di mandare in esecuzione un thread che è presente nella ready list.
Le lock sono simili. L’acquire () in una lock già ottenuta da qualche thread, porta il chiamante nello stato di Wait associato alla lock richiesta. Qualche momento più tardi, quando il proprietario della lock la rilascia con la release (), il TCB del chiamante precedente viene messo nella ready list.