Thread vs Task (parte 2): cancellazione, composizione e parallelismo
- #csharp
- #programmazione
- #thread
- #task
- #async
- #concorrenza
- #parallel
- #plinq
In questa lezione
- 1. Cancellazione
- 2. Composizione di Task
- WhenAll: attendere tutte le operazioni
- WhenAny: reagire al primo completamento
- WhenEach (.NET 9): risultati in ordine di completamento
- 3. Parallelismo CPU-bound: Parallel e PLINQ
- La classe Parallel
- PLINQ
- 4. Quando usare cosa
- Legge di Amdahl: perché più core non bastano
- 5. Anti-pattern da evitare
- 6. Quiz
- 7. Esercizi
- 7.1 Motore di reportistica batch
- 7.2 Gateway verso API esterne
- 7.3 Pipeline di elaborazione ordini
Nella parte 1 abbiamo visto che Thread è un’unità di esecuzione del sistema operativo, costosa e poco componibile, mentre Task rappresenta un lavoro e il suo completamento futuro, con risultato tipizzato ed eccezioni propagate. Qui vediamo cosa rende Task davvero superiore nella pratica: cancellazione, composizione e parallelismo.
1. Cancellazione
Thread.Abort() è deprecato (e non supportato in .NET moderno) perché interrompe l’esecuzione in un punto arbitrario, lasciando risorse e invarianti in stato incerto. Con Thread si usa quindi un flag manuale:
static volatile bool fermati;
Thread worker = new Thread(() =>
{
while (!fermati) { Console.WriteLine("Lavoro..."); Thread.Sleep(200); }
});
worker.Start();
Thread.Sleep(1000);
fermati = true;
worker.Join();
Con Task la cancellazione è sempre cooperativa, ma standardizzata tramite CancellationTokenSource e CancellationToken:
using System;
using System.Threading;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
using CancellationTokenSource cts = new();
cts.CancelAfter(800); // timeout
try
{
await LavoroAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Operazione cancellata");
}
}
static async Task LavoroAsync(CancellationToken ct)
{
for (int i = 0; i < 10; i++)
{
ct.ThrowIfCancellationRequested();
await Task.Delay(200, ct);
Console.WriteLine($"Passo {i}");
}
}
}
// Output: Passo 0, Passo 1, Passo 2, poi "Operazione cancellata"
Suggerimento
Propaga sempre il CancellationToken lungo tutta la catena di chiamate e passalo alle API .NET che lo accettano (Task.Delay, HttpClient, Stream.ReadAsync): è così che la cancellazione arriva davvero fino all’I/O.
2. Composizione di Task
La differenza più grande tra Thread e Task è che i task si compongono. Con thread grezzi dovresti costruire a mano join multipli, raccolta risultati, errori aggregati e timeout.
WhenAll: attendere tutte le operazioni
using System;
using System.Linq;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
int[] input = [1, 2, 3, 4];
// Avvia tutte le operazioni, poi attendile insieme
Task<int>[] tasks = input.Select(async n =>
{
await Task.Delay(100);
return n * n;
}).ToArray();
int[] quadrati = await Task.WhenAll(tasks);
Console.WriteLine(string.Join(", ", quadrati)); // 1, 4, 9, 16 (in circa 100 ms, non 400)
}
}
Per latenze indipendenti il tempo totale passa dalla somma al massimo:
Attenzione
Scrivere await A(); await B(); esegue le operazioni in sequenza. Per ottenere il beneficio di WhenAll devi prima avviare tutti i task e solo dopo attenderli.
WhenAny: reagire al primo completamento
Un uso tipico di WhenAny è implementare un timeout:
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
Task<string> download = ScaricaAsync();
Task timeout = Task.Delay(500);
Task vincitore = await Task.WhenAny(download, timeout);
Console.WriteLine(vincitore == download
? $"Dati: {await download}"
: "Timeout scaduto"); // Output: Timeout scaduto
}
static async Task<string> ScaricaAsync()
{
await Task.Delay(2000);
return "contenuto";
}
}
In .NET 6+ puoi scrivere lo stesso in una riga con await ScaricaAsync().WaitAsync(TimeSpan.FromMilliseconds(500)), che lancia TimeoutException.
WhenEach (.NET 9): risultati in ordine di completamento
List<Task<int>> tasks = [SimulaAsync(1, 900), SimulaAsync(2, 200), SimulaAsync(3, 500)];
await foreach (Task<int> completato in Task.WhenEach(tasks))
{
Console.WriteLine(await completato); // 2, 3, 1
}
static async Task<int> SimulaAsync(int valore, int delay)
{
await Task.Delay(delay);
return valore;
}
3. Parallelismo CPU-bound: Parallel e PLINQ
La classe Parallel
Parallel.For, Parallel.ForEach e Parallel.Invoke servono a spremere i core su lavoro CPU-bound. Sono chiamate sincrone: bloccano finché tutte le iterazioni non terminano.
using System;
using System.Threading.Tasks;
class Program
{
static void Main()
{
string[] immagini = ["a.png", "b.png", "c.png", "d.png"];
var opzioni = new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount };
Parallel.ForEach(immagini, opzioni, file =>
{
Console.WriteLine($"Elaboro {file} sul thread {Environment.CurrentManagedThreadId}");
});
Parallel.Invoke(
() => Console.WriteLine("A"),
() => Console.WriteLine("B"));
}
}
L’ordine di esecuzione delle iterazioni non è garantito. Il problema più comune, però, è lo stato condiviso:
using System;
using System.Threading;
using System.Threading.Tasks;
int sbagliato = 0;
Parallel.For(0, 100_000, i => sbagliato++); // race condition
Console.WriteLine(sbagliato); // es. 63412, cambia a ogni esecuzione
long totale = 0;
Parallel.For(0, 100_000,
() => 0L, // accumulatore locale per thread
(i, stato, locale) => locale + 1, // nessuna contesa
locale => Interlocked.Add(ref totale, locale)); // unione finale atomica
Console.WriteLine(totale); // 100000
Pericolo
totale += i dentro Parallel.For non è un’operazione atomica: produce risultati errati e diversi a ogni esecuzione. Usa accumulatori locali con Interlocked, un lock, oppure una riduzione PLINQ come .Sum().
Parallel.ForEach e Task.WhenAll non sono sinonimi: il primo distribuisce lavoro CPU-bound su una collezione, il secondo compone task già esistenti, spesso I/O-bound. Per dataset molto grandi con lavoro leggero, Partitioner.Create(0, n, chunkSize) permette di processare blocchi di indici e ridurre l’overhead per elemento.
Suggerimento
Per I/O asincrono su una collezione con concorrenza limitata esiste Parallel.ForEachAsync (.NET 6+), che accetta una lambda async e un MaxDegreeOfParallelism. Non passare mai una lambda async a Parallel.ForEach.
PLINQ
PLINQ aggiunge parallelismo alle query LINQ con AsParallel():
using System;
using System.Linq;
long sommaPrimi = Enumerable.Range(2, 2_000_000)
.AsParallel()
.WithDegreeOfParallelism(Environment.ProcessorCount / 2)
.Where(EPrimo)
.Sum(n => (long)n);
Console.WriteLine(sommaPrimi);
int[] ordinati = Enumerable.Range(1, 10)
.AsParallel()
.AsOrdered() // senza, l'ordine del risultato non è garantito
.Select(n => n * 10)
.ToArray(); // 10, 20, ..., 100
static bool EPrimo(int n)
{
for (int d = 2; d * d <= n; d++)
if (n % d == 0) return false;
return true;
}
Esiste anche WithCancellation(token) per interrompere la query. PLINQ rende quando la query è CPU-bound, i dati sono numerosi e ogni elemento richiede abbastanza calcolo; è controproducente su dataset piccoli con lavoro banale, con forte contesa su risorse condivise o per I/O.
4. Quando usare cosa
La domanda chiave è: il lavoro è CPU-bound, I/O-bound o richiede un thread dedicato?
flowchart TD
A["Devi eseguire lavoro concorrente?"] --> B{"Richiede un thread dedicato<br/>o controllo OS specifico?"}
B -->|Si| C["Usa Thread diretto<br/>solo in casi speciali"]
B -->|No| D{"Il collo di bottiglia e I/O?"}
D -->|Si| E["Usa async/await<br/>senza Task.Run inutile"]
D -->|No| F{"Hai una collezione CPU-bound?"}
F -->|Si| G{"Stile loop o stile LINQ?"}
G -->|Loop| H["Parallel.For / Parallel.ForEach"]
G -->|LINQ| I["PLINQ"]
F -->|No| J["Task.Run per lavoro CPU-bound isolato"]
- Thread diretto: quasi mai; interop OS, infrastruttura, loop dedicati.
Task.Run: lavoro CPU-bound isolato da delegare al pool.async/await: lavoro I/O-bound.Parallel.ForEach/ PLINQ: lavoro CPU-bound su collezioni.
Legge di Amdahl: perché più core non bastano
Se è la frazione parallelizzabile e il numero di processori, lo speedup massimo è:
Con e si ottiene : non 8x. La parte seriale satura presto il guadagno, quindi misura sempre prima di parallelizzare.
La legge di Gustafson, con frazione seriale, è più ottimista: misura quanto problema in più si gestisce nello stesso tempo, e descrive meglio batch e analytics che crescono con le risorse.
5. Anti-pattern da evitare
Task.Run sopra I/O già asincrono: aggiunge solo overhead.
await Task.Run(() => httpClient.GetStringAsync(url)); // inutile
string html = await httpClient.GetStringAsync(url); // corretto
Blocco sincrono su codice async: .Result e .Wait() sprecano un thread e possono causare deadlock.
// In un'app UI: il thread UI si blocca aspettando il task,
// ma il task deve tornare sul thread UI per completarsi -> deadlock
string dati = CaricaAsync().Result;
Pericolo
Con un SynchronizationContext (UI, vecchio ASP.NET) .Result su un metodo async può bloccare l’applicazione per sempre. Regola: async fino in cima, usa await anche nel chiamante.
Altri errori frequenti: creare un thread per ogni elemento di una lista grande, usare StartNew come sostituto “più professionale” di Task.Run, usare Parallel.ForEach per codice I/O-bound o PLINQ su liste minuscole.
In una frase: non scegliere in base al nome dell’API, ma in base alla natura del lavoro.
6. Quiz
Mettiti alla prova
0/8 risposte
Perche Thread.Abort() non va usato per fermare un thread?
Quale strumento e piu adatto per cancellare cooperativamente un Task?
Quale API attende il completamento di tutti i task indipendenti?
Combinando Task.WhenAny con Task.Delay(500), cosa si ottiene?
Perche totale += i dentro Parallel.For produce risultati errati?
Quando PLINQ tende a essere controproducente?
Secondo la legge di Amdahl, con il 90% di codice parallelizzabile e 8 core, lo speedup massimo e circa:
Perche chiamare .Result su un metodo async in un'app UI e pericoloso?
7. Esercizi
7.1 Motore di reportistica batch
Scenario: Hai un servizio che deve generare 500 report PDF da dati già presenti in memoria. Ogni report richiede calcolo CPU-bound intenso e il server ha 8 core.
Consegna:
- Implementa una versione con
Task.Runper ogni report eTask.WhenAll, misurando il tempo totale. - Implementa una seconda versione con
Parallel.ForEacheMaxDegreeOfParallelism. - Evita scritture concorrenti sulla stessa lista finale (accumulatori locali o
ConcurrentBag<T>). - Confronta throughput, consumo CPU e semplicità del codice.
Obiettivo didattico: capire quando un carico CPU-bound su collezioni è più naturale con Parallel rispetto a Task.WhenAll.
7.2 Gateway verso API esterne
Scenario: Un backend deve interrogare 6 API esterne indipendenti per costruire una dashboard. Le chiamate sono I/O-bound e devono essere cancellate se l’utente abbandona la pagina o se superano 2 secondi.
Consegna:
- Crea 6 metodi
async Task<string>che simulano chiamate HTTP conTask.Delay. - Passa un
CancellationTokena tutte le chiamate e imposta il timeout conCancelAfter. - Usa
Task.WhenAllper attendere tutti i risultati. - Gestisci sia il caso in cui una chiamata fallisca sia quello in cui l’operazione venga cancellata.
Obiettivo didattico: distinguere chiaramente async I/O da parallelismo CPU-bound.
7.3 Pipeline di elaborazione ordini
Scenario: Un sistema e-commerce riceve ordini, calcola promozioni CPU-bound, verifica la disponibilità via API e salva il risultato su database.
Consegna:
- Modella verifica disponibilità e salvataggio come operazioni async I/O-bound.
- Sposta il calcolo promozioni pesante su
Task.Run. - Componi la pipeline con
await. - Elabora una lista di ordini con
Task.WhenEache mostra i risultati nell’ordine di completamento.
Obiettivo didattico: combinare correttamente CPU-bound, I/O-bound e composizione moderna a task.