L’introduzione di async/await
in C# ha reso la programmazione asincrona molto più accessibile. Tuttavia, per chi lavora su sistemi scalabili, concurrent o ad alte prestazioni, usarli “alla leggera” può introdurre rallentamenti, memory leak o comportamenti imprevedibili.
In questo articolo vediamo cosa succede realmente dietro la keyword async
, perché ogni metodo asincrono ha un costo, e come scrivere codice asincrono veramente performante e robusto.
Cosa fa davvero il compilatore C#

Quando scrivi un metodo come:
public async Task<int> GetValueAsync()
{
await Task.Delay(1000);
return 42;
}
il compilatore C# esegue una trasformazione complessa:
1. Genera una state machine
Il metodo viene convertito in una struttura interna che implementa IAsyncStateMachine
. Questa struttura:
- Tiene traccia dello stato dell’esecuzione (
-1
all’inizio,0
,1
, …,-2
alla fine) - Memorizza awaiter come
TaskAwaiter
per ogniawait
- Contiene un builder (
AsyncTaskMethodBuilder<TResult>
) per orchestrare ilTask
restituito - Conserva tutte le variabili locali che devono “sopravvivere” tra uno
await
e l’altro
Il metodo originale, invece, inizializza la state machine, la avvia e restituisce il Task
risultante.
👉 È lo stesso principio degli iteratori (yield return
) ma con meccanismi più complessi.
🔗 Riferimento tecnico:
📖 How async/await really works in C# – devblogs.microsoft.com
Esempio concreto della trasformazione
Questo:
public async Task<int> GetValueAsync()
{
await Task.Delay(1000);
return 42;
}
Diventa (semplificato):
[AsyncStateMachine(typeof(<GetValueAsync>d__0))]
public Task<int> GetValueAsync()
{
var stateMachine = new <GetValueAsync>d__0();
stateMachine.<>t__builder = AsyncTaskMethodBuilder<int>.Create();
stateMachine.<>1__state = -1;
stateMachine.<>t__builder.Start(ref stateMachine);
return stateMachine.<>t__builder.Task;
}
Con MoveNext()
che gestisce le transizioni:
Stato -1 → await Task.Delay → Stato 0 → return 42 → Stato -2
Questa frammentazione in stati è la vera causa dell’overhead implicito.
🔍 Vedi anche:
📖 Understanding async state machines – blog.ndepend.com
📖 Async state machine breakdown – mykkon.work
Implicazioni pratiche
Aspetto | Impatto |
---|---|
Allocazioni | Ogni metodo async crea almeno un oggetto Task + struct state machine |
Performance | Overhead per gestione degli awaiter e del builder |
Debug | Stack trace più complessi e poco leggibili |
Garbage Collection | Più oggetti = più pressione sulla GC |
Best Practice #1: Evita async
se non c’è await
❌ Sbagliato:
public async Task<int> GetNumberAsync()
{
return 42;
}
✅ Corretto:
public Task<int> GetNumberAsync() => Task.FromResult(42);
🔍 Evita la generazione della state machine e del builder, riducendo l’allocazione.
✅ Best Practice #2: ConfigureAwait(false)
in ambienti server-side
In contesti come ASP.NET Core non serve tornare al thread originale dopo un await
.
✅ Usa sempre:
await SomeAsyncOperation().ConfigureAwait(false);
Questo evita deadlock e migliora la scalabilità, perché libera il SynchronizationContext
.
🔗 Approfondimento:
📖 ConfigureAwait FAQ – docs.microsoft.com
✅ Best Practice #3: Gestione della concorrenza con SemaphoreSlim
Lanciare troppe operazioni in parallelo può saturare le risorse (es. socket pool).
Esempio:
var semaphore = new SemaphoreSlim(10);
var tasks = urls.Select(async url =>
{
await semaphore.WaitAsync();
try {
return await httpClient.GetStringAsync(url);
} finally {
semaphore.Release();
}
});
await Task.WhenAll(tasks);
🔗 Guida:
📖 Throttling with SemaphoreSlim – devblogs.microsoft.com
✅ Best Practice #4: Supporta la cancellazione
Ogni metodo async
importante dovrebbe accettare un CancellationToken
.
public async Task DownloadAsync(string url, CancellationToken token)
{
using var response = await httpClient.GetAsync(url, token);
...
}
🔗 Approfondimento:
📖 Cancellation in async APIs – docs.microsoft.com
✅ Best Practice #5: Fire-and-forget sicuro
Non ignorare un Task
senza gestirne l’eccezione.
✅ Sicuro:
_ = DoWorkAsync().ContinueWith(t =>
{
logger.LogError(t.Exception, "Errore in background task");
}, TaskContinuationOptions.OnlyOnFaulted);
✅ Best Practice #6: Usa ValueTask
solo se necessario
ValueTask
evita allocazioni inutili se il valore è disponibile sincronicamente.
Ma:
- Non può essere atteso più volte
- È più difficile da usare
- È utile solo in hot path ad alto volume
🔗 Guida ufficiale:
📖 ValueTask vs Task – docs.microsoft.com
✅ Best Practice #7: Evita Task.Run
attorno a codice già asincrono
❌ Male:
await Task.Run(() => httpClient.GetAsync("https://..."));
✅ Meglio:
await httpClient.GetAsync("https://...");
httpClient.GetAsync
è già I/O-bound:Task.Run
introduce solo overhead.
✅ Bonus: IAsyncEnumerable<T>
e await foreach
Per lavorare con dati in streaming, usa IAsyncEnumerable<T>
:
public async IAsyncEnumerable<string> ReadLinesAsync(string path)
{
using var reader = new StreamReader(path);
while (!reader.EndOfStream)
{
yield return await reader.ReadLineAsync();
}
}
🔗 Guida:
📖 Asynchronous streams – docs.microsoft.com
📌 Conclusione
async/await
è uno strumento potente, ma non gratuito.
Ogni metodo asincrono:
- Alloca memoria extra
- Complica il flusso di esecuzione
- Richiede attenzione nella gestione delle risorse
Chi sviluppa software di produzione ad alte prestazioni deve conoscerne i dettagli interni, evitare gli anti-pattern, e scrivere codice asincrono consapevole.
📎 Link utili
- How async/await really works – devblogs.microsoft.com
- Async overview – docs.microsoft.com
- ConfigureAwait – Microsoft Docs
- Cancellation – Microsoft Docs
- ValueTask vs Task
FAQ – Domande frequenti
async/await
in C#? Quando usi async/await
in C#, il compilatore crea una state machine che gestisce il flusso asincrono tramite un metodo MoveNext
, un builder (AsyncTaskMethodBuilder
) e uno Task
restituito. Questo permette la sospensione e ripresa del metodo senza bloccare thread.
async
crea una state machine? Significa che il compilatore scompone il metodo in più stati interni, ciascuno rappresentante una fase del metodo (await
, ritorno, eccezione). Questi stati sono gestiti tramite una struct o class generata automaticamente, implementando IAsyncStateMachine
.
async/await
? Ogni metodo async
:
alloca memoria per una state machine,
crea un Task
,
aggiunge complessità al debugging,
può causare rallentamenti se abusato.
Per questo è importante evitarlo se non necessario (es. metodi che restituiscono valori immediati).
ConfigureAwait(false)
? Usalo in ambienti non-UI (come ASP.NET Core) per evitare di catturare il SynchronizationContext
. Questo migliora la scalabilità e previene deadlock.
Task
, ValueTask
e Task.Run
? Task
: standard per operazioni asincrone.ValueTask
: evita allocazioni se il risultato è spesso sincrono.Task.Run
: serve per operazioni CPU-bound, ma non va usato per I/O asincrono già supportato da await
.
async
? Usa SemaphoreSlim
per limitare il numero di task concorrenti, specialmente in scenari come download massivi o accesso a risorse condivise.
Non ignorare il Task
. Usa .ContinueWith()
con OnlyOnFaulted
per loggare o gestire eccezioni in task lanciati senza await
.
async/await
?Consulta la guida ufficiale:
📖 How async/await really works – devblogs.microsoft.com