Lezione 16 · Canale 1 · giovedì 5 novembre 2026
Sincronizzazione tra thread e Runnable
Progettazione del Software
Lezione 16 · Canale 1 · giovedì 5 novembre 2026
Progettazione del Software
Una BlockingQueue dà due garanzie: take() attende finché c'è un elemento, e gli accessi di più thread insieme non la danneggiano. Una lista ordinaria non le dà. Ogni operazione modifica più riferimenti interni: se un thread viene sospeso a metà e un altro cambia la stessa lista, il primo riprende trovandola in uno stato che non si aspetta.
Una LinkedList<Integer> si usa come coda: add(x) inserisce in fondo, add(0, x) in testa, remove(0) toglie il primo elemento. Aggiungendo 1, 2 e 3, escono in quest'ordine. A differenza di take(), remove(0) su una lista vuota non attende: lancia IndexOutOfBoundsException. Con un solo thread basta controllare prima che lista.size() > 0.
Ora due Runnable lavorano sulla stessa lista: Aggiungi inserisce, Togli rimuove se la lista non è vuota. main crea la lista e la passa a entrambi, che la tengono in un campo. Un nome dato al thread con setName serve a riconoscerlo nel debugger e nei messaggi d'errore.
import java.util.LinkedList;
public class Principale {
public static void main(String[] args) {
LinkedList<Integer> lista = new LinkedList<Integer>();
Aggiungi aggiungi = new Aggiungi(lista);
Togli togli = new Togli(lista);
Thread ta = new Thread(aggiungi);
Thread tt = new Thread(togli);
ta.setName("aggiungi");
tt.setName("togli");
ta.start();
tt.start();
}
}
import java.util.LinkedList;
public class Aggiungi implements Runnable {
private LinkedList<Integer> a;
public Aggiungi(LinkedList<Integer> a) {
this.a = a;
}
public void run() {
while (true) {
a.add(100);
}
}
}
import java.util.LinkedList;
public class Togli implements Runnable {
private LinkedList<Integer> b;
public Togli(LinkedList<Integer> b) {
this.b = b;
}
public void run() {
while (true) {
if (b.size() > 0) {
b.remove(0);
}
}
}
}
Eseguendo run() una volta in sequenza, la lista si riempie e si svuota senza errori. Con due thread che ripetono le operazioni all'infinito il thread togli termina quasi subito con una NullPointerException dentro remove, a ogni prova, mentre l'altro continua. Le operazioni, una alla volta, sono corrette. Il controllo size() > 0 riesce, ma durante remove il thread viene sospeso e l'altro, con add, cambia i riferimenti della lista: quando remove riprende, un riferimento che usa è ancora nullo. Con una sola esecuzione la sospensione cade raramente nel punto sbagliato, quindi servono i cicli per vedere l'errore.
Per impedire che i due accessi si sovrappongano, ognuno va in un blocco synchronized (oggetto) { … }. Le graffe sono sempre obbligatorie. Un thread entra nel blocco solo se nessun altro sta eseguendo un blocco synchronized sullo stesso oggetto. Altrimenti attende all'ingresso e riprende quando l'altro esce.
public void run() {
while (true) {
synchronized (a) {
a.add(100);
}
}
}
public void run() {
while (true) {
synchronized (b) {
if (b.size() > 0) {
b.remove(0);
}
}
}
}
Il blocco è come un tornello, un casello o il bagno di una casa. Più persone arrivano insieme, ma passano una per volta. Fuori si procede di nuovo in parallelo. In una casa con due bagni due persone possono essere dentro insieme, ciascuna nel suo. L'oggetto fra parentesi dice quale tornello.
Proteggere un lato solo non basta. L'altro thread non chiede l'ingresso, quindi continua a modificare la lista e l'errore resta. Ogni accesso, di qualunque thread, deve passare dallo stesso tornello.
Anche stampare la lista è un accesso: se avviene mentre un thread la modifica, può dare errore. Va quindi in un blocco sullo stesso oggetto. Stampare l'intera lista, anche solo ogni mezzo secondo, produce un output enorme, perché la lista diventa molto lunga, e la console non lo regge. Stampando solo size() si vede che la lista cresce e cala a caso, senza errori.
Due blocchi si escludono solo se riferiscono lo stesso oggetto. Con due stringhe diverse, anche per una sola lettera, i tornelli sono due e l'errore ritorna. Lo stesso accade con una copia o con un altro oggetto: deve essere proprio lo stesso oggetto.
L'oggetto può essere una stringa, un Object o la lista stessa. Non serve che sia statico, né che le variabili abbiano lo stesso nome. Basta che main lo crei una volta e lo passi a entrambi i Runnable, che lo tengono in due campi, a e b. Conviene sincronizzare sull'oggetto a cui si accede, la lista, senza crearne uno apposta.
Un solo oggetto, due riferimenti
Il problema riguarda anche un semplice int. Un campo int di istanza o statico non inizializzato vale 0. Una variabile locale invece deve ricevere un valore prima di essere letta, altrimenti non compila. Se ogni Runnable dichiara il proprio int x, le variabili sono due e non comunicano: per condividerla serve un campo statico, o un campo di un oggetto comune.
Qui Scrittore assegna a x un numero casuale da 0 a 999, e Lettore lo stampa solo se è minore di 995. Ogni valore stampato dovrebbe quindi essere minore di 995. Senza sincronizzazione, a lungo andare ne compare qualcuno che non lo è: Lettore controlla la condizione, viene sospeso prima della stampa, Scrittore assegna un altro valore e viene stampato quello. La versione protetta è questa.
public class Principale {
static int x;
static Object blocco = new Object();
public static void main(String[] args) {
Scrittore scrittore = new Scrittore();
Lettore lettore = new Lettore();
Thread ts = new Thread(scrittore);
Thread tl = new Thread(lettore);
ts.start();
tl.start();
}
}
public class Scrittore implements Runnable {
public void run() {
while (true) {
synchronized (Principale.blocco) {
Principale.x = (int) (1000 * Math.random());
}
}
}
}
public class Lettore implements Runnable {
public void run() {
while (true) {
synchronized (Principale.blocco) {
if (Principale.x < 995) {
System.out.println(Principale.x);
}
}
}
}
}
Controllo e stampa sono una sola operazione logica, quindi stanno nello stesso blocco. Il blocco di Lettore da solo non basta: Scrittore non chiede l'ingresso e cambia x quando vuole. Anche l'assegnazione va in un blocco sullo stesso oggetto.
Il blocco non rende l'esecuzione ininterrotta: un thread può essere sospeso anche al suo interno. Il controllo però non passa a un altro thread che sta per entrare in un blocco sullo stesso oggetto. Un thread che non chiede l'ingresso non è fermato da nessuno, quindi ogni accesso ai dati condivisi deve passare da un blocco.
Questi errori compaiono solo in alcune esecuzioni: una prova può andare bene e la successiva no. Un errore che si manifesta ogni dieci minuti è molto difficile da trovare. Per una semplice coda si usa quindi una BlockingQueue, già predisposta per gli accessi concorrenti, invece di sincronizzare a mano una lista.
Il metodo run() dell'interfaccia Runnable è void: scrivere int run() dà un errore di override. Il risultato non si restituisce, si scrive in un campo dell'oggetto. Anche i dati si passano nei campi, prima di avviare il thread. Si calcola la somma dei numeri da 0 a .
public class Calcola implements Runnable {
int n;
int risultato;
public void run() {
int somma = 0;
for (int k = 0; k <= n; k++) {
somma = somma + k;
}
this.risultato = somma;
}
}
Con c.run() il calcolo avviene nel flusso di main, in sequenza, e dopo la chiamata c.risultato è pronto. Con un thread, start() non esegue subito run(): mette il thread fra quelli eseguibili, e main può proseguire prima che parta. La prima stampa mostra allora il valore iniziale, 0. join() attende la fine del thread e, come sleep, può lanciare InterruptedException.
public class Principale {
public static void main(String[] args) {
Calcola c = new Calcola();
c.n = 10000;
Thread t = new Thread(c);
t.start();
System.out.println(c.risultato);
try {
t.join();
} catch (InterruptedException e) {
return;
}
System.out.println(c.risultato);
}
}
In genere la prima stampa dà 0 e la seconda . Lanciare il thread e attendere subito la fine non fa guadagnare nulla. Il vantaggio è che fra start() e join() main può fare altro, per esempio avviare un secondo calcolo, che procede insieme al primo invece che dopo.
Il risultato cresce in fretta: aumentando n, a un certo punto supera il massimo di un int e, in complemento a due, il numero «fa il giro» e diventa negativo.
Se al thread servono solo dati e nessun risultato, il Runnable si può passare al costruttore di Thread senza tenerne una variabile. Per leggere un risultato serve invece il riferimento all'oggetto, qui c: senza, nessuno può raggiungere il campo. Un metodo ha molti parametri ma un solo valore di ritorno. Un Runnable può avere più campi per i dati e più campi per i risultati, e lo stesso vale per ogni oggetto che rappresenta un metodo, cioè un funtore.
Lista come coda
add(x): in fondoremove(0): toglie il primoremove(0) non attendeBlocco sincronizzato
synchronized (oggetto) {…}Limiti
BlockingQueueRunnable
run() è void: dati e
risultati nei campirun() diretto: nel thread
corrente, in sequenzastart() non esegue subitojoin() attende la fine,
poi si legge il risultatoRunnable