Wrappers síncronos para métodos assíncronos

Quando uma biblioteca expõe apenas APIs assíncronas, os consumidores por vezes envolvem-nas em chamadas síncronas para satisfazer uma interface ou contrato síncrono. Este padrão de "sincronização sobre assíncrono" pode parecer simples, mas é uma fonte comum de bloqueios e problemas de desempenho.

Padrões básicos de empacotamento

Um wrapper síncrono em torno de um método Task-Based Asynchronous Pattern (TAP) acede à propriedade Result da tarefa, que bloqueia o thread chamador:

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

Esta abordagem parece simples, mas pode causar problemas sérios dependendo do ambiente em que decorre.

Deadlocks com contextos monofilados

O cenário mais perigoso ocorre quando se chama um wrapper síncrono a partir de um thread único que possui um SynchronizationContext. Este cenário é tipicamente um tópico de interface em aplicações WPF, Windows Forms ou 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

Eis o que acontece passo a passo:

  1. O thread da interface chama Delay, que chama DelayAsync(milliseconds).Wait().
  2. DelayAsync Executa-se de forma síncrona até atingir await Task.Delay(milliseconds).
  3. Como o atraso ainda não está completo, await captura o estado atual SynchronizationContext e suspende. DelayAsync retorna um Task para quem chamou.
  4. O thread da interface bloqueia .Wait(), aguardando a conclusão dessa tarefa.
  5. Quando o atraso termina, a continuação tem de correr no original SynchronizationContext , que é o thread da interface.
  6. O thread da interface não consegue processar a continuação porque está bloqueada em .Wait().
  7. Impasse.

Importante

O sucesso ou fracasso do código síncrono sobre assíncrono depende do ambiente em que é executado. O código que funciona numa aplicação de consola pode bloquear num thread de interface ou no ASP.NET (no .NET Framework). Esta dependência ambiental é uma razão fundamental para evitar expor wrappers síncronos.

Exaustão do pool de threads

Os deadlocks não se limitam aos tópicos da interface. Se um método assíncrono depende do pool de threads para completar o seu trabalho, por exemplo, ao enfileirar um passo final de processamento, bloquear muitos threads de pool com wrappers síncronos pode privar o pool:

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;
    }
}

Neste cenário:

  1. Muitos threads do pool de threads chamam Foo, que bloqueia em .Result.
  2. Cada operação assíncrona completa a sua I/O e necessita de um thread pool thread para executar o seu callback de completão.
  3. Como as chamadas bloqueadas ocupam threads de trabalho disponíveis, as conclusões podem esperar muito tempo até que um thread fique disponível.
  4. A versão moderna do .NET pode adicionar mais threads no pool de threads ao longo do tempo, mas a aplicação ainda pode sofrer de esgotamento severo do pool de threads, baixo rendimento, longos atrasos ou um aparente bloqueio.

Este padrão afetava HttpWebRequest.GetResponse no .NET Framework 1.x, onde o método síncrono era implementado como um wrapper em torno do assíncrono BeginGetResponse/EndGetResponse.

Orientação: Evite expor wrappers síncronos

Não exponhas um método síncrono que envolve uma implementação assíncrona. Em vez disso, deixe a decisão de bloquear ou não ao consumidor. O consumidor conhece o seu ambiente de execução de threads e pode tomar uma decisão informada.

Se precisares de chamar um método assíncrono de forma síncrona, considera primeiro se podes reestruturar o código para ser "assíncrono até ao fim". A refatoração é frequentemente a melhor solução a longo prazo.

Estratégias de mitigação quando o sync-over-async é inevitável

Por vezes, sincronizar sobre assíncrono é mesmo inevitável. Por exemplo, é inevitável quando se implementa uma interface que requer um método síncrono, e a única implementação disponível é assíncrona. Nesses casos, aplique as seguintes estratégias para reduzir o risco.

Utilização ConfigureAwait(false) na implementação assíncrona

Se controlares o método assíncrono, usa Task.ConfigureAwait com false em cada await para evitar que a continuação seja remetida ao 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 autor de biblioteca, use ConfigureAwait(false) em todas as operações de espera, a menos que o teu código precise especificamente de executar novamente no contexto capturado. O uso de ConfigureAwait(false) é a melhor prática em termos de desempenho e ajuda a evitar deadlocks quando os consumidores estão bloqueados.

Transferir para o pool de threads

Se não controlares a implementação assíncrona (e pode não ser usada ConfigureAwait(false)), descarrega a chamada para o pool de threads. O pool de threads não tem um SynchronizationContext, por isso o await não tentará redirecionar para uma thread bloqueada:

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

Teste em múltiplos ambientes

Se tiver de enviar um wrapper síncrono, teste-o a partir de:

  • Um tópico de interface (WPF, Windows Forms).
  • O pool de threads em carga.
  • O pool de threads com um número máximo de threads baixo.
  • Uma aplicação de consola.

Comportamento que funciona num ambiente pode entravar noutro.

Consulte também