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
| Primativa | Problema que resolve | Evite quando… |
|---|---|---|
sync.Mutex | Exclusão mútua de estado compartilhado | A seção crítica é longa ou faz I/O |
sync.RWMutex | Muitos leitores, poucos escritores | O workload não é read-heavy mensurável |
sync.WaitGroup | Esperar N tarefas terminarem | Você precisa cancelar/propagar o 1º erro → use errgroup |
sync.Once | Inicializar um recurso uma vez por processo | Precisa de reset, ou coordenação entre pods |
sync/atomic | Contadores, flags, swaps de ponteiro | Invariantes multi-campo |
| Channel | Passar ownership / eventos | Só 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:
- 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.
- Sempre
Unlockviadeferna mesma função que fezLock, salvo padrões avançados (e bem documentados) de unlock manual. - Não copie structs que contêm Mutex. Passe ponteiro. Copiar o mutex (e o estado) quebra a exclusão mútua.
- Documente o invariante. Um comentário
// mu protege n e dirtyevita 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
RLocke depois tentarLockna mesma goroutine deadlocka. Solte oRLock, adquiraLock, 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:
Addantes dego(ou umAdd(n)único com n conhecido).- Exatamente um
Donepor unidade adicionada —defer wg.Done()é o default seguro. - Não chame
Addcom delta negativo “criativo” para sinalizar; useDone. - Depois de
Wait, não reutilize o mesmo WaitGroup sem garantir contagem zero. Em Go 1.25+ existeWaitGroup.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.Valuede 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:
- Um campo, uma operação. Não construa invariantes “a + b” com dois atomics separados sem barreira clara.
- Não misture atomic e Mutex no mesmo campo.
- 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: muA → muB. Goroutine B: muB → muA. 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
- Todo estado compartilhado tem dono claro (uma goroutine) ou lock/atomic documentado.
- Nenhum I/O dentro de seção crítica sem justificativa escrita.
go test -racepassa no pacote e nos vizinhos.- Lock ordering documentado se houver mais de um mutex.
- WaitGroup:
Addantes dogo;Donecomdefer. - Once não esconde erro silencioso de init.
- Contenção medida (benchmark ou mutex profile) se o path for quente — benchmarks.
- Cancelamento: se o primeiro erro deve parar o resto, use errgroup + context, não só WaitGroup.
- Em APIs HTTP, prefira middleware e timeouts a locks globais no handler.
- 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
- Audite um serviço seu: liste globais e structs compartilhadas; marque o mecanismo de sync de cada um.
- Rode
go test -race ./...e corrija o primeiro report — costuma ensinar mais que teoria. - Leia channels em produção e errgroup para fechar o triângulo comunicação / junção / cancelamento.
- Se estiver montando API, combine com context, rate limiting e worker pools.
- 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.