A maior e melhor Black Friday que já fizemos! Descontos a partir de 50% em até 12x sem juros - Garanta seu Desconto

Async await no C#: o que acontece com um await eterno

Async await no C#: o que acontece com um await eterno

O async await no C# esconde uma pergunta pouco discutida: o que acontece com um método que aguarda uma tarefa que nunca termina? Neste conteúdo, analisamos o experimento de Steven Giesel e mostramos por que o método some da memória sem executar o finally, quando ele vaza memória de verdade e o que muda com o runtime async do .NET 11.

O que acontece quando o async await nunca termina

Muita gente imagina que um await pendente mantém uma thread parada em algum lugar. Na prática, isso não acontece, porque o async await não bloqueia nenhuma thread enquanto espera.

Quando o método encontra um await sobre uma tarefa incompleta, ele registra uma continuação e devolve o controle a quem chamou. Assim, o restante do método fica guardado em um objeto, aguardando um sinal que talvez nunca chegue.

A pergunta, então, deixa de ser sobre threads e passa a ser sobre referências. Afinal, quem mantém esse objeto vivo na memória?

Por isso, a resposta depende de um detalhe que costuma passar despercebido: o grafo de referências que o async await monta por baixo dos panos.

Como o compilador transforma o async await

Antes do experimento, vale entender o que o compilador produz. Até o .NET 10, cada método marcado com async vira uma máquina de estados gerada automaticamente.

Essa máquina de estados guarda o ponto em que o método parou. Além disso, ela transforma em campos as variáveis locais que precisam sobreviver ao await.

Quando o método chega a um await sobre uma tarefa incompleta, o builder do método assíncrono agenda a continuação. Na documentação, esse papel aparece no método AwaitOnCompleted do AsyncTaskMethodBuilder, que agenda a máquina de estados para avançar quando o awaiter completar.

Em seguida, o método retorna uma Task para quem chamou. Essa Task representa todo o trabalho restante, inclusive o trecho que ainda não executou.

Repare que, nesse momento, a pilha de chamadas já não contém o método. Com isso, o estado do async await passa a existir apenas no heap, como qualquer outro objeto.

Esse é o ponto central do artigo. Se o estado vive no heap, ele segue as mesmas regras de qualquer objeto: fica vivo enquanto alguma raiz o alcança e pode ser coletado quando nenhuma raiz o alcança mais.

Montando o experimento

Para observar o comportamento, precisamos de duas peças. Primeiro, um método que espere para sempre; em seguida, uma forma de saber se ele ainda está vivo.

O método que espera para sempre

Um TaskCompletionSource que ninguém completa resolve a primeira parte. Dessa forma, o await fica pendente de forma permanente.

static async Task AwaitForever(Tracker tracker)
{
    var tcs = new TaskCompletionSource();
    try
    {
        // Ninguém nunca chama SetResult, então este await não termina
        await tcs.Task;
        Console.WriteLine($"Resumed {tracker.Name}");
    }
    finally
    {
        Console.WriteLine($"finally for {tracker.Name}");
    }
}

O método recebe um Tracker como parâmetro. Como ele é usado depois do await, o compilador precisa preservá-lo junto com o restante do estado do método.

Um rastreador com finalizador

A segunda parte é uma classe pequena com um finalizador. Quando o garbage collector coleta a instância, o finalizador imprime uma mensagem no console.

sealed class Tracker(string name)
{
    public string Name { get; } = name;

    // Executa quando o GC coleta a instância
    ~Tracker() => Console.WriteLine($"Tracker '{Name}' was collected");
}

Finalizadores raramente são uma boa ideia em código de produção. No entanto, aqui eles servem como instrumento de medição, e esse é justamente um dos poucos casos em que fazem sentido.

Em seguida, chamamos o método sem guardar a Task retornada e forçamos uma coleta completa.

// Dispara e esquece: ninguém guarda a Task retornada
_ = AwaitForever(new Tracker("fire-and-forget"));

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();

Por que o finally nunca executa no async await

A saída do experimento surpreende quem espera ver o bloco finally no console.

Tracker 'fire-and-forget' was collected
Done.

O Tracker foi coletado, ou seja, o método inteiro desapareceu da memória. Contudo, a mensagem do finally nunca apareceu.

Isso acontece porque o finally só executa quando o fluxo sai do bloco try. Como o await nunca retoma, o fluxo nunca chega a esse ponto.

Da mesma forma, um using não ajudaria. Afinal, o using é apenas açúcar sintático que o compilador transforma em um try com finally.

Na prática, isso significa que o async await não oferece garantia de limpeza quando a tarefa aguardada nunca completa. Coletar um objeto não é o mesmo que retornar dele.

Quem segura quem no async await

Um método que aguarda não está esperando em lugar nenhum. Nenhuma thread fica bloqueada, porque o await apenas entrega uma continuação para a tarefa e retorna.

Quando o compilador gera a máquina de estados, o await funciona como um recado à tarefa: chame esta continuação quando terminar. Depois disso, o método devolve o controle.

O grafo de referências, portanto, fica assim: o TaskCompletionSource aponta para a sua Task, a Task aponta para a continuação, a continuação aponta para a máquina de estados e a máquina de estados guarda as variáveis locais, como o tracker.

Diagrama do grafo de referências de um async await pendente: TaskCompletionSource, Task, continuação, máquina de estados e variáveis locais formam uma ilha sem nenhuma raiz do garbage collector apontando para ela
Sem stack, campo estático ou thread apontando para a cadeia, o GC trata tudo como lixo.

As setas apontam em uma única direção. Por isso, nada de fora aponta para essa pequena ilha de objetos: nenhum frame de pilha, nenhum campo estático e nenhuma thread.

Para o garbage collector, essa ilha é lixo como qualquer outro. Assim, ele coleta a cadeia inteira, e o finally nunca roda porque o método simplesmente deixa de existir.

Guardando a Task retornada

O cenário muda quando guardamos a Task devolvida pelo método. Nesse caso, nada é coletado.

var task = AwaitForever(new Tracker("kept task"));
Collect();

// O método continua pendente e vivo na memória
Console.WriteLine(task.Status); // WaitingForActivation
GC.KeepAlive(task);

No .NET moderno, a Task retornada é a caixa que guarda a máquina de estados. Em outras palavras, quem segura a Task segura o método inteiro, incluindo cada variável local que ainda será usada depois do await.

Esse detalhe explica por que o async await raramente causa problemas visíveis no dia a dia. Normalmente, alguém guarda a tarefa e, mais cedo ou mais tarde, ela termina.

Vale notar a diferença entre os dois cenários. No primeiro, o descarte com _ = cortou a única referência externa; no segundo, a variável task funciona como raiz enquanto estiver em uso.

Esse mesmo raciocínio vale para coleções. Uma lista de tarefas pendentes guardada em um serviço singleton mantém vivas todas as máquinas de estado que ela referencia, mesmo que nenhuma delas jamais termine.

Quando a espera eterna vira vazamento de memória

O problema real aparece quando algo com vida longa guarda a tarefa pendente. Um SemaphoreSlim estático que ninguém libera ilustra bem esse caso.

static class Gate
{
    // Ninguém nunca chama Release
    public static readonly SemaphoreSlim Semaphore = new(0);
}

static async Task ForeverSemaphore()
{
    var buffer = new byte[100];
    await Gate.Semaphore.WaitAsync();
    GC.KeepAlive(buffer);
}

O semáforo mantém uma lista de quem está esperando. Cada item da lista aponta para uma continuação, e cada continuação aponta para a sua máquina de estados.

Como o semáforo é estático, ele funciona como uma raiz para o garbage collector. Consequentemente, toda a cadeia continua alcançável e nada é coletado.

O autor comparou as duas versões chamando cada método um milhão de vezes. O resultado mostra a diferença de forma clara.

Gráfico de barras comparando a memória retida após um milhão de chamadas de async await pendentes: zero megabytes com TaskCompletionSource local e 305 megabytes com SemaphoreSlim estático
A mesma espera eterna produz resultados opostos dependendo de quem guarda a continuação.

Com o TaskCompletionSource local, a memória retida fica em zero. Por outro lado, com o semáforo estático, cerca de 305 MB continuam vivos.

A lição é direta: o async await pendente só vaza memória quando uma raiz de vida longa alcança a continuação. Portanto, vale revisar semáforos, filas, eventos e caches estáticos que acumulam esperas sem fim.

Task.Delay infinito no async await

O Task.Delay traz uma variação curiosa do mesmo tema. Considere estas duas esperas.

await Task.Delay(Timeout.InfiniteTimeSpan); // A: espera infinita
await Task.Delay(TimeSpan.FromDays(1));     // B: espera de um dia

No caso A, o Tracker é coletado. Já no caso B, nada acontece durante o período de espera.

A explicação está na implementação. O Task.Delay usa um timer compartilhado, mas, quando a espera é infinita, o .NET não agenda nada nesse timer.

Sem o timer apontando para a tarefa, a cadeia volta a ser uma ilha isolada. Dessa forma, o garbage collector coleta tudo, exatamente como no experimento com o TaskCompletionSource.

No caso B, por outro lado, o timer guarda a tarefa até o prazo vencer. Por isso, o método continua vivo durante um dia inteiro.

Existe ainda um limite prático para esperas longas. Segundo a documentação do Task.Delay, a chamada lança ArgumentOutOfRangeException quando o valor passa de 4294967294 milissegundos, o que equivale a cerca de 49,7 dias.

Comparando os cenários do experimento

Juntando os resultados, fica fácil ver o padrão. A tabela resume quem segura a continuação em cada caso e o que acontece com o método.

Cenário Quem segura a continuação Resultado
TaskCompletionSource local, Task descartada Ninguém Método coletado, finally não executa
TaskCompletionSource local, Task guardada A variável que guarda a Task Método vivo enquanto a variável existir
SemaphoreSlim estático sem Release O semáforo estático Método vivo para sempre, memória retida
Task.Delay com espera infinita Ninguém Método coletado
Task.Delay de um dia O timer compartilhado Método vivo até o prazo vencer

Em todos os casos, a regra é a mesma. O async await pendente sobrevive exatamente enquanto alguma raiz alcança a sua continuação.

Ressuscitando o método com um finalizador

Sabemos que a máquina de estados só é alcançável pelo TaskCompletionSource. Também sabemos que finalizadores executam depois que o objeto fica inalcançável.

O autor então propõe um experimento provocativo: e se um finalizador completar a tarefa?

sealed class Necromancer(TaskCompletionSource tcs)
{
    // Ao ser coletado, completa a tarefa e retoma o método
    ~Necromancer() => tcs.SetResult();
}

static async Task Zombie()
{
    var tcs = new TaskCompletionSource();
    var necromancer = new Necromancer(tcs);
    try
    {
        await tcs.Task;
        Console.WriteLine("Braaaains!");
    }
    finally
    {
        GC.KeepAlive(necromancer);
    }
}

Quando o garbage collector encontra o Necromancer inalcançável, o finalizador chama SetResult. Com isso, a continuação executa, a mensagem aparece no console e o finally finalmente roda.

Obviamente, isso é uma curiosidade e não uma técnica para produção. Ainda assim, o experimento confirma que o método não estava preso a nenhuma thread, mas apenas a referências.

O runtime async do .NET 11 muda o async await?

Até o .NET 10, o compilador gera uma máquina de estados para cada método assíncrono. No .NET 11, porém, existe o runtime async, que transfere a suspensão e a retomada para o próprio runtime.

Segundo a documentação de novidades do runtime do .NET 11, o recurso produz stack traces mais limpos, melhor depuração e menor custo. Para ativá-lo em um projeto, basta incluir a propriedade abaixo.

<PropertyGroup>
  <Features>runtime-async=on</Features>
</PropertyGroup>

O autor repetiu todos os experimentos com o recurso ativado. O comportamento observado foi exatamente o mesmo.

Em outras palavras, a implementação mudou, mas a semântica do async await continua igual. Uma continuação que ninguém alcança continua sendo coletada, e uma continuação presa a uma raiz continua viva.

Boas práticas de async await em produção

Os experimentos deixam algumas lições práticas para quem escreve código assíncrono no dia a dia. A primeira delas vem diretamente da documentação oficial.

O guia Complete your tasks recomenda completar todo TaskCompletionSource exposto nos caminhos de sucesso, falha e cancelamento. Além disso, sugere usar TrySetResult, TrySetException e TrySetCanceled quando há risco de corrida.

Outra recomendação do guia trata de primitivas que podem ser reiniciadas. Antes de descartar a referência a um TaskCompletionSource, complete ou cancele a tarefa anterior.

public sealed class Signal
{
    private TaskCompletionSource<bool> _current = Create();

    public Task WaitAsync() => _current.Task;

    public void Pulse() => _current.TrySetResult(true);

    public void Reset()
    {
        // Troca de forma atômica e cancela quem esperava a tarefa antiga
        var previous = Interlocked.Exchange(ref _current, Create());
        previous.TrySetCanceled();
    }

    private static TaskCompletionSource<bool> Create() =>
        new(TaskCreationOptions.RunContinuationsAsynchronously);
}

Também vale não confiar em finally ou using para liberar recursos críticos depois de um await que pode nunca terminar. Nesse caso, prefira timeouts e tokens de cancelamento.

Por fim, observe raízes de vida longa. Semáforos estáticos, listas de assinantes e caches são os lugares onde o async await pendente transforma uma espera em vazamento.

Para fixar o básico de operações assíncronas antes de avançar nesses detalhes, vale conferir o artigo sobre como ler arquivos de forma assíncrona em C#.

Como diagnosticar um async await travado

O mesmo guia oficial sugere adicionar testes com timeout, para que travamentos falhem rápido no pipeline de integração contínua. Assim, um async await que nunca termina vira um teste vermelho, e não um processo parado.

Desde o .NET 6, o método WaitAsync permite aplicar um prazo a qualquer Task sem alterar o código que a produz.

[Fact]
public async Task Signal_ShouldComplete_WithinTimeout()
{
    var signal = new Signal();
    _ = Task.Run(() => signal.Pulse());

    // Falha com TimeoutException se a tarefa nunca completar
    await signal.WaitAsync().WaitAsync(TimeSpan.FromSeconds(5));
}

Em produção, a mesma ideia vale para chamadas a recursos externos. Portanto, combine timeouts com CancellationToken para que nenhuma espera dependa apenas da boa vontade de outro componente.

Além disso, ferramentas de diagnóstico de memória ajudam a encontrar continuações retidas. Quando a contagem de objetos de máquinas de estado cresce sem parar, existe uma raiz segurando esperas pendentes.

Conclusão

Um método que aguarda para sempre não ocupa thread nenhuma. Ele vive apenas enquanto alguma raiz alcança a sua continuação.

Quando nada alcança a cadeia, o garbage collector coleta o método inteiro, e o finally nunca executa. Quando um objeto de vida longa guarda a continuação, por outro lado, a espera eterna vira vazamento de memória.

Por isso, entender o async await como um grafo de referências ajuda a escrever código mais previsível. Complete suas tarefas, use cancelamento e desconfie de esperas sem prazo.

Vale lembrar também que o comportamento não depende da implementação. Com máquina de estados gerada pelo compilador ou com o runtime async do .NET 11, a regra continua sendo a alcançabilidade dos objetos.

FAQ

Um await que nunca termina bloqueia uma thread?

Não. O await registra uma continuação na tarefa e devolve o controle a quem chamou, sem bloquear nenhuma thread.

O bloco finally sempre executa em um método assíncrono?

Não. Se a tarefa aguardada nunca completa e o método é coletado, o fluxo nunca sai do try, então o finally não roda.

Um await pendente sempre causa vazamento de memória?

Não. Só há vazamento quando algo de vida longa, como um SemaphoreSlim estático, mantém a continuação alcançável.

Por que Task.Delay com Timeout.InfiniteTimeSpan permite a coleta?

Porque o .NET não agenda nada no timer para uma espera infinita, então nenhuma raiz aponta para a tarefa.

O runtime async do .NET 11 muda esse comportamento?

Não. A implementação passa para o runtime, mas o comportamento observado nos experimentos permanece o mesmo.

Glossário

Termo Significado
Continuação Trecho do método que executa depois que a tarefa aguardada termina.
Máquina de estados Estrutura gerada pelo compilador que guarda o ponto de execução e as variáveis de um método assíncrono.
TaskCompletionSource Tipo que cria uma Task controlada manualmente por SetResult, SetException ou SetCanceled.
Garbage collector Componente do runtime que libera objetos que nenhuma raiz consegue alcançar.
Raiz do GC Referência sempre alcançável, como campos estáticos, variáveis na pilha e threads ativas.
Finalizador Método executado pelo runtime antes de liberar a memória de um objeto coletado.
Runtime async Recurso do .NET 11 em que o runtime gerencia a suspensão e a retomada de métodos assíncronos.

Fontes