Parte I — Concorrenza a memoria condivisa · Capitolo 8

Concorrenza in Java: thread, synchronized, task ed executor

~45 min di lettura8 widget interattivi

In questo capitolo

  1. Multithreading in Java: Thread e Runnable
  2. Sezione critica in Java: synchronized
  3. Lost update e race condition
  4. Thread safety e metodi synchronized
  5. Lock espliciti: ReentrantLock
  6. Check-and-act e il BoundedCounter
  7. Laboratorio: barriera e latch
  8. Dal problema ai task: l'analisi task-oriented
  9. Il Java Executor Framework
  10. Callable, Future e task con risultato
  11. Cancellazione cooperativa e interruption
  12. Cronometro MVC: il bug della reattivita
  13. Cronometro MVC: la soluzione
  14. Sketch2: input asincrono da tastiera
  15. Simulazione fisica: palle che rimbalzano
  16. Fork-Join, work-stealing e structured concurrency
  17. Verifica le tue conoscenze

1. Multithreading in Java: Thread e Runnable

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.

Idea chiave

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.

Creazione di thread in Java

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 chiamato direttamente

Attenzione

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.

Comportamento attivo e terminazione

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.

Un esempio: ordinamento parallelo con quicksort

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.

2. Sezione critica in Java: synchronized

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.

Per l'esame

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.

Schema dell'esempio WorkerA/WorkerB

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.

3. Lost update e race condition

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.

Idea chiave

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.

Dimostrazione dell'interleaving

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:

4. Thread safety e metodi synchronized

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.

Nota del redattore

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).

synchronized a livello di metodo

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).

5. Lock espliciti: ReentrantLock

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:

MetodoComportamento
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.
Attenzione

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.

6. Check-and-act e il BoundedCounter

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.

Per l'esame

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.

7. Laboratorio: barriera e latch

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).

Barriera (Barrier)

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
    }
}
Per l'esame

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.

Latch (CountDown)

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(); }
        }
    }
}
Differenza chiave

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.

8. Dal problema ai task: l'analisi task-oriented

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).

Idea chiave

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:

  1. Quali sono i task? — Identificare le attivita concorrenti guardando il problema, non la soluzione.
  2. Quali sono le dipendenze tra i task? — Task completamente indipendenti possono essere assegnati a componenti attivi diversi senza coordinazione. Task con dipendenze richiedono meccanismi di sincronizzazione.
  3. Quali sono i confini dei task? — Definire chiaramente dove un task inizia e dove finisce, per facilitare il recovery da errori e la composizione.

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.

Per l'esame

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.

Division of Labor Pattern (Divide-et-Impera)

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.

9. Il Java Executor Framework

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.

L'Interfaccia Executor

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.

Le Politiche di Esecuzione

Una execution policy specifica:

Gli Executor Predefiniti

La classe factory Executors fornisce diverse implementazioni pronte:

Metodo factoryComportamento
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);
            });
        }
    }
}
Idea chiave

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.

Vantaggi del Pool-Based Approach

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.

Attenzione

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:

Attenzione: deadlock con thread pool

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.

10. Callable, Future e task con risultato

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>.

Callable

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;
}

Future

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 ...;
}
Nota del redattore

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.

Submit vs Execute

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
Idea chiave

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.

Esempio: Approssimazione di Integrale (somma di trapezi)

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.

Cancellazione dei Task

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:

Per attendere il completamento: awaitTermination(timeout, unit) sospende il chiamante finché tutti i task non sono finiti o scade il timeout.

11. Cancellazione cooperativa e interruption

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.

Per l'esame

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.

12. Cronometro MVC: il bug della reattivita

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.

Attenzione

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:

Idea chiave

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.

13. Cronometro MVC: la soluzione

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.

Idea chiave

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().

Per l'esame

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.

14. Sketch2: input asincrono da tastiera

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.

Nota del redattore

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.

15. Simulazione fisica: palle che rimbalzano

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:

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); }
    }
}
Idea chiave

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.

16. Fork-Join, work-stealing e structured concurrency

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));
Idea chiave

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.

Mappa-Riduci con Fork-Join

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:

// 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.

Idea chiave

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.

Verifica le tue conoscenze

Qual e la differenza tra un task e un thread?

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 CronometroMVCNotReactivePlusRace non funziona?

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.

Cosa fa SwingUtilities.invokeLater(Runnable)?

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.

Quale invariante soddisfa un semaforo?

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).

Quali sono le quattro condizioni necessarie per il deadlock (Coffman)?

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.

Differenza tra semaforo (wait/signal) e condition variable (waitC/signalC)?

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).

Perche un monitor usa while invece di if nella condizione di wait?

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.

Qual e la differenza tra testing e model checking?

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.

Cosa significa bounded overtaking nel CS problem?

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.