ExecutionContext y SynchronizationContext

Cuando se trabaja con async y await, dos tipos de contexto desempeñan roles importantes pero muy diferentes: ExecutionContext y SynchronizationContext. Aprenderá lo que hace cada uno, cómo interactúa cada uno con async/awaity por qué SynchronizationContext.Current no fluye a través de puntos de espera.

¿Qué es ExecutionContext?

ExecutionContext es un contenedor para el estado ambiente que fluye con el flujo de control lógico del programa. En un mundo sincrónico, la información ambiental reside en el almacenamiento local de subprocesos (TLS) y todo el código que se ejecuta en un subproceso determinado ve esos datos. En un mundo asincrónico, una operación lógica puede iniciarse en un subproceso, suspender y reanudar en otro subproceso. Los datos de subprocesos locales no se transfieren automáticamente:ExecutionContext se encarga de transferirlos.

Cómo fluye el ExecutionContext

Capture ExecutionContext mediante ExecutionContext.Capture(). Restáurelo al ejecutar un delegado mediante 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 las API asincrónicas de .NET que crean bifurcaciones de trabajo,Run, QueueUserWorkItem, BeginRead y otras, capturan ExecutionContext y usan el contexto almacenado al invocar su devolución de llamada. Este proceso de capturar el estado en un subproceso y restaurarlo en otro es lo que significa "flujo de ExecutionContext".

¿Qué es SynchronizationContext?

SynchronizationContext es una abstracción que representa un entorno de destino en el que desea que se ejecute el trabajo. Los distintos marcos de interfaz de usuario proporcionan sus propias implementaciones:

  • Windows Forms proporciona WindowsFormsSynchronizationContext, que invalida Post para llamar a Control.BeginInvoke.
  • WPF proporciona DispatcherSynchronizationContext, que invalida Post para llamar a Dispatcher.BeginInvoke.
  • ASP.NET (en .NET Framework) proporcionó su propio contexto que garantizaba que HttpContext.Current estuviera disponible.

Al usar SynchronizationContext en lugar de API de serialización específicas del marco, puede escribir componentes que funcionan en marcos de interfaz de usuario:

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

Captura de SynchronizationContext

Al capturar un SynchronizationContext, lee la referencia de SynchronizationContext.Current y la almacena para su uso posterior. A continuación, llame a Post en la referencia capturada para programar el trabajo de nuevo en ese entorno.

Flujo de ExecutionContext frente al uso de SynchronizationContext

Aunque ambos mecanismos implican capturar el estado de un subproceso, sirven para diferentes propósitos:

  • El flujo de ExecutionContext implica capturar el estado ambiente y hacer que ese mismo estado esté activo durante la ejecución de un delegado. El delegado se ejecuta dondequiera que termine ejecutándose: el estado le sigue.
  • El uso de SynchronizationContext implica capturar un objetivo de programación y usarlo para decidir dónde se ejecutará un delegado. El contexto capturado determina dónde se ejecuta el delegado.

En resumen: ExecutionContext responde "¿qué entorno debe ser visible?" mientras SynchronizationContext responde "¿dónde debe ejecutarse el código?"

Cómo interactúa async/await con ambos contextos

La async/await infraestructura interactúa automáticamente con ambos contextos, pero de diferentes maneras.

ExecutionContext siempre fluye

Cada vez que un objeto await suspende un método (porque el awaiter IsCompleted devuelve false), la infraestructura captura un ExecutionContext. Cuando se reanuda el método, la continuación se ejecuta dentro del contexto capturado. Este comportamiento está integrado en los generadores de métodos asincrónicos (por ejemplo, AsyncTaskMethodBuilder) y se aplica independientemente del tipo de awaitable que utilice.

SuppressFlow() existe, pero no es un conmutador específico de await como ConfigureAwait(false). Suprime la captura de ExecutionContext del trabajo que pone en cola mientras la supresión está activa. No proporciona una opción porawait modelo de programación que indica a los generadores de métodos asincrónicos omitir la restauración del ExecutionContext capturado para una continuación. Ese diseño es intencionado porque ExecutionContext es soporte a nivel de infraestructura que simula la semántica local de hilos en un mundo asincrónico, y la mayoría de los desarrolladores nunca tienen que preocuparse por ello.

Los awaiters de tareas capturan SynchronizationContext

Los awaiters para Task e Task<TResult> incluyen compatibilidad con SynchronizationContext. Los generadores de métodos asincrónicos no incluyen esta compatibilidad.

Cuando realiza await una tarea:

  1. El awaiter comprueba SynchronizationContext.Current.
  2. Si existe un contexto, el awaiter lo captura.
  3. Cuando se completa la tarea, la continuación se devuelve a ese contexto capturado en lugar de ejecutarse en el subproceso que completa o en el grupo de subprocesos.

Este comportamiento es cómo await "te trae de vuelta a dónde estabas". Por ejemplo, reanudar la ejecución en el subproceso de la interfaz de usuario en una aplicación de escritorio.

ConfigureAwait controla la captura del contexto de sincronización

Si no desea el comportamiento de serialización, llame a ConfigureAwait con false:

await task.ConfigureAwait(false);

Cuando se establece continueOnCapturedContext en false, el awaiter no comprueba SynchronizationContext y la continuación se ejecuta dondequiera que se complete la tarea (normalmente en un subproceso del grupo de subprocesos). Los autores de bibliotecas deben usar ConfigureAwait(false) en cada awaiter a menos que el código deba reanudarse específicamente en el contexto capturado.

SynchronizationContext.Current no se transmite entre los puntos de espera

Este aspecto es el más importante: SynchronizationContext.Currentno se propaga entre los puntos de espera. Los generadores de métodos asincrónicos del entorno de ejecución usan sobrecargas internas que suprimen de manera explícita el flujo de SynchronizationContext como parte de ExecutionContext.

Por qué esto importa

Técnicamente, SynchronizationContext es uno de los subcontextos que ExecutionContext puede contener. Si fluye como parte de ExecutionContext, el código que se ejecuta en un subproceso de grupo de subprocesos puede ver una interfaz de usuario SynchronizationContext como Current, no porque ese subproceso es el subproceso de la interfaz de usuario, sino porque el contexto se "ha filtrado" a través del flujo. Ese cambio modificaría el significado de SynchronizationContext.Current "el entorno en el que estoy actualmente" a "el entorno que históricamente existía en algún lugar de la cadena de llamadas".

Ejemplo de Task.Run

Considere un código que delegue tareas al grupo de subprocesos. El comportamiento del subproceso de interfaz de usuario que se describe aquí solo se aplica cuando SynchronizationContext.Current no es NULL, como en una aplicación de interfaz de usuario:

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

En una aplicación de consola, SynchronizationContext.Current suele ser null, por lo que el fragmento de código no se reanuda en un subproceso de interfaz de usuario real. En su lugar, el fragmento de código ilustra la regla conceptualmente: si una interfaz de usuario SynchronizationContext atravesara puntos de await, await dentro del delegado pasado a Task.Run vería ese contexto de la interfaz de usuario como Current. La continuación tras await DownloadAsync() se enviaría de nuevo al subproceso de la interfaz de usuario, provocando que se ejecute Compute(data) en el subproceso de la interfaz de usuario en lugar de en el grupo de subprocesos. Ese comportamiento derrota el propósito de la Task.Run llamada.

Dado que el tiempo de ejecución suprime el flujo en el flujo de SynchronizationContext en ExecutionContext, await dentro de Task.Run no hereda un contexto de interfaz de usuario externa, y la continuación sigue ejecutándose en el grupo de subprocesos como estaba previsto.

Resumen

Aspecto Contexto de Ejecución Contexto de Sincronización (SynchronizationContext)
propósito Mantiene el estado del entorno a través de límites asíncronos Representa un programador de destino (donde se debe ejecutar el código)
Capturado por Generadores de métodos asincrónicos (infraestructura) Awaiters de tareas (await task)
¿Fluye por await? Sí, siempre No, se han capturado y publicado, no han fluido.
API de supresión ExecutionContext.SuppressFlow (avanzado; rara vez necesario) ConfigureAwait(false)
Ámbito Todos los awaitables Task y Task<TResult> (los awaiters personalizados pueden agregar una lógica similar)

Consulte también