← Voltar para o blog

errgroup em Go: Concorrência com Cancelamento e Coleta de Erros

Aprenda errgroup em Go para orquestrar goroutines com cancelamento automático, controle de paralelismo e coleta do primeiro erro. Padrões fan-out/fan-in, SetLimit, TryGo e armadilhas de produção.

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:

  1. Qual erro vence quando várias goroutines falham.
  2. Quando cancelar as goroutines restantes após uma falha.
  3. 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 := url dentro do for cria uma cópia por iteração. Sem isso, em versões antigas do Go todas as goroutines poderiam ver o mesmo valor de url (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.NewRequestWithContext propaga o contexto derivado pelo errgroup. 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.Errorf com %w preserva o erro original para que o chamador possa usar errors.Is e errors.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:

  1. 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 em resultados[i] sem mutex — índices distintos não competem.
  2. Slice compartilhado sem mutex. append em 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érioerrgroup com SetLimitWorker pool com canal
ObjetivoLimitar fan-out de chamadas independentesProcessar uma fila durável com workers fixos
ErroCancela o grupo inteiro no primeiro erroGeralmente ignora erro de item e continua
BackpressureGo bloqueia; TryGo rejeitaCanal bloqueia naturalmente
Retry / DLQNão embutidoFácil de adicionar
LifetimeCurto, por requestLongo, 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 ctx devolvido por errgroup.WithContext em todas as chamadas bloqueantes dentro das goroutines.
  • Limite o fan-out com SetLimit sempre que o recurso de destino (API, banco, disco) tiver um limite real; use TryGo quando bloquear a entrada não for aceitável.
  • Lembre que Wait retorna só o primeiro erro; colete erros adicionais com errors.Join quando o chamador precisa de todos.
  • Proteja qualquer estado mutável compartilhado (sync.Mutex ou atomic), 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 append concorrente.
  • Escolha errgroup para 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 Wait no 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.