Costante Luca Giardino Daniele

Dimensione: px
Iniziare la visualizzazioe della pagina:

Download "2009-2010. Costante Luca Giardino Daniele"

Transcript

1 Costante Luca Giardino Daniele

2 PCP Costante Luca, Giardino Daniele SOMMARIO SOMMARIO... 1 CAPITOLO 1 - INTRODUZIONE AL CORSO... 1 Programmazione concorrente... 1 Le sfide... 2 Architetture multi-core... 2 Processori e Threads... 3 Le interconnessioni... 3 La memoria... 4 Cache... 4 Protocollo MESI... 4 Spinning... 5 False sharing... 5 Relax consistency... 6 Architettura multicore... 6 Domande di riepilogo... 7 CAPITOLO 2 - INTRODUZIONE ALLA PROGRAMMAZIONE CONCORRENTE... 8 Oggetti condivisi e sincronizzazione... 8 Una fiaba... 9 Il problema del Produttore-Consumatore Il problema Lettori/Scrittori Java e la concorrenza Thread e monitor Thread local objects La java virtual machine e i thread Domande di riepilogo CAPITOLO 3 MUTUA ESCLUSIONE Time Sezione critica Soluzione 2-thread La classe LockOne La classe LockTwo Il lock di Peterson Correttezza L algoritmo del fornaio di Lamport... 20

3 PCP Costante Luca, Giardino Daniele Motivazioni al lower bound sulla memoria utilizzata Domande di riepilogo CAPITOLO 4 OGGETTI CONCORRENTI Oggetti sequenziali Il singleton Concorrenza e correttezza Condizioni di correttezza Consistenza quiescente Consistenza sequenziale Linearizzabilità Condizioni di progresso Il modello di memoria di Java Domande di riepilogo CAPITOLO 5 SHARED MEMORY Lo spazio dei registri Spin lock e contesa Real Word Lock Test-And-Set TAS e TTAS basato su Spin Locks Backoff esponenziale Lock tramite code (QUEUE) La coda CLH Un lock di coda con timeout Lock composite Lock gerarchici Domande di riepilogo e di approfondimento CAPITOLO 6 MONITOR Lock di Monitor e condizioni Il problema del risveglio perso Lock readers-writers Un nostro Reentrant Lock Semafori CAPITOLO 7 LINKED LIST Set (insiemi) basati su liste Le ragioni della concorrenza... 59

4 PCP Costante Luca, Giardino Daniele Sincronizzazione coarse-grained Sincronizzazione Fine-Grained Optimistic Synchronization Lazy synchronization Sincronizzazione non bloccante CAPITOLO 8 SCHEDULING Pool di thread Analisi del parallelismo Scheduling realistico multiprocessore Distribuzione del lavoro Work Stealing Bilanciamento del carico CAPITOLO 9 MEMORIA TRANSAZIONALE Barriere Memoria transazionale Hardware Transactional Memory Software Transactional Memory LABORATORIO 1 INTRODUZIONE ALLA PROGRAMMAZIONE PARALLELA La legge di Amdahl Definizioni Concetti e terminologia Architetture della memoria Shared memory Distribuited Memory Hybrid architecture Modelli di programmazione parallela Progettazione di programmi Tecniche di partizione Le problematiche Granularità Input/Output Il costo del calcolo parallelo Esempio 1: Array processing Esempio 2: equazione del calore Domande di riepilogo

5 PCP Costante Luca, Giardino Daniele LABORATORIO 2 MESSAGE PASSING INTERFACE Struttura di un programma Tipi di routine di MPI MPI communicator e collective communication Gruppo di processi Topologie LABORATORIO 3 COMUNICAZIONE PUNTO-PUNTO Il bilanciamento del carico Comunicazione point-to-point Fairness Messaggi di send e receive La latenza tende ad allargarsi su una collezione distribuita di host Send Game of life LABORATORIO 4 - COMMUNICATING NON CONTIGUOUS DATA OR MIXED DATATYPES Tecniche di buffering LABORATORIO 5 COLLECTIVE COMMUNICATIONS LABORATORIO 6 - VIRTUAL TOPOLOGIES AND COMMUNICATORS Communicator

6 PCP CAPITOLO 1 - INTRODUZIONE AL CORSO CAPITOLO 1 - INTRODUZIONE AL CORSO Programmazione concorrente Lo sviluppo dei microprocessori è rivolto alla produzione di CPU multicore dove ogni core comunica con gli altri core attraverso una cache condivisa. La programmazione concorrente diventerà lo standard di programmazione, non più un caso particolare, in quando le architetture multiprocessore stanno avendo una notevole diffusione. La programmazione parallela sta quindi diventando sempre più accessibile e vicina a tanti problemi. Una delle leggi più citate dell informatica è quella di Moore: il numero di chip per transistor tende a raddoppiare ogni due anni. Il problema del rallentamento della crescita del numero di transistor è dovuto all effetto della termodinamica che crea numerosi problemi di surriscaldamento: alimentazione a basso voltaggio per limitare i problemi di raffreddamento aumento della velocità del clock. Il rallentamento di incremento del numero di transistor inizia ad avere effetto con la tecnologia al di sotto di 40nm (il core i7 usa 45nm), ma la Intel sta già studiando la tecnologia a 32nm. In pratica non si possono avere tanti transistor su un processore, che siano sia facili da raffreddare che veloci, ma si deve rinunciare a una di queste caratteristiche. Fino a poco tempo fa, il progresso tecnologico significava un incremento della velocità del clock del processori che si traduceva direttamente in un incremento della velocità di esecuzione del software. Con l introduzione delle CPU multicore, il progresso tecnologico significa incrementare il parallelismo e non la velocità del clock, ovvero più transistor ma organizzati in core multipli; sfruttare questo parallelismo è una delle maggiori sfida dell informatica moderna. 1

7 PCP CAPITOLO 1 - INTRODUZIONE AL CORSO Le sfide Quando si scrivono programmi per un solo processore si ignorano i dettagli architetturali della macchina. Sfortunatamente, quando i programmi sono destinati per macchine dotate di multi-processori, bisogna tener presente le caratteristiche dell architettura e come si possono ottenere vantaggi dall utilizzo dei thread, che in ogni caso comporta problemi dovuti all asincronia. Un singolo thread accede alla memoria (strutturata in oggetti), ma la situazione si complica notevolmente se si usano più thread che devono accedere alle stesse aree di memoria. Il trend del presente/futuro è chiaramente in direzione multi-core. Siano essi omogenei (come quelli della Intel e Sun) o eterogenei (AMD) o una via di mezzo (IBM), saranno numerosi. Secondo alcune ipotesi i core nel 2017 saranno: 128 sul desktop 512 sui server 4096 sui sistemi embedded. Architetture multi-core Il problema dell uso dei thread deriva dall accesso alla risorsa e dalla sincronia tra i thread che appartengono allo stesso gruppo. Due thread che condividono una risorsa, devono usarla in mutua esclusione. Ogni thread deve fare il lock della risorsa prima di usarla e fare unlock dopo averla usata. Assumiamo che lo stato del lock sia una semplice variabile booleana. Il metodo getandset(v) fa uno swap atomico del parametro con lo stato. Se l invocazione getandset(true) restituisce false allora si è fatto il lock, altrimenti la variabile era già locked e non si è acquisito il diritto all accesso della risorsa. Proponiamo 2 semplici esempi, ne proveremo l equivalenza e citeremo i risultati. public class TASLock implements Lock { public void lock() { while (state.getandset(true)) {} } } public class TTASLock implements Lock {... public void lock() { while (true) { while (state.get()) {}; if (!state.getandset(true)) return; } } } Test and set (TASLock) e Test-test and set (TTASLock) sono logicamente equivalenti, ognuno garantisce la mutua esclusione. TASLock sembra essere il più semplice: cerca ripetutamente di acquisire il lock finché getandset non restituisce false acquisendo così la risorsa. Al contrario, TTASLock legge ripetutamente il valore del campo state.get() fino a quando non restituisce false. A quel punto richiama state.getandset(true) e in caso del valore di ritorno uguale a false acquisisce il lock. È importante capire che il valore di get() e getandset() è atomico ma la combinazione dei due non lo è: tra i tempi delle 2 chiamate, il valore del lock può cambiare; getandset in TTASLock non garantisce l acquisizione del lock. Anderson, nel 1989 effettuò un test sui due algoritmi. Tale test riguardava una piccola sezione critica e venne ripetuto 1 milione di volte. 2

8 PCP CAPITOLO 1 - INTRODUZIONE AL CORSO Sottoponendo i differenti algoritmi a dei test, possiamo notare che il programma più semplice e più lineare è quello più lento al crescere del numero di task. Processori e Threads I processori sono componenti hardware che eseguono thread, esistono quindi più thread che processori ed ogni processore deve avere uno scheduler che gestisca l alternanza di esecuzione dei vari thread (contex switch). Quando un thread viene de-schedulato, può essere eseguito da un altro processore. Un multiprocessore è costituito da una serie di processori hardware, ognuno dei quali esegue codice sequenziale. L unità base di tempo è chiamato ciclo: il tempo che occorre ad un processore per processare ed eseguire una singola istruzione (si è passati da 10 milioni di cicli per secondo nel 1980 a circa 3000 milioni nel 2005). Le interconnessioni La interconnessione tra i vari processori e la memoria avviene tramite un mezzo di comunicazione chiamato bus. Sia il processore che il controller della memoria possono trasmettere dati sul bus, ma un solo processore per volta può trasmettere, mentre tutti gli altri possono ascoltare. Le interconnessioni possono essere essenzialmente di 2 tipi: SMP (Symmetric Multiprocessing): i processori e la memoria sono collegati da un bus. Vi è un bus controller tra i processori e la memoria centrale che controlla gli invii e le letture sul bus (quest ultime chiamate snooping). SMP è un architettura molto utilizzata perché è molto semplice da realizzare ma è poco scalabile per un elevato numero di processori perché in quel caso il bus diverrebbe sovraccarico. NUMA (Non uniform memory access): i processori sono collegati punto-punto. Ogni nodo ha uno o più processori e la propria memoria locale, ma può accedere alla memoria degli altri processori. L accesso alla memoria è non uniforme, quindi il tempo per l accesso alla memoria locale è più veloce. Questa architettura risulta essere più scalabile in relazione all aumentare del numero di processori. 3

9 PCP CAPITOLO 1 - INTRODUZIONE AL CORSO La memoria I punti cruciali per l ottimizzazione delle prestazioni sono la memoria centrale e la cache, non il numero di core. I processori condividono una memoria principale, vista come un array di word indicizzate da un indirizzo. In base alla piattaforma, una word è tipicamente di 32 o 64 bit. La lettura dalla memoria è effettuata tramite l invio di un messaggio dal processore alla memoria, con l indirizzo da leggere come parametro. La memoria risponde inviando il valore associato a quell indirizzo. Un processore scrive un valore inviando l indirizzo e il nuovo valore alla memoria e quest ultima risponde con un acknowledgment quando il nuovo valore viene effettivamente memorizzato. Cache Sfortunatamente, l accesso alla memoria centrale non è un operazione molto veloce ma richiede centinaia di cicli di clock. Continui accessi fanno sprecare tempo alla CPU. Per risolvere in parte questo problema sono state introdotte una o più cache, memorie di piccola dimensione situate tra i processori e la memoria centrale ma più veloci di quest ultima. Quando un processore aspetta di leggere un valore da un determinato indirizzo di memoria per prima cosa controlla se il valore è già presente in cache e se è cosi (hit) non effettua la chiamata alla memoria, altrimenti si (miss). Le cache sono efficaci se il programma ha locality, ovvero se una locazione acceduta nel passato ha buona probabilità di essere acceduta di nuovo. Nella pratica molti processori hanno 2 livelli di cache: L1 cache: sullo stesso chip del processore (1-2 cicli per l accesso) L2 cache: on oppure off-chip ma richiede decine di cicli per l acceso entrambe molto più veloci dei centinaia di cicli necessari per l accesso alla memoria centrale. Le cache sono notevolmente più piccole in quanto costose da costruire. Si rende necessario un algoritmo di replacement per aggiornare le word memorizzate nella cache. Protocollo MESI Un problema è quello di mantenere le cache coerenti. Quando un processore legge/scrive un indirizzo che è stata messa in cache da un altro processore si ha la memory contention. In caso di lettura nessun problema, in caso di scrittura la copia in cache dell altro processore va invalidata. Esistono diversi protocolli per assicurare la coerenza della cache. Il protocollo MESI è stato usato dai processori Pentium e PowerPC. Il nome deriva dai quattro stati in cui una cache line può trovarsi: Modified: la linea è stata modificata in cache e deve essere scritta (prima o poi) nella memoria principale. Nessun altro processore ha la stessa linea in cache. Exclusive: la linea non è stata modificata e nessun altro processore ha questa linea in cache. Shared: la linea non è stato modificata e altri processori possono avere questa linea in cache. Invalid: la linea di cache non contiene dati validi. Per semplicità, assumiamo che i processori e la memoria sono collegati tramite un bus. 4

10 PCP CAPITOLO 1 - INTRODUZIONE AL CORSO Quando il processore A legge il valore all indirizzo a, lo memorizza nella sua cache nello stato Exclusive (a). Quando il processore B vuole leggere da a, A riconosce il conflitto e risponde con il dato associato e lo setta con shared. Ora il valore a è cached sia in A che in B con lo stato shared (b). Se B scrive all indirizzo di a, allora cambia il suo stato in Modified e manda in broadcast (a tutti) un messaggio di warning per cambiare loro lo stato in Invalid (c). Se A legge da a, manda in broadcast una richiesta e B risponde inviando i dati modificati sia ad A che alla memoria principale, lasciando le copie nello stato Shared (d). Spinning Un processore fa spinnig se controlla ripetutamente una word in memoria, in attesa che qualche alto processore cambi il valore. In base all architettura, lo spinning può avere conseguenze catastrofiche sulle performance del sistema. Su una architettura SMP senza cache, lo spinning è davvero una pessima idea. Ogni volta che il processore legge la memoria, consuma banda dal bus senza realizzare alcun lavoro utile, non permettendo a processori che hanno lavoro utile da compiere di utilizzare il bus. Su una architettura NUMA senza cache, lo spinning può essere accettabile se la variabile è nella memoria locale del processore. Su una architettura SMP/NUMA con cache, lo spinning consuma meno risorse: alla prima lettura, si ha una cache miss e la variabile viene caricata in cache; se la variabile non è modificata, allora si continua a leggere dalla cache (senza usare risorse, siano esse il bus condiviso oppure la comunicazione con un altro processore); appena la variabile risulta modificata si ha un cache miss, si carica il valore e si ferma lo spinning. Ora il comportamento bizzarro tra TASLock e TTASLock risulta più chiaro. Ad ogni getandset(true) di TASLock viene generato traffico fino a saturare il bus condiviso, ritardando agli altri thread. Lo spinning di TTASLock legge da una copia in cache e non produce carico sul bus. Comunque TTAS non è ideale: quando il lock viene rilasciato, tutte le copie cached sono invalidate, a tutti i thread che erano in attesa chiamano getandset(true), aumentando il carico del bus, più di TASLock, ma meno significativamente. False sharing Il False sharing si verifica quando processori che dovrebbero accedere ai dati logicamente distinti, si trovano in una situazione di conflitto perché le locazioni giacciono sulla stessa cache line (quando si chiede una word dalla memoria centrale, si ottengono un gruppo di word vicine, dette appunto cache line). È necessario trovare un tradeoff in quanto cache line ampie aumentano la possibilità di sfruttare la locality, ma aumentano anche la possibilità di false sharing. Per evitare false sharing si dovrebbe poter avere un controllo a grana fine sui dati (possibile in C/C++, poco in Java): 5

11 PCP CAPITOLO 1 - INTRODUZIONE AL CORSO allineare i dati alla cache line; separare dati read-only da quelli che cambiano frequentemente; separare oggetti in oggetti locali al thread. Relax consistency Quando un valore viene scritto in memoria, il valore è mantenuto in cache e segnato come dirty in quanto deve essere ancora scritto in memoria. Le richieste di scrittura in memoria non vengono eseguite subito ma messe in un write buffer. Questa tecnica produce due vantaggi: batching delle richieste; write obsorption: se arriva una successiva write, la precedente non deve essere fatta. L uso di un write buffer comporta un importante conseguenza: l ordine con cui vengono richieste read/write in memoria non è l ordine con cui realmente avvengono. I compilatori complicano ancora di più la situazione. Un compilatore può ottimizzare il codice effettuato un reording delle read/write in memoria. Per esempio, può accadere che un thread riempia un buffer e poi setti un flag per indicare che è pieno. Il reordering può causare che gli altri thread vedano il flag che indica il buffer pieno quando ancora non lo è e leggano dati vecchi dal buffer. Tutte le architetture permettono di forzare le nostre scritture nell ordine in cui vengono chiamate, ma ad un costo. Una memory barriers effettua il flush dei buffer. Spesso vengono inserite automaticamente ed in modo trasparente per le operazioni atomiche o critiche. Sono costose in termini di tempo (anche centinaia di cicli) ma necessarie per liberarsi da bug. In Java, le istruzioni di read/write fuori da un blocco synchronized possono essere riordinate, mentre read/write su una variabile volatile non sono riordinate. Architettura multicore Un processore multicore contiene diversi core sullo stesso chip. Ogni core ha la sua L1 e tutti i core condividono la L2. I processori moderni combinano multicore con multithread: ogni core è in grado di eseguire due o più thread usando parallelismo interno. 6

12 PCP CAPITOLO 1 - INTRODUZIONE AL CORSO Domande di riepilogo Perchè, mentre la efficienza del software scalava tipicamente insieme al progresso tecnologico nei processori (fino a quando sono stati single-core) questo non avviene più nei processori multi-core? Perchè é importante la programmazione concorrente, alla luce del trend tecnologico di processori multi-core? Perché TASLock risulta essere meno efficiente di TTASLock? Perché TASLock è equivalente a TTASLock? Perchè in TTASLock, dopo aver fatto spinning in lettura con la get, si deve fare una getandset(true) invece di usare semplicemente una set(true)? Descrivere la architettura SMP Descrivere la architettura NUMA Confrontare pro e contro delle architetture SMP e NUMA (ad esempio, efficienza, costi, scalabilità,...) Per quali motivi le cache line dovrebbero essere più grandi possibile? In questo modo, si cerca di sfruttare il più possibile la locality, ossia il fatto che una word è già in cache. Per quali motivi le cache line dovrebbero essere più piccole possibile? In questo modo si diminuisce il fenomeno del false sarin, ossia la possibilità che due processori accedano a dati logicamente distinti ma si trovano in situazioni di conflitto perché le locazioni giacciono sulla stessa cache line. Perché le cache line non possono essere più piccole di una word? Poiché rappresentano un gruppo di word vicine e la più piccola cache line è costituita da una sola word. Definire cache hit, cache miss, hit ratio Cache hit se la pagina richiesta è già in cache, cache miss se la pagina richiesta in memoria non è in cache, hit ratio è il tasso di cache hit. Cosa è la locality di un programma? Descrivere le tecniche di rimpiazzo della cache Quali sono le differenze tra la cache L1 e L2 nei moderni multiprocessori (in termini architetturali, ma anche di funzionalità) Descrivere il protocollo MESI Provare a cercare di ridurre i 4 stati di MESI a 3 soli, eliminandone uno... e verificare i problemi che sorgono in ciascuna configurazione a 3 stati Descrivere lo spinning Descrivere la efficienza dello spinning per SMP senza cache, per NUMA con cache e per SMP/NUMA con cache Descrivere il False Sharing e le tecniche utilizzabili per limitarlo Cosa è la Relaxed Consistency? Come funziona e che vantaggi offre il write buffer? E che svantaggi porta? Cosa è la write absorption? Perchè è più efficiente fare il batching delle richieste di write? Cosa è il reordering effettuato dai compilatori? Perchè i compilatori effettuano il reordering? Per quale motivo ogni singolo core effettua il multi-thread? Cosa succederebbe se progettassimo una architetture tale che ogni core fosse single-thread? In che maniera in Java si può controllare la effettiva scrittura in memoria dei dati? E perchè, visto che esistono questi metodi, essi non vengono usati in ogni parte del codice? 7

13 PCP CAPITOLO 2 - INTRODUZIONE ALLA PROGRAMMAZIONE CONCORRENTE CAPITOLO 2 - INTRODUZIONE ALLA PROGRAMMAZIONE CONCORRENTE I sistemi basati su multiprocessore spesso sono chiamati anche shared-memory multiprocessors oppure semplicemente multicores. Ogni chip deve essere coordinato per poter accedere alle locazioni di memoria condivisa e su larga scala. La correttezza di un programma per multiprocessore è molto più complessa di quella di un programma sequenziale e richiede una serie di strumenti differenti per poter essere dimostrata. Safety è la proprietà che assicura che alcune condizioni negative non si verifichino mai, molto ma molto più difficile da dimostrare in quanto bisogna provare tutti i vari stati in cui un thread può trovarsi. Liveness è la proprietà che assicura una particolare condizione positiva si presenterà, cioè che il programma farà progressi verso una soluzione. Nel contesto dei multiprocessori, il programmatore deve soltanto essere più astuto nello scrivere programmi che possono sembrare bizzarri ma che invece funzionano bene con questo tipo di architettura. Oggetti condivisi e sincronizzazione Immaginiamo che il nostro primo giorno di lavoro, il nostro capo ci chiede di calcolare tutti ti numeri primi tra 1 e La macchina su cui effettuare il calcolo viene affittata a minuti cosicché quanto più tempo impiega il nostro programma per trovare la soluzione, tanto più costa. La macchina è parallela per poter sfruttare i core al massimo è necessario programmare con i thread. Un primo approccio è quello di suddividere il dominio (domain decomposition). Ogni thread riceve una parte del dominio, ciascuno di 10 9 numeri su cui effettuare il calcolo (distribuzione statica del carico). Questa soluzione fallisce in quanto: i numeri primi non sono distribuiti equamente (ci sono pochi primi tra 9*10 9 e ); testare la primalità di un numero grande rende più tempo di un numero piccolo. In parole povere, non ci sono motivi per credere che in questa maniera il lavoro sarebbe ripartito equamente tra i thread ed inoltre non è chiaro quale thread debba svolgere il lavoro più oneroso. I thread che lavorano di più sono più lenti e rallentano il tempo di completamento dell intero programma. Un altro modo per dividere il lavoro tra i thread è quello di assegnare a ciascun un intero per volta. Quando un thread finisce, inizia il calcolo per un altro intero (distribuzione dinamica del carico). C è la necessità di sincronizzare il lavoro e per questo motivo viene introdotto uno shared counter, un oggetto che incapsula un valore intero e che fornisce il metodo getandincrement() per incrementare il valore. 8

14 PCP CAPITOLO 2 - INTRODUZIONE ALLA PROGRAMMAZIONE CONCORRENTE Questa implementazione lavora bene quando si usa un solo thread ma fallisce quando viene condiviso tra più thread. Il problema è che l istruzione return value++; è in realtà un abbreviazione del seguente frammento di codice: long temp = value; value = temp + 1; return temp; Nel frammento di codice sopra riportato, value è il campo dell oggetto Counter e viene condiviso tra tutti i thread. Ogni thread, ha la copia della sua variabile locale temp. Ora immaginiamo che i thread A e B chiamino il metodo getandincrement() nello stesso istante. Essi leggono simultaneamente 1 da value, settano la loro variabile temp a 1, value a 2, e restituiscono 1. Le chiamati concorrenti restituiscono lo stesso valore, ma noi ci aspettiamo valori differenti. Il cuore del problema è che l incremento del valore del contatore richiede due distinte operazioni sulla variabile condivisa: la lettura del campo value nella variabile temporanea e scrittura nell oggetto Counter. Una fiaba Alice e Bob sono vicini e condividono un lago. Sia Alice che Bob hanno un drago ma entrambi non possono stare contemporaneamente nel lago a causa di incompatibilità di carattere. Decidono quindi di coordinarsi per far si che nel lago ci sia un solo drago. Bisogna quindi stabilire un protocollo. Poiché il lago è grande, Alice non può semplicemente guardare se nel lago c è o meno il drago, né tantomeno andare a casa di Bob per vedere se il suo drago è in casa, perché richiederebbe troppo tempo. Decidono quindi di avvertirsi reciprocamente richiedendo ad alta voce Può uscire il mio drago?. Questo risulta essere un problema in quanto se Bob decide di uscire, Alice aspetta inutilmente senza occupare la risorsa. Alice ha un altra idea. Utilizza una o più lattine sul davanzale di Bob, legando attorno ad ognuna una corda allungata fino a casa sua. Bob fa lo stesso. Quando Alice vuole inviare un segnale a Bob, tira la corda facendo ribaltare la lattina. Quando Bob vede che una lattina è ribaltata, la rialza. Anche questa soluzione non è risolutiva in quanto sia Alice che Bob hanno a disposizione di un numero limitato di lattine e prima o poi, uno può tirare giù tutte le lattine. In questo protocollo non è inoltre garantito che i partecipanti rispondano entro un tempo limite, ma appena possibile. Così, Alice e Bob escogitano un approccio differente. Ognuno ha a disposizione una bandiera, facilmente visibile all altro. Quando Alice vuole lasciare andare il suo drago nel lago: 1. alza la sua bandiera; 2. mentre la bandiera di Bob è giù, rilascia il drago; 3. quando l animale rientra, abbassa la bandiera. Le operazioni di Bob sono, invece, un po più complicate: 1. alza la sua bandiera; 2. mentre la bandiera di Alice è su: 9

15 PCP CAPITOLO 2 - INTRODUZIONE ALLA PROGRAMMAZIONE CONCORRENTE a. abbassa la sua bandiera b. aspetta che Alice abbassi la bandiera c. alza la bandiera 3. quando la sua bandiera è alzata e quella di Alice è giù rilascia il suo drago 4. quando l animale rientra, abbassa la bandiera In questo modo si cerca di evitare il deadlock. A livello intuitivo questo protocollo funziona in quanto segue il principio del flag. Se ognuno alza la bandiera e poi guarda quello dell altro allora almeno uno dei due vedrà la bandiera dell altro alzata e si comporta di conseguenza. Bob, ad esempio, da la precedenza ad Alice, ma cosa potrebbe succedere se Bob usasse lo stesso protocollo di Alice? Entrambi alzano la bandiera e nessuno occupa la risorsa. Teorema: gli animaletti di alice e bob non sono mai insieme nel laghetto. Dimostrazione: per contraddizione assumiamo che ci sia un conflitto. Entrambe le bandiere sono alzate. Sia Alice (w.l.o.g.) l ultima a guardare all altro dopo aver alzato. Bob ha guardato Alice prima che alzasse la bandiera (altrimenti avrebbe visto il conflitto). Quindi Bob avrebbe alzato la bandiera prima che Alice guardasse, ma in quel caso Alice avrebbe atteso quindi non ci sarebbe stato conflitto (contraddizione). Questo protocollo garantisce la mutua esclusione (niente conflitti), ovvero i due animali non si troveranno mai nel lago nello stesso tempo. La mutua esclusione è una delle importanti proprietà che ci interessano. Un altra proprietà molto importante è la deadlock freedom. Se entrambi gli animali vogliono entrare, uno di essi ci riesce: Bob lascia la precedenza ad Alice. Ancora, il protocollo è starvation freedom: se un animale vuole entrare, sicuramente ci riuscirà. Se Alice occupa lo stagno, Bob non ci riuscirà mai. L ultima proprietà è la waiting: immaginiamo che Alice alzi la bandiera e, a causa di un attacco di appendicite, debba correre in ospedale per qualche settimana. Durante questo periodo, Bob deve aspettare poiché vede alzata la bandiera e non può far uscire il suo drago. Normalmente, ci si aspetta che Alice e Bob rispondano in un tempo limitato, ma cosa accade se non lo fanno? Il problema della mutua esclusione richiede waiting. Questo problema è un esempio importante di fault-tolerance. Cercheremo di spiegare i vari problemi dei differenti approcci. Normalmente, in un sistema possono esserci due tipi di comunicazione: a. comunicazione transiente: richiede che le 2 parti siano attive nello stesso istante; b. comunicazione persistente: permette al sender e al receiver di partecipare alla comunicazione in tempi differenti. La mutua esclusione richiede una comunicazione persistente. La soluzione delle lattine è un sorta di interrupt. La bandiera invece funziona come una variabile di 1 bit. Questo significa che si deve considerare la bandiera alzata/non alzata come se ci fosse uno steccato che impedisce di vedere una parte e permettere quindi di distinguere inequivocabilmente i due stati differenti. Il problema del Produttore-Consumatore Alice e Bob si sposano ma poi divorziano. I due draghi hanno nel frattempo imparato ad andare d accordo. L accordo di divorzio è che Alice mantenga gli animali (che attaccano Bob quando lo vedono) e Bob deve fornire il cibo. Quindi il protocollo deve permettere a Bob di portare il cibo (produttore) ed ad Alice di rilasciare i draghi (consumatore) solo quando c è cibo senza sprecare tempo. Bob non vuole portare cibo se i draghi non l hanno finito e Alice non vuole rilasciare gli animali se non c è cibo. Per la soluzione di questo problema, entrambi decidono di usare il protocollo della lattina. Bob posiziona una lattina alzata sul davanzale di Alice legandola ad una corda che arriva sino in camera sua. 10

16 PCP CAPITOLO 2 - INTRODUZIONE ALLA PROGRAMMAZIONE CONCORRENTE Da ora in poi, quando Alice vuole liberare i draghi: 1. attende fin quando la lattina non sia giù; 2. rilascia gli animali; 3. quando tornano, controlla che hanno finito il cibo. Se è così, resetta la lattina rimettendola in piedi. Bob, invece: 1. aspetta che la lattina sia in piedi; 2. mette il cibo nel laghetto; 3. tira la corda e abbassa la lattina. La bandiera è una variabile di 1 bit che un thread può scrivere e l altro può solo leggere. La lattina invece è una variabile di 1 bit che può essere scritta e letta da entrambi (nell esempio Bob scrive solo 0 mentre Alice scrive solo 1). Anche qui sono presenti i soliti problemi di incremento atomico. Lo stato della lattina riflette lo stato del cibo: se è giù c è cibo, altrimenti il cibo non c è e Bob può metterne altro (può uscire fuori). Cerchiamo di dimostrare le 3 proprietà: Mutua esclusione: Bob e gli animali mai insieme nel giardino; Starvation-freedom: se Bob vuole fornire cibo infinito e gli animali hanno infinitamente fame, saranno soddisfatti sempre; Produttore-Consumatore: gli animali non entrano nel giardino se non c è cibo, e Bob non fornisce cibo nuovo se esiste cibo vecchio non ancora consumato. Un osservazione importante è che il protocollo Produttore-Consumatore non richiede deadlock free. Sia questo protocollo Produttore-Consumatore che il protocollo di mutua esclusione della sezione precedente assicurano che Alice e Bob non sono mai insieme nello stesso posto. Tuttavia Alice e Bob non possono usare questo protocollo Produttore-Consumatore per la mutua esclusione in quanto questa richiede deadlock-freedom: ognuno deve essere in grado di entrare nel luogo in comune anche se l altro non è in casa. Invece la proprietà di starvation-freedom del protocollo Produttore-Consumatore assume delle continue interazioni da entrambe le parti. Ora verranno dimostrate le seguenti proprietà: Mutua esclusione: è soddisfatta dal protocollo. (PROVA) La lattina ha due stati: down e up. Proviamo che in tutti gli stati e le transizioni di questa macchina a stati finiti la proprietà di mutua esclusione è mantenuta. Poniamo che all inizio sia down: in questo caso gli animali possono entrare ma Bob no (quindi proprietà vera). Da questo stato, può andare nello stato up ma solo perché Alice ha fatto uscire gli animali e Bob può entrare (proprietà vera). Bob quando porta il cibo, mette la lattina nello stato down in modo tale che gli animali possono uscire. Starvation-freedom: è soddisfatta dal protocollo. (PROVA) È il caso in cui gli animali di Alice sono sempre affamati, non c è cibo e Bob sta cercando di recuperare il cibo senza riuscirci. La lattina non può essere alzata, perciò Bob dovrà provvedere al cibo e tirare la corda, permettendo ai draghi di mangiare. Per questo la lattina è abbassata, i draghi affamati, Alice potrà eventualmente alzare la lattina, portandoci nel precedente caso. Produttore-Consumatore: è soddisfatta dal protocollo. (PROVA) la mutua esclusione implica che i draghi e Bob non saranno mai insieme nel giardino. Bob non vi entrerà fino a quando Alice non rialza la lattina e lo farà solo quando non ci sarà più cibo. Gli animali non entreranno nel giardino prima che Bob abbassi la lattina e lo farà solo dopo che avrà depositato il cibo nel cortile. 11

17 PCP CAPITOLO 2 - INTRODUZIONE ALLA PROGRAMMAZIONE CONCORRENTE Questo protocollo mostra anche waiting. Se Bob deposita il cibo nel giardino e immediatamente va in vacanza dimenticandosi di resettare la lattina, gli animali possono morire di fame. Il problema Lettori/Scrittori Alice e Bob vogliono comunicare dopo il divorzio. Bob mette una grande lavagna in giardino e per comunicare scrive sulla lavagna. Supponiamo che Bob stia scrivendo un messaggio ed in quel momento Alice legge ed interpreta male in quanto il messaggio è incompleto. Per risolvere questo problema, ci sono due soluzioni: a) usare un protocollo di mutua esclusione per assicurare che Alice legga solo frasi complete. Può accadere però, che Alice perda delle frasi (Bob non sa se Alice ha letto). b) usare il protocollo delle lattine come un produttore-consumatore, dove Bob produce frasi e Alice le consuma leggendole. Perché è importante il problema dei readers/writers? Entrambi, sia mutua esclusione che produttoreconsumatore richiedono il waiting in quanto se un partecipante ritarda, anche gli altri ritardano. Nel contesto della condivisione di memoria dei multiprocessori, una soluzione al problema dei readers/writers sarebbe quella di permettere ad un thread di fare la fotografia ad un insieme di locazioni senza waiting. Java e la concorrenza Thread e monitor Un Thread esegue una un singolo programma sequenziale. In java è generalmente una sottoclasse di java.lang.thread. Un oggetto Runnable può essere eseguito in un thread chiamando il costruttore della classe Thread che prende un oggetto Runnable come argomento. Un altro modo è quello di chiamare una classe interna anonima: Per eseguire un thread bisogna chiamare thread.start(). Se invece si vuole che il chiamante aspetti che il thread finisca, bisogna chiamare il metodo thread.join(). 12

18 PCP CAPITOLO 2 - INTRODUZIONE ALLA PROGRAMMAZIONE CONCORRENTE I monitor in java rappresentano il modo per sincronizzare l accesso ai dati condivisi ed è necessaria la mutua esclusione sulle invocazioni. Facciamo un esempio su una coda. Questa classe non funziona correttamente se due operazioni provano a chiamare deque allo stesso istante. Per rendere la coda corretta, bisogna assicurare che sono una operazione per volta possa chiamare la deque. Ogni oggetto ha un lock implicito. Se un thread acquisisce il lock di un oggetto A, nessun altro thread può acquisirlo fino a quando non è stato rilasciato. Per acquisire il lock, ad esempio, basta dichiarare un metodo synchronized ed in questo modo nessun altro metodo dell oggetto può essere invocato fino al rilascio del lock. Thread local objects Le variabili thread-local sono variabili che appartengono al thread: indipendentemente inizializzate; non vanno in memoria centrale (condivisa); non richiedono sincronizzazione; non generano traffico per mantenere la coerenza nella cache. ThreadLocal <T> ha metodi per: l inizializzazione initialvalue(); get() and set(). 13

19 PCP CAPITOLO 2 - INTRODUZIONE ALLA PROGRAMMAZIONE CONCORRENTE package capitolo_2; public class ThreadID { private static volatile int nextid=0; private static class ThreadLocalID extends ThreadLocal<Integer>{ protected synchronized Integer initialvalue(){ return nextid++; } } private static ThreadLocalID threadid=new ThreadLocalID(); public static int get(){ return threadid.get(); } } public static void set (int index){ threadid.set(index); } La java virtual machine e i thread Come sono mappati i thread in java? Nelle prime macchine virtuali (1.1, 1.2) su alcuni SO si usavano i green thread (simulazione di thread). Adesso le macchine virtuali sono altamente ottimizzatati per usare le capacità multi-thread dei SO su cui si poggiano, rendendo immediato l uso efficiente dei multiprocessori/multicore attualmente disponibili. 14

20 PCP CAPITOLO 2 - INTRODUZIONE ALLA PROGRAMMAZIONE CONCORRENTE Domande di riepilogo Cosa è la safety e la liveness di un programma? Perché in un programma sequenziale non si deve provare la liveness? Quale è la differenza sostanziale tra "lattine" e "bandiere"? Cosa è il flag principle dell'esempio di Alice e Bob? Cosa potrebbe accadere al protocollo di Mutua Esclusione descritto per Alice e Bob, se Bob usasse lo stesso protocollo di Alice? E se Alice usasse lo stesso protocollo di Bob? Dimostrare la correttezza dell'algoritmo di Mutua Esclusione per gli animaletti di Alice e Bob. Cosa significano le proprietà di un protocollo dette: mutua esclusione, deadlock-freedom, starvationfreedom, waiting? Per ciascuna delle proprietà sopra enunciate, quale è la efficacia del protocollo tra Alice e Bob? Perché c'è differenza tra comunicazione transiente e persistente? Perché Bob (o Alice) non possono semplicemente chiedere ad alta voce? Quali sono i problemi che una soluzione di questo tipo incontrerebbe? Quali sono le proprietà del protocollo descritto per il problema del Produttore-consumatore? Dimostrare la proprietà di mutua esclusione/starvation-free/produttore-consumatore per il protocollo descritto (lattine). Si può risolvere il problema del Produttore-Consumatore con 1 bandiera? e con 2? Perchè nella soluzione al problema readers/writers basata sulla mutua esclusione Alice può perdere delle frasi? Quale è la legge di Amdahl? Perché questo ha impatto su dove si sincronizza in un programma (granularità della sincronizzazione)? Cosa è un problema "embarassingly parallel"? A cosa servono i metodi: start(), join(), wait(), notify(), yield(), sleep()? Cosa sono i thread-local objects? Che caratteristiche hanno? Perchè ci interessa sapere che la nostra JVM non usa (più) i green tread? 15

21 PCP CAPITOLO 3 MUTUA ESCLUSIONE CAPITOLO 3 MUTUA ESCLUSIONE La mutua esclusione è la forma prevalente di coordinazione nella programmazione multiprocessore. Questo capitolo mostra come gli algoritmi lavorano nella lettura e scrittura di memoria condivisa. Anche se questi algoritmi non vengono usati nella pratica, vengono studiati per dare un introduzione ai tipi di algoritmi e alle problematiche sulla correttezza che si presentano nella sincronizzazione. Time Il tempo è un fattore fondamentale per il calcolo concorrente. A volte non vogliamo pensare che qualcosa accada simultaneamente, altre, invece, vogliamo che accadano in tempi differenti. Abbiamo bisogno di un linguaggio semplice e non ambiguo per poter parlare di eventi e della loro durata in termini temporali. In seguito vedremo costruzioni dove i contatori sono usati per ordinare i thread o per produrre identificatori univoci. Poiché il timestamp è un numero, utilizzando una struttura a 32 bit, si avrà un overflow (il timestamp inizia il calcolo il 1 gennaio 1970) il 18 gennaio 2038 che rappresenta Invece, le architettura a 64 bit non presentano questa limitazione. I thread condividono un tempo comune. Un thread è uno stato macchina e il cambio di stato è chiamato evento. Gli eventi sono istantanei, ossia si verificano in un singolo istante di tempo; distinti eventi si verificano in tempi distinti. Un thread A produce sequenze di eventi a 0, a 1, dato che può contenere cicli, quindi una singola istruzione di un programma può produrre diversi eventi. Indichiamo la j-esima occorrenza di un evento a i attraverso a i j. Un evento a precede un altro evento b (a b), se a si verifica prima. La precedente relazione è un ordine totale sugli eventi. Un intervallo (a 0, a 1 ) è la durata tra a 0 e a 1. L intervallo I A =(a 0, a 1 ) precede l intervallo I B =(b 0, b 1 ), scritto I A I B, se a 1 b 0 ossia che l evento finale di I A precede quello iniziale di I B. Sezione critica L esempio su Counter funziona bene su un sistema con un single thread, ma si comporta male quando viene utilizzato da due o più thread. Il problema si verifica quando i thread leggono il valore del campo dalla linea 8 alla 9. Il problema può essere evitato se trasformiamo queste due linee in una sezione critica: un blocco di codice che può essere eseguito da un solo thread per volta. Chiameremo questa proprietà mutua esclusione. Il modo migliore per sviluppare questo concetto è attraverso un oggetto Lock come descritto dall interfaccia mostrata in seguito. 16

22 PCP CAPITOLO 3 MUTUA ESCLUSIONE Diremo che un thread acquisisce un lock quando esegue una chiamata al metodo lock(), e lo rilascia quando esegue una chiamata al metodo unlock(). Un thread è ben formato se: 1. Ogni sezione critica è associata con un unico oggetto Lock; 2. Il thread chiama il metodo lock() dell oggetto quando si cerca di entrare nella sezione critica; 3. Il thread chiama il metodo unlock() quando esce fuori dalla sezione critica. Ora formalizziamo le proprietà che un buon algoritmo di Lock deve soddisfare. Indichiamo con CS A j l intervallo durante il quale A esegue la sezione critica j-volte. Assumiamo, per semplicità, che ogni thread acquisisce e rilascia il lock un numero infinito di volte: Mutua esclusione: la sezione critica dei vari thread non si sovrappone. Per il thread A e B con gli k j j interi k e j, la loro CS A CS B oppure CS B CS k A. Esente da deadlock: se qualche thread aspetta per acquisire il lock allora qualche altro thread dovrà riuscire ad acquisirlo, se il thread A chiama il metodo lock() ma non lo acquisisce mai, allora altri thread devono terminare un numero infinito di sezioni critiche. Esente da starvation: ogni thread che aspetta di acquisire il lock, può avere successo. Questa proprietà è chiamata lockout freedom. È da notare che se un algoritmo è esente da starvation allora è anche esente da deadlock. La proprietà di mutua esclusione è essenziale; senza di essa non si può garantire che il risultato della computazione sia corretto. La proprietà sul deadlock è molto importante, ci assicura che il sistema non va mai in freeze. Si ha deadlock quando due thread effettuano il lock di due risorse e per completare il proprio lavoro hanno bisogno della risorsa dell altro (aspettano sempre che l altro rilasci la risorsa). La proprietà della starvation, quando è richiesta, è la meno convincente delle tre. Risulta essere debole nel senso che non ci sono garanzie sulla quantità di tempo che un thread deve aspettare prima di entrare in una sezione critica. Soluzione 2-thread La classe LockOne Nell algoritmo 2-thread i thread hanno id 0 ed 1: uno si chiama i, l altro j=1-i. Ogni thread ottiene il suo indice attraverso la chiamata ThreadId.get(). In pratica, la variabile booleana flag deve essere dichiarata volatile per permettere all algoritmo di lavorare correttamente. Useremo write A x = v per indicare che un evento di A 17

23 PCP CAPITOLO 3 MUTUA ESCLUSIONE assegna valore v al campo x; read A (x == v) per indicare che un evento A legge v dal campo x. Possiamo omettere di indicare v quando il valore non è importante. Lemma. L algoritmo LockOne soddisfa la mutua esclusione. Dimostrazione. Per assurdo, supponiamo che vi siano gli interi j e k t.c. CS A j CS B k e CS B k CS A j. Considerando l ultima esecuzione del metodo lock() di ogni thread prima di entrare nella k-esima (j-esima) sezione critica. Guardando il codice, possiamo vedere: Possiamo notare che flag*b+ è settato a true e rimane a true. Ne deriva che l equazione è vera, altrimenti il thread A non avrebbe potuto leggere flag*b+ come falsa. L equazione segue le equazioni e e la transitività dell ordine precedente. Significa che write A flag A = true read B (flag[a] == true) senza interventi scrive nell array flag[], una contraddizione. Questo algoritmo è inadeguato perché si verifica deadlock se l esecuzione dei thread sono intervallate. Se gli eventi write A flag A = true e write B flag B = true avvengono contemporaneamente prima degli eventi read A (flag[b]) e read B (flag[a]), i thread aspetteranno per sempre. Ciò nonostante, LockOne ha un interessante proprietà: se un thread viene eseguito prima dell altro, non accade deadlock e tutto andrà bene. La classe LockTwo Lemma. L algoritmo soddisfa la mutua esclusione. Per assurdo, supponiamo che vi siano gli interi j e k t.c. CS A j CS B k e CS B k CS A j. Considerando che l ultima esecuzione del metodo lock() di ogni thread avvenga prima che entri nella k-esima (j-esima) sezione critica. Guardando il codice, possiamo vedere: Il thread B deve assegnare B al campo victim tra i tue eventi write A victim = A e read A victim = B (Eq ). Poiché questo assegnamento è l ultimo, abbiamo: 18

24 PCP CAPITOLO 3 MUTUA ESCLUSIONE Una volta che il campo victim è settato a B, non cambia, così qualsiasi sottosequenza di lettura ritorna B, contraddicendo la Questa classe è inadeguata perché può causare deadlock se un thread viene eseguito completamente prima degli altri. Ha, però, un interessante proprietà: se i thread vengono eseguiti correntemente, il metodo lock() ha successo. Le classi LockOne e LockTwo si completano a vicenda. Il lock di Peterson Ora, fondiamo gli algoritmi LockOne e LockTwo per costruire un algoritmo di Lock privo di starvation. È un modo più elegante di descrivere l algoritmo di mutua esclusione ed è conosciuto come algoritmo di Peterson, dal nome del suo inventore. Lemma. L algoritmo soddisfa la mutua esclusione. Dimostrazione. Supponiamo per assurdo di no. Come prima, consideriamo l ultima esecuzione del metodo lock() da parte dei thread A e B. Ispezionando il codice, possiamo vedere: Assumiamo che A sia l ultimo thread che scrive il campo victim. L equazione sopra riportata implica che A rileva che il campo victim vale A nell equazione Siccome A ciò nonostante entra nella sezione critica, deve aver osservato flag[b] falso, ottenendo: Le equazioni e , insieme con la transitività di, implicano: Significa che write B flag B = true read A (flag[b] == false). Questa osservazione fornisce una contraddizione perchè nessun altro scrive nel flag*b+ prima dell esecuzione della sezione critica. Lemma. L algoritmo è privo di starvation. Dimostrazione. Per assurdo, supponiamo che A esegue sempre il metodo lock(). Deve essere eseguita l istruzione while(), aspettando finché flag[b] diventi falsa o victim sia settata a B. Cosa sta facendo B 19

25 PCP CAPITOLO 3 MUTUA ESCLUSIONE mentre A non riesce ad andare avanti? Forse B entra ed esce ripetutamente dalla sezione critica. Se è così, B setta victim a B appena rientra nella sezione critica. Una volta che victim è settato a B, non cambia e A deve eventualmente uscire dal metodo lock(), una contraddizione. Quindi deve essere che anche B è bloccato nella sua chiamato al metodo lock(), aspettando finché il valore di flag[a] non diventi falso o victim sia settato ad A. Ma victim non può essere contemporaneamente A e B, una contraddizione. Corollario. L algoritmo di Lock di Peterson è esente da deadlock. Correttezza La proprietà starvation-freedom garantisce che ogni thread che chiama lock() eventualmente entra nella sezione critica ma non garantisce per quanto tempo lo farà. Idealmente, se A chiama lock() prima di B, allora A dovrebbe entrare nella sezione critica prima di B. Sfortunatamente non è possibile determinare quale thread chiama il lock() per primo. Per garantire ciò, dividiamo il metodo lock() in due sezioni di codice: 1. una sezione doorway, in cui l intervallo di esecuzione D A consiste in un limitato numeri di step; 2. una sezione waiting, in cui l intervallo di esecuzione W A può impiegare un numero di step illimitato. La necessita che la sezione doorway debba sempre finire in un limitato numero di step è un requisito molto forte. Chiameremo questo requisito bounded wait-free. Definizione. Un lock è first-come-first-served se, ogni volta che un thread A finisce la sua parte doorway prima che il thread B inizi la sua parte doorway, allora A non può essere superato da B. if D A j D B k, ten CS A jk CS B k L algoritmo del fornaio di Lamport Ogni thread prende un numero all entrata e aspetta finché nessun thread con un numero più piccolo aspetti di entrare. Nel lock del fornaio, flag[a] è un flag booleano che indica se A vuole entrare nella sezione critica, e label[a] è un intero che indica l ordine relativo ai thread quando entrano nel panettiere, per ogni thread A. Ogni volta che un thread acquisisce un lock, genera un nuovo array label[] in 2 step. Per primo, legge in qualsiasi ordine le label degli altri thread. Secondo, legge tutte le label degli altri thread uno dopo l altro e genera 20

26 PCP CAPITOLO 3 MUTUA ESCLUSIONE una label più grande di quella massima letta. Le linee 13 e 14 rappresentano la parte doorway. Se due thread eseguono la loro doorway concorrentemente, possono leggere la stessa etichetta massima e scegliere la stessa label. Per rompere questa simmetria, l algoritmo usa un ordine lessicografico << su una coppia di label[] e id dei thread: Nella riga 15 (sezione di waiting), un thread ripetutamente rilegge le etichette una dopo l altra in un ordine arbitrario prima di determinare che nessun thread abbia una coppia label/id più piccola. Siccome rilasciare un lock non resetta la label[], è facile vedere che le label dei thread sono rigorosamente crescenti. Sia nella sezione doorway che waiting, i thread leggono le etichette asincronamente e in ordine arbitrario, cosi che l insieme delle etichette visto prima della scelta della nuova può non essere mai esistito in memoria. Ciononostante, l algoritmo lavora bene. Lemma. L algoritmo del fornaio è esente da deadlock. Dimostrazione. Qualche thread aspetta che A ha l unica coppia più piccola (label[a],a), e questo thread non aspetta mai un altro thread. Lemma. L algoritmo è first-come-first-served. Dimostrazione. Se la parte doorway di A precede quella di B, D A D B, allora l etichetta di A è più piccola siccome: così B è bloccato finché flag[a] è true. Lemma. L algoritmo soddisfa la mutua esclusione. Dimostrazione. Per assurdo siano A e B due thread concorrenti nella sezione critica. Prendiamo labeling A e labeling B che rappresentano l ultima rispettiva sequenza di acquisizione della nuova etichetta di priorità prima di entrare nella sezione critica. Supponiamo che (label[a], A) << (label[b], B). Quando B completa in modo corretto il test nella sua sezione waiting, deve aver letto che flag[a] fosse falso oppure che (label[b], B) << (label[a], A). Comunque, per un dato thread, i rispettivi valori di id e di label[] sono sempre incrementati, così B deve aver dovuto vedere che flag[a] era falsa. Questo significa che: che contraddice l assunzione che (label[a], A) << (label[b], B). Motivazioni al lower bound sulla memoria utilizzata Qualsiasi algoritmo esente da deadlock richiede una allocazione per la lettura e la scrittura di al massimo n distinte locazioni di memoria, nella peggiore delle ipotesi. Questo risultato risulta essere di cruciale importanza perché ci motiva ad aggiungere alle nostre macchine dotate di multiprocessori, operazioni di sincronizzazione più sane di letture e scritture. Useremo questo come base per i nostri algoritmi di mutua esclusione. Ogni informazione scritta da un thread in una data locazione può essere sovrascritta senza che qualsiasi altro thread possa mai vederla. 21

27 PCP CAPITOLO 3 MUTUA ESCLUSIONE Domande di riepilogo Cosa è un ordine totale? e un ordine parziale? Perché è importante che gli eventi (nella nostra definizione) esibisca un ordine totale? Perché gli intervalli di tempo (come definiti) definiscono un ordine parziale? Cosa è un thread well-formed per la mutua esclusione? Definire e descrivere le conseguenze di ciascuna delle seguenti proprietà di un lock: mutua esclusione, deadlock-free, starvation-free, first-come-first-served Perché la starvation-freedom implica la deadlock-freedom? Perché si rende necessario introdurre la proprietà first-come-first-served? Descrivere (e criticare) l'algoritmo LockOne Dimostrare la proprietà di Mutua Esclusione per il lock definito da LockOne Perché LockOne va in deadlock se i due thread sono interfogliati? Esibire la sequenza di interleave che causa il deadlock Descrivere (e criticare) l'algoritmo LockTwo Dimostrare la proprietà di Mutua Esclusione per il lock definito da LockTwo Perché LockTwo va in deadlock se i due thread sono eseguiti uno completamente prima dell'altro? 22

28 PCP CAPITOLO 4 OGGETTI CONCORRENTI CAPITOLO 4 OGGETTI CONCORRENTI Oggetti sequenziali Un oggetto è un contenitore per dati, con metodi per manipolare l oggetto stesso. La classe definisce i metodi dell oggetto. La documentazione delle API è suddivisa in precondizioni e postcondizioni. Proponiamo un esempio sulla coda FIFO: nel caso di una invocazione di enq(x), se la coda è in uno stato q (precondizione) lascia la coda in stato q x dove indica la concatenazione. Questo stile di documentazione è chiamato specifica sequenziale: lineare nel numero di metodi. La documentazione dell oggetto descrive gli stati dell oggetto prima e dopo ogni chiamata, possiamo ignorare ogni stato intermedio. Questa specifica fallisce quando si parla di oggetti condivisi da più thread, quindi non ha senso in un mondo concorrente. Il singleton Noto design pattern della programmazione ad oggetti. Il Singleton ha lo scopo di garantire che per una determinata classe, venga creata una e una sola istanza, e di fornire un punto di accesso globale a tale istanza. L'implementazione più semplice prevede che la classe singleton abbia un unico costruttore privato, in modo da impedire l'istanziazione diretta della classe. La classe fornisce inoltre un metodo "getter" statico che restituisce una istanza della classe (sempre la stessa), creandola preventivamente o alla prima chiamata del metodo, e memorizzandone il riferimento in un attributo privato anch'esso statico. Il secondo approccio, basato sul principio della lazy initialization, prevede che la creazione dell'istanza della classe sia rimandata nel tempo e messa in atto solo al primo tentativo di uso. In applicazioni multi-thread l'utilizzo di questo pattern con la lazy initialization richiede un'attenzione particolare: se due thread tentano di eseguire contemporaneamente il costruttore quando la classe non è stata ancora istanziata, devono entrambi controllare se l'istanza esiste e soltanto uno deve creare la nuova istanza. 1 public static Singleton getinstance() { 2 if (instance == null){ 3 synchronized(singleton.class){ 4 if (instance == null) 5 instance = new Singleton(); 6 } 7 } 8 return instance; 9 } Concorrenza e correttezza 1 class LockBasedQueue<T> { 2 int head, tail; 3 T[] items; 4 Lock lock; 5 public LockBasedQueue(int capacity) { 6 head=0;tail=0; 7 lock = new ReentrantLock(); 8 items = (T[])new Object[capacity]; 9 } 10 public void enq(t x) throws FullException { 11 lock.lock(); 12 try { 13 if (tail - head == items.length) 14 throw new FullException(); 15 items[tail % items.length] = x; 16 tail++; 17 } finally { 18 lock.unlock(); 23

29 PCP CAPITOLO 4 OGGETTI CONCORRENTI 19 } 20 } 21 public T deq() throws EmptyException { 22 lock.lock(); 23 try { 24 if (tail == head) 25 throw new EmptyException(); 26 T x = items[head % items.length]; 27 head++; 28 return x; 29 } finally { 30 lock.unlock(); 31 } 32 } 33 } È facile capire la correttezza di LockBasedQueue: ci si affida al lock dell intero metodo. Infatti sia il metodo enq che deq hanno al proprio interno una chiamata a lock in modo da poter accedere in modo esclusivo sulla coda. La legge di Amdal suggerisce che per avere efficienza, dovremmo rinunciare alle CS (critical section) o renderle almeno più piccole possibili. Possiamo verificare la correttezza facendo un mapping con l esecuzione sequenziale e poi analizzarle. La barra sottostante indica il lasso di tempo. L etichetta q.enq(x) indica che un thread inserisce in coda l elemento x mentre q.deq(x) indica che il thread toglie l elemento in testa. Nell esempio sopra, notiamo che il thread C acquisisce il lock e cerca di fare una deque. Nota che la coda è vuota e rilascia il lock e genera un eccezione senza modificare la coda. Ora B acquisisce il lock, inserisce b e rilascia il lock. C riacquisisce il lock, deque b, rilascia il lock e ritorna. Ognuna di queste chiamate è effettuata in modo sequenziale e si può facilmente notare che la deque di b avviene prima di a. 1 class WaitFreeQueue<T> { 2 volatile int head=0,tail=0; 3 T[] items; 4 public WaitFreeQueue(int capacity) { 5 items = (T[])new Object[capacity]; 6 head=0;tail=0; 7 } 8 public void enq(t x) throws FullException { 9 if (tail - head == items.length) 10 throw new FullException(); 11 items[tail % items.length] = x; 12 tail++; 13 } 14 public T deq() throws EmptyException { 15 if (tail - head == 0) 16 throw new EmptyException(); 17 T x = items[head % items.length]; 18 head++; 19 return x; 20 } 21 } 24

30 PCP CAPITOLO 4 OGGETTI CONCORRENTI Consideriamo ora un altra implementazione della coda che lavora correttamente solo se vi è un solo thread che inserisce ed un altro solo che toglie gli elementi. Il concetto di funzionamento è simile a quello del lock. Condizioni di correttezza Le specifiche di correttezza di oggetti concorrenti sono sempre legate ad una certa equivalenza con il comportamento sequenziale ma con alcune differenze, a seconda del contesto. Esamineremo tre tipi di correttezza: Consistenza quiescente: appropriata per applicazioni che richiedono alte prestazioni, al costo di piazzare vincoli deboli sul comportamento degli oggetti. Consistenza sequenziale. Linearizzabilità. Consistenza quiescente Verifichiamo dove gli oggetti concorrenti seguono la nostra intuizione. Una chiamata ad un metodo inizia con l invocazione e termina con la risposta. Una chiamata è pendente se la sua invocazione è stata fatta ma non c è ancora la risposta. Un esempio non accettabile: In questo caso, due thread scrivono rispettivamente -3 e 7. Ci aspettiamo che il thread B legga -3 o 7, non -7! Principio 1: esecuzione istantanea. Le invocazioni dovrebbero apparire come se avvenissero in un ordine sequenziale. Un oggetto è quiescente se non ha invocazioni pendenti. Principio 2: quiescenza. Invocazione separate da un periodo di quiescenza dovrebbero apparire come se fossero avvenute nel loro ordine in real-time. Per esempio, supponiamo che A e B concorrentemente inseriscano nella coda FIFO x e y. La coda diventa quiescente, e successivamente C inserisce z. Non siamo capaci di predire il relativo ordine di x e y ma possiamo assicurare che sono poste prima di z. I principi 1 e 2 insieme definiscono una proprietà di correttezza chiamata consistenza quiescente. Quando un oggetto diventa quiescente, l esecuzione è equivalente a quella sequenziale. Un esempio di oggetto consistente quiescente è il contatore condiviso visto nel Capitolo 1. Il metodo getandincrement restituisce un numero senza duplicati o salti di numero, anche senza ordine. La consistenza quiescente non limita la concorrenza in quanto non blocca l invocazione di un metodo in attesa di una altra invocazione. In ogni esecuzione concorrente, per ogni invocazione pendente di un metodo totale esiste una risposta compatibile con la consistenza quiescente. Un metodo totale è un metodo definito per ogni stato dell oggetto (niente eccezioni). Una proprietà è composizionale se, laddove vale per ogni oggetto in un sistema, vale nell intero sistema. La consistenza quiescente è composizionale. Consistenza sequenziale 25

31 PCP CAPITOLO 4 OGGETTI CONCORRENTI Un singolo thread scrive prima 7 e poi -3. Quando legge, il valore che restituisce è 7. Per molte applicazioni il risultato non è accettabile perché non è l ultimo valore scritto nel registro. Le invocazioni fatte da un singolo thread sono fatte in ordine del programma. Principio 3: ordine del programma. Le invocazioni dei metodi dovrebbero apparire come se avessero effetto nell ordine del programma. Questo principio ci assicura che la computazione sequenziale segue la via che ci aspettiamo. Una computazione concorrente che rispetta sia il principio di esecuzione istantanea che quello di ordine del programma osserva la consistenza sequenziale. Nell esempio sopra ci sono due possibili ordini sequenziali che giustificano l esecuzione. Entrambi sono consistenti con l ordine del programma e solo uno è sufficiente per mostrare che l esecuzione e sequenzialmente consistente. Le due consistenze sono inconfrontabili se esistono esecuzioni che obbediscono la consistenza sequenziale e non quella quiescente e viceversa. L intuizione è che la consistenza quiescente non necessariamente preserva l ordine del programma e la consistenza sequenziale non viene influenzata da periodi di quiescenza. Nella maggior parte delle moderne architetture multiprocessore, le operazioni di lettura e scrittura della memoria non sono sequenziali: possono essere tipicamente riordinate in modo complesso. Teorema. La consistenza sequenziale non è composizionale. Dimostrazione. Si considerino queste due esecuzioni consistenti con l ordine del programma. B deve avere inserito y in p prima che A lo estragga. A deve avere inserito x in q prima che B lo estragga. In questo caso si crea un ciclo. Linearizzabilità È un nuovo tipo di consistenza. Introdotta perché la consistenza sequenziale non è composizionale. Principio 4: linearizzabilità: definisce l atomicità di oggetti individuali, richiedendo che ogni chiamata di un metodo su un dato oggetto abbia effetto istantaneamente tra l invocazione e la risposta. Questo principio dichiara che questo comportamento real time delle chiamate a metodi deve essere preservato. Quando ci sono sezioni critiche all interno di metodi, queste vengono usate per la linearizzazione; se non c è sezione critica, vengono identificati dei punti in cui i cambiamenti sono visibili. Ad esempio nella coda circolare waitfree (singolo writer/reader senza sezione critica) i punti di 26

32 PCP CAPITOLO 4 OGGETTI CONCORRENTI linearizzazione dipendono dall esecuzione. Se si restituisce un oggetto, il metodo deq ha un punto di linearizzazione quando il campo è modificato. Se la coda è vuota, con il metodo deq si ha un punto di linearizzazione sull invocazione dell eccezione. La linearizzabilità include la consistenza sequenziale (più strettamente). La linearizzabilità è composizionale e non bloccante. Condizioni di progresso Esistono delle implementazioni bloccanti dove un ritardo inaspettato da parte di un thread può impedire ad altri di fare progressi nella computazione: Cache miss: centinaia di cicli macchina. Page fault: milioni di cicli. Prelazione da parte del sistema operativo: centinaia di milioni di cicli. Una condizione di progresso non bloccante significa che un ritardo inaspettato ed arbitrario da parte di un thread non necessariamente impedisce agli altri thread di compiere progressi, un esempio è la proprietà wait-free. In un metodo wait free ogni chiamata termina l esecuzione in un numero finito di passi. In un metodo bounded wait-free ogni chiamta termina la esecuzione in un numero finito e limitato di passi (vi è un limite). Wait free è una proprietà attraente ma potenzialmente difficile da realizzare in maniera efficiente. Un metodo è lock free se garantisce che infinitamente spesso qualche chiamata di un metodo termini in un numero finito di passi. Ogni implementazione wait free è anche lock-free ma non viceversa. In alcuni casi la possibilità di starvation di qualche thread (ammessa da lock-free) è accettabile (se bassa). Proprietà di un lock. Le condizioni di progresso wait free e lock free garantiscono che la computazione effettui un progresso, indipendentemente da come il sistema schedula i thread. Deadlock-free e starvationfree sono condizioni di progresso dipendente, i progressi sono possibili solo se il sistema operativo fornisce alcune garanzie (ad esempio: ogni thread lascia, alla fine, la sezione critica ). Le classi che si basano su implementazioni con lock possono garantire solamente proprietà di progresso dipendente. Questa non è una limitazione fortissima se la preemption è rara o se il costo di assicurare progresso è basso (niente deadlock in caso di preemption in CS). Esistono anche condizioni di progresso dipendenti non bloccanti. Commenti alle condizioni di progresso. La scelta di una condizione di progresso dipende dall applicazione e dalle garanzie offerte dal S.O. Le proprietà wait-free e lock-free teoricamente lavorano su ogni piattaforma e possono essere usate per applicazioni come musica, videogiochi ed altre applicazioni interattive. Il modello di memoria di Java Il modello di memoria di Java non garantisce né linearizzabilità né consistenza sequenziale. Aderire alla consistenza sequenziale non permetterebbe di usare le ottimizzazioni dei compilatori (allocazione di registri, eliminazioni di sottoespressioni comuni, eliminazioni di letture inutili). Le ottimizzazioni non fanno differenza in una esecuzione sequenziale, ma sicuramente fanno differenza in un ambiente multi-threaded. Se l esecuzione sequenzialmente consistente del programma segue certe regole, allora ogni esecuzione in un modello rilassato è sequenzialmente consistente. Gli oggetti risiedono in memoria (condivisa) e i thread usano una memoria di lavoro (privata) dove hanno copie di lavoro dei campi letti/scritti. Senza un esplicita sincronizzazione, una scrittura può non essere subito propagata dalla memoria di lavoro alla memoria condivisa. La JVM mantiene le copie consistenti spesso, ma non è garantito che lo faccia in ogni istante. L unica garanzia che offre la JVM è che le read/write di un thread appaiono nell ordine in cui quel thread le 27

33 PCP CAPITOLO 4 OGGETTI CONCORRENTI ha istanziate (ordine del programma) e che ogni valore di un campo letto da un thread era stato in quel campo. La soluzione è la sincronizzazione, usata per la mutua esclusione ma anche per riconciliare cache e memoria. Gli eventi di sincronizzazione sono linearizzabili. Mutua esclusione tramite un lock oppure con un blocco synchronized. Tutte le read/write a un campo protetto dallo stesso lock sono linearizzabili: 1 class FinalFieldExample { 2 final int x; int y; 3 static FinalFieldExample f; 4 public FinalFieldExample() { 5 x=3; 6 y=4; 7 } 8 static void writer() { 9 f= new FinalFieldExample(); 10 } 11 static void reader() { 12 if (f!= null){ 13 int i = f.x; int j = f.y; 14 } 15 } 16 } Altre soluzione sono i campi volatile, linearizzabili. Leggere un campo volatile è come acquisire un lock in quanto la memoria di lavoro viene invalidata ed il valore corrente del campo volatile viene riletto dalla memoria. Attenzione! Letture/scritture multiple non sono atomiche. Quindi x++ può essere interfogliato, anche se x è volatile. 1 public class EventListener { 2 final int x; 3 public EventListener(EventSource eventsource) { 4 eventsource.registerlistener(this); // register with event source... 5 } 6 public onevent(event e) { 7... // handle the event 8 } 9 } Un pattern di uso comune per le variabili volatili si verifica quando un campo viene letto da più thread ma scritto da uno solo. È possibile usare AtomicReference o AtomicInteger che forniscono memoria linearizzabile. Un altra tecnica è quella dei campi final, campi che non possono essere modificati quando vengono inizializzati nel costruttore. Il concetto è semplice, il riferimentoa a this non deve essere rilasciato dal costruttore prima che esso termini. Un metodo invocato spesso (ad esempio table lookup in un firewall) dovrebbe essere wait free. Un metodo invocato poco frequentemente può essere implementato con la mutua esclusione. Un server di stampa, poco caricato, può accontentarsi della consistenza quiescente (ordine di stampa non importante). Un server di una banca dovrebbe usare code sequenzialmente consistenti per gli eventi (mettere 100 su un conto vuoto e poi ritirarne 50 non deve farti andare in rosso). Come programmatori, sarebbe ideale avere hardware linearizzabile, strutture dati linearizzabili e buone prestazioni. Sfortunatamente la tecnologia è imperfetta e l hardware con buone prestazioni non soddisfa neanche la consistenza sequenziale. Le strutture dati potrebbero essere linearizzabili con buone prestazioni. 28

34 PCP CAPITOLO 4 OGGETTI CONCORRENTI Domande di riepilogo Descrivere il problema del singleton in Java e come alcune soluzioni, come il doublechecked locking, non sono corrette Cosa significa che un oggetto concorrente è corretto? Descrivere l'algoritmo LockBasedQueue Descrivere l'algoritmo WaitFreeQueue Verificare come WaitFreeQueue è una soluzione per un single-enqueuer/single-dequeuer e che non è una solluzione in caso di multipli enqueuer/dequeuer Confrontare la LockBasedQueue con la WaitFreeQueue Perché è critico cercare di limitare al massimo l'utilizzo di lock negli oggetti concorrenti Cosa è la consistenza quiescente? Cosa è la composability di una consistenza? Perché la consistenza quiescente non limita la concorrenza? Cosa è la consistenza sequenziale? Quali sono i rapporti tra consistenza sequenziale e consistenza quiescente? Dimostrare che la consistenza sequenziale non è composizionale Cosa è la linearizzabilità e perché è necessario introdurla? Quali sono le condizioni di progresso non bloccanti? Quali sono le condizioni di progresso dipendenti? Descrivere il modello di memoria di Java Descrivere i lock e i blocchi synchronized in Java Descrivere i campi volatile in Java Descrivere i campi final in Java Quali sono le condizioni di progresso giuste in diverse situazioni? Quali sono le condizioni di correttezza giuste in diverse situazioni? 29

35 PCP CAPITOLO 5 SHARED MEMORY CAPITOLO 5 SHARED MEMORY Le fondamenta della computazione sequenziale sono state introdotte negli anni 30 con la legge di Church- Turing: qualsiasi problema intuitivamente calcolabile, può essere computato da una macchina di Turing; qualsiasi problema non risolvibile dalla macchina di Turing, non può essere risolto. Lo spazio dei registri A livello hardware, i thread comunicano leggendo e scrivendo su memoria condivisa. Un registro di lettura/scrittura (chiamato registro) è un oggetto che incapsula un valore che può essere osservato tramite una read() e modificato tramite una write() (generalmente questi metodi vengono chiamati load e store). public interface Register<T> { T read(); void write(t v); } Il valore T dell interfaccia sopra descritta può essere un Boolean, Integer, ecc. Un registro che implementa l interfaccia Register<Boolean> è chiamato registro booleano mentre un registro che implementa l interfaccia Register<Integer> di M valori interi è chiamato registro M-valori. Se le chiamate non si sovrappongono, è possibile effettuare una implementazione sequenziale. Ma su architetture multiprocessore, come già detto più volte in precedenza, l accesso alla memoria e quindi ai registri deve avvenire in modo concorrente. A tal motivo, si può proteggere il registro attraverso un mutex lock su ogni chiamata a read e write. Sfortunatamente, in questo caso non possiamo usare la mutua esclusione. Ricordiamo che un oggetto è wait-free se ogni chiamata termina in un numero finito di step, indipendentemente da come la sua esecuzione si sia alternata con gli step di un altro metodo concorrente. La condizione wait free può sembrare semplice e naturale ma comporta grandi conseguenze. In particolare, si esclude ogni forma di mutua esclusione e le garanzie indipendenti di progresso che non fanno affidamento sullo scheduler del SO. Un atomic register è un implementazione atomica linearizzabile della classe sequenziale riportata sopra. Informalmente, un registro atomico ad ogni lettura restituisce l ultimo valore scritto. Il modello di AMODEL è stato per lungo tempo il modello standard per il calcolo simultaneo. Senza sorpresa, scopriamo che è più facile realizzare un registro con un lettore unico e scrittore unico che con più lettori e scrittori. Indicheremo con SRSW registro con singolo lettore, singolo scrittore; MRSW multi lettore, singolo scrittore; MRMW multi lettore, multi scrittore. Un registro MRSW è sicuro se: 30

36 PCP CAPITOLO 5 SHARED MEMORY una read() che non si sovrappone ad una write() restituisce il valore scritto dalla più recente write(); in caso contrario, se una read() si sovrappone con un write(), questa può restituire qualsiasi valore all interno del range consentito (ad esempio da 0 a M-1 per un registro ad M-valori). In realtà i registri sono poco sicuri. Consideriamo l esempio sotto: La prima lettura restituisce 0. La seconda e la terza lettura vengono eseguite in concorrenza con W(1) e possono restituire qualsiasi valore del range del registro. Se il registro è regolare, R2 e R3 possono restituire 0 o 1. Se il registro è atomico, se R2 restituisce 1, allora anche R3 restituisce 1. Se R2 restituisce 0, R3 può restituire 0 o 1. Spin lock e contesa Quando scriviamo programmi per systemi monoprocessore, raramente teniamo conto dell architettura della nostra macchina, mentre diviene fondamentale conoscerla quando si progettano algoritmi per sistemi multiprocessore. Lo scopo di questo capitolo è di mostrare come sfruttare a pieno le performance di un architettura attraverso la programmazione concorrente. Qualsiasi protocollo di mutua esclusione si pone il seguente quesito: cosa facciamo se non acquisiamo il lock? Ci sono due alternative: spinning, riproviamo ad acquisire il lock; busy waiting. L algoritmo Filter e del fornaio sono chiamati spin lock. La fase di spinning è attuabile quando ci aspettiamo che il ritardo del lock sia piccolo. L alternativa è quella di sospendere l esecuzione e chiedere allo scheduler del SO di processare un altro thread sul processore (questa procedura è chiamata blocking). Poiché è una procedura costosa, fare blocking ha senso se ci aspettiamo che il ritardo del lock sia grande. Molti SO utilizzano entrambe le tecniche. Real Word Per questo esempio teniamo conto dell interfaccia Lock del package java.util.concurrent.locks che implementa due metodi fondamentali: lock() e unlock(). Creeremo un nuovo oggetto chiamato mutex. Poiché Lock è un interfaccia, non possiamo creare un oggetto Lock direttamente. Per fare ciò utilizziamo l oggetto LockImpl() che implementa l interfaccia Lock (il package java.util.concurrent.locks ha al suo interno diverse classi che implementano Lock). 31

37 PCP CAPITOLO 5 SHARED MEMORY Nella riga 3 acquisiamo il lock e se va a buon fine entriamo nella sezione critica. Nella sezione finally rilasciamo il lock. Non bisogna inserire il lock all interno del blocco try poiché la chiamata lock potrebbe restituire un eccezione prima di acquisire il lock portando alla chiamata di unlock nel blocco finally prima ancora di aver acquisito il lock. Se vogliamo attuare un lock efficace, perché non usare uno degli algoritmi che abbiamo studiato nel capitolo 2, come il Filter o Bakery? Un problema di questi algoritmi è il limite inferiore dello spazio necessario che abbiamo dimostrato essere lineare in n, il numero di thread che potenzialmente possono accedere al percorso. Ma c'è di peggio. Si consideri, per esempio, l algoritmo di lock 2-thread di Peterson, presentato di nuovo nella figura sotto. Ci sono due thread, A e B, con ID 0 o 1. Quando un thread vuole acquisire il blocco, setta la bandiera [A] a true, victim ad A, e le testa. Finché il test fallisce, il thread gira, ripetendo il test. Una volta che ha successo, entra nella sezione critica, abbassando di bandiera [A] a false. Sappiamo tale algoritmo fornisce mutua esclusione starvation-free. Supponiamo di scrivere un semplice programma concorrente, in cui ciascuno dei due thread acquisisce più volte il blocco Peterson, incrementa un contatore in comune, e quindi rilascia il blocco. Eseguiamo l algoritmo su un multiprocessore, in cui ogni thread esegue il ciclo di acquisizione-incremento-rilascio mezzo milione di volte. Nella maggior delle architetture moderne, i thread terminano velocemente. C è un problema, il valore del contatore può non essere 1 milione con un sottile margine di errore. Questo perché può accadere che due thread siano in sezione critica nello stesso tempo, anche se abbiamo dimostrato che non è così. Purtroppo, i multiprocessori moderi, in genere, non prevedono la coerenza sequenziale della memoria né necessariamente garantiscono l'ordine del programma tra le letture e scritture di un determinato thread. I colpevoli sono i compilatori che riordinano le istruzioni per migliorare le prestazioni. La maggior parte dei linguaggi di programmazione preservano l'ordine del programma per ogni singola variabile, ma non tra più variabili. E' quindi possibile che l'ordine di scrittura di flag[b] e victim da parte del thread B venga invertito dal compilatore Per evitare che i compilatori riordinino le operazioni è possibile utilizzare una specie di barriera della memoria, la quale forza le operazioni in sospeso. È responsabilità del programmatore però sapere dove inserire una barriera (ad esempio, il lock di Peterson può essere risolto mettendo una barriera prima di ogni lettura). Non sorprendentemente le barriere di memoria sono costose quasi quanto un istruzione compareandset () atomica. In realtà, le istruzioni di sincronizzazione come getandset () o compareandset () comprendono una barriera di memoria su molte architetture, come su letture e scritture di campi volatili. Dato che le barriere costano 32

38 PCP CAPITOLO 5 SHARED MEMORY circa quanto le istruzioni di sincronizzazione, può essere opportuno progettare algoritmi di mutua esclusione utilizzando direttamente le operazioni come getandset () o compareandset (). Lock Test-And-Set L operazione testandset () è stata la principale istruzione di sincronizzazione fornita da molte architetture multiprocessore. Questa istruzione opera su una singola parola di memoria (o byte). Quella parola contiene un valore binario, vero o falso. L istruzione, atomicamente, memorizza true nella word e restituisce il valore precedente di word, scambiando il valore true con il valore corrente della parola. A prima vista, questa istruzione sembra ideale per l'attuazione di un spin lock. Il lock è libero quando il valore della parola è falsa, e occupato quando il lock è true. Il metodo lock() applica ripetutamente testandset () fino a quando non restituisce false (vale a dire, fino a quando il lock diventa libero). Il metodo unlock() scrive semplicemente il valore false. Il package java.util.concurrent include una classe AtomicBoolean che memorizza un valore booleano. Essa fornisce un metodo set(b) che sostituisce il valore memorizzato con il valore di b, e un getandset(b) che sostituisce atomicamente il valore corrente con b, e restituisce il valore precedente. La vecchia testandset() è simile alla chiamata a getandset(true). Usiamo il termine test-and-set per rimanere compatibile con l'uso comune, ma nei nostri esempi verrà utilizzata l'espressione getandset(true) per essere compatibili con Java. La classe TASLock mostra un algoritmo di blocco basato sull istruzione testandset(). Ora prendiamo in considerazione l'algoritmo. Invece di eseguire la testandset() direttamente, il thread legge ripetutamente il lock fino a quando non sembra essere libero (cioè, fino a quando get() non restituisce false). Solo dopo che il lock sembra essere libero si applica testandset(). TASLock risulta essere molto lento, TTASLock pur essendo sostanzialmente migliore, è ancora al disotto di un ideale. Queste differenze possono essere spiegate in termini di architettura multiprocessore moderna. Quasi tutte le architetture moderne hanno problemi simili per quanto riguarda il caching e locality. Riguardo l accesso al bus, solo un processore può accedervi in scrittura mentre tutti in lettura. TAS e TTAS basato su Spin Locks Consideriamo ora come viene eseguito l'algoritmo TASLock su un architettura con bus condiviso. Ogni getandset () viene trasmessa sul bus in broadcast. Poiché tutti i thread usano in bus per comunicare con la 33

39 PCP CAPITOLO 5 SHARED MEMORY memoria, le chiamate a getandset () aggiungono ritardo a tutti i thread, anche a quelli in attesa del lock. Ancora peggio, getandset () forza gli altri processori a scartare le proprie copie in cache del lock, quindi ogni thread che fa spinning incontra un cache miss quasi ogni volta, e deve usare il bus per andare a prendere il nuovo ma invariato valore. Aggiungendo al danno la beffa, quando il vecchio thread vuole cercare di rilasciare il lock, vi è un delay in quanto il bus è monopolizzato dai thread che fanno spin. Ecco perché TASLock si comporta così male. Consideriamo ora il comportamento di TTASLock con il lock detenuto dal thread A. La prima volta, il thread B legge il lock, ha un cache miss e si blocca mentre il valore viene caricato nella sua cache. Finché A detiene il lock, B rilegge più volte il valore, ma va nella sua cache ogni volta. Quindi B non introduce nessun traffico sul bus e non rallenta gli accessi degli altri thread alla memoria. La situazione peggiora, tuttavia, quando il blocco viene rilasciato. Il possessore del lock lo rilascia, scrivendo false alla variabile di lock, invalidando le copie in cache degli spinner. Ognuno ha un cache miss, rilegge il nuovo valore, e chiama (tutti più o meno allo stesso tempo) getandset() per acquisire il lock. Il primo a riuscirvi invalida gli altri, che devono poi rileggere il valore, provocando una tempesta di traffico sul bus. Ecco perché TTAS risulta più performante di TAS, in quanto la lettura nella cache locale non introduce traffico sul bus. Backoff esponenziale Consideriamo ora come perfezionare l'algoritmo TTASLock. Contention si verifica quando più thread tentano di acquisire un lock allo stesso tempo, high contention significa che ci sono molti thread, low contention l'opposto. Nella classe TTASLock, il metodo lock() richiede due passaggi: ripetere la lettura del lock fino a quando sembra non essere libero e successivamente cercare di acquisire il lock chiamando getandset(true). Ecco un osservazione fondamentale: se qualche altro thread acquisisce il lock tra la prima e la seconda fase, molto probabilmente, vi sarà alta contesa per il lock. È più efficace che il thread faccia back off per una certa durata, dando ai thread concorrenti la possibilità di concludere. Maggiore è il numero di tentativi senza successo, maggiore è la probabilità di contesa, e più lungo deve essere il tempo di backoff. Ogni volta che il thread vede il che il lock è diventato libero, ma non riesce a ottenerlo, fa back off prima di riprovare (tutti hanno una durata random). Ogni volta che il thread tenta e non riesce a ottenere il lock, raddoppia il back-off, fino ad un massimo stabilito. Poiché il back off è comune a diversi algoritmi di lock, incapsuliamo questa logica in una classe Backoff semplice. Il costruttore prende questi argomenti: mindelay è il ritardo minimo iniziale (non ha senso per il thread fare backoff per una durata troppo breve), maxdelay necessario per evitare attese troppo lunghe. Il campo limit controlla il limite attuale di ritardo. Il metodo backoff() calcola un ritardo casuale tra zero e il limite corrente, e blocca il thread per tale durata. Raddoppia il limite per i prossimi back-off, fino a maxdelay. 34

40 PCP CAPITOLO 5 SHARED MEMORY Nella classe BackoffLock si utilizza un oggetto Backoff il cui minimo e massimo tempo di back-off sono disciplinati dalle costanti mindelay e maxdelay. È importante notare che il thread fa back off solo quando non riesce ad acquisire un lock che aveva immediatamente prima osservato di essere libero. Purtroppo, le prestazioni di questo algoritmo dipendono molto dalla scelta del mindelay e maxdelay. Testare questi valori su una singola macchina e scegliere quelli ottimali non è difficile, è difficile sceglierli per rendere la classe BackoffLock portabile su un'ampia gamma macchine diverse. Per L lock ed n thread, l algoritmo impiega O(Ln). Ci sono due problemi con l'algoritmo BackoffLock: traffico per la coerenza della cache: tutti i thread fanno spin nella stessa posizione condivisa causando traffico per la coerenza della cache ; sottoutilizzazione della sezione critica: il ritardo dei thread è più alto del necessario, cosicché la sezione critica risulta essere sottoutilizzata. Lock tramite code (QUEUE) Ora esploriamo un approccio diverso per l'attuazione di spinlock scalabile, leggermente più complicato di lock con backoff, ma molto più portabile. Come abbiamo visto, il lock basato su back off comporta 2 problemi. Si può ovviare a questi inconvenienti facendo uso di thread a forma di coda. In una coda, ogni thread può accoggersi se il suo predecessore ha finito. Il traffico per la coerenza della cache è ridotto poiché ogni thread fa spin su una posizione diversa. Una coda consente anche un utilizzo migliore della sezione critica dal momento che ogni thread è notificato direttamente dal suo predecessore nella coda e quindi viene subito utilizzata. Lock basato su array 35

41 PCP CAPITOLO 5 SHARED MEMORY La figura sopra mostra ALock, un semplice array basato su lock a coda. I thread condividono un campo AtomicInteger, inizialmente pari a zero. Per acquisire il blocco, ogni thread atomicamente incrementa la coda (linea 17). Lo slot è utilizzato come un indice in una matrice di Boolean. Se flag[j] è vero, allora il thread con lo j-esimo slot ha il permesso di acquisire il blocco. Inizialmente, flag[0] è true. Per acquisire il lock, un thread gira (spin) fino a quando il valore di flag non diventa vero (linea 19). Per rilasciare il blocco, il thread imposta il flag del suo slot a false (linea 23), e imposta il flag dello slot successivo a true (linea 24). Tutto il computo viene fatto modulo n, dove n è il numero massimo dei thread simultanei. Nel algoritmo ALock, myslotindex è una variabile locale del thread. Le variabili locali dei thread non devono essere archiviate nella memoria condivisa, non richiedono sincronizzazione e non generano alcun traffico di coerenza in quanto vi accedede un solo thread. L array flag[] è condiviso ma la contesa sulle locazioni dell array è ridotta al minimo in quanto ciascun thread, in qualsiasi momento, gira sulla sua copia locale della cache e su una posizione unica dell array, riducendo notevolmente il traffico dell invalidazione. Nell esempio seguente, nella parte (a), ALock ha 8 slot gestiti come un array circolare. I valori sono mappati generalmente in cache line consecutive. Come possiamo vedere, quando il thread A cambia il valore del rispettivo slot, il valore dello slot del thread B è mappato nella stessa cache line k incorrendo in una falsa invalidazione. Nella parte (b), ogni locazione è mappata ogni 4 slot e l array circolare è gestito modulo 32. In questo modo le entry sono mappate in cache line differenti tra i due thread non causando il problema sopra riportato. 36

42 PCP CAPITOLO 5 SHARED MEMORY Nell'esempio in figura, l'accesso alla posizione 8 può subire annullamenti inutili perché tutte le locazioni sono state memorizzate nella cache di entrambe le linee 4-word. Un modo per evitare false condivisioni è quello di mappare gli elementi dell array in modo che elementi distinti siano mappati in linee distinte di cache. ALock migliora BackoffLock poiché riduce le invalidazioni al minimo minimizzando l'intervallo tra il momento in cui il lock diventa libero da un thread e quando viene acquisito da un altro. A differenza del TASLock e BackoffLock, questo algoritmo garantisce che non si verifichi mai la starvation: il "primo arrivato è il primo servito". Purtroppo, il lock ALock non è efficiente in base allo spazio O(Ln). La coda CLH Questa classe registra ogni status di thread in un oggetto QNode, che ha un campo Boolean locked. Se tale campo è vero, allora il thread corrispondente o ha acquisito il lock o è in attesa del lock. Se tale campo è falso, allora il thread ha rilasciato il lock. Il lock stesso è rappresentato come una lista linkata di oggetti virtuali QNode. Usiamo il termine "virtuale" perché l'elenco è implicito: ogni thread si riferisce al suo predecessore con una variabile locale di thread pred. Il campo pubblico tail è un AtomicReference<QNode> che punta al nodo più recentemente aggiunto alla coda. Come mostrato nella figura precedente, quando si acquisisce un lock, un thread setta il campo locked della sua QNode a true, che indica che il thread non è 37

43 PCP CAPITOLO 5 SHARED MEMORY pronta a rilasciare il lock. Il thread applica getandset () al campo tail per fare del proprio nodo, la coda della coda e contemporaneamente acquisisce un riferimento al QNode del suo predecessore. Il thread poi gira (spin) sul campo locked del predecessore, fino a quando il predecessore rilascia il lock. Quando si rilascia un lock, il thread setta il campo locked su false. Riutilizza allora il mypred come nuovo nodo per gli accessi futuri. È possibile riciclare i nodi in modo che se ci sono lock di L, e ogni thread (ce ne sono n) accede al massimo un lock alla volta, allora la classe CLHLock rispetto allo spazio è O (L + n), a differenza di O(Ln) di ALock. La prossima figura mostra una esecuzione CLHLock tipica. Come il ALock, anche in questo algoritmo un thread agisce su una precisa zona, in modo che quando un thread rilascia il suo lock, invalida la cache solo del suo successore. Questo algoritmo richiede molto meno spazio rispetto alla classe ALock, e non richiede conoscenza del numero di thread che possono accedere al lock. Forse l'unico svantaggio di questo algoritmo è che si comporta male sulla cache-less di un architettura NUMA. Ogni thread effettua la fase di spin in attesa che il campo bloccato dal suo predecessore diventi false. La coda MCS Anche qui, il lock viene rappresentato come una lista concatenata di oggetti QNode, dove ogni QNode rappresenta sia un titolare di lock, sia un thread che è in attesa di acquisire il blocco. A differenza del CLHLock, la lista è esplicita non virtuale: invece di incarnare la lista nella variabile locale del thread, è incarnata nell oggetto QNode (accessibile a livello globale). Questo algoritmo è più adatto alla cache-less delle architetture NUMA perché ogni thread controlla la posizione su cui fa spin. Come CLHLock, i nodi possono essere riciclati in modo che questo lock ha una complessità di spazio O (L + n). Uno svantaggio dell'algoritmo MCSLock è che il rilascio di un lock richiede spinning. 38

44 PCP CAPITOLO 5 SHARED MEMORY Un lock di coda con timeout L'interfaccia Lock in Java comprende una metodo trylock() che consente al chiamante di specificare un timeout: la durata massima che il chiamante è disposto ad aspettare per l'acquisizione del lock. Se il timeout scade prima che il chiamante acquisisca il lock, il tentativo viene abbandonato. Un valore booleano viene restituito per indicare se il tentaivo è andato a buon fine o meno. Il timeout è wait-free, richiede solo un numero costante di passi. Come nel CLHLock, il lock è una coda virtuale di nodi, e ogni thread gira sul suo predecessore in attesa che il blocco venga rilasciato. Come è noto, quando ad un thread scade il timeout non può semplicemente abbandonare la coda, altrimenti il suo successore non sarà mai avvisato quando il lock verrà rilasciato. Prendiamo un approccio pigro (lazy): quando ad un thread scade il timeout, segna il suo nodo come abbandonato. Il suo successore in coda si accorge che il nodo su cui fa spin è stato abbandonato, e sposta la sua attenzione sul predecessore del nodo che ha abbandonato la coda. Questo approccio ha il vantaggio aggiunto che il successore può riciclare il nodo abbandonato. La figura 7.15 mostra i campi, il costruttore,e la classe QNode per la classe TOLock (TO=time out). 39

45 PCP CAPITOLO 5 SHARED MEMORY Quando un campo pred QNode è null, il thread associato o non ha acquisito il lock o lo ha rilasciato. Quando il campo pred di QNode si riferisce ad una classe distinta statica QNode chiamata AVAILABLE, il thread associato ha rilasciato il lock. Infine, se il campo pred si riferisce a qualche altro QNode, il thread associato ha abbandonato la richiesta di lock quindi il thread deve attendere il nodo predecessore del thread che ha abbandonato. La figura 7.16 mostra trylock della classe TOLock(). Il metodo trylock() crea un nuovo QNode con un campo pred=null e lo aggiunge alla lista, come nella classe CLHLock (linee 5-8). Se il lock è libero (linea 9), il thread entra nella sezione critica. In caso contrario, fa spin in attesa che il campo del suo predecessore cambi (linee 12-19). Se il timeout del thread predecessore scade, imposta il campo pred al suo predecessore, e il thread fa spin sul nuovo predecessore. Infine, se il thread fa spin su se stesso (Linea 20), tenta di rimuovere il suo QNode dalla lista applicando compareandset() al campo tail. Se la compareandset() ha esito negativo, indicando che il thread ha un successore, il thread setta il campo pred, precedentemente a null, al QNode del suo predecessore, indicando così che ha abbandonato la coda. Nel metodo unlock(), il thread controlla, usando compareandset (), se ha un successore (Linea 26), e se così setta il suo campo pred ad AVAILABLE. Si noti che non è sicuro riciclare un vecchio thread in questo punto, visto che il nodo potrebbe essere il riferimento del suo immediato successore, o di una catena di riferimenti. I nodi in una tale catena possono essere riciclati non appena un thread scavalca i nodi in timeout ed entra nella sezione critica. 40

46 PCP CAPITOLO 5 SHARED MEMORY Il TOLock ha molti dei vantaggi del CLHLock originale ma presenta alcuni inconvenienti, tra cui la necessità di allocare un nuovo nodo per bloccare l'accesso e il fatto che un thread che fa spin su un lock può dover scavalcare tutti i nodi in timeout prima di poter accedere alla sezione critica. Lock composite Gli algoritmi sulle code attuano la politica del primo arrivato, primo servito, rilascio veloce del lock, bassa contesa e richiedono protocolli non banali per il riciclo dei nodi che abbandonano il lock. Al contrario, gli algoritmi di backoff hanno protocolli banali di timeout, ma non sono intrinsecamente scalabili, rilascio lento del lock se i parametri di timeout non sono ben sintonizzati. In questa sezione, consideriamo un algoritmo di lock avanzato che combina il meglio di entrambi gli approcci. Si consideri la seguente semplice osservazione: un modo per bilanciare i vantaggi del lock con coda rispetto a quelli con backoff è quello di mantenere un numero ridotto di thread in attesa per la sezione critica, con l utilizzo di un backoff esponenziale durante il tentativo di entrata in questa coda. Il CompositeLock ha diverse caratteristiche interessanti. Quando più thread fanno backoff, accedono a punti differenti riducendo la contesa. Per L lock ed n thread, il CompositeLock richiede solo spazio O(L) nel caso peggiore. Lo svantaggio è che la classe CompositeLock non garantisce la proprietà "primo arrivato, primo servito. Anche se il CompositeLock è stato progettato per lavorare bene in presenza di contesa, è importante capire come si comporta quando vi è assenza di concorrenza. Idealmente, per un thread che gira da solo, l'acquisizione di un lock dovrebbe essere semplice, come in TASLock. Purtroppo, in questo algoritmo, il thread che è solo, dovrebbe effettuare un po di operazioni. È necessario quindi introdurre un fast path per risolvere questo problema. Se un thread nota che il blocco è libero, cerca una acquisizione rapida. Se ci riesce, allora ha acquisito il lock in un unico passaggio atomico. Se fallisce, allora si accoda come prima. Lock gerarchici Oggi molte architetture cache-coherent organizzano i processori in cluster. Naturalmente la comunicazione all'interno di un cluster è significativamente più veloce della comunicazione tra cluster. Ad esempio, un cluster potrebbe corrispondere a un gruppo di processori che condividono la memoria attraverso una veloce interconnessione, oppure potrebbe corrispondere a dei thread in esecuzione su un singolo core. Vorremmo progettare sistemi di lock sensibili a queste differenze di locality. Questi dispositivi di lock sono chiamati gerarchici perché prendono in considerazione la gerarchia dell architettura della memoria ed i costi di accesso. Consideriamo un architettura composta da cluster di processori, in cui i processori nello stesso cluster comunicano in maniera efficace attraverso una cache condivisa. Partiamo dal presupposto che ogni gruppo ha un cluster ID univoco noto ad ogni thread nel cluster, disponibile tramite ThreadID.getCluster(). I thread non migrano tra cluster. Un lock TTAS può essere facilmente adattato per sfruttare il clustering. Backoff gerarchico Supponiamo che il lock sia detenuto dal thread A. Se i thread dal cluster di A hanno un tempo di back off piu breve, quando il lock viene rilasciato, i thread locali hanno più probabilità di acquisire il lock rispetto a thread remoti, riducendo il tempo complessivo necessario per cambiare la proprietà di lock. La figura 7.27 mostra la classe HBOLock, un lock con backoff di tipo gerarchico. Uno svantaggio del HBOLock è che può avere troppo successo nell uso locale. C è il pericolo che i thread dello stesso cluster trasferiscano ripetutamente il lock tra di loro, mentre i thread fuori dal cluster 41

47 PCP CAPITOLO 5 SHARED MEMORY rimangano in attesa. Inoltre, acquisire e rilasciare il lock invalida una copia della cache, la cache-coherence delle architetture NUMA è molto costosa. CLH gerarchici Per fornire un bilanciamento più equilibrato per sfruttare il clustering, consideriamo un lock gerarchico a coda. Vorremmo favorire il trasferimento di lock all'interno dello stesso cluster al fine di evitare i costi elevati di comunicazione ma vogliamo anche, al fine di garantire un certo grado di equità, che le richieste di lock remoti non sono eccessivamente rinviata a favore di richieste locali. Il lock della coda di HCLHLock (Fig. 7.28) consiste in un insieme di code locali, uno per ogni cluster, e una coda unica globale. La figura 7.28 illustra come la classe HCLHLock acquisisce e rilascia un lock. Il metodo lock() prima aggiunge il nodo del thread nella coda locale, e poi aspetta fino a quando il thread può accedere alla sezione critica o il suo nodo sia in testa alla coda locale. In quest'ultimo caso, diciamo che il thread è il master del cluster, ed è responsabile di aggiungere la coda locale alla coda a livello globale. 42

48 PCP CAPITOLO 5 SHARED MEMORY Ogni coda è una lista concatenata di nodi, in cui i collegamenti sono impliciti, nel senso che sono contenuti nei campi dei thread locali, myqnode e mypred. Per ogni nodo in una coda (diversi da quelli in testa), il predecessore è il nodo mypred. La figura 7.30 mostra la classe QNode. Ogni nodo ha tre campi virtuali, l ownerid corrente del proprietario e due campi booleani, successormustwait e tailwhenspliced. Questi campi sono virtuali, nel senso che hanno bisogno di essere aggiornati in modo atomico. Il campo tailwhenspliced indica se il nodo è l'ultimo della sequenza attualmente in fase di splicing (congiunto) nella coda a livello globale. Il campo successormustwait è lo stesso dell algoritmo originale CLH: è impostato su true prima essere messo in coda, e impostato su false quando il proprietario del nodo rilascia il lock. Così, un thread in attesa di acquisire il lock può procedere quando il campo successormustwait del suo predecessore, diventa false. 43

49 PCP CAPITOLO 5 SHARED MEMORY Nella figura 7.31 vediamo il codice del metodo lock(). Nel nodo, il thread è stato inizializzato con successormustwait a vero, tailwhenspliced a falso e il campo ClusterId con l ID del cluster del chiamante. Il thread aggiunge poi il suo nodo alla fine della sua coda del cluster locale, utilizzando compareandset() per 44

50 PCP CAPITOLO 5 SHARED MEMORY cambiare la tail al suo nodo (linea 9). Se ha successo, il thread imposta il suo mypred al nodo che è stato sostituito come tail. Chiameremo questo nodo predecessore. Il thread chiama waitforgrantorclustermaster() (Linea 11), facendo in modo che il thread faccia spin fino a quando una delle seguenti condizioni non si verifichi: 1. il nodo precedente è dello stesso cluster, e tailwhenspliced e successormustwait sono entrambi falsi, oppure 2. il nodo precedente è di un cluster diverso o la bandiera del suo predecessore tailwhenspliced è vera. Nel primo caso, il nodo del thread è alla testa della coda globale, quindi entra la sezione critica e termina (Linea 14). Nel secondo caso, il nodo del thread è alla testa della coda locale, quindi è il cluster master, il che lo rende responsabile dello splicing della coda locale sulla globale. La maggior parte delle richieste di spinning waitforgrantorclustermaster() rappresentano una comunicazione locale, poco costosa. In caso contrario, o il cluster del suo predecessore è diverso da quello del thread, oppure il valore della bandiera tailwhenspliced del suo predecessore è vero. Se il predecessore appartiene ad un cluster diverso, non può essere nella coda locale di questo thread. Quindi il predecessore deve essere spostato nella coda globale e riciclato per un thread di un cluster diverso. D'altra parte, se tailwhenspliced del suo predecessore è vero, allora il nodo predecessore è stato l'ultimo che si muoveva per la coda a livello globale, e quindi il thread è ora alla testa della coda locale. Il master cluster legge la tail della coda locale e utilizza le chiamate compareandset() per modificare la tail della coda a livello globale per il nodo che ha visto in coda della sua coda locale (linea 22). Quando ci riesce, mypred è la tail della coda globale che ha sostituito (Linea 20). Quindi imposta a true il flag tailwhenspliced degli ultimi nodi giunti sulla coda a livello globale (Linea 24), indicando che il nodo (locale) successore è ora alla testa della coda locale. Questa sequenza di operazioni trasferisce i nodi locali (fino alla tail locale) nella CLH-coda nello stesso ordine nella coda locale. Una volta in coda a livello globale, il cluster master agisce come se fosse in una coda CLHLock ordinaria, entrando nella sezione critica quando il campo successormustwait del suo nuovo predecessore è falso (Linea 25). Ogni thread entrerà nella sezione critica quando il campo del suo predecessore successormustwait diventarà false (Fig. 7.32). Come con l algoritmo CLHLock, l'uso di riferimenti impliciti minimizza cache miss. 45

51 PCP CAPITOLO 5 SHARED MEMORY Domande di riepilogo e di approfondimento Cosa è il meccanismo dello spinning? Cosa è il meccanismo del blocking? Quale è la differenza tra spinning e blocking? In quali situazioni una soluzione è migliore dell'altra? Quali sono i due problemi che sconsigliano l'utilizzo di algoritmi di lock come Filter, Bakery o Peterson? IN che maniera la dimostrazione di correttezza del lock di Peterson viene inficiata dalla mancanza di consistenza sequenziale? A cosa può essere addebitato la mancanza di consistenza sequenziale delle moderne architetture hardware/software? Perché le memory barrier sono costose in termini di tempo? Descrivere il Test-and-Set lock Descrivere il Test-and-Test-and-Set lock Perché TTAS risulta sperimentalmente più efficiente di TAS? Quali sono le motivazioni per introdurre il backoff nei lock, a seconda di high o low contention? Descrivere l'algoritmo di BackoffLock Perché si fa backoff solamente all'interno del ciclo while (cioè se il Test-and-Set non va a buon fine) e non all'esterno (cioé durante il Test)? Cosa succederebbe se facessimo il backoff fuori dal ciclo (anche magari rendendo più lenta la crescita del ritardo (passandolo da esponenziale a lineare, ad esempio))? Quali sono i problemi di BackoffLock che portano alla definizione delle code di lock? Quali sono le caratteristiche principali di queue lock che le rendono più efficienti rispetto agli algoritmi precedentemente visti? Cosa offre in più come funzionalità una qualsiasi queuelock rispetto agli altri tipi di lock? Descrivere l'algoritmo di Lock di Anderson (ALock) Perché sono critiche le variabili thread-local in ALock? Perchè flag[] deve essere condiviso (e non thread-local) e ciò nonostante non crea problemi di efficienza? Descrivere il meccanismo di false sharing che può capitare con ALock e come può essere risolto Descrivere le motivazioni a CLH Lock Descrivere l'algoritmo di CLH Lock In che maniera in CLH si mantiene una lista "virtuale" di nodi per rappresentare la coda? Quali sono i vantaggi e gli svantaggi di CLH Lock? E rispetto ad ALock? Quali sono le motivazioni a MCSLock? E le differenza strutturali con CLH Lock? Descrivere l'algoritmo di MCSLock Perché MCSLock funziona meglio su architetture NUMA cache-less? In che cosa si differenzia la coda "gestita" da MCS con la coda "gestita" da CLH? Perchè servono le code con timeout? Descrivere un meccanismo di Backofflock con timeout 46

52 PCP CAPITOLO 5 SHARED MEMORY Quali sono i problemi che l'introduzione di un timeout può provocare su una implementazione classica (A, CHL, MCS) Descrivere l'algoritmo TOLock Quale motivazione hanno i Composite Locks? Quale è l'idea di un Composite Lock? Perchè serve il Fast Path? Perchè sono necessari gli hierarchical locks? In che architettura parallela sono particolarmente utili? Descrivere gli hierarchical Backoff lock Descrivere l'idea dei Hierarchical CLH lock 47

53 PCP CAPITOLO 6 MONITOR CAPITOLO 6 MONITOR I monitor sono un modo strutturato di combinare e di sincronizzare i dati. Proviamo ad immaginare un applicazione con due thread, un produttore e un consumatore, che comunicano attraverso una coda FIFO condivisa. I thread potrebbero condividere due oggetti: una coda non sincronizzata ed un blocco per proteggere la coda. Il produttore sembra essere qualcosa del genere: mutex.lock(); try { queue.enq(x) } finally { mutex.unlock(); } Supponiamo che la coda sia limitata, nel senso che non si può aggiungere un elemento ad una coda piena. La decisione di bloccare la chiamata o di farla procedere dipende dallo stato interno della coda, che è (e deve essere) inaccessibile al chiamante. Ancora peggio, si supponga che si decida di avere più produttori e/o più consumatori. Ogni thread deve tenere traccia sia del blocco che degli oggetti della coda, e l'applicazione sarà corretta solo se ogni thread seguirà lo stesso blocco. Un approccio più ragionevole è quella di permettere ad ogni coda di gestire la sua sincronizzazione. La coda di per sé ha il proprio lock interno, acquisito da ciascun metodo quando viene chiamato e rilasciato quando termina. Se un thread tenta di accodare un elemento in una coda che è già piena, il metodo enq() di per sé è in grado di rilevare il problema, sospendere il chiamante, e riprendere il chiamante quando la coda ha spazio. Lock di Monitor e condizioni Il lock è il meccanismo di base per garantire la mutua esclusione, solo un thread alla volta può avere il lock. Un monitor esporta una collezione di metodi, ognuno dei quali acquisisce il lock quando viene chiamato, e lo rilascia quando si restituisce. Se un thread non può immediatamente acquisire un lock, fa spin e ripete il test fino a quando l'evento desiderato non ha successo, oppure può bloccarsi dando la possibilità ad un altro thread di essere eseguito. Fare spinning ha senso quando si ha un multiprocessore e ci aspettiamo di attendere per un breve periodo, perché il blocco di un thread richiede una chiamata costosa per il sistema operativo. D'altra parte, il blocking ha senso solo se ci aspettiamo di attendere un periodo lungo, perché un thread che fa spinning continua ad occupare il processore senza fare alcun lavoro. Ad esempio, un thread in attesa che un altro thread rilasci un lock dovrebbe fare spin se tale lock è breve, mentre un thread consumatore in attesa di una dequeue di un elemento da un buffer vuoto dovrebbe bloccarsi, in quanto di solito non c'è modo di prevedere quanto tempo si debba aspettare. Spesso, ha senso combinare spinning e blocking: un thread in attesa di dequeue può fare spin per un breve periodo, e poi passare al blocco se il ritardo sembra essere lungo. Il blocking funziona su entrambi i tipi di processore mentre lo spin funziona solo sui multiprocessori. 48

54 PCP CAPITOLO 6 MONITOR 1 public interface Lock { 2 void lock(); 3 void lockinterruptibly() throws InterruptedException; 4 boolean trylock(); 5 boolean trylock(long time, TimeUnit unit); 6 Condition newcondition(); 7 void unlock(); 8 } Figura 8.1 The Lock Interface. Il metodo lock () blocca il chiamante fino a quando non acquista il blocco. Il lockinterruptibly() agisce come metodo lock(), ma crea un'eccezione se il thread è interrotto mentre è in attesa. Il metodo unlock() rilascia il lock. Il newcondition() è una factory che crea e restituisce un oggetto Condition associato al lock. Il metodo trylock() acquisisce il lock se è libero, e subito restituisce un valore booleano che indica se ha acquisito il lock. Questo metodo può anche essere chiamato con un timeout. Mentre un thread è in attesa che accada qualcosa, per esempio che un altro thread sistemi un elemento in una coda, è una buona idea rilasciare il lock sulla coda, perché altrimenti l'altro thread non sarà mai in grado di accodare l elemento. Il thread ha però bisogno di un metodo che ha il compito di informarlo quando deve riacquisire il lock e riprovare. Nel pacchetto di concorrenza di Java, la capacità di rilasciare un lock temporaneo è fornito da un oggetto Condition associato al lock. La figura 8.2 mostra l'utilizzo dell'interfaccia Condition fornita dal package java.util.concurrent.locks. Una Condition è associata ad un lock, ed è creata chiamando il metodo del lock newcondition(). Se il thread che ha il lock chiama il metodo await(), rilascia il lock e si ferma, dando ad un altro thread la possibilità di acquisire il lock. Quando la chiamata risveglia il thread, riacquisisce il lock, magari in concorrenza con altri thread. Un thread in Java può essere interrotto da altri thread. Se un thread viene interrotto durante una chiamata ad una await() della Condition, allora la chiamata lancerà una InterruptedException. 1 Condition condition = mutex.newcondition(); mutex.lock() 4 try { 5 while (!property) { // not happy 6 condition.await(); // wait for property 7 } catch (InterruptedException e) { 8... // application-dependent response 9 } // happy: property must hold 11 } Fig. 8.2 Esempio di uso di Condition Come i lock, gli oggetti Condition devono essere utilizzati in modo stilizzato. Supponiamo che un thread voglia aspettare fino a quando una certa proprietà valga. Il thread testa la proprietà mentre detiene il lock. Se la proprietà non diventa vera, allora il thread chiama await() per rilasciare il lock e va in sleep fino a quando non viene risvegliato da un altro thread. Ecco il punto chiave: non esiste alcuna garanzia che la proprietà varrà quando il thread sarà risvegliato. Il thread deve quindi ritestare la proprietà, ed in caso non valga deve chiamare di nuovo await(). 49

55 PCP CAPITOLO 6 MONITOR 1 public interface Condition { 2 void await() throws InterruptedException; 3 boolean await(long time, TimeUnit unit) 4 throws InterruptedException; 5 boolean awaituntil(date deadline) 6 throws InterruptedException; 7 long awaitnanos(long nanostimeout) 8 throws InterruptedException; 9 void awaituninterruptibly(); 10 void signal(); // wake up one waiting thread 11 void signalall(); // wake up all waiting threads 10 12} void signal(); // wake up one waiting thread 11 Figura 8.3 void Interfaccia signalall(); di Condition // wake up all waiting threads 12} Figura 8.3 Interfaccia di Condition L'interfaccia Condition offre diverse varianti di questo tipo, alcune delle quali forniscono la possibilità di specificare un tempo massimo durante il quale il chiamante può essere sospeso, o se il thread può essere interrotto mentre è in attesa. Immaginiamo il caso in cui si deve effettuare una cambiamento alla coda, il thread che ha apportato la modifica può notificare agli altri thread ed attendere una condition. La chiamata signal() sveglia un thread che è in attesa su una condition, oppure signalall() risveglia tutti i thread in attesa. La Fig. 8,4 descrive un schema di esecuzione di un blocco di monitor. La figura 8.5 mostra come implementare una coda FIFO utilizzando i lock espliciti e le condition. Il campo lock è un lock che deve essere acquistato da tutti i metodi. Dobbiamo inizializzare un'istanza di una classe che implementi l interfaccia Lock. Come discusso nella sezione precedente, questo blocco è rientrante: un thread che tiene il lock è in grado di acquisirlo di nuovo senza blocking. Ci sono due oggetti Condition: notempty notifica a coloro che sono in attesa di effettuare deque, che la coda non è vuota, notfull il contrario. Utilizzando due condition, il codice risulta essere più efficiente, in quanto un numero minore di thread viene svegliato inutilmente. Questa combinazione di metodi, lock di mutua esclusione ed oggetti condition è chiamata monitor. 50

56 PCP CAPITOLO 6 MONITOR 1 class LockedQueue<T> { 2 final Lock lock = new ReentrantLock(); 3 final Condition notfull = lock.newcondition(); 4 final Condition notempty = lock.newcondition(); 5 final T[] items; 6 int tail, head, count; 7 public LockedQueue(int capacity) { 8 items = (T[])new Object[capacity]; 9 } 10 public void enq(t x) { 11 lock.lock(); 12 try { 13 while (count == items.length) 14 notfull.await(); 15 items[tail] = x; 16 if (++tail == items.length) 17 tail = 0; 18 ++count; 19 notempty.signal();//notifica che la coda non è vuota 20 } finally { 21 lock.unlock(); 22 } 23 } 24 public T deq() { 25 lock.lock(); 26 try { 27 while (count == 0) 28 notempty.await(); 29 T x = items[head]; 30 if (++head == items.length) 31 head = 0; 32 --count; 33 notfull.signal(); 34 return x; 35 } finally { 36 lock.unlock(); 37 } 38 } 39 } Fig. 8.5 Classe LockedQueue Il problema del risveglio perso Proprio come i lock sono intrinsecamente vulnerabili al deadlock, gli oggetti condition sono vulnerabili ai risvegli persi, in cui uno o più thread aspettano per sempre senza rendersi conto che la condizione per cui sono in attesa è diventata vera. La figura 8.6 mostra una ottimizzazione della classe Queue<T>. Invece di segnalare la condizione notempty ogni volta che si effettua la enq(), non sarebbe più efficace segnalare la condizione solo quando la coda è realmente passata dallo stato vuoto a non vuoto? Questa ottimizzazione funziona come previsto se vi è solo un produttore e un consumatore, ma non funziona correttamente se ci sono più produttori e più consumatori. 51

57 PCP CAPITOLO 6 MONITOR 1 public void enq(t x) { 2 lock.lock(); 3 try { 4 while (count == items.length) 5 notfull.await(); 6 items[tail] = x; 7 if (++tail == items.length) 8 tail = 0; 9 ++count; 10 if (count == 1) { // Wrong! 11 notempty.signal(); 12 } 13 } finally { 14 lock.unlock(); 15 } 16 } Fig. 8.6 Consideriamo il seguente scenario: vi sono due consumatori A e B che cercano di fare dequeue di un elemento da una coda vuota. Appena visto che la coda è vuota, si bloccano in attesa della condizione notempty. Ora il produttore C accoda un elemento nel buffer e segnala notempty, svegliando A. Prima che A acquisisca il lock, un altro produttore D mette una seconda voce nella coda, e poichè la coda non è vuota, il segnale notempty raggiunge A. Allora A acquisisce il lock, rimuove il primo elemento, ma B, vittima del risveglio perso, attende sempre, anche se vi è un elemento nel buffer che può essere consumato. Per risolvere in parte questo problema si può: inviare a tutti i processi il segnale che sono in attesa di una condizione, non ad uno solo; specificare un timeout dell attesa. Lock readers-writers Molti oggetti condivisi hanno la proprietà che molti metodi, chiamati reader, restituiscono informazioni sullo stato dell oggetto senza modfificarlo, mentre solo un piccolo numero di chiamate, dette writers, effettivamente modificare l'oggetto. Non è necessario per i lettori la sincronizzazione con un altro lettore mentre lo è per i writers. Un lock readers-writer permette a più lettori o un singolo ad accedere alla sezione critica contemporaneamente. Usiamo la seguente interfaccia: public interface ReadWriteLock { Lock readlock(); Lock writelock(); } Questa interfaccia esporta due oggetti lock, il lock di lettura e il lock di scrittura. Essi devono soddisfare le seguenti caratteristiche di sicurezza: Nessun thread può acquisire il blocco di scrittura, mentre un altro thread ha il lock di scrittura o lettura. Nessun thread può acquisire il lock in lettura, mentre qualsiasi altro thread contiene il blocco di scrittura. Naturalmente, più thread possono tenere il blocco di lettura allo stesso tempo. 52

58 PCP CAPITOLO 6 MONITOR 1 public class SimpleReadWriteLock implements ReadWriteLock { 2 int readers; 3 boolean writer; 4 Lock lock; 5 Condition condition; 6 Lock readlock, writelock; 7 public SimpleReadWriteLock() { 8 writer = false; 9 readers = 0; 10 lock = new ReentrantLock(); 11 readlock = new ReadLock(); 12 writelock = new WriteLock(); 13 condition = lock.newcondition(); 14 } 15 public Lock readlock() { 16 return readlock; 17 } 18 public Lock writelock() { 19 return writelock; 20 } 21 class ReadLock implements Lock { 22 public void lock() { 23 lock.lock(); 24 try { 25 while (writer) { 26 condition.await(); 27 } 28 readers++; 29 } finally { 30 lock.unlock(); 31 } 32 } 33 public void unlock() { 34 lock.lock(); 35 try { 36 readers--; 37 if (readers == 0) 38 condition.signalall(); 39 } finally { 40 lock.unlock(); 41 } 42 } 43 } 44 protected class WriteLock implements Lock { 45 public void lock() { 46 lock.lock(); 47 try { 48 while (readers > 0 writer) { 49 condition.await(); 50 } 51 writer = true; 52 } finally { 53 lock.unlock(); 54 } 55 } 56 public void unlock() { 57 writer = false; 58 condition.signalall(); 59 } 60 } 61 } 53

59 PCP CAPITOLO 6 MONITOR Questa classe utilizza un contatore per tenere traccia del numero di lettori che hanno acquisito il lock, e un campo booleano che indica se uno scrittore ha acquisito il lock. Per definire i rispettivi lock di letturascrittura, questo codice utilizza le classi interne, una caratteristica di java che permette ad di creare altri oggetti all interno del primo. Entrambi i metodi readlock() e writelock() restituiscono gli oggetti che implementano l'interfaccia Lock. Questi oggetti comunicano attraverso il writelock() dell'oggetto. Poiché i metodi di lettura-scrittura del lock devono essere sincronizzati uno con l'altro, entrambi utilizzano la sincronizzazione e i campi condizione del loro oggetto comune SimpleReadWriteLock. Anche se l'algoritmo SimpleReadWriteLock è corretto, non è ancora abbastanza soddisfacente. Se le letture sono molto più frequenti delle scritture, come di solito accade, uno scrittore potrebbe essere bloccato per lungo tempo da un flusso continuo di lettori. La classe FifoReadWriteLock, mostrato nelle figure. 8,10-8,12, mostra un modo per dare priorità agli scrittori. Questa classe prevede che, quando uno scrittore chiama il lock di scrittura, nessun lettore sarà in grado di acquisire il lock in lettura fino a quando lo scrittore non lo rilasci. Il campo readacquires conta il numero totale di acquisizioni di lettura, e il campo readreleases conta il numero totale dei rilasci. Quando i due valori sono uguali, nessun thread ha lock di lettura. La classe ha un campo lock privato, tenuti da lettori di breve durata: invece di acquisire il lock, incrementa il campo readacquires, e rilascia il lock. In questo modo, uno scrittore può acquisire il blocco di scrittura per il tempo che serve e poi rilasciarlo. Questo protocollo garantisce che una volta che uno scrittore ha acquisito il lock, nessun lettore aggiuntivo può incrementare readacquires e quindi acquisire il lock in lettura. Quando uno scrittore tenta di acquisire il lock di scrittura, acquista il lock dell oggetto FifoReadWriteLock. Un lettore rilasciando il lock di lettura acquisisce tale lock e chiama il metodo della condizione associata signal() se tutti i lettori hanno rilasciato i loro lock. 1 public class FifoReadWriteLock implements ReadWriteLock { 2 int readacquires, readreleases; 3 boolean writer; 4 Lock lock; 5 Condition condition; 6 Lock readlock, writelock; 7 public FifoReadWriteLock() { 8 readacquires = readreleases = 0; 9 writer = false; 10 lock = new ReentrantLock(); 11 condition = lock.newcondition(); 12 readlock = new ReadLock(); 13 writelock = new WriteLock(); 14 } 15 public Lock readlock() { 16 return readlock; 17 } 18 public Lock writelock() { 19 return writelock; 20 } } 54

60 PCP CAPITOLO 6 MONITOR 23 private class ReadLock implements Lock { 24 public void lock() { 25 lock.lock(); 26 try { 27 while (writer) { 28 condition.await(); 29 } 30 readacquires++; 31 } finally { 32 lock.unlock(); 33 } 34 } 35 public void unlock() { 36 lock.lock(); 37 try { 38 readreleases++; 39 if (readacquires == readreleases) 40 condition.signalall(); 41 } finally { 42 lock.unlock(); 43 } 44 } 45 } private class WriteLock implements Lock { public void lock() { lock.lock(); try { while (writer) { condition.await(); } writer = true; while (readacquires!= readreleases) { condition.await(); } } finally { lock.unlock(); } } public void unlock() { writer = false; condition.signalall(); } } Un nostro Reentrant Lock Un thread che tenta di riacquistare un lock di cui ne è già proprietario, va in una situazione di stallo con se stesso. Questa situazione può verificarsi quando all interno del metodo che acquisisce un lock vi è una chiamata ad un altro metodo che acquisisce lo stesso lock. Un lock è reentrant se può essere acquisito più volte dalla stesso thread. Esaminiamo ora come creare un lock reentrant da un lock non reentrant. In pratica, il pacchetto java.util.concurrent.locks fornisce le classi di lock reentrant, quindi non c'è bisogno di scrivere la nostra. 55

61 PCP CAPITOLO 6 MONITOR 1 public class SimpleReentrantLock implements Lock{ 2 Lock lock; 3 Condition condition; 4 int owner, holdcount; 5 public SimpleReentrantLock() { 6 lock = new SimpleLock(); 7 condition = lock.newcondition(); 8 owner = 0; 9 holdcount = 0; 10 } 11 public void lock() { 12 int me = ThreadID.get(); 13 lock.lock(); 14 if (owner == me) { 15 holdcount++; 16 return; 17 } 18 while (holdcount!= 0) { 19 condition.await(); 20 } 21 owner = me; 22 holdcount = 1; 23 } 24 public void unlock() { 25 lock.lock(); 26 try { 27 if (holdcount == 0 owner!= ThreadID.get()) 28 throw new IllegalMonitorStateException(); 29 holdcount--; 30 if (holdcount == 0) { 31 condition.signal(); 32 } 33 } finally { 34 lock.unlock(); 35 } 36 } public Condition newcondition() { 39 throw new UnsupportedOperationException("Not supported yet."); 40 } } Fig La figura sopra mostra la classe SimpleReentrantLock. Il campo own detiene l'id dell ultimo thread che ha acquisito il lock e il campo holdcount è incrementato ogni volta che il blocco è acquisito e decrementato ogni volta che viene rilasciato. Il lock è libero quando il valore di holdcount è pari a zero. Poiché questi due campi sono manipolati atomicamente, abbiamo bisogno di un lock interno a breve termine. Il campo di lock è usato, tramite la chiamata a lock() e rilasciato tramite la unlock(), per manipolare i campi, e il campo condition è utilizzato dal thread in attesa che il blocco di diventare liberi. Abbiamo inizializzato il campo interno di lock con SimpleLock, una classe (fittizia) presumibilmente non reentrant (linea 6). Il metodo lock() acquisisce il blocco interno (linea 13). Se il thread corrente è già il proprietario, si incrementa il numero e termina (Linea 14). Altrimenti, se il numero di attesa non è zero, il blocco viene mantenuto da un altro thread, il chiamante rilascia il blocco e aspetta fino a quando la condizione è segnalata (linea 19). Quando il thread viene risvegliato, deve ancora verificare che il conteggio è zero. Quando il contatore hold 56

62 PCP CAPITOLO 6 MONITOR è pari a zero (è uscito fuori dal ciclo while), imposta il proprietario del lock a se stesso ed imposta il contatore hold a 1. Il metodo unlock() acquisisce il blocco interno (linea 25). Genera un eccezione sia se il blocco è libero, sia se il chiamante non è il proprietario (Linea 27). In caso contrario, diminuisce il conteggio di hold. Se il conteggio di hold è pari a zero, allora il lock è libero, così il thread segnala la condizione che sveglia un thread in attesa (Linea 31). Semafori Come abbiamo visto, un lock di mutua esclusione garantisce che solo un thread alla volta possa entrare in una sezione critica. Se un altro thread vuole entrare nella sezione critica mentre è occupata, si blocca fino a quando l altro thread non notifica che può riprovare. Un semaforo è una generalizzazione di un lock con mutua esclusione. Ogni semaforo ha una capacity, indicata con c. Invece di lasciare un solo thread alla volta nella sezione critica, un semaforo consente al massimo c thread. Tale capacità è determinata quando il semaforo è inizializzato. La classe Semaphore della fig. 8,14 fornisce due metodi: un thread chiama acquire() per richiedere il permesso di entrare nella sezione critica, e il release() per annunciare che è in uscita dalla sezione critica. Il semaforo è solo un contatore in quanto tiene traccia del numero di thread a cui è stato concesso entrare. Se una nuova acquire() è in procinto di superare la capacità c, la chiamata del thread è sospesa fino a quando non vi è spazio. Quando lascia la sezione critica, chiama release() che notifica ad un thread in attesa che c è posto in sezione critica. 1 public class Semaphore { 2 final int capacity; 3 int state; 4 Lock lock; 5 Condition condition; 6 public Semaphore(int c) { 7 capacity = c; 8 state = 0; 9 lock = new ReentrantLock(); 10 condition = lock.newcondition(); 11 } 12 public void acquire() { 13 lock.lock(); 14 try { 15 while (state == capacity) { 16 condition.await(); 17 } 18 state++; 19 } finally { 20 lock.unlock(); 21 } 22 } 23 public void release() { 24 lock.lock(); 25 try { 26 state--; 27 condition.signalall(); 28 } finally { 29 lock.unlock(); 30 } 31 } 32 } Semaphore implementation. 57

63 PCP CAPITOLO 7 LINKED LIST CAPITOLO 7 LINKED LIST Nel capitolo 5 abbiamo visto come è possibile creare spin lock scalabili che forniscono una mutua esclusione efficiente. I tipi di sincronizzazione sono: Coarse-grained synchronization: prendere una classe sequenziale, aggiungere un lock ad un campo scalabile e garantire che ogni chiamata al metodo, acquisisca e rilasci il lock. La sincronizzazione coarse-grained funziona bene quando i livelli di concorrenza sono bassi, ma se troppi thread tentano di accedere all'oggetto nello stesso tempo, l'oggetto stesso diventa un collo di bottiglia sequenziale, costringendo i thread ad attendere per l'accesso. Sincronizzazione fine-grained. Invece di usare un singolo lock per sincronizzare tutti gli accessi a un oggetto, suddividiamo l'oggetto in componenti indipendenti sincronizzati, garantendo che le chiamate dei metodi interferiscano solo quando si tenta di accedere allo stesso componente nello stesso istante. Sincronizzazione optimistic. Molti oggetti, come gli alberi o le liste sono costituiti da molteplici componenti collegati tra loro da riferimenti. Un modo per ridurre i costi del fine-grained è quello di effettuare la ricerca senza acquisire il lock di tutti. Se il metodo trova il componente ricercato, lo blocca e controlla che il componente non sia cambiato durante l intervallo di tempo da quando è stato ispezionato a quando è stato bloccato. Sincronizzazione Lazy. A volte ha senso rimandare il lavoro pesante. Per esempio, il compito di eliminare un componente da una struttura dati può essere suddiviso in due fasi: la componente è logicamente rimossa semplicemente impostando un bit tag, e più tardi, il componente può essere rimosso fisicamente scollegandolo dal resto della struttura dati. Sincronizzazione Nonblocking. A volte è possibile eliminare tutti i lock, basandosi su operazioni atomiche come compareandset () per la sincronizzazione. Come mostrata nella figura successiva, un Set fornisce tre metodi principali: 1. add(x) che inserisce l elemento x solo se non è già presente restituendo true in caso positivo, false altrimenti; 2. remove(x) rimuove x dall insieme e restituisce l esito dell operazione; 3. contains(x) restituisce true se l elemento x è contenuto nell insieme, false altrimenti. 1 public interface Set<T> { 2 boolean add(t x); 3 boolean remove(t x); 4 boolean contains(t x); 5 } Set (insiemi) basati su liste In questo paragrafo studieremo l accesso concorrente ad insiemi implementati con liste concatenate di nodi. Il campo item è l oggetto vero e proprio. Il valore key è il valore hash dell oggetto. Gli elementi sono ordinati in base a questo valore in modo da poter ricercare velocemente se è presente o meno nell insieme. Next è un riferimento all oggetto successivo nella lista. Il valore key è unico per ogni elemento. 1 private class Node { 2 T item; 3 int key; 4 Node next; 5 } 58

64 PCP CAPITOLO 7 LINKED LIST Abbiamo due tipi di nodi, quelli normali e due nodi sentinella che sono head e tail, rispettivamente il primo e l ultimo elemento. Questi elementi non vengono mai aggiunti, eliminati o ricercati e i valori key sono i valori minimo e massimo rappresentabile. Le ragioni della concorrenza Ragionare su strutture di dati con accessi simultanei può sembrare molto difficile, ma è una capacità che può essere appresa. Spesso, la chiave per capire ciò è quello di comprendere le sue invarianti: proprietà che valgono sempre. Siamo in grado di mostrare che una proprietà è invariante dimostrando che: 1. la proprietà vale quando l'oggetto viene creato, e 2. una volta che la proprietà vale, allora nessun thread può fare un passo che rende la proprietà falsa. In particolare, possiamo verificare che ogni invariante è preservata da ogni invocazione dei metodi add(), remove(), e contains(). Questo approccio funziona solo se si può supporre che questi metodi sono gli unici che modificano i nodi, una proprietà talvolta chiamata libertà da interferenze (freedom from interference). Negli algoritmi che considereremo, i nodi sono interni all implementazione della lista così la proprietà freedom from interference è garantita perché gli utenti che accedono alla lista non hanno l opportunità di modificare i nodi interni. Abbiamo bisogno di libertà da interferenze anche per i nodi che sono stati rimossi dalla lista, dal momento che alcuni dei nostri algoritmi permettono ad un thread di scollegare un nodo mentre viene attraversato da altri. Se a e b sono nodi, diciamo che a punta a b se il campo next di a referenzia a b. Diremo che b è reachable se vi è una sequenza di nodi che parte dalla testa e termina in b, dove ogni nodo della sequenza punta al suo successore. Un elemento fa parte dell'insieme se e solo se esso è raggiungibile dalla testa. La nostra safety è la linearizability. Come abbiamo visto nel capitolo 4, per dimostrare che una struttura di dati concorrente è una implementazione linearizable di uno specifico oggetto sequenziale, è sufficiente individuare un punto di linearizzazione, un solo passo atomico in cui la chiamata del metodo ha effetto. Questo passo può essere una lettura, di scrittura, o un operazione atomica più complessa. 59

65 PCP CAPITOLO 7 LINKED LIST Ricordiamo che: Un metodo è wait free se garantisce che ogni chiamata termini in un numero finito di passi; Un metodo è look-free se garantisce che qualche chiamata termini sempre in un numero finito di passi. Iniziamo ora a considerare una serie di algoritmi che trattano gli insiemi. Si comincia con algoritmi grossolani che utilizzano una sincronizzazione a grana fine, e successivamente affinarli per ridurre granularità di lock. In ognuno di questi algoritmi, i metodi effettuano la scansione utilizzando due variabili locali: curr è il nodo corrente e pred è il suo predecessore. Queste sono variali locali del thread, e denoteremo curr A e pred A per indicare le istanze del thread A. Sincronizzazione coarse-grained Si comincia con un semplice algoritmo utilizzando la sincronizzazione a grana grossa. La lista ha un unico lock che ogni metodo deve acquisire. Il maggior vantaggio di questo algoritmo, che non dovrebbe essere scontato è che è corretto. 1 public class CoarseList<T> { 2 private Node head; 3 private Lock lock = new ReentrantLock(); 4 public CoarseList() { 5 head = new Node(Integer.MIN_VALUE); 6 head.next = new Node(Integer.MAX_VALUE); 7 } 8 public boolean add(t item) { 9 Node pred, curr; 10 int key = item.hashcode(); 11 lock.lock(); 12 try { 13 pred = head; 14 curr = pred.next; 15 while (curr.key < key) { 16 pred = curr; 17 curr = curr.next; 18 } 19 if (key == curr.key) { 20 return false; 21 } else { 22 Node node = new Node(item); 23 node.next = curr; 24 pred.next = node; 25 return true; 26 } 27 } finally { 28 lock.unlock(); 29 } 30 } 31 public boolean remove(t item) { 32 Node pred, curr; 33 int key = item.hashcode(); 34 lock.lock(); 35 try { 36 pred = head; 37 curr = pred.next; 38 while (curr.key < key) { 39 pred = curr; 40 curr = curr.next; 41 } if (key == curr.key) { 43 pred.next = curr.next; 44 return true; 45 } else {

66 PCP CAPITOLO 7 LINKED LIST 39 pred = curr; 40 curr = curr.next; 41 } 42 if (key == curr.key) { 43 pred.next = curr.next; 44 return true; 45 } else { 46 return false; 47 } 48 } finally { 49 lock.unlock(); 50 } 51 } 52 } La classe CoarseList soddisfa la condizione di progresso: se il Lock è starvation-free, lo è anche la nostra implementazione. Se la contesa è molto bassa, questo algoritmo è un ottimo modo per realizzare una lista. Se, tuttavia, vi è contesa, quindi anche se il lock si comporta bene, i thread continueranno ad essere ritardati in attesa l'uno dell'altro. Sincronizzazione Fine-Grained Siamo in grado di migliorare la concorrenza bloccando i singoli nodi, piuttosto che un lock complessivo della lista. Invece di piazzare un lock su tutta la lista, cerchiamo di aggiungere un lock ad ogni nodo. Quando un thread attraversa la lista, fa il lock di ogni nodo prima che venga visto, e dopo un po di tempo lo rilascia. In questo modo permette a thread concorrenti di attraversare la lista tutti insieme in pipeline. Prendiamo in considerazione due nodi a e b dove a punta a b. Non è sicuro unlockare a prima di lockare b perché un altro thread potrebbe rimuovere b dalla lista durante l intervallo tra la fase di unlock di a e lock di b. Invece, il thread A deve acquisire i lock in una sorta di ordine "hand-over-hand": tranne che per il nodo iniziale, bisogna acquisire il lock per Curr A solo mentre si tiene il lock per Pred A. Questo protocollo di lock è chiamato lock coupling. Le figure successive mostrano l'algoritmo FineList con metodi add () e remove(). Proprio come nella lista precedente, la remove() rende Curr A non raggiungibile impostando il campo next di pred A uguale al successore di curr A. Per essere sicuri, la remove() deve bloccare sia Pred A e Curr A. 1 public boolean add(t item) { 2 int key = item.hashcode(); 3 head.lock(); 4 Node pred = head; 5 try { 6 Node curr = pred.next; 7 curr.lock(); 8 try { 9 while (curr.key < key) { 10 pred.unlock(); 11 pred = curr; 12 curr = curr.next; 13 curr.lock(); 14 } 15 if (curr.key == key) { 16 return false; 17 } 18 Node newnode = new Node(item); 19 newnode.next = curr; 20 pred.next = newnode; 21 return true; } finally { 23 curr.unlock(); 24 } 25 } finally {

67 PCP CAPITOLO 7 LINKED LIST 20 pred.next = newnode; 21 return true; 22 } finally { 23 curr.unlock(); 24 } 25 } finally { 26 pred.unlock(); 27 } 28 } 29 public boolean remove(t item) { 30 Node pred = null, curr = null; 31 int key = item.hashcode(); 32 head.lock(); 33 try { 34 pred = head; 35 curr = pred.next; 36 curr.lock(); 37 try { 38 while (curr.key < key) { 39 pred.unlock(); 40 pred = curr; 41 curr = curr.next; 42 curr.lock(); 43 } 44 if (curr.key == key) { 45 pred.next = curr.next; 46 return true; 47 } 48 return false; 49 } finally { 50 curr.unlock(); 51 } 52 } finally { 53 pred.unlock(); 54 } 55 } Per capire il perché, dobbiamo considerare il seguente scenario: Il thread A è in procinto di rimuovere il nodo a, il primo nodo della lista, mentre il thread B sta per rimuovere il nodo b, ed inoltre a punta a b. Supponiamo che A ha il lock sulla testa e B su a. A setta allora head.next a b, mentre B setta a.next a c. In questo modo si elimina a ma non b. Il problema è che non vi è alcuna sovrapposizione tra i blocchi mantenuti dai due remove(). La figura successiva illustra come si può evitare il problema. 62

68 PCP CAPITOLO 7 LINKED LIST Per garantire il progresso, è importante che tutti i metodi acquisiscano il lock nello stesso ordine, a partire dalla testa seguendo i next verso la coda. La figura 9.10 mostra, una situazione di stallo che potrebbe verificarsi se le chiamate di acquisizione di lock avvengono in ordine diverso (ad esempio se add() e remove() acquisiscono i lock in ordine inverso). In questo esempio, il thread A, cercando di aggiungere a, ha bloccato b e sta tentando di bloccare la testa, mentre B, cercando di rimuovere il nodo b, ha bloccato la testa e sta cercando di bloccare b. Chiaramente, questi metodi portano ad un deadlock, senza che mai nessuno riesca a terminare. L'algoritmo FineList mantiene l'invariante di rappresentazione: le sentinelle non sono mai aggiunte o rimosse ed i nodi vengono ordinati in base al valore key senza duplicati. Il punto di linearizzazione per una chiamata add(a) dipende dal fatto che la chiamata ha avuto successo. Una chiamata che ha successo (a è assente) è linearizzata quando il nodo con la chiave immediatamente superiore è bloccato (linea 7 o 13). L'algoritmo FineList è starvation-free, ma questa dimostrazione risulta essere più difficile rispetto al caso precedente. Partiamo dal presupposto che tutti i lock individuali sono stravation-free. Poiché tutti i metodi acquisiscono i lock nello stesso ordine (dal basso della lista), la situazione di stallo è impossibile. Se un thread A aspetta di acquisire il lock della testa, alla fine ci riesce. Da questo punto, poichè non ci sono situazioni di stallo, alla fine tutti i lock ottenuti dai thread prima di A verranno rilasciati, e così A riuscirà ad avere il lock di Pred A e Curr A. I lock a grana fine presentano ancora dei problemi: quando i thread tentano di accedere alle parti disgiunte della lista, può capitare ancora di creare deadlock. Optimistic Synchronization Un modo per ridurre i costi di sincronizzazione è di cercare un nodo senza acquisire lock, lockare i nodi trovati, e quindi confermare che i nodi bloccati siano corretti. Se vi è un conflitto di sincronizzazione a causa di lock di nodi sbagliati, si rilascia il lock e si ricomincia la ricerca. Normalmente, questo tipo di conflitto è raro ed è per questo che questa tecnica viene chiamata sincronizzazione ottimistica. 63

69 PCP CAPITOLO 7 LINKED LIST 1 public boolean add(t item) { 2 int key = item.hashcode(); 3 while (true){ 4 Node pred = head; 5 Node curr = pred.next; 6 while (curr.key <= key) { 7 pred = curr; curr = curr.next; 8 } 9 pred.lock(); curr.lock(); 10 try { 11 if (validate(pred, curr)) { 12 if (curr.key == key) { 13 return false; 14 } else { 15 Node node = new Node(item); 16 node.next = curr; 17 pred.next = node; 18 return true; 19 } 20 } 21 } finally { 22 pred.unlock(); curr.unlock(); 23 } 24 } 25 } The OptimisticList class: the add() method traverses the list ignoring locks, acquires locks, and validates before adding the new node. Il thread A fa una add() ottimistica. Attraversa la lista senza acquisire nessun look (linee da 6 a 8). In realtà, ignora i lock completamente. Quando termina l'attraversamento (chiave Curr A è maggiore o uguale a quella di a), effettua il lock su Pred A e Curr A, e invoca validate() per verificare che la pred A è raggiungibile e il suo campo next faccia ancora riferimento a Curr A. Se la validazione ha successo, allora A procede come prima: se la chiave Curr A è superiore ad a, aggiunge un nuovo nodo con voce a tra PredA e CurrA, e restituisce true. Altrimenti restituisce false. 26 public boolean remove(t item) { 27 int key = item.hashcode(); 28 while (true){ 29 Node pred = head; 30 Node curr = pred.next; 31 while (curr.key < key) { 32 pred = curr; curr = curr.next; 33 } 34 pred.lock(); curr.lock(); 35 try { 36 if (validate(pred, curr)) { 37 if (curr.key == key) { 38 pred.next = curr.next; 39 return true; 40 } else { 41 return false; 42 } 43 } 44 } finally { 45 pred.unlock(); curr.unlock(); 46 } 47 } 48 } The OptimisticList class: the remove() method traverses ignoring locks, acquires locks, and validates 64 before removing the node. 49 public boolean contains(t item) { 50 int key = item.hashcode(); 51 while (true){ 52 Entry pred = this.head; // sentinel node;

70 PCP CAPITOLO 7 LINKED LIST The OptimisticList class: the remove() method traverses ignoring locks, acquires locks, and validates before removing the node. 49 public boolean contains(t item) { 50 int key = item.hashcode(); 51 while (true){ 52 Entry pred = this.head; // sentinel node; 53 Entry curr = pred.next; 54 while (curr.key < key) { 55 pred = curr; curr = curr.next; 56 } 57 try { 58 pred.lock(); curr.lock(); 59 if (validate(pred, curr)) { 60 return (curr.key == key); 61 } 62 } finally { // always unlock 63 pred.unlock(); curr.unlock(); 64 } 65 } 66 } 67 private boolean validate(node pred, Node curr) { 68 Node node = head; 69 while (node.key <= pred.key) { 70 if (node == pred) 71 return pred.next == curr; 72 node = node.next; 73 } 74 return false; 75 } Percorrere qualsiasi struttura dati dinamica con lock ignorando i lock, richiede un'attenta riflessione. Dobbiamo assicurare di usare qualche forma di validazione e di garantire la libertà di interferenze. Come mostrato nella figura sopra, la convalida è necessaria perché il percorso dei riferimenti da PredA a CurrA potrebbe essere cambiato dall ultima lettura a quando A ha acquisito il lock. In particolare, A potrebbe l'attraversare parti della lista già rimossa. Per esempio, il nodo CurrA e tutti i nodi tra CurrA e a (compresa a) possono essere rimossi mentre A sta ancora attraversando CurrA. A scopre che CurrA punta ad a, e, senza la convalida, rimuove a "con successo, anche se a non è più nella lista. A chiama validate() e rileva che a non è più nella lista, e così il chiamante riavvia il metodo. Poiché stiamo ignorando i lock che proteggono le modifiche concorrenti, ciascuna chiamata può attraversare i nodi che sono stati rimossi dalla lista. Tuttavia, l'assenza di interferenze implica che una volta che un nodo è stato eliminato, il valore del suo campo next non cambia; ci garantisce che nessun nodo viene riciclato. Questo algoritmo non è starvationfree anche se tutti i lock del nodo sono individualmente starvation-free. Un thread può essere ritardato per sempre, se i nuovi nodi sono ripetutamente aggiunti e rimossi. OptimisticList funziona meglio se il costo per attraversare la lista due volte senza lock è significativamente inferiore al costo di attraversarla una volta 65

71 PCP CAPITOLO 7 LINKED LIST con il lock. Uno svantaggio di questo algoritmo particolare è che il metodo contains() acquisisce i lock, che non è interessante in quanto le chiamate a contains() possono essere molto più comuni rispetto alle invocazioni di altri metodi. Lazy synchronization Il passo successivo è quello di perfezionare l'algoritmo in modo che contains() sia wait-free, e che i metodi add() e remove(), pur continuando a fare lock, attraversano la lista solo una volta. Aggiungiamo a ciascun nodo un campo booleano che indica se tale nodo è nell insieme. 1 private class Node { 2 T item; 3 int key; 4 Node next; 5 boolean marked; 6 } Ora, gli attraversamenti non necessitano di bloccare il nodo target, e non c'è bisogno di validare che il nodo è raggiungibile tramite un riattraversamento dell intera lista. Invece, l algoritmo mantiene l'invariante che ogni nodo unmarked è raggiungibile. Se un thread che effettua la visita non trova un nodo, o lo trova segnato, allora quell item non è nel set. Come risultato, contains() necessita di una sola visita wait-free. Per aggiungere un elemento nella lista, add() scorrere l'elenco, blocca il predecessore del target, e inserisce il nodo. Il metodo remove () è pigro, necessita di due passi: in primo luogo, contrassegnare il nodo di destinazione, (rimozione logica) ed in secondo luogo redirect il campo next del suo predecessore rimuovendo fisicamente il nodo. Più in dettaglio, tutti i metodi che attraversano la lista, ignorano i lock. I metodi add() e remove() bloccano i nodi PredA e CurrA come prima (figura sotto), ma la convalida non la riattraversa tutta per determinare se un nodo è presente o meno nell insieme set. 1 private boolean validate(node pred, Node curr) { 2 return!pred.marked &&!curr.marked && pred.next == curr; 3 } The LazyList class: validation checks that neither the pred nor the curr nodes has been logically deleted, and that pred points to curr. 1 public boolean add(t item) { 2 int key = item.hashcode(); 3 while (true){ 4 Node pred = head; 5 Node curr = head.next; 6 while (curr.key < key) { 7 pred = curr; curr = curr.next; 8 } 9 pred.lock(); 10 try { 11 curr.lock(); 12 try { 13 if (validate(pred, curr)) { 14 if (curr.key == key) { 15 return false; 16 } else { 17 Node node = new Node(item); 18 node.next = curr; 19 pred.next = node; 66

72 PCP CAPITOLO 7 LINKED LIST 20 return true; 21 } 22 } 23 } finally { 24 curr.unlock(); 25 } 26 } finally { 27 pred.unlock(); 28 } 29 } 30 } 1 public boolean remove(t item) { 2 int key = item.hashcode(); 3 while (true) { 4 Node pred = head; 5 Node curr = head.next; 6 while (curr.key < key) { 7 pred = curr; curr = curr.next; 8 } 9 pred.lock(); 10 try { 11 curr.lock(); 12 try { 13 if (validate(pred, curr)) { 14 if (curr.key!= key) { 15 return false; 16 } else { 17 curr.marked = true; 18 pred.next = curr.next; 19 return true; 20 } 21 } 22 } finally { 23 curr.unlock(); 24 } 25 } finally { 26 pred.unlock(); 27 } 28 } 29 } 1 public boolean contains(t item) { 2 int key = item.hashcode(); 3 Node curr = head; 4 while (curr.key < key) 5 curr = curr.next; 6 return curr.key == key &&!curr.marked; 7 } Invece, poichè un nodo deve essere marked prima di essere rimosso fisicamente, la convalida deve solo controllare che CurrA non è stato marked. Si noti che il percorso lungo il quale il nodo è raggiungibile può contenere nodi marcati. Come nell algoritmo OptimisticList, i metodi add() e remove() non sono starvation-free, in quanto la lista attraversata potrebbe non mostrare tutte le modifiche in corso. Il metodo contains() attraversa la lista una 67

73 PCP CAPITOLO 7 LINKED LIST volta (figura sotto) ignorando i lock e restituisce true se il nodo cercato è presente e non marcato, false altrimenti. Il valore di un nodo marcato è ignorato. Ogni volta ci si sposta verso un nuovo nodo, che ha una chiave più grande del precedente anche se il nodo è logicamente cancellato. La rimozione logica richiede una piccola modifica alla mappa astratta: un oggetto è nel set di se, e solo se ci si riferisce ad un nodo non marcato raggiungibile. Le modifiche fisiche e gli attraversamenti avvengono esattamente come nella classe OptimisticList, e il lettore deve verificare che ogni nodo marcato raggiungibile rimanga raggiungibile anche se il suo predecessore è logicamente o fisicamente eliminato. I punti di linearizzazione per i metodi add () e remove() (senza successo) di LazyList sono gli stessi della OptimisticList. Una remove() (con successo) è linearizzata quando il mark del nodo quando è impostato (Linea 17). Per capire come linearizzare una contain() che non ha successo, si consideri lo scenario illustrato nella fig

74 PCP CAPITOLO 7 LINKED LIST Nella parte (a), il nodo a è contrassegnato come eliminato (il campo booleano è true) e il thread A tenta di trovare il nodo corrispondente alla chiave di a. Mentre A sta attraversando l'elenco, CurrA e tutti i nodi tra CurrA e a compreso vengono rimossi, sia logicamente che fisicamente. A vorrebbe ancora procedere verso il punto in cui CurrA punta ad a, e vorrebbe rilevare che a è marked e non più nel set astratto. La chiamata può essere linearizzata in questo punto. Consideriamo ora lo scenario descritto nella parte (b). Mentre A sta attraversando la sezione rimossa della lista fino ad arrivare a prima di a, un altro thread aggiunge un nuovo nodo con una chiave a per la parte raggiungibile della lista. Linearizzando il metodo contains() che non ha successo del thread A nel punto, trova il nodo segnato a il che sarebbe sbagliato, perché questo punto si verifica dopo l inserimento del nuovo nodo nella lista con chiave a. Un vantaggio di questo algoritmo è che siamo in grado di separare operazioni logiche come la creazione di un flag, da cambiamenti fisici alla struttura, come lo scollegamento di un nodo. Lo svantaggio principale dell'algoritmo LazyList è che le add() e remove() sono bloccanti: se un thread è in ritardo, tutti gli altri verranno ritardati. Sincronizzazione non bloccante Estendiamo l'idea di eliminare i lock del tutto, consentendo a tutti e tre i metodi: add(), remove(), e contains(), di essere nonblocking (ricordiamo che i primi due sono metodi lock-free e il terzo è wait-free). Un approccio nativo sarebbe quello di utilizzare compareandset() per cambiare i campi next. Purtroppo, questa idea non funziona. La parte inferiore della figura 9.22 mostra un thread A che tenta di aggiungere il nodo a tra i nodi PredA e curra. Tenendo presente la classe LazyList, ci si chiede perché bisogna che i campi segnati devono essere modificati atomicamente? Immaginiamo i seguenti scenari. Nella figura (a), il thread A sta per eliminare il nodo a, il primo nodo della lista mentre B sta aggiungendo il nodo b. supponiamo che A applica compareandset() ad head.next, mentre B applica compareandset() ad a.next. Il risultato è che a viene cancellato correttamente, ma b non viene aggiunto alla lista. Nella figura (b), invece, il thread A sta per rimuovere a, il primo nodo della lista mentre B sta per rimuovere b, dove a punt a b. Supponiamo che A applichi compareandset() a head.next mentre B ad a.next. Il risultato è che a verrà rimosso, b no. Abbiamo bisogno di un modo per garantire che i campi di un nodo non possono essere aggiornati dopo che il nodo è stato logicamente o fisicamente rimosso dalla lista. Il nostro approccio è quello di trattare i campi next e marked dei nodi come una singola unità atomica: ogni tentativo di aggiornare il campo next quando il campo marked è vero avrà esito negativo. 69

75 PCP CAPITOLO 7 LINKED LIST Pragma AtomicMarkableReference<T> è un oggetto del package java.util.concurrent.atomic che comprende sia un riferimento a un oggetto di tipo T, sia ad un Boolean mark. Questi campi possono essere aggiornati atomicamente, insieme o singolarmente. Ad esempio, il metodo compareandset() testa il riferimento atteso e il mark, e se entrambi i test hanno successo, li sostituisce con il riferimento ed il mark aggiornato. Il metodo attemptmark() fa un test di un riferimento atteso e se il test ha successo, lo sostituisce con un nuovo valore mark. Il metodo get() ha una insolita Interfaccia: restituisce il riferimento dell'oggetto T e memorizza il valore marked in un array booleano. 1 public boolean compareandset(t expectedreference, 2 T newreference, 3 boolean expectedmark, 4 boolean newmark); 5 public boolean attemptmark(t expectedreference, boolean newmark); 7 public T get(boolean[] marked); Modifichiamo ogni campo next di un nodo in AtomicMarkableReference<T>. Il thread A rimuove logicamente Curr A impostando il bit mark nel campo next del nodo e condivide l'eliminazione fisica con altri thread che effettuano add() o remove(): ogni thread scorre la lista, la ripulisce da rimozioni fisiche (con compareandset()) dei nodi marcati. In altre parole, i thread fanno si che add() e remove() non attraversino nodi marcati, li tolgono prima di continuare. Ci poniamo ora la seguente domanda: perché i thread che aggiungono o rimuovono non attraversano mai i nodi marcati e li rimuovono fisicamente tutti quando li incontrano? Supponiamo che il thread A attraversi i nodi marked senza rimuovere fisicamente, e dopo aver rimosso logicamente CurrA, aspetta che venga rimosso fisicamente in modo corretto. Può farlo chiamando compareandset() per provare a fare il redirect del campo next di preda nello stesso istante in cui verifica che preda non è marcata e che si riferisce a CurrA. La difficoltà è che poichè A non imposta i lock su PredA e CurrA, altri thread potrebbero inserire nuovi nodi o rimuovere preda prima della compareandset(). Si consideri uno scenario in cui un altro thread marchi preda. Come mostrato in figura 9.22, non possiamo tranquillamente reindirizzare il campo next di un nodo segnato; in questo modo A avrebbe dovuto riavviare l'eliminazione fisica riattraversando la lista. Questo esempio illustra perché le add() e le remove() non attraversano nodi segnati: quando si arriva al nodo da modificare possono essere costretti a riattraversare la lista per rimuovere i nodi precedenti contrassegnati. Invece, abbiamo scelto di avere sia le add() che le remove() tali che rimuovono fisicamente tutti i nodi marcati sul percorso verso il loro nodo obiettivo. Il metodo contains(), invece, non esegue alcuna modifica, e quindi non è necessario partecipare alla pulizia dei nodi logicamente rimossi. Come mostrato nella figura sotto, un oggetto Window è una struttura con i campi pred e curr. Il metodo find() prende il nodo head e una chiave a, attraversa l'elenco cercando di settare pred al nodo con la più grande chiave minore di a, e curr al nodo con il valore chiave almeno maggiore o uguale ad a. Man mano che A scorre la lista, fa avanzare CurrA e controlla se tale nodo è marcato (Linea 16). Se è così, chiama compareandset() per cercare di rimuovere fisicamente in nodo settando il campo next di pred al campo next di curr. Un thread concorrente può cambiare il valore mark tramite una rimozione logica di pred o di curr. Se la chiamata non riesce si ripete l attraversamento della lista dall inizio, altrimenti si continua. 70

76 PCP CAPITOLO 7 LINKED LIST 1 class Window { 2 public Node pred, curr; 3 Window(Node mypred, Node mycurr) { 4 pred = mypred; curr = mycurr; 5 } 6 } 7 public Window find(node head, int key) { 8 Node pred = null, curr = null, succ = null; 9 boolean[] marked = {false}; 10 boolean snip; 11 retry: while (true){ 12 pred = head; 13 curr = pred.next.getreference(); 14 while (true){ 15 succ = curr.next.get(marked); 16 while (marked[0]) { 17 snip = pred.next.compareandset(curr, succ, false, false); 18 if (!snip) continue retry; 19 curr = succ; 20 succ = curr.next.get(marked); 21 } 22 if (curr.key >= key) 23 return new Window(pred, curr); 24 pred = curr; 25 curr = succ; 26 } 27 } 28 } 1 public boolean add(t item) { 2 int key = item.hashcode(); 3 while (true){ 4 Window window = find(head, key); 5 Node pred = window.pred, curr = window.curr; 6 if (curr.key == key) { 7 return false; 8 } else { 9 Node node = new Node(item); 10 node.next = new AtomicMarkableReference(curr, false); 11 if (pred.next.compareandset(curr, node, false, false)) { 12 return true; 13 } 14 } 15 } 16 } 17 public boolean remove(t item) { 18 int key = item.hashcode(); 19 boolean snip; 20 while (true){ 21 Window window = find(head, key); 22 Node pred = window.pred, curr = window.curr; 23 if (curr.key!= key) { 24 return false; 25 } else { 26 Node succ = curr.next.getreference(); 27 snip = curr.next.attemptmark(succ, true); 28 if (!snip) 29 continue; 30 pred.next.compareandset(curr, succ, false, false); 31 return true; 32 } 71

77 PCP CAPITOLO 7 LINKED LIST 33 } 34 } 35 public boolean contains(t item) { 36 boolean[] marked = false{}; 37 int key = item.hashcode(); 38 Node curr = head; 39 while (curr.key < key) { 40 curr = curr.next; 41 Node succ = curr.next.get(marked); 42 } 43 return (curr.key == key &&!marked[0]) 44 } Supponiamo che il thread A voglia aggiungere il nodo a. A usa find() per individuare PredA e CurrA. Se la chiave di CurrA è uguale a quella di a, la chiamata restituisce false. In caso contrario, add() inizializza un nuovo nodo a come quello vecchio di a, e imposta il puntatore di a a CurrA. Quindi chiama compareandset() per impostare PredA ad a. Poichè compareandset() testa sia il mark che il riferimeto, ha successo solo se preda non è marcato e punta a curra. Se il metodo compareandset() ha successo, add() restituisce true, altrimenti ricomincia. Quando A chiama remove() per rimuovere un nodo, ad esempio il nodo a, usa find() per individuare PredA e CurrA. Se la chiave di CurrA non corrisponde con quella di a, la chiamata restituisce false. In caso contrario, la remove chiama attemptmark() per contrassegnare CurrA come logicamente rimosso (Linea 27). Il nodo verrà rimosso fisicamente dal thread successivo che attraversa quella regione della lista. Il metodo find dell algoritmo LockFreeList è praticamente lo stesso di LazyList. C'è un piccolo cambiamento: per verificare se curra è marcato dobbiamo applicare curr.next.get (marked) e verificare che marked[0] sia vero. Da un lato, l algoritmo LockFreeList garantisce il progresso difronte a ritardi arbitrari. Tuttavia, c'è un prezzo per questa garanzia forte di progresso: La necessità di supportare modifiche atomiche dei riferimenti e i mark booleani ha un costo aggiuntivo di prestazioni. Le add () e remove () attraversano la lista e devono impegnarsi in concorrenza per pulirla dai nodi rimossi, introducendo la possibilità di contesa tra di essi e a volte forzando la procedura di riavviare dall inizio l attraversamento, anche se non ci fossero cambiamenti nei nodi. D'altra parte, il lock lazy non garantisce il progresso di fronte a ritardi arbitrari: l add() e remove() sono i metodi bloccanti, non necessitano di attraversamenti per ripulire logicamente i nodi rimossi. L approccio da usare dipende dall applicazione, tenendo conto delle problematiche dovuto ai metodi add() e remove(), al sovraccarico etc. 72

78 PCP CAPITOLO 8 SCHEDULING CAPITOLO 8 SCHEDULING Pool di thread Alcune applicazioni possono essere naturalmente divise in thread paralleli, ad esempio web server o applicazioni strutturate come produttori e consumatori. In seguito analizzaremo applicazioni che hanno un parallelismo innato ma di cui non è chiaro come ottenere un vantaggio dall esecuzione parallela. Iniziamo dal problema di dover moltiplicare due matrici in parallelo. Ricordiamo che se a ij è il valore in posizione (i, j) della matrice A, il prodotto C di due matrici n x n A e B è dato da c i,j = Come primo passo possiamo assegnare ad un thread il calcolo di ogni c i,j. n 1 k=0 a ki b jk. 1 class MMThread { 2 double[][] a, b, c; 3 int n; 4 public MMThread(double[][] mya, double[][] myb) { 5 n = yma.length; 6 a = mya; 7 b = myb; 8 c = new double[n][n]; 9 } 10 void multiply() { 11 Worker[][] worker = new Worker[n][n]; 12 for (int row = 0; row < n; row++) 13 for (int col = 0; col < n; col++) 14 worker[row][col] = new Worker(row,col); 15 for (int row = 0; row < n; row++) 16 for (int col = 0; col < n; col++) 17 worker[row][col].start(); 18 for (int row = 0; row < n; row++) 19 for (int col = 0; col < n; col++) 20 worker[row][col].join(); //aspetta che ogni thread termini la computazione 21 } 22 class Worker extends Thread { 23 int row, col; 24 Worker(int myrow, int mycol) { 25 row = myrow; col = mycol; 26 } 27 public void run() { 28 double dotproduct = 0.0; 29 for (int i = 0; i < n; i++) 30 dotproduct += a[row][i] * b[i][col]; 31 c[row][col] = dotproduct; 32 } 33 } 34 } La figura sopra mostra un programa per la moltiplicazione di matrici che crea un array n x n di thread Worker. Il thread in posizione i, j computa c i,j. Il programma inizia tutti i task ed aspetta il loro completamento. 73

79 PCP CAPITOLO 8 SCHEDULING In linea di principio questo sembra essere l approccio ideale. Il programma è altamente parallelo ed i thread non hanno bisogno di sincronizzazione. In pratica, questo approccio funziona solo con matrici piccole mentre le sue prestazioni sono molto scarse con matrici abbastanza grandi da essere interessanti. Il motivo è che i thread richiedono memoria per gli stack ed altre informazioni. Creare, schedulare e distruggere thread richiede un notevole sforzo computazionale. Creare molti thread a vita breve è quindi un modo inefficiente di organizzare una computazione multi-thread. Un modo più efficiente di organizzare questo programma è di creare un pool di thread con una vita più lunga. Ogni thread nel pool aspetta ripetutamente fin quando non gli viene assegnato un task, una piccola unità di computazione. Quando un task viene assegnato ad un thread, questo lo esegue e dopo ritorna a far parte del pool in attesa di un altro task. I pool di thread possono essere dipendenti dalla piattaforma, ha senso fornire pool grandi per multiprocessori di larga scala e viceversa. Il meccanismo dei pool evita il costo di creare e distruggere thread in risposta alle fluttuazioni della richiesta computazionale. In aggiunta ai benefici sulle prestazioni, i pool di thread hanno un altro vantaggio: isolano il programmatore dai dettagli specifici della piattaforma come il numero di thread concorrenti che possono essere schedulati efficientemente. Con i pool di thread è possbile scrivere un singolo programma in gradi di girare bene su un sistema monoprocessore e su un multiprocessore su larga scala. Forniscono una semplice interfaccia che nasconde i complessi trade-off dovuti alla piattaforma. In java un pool di thread è chiamato executor service (java.util.executor-service). Fornisce la possibilità di sottomettere task, aspettare fin quando un insieme di task sottomessi venga completato e cancellare task incompleti. Un task che non restituisce un risultato è di solito rappresentato come un oggetto Runnable, nel quale il lavoro è eseguito dal metodo run(). Un task che restituisce un valore di tipo T è rappresentato, invece, come un oggetto Callable<T>, in cui il risultato è restituito dal metodo call(). Quando un oggeto Callable<T> è sottomesso ad un exectuor service, quest ultimo restituisce un oggeto che implementa l interfaccia Future<T>. Questa non è altro che una promessa di consegnare il risultato di una computazione asincrona, quando è pronto. Fornisce un metodo get() che restituisce il risultato, bloccando se necessario fino a quando il risultato non è pronto. È importante comprendere che la creazione di un oggetto future non garantisce che ogni computazione realmente avvenga in parallelo. Consideriamo ora come implementare operazioni parallele su matrici usando un executor service. La figura 16.3 mostra una classe Matrix che fornisce i metodi put() e get() per accedere agli elementi della matrice, insieme al metodo split() che divide la matrice n n in 4 sottomatrici (n/2) (n/2). Nella terminologia java, le quattro sottomatrici sono backed dalla matrice originale, ovvero le operazioni che intervengono sulle sottomatrici vengono riflesse sulla matrice orginale e viceversa. Bisogna quindi creare una classe MatrixTask con metodi paralleli per sommare e moltiplicare matrici. Questa classe ha un campo statico, un executor service chiamato exec, e due metodi statici per le operazioni di somma e moltiplicazione. Per semplicità consideriamo matrici con dimensione n potenza di 2. Ogni matrice può quindi essere decomposta in quattro sottomatrici: A = A 00 A 10 A 01 A 11. L addizione C = A + B può essere decomposta come segue: C 00 C 01 C 10 C 11 = A 00 A 01 A 10 A 11 + B 00 B 01 B 10 B 11 = A 00 + B 00 A 01 + B 01 A 10 + B 10 A 11 + B

80 PCP CAPITOLO 8 SCHEDULING 1 public class Matrix { 2 int dim; 3 double[][] data; 4 int rowdisplace, coldisplace; 5 public Matrix(int d) { 6 dim = d; 7 rowdisplace = coldisplace = 0; 8 data = new double[d][d]; 9 } 10 private Matrix(double[][] matrix, int x, int y, int d) { 11 data = matrix; 12 rowdisplace = x; 13 coldisplace = y; 14 dim = d; 15 } 16 public double get(int row, int col) { 17 return data[row+rowdisplace][col+coldisplace]; 18 } 19 public void set(int row, int col, double value) { 20 data[row+rowdisplace][col+coldisplace] = value; 21 } 22 public int getdim() { 23 return dim; 24 } 25 Matrix[][] split() { 26 Matrix[][] result = new Matrix[2][2]; 27 int newdim = dim / 2; 28 result[0][0] = 29 new Matrix(data, rowdisplace, coldisplace, newdim); 30 result[0][1] = 31 new Matrix(data, rowdisplace, coldisplace + newdim, newdim); 32 result[1][0] = 33 new Matrix(data, rowdisplace + newdim, coldisplace, newdim); 34 result[1][1] = 35 new Matrix(data, rowdisplace + newdim, coldisplace + newdim, newdim); 36 return result; 37 } 38 } Figure 16.3 The Matrix class. Il codice in figura 16.4 mostra l addizione multithread di matrici. La classe AddTask ha tre campi inizializzati dal costruttore: a e b sono le matrici da sommare, c il risultato. Ogni task, all inizio della ricorsione, semplicemente aggiunge i due valori scalari (riga 19) o divide ognuno dei suoi argomenti in quattro sottomatrici (riga 22) e lancia un nuovo task per ogni sottomatrice. Aspetta quindi finchè tutti i future 75

81 PCP CAPITOLO 8 SCHEDULING vengono valutati, ovvero le sottocomputazioni terminano (righe 28-30). A questo punto il task semplicemente restituisce il risultato della computazione, avendola memorizzata nella matrice risultato. 1 public class MatrixTask { 2 static ExecutorService exec = Executors.newCachedThreadPool(); static Matrix add(matrix a, Matrix b) throws ExecutionException { 5 int n = a.getdim(); 6 Matrix c = new Matrix(n); 7 Future<?> future = exec.submit(new AddTask(a, b, c)); 8 future.get(); 9 return c; 10 } 11 static class AddTask implements Runnable { 12 Matrix a, b, c; 13 public AddTask(Matrix mya, Matrix myb, Matrix myc) { 14 a = mya; b = myb; c = myc; 15 } 16 public void run() { 17 try { 18 int n = a.getdim(); 19 if (n == 1) { 20 c.set(0, 0, a.get(0,0) + b.get(0,0)); 21 } else { 22 Matrix[][] aa = a.split(), bb = b.split(), cc = c.split(); 23 Future<?>[][] future = (Future<?>[][]) new Future[2][2]; 24 for (int i = 0; i < 2; i++) 25 for (int j = 0; j < 2; j++) 26 future[i][j] = 27 exec.submit(new AddTask(aa[i][j], bb[i][j], cc[i][j])); 28 for (int i = 0; i < 2; i++) 29 for (int j = 0; j < 2; j++) 30 future[i][j].get(); 31 } 32 } catch (Exception ex) { 33 ex.printstacktrace(); 34 } 35 } 36 } 37 } Figure 16.4 The MatrixTask class: parallel matrix addition. La moltiplicazione C = A B può essere decomposta come segue: C 00 C 01 C 10 C 11 = A 00 A 01 A 10 A 11 B 00 B 01 B 10 B 11 = A 00 B 00 + A 01 B 10 A 00 B 01 + A 01 B 11 A 10 B 00 + A 11 B 10 A 10 B 01 + A 11 B 11 76

82 PCP CAPITOLO 8 SCHEDULING La figura 16.5 mostra il codice per la moltiplicazione parallela di matrici. La classe MultiTask crea due array di zeri per memorizzare il prodotto dei termini (riga 42). Divide le cinque matrici (riga 50), sottomettendo il task per calcolare gli otto termini della moltiplicazione in parallelo (riga 56) ed aspetta il loro completamento. Il thread sottomette quindi i task per calcolare le quattro somme in parallelo (riga 64) e aspetta il loro completamento (riga 65). L esempio usa i future sono per segnalare il completamento di un task, ma questi possono essere usati anche per passare valori da i task completati. 38 static class MulTask implements Runnable { 39 Matrix a, b, c, lhs, rhs; 40 public MulTask(Matrix mya, Matrix myb, Matrix myc) { 41 a = mya; b = myb; c = myc; 42 lhs = new Matrix(a.getDim()); 43 rhs = new Matrix(a.getDim()); 44 } 45 public void run() { 46 try { 47 if (a.getdim() == 1) { 48 c.set(0, 0, a.get(0,0) * b.get(0,0)); 49 } else { 50 Matrix[][] aa = a.split(), bb = b.split(), cc = c.split(); 51 Matrix[][] ll = lhs.split(), rr = rhs.split(); 52 Future<?>[][][] future = (Future<?>[][][]) new Future[2][2][2]; 53 for (int i = 0; i < 2; i++) 54 for (int j = 0; j < 2; j++) { 55 future[i][j][0] = 56 exec.submit(new MulTask(aa[i][0], bb[0][i], ll[i][j])); 57 future[i][j][1] = 58 exec.submit(new MulTask(aa[1][i], bb[i][1], rr[i][j])); 59 } 60 for (int i = 0; i < 2; i++) 61 for (int j = 0; j < 2; j++) 62 for (int k = 0; k < 2; k++) 63 future[i][j][k].get(); 64 Future<?> done = exec.submit(new AddTask(lhs, rhs, c)); 65 done.get(); 66 } 67 } catch (Exception ex) { 68 ex.printstacktrace(); 69 } 70 } 71 } } Figure 16.5 The MatrixTask class: parallel matrix multiplication. Per illustrare l utilizzo dei future, consideriamo come decomporre la ben nota funzione di Fibonacci in un programma multithread: 77

83 PCP CAPITOLO 8 SCHEDULING F n = 1 if n = 0, 1 if n = 1, F n 1 + F n 2 if n > 1. 1 class FibTask implements Callable<Integer> { 2 static ExecutorService exec = Executors.newCachedThreadPool(); 3 int arg; 4 public FibTask(int n) { 5 arg = n; 6 } 7 public Integer call() { 8 if (arg > 2) { 9 Future<Integer> left = exec.submit(new FibTask(arg-1)); 10 Future<Integer> right = exec.submit(new FibTask(arg-2)); 11 return left.get() + right.get(); 12 } else { 13 return 1; 14 } 15 } 16 } Figure 16.6 The FibTask class: a Fibonacci task with futures. La figura 16.6 mostra un modo di calcolare i numeri di Fibonacci in paralleo. Questa implementazione è veramente inefficiente ma è utile per illustrare le dipendenze multithread. Il metodo call() crea due future, una che calcolca F(n 2) ed un altra che calcola F(n 1). Su un sistema multiprocessore, il tempo speso per il blocking sul future per F(n 1) può essere usato per calcolare F(n 2). Analisi del parallelismo Pensiamo alla computazione multithread come un grafo direzionato aciclico (DAG), dove ogni nodo rappresenta un task ed ogni arco direzionato collega un task predecessore ad un task successore dove il sucessore dipende dal risultato del predecessore. 78

84 PCP CAPITOLO 8 SCHEDULING Per esempio, un thread convenzionale è una catena di nodi in cui ogni nodo dipende dal suo predecessore. Un nodo che crea un future ha due successori, un nodo è il suo successore nello stesso thread ed un altro è il primo nodo nella computazione del future. C è anche un arco in direzione opposta, dal figlio al padre, quando un thread che ha creato un future, chiama il metodo get() del future stesso, aspettando la fine della computazione del figlio. Alcune computazioni sono inerentemente più parallele di altre. Assumiamo che tutti gli step della computazione prendano lo stesso tempo, la nostra unità di misura di base. Sia T P il tempo minimo (misurato in passi) necessario per eseguire un programma multithread su un sistema con P processori dedicati. T P è quindi la latenza del programma, il tempo necessario per eseguirlo dall inizio alla fine, misurato da un osservatore esterno. T P è una misura ideale in quanto non è sempre possibile per ogni processore trovare step da eseguire e una computazione reale può essere limitata da altri fattori, come l uso della memoria. Ciò nonostante T P è chiaramente un limite inferiore a quanto parallelismo si può estrarre da una computazione multithread. T 1, il numero di step necessario per eseguire il programma su un singolo processore, è chiamato work e rappresenta anche il numero totale di step dell intera computazione. In uno step, P processori possono eseguire al massimo P step, quindi T P T 1 P. T è il numero di step per l esecuzione del programma su un numero illimitato di processori, chiamato criticalpath: T P T. Lo speedup su P processori è il rapporto T 1 TP. Diciamo che una computazione ha uno speedup lineare se T 1 TP = θ P. Infine, il parallelismo di una computazione è il massimo speedup possibile T 1 T o anche l ammontare medio di lavoro disponibile ad ogni step lungo il critical path; fornisce quindi una buona stima del numero di processori necessari ad una computazione. Rivediamo ora le implementazioni della somma e moltiplicazione di matrici spiegate precedentemente. Sia A P (n) il numero di step necessario per sommare due matrici n n su P processori. La somma di matrici richiede quattro addizioni di matrici (n/2) (n/2) più un tempo costante per dividere le matrici. A 1 n = 4A 1 n 2 + θ(1) = θ n 2 Siccome le addizioni possono essere fatte in parallelo, il critical path length è dato da: A n = A n 2 + θ(1) = θ log n Sia M P n il numero di step necessari per moltiplicare due matrici n n su P processori. La moltiplicazione tra matrici richiede otto moltiplicazioni (n/2) (n/2) e quattro addizioni: M 1 n = 8M 1 n 2 + 4A 1 n = 8M 1 n 2 + θ n 2 = θ n 3 Le otto moltiplicazioni possono essere effettuate in parallelo, come le addizioni ma queste devono aspettare che le moltiplicazioni siano complete, quindi: M n = M n 2 + A n = M n 2 + θ log n = θ log 2 n Il parallelismo per la moltiplicazione di matrici è dato da: M 1 (n) M (n) = θ n3 log 2 n 79

85 PCP CAPITOLO 8 SCHEDULING che è considerevolmente alto. Per esempio, supponiamo di voler moltiplicare due matrici n 3 = 10 9, log n = log quindi il parallelismo è approssimativamente = In parole povere, questa istanza di moltiplicazione di matrici potrebbe, in linea di principio, tenere circa un milione di processori impegnati, ben oltre la potenza di qualsiasi multiprocessore presente e dell'immediato futuro. È importante notare che il parallelismo definito per questa computazione è un limite superiore ideale delle prestazioni di qualsiasi programma di moltiplicazione di matrici multithread. Per esempio, quando ci sono thread inattivi, può non essere facile assegnare tali thread a processori idle. Inoltre, un programma che mostra meno parallelismo, ma consuma meno memoria potrebbe funzionare meglio perché incappa in meno page fault. Scheduling realistico multiprocessore La nostra analisi si è basata sull assunzione che ogni programma multithread avesse P processori dedicati; naturalmente questo scenario non è realistico. I moderni sistemi operativi forniscono thread user-level che comprendono un program counter ed uno stack. Il kernel del sistema operativo ha uno scheduler che si occupa di eseguire i thread su processori fisici. L applicazione, comunque, di solito non ha controllo sul mapping tra thread e processori e non può quindi controllare quando un thread sarà schedulato. Un modo per colmare il divario tra i thread user-level ed i processori è quello di fornire allo sviluppatore un modello a tre livelli. Al livello superiore, i programmi multithread (come la moltiplicazione di matrici) si decompongono in un numero dinamicamente variabile di task di breve durata. A livello intermedio, uno scheduler user-level mappa tali task con un numero fisso di thread. Al livello più basso, il kernel mappa questi thread su processori hardware, la cui disponibilità può variare dinamicamente. Questo ultimo livello di mappatura non è sotto il controllo dell'applicazione: le applicazioni non possono dire al kernel come pianificare i thread. Assumiamo per semplicità che il kernel lavori in step discreti. Allo step i, il kernel sceglie un sottoinsieme arbitrario di 0 p i P (con P=numero di processori) thread user-level da eseguire per uno step. La media del processore su T step è: P A = 1 T Invece di progettare una pianificazione user-level per ottenere uno speedup di P, possiamo provare ad ottenere un incremento di P A volte. Uno scheduler è greedy se il numero di step di un programma eseguiti in ogni time-step è il minimo di p i, il numero di processori disponibili e il numero di nodi liberi nel DAG del programma. In altre parole, esegue tutti i nodi disponibili possibili, dato il numero di processori disponibili. Teorema. Si consideri un programma multithread con work T 1, critical-path length T e P thread user-level. Ogni esecuzione greedy avrà lunghezza T che è al massimo Dimostrazione. La media del processore implica che: T 1 i=0 p i T 1 + T P 1 P A P A 80

86 PCP CAPITOLO 8 SCHEDULING T = 1 P A Limitiamo T limitando la somma delle p i. Ad ogni step, immaginiamo di ricevere un token per ogni thread che è stato assegnato ad un processore. Possiamo mettere questi token in uno o più bucket. Per ogni thread user-level che esegue un nodo allo step i, mettiamo un token in un work bucket e per ogni thread che rimane idle in tale step, mettiamo un token in un idle bucket. Dopo l ultimo step il work bucket conterrà T 1 token, uno per ogni nodo del DAG. Quanti token conterrà l idle bucket? Definiamo uno step idle come uno step in cui alcuni thread mettono un token nell idle bucket. Siccome l applicazione è ancora in esecuzione, almeno un nodo sarà eseguito e quindi almeno un processore non è idle. Quindi dei p i thread schedulati allo step i, al massimo p i 1 P 1 possono essere idle. Sia G i un sottografo del DAG della computazione, i cui nodi sono quelli non eseguiti alla fine dello step i. T 1 i=0 p i Ogni nodo che non ha archi entranti (escluso quello dal suo predecessore nell ordine del programma) in G i 1 è pronto all inizio dello step i. Ci devono essere meno di p i nodi del genere, altrimenti lo scheduler greedy ne potrebbe eseguire p i e lo step i non sarebbe stato idle. Quindi lo scheduler deve aver eseguito questo step. Ne segue che il path direzionato più lungo in G i è più corto di quello più lungo in G i 1. Il path direzionato più lungo prima dello step 0 è T, quindi lo scheduler greedy può avere al massimo T step idle. Combinando queste osservazioni, deduciamo che sono eseguiti al massimo T step idle con al massimo (P 1) token aggiunti in ognuno, quindi l idle bucket contiene al massimo T P 1 token. Il numero totale di token in entrambi i bucket è quindi: T 1 i=0 p i T 1 + T P 1 che produce il limite desiderato. 81

87 PCP CAPITOLO 8 SCHEDULING Questo limite è compreso in un fattore due dell ottimo. In realtà ottenere uno scheduler ottimo è un problema NP-completo, quindi gli scheduler greedy sono il modo più semplice e pratico di ottenere performance ragionevolmente vicine all ottimo. Distribuzione del lavoro La chiave per ottenere un buon speedup è di mantenere i thread user-level ben riforniti di task da eseguire, in modo tale che lo scheduler sia il più greedy possibile. Comunque, computazioni multithread creano, distruggono task dinamicamente, a volte in modo non predicibile. È necessario un algoritmo di distribuzione del lavoro per assegnare task pronti a thread idle nel modo più efficiente possibile. Work Stealing Ogni thread mantiene un pool di task che aspettano di essere eseguiti nella forma di una coda a doppia entrata (double-ended queue, DEQueue), che fornisce i metodi pushbottom(), popbottom() e poptop(). Quando un thread crea un nuovo task, chiama pushbottom() per inserire il task nella coda. Quando un thread ha bisongo di un task da eseguire chiama popbottom() per rimuovere un task dalla sua coda. Se un thread si accorge che la propria coda è vuota, diventa un ladro (thief): sceglie casualmente una vittima e chiama il metodo poptop() sulla coda del thread vittima per rubare un task. 1 public class WorkStealingThread { 2 DEQueue[] queue; 3 int me; 4 Random random; 5 public WorkStealingThread(DEQueue[] myqueue) { 6 queue = myqueue; 7 random = new Random(); 8 } 9 public void run() { 10 int me = ThreadID.get(); 11 Runnable task = queue[me].popbottom(); 12 while (true) { 13 while (task!= null) { 14 task.run(); 15 task = queue[me].popbottom(); 16 } 17 while (task == null) { 18 Thread.yield(); 19 int victim = random.nextint(queue.length); 20 if (!queue[victim].isempty()) { 21 task = queue[victim].poptop(); 22 } 23 } 24 } 25 } 26 } Figure 16.9 The WorkStealingThread class: a simplified work stealing executer pool. 82

88 PCP CAPITOLO 8 SCHEDULING La figura sopra mostra una possibile implementazione di thread che usano un work-stealing executor service. I thread condividono un array di DEQueue (riga 2), uno per ogni thread. Ogni thread rimuove ripetutamente un task dalla sua coda e lo esegue (riga 13-16). Se non ha task da eseguire sceglie ripetutamente una vittima a caso, a cui cerca di rubare un task (riga 17-23). Per evitare confusione di codice, ignoriamo la possibilità che il furto possa genereare un eccezione. Questo executor semplificato può infinitamente provare a rubare task, fino a quando tutti i task in tutte le code sono stati completati. Per evitare che un thread sia sempre alla ricerca di task non esistenti, possiamo usare una barriera terminationdetecting. Come notato precedentemente, un sistema multiprocessore fornisce un modello di computazione a tre livelli. In un ambiente multiprogrammato ci sono più thread che processori, il che implica che non tutti i thread possono essere eseguiti nello stesso istante. Per garantire il progresso della computazione, dobbiamo assicurare che i thread che hanno lavoro da svolgere non vengano ritardati dai thread che cercano di rubare task altrui. Per evitare questa situazione, ogni thread ladro chiama Thread.yield() (riga 18). Questa chiamata assegna il processore del thread ladro ad un altro thread, permettendo quindi di deschedulare il thread ladro. Bilanciamento del carico Un approccio alternativo è quello in cui ogni thread periodicamente bilancia il suo carico con un partner scelto casualmente. Per assicurare che thread con alto carico non sprechino risorse cercando di ribilanciarlo, rendiamo i thread meno caricati più propensi ad iniziare il bilanciamento. Ogni thread periodicamente lancia una moneta truccata per decidere se iniziare il bilanciamento con un altro. La probabilità che esca si è inversamente proporzionale al numero di task presenti nella sua coda. Una volta scelto il thread partner, se il carico di quest ultimo supera una certa soglia, alcuni thread vengono trasferiti da una coda all altra, fino a quando le due code non contengono lo stesso numero di task. 83

89 PCP CAPITOLO 9 MEMORIA TRANSAZIONALE CAPITOLO 9 MEMORIA TRANSAZIONALE Barriere Immaginiamo di dover scivere la parte grafica di un videogame. Il programma prepara una sequenza di frame da visualizzare. Questo tipo di programma è chiamato soft real-time, in quanto deve mostrare almeno 35 fps per essere efficace ma fallimenti occasionali non sono catastrofici. Su una macchina singlethread, si potrebbe scrivere un ciclo del genere: while(true) { frame.prepare(); frame.display(); } Se invece sono disponibili n thread paralleli, ha senso dividere i frame in n parti disgiunte ed avere ogni thread che prepara la sua parte parallelamente agli altri: int me = ThreadID.get(); while (true) { frame[me].prepare(); frame[me].display(); } Il problema di questo approccio è che thread differenti richiedono un differente ammontare di tempo per preparare e visualizzare la propria porzione del frame. Alcuni thread possono inizare a visualizzare l i-esimo thread prima che altri thread abbiano finito l (i 1)-esimo. Per evitare questi problemi di sincronizzazione, si può organizzare la computazione come una sequenza di fasi, in cui nessun thread possa iniziare la fase i-esima fino a quando tutti gli altri non abbiano finito la (i 1)-esima. Il meccanismo per forzare questo tipo di sincronizzazione è chiamato barriera. 1 public interface Barrier { 2 public void await(); 3 } 1 private Barrier b; while (true) { 4 frame[my].prepare(); 5 b.await(); 6 frame[my].display(); 7 } Figure 17.2 Using a barrier to synchronize concurrent displays. Una barriera è un modo per forzare thread asincrono a comportarsi come se fossero sincroni. Quando un thread, finendo la fase i, chiama il metodo await(), viene bloccato fino a quando tutti gli n thread hanno finito la fase. L implementazione della barriera solleva numerosi problemi di performance che affliggono anche spin lock ed anche di nuovi. Chiaramente si vuole minimizzare il tempo tra quando l ultimo thread raggiunge la barriera e quando l ultimo thread lascia la barriera. È anche importante che i thread lascino la barriera più o meno allo stesso tempo. Il tempo di notifica di un thread è l'intervallo di tempo tra il momento in cui qualche thread ha rilevato che tutti hanno raggiunto la barriera, e quando tale thread lascia la barriera. Avere tempi di notifica uniformi è importante per molte applicazioni soft real-time. Per esempio, la qualità delle immagini è maggiore se tutte le parti del frame sono aggiornate a più o meno nello stesso momento. 84

90 PCP CAPITOLO 9 MEMORIA TRANSAZIONALE 1 public class SimpleBarrier implements Barrier { 2 AtomicInteger count; 3 int size; 4 public SimpleBarrier(int n){ 5 count = new AtomicInteger(n); 6 size = n; 7 } 8 public void await() { 9 int position = count.getanddecrement(); 10 if (position == 1) { 11 count.set(size); 12 } else { 13 while (count.get()!= 0); 14 } 15 } 16 } Figure 17.3 The SimpleBarrier class. La classe in figura sopra, crea un contatore AtomicInteger inizializzato ad n, la dimensione della barriera. Ogni thread esegue il metodo getanddecrement() per far diminuire il contatore. Se la chiamata restituisce 1 (riga 10), il thread è l ultimo ad aver raggiunto la barriera, quindi resetta il contatore per il prossimo utilizzo (riga 11). Negli altri casi il thread fa spin sul contatore, aspettando che il valore raggiunga lo 0 (riga 13). Tale implementazione funziona solo se l oggetto viene usato una volta sola. Supponiamo che ci siano solo due thread. Il thread A esegue getanddecrement(), scoprendo di non essere l ultimo thread che ha raggiunto la barriera e inizia lo spin sul contatore. Quando il thread B arriva, si accorge di essere l ultimo thread ad essere arrivato, quindi resetta il contatore a n, in questo caso 2. Termina la fase successiva e chiama await(). Nel frattempo il thread A continua il suo spin, ma il contatore non varrà mai 0. Alla fine A sta aspettando che finisca la fase 0, mentre B sta aspettando il completamento della fase 1. Probabilmente il modo più semplice per corregere questo problema è quello di alternare due barriere, una per le fasi pari ed un altra per le fasi dispari. Comunque questo approccio spreca spazio e richiede maggiore gestione da parte dell applicazione. 1 public SenseBarrier(int n) { 2 count = new AtomicInteger(n); 3 size = n; 4 sense = false; 5 threadsense = new ThreadLocal<Boolean>() { 6 protected Boolean initialvalue() { return!sense; }; 7 }; 8 } 9 public void await() { 10 boolean mysense = threadsense.get(); 11 int position = count.getanddecrement(); 12 if (position == 1) { 13 count.set(size); 14 sense = mysense; 15 } else { 16 while (sense!= mysense) {} 17 } 18 threadsense.set(!mysense); 19 } Una barriera sense-reversing è più elegante ed è una soluzione pratica al problema del riuso delle barriere. Un sense di una fase è un valore booleano, vero per fasi pari, falso altrimenti. Ogni oggetto SenseBarrier ha un campo sense che indica il sense della fase correntemente in esecuzione. Ogni thread mantiene il sense 85

91 PCP CAPITOLO 9 MEMORIA TRANSAZIONALE corrente in una oggetto locale. Inizialmente il sense della barriera è il complemento del senso locale di tutti i thread. Quando un thread chiama await(), controlla se è l ultimo thread a decrementare il contatore. In tal caso cambia il sense e continua, altrimenti va in fase di spin aspettando che il sense cambi. Il decremento del contatore condiviso può causare memory contention, visto che tutti i thread provano ad accedere al contatore nello stesso istante. Una volta che il contatore è stato decrementato, ogni thread va in spin sul campo sense. Questa implementazione è adatta per architetture cache-coherent, in quanto lo spin viene effettuato sulla copia locale in cache del campo ed il campo viene modificato solo quando tutti i thread sono pronti a lasciare la barriera. Memoria transazionale Il locking, come disciplina di sincronizzazione, presenta numerosi trabocchetti a causa dell inesperienza dei programmatori. Si ha l inversione di priorità (priority inversion) quando un thread con priorità più bassa ha la precedenza mentre detiene un lock necessario ad un thread con priorità più alta. Si ha convoying quando un thread che detiene un lock viene deschedulato, probabilmente a causa di un page fault o altri tipi di interrupt. Mentre un thread che detiene un lock è inattivo, altri thread che richiedono tale lock saranno messi in coda, impedendo così il progresso della computazione. Anche dopo che il lock è rilasciato, può volerci un po di tempo prima di svuotare la coda. Si ha deadlock se diversi thread provano ad ottenere il lock degli stessi oggetti in ordine diverso. Prevenire il deadlock non è cosa facile. In passato venivano utilizzati team di sviluppatori esperti per analizzare il codice alla ricerca di deadlock. Ora questo approccio è semplicemente troppo costoso visto che le applicazioni su larga scala sono divenute molto comuni. Il cuore del problema è che nessuno sa veramente come organizzare un grosso sistema che si basa sui lock. Un modo per superare i problemi del lock è di basarsi su primitive atomiche come compareandset(). Gli algoritmi che usano queste primitive sono spesso difficili da progettare ed, a volte ma non sempre, hanno un alto overhead. La difficoltà principale è che quasi tutte le primitive di sincronizzazione operano solo su una singola word. Questa restrizione spesso obbliga ad usare strutture complesse ed innaturali negli algoritmi. 1 public class LockFreeQueue<T> { 2 private AtomicReference<Node> head; 3 private AtomicReference<Node> tail; public void enq(t item) { 6 Node node = new Node(item); 7 while (true) { 8 Node last = tail.get(); 9 Node next = last.next.get(); 10 if (last == tail.get()) { 11 if (next == null) { 12 if (last.next.compareandset(next, node)) { 13 tail.compareandset(last, node); 14 return; 15 } 16 } else { 17 tail.compareandset(last, next); 18 } 19 } 20 } 21 } 22 } Figure 18.2 The LockFreeQueue class: the enq() method. 86

92 PCP CAPITOLO 9 MEMORIA TRANSAZIONALE Rivediamo la coda lock-free (figura 18.2) con un occhio di riguardo alle primitive di sincorinizzazione sottostanti. Una complicazione sorge tra le righe 12 e 13. Il metodo enq() chiama compareandset() per cambiare sia il campo next del nodo last che il campo tail stesso con il nuovo nodo. Ci piacerebbe combinare atomicamente le chiamate compareandset(), ma siccome queste si verificano una alla volta, sia enq() che deq() devono essere preparati ad incontrare una enq() parzialmente completata (riga 12). Un modo per risolvere questo problema è di introdurre una primitiva multicompareandset(), come mostrato in figura <T> boolean multicompareandset( 2 AtomicReference<T>[] target, 3 T[] expect, 4 T[] update) { 5 atomic { 6 for (int i = 0; i < target.length) 7 if (!target[i].get().equals(expected[i].get())) 8 return false; 9 for (int i = 0; i < target.length) 10 target[i].set(update[i].get); 11 return true; 12 } 13 } Questo metodo prende come argomento un array di oggetti AtomicReference<T>, un array dei previsti valori T ed un array di valori T per l aggiornamento. Questo metodo esegue simultaneamente compareandset() su tutti gli elementi dell array e se ne fallisce una, falliscono tutte. 1 public void enq(t item) { 2 Node node = new Node(item); 3 while (true) { 4 Node last = tail.get(); 5 Node next = last.next.get(); 6 if (last == tail.get()) { 7 AtomicReference[] target = {last.next, tail}; 8 T[] expect = {next, last}; 9 T[] update = {node, node}; 10 if (multicompareandset(target, expect, update)) return; 11 } 12 } 13 } Tutti i meccanismi di sincronizzazione considerati fino ad ora, con o senza lock, hanno un problema principale: non possono essere facilmente composti. Immaginiamo di voler fare il dequeue di un elemento x dalla coda q0 e l enqueue di x in un altra coda q1. Il trasferimento deve essere atomico, nessun thread concorrente dovrebbe poter osservare che x sia scomparso o che sia presente in entrambe le code. Nell implementazione della coda basata sui monitor, ogni thread acquisisce il lock internamente, quindi è essenzialmente impossibile combinare le chiamate di due metodi in questo modo. L impossibilità della composizione non è ristretta alla mutua esclusione. Consideriamo una classe di una coda limitata il cui metodo deq() rimane bloccato fino a quando la coda è vuota. Immaginiamo di avere due code del genere e che vogliamo effettuare una dequeue di un oggetto da entrambe le code. Se tutte e due le code sono vuote, vogliamo mantenere il lock fin quando un elemento non viene inserito in una delle due. Nell implementazione di Queue con i monitor, ogni metodo aspetta sulla propria condizione e quindi è essenzialmente impossibile aspettare su due condizioni in questo modo. 87

93 PCP CAPITOLO 9 MEMORIA TRANSAZIONALE Possiamo riassumere il problema con le primitive di sincronizzazione convenzionali come segue: I lock sono difficili da gestire, specialmente su sistemi grandi. Primitive atomiche come compareandset() operano su una sola parola alla volta, producendo algoritmi complicati. È difficile comporre chiamate multiple ad oggetti multipli in unità atomiche. La soluzione a questi problemi è il modello di programmazione memoria transazionale (transactional memory). Una transazione è una sequenza di step eseguiti da un singolo thread. Le transazioni possono essere serializzabili, eseguite sequenzialmente una alla volta. La serializzabilità è un tipo di linearizzabilità coarse-grained. La linearizzabilità definisce l atomicità di oggetti individuali, richiedendo che ogni chiamata di un metodo su un dato oggetto abbia effetto istantaneamente tra l invocazione e la risposta. D altronde la serializzabilità definisce atomicità per l intera transazione, ovvero assicura che una transazione abia effetto tra l invocazione della sua prima chiamata e la risposta all ultima chiamata. La parola chiave atomic delimita una transazione più o meno nello stesso modo in cui la parola chiave synchronized delimita una sezione critica. Mentre i blocchi synchronized acquisiscono un lock specifico e sono atomici solo rispetto agli altri blocchi synchronized che acquisiscono lo stesso lock, un blocco atomic è atomico rispetto a tutti gli altri blocchi atomic. Blocchi synchronized nidificati possono andare in deadlock se acquisiscono lock in ordine diverso mentre i blocchi atomic nidificati no. 1 public class TransactionalQueue<T> { 2 private Node head; 3 private Node tail; 4 public TransactionalQueue() { 5 Node sentinel = new Node(null); 6 head = sentinel; 7 tail = sentinel; 8 } 9 public void enq(t item) { 10 atomic { 11 Node node = new Node(item); 12 node.next = tail; 13 tail = node; 14 } 15 } } Figure 18.5 An unbounded transactional queue: the enq() method Siccome le transazioni permettono aggiornamenti atomici in locazioni multiple, eliminano il bisogno di multicompareandset(). La figura 18.5 mostra il metodo enq() per una coda transazionale. Confrontando questo codice con quello lock-free della figura 18.2 emerge che non c è bisogno del campo AtomicReference, delle chiamate a compareandset() e dei cicli per riprovare. Le transazioni sono eseguite speculativamente: come una transazione parte, esegue dei tentativi di cambiare gli oggetti. Se completa l esecuzione senza incontrare conflitti di sincronizzazione, allora esegue la commit (il tentativo di cambiamento diventa permanente) oppure abort (il tentativo viene scartato). Le transazioni possono essere nidificate, devono esserlo per una modularià semplice. Un metodo dovrebbe essere in grado di iniziare una transazione ed in seguito chiamare un altro metodo senza doversi preoccupare se il metodo chiamato inizia una nuova transazione nidificata. Le transazioni nidificate sono 88

94 PCP CAPITOLO 9 MEMORIA TRANSAZIONALE molto utili soprattutto nel caso in cui l abort di una transazione nidificata non comporta l abort della transazione padre. 1 atomic { 2 x = q0.deq(); 3 q1.enq(x); 4 } Fig.18.7 Come abbiamo visto, il trasferimento atomico di un elemento da una coda ad un altra è essenzialmente impossibile con oggetti che usano i monitor lock al loro interno. Con le transazioni, comporre tali chiamate atomiche è piuttosto banale. La figura 18.7 mostra come comporre una chiamata a deq() che elimina un elemento x dalla coda q0 e una chiamata a enq() per inserire tale elemento in un altra coda. 1 public void enq(t x) { 2 atomic { 3 if (count == items.length) 4 retry; 5 items[tail] = x; 6 if (++tail == items.length) 7 tail = 0; 8 ++count; 9 } 10 } Figure 18.6 La figura 18.6 mostra il metodo enq() per un buffer limitato. Il metodo entra in un blocco atomic (riga 2) e testa se il buffer è pieno (riga 3). Se così chiama retry che effettua il rollback della transazione, la mette in pausa e ricomincia quando lo stato dell oggetto viene cambiato. La sincronizzazione condizionale è una ragione per cui può essere conveniente fare il rollback solo della transazione nidificata e non di quella padre. 1 atomic { 2 x = q0.deq(); 3 } orelse { 4 x = q1.deq(); 5 } Fiugura 18.8 Aspettare che una di numerose condizioni diventi vera era impossibile con oggetti che usavano le condition ed i monitor. Con retry questo tipo di composizioni diventa semplice. La figura 18.8 mostra uno snippet di codice che illustra lo statement orelse, che unisce due o più blocchi di codice. Il thread esegue il primo blocco (riga 2), se questo chiama la retry viene effettuato il rollback della sottotransazione ed il thread passa all esecuzione del secondo blocco (riga 4). Se anche questo blocco chiama la retry, il blocco orelse per intero fa una pausa e poi riesegue ogni blocco (quando qualcosa cambia) fino a quando uno non viene completato. Hardware Transactional Memory Andiamo a vedere come un architettura hardware standard può essere accresciuta per supportare transazioni piccole, corte direttamente in hardware. Nella maggior parte dei moderni sistemi multiprocessore, ogni processore ha una cache usata per evitare la comunicazione con le grandi e lente 89

95 PCP CAPITOLO 9 MEMORIA TRANSAZIONALE memorie centrali. L idea di base dietro HTM è che i moderni protocolli di cache-coherent già fanno gran parte del lavoro necessario per implementare le transazioni. Essi già scoprono e risolvono conflitti di sincronizzazione tra writer e tra reader e writer e già bufferizzano i tentativi di cambiamento invece di aggiornare la memoria direttamente. È possibile adattare il protocollo MESI per aggiungere il supporto alle transazioni. Aggiungiamo un bit transactional ad ogni tag di ogni linea di cache. Normalmente questo bit non è settato. Quando un valore viene messo in cache per conto di una transazione, tale bit viene settato. Dobbiamo solo assicurare che tale linea modificata non può essere scritta in memoria e che l invalidazione di una linea transactional fa abortire la transazione: Se il protocollo MESI invalida una entry transactional, la transazione viene abortita. Tale invalidazione rappresenta un conflitto di sincronizzazione o tra due scritture o tra una lettura ed una scrittura. Se una linea transactional modificata viene invalidata o cancellata, il suo valore viene scartato invece di essere scritto in memoria. Siccome ogni valore scritto dovuto ad una transazione è un tentativo, non possiamo lasciarlo uscire mentre la transazione è attiva. Invece dobbiamo abortirla. Se ogni cache cancella una linea transactional, allora la transazione deve essere abortita, in quanto una volta che la linea non è più in cache, il protocollo di cache-coherence non può più scoprire conflitti di sincronizzazione. Se, quando una transazione finisce, nessuna delle sue linee transactional è stata invalidata o cancellata, è possibile effettuare la commit, cancellando i bit transactional dalla cache. Queste regole assicurano che commit e abort sono step locali al processore. Sebbene lo schema implementa correttamente una memoria transazionale in hardware, ha qualche difetto e limitazione. Una prima limitazione è che la dimensione della transazione è limitata alla dimensione della cache. La maggior parte dei sistemi operativi cancellano la cache quando un thread è deschedulato, quindi la durata della transazione può essere limitata alla durata dello scheduling. Ne consegue che HTM è adatto per transazioni piccole e corte. Applicazioni che necessitano di transazioni più lunghe usano Software Transaction Memory o una combinazione di HTM e STM. Quando una transazione fallisce è importante che il sistema ne riporti il motivo. Se il fallimento è dovuto ad un conflitto di sincronizzazione allora ha senso ripetere la transazione, mentre se il fallimento è dovuto all esaurimento delle risorse non ha senso. Questo particolare design, comunque, ha alcuni inconvenienti addizionali. Molte cache sono direttamente mappate, un indirizzo mappa esattamente una linea di cache. Ogni transazione che accede a due indirizzi che mappano la stessa linea di cache è condannata a fallire perché il secondo accesso andrà a sfrattare il primo, abortendo la transazione. Alcune cache sono set-associative, mappando ogni indirizzo ad un insieme di k linee. Ogni transazione che accede a k+1 indirizzi che mappano lo stesso insieme è condannata a fallire. Poche cache sono fully-associative, mappando ogni indirizzo ad ogni linea di cache. Ci sono numerosi modi per alleviare questi problemi dividendo la cache. Uno è di dividere al cache in grandi, direct-mapped main cache e piccole, fully-associated victim cache usate per le entry che vanno in overflow nella main cache. Un altro è di dividere la cache in grandi cache set-associated non-transactional e piccole fully-associative transactional cache per le linee transactional. In entrambi i casi, il protocollo cachecoherent deve essere adattato per gestire la coerenza tra cache divise. 90

96 PCP CAPITOLO 9 MEMORIA TRANSAZIONALE Un altra pecca è la mancanza di gestione della contesa, che si traduce nella possibilità che più transazioni possono andare in starvation. La transazione A carica l indirizzo a in modo esclusivo, poi la transazione B carica a in modo esclusivo facendo abortire A che immediatamente ricomincia caricando a e facendo abortire B. Questo problema dovrebbe essere gestito a livello del protocollo di coerenza, permettendo al processore di rifiutare o ritardare una richiesta di invalidazione, o potrebbe essere gestito a livello software probabilmente con un back off esponenziale. Software Transactional Memory Tramite il STM è possibile implementare interamente in software operazioni lock-free, atomiche e multilocation, ma è richiesto un programma per dichiarare in anticipo le locazioni di memoria che devono essere accedute da una transazione. Il Dynamic STM fu il primo sistema STM che non richiedeva un programma per dichiarare le locazioni di memoria acceduta da una transazione. DSTM è un sistema STM objectgranularity, deferred-update. Una transazione modifica una copia privata di un oggetto ed i cambiamenti risultano visibili alle altre transazioni solo dopo la commit. Un altra transazione può accedere all oggetto originale mentre la prima transazione è ancora in esecuzione. Questo causa un conflitto logico di cui il sistema STM si accorge e risolve abortendo una delle due transazioni. Un sistema STM si può accorgere di un conflitto quando una transazione accede per primo ad un oggetto (early detection) o quando la transazione cerca di effettuare la commit (late detection). La early detection permette alla transazione di non eseguire computazioni inutili che una abort successiva possa scartare. Late detection può evitare abort non necessarie come quando la transazione in conflitto abortisce a causa di un conflitto con una terza transazione. Un altra complicazione si ha quando c è un conflitto tra una transazione che legge un oggetto ed un altra che invece modifica anche l oggetto. Siccome le letture sono molto più frequenti delle scritture, i sistemi STM clonano solo gli oggetti da modificare. Per ridurre l overhead, una transazione mantiene traccia degli oggetti che legge e, prima di fare la commit, assicura che non ci siano altre transazioni che li stiano modificando. DSTM è una libreria. Un oggetto manipolato in una transazione è prima registrato con un sistema DSTM, che restituisce un wrapper TMObject per l oggetto. Successivamente, il codice che esegue una transazione può aprire il TMObject per accessi read-only o read-write che restituiscono un puntatore all originale o all oggetto clonato. In entrambi i casi, la transazione manipola l oggetto direttamente senza ulteriori 91

97 PCP CAPITOLO 9 MEMORIA TRANSAZIONALE sincronizzazioni. Una transazione termina quando un programma cerca di effettuare la commit dei cambiamenti. Se la commit avviene con successo, il sistema DSTM rimpiazza atomicamente, per tutti gli oggetti modificati, il vecchio oggetto in una struttura Locator con la versione modificata. Una transazione T può effettuare una commit con successo se si verificano due condizioni. La prima è che non ci siano transazioni eseguite concorrentemente che modifichino un oggetto letto da T. Il sistema DSTM mantiene traccia degli oggetti aperti in lettura in un read set e valida le entry in questo insieme quando la transazione cerca di effettuare la commit. Un oggetto nel read set è valido se la sua versione non è cambiata da quando la transazione T l ha aperto. La validazione avviene anche mentre la transazione apre altri oggetti per evitare che l esecuzione continui inutilmente. La seconda condizione è che la transazione T non stia modificando un oggetto che anche un altra transazione sta modificando. DSTM previene questo tipo di conflitto permettendo solo una transazione di aprire un oggetto per la modifica. Quando accade un conflitto write-write, DSTM abortisce una delle due transazioni in conflitto e permette all altra di procedere. Le performance di DSTM, come gli altri sistemi STM, dipendono dai dettagli del carico di lavoro. In generale overhead grandi dei sistemi STM sono più costosi dei lock su un piccolo numero di processori. Comunque, se il numero di processori aumenta, aumenta anche la contesa per un lock ed il costo del locking. Quando questo accade ed i conflitti sono rari, i sistemi STM hanno mostrato di essere più efficienti dei lock su piccoli benchmark. 92

98 PCP LABORATORIO 1 INTRODUZIONE ALLA PROGRAMMAZIONE PARALLELA LABORATORIO 1 INTRODUZIONE ALLA PROGRAMMAZIONE PARALLELA In un mondo ideale, passando da un processore a n processori, ci dovrebbe essere un incremento pari a n. in pratica questo non accade. La prima ragione per ciò è che i più grandi problemi computazionali non possono essere parallelizzati senza aumentare il costo della comunicazione inter-processo e di coordinazione. La legge di Amdahl Ci sono 5 amici che vogliono dipingere una casa con 5 stanze. Se le stanze sono uguali in dimensione (ed anche gli amici sono ugualmente capaci e produttivi) allora finiscono in 1/5 del tempo che avrebbe impiegato una sola persona. Lo speedup S ottenuto è 5, pari al numero di amici. Se una stanza è grande il doppio, però il risultato è diverso. Il tempo per fare la stanza grande domina il tempo delle altre (naturalmente non consideriamo la complicazione di aiutare il poveretto cui è toccata la stanza grande, per l overload del coordinamento necessario). Lo speedup S di un programma X è il rapporto tra il tempo impiegato da un processore per eseguire X rispetto al tempo impiegato da n processori. Sia p la parte del programma X che è possibile parallelizzare (con n processori la parte parallela prende tempo p/n mentre la parte sequenziale prende tempo (1-p). Quindi la computazione parallela impiega: 1 p + p n La legge di Amdahl dice che lo speedup è il rapporto tra il tempo sequenziale di un singolo processore e quello parallelizzato ottenendo: 1 S = 1 p + p n La legge di Amdahl ci dice anche che la parte sequenziale del programma rallenta significativamente qualsiasi speedup che possiamo pensare di ottenere. 1 S = 1 p + p n = = 3 6 Quindi, per velocizzare un programma non basta investire sull hardware (più processori, più velocità,...) ma è assolutamente necessario e molto più cost-effective impegnarsi a rendere la parte parallela predominante rispetto alla parte sequenziale. Alcuni problemi sono facilmente eseguibili in parallelo perché non richiedono nessuna forma di coordinazione o comunicazione. Altri hanno solo una parte parallelizzabile con facilità: il problema è identificare quelli che hanno questa caratteristica. Un problema parallelo viene indicato con Embarassingly parallel se non c'è quasi nessuna dipendenza in ogni calcolo. Qualsiasisincronizzzazione avviene nella fase nella fase iniziale e/o in quella finale. Definizioni Cosa è il calcolo parallelo? Generalmente il software è scritto per calcoli seriali, per computer con una sola CPU, le istruzioni vengono eseguite una dopo l altra, una e soltanto una istruzione può essere eseguita in un 93

99 PCP LABORATORIO 1 INTRODUZIONE ALLA PROGRAMMAZIONE PARALLELA dato istante. Nel calcolo parallelo i programmi sono scritti per CPU multiple. Il problema è separato in parti diverse, eseguibili in maniera concorrente. Perché usare il calcolo parallelo (WHY?) Risparmiare tempo e denaro: maggiori risorse risolvono problemi più velocemente. Risolvere problemi su larga scala: i cluster possono essere costruiti economicamente collegando numerosi comuni PC ed usati per risolvere problemi di grande dimensioni, impraticabili per computer singoli (generalmente problemi che richiedono PetaFLOPS e PetaBytes). Altri esempi in cui è indispensabile usare computazione parallela sono i DB/Web search che effettuano milioni di transizioni al secondo. Fornire concorrenza su larga scala tramite le Grid. Usare risorse non locali disponibili gratuitamente. Esistono dei progetti che permettono di utilizzare cicli di clock di un normale PC per effettuare i propri calcoli come ad esempio seti@home PC con 528 TeraFlops e folding@home PC con 4.2 PetaFlops. Superamento limiti del calcolo seriale: ci sono ragioni sia pratiche che fisiche che pongono dei vincoli significativi allo sviluppo di computer seriali sempre più veloci: o Velocità di trasmissione o Limiti alla miniaturizzazione o Economicità: meglio numerosi commodity PC che costosi processori. Il calcolo parallelo è uno dei pochi campi dove il macismo informatico ottiene il massimo della sua ricompensa. Concetti e terminologia L architettura di Von Neumann fu presentata nel La macchina presentata dispone di 4 componenti: memoria, unità di controllo, ALU e I/O. Le read e write sulla memoria permettono di memorizzare e leggere istruzioni e dati. La Control Unit preleva le istruzioni/dati dalla memoria, li decodifica e sequenzialmente ne coordina l esecuzione. L Aritmetic Logic Unit esegue le operazioni aritmetiche di base. L I/O è l interfaccia di comunicazione con l utente. La tassonomia di Flynn rappresenta una modalità di classificazione dei sistemi paralleli; distingue due assi: Istruzioni/dati Esecuzione singola/multipla S I S D Single Instruction, Single Data S I M D Single Instruction, Multiple Data M I S D Multiple Instruction, Single Data M I M D Multiple Instruction, Multiple Data 94

100 PCP LABORATORIO 1 INTRODUZIONE ALLA PROGRAMMAZIONE PARALLELA Single Instruction, Single Data (SISD): computer seriale, istruzione singola. Viene eseguita soltanto una istruzione dalla CPU durante un ciclo di clock e soltanto uno stream di dati viene utilizzato come input. Multiple Instruction, Single Data (MISD): un singolo stream di dati viene processato da più processori e ognuno di essi opera sui dati indipendentemente. Single Instruction, Multiple Data (SIMD): un tipo di computer parallelo. È single instruction, ovvero tutte le CPU eseguono la stessa istruzione in un determinato ciclo di clock, ogni processore può operare su un elemento differente dei dati (multiple data). Ha una forte sincronia e si trova in un architettura processor array e vector pipelines. Multiple Instruction, Multiple Data (MIMD): ogni processore può eseguire un differente stream di istruzioni ed ogni processore può lavorare con un differente stream di dati. L esecuzione può essere sincrona o asincrona e deterministica o non deterministica. È il tipo più comune di computer paralleli. Task: sezione discreta di una computazione. Tipicamente un set di istruzioni eseguite da un processore. Task parallelo: un task eseguibile da più processori che produce risultati corretti. Pipelining: divider un task in passi eseguibili da diversi processori, simile ad una catena di montaggio. Memoria condivisa: da un punto di vista hardware, riguarda un architettura dove tutti i processori hanno un accesso diretto alla memoria fisica in comune. Da un punto di vista di programmazione, descrive un modello dove task paralleli hanno la stessa fotografia della memoria e possono accedere alle stesse locazioni senza essere consapevoli di dove la memoria realmente si trova. Symmetric Multi-Processor (SMP): architettura hardware dove più processori condividono un singolo spazio di indirizzamento. Memoria distribuita: in hardware si riferisce all accesso alla memoria fisica in rete, non molto comune. Come modello di programmazione, i task possono solo vedere logicamente la memoria della macchina locale e devono comunicare con le altre macchine, dove sono in esecuzione altri task, per accedere alla loro memoria. Granularità: misura qualitativa del rapporto tra computazione e comunicazione: Coarse: un relativamente grosso ammontare di computazione è effettuato per gestire la comunicazione; 95

101 PCP LABORATORIO 1 INTRODUZIONE ALLA PROGRAMMAZIONE PARALLELA Fine: un relativamente piccolo ammontare di computazione è effettuato per gestire la comunicazione. Wall clock: tempo impiegato per completare un task. È dato dalla somma dal tempo CPU + tempo di I/O + il delay del canale di comunicazione. Speedup osservato di un codice parallelizzato è definito come: wall clock dell esecuzione seriale/wall clock dell esecuzione parallela Parallel Overhead: ammontare di tempo richiesto per la coordinazione dei task (start-up, sincronizzazione, comunicazione dei dati, software, terminazione) Scalabilità: riferita ai sistemi paralleli, permette di dimostrare l incremento proporzionale dello speedup con l aumento del numero di processori. Architetture della memoria Shared memory Tutti i processori possono accedere ad uno spazio di indirizzi globali ed ogni processore è indipendente. I cambiamenti effettuati da un processore sono visibili anche agli altri processori. Macchine con questo tipo di memoria possono essere suddivise in due classi principali: UMA e NUMA. L architettura UMA (Uniform Memory Access) è comunemente rappresentata da macchine SMP con processori aventi la stessa politica e lo stesso tempo di accesso alla memoria. Inoltre è cache coherent, se un processore aggiorna una locazione nella memoria condivisa tutti gli altri processori saranno informati della modifica. L architettura NUMA (Non-Uniform Memory Access) è spesso implementata collegando fisicamente due o più SMP. Non tutti i processori hanno stesso tempo di accesso alle memorie, che risulta lento poiché effettuato tramite i collegamenti. Se la cache coherent è mantenuta, viene chiamato CC-NUMA (Cache Coherent NUMA). I vantaggi di questa architettura risiedono nel modello di programmazione user-friendly dato dallo spazio di indirizzamento globale e dalla condivisione dei dati tra task veloce ed uniforme, grazie alla vicinanza della memoria alle CPU. 96

102 PCP LABORATORIO 1 INTRODUZIONE ALLA PROGRAMMAZIONE PARALLELA Gli svantaggi riguardano la mancanza di scalabilità tra memoria e CPU, all aumentare delle CPU il traffico aumenta in maniera quadratica. La sincronizzazione che assicura la correttezza dell accesso alla memoria è responsabilità del programmatore. C è anche da considerare il costo molto elevato. Distribuited Memory È necessaria una rete di comunicazione per connettere la memoria tra i processori (anche solo ethernet). I processori hanno la loro memoria locale e queste non sono mappate in una memoria globale (niente spazio di indirizzamento globale). Ogni processore, quindi, opera indipendentemente ed i cambiamenti della memoria effettuati da un processore non hanno effetto sulle memorie degli altri. Di conseguenza non c è la necessità di mantenere la cache coherency. Quando un processore ha la necessità di accedere a dati presenti in altri processori, generalmente è il task stesso che definisce esplicitamente come e quando i dati debbano essere trasferiti. La sincronizzazione tra i vari task è quindi responsabilità del programmatore. I vantaggi riguardano la scalabilità, se si aumenta il numero dei processori proporzionalmente si aumenta anche la dimensione della memoria. Ogni processore può accedere rapidamente alla propria memoria senza interferenza e senza che si verifichi overhead dovuto alla cache coherency. È possibile utilizzare processori commodity o off-the-shelf con uno strato di rete per la comunicazione, quindi è conveniente da un punto di vista economico. Uno svantaggio è che il programmatore è responsabile dei dettagli della comunicazione. Inoltre risulta difficile mappare strutture dati esistenti su memorie distribuite. Accesso non uniforme. Hybrid architecture Unisce i vantaggi di shared e quelli di distribuited memory (se siete fortunati). La componente shared è di solito una SMP cache coherent (memoria globale). I processori su un singolo SMP possono mappare la memoria come globale. La componete distribuita è il networking processors, necessaria per spostare i dati da un SMP ad un altro. Modelli di programmazione parallela Ci sono diversi modelli di programmazione: Memoria condivisa: I task condividono uno spazio di indirizzamento comune nel quale leggono e scrivono in modo asincrono. Per controllare l accesso, vengono usati i lock/semafori. Nessun dato appartiene ad un processore, quindi viene semplificato lo sviluppo del software. 97

103 PCP LABORATORIO 1 INTRODUZIONE ALLA PROGRAMMAZIONE PARALLELA Thread: Nel modello basato sui thread, un singolo processo può avere più percorsi di esecuzione concorrenti. Un concetto più semplice per descrivere questo metodo è quello di pensare il thread parte di un singolo programma che ha diverse subroutine. Message Passing: Task che scambiano dati con messaggi di send e receive. Il trasferimento dei dati richiede operazioni di cooperazione; ad esempio un operazione di send deve avere una rispettiva operazione di receive. Per implementare lo scambio di messaggi sono state create numerose librerie nel corso degli anni. MPI (dal 1992) è uno standard de facto, implementato anche su architetture a memoria condivisa. Data Parallel: Il lavoro su un insieme di dati in una struttura dati condivisa, generalmente un array o un cubo, viene suddiviso tra vari task. Ogni task lavora su parti diverse della struttura effettuando la stessa operazione sulla propria partizione di lavoro. Le implementazioni più importanti sono Fortran 90 e 95, High Performance Fortran. Hybrid: combina le varie tipologie sopra indicate. I modelli di programmazione esistenti sono un astrazione tra il software e l architettura (hardware e memoria). Non sono strettamente legati ad una particolare architettura. Infatti teoricamente, qualsiasi di questo modello può essere implementato su qualsiasi hardware sottostante. Progettazione di programmi La progettazione e lo sviluppo di programmi paralleli è tipicamente un operazione manuale in quanto risulta essere un compito complesso ed error prone. Esistono tool per la parallelizzazione automatica che assistono i programmatori e che provvedono a convertire i programmi seriali in programmi paralleli. Ci 98

104 PCP LABORATORIO 1 INTRODUZIONE ALLA PROGRAMMAZIONE PARALLELA sono compilatori Fully Automatic che analizzano il codice ed identificano opportunità di utilizzo parallelo utilizzando calcolo pesato; i cicli sono gli obiettivi più frequenti per i tool di parallelizzazione automatica. Questa conversione può produrre un risultato scorretto, degradamento delle performance, mancanza di flessibilità, limitata ad un sottoinsieme del codice. I compilatori Programmer Directed, invece, utilizzano direttive o flag per aiutare il programmatore a parallelizzare un applicazione (molto difficile da usare). Bisogna vedere il programma da lontano e vedere se è parallelizzabile o meno. Per esempio il calcolo della serie di Fibonacci risulta essere un problema non parallelizzabile in quanto il calcolo della serie dipende dal calcolo dei precedenti valori (il calcolo di F(k+2) usa valori di F(k+1) e F(k)). È necessario identificare gli hotspot del programma tramite l uso di profiler per identificare dove viene fatto la maggior quantità di lavoro. Inoltre bisogna identificare i bottleneck del programma, che per la maggior parte delle volte risultano incentrati sulla sincronizzazione e sull I/O. Infine, bisogna identificare le dipendenze tra i dati. Tecniche di partizione Uno dei primi passi è dividere il problema in pezzi discreti di lavoro (partitioning) che possono essere distribuiti. Esistono due modalità fondamentali di fare partitioning: Decomposizione: in questo tipo di partitioning i dati che appartengono al problema vengono divisi ed ogni task parallelo lavora su una porzione di dati. Functional decomposition: in questo approccio il focus è sulla computazione che deve essere eseguita piuttosto che sui dati manipolati dalla computazione. Il problema viene decomposto in accordo al lavoro che deve essere fatto. Es. di modellazione di un ecosistema, modello del clima Le problematiche Il bisogno di comunicazione tra i task dipende dal problema. Un tipico esempio in cui non è necessaria la comunicazione è partizionamento di una immagine in regioni, con calcoli in locale. Vi sono molti fattori da considerare che influenzano le scelte del programmatore: Costo della comunicazione: i cicli macchina e le risorse sono usate per comunicare invece che per calcolare. Inoltre sono necessarie delle tecniche di sincronizzazione che risultano essere dei bottleneck. 99

105 PCP LABORATORIO 1 INTRODUZIONE ALLA PROGRAMMAZIONE PARALLELA Latency vs bandwidth: creare un package di molti messaggi piccoli in un unico messaggio grande può risultare più efficiente. Visibilità della comunicazione: può essere esplicita o implicita. Comunicazione sincrona/asincrona: la comunicazione sincrona richiede una fase di handshake tra i processi che condividono i dati. Una comunicazione di tipo sincrono è blocking in quanto il resto del lavoro deve aspettare il completamento della comunicazione. Comunicazioni asincrone sono nonblocking. Lo scope della comunicazione: è necessario conoscere quali task devono comunicare con gli altri. Ci sono due tipologie: o point-to-point: vi sono due task dove uno è il sender/produttore dei dati e l altro è il receiver/consumatore; o collettiva: le risorse vengono codivise tra più di 2 task. Ecco alcune variazioni: Efficienza della comunicazione: molto spesso il programmatore si trova di fronte delle scelte riguardanti i fattori che possono influenzare le prestazioni della comunicazione, come ad esempio l efficienza delle implementazioni su una particolare piattaforma, oppure l efficienza della comunicazione asincrona rispetto a quella sincrona, etc. Tipi di sincronizzazione sono: Barrier: ogni task si blocca alla barriera, fino a quando tutti arrivano lì. Quando l ultimo raggiunge la barriera tutti task sono sincronizzati. Lock/semaphore: può coinvolgere qualsiasi numero di task. Tipicamente sono usati per proteggere l accesso ai dati globali, solo un task per volta può usare il lock/semaforo nello stesso tempo. Il primo che lo acquisisce setta il lock e in questo periodo gli altri task possono attendere per acquisirlo finché il primo task non lo rilascia. Operazioni di comunicazione sincrona: riguarda quei task che eseguono un operazione di comunicazione, in quanto questa fase richiede una forma di coordinazione con gli altri task che partecipano alla comunicazione. Esiste dipendenza tra le istruzioni di un programma se l ordine di esecuzione influenza il risultato. Esiste dipendenza dati se task differenti usano contemporaneamente gli stessi dati. La dipendenza è importante per la programmazione parallela perché è un aspetto principale del parallelismo. È importante che tutti i task siano occupati per tutto il tempo per ragioni di performance. Può essere considerata come la minimizzazione del tempo di inattività di un task. 100

106 PCP LABORATORIO 1 INTRODUZIONE ALLA PROGRAMMAZIONE PARALLELA Una tecnica di bilanciamento del carico è quello dell equipartizionare il lavoro; questo risulta a volte facile, a volte impossibile. Nel caso in cui vi sono macchine eterogenee, bisogna utilizzare tool per l analisi delle performance in grado di identificare il carico di lavoro. L altra tecnica è l assegnamento dinamico del lavoro: vi è uno scheduler di coordinazione a cui i task richiedono un altro batch di lavoro. Granularità Rappresenta la misura qualitativa del rapporto tra computazione e comunicazione. I periodi di computazione sono tipicamente separati da periodi di comunicazione da eventi di sincronizzazione. Fine-grain: bassa computazione; facilita il bilanciamento; alto overhead di comunicazione. Coarse-grain: maggiori opportunità di miglioramento prestazioni; carico efficiente molto difficile. La granularità più efficiente dipende dall algoritmo e dall ambiente hardware dove viene eseguito. In molti casi l overhead associato alla comunicazione e alla sincronizzazione è relativamente alto per esecuzioni veloci per questo è vantaggioso avere una granularità coarse. La granularità fine può essere utile per ridurre l overhead generato dal bilanciamento del carico. Input/Output In generale, l I/O è un problema per il parallelismo, specialmente se condotto sulla rete. Alcuni sistemi paralleli di I/O sono immaturi e non eseguibili su tutte le piattaforme. Esistono sistemi paralleli di file system, alcuni anche con ottimizzazioni particolari (es. Google File system). Linee guida: se possibile, evitare l accesso parallelo. Sostituire l accesso parallelo con un accesso sequenziale seguito dalla distribuzione dei dati oppure rendere i file univoci. Il costo del calcolo parallelo In generale, le applicazioni parallele sono molto più complesse di quelle seriali. Il costo della complessità viene misurato tenendo conto di vari aspetti del ciclo di sviluppo: progettazione, codifica, debugging, tuning, manutenzione. Uno dei principali scopi della programmazione parallela è quella di diminuire il wall clock. Per esempio un codice parallelo eseguito in 1 ora su 8 CPU attualmente impiega 8 ore. La portabilità è stata migliorata con l introduzione di standard, ma ancora un problema (OS, HW). La scalabilità dipende da molti fattori in gioco: memoria, banda di rete, latenza, processori, librerie usate, etc. 101

107 PCP LABORATORIO 1 INTRODUZIONE ALLA PROGRAMMAZIONE PARALLELA Esempio 1: Array processing Questo esempio mostra il calcolo su elementi di un array bi-dimensionale, nel quale ogni elemento ha vita indipendentemente dagli altri. Il programma seriale calcola un elemento per volta. do j = 1,n do i = 1,n a(i,j) = fcn(i,j) end do end do Soluzione 1: ogni processore ha una porzione dell array. Il calcolo è indipendente, quindi nessuna comunicazione. La scelta del partizionamento è unicamente influenzata dall efficienza della cache. Una possibile soluzione è quella di implementare il modello SPMD dove un processo master inizializza l array ed invia le informazioni ai processi worker. Questi ultimi processano tali informazioni e inviano i risultati al master. Una soluzione è mostrata nel codice seguente (la parte in rosso è il codice aggiunto). find out if I am MASTER or WORKER if I am MASTER initialize the array send each WORKER info on part of array it owns send each WORKER its portion of initial array receive from each WORKER results else if I am WORKER receive from MASTER info on part of array I own receive from MASTER my portion of initial array # calculate my portion of array do j = my first column,my last column do i = 1,n a(i,j) = fcn(i,j) end do end do send MASTER results endif Soluzione 2: la soluzione precedente mostra un bilanciamento statico. Con processori eterogenei questo può essere un limite. Se si verificano problemi di bilanciamento è possibile avere benefici utilizzando lo schema a pool di task. Vi sono 2 processi: Processo master: mantiene il pool di task e li distribuisce su richiesta ai worker, ottenendo (e assemblando) i risultati. Processo worker: ripetutamente chiede un task, lo esegue e invia risultati al master. 102

108 PCP LABORATORIO 1 INTRODUZIONE ALLA PROGRAMMAZIONE PARALLELA Si verifica quindi un bilanciamento dinamico del carico (dimensionamento del task critico). Nel seguente esempio con pool di task, ogni task calcola un elemento individuale dell array come se fosse un job. Il rapporto tra la computazione e la comunicazione è a granularità fine, viene introdotto overhead per ridurre il tempo di inattività dei task. Una soluzione ottimale sarebbe quella di distribuire più lavoro con ogni job. find out if I am MASTER or WORKER if I am MASTER do until no more jobs send to WORKER next job receive results from WORKER end do tell WORKER no more jobs else if I am WORKER do until no more jobs receive from MASTER next job calculate array element: a(i,j) = fcn(i,j) send results to MASTER end do endif Esempio 2: equazione del calore La equazione descrive il cambiamento della temperatura nel tempo. La temperatura, all inizio, risulta essere alta al centro e zero al esterno. Nel tempo, c è la necessità di calcolare il cambiamento. Questo problema richiede comunicazione tra task con lo scambio di informazioni relative alla temperatura in quanto il calcolo è basato sul valore dei vicini. do iy = 2, ny - 1 do ix = 2, nx - 1 u2(ix, iy) = u1(ix, iy) + cx * (u1(ix+1,iy) + u1(ix-1,iy) - 2.*u1(ix,iy)) + cy * (u1(ix,iy+1) + u1(ix,iy-1) - 2.*u1(ix,iy)) end do end do Soluzione 1: uso di SPMD. L intero array è partizionato e distribuito come un subarray ad ogni task. Gli elementi interni non dipendono da altri task. Anche in questo caso utilizziamo il master ed il worker. Il processo master invia informazioni iniziali ai worker, controlla l ubicazione e colleziona i risultati. Il processo worker calcola la soluzione e comunica se necessario con i processi vicini. 103

109 PCP LABORATORIO 1 INTRODUZIONE ALLA PROGRAMMAZIONE PARALLELA find out if I am MASTER or WORKER if I am MASTER initialize array send each WORKER starting info and subarray do until all WORKERS converge gather from all WORKERS convergence data broadcast to all WORKERS convergence signal end do receive results from each WORKER else if I am WORKER receive from MASTER starting info and subarray do until solution converged update time send neighbors my border info receive from neighbors their border info update my portion of solution array determine if my solution has converged send MASTER convergence data receive from MASTER convergence signal end do send MASTER results endif Soluzione 2: nella soluzione precedente, è stata usata una comunicazione bloccante la quale attende il completamento della comunicazione prima di continuare con la prossima istruzione del programma. Ogni task deve comunicare con i vicini per aggiornare le proprie informazioni, questo risulta essere un collo di bottiglia. Si può migliorare utilizzando una comunicazione non-blocking, permettendo quindi che il lavoro sia performante mentre la comunicazione tra i processi è in atto. Ogni task può aggiornare la propria parte interna della soluzione dell array mentre la comunicazione ai bordi è in atto ed aggiornare i bordi dopo che la comunicazione è stata completata. find out if I am MASTER or WORKER if I am MASTER initialize array send each WORKER starting info and subarray do until all WORKERS converge gather from all WORKERS convergence data broadcast to all WORKERS convergence signal end do receive results from each WORKER else if I am WORKER receive from MASTER starting info and subarray do until solution converged update time non-blocking send neighbors my border info non-blocking receive neighbors border info update interior of my portion of solution array wait for non-blocking communication complete update border of my portion of solution array determine if my solution has converged send MASTER convergence data receive from MASTER convergence signal end do send MASTER results endif endif 104

110 PCP LABORATORIO 1 INTRODUZIONE ALLA PROGRAMMAZIONE PARALLELA Domande di riepilogo Quali sono le principali motivazioni ad utilizzare il calcolo parallelo? Quali sono alcuni campi di applicazioni del calcolo parallelo? Quali sono le ragioni tecnologiche al calcolo parallelo? Descrivere la tassonomia di Flynn. Cosa è la granularità di un programma parallelo? Cosa è ed in cosa consiste (da cosa è generato) l'overhead di un programma parallelo? Descrivere la architettura a Shared Memory (UMA e NUMA) con vantaggi e svantaggi Descrivere la architettura a Distributed Memory con vantaggi e svantaggi Descrivere le architetture ibride Cosa è il modello di programmazione shared memory? Cosa è il modello di programmazione multithreading? Cosa è il modello di programmazione message passing? In cosa consiste il modello di programmazione data parallel? Quale è l'obiettivo della parallelizzazione dei programmi paralleli e in cosa si differenziano i compilatori fully automatic da quelli programmer directed? Quale è l'obiettivo della tecnica del partitioning? Cosa è la tecnica del domain decomposition? Cosa è la tecnica della functional decomposition? Quali sono le problematiche di load balancing generate dalla tecnica del domain decomposition e del funcional decomposition? In quale dei due casi il load balancing è più semplice da effettuare, anche se non tutti i problemi sono risolti? Quali sono i fattori che influenzano la comunicazione? In che maniera la progettazione del nostro programma parallelo viene influenzata da latenza e banda della comunicazione, disponibile sulla macchina parallela reale che si vuole usare? Cosa sono le primitive di comunicazione broadcast, scatter, gather e reduction e perchè vengono utilizzate? Perchè la dipendenza dei dati influenza la parallelizzazione di un programma? Quale è il problema del bilanciamento del carico? In cosa si differenziano assegnamento statico e dinamico del lavoro e quali sono i pro ed i contro di ciascuna soluzione? Cosa è la granularità fine-grain e coarse-grain? E perchè questa scelta influenza, da un lato, l'overhead dall'altro il load balancing della macchina parallela? 105

111 PCP LABORATORIO 2 MESSAGE PASSING INTERFACE LABORATORIO 2 MESSAGE PASSING INTERFACE MPI (message passing interface) è una libreria di interfacce di message passing. Nel modello MPI i processi inviano e ricevono messaggi per comunicare con gli altri processi. Risulta essere il modello più utilizzato per la programmazione parallela su architetture con condivisione di memoria. MPI indirizza lo scambio di messaggi sul modello parallelo dove un dato è spostato da uno spazio indirizzabile di un processo ad un altro attraverso operazioni di cooperazione dei processi. MPI è una specifica, non una implementazione; di implementazioni ne esistono diverse. Tutte le operazioni di MPI vengono espresse come funzioni, subroutine o metodi in base al linguaggio che viene usato: C, C++, Fortran-77, etc. I maggiori vantaggi di MPI sono la portabilità e la facilità d uso. Lo standard MPI è rivolto a tutti coloro che vogliono scrivere programmi basati sul message passing e che siano portabili. Lo standard deve provvedere ad una semplice e facile interfaccia, anche per utenti non esperti, alle metodologie del message passing e della programmazione parallela. Come già spiegato in precedenza, bisogna utilizzare la programmazione parallela solo quando strettamente necessario. Per utilizzare le librerie MPI è necessario linkarle al momento della compilazione. Per eseguire un software scritto con MPI, bisogna utilizzare un wrapper chiamato mpirun; il seguente comando permette di eseguire il nostro programma su 4 processori: $ mpirun np 4 execfile Struttura di un programma Per poter scrivere un programma ed utilizzare MPI, bisogna specificare le seguenti librerie: #include <mpi.h> #include <stdio.h> che provvedono ad inizializzare l ambiente MPI. Prima dell Initialize e dopo il Terminate, il codice che viene scritto è seriale. Solo all interno delle due istruzioni il nostro codice risulta essere parallelo. Le funzioni MPI hanno un formato specifico: rc = MPI_Xxxxx(parameter,... ); Ad esempio: rc = MPI_Send (&buf,count,datatype,dest,tag,comm); 106

112 PCP LABORATORIO 2 MESSAGE PASSING INTERFACE Se la funzione che viene invocata termina correttamente, allora verrà restituita la costante MPI_SUCCESS, altrimenti un codice di errore relativo al problema. #include <stdio.h> #include <mpi.h> int main(int argc, char *argv[]) { int rank,size; MPI_Init(&argc, &argv); MPI_Comm_size(MPI_COMM_WORLD, &size); MPI_Comm_rank(MPI_COMM_WORLD, &rank); printf("hello, world! I am %d of %d.\n", rank, size); MPI_Finalize(); return 0; } In questo semplice esempio, ogni processo esegue lo stesso codice. L ordine in cui viene effettuata la stampa a video è del tutto casuale, non vi è una forma di sincronizzazione tra i processori quindi ogni volta che si lancia in esecuzione il codice, la stampa a video può cambiare ordine ogni volta. Tipi di routine di MPI Lo standard MPI include diverse tipologie di routine a seconda dell operazione che si intende effettuare: Comunicazione punto-punto: è la più semplice forma di comunicazione tra due processori dove uno invia dei dati e l altro li riceve. Comunicazione collective; Gruppo di processi; Topologie di processi; Gestione dell ambiente e inquiry. Durante una semplice comunicazione costituita da send e receive, un messaggio consiste di alcuni blocchi di dati che vengono trasferiti da un processore ad un altro. Un messaggio quindi è costituito da un envelope 107

113 PCP LABORATORIO 2 MESSAGE PASSING INTERFACE che specifica il processore mittente ed il destinatario ed un body che contiene i dati effettivi che devono essere inviati. Il body è costituito da 3 parti: 1. Il buffer: è un puntatore; 2. Datatype 3. Count: il numero di items dei tipi di dati che devono essere inviati. MPI provvede a trasmettere i messaggi in vari modi; vi è un insieme di criteri che determina quando una comunicazione è completata. Ci sono quattro tipi di comunicazione per chi invia: 1. Standard 2. Synchronous 3. Buffered 4. Ready mentre per chi riceve vi è solo ed un solo tipo di comunicazione. La ricezione è completata quando i dati arrivano correttamente a destinazione e sono pronti per essere usati. Inoltre la comunicazione può essere bloccante e non bloccante: nella comunicazione bloccante, il send e il receive non riprendono la computazione prima che l operazione non sia completata. Per esempio con una send bloccante, si è sicuri che le variabili inviate possono essere sovrascritte dal processore inviante; con una receive bloccante si è sicuri che i dati sono appena arrivati e sono pronti per l uso. In una comunicazione non bloccante, mittente e destinatario ritornano immediatamente senza nessuna informazione sulla soddisfacibilità dei criteri di completamento dell operazione. Questo tipo di comunicazione ha il vantaggio che il processore è libero di effettuare altre operazioni. Ad esempio, in una comunicazione sincrona non bloccante, il mittente ritorna immediatamente sebbene non abbia aspettato che gli venisse notificata la ricezione del messaggio; prima che la procedura sia terminata, non si può assumere che il messaggio sia stato ricevuto o che la variabile inviata sia stata sovrascritta. 108

114 PCP LABORATORIO 2 MESSAGE PASSING INTERFACE In una comunicazione sincrona, il mittente termina quando gli viene confermato, attraverso un ack, che il messaggio è stato ricevuto dal destinatario. In una comunicazione con buffer, il mittente termina quando i dati in uscita sono stati copiati in un buffer locale; questo non ci assicura che i dati siano giunti al destinatario. In ogni caso, il completamento di un invio implica che è possibile sovrascrivere la memoria dove i dati erano originariamente memorizzati. MPI communicator e collective communication Un communicator specifica un gruppo di processi a cui è permesso comunicare con altri processi ed ogni messaggio deve specificare il communicator a cui è riferito (bisogna specificare un name come parametro alla chiamata MPI). Di default, tutti i processi vengono definiti come membri del communicator MPI_COMM_WORLD. La routine collective comunication trasmette dati tra tutti i processi del gruppo e permette a gruppi di grosse dimensioni di comunicare in vari modi (tutte le comunicazioni sono bloccanti). Ci sono tre tipi di base di comunicazione collettiva in MPI: Synchronization: ogni processo aspetta che tutti gli altri processi del gruppo abbiano raggiunto lo specifico punto di sincronizzazione. Data movement: i dati vengono trasferiti a tutti i processi appartenenti al gruppo. Collective computation: un processo di un gruppo colleziona i dati dagli altri processi del gruppo ed effettua un operazione sui dati (addizione, moltiplicazione, ecc). In alcuni casi, però, accade che un processore non può effettuare la propria esecuzione fino a quando gli altri processori non hanno completato la loro operazione corrente; questo concetto è indicato come barrier: int MPI_Barrier(MPI_Comm comm); Questo metodo blocca il processo chiamante fino a quando tutti i processi del gruppo abbiano chiamato la funzione; quando ritorna implica che tutti i processi sono sincronizzati alla barriera. È da notare che la funzione MPI_Barrier sincronizza i processi ma non trasporta dati. Poiché causa un notevole overhead, bisogna usare MPI_Barrier solo se strettamente necessario. Esempi di computazione collettiva: operazioni di broadcast: un singolo processo invia una copia degli stessi dati a tutti gli altri processi del gruppo. Ogni riga della figura rappresenta un processo differente. Ogni blocco colorato in una colonna rappresenta un pezzo di dati. Blocchi con lo stesso colore che sono disposti su più processori, contengono copie degli stessi dati: int MPI_Bcast(void* buffer, int count, MPI_Datatype datatype, int root, MPI_Comm comm); operazioni di diffusione (scatter) e raccolta (gather): nell operazione di scatter, i dati sono array dello stesso tipo. L array, inizialmente, è su un solo processore e dopo l operazione di scatter, pezzi di dati vengono distribuiti su processori differenti: int MPI_Scatter(void* sendbuf, int sendcount, MPI_Datatype sendtype, void* recvbuf, int recvcount, MPI_Datatype recvtype, int root, MPI_Comm comm); L operazione di gather opera nel modo inverso, colleziona pezzi di dati su un singolo nodo: int MPI_Gather(void* sendbuf, int sendcount, 109

115 PCP LABORATORIO 2 MESSAGE PASSING INTERFACE MPI_Datatype sendtype, void* recvbuf, int recvcount, MPI_Datatype recvtype, int root, MPI_Comm comm); operazioni di riduzioni: è un operazione collettiva nel quale un singolo processo (il processo root) colleziona dati di altri processi di un gruppo e organizza l operazione sui dati producendo un singolo valore (generalmente operazioni aritmetiche). int MPI_Reduce(void* sendbuf, void* recvbuf, int count, MPI_Datatype datatype, MPI_Op op, int root, MPI_Comm comm); Le routine di comunicazione collettiva possono essere realizzate tramite comunicazione punto-punto. La comunicazione collettiva è più efficiente perché riduce significativamente la possibilità di errore. Gruppo di processi Un gruppo di processi è semplicemente un insieme ordinato di processi, nel quale ognuno di essi ha un valore intero unico chiamato rank del processo (riferito anche come ID del processo). Il rank in un gruppo parte da 0 e arriva ad n-1 dove n è il numero totale dei processi che appartengono al gruppo. Il numero di processi in un programma restano fissi durante l esecuzione ma gruppi e communicator possono essere creati ed eliminati dinamicamente durante l esecuzione del programma. In più, un determinato programma 110

116 PCP LABORATORIO 2 MESSAGE PASSING INTERFACE può essere membro di più gruppi o communicator e avrà un unico rank in ogni gruppo o communicator. Un gruppo è spesso associato con un oggetto communicator. Lo scopo principale di un gruppo e degli oggetti communicator è quello di: 1. Permette di organizzare task, basati su funzioni, in gruppi di task. 2. Permette operazioni di comunicazione collettiva attraverso un sottoinsieme di task connessi. 3. Permette comunicazione sicura. 4. Fornisce le basi per le implementazioni di tipologie virtuali definite dall utente. Esempio Attraverso la funzione MPI_Group_incl permette di creare sottogruppi a partire da quello principale. Fatto ciò, è possibile creare un nuovo communicator per il nuovo gruppo attraverso la funzione MPI_Comm_create e tramite la funzione MPI_Comm_rank si ottiene il rank del nuovo communicator. Ora è possibile scambiarsi messaggi ed una volta che il lavoro è terminato, tramite le funzioni MPI_Comm_free e MPI_Group_free è possibile liberare il gruppo e il communicator. Topologie Una topologia è un meccanismo per associare diversi schemi di identificazione con il processo appartenente ad un particolare gruppo. Descrive un mapping o un ordine di processi all interno delle forme geometriche. Sono supportate 2 topologie: 1. topologia cartesiana o grid; 2. topologia a grafi. Le topologie virtuali possono essere utili per applicazioni che hanno un determinato pattern di comunicazione. Alcune architetture hardware possono essere penalizzanti se la comunicazione avviene tra nodi troppo distanti. Una particolare implementazione può ottimizzare il mapping dei processi in base alle caratteristiche fisiche delle macchine parallele. È da ricordare che un mapping di processi in una topologia virtuale dipende dall implementazione e può essere totalmente ignorata. Vi sono diverse routine che possono essere utilizzate per gestire ed interrogare lo stato dell ambiente. Vengono usate per diverse ragioni: 1. Creare e terminare l esecuzione. 2. Terminare tutti i processi appartenenti ad un determinato communicator. 3. Determinare il numero di processi appartenenti ad un determinato communicator. 4. Determinare il rank dei processi chiamanti all interno di un determinato communicator. 111

117 PCP LABORATORIO 3 COMUNICAZIONE PUNTO-PUNTO LABORATORIO 3 COMUNICAZIONE PUNTO-PUNTO Questo tipo di comunicazione migliora le prestazioni. Può essere di tre tipi: 1. Bloccante e non bloccante 2. Di invio 3. Di ricezione Lo scopo principale della programmazione parallela è di migliorare le performance rispetto a quella seriale. Per realizzare ciò, però, bisogna tener presente diverse problematiche quali la decomposizione del problema, bilanciamento del carico, tempo di esecuzione, tempo di attesa di un processo. Un primo step durante la progettazione di programmi paralleli è quello di separare la parte computazionale da quella di input dei dati in sottoproblemi individuali, in modo da poter assegnare ogni sottoproblema ad un processo separato. Questa fase di decomposizione del problema è chiamata problem decomposition. Essenzialmente, vi sono due tipologie di decomposizioni e la migliore dipende dell applicazione: Domain decomposizion Functional decomposition. Il bilanciamento del carico Il bilanciamento del carico divide il carico richiesto equamente tra i processi disponibili; questo ci assicura che uno o più processi non rimangano in attesa mentre altri processi effettuano il lavoro che gli è stato assegnato, in modo tale da non sprecare risorse. Può essere semplice quando le stesse operazioni sono performanti se eseguite da tutti i processi su differenti insiemi di dati. Quando vi sono grosse differenze sulla durata dei processi, potremmo aver bisogno di adottare una strategia alternativa per risolvere il nostro problema. Il tempo totale di esecuzione è un concetto fondamentale nella programmazione parallela perché permette di paragonare e migliorare i nostri programmi. Vi sono tre componenti che costituiscono il tempo di esecuzione: 1. Tempo di computazione: è il tempo impiegato per elaborare i dati. Sfortunatamente, non tutto il tempo del processore viene impiegato per l elaborazione. 2. Tempo di attesa: è il tempo che il processo impiega mentre aspetta i dati da un altro processo. Durante questo periodo di tempo, il processore non lavora. 3. Tempo di comunicazione: è il tempo impiegato dai processi per inviare e ricevere i messaggi. Il costo di comunicazione durante il tempo di esecuzione può essere misurato in termini di latenza e larghezza di banda. È da ricordare, che se vengono effettuati dei paragoni con dei programmi seriali, quest ultimi non necessitano di meccanismi di comunicazione interprocessi. 112

118 PCP LABORATORIO 3 COMUNICAZIONE PUNTO-PUNTO Comunicazione point-to-point Le operazioni MPI point-to-point tipicamente sono rappresentate da messaggi scambiati tra due e soltanto due task differenti: un task esegue operazioni di send e l altro esegue la rispettiva operazione di receive. In un mondo perfetto, ogni operazione di send dovrebbe essere perfettamente sincronizzata con la rispettiva operazione di receive. Nella realtà questo è un fenomeno raro. L implementazione di MPI deve esere capace di gestire la memorizzazione dei dati quando i due task non sono sincronizzati. Qualsiasi tipologia di routine di send può essere accoppiata con una qualsiasi routine di receive. MPI ha delle routine che controllano lo stato di send e receive in modo tale da aspettare che un messaggio arrivi o di cercare di accorgersi se un messaggio è arrivato. Consideriamo i seguenti casi: 1) Un operazione di send si verifica 5 secondi prima che il receive sia pronto: dove è il messaggio mentre il destinatario è in attesa? 2) Più messaggi arrivano allo stesso task destinatario che può accettarne solo uno per volta: cosa accade agli altri messaggi? L implementazione di MPI (non lo standard) decide cosa fare in questi casi: tipicamente viene messo a disposizione un sistema di buffer in modo da memorizzare i dati della comunicazione. Lo spazio del buffer risulta: non trasparente al programmatore e la gestione avviene interamente tramite le librerie MPI; con risorse finite che possono facilmente esaurirsi; a volte misterioso e non ben documentato; posizionato lato mittente, lato destinatario e al centro. La maggior parte delle routine di MPI point-to-point possono essere usate in modo bloccante o non bloccante. Nel primo caso, può ritornare soltanto quando è sicuro di poter modificare il buffer dell applicazione (i dati inviati) per poterli riusare: sicuro significa che la modifica non avrà effetto sul task destinatario. Questo non implica che i dati siano stati effettivamente ricevuti. Un invio bloccante può essere sincronizzato se vi è una fase di handshaking, il destinatario conferma la corretta ricezione, come può essere non sincronizzato se viene utilizzato un buffer per memorizzare i dati prima che vengano consegnati al destinatario. Una ricezione bloccante può solo ritornare dopo che i dati sono arrivati e sono pronti per essere utilizzati. Un invio ed una ricezione non bloccante sono simili, ritornano quasi immediatamente, non aspettano che nessun evento di comunicazione sia completato. Un operazione non bloccante semplicemente richiede di eseguire l operazione quando è possibile, l utente non sa quando ciò avviene. 113

119 PCP LABORATORIO 3 COMUNICAZIONE PUNTO-PUNTO MPI garantisce che i messaggi arrivino a destinazione nello stesso ordine con cui sono stati inviati. Se il mittente invia due messaggi, uno dopo l altro, allo stesso destinatario, l operazione di ricezione riceverà prima il messaggio 1 e poi il 2. Se il destinatario pubblica due receive una dopo l altra ed entrambe aspettano lo stesso messaggio, receive 1 riceverà il messaggio prima di receive 2. Questo non accade però se vi sono più thread che partecipano alla comunicazione. Fairness MPI non garantisce la fairenss (imparzialità): dipende dal programmatore prevenire l operazione di starvation. Ad esempio, il task 0 invia un messaggio al task 2. Nello stesso istante, il task 1 invia un messaggio al task 2. In questo caso solo un mittente sarà servito. La comunicazione point-to-point generalmente ha una lista di argomenti in base alla sua natura: Blocking sends MPI_Send(buffer,count,type,dest,tag,comm) Non-blocking sends MPI_Isend(buffer,count,type,dest,tag,comm,request) Blocking receive MPI_Recv(buffer,count,type,source,tag,comm,status) Non-blocking receive MPI_Irecv(buffer,count,type,source,tag,comm,request) Nell invio e ricezione non bloccante: il buffer è il puntatore ai dati che bisogna inviare o ricevere. Il campo count indica il numero di elementi di dati di un particolare tipo che bisogna inviare; type può essere MPI_INT, MPI_FLOAT, MPI_DOUBLE, etc.; dest indica il rank del processo ricevente; source specifica da chi si vuole ricevere i messaggi: con MPI_ANY_SOURCE si sceglie di ricevere messaggi da qualsiasi task; tag è un intero arbitrario non negativo assegnato dal programmatore per identificare univocamente un messaggio. Le operazioni di send e receive devono avere per un messaggio lo stesso tag. Per un operazione di receive, il valore MPI_ANY_TAG può essere usato per ricevere qualsiasi messaggio con qualsiasi tag. comm indica il contesto di communication oppure l insieme dei processi nel quale i campi sorgente e destinazione sono validi. Generalmente è usato il valore MPI_COMM_WORLD request è un puntatore a una struttura predefinita MPI_Request usata per determinare il completamento di un operazione non bloccante. Ricezione bloccante: status è un puntatore ad una struttura predefinita MPI_Status la quale contiene informazioni quali: MPI_SOURCE, MPI_TAG. È possibile risalire al numero di bytes ricevuti tramite la routine MPI_Get_count. Esempio di comunicazione point to point: 114

120 PCP LABORATORIO 3 COMUNICAZIONE PUNTO-PUNTO #include <mpi.h> #include <stdio.h> #define BLOCK_SIZE int main (int argc, char **argv) { int myrank, mytag = 1337; MPI_Status status; MPI_Init(&argc, &argv); MPI_Comm_rank(MPI_COMM_WORLD, &myrank); if( myrank == 0 ) { double a[block_size]; MPI_Send( a, BLOCK_SIZE, MPI_DOUBLE, 1, mytag,mpi_comm_world ); } else { double b[block_size]; MPI_Recv( b, BLOCK_SIZE, MPI_DOUBLE, 0, mytag,mpi_comm_world, &status ); } MPI_Finalize(); return 0; } MPI esegue lo stesso codice su ogni client. Il modo in cui si distingue il master dallo slave è usando qualche calcolo sul rank del processo (generalmente il master è il processo 0). Quando due o più processi sono bloccati e ognuno aspetta un altro per fare un progresso, incorre il deadlock. Generalmente, evitare il deadlock richiede un accurata organizzazione della comunicazione in un programma. Un programmatore deve essere in grado di capire se il proprio programma può andare in deadlock o meno. In questo esempio, la comunicazione è organizzata in modo migliore e il programma non va in deadlock. 115

121 PCP LABORATORIO 3 COMUNICAZIONE PUNTO-PUNTO In questo esempio il processo 1 aspetta di scambiare messaggi con il processo 1 (entrambi i processi inviano prima e poi ricevono). L unica differenza significativa tra i programmi precedenti è la dimensione del messaggio. Le routine nonblocking sono facilmente prive di codice con deadlock. Questo è un grosso svantaggio perché è facile scrivere non intenzionalmente codice con deadlock. Messaggi di send e receive L interfaccia non bloccante della send e della receive richiede due chiamate per l operazione di comunicazione: una chiamata per iniziare l operazione (chiamata posting a send) ed una seconda quando è completata (chiamata posting a receive). Una volta che le operazioni di send e receive sono state inoltrate, MPI ha due modi per completarle: 1. Un processo può verificare se l operazione è completata senza blocco; 2. alternativamente, un processo può aspettare il completamento dell operazione. Se la send e la receive sono inoltrate da una routine non bloccante, dopo il loro completamento possono essere verificate invocando una delle famiglie di routine di completamento (per le routine bloccanti sono MPI_Wait mentre per quelle non bloccanti MPI_Test). Waits for a nonblocking operation to complete Waits for any specified nonblocking operation to complete Waits for a collection of nonblocking operations to complete Waits for at least one of a list of nonblocking operations to complete MPI_Wait(request, status) MPI_Waitany(count, array_of_requests, index, status) MPI_Waitall(count, array_of_requests, array_of_statuses) MPI_Waitsome(incount, array_of_requests, outcount, array_of_indices,array_of_statuses) incount è la lunghezza dell array delle richieste, array degli indici, array degli stati; array_of_request è un array di richieste (array di MPI_Request); outcount è il numero di richieste completate (intero); array_of_indices è l array degli indici delle operazioni completate (array di interi); array_of_statuses è l array degli stati degli oggetti per le operazioni completate (array di MPI_Status) 116

122 PCP LABORATORIO 3 COMUNICAZIONE PUNTO-PUNTO Checks to see if a nonblocking request has completed Tests for the completion of any nonblocking operation Tests a collection of nonblocking operations for Completion Tests a collection of nonblocking operations for Completion MPI_Test(request, flag, status) MPI_Testany(count, array_of_requests, index, flag, status) MPI_Testall(count, array_of_requests, flag, array_of_statuses) MPI_Testsome(incount, array_of_requests, outcount, array_of_offsets, array_of_statuses) Quando usare Test e quando Wait? Test può essere usata quando i tempi di computazione e di comunicazione si accavallano. Wait deve essere usata quando la computazione non può continuare prima che la comunicazione non è completata. La latenza tende ad allargarsi su una collezione distribuita di host. MPI_IRecv(...,request)... arrived = FALSE while (arrived == FALSE) { /* work planned for processor to do while waiting for message data */ MPI_Test(request,arrived,status) } /* work planned for processor to do with the message data */ Lo pseudocodice sopra riportato è un template per una coda che usa una receive non bloccante per non creare latenza. Il processo di destinazione esegue il lavoro nel ciclo mentre il messaggio coi dati è ancora in transito. Una volta che è arrivato, può utilizzare i dati. Usando una send e una receive non bloccante si incrementa la complessità del codice. Send In MPI ci sono 4 modi per inviare ma uno solo per ricevere: 1. Standard 2. Synchronous 3. Ready 4. Buffered 117

123 PCP LABORATORIO 3 COMUNICAZIONE PUNTO-PUNTO Un processo che riceve può usare la stessa chiamata per MPI_RECV o MPI_IRECVm indifferentemente dal modo di invio. Il modo standard è quello ampiamente usato. Ognuna delle 4 metodologie può utilizzare routine bloccanti e non bloccanti Send Mode Blocking Function Nonblocking Function Standard MPI_SEND MPI_ISEND Synchronous MPI_SSEND MPI_ISSEND Ready MPI_RSEND MPI_IRSEND Buffered MPI_BSEND MPI_IBSEND Quando si utilizza lo standard mode, può accadere: 1. Il messaggio è copiato dentro un buffer interno ed è trasferito in modo asincrono al processo destinatario 2. Il mittente ed il destinatario si sincronizzano sul messaggio L implementazione di MPI può scegliere tra buffering e sincronizzazione, in funzione della dimensione del messaggio, ecc. Se il messaggio viene copiato in un buffer interno, l operazione di send è ufficialmente completata appena la copia termina. Se i processi sono sincronizzati, l operazione è ufficialmente completata solo quando il processo ricevente ha dichiarato di aver ricevuto correttamente il messaggio. Un vantaggio della tecnica standard è quella di scegliere tra buffering e sincronizzazione. Generalmente, MPI ha una chiara visione dei tradeoff, specialmente dalle risorse a basso livello a quelle interne. Quando si utilizza la tecnica synchronize e l operazione di send è completata, il processo mittente deve assumere che il processo destinatario ha ricevuto il messaggio. Il processo destinatario deve aver iniziato la ricezione del messaggio. Nel ready mode si richiede che il destinatario, prima di chiamare il ready mode, posti quello che ha ricevuto. Nel caso in cui quello che si riceve non è lo stesso di quello che è stato inviato, è cura del programmatore gestire il problema. Nel buffered mode MPI richiede espressamente di utilizzare il buffer ed è cura del programmatore la gestione del buffer. Game of life È una simulazione sviluppata da John Conway. Il dominio è un array bidimensionale ed in ogni elemento può avere uno o due possibili stati, generalmente indicati con alive o dead, inizializzato usando numeri casuali. Vi sono 3 regole: 1. Se la cella ha 3 vicini alive, la cella diventerà alive 2. Se una cella ha 2 vicini alive, non cambia stato 3. In tutti gli altri casi è dead Inizialmente, le celle adiacenti sono richieste per determinare il nuovo stato di ogni cella, ma bisogna fare una considerazione: le celle sul bordo sinistro non hanno celle alla loro sinistra, per questo sono i bordi del dominio. Bisogna ripetere infinitamente i 3 passi in tutte le direzioni. 118

124 PCP LABORATORIO 3 COMUNICAZIONE PUNTO-PUNTO Game of life è un algoritmo di convergenza che calcola il numero di celle alive in una matrice. Per poter realizzare la versione parallela, bisogna effettuare una decomposizione del dominio problema. Il dominio viene diviso con una linea orizzontale in modo tale che la parte superiore venga processata da un processore e quella inferiore da un altro. Bisogna ricordare che ogni cella ha la necessità di avere informazioni dai blocchi adiacenti. Useremo send e receive bloccanti poiché per andare avanti, i nodi sono vincolati dal valore dei vicini (valori precedenti). Una tecnica comune per risolvere questo problema è quella delle celle fantasma. Una riga di celle fantasma è aggiunta sul lato inferiore del dominio superiore e sul lato superiore del dominio inferiore (per le colonne non c è problema in quanto considerato un toroide). Ad ogni passo nelle celle fantasma vengono inserite le informazioni provenienti dall altro processore. Per implementare le celle ghost, useremo due copie della matrice: una per il passo precedente e l altra per il passo successivo

125 PCP LABORATORIO 4 - COMMUNICATING NON CONTIGUOUS DATA OR MIXED DATATYPES LABORATORIO 4 - COMMUNICATING NON CONTIGUOUS DATA OR MIXED DATATYPES Fino ad oggi abbiamo visto invio e ricezione di dati contigui. Nonostante sia abbastanza comune, può anche non essere cosi. Possiamo trovarci in situazioni in cui i dati oltre ad essere non contigui, sono anche di tipo diverso. Le diverse situazioni in cui possiamo trovarci sono: dati contigui e dello stesso tipo; dati non contigui dello stesso tipo; dati contigui di tipo diverso; dati non contigui di tipo diverso. L approccio più semplice per la comunicazione di dati di diverso tipo e non contigui è di individuare i pezzi più grandi, contigui e omogenei ed inviare ogni pezzo con un messaggio separato. Per esempio, l invio di una sottomatrice tramite n messaggi diversi: Questa è un ottima soluzione solo che introduce un elevato overhead dovuto alle numerose send e receive utilizzate e per questo motivo deve essere scartata. Un altra soluzione è quella di creare un solo messaggio di grandi dimensioni contenente le diverse sottoparti. Tecniche di buffering Quando i dati da inviare sono non contigui, un approccio è quello di utilizzare un buffer (eliminando così i numerosi messaggi) e copiare in contenuto nel buffer contiguo. Nel caso in cui in cui vogliamo convertire anche il tipo, sono necessari diversi cicli di CPU. Questo può portare ad averi seri problemi in caso di utilizzo di macchine eterogenee (specialmente se big-endian/littleendian). MPI invia valori, non bit e per fare ciò trasforma i dati nei tipi standard MPI. L uso migliore di un buffer è tramite l uso di MPI_Pack che provvede a raggruppare in un unico buffer i dati e poi provvede quindi ad effettuare l invio del buffer di tipo MPI_PACKED. 120

126 PCP LABORATORIO 4 - COMMUNICATING NON CONTIGUOUS DATA OR MIXED DATATYPES Lato ricevente, una volta avuto il messaggio bisogna spacchettarlo tramite MPI_Unpack. Sotto, vediamo un esempio di invio di una sottomatrice. MPI ha un approccio più efficiente per la manipolazione di dati di diverso tipo e non contigui: derived datatypes (simile all operazione di packing e unpacking al volo). Prima di utilizzare un derived datatype bisogna: costruire il datatype; allocare il datatype; usare il datatype; 121

127 PCP LABORATORIO 4 - COMMUNICATING NON CONTIGUOUS DATA OR MIXED DATATYPES deallocare il datatype. Ci sono diverse routines in MPI per gestire i datatype: MPI_Type_contiguous: è il più semplice costruttore e produce un nuovo datatype creando una copia del tipo esistente dei dati. MPI_Type_vector: simile a quello contiguo ma permette intervalli regolari negli spostamenti. MPI_Type_indexed: viene messo a disposizione un array di displacement dei tipi di dati in input come mappa per i nuovi tipi di dati. MPI_Type_struct: è il costruttore più generale; il nuovo tipo di dati è formato in accordo ad una mappa completamente definita dei tipi di dati dei componenti. Un constructor data type deve essere sottomesso al sistema prima di poter essere utilizzato in una comunicazione tramite la funzione MPI_Type_commit. Al termine, bisogna liberare le risorse tramite MPI_Type_free. Esempio Inviare M righe, ognuna contenente N valori: 122

128 PCP LABORATORIO 4 - COMMUNICATING NON CONTIGUOUS DATA OR MIXED DATATYPES In questo esempio, stiamo creando un nuovo tipo chiamato my_mpi_type. Contiene N blocchi di dati, ognuno contente M MPI_DOUBLE con uno stride di dimensione MM tra ogni blocco. La chiamata MPI_Type_commit rende disponibile il nuovo data type per la comunicazione. Ricapitolando, i datatype sono un modo molto elegante e portabile per la comunicazione di dati non contigui e di tipo diverso. Forniscono tecniche efficienti di invio e ricezione di dati tra vari processori. 123

129 PCP LABORATORIO 5 COLLECTIVE COMMUNICATIONS LABORATORIO 5 COLLECTIVE COMMUNICATIONS Le comunicazioni collective riguardano l invio e la ricezione di dati fra un gruppo o gruppi di processi. Alcune sequenze di operazioni di comunicazione sono così comuni che MPI fornisce un insieme di routine per gestire tali comunicazioni collettive. Le funzioni messe a disposizione da MPI sono: MPI_BARRIER: barriera sincronizzata che riguarda tutti i membri del gruppo. MPI_BCAST: broadcast da un membro del gruppo a tutti gli altri. MPI_GATHER, MPI_GATHERV: accumula dati da tutti I membri di un gruppo in un solo membro. MPI_SCATTER, MPI_SCATTERV: diffonde dati da un membro a tutti gli altri del gruppo. MPI_ALLGATHER, MPI_ALLGATHERV: una variante del gather dove tutti i membri del gruppo ricevono il risultato. MPI_ALLTOALL, MPI_ALLTOALLV, MPI_ALLTOALLW: diffonde/accumula dati da tutti i membri a tutti i membri del gruppo (conosciuta anche come scambio completo) MPI_ALLREDUCE, MPI_REDUCE: operazione globale di riduzione come somma, massimo, minimo, o funzioni definite dall utente dove il risultato viene restituito a tutti i membri del gruppo oppure ad un solo membro. MPI_REDUCE_SCATTER: una combinazione di riduzione e diffusione. MPI_SCAN, MPI_EXSCAN: scansione su tutti i membri del gruppo. 124

130 PCP LABORATORIO 5 COLLECTIVE COMMUNICATIONS Un argomento chiave delle collective è il communicator che definisce i partecipanti del gruppo e fornisce il contesto alle operazioni. Alcuni argomenti delle collective sono specificate solo per il processo root e vengono ignorate dagli altri partecipanti. Le condizioni sui tipi per queste operazioni sono più ristrette rispetto alla comunicazione punto-punto. Le collective possono usare gli stessi communicators della comunicazione punto-punto; MPI garantisce che il messaggio generato con le collective non verrà confuso con quello generato dalla comunicazione punto punto. Lo scopo delle collective è quello di trasferire dati tra processi in un gruppo. È da sottolineare che non usano il meccanismo del TAG ma le send e receive sono associate all ordine di esecuzione del programma e per questo bisogna assicurarsi che ogni processo esegua una data chiamata alla comunicazione collective. Il passaggio dei dati può avvenire tra tutti i processi o tra un sottoinsieme di processi (basta definire il proprio communicator che provvede alla comunicazione tra il sottoinsieme di processi). In alcuni casi, vi sono processi che non possono continuare l esecuzione fino a quando altri processi non hanno terminato il loro lavoro, ad esempio, quando il processo root deve leggere dei dati da I/O e comunicarli agli altri processi (gli altri processi devono aspettare che la procedura di IO sia terminata). La comunicazione collective si basa su un gruppo o su gruppi di processi. Esistono due tipi di communicators: intra-communicators (un gruppo di processi linkati con un contesto) ed intercommunicators (identifica due gruppi distinti di processi linkati con un contesto). Barrier int MPI_Barrier(MPI_Comm comm): MPI blocca il chiamante fino a quando tutti i membri del gruppo non hanno invocato la funzione. Quando MPI_Barrier ritorna, tutti i processi sono sincronizzati con la barriera. NB. MPI sincronizza i processi ma non trasferisce dati. La barriera è un software e su alcune macchine può introdurre un overhead notevole; per questo motivo bisogna inserirle solo quando strettamente necessario. BCast Questa routine permette di copiare dati dal processo root agli altri processi del communicator. int MPI_Bcast(void* buffer, int count, MPI_Datatype datatype, int root, MPI_Comm comm) INOUT buffer starting address of buffer (choice) IN count number of entries in buffer (non-negative integer) IN datatype data type of buffer (handle) IN root rank of broadcast root (integer) IN comm communicator (handle) Se comm è di tipo intracommunicator, il messaggio sarà inviato a tutto il gruppo con rank uguale a quello del processo root. Il buffer del processo root sarà copiato nel buffer di tutti i processi. Ad esempio: #include <mpi.h> void main(int argc, char *argv[]) { int rank, param; MPI_Init(&argc, &argv); MPI_Comm_rank(MPI_COMM_WORLD,&rank); param = rank; printf("p:%d before broadcast parameter is %d\n",rank,param); MPI_Bcast(&param, 1, MPI_INT, 3, MPI_COMM_WORLD); printf("p:%d after broadcast parameter is %d\n",rank,param); MPI_Finalize(); } 125

131 PCP LABORATORIO 5 COLLECTIVE COMMUNICATIONS Reduce Questa procedura prende i dati da ogni processo e riduce i dati in un singolo valore (come ad esempio somma, media, ecc). int MPI_Reduce(void *sendbuf, void *recvbuf, int count, MPI_Datatype datatype, MPI_Op op, int root, MPI_Comm comm); IN sendbuf address of send buffer (choice) OUT recvbuf address of receive buffer (choice, significant only at root) IN count number of elements in send buffer (integer) IN datatype data type of elements of send buffer (handle) IN op reduce operation (handle) IN root rank of root process (integer) IN comm communicator (handle) #include <mpi.h> void main(int argc, char *argv[]) { int rank; int source, result; MPI_Init(&argc, &argv); MPI_Comm_rank(MPI_COMM_WORLD,&rank); source = rank + 1; result = 0; MPI_Barrier(MPI_COMM_WORLD); MPI_Reduce(&source, &result, 1, MPI_INT, MPI_SUM, 0, MPI_COMM_WORLD); } if (rank == 0) printf("the result is %d \n", result); MPI_Finalize(); 126

132 PCP LABORATORIO 5 COLLECTIVE COMMUNICATIONS Esecuzione con 5 processi, il risultato è 15 (il processo 0 ha source=1, il processo 1 ha source=2 e così via). Il risultato viene memorizzato nella variabile result. Gather È una routine di tipo all-to-one. int MPI_Gather(void* sendbuf, int sendcount, MPI_Datatype sendtype, void* recvbuf, int recvcount, MPI_Datatype recvtype, int root, MPI_Comm comm); IN sendbuf starting address of send buffer (choice) IN sendcount number of elements in send buer (non-negative inte-ger) IN sendtype data type of send buffer elements (handle) OUT recvbuf address of receive buffer (choice, signi cant only at root) IN recvcount number of elements for any single receive (non-negative integer, signi cant only at root) IN recvtype data type of recv buffer elements (signi cant only at root) IN root rank of receiving process (integer) IN comm. communicator (handle) Ogni processo (incluso il root) invia il contenuto del suo buffer al processo root, il quale riceve i dati ordinandoli in base al rank #include "mpi.h" void main(int argc, char *argv[]) { int rank,size; double *rcvbuf, sendbuf; int sndcnt, rcvcnt; int i, root; MPI_Init(&argc, &argv); MPI_Comm_rank(MPI_COMM_WORLD, &rank); MPI_Comm_size(MPI_COMM_WORLD, &size); root = 0; //inseriamo nel buffer di invio il valore 23 + il rank di ogni processo sendbuf = rank; sndcnt = 1; rcvcnt = 1; if (rank == root) rcvbuf = malloc(size * sizeof(double)); MPI_Gather(&sendbuf, sndcnt, MPI_DOUBLE, rcvbuf, rcvcnt, MPI_DOUBLE, 0, MPI_COMM_WORLD); if (rank == root) for (i = 0; i < size; ++i) printf("pe:%d param[%d] is %f \n", rank, i, rcvbuf[i]); MPI_Finalize(); } In questo caso, l output provvederà a stampare il valore dell array rcvbuf di tipo double (in ogni posizione vi è il valore del processo i-esimo ossia 23+rank i-esimo). 127

133 PCP LABORATORIO 5 COLLECTIVE COMMUNICATIONS Scatter Questa routine provvede ad inviare dati differenti dal processo root ad ogni altro processo. int MPI_Scatter (void *sendbuf, int sendcnt, MPI_Datatype sendtype, void *recvbuf, int recvcnt, MPI_Datatype recvtype, int root, MPI_Comm comm); IN sendbuf address of send buffer (choice, significant only at root) IN sendcount number of elements sent to each process (integer, significant only at root) IN sendtype data type of send buffer elements (significant only at root) (handle) OUT recvbuf address of receive buffer (choice) IN recvcount number of elements in receive buffer (integer) IN recvtype data type of receive buffer elements (handle) IN root rank of sending process (integer) IN comm. communicator (handle) #include "mpi.h" void main(int argc, char *argv[]) { int rank, size, i, root; double *sendbuf, recvbuf; int sndcnt, rcvcnt; MPI_Init(&argc, &argv); MPI_Comm_rank(MPI_COMM_WORLD,&rank); MPI_Comm_size(MPI_COMM_WORLD,&size); root = 0; if (rank == root) { //se il processo root sendbuf = malloc(size * sizeof(double)); //nell'array root inizializzo le n-size celle al valore 23+i for (i = 0; i < size; ++i) sendbuf[i] = i; sndcnt = 1; recvbuf = 0.0; rcvcnt = 0; } else { sendbuf = NULL; sndcnt = 0; recvbuf = 0.0; rcvcnt = 1; } MPI_Scatter( sendbuf, sndcnt, MPI_DOUBLE, &recvbuf, rcvcnt, MPI_DOUBLE, root, MPI_COMM_WORLD); for(i = 0; i < size; ++i) { //ogni processo stampa il proprio valore if (rank == i) printf("p:%d mine is %f \n", rank, recvbuf); fflush(stdout); MPI_Barrier(MPI_COMM_WORLD); } MPI_Finalize(); } 128

134 PCP LABORATORIO 6 - VIRTUAL TOPOLOGIES AND COMMUNICATORS LABORATORIO 6 - VIRTUAL TOPOLOGIES AND COMMUNICATORS Molti problemi computazionali scientifici effettuano operazioni su matrici di grosse dimensioni. Per questo motivo, le matrici vengono partizionate oppure divise in sottodomini in cui ognuna viene assegnata ad un singolo processo. Vi sono situazioni in cui è molto semplice identificare il punto di divisione della matrice dove è possibile numerare gli elementi dall alta verso il basso come nella figura successiva. Un altro esempio invece è nella divisione in sottomatrici che a martire da una matrice pxq si arriva ad avere matrici rxs. Nel secondo esempio, si è voluto dividere in questo modo la matrice per avere una facilità di esecuzione e una migliore chiarezza del codice. Siamo pronti a scrivere codice parallelo per una decomposizione in sottodomini, comunicando tramite le routine send e receive e sappiamo usare e datatype per le comunicazioni non contigue dei dati. I tipi di dati derivati (a sinistra) vengono utilizzati per inviare dati non contigui, offrono anche la possibilità di inviare sottomatrici di dati presi da una grande matrice. Le topologie virtuali (a destra) sono utilizzati per organizzare i processori; possono essere utilizzati per organizzare una matrice di processori. Per organizzare un nuovo layout topologico, MPI fornisce due tipi topologici: grafi e cartesiani. La routine MPI_Cart_create crea un nuovo communicator usando la topologia cartesiana. Rinomina il rank dei processori in una griglia topologica n-dimensionale. int MPI_Cart_create(MPI_Comm old_comm, int ndims, int *dim_size, int *periods, int reorder, MPI_Comm *new_comm) IN periods Specifica la periodicità di ogni dimensione IN reorder quando MPI permette il riordino del rank dei processi OUT new_comm handle del communicator 129

135 PCP LABORATORIO 6 - VIRTUAL TOPOLOGIES AND COMMUNICATORS Mentre i processi sono disposti logicamente come una topologia cartesiana, i processori corrispondenti possono essere sparsi. Per esempio all interno di una macchina shared-memory, due core condividono la cache e potrebbero essere assegnati a processi logicamente lontani. Se reorder è settato a true, MPI può riordinare il rank dei processori nel nuovo communicator in modo da guadagnare potenza. MPI_Cart_create è una funzione che deve essere chiamata da tutti i processi del gruppo. Il suo uso impone restrizioni sul numero di processori disponibili nel vecchio communicator: Se il numero di processori nella griglia cartesiana è minore dei processi disponibili, questi processi non inclusi nel nuovo communicator ritorneranno MPI_COMM_NULL Se il numero totale della griglia cartesiana è maggiore dei processi disponibili, la chiamata restituirà errore. Questa tecnica aiuta a scrivere codice più leggibile ma bisogna ricordare che molte funzioni MPI riconoscono solo il numero di rank. Per questo motivo, si consiglia di utilizzare le coordinate cartesiane per computazioni molto dispendiose. La funzione MPI_Cart_coords restituisce le corrispondenti coordinate cartesiane del rank. int MPI_Cart_coords( MPI_Comm comm, int rank, int maxdims, int *coords ) comm communicator handle rank rank del processo chiamante maxdims numero delle dimensioni della topologia cartesiana coords coordinate cartesiane corrispondenti del rank Restituisce il processo rank della coordinata cartesiana. int MPI_Cart_rank( MPI_Comm comm, int *coords, int *rank ) comm cords rank communicator handle la coordinate cartesiana il valore del rank del corrispondente processo 130

136 PCP LABORATORIO 6 - VIRTUAL TOPOLOGIES AND COMMUNICATORS Una volta che abbiamo definito la nuova topologia, bisogna stabilire i vicini. MPI_Cart_shift è usato per trovare due vicini del processo chiamante. int MPI_Cart_shift( MPI_Comm comm, int direction, int displ, int *source, int *dest ) comm Communicator handle direction The dimension along which shift is to be in effect displ Amount and sense of shift (<0; >0; or 0) source Where calling process should receive a message from during the shift dest where calling process should send a message to during the shift Communicator Un communicator specifica un gruppo di processi a cui è permesso di comunicare con un altro. MPI fornisce automaticamente un comunicator base chiamato MPI_COMM_WORLD, un communicator di tutti i processi. Usando MPI_COMM_WORLD, ogni processore può comunicare con qualsiasi altro processore. MPI_COMM_SPLIT può essere usato per dividere un communicator in due sotto-communicator. MPI_Comm_split(MPI_Comm comm, int color, int key, MPI_Comm *newcomm); Questa routing partiziona il gruppo comm in gruppi disgiunti, con un sottogruppo per ogni differente valore di color. Tutti i processi che hanno lo stesso color faranno parte dello stesso sub-communicator. Tutti i processi di un dato color saranno ordinati in base al valore key. Se questo valore è lo stesso per due o più processi, questi processi saranno ordinati in base al loro rank del communicator iniziale. 131

I Thread. I Thread. I due processi dovrebbero lavorare sullo stesso testo

I Thread. I Thread. I due processi dovrebbero lavorare sullo stesso testo I Thread 1 Consideriamo due processi che devono lavorare sugli stessi dati. Come possono fare, se ogni processo ha la propria area dati (ossia, gli spazi di indirizzamento dei due processi sono separati)?

Dettagli

Scheduling della CPU. Sistemi multiprocessori e real time Metodi di valutazione Esempi: Solaris 2 Windows 2000 Linux

Scheduling della CPU. Sistemi multiprocessori e real time Metodi di valutazione Esempi: Solaris 2 Windows 2000 Linux Scheduling della CPU Sistemi multiprocessori e real time Metodi di valutazione Esempi: Solaris 2 Windows 2000 Linux Sistemi multiprocessori Fin qui si sono trattati i problemi di scheduling su singola

Dettagli

Introduzione. Classificazione di Flynn... 2 Macchine a pipeline... 3 Macchine vettoriali e Array Processor... 4 Macchine MIMD... 6

Introduzione. Classificazione di Flynn... 2 Macchine a pipeline... 3 Macchine vettoriali e Array Processor... 4 Macchine MIMD... 6 Appunti di Calcolatori Elettronici Esecuzione di istruzioni in parallelo Introduzione... 1 Classificazione di Flynn... 2 Macchine a pipeline... 3 Macchine vettoriali e Array Processor... 4 Macchine MIMD...

Dettagli

Multithreading in Java. Fondamenti di Sistemi Informativi 2014-2015

Multithreading in Java. Fondamenti di Sistemi Informativi 2014-2015 Multithreading in Java Fondamenti di Sistemi Informativi 2014-2015 Multithreading La programmazione concorrente consente di eseguire più processi o thread nello stesso momento. Nel secondo caso si parla

Dettagli

GESTIONE DEI PROCESSI

GESTIONE DEI PROCESSI Sistemi Operativi GESTIONE DEI PROCESSI Processi Concetto di Processo Scheduling di Processi Operazioni su Processi Processi Cooperanti Concetto di Thread Modelli Multithread I thread in Java Concetto

Dettagli

Università di Roma Tor Vergata Corso di Laurea triennale in Informatica Sistemi operativi e reti A.A. 2013-14. Pietro Frasca.

Università di Roma Tor Vergata Corso di Laurea triennale in Informatica Sistemi operativi e reti A.A. 2013-14. Pietro Frasca. Università di Roma Tor Vergata Corso di Laurea triennale in Informatica Sistemi operativi e reti A.A. 2013-14 Pietro Frasca Lezione 11 Martedì 12-11-2013 1 Tecniche di allocazione mediante free list Generalmente,

Dettagli

Il Sistema Operativo. C. Marrocco. Università degli Studi di Cassino

Il Sistema Operativo. C. Marrocco. Università degli Studi di Cassino Il Sistema Operativo Il Sistema Operativo è uno strato software che: opera direttamente sull hardware; isola dai dettagli dell architettura hardware; fornisce un insieme di funzionalità di alto livello.

Dettagli

Coordinazione Distribuita

Coordinazione Distribuita Coordinazione Distribuita Ordinamento degli eventi Mutua esclusione Atomicità Controllo della Concorrenza 21.1 Introduzione Tutte le questioni relative alla concorrenza che si incontrano in sistemi centralizzati,

Dettagli

DTI / ISIN / Titolo principale della presentazione. La cena dei filosofi. Amos Brocco, Ricercatore, DTI / ISIN. 14 maggio 2012

DTI / ISIN / Titolo principale della presentazione. La cena dei filosofi. Amos Brocco, Ricercatore, DTI / ISIN. 14 maggio 2012 DTI / ISIN / Titolo principale della presentazione 1 La cena dei filosofi Amos Brocco, Ricercatore, DTI / ISIN 14 maggio 2012 Il problema dei filosofi a cena Il problema dei filosofi a cena Il problema:

Dettagli

Tipi primitivi. Ad esempio, il codice seguente dichiara una variabile di tipo intero, le assegna il valore 5 e stampa a schermo il suo contenuto:

Tipi primitivi. Ad esempio, il codice seguente dichiara una variabile di tipo intero, le assegna il valore 5 e stampa a schermo il suo contenuto: Tipi primitivi Il linguaggio Java offre alcuni tipi di dato primitivi Una variabile di tipo primitivo può essere utilizzata direttamente. Non è un riferimento e non ha senso tentare di istanziarla mediante

Dettagli

Un sistema operativo è un insieme di programmi che consentono ad un utente di

Un sistema operativo è un insieme di programmi che consentono ad un utente di INTRODUZIONE AI SISTEMI OPERATIVI 1 Alcune definizioni 1 Sistema dedicato: 1 Sistema batch o a lotti: 2 Sistemi time sharing: 2 Sistema multiprogrammato: 3 Processo e programma 3 Risorse: 3 Spazio degli

Dettagli

Sistemi Operativi. Lez. 13: primitive per la concorrenza monitor e messaggi

Sistemi Operativi. Lez. 13: primitive per la concorrenza monitor e messaggi Sistemi Operativi Lez. 13: primitive per la concorrenza monitor e messaggi Osservazioni I semafori sono strumenti particolarmente potenti poiché consentono di risolvere ogni problema di sincronizzazione

Dettagli

Con il termine Sistema operativo si fa riferimento all insieme dei moduli software di un sistema di elaborazione dati dedicati alla sua gestione.

Con il termine Sistema operativo si fa riferimento all insieme dei moduli software di un sistema di elaborazione dati dedicati alla sua gestione. Con il termine Sistema operativo si fa riferimento all insieme dei moduli software di un sistema di elaborazione dati dedicati alla sua gestione. Compito fondamentale di un S.O. è infatti la gestione dell

Dettagli

Pronto Esecuzione Attesa Terminazione

Pronto Esecuzione Attesa Terminazione Definizione Con il termine processo si indica una sequenza di azioni che il processore esegue Il programma invece, è una sequenza di azioni che il processore dovrà eseguire Il processo è quindi un programma

Dettagli

Java threads (2) Programmazione Concorrente

Java threads (2) Programmazione Concorrente Java threads (2) emanuele lattanzi isti information science and technology institute 1/28 Programmazione Concorrente Utilizzo corretto dei thread in Java emanuele lattanzi isti information science and

Dettagli

FONDAMENTI di INFORMATICA L. Mezzalira

FONDAMENTI di INFORMATICA L. Mezzalira FONDAMENTI di INFORMATICA L. Mezzalira Possibili domande 1 --- Caratteristiche delle macchine tipiche dell informatica Componenti hardware del modello funzionale di sistema informatico Componenti software

Dettagli

A intervalli regolari ogni router manda la sua tabella a tutti i vicini, e riceve quelle dei vicini.

A intervalli regolari ogni router manda la sua tabella a tutti i vicini, e riceve quelle dei vicini. Algoritmi di routing dinamici (pag.89) UdA2_L5 Nelle moderne reti si usano algoritmi dinamici, che si adattano automaticamente ai cambiamenti della rete. Questi algoritmi non sono eseguiti solo all'avvio

Dettagli

La Gestione delle risorse Renato Agati

La Gestione delle risorse Renato Agati Renato Agati delle risorse La Gestione Schedulazione dei processi Gestione delle periferiche File system Schedulazione dei processi Mono programmazione Multi programmazione Gestione delle periferiche File

Dettagli

Laboratorio di Informatica

Laboratorio di Informatica per chimica industriale e chimica applicata e ambientale LEZIONE 4 - parte II La memoria 1 La memoriaparametri di caratterizzazione Un dato dispositivo di memoria è caratterizzato da : velocità di accesso,

Dettagli

Java Virtual Machine

Java Virtual Machine Java Virtual Machine programmi sorgente: files.java compilatore files.class bytecode linker/loader bytecode bytecode Java API files.class interprete macchina ospite Indipendenza di java dalla macchina

Dettagli

Testi di Esercizi e Quesiti 1

Testi di Esercizi e Quesiti 1 Architettura degli Elaboratori, 2009-2010 Testi di Esercizi e Quesiti 1 1. Una rete logica ha quattro variabili booleane di ingresso a 0, a 1, b 0, b 1 e due variabili booleane di uscita z 0, z 1. La specifica

Dettagli

Architettura hardware

Architettura hardware Architettura dell elaboratore Architettura hardware la parte che si può prendere a calci Sistema composto da un numero elevato di componenti, in cui ogni componente svolge una sua funzione elaborazione

Dettagli

Gestione della memoria centrale

Gestione della memoria centrale Gestione della memoria centrale Un programma per essere eseguito deve risiedere in memoria principale e lo stesso vale per i dati su cui esso opera In un sistema multitasking molti processi vengono eseguiti

Dettagli

Introduzione. Coordinazione Distribuita. Ordinamento degli eventi. Realizzazione di. Mutua Esclusione Distribuita (DME)

Introduzione. Coordinazione Distribuita. Ordinamento degli eventi. Realizzazione di. Mutua Esclusione Distribuita (DME) Coordinazione Distribuita Ordinamento degli eventi Mutua esclusione Atomicità Controllo della Concorrenza Introduzione Tutte le questioni relative alla concorrenza che si incontrano in sistemi centralizzati,

Dettagli

Funzioni in C. Violetta Lonati

Funzioni in C. Violetta Lonati Università degli studi di Milano Dipartimento di Scienze dell Informazione Laboratorio di algoritmi e strutture dati Corso di laurea in Informatica Funzioni - in breve: Funzioni Definizione di funzioni

Dettagli

Memoria Virtuale. Anche la memoria principale ha una dimensione limitata. memoria principale (memoria fisica) memoria secondaria (memoria virtuale)

Memoria Virtuale. Anche la memoria principale ha una dimensione limitata. memoria principale (memoria fisica) memoria secondaria (memoria virtuale) Memoria Virtuale Anche la memoria principale ha una dimensione limitata. Possiamo pensare di superare questo limite utilizzando memorie secondarie (essenzialmente dischi) e vedendo la memoria principale

Dettagli

Soluzione dell esercizio del 2 Febbraio 2004

Soluzione dell esercizio del 2 Febbraio 2004 Soluzione dell esercizio del 2 Febbraio 2004 1. Casi d uso I casi d uso sono riportati in Figura 1. Figura 1: Diagramma dei casi d uso. E evidenziato un sotto caso di uso. 2. Modello concettuale Osserviamo

Dettagli

Realizzazione di Politiche di Gestione delle Risorse: i Semafori Privati

Realizzazione di Politiche di Gestione delle Risorse: i Semafori Privati Realizzazione di Politiche di Gestione delle Risorse: i Semafori Privati Condizione di sincronizzazione Qualora si voglia realizzare una determinata politica di gestione delle risorse,la decisione se ad

Dettagli

Introduzione alla programmazione in C

Introduzione alla programmazione in C Introduzione alla programmazione in C Testi Consigliati: A. Kelley & I. Pohl C didattica e programmazione B.W. Kernighan & D. M. Ritchie Linguaggio C P. Tosoratti Introduzione all informatica Materiale

Dettagli

1. Che cos è la multiprogrammazione? Si può realizzare su un sistema monoprocessore? 2. Quali sono i servizi offerti dai sistemi operativi?

1. Che cos è la multiprogrammazione? Si può realizzare su un sistema monoprocessore? 2. Quali sono i servizi offerti dai sistemi operativi? 1. Che cos è la multiprogrammazione? Si può realizzare su un sistema monoprocessore? 2. Quali sono i servizi offerti dai sistemi operativi? 1. La nozione di multiprogrammazione prevede la possibilità di

Dettagli

CAPITOLO 7 - SCAMBIO DI MESSAGGI

CAPITOLO 7 - SCAMBIO DI MESSAGGI CAPITOLO 7 - SCAMBIO DI MESSAGGI Lo scambio di messaggi è una forma di comunicazione nel quale un processo richiede al sistema operativo di mandare dei dati direttamente ad un altro processo. In alcuni

Dettagli

Database. Si ringrazia Marco Bertini per le slides

Database. Si ringrazia Marco Bertini per le slides Database Si ringrazia Marco Bertini per le slides Obiettivo Concetti base dati e informazioni cos è un database terminologia Modelli organizzativi flat file database relazionali Principi e linee guida

Dettagli

Dispensa di Informatica I.1

Dispensa di Informatica I.1 IL COMPUTER: CONCETTI GENERALI Il Computer (o elaboratore) è un insieme di dispositivi di diversa natura in grado di acquisire dall'esterno dati e algoritmi e produrre in uscita i risultati dell'elaborazione.

Dettagli

Tipi classici di memoria. Obiettivo. Principi di localita. Gerarchia di memoria. Fornire illimitata memoria veloce. Static RAM. Problemi: Dynamic RAM

Tipi classici di memoria. Obiettivo. Principi di localita. Gerarchia di memoria. Fornire illimitata memoria veloce. Static RAM. Problemi: Dynamic RAM Obiettivo Tipi classici di memoria Fornire illimitata memoria veloce Problemi: costo tecnologia Soluzioni: utilizzare diversi tipi di memoria... Static RAM access times are 2-25ns at cost of $100 to $250

Dettagli

La gestione di un calcolatore. Sistemi Operativi primo modulo Introduzione. Sistema operativo (2) Sistema operativo (1)

La gestione di un calcolatore. Sistemi Operativi primo modulo Introduzione. Sistema operativo (2) Sistema operativo (1) La gestione di un calcolatore Sistemi Operativi primo modulo Introduzione Augusto Celentano Università Ca Foscari Venezia Corso di Laurea in Informatica Un calcolatore (sistema di elaborazione) è un sistema

Dettagli

4 3 4 = 4 x 10 2 + 3 x 10 1 + 4 x 10 0 aaa 10 2 10 1 10 0

4 3 4 = 4 x 10 2 + 3 x 10 1 + 4 x 10 0 aaa 10 2 10 1 10 0 Rappresentazione dei numeri I numeri che siamo abituati ad utilizzare sono espressi utilizzando il sistema di numerazione decimale, che si chiama così perché utilizza 0 cifre (0,,2,3,4,5,6,7,8,9). Si dice

Dettagli

Introduzione ai Metodi Formali

Introduzione ai Metodi Formali Intruzione ai Meti Formali Sistemi software anche molto complessi regolano la vita quotidiana, anche in situazioni life-critical (e.g. avionica) e business-critical (e.g. operazioni bancarie). Esempi di

Dettagli

Approccio stratificato

Approccio stratificato Approccio stratificato Il sistema operativo è suddiviso in strati (livelli), ciascuno costruito sopra quelli inferiori. Il livello più basso (strato 0) è l hardware, il più alto (strato N) è l interfaccia

Dettagli

Sistema Operativo. Fondamenti di Informatica 1. Il Sistema Operativo

Sistema Operativo. Fondamenti di Informatica 1. Il Sistema Operativo Sistema Operativo Fondamenti di Informatica 1 Il Sistema Operativo Il Sistema Operativo (S.O.) è un insieme di programmi interagenti che consente agli utenti e ai programmi applicativi di utilizzare al

Dettagli

Il Sistema Operativo

Il Sistema Operativo Il Sistema Operativo Il Sistema Operativo Il Sistema Operativo (S.O.) è un insieme di programmi interagenti che consente agli utenti e ai programmi applicativi di utilizzare al meglio le risorse del Sistema

Dettagli

Sistemi Operativi IMPLEMENTAZIONE DEL FILE SYSTEM. D. Talia - UNICAL. Sistemi Operativi 9.1

Sistemi Operativi IMPLEMENTAZIONE DEL FILE SYSTEM. D. Talia - UNICAL. Sistemi Operativi 9.1 IMPLEMENTAZIONE DEL FILE SYSTEM 9.1 Implementazione del File System Struttura del File System Implementazione Implementazione delle Directory Metodi di Allocazione Gestione dello spazio libero Efficienza

Dettagli

Programmazione concorrente in Java. Dr. Paolo Casoto, Ph.D. - 2012 1

Programmazione concorrente in Java. Dr. Paolo Casoto, Ph.D. - 2012 1 + Programmazione concorrente in Java 1 + Introduzione al multithreading 2 La scomposizione in oggetti consente di separare un programma in sottosezioni indipendenti. Oggetto = metodi + attributi finalizzati

Dettagli

DMA Accesso Diretto alla Memoria

DMA Accesso Diretto alla Memoria Testo di rif.to: [Congiu] - 8.1-8.3 (pg. 241 250) 08.a DMA Accesso Diretto alla Memoria Motivazioni Organizzazione dei trasferimenti DMA Arbitraggio del bus di memoria Trasferimento di un blocco di dati

Dettagli

Il processore. Il processore. Il processore. Il processore. Architettura dell elaboratore

Il processore. Il processore. Il processore. Il processore. Architettura dell elaboratore Il processore Architettura dell elaboratore Il processore La esegue istruzioni in linguaggio macchina In modo sequenziale e ciclico (ciclo macchina o ciclo ) Effettuando operazioni di lettura delle istruzioni

Dettagli

Più processori uguale più velocità?

Più processori uguale più velocità? Più processori uguale più velocità? e un processore impiega per eseguire un programma un tempo T, un sistema formato da P processori dello stesso tipo esegue lo stesso programma in un tempo TP T / P? In

Dettagli

Mutua esclusione distribuita

Mutua esclusione distribuita Sincronizzazione del clock Il clock di CPU distribuite non é sincronizzato Clock fisico (difficile) / Clock logico (semplice) In molti casi basta sincronizzare il clock logico Sincronizzazione del clock

Dettagli

Speedup. Si definisce anche lo Speedup relativo in cui, invece di usare T 1 si usa T p (1).

Speedup. Si definisce anche lo Speedup relativo in cui, invece di usare T 1 si usa T p (1). Speedup Vediamo come e' possibile caratterizzare e studiare le performance di un algoritmo parallelo: S n = T 1 T p n Dove T 1 e' il tempo impegato dal miglior algoritmo seriale conosciuto, mentre T p

Dettagli

Siamo così arrivati all aritmetica modulare, ma anche a individuare alcuni aspetti di come funziona l aritmetica del calcolatore come vedremo.

Siamo così arrivati all aritmetica modulare, ma anche a individuare alcuni aspetti di come funziona l aritmetica del calcolatore come vedremo. DALLE PESATE ALL ARITMETICA FINITA IN BASE 2 Si è trovato, partendo da un problema concreto, che con la base 2, utilizzando alcune potenze della base, operando con solo addizioni, posso ottenere tutti

Dettagli

Sistema operativo: Gestione della memoria

Sistema operativo: Gestione della memoria Dipartimento di Elettronica ed Informazione Politecnico di Milano Informatica e CAD (c.i.) - ICA Prof. Pierluigi Plebani A.A. 2008/2009 Sistema operativo: Gestione della memoria La presente dispensa e

Dettagli

Calcolatori Elettronici A a.a. 2008/2009

Calcolatori Elettronici A a.a. 2008/2009 Calcolatori Elettronici A a.a. 2008/2009 PRESTAZIONI DEL CALCOLATORE Massimiliano Giacomin Due dimensioni Tempo di risposta (o tempo di esecuzione): il tempo totale impiegato per eseguire un task (include

Dettagli

Sistemi Operativi mod. B. Sistemi Operativi mod. B A B C A B C P 1 2 0 0 P 1 1 2 2 3 3 2 P 2 3 0 2 P 2 6 0 0 P 3 2 1 1 P 3 0 1 1 < P 1, >

Sistemi Operativi mod. B. Sistemi Operativi mod. B A B C A B C P 1 2 0 0 P 1 1 2 2 3 3 2 P 2 3 0 2 P 2 6 0 0 P 3 2 1 1 P 3 0 1 1 < P 1, > Algoritmo del banchiere Permette di gestire istanze multiple di una risorsa (a differenza dell algoritmo con grafo di allocazione risorse). Ciascun processo deve dichiarare a priori il massimo impiego

Dettagli

Il costrutto monitor [Hoare 74]

Il costrutto monitor [Hoare 74] Il monitor 1 Il costrutto monitor [Hoare 74] Definizione: Costrutto sintattico che associa un insieme di operazioni (entry, o public) ad una struttura dati comune a più processi, tale che: Le operazioni

Dettagli

Sistemi Operativi IMPLEMENTAZIONE DEL FILE SYSTEM. Implementazione del File System. Struttura del File System. Implementazione

Sistemi Operativi IMPLEMENTAZIONE DEL FILE SYSTEM. Implementazione del File System. Struttura del File System. Implementazione IMPLEMENTAZIONE DEL FILE SYSTEM 9.1 Implementazione del File System Struttura del File System Implementazione Implementazione delle Directory Metodi di Allocazione Gestione dello spazio libero Efficienza

Dettagli

Il problema del produttore e del consumatore. Cooperazione tra processi

Il problema del produttore e del consumatore. Cooperazione tra processi Il problema del produttore e del consumatore Cooperazione tra processi Risorsa consumabile I processi disgiunti possono interferire tra loro a causa dell'uso di risorse permanenti, ma ognuno di essi ignora

Dettagli

Corso di Sistemi di Elaborazione delle informazioni

Corso di Sistemi di Elaborazione delle informazioni Corso di Sistemi di Elaborazione delle informazioni Sistemi Operativi Francesco Fontanella Complessità del Software Software applicativo Software di sistema Sistema Operativo Hardware 2 La struttura del

Dettagli

Il calendario di Windows Vista

Il calendario di Windows Vista Il calendario di Windows Vista Una delle novità introdotte in Windows Vista è il Calendario di Windows, un programma utilissimo per la gestione degli appuntamenti, delle ricorrenze e delle attività lavorative

Dettagli

Prestazioni CPU Corso di Calcolatori Elettronici A 2007/2008 Sito Web:http://prometeo.ing.unibs.it/quarella Prof. G. Quarella prof@quarella.

Prestazioni CPU Corso di Calcolatori Elettronici A 2007/2008 Sito Web:http://prometeo.ing.unibs.it/quarella Prof. G. Quarella prof@quarella. Prestazioni CPU Corso di Calcolatori Elettronici A 2007/2008 Sito Web:http://prometeo.ing.unibs.it/quarella Prof. G. Quarella prof@quarella.net Prestazioni Si valutano in maniera diversa a seconda dell

Dettagli

Correttezza. Corso di Laurea Ingegneria Informatica Fondamenti di Informatica 1. Dispensa 10. A. Miola Novembre 2007

Correttezza. Corso di Laurea Ingegneria Informatica Fondamenti di Informatica 1. Dispensa 10. A. Miola Novembre 2007 Corso di Laurea Ingegneria Informatica Fondamenti di Informatica 1 Dispensa 10 Correttezza A. Miola Novembre 2007 http://www.dia.uniroma3.it/~java/fondinf1/ Correttezza 1 Contenuti Introduzione alla correttezza

Dettagli

Il sistema di I/O. Hardware di I/O Interfacce di I/O Software di I/O. Introduzione

Il sistema di I/O. Hardware di I/O Interfacce di I/O Software di I/O. Introduzione Il sistema di I/O Hardware di I/O Interfacce di I/O Software di I/O Introduzione 1 Sotto-sistema di I/O Insieme di metodi per controllare i dispositivi di I/O Obiettivo: Fornire ai processi utente un interfaccia

Dettagli

SISTEMI OPERATIVI. Deadlock (blocco critico) Domande di verifica. Luca Orrù Centro Multimediale Montiferru 04/06/2007

SISTEMI OPERATIVI. Deadlock (blocco critico) Domande di verifica. Luca Orrù Centro Multimediale Montiferru 04/06/2007 2007 SISTEMI OPERATIVI Deadlock (blocco critico) Domande di verifica Luca Orrù Centro Multimediale Montiferru 04/06/2007 Deadlock (blocco critico) 1. Si descriva il deadlock e le condizioni sotto cui si

Dettagli

Sistemi Operativi. Scheduling della CPU SCHEDULING DELLA CPU. Concetti di Base Criteri di Scheduling Algoritmi di Scheduling

Sistemi Operativi. Scheduling della CPU SCHEDULING DELLA CPU. Concetti di Base Criteri di Scheduling Algoritmi di Scheduling SCHEDULING DELLA CPU 5.1 Scheduling della CPU Concetti di Base Criteri di Scheduling Algoritmi di Scheduling FCFS, SJF, Round-Robin, A code multiple Scheduling in Multi-Processori Scheduling Real-Time

Dettagli

Sistemi Operativi SCHEDULING DELLA CPU. Sistemi Operativi. D. Talia - UNICAL 5.1

Sistemi Operativi SCHEDULING DELLA CPU. Sistemi Operativi. D. Talia - UNICAL 5.1 SCHEDULING DELLA CPU 5.1 Scheduling della CPU Concetti di Base Criteri di Scheduling Algoritmi di Scheduling FCFS, SJF, Round-Robin, A code multiple Scheduling in Multi-Processori Scheduling Real-Time

Dettagli

Lezione 8. La macchina universale

Lezione 8. La macchina universale Lezione 8 Algoritmi La macchina universale Un elaboratore o computer è una macchina digitale, elettronica, automatica capace di effettuare trasformazioni o elaborazioni su i dati digitale= l informazione

Dettagli

Sommario. Definizione di informatica. Definizione di un calcolatore come esecutore. Gli algoritmi.

Sommario. Definizione di informatica. Definizione di un calcolatore come esecutore. Gli algoritmi. Algoritmi 1 Sommario Definizione di informatica. Definizione di un calcolatore come esecutore. Gli algoritmi. 2 Informatica Nome Informatica=informazione+automatica. Definizione Scienza che si occupa dell

Dettagli

APPUNTI DI MATEMATICA LE FRAZIONI ALGEBRICHE ALESSANDRO BOCCONI

APPUNTI DI MATEMATICA LE FRAZIONI ALGEBRICHE ALESSANDRO BOCCONI APPUNTI DI MATEMATICA LE FRAZIONI ALGEBRICHE ALESSANDRO BOCCONI Indice 1 Le frazioni algebriche 1.1 Il minimo comune multiplo e il Massimo Comun Divisore fra polinomi........ 1. Le frazioni algebriche....................................

Dettagli

Input/Output. Moduli di Input/ Output. gestiscono quantità di dati differenti a velocità diverse in formati diversi. n Grande varietà di periferiche

Input/Output. Moduli di Input/ Output. gestiscono quantità di dati differenti a velocità diverse in formati diversi. n Grande varietà di periferiche Input/Output n Grande varietà di periferiche gestiscono quantità di dati differenti a velocità diverse in formati diversi n Tutti più lenti della CPU e della RAM n Necessità di avere moduli di I/O Moduli

Dettagli

Corso di Sistemi Operativi Ingegneria Elettronica e Informatica prof. Rocco Aversa. Raccolta prove scritte. Prova scritta

Corso di Sistemi Operativi Ingegneria Elettronica e Informatica prof. Rocco Aversa. Raccolta prove scritte. Prova scritta Corso di Sistemi Operativi Ingegneria Elettronica e Informatica prof. Rocco Aversa Raccolta prove scritte Realizzare una classe thread Processo che deve effettuare un numero fissato di letture da una memoria

Dettagli

COME AVERE SUCCESSO SUL WEB?

COME AVERE SUCCESSO SUL WEB? Registro 3 COME AVERE SUCCESSO SUL WEB? Guida pratica per muovere con successo i primi passi nel web MISURAZIONE ED OBIETTIVI INDEX 3 7 13 Strumenti di controllo e analisi Perché faccio un sito web? Definisci

Dettagli

Reti sequenziali sincrone

Reti sequenziali sincrone Reti sequenziali sincrone Un approccio strutturato (7.1-7.3, 7.5-7.6) Modelli di reti sincrone Analisi di reti sincrone Descrizioni e sintesi di reti sequenziali sincrone Sintesi con flip-flop D, DE, T

Dettagli

Gestione della memoria. Paginazione Segmentazione Segmentazione con paginazione

Gestione della memoria. Paginazione Segmentazione Segmentazione con paginazione Gestione della memoria Paginazione Segmentazione Segmentazione con paginazione Modello di paginazione Il numero di pagina serve come indice per la tabella delle pagine. Questa contiene l indirizzo di base

Dettagli

Università di Roma Tor Vergata Corso di Laurea triennale in Informatica Sistemi operativi e reti A.A. 2015-16. Pietro Frasca.

Università di Roma Tor Vergata Corso di Laurea triennale in Informatica Sistemi operativi e reti A.A. 2015-16. Pietro Frasca. Università di Roma Tor Vergata Corso di Laurea triennale in Informatica Sistemi operativi e reti A.A. 2015-16 Pietro Frasca Lezione 15 Martedì 24-11-2015 Struttura logica del sottosistema di I/O Processi

Dettagli

3. Introduzione all'internetworking

3. Introduzione all'internetworking 3. Introduzione all'internetworking Abbiamo visto i dettagli di due reti di comunicazione: ma ce ne sono decine di tipo diverso! Occorre poter far comunicare calcolatori che si trovano su reti di tecnologia

Dettagli

Esercizi su. Funzioni

Esercizi su. Funzioni Esercizi su Funzioni ๒ Varie Tracce extra Sul sito del corso ๓ Esercizi funz_max.cc funz_fattoriale.cc ๔ Documentazione Il codice va documentato (commentato) Leggibilità Riduzione degli errori Manutenibilità

Dettagli

Prova di Laboratorio di Programmazione

Prova di Laboratorio di Programmazione Prova di Laboratorio di Programmazione 6 febbraio 015 ATTENZIONE: Non è possibile usare le classi del package prog.io del libro di testo. Oltre ai metodi richiesti in ciascuna classe, è opportuno implementare

Dettagli

12 - Introduzione alla Programmazione Orientata agli Oggetti (Object Oriented Programming OOP)

12 - Introduzione alla Programmazione Orientata agli Oggetti (Object Oriented Programming OOP) 12 - Introduzione alla Programmazione Orientata agli Oggetti (Object Oriented Programming OOP) Programmazione e analisi di dati Modulo A: Programmazione in Java Paolo Milazzo Dipartimento di Informatica,

Dettagli

Università di Torino Facoltà di Scienze MFN Corso di Studi in Informatica. Programmazione I - corso B a.a. 2009-10. prof.

Università di Torino Facoltà di Scienze MFN Corso di Studi in Informatica. Programmazione I - corso B a.a. 2009-10. prof. Università di Torino Facoltà di Scienze MFN Corso di Studi in Informatica Programmazione I - corso B a.a. 009-10 prof. Viviana Bono Blocco 9 Metodi statici: passaggio parametri, variabili locali, record

Dettagli

CALCOLATORI ELETTRONICI A cura di Luca Orrù. Lezione n.7. Il moltiplicatore binario e il ciclo di base di una CPU

CALCOLATORI ELETTRONICI A cura di Luca Orrù. Lezione n.7. Il moltiplicatore binario e il ciclo di base di una CPU Lezione n.7 Il moltiplicatore binario e il ciclo di base di una CPU 1 SOMMARIO Architettura del moltiplicatore Architettura di base di una CPU Ciclo principale di base di una CPU Riprendiamo l analisi

Dettagli

Corso di Informatica

Corso di Informatica Corso di Informatica Modulo T3 1-Sottoprogrammi 1 Prerequisiti Tecnica top-down Programmazione elementare 2 1 Introduzione Lo scopo di questa Unità è utilizzare la metodologia di progettazione top-down

Dettagli

INTRODUZIONE AGLI ALGORITMI INTRODUZIONE AGLI ALGORITMI INTRODUZIONE AGLI ALGORITMI INTRODUZIONE AGLI ALGORITMI

INTRODUZIONE AGLI ALGORITMI INTRODUZIONE AGLI ALGORITMI INTRODUZIONE AGLI ALGORITMI INTRODUZIONE AGLI ALGORITMI INTRODUZIONE AGLI ALGORITMI Prima di riuscire a scrivere un programma, abbiamo bisogno di conoscere un metodo risolutivo, cioè un metodo che a partire dai dati di ingresso fornisce i risultati attesi.

Dettagli

La memoria - generalità

La memoria - generalità Calcolatori Elettronici La memoria gerarchica Introduzione La memoria - generalità n Funzioni: Supporto alla CPU: deve fornire dati ed istruzioni il più rapidamente possibile Archiviazione: deve consentire

Dettagli

Capitolo Quarto...2 Le direttive di assemblaggio di ASM 68000...2 Premessa...2 1. Program Location Counter e direttiva ORG...2 2.

Capitolo Quarto...2 Le direttive di assemblaggio di ASM 68000...2 Premessa...2 1. Program Location Counter e direttiva ORG...2 2. Capitolo Quarto...2 Le direttive di assemblaggio di ASM 68000...2 Premessa...2 1. Program Location Counter e direttiva ORG...2 2. Dichiarazione di dati: le direttive DS e DC...3 2.1 Direttiva DS...3 2.2

Dettagli

Monitor. Introduzione. Struttura di un TDA Monitor

Monitor. Introduzione. Struttura di un TDA Monitor Monitor Domenico Cotroneo Dipartimento di Informatica e Sistemistica Introduzione E stato introdotto per facilitare la programmazione strutturata di problemi in cui è necessario controllare l assegnazione

Dettagli

SOMMARIO Coda (queue): QUEUE. QUEUE : specifica QUEUE

SOMMARIO Coda (queue): QUEUE. QUEUE : specifica QUEUE SOMMARIO Coda (queue): Specifica: interfaccia. Implementazione: Strutture indicizzate (array): Array di dimensione variabile. Array circolari. Strutture collegate (nodi). Prestazioni. Strutture Software

Dettagli

Consiglio regionale della Toscana. Regole per il corretto funzionamento della posta elettronica

Consiglio regionale della Toscana. Regole per il corretto funzionamento della posta elettronica Consiglio regionale della Toscana Regole per il corretto funzionamento della posta elettronica A cura dell Ufficio Informatica Maggio 2006 Indice 1. Regole di utilizzo della posta elettronica... 3 2. Controllo

Dettagli

Sistemi Operativi GESTIONE DELLA MEMORIA CENTRALE. D. Talia - UNICAL. Sistemi Operativi 6.1

Sistemi Operativi GESTIONE DELLA MEMORIA CENTRALE. D. Talia - UNICAL. Sistemi Operativi 6.1 GESTIONE DELLA MEMORIA CENTRALE 6.1 Gestione della Memoria Background Spazio di indirizzi Swapping Allocazione Contigua Paginazione 6.2 Background Per essere eseguito un programma deve trovarsi (almeno

Dettagli

10 - Programmare con gli Array

10 - Programmare con gli Array 10 - Programmare con gli Array Programmazione e analisi di dati Modulo A: Programmazione in Java Paolo Milazzo Dipartimento di Informatica, Università di Pisa http://www.di.unipi.it/ milazzo milazzo di.unipi.it

Dettagli

Sistemi Operativi MECCANISMI E POLITICHE DI PROTEZIONE. D. Talia - UNICAL. Sistemi Operativi 13.1

Sistemi Operativi MECCANISMI E POLITICHE DI PROTEZIONE. D. Talia - UNICAL. Sistemi Operativi 13.1 MECCANISMI E POLITICHE DI PROTEZIONE 13.1 Protezione Obiettivi della Protezione Dominio di Protezione Matrice di Accesso Implementazione della Matrice di Accesso Revoca dei Diritti di Accesso Sistemi basati

Dettagli

MECCANISMI E POLITICHE DI PROTEZIONE 13.1

MECCANISMI E POLITICHE DI PROTEZIONE 13.1 MECCANISMI E POLITICHE DI PROTEZIONE 13.1 Protezione Obiettivi della Protezione Dominio di Protezione Matrice di Accesso Implementazione della Matrice di Accesso Revoca dei Diritti di Accesso Sistemi basati

Dettagli

Dispense di Informatica per l ITG Valadier

Dispense di Informatica per l ITG Valadier La notazione binaria Dispense di Informatica per l ITG Valadier Le informazioni dentro il computer All interno di un calcolatore tutte le informazioni sono memorizzate sottoforma di lunghe sequenze di

Dettagli

Definire all'interno del codice un vettore di interi di dimensione DIM, es. int array[] = {1, 5, 2, 4, 8, 1, 1, 9, 11, 4, 12};

Definire all'interno del codice un vettore di interi di dimensione DIM, es. int array[] = {1, 5, 2, 4, 8, 1, 1, 9, 11, 4, 12}; ESERCIZI 2 LABORATORIO Problema 1 Definire all'interno del codice un vettore di interi di dimensione DIM, es. int array[] = {1, 5, 2, 4, 8, 1, 1, 9, 11, 4, 12}; Chiede all'utente un numero e, tramite ricerca

Dettagli

Sistemi Operativi Kernel

Sistemi Operativi Kernel Approfondimento Sistemi Operativi Kernel Kernel del Sistema Operativo Kernel (nocciolo, nucleo) Contiene i programmi per la gestione delle funzioni base del calcolatore Kernel suddiviso in moduli. Ogni

Dettagli

I THREAD O PROCESSI LEGGERI Generalità

I THREAD O PROCESSI LEGGERI Generalità I THREAD O PROCESSI LEGGERI Generalità Thread: segmento di codice (funzione) Ogni processo ha un proprio SPAZIO DI INDIRIZZAMENTO (area di memoria) Tutti i thread genereti dallo stesso processo condividono

Dettagli

La memoria centrale (RAM)

La memoria centrale (RAM) La memoria centrale (RAM) Mantiene al proprio interno i dati e le istruzioni dei programmi in esecuzione Memoria ad accesso casuale Tecnologia elettronica: Veloce ma volatile e costosa Due eccezioni R.O.M.

Dettagli

Esame di INFORMATICA

Esame di INFORMATICA Università di L Aquila Facoltà di Biotecnologie Esame di INFORMATICA Lezione 4 MACCHINA DI VON NEUMANN Anni 40 i dati e i programmi che descrivono come elaborare i dati possono essere codificati nello

Dettagli

SISTEMI OPERATIVI. Prof. Enrico Terrone A. S: 2008/09

SISTEMI OPERATIVI. Prof. Enrico Terrone A. S: 2008/09 SISTEMI OPERATIVI Prof. Enrico Terrone A. S: 2008/09 Che cos è il sistema operativo Il sistema operativo (SO) è il software che gestisce e rende accessibili (sia ai programmatori e ai programmi, sia agli

Dettagli

Laboratorio di reti Relazione N 5 Gruppo 9. Vettorato Mattia Mesin Alberto

Laboratorio di reti Relazione N 5 Gruppo 9. Vettorato Mattia Mesin Alberto Laboratorio di reti Relazione N 5 Gruppo 9 Vettorato Mattia Mesin Alberto Virtual LAN Che cosa è una VLAN? Il termine Virtual LAN indica una serie di tecniche atte a separare un dominio di broadcast, di

Dettagli

ARCHITETTURA DI RETE FOLEGNANI ANDREA

ARCHITETTURA DI RETE FOLEGNANI ANDREA ARCHITETTURA DI RETE FOLEGNANI ANDREA INTRODUZIONE È denominata Architettura di rete un insieme di livelli e protocolli. Le reti sono organizzate gerarchicamente in livelli, ciascuno dei quali interagisce

Dettagli

Arduino: Programmazione

Arduino: Programmazione Programmazione formalmente ispirata al linguaggio C da cui deriva. I programmi in ARDUINO sono chiamati Sketch. Un programma è una serie di istruzioni che vengono lette dall alto verso il basso e convertite

Dettagli

Sistemi Operativi. 5 Gestione della memoria

Sistemi Operativi. 5 Gestione della memoria Gestione della memoria Compiti del gestore della memoria: Tenere traccia di quali parti della memoria sono libere e quali occupate. Allocare memoria ai processi che ne hanno bisogno. Deallocare la memoria

Dettagli

STRUTTURE DEI SISTEMI DI CALCOLO

STRUTTURE DEI SISTEMI DI CALCOLO STRUTTURE DEI SISTEMI DI CALCOLO 2.1 Strutture dei sistemi di calcolo Funzionamento Struttura dell I/O Struttura della memoria Gerarchia delle memorie Protezione Hardware Architettura di un generico sistema

Dettagli