Task.Delay() é a escolha certa em quase todo código C# moderno: ele espera de forma assíncrona, sem bloquear a thread, então o pool de threads continua trabalhando enquanto o nosso código está pausado.
Thread.Sleep() bloqueia completamente a thread que faz a chamada. Ele só é aceitável em ferramentas de console, testes ou threads dedicadas em segundo plano. Em um método assíncrono, Thread.Sleep() é sempre um bug.
Como pausar a execução em C#?
O C# nos dá duas formas de pausar a execução, e a assinatura do método mostra qual delas estamos usando. Thread.Sleep(milliseconds) é a pausa síncrona: suspende a thread atual pelo tempo indicado, e essa thread não pode fazer mais nada até acordar. await Task.Delay(milliseconds) é a pausa assíncrona: devolve a thread ao pool imediatamente e retoma o nosso método depois que o tempo passa.
Os dois aceitam um TimeSpan, e os dois dependem do mesmo temporizador do sistema.
A regra prática é simples. Dentro de qualquer método marcado como async, sempre usamos await Task.Delay(). Em uma ferramenta de console síncrona ou em um teste, Thread.Sleep() não tem problema. Se fazemos pausas em um loop para verificar uma condição periodicamente, devemos recorrer a PeriodicTimer em vez disso.
Nenhum dos dois tem precisão de milissegundo, e o motivo é o relógio, e não o método. A referência de Thread.Sleep no Microsoft Learn explica assim: “O tempo limite real pode não ser exatamente o tempo limite especificado, porque o tempo limite especificado será ajustado para coincidir com os ticks do relógio.”
O que é Thread.Sleep()?
Thread.Sleep() é uma abordagem mais tradicional e pertence ao namespace System.Threading. Ele introduz um atraso ao bloquear a thread atual, o que significa que a thread inteira deixa de responder durante todo o período de pausa.
Todos os métodos deste artigo ficam dentro da classe Program de uma aplicação de console. Essa classe substitui o código do modelo em Program.cs, abaixo de uma linha using System.Diagnostics; para o Stopwatch que usamos mais adiante. Vamos colocar uma thread para dormir:
public static void UseThreadSleep(int sleepMilliseconds = 2000)
{
Console.WriteLine($"Before sleep: Thread id = {Environment.CurrentManagedThreadId}");
Thread.Sleep(sleepMilliseconds);
Console.WriteLine($"After sleep: Thread id = {Environment.CurrentManagedThreadId}");
}
Criamos UseThreadSleep(), um método que exibe o ID da thread em execução. Em seguida, colocamos a thread para dormir por sleepMilliseconds (cujo valor padrão é de 2 segundos). Depois de esperar o tempo especificado, exibimos de novo o ID da thread em execução.
O que é Task.Delay()?
Task.Delay() faz parte da Task Parallel Library (TPL) do .NET e foi desenvolvido especialmente para a programação assíncrona. Ele permite introduzir um atraso sem bloquear a thread que faz a chamada. Isso é crucial em situações em que a capacidade de resposta é importante, como aplicações com interface gráfica ou operações no lado do servidor.
Vamos atrasar uma tarefa:
public static async Task UseTaskDelay(int delayMilliseconds = 2000)
{
Console.WriteLine($"Before delay: Thread id = {Environment.CurrentManagedThreadId}");
await Task.Delay(delayMilliseconds);
Console.WriteLine($"After delay: Thread id = {Environment.CurrentManagedThreadId}");
}
Criamos UseTaskDelay(), um método que também exibe o ID da thread antes e depois de um atraso de delayMilliseconds.

E-book gratuito
Sua Web API está pronta para produção?
33 itens para verificar antes de implantá-la, com a correção de cada um. Um PDF gratuito de 76 páginas para .NET 10.
O e-book está em inglês.
Baixe o checklist gratuitoPDF gratuito. Um único e-mail para enviá-lo. Cancele a inscrição quando quiser.
Task.Delay() retorna uma Task que precisamos aguardar. Sem o await, o nosso código segue em frente no instante em que Delay() é chamado.
Comparação entre Thread.Sleep() e Task.Delay()
Vamos usar os dois métodos:
private static async Task Main()
{
Console.WriteLine("Starting Thread.Sleep test...");
UseThreadSleep();
Console.WriteLine("Thread.Sleep test completed.\n");
Console.WriteLine("Starting Task.Delay test...");
await UseTaskDelay();
Console.WriteLine("Task.Delay test completed.");
}
Chamamos UseThreadSleep() e UseTaskDelay() dentro do nosso Main() e adicionamos alguns logs informativos no console.
Vejamos a saída:
Starting Thread.Sleep test... Before sleep: Thread id = 2 After sleep: Thread id = 2 Thread.Sleep test completed. Starting Task.Delay test... Before delay: Thread id = 2 After delay: Thread id = 5 Task.Delay test completed.
Antes de chamarmos UseThreadSleep(), o ID da thread é 2. Depois de esperar 2 segundos com Thread.Sleep(), o ID da thread continua sendo 2. Isso acontece porque Thread.Sleep() bloqueia a thread, e a thread continua a mesma.
Antes de chamarmos UseTaskDelay(), o ID da thread ainda é 2. Depois de esperarmos 2 segundos com Task.Delay(), o ID da thread muda para 5. Como Task.Delay() não é bloqueante e devolve o controle a quem fez a chamada, a continuação do método pode ser executada em outra thread. Por isso, o ID da thread pode ser diferente antes e depois do atraso. O valor depois do atraso é uma atribuição do pool de threads, e não uma garantia, então pode variar de uma execução para outra.
Por que Thread.Sleep() é perigoso no ASP.NET Core?
O ASP.NET Core atende cada requisição em uma thread do pool de threads, e o pool mantém prontas apenas algumas threads de trabalho, mais ou menos uma por núcleo de CPU. Quando um handler de requisição chama Thread.Sleep(), essa thread fica ociosa durante toda a pausa, mas o pool continua a contá-la como ocupada.
O pool até cresce, mas injeta novas threads de forma lenta e deliberada, então, sob carga, as requisições se acumulam na fila mais rápido do que chegam as threads substitutas. A latência sobe enquanto a CPU fica quase ociosa. Isso é o esgotamento do pool de threads (thread pool starvation).
await Task.Delay() evita esse problema por completo: a requisição em espera não ocupa nenhuma thread, então o mesmo pool pequeno continua atendendo outras requisições. O sintoma a observar em produção é o aumento do tempo de resposta com baixo uso de CPU. Essa combinação quase sempre significa que algo está bloqueando as threads do pool.
Podemos observar o mesmo esgotamento sem um servidor web, porque o ASP.NET Core despacha os handlers de requisição para o mesmo pool de threads que Task.Run() usa. O primeiro método abaixo reproduz o bug de propósito, por isso nunca deve aparecer em código real:
public static async Task<long> RunBlockingWorkAsync(int workItems, int milliseconds)
{
var stopwatch = Stopwatch.StartNew();
var blocking = Enumerable.Range(0, workItems)
.Select(_ => Task.Run(() => Thread.Sleep(milliseconds)));
await Task.WhenAll(blocking);
return stopwatch.ElapsedMilliseconds;
}
public static async Task<long> RunNonBlockingWorkAsync(int workItems, int milliseconds)
{
var stopwatch = Stopwatch.StartNew();
var waiting = Enumerable.Range(0, workItems)
.Select(_ => Task.Delay(milliseconds));
await Task.WhenAll(waiting);
return stopwatch.ElapsedMilliseconds;
}
Chamamos os dois no final de Main():
Console.WriteLine($"Processor count: {Environment.ProcessorCount}");
var blockingMs = await RunBlockingWorkAsync(50, 1000);
Console.WriteLine($"50 blocking items: {blockingMs} ms");
var nonBlockingMs = await RunNonBlockingWorkAsync(50, 1000);
Console.WriteLine($"50 non-blocking items: {nonBlockingMs} ms");
Os dois métodos iniciam as mesmas 50 esperas, e só o segundo termina no tempo que essas esperas realmente levam:
Processor count: 12 50 blocking items: 4031 ms 50 non-blocking items: 1009 ms
A execução bloqueante leva quatro vezes mais tempo nesta máquina de 12 núcleos, e a diferença aumenta à medida que adicionamos itens, porque o pool começa com uma thread de trabalho por núcleo e injeta substitutas lentamente. Nenhum processamento acontece durante esses segundos a mais. As threads simplesmente estão indisponíveis.

E-book gratuito
Sua Web API está pronta para produção?
33 itens para verificar antes de implantá-la, com a correção de cada um. Um PDF gratuito de 76 páginas para .NET 10.
O e-book está em inglês.
Baixe o checklist gratuitoPDF gratuito. Um único e-mail para enviá-lo. Cancele a inscrição quando quiser.
No ASP.NET Core, essas threads retidas são requisições que não podem ser atendidas. Aqui, Task.Run() faz o papel do despachador de requisições: mesmo pool, mesmo esgotamento, um único arquivo para executar tudo.
Thread.Sleep vs Task.Delay: qual usar?
Usamos Task.Delay() por padrão e Thread.Sleep() só quando houver um motivo concreto. Task.Delay() deve estar em todo método assíncrono, em todo handler do ASP.NET Core, em todo manipulador de eventos da interface do usuário e em qualquer lugar em que um CancellationToken precise poder interromper a espera.
Thread.Sleep() se justifica em exatamente três lugares: um utilitário de console síncrono, um teste que simula um trabalho lento e uma thread dedicada que nós mesmos criamos e que podemos bloquear.
Se o atraso estiver dentro de um loop (consultando uma fila, atualizando um cache, mantendo um heartbeat), não devemos usar nenhum dos dois. PeriodicTimer nos dá um tick aguardável e cancelável que não acumula desvio, porque mede de um tick a outro, e não a partir do fim do nosso trabalho. Ele faz parte do framework desde o .NET 6 e continua atual no .NET 10, a versão usada neste artigo. E, se algum dia virmos Thread.Sleep() dentro de um método marcado como async, não há o que ponderar. É um bug a corrigir.
| Critério | Task.Delay() | Thread.Sleep() |
|---|---|---|
| Bloqueia a thread | Não, devolve a thread ao pool | Sim, a thread fica sem fazer nada |
| Pode ser usado com await | Sim | Não (anula o propósito do async) |
| Cancelável | Sim (CancellationToken) | Não |
| Precisão do temporizador | ~15 ms no Windows (temporizador do sistema) | ~15 ms no Windows (o mesmo) |
| Processamento de requisições no ASP.NET Core | Seguro | Esgota o pool de threads |
| Onde usar | Qualquer código assíncrono | Aplicações de console, testes, threads dedicadas |
As próprias orientações da Microsoft sobre programação assíncrona chegam ao mesmo veredito desta tabela: a linha sobre continuar depois de um certo tempo coloca Thread.Sleep na coluna do código atual e await Task.Delay na coluna do que deve substituí-lo. Para a diferença entre esperar uma tarefa de forma síncrona e assíncrona, veja o artigo sobre a diferença entre await e Task.Wait; para os fundamentos da programação assíncrona com async e await, confira o nosso artigo aprofundado. Para o padrão de atraso em loop descrito acima, veja o guia sobre PeriodicTimer em C# e, para entender como o pool de threads funciona, o guia sobre tarefas vs threads.
O token vem de quem chama o loop: um BackgroundService o recebe do host, e o nosso exemplo de console cria um com um CancellationTokenSource.
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (await timer.WaitForNextTickAsync(cancellationToken))
{
await RefreshCacheAsync(); // runs every second, no drift, cancellable
}
Observe que WaitForNextTickAsync() é o que aguardamos em vez de fazer uma pausa: ele retorna true a cada tick e false apenas quando o temporizador é descartado, então o loop termina sozinho, sem nenhum controle extra. Cancelar o token encerra o loop de outra forma, lançando OperationCanceledException a partir do await, e cabe a quem chama tratar essa exceção. Uma restrição vem diretamente da referência da classe PeriodicTimer no Microsoft Learn, que diz que o temporizador é “destinado a ser usado por apenas um consumidor de cada vez”, então dois loops nunca devem aguardar o mesmo temporizador.
Conclusão
A escolha entre Thread.Sleep() e Task.Delay() depende do contexto da aplicação. Se estamos trabalhando com código assíncrono e precisamos manter a capacidade de resposta, optamos por Task.Delay(). Por outro lado, se estamos trabalhando com operações síncronas e não nos importamos em bloquear a thread atual, Thread.Sleep() pode ser mais adequado. Ao entender as sutilezas de cada método, podemos tomar decisões bem fundamentadas durante o desenvolvimento.
Testado com o .NET 10.0.10.