Parte I — Concorrenza a memoria condivisa · Capitolo 6

Deadlock

~20 min di lettura6 widget interattivi

In questo capitolo

  1. Il problema dei Filosofi a cena
  2. Primo tentativo: un semaforo per forchetta
  3. Deadlock e condizioni di Coffman
  4. Soluzioni per i Filosofi: ticket e ordinamento
  5. La regola generale per evitare il deadlock
  6. Liveness in Java: deadlock, starvation, livelock
  7. Deadly embrace in Java

1. Il problema dei Filosofi a cena

Problema classico della programmazione concorrente, introdotto da Dijkstra nel 1971 (originariamente come problema di sincronizzazione per 5 computer in competizione per 5 unita a nastro) e reso celebre da Tony Hoare nella versione dei filosofi a cena.

Descrizione: Cinque filosofi trascorrono la vita alternando due attivita: pensare e mangiare. I pasti sono consumati a un tavolo con 5 piatti e 5 forchette. Al centro c'e una scodiglia di spaghetti sempre piena. Ogni filosofo ha bisogno di due forchette per mangiare — quella a sinistra e quella a destra — ma può raccoglierle solo una alla volta.

L'obiettivo e progettare protocolli pre- e post- che garantiscano: mutua esclusione sulle forchette, assenza di deadlock, assenza di starvation, ed efficienza in assenza di contesa.

Trappola comune

Il primo tentativo — modellare ogni forchetta come semaforo binario e far prendere a ogni filosofo prima la sinistra poi la destra — porta a deadlock. Se tutti i filosofi prendono la forchetta sinistra contemporaneamente, nessuno potra mai prendere quella destra. E l'esempio didattico perfetto di circular wait.

Il professor Ricci riassume cosi i requisiti dei protocolli di pre- e post- da progettare:

Il ciclo di vita di un singolo filosofo puo essere esplorato interattivamente:

2. Primo tentativo: un semaforo per forchetta

Il problema fu introdotto da Dijkstra proprio per illustrare il problema del deadlock (che chiamava deadly embrace); oggi e considerato un classico veicolo didattico per confrontare formalismi di programmazione concorrente. La sfida e che le forchette sono condivise tra filosofi adiacenti: la forchetta i e usata dai filosofi i e (i+1)%N.

Primo tentativo (incorretto: deadlock!)

semaphore array[0..4] fork ← [1,1,1,1,1]

philosopher[i]:   { per i = 0..4 }
  loop forever
    think
    wait(fork[i])             { prendi forchetta sinistra }
    wait(fork[(i+1)%N])       { prendi forchetta destra }
    eat
    signal(fork[i])
    signal(fork[(i+1)%N])

Questo tentativo e corretto per la mutua esclusione (nessuna forchetta e mai tenuta da due filosofi contemporaneamente, perche ogni forchetta e un semaforo binario), ma soffre di deadlock: se tutti i filosofi prendono contemporaneamente la forchetta sinistra prima che qualcuno provi a prendere la destra, nessuno puo mangiare e tutti restano bloccati per sempre in attesa della forchetta destra.

In sintesi: ogni forchetta e modellata come un semaforo binario, e il filosofo puo raccoglierne solo una alla volta. Purtroppo questa soluzione deadlocka: se tutti i filosofi raccolgono la forchetta sinistra prima che qualcuno provi a raccogliere la destra, nessuno puo mai mangiare.

3. Deadlock e condizioni di Coffman

Un deadlock e una situazione in cui due o piu azioni in competizione aspettano che l'altra finisca, e quindi nessuna finisce mai. Il professore lo chiama anche deadly embrace, il termine originale di Dijkstra. Nel 1971, Coffman identifico le quattro condizioni necessarie affinche un deadlock possa verificarsi in un sistema:

  1. 1. Mutua esclusione — la risorsa coinvolta non puo essere usata da piu di un processo alla volta
  2. 2. Hold and wait — i processi che gia detengono risorse possono richiederne di nuove, senza rilasciare le prime
  3. 3. No preemption — una risorsa non puo essere rimossa forzatamente dal processo che la detiene; solo il processo stesso puo rilasciarla esplicitamente
  4. 4. Circular wait — esiste una catena circolare di processi, dove ciascuno attende una risorsa detenuta dal successivo nella catena

Il deadlock puo verificarsi solo in sistemi dove tutte e quattro le condizioni sono vere contemporaneamente. Rimuovendone anche una sola, il deadlock e prevenuto.

Deadlock con i lock (deadly embrace)

Il caso piu semplice: thread A tiene il lock L e cerca di acquisire M, mentre thread B tiene M e cerca di acquisire L. Entrambi aspettano per sempre. Nei database, questo scenario viene gestito automaticamente: il DBMS analizza il grafo delle dipendenze, identifica il ciclo, sceglie una vittima e abortisce la transazione, rilasciando i lock. Nella JVM, invece, non esiste alcun meccanismo automatico di rilevamento e recovery: "if threads deadlock, that's all, folks!" — l'unica opzione pratica e spegnere l'applicazione e fare una diagnosi post-mortem.

Attenzione

Nei database i deadlock vengono rilevati e gestiti automaticamente (si analizza il grafo is-waiting, si cerca un ciclo, si sceglie una vittima). Nella JVM non c'e nulla di tutto questo: se i thread vanno in deadlock, basta. Si puo solo fare post-mortem diagnosis.

Nel caso dei filosofi, la catena circolare di attesa e esattamente questa:

flowchart LR subgraph Deadlock P1["F1: ha forchetta 1
attende forchetta 2"] --> P2["F2: ha forchetta 2
attende forchetta 3"] P2 --> P3["F3: ha forchetta 3
attende forchetta 4"] P3 --> P4["F4: ha forchetta 4
attende forchetta 5"] P4 --> P5["F5: ha forchetta 5
attende forchetta 1"] P5 --> P1 end

Nella lezione di ripasso il professore richiama le condizioni di Coffman e ripropone la simulazione completa con tutti e cinque i filosofi: guidando a mano l'interleaving si puo riprodurre (o evitare) il deadlock del primo tentativo.

4. Soluzioni per i Filosofi: ticket e ordinamento

Il professore presenta due soluzioni classiche al problema dei Filosofi, entrambe valide e che soddisfano tutte le proprieta (mutua esclusione, no deadlock, no starvation, comportamento efficiente in assenza di contesa).

Soluzione con ticket (N-1)

Limitiamo il numero di filosofi che possono mangiare contemporaneamente a N-1 (quindi 4 su 5). L'idea e che se solo 4 filosofi possono tentare di prendere le forchette, almeno uno riuscira sempre a prendere entrambe. Si introduce un semaforo counting ticket inizializzato a 4.

Il professore commenta: "Se hai cinque filosofi che vogliono mangiare tutti insieme, metti quattro permessi, fai mangiare solo quattro alla volta. Se metti quattro permessi, come se dovessero prendere un ticket prima di iniziare a mangiare, si risolve il problema."

Soluzione con ordinamento (breaking the wait-for chain)

Si osserva che non c'e deadlock se si impone un ordine totale nell'acquisizione delle forchette. L'idea e che ogni filosofo prende prima la forchetta con indice minore e poi quella con indice maggiore.

Il professore spiega l'intuizione: "Se lui cerca di prendere prima la forchetta con indice minore, e questa e gia stata presa da un altro, si blocca subito senza avere l'altra forchetta. Quindi l'altra forchetta puo essere presa dal filosofo vicino. Non si crea mai un ciclo di attesa."

Limitare il numero di filosofi che possono mangiare simultaneamente a N-1 (4 su 5), introducendo un semaforo ticket inizializzato a 4:

semaphore ticket := (4, {})

loop forever
  think
  wait(ticket)
  wait(fork[i])
  wait(fork[(i+1) % N])
  eat
  signal(fork[i])
  signal(fork[(i+1) % N])
  signal(ticket)

Questa soluzione soddisfa tutte le proprieta.

Rimuovere la condizione di circular wait facendo prendere le forchette in un ordine totale (es., prima la forchetta con indice minore, poi quella con indice maggiore):

integer first = min(i, (i+1) % N)
integer second = max(i, (i+1) % N)

loop forever
  think
  wait(fork[first])
  wait(fork[second])
  eat
  signal(fork[first])
  signal(fork[second])

L'ultimo filosofo (indice 4) prendera prima la forchetta 0 e poi la 4, invertendo l'ordine rispetto agli altri — il wait-for chain si spezza.

Un modo intuitivo di vedere la soluzione con ticket e la sala da pranzo: il semaforo ticket inizializzato a N-1 fa entrare in sala al massimo N-1 filosofi su N — solo loro possono competere per le forchette, rompendo la circolarita. Per la soluzione con ordinamento, equivalentemente, si osserva che il deadlock non si verifica se l'ultimo filosofo prende prima la forchetta destra e poi la sinistra: la regola e acquisire sempre i lock in un ordine totale prestabilito, il che rende impossibile la condizione di circular wait.

5. La regola generale per evitare il deadlock

Dall'esperienza dei Filosofi si generalizza una regola semplice ma potentissima per evitare deadlock in sistemi con lock multipli. Il professore la presenta come forse la cosa piu importante da ricordare quando si scrive codice concorrente:

Regola d'oro
  1. Assegnare un ordine totale a tutti i lock del sistema
  2. Acquisire i lock sempre nello stesso ordine, in ogni punto del codice

Perche funziona?

Rende impossibile la condizione di circular wait, che e una delle quattro condizioni necessarie di Coffman. Se ogni processo acquisisce i lock in ordine crescente, non si puo formare un ciclo: se P aspetta una risorsa tenuta da Q, significa che P ha lock con indici piu piccoli e Q con indici piu grandi, quindi Q non puo a sua volta aspettare una risorsa di P (perche la richiederebbe in ordine crescente, ma ha gia lock con indice maggiore).

Piuttosto che dimostrarlo formalmente, il professore ne da l'intuizione: "Notate che cosa succede se lui cerca di prendere prima la forchetta con indice minore. L'ha gia presa? Se e gia stata presa si blocca in attesa senza avere il possesso dell'altra. Quindi sostanzialmente il fatto di imporre l'ordine permette di fare in modo che, quando si crea una situazione che va verso l'essere ciclico, un processo non riesce a prendere neanche la prima."

Per l'esame

La regola "acquisisci sempre i lock nello stesso ordine" e forse il singolo concetto piu importante da ricordare per scrivere codice concorrente corretto. Il deadlock e stato ampiamente studiato, e sono state identificate le condizioni necessarie: romperne anche solo una e sufficiente.

6. Liveness in Java: deadlock, starvation, livelock

Il professore dedica una parte della lezione alle proprieta di liveness dei thread, riprendendo materiale da "Java Concurrency in Practice". Anche se la sezione critica garantisce mutua esclusione, non e sufficiente: il programma deve anche progressare. Due problemi classici:

Deadlock (stallo): due o piu thread aspettano ciascuno un lock che l'altro possiede, e nessuno puo proseguire. Le condizioni necessarie (Coffman, 1971) sono quattro e devono valere tutte contemporaneamente:

  1. Mutua esclusione: le risorse non sono condivisibili.
  2. Hold and wait: un thread tiene una risorsa mentre ne richiede un'altra.
  3. No preemption: le risorse non possono essere sequestrate.
  4. Attesa circolare: esiste un ciclo di thread che si aspettano a vicenda.

Starvation (inedia): un thread e pronto per eseguire ma non ottiene mai la CPU o il lock che richiede, perche altri thread vengono sempre preferiti dallo scheduler. A differenza del deadlock, il thread non e bloccato permanentemente, ma non fa progressi per un periodo indefinito.

Livelock: i thread non sono bloccati ma continuano a eseguire azioni che non portano a progresso. Un esempio tipico: due thread che rilevano un conflitto e "cedono" il passo all'altro, finendo per oscillare senza mai procedere. Il professore lo menziona a proposito del terzo tentativo di soluzione della sezione critica.

Il professore sottolinea che la JVM non ha un meccanismo automatico di rilevamento o recupero dai deadlock: "if threads deadlock, that's all, folks!" — l'unica opzione e arrestare l'applicazione. Il supporto si limita a strumenti di diagnostica post-mortem (stack trace, thread dump).

7. Deadly embrace in Java

Il professore mostra il deadlock nella sua forma piu semplice: deadly embrace (abbraccio mortale). Due thread, A e B, e due lock, L1 e L2:

La soluzione strutturale al deadlock segue una regola semplice ma potente: acquisire i lock sempre nello stesso ordine globale. Se tutti i thread acquisiscono L1 prima di L2, il ciclo di attesa non puo formarsi, perche non ci puo essere un thread che tiene L2 e richiede L1 mentre un altro tiene L1 e richiede L2.

Per l'esame

Le condizioni di Coffman sono necessarie ma non sufficienti: se anche tutte valgono, un deadlock potrebbe non verificarsi (serve il giusto interleaving). Per evitarli: (1) acquisire lock in ordine globale, (2) usare tryLock() con timeout, (3) ridurre il numero di lock, (4) usare astrazioni di piu alto livello.