CodeGym /Corsi /C# SELF /Prevenzione e risoluzione del Deadlock

Prevenzione e risoluzione del Deadlock

C# SELF
Livello 57 , Lezione 2
Disponibile

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:

  1. Mutual Exclusion (Esclusione reciproca): almeno una risorsa può essere acquisita da un solo thread alla volta.
  2. Hold and Wait (Trattenimento e attesa): un thread che ha acquisito una risorsa può attendere altre risorse.
  3. No Preemption (Nessuna preemptione): una risorsa non può essere strappata via da un thread forzatamente — solo il thread che la detiene può rilasciarla.
  4. 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.

Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION