Menu

Istruzione lock in C#: race condition, Interlocked e deadlock

L'istruzione lock permette a un solo thread alla volta di eseguire un blocco di codice. Guarda una race condition corrompere un contatore, correggila con lock, scopri su quale oggetto fare lock, usa Interlocked per i contatori semplici, evita i deadlock dovuti all'ordine dei lock e usa SemaphoreSlim quando il codice all'interno fa await.

Questa pagina include editor eseguibili: modifica, esegui e vedi subito l'output.

Quando più thread leggono e scrivono gli stessi dati nello stesso momento, gli aggiornamenti possono andare persi o essere visti a metà. L'istruzione lock rende un blocco di codice mutuamente esclusivo: mentre un thread è al suo interno, ogni altro thread che arriva a un lock sullo stesso oggetto aspetta il suo turno.

Il problema: una race condition

Quattro task aggiungono ciascuno 100.000 a un contatore condiviso. Il risultato dovrebbe essere 400.000:

Output di esempio:

Expected 400000, got 245609

Il totale di solito risulta più basso, e ogni volta di una quantità diversa (su una macchina con un solo core a volte può risultare giusto, ed è proprio questo che rende questi bug difficili da scovare). count++ sembra un'unica operazione ma sono tre: leggere count, aggiungere uno, riscriverlo. Due thread possono leggere entrambi 500, calcolare entrambi 501 e scrivere entrambi 501, e un incremento sparisce.

La soluzione: lock

Racchiudi la lettura, modifica e scrittura in un lock su un oggetto condiviso:

Output:

Expected 400000, got 400000

Ora un solo thread alla volta può trovarsi dentro il blocco, quindi ogni incremento si completa prima che il successivo legga il valore. Il lock garantisce anche la visibilità: un thread che entra nel lock vede tutte le scritture fatte dal precedente detentore prima di uscire.

La protezione funziona solo se ogni accesso a count passa per lo stesso lock. Un solo count++ senza lock altrove nel programma riporta la race condition.

In cosa viene compilato lock

lock è una scorciatoia per la classe Monitor, con un try/finally in modo che il lock venga rilasciato anche quando il blocco lancia un'eccezione:

lock (sync)
{
    count++;
}

// is compiled roughly as:
bool taken = false;
try
{
    Monitor.Enter(sync, ref taken);
    count++;
}
finally
{
    if (taken) Monitor.Exit(sync);
}

Monitor offre anche TryEnter(obj, timeout), che rinuncia dopo un timeout invece di aspettare per sempre, e Wait/Pulse per scambiarsi segnali tra thread. Un thread che tiene già un lock può rientrarci (i lock sono rientranti), quindi un metodo con lock può chiamare un altro metodo con lock sullo stesso oggetto.

Scegliere l'oggetto di lock

L'oggetto in lock (...) è solo un gettone su cui i thread si mettono d'accordo. Le regole:

  • Usa un campo privato di sola lettura di tipo object. private readonly object sync = new object();. Privato significa che nessun codice esterno può prendere lo stesso lock; readonly significa che il gettone non può essere sostituito mentre un thread lo tiene.
  • Mai lock (this). Chiunque abbia un riferimento al tuo oggetto può fare lock anche lui, e a quel punto il suo codice blocca il tuo o va in deadlock con esso.
  • Mai fare lock su una stringa o su typeof(...). I letterali stringa sono internati (ogni "orders" nel processo è lo stesso oggetto), e gli oggetti Type sono condivisi in tutta l'applicazione, quindi codice che non c'entra nulla può finire a contendersi lo stesso lock.
  • Mai fare lock su un tipo valore. lock (count) su un int non compila (errore CS0185), e farne il boxing a mano crea un oggetto nuovo ogni volta, quindi non bloccherebbe niente.
  • Usa un lock per ogni insieme di dati che deve restare coerente: un campo di lock static per i dati statici, un campo di istanza per i dati di ogni istanza.

Proteggere operazioni in più passi

lock serve soprattutto dove un controllo e un'azione devono avvenire insieme. Un conto thread safe mostra sia lo schema controlla e poi agisci sia un'istantanea coerente di due campi:

Output:

10 left after 33 withdrawals

Senza il lock, due thread potrebbero vedere entrambi un saldo di 40, superare entrambi il controllo e prelevare entrambi 30, lasciando il conto a meno 20. Anche il metodo Summary fa lock, quindi non riporta mai un balance successivo a un prelievo insieme a un conteggio withdrawals precedente.

Nemmeno Dictionary<TKey, TValue> e List<T> sono thread safe: le scritture concorrenti possono corrompere i loro array interni, non solo perdere aggiornamenti. Proteggili con un lock oppure usa ConcurrentDictionary<TKey, TValue> e gli altri tipi di System.Collections.Concurrent.

Interlocked per valori singoli

Quando lo stato condiviso è un intero o un riferimento e l'aggiornamento è un solo passo, la classe Interlocked lo esegue in modo atomico senza lock:

Output:

Visits: 10000
Bytes: 5120000
Peak: 10000

Increment, Decrement, Add ed Exchange corrispondono a operazioni atomiche del processore (una sola istruzione su x64). CompareExchange(ref location, newValue, expected) scrive solo se la posizione contiene ancora expected e restituisce ciò che ha trovato, il che ti permette di costruire qualsiasi aggiornamento come un ciclo di tentativi. Tutto ciò che tocca due variabili contemporaneamente ha comunque bisogno di un lock.

Deadlock

Un deadlock si verifica quando due thread tengono ciascuno un lock di cui l'altro ha bisogno:

// Thread 1                          // Thread 2
lock (accountA)                      lock (accountB)
{                                    {
    lock (accountB) { /* ... */ }        lock (accountA) { /* ... */ }
}                                    }

Se il thread 1 prende accountA mentre il thread 2 prende accountB, ognuno poi aspetta l'altro per sempre. Non c'è eccezione né timeout; il programma si blocca. Un trasferimento tra due conti che fa lock su "origine" e poi su "destinazione" produce esattamente questo quando due trasferimenti opposti girano nello stesso momento.

Le difese standard:

  • Prendi i lock in un ordine globale fisso. Per un trasferimento, blocca prima il conto con l'ID più piccolo, in qualunque direzione vadano i soldi.
  • Tieni i lock per poco tempo e fai il lavoro lento (I/O, logging, chiamate di rete) fuori da essi.
  • Non chiamare codice sconosciuto mentre tieni un lock: eventi, callback e metodi virtuali potrebbero prendere lock propri.
  • Usa Monitor.TryEnter con un timeout dove un blocco del programma sarebbe peggio di un errore.

Niente await dentro lock: usa SemaphoreSlim

await non è consentito dentro un blocco lock (errore di compilazione CS1996). Dopo un await il metodo può continuare su un thread diverso, e un lock di Monitor deve essere rilasciato dal thread che l'ha preso. Per il codice async, SemaphoreSlim con un contatore pari a 1 funziona come un lock compatibile con async:

Output:

5 saved, one at a time

Abbina sempre WaitAsync a Release in un finally, altrimenti un'eccezione lascia il cancello chiuso per sempre. Un SemaphoreSlim(3, 3) lascia entrare tre chiamanti alla volta, ed è così che si limita il numero di richieste concorrenti verso un'API con limiti di utilizzo.

System.Threading.Lock (.NET 9)

.NET 9 con C# 13 aggiunge un tipo dedicato System.Threading.Lock. Quando l'oggetto di un'istruzione lock è un Lock, il compilatore usa la sua API EnterScope, più veloce, invece di Monitor:

private readonly Lock sync = new Lock();

public void Add(decimal amount)
{
    lock (sync)          // uses Lock.EnterScope(), not Monitor
    {
        balance += amount;
    }
}

Le regole per scegliere l'oggetto restano le stesse. Nelle versioni precedenti, private readonly object è la scelta corretta.

Errori comuni

  • Proteggere con lock alcuni accessi ma non tutti. Ogni lettura e scrittura dei dati condivisi deve prendere lo stesso lock.
  • lock (this), lock (typeof(X)), lock ("name"). Il codice esterno può prendere lo stesso lock.
  • Fare I/O lento dentro un lock. Tutti gli altri thread aspettano; tieni il blocco corto.
  • Prendere due lock in ordini diversi in punti diversi. Il deadlock classico.
  • Usare lock attorno ad await. Non compila; usa SemaphoreSlim.
  • Un lock per ogni chiamata. lock (new object()) non protegge nulla; l'oggetto deve essere condiviso.

Domande frequenti

Cosa fa lock in C#?

lock (obj) { ... } permette a un solo thread alla volta di eseguire il blocco per un dato oggetto di lock. Un secondo thread che arriva a un lock sullo stesso oggetto aspetta finché il primo non esce dal blocco. Il lock viene rilasciato anche se il blocco lancia un'eccezione, perché lock viene compilato in Monitor.Enter e Monitor.Exit dentro un try/finally.

Su quale oggetto devo fare lock in C#?

Su un campo privato dedicato: private readonly object _sync = new object();. Deve essere un tipo riferimento, condiviso da tutti i thread che toccano i dati protetti, e non raggiungibile dall'esterno della tua classe. Non fare mai lock su this, su un Type (typeof(MyClass)) o su una stringa, perché altro codice può fare lock sullo stesso oggetto e bloccarti o causare un deadlock.

Quando conviene usare Interlocked invece di lock?

Quando lo stato condiviso è un singolo numero o riferimento e l'operazione è un solo passo: Interlocked.Increment(ref count), Interlocked.Add(ref total, x), Interlocked.Exchange o CompareExchange. Sono operazioni atomiche dell'hardware e sono più veloci di un lock. Per tutto ciò che tocca più campi o una collezione, usa lock.

Posso usare await dentro un lock in C#?

No, il compilatore rifiuta await dentro un blocco lock (errore CS1996), perché il codice dopo l'await può riprendere su un thread diverso da quello che tiene il lock. Usa invece SemaphoreSlim con un contatore pari a 1: await sem.WaitAsync(); try { ... } finally { sem.Release(); }.

Come si verificano i deadlock con lock?

Il thread 1 tiene il lock A e aspetta il lock B, mentre il thread 2 tiene il lock B e aspetta il lock A. Nessuno dei due può proseguire e il programma si blocca senza alcuna eccezione. Per evitarlo prendi sempre più lock nello stesso ordine globale, tienili per il minor tempo possibile e non chiamare mai codice sconosciuto mentre tieni un lock.

Illustrazione dei linguaggi di programmazione di Coddy

Impara a programmare con Coddy

INIZIA