1. Le classiche condizioni di Coffman
Nel 1971 lo scienziato informatico Edward G. Coffman Jr. ha descritto quattro condizioni, senza le quali il deadlock non è possibile. Queste regole sono da tempo l'"abc" per colloqui e corsi di multithreading. Conviene non solo saperle a memoria, ma capire perché servono:
- Mutual Exclusion (Esclusione reciproca): almeno una risorsa può essere acquisita da un solo thread alla volta.
- Hold and Wait (Trattenimento e attesa): un thread che ha acquisito una risorsa può attendere altre risorse.
- No Preemption (Nessuna preemptione): una risorsa non può essere strappata via da un thread forzatamente — solo il thread che la detiene può rilasciarla.
- Circular Wait (Attesa circolare): esiste una catena di thread in cui ciascuno aspetta una risorsa detenuta dal successivo.
Se si verificano tutte e quattro le condizioni, il deadlock è possibile. Se si violate almeno una — la possibilità di deadlock scompare.
Decisione rapida
flowchart TD
A[Devi bloccare più risorse?] -- No --> B[Standard lock]
A -- Sì --> C[È possibile sempre acquisirle nello stesso ordine?]
C -- Sì --> D[Acquisire sempre nello stesso ordine]
C -- No --> E[Evitare lock annidati]
D --> F[Minimizzare il tempo sotto lock]
E --> F
B --> F
F --> G{Hai ancora paura?}
G -- Sì --> H[Aggiungi timeout e retry]
G -- No --> I[Dormi tranquillo!]
2. Strategie principali per prevenire il Deadlock
Dal momento che il deadlock è parte integrante del mondo multithread, il nostro compito è imparare a conviverci. Diverse strategie aiutano: dalle più semplici alle più furbe.
Acquisire sempre le risorse nello stesso ordine
L'idea è semplice: se tutti i thread acquisiscono le risorse nello stesso ordine, l'attesa circolare non può formarsi. Questa tecnica è spesso chiamata ordered locking — "blocco ordinato".
Esempio
Codice pericoloso:
// Thread 1
lock (resA)
{
lock (resB)
{
// ...
}
}
// Thread 2
lock (resB)
{
lock (resA)
{
// ...
}
}
Qui è possibile un deadlock: il primo thread ha preso resA e aspetta resB, il secondo il contrario. Entrambi rimangono appesi e non si sbloccheranno mai.
Codice corretto:
// Sempre: prima resA, poi resB (mai il contrario)
lock (resA)
{
lock (resB)
{
// ...
}
}
Ora l'ordine è uniforme e il deadlock è impossibile. Non importa quante risorse hai o come si chiamano — l'importante è che tutti i thread prendano i lock nello stesso ordine.
Errore tipico: se nel codice esiste anche un solo punto dove l'ordine è violato, il rischio di deadlock ritorna. Quindi attenzione!
Evitare di bloccare più risorse contemporaneamente
Questo è il più semplice: se puoi non prendere lock su due o più risorse contemporaneamente — non farlo! Più lock annidati ci sono, più alto è il rischio di restare bloccati. Invece puoi, per esempio, copiare i dati necessari in variabili temporanee, rilasciare il lock e solo dopo operare su altre risorse.
Usare timeout quando si acquisisce un lock
Se proprio devi prendere più lock, i timeout vengono in aiuto. Per esempio, invece del solito lock usa metodi che permettono di "provare" a prendere il lock e, se non ci riesci, fare rollback, per esempio Monitor.TryEnter.
object resourceA = new object();
object resourceB = new object();
bool successA = false, successB = false;
try
{
// Proviamo a prendere entrambi i lock al massimo per 2 secondi
successA = Monitor.TryEnter(resourceA, TimeSpan.FromSeconds(2));
if (!successA) return; // non ci siamo riusciti, usciamo
successB = Monitor.TryEnter(resourceB, TimeSpan.FromSeconds(2));
if (!successB) return;
// Sezione critica
}
finally
{
if (successB) Monitor.Exit(resourceB);
if (successA) Monitor.Exit(resourceA);
}
Se il secondo lock non viene acquisito entro 2 secondi, rilasciamo il primo e usciamo. Così il deadlock non si verifica, ma alcuni thread dovranno abbandonare il tentativo.
Minimizzare la quantità di codice dentro il lock
Più piccolo è il blocco di codice "sotto lock", meno tempo un altro thread dovrà aspettare. Evita calcoli complessi, chiamate di rete e operazioni su disco dentro la sezione critica. Prendi il lock — modifica rapidamente la risorsa condivisa — rilascia. Tutto il resto fallo fuori.
Separare lo stato — evitare risorse condivise
A volte è meglio non lottare coi lock, ma evitare lo stato condiviso:
- Usa oggetti immutabili (immutable).
- Lavora con copie o variabili locali invece di globali.
- Passa i dati tramite messaggi (Actor Model, message queue).
- Usa collezioni thread-safe: ConcurrentDictionary, ConcurrentQueue ecc. da System.Collections.Concurrent.
3. Risolvere il Deadlock, se è già successo
Uccidere e riavviare manualmente (non la soluzione migliore)
Il modo più semplice è terminare tutti i processi bloccati. Però questo comporta rischio di perdita di dati. È l'extrema ratio, che è meglio evitare.
Rilevare il Deadlock in runtime
Alcuni sistemi possono rilevare automaticamente il deadlock. Se un thread non riesce a prendere un lock troppo a lungo, può loggare l'evento, avvisare il monitoring e iniziare il recupero.
if (!Monitor.TryEnter(resource, TimeSpan.FromSeconds(10)))
{
Console.WriteLine("Sembra che siamo finiti in deadlock o qualcuno tiene il lock troppo a lungo!");
// Si può iniziare un recupero, esportare un dump o avvisare l'utente
}
Nei sistemi distribuiti si usano detector separati che analizzano il wait-for graph e sbloccano forzatamente le transazioni bloccate.
Rollback automatico e retry
Una pratica comoda è "arrendersi" e riprovare: se non riesci a prendere tutti i lock in tempo ragionevole, fai rollback, aspetta un po' (spesso un tempo casuale per desincronizzare i tentativi) e riprova.
for (int attempt = 0; attempt < 3; attempt++)
{
bool gotRes1 = Monitor.TryEnter(res1, TimeSpan.FromSeconds(2));
bool gotRes2 = false;
try
{
if (gotRes1)
{
gotRes2 = Monitor.TryEnter(res2, TimeSpan.FromSeconds(2));
if (gotRes2)
{
// Sezione critica
break;
}
}
}
finally
{
if (gotRes2) Monitor.Exit(res2);
if (gotRes1) Monitor.Exit(res1);
}
// Non ci siamo riusciti — aspettiamo e riproviamo
Thread.Sleep(500 + new Random().Next(500));
}
4. Best practice e raccomandazioni
- Analizza l'ordine di acquisizione dei lock. Raccogli i punti di ingresso e verifica l'ordine.
- Usa quando possibile le collezioni da System.Collections.Concurrent.
- Applica strumenti di analisi del codice. IDE come Rider o Visual Studio aiutano a trovare lock annidati.
- Logga i tentativi di acquisizione lunghi.
- Esegui stress-test sui componenti multithread.
- Documenta le convenzioni sull'ordine dei lock. I commenti salvano dall'incoerenza.
5. Esempi pratici
Esempio: Database
Nei DBMS il deadlock è ospite frequente: due transazioni aggiornano le stesse tabelle ma in ordine diverso (A→B e B→A). La maggior parte dei DBMS sa rilevare queste situazioni e "uccide" una delle transazioni.
Esempio: Metodi asincroni
In .NET, se mescoli async/await e lock, puoi ottenere deadlock: await può sospendere il thread che detiene un lock, mentre un altro thread aspetta la stessa risorsa. Pianifica i confini delle sezioni critiche ed evita await dentro di esse.
6. Errori tipici quando si lavora con i lock
Errore #1: fare lock su stringhe.
lock (myString) — cattiva idea. In .NET le stringhe sono internate, quindi stai di fatto bloccando la tabella globale delle stringhe.
Errore #2: ordine diverso nell'acquisizione degli oggetti.
Se in punti diversi del programma i lock vengono presi in ordine diverso, la probabilità di deadlock aumenta notevolmente.
Errore #3: lock troppo lunghi.
Tenere un lock più a lungo del necessario, o chiamare metodi esterni dentro la sezione critica — è una strada sicura verso blocchi e deadlock.
GO TO FULL VERSION