1. CountDownLatch: avvio su segnale
Nel mondo multithread è spesso necessario far lavorare un gruppo di thread in modo coordinato — affinché tutti inizino, finiscano o passino alla fase successiva insieme. Per esempio:
Immaginate una gara. Le auto sono alla partenza — qualcuno ha già scaldato il motore, qualcuno sta ancora controllando le gomme. Ma finché il giudice non agita la bandiera, nessuno parte. Questa è proprio la classica esigenza di coordinazione.
Oppure un altro esempio: state preparando la cena con gli amici — qualcuno taglia le verdure, qualcuno mette l’acqua sul fuoco, qualcuno cerca dove sia finito il sale. L’importante è che tutti finiscano i preparativi prima di iniziare a cucinare.
Per questi casi Java ci offre strumenti di sincronizzazione pronti all’uso — sicuri, chiari e senza il dolore di wait() e notify(). Uno dei più utili è CountDownLatch. Funziona come un contatore-blocco: finché non scende a zero, la «porta» è chiusa e nessuno prosegue. Quando tutti hanno dato il proprio segnale, il latch si apre e i thread partono all’unisono.
CountDownLatch
CountDownLatch è una «valvola monouso» che consente a uno o più thread di attendere finché altri thread non completano un certo numero di operazioni.
È come la partenza di una maratona: tutti i corridori sono sulla linea, aspettano lo sparo della pistola. Non appena l’arbitro spara (il countdown arriva a zero), partono tutti.
Come funziona in pratica
CountDownLatch è come un fischietto di partenza per i thread. Alla creazione si imposta un numero — per esempio, 3. È come tre segnali che devono arrivare prima che inizi la corsa.
I thread che devono aspettare l’avvio chiamano await(). Stanno sulla linea e sono pronti a scattare, ma tengono ancora il freno. Altri thread, man mano che completano la preparazione, chiamano countDown() — come se dicessero: «Sono pronto!».
Non appena il contatore arriva a zero — boom! — tutti i thread in attesa partono simultaneamente.
Ma ricordate: CountDownLatch è monouso. Una volta che il contatore è arrivato a zero, non si può riportarlo indietro. Non è un revolver, ma un petardo: scoppia — e basta.
Esempio: attendere il completamento di N task
import java.util.concurrent.CountDownLatch;
public class LatchDemo {
public static void main(String[] args) throws InterruptedException {
int workers = 3;
CountDownLatch latch = new CountDownLatch(workers);
for (int i = 1; i <= workers; i++) {
int id = i;
new Thread(() -> {
System.out.println("Lavoratore " + id + " ha iniziato il lavoro");
try { Thread.sleep(500 + id * 200); } catch (InterruptedException ignored) {}
System.out.println("Lavoratore " + id + " ha terminato il lavoro");
latch.countDown(); // decrementiamo il contatore
}).start();
}
System.out.println("Il thread principale attende il completamento di tutti i lavoratori...");
latch.await(); // attendiamo finché tutti i lavoratori terminano
System.out.println("Tutti i lavoratori hanno finito! Continuiamo il lavoro principale.");
}
}
Output:
Il thread principale attende il completamento di tutti i lavoratori...
Lavoratore 1 ha iniziato il lavoro
Lavoratore 2 ha iniziato il lavoro
Lavoratore 3 ha iniziato il lavoro
Lavoratore 1 ha terminato il lavoro
Lavoratore 2 ha terminato il lavoro
Lavoratore 3 ha terminato il lavoro
Tutti i lavoratori hanno finito! Continuiamo il lavoro principale.
Esempio: partenza simultanea «su segnale»
CountDownLatch startSignal = new CountDownLatch(1);
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
System.out.println(Thread.currentThread().getName() + " in attesa del via");
startSignal.await(); // attendiamo il segnale
System.out.println(Thread.currentThread().getName() + " parte!");
} catch (InterruptedException ignored) {}
}).start();
}
Thread.sleep(1000);
System.out.println("Segnale di partenza!");
startSignal.countDown(); // tutti i thread partono contemporaneamente
2. CyclicBarrier: fasi ripetute, azioni di barriera
CyclicBarrier: ci vediamo al falò
CyclicBarrier è un punto d’incontro per i thread. Ognuno segue il proprio percorso, fa qualcosa di suo, e poi tutti si ritrovano alla «barriera» — come attorno a un falò in montagna. Quando sono arrivati tutti, la barriera si apre e il gruppo riparte compatto.
La differenza principale rispetto a CountDownLatch è che questa barriera può essere riutilizzata più e più volte. Dopo ogni sosta comune si «ricarica» e il team può proseguire verso la tappa successiva.
Immaginate: Un gruppo di escursionisti percorre un lungo itinerario. Ognuno va al proprio ritmo: c’è chi fotografa le farfalle, c’è chi cerca il Wi‑Fi. Ma a ogni valico si incontrano al falò, si aspettano a vicenda e decidono dove andare dopo. Ecco CyclicBarrier in azione.
Come funziona
Si crea una barriera specificando quanti partecipanti devono riunirsi, per esempio 4. Ogni thread, arrivato al checkpoint, chiama await() — e attende gli altri. Quando tutti e quattro sono arrivati, la barriera «scatta» e li lascia andare.
Si può perfino specificare un’«azione di barriera» — un pezzetto di codice che verrà eseguito esattamente una volta quando il gruppo si è riunito. Per esempio, accendere quel falò o scrivere un log: «Fase completata, si prosegue». Per questo, al costruttore si passa un Runnable.
Importante: a differenza del monouso CountDownLatch, CyclicBarrier è riutilizzabile. Dopo ogni «ritrovo» è di nuovo pronto per la tappa successiva — come un falò da campo che si può riaccendere ancora e ancora.
Esempio: sincronizzazione delle fasi
import java.util.concurrent.CyclicBarrier;
public class BarrierDemo {
public static void main(String[] args) {
int parties = 3;
CyclicBarrier barrier = new CyclicBarrier(parties, () -> {
System.out.println("Tutti sono arrivati alla barriera! Iniziamo una nuova fase.");
});
for (int i = 1; i <= parties; i++) {
int id = i;
new Thread(() -> {
try {
System.out.println("Thread " + id + " lavora nella fase 1");
Thread.sleep(300 + id * 200);
System.out.println("Thread " + id + " attende la barriera");
barrier.await(); // attendiamo gli altri
System.out.println("Thread " + id + " lavora nella fase 2");
Thread.sleep(200 + id * 100);
System.out.println("Thread " + id + " attende la barriera (2)");
barrier.await(); // attendiamo di nuovo
System.out.println("Thread " + id + " ha terminato il lavoro");
} catch (Exception e) {
System.out.println("Errore: " + e);
}
}).start();
}
}
}
Output:
Thread 1 lavora nella fase 1
Thread 2 lavora nella fase 1
Thread 3 lavora nella fase 1
Thread 1 attende la barriera
Thread 2 attende la barriera
Thread 3 attende la barriera
Tutti sono arrivati alla barriera! Iniziamo una nuova fase.
Thread 1 lavora nella fase 2
...
Azione di barriera
Si può passare al costruttore di CyclicBarrier un’azione (Runnable) che verrà eseguita una volta quando tutti i thread sono arrivati alla barriera (per esempio, aggiornare lo stato o scrivere un log).
Trappole: e se un thread cade in errore?
Se uno dei thread lancia un’eccezione o non raggiunge la barriera, gli altri attenderanno per sempre — o riceveranno una BrokenBarrierException. La barriera si «rompe» e va ricreata.
Ecco come si può riscrivere questa sezione in uno stile più vivido, figurato e colloquiale — in modo che suoni come una naturale continuazione della linea dell’«orchestra»:
3. Phaser: un abile direttore di un grande concerto
Phaser è una sorta di «superbarriera». Unisce i pregi di CountDownLatch e CyclicBarrier, ma è molto più flessibile. È come un’orchestra in cui i musicisti possono entrare e uscire tra le parti del concerto, e il direttore riesce comunque a far sì che ogni parte inizi quando tutti sono pronti.
A differenza di una barriera tradizionale, Phaser lavora a tappe — le fasi si susseguono una dopo l’altra. C’è chi suona solo nella prima parte, chi si unisce più tardi e chi lascia prima — tutto questo Phaser lo gestisce senza problemi.
Come funziona
Per prima cosa si crea un Phaser, di solito indicando il numero di partecipanti — parties. Ogni thread si registra (register()), esegue la propria parte e alla fine della fase chiama arriveAndAwaitAdvance() — segnala di aver finito e attende gli altri. Quando tutti sono arrivati a questo punto, il Phaser passa alla fase successiva e il processo si ripete.
Se un partecipante non serve più — può fare un bell’«inchino» e lasciare la scena tramite arriveAndDeregister(). Nuovi partecipanti, al contrario, possono unirsi anche durante il concerto — con register().
Quando Phaser è meglio di Barrier
Phaser è da preferire se il vostro programma non vive in un solo ritmo, ma in più ritmi:
- il numero di thread cambia al volo,
- ci sono più fasi e non tutti i partecipanti devono prender parte a tutte,
- oppure volete la massima flessibilità senza troppi problemi di sincronizzazione manuale.
In sostanza, Phaser è il direttore d’orchestra. Non solo agita la bacchetta, ma si adatta anche all’organico, al numero di parti del concerto e perfino al fatto che qualcuno sia in ritardo o esca prima.
Esempio: elaborazione a fasi con numero di thread dinamico
import java.util.concurrent.Phaser;
public class PhaserDemo {
public static void main(String[] args) {
Phaser phaser = new Phaser(1); // thread principale
for (int i = 1; i <= 3; i++) {
phaser.register(); // registriamo un partecipante
int id = i;
new Thread(() -> {
for (int phase = 1; phase <= 2; phase++) {
System.out.println("Thread " + id + " lavora nella fase " + phase);
try { Thread.sleep(200 + id * 100); } catch (InterruptedException ignored) {}
phaser.arriveAndAwaitAdvance(); // aspettiamo gli altri
}
System.out.println("Thread " + id + " ha terminato il lavoro");
phaser.arriveAndDeregister(); // usciamo dal phaser
}).start();
}
// Il thread principale partecipa anche alle fasi
for (int phase = 1; phase <= 2; phase++) {
phaser.arriveAndAwaitAdvance();
System.out.println("Thread principale: fase " + phase + " completata");
}
phaser.arriveAndDeregister();
System.out.println("Tutte le fasi sono terminate!");
}
}
Particolarità:
- Si possono aggiungere/rimuovere partecipanti al volo.
- Si può ottenere il numero della fase corrente: phaser.getPhase().
- Si può terminare il phaser: phaser.forceTermination().
4. Exchanger: scambio di blocchi di dati tra thread
Exchanger<T> è un sincronizzatore per lo scambio di dati tra due thread. Ogni thread chiama exchange(data) e, quando i due thread si «incontrano», si scambiano i rispettivi dati.
Analogia: Due corrieri si incontrano a un incrocio e si scambiano i pacchi.
Come funziona?
- Un thread chiama exchange(data1) — attende il secondo.
- Il secondo thread chiama exchange(data2) — entrambi ricevono i dati dell’altro.
- Se il secondo thread non arriva — il primo aspetta (si può impostare un timeout).
Esempio: scambio di buffer tra producer e consumer
import java.util.concurrent.Exchanger;
public class ExchangerDemo {
public static void main(String[] args) {
Exchanger<String> exchanger = new Exchanger<>();
// Producer
new Thread(() -> {
String data = "Dati dal producer";
try {
System.out.println("Producer: invia i dati");
String response = exchanger.exchange(data);
System.out.println("Producer: ha ricevuto la risposta: " + response);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
// Consumer
new Thread(() -> {
try {
String received = exchanger.exchange("Risposta dal consumer");
System.out.println("Consumer: ha ricevuto i dati: " + received);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
}
Output:
Producer: invia i dati
Consumer: ha ricevuto i dati: Dati dal producer
Producer: ha ricevuto la risposta: Risposta dal consumer
Applicazioni:
- Scambio di buffer tra thread (ad esempio, uno legge da file, l’altro scrive in rete).
- Sincronizzazione di fasi tra due thread.
5. Pratica: elaborazione a pipeline parallela
Compito: «tick» di gioco (fasi)
Supponiamo di avere più thread, ciascuno responsabile della propria parte del mondo di gioco (per esempio, fisica, AI, rendering). Tutti devono sincronizzarsi a ogni «tick» (fase), per evitare desincronizzazioni.
Soluzione: usiamo CyclicBarrier o Phaser.
import java.util.concurrent.CyclicBarrier;
public class GameTickDemo {
public static void main(String[] args) {
int subsystems = 3;
CyclicBarrier barrier = new CyclicBarrier(subsystems, () -> {
System.out.println("Tutti i sottosistemi hanno terminato il tick. Iniziamo il successivo.");
});
for (int i = 1; i <= subsystems; i++) {
int id = i;
new Thread(() -> {
for (int tick = 1; tick <= 5; tick++) {
System.out.println("Sottosistema " + id + " lavora nel tick " + tick);
try { Thread.sleep(100 + id * 50); } catch (InterruptedException ignored) {}
try {
barrier.await();
} catch (Exception e) {
e.printStackTrace();
}
}
}).start();
}
}
}
Compito: «valvola» per un gran numero di lavoratori
Supponiamo di avere 100 thread-lavoratori, che devono partire contemporaneamente dopo la preparazione (per esempio, un test di carico).
Soluzione: usiamo CountDownLatch.
import java.util.concurrent.CountDownLatch;
public class MassStartDemo {
public static void main(String[] args) throws InterruptedException {
int workers = 100;
CountDownLatch ready = new CountDownLatch(workers);
CountDownLatch start = new CountDownLatch(1);
for (int i = 0; i < workers; i++) {
new Thread(() -> {
System.out.println("Thread pronto alla partenza");
ready.countDown(); // segnaliamo la prontezza
try {
start.await(); // attendiamo il segnale comune
System.out.println("Il thread parte!");
} catch (InterruptedException ignored) {}
}).start();
}
ready.await(); // aspettiamo finché tutti i thread sono pronti
System.out.println("Tutti pronti! VIA!");
start.countDown(); // diamo il segnale di partenza
}
}
6. Errori tipici nell’uso dei sincronizzatori
Errore n. 1: usare CountDownLatch come barriera riutilizzabile.
CountDownLatch è monouso! Dopo che è arrivato a zero non può essere «ricaricato». Per fasi riutilizzabili usate CyclicBarrier o Phaser.
Errore n. 2: eccezioni non gestite (InterruptedException, BrokenBarrierException).
I metodi await() possono lanciare eccezioni — gestitele sempre, altrimenti un thread può «bloccarsi» o terminare con errore. Prestate attenzione a InterruptedException e BrokenBarrierException.
Errore n. 3: uno dei thread non è arrivato alla barriera.
Se un thread «cade» o non chiama await(), gli altri attenderanno per sempre (o riceveranno una BrokenBarrierException). Assicuratevi che tutti i partecipanti arrivino alla barriera.
Errore n. 4: ci si dimentica di deregister() in Phaser.
Se un thread ha terminato il lavoro ma non ha chiamato arriveAndDeregister(), il Phaser attenderà un partecipante «morto». Rimuovete sempre correttamente i thread dal Phaser.
Errore n. 5: usare Exchanger con più di due thread.
Exchanger funziona solo per lo scambio tra due thread. Se i thread sono di più — andate incontro a un deadlock.
Errore n. 6: mescolare sincronizzatori diversi senza capirne il funzionamento.
Non usate contemporaneamente diversi tipi di barriere/latch per lo stesso gruppo di thread — può portare a confusione e blocchi.
GO TO FULL VERSION