Vai al contenuto

Ti sono utili questi appunti? Sostieni AppuntiFacili con una piccola donazione.

Dona con PayPal

Thread vs Task (parte 1): confronto

Dennis Turco 7 min di lettura Avanzato
  • #csharp
  • #programmazione
  • #thread
  • #task
  • #async
  • #concorrenza
In questa lezione

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:

  • Thread controlla il mezzo fisico di esecuzione;
  • Task rappresenta un lavoro e il suo completamento futuro;
  • async/await descrive come comporre quel completamento;
  • Parallel e 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

AspettoThreadTask
Livello di astrazioneBasso (unità OS)Alto (operazione futura)
Costo di creazioneAltoBasso, usa il ThreadPool
SchedulingSistema operativoTaskScheduler
Valore di ritornoNessun supporto nativoTask<T>
EccezioniGestione manualePropagazione strutturata
CancellazioneFlag manualeCancellationToken
ComposizioneManualeawait, WhenAll, WhenAny
Async I/ONoSì, naturalmente
Uso idealeCasi speciali e infrastrutturaliQuasi 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

  1. Qual e la differenza concettuale principale tra Thread e Task?

  2. Quale opzione descrive meglio il costo di un Thread?

  3. Cosa succede se un'eccezione non gestita viene lanciata dentro un Thread creato a mano?

  4. Quale API e normalmente consigliata per spostare lavoro CPU-bound breve sul pool?

  5. Con await, un eccezione lanciata da un Task viene tipicamente:

  6. Cosa restituisce Task.Factory.StartNew con una lambda async che ritorna un int?

  7. Quando ha piu senso usare TaskCreationOptions.LongRunning?

  8. 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:

  1. Riscrivi il calcolo come metodo che restituisce Task<string> usando Task.Run.
  2. Elimina le variabili condivise: il risultato deve arrivare dal valore di ritorno del task.
  3. Fai fallire un calcolo e cattura l’eccezione prima con .Wait() e poi con await, stampando il tipo di eccezione ricevuto nei due casi.
  4. 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:

  1. Implementa un Thread dedicato con IsBackground = false e un Name significativo.
  2. Gestisci l’arresto con un flag condiviso controllato a ogni iterazione del loop.
  3. Cattura le eccezioni nel corpo del thread e logga avvio, ciclo di lavoro e shutdown.
  4. Spiega perché questo scenario non è un buon candidato per Task.Run, e in quale caso valuteresti invece TaskCreationOptions.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:

  1. Scrivi l’handler async void con await per il download, senza bloccare la UI.
  2. Sposta il calcolo delle statistiche su Task.Run e attendine il risultato tipizzato.
  3. Aggiorna la label dopo l’ultimo await e spiega su quale thread avviene la ripresa e perché.
  4. 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.