Scarica Programmazione Concorrente - Dispensa e più Dispense in PDF di Programmazione Avanzata solo su Docsity!
GENERALITA’:
Un algoritmo è un procedimento che risolve un determinato problema attraverso un numero finito di passi. Un algoritmo viene codificato in un programma utilizzando un qualche linguaggio di programmazione. Le istruzioni del programma vengono messe in esecuzione dal processore fisico. La sequenza di azioni corrispondenti all’esecuzione delle istruzioni del programma sul processore viene definita processo. Un processo è quindi per definizione un programma in esecuzione. Programma: entità passiva che descrive le azioni da compiere; Processo: entità attiva che rappresenta l’esecuzione di tali azioni. Un’applicazione concorrente è composta da molti processi che lavorano assieme per portare a termine il compito loro assegnato. Tali processi eseguono quasi allo stesso tempo o pseudo-contemporaneamente. Per lavorare assieme i processi devono comunicare e sincronizzarsi tra loro, in particolare per coordinare le azioni e condividere le risorse. I processi comunicano utilizzando variabili condivise (shared variables) o scambiandosi messaggi (message passing), e si sincronizzano mediante appositi strumenti di sincronizzazione. La programmazione concorrente, nata per sfuttare al meglio le risorse hardware, ha trovato utilizzo per migliorare la velocità di esecuzione delle applicazioni rispetto a una loro realizzazione sequenziale. Si utilizza in 3 classi di sistemi: multithreaded, distribuiti e paralleli. Lo speed up è un parametro che indica il miglioramento nella velocità di esecuzione di uno stesso task eseguito su due architetture simili ma con diverse capacità computazionali. La Legge di Amdahl descrive l’andamento dello speed-up al variare del numero di processori (N). Tale legge è espressione di quanto siano determinanti le caratteristiche dell’algoritmo, in particolare l’effettiva potenzialità di parallelismo insita in un algoritmo, la parallel portion. Due processi P0 e P1 si definiscono concorrenti se la loro esecuzione si sovrappone nel tempo, nel senso che la prima operazione di uno inizia prima dell’ultima
dell’altro. Solo nel caso multiprocessore ( 1 ) si ha reale parallelismo di esecuzione di P0 e P1. Nel caso monoprocessore (2), i processi sono alternati nel tempo, non vi è quindi una reale esecuzione parallela. Il parallelismo indica se diverse parti di un’applicazione (processi) stanno eseguendo realmente in parallelo, quindi effettivamente contemporaneamente. Modello ad ambiente locale: il sistema è visto come un insieme di processi ciascuno operante in un ambiente locale non direttamente accessibile a nessun altro processo. Tali processi interagiscono (per cooperare o per competere) esclusivamente attraverso lo scambio di messaggi [send(m), receive(m)], non esistono risorse condivise direttamente tra i processi (o alla risorsa viene associato un processo servitore o viene passata sotto forma di messaggio da un processo all’altro). Il modello a scambio di messaggi rappresenta la naturale astrazione di un sistema privo di memoria comune, in cui ciascun processore è dotato di una memoria privata, sistema noto come distributed memory multicomputers. Modello ad ambiente globale: il sistema è visto come un insieme di processi e risorse o oggetti. Tali processi interagiscono esclusivamente attraverso l’uso di risorse comuni (varibili condivise su memoria condivisa). Il modello ad ambiente globale rappresenta la naturale astrazione di un sistema costituito da uno o più processori cha hanno accesso a una memoria comune, sistema noto come shared memory multiprocessors. Nel caso di ambiente globale, i processi condividono delle risorse, tipicamente delle aree di memoria. Ciò consente una cooperazione stretta e molto efficiente tra processi diversi, ma allo stesso tempo espone ai problemi connessi ad un’errata sincronizzazione dei processi nell’accesso ad una risorsa condivisa (si pensi a due processi che scrivono contemporaneamente su una stessa variabile condivisa). Nel modello ad ambiente globale, i processi vengono anche chiamati thread (lightweight process – processo leggero). Un thread è un singolo flusso sequenziale di esecuzione all’interno di un processo. Il nome processo in questo caso indica il contesto di esecuzione, eventualmente condiviso da più thread. Per multithreading s’intende l’esecuzione concorrente di diversi thread nell’ambito di uno stesso processo. I thread eseguono all’interno del contesto di escuzione di un unico processo, per cui non hanno uno spazio di indirizzamento riservato: tutti i thread appartenenti allo stesso processo condividono lo stesso spazio di indirizzamento, ma hanno execution stack e program counter privati.
PROPRIETA’ NON FUNZIONALI:
Un programma si può definire corretto sulla base di aspetti funzionali, ad esempio un calcolo eseguito correttamente. Nell’ambito della programmazione concorrente però occorre considerare anche aspetti non funzionali, come ad esempio il tempo di esecuzione di un’applicazione in un sistema real-time, la sicurezza di un’applicazione o l’assenza di deadlock. Le applicazioni concorrenti si definiscono corrette se sono garantite anche le proprietà non funzionali, ovvero: SAFETY: tutte le risorse del sistema sono sempre in uno stato consistente. Per garantire ciò, si devono eliminare le interferenze tra i processi. La safety è una proprietà non funzionale che riguarda lo stato delle risorse, degli oggetti; LIVENESS: prima o poi tutti i processi entrano in uno stato corretto (il sistema non si blocca, non c’è deadlock*). Per garantire ciò, si deve assicurare la corretta cooperazione tra i processi. La liveness è una proprietà non funzionale che riguarda le attività, i processi. *DEADLOCK: un insieme di processi è in uno stato di blocco critico quando ogni processo è in attesa di un evento che può essere causato solo da un altro processo dell’insieme (un processo non si deve mai sospendere mantenendo occupata una risorsa, altrimenti prima o poi deadlock come nel caso dei filosofi). I metodi devono agire solo su stati consistenti, dove per consistente s’intende che devono essere sempre soddisfatte le relazioni invarianti. Ci possono essere eventualmente stati di temporanea inconsistenza, i quali però non devono essere visibili agli altri metodi, in quanto azioni eseguite su stati inconsistenti determinano errori impredicibili. Per preservare la consistenza dello stato delle risorse e quindi garantire la safety occorre evitare:
- corse critiche, causate da mutua esclusione non corretta;
- violazioni dell’atomicità di alcune operazioni (non rispettando un lock ad esempio);
- accesso e azioni su stati di temporanea inconsistenza;
- violazioni delle relazioni invarianti;
- errori semantici, esecuzione di azioni dove dovrebbero essere proibite;
- azioni su dati cachati non consistenti. La multi-threaded safety introduce la dimensione del tempo (quando avviene l’accesso a una risorsa?). Ciò non può essere controllato a tempo di compilazione, pertanto si devono utilizzare tecniche di programmazione concorrente che preservino la consistenza evitando interferenze.
La liveness garantisce che tutti i processi progrediscano verso il completamento. Possono esserci blocchi temporanei di alcune attività, si devono evitare però situazioni di deadlock o di starvation*. Tuttavia, mentre il deadlock è certamente un errore, la starvation potrebbe essere frutto di una scelta politica. Per garantire la liveness occorre evitare:
- deadlock e starvation;
- errori con i segnali;
- fallimenti, ad esempio un thread attende un segnale da un altro thread che è crashato;
- livelock: continuo tentativo di azione che fallirà sempre;
- lockout: chiamata ad un metodo che non sarà mai disponibile, sono bloccato fuori ad una struttura a cui non riuscirò mai ad accedere;
- esaurimento risorse. *STARVATION: un processo non viene mai eseguito (a causa di una priorità troppo bassa ad esempio). Garantire la correttezza di un’applicazione concorrente potrebbe impattare sulle prestazioni. Safety e liveness sono proprietà in contrasto tra loro, la soluzione di una potrebbe causare problemi all’altra. La realizzazione di una corretta applicazione concorrente deve bilanciare le esigenze di safety con quelle di liveness. Chiaramente la safety non è contrattabile (non sono accettate interferenze), però occorre garantire l’accesso alle strutture dati in maniera safe e allo stesso tempo in modo da non mandare in crisi la liveness, scegliendo correttamente la granularità degli interventi che garantiscono la mutua esclusione. Sincronizzazioni troppo forti potrebbero ridurre la concorrenza di esecuzione.
wait(mutex) if(risorsa_occupata<mutex==0>) che in termini generali è à then Signal(mutex) else <lock su risorsa (porto mutex a 0) e accesso alla risorsa> if(c’è un processo in attesa) then else <rilascio il lock sulla risorsa (porto mutex a 1)> ESEMPIO:
MONITOR:
Il monitor è un tipo di dato astratto, che nasconde l’implementazione di una risorsa e offre le procedure per modificarla. Tali procedure sono le uniche autorizzate ad agire sulla risorsa e garantiscono corretta sincronizzazione nell’accesso. Il monitor, quindi, specifica e controlla la sincronizzazione tra i processi.
- variabili permanenti: descrivono lo stato della risorsa e possono essere accedute solo dalle procedure del monitor. Vengono dette permanenti perché il loro valore viene mantenuto tra successive esecuzioni delle procedure del monitor, anche da parte di processi diversi. Esse devono soddisfare relazioni invarianti;
- procedure entry: operazioni sfruttate dai processi per accedere alla risorsa;
- procedure: sono visibili e quindi invocabili solo dall’interno del monitor (dalle procedure entry); N.B.: l’inizializzazione delle variabili permanenti viene eseguita una volta sola, prima dell’esecuzione di qualunque procedura. Un’istanza di un monitor è accessibile da più processi concorrenti che vogliono accedere alla risorsa condivisa. Il monitor protegge e incapsula la risorsa e garantisce la corretta sincronizzazione dei processi. Il monitor regola l’assegnazione di una risorsa tra processi concorrenti in base a 2 livelli di controllo:
- mutua esclusione nell’accesso alle procedure entry;
- utilizzo di variabili condizione; N.B.: il monitor viene compilato in termini di semafori quindi la corretta sincronizzazione dei processi è garantita dal compilatore.
- SIGNAL AND CONTINUE (SC): il processo segnalante risveglia un processo che era sospeso nella coda associata ad una certa variabile condizione. Il processo risvegliato si va a sospendere immediatamente nella entry queue, mentre il processo segnalante continua la sua esecuzione; N.B.: il processo segnalante continua la sua esecuzione e potrebbe modificare la condizione di sincronizzazione rendendola non più vera per il processo segnalato, il quale, quindi, dovrà ritestare la guardia al risveglio.
- SIGNAL AND URGENT WAIT (SUW): il processo segnalante risveglia un processo che era sospeso nella coda associata ad una certa variabile condizione. Il processo risvegliato va in esecuzione all’interno del monitor, mentre il processo segnalante si va a sospendere nella urgent queue (coda a priorità maggiore rispetto alla entry queue). Nel momento in cui il processo risvegliato termina o si sospende, verrà rimesso in esecuzione il primo processo nella coda urgent;
- SIGNAL AND RETURN (caso particolare di signal and urgent wait): impone l’utilizzo dell’operazione signal come ultima istruzione di una procedure entry. In sostanza, non c’è bisogno di sospendere il processo segnalante in una qualche coda in quanto sta già uscendo dal monitor.
SUPPORTO HARDWARE ALLA SINCRONIZZAZIONE:
Una prima soluzione per garantire la non interrompibilità della wait e della signal è quella di disabilitare gli interrupt del processore durante la loro esecuzione. Tuttavia, dare ai processi la possibilità di disabilitare le interruzioni potrebbe comportare gravi errori ed eventuale blocco del sistema. Non solo, tale soluzione riduce il parallelismo e funziona solo nel caso monoprocessore. Una seconda soluzione prevede l’utilizzo di una variabile x condivisa tra i processi, con il significato: x=1 à risorsa libera x=0 à risorsa occupata Il processo che intende eseguire una wait deve invocare lock(x), prima di eseguire il codice della wait, e dopo averlo eseguito, unlock(x). lock(x): while(x=0); x=0; unlock(x): x=1; Tuttavia, vi è un problema di interferenza in quanto solo unlock è indivisibile. Per rendere indivisibile anche la lock, è stata introdotta una nuova istruzione hardware la TEST AND SET LOCK (TSL), che atomicamente copia il valore di x in un registro e inserisce in x il valore 0. LINGUAGGIO ASSEMBLY: lock(x): tsl register, x {copia x in register e pone x=0} cmp register, 1 {compara x in register con 1} jne lock {se x valeva 0 ricicla, perché la risorsa era già occupata} ret {se x valeva 1 ritorna al chiamante, il quale avrà accesso alla sezione critica}
I metodi per affrontare il deadlock sono sostanzialmente 4:
- Prevenzione statica (deadlock prevention): consiste nell’adottare una politica di allocazione delle risorse che assicuri a priori che non si verifichi mai almeno una delle 4 condizioni necessarie e sufficienti per il deadlock; PREVENIRE
- Prevenzione dinamica (deadlock avoidance): consiste nel valutare di volta in volta (dinamicamente appunto) se il soddisfacimento di una richiesta per una risorsa può portare al deadlock; EVITARE
- Eliminazione delle situazioni di deadlock (deadlock detection and recovery): consiste nell’effettuare periodicamente una verifica dell’esistenza di situazioni di deadlock, ed in caso eliminare il blocco incriminato e ripristinare il sistema; ELIMINARE
- Ignorare il problema; IGNORARE DEADLOCK PREVENTION: In realtà, per implementare la prevenzione statica si può agire soltanto sulle condizioni necessarie e sufficienti: hold and wait e attesa circolare. Si può eliminare la hold and wait mediante allocazione globale delle risorse, nel senso che: s’impone al processo di richiedere tutte le risorse tramite un’unica primitiva di richiesta “richiedi R1,…,Rn”; tali risorse verranno assegnate al processo solo se sono tutte disponibili, altrimenti il processo viene sospeso senza impegnare alcuna risorsa. Ogni volta che un processo termina, rilascia tutte le risorse contemporaneamente tramite un’unica primitiva di rilascio. Tuttavia, tale soluzione presenta come svantaggi:
- la complessità delle primitive richiedi e rilascia;
- il fatto che il programmatore deve conoscere a priori tutte le risorse che il programma utilizzerà (buffer di I/O, ecc…). Si può eliminare l’attesa circolare mediante allocazione gerarchica delle risorse, nel senso che: se ad un processo è stata assegnata una risorsa Rk di livello k, tale processo non può richiedere una risorsa Rl di livello l≤k a meno di non rilasciare Rk, per poi chiedere e acquisire Rl e richiedere Rk. P1 che ha acquisito R1 può richiedere R2, ma P2 che ha acquisito R2 non può richiedere R1, per cui si spezza la catena. Tuttavia, tale soluzione presenta come svantaggi:
- una gestione complessa;
- l’impossibilità talvolta di rilasciare una risorsa e poi riacquisirla (come un file da aggiornare).
Inoltre, è possibile adottare soluzioni miste: nel caso di risorse di tipo diverso (stampante, area di memoria, CPU, ecc…) adotto l’allocazione gerarchica, mentre nel caso di risorse dello stesso tipo (quindi se sono presenti più stampanti, più memorie, ecc…) allocazione globale. DEADLOCK AVOIDANCE (algoritmo del banchiere): Quando un processo richiede una o più risorse, gli vengono assegnate solo se esiste una sequenza salva, ossia una sequenza di esecuzione per la quale tutti i processi possono essere portati a termine usando le risorse disponibili in quel momento e quelle che via via saranno restituite durante l’esecuzione. In pratica, per ogni processo si prende in considerazione il caso peggiore, ossia la massima richiesta di risorse; se il sistema rimane sempre in stati salvi, non si possono verificare fenomeni di blocco critico. DEADLOCK DETECTION AND RECOVERY: Esistono algoritmi per rilevare situazioni di blocco critico, ad esempio algoritmi di ricerca di cicli nei grafi di allocazione delle risorse; ed esistono algoritmi per recuperare da situazioni di blocco:
- kill dei processi, perché un processo che muore libera le risorse che deteneva;
- preemption delle risorse. Tuttavia, vi sono una serie di problematiche: quali processi uccidere, tutti quelli coinvolti in un blocco o uno alla volta, chi sceglie i processi da uccidere, quali risorse sottrarre e a quali processi, come sottrarle, con quale frequenza eseguire tali algoritmi, ecc… ATOMICLONG: Esempio grafico di corsa critica nell’accesso ad una variabile di stato shared (condivisa) e mutable (mutabile): l’istruzione: value++; à richiede: fetch and store di value
astrazione più alto che garantiscono prestazioni migliori, maggiore usabilità, leggibilità, affidabilità e produttività. SYNCHRONIZED: Java supporta la mutua esclusione nell’accesso a risorse condivise tramite la keyword synchronized, la quale può essere anteposta a protezione di un blocco di istruzioni o di un singolo metodo. Ogni oggetto Java ha associato un lock, spesso chiamato implicit monitor lock, che viene acquisito quando si invocano metodi o istruzioni synchronized. Ad esempio, se consideriamo un ipotetico metodo: synchronized withdraw(int amount) {…}, un thread che effettua la chiamata cc.withdraw(amount) acquisisce l’implicit monitor lock associato all’oggetto ContoCorrente cc. Un implicit monitor lock può essere in possesso di un solo thread alla volta. Un thread che non riesce ad acquisire un implicit monitor lock già occupato rimane sospeso in una coda chiamata entry set (o entry queue) fino a che il lock non ritorna disponibile. Il lock viene automaticamente rilasciato quando il thread esce dalla sezione synchronized o se viene interrotto da un’eccezione (altrimenti rischio starvation per gli altri processi). Un singolo oggetto con eventualmente molti metodi o blocchi synchronized ha comunque un solo lock associato, di conseguenza:
- due thread diversi non possono accedere contemporaneamente a due sezioni synchronized diverse di uno stesso oggetto;
- tutti i thread che cercano di chiamare un qualunque metodo sincronizzato di uno stesso oggetto per cui è stato già acquisito il lock da parte di un altro thread, si vanno a sospendere sulla stessa coda, la entry set. Inoltre, un thread che è entrato in una sezione sincronizzata di un oggetto, poiché detiene il lock sull’oggetto, può accedere alla stessa sezione sincronizzata in modo ricorsivo. Nel caso di chiamate ricorsive, il lock sull’oggetto viene effettivamente rilasciato solo al termine della prima invocazione. N.B.: qualora un oggetto abbia almeno un metodo sincronizzato, è buona norma sincronizzarli tutti; ad ogni modo, l’importante è che non si lascino sprotetti metodi che coinvolgono variabili di stato shared mutable. Se una relazione invariante è un’azione composta che coinvolge più variabili di stato, tutte le variabili devono essere protette da uno stesso lock. WAIT(): la primitiva wait() sospende il thread che la invoca. Ogni oggetto Java è dotato di una wait queue (o wait set), una coda dove vanno a sospendersi i thread a seguito di una wait(). Si può parlare di una condition variable intrinseca per ogni oggetto. Un thread può invocare la primitiva wait() solo dopo aver acquisito il lock sull’oggetto, quindi solo all’interno di un costrutto synchronized. La wait() sospende il thread sulla wait queue e in maniera atomica viene rilasciato l’implicit monitor lock sull’oggetto. Il lock viene
rilasciato anche nel caso di chiamate ricorsive, ma non vengono rilasciati altri lock eventualmente acquisiti su altri oggetti (quindi nel caso di chiamate innestate, vedi slide 47). Quindi, il monitor associato a ogni oggetto Java ha due code associate:
- la entry set, dove si sospendono i thread a seguito di una chiamata ad un metodo synchronized di un oggetto per cui era già stato acquisito l’implicit monitor lock da un altro thread;
- la entry queue, dove si sospendono i thread che invocano la primitiva wait(). Esistono versioni della primitiva wait() dotate di timeout, per cui i threads vengono risvegliati automaticamente al termine del timeout. NOTIFY(): un thread può invocare la primitiva notify() su un oggetto per risvegliare un solo thread che era sospeso nella wait-queue associata a quell’oggetto. Il thread risvegliato viene scelto arbitrariamente, la scelta dipende dall’implementazione della JVM. NOTIFYALL(): un thread può invocare la primitiva notifyAll() su un oggetto per risvegliare tutti i threads che erano sospesi nella wait-queue associata a quell’oggetto. I threads risvegliati vengono resi logicamente pronti ad eseguire ma ovviamente entrano nel monitor uno alla volta. Java adotta la semantica signal and continue, per cui un thread che invoca la primitiva notify() o notifyAll() su un oggetto, continua ad eseguire. Per cui, il processo o i processi risvegliati, dovendo aspettare che il thread segnalante esca dal monitor e rilasci quindi l’implicit monitor lock, dalla wait queue (associata all’oggetto su cui è stata invocata la notify() o la notifyAll()) vanno ad accodarsi nella entry queue. RISVEGLI SPURI: i threads possono essere risvegliati anche per via di interruzioni o eccezioni. Pertanto, l’invocazione di una wait() o di una sleep() deve essere sempre eseguita all’interno di un costrutto try-catch, per cercare di raccogliere un’eventuale eccezione che possa arrivare e forzare il risveglio del
MODELLO THREAD-PER-TASK: un’applicazione concorrente è composta da molti task logici che devono essere eseguiti sulla macchina. I task sono le operazioni logiche da svolgere, come il servizio eseguito da un web server in risposta ad una richiesta di un client. L’esecuzione di un task viene concretamente attuata da un thread su cui il task viene ad essere mappato. Ci sono varie possibilità di mapping. I server sequenziali mappano tutti i task logici su un singolo thread. I server concorrenti multi-threaded mappano ogni task logico su un thread separato, secondo il modello thread-per-task. Nella pratica un web server di questo tipo genera un thread per ogni richiesta arrivata da un client. Si parla di unbounded thread creation, soluzione che però può presentare dei problemi, in termini di:
- overhead di gestione, per la creazione e terminazione dei threads;
- consumo di risorse, un numero troppo elevato di threads comporta elevati costi di scheduling per la loro gestione;
- problemi di stabilità, qualunque JVM o sistema operativo ha un limite massimo di thread generabili (possibili errori OutOfMemory). MODELLO THREAD POOL: per evitare i problemi connessi all’unbounded thread creation, si può adottare un modello diverso dal thread-per-task, il modello thread pool, in cui il sistema mette a disposizione un numero prefissato di threads su cui possono essere mappati i task logici da eseguire. I threads vengono generati una sola volta all’inizio e poi eseguono i task man mano che arrivano. Uno stesso thread esegue quindi molti task, uno dopo l’altro. Nel modello thread pool i threads vengono creati all’inizio per poi essere allocati su richiesta, per cui i costi di creazione/terminazione sono bassissimi (corrispondono soltanto alla creazione iniziale). La dimensione del pool è un parametro di configurazione significativo che dipende da molti fattori (applicazione, algoritmo, architettura hardware, ecc…). Il modello thread pool è a tutti gli effetti un pattern Produttori/Consumatori, nel senso che ci sono delle attività che producono dei task logici da eseguire e dei threads (o workers) che li eseguono o in altre parole li consumano. Tuttavia, il modello thread pool presenta il problema della saturazione: nella situazione in cui tutti i thread del pool sono allocati a dei task, si pone il problema di come comportarsi con ulteriori richieste di servizio. Possibili soluzioni sono:
- allargare la pool size;
- accodare le richieste in attesa che si liberi un worker, informando il client dell’attesa;
- segnalare il sovraccarico ai client per rallentarli;
- scartare ulteriori richieste in caso di saturazione.
EXECUTOR FRAMEWORK:
Il framework Executor offre tutti gli strumenti per realizzare il modello thread pool in Java. Tale framework consente di disaccoppiare la reale esecuzione dei threads dalla politica di esecuzione, mediante la quale è possibile definire quanti thread mettere a disposizione, come allocarli, come gestire i casi di saturazione, ecc…Per creare un thread pool si utilizza la classe statica Executors, che fornisce diverse implementazioni di modelli thread pool tra cui scegliere, come:
- newFixedThreadPool: crea un thread pool statico, di dimensione fissa;
- newCachedThreadPool: crea un thread pool dinamico, di dimensione variabile, è stato pensato principalmente per task asincroni e brevi;
- newSingleThreadPool: crea un thread pool costituito da un singolo thread;
- newScheduledThreadPool: è una variante del FixedThreadPool che supporta la periodic o delayed task execution. N.B.: in tutte le implementazioni i thread idle vengono riusati e i thread morti rimpiazzati da nuovi thread. Se creiamo un pool di thread con Executor, questi rimangono in esecuzione fino a quando il pool non viene spento, utilizzando l’apposito metodo shutdown(). Però prima di chiamare tale metodo sul pool creato si deve attendere la terminazione di tutti i task. A tal fine, le concurrency utilities mettono a disposizione un contatore all’indietro MT-safe, il CountDownLatch, il quale va inizializzato con un valore count che rappresenta quanti task attendere. Ogni thread, come ultima istruzione, deve invocare il metodo countDown() sull’istanza CountDownLatch per decrementare il latch. Il thread main, invece, che si occupa di lanciare i thread creati con executor, prima di eseguire lo shutdown sull’istanza ExecutorService, deve invocare il metodo await() sull’istanza CountDownLatch per attendere la terminazione dei task. SEMAPHORE: La classe Semaphore permette di creare semafori con una semantica identica a quella dei semafori tradizionali. Un oggetto Semaphore è una struttura dati costituita da un contatore (che rappresenta il numero di permessi disponibili) e da una coda su cui si vanno a sospendere i thread. Su un oggetto Semaphore si possono invocare i metodi acquire() e release(). Acquire(), corrisponde alla wait() dei semafori tradizionali, sospende un thread se il numero di permessi è 0 oppure decrementa il numero di permessi. Release(), corrisponde alla signal() dei semafori tradizionali, risveglia un eventuale thread sospeso (ossia rilascia un permesso che viene subito acquisito da un thread sospeso) oppure incrementa il numero di permessi. Il costruttore Semaphore() consente di inizializzare un