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 (
-1all’inizio,0,1, …,-2alla fine) - Memorizza awaiter come
TaskAwaiterper ogniawait - Contiene un builder (
AsyncTaskMethodBuilder<TResult>) per orchestrare ilTaskrestituito - Conserva tutte le variabili locali che devono “sopravvivere” tra uno
awaite 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.Runintroduce 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
