Encapsuladores sincrónicos para métodos asincrónicos

Cuando una biblioteca expone solo API asincrónicas, los consumidores a veces los encapsulan en llamadas sincrónicas para satisfacer una interfaz o contrato sincrónicos. Este patrón de "sincronización sobre asincronía" puede parecer sencillo, pero es una fuente común de interbloqueos y problemas de rendimiento.

Patrones de envoltura básicos

Un contenedor sincrónico alrededor de un método basado en el patrón asincrónico de tareas (TAP) accede a la propiedad Result de la tarea, lo que bloquea el hilo que realiza la llamada.

public class TapWrapper
{
    public static int Foo(Func<Task<int>> fooAsync)
    {
        return fooAsync().Result;
    }
}
Public Module TapWrapper
    Public Function Foo(fooAsync As Func(Of Task(Of Integer))) As Integer
        Return fooAsync().Result
    End Function
End Module

Este enfoque es sencillo, pero puede causar problemas graves en función del entorno en el que se ejecute.

Interbloqueos con contextos de un solo hilo

El escenario más peligroso se produce cuando se llama a un contenedor sincrónico desde un subproceso que tiene un único subproceso SynchronizationContext. Este escenario suele ser un subproceso de interfaz de usuario en aplicaciones de WPF, Windows Forms o MAUI.

public static class DeadlockExample
{
    private static void Delay(int milliseconds)
    {
        DelayAsync(milliseconds).Wait();
    }

    private static async Task DelayAsync(int milliseconds)
    {
        await Task.Delay(milliseconds);
    }
}
Public Module DeadlockExample
    Private Sub Delay(milliseconds As Integer)
        DelayAsync(milliseconds).Wait()
    End Sub

    Private Async Function DelayAsync(milliseconds As Integer) As Task
        Await Task.Delay(milliseconds)
    End Function
End Module

Esto es lo que sucede paso a paso:

  1. El subproceso de interfaz de usuario llama a Delay, que llama a DelayAsync(milliseconds).Wait().
  2. DelayAsync se ejecuta sincrónicamente hasta que alcanza await Task.Delay(milliseconds).
  3. Dado que el retraso aún no se ha completado, await captura el estado actual de SynchronizationContext y se suspende. DelayAsync devuelve un Task al autor de la llamada.
  4. El subproceso de interfaz de usuario se bloquea en .Wait(), esperando a que se complete esa tarea.
  5. Cuando finalice el retraso, la continuación debe ejecutarse en el hilo original SynchronizationContext, que es el hilo de la interfaz de usuario.
  6. El subproceso de interfaz de usuario no puede procesar la continuación porque está bloqueado en .Wait().
  7. Interbloqueo.

Importante

El éxito o error del código sincronizado a través de async depende del entorno en el que se ejecute. El código que funciona en una aplicación de consola podría bloquearse en un hilo de interfaz de usuario o en ASP.NET (en .NET Framework). Esta dependencia del entorno es una razón fundamental para evitar exponer envoltorios sincrónicos.

Agotamiento del grupo de hilos

Los interbloqueos no están limitados a los subprocesos de la interfaz de usuario. Si un método asincrónico depende del grupo de subprocesos para completar su trabajo, por ejemplo, mediante la puesta en cola de un paso de procesamiento final, el bloqueo de muchos subprocesos del grupo con envoltorios síncronos puede dejar sin recursos al grupo.

public static class ThreadPoolDeadlockExample
{
    public static int Foo(Func<Task<int>> fooAsync)
    {
        return fooAsync().Result;
    }

    public static async Task DemonstrateDeadlockRiskAsync()
    {
        var tasks = Enumerable.Range(0, 25)
            .Select(_ => Task.Run(() => Foo(() => SomeIOOperationAsync())));
        await Task.WhenAll(tasks);
    }

    private static async Task<int> SomeIOOperationAsync()
    {
        await Task.Delay(100);
        return 42;
    }
}

En este escenario:

  1. Muchos hilos del grupo de hilos llaman a Foo, que bloquea en .Result.
  2. Cada operación asincrónica completa su E/S y necesita un hilo del pool de hilos para ejecutar su callback de finalización.
  3. Dado que las llamadas bloqueadas ocupan subprocesos de trabajo disponibles, las finalizaciones pueden esperar mucho tiempo para que un subproceso esté disponible.
  4. La versión moderna de .NET puede agregar más subprocesos de grupo con el tiempo, pero la aplicación todavía puede sufrir un grave agotamiento del grupo de subprocesos, un rendimiento deficiente, retrasos prolongados o un bloqueo aparente.

Este patrón afectó a HttpWebRequest.GetResponse en .NET Framework 1.x, donde el método sincrónico se implementó como un envoltorio alrededor del método asincrónico BeginGetResponse/EndGetResponse.

Directriz: Evite exponer envoltorios sincrónicos

No exponga un método sincrónico que encapsula una implementación asincrónica. En cambio, deje al consumidor la decisión de bloquear o no. El consumidor conoce su entorno de subprocesos y puede tomar una decisión informada.

Si necesita llamar a un método asincrónico de forma sincrónica, considere primero si puede reestructurar el código para que sea "asincrónico todo el camino hacia abajo". La refactorización suele ser la mejor solución a largo plazo.

Estrategias de mitigación cuando la sincronización sobre asincronía es inevitable

A veces, la sincronización sobre asincronía es realmente inevitable. Por ejemplo, es inevitable cuando se implementa una interfaz que requiere un método sincrónico y la única implementación disponible es asincrónica. En esos casos, aplique las siguientes estrategias para reducir el riesgo.

Uso ConfigureAwait(false) en la implementación asincrónica

Si controla el método asincrónico, use Task.ConfigureAwait con false en cada await para evitar que la continuación vuelva a descargarse en el original SynchronizationContext:

public static class ConfigureAwaitMitigation
{
    public static async Task<int> LibraryMethodAsync()
    {
        await Task.Delay(100).ConfigureAwait(false);
        return 42;
    }

    public static int Sync()
    {
        return LibraryMethodAsync().GetAwaiter().GetResult();
    }
}
Public Module ConfigureAwaitMitigation
    Public Async Function LibraryMethodAsync() As Task(Of Integer)
        Await Task.Delay(100).ConfigureAwait(False)
        Return 42
    End Function

    Public Function Sync() As Integer
        Return LibraryMethodAsync().Result
    End Function
End Module

Como desarrollador de bibliotecas, use ConfigureAwait(false) en todos los awaits a menos que el código necesite reanudarse específicamente en el contexto capturado. ConfigureAwait(false) es una práctica recomendada para el rendimiento y ayuda a evitar bloqueos cuando los consumidores bloquean.

Descarga en el grupo de subprocesos

Si no controla la implementación asincrónica (y es posible que no use ConfigureAwait(false)), delegue la llamada al grupo de subprocesos. El grupo de subprocesos no tiene un SynchronizationContext, por lo que await no intentará retomar en un subproceso bloqueado.

public int Sync()
{
    return Task.Run(() => Library.FooAsync()).Result;
}
Public Function Sync() As Integer
    Return Task.Run(Function() Library.FooAsync()).Result
End Function

Prueba en varios entornos

Si debe enviar un contenedor sincrónico, pruébelo desde:

  • Un subproceso de interfaz de usuario (WPF, Windows Forms).
  • Grupo de subprocesos bajo carga.
  • El grupo de hilos con un número máximo de hilos bajo.
  • Una aplicación de consola.

El comportamiento que funciona en un entorno podría llegar a interbloquearse en otro.

Consulte también