1. Introduzione
Oggi vedremo come processare grandi quantità di dati il più velocemente possibile, sfruttando tutti i core del processore del tuo computer (o server). Per questo ci servono le classi dello spazio dei nomi System.Threading.Tasks.Parallel, in particolare i metodi Parallel.For e Parallel.ForEach.
E se il task è puro CPU-bound?
Un ciclo classico for o foreach elabora gli elementi uno dopo l'altro. Semplice e affidabile. Ma se hai una CPU multicore, il ciclo usa solo un core e gli altri restano praticamente a guardare. Perché non spartire l'array tra i core e processare in contemporanea?
Esempio:
// Calcoliamo la somma dei quadrati da 1 a N
long sum = 0;
for (int i = 1; i <= 1_000_000; i++)
{
sum += i * i;
}
Questo codice è semplice, ma lavora in modo sequenziale. E se distribuissimo i task sui core?
Meet the Family: Parallel.For e Parallel.ForEach
Cos'è?
- Parallel.For — funziona come un normale ciclo for, ma divide il lavoro in parti e le distribuisce automaticamente tra thread, sfruttando tutti i core disponibili.
- Parallel.ForEach — elabora una collezione come un normale foreach, ma in parallelo.
Documentazione ufficiale:
Perché è comodo?
Non devi creare, avviare e gestire i thread manualmente. Il framework fa il lavoro pesante per te. Scrivi codice simile a un ciclo normale e il parallelismo avviene automaticamente sotto il cofano.
2. Sintassi: esempi base
Parallel.For
long total = 0;
Parallel.For(1, 1_000_001, i =>
{
// Questa lambda può essere eseguita contemporaneamente da thread diversi
Interlocked.Add(ref total, i * i); // Per evitare race
});
Console.WriteLine($"Somma dei quadrati: {total}");
Nota: la variabile total la aggiorniamo tramite Interlocked.Add — per evitare race condition.
Parallel.ForEach
var numbers = Enumerable.Range(1, 10_000_000).ToArray();
long sum = 0;
Parallel.ForEach(numbers, num =>
{
Interlocked.Add(ref sum, num * num); // Somma sicura
});
Console.WriteLine($"Somma dei quadrati: {sum}");
Uno sguardo dall'interno (schema visivo)
+-------------------+
|Collezione/intervallo |
+---------+---------+
|
v
+----------------------+
| Parallel.ForEach |
+----------+-----------+
|
+----+----+----+----+
| | |
v v v
Task #1 Task #2 Task #3 ... (core disponibili)
| | |
+--+----+ +--+-----+ +--+-----+
|Elaborazione| |Elaborazione| |Elaborazione|
+-----------+ +-----------+ +-----------+
\ | /
+--------+--------+
|
v
Risultato
3. Analisi di grandi file (elaborazione CPU-bound)
Immagina di avere un file di testo con decine di migliaia di righe — per esempio ogni riga contiene un numero. Bisogna leggere il file, elevare ogni numero al quadrato e sommare i quadrati.
Versione sincrona
string[] lines = File.ReadAllLines("numbers.txt");
long sum = 0;
foreach (var line in lines)
{
if (long.TryParse(line, out long n))
{
sum += n * n;
}
}
Console.WriteLine($"Somma dei quadrati: {sum}");
Versione parallela con Parallel.For
string[] lines = File.ReadAllLines("numbers.txt");
long sum = 0;
Parallel.For(0, lines.Length, i =>
{
if (long.TryParse(lines[i], out long n))
{
Interlocked.Add(ref sum, n * n);
}
});
Console.WriteLine($"Somma dei quadrati: {sum}");
Cosa è cambiato: abbiamo sostituito il ciclo normale con uno parallelo, e sum lo incrementiamo ora tramite Interlocked.Add — per evitare conflitti tra thread.
4. Cosa succede sotto il cofano?
Quando chiami Parallel.For o Parallel.ForEach, .NET divide automaticamente il lavoro in chunk e li distribuisce sui core disponibili usando il thread pool. Ogni chunk viene processato in modo indipendente su un thread.
Vantaggio: se hai 4 core, il lavoro può essere quasi 4 volte più veloce (se il task non dipende da risorse esterne e non è limitato da altri colli di bottiglia come memoria o velocità di I/O su disco).
Confronto tempi di esecuzione
var numbers = Enumerable.Range(1, 100_000_000).ToArray();
long sumSync = 0;
var sw = System.Diagnostics.Stopwatch.StartNew();
foreach (var n in numbers)
sumSync += n * n;
sw.Stop();
Console.WriteLine($"Sync: {sw.ElapsedMilliseconds} ms, somma: {sumSync}");
long sumParallel = 0;
sw.Restart();
Parallel.ForEach(numbers, n =>
Interlocked.Add(ref sumParallel, n * n)
);
sw.Stop();
Console.WriteLine($"Parallel: {sw.ElapsedMilliseconds} ms, somma: {sumParallel}");
Provalo tu stesso! Su una macchina potente la velocità può aumentare di molto, ma tutto dipende dal tipo di task e dai colli di bottiglia.
5. Dettagli utili
Controllare il grado di parallelismo
A volte ha senso limitare il numero di thread usati (per non sovraccaricare la macchina). Per farlo usa MaxDegreeOfParallelism:
using System.Threading.Tasks;
long sum = 0;
var options = new ParallelOptions {
MaxDegreeOfParallelism = 2
};
Parallel.For(0, 100, options, i =>
{
Interlocked.Add(ref sum, i * i);
});
Console.WriteLine($"Somma dei quadrati: {sum}");
Quando è utile: se sai che parte del calcolo carica molto il disco e non la CPU — riduci i thread e misura l'impatto sulle prestazioni.
Quando usare i cicli paralleli
| Normale for | Parallel.For/Parallel.ForEach | |
|---|---|---|
| CPU | Usa un solo core | Usa tutti i core |
| Ordine | Garantito | Non garantito |
| Velocità | Di solito più lento | Spesso significativamente più veloce |
| Semplicità | Molto semplice | Serve attenzione alla thread-safety |
| Miglior uso | Piccoli volumi di dati, I/O-bound | Grandi volumi di dati, CPU-bound |
Estensione: cos'altro può fare Parallel?
Parallel.Invoke() — esegue più metodi indipendenti contemporaneamente:
static void DoTask1() => Console.WriteLine("Compito 1 completato");
static void DoTask2() => Console.WriteLine("Compito 2 completato");
static void DoTask3() => Console.WriteLine("Compito 3 completato");
Parallel.Invoke(
() => DoTask1(),
() => DoTask2(),
() => DoTask3()
);
Ogni metodo verrà eseguito su un core quando possibile.
Applicazioni nella vita reale
- Elaborazione immagini: processare simultaneamente blocchi diversi (es. applicare un filtro).
- Calcoli su array: calcoli finanziari, simulazioni (valutazione di portafogli su scenari).
- Analisi di grandi log: ricerca e aggregazione su più core.
- Machine learning: suddivisione in task indipendenti (batch di dati, feature engineering).
E, naturalmente, a un colloquio potrai non solo spiegare cosa sono i cicli paralleli, ma anche commentarne pregi e difetti in modo onesto.
6. Errori tipici con Parallel.For e Parallel.ForEach
Errore #1: Ignorare le race condition.
Aggiornare una variabile condivisa senza Interlocked o lock porta a risultati errati a causa dell'accesso concorrente dei thread.
Errore #2: Usarlo per task I/O-bound.
I cicli paralleli non accelerano task dipendenti da disco o rete e possono persino rallentarli per l'overhead aggiuntivo.
Errore #3: Presumere l'ordine di esecuzione.
I cicli paralleli non garantiscono l'ordine di elaborazione degli elementi, il che può rompere la logica se dipende dalla sequenza.
Errore #4: Ignorare side-effects.
Modificare stato condiviso (es. collezioni) in cicli paralleli può causare errori se non si usano strutture thread-safe.
GO TO FULL VERSION