C# async state machine

Async State Machines in C#: cosa genera il compilatore dietro ogni await


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

  1. Ogni metodo async diventa una state machine: non c’è magia, ma una riscrittura del compilatore.
  2. Gli awaiter gestiscono la sospensione: TaskAwaiter determina se il task è già completato o se bisogna sospendere.
  3. Gestione delle eccezioni: le Exception vengono catturate e propagate attraverso il Task.
  4. 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.
  5. 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 senza Invoke().
  • 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 non async void (a meno che non sia un event handler).
  • Evita async inutili: se un metodo non contiene await, non renderlo async.
  • 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



Pubblicato

in

da

Tag: