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, è:
- Leggere dalla memoria: register = counter;
- Incrementare: register = register + 1;
- 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 | Sì | |
| Più thread scrivono | Sì | |
| Più thread modificano una collezione | Sì | Collezioni thread-safe |
| Più thread usano un flag | Sì | 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 int né Interlocked — 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.
GO TO FULL VERSION