Parte I — Concorrenza a memoria condivisa · Capitolo 8

Progettare programmi concorrenti

~15 min di lettura4 schemi di architettura

In questo capitolo

  1. Dal problema all'architettura in due passi
  2. Componenti attivi e passivi
  3. L'analisi: decomporre e trovare le dipendenze
  4. Tre modi di pensare il parallelismo
  5. Le architetture concorrenti
  6. Come si capisce che il lavoro è finito
  7. Model-View-Controller in un programma concorrente
  8. Verifica le tue conoscenze

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.

1. Dal problema all'architettura in due passi

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.

  1. Analisi. Si cerca la concorrenza che il problema offre: quali task ci sono, come si possono scomporre il lavoro e i dati, quali dipendenze legano i task. Si guarda il problema, non la soluzione. È la parte più difficile, perché viene spontaneo analizzare pensando già a come implementare.
  2. Progetto. Si sceglie un'architettura concorrente. I task vengono assegnati ad agenti, componenti attivi che ne sono responsabili e ne incapsulano la logica e il controllo. Per lavorare insieme gli agenti usano componenti passivi condivisi, di due tipi: risorse che raccolgono il risultato del loro lavoro, e mezzi di coordinazione che danno forma alla loro interazione e gestiscono le dipendenze tra i task.

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.

2. Componenti attivi e passivi

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
Incapsulaun proprio flusso di controllo, oltre a stato e comportamentostato e operazioni, con le politiche di mutua esclusione e sincronizzazione
Si progetta a partire dai compiti che deve svolgereil servizio che offre
Nella parte I si realizza comethreadmonitor (capitolo 7)
Esempimaster, worker, produttore, consumatore, controller attivobounded buffer, bag of tasks, contatore condiviso, latch, barriera

Le regole sono tre:

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.

3. L'analisi: decomporre e trovare le dipendenze

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 decompositionData decomposition
Quandoil problema si scompone in modo naturale, divide et impera, in task indipendenti o quasii dati si dividono in unità su cui si può lavorare in modo relativamente indipendente
Ordineprima i task, poi come dividere i dati: la decomposizione dei dati segue quella dei taskprima le unità di dati, poi i task che lavorano su ciascuna: la decomposizione dei task segue
Requisito o vantaggioi task devono essere abbastanza indipendenti che gestire le dipendenze costi una piccola parte del tempo totalescala bene con il numero di processori
Esempioproduttori e consumatori; un web server con un task per richiestaprodotto 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.

Oltre le slide

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

4. Tre modi di pensare il parallelismo

Per passare dall'analisi al progetto, Carriero e Gelernter (1989) propongono tre classi architetturali concettuali. L'esempio è costruire una casa.

ClasseSi progetta attorno aOgni agente…Strutture condiviseLa casa
Result parallelismla struttura dati o la risorsa che costituisce il risultato; si calcolano tutti i suoi elementi insieme. È legata alla data decompositionproduce un pezzo del risultatostrutture che contengono il risultato in costruzione e ne incapsulano mutua esclusione e sincronizzazionesi costruiscono in parallelo le parti (muri, fondamenta, tetto) e si assemblano man mano che sono pronte
Specialist parallelismun insieme di specialisti collegati in una rete logica, tutti attivi insiemesvolge un solo tipo di compitomezzi di coordinazione tra specialisti: caselle di messaggi, blackboard, servizi di eventiuna squadra con uno specialista per mestiere (scavatore, carpentiere, copritetto); all'inizio molti aspettano, poi lavorano in tanti insieme
Agenda parallelismun'agenda di attività: molti agenti su ogni passo, eventualmente più passi insieme (un workflow)aiuta nel punto corrente dell'agendastrutture per i dati prodotti e consumati dalle attività, come i bounded buffersi 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:

  1. scegliere la classe concettuale più naturale per il problema;
  2. scrivere il programma con il metodo più naturale per quella classe;
  3. se il programma non è abbastanza efficiente, trasformarlo metodicamente in una versione più efficiente.

5. Le architetture concorrenti

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.

Master-workers

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.

I framework a task sono master-workers

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

Oltre le slide

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.

Fork-join

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

Filter-pipeline

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.

Shared-space (blackboard)

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

Oltre le slide

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.

Announcer-listeners

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.

ArchitetturaAgentiComponente passivo di coordinazionePiù vicina a
Master-workersmaster, worker generalistibag of tasks, raccoglitore dei risultatiagenda o risultato
Fork-joinworker che sono anche masterbag of tasks, oppure nessunocome master-workers, in modo ricorsivo
Filter-pipelinegeneratore, filtri, sinkpipe (bounded buffer, canali)specialisti
Shared-spaceagenti anonimispazio condiviso (blackboard, spazio di tuple)specialisti o agenda
Announcer-listenersannouncer, listenerservizio di eventispecialisti

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.

6. Come si capisce che il lavoro è finito

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.

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

Per l'esame

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.

7. Model-View-Controller in un programma concorrente

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:

  1. Le GUI hanno un solo componente attivo. I toolkit grafici affidano tutta la GUI a un thread, in Swing l'event dispatch thread, e i componenti non sono thread-safe. Quindi un listener non esegue logica di controllo lunga, che bloccherebbe l'EDT, ma la passa a un agente; e un agente aggiorna la view passando l'aggiornamento all'EDT con SwingUtilities.invokeLater, per evitare race condition, non per la reattività. Dettagli ed esempio del cronometro nel capitolo 10.
  2. L'observer originale è sincrono e accoppia i flussi di controllo: notificare vuol dire chiamare il metodo dell'osservatore nel thread di chi notifica. In un sistema concorrente lo si ripensa come produttore-consumatore. Un bounded buffer fa da coda di eventi, chi genera gli eventi (per esempio la view, a ogni tasto premuto) è il produttore, e un agente che li preleva e reagisce, un controller attivo, è il consumatore. La coda è un componente passivo a sé, non un metodo pubblico del controller: altrimenti il controller sarebbe un'entità ibrida.

Verifica le tue conoscenze

Quali sono i due passi per progettare un programma concorrente?

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.

Che differenza c'è tra componenti attivi e passivi? Fai degli esempi.

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.

Che cos'è un agente? È attivo o passivo? E perché non si ammettono entità ibride?

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

Che differenza c'è tra task decomposition e data decomposition?

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.

Che cosa si intende per dipendenze tra task?

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.

Quali sono le tre classi architetturali, e i loro confini sono rigidi?

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.

Quali sono le architetture concorrenti di riferimento?

Master-workers, con le varianti dei framework a task e del fork-join; filter-pipeline; shared-space o blackboard; announcer-listeners.

In che senso un executor è un master-workers? Che vincolo hanno i suoi task?

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.

Come fa il master a sapere che tutti i task sono finiti, se i task ne generano altri?

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.

Da quali lezioni viene questo capitolo

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.