Parte I — Concorrenza a memoria condivisa · Capitolo 6

Deadlock

~12 min di lettura1 simulatore dei filosofi a cena

In questo capitolo

  1. Deadlock, starvation, livelock
  2. I filosofi a cena
  3. Quando può esserci un deadlock: le condizioni di Coffman
  4. Due soluzioni per i filosofi
  5. La regola generale, in Java
  6. Si può rilevare e recuperare un deadlock?
  7. Verifica le tue conoscenze

Il capitolo in un paragrafo. Un deadlock è un gruppo di processi fermi per sempre, ciascuno in attesa di una risorsa tenuta da un altro del gruppo. Perché accada devono valere insieme le quattro condizioni di Coffman: mutua esclusione, possesso e attesa, assenza di prelazione e attesa circolare. Basta romperne una. I filosofi a cena mostrano il caso tipico e due rimedi: limitare con un ticket i processi in gara, oppure acquisire le risorse sempre nello stesso ordine. Quest'ultima è la regola generale per chi usa più lock. I database rilevano i deadlock e li risolvono abortendo una transazione; la JVM invece li può solo diagnosticare, a programma ormai bloccato.

1. Deadlock, starvation, livelock

Un deadlock è una situazione in cui due o più azioni in competizione aspettano ciascuna che l'altra finisca, e quindi nessuna finisce. Il caso più semplice con i lock è l'abbraccio mortale (deadly embrace): il thread A tiene il lock L e chiede M, mentre B tiene M e chiede L. Entrambi aspettano per sempre. Nel diagramma di stato (capitolo 4) è uno stato raggiungibile da cui i processi coinvolti non hanno più transizioni, e nessun evento esterno può sbloccarli.

Il deadlock è il più grave dei liveness hazard, ma non l'unico:

ProblemaChe cosa succedeEsempio tipico
DeadlockUn gruppo di thread è bloccato per sempre, e ognuno aspetta un altro del gruppo.Due lock presi in ordine opposto.
StarvationUn thread aspetta indefinitamente mentre gli altri avanzano; non serve un ciclo di attese.Semaforo debole (capitolo 5), uso delle priorità dei thread.
LivelockI thread non sono bloccati, ma continuano a ritentare un'operazione che fallisce sempre.Processi che girano a vuoto o si cedono il passo in sincronia, come nel 3° e nel 4° tentativo del capitolo 4.
Scarsa reattivitàNessuno è bloccato per sempre, ma chi aspetta aspetta troppo.Compiti lunghi nel thread della GUI; un lock tenuto mentre si elabora una grande collezione.

Il livelock si rompe introducendo un po' di casualità nei tentativi, per esempio un'attesa casuale prima di riprovare, così i processi smettono di muoversi in sincronia. Le priorità dei thread Java sono sconsigliate proprio perché dipendono dalla piattaforma e causano problemi di liveness.

Tra safety e liveness c'è una tensione. I lock garantiscono la thread safety, ma usati senza criterio creano deadlock di ordinamento dei lock. Semafori e thread pool limitano le risorse, ma se non si capisce che cosa limitano creano deadlock di risorse: cento task che si aspettano su una barriera, eseguiti da un pool di sedici thread, bloccano tutti i thread e gli altri task non partono mai (capitolo 10). All'estremo opposto, troppe sezioni critiche o troppo lunghe serializzano il programma (poor concurrency): vanno tenute brevi, senza però barattare la safety con le prestazioni.

2. I filosofi a cena

Il problema nasce da una domanda d'esame di Dijkstra del 1971 (cinque computer in competizione per cinque unità a nastro); Hoare lo ha raccontato come i filosofi a cena, e oggi è il banco di prova per confrontare formalismi concorrenti. Cinque filosofi pensano e mangiano, a un tavolo con cinque piatti, cinque forchette e una zuppiera di spaghetti sempre piena. Gli spaghetti sono così aggrovigliati che servono due forchette, e ogni filosofo può prendere solo quella alla sua sinistra e quella alla sua destra, una alla volta. Rispetto alla sezione critica, ogni processo ha bisogno di due risorse, condivise in cerchio.

Bisogna progettare un pre-protocollo e un post-protocollo intorno a eat che garantiscano cinque proprietà:

Il primo tentativo modella ogni forchetta con un semaforo binario: wait la prende, signal la posa.

semaphore array[0..4] fork := [1, 1, 1, 1, 1]

philosopher i: loop forever
p1: think
p2: wait(fork[i])              -- sinistra
p3: wait(fork[(i+1) mod N])    -- destra
p4: eat
p5: signal(fork[i])
p6: signal(fork[(i+1) mod N])

Si dimostra che nessuna forchetta è mai tenuta da due filosofi. Però la soluzione va in deadlock: basta un interleaving in cui tutti prendono la forchetta sinistra prima che qualcuno provi a prendere la destra. A quel punto F0 aspetta f1, tenuta da F1, che aspetta f2, e così via fino a F4, che aspetta f0, tenuta da F0. Si è formato un ciclo di attese che nessun evento può sciogliere.

3. Quando può esserci un deadlock: le condizioni di Coffman

Coffman (1971) ha individuato quattro condizioni necessarie. Un deadlock può verificarsi solo in un sistema in cui valgono tutte e quattro:

  1. Mutua esclusione: una risorsa non può essere usata da più processi alla volta.
  2. Possesso e attesa (hold and wait): un processo che tiene già delle risorse può chiederne altre.
  3. Nessuna prelazione (no preemption): una risorsa non può essere tolta a chi la tiene, e si libera solo con un'azione esplicita del processo.
  4. Attesa circolare (circular wait): due o più processi formano una catena circolare, in cui ciascuno aspetta una risorsa tenuta dal successivo.

Sono necessarie, non sufficienti: nel primo tentativo dei filosofi valgono tutte, ma molti interleaving non si bloccano mai. Il valore pratico è l'altra direzione: per prevenire il deadlock basta rompere una delle quattro. Le soluzioni di questo capitolo rompono l'attesa circolare. Con i lock di Java si può anche rompere il possesso e attesa, prendendo un lock alla volta oppure rinunciando a quelli già presi quando un tryLock fallisce. Mutua esclusione e assenza di prelazione, invece, di solito sono proprio ciò che si vuole da un lock.

Per l'esame

«Che cos'è un deadlock?» è stata chiesta all'orale. Oltre alla definizione, conviene dare subito le quattro condizioni e il fatto che basta romperne una.

4. Due soluzioni per i filosofi

semaphore array[0..4] fork := [1, 1, 1, 1, 1]
semaphore ticket := (4, {})

philosopher i: loop forever
p1: think
p2: wait(ticket)
p3: wait(fork[i])
p4: wait(fork[(i+1) mod N])
p5: eat
p6: signal(fork[i])
p7: signal(fork[(i+1) mod N])
p8: signal(ticket)

Si limita il numero di filosofi che possono entrare in sala da pranzo: N − 1 ticket per N filosofi, con un semaforo generale (capitolo 5). Il ciclo di attese richiede tutti e cinque i filosofi, ciascuno con una forchetta in mano: con quattro al tavolo almeno uno trova libere entrambe le forchette. Non vuol dire che ne mangino quattro insieme, perché con cinque forchette ne mangiano al più due. Si dimostra che la soluzione soddisfa tutte le proprietà; l'assenza di starvation richiede però semafori forti, altrimenti un filosofo può perdere ogni volta la gara per il ticket.

semaphore array[0..4] fork := [1, 1, 1, 1, 1]
integer first  := min(i, (i+1) mod N)
integer second := max(i, (i+1) mod N)

philosopher i: loop forever
p1: think
p2: wait(fork[first])
p3: wait(fork[second])
p4: eat
p5: signal(fork[first])
p6: signal(fork[second])

La situazione del primo tentativo sembra perfettamente simmetrica, ma non lo è. Tutti prendono prima la forchetta di indice minore, tranne l'ultimo: F4 prende prima f4 e poi f0. Se anche F4 rispetta l'ordine degli indici, cioè prende prima f0, il ciclo si spezza. Quando F4 trova f0 già presa da F0 si blocca senza tenere nulla, e f4 resta libera per F3. In generale, chi arriva per secondo si ferma sulla prima risorsa, e non prenota la seconda.

L'ordinamento non costa nulla, ma richiede di poter ordinare le risorse. Se le risorse sono dinamiche, cioè le forchette vanno e vengono, un ordine globale può non esistere, e resta il ticket.

Simulatore

Ogni pulsante esegue un'istruzione atomica di un filosofo. Una wait su una forchetta occupata non si può eseguire finché la forchetta non torna libera. Il simulatore cerca a ogni passo i cicli nel grafo delle attese, e le tracce precaricate mostrano il deadlock del primo tentativo e perché ticket e ordine totale lo evitano.

Attiva JavaScript per usare il simulatore.

5. La regola generale, in Java

Con N processi che condividono e acquisiscono più lock, la regola per evitare il deadlock è:

  1. assegnare un ordine totale ai lock;
  2. acquisirli sempre nello stesso ordine.

Funziona perché rende impossibile l'attesa circolare. In un ciclo ogni processo tiene un lock e ne chiede uno di rango maggiore, quindi percorrendo il ciclo si avrebbe r₀ < r₁ < … < r₀, che è assurdo. Un programma che non tiene mai più di un lock alla volta non può avere deadlock di ordinamento. Se deve prenderne più di uno, l'ordine va progettato: si minimizzano le interazioni tra lock e si documenta il protocollo d'ordinamento. Verificare che un programma rispetti un ordine coerente richiede infatti un'analisi globale di tutti i punti in cui prende lock.

L'esempio delle slide è il trasferimento di denaro tra conti. transferMoney(from, to, amount) prende il lock di from e poi quello di to. Con venti thread che fanno trasferimenti casuali, prima o poi uno esegue transfer(a, b) mentre un altro esegue transfer(b, a): è l'abbraccio mortale. La correzione ordina i lock in base all'indice del conto:

public void transferMoney(int from, int to, int amount) throws InsufficientBalanceException {
    int first = Math.min(from, to), last = Math.max(from, to);   // ordine globale
    synchronized (accounts[first]) {
        synchronized (accounts[last]) {
            if (accounts[from].getBalance() < amount) throw new InsufficientBalanceException();
            accounts[from].debit(amount);
            accounts[to].credit(amount);
        }
    }
}

Più insidiosi sono i deadlock tra oggetti che cooperano, in cui nessun metodo prende esplicitamente due lock. Nel pattern Observer delle slide, changeState è synchronized sull'osservato e chiama notifyStateChanged, che è synchronized sull'osservatore. Intanto un altro thread esegue getOverallState, synchronized sull'osservatore, che chiama getState, synchronized sull'osservato. Sono due lock presi in ordine opposto, nascosti in due chiamate di metodo. È lo stesso rischio delle chiamate annidate tra monitor (capitolo 7), e riguarda ogni implementazione a eventi in un linguaggio a oggetti, come l'MVC.

Oltre le slide

Il rimedio suggerito da Java Concurrency in Practice, il testo di riferimento di queste note, è la chiamata aperta (open call): non chiamare metodi di altri oggetti mentre si tiene un lock. L'osservato copia la lista degli osservatori dentro il blocco synchronized e li notifica dopo averlo lasciato.

L'alternativa all'ordinamento sono i lock temporizzati. tryLock di ReentrantLock, con o senza timeout, permette di accorgersi che un lock non arriva, rilasciare quelli già presi e riprovare più tardi. In questo modo si rompe il possesso e attesa. Con i lock intrinseci di synchronized questo non è possibile. Le attese prima di riprovare vanno rese casuali, altrimenti i thread rischiano di ritentare in sincronia per sempre: sarebbe un livelock.

6. Si può rilevare e recuperare un deadlock?

In generale sì, ed è ciò che fanno i database. Una transazione accumula lock fino al commit, quindi non è raro che due transazioni finiscano in deadlock. Il DBMS mantiene il grafo delle attese («chi aspetta chi»), vi cerca cicli e, se ne trova uno, sceglie una vittima e ne abortisce la transazione. I lock della vittima vengono rilasciati, le altre proseguono, e la transazione abortita può essere rieseguita.

La JVM no: non ha alcun meccanismo automatico di rilevamento e recupero. Se dei thread vanno in deadlock su synchronized, restano lì: non si possono nemmeno interrompere, e l'unica via d'uscita è chiudere l'applicazione. Resta la diagnosi post-mortem con il thread dump. Su Unix si ottiene inviando SIGQUIT al processo (kill -3) o premendo Ctrl-\; su Windows con Ctrl-Break. Il dump contiene lo stack di ogni thread e le informazioni sui lock: quali tiene ciascun thread, in quale frame li ha presi, e quale lock aspetta chi è bloccato. La JVM vi segnala anche i deadlock trovati. Strumenti come jstack, JConsole e VisualVM mostrano le stesse informazioni.

Oltre le slide

Il rilevamento si può fare anche da programma: ManagementFactory.getThreadMXBean().findDeadlockedThreads() restituisce i thread coinvolti in un ciclo, sia su monitor sia su lock di java.util.concurrent. È comunque solo rilevamento. Un recupero va progettato a mano, usando lock interrompibili (lockInterruptibly) o temporizzati (tryLock) e riportando lo stato a una situazione coerente.

Per l'esame

«È possibile fare recovery da un deadlock? Come? Java lo fa?». In generale sì: rilevamento dei cicli nel grafo delle attese e abort di una vittima, come nei DBMS. Java no: nessun recupero automatico, solo diagnosi con thread dump (e rilevamento con ThreadMXBean). Quindi il deadlock in Java si previene con l'ordine dei lock.

Verifica le tue conoscenze

Che differenza c'è tra deadlock, starvation e livelock?

Nel deadlock un gruppo di processi è bloccato per sempre in un ciclo di attese. Nella starvation un processo aspetta indefinitamente mentre gli altri avanzano, senza bisogno di un ciclo. Nel livelock i processi non sono bloccati, ma ritentano all'infinito un'operazione che fallisce sempre.

Quali sono le condizioni di Coffman?

Mutua esclusione, possesso e attesa, nessuna prelazione, attesa circolare. Sono necessarie: se ne manca una il deadlock è impossibile, e prevenirlo significa romperne una.

Perché il primo tentativo dei filosofi va in deadlock?

Se tutti prendono la forchetta sinistra prima che qualcuno provi a prendere la destra, ognuno aspetta una forchetta tenuta dal vicino e si forma un ciclo di attese tra tutti e cinque.

Perché il ticket N−1 elimina il deadlock?

Il ciclo di attese richiederebbe tutti gli N filosofi, ciascuno con una forchetta. Con al più N − 1 filosofi in gara, almeno uno trova libere entrambe le forchette, mangia e poi le rilascia.

Qual è la regola generale per evitare il deadlock con più lock, e perché funziona?

Dare un ordine totale ai lock e acquisirli sempre in quell'ordine. Un ciclo di attese richiederebbe che ciascuno aspetti un lock di rango maggiore di quello che tiene, fino a tornare al primo: impossibile.

Come si può avere un deadlock senza che nessun metodo prenda due lock?

Con oggetti che cooperano: un metodo synchronized chiama un metodo synchronized di un altro oggetto, mentre un altro thread fa il percorso inverso. È il caso dell'Observer nelle slide.

Come recupera un deadlock un DBMS? E la JVM?

Il DBMS cerca cicli nel grafo delle attese e abortisce una transazione vittima. La JVM non recupera nulla: si può solo diagnosticare il deadlock con un thread dump e chiudere l'applicazione.

Come si ottiene un thread dump e che cosa contiene?

Con kill -3 o Ctrl-\ su Unix, o con Ctrl-Break su Windows. Contiene lo stack di ogni thread, i lock che ciascuno tiene e dove li ha presi, e il lock che aspetta chi è bloccato.

Da quali lezioni viene questo capitolo

27 febbraio 2026 (§17: anticipazione di deadlock e filosofi) · 2 marzo (§9–10: laboratorio «Thread Liveness», deadly embrace) · 6 marzo (§12–15: filosofi, Coffman, ticket e ordinamento, regola generale, thread dump) · 13 marzo (§13), 16 marzo (§5–7), 20 marzo (§13), 27 marzo (§16): ripassi · 20 e 30 marzo: deadlock dei task su un thread pool. La mappa completa è in coverage-matrix.json.