← Voltar para o blog

sync em Go: Mutex, WaitGroup, RWMutex e Once

sync.Mutex, RWMutex, WaitGroup, Once e atomic em Go: quando usar cada primitiva, como evitar deadlock e race condition, com exemplos práticos.

Resposta rápida: o pacote sync é a caixa de ferramentas de sincronização em memória do Go. Use Mutex para seções críticas curtas, RWMutex só quando leitura domina de verdade, WaitGroup para esperar um conjunto conhecido de goroutines, Once para inicialização lazy por processo e atomic para contadores/flags simples. Channels resolvem comunicação; sync resolve estado compartilhado. Em produção, meça com -race, evite lock ordering inconsistente e prefira designs que reduzam o estado compartilhado em vez de empilhar locks.

Este guia fecha o espaço entre o tutorial de concorrência, o guia de channels e o de errgroup: o dia a dia de Mutex, RWMutex, WaitGroup, Once e atomic com exemplos copy-paste, armadilhas e checklist.

Comparação rápida das primitivas

PrimativaProblema que resolveEvite quando…
sync.MutexExclusão mútua de estado compartilhadoA seção crítica é longa ou faz I/O
sync.RWMutexMuitos leitores, poucos escritoresO workload não é read-heavy mensurável
sync.WaitGroupEsperar N tarefas terminaremVocê precisa cancelar/propagar o 1º erro → use errgroup
sync.OnceInicializar um recurso uma vez por processoPrecisa de reset, ou coordenação entre pods
sync/atomicContadores, flags, swaps de ponteiroInvariantes multi-campo
ChannelPassar ownership / eventosSó para “proteger um int”

Mutex: a seção crítica bem-feita

Mutex garante que no máximo uma goroutine executa o bloco protegido. O padrão idiomático é embedar o mutex junto do estado que ele protege e nunca exportar o mutex solto.

package cache

import "sync"

type Counter struct {
	mu sync.Mutex
	n  int
}

func (c *Counter) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
}

func (c *Counter) Value() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.n
}

Regras práticas:

  1. Segure o lock o mínimo possível. Não faça HTTP, SQL ou sleep com o mutex preso — isso transforma um contador em bottleneck global.
  2. Sempre Unlock via defer na mesma função que fez Lock, salvo padrões avançados (e bem documentados) de unlock manual.
  3. Não copie structs que contêm Mutex. Passe ponteiro. Copiar o mutex (e o estado) quebra a exclusão mútua.
  4. Documente o invariante. Um comentário // mu protege n e dirty evita que o próximo commit acesse o campo “só rapidinho” sem lock.

Para um mapa concurrent-safe simples:

type Store struct {
	mu sync.Mutex
	m  map[string]string
}

func NewStore() *Store {
	return &Store{m: make(map[string]string)}
}

func (s *Store) Set(k, v string) {
	s.mu.Lock()
	s.m[k] = v
	s.mu.Unlock()
}

func (s *Store) Get(k string) (string, bool) {
	s.mu.Lock()
	v, ok := s.m[k]
	s.mu.Unlock()
	return v, ok
}

Se o mapa for o hot path de leitura com poucas escritas, avalie RWMutex ou um snapshot imutável trocado via atomic.Value / atomic.Pointer.

RWMutex: só quando a leitura realmente domina

RLock permite vários leitores; Lock é exclusivo. O custo do RWMutex é maior que o do Mutex simples — por isso a regra é medir, não “otimizar no feeling”.

type ConfigView struct {
	mu   sync.RWMutex
	data map[string]string
}

func (c *ConfigView) Get(k string) (string, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	v, ok := c.data[k]
	return v, ok
}

func (c *ConfigView) Replace(next map[string]string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.data = next
}

Armadilhas clássicas:

  • Upgrade de lock: segurar RLock e depois tentar Lock na mesma goroutine deadlocka. Solte o RLock, adquira Lock, ou redesenhe.
  • Escritor faminto: leitores contínuos podem adiar o escritor. Se isso importa, prefira snapshot imutável.
  • Seção crítica “quase” read-only que muda um campo: isso é escrita — use Lock.

Quando o padrão “trocar o mundo de uma vez” funciona melhor:

package configsnap

import "sync/atomic"

type Snapshot struct {
	v atomic.Pointer[map[string]string]
}

func (s *Snapshot) Load() map[string]string {
	m := s.v.Load()
	if m == nil {
		return map[string]string{}
	}
	return *m
}

func (s *Snapshot) Store(m map[string]string) {
	// callers não devem mutar m depois do Store
	s.v.Store(&m)
}

Leituras ficam lock-free; escritas publicam um novo mapa. É o padrão de configuração e feature flags em muitos serviços.

WaitGroup: fan-out com contagem conhecida

WaitGroup espera um número conhecido de goroutines. É a ferramenta certa para “dispare N workers e junte o resultado”, desde que você não precise cancelar no primeiro erro — nesse caso, use errgroup.

package main

import (
	"fmt"
	"sync"
)

func main() {
	urls := []string{"a", "b", "c"}
	var wg sync.WaitGroup

	wg.Add(len(urls)) // Add ANTES do go
	for _, u := range urls {
		u := u
		go func() {
			defer wg.Done()
			fmt.Println("processou", u)
		}()
	}
	wg.Wait()
}

Checklist de WaitGroup:

  1. Add antes de go (ou um Add(n) único com n conhecido).
  2. Exatamente um Done por unidade adicionada — defer wg.Done() é o default seguro.
  3. Não chame Add com delta negativo “criativo” para sinalizar; use Done.
  4. Depois de Wait, não reutilize o mesmo WaitGroup sem garantir contagem zero. Em Go 1.25+ existe WaitGroup.Go (atalho para Add+goroutine+Done) — confira a doc da sua versão antes de adotar.

Para pipelines com cancelamento, prefira errgroup + context (timeout e cancelamento).

Once: inicialização lazy por processo

Once garante que uma função rode no máximo uma vez. Ideal para clientes HTTP compartilhados, parsers de regex caros, ou registro de métricas.

package httpclient

import (
	"net/http"
	"sync"
	"time"
)

var (
	once   sync.Once
	client *http.Client
)

func HTTPClient() *http.Client {
	once.Do(func() {
		client = &http.Client{
			Timeout: 10 * time.Second,
			Transport: &http.Transport{
				MaxIdleConns:        100,
				MaxIdleConnsPerHost: 10,
			},
		}
	})
	return client
}

Limitações importantes:

  • Sem reset. Se a inicialização falhar, Once ainda marca como executado. Capture o erro fora ou use um padrão Once + atomic.Value de resultado.
  • Por processo. Em Kubernetes, cada pod inicializa o seu. Não é lock distribuído.
  • Não use para “rodar uma vez por request key” — isso é singleflight.

Padrão com erro:

package lazydb

import (
	"context"
	"database/sql"
	"os"
	"sync"
)

type lazyDB struct {
	once sync.Once
	db   *sql.DB
	err  error
}

func (l *lazyDB) Conn(ctx context.Context) (*sql.DB, error) {
	l.once.Do(func() {
		l.db, l.err = sql.Open("pgx", os.Getenv("DATABASE_URL"))
		if l.err != nil {
			return
		}
		l.err = l.db.PingContext(ctx)
	})
	return l.db, l.err
}

Se Ping falhar na primeira chamada, todas as seguintes recebem o mesmo erro — pode ser o desejado (fail fast) ou não (aí você precisa de retry controlado fora do Once).

atomic: contadores e flags sem Mutex

Para métricas locais, circuit breakers simples e flags, sync/atomic evita o custo do mutex:

package stats

import "sync/atomic"

type Stats struct {
	hits atomic.Int64
}

func (s *Stats) Hit() { s.hits.Add(1) }

func (s *Stats) Hits() int64 { return s.hits.Load() }

Use atomic.Bool, atomic.Int64, atomic.Pointer[T] e atomic.Value da biblioteca padrão. Regras:

  1. Um campo, uma operação. Não construa invariantes “a + b” com dois atomics separados sem barreira clara.
  2. Não misture atomic e Mutex no mesmo campo.
  3. Para mapas ou slices, prefira Mutex, concurrent map dedicado, ou snapshot via atomic.Pointer.

Mutex vs channel: a decisão que evita overengineering

A frase clássica — “não comunique compartilhando memória; compartilhe memória comunicando” — é um norte, não um dogma.

Prefira sync quando:

  • Várias goroutines leem/escrevem o mesmo cache, registry ou contador.
  • A seção crítica é curta e local.
  • Você está protegendo invariantes de uma struct.

Prefira channels quando:

  • Uma goroutine “dona” do recurso recebe comandos (actor model leve).
  • Você está montando pipeline, fan-in/fan-out, ou handoff de ownership.
  • O dado é a mensagem (jobs, eventos), não um campo a proteger.

Exemplo de actor (channel) versus mutex no mesmo problema de saldo:

package bank

// Actor: uma goroutine serializa as operações
type Bank struct {
	ops chan func(map[string]int)
}

func NewBank() *Bank {
	b := &Bank{ops: make(chan func(map[string]int))}
	go func() {
		balances := map[string]int{}
		for op := range b.ops {
			op(balances)
		}
	}()
	return b
}

Os dois estão corretos. O Mutex costuma ser mais simples para estado pequeno; o actor escala melhor quando a lógica ao redor do estado cresce (validação, journaling, métricas).

Deadlocks e races: o que o -race não perdoa

Três bugs que aparecem em code review de produção:

1. Lock ordering

Goroutine A: muAmuB. Goroutine B: muBmuA. Deadlock clássico. Ordene locks globalmente (por endereço, por nome de recurso) ou agregue o estado sob um único mutex.

Veja também a página de erro deadlock em goroutines.

2. Add depois do go

// ERRADO
go func() {
    wg.Add(1) // race com Wait
    defer wg.Done()
}()
wg.Wait()

3. Captura de variável de loop (pré-1.22) e dados sem sync

Mesmo com Go moderno, escrever em um mapa/slice compartilhado sem sincronização é data race. Rode sempre:

go test -race ./...

Em CI, trate race como falha de build. O guia de race conditions e o de testes cobrem o fluxo completo. Para observar contenção em produção, use pprof (mutex/block profiles) e go tool trace.

Padrões de produção que funcionam

Pool de workers com WaitGroup

Combine WaitGroup (junção) com channel bufferizado (fila) — o worker pool é o desenho completo. O esqueleto mínimo:

jobs := make(chan Job, 64)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		for j := range jobs {
			process(j)
		}
	}()
}
// envie jobs, feche o channel, depois:
close(jobs)
wg.Wait()

Cache com singleflight + Mutex

Mutex protege o mapa local; singleflight evita stampede na origem. São complementary, não rivais.

Graceful shutdown

No shutdown, pare de aceitar trabalho novo, feche channels de entrada e Wait nos workers — veja graceful shutdown. WaitGroup ou errgroup fecham o ciclo.

Checklist antes de mergear código concorrente

  1. Todo estado compartilhado tem dono claro (uma goroutine) ou lock/atomic documentado.
  2. Nenhum I/O dentro de seção crítica sem justificativa escrita.
  3. go test -race passa no pacote e nos vizinhos.
  4. Lock ordering documentado se houver mais de um mutex.
  5. WaitGroup: Add antes do go; Done com defer.
  6. Once não esconde erro silencioso de init.
  7. Contenção medida (benchmark ou mutex profile) se o path for quente — benchmarks.
  8. Cancelamento: se o primeiro erro deve parar o resto, use errgroup + context, não só WaitGroup.
  9. Em APIs HTTP, prefira middleware e timeouts a locks globais no handler.
  10. Leitura adicional: Effective Go 2026 e o cheatsheet de concorrência.

Quando NÃO usar sync

  • Estado por request: passe valores; não grave em globais.
  • Distribuído: Mutex local não coordena pods — use banco, Redis lock, ou fila (SQS, Redis Streams).
  • CPU pura e barata: às vezes recalcular é mais barato que sincronizar (sync.Pool e atomics existem para hot paths medidos).
  • API pública que “parece” thread-safe mas não documenta: ou documente “not concurrent-safe”, ou torne seguro de verdade.

Próximos passos

  1. Audite um serviço seu: liste globais e structs compartilhadas; marque o mecanismo de sync de cada um.
  2. Rode go test -race ./... e corrija o primeiro report — costuma ensinar mais que teoria.
  3. Leia channels em produção e errgroup para fechar o triângulo comunicação / junção / cancelamento.
  4. Se estiver montando API, combine com context, rate limiting e worker pools.
  5. Para carreira e entrevistas, pratique explicar Mutex vs channel em voz alta — cai bastante em perguntas de entrevista e no plano de carreira.

sync não é o inimigo da simplicidade: estado compartilhado mal definido é. Escolha a primitiva menor que expressa o invariante, meça contenção, e deixe channels / errgroup / filas cuidarem do resto do desenho concorrente.

Perguntas frequentes

Quando usar Mutex e quando usar channel em Go?

Use Mutex (ou atomic) quando várias goroutines compartilham o mesmo estado em memória e a seção crítica é curta. Prefira channels quando a comunicação é a mensagem em si — pipelines, fan-in, handoff de ownership. A regra prática: compartilhe memória sincronizando o acesso; não invente um channel só para proteger um contador. Detalhes no guia de channels.

Qual a diferença entre Mutex e RWMutex?

Mutex serializa todos os acessos. RWMutex permite múltiplos leitores simultâneos (RLock) e um único escritor (Lock). Só vale a pena se a leitura for claramente dominante e a seção crítica for mensurável; em hot paths curtos, Mutex simples costuma ser mais rápido e menos propenso a bugs de upgrade de lock.

WaitGroup precisa de Add antes de cada go?

Sim. Chame Add (ou um Add(n) com n conhecido) antes de lançar a goroutine. Adicionar dentro da goroutine cria race com Wait. Garanta exatamente um Done por unidade — em geral com defer wg.Done(). Se você precisa cancelar no primeiro erro, troque para errgroup.

sync.Once garante execução única entre pods?

Não. Once é por processo. Cada réplica no Kubernetes tem o próprio Once. Para inicialização única no cluster, use um lock distribuído, um Job de bootstrap ou um recurso externo. Para deduplicar trabalho por chave dentro do processo, veja singleflight.

atomic substitui Mutex?

Para contadores, flags e ponteiros únicos, atomic costuma ser suficiente e mais barato. Para invariantes que envolvem vários campos, Mutex (ou um design imutável com snapshot) é mais seguro. Misturar atomic e Mutex no mesmo estado sem disciplina clara é fonte clássica de bugs sutis — e o detector de race não salva invariantes lógicos errados.