Contexto de Execução e Contexto de Sincronização

Quando se trabalha com async e await, dois tipos de contexto desempenham papéis importantes mas muito diferentes: ExecutionContext e SynchronizationContext. Aprendes o que cada um faz, como cada um interage com async/await, e por que SynchronizationContext.Current não flui através dos pontos de espera.

O que é o ExecutionContext?

ExecutionContext é um container para o estado contextual que acompanha o fluxo de controlo lógico do seu programa. Num mundo síncrono, a informação ambiente vive em armazenamento local de threads (TLS), e todo o código a correr num determinado thread vê esses dados. Num mundo assíncrono, uma operação lógica pode começar numa thread, suspender e retomar numa thread diferente. Os dados locais de thread não seguem automaticamente —ExecutionContext fazem com que sigam.

O fluxo do ExecutionContext

Captura ExecutionContext usando ExecutionContext.Capture(). Restaure-o durante a execução de um delegado ao usar 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 em .NET que funcionam em fork — Run, QueueUserWorkItem, BeginRead, entre outras, — capturam ExecutionContext e usam o contexto armazenado ao invocar o seu callback. Este processo de capturar o estado num thread e restaurá-lo noutro é o que significa "flowing ExecutionContext".

O que é o SynchronizationContext?

SynchronizationContext é uma abstração que representa um ambiente-alvo onde queres que o trabalho corra. Diferentes frameworks de interface fornecem as 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 o seu próprio contexto que garantiu que HttpContext.Current estava disponível.

Ao usar SynchronizationContext , em vez de APIs de marshaling específicas do framework, pode escrever componentes que funcionam entre frameworks de interface:

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 Contexto de Sincronização

Quando capturas um SynchronizationContext, lês a referência de SynchronizationContext.Current e guardas-a para uso posterior. Depois, utilizas Post com a referência capturada para agendar novamente o trabalho nesse ambiente.

Execução de ExecutionContext vs. utilização de SynchronizationContext

Embora ambos os mecanismos envolvam capturar estado a partir de um thread, servem propósitos diferentes:

  • Flowing ExecutionContext significa capturar o estado ambiente e tornar esse mesmo estado atual durante a execução de um delegado. O delegado candidata-se onde quer que acabe por concorrer — o estado segue-o.
  • Usar o SynchronizationContext significa capturar um destino de agendamento e usá-lo para decidir onde um delegado executa. O contexto capturado controla onde o delegado corre.

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

Como o async/await interage com ambos os contextos

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

ExecutionContext está sempre ativo e fluindo

Sempre que um await suspende um método (porque o IsCompleted esperante retorna false), a infraestrutura captura um ExecutionContext. Quando o método recomeça, a continuação decorre dentro do contexto capturado. Este comportamento está incorporado nos construtores de métodos assíncronos (por exemplo, AsyncTaskMethodBuilder) e aplica-se independentemente do tipo de awaitable que uses.

SuppressFlow() existe, mas não é um interruptor específico de espera como ConfigureAwait(false). Suprime ExecutionContext a captura para trabalhos que colocas na fila enquanto a supressão está ativa. Não fornece uma opção por modelo de programação await para que os construtores de métodos assíncronos não restaurem o contexto capturado ExecutionContext durante uma continuação. Esse design é intencional porque ExecutionContext é suporte ao nível da infraestrutura que simula semântica local de threads num mundo assíncrono, e a maioria dos programadores nunca precisa de pensar nisso.

Os esperadores de tarefas capturam SynchronizationContext

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

Quando faz await uma tarefa:

  1. O empregado verifica SynchronizationContext.Current.
  2. Se existe um contexto, o esperante capta-o.
  3. Quando a tarefa termina, a continuação é enviada de volta para esse contexto capturado em vez de ser executada no tópico de conclusão ou no conjunto de tópicos.

Este comportamento é como await "te traz de volta ao que estavas". Por exemplo, retomar o tópico da interface numa aplicação de ambiente de trabalho.

ConfigureAwait controla a captura do contexto de sincronização

Se não quiser o comportamento de organização, ligue ConfigureAwait com false:

await task.ConfigureAwait(false);

Quando defines continueOnCapturedContext para false, o awaiter não verifica o SynchronizationContext e a continuação é executada onde a tarefa for concluída (normalmente num pool de threads). Os autores da biblioteca devem usar ConfigureAwait(false) em cada await, a menos que o código precise especificamente de retomar o contexto capturado.

SynchronizationContext.Current não se propaga com await

Este ponto é o mais importante: SynchronizationContext.Currentnão flui através dos pontos de espera. Os construtores de métodos assíncronos em tempo de execução utilizam sobrecargas internas que suprimem explicitamente SynchronizationContext de fluir como parte de ExecutionContext.

Por que motivo isto é importante

Tecnicamente, SynchronizationContext é um dos subcontextos que ExecutionContext pode conter. Se fluía como parte de ExecutionContext, o código a correr num thread pool pode ver uma interface SynchronizationContext como Current, não porque esse thread seja o thread UI, mas porque o contexto "vazou" via flow. Essa mudança alteraria o significado de SynchronizationContext.Current de "o ambiente em que estou atualmente" para "o ambiente existente historicamente em algum lugar na cadeia de chamadas."

O exemplo Task.Run

Considere código que transfere trabalho para o pool de threads. O comportamento UI-thread descrito aqui aplica-se apenas quando SynchronizationContext.Current não é nulo, como numa aplicação UI:

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

Numa aplicação de consola, SynchronizationContext.Current é tipicamente null, por isso o excerto não retoma numa linha de execução real de interface. Em vez disso, o excerto ilustra a regra conceptualmente: se uma interface SynchronizationContext fluísse através await de pontos, o await interior do delegado passado para Task.Run veria esse contexto da interface como Current. A continuação após await DownloadAsync() seria então reenviada para o thread da interface, fazendo com que Compute(data) fosse executado no thread da interface em vez de no pool de threads. Esse comportamento anula o propósito da chamada Task.Run.

Como o runtime suprime SynchronizationContext o fluxo em ExecutionContext, o await interior Task.Run não herda um contexto externo da interface, e a continuação continua a correr no pool de threads como pretendido.

Resumo

Aspeto Contexto de Execução SincronizaçãoContexto
Objetivo Transporta o estado ambiental através de fronteiras assíncronas Representa um agendador de alvo (onde o código deve ser executado)
Capturado por Construtores de métodos assíncronos (infraestrutura) Esperadores de tarefas (await task)
Os fluxos atravessam à espera? Sim, sempre Não—capturado e publicado, não transmitido em tempo real
API de remoção ExecutionContext.SuppressFlow (avançado; raramente necessário) ConfigureAwait(false)
Scope Todos os aguardáveis Task e Task<TResult> (awaiters personalizados podem adicionar lógica semelhante)

Consulte também