Worker pools em Go vs goroutine por tarefa para jobs em segundo plano
Worker pools vs goroutine-por-task em Go: entenda como cada modelo afeta throughput, uso de memória e backpressure para processamento em segundo plano e workflows longos.

Que problema estamos resolvendo?
A maioria dos serviços Go faz mais do que responder requisições HTTP. Eles também executam trabalho em segundo plano: enviar emails, redimensionar imagens, gerar faturas, sincronizar dados, processar eventos ou reconstruir um índice de busca. Alguns jobs são rápidos e independentes. Outros formam workflows longos em que cada passo depende do anterior (cobrar um cartão, aguardar confirmação, depois notificar o cliente e atualizar relatórios).
Quando as pessoas comparam "Go worker pools vs goroutine-per-task", geralmente tentam resolver um problema de produção: como rodar muito trabalho em segundo plano sem deixar o serviço lento, caro ou instável.
Você sente o impacto em alguns lugares:
- Latência: trabalho em segundo plano rouba CPU, memória, conexões com DB e largura de banda de rede de requisições voltadas ao usuário.
- Custo: concorrência desenfreada empurra você para máquinas maiores, mais capacidade de banco ou contas mais altas em filas e APIs.
- Estabilidade: rajadas (imports, envios de marketing, tempestades de retry) podem disparar timeouts, crashes por OOM ou falhas em cascata.
A troca real é simplicidade vs controle. Criar uma goroutine por tarefa é fácil de escrever e muitas vezes funciona quando o volume é baixo ou naturalmente limitado. Um worker pool adiciona estrutura: concorrência fixa, limites claros e um lugar natural para colocar timeouts, retries e métricas. O custo é código extra e a decisão sobre o que fazer quando o sistema estiver ocupado (as tarefas esperam, são rejeitadas ou armazenadas em outro lugar?).
Isto trata do processamento do dia a dia: throughput, uso de memória e backpressure (como evitar sobrecarga). Não tenta cobrir todas as tecnologias de fila, engines de workflow distribuídas ou semânticas exactly-once.
Se você está construindo apps completos com lógica em background usando uma plataforma como AppMaster, as mesmas questões aparecem rapidamente. Seus processos de negócio e integrações ainda precisam de limites em torno de bancos, APIs externas e provedores de email/SMS para que um workflow ocupado não desacelere todo o resto.
Dois padrões comuns em termos simples
Goroutine-por-tarefa
É a abordagem mais simples: sempre que um job chega, inicia-se uma goroutine para tratá-lo. A “fila” costuma ser o que aciona o trabalho, como um receptor de channel ou uma chamada direta de um handler HTTP.
Um formato típico é: receber um job, depois go handle(job). Às vezes um channel ainda é usado, mas só como ponto de passagem, não como limitador.
Funciona bem quando jobs passam a maior parte do tempo esperando I/O (chamadas HTTP, queries ao DB, uploads), o volume é modesto e as rajadas são pequenas ou previsíveis.
A desvantagem é que a concorrência pode crescer sem um limite claro. Isso pode estourar memória, abrir conexões demais ou sobrecarregar um serviço a jusante.
Worker pool
Um worker pool inicia um número fixo de goroutines worker e alimenta-as com jobs de uma fila, normalmente um channel buffered em memória. Cada worker faz um loop: pega um job, processa, repete.
A diferença chave é controle. O número de workers é um limite rígido de concorrência. Se os jobs chegarem mais rápido do que os workers conseguem terminá-los, os jobs esperam na fila (ou são rejeitados se a fila estiver cheia).
Pools são bons quando o trabalho é pesado em CPU (processamento de imagens, geração de relatórios), quando você precisa de uso de recursos previsível ou quando deve proteger um banco ou API de terceiros de rajadas.
Onde a fila fica
Ambos os padrões podem usar um channel em memória, que é rápido mas some no restart. Para jobs que não podem ser perdidos ou workflows longos, a fila costuma sair do processo (uma tabela no banco, Redis ou um message broker). Nesse caso, você ainda escolhe entre goroutine-por-task e worker pools, mas agora eles rodam como consumidores da fila externa.
Como exemplo simples: se o sistema precisar enviar 10.000 emails de repente, goroutine-por-task pode tentar disparar todos de uma vez. Um pool pode enviar 50 por vez e manter o resto esperando de forma controlada.
Throughput: o que muda e o que não muda
É comum esperar uma grande diferença de throughput entre worker pools e goroutine-por-task. Na maior parte das vezes, o throughput bruto é limitado por outra coisa, não por como você inicia goroutines.
O throughput normalmente chega a um teto no recurso compartilhado mais lento: banco ou limites de API, disco ou banda de rede, trabalho intensivo de CPU (JSON/PDF/redimensionamento de imagem), locks e estado compartilhado, ou serviços a jusante que desaceleram sob carga.
Se um recurso compartilhado é o gargalo, lançar mais goroutines não acelera o fim do trabalho. Isso só cria mais espera no mesmo ponto crítico.
Goroutine-por-task pode vencer quando tarefas são curtas, majoritariamente I/O-bound e não competem por limites compartilhados. O startup de goroutines é barato, e o Go escala bem com grandes números. Em um loop de “fetch, parse, escreve uma linha”, isso pode manter CPUs ocupados e esconder latência de rede.
Pools vencem quando você precisa limitar recursos caros. Se cada job segura uma conexão de DB, abre arquivos, aloca buffers grandes ou consome cota de API, concorrência fixa mantém o serviço estável enquanto atinge o throughput máximo seguro.
Latência (especialmente p99) é onde a diferença costuma aparecer. Goroutine-por-task pode parecer ótimo em carga baixa, depois desabar quando muitas tasks se acumulam. Pools introduzem delay por enfileiramento (jobs aguardando um worker livre), mas o comportamento é mais estável porque você evita multidões competindo pelo mesmo limite.
Um modelo mental simples:
- Se o trabalho é barato e independente, mais concorrência pode aumentar throughput.
- Se o trabalho é limitado por um limite compartilhado, mais concorrência só aumenta espera.
- Se você se importa com p99, meça o tempo na fila separadamente do tempo de processamento.
Uso de memória e recursos
Muito do debate worker-pool vs goroutine-por-task é realmente sobre memória. CPU pode ser escalada vertical ou horizontalmente. Falhas por memória são mais súbitas e podem derrubar o serviço inteiro.
Uma goroutine é barata, mas não gratuita. Cada uma começa com uma stack pequena que cresce conforme chama funções mais profundas ou mantém variáveis locais grandes. Há também overhead do scheduler e do runtime. Dez mil goroutines podem ser aceitáveis. Cem mil podem surpreender se cada uma mantiver referências a dados grandes do job.
O custo oculto maior frequentemente não é a goroutine em si, mas o que ela mantém viva. Se tarefas chegam mais rápido do que terminam, goroutine-por-task cria backlog não limitado. A “fila” pode ser implícita (goroutines aguardando locks ou I/O) ou explícita (um channel buffered, um slice, um batch em memória). De qualquer forma, a memória cresce com o backlog.
Worker pools ajudam porque impõem um limite. Com workers fixos e uma fila limitada, você tem um limite real de memória e um modo de falha claro: quando a fila enche, você bloqueia, descarta carga ou empurra o trabalho para cima.
Uma checagem rápida de bolso:
- Peak goroutines = workers + jobs em voo + jobs “esperando” que você criou
- Memória por job = payload (bytes) + metadata + qualquer coisa referenciada (requests, JSON decodificado, linhas do DB)
- Memória máxima do backlog ~= jobs esperando * memória por job
Exemplo: se cada job mantém um payload de 200 KB (ou referencia um grafo de objetos de 200 KB) e você permite 5.000 jobs se acumularem, isso dá ~1 GB só de payloads. Mesmo que goroutines fossem magicamente grátis, o backlog não é.
Backpressure: evitar que o sistema derreta
Backpressure é simples: quando trabalho chega mais rápido do que você consegue finalizar, o sistema devolve pressão de forma controlada em vez de empilhar silenciosamente. Sem isso, você não só fica mais lento; você ganha timeouts, crescimento de memória e falhas difíceis de reproduzir.
Percebe-se falta de backpressure quando uma rajada (imports, envios de email, exports) desencadeia padrões como memória subindo e não caindo, tempo na fila crescendo enquanto CPU permanece ocupada, picos de latência para requisições não relacionadas, retries se acumulando ou erros como “too many open files” e exaustão de pools de conexão.
Uma ferramenta prática é um channel limitado: limite quantos jobs podem esperar. Produtores bloqueiam quando o channel enche, o que desacelera a criação de jobs na origem.
Bloquear nem sempre é a escolha certa. Para trabalho opcional, escolha uma política explícita para que a sobrecarga seja previsível:
- Descartar tarefas de baixo valor (por exemplo, notificações duplicadas)
- Agrupar muitas tarefas pequenas em uma única escrita ou chamada de API
- Atrasar trabalho com jitter para evitar picos de retry
- Delegar para uma fila persistente e retornar rapidamente
- Shedar carga retornando um erro claro quando já estiver sobrecarregado
Rate limiting e timeouts também são ferramentas de backpressure. Rate limiting limita a velocidade com que você atinge uma dependência (provedor de email, DB, API de terceiros). Timeouts limitam quanto tempo um worker pode ficar preso. Juntos, eles impedem que uma dependência lenta vire um outage completo.
Exemplo: geração de extratos de fim de mês. Se 10.000 requisições chegam ao mesmo tempo, goroutines ilimitadas podem disparar 10.000 renderizações de PDF e uploads. Com fila limitada e workers fixos, você renderiza e tenta novamente em um ritmo seguro.
Como construir um worker pool passo a passo
Um worker pool limita concorrência rodando um número fixo de workers e alimentando-os com jobs de uma fila.
1) Escolha um limite de concorrência seguro
Comece com base no tempo que seus jobs gastam:
- Para trabalho pesado em CPU, mantenha workers próximos ao número de núcleos de CPU.
- Para trabalho I/O-heavy (DB, HTTP, storage), você pode ir além, mas pare quando dependências começarem a dar timeout ou throttling.
- Para trabalho misto, meça e ajuste. Um intervalo razoável inicial costuma ser 2x a 10x dos núcleos de CPU, depois você afina.
- Respeite limites compartilhados. Se o pool de DB é 20 conexões, 200 workers só vão competir por essas 20.
2) Escolha a fila e defina seu tamanho
Um channel buffered é comum porque é built-in e fácil de raciocinar. O buffer é seu amortecedor para rajadas.
Buffers pequenos expõem sobrecarga cedo (senders bloqueiam antes). Buffers maiores suavizam picos, mas podem esconder problemas e aumentar memória e latência. Dimensione o buffer com propósito e decida o que acontece quando ele encher.
3) Torne cada tarefa cancelável
Passe um context.Context para cada job e garanta que o código do job o use (DB, HTTP). É assim que você para limpo em deploys, shutdowns e timeouts.
func StartPool(ctx context.Context, workers, queueSize int, handle func(context.Context, Job) error) chan<- Job {
jobs := make(chan Job, queueSize)
for i := 0; i < workers; i++ {
go func() {
for {
select {
case <-ctx.Done():
return
case j := <-jobs:
_ = handle(ctx, j)
}
}
}()
}
return jobs
}
4) Adicione as métricas que você realmente usará
Se você só rastrear alguns números, acompanhe estes:
- Profundidade da fila (quão atrasado você está)
- Tempo de ocupação dos workers (o quão saturado está o pool)
- Duração das tarefas (p50, p95, p99)
- Taxa de erro (e contagem de retries se houver)
Isso é suficiente para ajustar número de workers e tamanho da fila com base em evidência, não em suposições.
Erros comuns e armadilhas
A maioria das equipes não se dá mal por escolher o padrão “errado”. Elas se dão mal por defaults pequenos que viram outages quando o tráfego sobe.
Quando as goroutines se multiplicam
A armadilha clássica é spawnar uma goroutine por job durante uma rajada. Algumas centenas são normais. Centenas de milhares podem entupir scheduler, heap, logs e sockets. Mesmo que cada goroutine seja pequena, o custo total soma, e a recuperação demora porque o trabalho já está em voo.
Outro erro é tratar um channel muito grande como “backpressure”. Um buffer grande é só uma fila escondida. Pode ganhar tempo, mas também esconde problemas até você atingir o limite de memória. Se você precisa de uma fila, dimensione-a deliberadamente e decida o que fazer quando ela encher (bloquear, dropar, tentar depois ou persistir).
Gargalos ocultos
Muitos jobs em background não são CPU-bound. Eles são limitados por algo a jusante. Se você ignorar esses limites, um produtor rápido sobrecarrega um consumidor lento.
Armadilhas comuns:
- Falta de cancelamento ou timeout, então workers podem bloquear para sempre em uma API ou query de DB
- Contagem de workers escolhida sem checar limites reais como conexões de DB, I/O de disco ou cotas de terceiros
- Retries que amplificam carga (retries imediatos em 1.000 jobs falhos)
- Um lock compartilhado ou transação que serializa tudo, então “mais workers” só adiciona overhead
- Falta de visibilidade: sem métricas de profundidade de fila, idade de job, contagem de retries e utilização de workers
Exemplo: um export noturno dispara 20.000 tarefas de “enviar notificação”. Se cada tarefa bate no banco e em um provedor de email, é fácil exceder pools de conexão ou cotas. Um pool de 50 workers com timeouts por tarefa e uma fila pequena torna o limite óbvio. Uma goroutine por tarefa mais um buffer gigante faz o sistema parecer bem até que não esteja.
Exemplo: exports e notificações em rajada
Imagine um time de suporte que precisa de dados para uma auditoria. Uma pessoa clica em "Export", alguns colegas fazem o mesmo e, de repente, 5.000 jobs de export são criados em um minuto. Cada export lê do banco, formata um CSV, armazena um arquivo e envia uma notificação (email ou Telegram) quando estiver pronto.
Com goroutine-por-task, o sistema parece ótimo por um momento. Todos os 5.000 jobs começam quase instantaneamente e parece que a fila está drenando rápido. Depois aparecem os custos: milhares de queries concorrentes competem por conexões, memória sobe enquanto jobs mantêm buffers, e timeouts viram comuns. Jobs que poderiam terminar rápido ficam presos atrás de retries e queries lentas.
Com um worker pool, o início é mais lento mas a execução é mais calma. Com 50 workers, só 50 exports fazem trabalho pesado ao mesmo tempo. Uso de banco fica em uma faixa previsível, buffers são reutilizados mais frequentemente e latência é mais estável. O tempo total de conclusão fica mais fácil de estimar: aproximadamente (jobs / workers) * duração média do job, mais algum overhead.
A diferença chave não é que pools são magicamente mais rápidos. É que eles impedem que o sistema se autodestrua durante rajadas. Uma execução controlada de 50 por vez frequentemente termina antes de 5.000 jobs brigando entre si.
Onde aplicar backpressure depende do que você quer proteger:
- Na camada de API, rejeitar ou atrasar novos exports quando o sistema estiver ocupado.
- Na fila, aceitar requisições mas enfileirar jobs e drená-los a uma taxa segura.
- No worker pool, limitar concorrência nas partes caras (leitura do DB, geração de arquivo, envio de notificações).
- Por recurso, dividir em limites separados (por exemplo, 40 workers para exports e apenas 10 para notificações).
- Em chamadas externas, rate-limit em email/SMS/Telegram para não ser bloqueado.
Checklist rápido antes de colocar em produção
Antes de rodar jobs em background em produção, revise limites, visibilidade e tratamento de falhas. A maioria dos incidentes não vem de “código lento”. Vem de falta de guardrails quando o load sobe ou uma dependência fica instável.
- Defina máxima concorrência por dependência. Não escolha um número global e espere que sirva para tudo. Limite gravações no DB, chamadas HTTP de saída e trabalho intensivo em CPU separadamente.
- Deixe a fila limitada e observável. Coloque um limite real em jobs pendentes e exponha algumas métricas: profundidade da fila, idade do job mais antigo e taxa de processamento.
- Adicione retries com jitter e caminho de dead-letter. Retry seleto, espalhe retries e, após N falhas, mova o job para uma dead-letter ou tabela “failed” com detalhes para revisão e reprocessamento.
- Verifique comportamento de shutdown: drenar, cancelar, retomar com segurança. Decida o que acontece em deploy ou crash. Torne jobs idempotentes para reprocessamento seguro e armazene progresso para workflows longos.
- Proteja o sistema com timeouts e circuit breakers. Toda chamada externa precisa de timeout. Se uma dependência estiver caída, falhe rápido (ou pause a entrada) em vez de empilhar trabalho.
Próximos passos práticos
Escolha o padrão que corresponde ao comportamento do seu sistema em um dia normal, não em um dia perfeito. Se o trabalho chega em rajadas (uploads, exports, envios de email), um worker pool fixo com fila limitada costuma ser o padrão mais seguro. Se o trabalho é estável e cada tarefa é pequena, goroutine-por-task pode ser aceitável, desde que você ainda aplique limites em algum lugar.
A escolha vencedora costuma ser a que torna o fracasso chato. Pools deixam limites óbvios. Goroutine-por-task facilita esquecer limites até o primeiro pico real.
Comece simples, depois adicione limites e visibilidade
Comece com algo direto, mas adicione dois controles cedo: um limite de concorrência e uma forma de ver enfileiramento e falhas.
Um plano de rollout prático:
- Defina o formato do workload: bursty, steady ou misto (e como é o “pico”).
- Coloque um teto em trabalho em voo (tamanho do pool, semáforo ou channel limitado).
- Decida o que acontece quando o teto é atingido: bloquear, dropar ou retornar um erro claro.
- Adicione métricas básicas: profundidade da fila, tempo na fila, tempo de processamento, retries e dead letters.
- Teste com uma rajada 5x seu pico esperado e observe memória e latência.
Quando um pool não é suficiente
Se workflows podem rodar minutos ou dias, um pool simples pode sofrer porque trabalho não é só “fazer uma vez”. Você precisa de estado, retries e resiliência. Normalmente isso significa persistir progresso, usar passos idempotentes e aplicar backoff. Também pode significar dividir um job grande em passos menores para permitir retomada segura após um crash.
Se quiser entregar um backend completo com workflows mais rápido, AppMaster (appmaster.io) pode ser uma opção prática: você modela dados e lógica de negócio visualmente, e ele gera código Go real para o backend, assim mantendo disciplina em limites de concorrência, enfileiramento e backpressure sem ter que conectar tudo à mão.
FAQ
Prefira um worker pool quando os jobs chegarem em rajadas ou tocar em limites compartilhados como conexões de DB, CPU ou cotas de APIs externas. Use goroutine-por-task quando o volume for moderado, as tarefas forem curtas e você ainda tiver um limite claro em algum lugar (por exemplo, um semáforo ou rate limiter).
Criar uma goroutine por tarefa é rápido de escrever e pode ter ótimo throughput com baixa carga, mas pode gerar backlog não limitado em picos. Um worker pool adiciona um limite rígido de concorrência e um lugar claro para aplicar timeouts, retries e métricas, o que costuma tornar o comportamento em produção mais previsível.
Na maioria dos casos, não muito. Em sistemas reais o throughput costuma ser limitado por um gargalo compartilhado (banco de dados, API externa, I/O de disco ou etapas intensivas de CPU). Mais goroutines não vencem esse limite; elas só aumentam espera e contenção.
Goroutine-por-task costuma ter latência melhor em cargas baixas, mas pode piorar muito em cargas altas porque tudo compete ao mesmo tempo. Um pool pode adicionar atraso de enfileiramento, mas tende a manter o p99 mais estável ao evitar um frenesi nas mesmas dependências.
O custo real não é a goroutine em si, e sim o backlog. Se tarefas se acumulam e cada uma mantém payloads ou objetos grandes, a memória sobe rápido. Um worker pool com fila limitada transforma isso em um teto de memória definido e um modo de falha previsível.
Backpressure significa reduzir ou parar de aceitar trabalho quando o sistema já está ocupado, em vez de deixar tarefas se acumularem silenciosamente. Uma fila limitada é uma forma simples: quando cheia, os produtores bloqueiam ou você retorna um erro, impedindo estouro de memória e exaustão de conexões.
Comece pelo limite real. Para trabalhos CPU-bound, inicie próximo ao número de núcleos. Para I/O-bound, você pode aumentar, mas pare de subir quando o banco, rede ou APIs começarem a dar timeout ou throttling; respeite também tamanhos de pool de conexões.
Escolha um tamanho que absorva picos normais, mas que não esconda problemas por minutos. Buffers pequenos expõem sobrecarga rapidamente; buffers grandes aumentam uso de memória e fazem usuários esperarem mais antes de ver falhas. Decida o que acontece quando a fila enche: bloquear, rejeitar, descartar ou persistir em outro lugar.
Use context.Context por job e garanta que chamadas a DB e HTTP o respeitem. Defina timeouts em chamadas externas e torne o comportamento de shutdown explícito para que workers parem limpos, sem deixar goroutines presas ou trabalho pela metade.
Monitore profundidade da fila, tempo na fila, duração das tarefas (p50/p95/p99) e contagens de erro/retry. Essas métricas dizem se você precisa de mais workers, fila menor, timeouts mais rígidos ou rate limiting contra uma dependência.


