Menu

async await in C#: Task, Task<T>, WhenAll ed errori comuni

async e await permettono al codice C# di attendere operazioni lente senza bloccare un thread. Scopri come viene eseguito un metodo async, Task e Task<T>, come eseguire insieme operazioni indipendenti con Task.WhenAll, le eccezioni nel codice async e perché async void e .Result causano bug e deadlock.

Questa pagina include editor eseguibili: modifica, esegui e vedi subito l'output.

La maggior parte delle operazioni lente in un programma sono attese: che un server web risponda, che un database restituisca delle righe, che un file venga letto. async e await permettono a un metodo di mettersi in pausa durante un'attesa del genere senza tenere in ostaggio un thread, e poi di riprendere da dove si era fermato. Il codice si legge comunque dall'alto verso il basso come codice normale.

Un primo metodo async

Un metodo async restituisce Task (nessun risultato) o Task<T> (un risultato di tipo T). Al suo interno, await attende un altro task e ti dà il suo risultato.

Output:

Tea 2.50, cake 4.00, total 6.50

GetPriceAsync scrive return 2.50m, e il compilatore lo avvolge nel Task<decimal> che il metodo restituisce. await lo scarta di nuovo. Task.Delay è la versione async di Thread.Sleep: si completa quando è trascorso il tempo, senza bloccare un thread nel frattempo.

Da C# 7.1 anche Main può essere async, ed è così che scriveresti questo programma nel .NET moderno:

static async Task Main()
{
    decimal tea = await GetPriceAsync("tea");
    Console.WriteLine(tea);
}

Gli esempi eseguibili di questa pagina chiamano un metodo async RunAsync da un Main normale con .GetAwaiter().GetResult(), che è ciò che il compilatore genera per un Main async. In un'app console è sicuro; la sezione sui deadlock spiega perché la stessa chiamata è pericolosa nel codice di una UI.

Cosa succede a un await

Un metodo async viene eseguito in modo sincrono fino al primo await su un task non terminato. A quel punto restituisce un Task al chiamante, e il resto del metodo diventa una continuazione che viene eseguita quando il task atteso si completa.

Output:

Calling DownloadAsync
  Download: starting
DownloadAsync returned a task; doing other work
  Download: finished
Download awaited

"Download: starting" viene stampato prima che DownloadAsync ritorni, perché tutto ciò che precede il primo await viene eseguito sul thread del chiamante. Poi il metodo restituisce un task non terminato, il chiamante prosegue, e "Download: finished" compare solo quando il ritardo è finito. Chiamare un metodo async lo avvia; attenderlo è il modo per aspettarne il risultato.

async non crea un thread. Mentre il metodo è in pausa non c'è alcun thread che lo aspetta, ed è per questo che un server può avere migliaia di richieste in attesa di un database con solo una manciata di thread. Per il lavoro che impegna la CPU e deve girare su un altro thread, usa Task.Run, trattato nei task.

Eseguire operazioni nello stesso momento con Task.WhenAll

Attendere una chiamata dopo l'altra è sequenziale: ognuna parte solo dopo che la precedente è finita. Quando le operazioni non dipendono l'una dall'altra, avviale tutte e poi attendile insieme con Task.WhenAll:

Output di esempio:

Sequential: 160 units in ~900 ms
Concurrent: 160 units in ~300 ms

La versione sequenziale impiega la somma delle tre attese, quella concorrente più o meno quanto la più lenta. Task.WhenAll restituisce i risultati nello stesso ordine dei task che hai passato, indipendentemente da quale sia finito prima. Funziona anche con una lista, che è la forma tipica per "recupera ogni elemento": await Task.WhenAll(ids.Select(id => FetchAsync(id))).

Task.WhenAny è la controparte che si completa non appena si completa il primo task, utile per i timeout e per "vince la prima risposta".

Eccezioni nel codice async

Un'eccezione lanciata dentro un metodo async viene memorizzata nel task restituito e rilanciata quando il task viene atteso, quindi un normale try/catch attorno all'await funziona:

Output:

Task created, completed yet: False
Caught ArgumentOutOfRangeException for userId
WhenAll rethrew the first failure
All failures: 1
The other task still succeeded: profile 7

Chiamare LoadProfileAsync(-1) non ha lanciato nulla: l'eccezione vive nel task finché qualcosa non lo attende. Un task che nessuno attende mai nasconde completamente la sua eccezione, un motivo in più per attendere sempre ciò che avvii. Con WhenAll, await rilancia la prima eccezione, mentre la proprietà Exception del task combinato (una AggregateException) le contiene tutte. Leggere ok.Result lì va bene perché ok è già completato.

async void: solo per gli event handler

Un metodo async può anche restituire void. Evitalo ovunque tranne che negli event handler:

// Bad: the caller cannot await it or catch its exceptions.
static async void SaveAsync(Order order)
{
    await db.InsertAsync(order);   // if this throws, the process may crash
}

// Good: return Task, so callers can await and handle errors.
static async Task SaveAsync(Order order)
{
    await db.InsertAsync(order);
}

// Acceptable: an event handler must return void.
private async void SaveButton_Click(object sender, EventArgs e)
{
    try { await SaveAsync(currentOrder); }
    catch (Exception ex) { ShowError(ex); }
}

Con async void non c'è alcun task da attendere, quindi il chiamante prosegue prima che il lavoro sia finito, e un'eccezione lanciata all'interno viene sollevata direttamente sul contesto di sincronizzazione (o sul thread pool), dove nessun try/catch del chiamante può raggiungerla. In un'app console o server, di solito questo termina il processo. Dentro un event handler async void, cattura tutto tu.

Un errore collegato è chiamare un metodo async senza await. Il compilatore dà un warning (CS4014) e il metodo gira in background senza che nessuno ne osservi il risultato o gli errori.

Deadlock causati da .Result e .Wait()

Bloccarsi su un task con .Result o .Wait() è il punto in cui il codice async va storto più spesso. In un'applicazione con un contesto di sincronizzazione (WinForms, WPF, MAUI, ASP.NET classico), la sequenza è:

  1. Il thread della UI chiama GetDataAsync().Result e si blocca, in attesa del task.
  2. Dentro GetDataAsync, un await termina. Per impostazione predefinita la sua continuazione deve essere eseguita sul contesto in cui è partita: il thread della UI.
  3. Il thread della UI è bloccato al passo 1, quindi la continuazione non viene mai eseguita, quindi il task non si completa mai, quindi il passo 1 non finisce mai.

L'app si blocca senza alcuna eccezione. Le app console e ASP.NET Core non hanno un contesto del genere, ed è per questo che lo stesso codice funziona in un programma di prova e si pianta in un'app desktop. Le soluzioni:

  • Usa await lungo tutta la catena di chiamate invece di bloccare ("async all the way"). Gli event handler possono essere async void proprio per questo.
  • Nel codice di libreria che non ha bisogno di tornare al contesto del chiamante, scrivi await SomethingAsync().ConfigureAwait(false);. La continuazione viene allora eseguita sul thread pool invece che sul contesto catturato, e la libreria evita un cambio di thread inutile. Protegge un chiamante che blocca solo se ogni await della catena fa lo stesso, quindi consideralo una buona abitudine per le librerie, non una soluzione per .Result.

Il codice applicativo in ASP.NET Core non ha bisogno di ConfigureAwait(false), perché non c'è alcun contesto a cui tornare.

Errori comuni

  • Attendere chiamate indipendenti una alla volta. Avviale, poi await Task.WhenAll(...).
  • Metodi async void. Restituisci Task; tieni async void per gli event handler.
  • .Result e .Wait() nel codice di una UI o di ASP.NET classico. Causano deadlock; usa await.
  • Dimenticare await. Il lavoro gira senza essere osservato e le sue eccezioni spariscono.
  • Avvolgere l'I/O in Task.Run. Un'API async come File.ReadAllTextAsync o HttpClient.GetStringAsync libera già il thread; Task.Run aggiunge solo un passaggio dal thread pool.
  • Pensare che async significhi "gira su un altro thread". Significa "può mettersi in pausa senza bloccare". Usa Task.Run per il lavoro legato alla CPU.

Domande frequenti

Come funzionano async e await in C#?

Marcare un metodo come async gli permette di usare await. Quando il metodo arriva a un await su un task non ancora terminato, torna subito al chiamante, restituendogli un Task che rappresenta il resto del lavoro. Quando l'operazione attesa si completa, il metodo riprende dopo l'await. Nessun thread resta bloccato mentre aspetta.

Che differenza c'è tra Task e Task<T>?

Task rappresenta un'operazione che non produce alcun valore, la versione async di un metodo void. Task<T> rappresenta un'operazione che produce un T: await su un Task<int> ti dà l'int. Un metodo async dichiarato async Task<int> scrive semplicemente return 42; e il compilatore lo avvolge nel task.

Come eseguo più operazioni async nello stesso momento?

Avviale tutte prima, poi attendile insieme: var a = GetUserAsync(); var b = GetOrdersAsync(); await Task.WhenAll(a, b);. Scrivere await GetUserAsync(); await GetOrdersAsync(); le esegue una dopo l'altra, quindi il tempo totale è la somma invece di quello della più lunga.

Perché async void è sconsigliato in C#?

Un metodo async void non può essere atteso, quindi il chiamante non sa quando termina, e un'eccezione lanciata al suo interno non può essere catturata dal chiamante: viene sollevata sul contesto di sincronizzazione e di solito fa chiudere il processo. Restituisci invece Task. async void esiste solo per gli event handler, la cui firma richiede void.

Perché .Result o .Wait() causano un deadlock?

In un'app con un contesto di sincronizzazione (WinForms, WPF, ASP.NET classico), .Result blocca il thread della UI o della richiesta mentre la continuazione del metodo atteso aspetta di essere eseguita proprio su quel thread. Ognuno aspetta l'altro per sempre. Usa await lungo tutta la catena invece di bloccare, e usa ConfigureAwait(false) nel codice delle librerie.

Main può essere async in C#?

Sì, da C# 7.1: static async Task Main() o static async Task<int> Main(). Le istruzioni di primo livello (C# 9) possono usare await direttamente. Nel codice più vecchio l'equivalente è chiamare MainAsync().GetAwaiter().GetResult() da un Main normale, cosa sicura lì perché un'app console non ha un contesto di sincronizzazione.

Illustrazione dei linguaggi di programmazione di Coddy

Impara a programmare con Coddy

INIZIA