← tutti gli articoli

.NET

Come capisco la concorrenza in .NET

Pensavo che la concorrenza significasse solo thread. Copre race condition, deadlock, transaction, e il lost update problem, e un lock in C# non protegge in nessun modo una riga di database.

Come capisco la concorrenza in .NET

Quando ho sentito la parola concorrenza per la prima volta, pensavo significasse semplicemente usare più thread.

Dopo aver studiato di più su .NET e SQL Server, ho imparato che la concorrenza è un argomento più ampio. Succede ogni volta che più operazioni sono in corso e possono accedere o modificare le stesse resource. Due thread che cambiano la stessa variabile. Due request di API che aggiornano lo stesso record di database. Due transaction che aspettano resource che l'una tiene bloccate per l'altra. Due utenti che modificano la stessa informazione quasi nello stesso momento.

Come sviluppatore junior non ho bisogno di conoscere ogni dettaglio interno di thread, lock e del motore di SQL Server. Dovrei capire i problemi più comuni e gli strumenti disponibili per affrontarli.

Cosa significa davvero concorrenza

Concorrenza significa che più operazioni avanzano durante lo stesso periodo.

Immagina un'API ASP.NET Core che riceve due request quasi nello stesso momento:

Request A: Compra l'ultimo prodotto disponibile
Request B: Compra l'ultimo prodotto disponibile

Entrambi i request possono leggere che il prodotto ha un'unità disponibile. Se entrambi procedono senza alcuna protezione, entrambi possono completare l'acquisto, e il sistema vende lo stesso articolo due volte.

Ecco perché questo conta. Codice che funziona correttamente con un utente può produrre risultati inattesi quando più utenti lo colpiscono simultaneamente.

La concorrenza compare a tre livelli: dentro l'applicazione .NET quando thread o task condividono dati, dentro SQL Server quando le transaction accedono alle stesse righe o tabelle, e tra istanze dell'applicazione quando gira su più server o container.

Questi livelli sono collegati, ma non sono controllati allo stesso modo. Un lock in C# può proteggere memoria dentro un'istanza dell'applicazione. Non può proteggere una riga di SQL Server da un altro server dell'applicazione.

Race condition

Una race condition succede quando il risultato di un'operazione dipende da quale thread arriva per primo su un pezzo di codice.

Considera un counter semplice:

public class RequestCounter
{
    private int _count;

    public void Increment()
    {
        _count++;
    }
}

Quel _count++ sembra un'unica operazione, ma internamente sono tre passi: leggere il valore attuale, sommare uno, salvare il nuovo valore.

Supponi che il valore attuale sia 10. Due thread potrebbero entrambi leggere 10, calcolare entrambi 11, e salvare entrambi 11. Il risultato atteso era 12. Il risultato reale è 11.

Per operazioni numeriche semplici, .NET fornisce la classe Interlocked:

using System.Threading;

public class RequestCounter
{
    private int _count;

    public void Increment()
    {
        Interlocked.Increment(ref _count);
    }
}

Interlocked.Increment esegue l'incremento come un'unica operazione atomica, quindi nessun altro thread può interferire a metà.

Usare lock in C#

Per operazioni più complesse, C# offre l'istruzione lock:

public class BankAccount
{
    private readonly object _balanceLock = new();
    private decimal _balance;

    public void Deposit(decimal amount)
    {
        lock (_balanceLock)
        {
            _balance += amount;
        }
    }
}

Solo un thread può eseguire la sezione protetta usando quel lock object alla volta.

Aggiungere lock ovunque non è comunque una buona soluzione. I lock riducono le performance, fanno aspettare i thread, aumentano la complessità, introducono deadlock, e possono nascondere problemi causati da un design scadente. Dove possibile, è meglio evitare shared mutable state piuttosto che proteggere tutto con i lock.

Un lock in C# funziona anche solo dentro il processo che lo possiede. Se un'applicazione gira su tre server, ogni server ha il proprio lock object. La concorrenza del database deve essere gestita dal database stesso o tramite un altro meccanismo distribuito.

Deadlock

Un deadlock avviene quando due operazioni aspettano permanentemente resource che l'una tiene bloccate per l'altra.

Thread A:
1. Blocca la Resource 1
2. Aspetta la Resource 2

Thread B:
1. Blocca la Resource 2
2. Aspetta la Resource 1

Nessuno dei due thread può continuare. La stessa cosa succede in SQL Server:

Transaction A:
1. Aggiorna Customer 1
2. Prova ad aggiornare Customer 2

Transaction B:
1. Aggiorna Customer 2
2. Prova ad aggiornare Customer 1

SQL Server rileva questo ciclo e sceglie una transaction come vittima del deadlock. Quella transaction subisce un rollback così che l'altra possa continuare. Le applicazioni dovrebbero essere pronte a ricevere errori di deadlock, e a seconda dell'operazione, riprovare può essere appropriato.

Alcuni modi di base per ridurre i deadlock: tenere le transaction brevi, accedere alle resource in un ordine coerente, evitare operazioni non necessarie dentro le transaction, creare indici appropriati, non aspettare mai input dell'utente mentre una transaction è aperta, e implementare retry logic per operazioni che è sicuro ripetere.

I deadlock non possono sempre essere eliminati in sistemi con alta concorrenza. L'obiettivo è ridurre quanto spesso accadono e gestirli correttamente quando accadono.

Blocking non è un deadlock

Prima trattavo queste due cose come la stessa cosa. Non lo sono.

Blocking succede quando un'operazione aspetta perché un'altra tiene bloccata una resource di cui ha bisogno:

Transaction A aggiorna un prodotto e tiene aperta la transaction.
Transaction B prova ad aggiornare lo stesso prodotto.
Transaction B aspetta finché Transaction A non finisce.

Quell'attesa può essere del tutto normale. Un deadlock richiede una dipendenza circolare, dove A aspetta B e B aspetta A.

Il blocking di solito finisce quando la prima transaction fa commit o rollback. Un deadlock non si risolve naturalmente, ed è per questo che SQL Server deve interrompere una delle transaction. Le transaction di lunga durata peggiorano il blocking, perché i lock possono restare presi finché la transaction non finisce.

Perché le transaction contano

Una transaction di database raggruppa diverse operazioni in un'unica unità logica di lavoro. L'esempio classico è trasferire denaro:

1. Togli $100 da Account A.
2. Aggiungi $100 su Account B.

Entrambe dovrebbero riuscire o entrambe dovrebbero fallire. Senza una transaction, la prima potrebbe riuscire e la seconda potrebbe fallire, e il denaro sparisce.

await using var transaction =
    await dbContext.Database.BeginTransactionAsync();

try
{
    sender.Balance -= 100m;
    receiver.Balance += 100m;

    await dbContext.SaveChangesAsync();
    await transaction.CommitAsync();
}
catch
{
    await transaction.RollbackAsync();
    throw;
}

EF Core avvolge già le modifiche di un'unica chiamata a SaveChanges in una transaction quando il provider lo supporta. Il controllo manuale conta di più quando un'operazione di business coinvolge più chiamate a SaveChanges, SQL raw, o altre operazioni che devono riuscire insieme.

ACID

Una transaction affidabile segue quattro proprietà.

Atomicity significa che la transaction è un'unica unità. O tutte le operazioni avvengono o nessuna avviene. SQL Server non deve addebitare il mittente senza accreditare il destinatario.

Consistency significa che la transaction lascia il database in uno stato valido, con constraint e regole di business ancora soddisfatte. Un campo obbligatorio non dovrebbe diventare null, una foreign key dovrebbe continuare a puntare a un record esistente, un saldo non dovrebbe diventare negativo quando il business non lo permette.

Isolation controlla come le transaction concorrenti osservano i cambiamenti l'una dell'altra. Un altro request non dovrebbe vedere il denaro dopo che ha lasciato il mittente ma prima che arrivi al destinatario.

Durability significa che, una volta che una transaction fa commit, le sue modifiche sopravvivono anche se il server va in crash dopo. SQL Server usa il suo transaction log per questo.

Il lost update problem

Questo è il problema di concorrenza del database che ho trovato più facile da capire.

Due dipendenti aprono lo stesso record cliente, dove il numero di telefono è 1111. Il dipendente A lo cambia in 2222. Il dipendente B, che tiene ancora la vecchia versione, lo cambia in 3333. A salva per primo, poi B salva.

Senza un controllo di concorrenza, l'aggiornamento di B sovrascrive quello di A, e la modifica di A va persa senza che nessuno venga avvisato.

Optimistic concurrency

L'optimistic concurrency presume che i conflitti siano possibili ma non comuni. Invece di bloccare un record per tutto il tempo in cui qualcuno lo sta modificando, l'applicazione controlla se il record è cambiato prima di salvare.

Leggi una riga e la sua versione, lasciala modificare, invia l'aggiornamento con la versione originale, aggiorna solo quando la versione corrisponde ancora, e segnala un conflitto quando non corrisponde.

Questo si adatta alle applicazioni web, perché gli utenti non tengono aperti lock del database mentre visualizzano e modificano le pagine.

EF Core supporta questo lasciando che una proprietà venga configurata come concurrency token. Durante un aggiornamento include il token originale nella clausola WHERE dell'SQL. Se un altro processo ha modificato la riga, niente corrisponde, e EF Core lancia una DbUpdateConcurrencyException.

rowversion di SQL Server

SQL Server fornisce il tipo rowversion. Nonostante il suo vecchio sinonimo timestamp, non è una data né un'ora. È un valore binario generato automaticamente che cambia ogni volta che la riga viene aggiornata, il che lo rende utile per rilevare se una riga è cambiata da quando l'hai letta.

using System.ComponentModel.DataAnnotations;

public class Product
{
    public int Id { get; set; }

    public string Name { get; set; } = string.Empty;

    public decimal Price { get; set; }

    [Timestamp]
    public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}

L'SQL generato segue questa idea:

UPDATE Products
SET Price = @newPrice
WHERE Id = @id
  AND RowVersion = @originalRowVersion;

Se un altro request ha già aggiornato il prodotto, il suo RowVersion è diverso, l'aggiornamento colpisce zero righe, e EF Core rileva il conflitto.

try
{
    await dbContext.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
    throw new InvalidOperationException(
        "This record was changed by another user. Reload it and try again.");
}

In un'API reale questo diventa una risposta HTTP appropriata, di solito 409 Conflict. L'applicazione può chiedere all'utente di ricaricare, mostrare i valori attuali, lasciargli scegliere quale versione tenere, unire modifiche compatibili, o riprovare automaticamente dove è sicuro farlo.

Pessimistic e optimistic

Le strategie di concorrenza si dividono in due approcci generali.

La pessimistic concurrency presume che un conflitto sia probabile, quindi il sistema blocca la resource e le altre operazioni aspettano. Utile quando i conflitti sarebbero costosi o le operazioni devono coordinarsi rigidamente, al costo di più blocking e rischio di deadlock.

L'optimistic concurrency presume che i conflitti siano rari. Le operazioni procedono senza un lock lungo, e l'applicazione verifica che nulla sia cambiato prima di salvare. rowversion e i concurrency token di EF Core sono gli esempi comuni.

Nessuna delle due è sempre migliore. Dipende dai dati, dal numero atteso di conflitti, e dalle regole di business.

Isolation level

Gli isolation level controllano quanto una transaction è isolata dalle altre. I principali livelli di SQL Server sono READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SNAPSHOT, e SERIALIZABLE.

La lezione importante al mio livello è che un isolamento più forte dà maggiore consistenza ma aumenta il blocking e riduce la concorrenza. Un isolamento più basso migliora la concorrenza ma può lasciare che le transaction osservino i dati in modi che alcune operazioni di business non possono accettare.

SNAPSHOT e le opzioni basate su row version lasciano che le letture usino versioni di riga salvate invece di aspettare gli scrittori, il che riduce il blocking tra lettura e scrittura. Aggiunge altre considerazioni, e non elimina la necessità di capire i conflitti di aggiornamento.

Non cambierei l'isolation level solo perché un'applicazione ha problemi di blocking. Le query, gli indici, i confini delle transaction, e il requisito di business reale vengono tutti prima.

Cosa mi porto via da questo

Non dare per scontato che una riga significhi un'operazione atomica, perché _count++ sono tre passi e può andare in race.

Tieni le transaction brevi. Una transaction dovrebbe contenere le operazioni di database di una singola azione di business logica, e non dovrebbe restare aperta mentre chiama API esterne, esegue calcoli lunghi, o aspetta input dell'utente.

Non usare un lock in C# come un lock del database. Protegge memoria dentro un processo e non coordina niente tra server.

Aspettati conflitti. In un sistema multiutente, due request che aggiornano lo stesso record non è un edge case impossibile, e l'applicazione dovrebbe decidere cosa succede quando accade.

Capisci prima la regola di business, perché la soluzione giusta dipende dall'operazione. Aggiornare il titolo di un post del blog non è la stessa cosa che prenotare l'ultima camera d'albergo, processare un pagamento, ridurre l'inventario, trasferire denaro, o riscattare un voucher monouso. Più importante è l'operazione, più attentamente va progettato il suo comportamento di concorrenza.

C'è molto di più da imparare qui, inclusi i tipi di lock, gli execution plan, i deadlock graph, le strategie di retry, e i sistemi distribuiti. Queste basi mi aiutano già a scrivere applicazioni più sicure e a individuare problemi che compaiono solo quando più request girano nello stesso momento.

Riferimenti

  • Microsoft Learn, Managed Threading Best Practices: race condition, synchronization, e Interlocked
  • Microsoft Learn, Synchronizing Data for Multithreading
  • Microsoft Learn, Deadlocks Guide for SQL Server
  • Microsoft Learn, Transaction Locking and Row Versioning Guide
  • Microsoft Learn, Handling Concurrency Conflicts in EF Core
  • Microsoft Learn, SQL Server Value Generation in EF Core
  • Microsoft Learn, Transactions in EF Core
  • Microsoft Learn, SET TRANSACTION ISOLATION LEVEL