Limitando a Concorrência 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 na concorrência em Go: cada declaração go é mais uma tarefa em execução, sem limite e sem responsável. Este artigo trata de fornecer ambos.
Cancelamento e limitação são controles separados. O cancelamento decide quando o trabalho para; a limitação decide quanta parte dele existe ao mesmo tempo. Um serviço pode cancelar perfeitamente e ainda assim falhar porque dez mil goroutines decidiram, independentemente, que agora 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 forma certa trata o contexto 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 — além da única forma de vazamento que sobrevive ao errgroup.Wait mesmo quando todos os caminhos de cancelamento parecem configurados.
Cinco primitivos, uma tabela de decisão
| Primitivo | Resultados parciais | Falha rápida | Backpressure | Trabalho dinâmico | Melhor para |
|---|---|---|---|---|---|
sync.WaitGroup + errors.Join |
sim | não | não | não | lotes de espera total onde todos os resultados contam |
errgroup.WithContext |
não | sim | não | não | fan-out onde o primeiro erro encerra a requisição |
errgroup.SetLimit(n) |
não | sim | sim (bloqueia Go) |
não | fan-out de falha rápida limitado |
| Canal semáforo | sim | manual | sim (bloqueia na aquisição) | sim | controle de admissão personalizado |
| Pool de workers | sim | manual | sim (fila enche) | sim | fluxos contínuos, 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 com WaitGroup viável — ele coleta todos os erros de cada worker em vez de manter apenas o primeiro, o que se 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 ela 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 é dono desta 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 é cancelado e Wait retorna. O produtor está estacionado em results <- i — e o cancelamento de contexto não desbloqueia um envio em canal. Ninguém é dono daquela goroutine, então ela permanece estacionada pela vida do processo. Execute essa forma em um loop e o vazamento é exatamente linear:
| Variante | Goroutines vivas após 500 iterações |
|---|---|
Envio sem um braço de 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 em canal dentro de uma tarefa
// cancelável recebe um braço de Done
select {
case results <- i:
case <-gctx.Done():
return
}
A mesma lacuna existe no lado do recebimento. A regra é mecânica: toda operação em canal dentro de código que pode ser cancelado recebe um braço de <-ctx.Done(), e toda goroutine tem um dono que espera por ela ou a cancela. O goleak no CI pega os casos que a revisão de código perde — ele se encaixa na mesma porta de qualidade automatizada como as ferramentas em Go Linters: Ferramentas Essenciais para Qualidade de Código.
Duas formas relacionadas valem a pena serem nomeadas. Se o produtor é um membro do grupo, o mesmo código produz não um vazamento, mas um deadlock — Wait bloqueia para sempre esperando pelo envio estacionado, o que pelo menos falha de forma barulhenta. E um produtor que envia com select mas recebe em um canal cujo consumidor saiu vaza da mesma forma; os braços de Done pertencem a ambos os lados.
errgroup.SetLimit: fan-out de falha rápida limitado
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 backpressure ao produtor — isso é geralmente o que você quer, mas significa que a goroutine do chamador pode travar, então um handler de requisição alimentando um lote grande precisa do seu próprio orçamento de timeout ao redor de todo o loop. 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 contexto e Wait retorna o primeiro erro, descartando resultados de trabalho ainda em execução. É a primitiva certa quando falhar a operação inteira na primeira falha é correto — buscar uma página de dados, chamar um conjunto de verificações de saúde independentes, enviar uma requisição para shards.
Canais semáforo: backpressure 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 execução
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 contexto, pools por 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 performance.
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 encaixam em trabalho que chega continuamente — consumidores de fila, loops de polling, workers de requisição — onde um grupo por lote seria criado e destruído sem fim. Desde o Go 1.25, sync.WaitGroup.Go remove o boilerplate do par Add/Done para a forma comum. O contrato de desligamento é a parte que precisa estar certa: fechar jobs termina os workers após a fila drenar; cancelar ctx abandona o trabalho enfileirado. Ambos são legítimos, mas apenas um pode ser o comportamento documentado. O Go 1.27 continua na mesma direção — métodos genéricos, novos modernizadores go fix, e um httptest.NewTestServer em memória — tudo coberto no guia de upgrade do Go 1.27.
Filas limitadas: bloquear ou descartar
Um canal com buffer também é uma fila com um teto. Uma vez que o buffer enche, o produtor bloqueia — e esse momento é a decisão de design. Bloquear propaga a pressão a montante para quem alimenta a fila; descartar reduz a carga ao custo de trabalho perdido. Um pipeline de monitoramento pode descartar; um pipeline de pedidos deve bloquear ou despejar em armazenamento durável.
select {
case queue <- event: // admitido
default: // fila cheia: a política de descarte vive aqui
dropped.Add(1)
}
Independentemente da política, faça-a explícita e medida. Um ramo default silencioso é para onde os eventos vão desaparecer.
O que limitação custa — medido
A mesma carga de trabalho, 10.000 tarefas de 5 ms de I/O simulado cada, Go 1.27.1:
| Estratégia | Pico de goroutines | Tempo de execução |
|---|---|---|
go por tarefa ilimitado |
~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 em tempo de execução porque nada no experimento empurra de volta — dez mil goroutines de timer simplesmente dormem em paralelo. Essa é exatamente a armadilha. O custo do fan-out ilimitado nunca é CPU no seu próprio processo; são dez mil conexões simultâneas, um downstream que começa a dar timeout sob o burst, 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 são 6,25 s de serialização inevitável, e o teto é o ponto: ele transforma um burst não controlado em um dreno previsível de 6,25 segundos.
Um refinamento importa quando o teto é maior que um punhado de slots: mantenha o teto no máximo o que o downstream pode sustentar concorrentemente, e derive-o desse limite (tamanho do pool de conexões, limite de taxa, capacidade de worker) em vez de de uma sensação sobre paralelismo “razoável”.
Medindo e testando código limitado
Três sinais cobrem a maior parte disso. Tendências de contagem de goroutines (/sched/goroutines:goroutines exportado via coletor Go do Prometheus) pegam vazamentos como uma inclinação. Latência do agendador (/sched/latencies:seconds) pega 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 estacionadas vivem.
Para os testes, workers limitados são o caso para o qual Testando Código Go Concorrente com synctest foi construído: tempo falso faz um dreno completo de 10.000 tarefas rodar em milissegundos, synctest.Wait substitui sleep-and-hope para quiescência, e uma bolha falha rápido quando um worker permanece bloqueado de forma durável — a reprodução do vazamento acima morre em uma bolha de teste em vez de em produção.
Em 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 o downstream — Padrão Circuit Breaker em Go cobre essa 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[Backpressure a montante] J -- descartar --> L[Reduzir carga, contar os descartes]
A tabela e o fluxograma comprimem-se em um único hábito: antes de escrever go, nomeie o teto e o dono. O teto é SetLimit, um semáforo, ou uma fila limitada; o dono é um Wait, um braço de ctx.Done em toda operação de canal, ou ambos. Todo local de criação de goroutine que possa responder esses dois nomes num relance é um que não fará ninguém acordar às 3 da manhã.