Concorrenza e parallelismo in C#

Concorrenza e parallelismo in C#

Nella serie intermedia abbiamo studiato la programmazione asincrona con async e await, ideale per le operazioni di attesa come l'accesso a rete o disco. Esiste però un ambito distinto: sfruttare i molti core dei processori moderni per eseguire calcoli in parallelo. Concorrenza e parallelismo sono concetti correlati ma diversi, e C# offre strumenti dedicati a ciascuno. In questo articolo esploriamo il parallelismo dei dati, la sincronizzazione dell'accesso condiviso e le collezioni sicure per la concorrenza.

Concorrenza e parallelismo: la differenza

La concorrenza riguarda la gestione di più attività che progrediscono nello stesso periodo, alternandosi; è ciò che l'asincronia realizza efficacemente per le operazioni di attesa. Il parallelismo, invece, riguarda l'esecuzione simultanea di più calcoli su core diversi, per ridurre il tempo di elaborazioni intensive. In breve, l'asincronia serve quando si aspetta, il parallelismo quando si calcola.

Il parallelismo dei dati con Parallel

Quando la stessa operazione va applicata a molti elementi indipendenti, la classe Parallel distribuisce automaticamente il lavoro sui core disponibili. I metodi Parallel.For e Parallel.ForEach ricalcano i cicli tradizionali, ma eseguono le iterazioni in parallelo.

using System.Threading.Tasks;

var results = new int[1000];

// Ogni iterazione viene distribuita sui core disponibili
Parallel.For(0, results.Length, i =>
{
    // Calcolo intensivo indipendente per ogni indice
    results[i] = ExpensiveComputation(i);
});

La condizione fondamentale è l'indipendenza: le iterazioni non devono dipendere l'una dall'altra né modificare uno stato condiviso senza protezione, altrimenti si introducono errori difficili da diagnosticare, di cui parleremo tra poco.

PLINQ

LINQ ha una controparte parallela chiamata PLINQ. Aggiungendo AsParallel a una query, gli operatori vengono eseguiti in parallelo quando possibile. È il modo più semplice per parallelizzare l'elaborazione di una sequenza, mantenendo la sintassi dichiarativa di LINQ.

using System.Linq;

var numbers = Enumerable.Range(1, 1_000_000);

// L'elaborazione viene distribuita sui core
var primes = numbers
    .AsParallel()
    .Where(n => IsPrime(n))
    .ToList();

PLINQ conviene solo quando il lavoro per elemento è sostanzioso e gli elementi sono numerosi: per sequenze piccole o operazioni banali, il costo di coordinamento supera il beneficio, e la versione sequenziale risulta più veloce.

Il problema delle race condition

Quando più thread accedono contemporaneamente allo stesso dato e almeno uno lo modifica, si verifica una race condition: il risultato dipende dall'ordine imprevedibile delle operazioni. Un'operazione apparentemente semplice come l'incremento di un contatore non è atomica, e in parallelo può produrre risultati errati.

int counter = 0;

// ERRATO: l'incremento non e atomico, si perdono aggiornamenti
Parallel.For(0, 100000, i =>
{
    counter++; // race condition
});

// Il risultato finale sara imprevedibile e minore di 100000

Sincronizzazione con lock

Il modo più comune per proteggere una sezione critica, ovvero un blocco di codice che deve essere eseguito da un solo thread alla volta, è l'istruzione lock. Essa acquisisce un blocco su un oggetto dedicato, garantendo l'accesso esclusivo per la durata del blocco.

int counter = 0;
object lockObject = new object();

Parallel.For(0, 100000, i =>
{
    // Solo un thread alla volta entra in questa sezione
    lock (lockObject)
    {
        counter++;
    }
});

Console.WriteLine(counter); // Ora 100000, correttamente

Il lock va usato con attenzione: proteggere sezioni troppo ampie riduce il parallelismo, vanificandone i benefici, mentre un uso scorretto di più lock può portare a un deadlock, una situazione di stallo in cui due thread si attendono a vicenda all'infinito.

Operazioni atomiche con Interlocked

Per operazioni semplici come l'incremento o lo scambio, la classe Interlocked offre versioni atomiche più efficienti di un lock completo. Il processore garantisce che l'operazione avvenga in un unico passaggio indivisibile, senza il costo della sincronizzazione esplicita.

int counter = 0;

Parallel.For(0, 100000, i =>
{
    // Incremento atomico, senza bisogno di lock
    Interlocked.Increment(ref counter);
});

Le collezioni concorrenti

Le collezioni ordinarie viste nella prima serie non sono sicure per l'accesso concorrente. Il namespace System.Collections.Concurrent fornisce alternative progettate per essere usate da più thread contemporaneamente senza sincronizzazione manuale, come ConcurrentDictionary e ConcurrentQueue.

using System.Collections.Concurrent;

var counts = new ConcurrentDictionary<string, int>();

Parallel.ForEach(words, word =>
{
    // Aggiornamento sicuro anche con accessi simultanei
    counts.AddOrUpdate(word, 1, (key, existing) => existing + 1);
});

Comunicare tra task con i Channel

Per scenari di tipo produttore-consumatore, in cui alcuni task generano dati e altri li elaborano, i canali del namespace System.Threading.Channels offrono una struttura sicura ed efficiente. Un canale disaccoppia chi scrive da chi legge, gestendo la sincronizzazione internamente.

using System.Threading.Channels;

var channel = Channel.CreateUnbounded<int>();

// Produttore
_ = Task.Run(async () =>
{
    for (int i = 0; i < 10; i++)
    {
        await channel.Writer.WriteAsync(i);
    }
    channel.Writer.Complete();
});

// Consumatore
await foreach (int item in channel.Reader.ReadAllAsync())
{
    Console.WriteLine($"Ricevuto {item}");
}

Scegliere lo strumento giusto

Riepilogando, la scelta dipende dalla natura del lavoro. Per operazioni di attesa si usa l'asincronia con async e await. Per calcoli intensivi su molti elementi indipendenti si ricorre a Parallel o a PLINQ. Quando è inevitabile condividere stato, lo si protegge con lock, Interlocked o le collezioni concorrenti. E per i flussi di dati tra task, i canali offrono una soluzione ordinata.

Conclusione

Abbiamo distinto concorrenza e parallelismo e visto come sfruttare i core multipli con Parallel e PLINQ. Abbiamo affrontato il rischio delle race condition e i meccanismi per gestirlo: il lock per le sezioni critiche, Interlocked per le operazioni atomiche, le collezioni concorrenti e i canali per la comunicazione produttore-consumatore. Nel prossimo articolo ci occuperemo della serializzazione JSON con System.Text.Json, essenziale per lo scambio di dati tra applicazioni.