Wrappers síncronos para métodos assíncronos

Quando uma biblioteca expõe apenas APIs assíncronas, os consumidores às vezes os encapsulam em chamadas síncronas para satisfazer uma interface ou contrato síncrono. Esse padrão "sync-over-async" pode parecer simples, mas é uma fonte comum de deadlocks e problemas de desempenho.

Padrões básicos de encapsulamento

Um wrapper síncrono em torno de um método TAP (Padrão Assíncrono baseado em tarefa) acessa a propriedade tarefa Result, o que bloqueia o thread de chamada.

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

Essa abordagem parece simples, mas pode causar sérios problemas dependendo do ambiente em que ela é executada.

Deadlocks com contextos de thread único

O cenário mais perigoso ocorre quando você chama um wrapper síncrono de um thread que tem um thread SynchronizationContextúnico. Esse cenário normalmente é um thread de interface do usuário em aplicativos 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

Veja o que acontece passo a passo:

  1. A thread da interface do usuário chama Delay, que chama DelayAsync(milliseconds).Wait().
  2. DelayAsync é executado de forma síncrona até atingir await Task.Delay(milliseconds).
  3. Como o atraso ainda não foi concluído, await captura o atual SynchronizationContext e suspende. DelayAsync retorna um Task para o chamador.
  4. O thread da interface do usuário bloqueia .Wait(), aguardando a conclusão dessa tarefa.
  5. Quando o atraso for concluído, a continuação precisará ser executada no SynchronizationContext original, que é a thread de UI.
  6. A thread de interface do usuário não pode processar a continuação porque está bloqueada em .Wait().
  7. Deadlock.

Importante

O êxito ou falha do código sincronizado sobre assíncrono depende do ambiente em que ele é executado. O código que funciona em um aplicativo de console pode ficar em deadlock em um thread de interface do usuário ou em ASP.NET (no .NET Framework). Essa dependência ambiental é um motivo central para evitar expor wrappers síncronos.

Esgotamento do pool de threads

Deadlocks não se limitam a threads da interface do usuário. Se um método assíncrono depender do pool de threads para concluir seu trabalho, por exemplo, enfileirando uma etapa de processamento final, bloquear muitos threads do pool com wrappers síncronos pode esgotar os recursos do 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. Muitas threads do pool fazem chamadas para Foo, que bloqueiam em .Result.
  2. Cada operação assíncrona conclui sua E/S e precisa de uma thread do pool para executar seu retorno de chamada de conclusão.
  3. Como as chamadas bloqueadas ocupam os threads de trabalho disponíveis, as conclusões podem esperar muito tempo até que um thread fique disponível.
  4. O .NET moderno pode adicionar mais threads ao pool de threads ao longo do tempo, mas o aplicativo ainda pode sofrer esgotamento severo de threads do pool, baixa taxa de transferência, longos atrasos ou um bloqueio aparente.

Esse padrão afetou HttpWebRequest.GetResponse no .NET Framework 1.x, em que o método síncrono foi implementado como um encapsulamento do método assíncrono BeginGetResponse/EndGetResponse.

Diretriz: evite expor os wrappers síncronos

Não exponha um método síncrono que encapsula uma implementação assíncrona. Em vez disso, deixe a decisão de bloquear ao consumidor. O consumidor conhece seu ambiente de threading e pode fazer uma escolha informada.

Se você precisar chamar um método assíncrono de forma síncrona, considere primeiro se pode reestruturar o código para ser "assíncrono até o fim". A refatoração geralmente é a melhor solução de longo prazo.

Estratégias de mitigação quando a sincronização sobre assíncrono é inevitável

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

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

Se você controlar o método assíncrono, use Task.ConfigureAwait, com false em cada await, para impedir que a continuação realize marshaling de volta para o 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 todos os awaits, a menos que seu código precise continuar execuções especificamente no contexto capturado. O uso de ConfigureAwait(false) é uma prática recomendada para melhorar o desempenho e ajuda a prevenir deadlocks quando os consumidores estão bloqueados.

Descarregar no pool de threads

Se você não controlar a implementação assíncrona (e ela pode não usar ConfigureAwait(false)), delegue a chamada para o pool de threads. O pool de threads não tem um SynchronizationContext, portanto, o await não tentará fazer marshalback para um thread bloqueado:

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

Testar em vários ambientes

Se você precisar enviar um wrapper síncrono, teste-o a partir de:

  • Um thread de interface do usuário (WPF, Windows Forms).
  • O pool de threads em condição de carga.
  • O pool de threads com uma contagem máxima baixa de threads.
  • Um aplicativo de console.

O comportamento que funciona em um ambiente pode entrar em deadlock em outro.

Consulte também