Threads, ThreadPool e Starvation
Objective
Um programa .NET roda em threads do sistema operacional, mas a maior parte do
código nunca cria uma. O trabalho vai para o thread pool, um conjunto
compartilhado de threads que o runtime aumenta e diminui sozinho. Isso é eficiente
até o pool ficar sem threads livres enquanto muitas requisições esperam por uma.
Aí a latência sobe, a CPU fica baixa e o serviço parece travado, embora não haja
nada de errado com o código que está preso. Este conceito cobre o que é uma
Thread, como o pool decide quantas threads rodar, por que bloquear em trabalho
async causa starvation e como ver isso acontecendo.
Use Cases
- Um serviço ASP.NET Core que é rápido com carga leve e dá timeout com carga moderada, com a CPU quase ociosa.
- Um app de console que nunca termina porque uma thread de trabalho ainda está rodando.
- Decidir se um laço de longa duração (um message pump, um leitor de socket) deve
usar
Task.Run,LongRunningou umaThreadprópria. - Ler a saída do
dotnet-countersdurante um incidente e saber se o thread pool é o gargalo.
Deep Dive
Uma Thread é uma thread do SO
new Thread(...) cria uma thread real do sistema operacional, com a própria pilha
(o tamanho de pilha padrão, que varia por plataforma e configuração, então criar
milhares é caro). Ela é
foreground por padrão, e o processo não termina até que todas as threads
foreground tenham acabado:
csharpvar worker = new Thread(() =>
{
Thread.Sleep(5_000);
Console.WriteLine("worker done");
});
// worker.IsBackground = true; // sem esta linha, o retorno de Main não encerra o processo
worker.Start();
Console.WriteLine("main done"); // o processo continua vivo por mais uns 5 segundosDefinir IsBackground = true faz a thread morrer junto com o processo, o que é
certo para auxiliares e errado para trabalho que precisa terminar. As threads do
thread pool são sempre background, e por isso um Task.Run ainda em execução não
mantém o processo vivo.
Uma exceção não tratada numa Thread derruba o processo inteiro. Dentro de uma
Task, a exceção fica guardada na task e só aparece quando alguém dá await nela,
então uma task esquecida falha em silêncio:
csharpnew Thread(() => throw new InvalidOperationException("boom")).Start(); // o processo cai
_ = Task.Run(() => throw new InvalidOperationException("boom")); // engolida se ninguém observarO thread pool e a injeção gradual
Task.Run, continuações de async, laços Parallel e timers enfileiram trabalho
no mesmo pool. O pool começa com um número mínimo de threads, por padrão o número de
processadores, e cria essas sob demanda sem atraso. Acima desse mínimo ele adiciona
threads devagar, usando um algoritmo de hill climbing que tenta mais uma thread, mede
a vazão e só a mantém se a vazão melhorou. A documentação diz apenas que o pool
cria e destrói threads para otimizar a vazão, e não informa uma taxa de injeção.
Espere uma subida lenta, da ordem de uma thread por segundo nas descrições comuns
do pool (meça, não é uma garantia documentada), quando a demanda passa do mínimo:
csharpThreadPool.GetMinThreads(out var minWorker, out var minIo);
ThreadPool.GetAvailableThreads(out var freeWorker, out var freeIo);
Console.WriteLine($"min {minWorker}, threads now {ThreadPool.ThreadCount}, " +
$"queued {ThreadPool.PendingWorkItemCount}");O crescimento lento é proposital: evita criar centenas de threads que brigam por poucos núcleos. É também o que transforma um pico curto de bloqueio num travamento longo.
Starvation por sync-over-async
Sync-over-async é chamar .Result, .Wait() ou .GetAwaiter().GetResult() numa
task a partir de uma thread que não pode continuar até ela terminar. Cada chamada
bloqueada mantém uma thread do pool parada, e a continuação que ela espera precisa
de uma thread do pool para rodar:
csharp// Chamado numa thread do pool a cada requisição.
public IActionResult Get()
{
var data = _client.GetStringAsync(url).Result; // bloqueia esta thread até o I/O terminar
return Ok(data);
}Com 8 núcleos, as 8 primeiras requisições simultâneas bloqueiam 8 threads. As
conclusões de I/O ficam na fila atrás delas, nenhuma thread está livre para
executá-las e o pool vai pingando novas threads no seu ritmo lento. O ASP.NET Core
não tem synchronization context, então isso não é o deadlock clássico, e sim
starvation: ela se resolve em algum momento, depois de um longo atraso. A correção é
continuar async até o topo (veja o conceito de .NET sobre internals de Task e
async/await):
csharppublic async Task<IActionResult> Get()
{
var data = await _client.GetStringAsync(url); // a thread volta ao pool enquanto espera
return Ok(data);
}LongRunning, e por que SetMinThreads não é remédio
Um laço que roda pela vida inteira do app ocuparia uma thread do pool para sempre,
reduzindo o que o pool pode oferecer a todo o resto. TaskCreationOptions.LongRunning
é uma dica de que superalocação (oversubscription) pode ser justificada, então o
scheduler padrão roda a tarefa numa thread dedicada em vez de uma thread do pool:
csharpvar reader = Task.Factory.StartNew(
() => ReadSocketForever(token),
token,
TaskCreationOptions.LongRunning,
TaskScheduler.Default);ThreadPool.SetMinThreads eleva o mínimo para que o pool crie threads imediatamente
até esse número. Pode encurtar um travamento, mas esconde o código bloqueante que o
causou, e as threads extras custam memória e trocas de contexto. Trate como uma
mitigação temporária enquanto você remove as chamadas bloqueantes.
Vendo acontecer
dotnet-counters monitor --process-id <pid> System.Runtime mostra os números que
importam: quantidade de threads do pool, tamanho da fila e itens de trabalho
concluídos. No .NET 9 e posteriores eles aparecem como
dotnet.thread_pool.thread.count, dotnet.thread_pool.queue.length e
dotnet.thread_pool.work_item.count. No .NET 8 e anteriores os nomes são
ThreadPool Thread Count, ThreadPool Queue Length e
ThreadPool Completed Work Item Count. Uma fila crescendo com a contagem de
threads subindo um passo de cada vez e a CPU baixa é a assinatura da starvation. Os
valores abaixo são ilustrativos:
textThreadPool Thread Count : 18 ThreadPool Queue Length : 412 CPU Usage (%) : 6
Trade-offs
- Uma
Threaddedicada custa uma pilha e um recurso do SO. É a escolha certa para um punhado de laços de longa duração e a errada para trabalho por requisição. UmaTaskno pool reaproveita threads e custa um objeto pequeno. LongRunningnão ajuda código async. A doc descreveLongRunningapenas como uma dica de agendamento. Na prática, uma lambdaasynciniciada com ele libera a thread dedicada no primeiroawaite o resto roda no pool (esse comportamento aparece em textos da comunidade, não na página oficial).csharp// A thread dedicada termina no primeiro await. O laço continua no pool. Task.Factory.StartNew(async () => { while (true) await Task.Delay(1000); }, TaskCreationOptions.LongRunning);- Threads background podem ser cortadas no meio do trabalho. Quando a última thread foreground termina, o processo sai e o runtime para as threads background sem lançar exceção nelas, então o código de limpeza dessas threads não tem garantia de rodar. Trabalho que precisa terminar exige uma thread foreground ou um shutdown gracioso.
SetMinThreadstroca latência por memória e esconde a causa. Elevá-lo a um valor grande faz o travamento sumir num teste e voltar com uma carga maior, com mais threads disputando os mesmos núcleos.- Embrulhar código bloqueante em
Task.Runsó muda o bloqueio de lugar. Libera a thread da requisição tomando outra do pool, então o pool continua com starvation, só que mais tarde. Use APIs async de verdade para I/O e deixeTask.Runpara trabalho limitado por CPU. - Os números mudam entre versões do runtime. A taxa de injeção e o mínimo padrão são detalhes do runtime. Meça no runtime que você publica e não fixe suposições sobre o atraso.