CodeGym /Corsi /C# SELF /Analisi approfondita delle Race Conditions

Analisi approfondita delle Race Conditions

C# SELF
Livello 57 , Lezione 0
Disponibile

1. Esempi più complessi di Race Condition

La Race Condition è uno dei bug più subdoli. Perché? Perché di solito succedono raramente, dipendono dalla velocità della CPU, dalle latenze del sistema operativo, dai cambi di contesto casuali tra i thread, e si riproducono proprio quando vorresti andare a casa. Nessun analizzatore statico le cattura. I test unitari non le beccano (a meno che tu non sia un sensitivo), ma prima o poi... si manifestano in produzione.

Per evitare brutte sorprese bisogna capire bene come nascono le Race Conditions e cosa fare per evitarle.

Esempio 1. Una banca senza etica bancaria

Supponiamo di avere una classe di conto bancario molto semplice:

public class Account
{
    public int Balance = 0;

    public void Deposit(int amount)
    {
        Balance += amount;
    }

    public void Withdraw(int amount)
    {
        Balance -= amount;
    }
}

Immagina che due thread provino contemporaneamente a depositare 100 e 200 euro, e poi ognuno prelevi 50 euro. In un mondo single-thread il saldo sarebbe sempre: (0 + 100 + 200 - 50 - 50) = 200.

E nella realtà multithread? Facciamo un esperimento:

var acc = new Account();

var t1 = new Thread(() => {
    acc.Deposit(100);
    acc.Withdraw(50);
});
var t2 = new Thread(() => {
    acc.Deposit(200);
    acc.Withdraw(50);
});

t1.Start(); t2.Start();
t1.Join(); t2.Join();

Console.WriteLine(acc.Balance); // E qui ogni esecuzione può dare una risposta diversa!

Spiegazione:
Se entrambi i thread leggono Balance come 0, poi uno scrive 100, l'altro scrive 200, ma tra queste operazioni avviene un interleaving — il balance può "perdere" o "aggiungere" soldi. Una banca senza lock è il sogno di un truffatore!

Esempio 2. Variabile-flag

Un errore molto comune è usare una variabile-flag come indicatore di stato:

bool isReady = false;

void Worker()
{
    while (!isReady)
    {
        // aspettiamo...
    }
    // facciamo qualcosa
}

Un altro thread potrebbe scrivere isReady = true. A prima vista sembra sicuro: cosa può andare storto? In realtà anche leggere e scrivere una variabile booleana può essere insicuro! Motivo: ottimizzazioni del compilatore, cache della CPU, reordering delle istruzioni in un sistema multiprocessore.

Cosa può succedere?
Un thread potrebbe non vedere mai il cambiamento della variabile e rimanere bloccato nel ciclo, anche se un altro thread ha già impostato isReady = true. Per passare "flag" tra thread usa sempre i primitivi adatti (volatile, Interlocked, eventi ecc. — vedi la documentazione ufficiale).

Esempio 3. Controllo null e creazione oggetto

Immagina un singleton:

public class Singleton
{
    private static Singleton _instance;

    public static Singleton Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = new Singleton();
            }
            return _instance;
        }
    }
}

Il codice sembra banale? Ma se molti thread chiamano Instance contemporaneamente, potrebbero essere creati più istanze della classe! È il classico caso di double-check senza sincronizzazione: il campo _instance viene inizializzato in una race se non si protegge con lock.

2. Come la Race Condition appare nel codice

L'operazione "Read-modify-write" — anatomia del male

Prendiamo di nuovo il nostro incremento: counter++

In termini di CPU, è:

  1. Leggere dalla memoria: register = counter;
  2. Incrementare: register = register + 1;
  3. Scrivere indietro: counter = register;

Cosa succede se due thread eseguono questo blocco quasi contemporaneamente?

  • Entrambi leggono counter = 0.
  • Entrambi incrementano il registro interno a 1.
  • Entrambi scrivono indietro 1.

Risultato: con due incrementi il valore è aumentato solo di 1! Un incremento è stato "mangiato" — e nessuno se n'è accorto.

Visualizzazione dello "scontro" tra due thread


Thread A        |   Thread B        |   counter-------------------------------------------
Legge (0)       |                   |   0
                |   Legge (0)       |   0
Incrementa (1)  |                   |   0
                |   Incrementa (1)  |   0
Scrive (1)      |                   |   1
                |   Scrive (1)      |   1  <-- Oops! Ci aspettavamo 2, abbiamo 1

Perché le Race Condition sono così difficili da catturare?

  • La Race Condition si "maschera". Aggiungi un paio di Console.WriteLine — e il bug può sparire. Solo perché i thread hanno seguito un percorso diverso.
  • Il bug dipende dal numero di core, dal loro carico, dalla versione del SO e di .NET. Ieri funzionava, oggi si rompe.
  • Il risultato è fluttuante: il bug appare non sempre, ma solo in "casi speciali".
  • Fare il debug è un bel divertimento (sarcastico).

3. Race Condition nelle collezioni e nelle classi .NET

La Race Condition non riguarda solo variabili primitive. Collezioni, queue e persino classi standard .NET non sono sempre protette.

Esempio: List<T> non è thread-safe

Se due thread fanno contemporaneamente .Add() su una normale List<T>, le conseguenze possono essere catastrofiche:

var list = new List<int>();
var tasks = new List<Task>();

for (int t = 0; t < 10; t++)
{
    int threadNum = t;
    tasks.Add(Task.Run(() => {
        for (int i = 0; i < 1000; i++)
            list.Add(threadNum * 1000 + i);
    }));
}
Task.WaitAll(tasks.ToArray());
Console.WriteLine(list.Count); // Molto probabile che sia < 10000

La Race Condition qui avviene dentro il metodo Add(), perché la collezione potrebbe espandere l'array interno, copiare gli elementi, e nel frattempo un altro thread aggiunge... Risultato — dati persi, eccezioni o collezione corrotta.

Esempio: più thread usano lo stesso indicizzatore

var array = new int[10];
void Worker(int index, int value)
{
    array[index] = value;
}

Parallel.Invoke(
    () => Worker(5, 1),
    () => Worker(5, 2)
);

Alla fine in array[5] può esserci o 1 o 2 — dipende da quale thread ha scritto per ultimo. A volte questo comportamento è voluto, ma più spesso è fonte di bug sottili e difficili da trovare.

4. Suggerimenti utili

Race Condition e operazioni non atomiche

Stai particolarmente attento se un'operazione sembra "piccola" ma non è atomica.

  • i++, i--
  • a = b
  • myObject.Property = value
  • "Ho controllato il flag — poi ho cambiato il valore"
  • "Ho ottenuto l'oggetto — poi ho modificato il suo stato interno"

Tutto questo non è atomico! Altri thread possono "intercettare" l'esecuzione tra i micro-passi.

Miti e insidie delle Race Condition

Mito 1: "Se la variabile è int, allora è sicura, è un primitivo".
Realtà: Anche le operazioni su int non garantiscono atomicità, se non sono marcate volatile o usate tramite API apposite.

Mito 2: "Se io scrivo nella collezione e un altro thread solo legge — va bene".
Realtà: No! In .NET le collezioni non garantiscono integrità con letture e scritture concorrenti: puoi ottenere oggetti "corrotti" o eccezioni strane.

Mito 3: "Le Race Condition si manifestano solo in servizi molto carichi".
Realtà: Anche in piccole utility, giochi, applicazioni desktop ci si può imbattere in condizioni di gara. Semplicemente si manifestano meno spesso o non subito.

Come proteggersi dalle Race Condition?

  • Usa primitivi di sincronizzazione (lock, Mutex, Monitor ecc.).
  • Per operazioni base di incremento/decremento su variabili numeriche usa Interlocked (documentazione).
  • Per le collezioni usa classi thread-safe (ConcurrentBag, ConcurrentDictionary, ecc. — vedi la documentazione).
  • Non usare flag e stati senza protezione (volatile, eventi, primitivi di sincronizzazione).

Quando sorge una Race Condition

Scenario Può esserci Race Condition? Come proteggersi
Più thread leggono dati No (se non ci sono scritture) -
Un thread scrive, un altro legge
lock/volatile
Più thread scrivono
lock/Interlocked
Più thread modificano una collezione Collezioni thread-safe
Più thread usano un flag volatile, lock, eventi

Atomicità non è la panacea

A volte anche operazioni atomiche non risolvono gare logiche.

Esempio:

if (!cache.ContainsKey(key))
{
    cache[key] = GetData(key);
}

Se due thread entrano contemporaneamente in questo if, entrambi vedranno che la chiave non c'è e creeranno un nuovo valore — il secondo sovrascriverà il primo! Qui non bastano operazioni atomiche su intInterlocked — serve un lock completo oppure una funzione specializzata (come GetOrAdd su ConcurrentDictionary).

4. Errori tipici degli studenti quando lavorano con le Race Condition

Errore №1: pensare che lock serva solo per "cose grosse".
In realtà anche una variabile semplice può rompersi senza protezione.

Errore №2: bloccare l'oggetto sbagliato.
Per esempio una stringa o un oggetto accessibile fuori dalla classe. Alla fine la sincronizzazione non funziona come pensavi.

Errore №3: contare sul fatto che il risultato sia "quasi sempre corretto".
Se il bug si manifesta raramente — non significa che puoi fare a meno di proteggere le risorse.

Errore №4: usare metodi async senza sincronizzazione.
Si spera "tanto va bene" e si ottengono errori casuali nei posti più inaspettati.

Errore №5: scrivere un Singleton con double-check ma senza lock.
Invece di avere un solo oggetto in memoria compaiono più copie, e il debugging diventa un incubo.

2
Compito
C# SELF, livello 57, lezione 0
Bloccato
Sincronizzazione dell'accesso alla risorsa tramite `Monitor`
Sincronizzazione dell'accesso alla risorsa tramite `Monitor`
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION