Start Debugging

Migrar de chamadas bloqueantes .Result/.Wait() para async em toda a cadeia em uma base de código C# legada

Um manual em etapas para remover sync-over-async de uma base de código .NET existente: inventariar com analisadores, medir a inanição do ThreadPool, converter uma cadeia de chamadas por vez e reduzir a contagem a zero no .NET 11.

Remover sync-over-async de uma base de código real não é um localizar e substituir. Reserve de um a três sprints para um serviço com algumas centenas de milhares de linhas, e espere que o trabalho tenha o formato de uma série de fatias verticais em vez de um único PR gigante. O que quebra são principalmente as assinaturas: todo método que deixa de bloquear precisa retornar Task, e isso se propaga para cima através de interfaces, construtores, Dispose, blocos lock e a superfície pública da sua API. Vale a pena fazer quando você está vendo inanição do ThreadPool sob carga ou deadlocks graves em uma thread de UI, e vale a pena adiar quando a chamada bloqueante está em uma ferramenta de linha de comando que roda uma vez e encerra. Este manual tem como alvo o .NET 11 (Microsoft.NET.Sdk 11.0.0, C# 14); todas as ferramentas mencionadas funcionam desde o .NET 6, com a etapa de rastreamento em runtime exigindo .NET 9 ou posterior.

Por que as chamadas bloqueantes precisam sair

O que quebra ao ir para assíncrono

ÁreaMudançaSeveridade
Superfície pública da APIT Get() vira Task<T> GetAsync(): quebra de código-fonte e binária para os consumidoresalta
Interfaces que não são suasUm método de interface de terceiros ou do framework não pode receber um tipo de retorno Taskalta
Construtores, getters de propriedadeNenhum dos dois pode ser async; o trabalho migra para um método de fábrica ou um inicializador preguiçosoalta
Instruções lockawait dentro de lock é o erro de compilação CS1996; exige SemaphoreSlimmédia
Tratamento de exceçõesAggregateException deixa de aparecer, então catch (AggregateException) para de casar silenciosamentemédia
TransactionScopeNão flui através de await a menos que seja construído com TransactionScopeAsyncFlowOption.Enabledmédia
IDisposableLimpeza assíncrona no Dispose precisa de IAsyncDisposable e await usingmédia
Suíte de testesMétodos de teste síncronos que chamam código agora assíncrono viram async Taskbaixa

As linhas de severidade alta são as que definem seu sequenciamento. Todo o resto é mecânico.

Checklist de preparação

Etapas da migração

  1. Monte o inventário com analisadores, não com grep.

    grep -r "\.Result" encontra acessos a propriedades em qualquer coisa chamada Result e perde completamente a E/S síncrona. Ative as duas regras que de fato entendem o padrão:

    # .editorconfig -- .NET 11 SDK 11.0.0
    [*.cs]
    # Avoid problematic synchronous waits (.Result, .Wait(), GetAwaiter().GetResult())
    dotnet_diagnostic.VSTHRD002.severity = warning
    # Call async methods when in an async method
    dotnet_diagnostic.VSTHRD103.severity = warning
    # Built-in equivalent; off by default through .NET 10
    dotnet_diagnostic.CA1849.severity = warning

    A distinção importa numa base de código legada. O CA1849 só dispara dentro de um método que retorna Task, então em código onde nada ainda é assíncrono ele reporta quase nada. O VSTHRD002 dispara na chamada bloqueante onde quer que ela esteja, que é exatamente a população que você está tentando contar.

    Verificação: compile a solution e conte as linhas VSTHRD002 na saída. Guarde esse número. Ele é o seu gráfico de queima.

  2. Capture uma linha de base sob carga antes de mudar uma linha.

    Rode seu teste de carga e observe o pool:

    dotnet-counters monitor -n YourApp System.Runtime

    No .NET 9 e posteriores, os contadores a ler são dotnet.thread_pool.thread.count, dotnet.thread_pool.queue.length e dotnet.thread_pool.work_item.count. O sinal de inanição é uma contagem de threads subindo lentamente enquanto a CPU fica bem abaixo de 100%. Uma contagem que estabiliza acima de aproximadamente três vezes o número de processadores significa que o código está bloqueando threads do pool e o runtime está compensando criando mais.

    Verificação: registre a contagem de threads estabilizada, a latência p95 e as requisições por segundo. Você vai comparar com esses valores na etapa de verificação.

  3. Encontre as chamadas bloqueantes que a análise estática não enxerga.

    Analisadores não conseguem sinalizar File.ReadAllText, SqlCommand.ExecuteReader ou um SemaphoreSlim.Wait() enterrado em uma dependência da qual você não tem o código-fonte. O .NET 9 adicionou o evento WaitHandleWait exatamente para isso:

    dotnet trace collect -n YourApp --clrevents waithandle --clreventlevel verbose --duration 00:00:30

    Abra o arquivo .nettrace resultante no PerfView ou no .NET Events Viewer da comunidade e expanda as pilhas WaitHandleWaitStart. Qualquer pilha cujos frames de base mencionem ThreadPoolWorkQueue.Dispatch ou WorkerThread.WorkerThreadStart é uma thread do pool sendo bloqueada, e o frame acima da espera nomeia o seu método.

    Verificação: cada pilha do trace ou corresponde a um ponto de chamada que já está no inventário da etapa 1, ou é adicionada a ele.

  4. Converta uma cadeia de chamadas de ponta a ponta, não um arquivo.

    Escolha o único ponto de entrada mais quente da etapa 3. Comece pela folha (o método que de fato chama HttpClient ou o EF Core), dê a ele um gêmeo assíncrono e suba a pilha convertendo cada chamador até chegar a um método que possa fazer await sem ter um chamador próprio: uma action de controller, um BackgroundService.ExecuteAsync, um manipulador de eventos ou Main.

    // .NET 11, C# 14 -- before: the block is three frames below the controller
    public IActionResult GetOrder(int id)
    {
        var order = _repository.Get(id);          // sync wrapper
        return Ok(order);
    }
    
    // after: no wrapper, no block, Task all the way to the framework
    public async Task<IActionResult> GetOrderAsync(int id, CancellationToken ct)
    {
        var order = await _repository.GetAsync(id, ct);
        return Ok(order);
    }

    Conversão parcial é pior do que nenhuma nesse caminho. Um único .Result restante em qualquer ponto do trecho síncrono reintroduz tanto o deadlock quanto a thread estacionada, então uma fatia só está pronta quando alcança um ponto de entrada.

    Verificação: rode de novo o trace da etapa 3 contra apenas aquele endpoint. Zero eventos WaitHandleWait em threads do pool para aquela pilha.

  5. Apague o gêmeo síncrono em vez de manter os dois.

    O atalho tentador é deixar Get() no lugar como GetAsync().GetAwaiter().GetResult() para que nada mais precise mudar. Esse é o wrapper síncrono contra o qual Stephen Toub argumenta em Should I expose synchronous wrappers for asynchronous methods?, e numa migração ele é ativamente prejudicial: o wrapper é onde as chamadas bloqueantes restantes se escondem, e ele permite que os chamadores escapem do trabalho para sempre.

    Se você realmente tem um consumidor síncrono e outro assíncrono e não pode abrir mão de nenhum, use o padrão de argumento de flag que a BCL usa em vez de um wrapper:

    // .NET 11, C# 14 -- one implementation, two entry points, no sync-over-async
    public int Read(byte[] buffer) => ReadCoreAsync(buffer, sync: true).GetAwaiter().GetResult();
    public Task<int> ReadAsync(byte[] buffer) => ReadCoreAsync(buffer, sync: false);
    
    private async Task<int> ReadCoreAsync(byte[] buffer, bool sync)
    {
        // Every I/O call inside branches on `sync`, so the synchronous path
        // never awaits an incomplete task and cannot deadlock.
        return sync ? _stream.Read(buffer) : await _stream.ReadAsync(buffer);
    }

    Verificação: o ponto de entrada síncrono não aparece mais num trace WaitHandleWait, porque ele nunca espera por uma tarefa incompleta.

  6. Trate as costuras que realmente não podem ser assíncronas.

    Três aparecem em toda migração. Um construtor não pode ser async, então mova a inicialização para uma fábrica estática (public static async Task<Foo> CreateAsync()) ou para um campo Lazy<Task<T>> que os chamadores aguardam. Um Dispose que faz limpeza assíncrona deve implementar IAsyncDisposable e ser consumido com await using. Um bloco lock contendo trabalho assíncrono novo falha ao compilar com CS1996, porque um monitor precisa ser liberado na mesma thread que o adquiriu:

    // .NET 11, C# 14 -- lock cannot span an await; SemaphoreSlim can
    private readonly SemaphoreSlim _gate = new(1, 1);
    
    public async Task<Config> LoadAsync(CancellationToken ct)
    {
        await _gate.WaitAsync(ct);
        try { return _cached ??= await FetchAsync(ct); }
        finally { _gate.Release(); }
    }

    Verificação: o projeto compila sem CS1996 e sem novos async void fora de manipuladores de eventos.

  7. Propague o CancellationToken enquanto as assinaturas já estão abertas.

    Adicionar CancellationToken ct = default não custa nada em uma assinatura que você já vai mudar, e é doloroso adaptar depois. Passe-o para cada chamada assíncrona da cadeia, não só para a mais externa, seguindo as regras de propagar um CancellationToken através de métodos assíncronos.

    Verificação: cancele uma requisição em pleno voo (derrube a conexão do cliente) e confirme que a chamada ao banco de dados é de fato abandonada em vez de rodar até o fim.

  8. Trave o analisador como catraca para que a contagem só possa cair.

    Assim que um projeto chega a zero, trave-o:

    <!-- Directory.Build.props -- .NET 11 SDK 11.0.0 -->
    <PropertyGroup>
      <TreatWarningsAsErrors>false</TreatWarningsAsErrors>
      <WarningsAsErrors>$(WarningsAsErrors);VSTHRD002;CA1849</WarningsAsErrors>
    </PropertyGroup>

    Para projetos ainda em meio à migração, mantenha as regras em warning e faça o CI falhar diante de um aumento na contagem em vez de diante de qualquer aviso. Uma catraca que bloqueia dívida nova enquanto a dívida antiga é queimada é a única versão disso que os times realmente mantêm.

    Verificação: adicione um .Result proposital em um projeto já convertido e confirme que a build falha.

Verificando se a migração realmente funcionou

Assinaturas compilando não são evidência. Rode o mesmo teste de carga da etapa 2 e compare quatro números:

Depois rode as verificações funcionais: dotnet test com zero falhas, um teste de cancelamento que prove que uma desconexão do cliente aborta a chamada downstream, e uma passada manual sobre qualquer bloco catch (AggregateException) no código tocado, já que eles não casam mais com nada depois que as chamadas bloqueantes somem.

Plano de rollback

Fatia a fatia, esta migração reverte de forma limpa: cada fatia vertical é um PR autocontido, e revertê-lo restaura a chamada bloqueante e suas assinaturas. Esse é o principal argumento para fatiar por cadeia de chamadas em vez de por camada.

O que não reverte de forma limpa é uma biblioteca publicada. Mudar T Get() para Task<T> GetAsync() é uma quebra binária para todo consumidor que compilou contra o assembly antigo, então para um pacote NuGet isso é uma migração de versão maior e a reversão precisa ser um novo release, não um git revert. Decida antes de começar se o pacote entrega as duas superfícies por uma versão maior (usando o padrão de argumento de flag da etapa 5, nunca um wrapper síncrono) ou se quebra de uma vez.

Armadilhas que nos custaram tempo

async void volta escondido através de lambdas. Uma lambda passada para um parâmetro do tipo Action vira async void, então exceções dentro dela derrubam o processo em vez de aparecerem em uma tarefa. List<T>.ForEach(async x => ...) e Parallel.ForEach com um corpo assíncrono são os dois portadores comuns. O VSTHRD101 pega o caso do delegate; a fronteira entre uso legítimo e quebrado está em quando async void é correto e quando é uma armadilha.

.Select(async x => ...) produz IEnumerable<Task>, não resultados. Compila, parece convertido, e nada o aguarda. Complemente com await Task.WhenAll(...) ou mude a enumeração para IAsyncEnumerable.

TransactionScope para de fluir silenciosamente. O construtor padrão não propaga a transação ambiente através de um await, então o código depois do primeiro await roda fora da transação sem nenhum erro. Construa-o com TransactionScopeAsyncFlowOption.Enabled.

O ASP.NET Core lança exceções antes de você terminar. Converter as camadas externas pode revelar InvalidOperationException: Synchronous operations are disallowed vindo de um Stream.Read síncrono mais abaixo, porque AllowSynchronousIO é false por padrão. Essa exceção é um mapa do trabalho restante, não um motivo para religar a chave; os detalhes estão em como corrigir synchronous operations are disallowed.

Bloquear um ValueTask é comportamento indefinido, não apenas lento. Se uma folha convertida retorna ValueTask<T> e algum chamador acima ainda bloqueia, .Result sobre ele é comportamento indefinido, não só risco de deadlock. Converta com .AsTask() nessa fronteira até o chamador estar pronto, e leia as restrições em o que o ValueTask custa a você.

Não use ConfigureAwait(false) como substituto de terminar o trabalho. Ele desarma o deadlock dentro de uma biblioteca que você controla, mas não faz nada quanto à thread estacionada, e no ASP.NET Core não há contexto do qual sair de qualquer forma. É uma mitigação para código que você não pode mudar, não uma estratégia de migração.

A medida de sucesso não é a contagem do analisador chegar a zero. É a contagem de threads do pool parar de subir sob carga, e uma requisição cancelada agora de fato cancelar alguma coisa.

Relacionados

Fontes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Voltar