DEV Community

Cover image for Busy waiting, falhas que derrubam o worker e estado de job: armadilhas ao projetar o loop de processamento
Paulo Walraven
Paulo Walraven

Posted on

Busy waiting, falhas que derrubam o worker e estado de job: armadilhas ao projetar o loop de processamento

"Pega trabalho, processa, repete." O loop de um Worker Service parece a coisa mais simples do mundo — até ele ir para produção e queimar 100% de CPU sem ter nada para fazer, ou cair inteiro porque um job lançou uma exceção. O loop trivial é uma ilusão.

Neste post você vai percorrer as três armadilhas clássicas ao projetar esse loop — busy waiting, falhas mal tratadas e estado de job inconsistente — e ver a correção idiomática de cada uma. Spoiler: o lugar do try-catch e o lugar do decremento do contador fazem toda a diferença.

O ponto de partida: o padrão "recuperar → processar → repetir"

Quase todo worker segue o mesmo esqueleto. Ele recupera algum trabalho, processa, e repete:

while (!stoppingToken.IsCancellationRequested)
{
    var job = await jobProcessor.GetNextJob();
    await jobProcessor.ProcessJob(job);

    await Task.Delay(loopInterval, stoppingToken);
}
Enter fullscreen mode Exit fullscreen mode

Isso é frequentemente descrito como a fundação de qualquer sistema de processamento em background. Mas, sozinho, não é suficiente para produção. Vamos ver onde ele quebra.

Armadilha 1: busy waiting queimando CPU à toa

O primeiro problema é o busy waiting: quando não há trabalho disponível, o loop continua girando e consumindo CPU desnecessariamente. Você está pagando por ciclos de processador para... processar null.

A correção mais simples é só processar quando realmente existe um job:

var job = await jobProcessor.GetNextJob();

if (job is not null)
{
    await jobProcessor.ProcessJob(job);
}

await Task.Delay(loopInterval, stoppingToken);
Enter fullscreen mode Exit fullscreen mode

Repare: não estamos introduzindo um delay novo — já temos o Task.Delay do fim do loop. Estamos apenas evitando processar à toa quando não há nada. Sem job, o worker dá sua soneca habitual e volta a checar.

A versão melhor: verifique quantos jobs existem

Se você consegue saber quantos jobs estão pendentes, dá para ser mais esperto e processá-los em lote — garantindo que só trabalha quando há trabalho:

var pendingJobs = await jobProcessor.GetTotalPendingJobs();

if (pendingJobs > 0)
{
    var job = await jobProcessor.GetNextJob();
    // processa...
}
Enter fullscreen mode Exit fullscreen mode

Contar jobs costuma ser uma operação barata (o tamanho de uma coleção ou de uma fila, por exemplo), então o overhead é baixo.

Mas atenção ao contexto — essa é a nuance que quase ninguém comenta:

  • Múltiplos workers compartilhando a fila: use uma checagem condicional simples. Se há 42 jobs e você pega um, não sabe quantos sobraram (outro worker pode ter pegado) — você teria que verificar de novo a cada vez.
  • Você é o único worker (a fila vive dentro do próprio serviço): aí não há risco da contagem mudar por baixo dos panos. Você pode processar em batch até esgotar:
var pendingJobs = await jobProcessor.GetTotalPendingJobs();

while (pendingJobs > 0)
{
    var job = await jobProcessor.GetNextJob();
    await jobProcessor.ProcessJob(job);
    pendingJobs--;
}

await Task.Delay(loopInterval, stoppingToken);
Enter fullscreen mode Exit fullscreen mode

A ideia: você acorda, processa os 42 jobs, dorme por dois segundos, volta e checa se surgiu mais alguma coisa. Esse modelo te deixa acelerar quando há trabalho acumulado — mas só é seguro se você for o único consumidor daquela fila. Com vários serviços competindo pelo mesmo conjunto centralizado de jobs, não use essa abordagem.

Armadilha 2: uma falha que derruba o worker inteiro

Aqui está a regra que você nunca pode violar: uma única falha no processamento jamais pode derrubar o worker inteiro.

Se um job lança uma exceção e você não trata, o loop morre e o processo cai — junto com todos os outros jobs que processariam normalmente. A solução básica é um try-catch ao redor do processamento específico, registrando a exceção:

while (pendingJobs > 0)
{
    var job = await jobProcessor.GetNextJob();

    try
    {
        await jobProcessor.ProcessJob(job);
    }
    catch (Exception ex)
    {
        logger.LogError(ex, "Falha ao processar o job");
    }

    pendingJobs--; // FORA do try — sempre decrementa
}
Enter fullscreen mode Exit fullscreen mode

Como não há UI em um worker, o log é a sua única fonte de verdade. Capturar a exceção e logá-la é o que te permite ver, depois, que um job específico falhou — enquanto o resto continua rodando.

O detalhe fatal: onde fica o decremento

Olhe de novo para o exemplo acima. O pendingJobs-- está fora do try. Isso é intencional e crítico.

Se você colocar o decremento dentro do try, e o job sempre falha, você nunca decrementa — e o loop fica preso para sempre com a mesma contagem, ou o contador sai de sincronia. Por isso: o try envolve só o trabalho que você quer proteger contra falha; o controle do loop fica de fora. Seja cuidadoso com essa fronteira.

Armadilha 3: estado de job inconsistente

A terceira armadilha é mais sutil: conforme processa, o worker precisa rastrear o estado do job — pendente, em progresso, completo, falho.

O padrão geral é marcar o job ao longo do ciclo de vida:

var job = await jobProcessor.GetNextJob();

try
{
    await jobProcessor.MarkJobInProgress(job);
    await jobProcessor.ProcessJob(job);
    await jobProcessor.MarkJobComplete(job);
}
catch (Exception ex)
{
    logger.LogError(ex, "Falha ao processar o job");
    await jobProcessor.MarkJobFailed(job);
}
Enter fullscreen mode Exit fullscreen mode

A ideia é simples: recuperou o job → marca como em progresso → processa → marca como completo. Se falhar, marca como falho no catch, mantendo o estado consistente. Você precisa ser bem cuidadoso ao atualizar esse estado, justamente para que uma falha não deixe um job pendurado em "em progresso" para sempre.

Máquina de estados de um job: pendente, em progresso, completo e falho

Não esqueça: respeite sempre o cancellation token

Por mais que pareça repetitivo, vale insistir: sempre respeite o CancellationToken. Poder sinalizar que algo foi cancelado é uma prática crucial em qualquer worker. Repare que ele aparece tanto na condição do while quanto no Task.Delay dos exemplos — é assim que o worker desliga de forma graciosa quando a aplicação está parando.

Conclusão

O loop "pega, processa, repete" é enganosamente simples. Em produção, ele exige três cuidados: não queimar CPU em busy waiting, isolar falhas para que um job ruim não derrube o worker e rastrear o estado do job de forma consistente — tudo isso respeitando o cancellation token. A mentalidade certa: um worker não é "só mais um loop em background", é um componente de sistema de longa duração que precisa se comportar de forma previsível.

Revise o loop do seu worker hoje: o try-catch está ao redor só do processamento? O decremento está fora dele? Se respondeu "não" para alguma, você já encontrou a próxima refatoração. No próximo post, vamos tirar o worker do localhost e colocá-lo para rodar em produção.

Top comments (0)