Sostieni AppuntiFacili con una piccola donazione su PayPal
Dona con PayPalLa programmazione asincrona in C# serve a gestire operazioni con latenza significativa senza occupare inutilmente il thread chiamante. Il caso tipico è l’I/O: rete, file system, database, chiamate HTTP, attese temporali, stream. In questi scenari il costo principale non è il calcolo, ma il tempo di attesa.
Una scomposizione utile è:
Se , usare await migliora la reattività dell’applicazione perché il thread può tornare al chiamante e fare altro durante l’attesa.
I tre pilastri sono:
async: dichiara che un metodo può sospendersi e riprendere.await: sospende logicamente il metodo finché l’operazione non termina, senza bloccare il thread.Task / Task<T>: rappresentano il risultato futuro di un’operazione.È importante distinguere:
Un’operazione async non crea automaticamente un nuovo thread. Spesso non usa alcun thread mentre aspetta il completamento di I/O.
sequenceDiagram
participant Caller as Thread chiamante
participant Metodo as Metodo async
participant IO as Operazione I/O
Caller->>Metodo: chiamata
Metodo->>IO: avvio richiesta
Metodo-->>Caller: restituisce Task
Note over Caller: il thread è libero
IO-->>Metodo: completamento
Metodo-->>Caller: continuazione e risultato
Se hai richieste indipendenti con latenze :
Task.WhenAll: Questo vantaggio vale soprattutto per carichi I/O-bound.
Task è l’astrazione centrale dell’asincronia moderna in .NET:
Task: operazione asincrona senza valore finale;Task<T>: operazione asincrona che restituisce un valore di tipo T.Task task = Task.Run(() => Console.WriteLine("Eseguito nel thread pool"));
Task<int> taskConRitorno = Task.Run(() => 42);
int valore = await taskConRitorno;
Un Task può essere visto come una promessa di completamento con tre esiti possibili:
stateDiagram-v2
[*] --> Created
Created --> WaitingForActivation : metodo async / scheduler
WaitingForActivation --> Running : esecuzione
Running --> RanToCompletion : successo
Running --> Faulted : eccezione
Running --> Canceled : cancellazione
Nella pratica, la maggior parte dei task creati da metodi async è già “in movimento”: il metodo parte subito fino al primo await incompleto. Non sono come enumerazioni lazy che aspettano di essere avviate manualmente.
public async Task<int> CalcolaAsync()
{
Console.WriteLine("Parte subito");
await Task.Delay(1000);
return 10;
}
La riga CalcolaAsync() esegue immediatamente il corpo fino al primo await che non può completare in modo sincrono.
Molti task che eseguono codice CPU-bound usano il thread pool di .NET, un insieme gestito di thread worker riutilizzabili. Il thread pool:
Task.Run accoda lavoro CPU-bound al thread pool:
int risultato = await Task.Run(() =>
{
int somma = 0;
for (int i = 0; i < 1_000_000; i++)
somma += i;
return somma;
});
Qui c’è una distinzione fondamentale:
| Scenario | Soluzione corretta |
|---|---|
| Chiamata HTTP, DB, file async disponibili | usa direttamente API async |
| Compressione, parsing pesante, hashing, trasformazioni CPU | valuta Task.Run |
Wrappare Thread.Sleep, .Result, API sincrone lente | spesso è un anti-pattern |
Esempio corretto per I/O:
public async Task<string> ScaricaHtmlAsync(HttpClient httpClient, string url)
{
return await httpClient.GetStringAsync(url);
}
Qui Task.Run sarebbe inutile: l’I/O è già asincrono.
Esempio corretto per CPU-bound:
public async Task<byte[]> ComprimiAsync(byte[] dati)
{
return await Task.Run(() => Comprimi(dati));
}
Regola pratica:
Task.Run solo quando serve non bloccare il thread corrente.Un metodo async restituisce tipicamente:
TaskTask<T>ValueTaskValueTask<T>void solo per event handlerpublic string LeggiDati()
{
Thread.Sleep(2000);
return "Dati letti";
}
public async Task<string> LeggiDatiAsync()
{
await Task.Delay(2000);
return "Dati letti";
}
awaitQuando esegui:
string risultato = await LeggiDatiAsync();
accade questo:
await.Il compilatore non esegue magia: trasforma il metodo async in una macchina a stati. Ogni await diventa un possibile punto di sospensione.
Metodo sorgente:
public async Task<int> SommaDopoAttesaAsync()
{
int a = 10;
await Task.Delay(1000);
int b = 32;
return a + b;
}
Struttura concettuale generata dal compilatore:
private struct SommaDopoAttesaAsyncStateMachine : IAsyncStateMachine
{
public int _state;
public AsyncTaskMethodBuilder<int> _builder;
private int _a;
private int _b;
private TaskAwaiter _awaiter;
public void MoveNext()
{
int result;
try
{
if (_state == -1)
{
_a = 10;
var delayTask = Task.Delay(1000);
_awaiter = delayTask.GetAwaiter();
if (!_awaiter.IsCompleted)
{
_state = 0;
_builder.AwaitUnsafeOnCompleted(ref _awaiter, ref this);
return;
}
}
if (_state == 0)
{
_awaiter.GetResult();
}
_b = 32;
result = _a + _b;
}
catch (Exception ex)
{
_state = -2;
_builder.SetException(ex);
return;
}
_state = -2;
_builder.SetResult(result);
}
public void SetStateMachine(IAsyncStateMachine stateMachine) { }
}
Da capire bene:
_state indica il punto di ripresa;_builder costruisce il Task<int> restituito al chiamante;await diventano campi;flowchart TD
A[Entrata nel metodo async] --> B[Codice fino al primo await]
B --> C{Task già completato?}
C -- Sì --> D[Continua subito]
C -- No --> E[Salva stato e registra continuazione]
E --> F[Restituisce Task al chiamante]
F --> G[Task completato in futuro]
G --> H[Ripristina stato]
H --> I[Resume da dopo await]
Dal punto di vista del chiamante:
Per operazioni brevissime e sempre sincrone, l’overhead di Task può essere rilevante rispetto al lavoro reale. Da qui nasce l’utilità di ValueTask in casi speciali.
Le lambda async catturano variabili esterne. Se la variabile cambia dopo la cattura, tutte le lambda possono vedere lo stesso valore finale.
var tasks = new List<Task>();
for (int i = 0; i < 3; i++)
{
tasks.Add(Task.Run(async () =>
{
await Task.Delay(100);
Console.WriteLine(i); // rischio: stampa 3, 3, 3
}));
}
await Task.WhenAll(tasks);
Versione corretta:
var tasks = new List<Task>();
for (int i = 0; i < 3; i++)
{
int copia = i;
tasks.Add(Task.Run(async () =>
{
await Task.Delay(100);
Console.WriteLine(copia);
}));
}
await Task.WhenAll(tasks);
async void e async Task non sono equivalenti.
| Aspetto | async void | async Task |
|---|---|---|
| Tipo di ritorno | void | Task |
| Può essere atteso | No | Sì |
| Propagazione eccezioni | difficile da gestire dal chiamante | naturale con await |
Composizione con WhenAll, WhenAny | No | Sì |
| Uso corretto | event handler | quasi sempre |
public async void MetodoSbagliato()
{
await Task.Delay(1000);
throw new Exception("Eccezione fuori controllo del chiamante");
}
public async Task MetodoCorretto()
{
await Task.Delay(1000);
throw new Exception("Eccezione osservabile con await");
}
A volte si vede questo pattern:
_ = SalvaLogAsync();
Questo è un fire-and-forget: avvii un task e non lo attendi. È lecito solo se:
Pericoli:
Versione più sicura:
public static void AvviaInBackground(Task task, ILogger logger)
{
_ = task.ContinueWith(
t => logger.LogError(t.Exception, "Errore in task fire-and-forget"),
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted,
TaskScheduler.Default);
}
Ancora meglio, in ASP.NET Core o servizi worker, conviene delegare il lavoro a:
BackgroundServiceinvece di lanciare task scollegati dal ciclo di vita dell’app.
awaitQuesto anti-pattern è molto comune:
public Task SalvaAsync() => Task.Delay(1000);
public void Metodo()
{
SalvaAsync(); // il task parte, ma nessuno controlla esito o errori
}
Se il risultato ti serve o se l’operazione può fallire, await non è opzionale.
Le eccezioni dei metodi async vengono memorizzate nel Task e rilanciate quando fai await.
public async Task<int> DividiAsync(int a, int b)
{
await Task.Delay(100);
if (b == 0)
throw new DivideByZeroException("Divisione per zero!");
return a / b;
}
public async Task EsempioDiGestioneEccezioni()
{
try
{
int risultato = await DividiAsync(10, 0);
}
catch (DivideByZeroException ex)
{
Console.WriteLine($"Errore catturato: {ex.Message}");
}
finally
{
Console.WriteLine("Finally eseguito sempre");
}
}
await vs .Result e .Wait()Con await l’eccezione originale viene rilanciata in modo naturale. Con .Result e .Wait() si entra nel mondo delle attese bloccanti e spesso delle AggregateException.
try
{
var valore = MetodoAsync().Result;
}
catch (AggregateException ex)
{
Console.WriteLine(ex.InnerException?.Message);
}
Se più task falliscono:
Task.WhenAll produce un task faulted;Exception aggregata del task finale;await, il runtime rilancia l’errore, ma le eccezioni complete sono comunque consultabili.Task t1 = Task.Run(() => throw new InvalidOperationException("Errore 1"));
Task t2 = Task.Run(() => throw new ArgumentException("Errore 2"));
Task all = Task.WhenAll(t1, t2);
try
{
await all;
}
catch
{
foreach (var ex in all.Exception!.InnerExceptions)
Console.WriteLine(ex.Message);
}
Due anti-pattern classici:
.Wait() o .Result.Esempio di async over sync discutibile:
public Task<string> LeggiFileMaleAsync(string path)
{
return Task.Run(() => File.ReadAllText(path));
}
Se esiste File.ReadAllTextAsync, va preferita.
Esempio di sync over async pericoloso:
public string OttieniConfigurazione()
{
return CaricaConfigurazioneAsync().Result;
}
Regola: async all the way, cioè mantieni asincrona l’intera catena finché possibile.
public async Task EsempiWhenAll()
{
Task<string> task1 = FetchDaApiAsync("https://api1.example.com");
Task<string> task2 = FetchDaApiAsync("https://api2.example.com");
Task<string> task3 = FetchDaApiAsync("https://api3.example.com");
string[] risultati = await Task.WhenAll(task1, task2, task3);
foreach (var r in risultati)
Console.WriteLine(r);
}
WhenAll non rende magico il codice: funziona bene se i task sono indipendenti. Se ogni task dipende dal precedente, l’ordine logico resta sequenziale.
public async Task EsempiWhenAny()
{
Task<string> task1 = Task.Delay(3000).ContinueWith(_ => "Lento");
Task<string> task2 = Task.Delay(500).ContinueWith(_ => "Veloce");
Task<string> primoCompletato = await Task.WhenAny(task1, task2);
string risultato = await primoCompletato;
Console.WriteLine($"Primo completato: {risultato}");
}
È utile per:
await è miglioreLe continuazioni esplicite erano comuni prima di await:
Task<int> t = Task.Run(() => 10);
Task continuazione = t.ContinueWith(antecedent =>
{
Console.WriteLine(antecedent.Result * 2);
});
await continuazione;
Problemi di ContinueWith:
TaskScheduler.Current o TaskScheduler.Default;Versione preferita:
int valore = await Task.Run(() => 10);
Console.WriteLine(valore * 2);
await preserva meglio il flusso logico, il try/catch, il using e il debugging.
Quando hai una collezione e vuoi eseguire lavoro concorrente controllato, .NET 6+ offre Parallel.ForEachAsync.
public async Task ScaricaTuttiAsync(IEnumerable<string> urls, HttpClient httpClient, CancellationToken ct)
{
var options = new ParallelOptions
{
MaxDegreeOfParallelism = 4,
CancellationToken = ct
};
await Parallel.ForEachAsync(urls, options, async (url, token) =>
{
string html = await httpClient.GetStringAsync(url, token);
Console.WriteLine($"{url}: {html.Length} caratteri");
});
}
È utile quando:
Se una frazione del lavoro è parallelizzabile, il miglior speedup teorico con worker è:
Quindi:
Il CancellationToken è il modo cooperativo per interrompere operazioni asincrone.
public async Task OperazioneLungaAsync(CancellationToken cancellationToken)
{
for (int i = 0; i < 100; i++)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Delay(100, cancellationToken);
Console.WriteLine($"Step {i}");
}
}
public async Task EsempioCancellazione()
{
var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromMilliseconds(500));
try
{
await OperazioneLungaAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Operazione cancellata!");
}
}
Per notificare progressi in modo pulito, usa IProgress<T>.
public async Task DownloadConProgressoAsync(IProgress<int> progress, CancellationToken ct)
{
for (int percentuale = 0; percentuale <= 100; percentuale += 10)
{
ct.ThrowIfCancellationRequested();
await Task.Delay(100, ct);
progress.Report(percentuale);
}
}
public async Task EsempioProgressoAsync()
{
IProgress<int> progress = new Progress<int>(p =>
{
Console.WriteLine($"Completamento: {p}%");
});
await DownloadConProgressoAsync(progress, CancellationToken.None);
}
Progress<T> invoca il callback sul contesto catturato al momento della creazione, quindi in una UI è utile per aggiornare controlli in sicurezza.
IAsyncEnumerable<T> e await foreachQuando i dati arrivano nel tempo, non vuoi aspettare l’intera collezione finale. Gli async streams permettono di produrre elementi uno alla volta.
public async IAsyncEnumerable<int> GeneraValoriAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
for (int i = 1; i <= 5; i++)
{
ct.ThrowIfCancellationRequested();
await Task.Delay(200, ct);
yield return i;
}
}
public async Task ConsumaAsync()
{
await foreach (int valore in GeneraValoriAsync())
{
Console.WriteLine(valore);
}
}
Vantaggi:
Se una sorgente produce elementi:
IAsyncEnumerable<T> non implica parallelismo. Significa solo che:
ConfigureAwait(false) dice che la continuazione dopo un await non deve tentare di rientrare nel contesto originale.
public async Task<string> MetodoDiLibreriaAsync()
{
var dati = await FetchAsync()
.ConfigureAwait(false);
return dati.ToUpper();
}
SynchronizationContext è un’astrazione che decide dove eseguire una continuazione.
Esempi:
SynchronizationContext custom per riprendere sul thread originale;Quando fai await senza ConfigureAwait(false):
In WPF questo è cruciale: dopo l’await, puoi toccare i controlli UI perché la ripresa torna sul thread giusto.
public async Task AggiornaLabelAsync()
{
string testo = await httpClient.GetStringAsync("https://example.com");
MiaLabel.Content = testo; // valido in WPF se il contesto UI è stato catturato
}
In ASP.NET Core invece non hai quel vincolo di thread UI. La continuazione può proseguire su un thread pool qualunque, e normalmente questo va bene.
ExecutionContext è diverso da SynchronizationContext. Non decide su quale thread logico riprendere, ma quale contesto ambientale fluisce attraverso chiamate async e thread pool.
Tipicamente include:
AsyncLocal<T>;Esempio con AsyncLocal<T>:
private static readonly AsyncLocal<string> CorrelationId = new();
public async Task EsempioExecutionContextAsync()
{
CorrelationId.Value = "REQ-123";
await Task.Delay(100);
Console.WriteLine(CorrelationId.Value); // "REQ-123"
}
Questo valore fluisce anche se la continuazione avviene su un thread fisico diverso.
await cattura i contestiSchema semplificato:
flowchart LR
A[Codice corrente] --> B[Legge SynchronizationContext / TaskScheduler]
B --> C[Legge ExecutionContext]
C --> D[Registra continuazione]
D --> E[Operazione completa]
E --> F[Esegue continuazione nel contesto opportuno]
Punti chiave:
SynchronizationContext riguarda il luogo di esecuzione della continuazione;ExecutionContext riguarda i dati ambientali che fluiscono;ConfigureAwait(false) evita la cattura del context/scheduler di sincronizzazione;ConfigureAwait(false) non annulla automaticamente il flusso di ExecutionContext.| Aspetto | WPF | ASP.NET Core |
|---|---|---|
| UI thread dedicato | Sì | No |
SynchronizationContext significativo | Sì | In genere no |
Rientrare sul contesto dopo await | spesso necessario | spesso irrilevante |
ConfigureAwait(false) | da usare con cautela nel codice UI | di solito non necessario, ma possibile in librerie |
Un deadlock async tipico nasce quando blocchi sincronicamente un thread che serve anche per la continuazione.
public void MetodoSincrono()
{
var risultato = MetodoAsincrono().Result;
}
public async Task<string> MetodoAsincrono()
{
await Task.Delay(1000);
return "Pronto";
}
Scenario classico in WPF:
.Result;MetodoAsincrono completa il delay e vuole riprendere sul contesto UI catturato;.Result;sequenceDiagram
participant UI as UI Thread
participant Async as MetodoAsincrono
UI->>Async: .Result
UI->>UI: resta bloccato
Async-->>UI: continuazione dopo await
Note over UI: il contesto è occupato
UI--x Async: deadlock
ASP.NET Core non ha il classico SynchronizationContext della UI, quindi questo deadlock specifico è meno frequente. Tuttavia bloccare thread con .Result o .Wait() resta una cattiva idea perché:
.Result o .Wait() su task async;Task.Run senza motivo;await;lock lunghi, attese bloccanti e chiamate async;Task.Run soprattutto per CPU-bound o per non bloccare la UI;CancellationToken fino ai livelli bassi;await invece di ContinueWith, salvo casi speciali;async void tranne negli event handler;.Result / .Wait() se puoi evitarlo;ConfigureAwait(false) nelle librerie;Parallel.ForEachAsync;IProgress<T> per feedback di avanzamento;IAsyncEnumerable<T> quando i dati arrivano nel tempo;ValueTask<T> è utile quando un metodo completa spesso in modo sincrono e vuoi ridurre allocazioni inutili di Task<T>.
public async ValueTask<int> LeggiDaCache(string chiave)
{
if (_cache.TryGetValue(chiave, out int valore))
return valore;
int daDb = await _db.LeggiAsync(chiave);
_cache[chiave] = daDb;
return daDb;
}
Task<T> è preferibile;Infatti un ValueTask<T> ha regole più delicate:
Task<T> se serve riuso.ValueTask<int> vt = LeggiDaCache("x");
int valore = await vt;
Puoi leggere async/await così:
Task descrive il lavoro futuro;await spezza il metodo in stati;Se vuoi un criterio rapido:
| Problema | Strumento principale |
|---|---|
| Attesa rete/file/database | API async native + await |
| Calcolo pesante che non deve bloccare | Task.Run |
| Molti task indipendenti | Task.WhenAll |
| Primo risultato utile | Task.WhenAny |
| Progressi | IProgress<T> |
| Flusso graduale di dati | IAsyncEnumerable<T> |
| Parallelismo controllato su collezioni | Parallel.ForEachAsync |
| Libreria senza bisogno del contesto chiamante | ConfigureAwait(false) |
Cosa fa la keyword await in C#?
Quale tipo di ritorno è consigliato per un metodo asincrono?
async void è consigliato in quale caso?
Task.WhenAll fa:
Cosa lancia ThrowIfCancellationRequested()?
ConfigureAwait(false) è particolarmente utile in:
Quale chiamata può causare un deadlock in WPF?
ValueTask<T> è preferibile quando:
Come si avvia un metodo asincrono senza attenderne il risultato (fire-and-forget)?
Task.WhenAny fa:
Scenario: Vuoi simulare il download di più file in parallelo.
Consegna:
async Task<string> DownloadFileAsync(string url) che simula un download con Task.Delay (delay casuale tra 500ms e 2000ms) e restituisce il nome del file.Main, avvia 5 download in parallelo con Task.WhenAll.Obiettivo: comprendere il parallelismo con Task.WhenAll e i benefici dell’async.
Scenario: Vuoi che un’operazione lunga venga cancellata se supera un certo tempo.
Consegna:
async Task ElaboraAsync(CancellationToken ct) che esegue 20 step con Task.Delay(300, ct).Main, usa CancellationTokenSource con timeout di 1 secondo.OperationCanceledException e mostra un messaggio appropriato.Obiettivo: imparare a usare CancellationToken per controllare la durata delle operazioni.
Scenario: Devi chiamare più API e alcune potrebbero fallire.
Consegna:
async Task<int> ChiamaApiAsync(int id) che lancia un’eccezione se id % 3 == 0.Task.WhenAll.AggregateException.Obiettivo: capire come le eccezioni si propagano in contesti di task paralleli.
Scenario: Devi costruire una pipeline: leggi dati elabora salva.
Consegna:
async Task<string> LeggiAsync() restituisce dati grezzi dopo 500ms.async Task<string> ElaboraAsync(string dati) trasforma i dati dopo 300ms.async Task SalvaAsync(string dati) simula salvataggio dopo 200ms.Main, esegui la pipeline in sequenza con await.Obiettivo: comprendere la composizione di metodi asincroni in sequenza.
Prenota una lezione