ExecutionContext e SynchronizationContext

Quando você trabalha com async e awaitdois tipos de contexto desempenham funções importantes, mas muito diferentes: ExecutionContext e SynchronizationContext. Você aprende o que cada um faz, como cada um interage com async/await e por que SynchronizationContext.Current não flui através dos pontos await.

O que é ExecutionContext?

ExecutionContext é um contêiner para o estado ambiente que flui com o fluxo de controle lógico do seu programa. Em um mundo síncrono, as informações de ambiente residem no TLS (armazenamento local de thread) e todo o código em execução em um determinado thread vê esses dados. Em um mundo assíncrono, uma operação lógica pode começar em um thread, suspender e retomar em um thread diferente. Os dados locais de thread não acompanham automaticamente – ExecutionContext os faz seguir.

Como o fluxo de ExecutionContext ocorre

Capturar ExecutionContext usando ExecutionContext.Capture(). Restaure-os durante a execução de um delegate 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

Todas as APIs assíncronas no .NET que bifurcam o trabalho – Run, QueueUserWorkItem, BeginRead e outros – capturam ExecutionContext e usam o contexto armazenado quando invocam o retorno de chamada. Esse processo de capturar o estado em um thread e restaurá-lo em outro é o que significa "ExecutionContext fluindo".

O que é SynchronizationContext?

SynchronizationContext é uma abstração que representa um ambiente de destino onde você deseja que o trabalho seja executado. Estruturas de interface do usuário diferentes fornecem suas próprias implementações:

  • Windows Forms fornece WindowsFormsSynchronizationContext, que substitui Post para chamar Control.BeginInvoke.
  • WPF fornece DispatcherSynchronizationContext, que substitui Post para chamar Dispatcher.BeginInvoke.
  • ASP.NET (no .NET Framework) forneceu seu próprio contexto que garantiu que HttpContext.Current estivesse disponível.

Ao usar SynchronizationContext em vez de usar APIs de marshaling específicas do framework, você pode escrever componentes que funcionam em diferentes frameworks de interface do usuário.

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

Capturar um SynchronizationContext

Quando capturar SynchronizationContext, leia a referência direto de SynchronizationContext.Current e guarde-a para uso posterior. Em seguida, você chama Post na referência capturada para agendar o trabalho de volta àquele ambiente.

Comparando Contexto de Execução em Fluxo com o uso de Contexto de Sincronização

Embora ambos os mecanismos envolvam a captura de estado de um thread, eles atendem a diferentes finalidades:

  • ExecutionContext fluindo significa capturar o estado ambiente e tornar esse mesmo estado atual durante a execução de um delegate. delegate é executado onde acabar sendo executado – o estado segue-o.
  • Usar SynchronizationContext significa capturar um destino de agendamento e usá-lo para decidir onde delegate será executado. Os controles de contexto capturados em que delegate é executado.

Resumindo: ExecutionContext responde "qual ambiente deve ser visível?" enquanto SynchronizationContext responde "onde o código deve ser executado?"

Como async/await interage com ambos os contextos

A async/await infraestrutura interage com ambos os contextos automaticamente, mas de maneiras diferentes.

ExecutionContext sempre se flui

Sempre que await suspende um método (porque IsCompleted do awaiter retorna false), a infraestrutura captura ExecutionContext. Quando o método é retomado, a continuação é executada dentro do contexto capturado. Esse comportamento é integrado aos construtores de métodos assíncronos (por exemplo, AsyncTaskMethodBuilder) e se aplica independentemente do tipo de awaitable que você usa.

SuppressFlow() existe, mas não é uma opção específica de espera como ConfigureAwait(false). Ele suprime a captura ExecutionContext para o trabalho que você coloca na fila enquanto a supressão está ativa. Ele não fornece uma opção de modelo de programação porawait que informa aos construtores de métodos assíncronos para ignorar a restauração de ExecutionContext capturado para uma continuação. Esse design é intencional porque ExecutionContext é um suporte em nível de infraestrutura que simula a semântica local de thread em um mundo assíncrono, e a maioria dos desenvolvedores nunca precisa pensar nisso.

Awaiters de tarefa capturam SynchronizationContext

Os awaiters para Task e Task<TResult> incluem suporte para SynchronizationContext. Os construtores de métodos assíncronos não incluem esse suporte.

Quando você executa await uma tarefa:

  1. O awaiter verifica SynchronizationContext.Current.
  2. Se houver um contexto, o awaiter o capturará.
  3. Quando a tarefa é concluída, a continuação é postada novamente no contexto capturado em vez de ser executada na thread em que foi concluída ou no pool de threads.

Esse comportamento é como await "traz você de volta para onde você estava". Por exemplo, retomar no thread da interface de usuário em um aplicativo de área de trabalho.

Controla a captura de SynchronizationContext com ConfigureAwait

Se você não quiser o comportamento de marshaling, chame ConfigureAwait com false:

await task.ConfigureAwait(false);

Quando você define continueOnCapturedContext como false, o awaiter não verifica se há um SynchronizationContext e a continuação é executada onde quer que a tarefa seja concluída (normalmente em um thread do pool de threads). Os autores da biblioteca devem usar ConfigureAwait(false) em cada await, salvo se o código precisar ser especificamente retomado no contexto capturado.

SynchronizationContext.Current não flui entre awaits

Esse ponto é o mais importante: SynchronizationContext.Current não flui através dos pontos await. Os construtores de métodos assíncronos no runtime usam as sobrecargas internas que suprimem explicitamente SynchronizationContext de fluir como parte de ExecutionContext.

Por que isso importa

Tecnicamente, SynchronizationContext é um dos subcontextos que ExecutionContext pode conter. Se ele fluiu como parte de ExecutionContext, o código que está sendo executado em um thread do pool de threads pode ver uma interface do usuário SynchronizationContext como Current, e não porque esse thread seja o thread de interface do usuário, mas porque o contexto "vazou" através do fluxo. Essa mudança alteraria o significado de SynchronizationContext.Current "o ambiente em que estou atualmente" para "o ambiente que historicamente existia em algum lugar da cadeia de chamadas".

O exemplo Task.Run

Considere o código que descarrega o trabalho para o pool de threads. O comportamento da thread da interface gráfica descrito aqui se aplica somente quando SynchronizationContext.Current não é nulo, como em um aplicativo de interface gráfica.

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

Em um aplicativo de console, SynchronizationContext.Current é normalmente null, portanto, o snippet não é retomado em um thread real de interface de usuário. Em vez disso, o snippet ilustra a regra conceitualmente: se uma interface de usuário SynchronizationContext fluísse através dos pontos await, o await interno que delegate tiver passado para Task.Run veria esse contexto de interface do usuário como Current. A continuação depois de await DownloadAsync() seria enviada de volta para o thread da interface de usuário, fazendo com que Compute(data) fosse executado no thread da interface de usuário em vez de no pool de threads. Esse comportamento contraria a finalidade da Task.Run chamada.

Como o runtime suprime o fluxo SynchronizationContext em ExecutionContext, o await dentro de Task.Run não herda um contexto de interface do usuário externo, e a continuação permanece em execução no pool de threads conforme o esperado.

Resumo

Aspecto Contexto de Execução SynchronizationContext
Purpose Carrega o estado ambiente entre limites assíncronos Representa um agendador de destino (em que o código deve ser executado)
Capturado por Construtores de métodos assíncronos (infraestrutura) Awaiters de tarefa (await task)
Flui por await? Sim, sempre Não – capturado e postado, e não flui
API de supressão ExecutionContext.SuppressFlow (avançado; raramente necessário) ConfigureAwait(false)
Âmbito Todos os awaitables Task e Task<TResult> (os awaiters personalizados podem adicionar lógica semelhante)

Consulte também