Il capitolo in un paragrafo. Progettare un programma concorrente vuol dire passare dal problema a un'architettura in due passi. Nell'analisi si trovano i task, scomponendo il lavoro o i dati, e le dipendenze tra loro, guardando il problema e non la soluzione. Nel progetto si affidano i task a componenti attivi, gli agenti, che interagiscono solo attraverso componenti passivi, i monitor. Tre classi concettuali (parallelismo del risultato, degli specialisti, dell'agenda) orientano la scelta. Quattro architetture di riferimento la portano verso il codice: master-workers, filter-pipeline, spazio condiviso e announcer-listeners.
I capitoli precedenti hanno dato i meccanismi. Resta la domanda dell'ingegnere: dato un problema, come si organizza il programma? Il metodo del corso ha due passi.
I due passi non sono una cascata: analisi, progetto e implementazione si ripetono a spirale, come nei metodi agili. Ma non c'è buon progetto senza una buona analisi.
Un task è, in senso operativo, una sequenza di istruzioni che operano insieme come un gruppo e corrispondono a una parte logica del programma; in senso più astratto, un lavoro da svolgere come responsabilità di un agente. In entrambi i casi è un'unità di lavoro logica che inizia e finisce, non un thread: il thread, o l'agente, è ciò che lo esegue.
Un oggetto incapsula stato e comportamento, ma non il flusso di controllo: i suoi metodi sono eseguiti dal thread che li chiama, e leggendo la classe non si sa quale sarà. La disciplina del corso aggiunge l'incapsulamento del flusso di controllo, con una partizione netta dei componenti in due tipi.
| Componente attivo (agente) | Componente passivo | |
|---|---|---|
| Incapsula | un proprio flusso di controllo, oltre a stato e comportamento | stato e operazioni, con le politiche di mutua esclusione e sincronizzazione |
| Si progetta a partire da | i compiti che deve svolgere | il servizio che offre |
| Nella parte I si realizza come | thread | monitor (capitolo 7) |
| Esempi | master, worker, produttore, consumatore, controller attivo | bounded buffer, bag of tasks, contatore condiviso, latch, barriera |
Le regole sono tre:
Thread con metodi pubblici synchronized chiamati da altri thread diventa un agente più un monitor.Un agente è quindi un componente attivo, che porta avanti un compito di sua iniziativa, sia esso un thread, un event loop o un attore. Monitor, buffer e future sono passivi: agiscono solo quando un agente li chiama.
In Java un agente estende Thread: il costruttore riceve la configurazione e i componenti passivi da usare, il comportamento sta in run, e i metodi privati o protetti sono le sue «abilità», che un sottotipo eredita. run è pubblico solo perché lo impone Thread. Nell'ordinamento concorrente del corso i worker ordinano ciascuno una parte dell'array e un merger le fonde; qui le parti si raccolgono in un monitor:
// Componente passivo: raccoglie le parti ordinate e sa quando ci sono tutte
class SortedParts {
private final List<int[]> parts = new ArrayList<>();
private final int expected;
SortedParts(int expected) { this.expected = expected; }
public synchronized void add(int[] part) { parts.add(part); notifyAll(); }
public synchronized List<int[]> awaitAll() throws InterruptedException {
while (parts.size() < expected) wait();
return List.copyOf(parts);
}
}
// Componente attivo: nessun metodo pubblico oltre a run(), imposto da Thread
class SortingWorker extends Thread {
private final int[] slice;
private final SortedParts out;
SortingWorker(int[] slice, SortedParts out) { this.slice = slice; this.out = out; }
@Override public void run() { Arrays.sort(slice); out.add(slice); }
}
class MergingWorker extends Thread {
private final SortedParts in;
MergingWorker(SortedParts in) { this.in = in; }
@Override public void run() {
try { int[] all = merge(in.awaitAll()); /* usa il risultato */ }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
// abilità privata: a ogni passo prende il più piccolo tra i primi elementi delle parti
private int[] merge(List<int[]> parts) {
int[] out = new int[parts.stream().mapToInt(p -> p.length).sum()];
int[] next = new int[parts.size()]; // indice del prossimo elemento di ogni parte
for (int k = 0; k < out.length; k++) {
int best = -1;
for (int i = 0; i < parts.size(); i++)
if (next[i] < parts.get(i).length
&& (best < 0 || parts.get(i)[next[i]] < parts.get(best)[next[best]])) best = i;
out[k] = parts.get(best)[next[best]++];
}
return out;
}
}
Si vede a colpo d'occhio chi fa cosa, e merge, privato, lo esegue solo il merger. Dare un nome esplicito a ruoli e componenti, invece di nasconderli in una lambda, rende visibile il progetto nel codice.
L'analisi ha due fasi: la decomposizione in task e dati, poi l'analisi delle dipendenze. Per la decomposizione ci sono due pattern, che differiscono per il punto di partenza.
| Task decomposition | Data decomposition | |
|---|---|---|
| Quando | il problema si scompone in modo naturale, divide et impera, in task indipendenti o quasi | i dati si dividono in unità su cui si può lavorare in modo relativamente indipendente |
| Ordine | prima i task, poi come dividere i dati: la decomposizione dei dati segue quella dei task | prima le unità di dati, poi i task che lavorano su ciascuna: la decomposizione dei task segue |
| Requisito o vantaggio | i task devono essere abbastanza indipendenti che gestire le dipendenze costi una piccola parte del tempo totale | scala bene con il numero di processori |
| Esempio | produttori e consumatori; un web server con un task per richiesta | prodotto di matrici: ogni elemento del risultato è il prodotto di una riga per una colonna, quindi n² task indipendenti |
L'analisi delle dipendenze esamina le relazioni tra i task per raggrupparli e ordinarli, in modo da massimizzare le prestazioni. Le dipendenze sono di due tipi:
Task del tutto indipendenti vanno ad agenti che non si coordinano. Dove ci sono dipendenze, gli agenti si coordinano con un componente passivo: una barriera o un latch per l'ordine, un monitor per i dati condivisi. Malone e Crowston (1993) classificano le dipendenze nei problemi di coordinazione, un tema interdisciplinare.
In Patterns for Parallel Programming (Mattson et al.), da cui vengono questi pattern, l'analisi delle dipendenze ha tre passi: group tasks (raggruppare i task con vincoli comuni), order tasks (fissare l'ordine tra i gruppi) e data sharing (stabilire come i task condividono i dati).
Per passare dall'analisi al progetto, Carriero e Gelernter (1989) propongono tre classi architetturali concettuali. L'esempio è costruire una casa.
| Classe | Si progetta attorno a | Ogni agente… | Strutture condivise | La casa |
|---|---|---|---|---|
| Result parallelism | la struttura dati o la risorsa che costituisce il risultato; si calcolano tutti i suoi elementi insieme. È legata alla data decomposition | produce un pezzo del risultato | strutture che contengono il risultato in costruzione e ne incapsulano mutua esclusione e sincronizzazione | si costruiscono in parallelo le parti (muri, fondamenta, tetto) e si assemblano man mano che sono pronte |
| Specialist parallelism | un insieme di specialisti collegati in una rete logica, tutti attivi insieme | svolge un solo tipo di compito | mezzi di coordinazione tra specialisti: caselle di messaggi, blackboard, servizi di eventi | una squadra con uno specialista per mestiere (scavatore, carpentiere, copritetto); all'inizio molti aspettano, poi lavorano in tanti insieme |
| Agenda parallelism | un'agenda di attività: molti agenti su ogni passo, eventualmente più passi insieme (un workflow) | aiuta nel punto corrente dell'agenda | strutture per i dati prodotti e consumati dalle attività, come i bounded buffer | si segue l'agenda (fondamenta, struttura, tetto) con una squadra di generalisti che lavorano tutti sulla fase corrente |
In tutte e tre gli agenti lavorano in parallelo fin dove lo consentono i vincoli del problema. Risultato e specialisti sono approcci opposti: il primo divide il prodotto, il secondo i mestieri, e lì pesa la coordinazione tra i lavoratori, per esempio in pipeline.
I confini tra le tre classi non sono rigidi. Un lavoro reale mescola spesso più approcci: un progetto a specialisti può assegnare una squadra di lavoratori a una specialità, e quella squadra lavora ad agenda. Restano però tre modi di pensare nettamente distinti. Il risultato guarda alla forma del prodotto finito, gli specialisti alla composizione della squadra, l'agenda alla lista delle cose da fare. L'approccio si può applicare ricorsivamente, seguendo la decomposizione. Il metodo suggerito ha tre passi:
Tra la classe concettuale e l'implementazione fanno da ponte quattro architetture di riferimento, tratte da Magee e Kramer e da Ortega-Arjona: master-workers (con i framework a task e la variante fork-join), filter-pipeline (pattern a flussi), shared-space o blackboard (coordinazione tramite uno spazio) e announcer-listeners (coordinazione a eventi). Nelle figure i cerchi sono agenti e i rettangoli componenti passivi.
Un agente master e un insieme di agenti worker, anche dinamico, interagiscono attraverso un mezzo di coordinazione che funziona da bag of tasks, di solito una blackboard o un bounded buffer. Il master scompone il task globale in sottotask, li assegna inserendone la descrizione nel bag e raccoglie i risultati. Ogni worker preleva un task dal bag, lo esegue e ne comunica il risultato. In una variante i risultati si aggregano in un monitor apposito. Esempio: la quadratura, cioè l'integrale definito calcolato come somma delle aree di tanti trapezi, uno per task.
Nella variante semplice il master conosce i worker e crea ciascuno con la sua parte, come nell'ordinamento della sezione 2. In quella generale il bag disaccoppia master e worker, il cui numero dipende dall'hardware e non dai task: il prodotto di due matrici 1000 × 1000 genera un milione di task, ma su 16 core un compito CPU-bound vuole 17 worker.
Un executor è un master-workers di cui il programmatore scrive solo il master: il bag (di solito un bounded buffer) e il pool di worker, cioè la politica di esecuzione, sono nascosti in un componente passivo. submit ritorna subito, e il task è eseguito in modo asincrono rispetto a chi lo sottomette. I risultati si raccolgono in un bag dei risultati, in un monitor aggregatore, oppure con le future: oggetti restituiti subito che rappresentano il risultato futuro, e permettono di controllare se è pronto, di bloccarsi fino al suo arrivo, di cancellare il task e di intercettarne gli errori. Task e future sono asincroni ma ammettono ancora attese bloccanti, a differenza della programmazione a eventi (capitolo 12).
I task di un executor devono essere indipendenti: bloccare un task significa bloccare il thread del pool che lo esegue. Cento task che attendono tutti su una barriera da 100, con un pool di 16 thread, vanno in deadlock: i primi 16 occupano tutti i thread in attesa, e gli altri 84 non partono mai. Le sezioni critiche brevi invece vanno bene, perché il lock prima o poi si rilascia.
La domanda d'orale su questo caso (pool da 4 thread, 100 task) è svolta, con un laboratorio, nel capitolo 10.
Le slide collegano ai task anche i virtual thread, thread logici gestiti dalla JVM e slegati da quelli del sistema operativo, e la structured concurrency, che tratta un gruppo di task correlati come un'unica unità di lavoro per semplificare errori e cancellazione (capitolo 10).
Le slide citano per entrambi la JEP 425, che riguarda solo i virtual thread (definitivi in Java 21 con la JEP 444). La structured concurrency nasce con la JEP 428 ed è ancora in anteprima in Java 25.
È il master-workers applicato ricorsivamente: i worker sono a loro volta master, che producono sottotask e ne raccolgono i risultati. Realizza in concorrenza il divide et impera: un worker con un compito troppo grande lo divide, finché i pezzi si possono eseguire direttamente. Si realizza con un bag of tasks in cui anche i worker inseriscono task, oppure direttamente. Esempi: la quadratura adattiva e il merge sort. In Java c'è ForkJoinPool, dove un task può attendere i propri sottotask con join (il thread in attesa esegue altri task invece di restare fermo), cosa che negli executor normali rischia il deadlock descritto sopra.
Una catena lineare di agenti collegati da pipe: bounded buffer o canali. Il generatore apre la catena e produce i dati da elaborare. Ogni filtro intermedio consuma dalla pipe in ingresso e produce nella pipe in uscita. Il sink chiude la catena e raccoglie i risultati. Esempio: l'elaborazione di immagini, con uno stadio per ogni trasformazione. Il parallelismo viene dal fatto che gli stadi lavorano insieme su elementi diversi del flusso.
Gli agenti interagiscono indirettamente attraverso uno spazio condiviso che funziona da lavagna (blackboard). Lo spazio offre operazioni per aggiungere informazioni e per cercarle, leggerle o rimuoverle, spesso con sincronizzazione: una lettura può sospendersi finché l'informazione cercata non c'è. La coordinazione è quindi guidata dai dati, perché un agente reagisce a ciò che compare nello spazio e non a chi lo ha scritto, e c'è disaccoppiamento temporale: chi scrive e chi legge non devono esistere nello stesso momento. Il riferimento classico sono gli spazi di tuple del linguaggio di coordinazione Linda (Gelernter, 1985).
In Linda out(t) inserisce una tupla, in(p) preleva una tupla che corrisponde al modello p sospendendosi finché non c'è, rd(p) la legge senza toglierla. Il bag of tasks del master-workers è uno spazio di questo tipo.
Un agente announcer e un insieme dinamico di agenti listener interagiscono attraverso un servizio di eventi. L'announcer annuncia sul servizio che un evento è accaduto. I listener si registrano sul servizio per essere avvisati degli eventi che li interessano. Il servizio disaccoppia i due ruoli, raccoglie gli eventi e li smista. Esempio: parallelizzare un'applicazione a eventi eseguendo in parallelo i suoi gestori di eventi.
| Architettura | Agenti | Componente passivo di coordinazione | Più vicina a |
|---|---|---|---|
| Master-workers | master, worker generalisti | bag of tasks, raccoglitore dei risultati | agenda o risultato |
| Fork-join | worker che sono anche master | bag of tasks, oppure nessuno | come master-workers, in modo ricorsivo |
| Filter-pipeline | generatore, filtri, sink | pipe (bounded buffer, canali) | specialisti |
| Shared-space | agenti anonimi | spazio condiviso (blackboard, spazio di tuple) | specialisti o agenda |
| Announcer-listeners | announcer, listener | servizio di eventi | specialisti |
L'ultima colonna è indicativa: le slide citano blackboard e servizi di eventi tra i mezzi degli specialisti, e la pipeline come loro strategia di lavoro.
Il master deve sapere quando tutti i task sono stati eseguiti, per aggregare i risultati, passare alla fase successiva o terminare. La tecnica dipende da come nascono i task.
SortedParts nella sezione 2. Oppure un latch (il «cancello») inizializzato a N: ogni worker fa countDown e il master aspetta su await. Se ogni worker ha un solo task, basta la join sui thread.submit e si chiama get su ciascuna, o si usa invokeAll. Se l'executor non serve più, si chiama shutdown e poi awaitTermination, che ritorna quando tutti i task sono finiti.interface Task { void execute(BagOfTasks<Task> bag); }
class BagOfTasks<T> {
private final Deque<T> tasks = new ArrayDeque<>();
private int pending; // inseriti e non ancora completati
public synchronized void put(T task) { tasks.addLast(task); pending++; notifyAll(); }
/** Il prossimo task, oppure empty quando tutto il lavoro è finito. */
public synchronized Optional<T> take() throws InterruptedException {
while (tasks.isEmpty() && pending > 0) wait();
return Optional.ofNullable(tasks.pollFirst());
}
/** Il worker lo chiama dopo aver completato un task e inserito i suoi sottotask. */
public synchronized void done() { if (--pending == 0) notifyAll(); }
public synchronized void awaitCompletion() throws InterruptedException {
while (pending > 0) wait();
}
}
// ciclo di ogni worker
for (Optional<Task> t = bag.take(); t.isPresent(); t = bag.take()) {
try { t.get().execute(bag); } // può chiamare bag.put(sottotask)
finally { bag.done(); }
}
Il master inserisce i task iniziali prima di avviare i worker e poi aspetta con awaitCompletion. A zero, take restituisce empty a tutti i worker, che terminano. Se i worker leggono da un semplice bounded buffer, il modo classico per fermarli è la poison pill: un elemento speciale, uno per worker, che significa «fine» (capitolo 10).
Ricci ha annunciato che chiederà come fate a capire, nel vostro assignment 1, che tutti i task sono finiti. Dite quale di questi casi si applica, quale componente passivo lo realizza (contatore in un monitor, latch, barriera, future, awaitTermination) e perché è corretto. Allo stesso modo sappiate descrivere come agenti e monitor si coordinano tra una fase e l'altra.
Model-View-Controller separa il dominio (model), la presentazione (view), che osserva il model con il pattern observer, e la gestione dell'input (controller). Con componenti attivi e passivi diventa:
Due questioni restano aperte:
SwingUtilities.invokeLater, per evitare race condition, non per la reattività. Dettagli ed esempio del cronometro nel capitolo 10.L'analisi, che individua i task, la decomposizione di task e dati e le loro dipendenze guardando il problema. Poi il progetto, che sceglie un'architettura concorrente assegnando i task ad agenti coordinati da componenti passivi.
Un componente attivo, o agente, incapsula un proprio flusso di controllo, non ha interfaccia pubblica e si progetta a partire dai suoi compiti: worker, produttori, consumatori, attori. Un componente passivo incapsula stato e sincronizzazione ed è usato dagli agenti: monitor come bounded buffer, bag of tasks, latch.
È un componente attivo responsabile di uno o più task, di cui incapsula la logica e il controllo. Un'entità ibrida, con un proprio thread e metodi chiamati da altri thread, farebbe eseguire il proprio codice ad altri flussi di controllo. Va scomposta in agenti e monitor.
Nella prima si individuano prima i task (per esempio produttori e consumatori) e la divisione dei dati segue. Nella seconda si dividono prima i dati in unità indipendenti (per esempio gli elementi del prodotto di matrici) e i task seguono; scala bene con il numero di processori.
Vincoli temporali (un task deve venire dopo un altro) o sui dati e sulle risorse (un task usa dati di un altro, o più task condividono una risorsa). L'analisi delle dipendenze serve a raggruppare e ordinare i task, e dice dove gli agenti dovranno coordinarsi.
Result parallelism (attorno alla forma del risultato), specialist parallelism (attorno alla squadra di specialisti) e agenda parallelism (attorno alla lista di attività). I confini non sono rigidi: spesso si mescolano, ma restano tre modi distinti di pensare il problema.
Master-workers, con le varianti dei framework a task e del fork-join; filter-pipeline; shared-space o blackboard; announcer-listeners.
L'executor nasconde il bag of tasks e il pool di worker, e il programmatore scrive solo il master che sottomette i task. I task devono essere indipendenti: se uno aspetta un altro blocca un thread del pool, e con abbastanza attese si arriva al deadlock.
Non basta che il bag sia vuoto, perché un worker attivo può ancora inserire sottotask. Si tiene un contatore dei task pendenti, incrementato a ogni inserimento e decrementato a ogni completamento: il lavoro è finito quando arriva a zero.
Slide [module-1.4] Elements of Concurrent Program Design, con le slide di laboratorio Task-Oriented Programming in Java per le poison pill · 20 febbraio 2026 (incapsulamento del flusso di controllo) · 23 febbraio (§3: la disciplina dei componenti attivi nell'ordinamento concorrente) · 13 marzo (modulo 1.4: analisi, classi architetturali, master-workers) · 16 marzo (latch e barriera) · 20 marzo (§1–4: analisi e task, master-workers, executor, future, fork-join, MVC concorrente) · 30 marzo (§12: executor e attesa della terminazione) · 17 aprile (componenti attivi e passivi, ripresi per il message passing). La mappa completa è in coverage-matrix.json.