1. Retroscena del problema
In un'applicazione single-thread le collezioni come List<T>, Dictionary<T> si comportano in modo prevedibile. Ma non appena più thread iniziano ad accedere alla stessa collezione contemporaneamente, emerge il problema noto: race conditions.
Se più thread cercano di leggere e/o scrivere nella stessa collezione senza sincronizzazione adeguata, puoi ottenere:
- Dati corrotti: un elemento potrebbe essere rimosso da un thread mentre un altro cerca di aggiornarlo.
- Perdita di dati: un thread ha aggiunto un elemento e un altro lo sovrascrive senza sapere della precedente scrittura.
- Eccezioni: la collezione può trovarsi in uno stato non valido, ottenendo InvalidOperationException (per esempio, "Collection was modified; enumeration operation may not execute.") o perfino NullReferenceException.
Esempio 1: Race condition in List<T> (semplice incremento)
Due thread incrementano contemporaneamente lo stesso elemento della lista.
using System.Collections.Generic;
using System.Threading.Tasks; // Per Task.Run
class RaceConditionExample
{
static List<int> numbers = new List<int> { 0 }; // Lista con un elemento
static void Main(string[] args)
{
Console.WriteLine("Valore iniziale: " + numbers[0]); // 0
// Avviamo due thread, ognuno incrementa numbers[0]
Task task1 = Task.Run(() => IncrementNumbers(500_000));
Task task2 = Task.Run(() => IncrementNumbers(500_000));
Task.WaitAll(task1, task2); // Aspettiamo che entrambi i thread finiscano
Console.WriteLine("Valore finale: " + numbers[0]); // Ci aspettiamo 1_000_000, ma...
// Il risultato quasi sempre sarà minore di 1_000_000!
}
static void IncrementNumbers(int count)
{
for (int i = 0; i < count; i++)
{
// Questa operazione "numbers[0]++" in realtà consiste di 3 passi:
// 1. Leggere numbers[0]
// 2. Incrementare il valore di 1
// 3. Scrivere il nuovo valore in numbers[0]
numbers[0]++;
}
}
}
Perché è una race? Se il thread A legge numbers[0] (valore 0), e poi il thread B legge numbers[0] (anche 0) prima che A abbia scritto 1, entrambi incrementeranno 0 a 1 e scriveranno 1. Un incremento viene perso. L'operazione numbers[0]++ non è atomica.
Esempio 2: InvalidOperationException quando si modifica un Dictionary
Un thread itera sul dizionario, un altro lo modifica.
using System.Collections.Generic;
using System.Threading; // Per Thread.Sleep
class DictionaryRaceExample
{
static Dictionary<int, string> users = new Dictionary<int, string>();
static void Main(string[] args)
{
// Inizializzazione del dizionario
for (int i = 0; i < 5; i++) users.Add(i, $"User {i}");
// Thread lettore
Thread readerThread = new Thread(() =>
{
try
{
foreach (var user in users) // Iterazione sul dizionario
{
Console.WriteLine($"Lettore: {user.Key} - {user.Value}");
Thread.Sleep(10); // Simulazione di lavoro
}
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Lettore: ERRORE! {ex.Message}");
}
});
// Thread scrittore
Thread writerThread = new Thread(() =>
{
Thread.Sleep(5); // Diamo al lettore un po' di tempo per iniziare
for (int i = 5; i < 10; i++)
{
users.Add(i, $"New User {i}"); // Aggiungiamo elementi
Console.WriteLine($"Scrittore: Aggiunto User {i}");
Thread.Sleep(15);
}
});
readerThread.Start();
writerThread.Start();
readerThread.Join(); // Aspettiamo che i thread finiscano
writerThread.Join();
Console.WriteLine("Esempio completato.");
}
}
Perché avviene l'errore? Dictionary<TKey, TValue> (come List<T>) non è pensato per letture e scritture concorrenti da thread diversi senza sincronizzazione. Quando il thread scrittore cambia la struttura interna, il thread lettore continua il foreach su dati ormai modificati, causando InvalidOperationException.
2. Perché semplici lock (lock) non sono sempre ottimali?
L'idea di "avvolgere tutto in un lock" sembra semplice, ma ha dei difetti:
// Esempio pessimo: lock troppo ampio
// (Solo per dimostrazione, non farlo!)
static object _lock = new object();
static List<int> _sharedList = new List<int>();
void AddItem(int item)
{
lock (_lock)
{
_sharedList.Add(item);
}
}
int GetItemCount()
{
lock (_lock)
{
return _sharedList.Count;
}
}
- Prestazioni (bottleneck): il lock blocca l'accesso all'intera collezione. Con 100 thread, 99 aspettano uno solo, anche se le operazioni non confliggono direttamente.
- Complessità: devi ricordarti di mettere il lock in ogni punto dove usi la collezione. Un punto dimenticato e la race ritorna.
- Deadlock: più lock su oggetti diversi possono facilmente causare deadlock.
- Iteratori: foreach non salva se un altro thread modifica la collezione.
Per questo in .NET sono state introdotte collezioni specifiche thread-safe.
Operazioni atomiche
Una collezione thread-safe garantisce un comportamento corretto in presenza di accessi concorrenti da più thread senza lock esterni da parte dell'utente. La chiave sono le operazioni atomiche: l'azione è eseguita tutta oppure non eseguita affatto — gli altri thread non vedono "stati intermedi".
- Aggiunta, rimozione, lettura — si comportano come se fossero eseguite una alla volta.
- Internamente si usano tecniche a basso livello: operazioni interlocked (Interlocked), Compare-And-Swap (CAS), lock più fini — invece di un lock globale sulla collezione.
3. Panoramica di System.Collections.Concurrent
Lo namespace System.Collections.Concurrent fornisce un insieme di collezioni costruite da zero per il multithreading. La loro filosofia è massimo parallelismo e minima contesa.
- Prestazioni: scalano con l'aumentare dei core.
- Semplicità: non serve mettere manualmente lock attorno a ogni operazione.
- Meno errori: si elimina una classe di problemi legati alla sincronizzazione manuale.
- Ottimizzate per concorrenza: funzionano efficacemente con aggiunte/rimozioni concorrenti.
4. Classi principali
ConcurrentQueue<T> (coda thread-safe)
Principio: FIFO — "first in, first out". Scenari: producer–consumer, logging, queue di task.
using System.Collections.Concurrent;
ConcurrentQueue<string> messageQueue = new ConcurrentQueue<string>();
void Producer() => messageQueue.Enqueue("Messaggio 1");
void Consumer()
{
if (messageQueue.TryDequeue(out string message))
{
Console.WriteLine($"Elaborato: {message}");
}
else
{
Console.WriteLine("La coda è vuota.");
}
}
ConcurrentStack<T> (stack thread-safe)
Principio: LIFO — "last in, first out". Scenari: cronologia azioni, DFS traversal, object pool.
using System.Collections.Concurrent;
ConcurrentStack<int> historyStack = new ConcurrentStack<int>();
void PushAction(int value) => historyStack.Push(value);
void PopAction()
{
if (historyStack.TryPop(out int action))
{
Console.WriteLine($"Azione annullata: {action}");
}
else
{
Console.WriteLine("Lo stack è vuoto.");
}
}
ConcurrentBag<T> (il "sacco" thread-safe)
Collezione non ordinata, l'ordine non è garantito. Ottimizzata per lo scenario "un thread prende più spesso ciò che lui stesso ha messo". Ottima per pool di oggetti.
using System.Collections.Concurrent;
ConcurrentBag<System.Guid> objectPool = new ConcurrentBag<System.Guid>();
void AddObject() => objectPool.Add(System.Guid.NewGuid());
void TakeObject()
{
if (objectPool.TryTake(out System.Guid obj))
{
Console.WriteLine($"Preso oggetto: {obj}");
}
else
{
Console.WriteLine("Il pool è vuoto.");
}
}
ConcurrentDictionary<TKey, TValue> (dizionario thread-safe)
Supporta operazioni atomiche di aggiunta, aggiornamento e lettura per chiave. Ottimo per cache, sessioni, contatori.
using System.Collections.Concurrent;
ConcurrentDictionary<string, int> userScores = new ConcurrentDictionary<string, int>();
void UpdateScore(string user, int score)
{
// Aggiunge atomically se non esiste, o aggiorna se esiste
userScores.AddOrUpdate(user, score, (key, existingVal) => existingVal + score);
Console.WriteLine($"Punteggio {user}: {userScores[user]}");
}
void GetScore(string user)
{
if (userScores.TryGetValue(user, out int score))
{
Console.WriteLine($"Punteggio attuale di {user}: {score}");
}
else
{
Console.WriteLine($"Utente {user} non trovato.");
}
}
5. Quando usare queste collezioni invece di quelle normali?
- L'app è multithread: con un solo thread le collezioni normali sono più veloci (nessun overhead).
- Una collezione condivisa tra più thread: è il segnale principale per usare System.Collections.Concurrent.
- Serve alta performance e scalabilità: le collezioni sono progettate per minimizzare l'attesa.
- Vuoi semplificare il codice: niente lock manuali attorno a ogni operazione.
- Hai bisogno di operazioni atomiche: add/remove/get non lasceranno la collezione in uno stato inconsistente.
Non usare le collezioni Concurrent quando:
- L'applicazione è strettamente single-thread.
- Hai bisogno di "transazionalità" su più operazioni correlate (potrebbe servire sincronizzazione esterna o altri meccanismi).
- L'ordine rigoroso di estrazione è importante dove non è garantito (per esempio in ConcurrentBag<T>).
GO TO FULL VERSION