async await immagine evocativa

Async/Await : quello che (forse) ti sfugge potrebbe rallentare tutto

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 ogni await
  • Contiene un builder (AsyncTaskMethodBuilder<TResult>) per orchestrare il Task 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

AspettoImpatto
AllocazioniOgni metodo async crea almeno un oggetto Task + struct state machine
PerformanceOverhead per gestione degli awaiter e del builder
DebugStack trace più complessi e poco leggibili
Garbage CollectionPiù 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


FAQ – Domande frequenti

Cosa succede quando uso 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.

Cosa significa che 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.

Quali sono i costi nascosti di 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).

Quando usare ConfigureAwait(false)?

Usalo in ambienti non-UI (come ASP.NET Core) per evitare di catturare il SynchronizationContext. Questo migliora la scalabilità e previene deadlock.

Qual è la differenza tra 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.

Come evitare troppa concorrenza in async?

Usa SemaphoreSlim per limitare il numero di task concorrenti, specialmente in scenari come download massivi o accesso a risorse condivise.

Come gestire errori in task “fire-and-forget”?

Non ignorare il Task. Usa .ContinueWith() con OnlyOnFaulted per loggare o gestire eccezioni in task lanciati senza await.

Dove posso approfondire il funzionamento interno di async/await?
Consulta la guida ufficiale:
📖 How async/await really works – devblogs.microsoft.com


Pubblicato

in

da

Tag: