sync.WaitGroup, Mutex e errgroup: orquestrando goroutines em Go
1. Visão geral
Executar uma goroutine em Go é muito fácil, basta adicionar a palavra-chave go. A parte mais difícil é saber quando as goroutines terminam, proteger dados compartilhados e tratar erros quando uma goroutine falha.
Este artigo aborda três ferramentas para essas três tarefas: sync.WaitGroup, sync.Mutex e errgroup.Group. Depois de ler, você saberá qual ferramenta escolher para cada situação, como escrever testes com a flag -race e quais são os erros mais comuns que deve evitar.
Os exemplos usam Go 1.22 ou superior. A partir desta versão, cada iteração de um loop for cria uma nova variável para cada iteração, então você não precisa mais escrever id := id.
2. Problema de exemplo
Suponha que você tenha uma lista de códigos de pedido (Order). Cada pedido precisa chamar um serviço lento, que leva cerca de 100ms. Se você chamar 10 pedidos em sequência, isso levará cerca de 1 segundo. Se você chamar em paralelo, levará cerca de 100ms.
Esta é uma função que simula a chamada do serviço:
package main
import (
"fmt"
"time"
)
func fetchOrder(id int) string {
time.Sleep(100 * time.Millisecond)
return fmt.Sprintf("order-%d", id)
}
Se você apenas escrever go fetchOrder(id) em um loop, a função main terminará antes que as goroutines terminem. O programa sai e os resultados são perdidos. A próxima seção resolve este problema.
3. sync.WaitGroup: esperar um grupo de goroutines
3.1. Uso básico
WaitGroup é um contador. Você aumenta o contador com Add, diminui com Done, e Wait bloqueia até que o contador chegue a 0.
func main() {
var wg sync.WaitGroup
ids := []int{1, 2, 3}
for _, id := range ids {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("fetched", fetchOrder(id))
}()
}
wg.Wait()
fmt.Println("all done")
}
O código acima funciona assim:
wg.Add(1)é chamado antes de iniciar a goroutine, na goroutine pai.defer wg.Done()garante que o contador sempre diminua, mesmo que a goroutine termine cedo ou sofra panic.wg.Wait()bloqueiamainaté que todas as três goroutines chamemDone.
O resultado pode ser assim. A ordem das linhas fetched muda entre execuções:
fetched order-2
fetched order-1
fetched order-3
all done
3.2. WaitGroup.Go em Go 1.25
A partir do Go 1.25, WaitGroup possui um novo método Go. Este método combina Add, go e Done em uma única chamada:
var wg sync.WaitGroup
for _, id := range ids {
wg.Go(func() {
fmt.Println("fetched", fetchOrder(id))
})
}
wg.Wait()
Escrever desta forma é mais conciso e evita dois erros comuns: esquecer de chamar Done, ou chamar Add dentro da goroutine.
3.3. Coletar resultados sem lock
Você geralmente precisa guardar os resultados, não apenas imprimi-los na tela. Uma forma segura é criar um slice com tamanho já definido, depois deixar cada goroutine escrever em um índice próprio:
results := make([]string, len(ids))
var wg sync.WaitGroup
for i, id := range ids {
wg.Go(func() {
results[i] = fetchOrder(id)
})
}
wg.Wait()
fmt.Println(results)
[order-1 order-2 order-3]
Nenhuma goroutine escreve no mesmo elemento, então não há data race aqui. wg.Wait() também cria uma relação happens-before. Portanto, após Wait, a goroutine pai consegue ler todos os dados que as goroutines filhas escreveram.
Aviso: Não chame
wg.Add(1)dentro de uma goroutine. Se fizer isso,Wait()pode retornar antes queAddseja chamado e retornar enquanto o trabalho ainda não foi concluído.
4. sync.Mutex: proteger dados compartilhados
4.1. Como parecer uma data race
Agora suponha que você precise somar a receita por cliente (Customer), e múltiplas goroutines atualizam um map:
revenue := map[string]float64{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Go(func() {
revenue["alice"] += 10
})
}
wg.Wait()
Este programa pode falhar com o seguinte erro:
fatal error: concurrent map writes
O caso de usar apenas uma variável int é ainda mais perigoso. O programa não falha, mas silenciosamente produz um resultado errado, por exemplo 9730 em vez de 10000. Para detectar este tipo de erro, execute com o race detector:
go run -race main.go
WARNING: DATA RACE
Write at 0x00c000012345 by goroutine 8:
...
4.2. Corrigir com Mutex
sync.Mutex garante que, a cada momento, apenas uma goroutine execute o código entre Lock e Unlock. A prática comum é agrupar o mutex com os dados que ele protege, dentro da mesma struct:
package shop
import "sync"
type RevenueTracker struct {
mu sync.Mutex
total map[string]float64
}
func NewRevenueTracker() *RevenueTracker {
return &RevenueTracker{total: make(map[string]float64)}
}
func (r *RevenueTracker) Add(customer string, amount float64) {
r.mu.Lock()
defer r.mu.Unlock()
r.total[customer] += amount
}
func (r *RevenueTracker) Total(customer string) float64 {
r.mu.Lock()
defer r.mu.Unlock()
return r.total[customer]
}
Alguns pontos a notar:
- Todo acesso a
total, inclusive leitura, deve passar por lock. defer r.mu.Unlock()garante que o lock sempre seja liberado, mesmo que a função tenha múltiplosreturn.- Os métodos usam receiver com ponteiro (
*RevenueTracker). Se usar receiver por valor, cada chamada copiará o mutex inteiro, e aquele lock não protegeria mais nada.
Quando a quantidade de leitura é muito maior que a de escrita, você pode usar sync.RWMutex. Os métodos de leitura chamam RLock/RUnlock, enquanto os métodos de escrita chamam Lock/Unlock. Múltiplas goroutines podem ler simultaneamente, mas durante a escrita apenas uma goroutine mantém o lock.
Aviso: Não copie uma struct contendo
Mutexapós já tê-la usado. O comandogo vetalertará sobre isso com a mensagemcopylocks. Além disso, Go não possui reentrant locks: se uma goroutine chamarLock()duas vezes no mesmo mutex, ela sofrerá deadlock.
4.3. Quando não precisa de Mutex
Para um contador simples, sync/atomic é mais conciso e rápido:
var processed atomic.Int64
processed.Add(1)
fmt.Println(processed.Load())
Quando dados precisam fluir de uma goroutine para outra em forma de stream, channel é frequentemente mais apropriado. Mutex é mais adequado quando você tem um estado compartilhado, como um map ou struct, e múltiplas goroutines precisam ler ou escrever nele.
5. errgroup: WaitGroup com tratamento de erros
WaitGroup não sabe nada sobre erros. Se fetchOrder pode retornar um error, você precisa coletar os erros manualmente usando mutex ou channel, e cancelar as goroutines restantes. O pacote errgroup faz isso tudo para você.
5.1. Adicionar dependência
errgroup está no módulo golang.org/x/sync, não faz parte da biblioteca padrão:
go get golang.org/x/sync
5.2. Agregar erros e cancelar com context
O primeiro passo é reescrever fetchOrder para aceitar context.Context e poder retornar um erro:
package shop
import (
"context"
"fmt"
"time"
"golang.org/x/sync/errgroup"
)
type Order struct {
ID int
Customer string
}
func fetchOrder(ctx context.Context, id int) (Order, error) {
if id < 0 {
return Order{}, fmt.Errorf("invalid order id %d", id)
}
select {
case <-time.After(100 * time.Millisecond):
return Order{ID: id, Customer: "alice"}, nil
case <-ctx.Done():
return Order{}, ctx.Err()
}
}
func LoadOrders(ctx context.Context, ids []int) ([]Order, error) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(3)
orders := make([]Order, len(ids))
for i, id := range ids {
g.Go(func() error {
o, err := fetchOrder(ctx, id)
if err != nil {
return err
}
orders[i] = o
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return orders, nil
}
O código acima funciona assim:
errgroup.WithContext(ctx)cria um grupo e um context filho. Assim que uma goroutine retorna o primeiro erro, o context filho é cancelado.g.SetLimit(3)permite no máximo 3 goroutines rodando simultaneamente. A próxima chamada ag.Gobloqueará até haver espaço disponível. Assim você evita sobrecarregar o serviço quando a lista tem dezenas de milhares de códigos de pedido.g.Goaceita uma função do tipofunc() error, e você não precisa chamarAdd/Done.g.Wait()espera todas as goroutines terminarem e retorna o primeiro erro. Erros que acontecem depois são ignorados.fetchOrderescutactx.Done(), então as goroutines restantes param mais cedo em vez de rodar os 100ms completos.
Chamando a função com um código de pedido inválido:
_, err := shop.LoadOrders(context.Background(), []int{1, 2, -5, 4})
fmt.Println(err)
invalid order id -5
Se você precisar tentar rodar sem bloquear ao atingir o limite, use g.TryGo(f). Esta função retorna false em vez de esperar.
Aviso: Cancelar o context apenas funciona quando o código dentro da goroutine verifica ativamente
ctx. SefetchOrderusartime.Sleepsem escutar o context, a goroutine ainda rodará o tempo inteiro mesmo se o grupo falhar.
6. Escrever testes com -race
Go não possui JUnit ou AssertJ. Você usa o pacote testing da biblioteca padrão. O mais importante é sempre rodar testes com a flag -race, pois muitos erros de concorrência só aparecem quando o race detector está ativo.
package shop
import (
"context"
"sync"
"testing"
)
func TestRevenueTracker_ConcurrentAdd(t *testing.T) {
tracker := NewRevenueTracker()
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Go(func() {
tracker.Add("alice", 10)
})
}
wg.Wait()
if got := tracker.Total("alice"); got != 10000 {
t.Fatalf("Total(alice) = %v, want 10000", got)
}
}
func TestLoadOrders_ReturnsAllOrders(t *testing.T) {
orders, err := LoadOrders(context.Background(), []int{1, 2, 3})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for i, o := range orders {
if o.ID != i+1 {
t.Errorf("orders[%d].ID = %d, want %d", i, o.ID, i+1)
}
}
}
func TestLoadOrders_StopsOnFirstError(t *testing.T) {
orders, err := LoadOrders(context.Background(), []int{1, -1, 3})
if err == nil {
t.Fatal("expected error, got nil")
}
if orders != nil {
t.Errorf("expected nil orders, got %v", orders)
}
}
Rodando os testes:
go test -race ./...
ok example.com/shop 0.412s
Tente remover as duas linhas Lock/Unlock em Add e rodar novamente. O teste falhará com WARNING: DATA RACE ou com crash devido a concurrent map writes. Esta é uma forma rápida de verificar que o mutex é realmente necessário.
O segundo teste também confirma que a ordem dos resultados é mantida corretamente: orders[i] corresponde a ids[i], mesmo que as goroutines terminem em qualquer ordem.
7. Erros comuns e como escolher a ferramenta
Os erros mais comuns são:
- Chamar
Adddentro da goroutine.Waitpode retornar muito cedo. ChameAddantes da instruçãogo, ou usewg.Go. - Passar
WaitGroupouMutexpor valor. A função recebe uma cópia, e o contador ou lock original não muda. Passe um ponteiro*sync.WaitGroup, ou use closure. - Manter lock enquanto faz I/O. Por exemplo, chamar HTTP dentro de
Lock. Toda outra goroutine fica aguardando na fila, e o programa perde o benefício de rodar em paralelo. Segure o lock apenas no trecho de código mais curto possível. - Usar
errgroup.Groupvazia (var g errgroup.Group) esperando que o context seja cancelado. ApenasWithContextcria um context que pode ser cancelado. - Ignorar o valor retornado por
g.Wait(). Quando isso acontece, o erro desaparece sem que ninguém saiba. - Iniciar um número ilimitado de goroutines para dezenas de milhares de requisições de fora. Use
SetLimitou um worker pool.
Como escolher a ferramenta:
| Situação | Ferramenta |
|---|---|
| Esperar múltiplas tarefas que não retornam erro | sync.WaitGroup |
| Múltiplas goroutines modificam um map ou struct compartilhado | sync.Mutex / sync.RWMutex |
| Contador simples ou flag | sync/atomic |
| Tarefas que podem falhar, precisam ser canceladas cedo, limitar goroutines | errgroup |
Aviso:
errgroupguarda apenas o primeiro erro. Se você precisa conhecer todos os erros, por exemplo para relatar qual pedido falhou, deixe cada goroutine escrever seu erro emerrs[i], depois agregue comerrors.Join(errs...)após chamarWait.
8. Conclusão
sync.WaitGroup responde à pergunta “quando termina?”. sync.Mutex responde à pergunta “quem pode modificar os dados agora?”. errgroup combina esperar com tratamento de erros, cancelamento via context e limite de goroutines rodando simultaneamente.
Em código real, ao chamar múltiplos serviços em paralelo, errgroup.WithContext combinado com SetLimit geralmente é a escolha padrão mais sensata. Qualquer que seja a ferramenta usada, sempre rode go test -race. Esta é a forma mais econômica de detectar data races antes deles chegarem à produção.