Thread vs Task (parte 1): confronto
- #csharp
- #programmazione
- #thread
- #task
- #async
- #concorrenza
In questa lezione
- 1. Panoramica del modello di concorrenza in .NET
- 2. Thread: caratteristiche, costi, limiti
- Nessun risultato e nessuna eccezione propagata
- Quando ha senso usare Thread direttamente
- 3. Task: astrazione ad alto livello
- Task.Run e Task<T>
- Task.Factory.StartNew e LongRunning
- await al posto di ContinueWith
- Eccezioni: AggregateException vs await
- 4. Tabella comparativa: Thread vs Task
- 5. TaskScheduler e SynchronizationContext
- 6. Quiz
- 7. Esercizi
- 7.1 Da Thread con variabili condivise a Task
- 7.2 Servizio legacy con thread dedicato
- 7.3 Pulsante “Carica” in un’app desktop
1. Panoramica del modello di concorrenza in .NET
In C# moderno, parlare di concorrenza significa distinguere bene quattro concetti:
- thread: unità di esecuzione schedulata dal sistema operativo;
- concorrenza: più attività in avanzamento nello stesso intervallo di tempo;
- parallelismo: più attività eseguite davvero insieme su core distinti;
- asincronia: non bloccare un thread mentre si aspetta un evento esterno, tipicamente I/O.
Gli strumenti che vedremo rispondono a domande diverse:
Threadcontrolla il mezzo fisico di esecuzione;Taskrappresenta un lavoro e il suo completamento futuro;async/awaitdescrive come comporre quel completamento;Parallele PLINQ descrivono come distribuire lavoro CPU-bound su più core.
Nota
L’evoluzione storica è lineare: Thread (.NET 2.0), poi il ThreadPool per riusare i thread, la Task Parallel Library con Task, Parallel e PLINQ (.NET 4.0) e infine async/await (C# 5.0). Ogni passo ha alzato il livello di astrazione.
2. Thread: caratteristiche, costi, limiti
System.Threading.Thread è l’astrazione più vicina al thread OS. Creare un thread significa chiedere al sistema operativo una nuova unità schedulabile, e questo non è gratis:
- ha uno stack dedicato (come ordine di grandezza, circa 1 MB riservato per thread);
- richiede scheduling a livello OS e aumenta i context switch;
- consuma memoria anche quando è inattivo.
Se crei 1000 thread per 1000 richieste lente, memoria e scheduling esplodono molto prima che il problema sia il codice applicativo.
using System;
using System.Threading;
class Program
{
static void Main()
{
Thread worker = new Thread(() =>
{
Thread.Sleep(1000);
Console.WriteLine("Lavoro su thread dedicato");
});
worker.Start();
Console.WriteLine("Thread principale: attendo...");
worker.Join(); // blocca finché il worker non termina
Console.WriteLine("Lavoro completato");
}
}
Nessun risultato e nessuna eccezione propagata
Thread non restituisce valori e non riporta gli errori al chiamante. Devi passare tutto attraverso variabili condivise, gestendo a mano visibilità e sincronizzazione:
using System;
using System.Threading;
class Program
{
static int risultato;
static Exception? errore;
static void Main()
{
Thread worker = new Thread(() =>
{
try { risultato = Calcola(); }
catch (Exception ex) { errore = ex; } // cattura manuale
});
worker.Start();
worker.Join();
Console.WriteLine(errore is null ? $"Risultato: {risultato}" : $"Errore: {errore.Message}");
}
static int Calcola() => 21 * 2;
}
Questa assenza di composizione è il motivo principale per cui Task ha sostituito Thread nella maggior parte dei casi.
Pericolo
Un’eccezione non gestita dentro un Thread non arriva al chiamante: termina l’intero processo. Con thread grezzi il try/catch nel corpo del thread è obbligatorio.
Quando ha senso usare Thread direttamente
Oggi raramente: integrazione con API legacy o primitive OS che richiedono un thread dedicato, un foreground thread che tenga vivo il processo, oppure controllo esplicito di IsBackground, Priority o apartment state.
Thread servizio = new Thread(LoopServizio)
{
IsBackground = false, // tiene vivo il processo
Name = "LegacyWorker",
Priority = ThreadPriority.BelowNormal
};
servizio.Start();
3. Task: astrazione ad alto livello
Task non rappresenta necessariamente un thread. Rappresenta un’operazione che potrà completarsi in futuro, produrre un risultato, fallire o essere cancellata, e che può essere attesa e composta con altre. È una promessa di completamento futuro.
Task.Run e Task<T>
Task.Run è il modo più comune per spostare lavoro CPU-bound sul ThreadPool, mentre Task<T> risolve il problema del valore di ritorno:
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
// CPU-bound sul ThreadPool, con risultato tipizzato
long somma = await Task.Run(() =>
{
long totale = 0;
for (int i = 0; i < 1_000_000; i++) totale += i;
return totale;
});
// I/O simulato: nessun thread occupato durante l'attesa
decimal prezzo = await CalcolaPrezzoAsync();
Console.WriteLine($"{somma} - {prezzo}"); // 499999500000 - 149,90
}
static async Task<decimal> CalcolaPrezzoAsync()
{
await Task.Delay(300);
return 149.90m;
}
}
Il vantaggio non è “creare un thread”, ma riusare un thread del pool, ottenere un oggetto componibile con valore di ritorno, e propagare eccezioni e cancellazione in modo strutturato. Nota che CalcolaPrezzoAsync non usa Task.Run: per l’I/O il vantaggio di Task è proprio non occupare thread mentre si aspetta.
Task.Factory.StartNew e LongRunning
Task.Factory.StartNew offre opzioni avanzate (scheduler esplicito, AttachedToParent, LongRunning). L’unico caso comune è LongRunning, che suggerisce al runtime di usare un thread dedicato invece di occupare a lungo un thread del pool:
Task loop = Task.Factory.StartNew(
() => LoopBloccante(),
CancellationToken.None,
TaskCreationOptions.LongRunning,
TaskScheduler.Default);
Regola pratica: breve lavoro CPU-bound → Task.Run; loop lungo e bloccante → valuta LongRunning. Se tutto è “long running”, hai semplicemente perso i benefici del pool.
Attenzione
Task.Factory.StartNew(async () => ...) restituisce Task<Task<int>>, non Task<int>: aspettandolo attendi solo l’avvio della lambda, non la sua conclusione. Per lambda async usa Task.Run, che fa l’Unwrap() automaticamente.
// Sbagliato: Task<Task<int>>
var doppio = Task.Factory.StartNew(async () => { await Task.Delay(500); return 42; });
// Corretto: Task<int>
int valore = await Task.Run(async () => { await Task.Delay(500); return 42; });
await al posto di ContinueWith
Prima di await, la composizione si faceva con ContinueWith. Oggi await è quasi sempre preferibile: più leggibile, flusso delle eccezioni naturale, meno rischi legati allo scheduler.
// Stile vecchio
await CalcolaAsync().ContinueWith(t => Console.WriteLine(t.Result));
// Stile moderno
int risultato = await CalcolaAsync();
Console.WriteLine(risultato);
Eccezioni: AggregateException vs await
Bloccando con .Wait() o .Result, le eccezioni arrivano incapsulate in AggregateException. Con await, invece, vengono rilanciate nel loro tipo originale:
using System;
using System.Threading.Tasks;
class Program
{
static Task FallisciAsync() => Task.Run(() => throw new InvalidOperationException("Boom"));
static async Task Main()
{
try { FallisciAsync().Wait(); }
catch (AggregateException ex) { Console.WriteLine($"Wait: {ex.InnerException?.Message}"); }
try { await FallisciAsync(); }
catch (InvalidOperationException ex) { Console.WriteLine($"await: {ex.Message}"); }
}
}
// Output:
// Wait: Boom
// await: Boom
4. Tabella comparativa: Thread vs Task
| Aspetto | Thread | Task |
|---|---|---|
| Livello di astrazione | Basso (unità OS) | Alto (operazione futura) |
| Costo di creazione | Alto | Basso, usa il ThreadPool |
| Scheduling | Sistema operativo | TaskScheduler |
| Valore di ritorno | Nessun supporto nativo | Task<T> |
| Eccezioni | Gestione manuale | Propagazione strutturata |
| Cancellazione | Flag manuale | CancellationToken |
| Composizione | Manuale | await, WhenAll, WhenAny |
| Async I/O | No | Sì, naturalmente |
| Uso ideale | Casi speciali e infrastrutturali | Quasi tutto il codice moderno |
In sintesi: Thread controlla come un’unità gira, Task descrive cosa deve completarsi.
5. TaskScheduler e SynchronizationContext
TaskScheduler decide dove viene eseguito un task. Con Task.Run o TaskScheduler.Default il lavoro finisce sul ThreadPool.
Nelle app UI (WPF, WinForms, MAUI) esiste un SynchronizationContext legato al thread della finestra: dopo un await, la continuazione torna su quel contesto, così puoi aggiornare la UI in sicurezza.
// WPF / WinForms
private async void BottoneCarica_Click(object sender, EventArgs e)
{
string dati = await httpClient.GetStringAsync(url); // nessun blocco della UI
testoLabel.Text = dati; // ripresa sul thread UI
}
Nel codice di libreria, che non tocca la UI, puoi evitare il ritorno al contesto:
string json = await httpClient.GetStringAsync(url).ConfigureAwait(false);
Per scenari particolari (limitare la concorrenza, serializzare certi task) si possono usare scheduler custom; spesso basta ConcurrentExclusiveSchedulerPair, con maxConcurrencyLevel configurabile.
Attenzione
Dopo un await ... .ConfigureAwait(false) la continuazione può riprendere su un thread qualsiasi del pool: in un handler UI non accedere più ai controlli (testoLabel.Text = ...) dopo quel punto, altrimenti ottieni un’eccezione di accesso cross-thread.
Nella parte 2 vedremo cancellazione, composizione con WhenAll/WhenAny, parallelismo con Parallel e PLINQ, e come scegliere lo strumento giusto.
6. Quiz
Mettiti alla prova
0/8 risposte
Qual e la differenza concettuale principale tra Thread e Task?
Quale opzione descrive meglio il costo di un Thread?
Cosa succede se un'eccezione non gestita viene lanciata dentro un Thread creato a mano?
Quale API e normalmente consigliata per spostare lavoro CPU-bound breve sul pool?
Con await, un eccezione lanciata da un Task viene tipicamente:
Cosa restituisce Task.Factory.StartNew con una lambda async che ritorna un int?
Quando ha piu senso usare TaskCreationOptions.LongRunning?
In un'app WPF, cosa fa ConfigureAwait(false) dopo un await?
7. Esercizi
7.1 Da Thread con variabili condivise a Task
Scenario:
Un’utility calcola checksum di file usando un Thread per ciascun file; risultati ed errori vengono scritti in campi statici condivisi, come nell’esempio della sezione 2.
Consegna:
- Riscrivi il calcolo come metodo che restituisce
Task<string>usandoTask.Run. - Elimina le variabili condivise: il risultato deve arrivare dal valore di ritorno del task.
- Fai fallire un calcolo e cattura l’eccezione prima con
.Wait()e poi conawait, stampando il tipo di eccezione ricevuto nei due casi. - Confronta quanto osservato con le righe “Valore di ritorno” ed “Eccezioni” della tabella comparativa.
Obiettivo didattico: toccare con mano valore di ritorno ed eccezioni strutturate di Task rispetto a Thread.
7.2 Servizio legacy con thread dedicato
Scenario: Devi integrare una libreria legacy che richiede un thread dedicato in foreground, con un loop che resta attivo fino allo shutdown del processo.
Consegna:
- Implementa un
Threaddedicato conIsBackground = falsee unNamesignificativo. - Gestisci l’arresto con un flag condiviso controllato a ogni iterazione del loop.
- Cattura le eccezioni nel corpo del thread e logga avvio, ciclo di lavoro e shutdown.
- Spiega perché questo scenario non è un buon candidato per
Task.Run, e in quale caso valuteresti inveceTaskCreationOptions.LongRunning.
Obiettivo didattico: riconoscere i pochi casi in cui Thread diretto ha ancora senso.
7.3 Pulsante “Carica” in un’app desktop
Scenario: In un’app WinForms o WPF un pulsante scarica dati via HTTP, poi esegue un calcolo pesante di statistiche e infine mostra il risultato in una label.
Consegna:
- Scrivi l’handler
async voidconawaitper il download, senza bloccare la UI. - Sposta il calcolo delle statistiche su
Task.Rune attendine il risultato tipizzato. - Aggiorna la label dopo l’ultimo
awaite spiega su quale thread avviene la ripresa e perché. - Prova ad aggiungere
ConfigureAwait(false)al download: cosa cambia per l’aggiornamento della label? Dove invece sarebbe corretto usarlo?
Obiettivo didattico: capire il ruolo di SynchronizationContext e ConfigureAwait nelle app UI.