---
title: "errgroup em Go: Concorrência com Cancelamento e Coleta de Erros"
url: "https://golang.com.br/blog/errgroup-go-concorrencia-cancelamento-erros/"
markdown_url: "https://golang.com.br/blog/errgroup-go-concorrencia-cancelamento-erros.MD"
description: "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."
date: "2026-06-20"
author: "Golang Brasil"
---

# 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](/aprenda/concorrencia-go/), [context com timeout e cancelamento](/blog/context-timeout-cancelamento-go/), [worker pool com fila de jobs](/blog/worker-pool-go-fila-jobs/) e [errors.Join para agregar erros](/blog/errors-join-go-multiplos-erros-producao/).

## 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:

```go
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:

```go
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.

```go
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](/aprenda/golang-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.

```go
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](/blog/context-timeout-cancelamento-go/) 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.

```go
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](/blog/worker-pool-go-fila-jobs/) 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.

```go
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.

```go
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.

```go
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](/blog/graceful-shutdown-go-producao/) 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](/blog/worker-pool-go-fila-jobs/) ou um [consumer group com Redis Streams](/blog/redis-streams-go-filas-consumer-groups/) caem melhor. Para mensageria com idempotência e dead-letter, veja também [idempotência com retry e DLQ](/blog/idempotencia-retry-dlq-go/).

## Armadilhas comuns em produção

### 1. Ignorar o contexto derivado

```go
// 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`:

```go
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](/blog/errors-join-go-multiplos-erros-producao/).

### 3. Compartilhar estado mutável sem sincronização

O exemplo clássico é o contador compartilhado:

```go
// 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:

```go
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](/blog/go-testcontainers-testes-integracao-containers/) e table-driven tests do [guia de testes em tabela](/blog/testes-tabela-go-guia-table-driven-tests/). 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 <a href="https://python.dev.br/blog/concorrencia-asyncio/" target="_blank" rel="noopener noreferrer" onclick="umami.track('portfolio-site-click', { destination: 'python.dev.br' })">guia de concorrência do Python Dev Brasil</a> para ver como outras linguagens resolvem o mesmo problema de orquestração concorrente com erro.
