Introdução

Em aplicações corporativas com alto volume de processamento, nem tudo pode — ou deve — acontecer dentro de um request HTTP. Importações massivas, consumo de filas de mensageria, sincronizações periódicas, envio de notificações em lote, processamento de eventos de domínio — esses cenários exigem processos em background que executam continuamente, independente de requests, e que sobrevivem a reinícios de forma controlada.

O .NET 8+ oferece uma infraestrutura robusta para isso: os Worker Services e a classe BackgroundService. Baseados no Generic Host (IHost), eles compartilham o mesmo pipeline de injeção de dependências, configuração e logging das Web APIs — mas sem o overhead do Kestrel e do middleware HTTP. Isso significa que tudo o que você já sabe sobre IOptions<T>, ILogger<T>, IServiceProvider e ciclos de vida de DI se aplica diretamente aos Workers.

Neste artigo, vamos explorar os tipos de Workers, como os ciclos de vida Singleton, Scoped e Transient se comportam nesse contexto (com armadilhas que pegam muitos desenvolvedores de surpresa), e como Workers se integram nativamente com paginação de grandes volumes, mensageria para desacoplamento, paralelismo com Parallel e Tasks e programação assíncrona com async/await.

💡 Dica: Se sua aplicação hoje usa um Task.Run() dentro de um controller para “processar em background”, este artigo vai mostrar a abordagem correta e production-ready para esse cenário.

📦 Código-fonte: A implementação completa deste artigo está no repositório blog-zocateli-sample no GitHub. Clone, explore e adapte ao seu contexto.


O Que É um .NET Worker Service

Um Worker Service é uma aplicação .NET sem interface web, projetada para executar tarefas em background de forma contínua ou periódica. Ele usa o Generic Host — a mesma infraestrutura que uma Web API usa internamente — mas sem o servidor HTTP (Kestrel).

Criando um Worker Service

1
2
3
4
5
6
7
8
9
# Criar um novo projeto Worker
dotnet new worker -n MeuWorker -o src/MeuWorker

# Estrutura gerada:
# src/MeuWorker/
# ├── Program.cs           ← registro do host e DI
# ├── Worker.cs            ← classe BackgroundService
# ├── appsettings.json     ← configuração (mesmo pipeline das Web APIs)
# └── MeuWorker.csproj
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Program.cs — ponto de entrada do Worker
var builder = Host.CreateApplicationBuilder(args);

// Mesma configuração de uma Web API:
// appsettings.json → appsettings.{Env}.json → User Secrets → Env Vars → CLI args
// Para detalhes do pipeline de configuração, veja:
// /posts/2026/pipeline-configuracao-dotnet8-ioptions-secrets-docker/

builder.Services.AddHostedService<MeuWorker>();

var host = builder.Build();
host.Run();
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Worker.cs — BackgroundService padrão
public class MeuWorker(ILogger<MeuWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            logger.LogInformation("Worker executando em: {Time}", DateTimeOffset.Now);
            await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
        }
    }
}

Generic Host vs WebApplication

AspectoHost.CreateApplicationBuilderWebApplication.CreateBuilder
Pipeline de configuração✅ Idêntico✅ Idêntico
Injeção de dependências✅ Idêntico✅ Idêntico
Logging✅ Idêntico✅ Idêntico
IOptions✅ Idêntico✅ Idêntico
Servidor HTTP (Kestrel)❌ Não inclui✅ Inclui
Middleware pipeline❌ Não se aplica✅ Inclui
Endpoints / Controllers❌ Não se aplica✅ Inclui
Uso típicoBackground processing, filasAPIs HTTP, web apps

ℹ️ Informação: Como o pipeline de configuração é idêntico, tudo o que foi explicado em Pipeline de Configuração do .NET 8+ — ordem de providers, IOptions<T>, Docker secrets, AddKeyPerFile — se aplica diretamente aos Workers.


IHostedService vs BackgroundService

O .NET oferece duas abstrações para serviços em background. Entender a diferença é fundamental:

IHostedService — Controle Total

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class MeuServicoHosted : IHostedService, IDisposable
{
    private Timer? _timer;

    public Task StartAsync(CancellationToken cancellationToken)
    {
        // Executado UMA VEZ quando o host inicia
        _timer = new Timer(
            callback: ExecutarTarefa,
            state: null,
            dueTime: TimeSpan.Zero,
            period: TimeSpan.FromMinutes(5));

        return Task.CompletedTask;
    }

    private void ExecutarTarefa(object? state)
    {
        // Lógica executada a cada 5 minutos
        // ⚠️ CUIDADO: este callback roda numa thread do ThreadPool,
        // não em um contexto async — evite operações bloqueantes longas
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        // Executado UMA VEZ quando o host está parando
        _timer?.Change(Timeout.Infinite, 0);
        return Task.CompletedTask;
    }

    public void Dispose() => _timer?.Dispose();
}

BackgroundService — Abstração Simplificada

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public class MeuWorker(ILogger<MeuWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // Roda em loop enquanto a aplicação estiver ativa
        // O CancellationToken é disparado automaticamente no shutdown
        while (!stoppingToken.IsCancellationRequested)
        {
            await ProcessarAsync(stoppingToken);
            await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
        }
    }

    private async Task ProcessarAsync(CancellationToken ct)
    {
        logger.LogInformation("Processando...");
        // Lógica assíncrona aqui
    }
}
AspectoIHostedServiceBackgroundService
ControleStartAsync + StopAsync manuaisExecuteAsync com loop automático
Timer/PollingImplemente manualmente com TimerUse Task.Delay dentro do loop
Cenário idealInicialização/cleanup complexosLoops de processamento contínuo
CancellationTokenRecebido em StartAsync/StopAsyncRecebido em ExecuteAsync
Herda deInterface (IHostedService)Classe abstrata (BackgroundService)

⚠️ Atenção: O ExecuteAsync do BackgroundService roda em background. Se ele lançar uma exceção não tratada antes do .NET 8, o host continuava rodando silenciosamente com o worker morto. A partir do .NET 8, exceções não tratadas no ExecuteAsync param o host inteiro por padrão (comportamento configurável via HostOptions.BackgroundServiceExceptionBehavior).


Tipos de Workers e Suas Aplicabilidades

Na prática corporativa, os Workers se dividem em padrões recorrentes. Escolher o tipo certo para cada cenário evita complexidade desnecessária:

1. Worker de Timer (Polling)

Executa uma tarefa em intervalos regulares. Ideal para sincronizações periódicas, limpeza de dados expirados e health checks:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class SincronizacaoWorker(
    ILogger<SincronizacaoWorker> logger,
    IServiceScopeFactory scopeFactory) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                using var scope = scopeFactory.CreateScope();
                var servico = scope.ServiceProvider
                    .GetRequiredService<ISincronizacaoService>();

                await servico.SincronizarAsync(stoppingToken);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                logger.LogError(ex, "Erro na sincronização periódica");
            }

            await Task.Delay(TimeSpan.FromMinutes(10), stoppingToken);
        }
    }
}

2. Worker de Fila (Mensageria)

Consome mensagens de uma fila (RabbitMQ, Azure Service Bus, Amazon SQS) em loop contínuo. Este é o padrão mais comum para desacoplamento entre serviços — exatamente o cenário descrito em Gargalo em Banco de Dados: Mensageria e Paginação:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class FilaPedidosWorker(
    ILogger<FilaPedidosWorker> logger,
    IServiceScopeFactory scopeFactory,
    IConnection rabbitConnection) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var channel = await rabbitConnection.CreateChannelAsync(
            cancellationToken: stoppingToken);

        await channel.QueueDeclareAsync(
            queue: "pedidos-processamento",
            durable: true,
            exclusive: false,
            autoDelete: false,
            cancellationToken: stoppingToken);

        var consumer = new AsyncEventingBasicConsumer(channel);
        consumer.ReceivedAsync += async (_, ea) =>
        {
            try
            {
                using var scope = scopeFactory.CreateScope();
                var processador = scope.ServiceProvider
                    .GetRequiredService<IProcessadorPedido>();

                var pedido = JsonSerializer.Deserialize<PedidoEvento>(
                    ea.Body.Span);

                await processador.ProcessarAsync(pedido!, stoppingToken);

                await channel.BasicAckAsync(ea.DeliveryTag, false,
                    stoppingToken);
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Erro ao processar pedido");
                await channel.BasicNackAsync(ea.DeliveryTag, false, true,
                    stoppingToken);
            }
        };

        await channel.BasicConsumeAsync(
            queue: "pedidos-processamento",
            autoAck: false,
            consumer: consumer,
            cancellationToken: stoppingToken);

        // Manter o worker rodando enquanto não for cancelado
        await Task.Delay(Timeout.Infinite, stoppingToken);
    }
}

3. Worker de Processamento em Lote (Batch)

Processa grandes volumes de dados em lotes paginados, evitando carregar tudo em memória. Combina naturalmente com a paginação Keyset para processar milhões de registros de forma eficiente:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class ProcessamentoLoteWorker(
    ILogger<ProcessamentoLoteWorker> logger,
    IServiceScopeFactory scopeFactory) : BackgroundService
{
    private const int TamanhoPagina = 500;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var processados = await ProcessarLoteAsync(stoppingToken);

            if (processados == 0)
            {
                // Sem itens pendentes — aguardar antes de verificar novamente
                await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
            }
        }
    }

    private async Task<int> ProcessarLoteAsync(CancellationToken ct)
    {
        using var scope = scopeFactory.CreateScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();

        // Paginação Keyset — eficiente para grandes volumes
        var pendentes = await dbContext.Pedidos
            .Where(p => p.Status == StatusPedido.Pendente)
            .OrderBy(p => p.Id)
            .Take(TamanhoPagina)
            .ToListAsync(ct);

        if (pendentes.Count == 0) return 0;

        foreach (var pedido in pendentes)
        {
            pedido.Status = StatusPedido.Processado;
            pedido.ProcessadoEm = DateTimeOffset.UtcNow;
        }

        await dbContext.SaveChangesAsync(ct);
        logger.LogInformation("Processados {Count} pedidos", pendentes.Count);

        return pendentes.Count;
    }
}

4. Worker com Paralelismo

Para cenários onde o processamento individual é lento (chamadas HTTP, cálculos pesados), o Worker pode usar paralelismo controlado. Isso se conecta diretamente com os conceitos de Paralelismo em C#: Parallel, PLINQ e Tasks:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class ProcessamentoParaleloWorker(
    ILogger<ProcessamentoParaleloWorker> logger,
    IServiceScopeFactory scopeFactory,
    IOptions<WorkerConfig> options) : BackgroundService
{
    private readonly int _maxParallelism = options.Value.MaxParallelism;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using var scope = scopeFactory.CreateScope();
            var dbContext = scope.ServiceProvider
                .GetRequiredService<AppDbContext>();

            var itens = await dbContext.FilaProcessamento
                .Where(f => f.Status == StatusFila.Aguardando)
                .OrderBy(f => f.CriadoEm)
                .Take(100)
                .ToListAsync(stoppingToken);

            if (itens.Count == 0)
            {
                await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
                continue;
            }

            // Processar com paralelismo controlado via SemaphoreSlim
            using var semaphore = new SemaphoreSlim(_maxParallelism);
            var tasks = itens.Select(async item =>
            {
                await semaphore.WaitAsync(stoppingToken);
                try
                {
                    // Cada item processa em seu próprio escopo
                    using var itemScope = scopeFactory.CreateScope();
                    var processador = itemScope.ServiceProvider
                        .GetRequiredService<IProcessadorItem>();

                    await processador.ProcessarAsync(item, stoppingToken);
                }
                finally
                {
                    semaphore.Release();
                }
            });

            await Task.WhenAll(tasks);
            logger.LogInformation("Lote processado: {Count} itens", itens.Count);
        }
    }
}

💡 Dica: Note o padrão SemaphoreSlim + Task.WhenAll — ele permite paralelismo controlado sem sobrecarregar recursos. Cada item usa seu próprio escopo de DI (CreateScope), garantindo que DbContext e outros serviços Scoped sejam independentes entre as tasks. Para mais detalhes sobre paralelismo controlado, veja Paralelismo em C#.


Ciclos de Vida no Worker Service

Esta é a seção mais importante do artigo — e a que causa mais bugs em produção. Os ciclos de vida de DI (Singleton, Scoped, Transient) se comportam de forma diferente em Workers comparado a Web APIs, porque não existe o conceito de “request HTTP” para criar escopos automaticamente.

O Problema: Workers São Singleton

O BackgroundService é registrado como Singleton pelo AddHostedService<T>(). Isso significa que todas as suas dependências injetadas via construtor também precisam ser Singleton — ou você terá problemas:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// ❌ ERRADO — DbContext é Scoped, Worker é Singleton
public class WorkerErrado(
    ILogger<WorkerErrado> logger,
    AppDbContext dbContext) : BackgroundService  // ⚠️ Vai lançar InvalidOperationException!
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // Este código NUNCA executa — a DI falha na resolução
        var itens = await dbContext.Pedidos.ToListAsync(stoppingToken);
    }
}

// Erro em runtime:
// InvalidOperationException: Cannot consume scoped service 'AppDbContext'
// from singleton 'WorkerErrado'.

A Solução: IServiceScopeFactory

O padrão correto é injetar IServiceScopeFactory (que é Singleton) e criar escopos manualmente:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// ✅ CORRETO — cria escopo manual para serviços Scoped
public class WorkerCorreto(
    ILogger<WorkerCorreto> logger,
    IServiceScopeFactory scopeFactory) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            // Cada iteração cria um escopo novo — como um "request virtual"
            using (var scope = scopeFactory.CreateScope())
            {
                var dbContext = scope.ServiceProvider
                    .GetRequiredService<AppDbContext>();

                var servico = scope.ServiceProvider
                    .GetRequiredService<IProcessadorPedido>();

                await servico.ProcessarPendentesAsync(stoppingToken);
            }
            // O escopo é descartado aqui — DbContext é liberado,
            // connections são devolvidas ao pool

            await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
        }
    }
}

Comportamento dos Ciclos de Vida em Workers

Ciclo de VidaInjetar no Construtor do Worker?Via CreateScope?Comportamento
Singleton✅ Sim✅ SimMesma instância para toda a vida do host
ScopedInvalidOperationException✅ Sim (obrigatório)Nova instância por escopo criado manualmente
Transient⚠️ Funciona, mas perigoso✅ Sim (recomendado)Nova instância a cada resolução — sem escopo, vaza memória

Por Que Transient no Construtor É Perigoso

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ⚠️ PERIGOSO — Transient no construtor de Singleton
public class WorkerPerigoso(
    ILogger<WorkerPerigoso> logger,
    IServiceScopeFactory scopeFactory,
    HttpClient httpClient) : BackgroundService  // HttpClient é Transient...
{
    // httpClient é criado UMA VEZ e nunca descartado
    // → vazamento de memória (connection pool esgotado)
    // → DNS stale (não respeita TTL do DNS)
}

// ✅ CORRETO — usar IHttpClientFactory (Singleton-safe)
public class WorkerSeguro(
    ILogger<WorkerSeguro> logger,
    IServiceScopeFactory scopeFactory,
    IHttpClientFactory httpClientFactory) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            // Cria um HttpClient com pool gerenciado (Singleton-safe)
            using var client = httpClientFactory.CreateClient("meu-servico");
            var response = await client.GetAsync("/api/status", stoppingToken);
            // ...
        }
    }
}

⚠️ Atenção: Em Web APIs, o ASP.NET Core cria escopos automaticamente para cada request HTTP. Em Workers, você é responsável por criar escopos manualmente sempre que precisar de serviços Scoped ou Transient. Esquecer de criar o escopo é a causa #1 de memory leaks e connection pool exhaustion em Workers.

Diagrama: Escopo de DI em Web API vs Worker

Diagrama comparando o comportamento de ciclo de vida de DI (Singleton, Scoped, Transient) entre Web API com escopo automático por request e Worker Service com escopo manual via IServiceScopeFactory

Em uma Web API, cada request HTTP cria automaticamente um escopo de DI. No Worker, o ExecuteAsync roda em um único escopo Singleton — por isso você precisa criar escopos manualmente com IServiceScopeFactory.CreateScope() para simular o mesmo isolamento.


Workers na Prática Corporativa

Worker como Consumidor de Mensageria

O padrão mais poderoso de Workers em ambiente corporativo é o consumo de filas de mensageria. Em vez de processar tudo sincronicamente dentro da API, a API publica um evento na fila e o Worker consome e processa em background — exatamente o padrão descrito em Gargalo em Banco de Dados com EF Core: Mensageria e Paginação:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// Program.cs — registro completo de um Worker com mensageria
var builder = Host.CreateApplicationBuilder(args);

// Configuração tipada
builder.Services.Configure<RabbitMqConfig>(
    builder.Configuration.GetSection("RabbitMq"));

// Conexão RabbitMQ (Singleton)
builder.Services.AddSingleton<IConnection>(sp =>
{
    var config = sp.GetRequiredService<IOptions<RabbitMqConfig>>().Value;
    var factory = new ConnectionFactory
    {
        HostName = config.Host,
        Port = config.Port,
        UserName = config.Username,
        Password = config.Password,
        DispatchConsumersAsync = true  // Obrigatório para AsyncEventingBasicConsumer
    };
    return factory.CreateConnectionAsync().GetAwaiter().GetResult();
});

// Serviços de domínio (Scoped — resolvidos via CreateScope no Worker)
builder.Services.AddScoped<IProcessadorPedido, ProcessadorPedido>();
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration
        .GetConnectionString("Default")));

// Worker (Singleton)
builder.Services.AddHostedService<FilaPedidosWorker>();

var host = builder.Build();
host.Run();

Worker para Processamento de Grandes Volumes com Paginação

Quando você precisa processar milhões de registros (migração de dados, recálculo de saldos, reindexação), o Worker se combina com paginação Keyset para processar em blocos sem sobrecarregar a memória:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class MigracaoWorker(
    ILogger<MigracaoWorker> logger,
    IServiceScopeFactory scopeFactory) : BackgroundService
{
    private const int TamanhoPagina = 1000;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        long ultimoId = 0;
        var totalProcessados = 0;

        while (!stoppingToken.IsCancellationRequested)
        {
            using var scope = scopeFactory.CreateScope();
            var dbContext = scope.ServiceProvider
                .GetRequiredService<AppDbContext>();

            // Paginação Keyset — sem OFFSET, performance constante
            // Veja: /posts/2026/paginacao-api-rest-csharp-efcore-sqlserver-oracle-postgres/
            var lote = await dbContext.Clientes
                .Where(c => c.Id > ultimoId && !c.Migrado)
                .OrderBy(c => c.Id)
                .Take(TamanhoPagina)
                .ToListAsync(stoppingToken);

            if (lote.Count == 0)
            {
                logger.LogInformation(
                    "Migração concluída. Total: {Total}", totalProcessados);
                return; // Worker encerra quando não há mais dados
            }

            foreach (var cliente in lote)
            {
                cliente.NovoFormatoCpf = FormatarCpf(cliente.Cpf);
                cliente.Migrado = true;
            }

            await dbContext.SaveChangesAsync(stoppingToken);

            ultimoId = lote[^1].Id; // Último ID do lote para a próxima página
            totalProcessados += lote.Count;

            logger.LogInformation(
                "Lote processado: {Count} | Total: {Total} | Último ID: {Id}",
                lote.Count, totalProcessados, ultimoId);
        }
    }

    private static string FormatarCpf(string cpf) =>
        cpf.Length == 11
            ? $"{cpf[..3]}.{cpf[3..6]}.{cpf[6..9]}-{cpf[9..]}"
            : cpf;
}

Worker com Async/Await e CancellationToken

O CancellationToken é o contrato de shutdown graceful do Worker. Quando o host recebe um sinal de parada (SIGTERM no Linux, Ctrl+C, docker stop), ele dispara o token — e o Worker tem um tempo limitado para finalizar o que está fazendo. Esse mecanismo se conecta diretamente com os conceitos de Programação Assíncrona com async/await:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class WorkerGraceful(
    ILogger<WorkerGraceful> logger,
    IServiceScopeFactory scopeFactory) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        logger.LogInformation("Worker iniciando...");

        // Registrar callback para o shutdown
        stoppingToken.Register(() =>
            logger.LogWarning("Shutdown solicitado — finalizando operação atual"));

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                using var scope = scopeFactory.CreateScope();
                var processador = scope.ServiceProvider
                    .GetRequiredService<IProcessadorRelatorio>();

                // Propagar o CancellationToken para TODAS as operações async
                await processador.GerarRelatorioAsync(stoppingToken);
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                // Shutdown graceful — não é erro
                logger.LogInformation("Worker finalizado de forma graciosa");
                break;
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Erro no processamento — tentando novamente em 30s");
                await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
            }
        }
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Program.cs — configurar timeout de shutdown
var builder = Host.CreateApplicationBuilder(args);

builder.Services.Configure<HostOptions>(options =>
{
    // Tempo que o host aguarda o ExecuteAsync finalizar após
    // o CancellationToken ser disparado. Padrão: 30s.
    // Em processos pesados, aumente para evitar kill forçado.
    options.ShutdownTimeout = TimeSpan.FromMinutes(2);

    // .NET 8: comportamento quando ExecuteAsync lança exceção
    options.BackgroundServiceExceptionBehavior =
        BackgroundServiceExceptionBehavior.StopHost; // padrão no .NET 8
});

ℹ️ Informação: Em containers Docker/Podman, o docker stop envia SIGTERM e aguarda 10 segundos por padrão antes de enviar SIGKILL. Se seu Worker precisa de mais tempo para shutdown graceful, configure ShutdownTimeout no código e --stop-timeout no container.


Múltiplos Workers no Mesmo Host

Uma aplicação pode registrar vários Workers no mesmo host — cada um roda em sua própria Task:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
var builder = Host.CreateApplicationBuilder(args);

// Cada Worker roda em paralelo no mesmo processo
builder.Services.AddHostedService<ImportacaoWorker>();
builder.Services.AddHostedService<NotificacaoWorker>();
builder.Services.AddHostedService<LimpezaWorker>();
builder.Services.AddHostedService<SincronizacaoWorker>();

// Todos compartilham: IConfiguration, ILogger, IServiceProvider
var host = builder.Build();
host.Run();

⚠️ Atenção: Os Workers iniciam na ordem em que foram registrados, mas executam em paralelo. O StartAsync de cada um é chamado sequencialmente (na ordem de registro), mas o ExecuteAsync do BackgroundService roda em background imediatamente. Se um Worker depende de outro já estar rodando, use IHostApplicationLifetime para coordenação.

Quando Separar em Processos Distintos vs Mesmo Host

CenárioMesmo HostProcessos Separados
Workers leves (timers, health checks)✅ RecomendadoOverhead desnecessário
Workers com requisitos de escala diferentes✅ Escalar independentemente
Workers com dependências conflitantes✅ Isolamento de dependências
Workers que consomem filas diferentesDepende do volume✅ Escalar por fila
Deploy em Kubernetes✅ Um Deployment por Worker
Deploy em VPS/VM✅ (systemd services)Gerenciamento complexo

Rodando Workers como Serviço do Sistema Operacional

Em produção, Workers rodam como serviços gerenciados pelo sistema operacional:

Linux — systemd

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# Publicar o Worker
dotnet publish src/MeuWorker -c Release -o /opt/meu-worker

# Criar o arquivo de serviço systemd
sudo tee /etc/systemd/system/meu-worker.service > /dev/null << 'EOF'
[Unit]
Description=MeuWorker Background Service
After=network.target

[Service]
Type=notify
ExecStart=/opt/meu-worker/MeuWorker
WorkingDirectory=/opt/meu-worker
Environment=DOTNET_ENVIRONMENT=Production
Environment=ConnectionStrings__Default=Host=db;Database=prod;Password=S3cret!
Restart=always
RestartSec=10
User=workeruser
Group=workergroup

# Shutdown graceful
TimeoutStopSec=120

[Install]
WantedBy=multi-user.target
EOF

# Habilitar e iniciar
sudo systemctl daemon-reload
sudo systemctl enable meu-worker
sudo systemctl start meu-worker

# Verificar logs
journalctl -u meu-worker -f

Docker / Podman

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Dockerfile — multi-stage build
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["src/MeuWorker/MeuWorker.csproj", "MeuWorker/"]
RUN dotnet restore "MeuWorker/MeuWorker.csproj"
COPY src/MeuWorker/ MeuWorker/
RUN dotnet publish "MeuWorker/MeuWorker.csproj" -c Release -o /app/publish

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .

# Rodar como non-root (segurança)
USER app
ENTRYPOINT ["dotnet", "MeuWorker.dll"]
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# docker-compose.yml / podman-compose.yml
services:
  meu-worker:
    build: .
    restart: always
    environment:
      - DOTNET_ENVIRONMENT=Production
      - ConnectionStrings__Default=Host=db;Database=prod;Password=${DB_PASSWORD}
    secrets:
      - db_password
    depends_on:
      db:
        condition: service_healthy

secrets:
  db_password:
    file: ./secrets/db_password.txt

Dicas e Boas Práticas

  1. Sempre use IServiceScopeFactory para serviços Scoped — nunca injete DbContext, repositórios ou serviços Scoped diretamente no construtor do Worker. Crie escopos manualmente dentro do loop.

  2. Propague o CancellationToken para todas as operações async — de ExecuteAsync até a chamada ao banco, à fila de mensageria e ao HttpClient. Um Worker que ignora o token demora para parar e pode perder dados no shutdown.

  3. Trate exceções dentro do loop — no .NET 8, exceções não tratadas no ExecuteAsync param o host. Use try/catch com retry e logging para evitar que um erro transiente mate o Worker.

  4. Configure HostOptions.ShutdownTimeout adequadamente — o padrão de 30 segundos pode ser insuficiente para Workers que processam lotes grandes. Alinhe com o --stop-timeout do Docker/Podman.

  5. Use IHttpClientFactory em vez de new HttpClient() — em serviços Singleton como Workers, instanciar HttpClient diretamente causa vazamento de sockets e DNS stale. O IHttpClientFactory gerencia o pool de handlers.

  6. Prefira paginação Keyset para processamento em lote — evite Skip/Take com OFFSET para datasets grandes. Keyset (WHERE Id > lastId) mantém performance constante independente do volume.

  7. Separe Workers por responsabilidade — um Worker deve fazer uma coisa bem. Workers “canivete suíço” com múltiplas responsabilidades são difíceis de escalar, monitorar e debugar.

  8. Adicione health checks ao Worker — use IHealthCheck e exponha um endpoint HTTP mínimo (com MapHealthChecks) para que orquestradores (Kubernetes, Docker) possam verificar se o Worker está saudável.


Conclusão

Os .NET Workers e Background Services são a solução nativa do .NET para processamento em background — sem gambiarras com Task.Run() dentro de controllers, sem bibliotecas de terceiros para cenários que o framework já resolve. O Generic Host fornece a mesma infraestrutura de configuração, DI e logging das Web APIs, permitindo que equipes compartilhem patterns e conhecimento entre projetos HTTP e Workers.

O ponto mais crítico — e que gera mais bugs em produção — é entender que Workers são Singleton. Isso muda fundamentalmente como você lida com DbContext, repositórios e qualquer serviço Scoped. O padrão IServiceScopeFactory.CreateScope() não é opcional: é a base de todo Worker que acessa banco de dados ou serviços com estado.

Para aplicações corporativas com alto volume, a combinação de Workers com mensageria para desacoplamento, paginação Keyset para processamento em lote, paralelismo controlado com SemaphoreSlim e async/await com CancellationToken forma um arsenal completo para construir sistemas que processam milhões de registros de forma confiável, sem travar a API e sem perder dados no shutdown.


Leia Também


Referências