Resposta rápida: um worker pool em Go usa um número fixo de goroutines para consumir jobs de um channel. Isso limita o paralelismo, cria backpressure quando a fila enche e protege CPU, memória, banco de dados e APIs externas. Para produção, o pool também precisa respeitar context.Context, distinguir erros permanentes de temporários, expor métricas e encerrar com sync.WaitGroup. Se o job não puder desaparecer em um restart, substitua a fila em memória por um broker durável.
Worker pool em Go é um dos padrões mais úteis para transformar concorrência em trabalho controlado. Em vez de disparar uma goroutine para cada tarefa e torcer para o sistema aguentar, você define um número fixo de workers, envia jobs por um channel e controla consumo de CPU, memória, conexões e chamadas externas.
Esse padrão aparece em APIs que precisam processar imagens, enviar e-mails, recalcular relatórios, consumir webhooks, sincronizar dados, enriquecer cadastros, executar ETLs pequenos ou chamar serviços externos com limite de taxa. Também é uma ponte natural entre Go básico e sistemas de produção: você usa goroutines e channels, mas precisa pensar em cancelamento, retries, backpressure, observabilidade e shutdown gracioso.
Este guia mostra uma implementação prática de worker pool em Go, explica onde ela funciona bem, onde ela quebra e quando vale sair do channel local para RabbitMQ, Kafka, NATS, SQS ou outro broker. Se você ainda está montando a base, leia também o tutorial de concorrência em Go, o guia de API REST em Go, o comparativo de mensageria em Go e dependency injection em Go para testar workers sem broker real.
Worker pool, semaphore ou errgroup?
Os três mecanismos controlam concorrência, mas resolvem problemas diferentes:
| Opção | Use quando | Mantém fila explícita? | Duração típica |
|---|---|---|---|
| Worker pool | há um fluxo contínuo de jobs e um limite estável de consumidores | Sim | vida do serviço ou lote |
| Semaphore | várias goroutines já existem e apenas uma operação precisa de limite | Não | duração da operação |
errgroup.Group | um conjunto finito de tarefas deve cancelar ao primeiro erro | Não | duração da requisição ou comando |
| Uma goroutine por item | o lote é pequeno e o limite natural já é seguro | Não | tarefa curta |
Um pool não é automaticamente melhor. Para buscar cinco URLs em paralelo dentro de uma requisição, errgroup com limite pode ser mais simples. Para um consumidor que recebe milhares de jobs durante todo o dia, workers persistentes e uma fila explícita tornam capacidade, backlog e shutdown mais fáceis de observar.
O problema: goroutine infinita não é arquitetura
Go torna goroutines baratas, mas “barato” não significa “infinito”. O erro comum é escrever algo assim:
for _, item := range itens {
go processar(item)
}
Para 100 itens, talvez funcione. Para 100 mil, você acabou de criar uma explosão de goroutines competindo por memória, conexões de banco, sockets HTTP e tempo de CPU. Se cada job chama uma API externa, você pode derrubar o fornecedor, tomar 429 ou travar sua própria fila de conexões.
Um worker pool resolve esse problema colocando um limite explícito de paralelismo. Em vez de cada item virar uma goroutine, os jobs entram em uma fila e um número fixo de workers consome essa fila.
Estrutura mínima de um worker pool
A versão mais simples usa três peças:
- Um tipo
Job, com os dados necessários para processar a tarefa. - Um channel
jobs, usado como fila interna. - N workers, cada um rodando em uma goroutine e lendo do channel.
package main
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
)
type Job struct {
ID string
Payload string
}
type Processor func(context.Context, Job) error
type WorkerPool struct {
workers int
jobs chan Job
logger *slog.Logger
process Processor
}
func NewWorkerPool(workers int, buffer int, logger *slog.Logger) *WorkerPool {
return NewWorkerPoolWithProcessor(workers, buffer, logger, processar)
}
func NewWorkerPoolWithProcessor(
workers int,
buffer int,
logger *slog.Logger,
process Processor,
) *WorkerPool {
return &WorkerPool{
workers: workers,
jobs: make(chan Job, buffer),
logger: logger,
process: process,
}
}
func (p *WorkerPool) Start(ctx context.Context) *sync.WaitGroup {
var wg sync.WaitGroup
for i := 1; i <= p.workers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
p.runWorker(ctx, workerID)
}(i)
}
return &wg
}
func (p *WorkerPool) runWorker(ctx context.Context, workerID int) {
for {
select {
case <-ctx.Done():
p.logger.Info("worker encerrado por contexto", slog.Int("worker_id", workerID))
return
case job, ok := <-p.jobs:
if !ok {
p.logger.Info("worker encerrado: fila fechada", slog.Int("worker_id", workerID))
return
}
started := time.Now()
if err := p.process(ctx, job); err != nil {
p.logger.Error("falha ao processar job",
slog.Int("worker_id", workerID),
slog.String("job_id", job.ID),
slog.Duration("duration", time.Since(started)),
slog.Any("err", err),
)
continue
}
p.logger.Info("job processado",
slog.Int("worker_id", workerID),
slog.String("job_id", job.ID),
slog.Duration("duration", time.Since(started)),
)
}
}
}
func (p *WorkerPool) Enqueue(ctx context.Context, job Job) error {
select {
case <-ctx.Done():
return ctx.Err()
case p.jobs <- job:
return nil
}
}
func (p *WorkerPool) Close() {
close(p.jobs)
}
func processar(ctx context.Context, job Job) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(150 * time.Millisecond):
fmt.Println("processado", job.ID)
return nil
}
}
Esse código já cobre o essencial: workers fixos, fila com buffer, cancelamento via context.Context, logging estruturado com slog e encerramento quando o channel fecha. Para logs em produção, combine com o guia de slog em Go.
Como usar no main
O main deve iniciar o pool, enviar jobs e esperar os workers terminarem. Em serviço real, o ctx viria de sinal do sistema (SIGTERM) ou do ciclo de vida do servidor HTTP.
func main() {
logger := slog.Default()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
pool := NewWorkerPool(5, 100, logger)
wg := pool.Start(ctx)
for i := 1; i <= 20; i++ {
job := Job{
ID: fmt.Sprintf("job-%02d", i),
Payload: "dados",
}
if err := pool.Enqueue(ctx, job); err != nil {
logger.Error("erro ao enfileirar job", slog.Any("err", err))
}
}
pool.Close()
wg.Wait()
}
O número de workers não deve ser escolhido no chute. Para tarefas CPU-bound, comece perto de runtime.NumCPU(). Para tarefas I/O-bound, como HTTP e banco, você pode usar mais workers, mas o limite real costuma ser conexão, latência, rate limit e orçamento do serviço externo.
Quantos workers e qual tamanho de buffer usar?
Não existe número universal. Comece pelo recurso que realmente limita o sistema:
| Tipo de job | Ponto inicial | Limite que deve ser respeitado |
|---|---|---|
| CPU-bound (compressão, parsing pesado) | runtime.GOMAXPROCS(0) workers | CPU e tempo de GC |
| Banco de dados | igual ou menor que o pool disponível para esse fluxo | MaxOpenConns, locks e latência |
| API externa | concorrência aceita pelo fornecedor | rate limit, timeout e orçamento |
| Disco ou rede | carga medida em teste | IOPS, banda e memória |
O buffer absorve picos curtos; ele não aumenta a capacidade real. Um channel com 100 mil posições pode apenas esconder saturação e consumir memória. Meça tempo na fila, tamanho do backlog, taxa de chegada e taxa de processamento. Se a fila cresce continuamente, a solução é reduzir entrada, aumentar capacidade segura ou mover o trabalho para uma arquitetura durável — não apenas aumentar o buffer.
Backpressure: o detalhe que salva produção
O buffer do channel é uma forma simples de backpressure. Se a fila está cheia, Enqueue bloqueia até haver espaço ou até o contexto ser cancelado. Isso é bom: o sistema está dizendo que não consegue absorver mais trabalho naquela velocidade.
Em uma API HTTP, você talvez não queira bloquear indefinidamente. Uma variação comum é usar timeout curto:
func (p *WorkerPool) EnqueueWithTimeout(job Job, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
select {
case p.jobs <- job:
return nil
case <-ctx.Done():
return fmt.Errorf("fila cheia: %w", ctx.Err())
}
}
Com isso, a API pode responder 503 Service Unavailable ou 429 Too Many Requests em vez de aceitar trabalho que não vai processar a tempo. Essa decisão é importante para sistemas de backend Go, especialmente quando há SLA, fila de e-mail, pagamento ou webhook envolvido.
Retries sem virar loop infinito
Retries são necessários, mas perigosos. Se todo erro gera retry imediato, uma instabilidade externa vira tempestade. O mínimo saudável é limitar tentativas, aplicar backoff e separar erro temporário de erro permanente.
type Job struct {
ID string
Payload string
Attempts int
}
func retryDelay(attempt int) time.Duration {
switch attempt {
case 0:
return 500 * time.Millisecond
case 1:
return 2 * time.Second
default:
return 10 * time.Second
}
}
Em worker pool local, retry com time.Sleep dentro do worker é simples, mas reduz throughput porque o worker fica parado. Em produção pesada, prefira reenfileirar com atraso em um broker que suporte delay, dead-letter queue ou agendamento. RabbitMQ com DLX, SQS com visibility timeout e NATS JetStream são exemplos comuns.
Quando usar channel local e quando usar broker
Channel local é ótimo quando:
- O trabalho pode ser perdido se o processo reiniciar.
- A fila é curta e vive dentro de uma única instância.
- Você quer limitar paralelismo dentro de uma requisição, CLI ou worker único.
- A tarefa é derivada de outra fonte durável, como banco ou arquivo.
Use broker quando:
- O job não pode sumir em deploy, crash ou restart.
- Há várias instâncias consumindo a mesma fila.
- Você precisa de retry durável, dead-letter queue ou auditoria.
- O produtor e o consumidor são serviços diferentes.
- O volume exige particionamento, replay ou retenção.
No ecossistema Go, RabbitMQ costuma ser direto para filas clássicas de trabalho. Veja o tutorial de Go com RabbitMQ para um caminho prático. Kafka faz mais sentido quando o problema é streaming, retenção e consumo por múltiplos grupos; para isso, veja Go com Kafka. Para decidir entre RabbitMQ, Kafka, NATS, SQS e Redis Streams, use o guia de mensageria em Go. Para evitar duplicidade quando o broker reenviar mensagens, leia também idempotência, retry e DLQ em Go.
Como testar o worker pool sem usar time.Sleep
Testes de concorrência ficam frágeis quando dependem de pausas arbitrárias. Injete a função que processa o job e sincronize o teste por channels. Assim o teste sabe exatamente quando o processamento ocorreu.
No exemplo principal, Processor é uma função injetável e NewWorkerPoolWithProcessor permite substituir processar durante o teste. No código de produção, NewWorkerPool continua usando o processador real.
No teste, use um channel como confirmação:
func TestWorkerPoolProcessaTodosOsJobs(t *testing.T) {
processed := make(chan string, 3)
process := func(ctx context.Context, job Job) error {
processed <- job.ID
return nil
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
pool := NewWorkerPoolWithProcessor(2, 3, slog.Default(), process)
wg := pool.Start(ctx)
for _, id := range []string{"a", "b", "c"} {
if err := pool.Enqueue(ctx, Job{ID: id}); err != nil {
t.Fatal(err)
}
}
pool.Close()
wg.Wait()
close(processed)
got := map[string]bool{}
for id := range processed {
got[id] = true
}
for _, id := range []string{"a", "b", "c"} {
if !got[id] {
t.Fatalf("job %q não foi processado", id)
}
}
}
Além do caminho feliz, teste cancelamento, erro do processador, tentativa de enfileirar com contexto expirado e comportamento quando o buffer está cheio. Rode go test -race ./...: o detector de corrida é especialmente importante quando o pool atualiza contadores, mapas ou estado compartilhado. Para organizar os casos, use table-driven tests em Go.
Observabilidade: métricas que importam
Um worker pool sem métrica vira caixa-preta. No mínimo, acompanhe:
- Tamanho atual da fila.
- Jobs processados com sucesso.
- Jobs com erro, por tipo.
- Duração de processamento.
- Número de retries.
- Jobs descartados por timeout ou fila cheia.
- Tempo de shutdown.
Se o site ou API já usa Prometheus, exporte contadores e histogramas. O tutorial de Go com Prometheus mostra como instrumentar handlers; o mesmo raciocínio vale para workers. Para investigação de gargalos, combine logs estruturados, métricas e profiling com pprof em Go.
Shutdown gracioso em deploy
O erro clássico em deploy é receber SIGTERM, matar o processo e perder jobs em andamento. Em Kubernetes, systemd ou Cloud Run, você normalmente recebe alguns segundos para encerrar. Use esse tempo para parar de aceitar jobs novos, fechar a fila e esperar os workers terminarem.
pool.Close()
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
logger.Info("todos os workers finalizaram")
case <-time.After(20 * time.Second):
logger.Warn("shutdown excedeu timeout")
}
Se o job é durável em broker, o worker pode simplesmente não confirmar a mensagem quando o contexto cancela; o broker entrega de novo depois. Se o job está apenas em memory channel, fechar o processo perde o que ainda não foi processado. Essa diferença deve orientar a arquitetura.
Erros comuns em worker pools Go
Os bugs mais frequentes são previsíveis:
- Fechar channel do lado errado: quem produz e controla o ciclo de vida fecha. Worker consumidor não deve fechar a fila compartilhada.
- Ignorar
context.Context: sem cancelamento, shutdown trava e deploy fica lento. - Criar worker demais: mais workers podem piorar latência por saturar banco, CPU ou rede.
- Não medir fila: se você não sabe o backlog, não sabe se o sistema está saudável.
- Retry infinito: erro permanente precisa morrer em dead-letter, não rodar para sempre.
- Misturar regra de negócio com infraestrutura: worker deve orquestrar; processamento deve ficar em função testável.
Esses erros também aparecem em entrevistas. Se você está se preparando para vagas, worker pools são um ótimo tema para explicar concorrência, trade-offs e produção. Depois deste guia, revise 50 perguntas de entrevista Go e acompanhe vagas Go no Brasil. Para quem ainda está entrando no mercado tech e quer comparar oportunidades de estágio ou júnior em outras stacks, o portal eu.dev.br reúne vagas brasileiras de entrada em tecnologia.
Perguntas frequentes
O que é um worker pool em Go?
É um conjunto fixo de goroutines que consome tarefas de uma fila, normalmente um channel. O limite de workers impede que um pico de entrada crie concorrência ilimitada e sature recursos compartilhados.
Como definir o número de workers?
Para CPU, comece perto de runtime.GOMAXPROCS(0). Para I/O, você pode usar mais workers, mas respeite o menor limite entre conexões de banco, rate limit externo, memória e latência aceitável. Ajuste com teste de carga e métricas, não por intuição.
Worker pool e semaphore são a mesma coisa?
Não. O pool combina consumidores persistentes e fila de jobs. O semaphore limita quantas goroutines entram em uma região concorrente, sem necessariamente criar fila ou workers dedicados.
Quando usar RabbitMQ, Kafka, NATS ou SQS?
Quando o job precisa sobreviver a reinícios, escalar por várias instâncias, ter retry durável, DLQ, auditoria ou replay. Um channel é memória local do processo; depois de crash ou deploy, o conteúdo desaparece.
Como encerrar sem perder jobs?
Pare os produtores, feche a fila, aguarde o WaitGroup e imponha um prazo de shutdown. Se perder jobs for inaceitável, confirme o processamento somente depois do sucesso e mantenha a fonte da tarefa em um broker ou banco durável.
Conclusão
Worker pool é simples no código e profundo na operação. A ideia básica — N workers lendo de um channel — resolve o excesso de goroutines e dá controle de paralelismo. O que separa exemplo de produção é o restante: contexto, backpressure, retry limitado, logs, métricas, shutdown e clareza sobre durabilidade.
Use channel local quando o trabalho é efêmero ou derivado de outra fonte. Use broker quando o job precisa sobreviver a restart, escalar entre instâncias ou carregar histórico. Em ambos os casos, Go é uma excelente escolha porque goroutines, channels e context.Context tornam o modelo direto, legível e eficiente.
Última atualização: Agosto de 2026 — comparação com semaphore e errgroup, dimensionamento, testes determinísticos, backpressure e FAQ de produção.