Il professor Ricci introduce la programmazione multi-thread in Java con una precisazione importante: Java adotta un approccio ibrido. Da un lato, le classi per i thread sono librerie di base (J2SE, java.util.concurrent), indipendenti dal sistema operativo — a differenza dei PThread in C che sono specifici per sistemi POSIX. Dall'altro, il linguaggio include elementi nativi come la parola chiave synchronized e il fatto che ogni oggetto Java ha nativamente un lock associato.
Java non ha voluto "bucare" il paradigma a oggetti introducendo oggetti attivi come concetto di prima classe. Se lo avesse fatto, si sarebbe aperto un mondo di ricerca (a cui il gruppo di Bologna ha contribuito) su "oggetti che inglobano il flusso di controllo". Ma non sarebbe piu stata programmazione orientata agli oggetti pura. Java ha scelto la via pragmatica: thread come oggetti normali, con il supporto del linguaggio per la sincronizzazione.
Due modi principali per creare thread in Java:
Si estende la classe Thread e si sovrascrive il metodo run():
class MyThread extends Thread {
private int id;
MyThread(int id) {
this.id = id;
}
public void run() {
System.out.println("Thread " + id + " avviato");
try { Thread.sleep(5000); } catch (InterruptedException e) {}
System.out.println("Thread " + id + " done");
}
}
// Uso:
MyThread t1 = new MyThread(1);
MyThread t2 = new MyThread(2);
t1.start();
t2.start();
Si implementa l'interfaccia Runnable e si passa l'istanza a un thread:
class MyRunnable implements Runnable {
private String name;
MyRunnable(String name) {
this.name = name;
}
public void run() {
System.out.println(name + " avviato");
}
}
// Uso:
Thread t1 = new Thread(new MyRunnable("Thread-1"));
Thread t2 = new Thread(new MyRunnable("Thread-2"));
t1.start();
t2.start();
Questo approccio e preferibile perche disaccoppia il comportamento attivo dalla gerarchia di classi, permettendo alla classe di estendere altre classi se necessario.
Il metodo run() non va mai chiamato direttamente dal codice applicativo. Quando chiamiamo start(), il sistema (JVM + librerie) crea un nuovo thread del sistema operativo e invoca run() su quel thread. Se chiamiamo run() direttamente, viene eseguito sul thread corrente (quello del main) in modo sequenziale, senza creare un nuovo flusso di controllo. run() e pubblico solo per definizione del contratto dell'interfaccia — idealmente non dovrebbe essere pubblico.
I componenti attivi spesso non devono terminare. A differenza di una computazione tradizionale (input, processa, output, termina), i thread tipicamente hanno un comportamento ciclico, persistente. Il metodo run() non restituisce nulla (void) e non viene chiamato da noi: lo chiama la JVM sul nuovo thread.
Il professor Ricci anticipa un esempio che sara sviluppato nella prossima lezione. L'idea e ordinare un vettore enorme usando quicksort, ma sfruttando tutti i core del processore. Nel main si genera un array di 400 milioni di elementi, si chiama Arrays.sort() (sequenziale) e si misura il tempo. La sfida e parallelizzare l'ordinamento.
Il professore usa l'esempio concreto dal repo labactivity03 per mostrare come si realizza una sezione critica in Java con il costrutto synchronized. Due thread, WorkerA e WorkerB, eseguono azioni cicliche. WorkerA esegue A1, A2, A3; WorkerB esegue B1, B2, B3. L'obiettivo e fare in modo che A2 e A3 (oppure A2 da sola) siano eseguite in mutua esclusione rispetto a B2 e B3.
L'idea base e: un blocco synchronized(lock) crea una regione critica: prima di entrare, il thread cerca di acquisire il lock intrinseco dell'oggetto specificato. Se un altro thread lo possiede gia, il thread si blocca (viene messo nell'entry set dell'oggetto). Quando il lock viene rilasciato (usciendo dal blocco), un thread in attesa viene risvegliato e puo acquisirlo.
Il costrutto synchronized implementa automaticamente: (1) mutua esclusione sul blocco, (2) rilascio automatico del lock anche in caso di eccezione, (3) relazione happens-before di memoria. Non e possibile dimenticare di rilasciare il lock, a differenza dei lock espliciti.
Nel codice mostrato dal professore, i worker estendono una classe base Worker che fornisce metodi di utilita come waitSome() (attesa casuale). La struttura e:
// Idea: sezione critica con blocco synchronized su un oggetto condiviso
Object lock = new Object();
// Thread A
synchronized (lock) {
// A2: azione in sezione critica
// A3: azione in sezione critica
}
// Thread B
synchronized (lock) {
// B2: azione in sezione critica
// B3: azione in sezione critica
}
Il professore sottolinea che questo e il modo "piu raw, piu semplice, piu basso livello" per realizzare una sezione critica. Nella pratica si usano astrazioni di piu alto livello, ma comprendere synchronized e fondamentale.
Il cuore del laboratorio e l'esempio del contatore condiviso. Il professore presenta una classe Counter con un campo int count e un metodo inc(). Due thread incrementano lo stesso contatore. Senza sincronizzazione, l'operazione count++ non e atomica: e una sequenza read-modify-write (leggi, incrementa, scrivi).
Nel codice del professore, questo si manifesta come lost update: due thread possono leggere lo stesso valore, incrementarlo ciascuno nella propria variabile locale, e riscriverlo, perdendo uno degli incrementi. Con 100.000 incrementi per thread (totale atteso 200.000), il risultato effettivo sara minore, e diverso a ogni esecuzione.
count++ non e atomico. A livello macchina corrisponde a: (1) carica count in un registro, (2) incrementa il registro, (3) scrivi il registro in count. Un interleaving fra (1) e (3) di due thread produce risultati errati. synchronized rende l'intera sequenza atomica rispetto agli altri thread sincronizzati sullo stesso lock.
Il professore simula mentalmente lo scenario: se entrambi i thread leggono count=5 prima che uno dei due lo abbia scritto, entrambi scriveranno 6 invece di 7. Ecco un simulatore interattivo per visualizzare il fenomeno:
Il professore introduce la definizione di thread safety tratta dal testo "Java Concurrency in Practice": una classe e thread-safe se puo essere usata correttamente in qualsiasi contesto, sequenziale o concorrente, senza che chi la usa debba preoccuparsi di sincronizzazione aggiuntiva. Le classi di java.util (es. ArrayList) non sono thread-safe perche la sincronizzazione ha un costo e sarebbe sprecata in contesti sequenziali.
La scelta di non rendere thread-safe le classi della libreria standard per default e una decisione progettuale: il programmatore sceglie quando serve e paga solo quel costo. In contesti concorrenti si usano wrapper sincronizzati (Collections.synchronizedList(...)) o strutture specializzate (ConcurrentHashMap).
Il professore mostra un'evoluzione: invece di un blocco synchronized esplicito, si puo dichiarare l'intero metodo come synchronized. In Java, scrivere:
public synchronized void inc() {
count++;
}
equivale a:
public void inc() {
synchronized (this) {
count++;
}
}
Il lock viene acquisito sull'oggetto stesso (this) prima di eseguire il corpo del metodo e rilasciato automaticamente all'uscita. Questo pattern si avvicina all'idioma del monitor: una classe i cui metodi pubblici sono eseguiti in mutua esclusione per costruzione.
public class Counter {
private int count = 0;
public void inc() { count++; }
public int get() { return count; }
}
// Uso concorrente: PERICOLOSO!
// count++ non atomico -> lost update
public class SafeCounter {
private int count = 0;
public synchronized void inc() { count++; }
public synchronized int get() { return count; }
}
// Uso concorrente: SICURO
// synchronized rende inc() e get() atomici
Il professore nota che get() deve essere synchronized anch'esso: altrimenti un thread potrebbe leggere un valore incoerente mentre un altro sta eseguendo inc() (anche se count++ e su una variabile int, la visibilita di memoria non e garantita senza sincronizzazione).
Oltre a synchronized, Java offre lock espliciti nel pacchetto java.util.concurrent.locks. Il professore presenta ReentrantLock e le sue varianti, sottolineando un aspetto critico: il lock va rilasciato esplicitamente, e per questo il pattern canonico prevede try/finally.
import java.util.concurrent.locks.ReentrantLock;
ReentrantLock lock = new ReentrantLock();
public void m() {
lock.lock(); // acquisisce il lock (si blocca se necessario)
try {
// sezione critica
} finally {
lock.unlock(); // rilasciato SEMPRE, anche in caso di eccezione
}
}
Il professore elenca le varianti offerte da ReentrantLock:
| Metodo | Comportamento |
|---|---|
lock() | Acquisisce il lock, bloccandosi se necessario. Ignora gli interrupt. |
lockInterruptibly() | Come lock() ma risponde all'interrupt: se il thread viene interrotto mentre aspetta, lancia InterruptedException e si sblocca. |
tryLock() | Tenta di acquisire il lock senza bloccarsi: restituisce subito true (lock preso) o false (lock occupato). |
tryLock(time, unit) | Tenta di acquisire il lock aspettando al piu il tempo specificato; restituisce false se scaduto. |
A differenza di synchronized, con ReentrantLock e possibile dimenticare di rilasciare il lock (se si omette il finally). Un'eccezione nel corpo del blocco lascerebbe il lock perpetuamente acquisito, bloccando tutti gli altri thread. Il pattern try/finally e obbligatorio.
Il professore nota che Java offre anche una sintassi piu moderna con try(var lock = ...) (ARM per lock, introdotta in versioni recenti), ma il pattern classico rimane il piu diffuso.
Il professore introduce un esempio piu complesso che illustra il pattern check-and-act e le sue insidie. Un BoundedCounter e un contatore con soglie minime e massime: se si tenta di incrementare oltre il massimo, lancia OverflowException; se si tenta di decrementare sotto il minimo, lancia UnderflowException.
Il problema emerge quando due worker operano sullo stesso contatore:
Il pattern check-and-act consiste nel controllare una condizione e poi agire di conseguenza. In un contesto concorrente, questo pattern e intrinsecamente non atomico: tra la lettura della condizione e l'azione, un altro thread puo modificare lo stato, invalidando la condizione appena letta.
Il pattern check-and-act e una delle cause piu comuni di race condition. La soluzione e rendere atomico l'intero blocco check+act usando synchronized o un lock. Il BoundedCounter mostra anche che bisogna sincronizzare tutti i metodi pubblici, non solo quelli che modificano lo stato.
Il professore ha dedicato la parte finale della lezione a mostrare due componenti di coordinazione riutilizzabili implementati come monitor: la barriera e il latch (countdown).
Una barriera e un punto di sincronizzazione in cui N thread si incontrano. Ogni thread chiama await() e rimane bloccato finche tutti gli N partecipanti non sono arrivati. L'ultimo thread che arriva sblocca tutti gli altri.
Implementazione con synchronized:
public class CyclicBarrierMonitor {
private int nParticipants;
private int nArrived;
public CyclicBarrierMonitor(int n) {
nParticipants = n;
nArrived = 0;
}
public synchronized void await() throws InterruptedException {
nArrived++;
while (nArrived < nParticipants) {
wait(); // aspetta che arrivino tutti
}
notifyAll(); // sblocca tutti
}
}
Esercizio del professore: pensate a come specificare la correttezza di una barriera in Java usando le proprieta di safety e liveness. La proprieta di safety e: "nessun thread procede oltre la barriera prima che tutti gli N thread siano arrivati". La liveness e: "se tutti gli N thread sono in attesa, qualcuno procede".
Versione con ReentrantLock + Condition: nella seconda implementazione, si usa una condition variable allArrived con await() e signalAll(). Attenzione: notifyAll chiamato sulla condition variable non equivale a signalAll — un errore subdolo che il professore ha mostrato in aula.
Un latch permette a thread di attendere (await()) finche non viene raggiunto un conteggio zero tramite chiamate countDown(). A differenza della barriera, non e ciclico e il conteggio e monotono decrescente.
public class CountDownLatchMonitor {
private int count;
public CountDownLatchMonitor(int count) { this.count = count; }
public synchronized void await() throws InterruptedException {
while (count > 0) { wait(); }
}
public synchronized void countDown() {
if (count > 0) {
count--;
if (count == 0) { notifyAll(); }
}
}
}
Nella barriera, tutti i thread aspettano tutti gli altri — e simmetrica. Nel latch, un thread (o piu) aspettano che un contatore si azzeri — e asimmetrica. Usi tipici del latch: aspettare che un insieme di servizi sia pronto prima di procedere.
Il professor Ricci apre la lezione introducendo il concetto fondamentale del design task-oriented: quando progettiamo un sistema concorrente, dobbiamo iniziare dall'analisi del problema per identificare i task — unita di lavoro ben definite, con un inizio e una fine — che compongono il sistema. Questa analisi puo partire dai dati (data-driven) o dalle funzionalita (function-driven).
Un task non e un thread. Un task e un'unita di lavoro logica, astratta, indipendente. Il thread e il veicolo che esegue il task. Separare i due concetti e il primo passo verso un buon design concorrente.
L'analisi procede rispondendo a tre domande fondamentali:
Il professore sottolinea l'importanza di adottare un approccio limpido e lineare: esplicitare le cose, non nasconderle dietro astrazioni troppo sofisticate. La semplicita nel design paga sempre, soprattutto nei sistemi concorrenti dove la complessita intrinseca e gia elevata.
Sull'Assignment 1: il docente sara disponibile come "coach". Non si tratta di ricevere soluzioni prefabbricate, ma di discutere insieme l'approccio. Se un gruppo arriva dicendo "non riesco a capire come fare una certa cosa", si puo discutere. Il vero obiettivo dell'Assignment e fare qualcosa di concreto, non banale, che permetta di discutere insieme durante il colloquio orale.
Il pattern Division of Labor, noto anche come strategia del divide-et-impera, consiste nel:
Il risultato di una buona analisi task-oriented e un'organizzazione del programma piu semplice, che facilita il recovery da errori (i task sono confini naturali per le transazioni) e promuove naturalmente la concorrenza.
Il professor Ricci introduce il Executor Framework di Java (java.util.concurrent, JDK 5.0) come l'astrazione principale per il task-oriented programming. Il framework disaccoppia la sottomissione di un task dalla sua esecuzione.
public interface Executor {
void execute(Runnable task);
}
Questa interfaccia semplice e il cuore del framework. Invece di creare un thread per ogni task (one-thread-per-task), si sottomette il task a un executor che decide quando, dove e come eseguirlo secondo una specifica execution policy.
Una execution policy specifica:
La classe factory Executors fornisce diverse implementazioni pronte:
| Metodo factory | Comportamento |
|---|---|
newFixedThreadPool(n) | Pool di dimensione fissa: mantiene n thread, li riusa per i task successivi |
newCachedThreadPool() | Pool flessibile: thread inattivi vengono terminati dopo 60s, se ne creano di nuovi se serve |
newSingleThreadExecutor() | Un singolo worker thread, task in coda FIFO |
newScheduledThreadPool(n) | Pool per task con ritardo o periodici |
Il docente mostra un esempio concreto di web server che usa un fixed thread pool al posto del pattern one-thread-per-task:
class TaskExecutionWebServer {
private static final int NTHREADS
= Runtime.getRuntime().availableProcessors() + 1;
private static final Executor exec =
Executors.newFixedThreadPool(NTHREADS);
public static void main(String[] args) throws IOException {
ServerSocket socket = new ServerSocket(80);
while (true) {
Socket connection = socket.accept();
exec.execute(() -> {
handleRequest(connection);
});
}
}
}
Il docente sottolinea: "Eseguire i task in modo sequenziale e come il SingleThreadWebServer: poor performance, poor responsiveness, poor resource usage. Con un thread per task si migliora tutto, ma si crea il problema della stabilita sotto carico pesante." Il thread pool e il punto di equilibrio.
Rispetto alla creazione esplicita di thread per ogni task (one-thread-per-task), l'approccio pool-based offre:
Il professore sottolinea anche un avvertimento importante: nel framework Executor, i task devono essere indipendenti. Un task non dovrebbe aspettare un altro task, perche bloccando un task si blocca anche il thread fisico che lo esegue, aprendo la possibilita a deadlock.
I task nell'Executor Framework sono unita di lavoro ben definite che iniziano e terminano. Non sono loop infiniti (come potrebbero essere thread, agenti o processi). Questa differenza e cruciale: un task compute-area termina; un thread server no.
Prima di immergersi completamente nell'asincrono, il professore dedica una parte importante della lezione al task-oriented programming in Java, che fa da ponte tra il mondo dei thread e quello degli event loop.
Un task è un'unità di lavoro astratta, discreta e indipendente, disaccoppiata dal concetto di thread. L'idea è di adottare una strategia divide-et-impera: identificare i confini dei task (attività indipendenti, che non dipendono dallo stato/risultato di altri task) e scegliere una execution policy adeguata.
Le tre strategie di esecuzione tradizionali sono:
| Strategia | Descrizione | Pro | Contro |
|---|---|---|---|
| Sequenziale | Un singolo thread serve le richieste una dopo l'altra | Semplice, nessuna concorrenza | Throughput e reattività bassissimi |
| Thread-per-task | Un nuovo thread per ogni task | Semplice, migliora throughput | Overhead di creazione, consumo risorse, instabilità |
| Executor con pool | Thread pool dimensionato sulle risorse disponibili | Stabile, efficiente, separa logica da fisica | Richiede attenzione a deadlock e blocking |
Le slide di laboratorio ripropongono lo stesso esempio del TaskExecutionWebServer con fixed thread pool mostrato sopra.
Il thread pool segue il pattern producer-consumer: i produttori sono le attività che inviano task, i consumatori sono i thread del pool che li eseguono. I thread del pool eseguono un ciclo implicito: prendi il prossimo task dalla coda, eseguilo, torna in attesa.
Il professore mette in guardia da un pericolo subdolo:
Se i task all'interno del pool usano una barriera o attendono il completamento di altri task, e il pool ha un numero limitato di thread, si può verificare un deadlock: tutti i thread del pool sono bloccati in attesa, e nessuno può eseguire i task che sbloccherebbero la situazione. La soluzione "sbagliata" ma immediata è creare un pool con tanti thread quanti sono i task. La soluzione corretta è ripensare la struttura dei task per evitare attese bloccanti all'interno del pool.
Non tutti i task sono di tipo fire-and-forget (Runnable). Spesso un task deve restituire un risultato. Per questo l'Executor Framework introduce due interfacce complementari: Callable<V> e Future<V>.
Simile a Runnable ma con due differenze fondamentali: il metodo si chiama call() invece di run(), e puo restituire un valore oltre a poter lanciare eccezioni.
public interface Callable<V> {
V call() throws Exception;
}
Un Future<V> rappresenta il risultato di un calcolo asincrono. Il professore spiega che e un contenitore che sara valorizzato nel tempo, una sorta di promessa di un risultato futuro.
public interface Future<V> {
boolean cancel(boolean mayInterruptIfRunning);
boolean isCancelled();
boolean isDone();
V get() throws InterruptedException, ExecutionException,
CancellationException();
V get(long timeout, TimeUnit unit) throws ...;
}
Il docente accenna a un dibattito interessante: tecnicamente le Future non sono "funzionali" perche introducono tempo e non-determinismo. Un collega (Viroli) sostiene che con le monadi si possa conciliare il tutto, ma il professore preferisce rimanere su una spiegazione pragmatica.
La differenza tra execute(Runnable) e submit(Callable) e la seguente:
// Esegue un task senza risultato
executor.execute(runnableTask);
// Sottomette un task con risultato e restituisce un Future
Future<Integer> future = executor.submit(callableTask);
Integer result = future.get(); // bloccante finche il task non completa
future.get() e bloccante: il thread chiamante si sospende finche il task non ha prodotto il risultato. Questo e il punto in cui la concorrenza si riconcilia con la sequenzialita.
Il professore presenta il classico problema del calcolo di un integrale definito per approssimazione trapezoidale:
// ComputeAreaTask: calcola l'area di un singolo trapezio
class ComputeAreaTask implements Callable<Double> {
private final double x0, delta;
private final Function<Double,Double> f;
// costruttore...
public Double call() {
return (f.apply(x0) + f.apply(x0 + delta)) * delta / 2.0;
}
}
// Master: scompone l'intervallo e colleziona i risultati
List<Future<Double>> futures = new ArrayList<>();
for (double x = a; x < b; x += delta) {
futures.add(executor.submit(new ComputeAreaTask(x, delta, f)));
}
double totale = 0.0;
for (Future<Double> f : futures) {
totale += f.get(); // si blocca finche il task non e pronto
}
Il docente sottolinea che il master non si sospende subito: sottomette tutti i task, colleziona i future, e solo dopo chiama get() su ciascuno. In questo modo i task possono essere eseguiti concorrentemente mentre il master continua a preparare i task successivi.
L'esempio del PrimeGenerator mostra come implementare la cancellazione cooperativa usando un flag volatile:
public class PrimeGenerator implements Runnable {
private final List<BigInteger> primes = new ArrayList<>();
private volatile boolean cancelled;
public void run() {
BigInteger p = BigInteger.ONE;
while (!cancelled) {
p = p.nextProbablePrime();
synchronized (this) { primes.add(p); }
}
}
public void cancel() { cancelled = true; }
public synchronized List<BigInteger> get() {
return new ArrayList<>(primes);
}
}
// Uso: genera numeri primi per 1 secondo
List<BigInteger> aSecondOfPrimes() throws InterruptedException {
PrimeGenerator gen = new PrimeGenerator();
new Thread(gen).start();
try { SECONDS.sleep(1); } finally { gen.cancel(); }
return gen.get();
}
Il docente avverte: la cancellazione cooperativa (con flag) non funziona se il thread e bloccato su una put bloccante di un buffer pieno. In quel caso serve l'interruption.
class PrimeProducer extends Thread {
private BlockingQueue<BigInteger> queue;
// costruttore...
public void run() {
BigInteger p = BigInteger.ONE;
try {
while (!Thread.currentThread().isInterrupted()) {
queue.put(p.nextProbablePrime());
}
} catch (InterruptedException ex) {
// allow thread to exit
}
}
public void cancel() { interrupt(); }
}
L'interfaccia ExecutorService estende Executor aggiungendo il controllo del ciclo di vita e il supporto per task con risultato. Ecco la gerarchia:
flowchart BT
Exec["<<interface>> Executor\nexecute(Runnable)"] --> ExecService["<<interface>> ExecutorService\nsubmit(Callable<T>): Future<T>\nshutdown(), awaitTermination()"]
ExecService --> SchedExec["<<interface>> ScheduledExecutorService\nschedule(), scheduleAtFixedRate()"]
ExecFactory["Executors (factory)\nnewFixedThreadPool(), newCachedThreadPool(),\nnewSingleThreadExecutor(), newScheduledThreadPool()"]
ExecFactory -.->|crea| ExecService
Un task che produce un risultato si definisce tramite l'interfaccia Callable<V> (che ha V call() throws Exception invece di void run()) e viene sottomesso con submit(), che restituisce un oggetto Future<V>.
// Callable + Future pattern
ExecutorService exec = Executors.newFixedThreadPool(4);
Future<Integer> future = exec.submit(() -> {
// computazione lunga...
return 42;
});
// ... altro lavoro concorrente ...
Integer risultato = future.get(); // bloccante se non ancora pronto
Il ciclo di vita di un ExecutorService ha tre stati:
shutdown() (non accetta nuovi task ma completa quelli in esecuzione) o shutdownNow() (tenta di cancellare i task in esecuzione);Per attendere il completamento: awaitTermination(timeout, unit) sospende il chiamante finché tutti i task non sono finiti o scade il timeout.
La cancellazione di un'attività concorrente segue un approccio cooperativo, non asincrono/preemptive. In Java, il meccanismo principale è l'interruption:
// Pattern di cancellazione cooperativa (dalle slide)
public class PrimeGenerator implements Runnable {
private volatile boolean cancelled;
public void run() {
BigInteger p = BigInteger.ONE;
while (!cancelled) {
p = p.nextProbablePrime();
// ... elaborazione ...
}
}
public void cancel() { cancelled = true; }
}
Il flag volatile garantisce che le scritture di cancel() siano visibili immediatamente al thread in esecuzione. Se il thread è bloccato su una wait(), sleep() o join(), si usa il meccanismo di interrupt() che sblocca questi stati e lancia InterruptedException.
La differenza tra shutdown() (graceful: completa i task in coda) e shutdownNow() (abrupt: tenta di cancellare) è un classico da esame. shutdownNow() restituisce la lista dei task che non sono stati avviati. La poison pill è una tecnica alternativa: si inserisce un oggetto "sentinella" nella coda condivisa che segnala ai consumatori di terminare.
Il professor Ricci passa a un esempio pratico e doloroso: un cronometro implementato con pattern Model-View-Controller, ma con due bug classici della programmazione concorrente in GUI Java.
Il programma si chiama CronoMVCNotReactivePlusRace ed e l'esempio di come NON si fa. L'idea e semplice: un cronometro con pulsanti Start, Stop e Reset che conta i secondi in un text field.
Questa versione ha due problemi fondamentali:
1. Non e reattiva (non responsive) — premere Start blocca completamente l'interfaccia
2. Race condition — viola la mutua esclusione sullo stato del modello
Il bug principale e nel controller: l'azione di start viene eseguita direttamente sull'Event Dispatch Thread (EDT), il thread di Swing che gestisce tutti gli eventi della GUI. Cosa succede?
// VERSIONE BUGGY: il controller esegue il loop sull'EDT!
buttonStart.addActionListener(e -> {
started = true;
while (started) {
model.updateState();
display.setText(String.valueOf(model.getValue()));
// ^^^^^ setText accoda un task all'EDT...
// ma l'EDT e gia occupato qui nel loop!
Thread.sleep(100); // blocca tutto
}
});
Il problema e duplice:
setText() su un componente Swing non aggiorna immediatamente il display: accoda un task nella coda eventi dell'EDT.while(started) del controller. Quindi non puo servire la coda eventi. Il display non si aggiorna mai e l'interfaccia e completamente bloccata.L'EDT e un thread a event loop: deve stare sempre in attesa sulla coda eventi, eseguire rapidamente un handler, e tornare ad aspettare. Se lo "imbragliamo" in un loop che non compete a lui, il sistema si blocca.
La versione corretta si chiama CronoMVCReactiveNonRaces. La regola fondamentale e: l'EDT si occupa solo della business logic che riguarda la view, non il model, non il controller.
Separare il flusso di controllo: la reazione agli eventi (notifyStarted, notifyStopped) appartiene al controller, ma l'esecuzione del loop di conteggio deve avvenire su un thread separato, non sull'EDT.
// VERSIONE CORRETTA: il controller delega a un thread separato
class Controller {
private final Model model;
private final View view;
private Thread countingThread; // thread separato per il conteggio
public void notifyStarted() {
countingThread = new Thread(() -> {
while (isRunning()) {
model.updateState(); // aggiorno il modello
SwingUtilities.invokeLater(() -> {
view.updateDisplay( // aggiorno la view sull'EDT
model.getValue());
});
Thread.sleep(100);
}
});
countingThread.start();
}
public void notifyStopped() {
stopRunning(); // flag per fermare il loop
}
}
SwingUtilities.invokeLater() accoda un task nella coda dell'EDT e torna immediatamente. Il thread di conteggio puo continuare a eseguire senza bloccare l'interfaccia. In questo modo l'EDT rimane libero di servire eventi (pulsanti, aggiornamenti) e il cronometro funziona correttamente.
Il docente sottolinea che invokeAndWait() e un'alternativa sincrona: il chiamante si blocca finche l'EDT non ha completato il task. Utile per sincronizzarsi, ma per l'aggiornamento del display e preferibile invokeLater().
Ricorda: invokeLater e invokeAndWait — entrambi accodano un task nella Event Queue dell'EDT. La differenza e che invokeAndWait e sincrono: il chiamante attende il completamento. invokeLater e asincrono: accoda e ritorna immediatamente.
Il secondo esempio pratico e Sketch2, un programma GUI che illustra come gestire input asincrono da tastiera all'interno di un'architettura Model-View-Controller.
L'applicazione mostra un conteggio che viene aggiornato sia automaticamente (da un componente attivo che incrementa il valore periodicamente) sia manualmente (premendo il tasto I sulla tastiera). Il tasto R resetta il conteggio.
La sfida: la tastiera genera eventi in modo asincrono, indipendentemente dal ciclo di vita del programma. Come li integriamo nel MVC?
// Gestione dell'input da tastiera in Java con MVC
public class Sketch2 {
public static void main(String[] args) {
Model model = new Model();
View view = new View(model);
Controller controller = new Controller(model, view);
// Il controller registra listener per gli eventi di tastiera
view.addKeyListener(new KeyAdapter() {
public void keyPressed(KeyEvent e) {
if (e.getKeyCode() == KeyEvent.VK_I) {
controller.notifyIncrementRequest();
} else if (e.getKeyCode() == KeyEvent.VK_R) {
controller.notifyResetRequest();
}
}
});
}
}
Il professore spiega che la gestione e concettualmente identica a quella del cronometro: il controller reagisce agli eventi, modifica il modello su un thread separato (non l'EDT), e usa SwingUtilities.invokeLater() per aggiornare la vista. In questo modo l'interfaccia rimane reattiva anche durante computazioni lunghe.
Sketch2 rappresenta un pattern generale per la gestione di input asincrono in GUI concorrenti: gli eventi esterni (tastiera, mouse, rete) vengono catturati da listener che notificano il controller, il quale orchestra le modifiche al modello su thread separati.
L'esercizio delle palle che rimbalzano e un caso studio di simulazione fisica concorrente. Il professore presenta un modello in cui delle palline si muovono in uno spazio bidimensionale, con:
friction factor)Ecco la logica di aggiornamento del modello fisico:
class Particle {
double x, y; // posizione
double vx, vy; // velocita
void update(double dt) {
// Applica attrito (decelerazione costante)
double friction = 0.98; // coefficiente
vx *= friction;
vy *= friction;
// Se la velocita e molto bassa, ferma del tutto
if (Math.abs(vx) < 0.01) vx = 0;
if (Math.abs(vy) < 0.01) vy = 0;
// Aggiorna posizione (cinematica)
x += vx * dt;
y += vy * dt;
// Boundary check: rimbalzo sui bordi
if (x < 0 || x > maxX) { vx = -vx; x = clamp(x, 0, maxX); }
if (y < 0 || y > maxY) { vy = -vy; y = clamp(y, 0, maxY); }
}
}
Il professore sottolinea la separazione netta: le coordinate del modello sono logiche, non pixel. Sara la view a mappare le coordinate logiche nello spazio visivo (viewport). Questo e il pattern MVC applicato alla simulazione fisica.
Il professor Ricci introduce il Fork-Join Framework (Java SE 7) come evoluzione del pattern Executor per algoritmi che non hanno una topologia dei dati conosciuta a priori (es. strutture a grafo o ad albero).
Il problema: con un executor tradizionale, se un task Callable aspetta il risultato di un altro Callable, il thread che lo esegue rimane in attesa sprecando una risorsa. Fork-Join risolve questo con l'algoritmo di work-stealing.
// Esempio: somma degli elementi di un array (divide & conquer)
class SumTask extends RecursiveTask<Long> {
static final int THRESHOLD = 1000;
private final int[] array;
private final int lo, hi;
SumTask(int[] a, int lo, int hi) { this.array = a; this.lo = lo; this.hi = hi; }
protected Long compute() {
if (hi - lo < THRESHOLD) {
long sum = 0;
for (int i = lo; i < hi; i++) sum += array[i];
return sum;
}
int mid = (lo + hi) / 2;
SumTask left = new SumTask(array, lo, mid);
SumTask right = new SumTask(array, mid, hi);
left.fork(); // fork: avvia il task in parallelo
long rightResult = right.compute(); // compute: esegui direttamente
long leftResult = left.join(); // join: attendi il risultato
return leftResult + rightResult;
}
}
// Uso con ForkJoinPool
ForkJoinPool pool = new ForkJoinPool();
long total = pool.invoke(new SumTask(array, 0, array.length));
Il work-stealing e il trucco: quando un thread si blocca in attesa del risultato di un fork, invece di rimanere idle, ruba (steal) un altro task dalla coda di un altro thread. Questo mantiene tutti i thread occupati e migliora il throughput complessivo.
Fork-Join realizza il pattern Map-Reduce (o divide-et-impera):
Il Fork-Join Framework e particolarmente efficace per algoritmi su strutture ricorsive come alberi, grafi, e per elaborazione di grandi volumi di dati con pattern map/reduce.
Il Fork-Join Framework (Java SE 7) estende il modello degli executor per gestire algoritmi map/reduce e divide-et-impera dove la topologia dei dati non è nota in anticipo. Le classi principali sono:
ForkJoinPool — executor specializzato che supporta il work-stealing: quando un thread è in attesa (join()), ruba lavoro dalla coda di un altro thread;ForkJoinTask con le sottoclassi RecursiveAction (senza risultato) e RecursiveTask (con risultato);fork() (lancia un subtask asincrono) e join() (attende il risultato).// Schema fork-join: merge sort parallelo (idea dalle slide)
class MergeSortTask extends RecursiveAction {
private int[] array;
private int lo, hi;
protected void compute() {
if (hi - lo < THRESHOLD) {
Arrays.sort(array, lo, hi);
} else {
int mid = (lo + hi) / 2;
var left = new MergeSortTask(array, lo, mid);
var right = new MergeSortTask(array, mid, hi);
left.fork(); // fork asincrono
right.compute();
left.join(); // attendi il risultato
merge(array, lo, mid, hi);
}
}
}
Più recentemente, Java ha introdotto la Structured Concurrency (JEP 437, JDK 20+), che estende il modello fork-join con un approccio strutturato: se un task si divide in sottotask concorrenti, questi devono tornare tutti allo stesso blocco di codice (scope) del task padre.
Il codice d'esempio con StructuredTaskScope, identico nelle due lezioni, e riportato una sola volta qui sotto.
La Structured Concurrency risolve un problema di visibilità: con i thread tradizionali, un task avviato ma non completato può "fuggire" e rimanere in esecuzione anche dopo che il metodo chiamante è terminato. Con StructuredTaskScope, la durata dei sottotask è lessicalmente delimitata dal blocco try-with-resources.
Il professore accenna, nelle slide, al concetto di Structured Concurrency introdotto in JDK 20 (incubating). L'idea e semplice ma potente: se un task si divide in subtask concorrenti, tutti i subtask devono tornare allo stesso punto — il blocco di codice del task padre.
La classe StructuredTaskScope permette di strutturare un task come una famiglia di subtask concorrenti, coordinati come unita:
// Esempio: fetch parallelo di due sorgenti
Response handle() throws ExecutionException, InterruptedException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> user = scope.fork(() -> findUser());
Future<Integer> order = scope.fork(() -> fetchOrder());
scope.join(); // attendi entrambi i fork
scope.throwIfFailed(); // propaga errori
// Entrambi completati con successo
return new Response(user.resultNow(), order.resultNow());
} // scope si chiude: tutti i fork sono completati
}
Il vantaggio: il ciclo di vita dei subtask e confinato al blocco try-with-resources. Non ci sono thread o task "orfani" che continuano dopo che il parent e terminato. La leggibilita e la manutenibilita sono simili al codice sequenziale.
Un task e un'unita di lavoro logica, astratta, con inizio e fine. Un thread e il veicolo fisico che esegue il task. Il task-oriented programming separa i due concetti: il programmatore definisce i task; l'executor decide come e quando eseguirli su thread.
Perche il loop di conteggio viene eseguito direttamente sull'Event Dispatch Thread (EDT). L'EDT rimane bloccato nel loop, non puo servire altri eventi (pulsanti, aggiornamenti), e setText() sulla GUI accoda un task nella coda eventi che non viene mai eseguito. Risultato: interfaccia bloccata e display non aggiornato.
Accoda un task nella coda degli eventi dell'EDT e ritorna immediatamente (asincrono). Il task verra eseguito dall'EDT quando sara il suo turno. E il meccanismo standard per aggiornare la GUI da un thread diverso dall'EDT.
Due invarianti: S.V >= 0 e S.V = k + #signal(S) - #wait(S), dove k e il valore iniziale, #signal(S) e il numero di signal completati, #wait(S) e il numero di wait completati (esclusi i processi bloccati).
1. Mutua esclusione — una risorsa non condivisibile
2. Hold and wait — processi tengono risorse mentre ne richiedono altre
3. No preemption — le risorse non possono essere revocate
4. Circular wait — esiste un ciclo di attesa tra processi
Se una qualsiasi delle quattro e assente, il deadlock e impossibile.
Tre differenze chiave: (1) wait sul semaforo puo non bloccare se S.V > 0; waitC blocca sempre. (2) signal sul semaforo ha sempre effetto (incrementa S.V); signalC non ha effetto se la coda e vuota. (3) signal sblocca un processo arbitrario; signalC sblocca il processo in testa alla coda (FIFO).
Con la semantica Signal & Continue (usata da Java), il thread risvegliato non ha garanzia che la condizione sia ancora vera: un altro thread potrebbe averla invalidata prima che il risvegliato riacquisisca il lock. Il while ricontrolla la condizione, garantendo la correttezza.
Il testing verifica che una proprieta valga per alcuni scenari selezionati. Il model checking verifica che la proprieta valga per tutti gli scenari possibili, esplorando esaustivamente lo spazio degli stati. Il testing rivela la presenza di errori; il model checking puo provare l'assenza.
Dal momento in cui un processo P cerca di entrare in CS, un altro processo Q puo entrare al massimo k volte prima che P riesca ad entrare. In LTL: tryp → (¬CSq) W (CSq W ((¬CSq) W CSp)) per 1-bounded overtaking.