Limitando a Concurrence em Go: errgroup, Pools de Workers e Backpressure
Limite o trabalho em andamento, não apenas cancele-o.
O fan-out ilimitado é o modo de falha padrão da concorrência em Go: cada declaração go representa mais uma tarefa em andamento, sem limite e sem responsável. Este artigo é sobre dar a ambas.
Cancelamento e limitação são controles separados. O cancelamento decide quando o trabalho para; a limitação decide quanto dele existe ao mesmo tempo. Um serviço pode cancelar perfeitamente e ainda assim cair porque dez mil goroutines decidiram, independentemente, que aquele era um bom momento para chamar o banco de dados.

As páginas ao redor desta cobrem a outra metade do problema. Go context.Context Feito da Maneira Certa trata o context como o plano de controle para parar o trabalho; aqui, o assunto é o plano de capacidade — errgroup.SetLimit, canais semáforo, pools de workers e filas limitadas — mais a forma de vazamento que sobrevive ao errgroup.Wait, mesmo quando todos os caminhos de cancelamento parecem configurados.
Cinco primitivas, uma tabela de decisão
| Primitiva | Resultados parciais | Falha rápida (fail-fast) | Contrapressão | Trabalho dinâmico | Melhor para |
|---|---|---|---|---|---|
sync.WaitGroup + errors.Join |
sim | não | não | não | lotes “aguarde todos” onde todos os resultados contam |
errgroup.WithContext |
não | sim | não | não | fan-out onde o primeiro erro termina a requisição |
errgroup.SetLimit(n) |
não | sim | sim (bloqueia Go) |
não | fan-out limitado com falha rápida |
| Canal semáforo | sim | manual | sim (bloqueia aquisição) | sim | controle de admissão customizado |
| Pool de workers | sim | manual | sim (fila enche) | sim | fluxos constantes, workers de longa duração |
Duas perguntas escolhem a linha. Primeira: quando uma tarefa falha, você ainda quer os resultados das outras? Se sim, a semântica de grupo de falha rápida descarta trabalho de que você precisava. Segunda: o trabalho chega continuamente ou é um lote fixo que você pode contar antes de lançar? Grupos lidam com lotes; pools e filas lidam com fluxos.
errors.Join é o que torna a linha simples de WaitGroup viável — ele coleta todos os erros dos workers em vez de manter apenas o primeiro, o que combina com as regras de tradução de fronteira em Arquitetura de Tratamento de Erros em Go.
O vazamento que sobrevive ao errgroup.Wait
O modo de falha mais conhecido do errgroup não tem nada a ver com limitação. É uma lacuna de cancelamento, e se esconde atrás de código que parece correto:
g, gctx := errgroup.WithContext(ctx)
results := make(chan int) // sem buffer
go func() { // produtor — ninguém possui esta goroutine
for i := 0; i < 100; i++ {
results <- i
}
close(results)
}()
g.Go(func() error { // consumidor — membro do grupo
for r := range results {
if r == 5 {
return fmt.Errorf("falha ao salvar")
}
}
return nil
})
err := g.Wait() // retorna no erro do consumidor
Quando o consumidor retorna, gctx cancela e Wait retorna. O produtor está parado em results <- i — e o cancelamento de context não desbloqueia um envio de canal. Ninguém possui essa goroutine, então ela fica parada pela vida do processo. Execute esta forma em um loop e o vazamento é exatamente linear:
| Variante | Goroutines vivas após 500 iterações |
|---|---|
Envio sem o braço ctx.Done |
501 (+500 vazadas, uma por iteração) |
Envio envolto em select com <-gctx.Done() |
502 (+1 de base) |
// a correção: toda operação de canal dentro de uma tarefa cancelável
// recebe um braço Done
select {
case results <- i:
case <-gctx.Done():
return
}
A mesma lacuna existe no lado do recebimento. A regra é mecânica: toda operação de canal dentro de código que pode ser cancelada recebe um braço <-ctx.Done(), e toda goroutine tem um proprietário que aguarda por ela ou a cancela. O goleak no CI captura os casos que a revisão de código perde — ele se encaixa no mesmo portão de qualidade automatizado como as ferramentas em Linters para Go: Ferramentas Essenciais para Qualidade de Código.
Duas formas relacionadas merecem ser nomeadas. Se o produtor é um membro do grupo, o mesmo código produz não um vazamento, mas um deadlock — o Wait bloqueia para sempre aguardando o envio parado, o que pelo menos falha de forma audível. E um produtor que envia com select mas recebe em um canal cujo consumidor saiu vaza da mesma forma; os braços Done pertencem a ambos os lados.
errgroup.SetLimit: fan-out limitado com falha rápida
SetLimit transforma um errgroup em um grupo com controle de admissão. Cada chamada Go além do limite bloqueia até que um slot se liberte:
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
for _, item := range items {
g.Go(func() error {
return process(ctx, item)
})
}
err := g.Wait()
Três semânticas importam em produção. Go bloqueia, então o loop acima aplica contrapressão ao produtor — geralmente é isso que se quer, mas significa que a goroutine do chamador pode travar, então um manipulador de requisição alimentando um lote grande precisa de seu próprio orçamento de timeout ao redor do loop inteiro. SetLimit(0) bloqueia toda chamada Go para sempre. E mudar o limite enquanto goroutines estão ativas causa panic — o limite é fixo pela vida do grupo.
SetLimit herda a semântica de grupo: o primeiro erro cancela o context e Wait retorna o primeiro erro, descartando resultados de trabalho ainda em andamento. É a primitiva certa quando falhar toda a operação na primeira falha é correto — buscar uma página de dados, chamar um conjunto de verificações de saúde independentes, distribuir uma requisição para shards.
Canais semáforo: contrapressão como recurso
Um canal com buffer de capacidade n é um semáforo sem dependências:
sem := make(chan struct{}, 8)
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
sem <- struct{}{} // bloqueia quando 8 tarefas estão em andamento
go func() {
defer wg.Done()
defer func() { <-sem }()
_ = process(ctx, item)
}()
}
wg.Wait()
A aquisição acontece antes da declaração go, então goroutines só são criadas quando um slot existe. O tamanho do buffer é o contrato: é simultaneamente o teto de concorrência e a fila de admissões pendentes.
A primitiva justifica seu lugar quando você precisa de controle de admissão que os grupos não expressam — aquisição consciente de context, pools por dependência (downstream) ou resultados parciais em falha:
select {
case sem <- struct{}{}: // slot adquirido
case <-ctx.Done(): // chamador desistiu antes de entrar
return ctx.Err()
}
Medido na mesma carga de trabalho, o canal semáforo e SetLimit(8) terminam dentro de 1,5% um do outro — a escolha entre eles é semântica, não desempenho.
Pools de workers: para fluxos, não para lotes
Um pool fixo de workers de longa duração separa a admissão (a fila) da execução (os workers):
func pool(ctx context.Context, workers int) chan<- func() {
jobs := make(chan func())
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case f, ok := <-jobs:
if !ok {
return
}
f()
case <-ctx.Done():
return
}
}
}()
}
return jobs
}
Pools se ajustam a trabalho que chega continuamente — consumidores de fila, loops de sondagem, workers de requisição — onde um grupo por lote seria criado e destruído continuamente. Desde o Go 1.25, sync.WaitGroup.Go remove a burocracia do par Add/Done para a forma comum. O contrato de desligamento é a parte a acertar: fechar jobs termina os workers após a fila drenar; cancelar ctx abandona o trabalho na fila. Ambos são legítimos, mas apenas um pode ser o comportamento documentado.
Filas limitadas: bloquear ou descartar
Um canal com buffer é também uma fila com um teto. Quando o buffer enche, o produtor bloqueia — e aquele momento é a decisão de design. Bloquear propaga a pressão para cima, para quem alimenta a fila; descartar alivia carga ao custo de trabalho perdido. Um pipeline de monitoramento pode descartar; um pipeline de pedidos deve bloquear ou derramar para armazenamento durável.
select {
case queue <- event: // admitido
default: // fila cheia: a política de descarte vive aqui
dropped.Add(1)
}
Qualquer que seja a política, deixe-a explícita e medida. Um ramo default silencioso é onde eventos vão para desaparecer.
O que a limitação custa — medido
A mesma carga de trabalho, 10.000 tarefas de 5 ms de I/O simulada cada, Go 1.27.1:
| Estratégia | Pico de goroutines | Tempo real |
|---|---|---|
go ilimitado por tarefa |
~10.000 lançadas | 18 ms |
errgroup.SetLimit(8) |
8 | 6,9 s |
| Canal semáforo (cap 8) | 8 | 6,6 s |
A execução ilimitada vence no tempo real porque nada no experimento faz contrapressão — dez mil goroutines de timer simplesmente dormem em paralelo. É exatamente essa a armadilha. O custo do fan-out ilimitado nunca é CPU no seu próprio processo; são dez mil conexões simultâneas, uma dependência que começa a dar timeout sob o impulso, e um gráfico de memória que segue a profundidade da fila de quem você está chamando. Em uma dependência real, a execução ilimitada não termina em 18 ms — ela dá timeout. As execuções limitadas pagam 6,6 segundos porque 10.000 tarefas ÷ 8 slots × 5 ms é 6,25 s de serialização inevitável, e o teto é o ponto: ele transforma um impulso não controlado em um escoamento previsível de 6,25 segundos.
Um refinamento importa quando o teto é maior que um punhado de slots: mantenha o teto igual ou inferior ao que a dependência pode sustentar em concorrência, e derive-o desse limite (tamanho do pool de conexões, limite de taxa, capacidade de workers) em vez de uma sensação sobre paralelismo “razoável”.
Medir e testar código limitado
Três sinais cobrem a maior parte. Tendências de contagem de goroutines (/sched/goroutines:goroutines exportado via colecionador Go do Prometheus) capturam vazamentos como uma inclinação. Latência do agendador (/sched/latencies:seconds) captura saturação — goroutines executáveis esperando por tempo de CPU é pressão real, independentemente da contagem. Um delta pprof de dois despejos de goroutines aponta onde as goroutines paradas vivem.
Para os testes, workers limitados são o caso para o qual Testando Código Concorrente em Go com synctest foi feito: o tempo falso faz uma drenagem completa de 10.000 tarefas correr em milissegundos, synctest.Wait substitui dormir-e-torcer por quiescência, e uma bolha falha rápido quando um worker fica permanentemente bloqueado — o reprodutor de vazamento acima morre em uma bolha de teste em vez de em produção.
Na escala de serviço, a mesma pergunta de limitação reaparece entre serviços, onde o semáforo se torna um limite de taxa e o circuit breaker protege a dependência — Padrão Circuit Breaker em Go cobre aquela camada, e Microsserviços Go para Orquestração de IA/ML mostra as formas baseadas em fila que a camada de orquestração assume.
Escolhendo
na primeira falha?} D -- sim --> E[WaitGroup + errors.Join
ou canal semáforo] D -- não --> F[errgroup.WithContext] F --> G{Precisa de um teto de concorrência?} G -- sim --> H[errgroup.SetLimit] G -- não --> I[grupo sem limite
apenas quando o lote é comprovadamente pequeno] C --> J{Fila cheia: bloquear ou descartar?} J -- bloquear --> K[Contrapressão para cima] J -- descartar --> L[Aliviar carga, contar os descartos]
A tabela e o diagrama de fluxo se comprimem em um hábito: antes de escrever go, nomeie o teto e o proprietário. O teto é SetLimit, um semáforo ou uma fila limitada; o proprietário é um Wait, um braço ctx.Done em toda operação de canal, ou ambos. Todo ponto de criação de goroutine que pode responder esses dois nomes num relance é aquele que não vai chamar ninguém às 3h da manhã.