CodeGym /Corsi /C# SELF /Elaborazione dei dati in parallelo

Elaborazione dei dati in parallelo

C# SELF
Livello 60 , Lezione 3
Disponibile

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.

Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION