Sostieni AppuntiFacili con una piccola donazione su PayPal
Dona con PayPalIn C# moderno, parlare di concorrenza significa distinguere bene quattro concetti:
Storicamente .NET è passato da un modello molto vicino al sistema operativo a un modello sempre più dichiarativo e componibile:
Thread, ThreadStart, ParameterizedThreadStart;Task, Task<T>, Parallel, PLINQ;async / await, che rende naturale scrivere asincronia composibile.flowchart LR
A[".NET 2.0<br/>Thread"] --> B["ThreadPool<br/>riuso thread"]
B --> C[".NET 4.0<br/>Task Parallel Library"]
C --> D["C# 5.0<br/>async / await"]
D --> E[".NET moderno<br/>Task, ValueTask, Parallel, PLINQ"]
L’idea centrale è questa:
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.Se hai un carico con parte seriale e parte parallelizzabile, il limite teorico del guadagno è vincolato dalla frazione seriale:
Più avanti formalizzeremo questo limite con la legge di Amdahl.
System.Threading.Thread è l’astrazione più vicina al thread OS. Creare un thread significa chiedere al runtime e al sistema operativo una nuova unità schedulabile.
Un thread non è gratis:
Se crei 1000 thread per 1000 richieste lente, la memoria e il costo di scheduling esplodono molto prima che il problema sia il codice applicativo.
Threadusing System;
using System.Threading;
class Program
{
static void Main()
{
Thread worker = new Thread(Lavoro);
worker.Start();
Console.WriteLine("Thread principale: attendo...");
worker.Join();
Console.WriteLine("Lavoro completato");
}
static void Lavoro()
{
Thread.Sleep(1000);
Console.WriteLine("Lavoro su thread dedicato");
}
}
Thread direttamenteOggi succede raramente. I casi realistici sono:
IsBackground, Priority, apartment state o il ciclo di vita del thread.Per il normale lavoro applicativo, Thread è quasi sempre troppo basso livello.
Join: attendere il completamentoThread.Join() blocca il thread chiamante finché il thread target non termina.
using System;
using System.Threading;
class Program
{
static int risultato;
static void Main()
{
Thread worker = new Thread(() =>
{
risultato = 21 * 2;
});
worker.Start();
worker.Join();
Console.WriteLine(risultato);
}
}
Funziona, ma nota subito i limiti:
Thread non ha un equivalente naturale di Task<T>. Se vuoi un risultato, devi passarlo attraverso:
BlockingCollection<T>, Channel<T>, ecc.).Questo aumenta l’accoppiamento e il rischio di bug.
Con Thread non puoi fare:
int valore = await qualcosa;
Se il lavoro nel thread fallisce, il chiamante non riceve automaticamente l’errore come parte di una composizione strutturata. Devi catturarlo e propagarlo manualmente.
using System;
using System.Threading;
class Program
{
static Exception? errore;
static void Main()
{
Thread worker = new Thread(() =>
{
try
{
throw new InvalidOperationException("Errore nel worker");
}
catch (Exception ex)
{
errore = ex;
}
});
worker.Start();
worker.Join();
if (errore is not null)
{
Console.WriteLine($"Gestione manuale: {errore.Message}");
}
}
}
Questa assenza di composizione è uno dei motivi principali per cui Task ha sostituito Thread nella maggior parte dei casi.
Task non rappresenta necessariamente un thread. Rappresenta un’operazione che:
È una promessa di completamento futuro.
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
Task lavoro = Task.Run(() =>
{
Console.WriteLine("CPU-bound sul ThreadPool");
});
await lavoro;
}
}
Task.Run: esecuzione sul ThreadPoolTask.Run è il modo più comune per spostare lavoro CPU-bound sul ThreadPool.
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
int somma = await Task.Run(() =>
{
int totale = 0;
for (int i = 0; i < 1_000_000; i++)
{
totale += i;
}
return totale;
});
Console.WriteLine(somma);
}
}
Qui il vantaggio non è “creare un thread”, ma:
Task<T> per i valori di ritornoTask<T> risolve elegantemente il limite storico di Thread.
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
Task<decimal> prezzoTask = CalcolaPrezzoAsync();
decimal prezzo = await prezzoTask;
Console.WriteLine(prezzo);
}
static async Task<decimal> CalcolaPrezzoAsync()
{
await Task.Delay(300);
return 149.90m;
}
}
Task.Factory.StartNew: opzioni avanzateTask.Factory.StartNew è più potente ma più facile da usare male. Ti permette di specificare:
TaskCreationOptions.LongRunning;TaskCreationOptions.AttachedToParent;using System;
using System.Threading;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
Task task = Task.Factory.StartNew(
action: () =>
{
Thread.Sleep(2000);
Console.WriteLine("Task lungo");
},
cancellationToken: CancellationToken.None,
creationOptions: TaskCreationOptions.LongRunning,
scheduler: TaskScheduler.Default);
await task;
}
}
LongRunning suggerisce al runtime che quel lavoro non dovrebbe occupare un thread del pool per molto tempo. Tipicamente il runtime usa un thread dedicato.
Per il codice applicativo generale:
Task.Run per la maggior parte dei casi;StartNew solo quando ti servono davvero opzioni avanzate.StartNew con lambda asyncQuesto è un anti-pattern classico:
Task<Task<int>> doppioTask = Task.Factory.StartNew(async () =>
{
await Task.Delay(500);
return 42;
});
Il risultato è Task<Task<int>>, non Task<int>. Devi fare Unwrap() oppure, più semplicemente, evitare StartNew per lambda async e preferire Task.Run.
int valore = await Task.Run(async () =>
{
await Task.Delay(500);
return 42;
});
ContinueWith e awaitPrima di await, la composizione si faceva spesso con ContinueWith.
using System;
using System.Threading.Tasks;
class Program
{
static Task<int> CalcolaAsync() => Task.Run(() => 21 * 2);
static async Task Main()
{
Task continuazione = CalcolaAsync().ContinueWith(t =>
{
Console.WriteLine($"Risultato: {t.Result}");
});
await continuazione;
}
}
Oggi await è quasi sempre preferibile:
using System;
using System.Threading.Tasks;
class Program
{
static Task<int> CalcolaAsync() => Task.Run(() => 21 * 2);
static async Task Main()
{
int risultato = await CalcolaAsync();
Console.WriteLine($"Risultato: {risultato}");
}
}
await è:
AggregateException e unwrapping con awaitQuando blocchi con .Wait() o .Result, le eccezioni vengono in genere incapsulate in AggregateException.
using System;
using System.Threading.Tasks;
class Program
{
static Task FallisciAsync() => Task.Run(() => throw new InvalidOperationException("Boom"));
static void Main()
{
try
{
FallisciAsync().Wait();
}
catch (AggregateException ex)
{
Console.WriteLine(ex.InnerException?.Message);
}
}
}
Con await, invece, l’eccezione viene “scartata” e rilanciata nel suo tipo originale:
using System;
using System.Threading.Tasks;
class Program
{
static Task FallisciAsync() => Task.Run(() => throw new InvalidOperationException("Boom"));
static async Task Main()
{
try
{
await FallisciAsync();
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}
}
}
sequenceDiagram
participant C as Chiamante
participant T as Task
participant P as ThreadPool
C->>T: Task.Run(lavoro)
T->>P: schedula delegate
T-->>C: restituisce Task
Note over C: puo continuare o fare await
P-->>T: completamento / errore / cancellazione
T-->>C: continuazione await
| Aspetto | Thread | Task |
|---|---|---|
| Livello di astrazione | Basso livello | Alto livello |
| Modello mentale | Unita di esecuzione OS | Operazione futura componibile |
| Costo di creazione | Alto | Basso o nullo se usa ThreadPool |
| Scheduling | Sistema operativo | TaskScheduler |
| Stack dedicato | Si | Non necessariamente |
| Valore di ritorno | No supporto nativo | Task<T> |
| Eccezioni | Gestione manuale | Propagazione strutturata |
| Cancellazione | Nessun meccanismo nativo moderno | CancellationToken |
| Composizione | Manuale | await, WhenAll, WhenAny, continuations |
| Scalabilita | Scarsa se usato in massa | Molto migliore |
| Uso ideale | Casi speciali e infrastrutturali | Quasi tutto il codice moderno |
| Supporto async I/O | No | Si, naturalmente |
| Integrazione con linguaggio | Minima | Totale con async/await |
In sintesi:
Thread controlla come un’unità gira;Task descrive cosa deve completarsi.TaskScheduler decide dove e come un task viene eseguito.
Se usi Task.Run o TaskScheduler.Default, il lavoro finisce normalmente sul ThreadPool.
Task task = Task.Run(() => Console.WriteLine("ThreadPool"));
È l’opzione ideale per:
SynchronizationContextNelle app UI esiste spesso un SynchronizationContext che rappresenta il thread della finestra. Se fai await senza ConfigureAwait(false), la continuazione prova a tornare su quel contesto.
Questo è utile perché permette di aggiornare la UI in sicurezza:
// Esempio concettuale WPF / WinForms
private async Task CaricaAsync()
{
string dati = await httpClient.GetStringAsync(url);
testoLabel.Text = dati; // ripresa sul contesto UI
}
Con ContinueWith, invece, devi stare molto attento allo scheduler usato:
TaskScheduler uiScheduler = TaskScheduler.FromCurrentSynchronizationContext();
Task.Run(() => "dati")
.ContinueWith(
t => label.Text = t.Result,
CancellationToken.None,
TaskContinuationOptions.None,
uiScheduler);
Puoi anche definire un TaskScheduler custom quando vuoi:
Molto spesso, pero, basta usare strumenti gia pronti come ConcurrentExclusiveSchedulerPair.
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
var pair = new ConcurrentExclusiveSchedulerPair(TaskScheduler.Default, maxConcurrencyLevel: 2);
TaskFactory factory = new TaskFactory(pair.ConcurrentScheduler);
Task[] tasks =
[
factory.StartNew(() => Console.WriteLine("A")),
factory.StartNew(() => Console.WriteLine("B")),
factory.StartNew(() => Console.WriteLine("C"))
];
await Task.WhenAll(tasks);
}
}
Task.Run vs Task.Factory.StartNew(..., LongRunning)Per un task molto lungo:
Task.Run usa il ThreadPool;StartNew(..., TaskCreationOptions.LongRunning, ...) segnala che potrebbe servire un thread dedicato.Regola pratica:
Task.Run;LongRunning.Non usare LongRunning in modo indiscriminato: se tutto e “long running”, hai semplicemente perso i benefici del pool.
Storicamente esisteva Thread.Abort(), ma e deprecato e concettualmente pericoloso perché interrompe l’esecuzione in modo asincrono, lasciando invarianti e risorse in stato incerto.
Con Thread oggi si lavora quasi sempre con un flag manuale:
using System;
using System.Threading;
class Program
{
static volatile bool fermati;
static void Main()
{
Thread worker = new Thread(() =>
{
while (!fermati)
{
Console.WriteLine("Lavoro...");
Thread.Sleep(200);
}
});
worker.Start();
Thread.Sleep(1000);
fermati = true;
worker.Join();
}
}
Funziona, ma e un protocollo completamente manuale.
CancellationToken e CancellationTokenSourceCon Task la cancellazione e cooperativa ma standardizzata.
using System;
using System.Threading;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
using CancellationTokenSource cts = new();
Task task = LavoroAsync(cts.Token);
cts.CancelAfter(800);
try
{
await task;
}
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);
}
}
}
Vantaggi:
CancelAfter;TaskStatus.Canceled.La differenza piu grande tra Thread e Task e che i task si compongono.
Con thread grezzi devi costruire da solo:
WhenAllTask.WhenAll e ideale quando vuoi attendere tutte le operazioni indipendenti.
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
Task<int> t1 = Task.Run(() => 10);
Task<int> t2 = Task.Run(() => 20);
Task<int> t3 = Task.Run(() => 30);
int[] risultati = await Task.WhenAll(t1, t2, t3);
Console.WriteLine(string.Join(", ", risultati));
}
}
Per latenze indipendenti:
mentre in sequenza:
WhenAnyTask.WhenAny permette di reagire al primo completamento:
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
Task<int> veloce = Task.Run(async () => { await Task.Delay(200); return 1; });
Task<int> lenta = Task.Run(async () => { await Task.Delay(1000); return 2; });
Task<int> prima = await Task.WhenAny(veloce, lenta);
Console.WriteLine(await prima);
}
}
WhenEach in .NET 9Con .NET 9, Task.WhenEach permette di consumare i risultati nell’ordine di completamento, senza aspettare tutti subito.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
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);
}
}
static async Task<int> SimulaAsync(int valore, int delay)
{
await Task.Delay(delay);
return valore;
}
}
Select e awaitTask permette di costruire pipeline molto leggibili:
using System;
using System.Linq;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
int[] input = [1, 2, 3, 4];
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));
}
}
Con thread grezzi, la stessa espressivita richiede molto piu codice infrastrutturale.
La classe Parallel serve al parallelismo CPU-bound su collezioni o insiemi di azioni.
Parallel.Forusing System;
using System.Threading.Tasks;
class Program
{
static void Main()
{
Parallel.For(0, 10, i =>
{
Console.WriteLine($"Iterazione {i}");
});
}
}
Parallel.ForEachusing System;
using System.Collections.Generic;
using System.Threading.Tasks;
class Program
{
static void Main()
{
List<int> numeri = [1, 2, 3, 4, 5];
Parallel.ForEach(numeri, n =>
{
Console.WriteLine($"{n} -> {n * n}");
});
}
}
Parallel.Invokeusing System;
using System.Threading.Tasks;
class Program
{
static void Main()
{
Parallel.Invoke(
() => Console.WriteLine("A"),
() => Console.WriteLine("B"),
() => Console.WriteLine("C"));
}
}
Task.WhenAllParallel e Task.WhenAll non sono sinonimi:
Parallel.For/ForEach/Invoke puntano a spremere i core per lavoro CPU-bound;Task.WhenAll compone task gia esistenti, spesso I/O-bound o misti.Regola utile:
Parallel;Task.WhenAll.PartitionerQuando i dati sono tanti, il partizionamento influisce molto sulle prestazioni. Partitioner permette di controllare come il lavoro viene suddiviso.
using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;
class Program
{
static void Main()
{
var rangePartitioner = Partitioner.Create(0, 1_000_000, 50_000);
Parallel.ForEach(rangePartitioner, range =>
{
long sommaLocale = 0;
for (int i = range.Item1; i < range.Item2; i++)
{
sommaLocale += i;
}
Console.WriteLine($"{range.Item1}-{range.Item2}: {sommaLocale}");
});
}
}
Partitioner e utile quando:
PLINQ aggiunge parallelismo alle query LINQ con AsParallel().
using System;
using System.Linq;
class Program
{
static void Main()
{
int[] risultati = Enumerable.Range(1, 1000)
.AsParallel()
.Select(n => n * n)
.Where(n => n % 2 == 0)
.ToArray();
Console.WriteLine(risultati.Length);
}
}
WithDegreeOfParallelismPermette di limitare il numero massimo di worker:
var query = Enumerable.Range(1, 1_000_000)
.AsParallel()
.WithDegreeOfParallelism(Environment.ProcessorCount / 2)
.Select(n => n * n);
WithCancellationusing CancellationTokenSource cts = new();
var query = Enumerable.Range(1, 1_000_000)
.AsParallel()
.WithCancellation(cts.Token)
.Select(n => Math.Sqrt(n));
AsOrdered()Per default PLINQ puo perdere l’ordine originale perché privilegia le prestazioni. Se ti serve l’ordine:
var ordinati = Enumerable.Range(1, 20)
.AsParallel()
.AsOrdered()
.Select(n => n * 10)
.ToArray();
PLINQ e forte quando:
PLINQ puo peggiorare le cose se:
async/await e spesso piu adatto.La scelta corretta dipende soprattutto da una domanda: il lavoro e 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"]
Task.Run: lavoro CPU-bound che vuoi delegare al pool.async/await: lavoro I/O-bound.Parallel.ForEach: lavoro CPU-bound su collezioni.Task.Run per semplice I/O async, ottenendo solo overhead;Parallel.ForEach con codice fortemente I/O-bound;Se e la frazione parallelizzabile del programma e e il numero di processori, lo speedup teorico massimo e:
Interpretazione:
Esempio con e :
Non ottieni 8x, ma circa 4.71x.
Quando il problema cresce con il numero di processori, Gustafson fornisce un modello piu ottimista:
dove e la frazione seriale osservata.
Interpretazione:
Per sistemi data-parallel, analytics o batch molto grandi, Gustafson descrive spesso meglio la realta operativa.
Task come default e Thread solo per casi eccezionali.await.Task.Run.Parallel o PLINQ.CancellationToken quando disponibile.await a .Wait() e .Result.Task.WhenAll per operazioni indipendenti.LongRunning invece di saturare il pool.Task.Run sopra I/O async// Anti-pattern
await Task.Run(() => httpClient.GetStringAsync(url));
Se l’API e gia asincrona, fai semplicemente:
string html = await httpClient.GetStringAsync(url);
// Anti-pattern
var risultato = servizio.CalcolaAsync().Result;
Puoi causare deadlock in ambienti con SynchronizationContext e peggiori l’uso dei thread.
Creare un thread per ogni elemento di una lista grande e quasi sempre una cattiva idea.
// Anti-pattern
int totale = 0;
Parallel.For(0, 1000, i => totale += i);
Qui hai race condition. Devi usare riduzioni corrette, lock o accumulatori locali.
StartNew usato come sostituto universale di Task.RunStartNew non e la scelta “piu professionale” per default; e piu pericoloso e piu facile da usare male, soprattutto con lambda async.
Se vuoi ricordare una sola regola:
Thread riguarda l’infrastruttura.
Task riguarda il completamento.
async/await riguarda la composizione.
Parallel e PLINQ riguardano il throughput CPU-bound.
Qual e la differenza concettuale principale tra Thread e Task?
Quale opzione descrive meglio il costo di un Thread?
Quale API e normalmente consigliata per spostare lavoro CPU-bound breve sul pool?
Quale tipo permette un valore di ritorno asincrono tipizzato?
Con await, un eccezione lanciata da un Task viene tipicamente:
Quando ha piu senso usare TaskCreationOptions.LongRunning?
Quale strumento e piu adatto per cancellare cooperativamente un Task?
Quale API attende il completamento di tutti i task indipendenti?
Qual e la differenza piu importante tra Parallel.ForEach e Task.WhenAll?
Quando PLINQ tende a essere controproducente?
Scenario: Hai un servizio che deve generare 500 report PDF da dati gia presenti in memoria. Ogni report richiede calcolo CPU-bound intenso e il server ha 8 core.
Consegna:
Task.Run per ogni report e misura il tempo totale.Parallel.ForEach.Obiettivo didattico: capire quando un carico CPU-bound su collezioni e piu naturale con Parallel rispetto a Task.WhenAll.
Scenario: Un backend deve interrogare 6 API esterne indipendenti per costruire una dashboard. Le chiamate sono tutte I/O-bound e ognuna puo essere cancellata se l’utente abbandona la pagina.
Consegna:
async Task<string> che simulano chiamate HTTP con Task.Delay.CancellationToken a tutte le chiamate.Task.WhenAll per attendere tutti i risultati.Obiettivo didattico: distinguere chiaramente async I/O da parallelismo CPU-bound.
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:
Thread dedicato con IsBackground = false.CancellationToken adattato al loop.Task.Run.Obiettivo didattico: riconoscere i pochi casi in cui Thread diretto ha ancora senso.
Scenario: Un sistema e-commerce riceve ordini, calcola promozioni CPU-bound, verifica disponibilita via API e salva il risultato su database.
Consegna:
Task.Run.await.Task.WhenEach e mostra i risultati nell’ordine di completamento.Obiettivo didattico: combinare correttamente CPU-bound, I/O-bound e composizione moderna a task.
Prenota una lezione