//JorgenHoc
← Todos os artigos
Async C#Por Jorge CalderónAtualizado 9 min read

Como Evitar Deadlocks Assíncronos em C#

Entenda por que deadlocks assíncronos acontecem em C# com .Result e .Wait(), como o SynchronizationContext os causa, e os padrões que os previnem completamente.

#csharp#async#dotnet

Deadlocks assíncronos são um dos bugs mais confusos em C#. A aplicação trava. Sem exceção. Sem erro. A requisição simplesmente nunca responde. Entender por que eles acontecem — e, mais importante, por que eles não acontecem em determinados contextos — é conhecimento essencial para todo desenvolvedor .NET.

O Cenário do Deadlock

Este código gera um deadlock no ASP.NET clássico e em aplicações de interface gráfica (WinForms, WPF):

// Em um controller ASP.NET clássico ou handler de evento WinForms
public ActionResult GetData()
{
    // .Result bloqueia o thread atual e aguarda a conclusão da task
    var data = FetchDataAsync().Result;
    return View(data);
}
 
private async Task<string> FetchDataAsync()
{
    // Este await captura o SynchronizationContext atual
    var result = await httpClient.GetStringAsync("https://api.example.com/data");
    return result;
}

A aplicação trava indefinidamente. Aqui está exatamente o motivo.

Este não é um cenário teórico: o travamento é reproduzido de forma determinística em samples/async-deadlocks-csharp — uma aplicação de console que instala um SynchronizationContext estilo UI de ~40 linhas (a mesma regra de um-único-thread que WinForms e WPF impõem), provoca o deadlock, o observa com um timeout em vez de travar, e depois verifica com asserções que cada rota de fuga deste artigo realmente escapa.

Por Que o Deadlock Acontece

O deadlock requer que duas condições ocorram simultaneamente:

  1. Existe um SynchronizationContext no thread que faz a chamada
  2. O thread que faz a chamada está bloqueado com .Result ou .Wait()

A sequência:

  1. GetData() executa no thread de requisição do ASP.NET (que possui um SynchronizationContext)
  2. FetchDataAsync() inicia e chega ao await httpClient.GetStringAsync(...)
  3. await captura o SynchronizationContext atual — ele registra "quando isso for concluído, retomar no contexto de requisição do ASP.NET"
  4. O controle retorna para GetData(), que chama .Resultisso bloqueia o thread de requisição do ASP.NET
  5. A chamada HTTP é concluída e tenta retomar FetchDataAsync() no contexto de requisição do ASP.NET
  6. Mas o contexto de requisição está ocupado pela chamada .Result bloqueada
  7. Deadlock. A task aguarda o thread; o thread aguarda a task.
Thread (bloqueado em .Result)
  └── aguardando FetchDataAsync ser concluída
        └── aguardando retomar NESTE thread
              └── mas este thread está bloqueado
                    └── DEADLOCK

O sample prova que essa espera mútua é o que realmente acontece, não apenas uma história plausível: quando o Wait com timeout finalmente libera o thread bloqueado, a continuação enfileirada executa e a task se completa sozinha, imediatamente. A task nunca esteve presa — só esperava pelo thread que esperava por ela.

Por Que o ASP.NET Core Não Gera Deadlock

O ASP.NET Core não possui SynchronizationContext. Esta é uma decisão de design deliberada. Quando await é concluído no ASP.NET Core, ele retoma em qualquer thread disponível do thread pool — não tenta voltar a um thread específico.

// Isso não gera deadlock no ASP.NET Core (mas ainda é uma má prática!)
[HttpGet]
public IActionResult GetData()
{
    var data = FetchDataAsync().Result; // Sem deadlock no Core — mas ainda desperdiça um thread
    return Ok(data);
}
⚠️

Mesmo que .Result não gere deadlock no ASP.NET Core, ele ainda bloqueia um thread do thread pool enquanto aguarda. Sob carga, isso esgota o thread pool. Nunca use .Result ou .Wait() em handlers de requisição do ASP.NET Core.

O Problema com SynchronizationContext

SynchronizationContext é uma abstração que controla onde o código é executado após a conclusão de uma operação assíncrona. Frameworks diferentes têm contextos diferentes:

ContextoOnde o código retoma após await
ASP.NET ClássicoThread de requisição original
WinFormsThread de UI
WPFThread de UI (Dispatcher)
ASP.NET CoreQualquer thread do thread pool (sem SynchronizationContext)
Aplicação consoleQualquer thread do thread pool (sem SynchronizationContext)
Testes xUnitContexto personalizado (pode gerar deadlock!)

ConfigureAwait(false) como Solução

ConfigureAwait(false) diz ao await para não capturar o SynchronizationContext:

private async Task<string> FetchDataAsync()
{
    // ConfigureAwait(false) — retomar em qualquer thread do thread pool
    var result = await httpClient.GetStringAsync("https://api.example.com/data")
                                 .ConfigureAwait(false);
    return result;
}

Agora a cadeia de deadlock está quebrada. Quando a chamada HTTP é concluída, ela retoma em um thread do thread pool (não no contexto de requisição capturado), então a chamada .Result bloqueada não está mais no caminho.

ConfigureAwait(false) em Código de Biblioteca

O código de biblioteca deve sempre usar ConfigureAwait(false). Bibliotecas não controlam o ambiente de chamada — sua biblioteca pode ser chamada a partir de WinForms, ASP.NET clássico, ou de qualquer lugar com um SynchronizationContext.

// Bom código de biblioteca — seguro para chamar de qualquer lugar
public static async Task<ApiResponse> GetApiDataAsync(string url)
{
    var response = await httpClient.GetAsync(url).ConfigureAwait(false);
    response.EnsureSuccessStatusCode();
    var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
    return JsonSerializer.Deserialize<ApiResponse>(content)!;
}

A Solução Real: Async do Começo ao Fim

ConfigureAwait(false) é um paliativo. A solução real é nunca bloquear em código assíncrono:

// INCORRETO
public ActionResult GetData()
{
    var data = FetchDataAsync().Result; // Bloqueante
    return View(data);
}
 
// CORRETO — async do começo ao fim
public async Task<ActionResult> GetDataAsync()
{
    var data = await FetchDataAsync(); // Não bloqueante
    return View(data);
}

Isso requer tornar toda a cadeia de chamadas assíncrona. Cada método que chamar um método assíncrono deve ser assíncrono também. Este é o princípio do "async do começo ao fim".

Padrões Comuns de Deadlock

Padrão 1: .Result em um construtor

// DEADLOCK — construtores não podem ser async
public class DataCache
{
    private readonly List<Item> _items;
 
    public DataCache(IDataService service)
    {
        _items = service.GetItemsAsync().Result; // Gera deadlock em contexto síncrono
    }
}
 
// SOLUÇÃO — usar um método de fábrica
public class DataCache
{
    private readonly List<Item> _items;
    private DataCache(List<Item> items) => _items = items;
 
    public static async Task<DataCache> CreateAsync(IDataService service)
    {
        var items = await service.GetItemsAsync();
        return new DataCache(items);
    }
}

Padrão 2: .Wait() em um getter de propriedade

// DEADLOCK
public string CurrentUserName
{
    get => GetCurrentUserAsync().Result; // Gera deadlock em apps de UI
}
 
// SOLUÇÃO — não fazer chamadas assíncronas em getters de propriedades
// Use métodos async em vez disso, ou faça cache do valor
public async Task<string> GetCurrentUserNameAsync()
{
    return await _userService.GetCurrentUserAsync();
}

Padrão 3: async void com .Wait()

// PROBLEMÁTICO
private async void LoadData()
{
    var data = await FetchAsync(); // Ok
    Display(data);
}
 
// Chamando e aguardando
void OnButtonClick()
{
    LoadData();
    // Não é possível fazer await em async void — sem como saber quando termina
    UpdateUI(); // Executa antes de LoadData ser concluído
}

Padrão 4: Task.Run para contornar deadlocks

// Solução alternativa (não ideal, mas evita o deadlock)
public string GetData()
{
    // Task.Run executa em um thread do thread pool — sem SynchronizationContext
    var data = Task.Run(() => FetchDataAsync()).Result;
    return data;
}

Isso funciona porque Task.Run executa em um thread do thread pool que não possui SynchronizationContext. O await dentro de FetchDataAsync não tem nada para capturar, então a conclusão é postada no thread pool em vez de tentar retornar ao thread bloqueado.

⚠️

Usar Task.Run como contorno para deadlock é um code smell. Funciona, mas usa threads extras desnecessariamente. Corrija a causa raiz tornando o chamador async.

Quando Deadlocks Não Podem Ocorrer

Deadlocks só ocorrem quando AMBAS as condições são verdadeiras:

  1. Um SynchronizationContext está presente
  2. O thread com esse contexto está bloqueado

Sem possibilidade de deadlock em:

  • ASP.NET Core (sem SynchronizationContext)
  • Aplicações console (sem SynchronizationContext)
  • Threads do thread pool (sem SynchronizationContext)
  • Código que usa ConfigureAwait(false) em cada ponto de await

Deadlock possível em:

  • ASP.NET Clássico (handlers .aspx, Web API 2)
  • Handlers de eventos WinForms
  • Handlers de eventos WPF
  • Qualquer contexto onde SynchronizationContext.Current não seja null E você bloqueie o thread

O sample executa esta tabela como quatro asserções — mesmo método async, mesma chamada bloqueante, alternando um ingrediente por vez:

DemoContexto?Bloqueio?Resultado medido
Thread de console simplesnãosimcompleta
Thread estilo UIsimsimdeadlock (observado via timeout de 2s)
Thread estilo UI + ConfigureAwait(false)simsimcompleta
Thread estilo UI + Task.Runsimsimcompleta
Saída de console do sample de deadlock: o thread de console simples não tem SynchronizationContext e o bloqueio completa sem problema; o thread estilo UI entra em deadlock e o Wait esgota seus dois segundos, após o que a task se completa imediatamente quando o thread é liberado; ConfigureAwait(false) e Task.Run completam no mesmo thread estilo UI. Todas as verificações passaram.
A execução do sample: mesmo método async e mesma chamada bloqueante nos quatro demos — só os ingredientes mudam, e só a combinação contexto-mais-bloqueio gera deadlock.

Detectando Deadlocks

Quando uma aplicação trava sem exceção, verifique:

  1. Dump de threads — procure por threads aguardando em WaitOne, WaitAll ou Monitor.Wait enquanto outros threads estão na fila esperando para postar neles
  2. Visual Studio Parallel Stacks — mostra dependências entre threads, tornando deadlocks visíveis
  3. Adicione um timeout.Wait(TimeSpan.FromSeconds(5)) retorna false após o timeout em vez de travar para sempre, permitindo que você lance uma exceção e torne o bug visível
// Versão de diagnóstico — lança exceção após o timeout em vez de travar
if (!FetchDataAsync().Wait(TimeSpan.FromSeconds(5)))
{
    throw new TimeoutException("FetchDataAsync atingiu o timeout — possível deadlock");
}

Esse padrão de timeout é exatamente como o sample observa seu deadlock deliberado sem nunca travar — e tem um bônus de diagnóstico: depois que o Wait esgotado libera o thread, uma task em deadlock por contexto se completa imediatamente, o que distingue esse deadlock de uma dependência genuinamente presa.

Resumo

CenárioO Que Fazer
Código de bibliotecaSempre use ConfigureAwait(false)
ASP.NET CoreAsync do começo ao fim (.Result não gera deadlock mas desperdiça threads)
ASP.NET ClássicoAsync do começo ao fim OU use ConfigureAwait(false) em todo o código
WinForms/WPFAsync do começo ao fim; não bloquear o thread de UI
ConstrutoresUsar métodos de fábrica estáticos em vez disso
Getters de propriedadesNão fazer chamadas assíncronas a partir de getters de propriedades

A regra de ouro: nunca bloqueie em código assíncrono. Se você se pegar recorrendo a .Result, .Wait() ou .GetAwaiter().GetResult(), torne o chamador async em vez disso.

Leituras adicionais

Sobre o autor

Jorge Calderón

Engenheiro de software com mais de uma década construindo e operando aplicações .NET em produção — camadas de dados com EF Core, serviços intensivos em async e implantações em Azure e contêineres. Todos os benchmarks e projetos de exemplo destes guias estão publicados em um repositório público no GitHub para que você possa reproduzi-los.

Perfil no GitHubLinkedIn ↗Benchmarks e código de exemplo

Artigos relacionados