Quando scriviamo codice asincrono in C# utilizzando async
e await
, il compilatore fa molto più lavoro di quanto possa sembrare. Dietro la semplicità sintattica si nasconde un meccanismo sofisticato: la generazione di state machine asincrone. (C# async state machine) Comprendere come funzionano queste macchine a stati è fondamentale per chiunque voglia padroneggiare le performance, il debugging e i casi avanzati della programmazione asincrona in .NET.
Introduzione
Dal .NET Framework 4.5 in poi, la programmazione asincrona in C# ha rivoluzionato il modo di scrivere codice non bloccante. Tuttavia, quando scriviamo:
public async Task<int> GetDataAsync()
{
var result = await SomeApiCallAsync();
return result + 10;
}
quello che vediamo non è ciò che realmente viene eseguito dal runtime. Il compilatore trasforma questo metodo in una macchina a stati che gestisce il flusso asincrono, gli eventuali errori e la ripresa dopo la sospensione.
Cos’è una Async State Machine?
Una state machine (macchina a stati) è una rappresentazione del flusso di esecuzione suddivisa in stati numerici. Ogni await
diventa un potenziale punto di sospensione e il compilatore assegna uno stato che rappresenta “dove riprendere”.
Il compilatore genera:
- Una struttura che implementa
IAsyncStateMachine
- Un campo di stato (
int <>1__state
) - Un builder asincrono (
AsyncTaskMethodBuilder
o simili) - Campi per catturare variabili locali
- Un metodo
MoveNext()
che contiene la logica principale
Come il compilatore riscrive il codice
Il metodo precedente:
public async Task<int> GetDataAsync()
{
var result = await SomeApiCallAsync();
return result + 10;
}
viene riscritto (semplificato) così:
private struct <GetDataAsync>d__0 : IAsyncStateMachine
{
public int <>1__state;
public AsyncTaskMethodBuilder<int> <>t__builder;
private TaskAwaiter<int> <>u__1;
private int result;
void IAsyncStateMachine.MoveNext()
{
int num = <>1__state;
int returnValue;
try
{
TaskAwaiter<int> awaiter;
if (num != 0)
{
awaiter = SomeApiCallAsync().GetAwaiter();
if (!awaiter.IsCompleted)
{
<>1__state = 0;
<>u__1 = awaiter;
<>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
return;
}
}
else
{
awaiter = <>u__1;
<>u__1 = default;
<>1__state = -1;
}
result = awaiter.GetResult();
returnValue = result + 10;
}
catch (Exception ex)
{
<>1__state = -2;
<>t__builder.SetException(ex);
return;
}
<>1__state = -2;
<>t__builder.SetResult(returnValue);
}
void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine) { }
}
Punti Chiave
- Ogni metodo
async
diventa una state machine: non c’è magia, ma una riscrittura del compilatore. - Gli awaiter gestiscono la sospensione:
TaskAwaiter
determina se il task è già completato o se bisogna sospendere. - Gestione delle eccezioni: le
Exception
vengono catturate e propagate attraverso ilTask
. - Performance: c’è un overhead, ma il compilatore ottimizza molto. Comprendere la state machine aiuta ad evitare
async void
e a ragionare su scenari ad alta concorrenza. - Debugging avanzato: in debugger si vedono i metodi “normali”, ma dietro c’è il
MoveNext
generato.
Cos’è il Context Switching
Il context switching (cambio di contesto) è l’operazione con cui un sistema passa l’esecuzione da un contesto a un altro.
Può avere significati diversi a seconda del livello a cui guardiamo:
1. A livello di sistema operativo
- Ogni thread ha uno stato: registri CPU, stack, memoria, puntatore d’istruzione.
- Quando il sistema operativo interrompe un thread e ne fa ripartire un altro, deve salvare lo stato del thread uscente e ripristinare quello del thread entrante.
- Questo costa tempo: non si fa “lavoro utile”, ma solo amministrazione.
In pratica, ogni volta che la CPU cambia thread, si perde un po’ di tempo e cache locality.
2. A livello di .NET e async/await
Quando usi await
, per default il runtime cerca di riprendere l’esecuzione sullo stesso contesto di sincronizzazione.
Esempio:
- In un’applicazione UI (WPF, WinForms, MAUI), il contesto è il thread principale della UI: il codice dopo l’
await
torna lì, così puoi accedere a controlli senzaInvoke()
. - In ASP.NET Core, il contesto è tipicamente irrilevante perché non c’è un thread di UI, e il framework non cattura il contesto (da .NET Core in poi).
- Se il contesto viene catturato e poi cambiato, avviene un context switch logico: il tuo codice riprende su un thread diverso o in un SynchronizationContext differente.
Questo passaggio costa risorse, anche se meno rispetto al cambio di thread a livello OS.
3. Effetto pratico
Ogni await
può generare un context switch:
- Con
ConfigureAwait(true)
(default): il runtime cattura e ripristina il contesto → più sicuro in UI apps, ma meno efficiente. - Con
ConfigureAwait(false)
: il runtime non forza il ritorno al contesto → più efficiente, consigliato nelle librerie o in codice server-side.
Esempio
public async Task DemoAsync()
{
// Parte nel thread UI
var result = await SomeApiCallAsync();
// Default: torna nel thread UI → context switch
Console.WriteLine(result);
}
public async Task DemoAsyncNoContext()
{
var result = await SomeApiCallAsync().ConfigureAwait(false);
// Qui NON c’è garanzia di tornare nel thread UI
// ma si evita il costo del context switch
Console.WriteLine(result);
}
Quando preoccuparsi del context switching
Scenari ad alta concorrenza: ogni context switch in più = overhead accumulato → attenzione nelle performance-critical code paths.
App desktop/UI: va bene catturare il contesto, perché semplifica l’accesso ai controlli.
Codice di libreria / backend (ASP.NET Core, servizi): meglio usare ConfigureAwait(false)
per ridurre i costi e scalare meglio.
Best Practices da ricavare
- Usa
async Task
e nonasync void
(a meno che non sia un event handler). - Evita
async
inutili: se un metodo non contieneawait
, non renderloasync
. - Comprendi l’overhead: in scenari di altissimo throughput, la conoscenza interna può guidare verso ottimizzazioni (es.
ValueTask
). - Occhio alla cattura del contesto: usare
ConfigureAwait(false)
in librerie riduce il costo di context switching.
FAQ
🔹 Cosa succede se ci sono più await
nello stesso metodo?
Ogni await
diventa un nuovo stato nella state machine. Più await
, più stati da gestire.
🔹 Posso vedere sempre il codice generato dal compilatore?
Sì, usando strumenti come ILSpy o SharpLab.
🔹 Le state machine sono uguali per Task
e ValueTask
?
Il meccanismo è simile, ma ValueTask
è pensato per ridurre l’allocazione di oggetti Task
in scenari hot path.
🔹 Perché il debugger non mostra il MoveNext
?
Il compilatore aggiunge attributi come AsyncStateMachine
e il debugger mostra il codice sorgente “tradotto” per semplificare la lettura.
🔹 Posso scrivere io manualmente una IAsyncStateMachine
?
Tecnicamente sì, ma è rarissimo: si fa solo in scenari molto particolari o per studio.
Link di Approfondimento
- Microsoft Docs: Async state machines
- Stephen Cleary – Async/Await FAQs
- SharpLab per vedere il codice generato
- ValueTask vs Task – Performance considerations
- Un nostro approfondimento sull’async await