Disparar dez goroutines para buscar dados de dez serviços diferentes e descobrir o que falhou é um problema que reaparece em todo serviço backend. Sem uma estrutura, o código vira uma mistura frágil de sync.WaitGroup, canais de erro, context.WithCancel e variáveis protegidas por mutex só para responder a uma pergunta simples: alguma goroutine falhou e, se sim, qual foi o primeiro erro? O pacote golang.org/x/sync/errgroup existe para resolver exatamente isso. Este guia mostra como usar errgroup em serviços reais, complementando o guia de concorrência com goroutines e channels, context com timeout e cancelamento, worker pool com fila de jobs e errors.Join para agregar erros.
Por que errgroup existe
O padrão manual com sync.WaitGroup funciona, mas força você a reimplementar três decisões que todo código concorrente precisa tomar:
- Qual erro vence quando várias goroutines falham.
- Quando cancelar as goroutines restantes após uma falha.
- Como limitar o paralelismo para não esgotar conexões ou memória.
Veja o equivalente manual que errgroup substitui:
func buscarTudo(ctx context.Context) ([]Resultado, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var wg sync.WaitGroup
var mu sync.Mutex
var primeiroErr error
resultados := make([]Resultado, 0, 10)
for _, svc := range servicos {
wg.Add(1)
go func(s Servico) {
defer wg.Done()
r, err := s.Buscar(ctx)
if err != nil {
mu.Lock()
if primeiroErr == nil {
primeiroErr = err
cancel()
}
mu.Unlock()
return
}
mu.Lock()
resultados = append(resultados, r)
mu.Unlock()
}(svc)
}
wg.Wait()
if primeiroErr != nil {
return nil, primeiroErr
}
return resultados, nil
}
Funciona, mas são vinte linhas de plumping para uma ideia simples. Com errgroup, o mesmo comportamento cabe em cinco:
func buscarTudo(ctx context.Context) ([]Resultado, error) {
g, ctx := errgroup.WithContext(ctx)
var mu sync.Mutex
resultados := make([]Resultado, 0, 10)
for _, svc := range servicos {
svc := svc
g.Go(func() error {
r, err := svc.Buscar(ctx)
if err != nil {
return err
}
mu.Lock()
resultados = append(resultados, r)
mu.Unlock()
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return resultados, nil
}
A diferença não é só tamanho. O errgroup garante que, assim que uma goroutine retorna erro não-nil, o contexto derivado é cancelado, e Wait devolve exatamente o primeiro erro registrado. Você não precisa mais lembrar de chamar cancel() no lugar certo.
O básico: Group, Go e Wait
Um *errgroup.Group coordena um conjunto de goroutines. O método Go dispara uma função em uma nova goroutine; o método Wait bloqueia até todas terminarem e retorna o primeiro erro não-nil.
package main
import (
"context"
"fmt"
"net/http"
"golang.org/x/sync/errgroup"
)
func main() {
g, ctx := errgroup.WithContext(context.Background())
urls := []string{
"https://api.exemplo.com/usuarios",
"https://api.exemplo.com/pedidos",
"https://api.exemplo.com/estoque",
}
for _, url := range urls {
url := url
g.Go(func() error {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("buscar %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
return fmt.Errorf("buscar %s: status %d", url, resp.StatusCode)
}
return nil
})
}
if err := g.Wait(); err != nil {
fmt.Println("falhou:", err)
} else {
fmt.Println("todas as chamadas ok")
}
}
Repare em três detalhes que aparecem em todo código de produção:
url := urldentro doforcria uma cópia por iteração. Sem isso, em versões antigas do Go todas as goroutines poderiam ver o mesmo valor deurl(desde o Go 1.22 a variável de loop é escopada por iteração, mas o hábito ainda é comum em códigos que precisam rodar em versões anteriores).http.NewRequestWithContextpropaga o contexto derivado peloerrgroup. Quando uma chamada falha, o contexto é cancelado e as demais requisições em andamento são interrompidas imediatamente, em vez de esperar o timeout delas expirar.fmt.Errorfcom%wpreserva o erro original para que o chamador possa usarerrors.Iseerrors.As. Veja o guia de tratamento de erros para os padrões completos de wrapping.
Cancelamento automático: a parte que mais surpreende
O método WithContext devolve um contexto derivado do que você passou. Esse contexto é cancelado no momento em que qualquer goroutine do grupo retorna erro não-nil, ou quando Wait retorna. Esse comportamento é o que diferencia errgroup de um WaitGroup comum.
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
// Roda por 30s, mas respeita ctx.
return processoLongo(ctx)
})
g.Go(func() error {
time.Sleep(2 * time.Second)
return errors.New("falha crítica")
})
err := g.Wait()
// processoLongo recebe ctx.Done() logo após a segunda goroutine falhar,
// mesmo que seu timeout original fosse de 30s.
Para que isso funcione de verdade, toda operação bloqueante dentro das goroutines precisa aceitar e respeitar o ctx. Se você chamar time.Sleep em vez de select com ctx.Done(), ou usar http.Get em vez de http.NewRequestWithContext, o cancelamento não tem efeito e o errgroup vira só um WaitGroup mais caro. Combine sempre com o padrão de context com timeout nas chamadas externas.
Controlando paralelismo com SetLimit
Disparar mil goroutines para chamar uma API com rate limit de 10 por segundo é a receita certa para ser bloqueado. O método SetLimit (Go 1.17+ no x/sync) limita o número máximo de goroutines ativas no grupo.
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10) // no máximo 10 goroutines rodando ao mesmo tempo
for _, item := range itens {
item := item
g.Go(func() error {
return processar(ctx, item)
})
}
if err := g.Wait(); err != nil {
return err
}
A partir de SetLimit(n), cada chamada de Go bloqueia até que existam menos de n goroutines ativas. Isso transforma o fan-out descontrolado em um pool de workers sem que você precise criar um canal de jobs. Para casos mais estruturados — fila persistente, retry, dead-letter — o padrão de worker pool com canal continua sendo a escolha certa. Use SetLimit quando o objetivo é só evitar saturar um recurso.
TryGo quando bloquear não é opção
SetLimit faz Go bloquear quando o limite está cheio. Em hot paths onde bloquear a goroutine chamadora não é aceitável, use TryGo: ele retorna false em vez de bloquear quando o limite foi atingido.
g.SetLimit(5)
for _, item := range itens {
item := item
if !g.TryGo(func() error {
return processar(ctx, item)
}) {
// Fila cheia: processe síncrono, descarte com métrica,
// ou enfileire em um buffer externo.
if err := processar(ctx, item); err != nil {
return err
}
}
}
TryGo é útil em pipelines de streaming e em servidores que não podem acumular backpressure na goroutine de entrada. Em batch jobs, prefira Go com SetLimit: a simplicidade compensa.
Padrão fan-out/fan-in com errgroup
O uso mais comum em produção é o fan-out/fan-in: disparar N chamadas concorrentes e coletar os resultados em um único slice. O errgroup cuida do erro e do cancelamento; um sync.Mutex (ou canal) cuida da coleta.
func buscarMultiplos(ctx context.Context, ids []int) ([]Resultado, error) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
resultados := make([]Resultado, 0, len(ids))
var mu sync.Mutex
for _, id := range ids {
id := id
g.Go(func() error {
r, err := repo.Buscar(ctx, id)
if err != nil {
return fmt.Errorf("id %d: %w", id, err)
}
mu.Lock()
resultados = append(resultados, r)
mu.Unlock()
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return resultados, nil
}
Atenção a duas armadilhas comuns nesse padrão:
- Ordem dos resultados. O append concorrente não preserva a ordem dos
ids. Se o chamador espera resultados na mesma ordem de entrada, pré-aloque um slice indexado (resultados := make([]Resultado, len(ids))) e escreva emresultados[i]sem mutex — índices distintos não competem. - Slice compartilhado sem mutex.
appendem um slice compartilhado sem proteção é um data race silencioso que corrompe dados sob carga. Sempre proteja com mutex ou use índices fixos.
Pipeline com estágios concorrentes
errgroup também brilha em pipelines onde cada estágio roda em sua própria goroutine e lê/escreve em channels. O grupo garante que uma falha em qualquer estágio cancele todo o pipeline.
func processarPedidos(ctx context.Context, entrada []Pedido) error {
g, ctx := errgroup.WithContext(ctx)
estagio1 := make(chan Pedido)
estagio2 := make(chan Pedido processado)
// Produtor
g.Go(func() error {
defer close(estagio1)
for _, p := range entrada {
select {
case estagio1 <- p:
case <-ctx.Done():
return ctx.Err()
}
}
return nil
})
// Worker: valida e transforma
g.Go(func() error {
defer close(estagio2)
for p := range estagio1 {
pp, err := validar(ctx, p)
if err != nil {
return err
}
select {
case estagio2 <- pp:
case <-ctx.Done():
return ctx.Err()
}
}
return nil
})
// Consumidor: persiste
g.Go(func() error {
for pp := range estagio2 {
if err := salvar(ctx, pp); err != nil {
return err
}
}
return nil
})
return g.Wait()
}
O select com ctx.Done() em cada send é o que dá ao errgroup o poder de cancelar: quando qualquer estágio retorna erro, o contexto é cancelado, e os demais estágios param no próximo send/receive em vez de travar. Sem esses selects, o pipeline pode deadlockar quando um estágio fecha cedo. Esse padrão combina bem com graceful shutdown para terminar pipelines em andamento quando o servidor recebe SIGTERM.
errgroup versus worker pool: qual usar
Uma confusão frequente é quando usar errgroup.SetLimit e quando montar um worker pool com canal. A distinção é útil:
| Critério | errgroup com SetLimit | Worker pool com canal |
|---|---|---|
| Objetivo | Limitar fan-out de chamadas independentes | Processar uma fila durável com workers fixos |
| Erro | Cancela o grupo inteiro no primeiro erro | Geralmente ignora erro de item e continua |
| Backpressure | Go bloqueia; TryGo rejeita | Canal bloqueia naturalmente |
| Retry / DLQ | Não embutido | Fácil de adicionar |
| Lifetime | Curto, por request | Longo, durante a vida do processo |
Em APIs HTTP, onde cada request processa um conjunto de itens e falha como um todo se qualquer item falhar, errgroup é a escolha natural. Em consumers de fila (SQS, RabbitMQ, Kafka), onde cada mensagem é independente e você quer workers estáveis com retry, o worker pool com canal ou um consumer group com Redis Streams caem melhor. Para mensageria com idempotência e dead-letter, veja também idempotência com retry e DLQ.
Armadilhas comuns em produção
1. Ignorar o contexto derivado
// Errado: usa o contexto original, não o derivado.
g, _ := errgroup.WithContext(ctx)
g.Go(func() error {
return http.Get(originalURL) // ctx externo, sem cancelamento
})
Sempre use o ctx devolvido por WithContext. Sem isso, uma falha em outra goroutine não cancela esta.
2. Assumir que Wait coleta todos os erros
Wait retorna apenas o primeiro erro não-nil. Os demais são descartados. Se você precisa de todos os erros (validação em lote, por exemplo), combine com errors.Join:
g, ctx := errgroup.WithContext(ctx)
var mu sync.Mutex
var todosErrs []error
for _, item := range itens {
item := item
g.Go(func() error {
if err := validar(ctx, item); err != nil {
mu.Lock()
todosErrs = append(todosErrs, fmt.Errorf("item %v: %w", item, err))
mu.Unlock()
}
return nil // não retorna erro para não cancelar o grupo
})
}
_ = g.Wait()
return errors.Join(todosErrs...)
Retornar nil de cada goroutine impede o cancelamento prematuro; coletar os erros em slice e juntar com errors.Join entrega a visão completa ao chamador. Esse padrão é detalhado no guia de errors.Join.
3. Compartilhar estado mutável sem sincronização
O exemplo clássico é o contador compartilhado:
// Errado: data race.
g, ctx := errgroup.WithContext(ctx)
count := 0
for i := 0; i < 1000; i++ {
g.Go(func() error {
count++ // race detectável com -race
return nil
})
}
g.Wait()
Use sync/atomic para contadores simples ou sync.Mutex para estruturas maiores. Para agregação de resultados, prefira slice pré-alocado com índices fixos.
4. Goroutine leak quando o chamador abandona
Se a função que cria o grupo retorna antes de Wait (por exemplo, em um handler que devolve o grupo para um canal), as goroutines podem rodar para sempre. Sempre chame Wait no mesmo escopo que cria o grupo, ou estruture o grupo como parte de um objeto com lifecycle explícito.
5. SetLimit global em grupos reutilizados
SetLimit afeta apenas o grupo onde foi chamado. Reutilizar a mesma variável *errgroup.Group entre chamadas não é seguro nem recomendado — cada operação concorrente deve criar seu próprio grupo. Para limitar concorrência entre operações (ex.: limite global de conexões outbound), use um semaphore.Weighted do x/sync ou um pool de conexões configurado no cliente HTTP.
Testando código com errgroup
Código concorrente precisa de testes que exercitem o caminho de erro e o cancelamento. O pacote errgroup é determinístico o suficiente para testar sem sleeps na maioria dos casos:
func TestFanOutCancelaNoPrimeiroErro(t *testing.T) {
g, ctx := errgroup.WithContext(context.Background())
g.Go(func() error {
<-ctx.Done()
return ctx.Err()
})
g.Go(func() error {
return errors.New("boom")
})
err := g.Wait()
if err == nil || err.Error() != "boom" {
t.Fatalf("erro inesperado: %v", err)
}
}
Para testar com dependências externas (banco, HTTP), combine com Testcontainers e table-driven tests do guia de testes em tabela. Para simular falhas de rede, injete clientes que retornam erro controlado por um canal de teste — isso evita flakes e mantém o teste rápido.
Boas práticas resumidas
- Sempre use o
ctxdevolvido porerrgroup.WithContextem todas as chamadas bloqueantes dentro das goroutines. - Limite o fan-out com
SetLimitsempre que o recurso de destino (API, banco, disco) tiver um limite real; useTryGoquando bloquear a entrada não for aceitável. - Lembre que
Waitretorna só o primeiro erro; colete erros adicionais comerrors.Joinquando o chamador precisa de todos. - Proteja qualquer estado mutável compartilhado (
sync.Mutexouatomic), mesmo que “raramente” concorrente — o race detector pega, mas só se o teste exercitar o caminho. - Prefira índices fixos em slices pré-alocados para coletar resultados em ordem, em vez de
appendconcorrente. - Escolha
errgrouppara fan-out por request com falha conjunta; escolha worker pool com canal para filas duráveis com retry e itens independentes. - Teste explicitamente o caminho de cancelamento: dispare uma goroutine que falha e verifique que as demais recebem
ctx.Done(). - Sempre chame
Waitno mesmo escopo que cria o grupo; nunca deixe goruntas órfãs.
errgroup não substitui channels, semáforos ou worker pools — ele substitui o boilerplate de coordenar erro e cancelamento entre goroutines que falham juntas. Usado onde cabe, ele reduz vinte linhas suscetíveis a bug para cinco linhas claras; usado onde não cabe (fila durável, retry, DLQ), ele esconde decisões que pertencem ao seu domínio. A diferença entre os dois casos é a mesma que separa um serviço estável de um que cria incidentes às 3h da manhã. Se você trabalha em múltiplas stacks, vale comparar com o guia de concorrência do Python Dev Brasil para ver como outras linguagens resolvem o mesmo problema de orquestração concorrente com erro.