ExecutionContext e SynchronizationContext

Quando si lavora con async e await, due tipi di contesto svolgono ruoli importanti ma molto diversi: ExecutionContext e SynchronizationContext. Si apprenderà cosa fa ognuno di essi, come ognuno interagisce con async/awaite perché SynchronizationContext.Current non scorre attraverso i punti await.

Che cos'è ExecutionContext?

ExecutionContext è un contenitore per lo stato di ambiente che scorre con il flusso di controllo logico del programma. In un mondo sincrono, le informazioni di ambiente si trovano nell'archiviazione locale del thread (TLS) e tutto il codice in esecuzione in un determinato thread rileva tali dati. In un mondo asincrono, un'operazione logica può iniziare su un thread, sospendere e riprendere in un thread diverso. I dati locali del thread non seguono automaticamente—ExecutionContext li rende effettivamente seguire.

Come fluisce ExecutionContext

Acquisire ExecutionContext tramite ExecutionContext.Capture(). Ripristinarlo durante l'esecuzione di un delegato usando ExecutionContext.Run:

static void ExecutionContextCaptureDemo()
{
    // Capture the current ExecutionContext
    ExecutionContext? ec = ExecutionContext.Capture();

    // Later, run a delegate within that captured context
    if (ec is not null)
    {
        ExecutionContext.Run(ec, _ =>
        {
            // Code here sees the ambient state from the point of capture
            Console.WriteLine("Running inside captured ExecutionContext.");
        }, null);
    }
}
Sub ExecutionContextCaptureExample()
    ' Capture the current ExecutionContext
    Dim ec As ExecutionContext = ExecutionContext.Capture()

    ' Later, run a delegate within that captured context
    If ec IsNot Nothing Then
        ExecutionContext.Run(ec,
            Sub(state)
                ' Code here sees the ambient state from the point of capture
                Console.WriteLine("Running inside captured ExecutionContext.")
            End Sub, Nothing)
    End If
End Sub

Tutte le API asincrone in .NET che funzionano tramite fork, Run, QueueUserWorkItem, BeginRead e altre, acquisiscono ExecutionContext e usano il contesto archiviato quando si richiama il callback. Questo processo di acquisizione dello stato in un thread e ripristino su un altro è ciò che si intende per "flowing ExecutionContext".

Che cos'è SynchronizationContext?

SynchronizationContext è un'astrazione che rappresenta un ambiente di destinazione in cui si desidera eseguire il lavoro. I diversi framework dell'interfaccia utente offrono implementazioni specifiche:

  • Windows Forms fornisce WindowsFormsSynchronizationContext, che sovrascrive Post per chiamare Control.BeginInvoke.
  • macchine virtuali Windows fornisce DispatcherSynchronizationContext, che esegue l'override di Post per chiamare Dispatcher.BeginInvoke.
  • ASP.NET (in .NET Framework) ha fornito il proprio contesto che ha garantito la disponibilità di HttpContext.Current.

SynchronizationContext Usando invece di API di marshalling specifiche del framework, è possibile scrivere componenti che funzionano tra framework dell'interfaccia utente:

static class SyncContextExample
{
    public static void DoWork()
    {
        // Capture the current SynchronizationContext
        SynchronizationContext? sc = SynchronizationContext.Current;

        ThreadPool.QueueUserWorkItem(_ =>
        {
            // ... do work on the ThreadPool ...

            if (sc is not null)
            {
                sc.Post(_ =>
                {
                    // This runs on the original context (e.g. UI thread)
                    Console.WriteLine("Back on the original context.");
                }, null);
            }
        });
    }
}
Class SyncContextExample
    Public Shared Sub DoWork()
        ' Install a custom SynchronizationContext for demonstration
        Dim customContext As New SimpleSynchronizationContext()
        SynchronizationContext.SetSynchronizationContext(customContext)

        ' Capture the current SynchronizationContext
        Dim sc As SynchronizationContext = SynchronizationContext.Current

        ThreadPool.QueueUserWorkItem(
            Sub(state)
                ' ... do work on the ThreadPool ...

                If sc IsNot Nothing Then
                    sc.Post(
                        Sub(s)
                            ' This runs on the original context (e.g. UI thread)
                            Console.WriteLine("Back on the original context.")
                        End Sub, Nothing)
                Else
                    Console.WriteLine("No SynchronizationContext was captured.")
                End If
            End Sub)
    End Sub
End Class

' A minimal SynchronizationContext for demonstration purposes
Class SimpleSynchronizationContext
    Inherits SynchronizationContext

    Public Overrides Sub Post(d As SendOrPostCallback, state As Object)
        ' Queue the callback to run on a thread pool thread
        ThreadPool.QueueUserWorkItem(
            Sub(s)
                d(state)
            End Sub)
    End Sub
End Class

Acquisire un oggetto SynchronizationContext

Quando si acquisisce un SynchronizationContext, si legge il riferimento da SynchronizationContext.Current e lo si archivia per usarlo in un secondo momento. Si chiama Post sul riferimento acquisito per pianificare il lavoro in quell'ambiente.

Propagazione di ExecutionContext vs. uso di SynchronizationContext

Anche se entrambi i meccanismi comportano l'acquisizione dello stato da un thread, servono scopi diversi:

  • Flowing ExecutionContext significa acquisire lo stato di ambiente e rendere lo stesso stato corrente durante l'esecuzione di un delegato. Il delegato viene eseguito ovunque venga eseguito e lo stato lo segue.
  • L'uso di SynchronizationContext significa acquisire una destinazione di pianificazione e usarla per decidere dove viene eseguito un delegato. Il contesto acquisito controlla la posizione in cui viene eseguito il delegato.

In breve: ExecutionContext risponde "quale ambiente deve essere visibile?" mentre SynchronizationContext risponde "dove deve essere eseguito il codice?"

Come async/await interagisce con entrambi i contesti

L'infrastruttura async/await interagisce automaticamente con entrambi i contesti, ma in modi diversi.

ExecutionContext si propaga sempre

Ogni volta che un oggetto await sospende un metodo (perché il metodo awaiter restituisce IsCompleted), l'infrastruttura false acquisisce un oggetto ExecutionContext. Quando il metodo riprende, la continuazione viene eseguita all'interno del contesto acquisito. Questo comportamento è integrato nei generatori di metodi asincroni (ad esempio , AsyncTaskMethodBuilder) e si applica indipendentemente dal tipo di awaitable in uso.

SuppressFlow() esiste, ma non è un interruttore specifico per await come ConfigureAwait(false). Sopprime l'acquisizione ExecutionContext per il lavoro che si accoda mentre la soppressione è attiva. Non fornisce un'opzione perawait ogni modello di programmazione che indica ai generatori di metodi asincroni di ignorare il ripristino dell'oggetto acquisito ExecutionContext per una continuazione. Questa progettazione è intenzionale perché ExecutionContext è il supporto a livello di infrastruttura che simula la semantica locale dei thread in un mondo asincrono e la maggior parte degli sviluppatori non deve mai pensarci.

Gli task awaiter catturano il SynchronizationContext

Gli awaiters per Task e Task<TResult> includono il supporto per SynchronizationContext. I generatori di metodi asincroni non includono questo supporto.

Quando si esegue await un'attività:

  1. Il awaiter controlla SynchronizationContext.Current.
  2. Se esiste un contesto, l’awaiter lo acquisisce.
  3. Al termine dell'attività, la continuazione viene ripristinata in tale contesto salvato anziché essere eseguita nel thread di completamento o nel pool di thread.

Questo comportamento è come await "ti riporta dove eri". Ad esempio, continuare il thread dell'interfaccia utente in un'applicazione desktop.

ConfigureAwait controlla l'acquisizione del SynchronizationContext

Se non desideri il comportamento di marshalling, chiama ConfigureAwait con false:

await task.ConfigureAwait(false);

Quando si imposta continueOnCapturedContext su false, l'awaiter non verifica la presenza di un SynchronizationContext e la continuazione viene eseguita ovunque l'attività venga completata (in genere in un thread del pool di thread). Gli autori di librerie devono usare ConfigureAwait(false) in ogni await, a meno che il codice non debba riprendere in modo specifico nel contesto acquisito.

SynchronizationContext.Current non viene propagato attraverso 'awaits'

Questo punto è il più importante: SynchronizationContext.Currentnon prosegue attraverso i punti await. I generatori di metodi asincroni nel runtime usano overload interni che sopprimono esplicitamente SynchronizationContext dal fluire come parte di ExecutionContext.

Perché è importante:

Tecnicamente, SynchronizationContext è uno dei contesti secondari che ExecutionContext possono contenere. Se è stato eseguito come parte di ExecutionContext, il codice in esecuzione in un thread del pool di thread potrebbe visualizzare un'interfaccia utente SynchronizationContext come Current, non perché tale thread è il thread dell'interfaccia utente, ma perché il contesto è stato "traspelato" tramite flusso. Tale modifica altererebbe il significato di SynchronizationContext.Current da "ambiente in cui sono attualmente in" a "l'ambiente che storicamente esisteva da qualche parte nella catena di chiamate".

Esempio di Task.Run

Si consideri il codice che delega il lavoro al pool di thread. Il comportamento del thread dell'interfaccia utente descritto di seguito si applica solo quando SynchronizationContext.Current non è Null, ad esempio in un'app dell'interfaccia utente:

static class TaskRunExample
{
    public static async Task ProcessOnUIThread()
    {
        // This method is called from a thread with a SynchronizationContext.
        // Task.Run offloads work to the thread pool.
        string result = await Task.Run(async () =>
        {
            string data = await DownloadAsync();
            // Compute runs on the thread pool, not the original context,
            // because SynchronizationContext doesn't flow into Task.Run.
            return Compute(data);
        });

        // Back on the original context (the continuation is posted back).
        Console.WriteLine(result);
    }

    private static async Task<string> DownloadAsync()
    {
        await Task.Delay(100);
        return "downloaded data";
    }

    private static string Compute(string data) =>
        $"Computed: {data.Length} chars";
}
Class TaskRunExampleClass
    Public Shared Async Function ProcessOnUIThread() As Task
        ' If a SynchronizationContext is present when this method starts,
        ' the outer await captures it. Task.Run still offloads work to the thread pool.
        Dim result As String = Await Task.Run(
            Async Function()
                Dim data As String = Await DownloadAsync()
                ' Compute runs on the thread pool, not the caller's context,
                ' because SynchronizationContext doesn't flow into Task.Run.
                Return Compute(data)
            End Function)

        ' Resume on the captured context, if one was available.
        Console.WriteLine(result)
    End Function

    Private Shared Async Function DownloadAsync() As Task(Of String)
        Await Task.Delay(100)
        Return "downloaded data"
    End Function

    Private Shared Function Compute(data As String) As String
        Return $"Computed: {data.Length} chars"
    End Function
End Class

In un'applicazione console, SynchronizationContext.Current è in genere null, quindi il codice non riprende in un thread reale dell'interfaccia utente. Il frammento di codice illustra invece la regola concettualmente: se un'interfaccia utente SynchronizationContext è stata propagata attraverso await punti, la parte interna await del delegato passato a Task.Run vedrebbe quel contesto dell'interfaccia utente come Current. La continuazione dopo await DownloadAsync() avrebbe quindi fatto sì che si effettuasse un postback al thread dell'interfaccia utente, causando Compute(data) ad essere eseguito nel thread dell'interfaccia utente anziché nel pool di thread. Questo comportamento sconfigge lo scopo della Task.Run chiamata.

Poiché il runtime elimina il flusso in ExecutionContext, await all'interno di Task.Run non eredita un contesto UI esterno e la continuazione rimane eseguita nel pool di thread come da previsione.

Sommario

Aspetto ExecutionContext SynchronizationContext
Purpose Trasporta lo stato ambientale attraverso i limiti asincroni Rappresenta un pianificatore di destinazione (dove deve essere eseguito il codice)
Acquisito da Generatori di metodi asincroni (infrastruttura) Gestori di attesa delle attività (await task)
Flussi in attesa di completamento? Sì, sempre No: catturati e pubblicati su, non incanalati
API di eliminazione ExecutionContext.SuppressFlow (avanzato; raramente necessario) ConfigureAwait(false)
Scope Tutti gli oggetti awaitable Task e Task<TResult> (i awaiter personalizzati possono aggiungere logica simile)

Vedere anche