Tecnologias/01 - Linguagens/01 - Go13 min
Mutex vs Channel
Conceito
Go oferece duas famílias de sincronização, e a cultura da linguagem confundiu isso por anos ao repetir "não comunique compartilhando memória" como se channel fosse sempre a resposta. A orientação oficial (no wiki do Go) é bem mais sóbria, e cabe numa frase:
Mutex protege estado compartilhado. Channel transfere posse de um dado ou coordena etapas.
O teste que decide, na prática:
Pergunte: "neste instante, quem é o DONO deste dado?"
├─ "todos, brevemente" → é ESTADO compartilhado → sync (Mutex/atomic)
└─ "exatamente uma goroteina,
e a posse muda de mão" → é TRANSFERÊNCIA / FLUXO → channel
A família sync: proteger estado no lugar
| Primitiva | Para quê | Cuidado |
|---|---|---|
sync.Mutex |
exclusão mútua sobre uma struct/map | não é reentrante; nunca copie |
sync.RWMutex |
N leitores ou 1 escritor | pior que Mutex se a seção é curta ou há muita escrita |
atomic.Int64/Bool/Pointer[T] |
contador, flag, troca de ponteiro | tipos struct (Go 1.19+) evitam bug de alinhamento |
sync.Once |
inicialização única e preguiçosa | f roda uma vez mesmo se der erro |
sync.WaitGroup |
esperar N goroutines | Add antes do go; wg.Go desde o Go 1.25 |
errgroup.Group |
WaitGroup + primeiro erro + SetLimit |
golang.org/x/sync |
sync.Map |
chaves estáveis, leitura predominante | não é um "map concorrente" de uso geral |
sync.Cond |
esperar condição | quase sempre um channel é mais claro |
singleflight |
um trabalho por chave, os demais aguardam | resolve cache stampede |
As regras de mutex que evitam quase todo problema:
type Contador struct {
mu sync.Mutex // 1) campo NÃO exportado, junto do dado que ele protege
n int // (convenção: o mutex vem imediatamente antes)
}
func (c *Contador) Inc() {
c.mu.Lock()
defer c.mu.Unlock() // 2) defer sempre — sobrevive a panic e a return antecipado
c.n++
}
func (c *Contador) Valor() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n // 3) LER também precisa de lock (visibilidade!)
}
E as três armadilhas:
// ❌ receptor por VALOR copia o mutex: cada chamada trava uma cópia diferente
func (c Contador) Inc() { c.mu.Lock(); ... } // `go vet` acusa: copylocks
// ❌ mutex não é reentrante: deadlock imediato
func (c *Contador) Reset() {
c.mu.Lock(); defer c.mu.Unlock()
c.Inc() // Inc() tenta Lock() de novo → travou
}
// ❌ I/O dentro da seção crítica: todo mundo espera a rede
func (c *Cache) Refresh(k string) {
c.mu.Lock(); defer c.mu.Unlock()
v, _ := http.Get(...) // 300 ms com o cache inteiro travado
c.m[k] = v
}
A família channel: transferir posse e coordenar
ch := make(chan T) // sem buffer: rendez-vous (sincroniza os dois lados)
ch := make(chan T, 100) // com buffer: desacopla até o buffer encher
Os padrões que cobrem quase tudo:
| Padrão | Forma |
|---|---|
| Fila de trabalho | um channel de tarefas, N workers consumindo (Competing Consumers) |
| Pipeline | estágios ligados por channels, cada um com sua goroutine |
| Semáforo | channel com buffer de N: sem <- struct{}{} / <-sem |
| Fan-in / fan-out | vários produtores num channel; um channel distribuído a vários |
| Resultado assíncrono | devolver <-chan Resultado de uma função |
| Ator / serializador | uma goroutine dona do estado; as outras mandam comandos |
| Cancelamento | context + select { case <-ctx.Done(): } |
O padrão ator é o que substitui o mutex por completo, e vale conhecer bem:
// Uma goroutine é dona do estado. Ninguém mais toca. Não existe lock.
type comando struct {
acao func(estado *Estado)
resultado chan any // nil se o chamador não precisa de resposta
}
func servidor(cmds <-chan comando) {
e := &Estado{} // ESTADO LOCAL desta goroutine: por construção seguro
for c := range cmds {
c.acao(e)
if c.resultado != nil { close(c.resultado) }
}
}
Por que isso é seguro sem nenhuma primitiva de sync: o estado nunca é compartilhado. A
sincronização vem do próprio channel, que já dá a garantia happens-before
(8. Race Conditions e o Modelo de Memória).
A tabela de decisão
| Situação | Ferramenta | Por quê |
|---|---|---|
| Contador, gauge, flag | atomic |
uma instrução; mutex é exagero |
| Cache / mapa compartilhado | RWMutex |
leitura precisa ser paralela e barata |
| Struct com vários campos e invariantes | Mutex |
protege o conjunto, não os campos |
| Inicialização preguiçosa | Once |
é literalmente o caso de uso |
| Esperar N tarefas terminarem | WaitGroup / errgroup |
idem |
| Distribuir unidades de trabalho | channel | dá bloqueio, backpressure e cancelamento juntos |
| Passar posse de buffer/conexão | channel | a posse muda de mão: é o significado do channel |
| Devolver resultado assíncrono | channel | o chamador espera com select + timeout |
| Limitar concorrência | channel com buffer ou SetLimit |
semáforo natural (Back Pressure) |
| Timeout / cancelamento | context + select |
único jeito idiomático |
| Pipeline de estágios | channel | o fluxo é a estrutura |
| Config recarregável em caminho quente | atomic.Pointer[T] |
leitura sem lock, troca atômica |
| Muitas operações distintas sobre o mesmo estado | Mutex ou ator | ver os casos 2 e 5 abaixo |
Custo: ordem de grandeza
Números aproximados, em hardware x86-64 típico, sem contenção — meça no seu caso com
go test -bench -benchmem:
| Operação | Custo aproximado |
|---|---|
atomic.Int64.Add |
~1–5 ns |
Mutex.Lock + Unlock |
~15–25 ns |
RWMutex.RLock + RUnlock |
~20–30 ns (e pior que Mutex sob escrita frequente) |
| Envio + recebimento em channel | ~70–120 ns |
| Channel sob contenção (park/unpark de goroutine) | centenas de ns a µs |
A leitura correta desses números: abaixo de ~1 milhão de operações por segundo, a diferença raramente decide. Escolha por clareza. Mas para um contador em caminho quente, um channel é 20–100× mais caro e exige uma goroutine dedicada viva — aí a diferença é de projeto, não de microssegundos.
Casos de uso — os dois lados, concretos
Caso 1 — Contador de métricas → atomic
type Métricas struct {
requisicoes atomic.Int64
erros atomic.Int64
}
func (m *Métricas) Req() { m.requisicoes.Add(1) }
func (m *Métricas) Erro() { m.erros.Add(1) }
func (m *Métricas) Snapshot() (int64, int64) {
return m.requisicoes.Load(), m.erros.Load()
}
Por que não mutex: funcionaria, e custa ~5× mais numa operação que roda em toda requisição. Por que não channel: exigiria uma goroutine agregadora e um salto de scheduler para incrementar um número.
⚠️
Snapshotnão é atômico como par: os doisLoadsão de instantes diferentes. Para métricas isso é irrelevante; se você precisasse de coerência entre os dois, o caso viraria mutex.
Caso 2 — Cache em memória → RWMutex
type Cache struct {
mu sync.RWMutex
m map[string]*Item
}
func (c *Cache) Get(k string) (*Item, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
it, ok := c.m[k] // N leitores em paralelo
return it, ok
}
func (c *Cache) Set(k string, it *Item) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[k] = it // escritor exclusivo
}
Por que mutex e não channel: com um ator, toda leitura viraria um envio + espera de resposta — os leitores seriam serializados numa fila, ganhando latência e perdendo o paralelismo que é exatamente o ponto de um cache. Aqui o dado é estado compartilhado, e ninguém está transferindo posse.
Por que RWMutex e não Mutex: só se a leitura dominar de fato (ordem de 10:1 ou
mais) e a seção crítica não for trivial. Para seções muito curtas, o Mutex simples
costuma ganhar — o RWMutex tem mais contabilidade interna. Meça.
Caso 3 — Pool de workers → channel
func processar(ctx context.Context, tarefas []Tarefa, n int) error {
ch := make(chan Tarefa)
g, ctx := errgroup.WithContext(ctx)
for range n { // N workers
g.Go(func() error {
for t := range ch { // pega a próxima; bloqueia quando vazio
if err := executar(ctx, t); err != nil { return err }
}
return nil
})
}
g.Go(func() error {
defer close(ch) // fecha ao terminar: os workers saem do range
for _, t := range tarefas {
select {
case ch <- t:
case <-ctx.Done(): // primeiro erro cancela a distribuição
return ctx.Err()
}
}
return nil
})
return g.Wait()
}
Por que channel e não mutex: com mutex você reimplementaria uma fila — slice +
sync.Mutex + sync.Cond para acordar workers ociosos + um flag de encerramento. O
channel entrega bloqueio, backpressure, cancelamento e sinal de fim de graça, e a posse
de cada Tarefa de fato muda de mão. É Queue-Based Load Leveling na memória.
Caso 4 — Configuração recarregável → atomic.Pointer (copy-on-write)
type Config struct{ Timeout time.Duration; Feature bool }
type Store struct{ cfg atomic.Pointer[Config] }
func (s *Store) Load() *Config { return s.cfg.Load() } // leitura SEM lock
func (s *Store) Reload(novo *Config) { s.cfg.Store(novo) } // troca atômica do ponteiro
Por que não mutex: a leitura está no caminho quente de toda requisição, e a escrita
acontece uma vez por hora. RWMutex funcionaria e cobraria ~20 ns por leitura contra ~1
ns. Por que não channel: o leitor não quer pedir a config a ninguém — ele quer ler.
A regra do padrão: o *Config apontado é imutável. Nunca mute o struct depois de
publicá-lo; para mudar, construa um novo e faça Store. Ver 4. Mutabilidade.
Caso 5 — Estado com muitas operações e invariantes → ator (channel)
Uma sessão de jogo, um agregado de domínio, uma máquina de estados: dez operações diferentes que precisam manter invariantes entre si.
type Mesa struct{ cmds chan func(*estadoMesa) }
func NovaMesa() *Mesa {
m := &Mesa{cmds: make(chan func(*estadoMesa), 64)}
go func() {
e := &estadoMesa{} // dono único do estado
for fn := range m.cmds { fn(e) }
}()
return m
}
func (m *Mesa) Entrar(j Jogador) { m.cmds <- func(e *estadoMesa) { e.entrar(j) } }
func (m *Mesa) Apostar(j Jogador, v int) error {
resp := make(chan error, 1)
m.cmds <- func(e *estadoMesa) { resp <- e.apostar(j, v) }
return <-resp // request-response
}
Por que ator e não mutex: com dez métodos e um mutex, alguém eventualmente esquece o
Lock — e o bug é silencioso (8. Race Conditions e o Modelo de Memória). No
modelo de ator, esquecer é impossível: o estado não é alcançável de fora. Além disso,
sequências de operações que precisam ser atômicas em conjunto ficam naturalmente atômicas,
porque são um único comando.
O preço: tudo é serializado, inclusive leitura, e a goroutine dona é um gargalo de um núcleo. Vale quando as invariantes valem mais que a vazão.
Caso 6 — Limitar concorrência externa → channel como semáforo
sem := make(chan struct{}, 10) // no máximo 10 chamadas simultâneas
for _, url := range urls {
sem <- struct{}{} // adquire (bloqueia no 11º)
go func() {
defer func() { <-sem }() // libera
buscar(url)
}()
}
Por que channel: o semáforo é uma capacidade transferida. errgroup.SetLimit(10)
faz o mesmo com menos código e deve ser a primeira escolha; o padrão manual importa quando
você precisa do controle explícito. Protege banco e serviços externos do mesmo jeito que
MaxOpenConns protege o Postgres (5. Go em Produção).
Trade-offs
Erro de mutex é silencioso; erro de channel é ruidoso. Esquecer um Lock corrompe
dados e você descobre no relatório errado, semanas depois. Esquecer um receptor trava uma
goroutine — o que aparece como memória crescendo e é achável com o perfil goroutineleak
do Go 1.27 (2. Goroutines e Channels). Falhar de forma visível é uma vantagem real do
channel.
Channel custa alocação e agendamento; mutex custa disciplina. Cada envio pode estacionar e acordar goroutines (trabalho de scheduler — 6. Runtime, Scheduler e GC). Cada mutex depende de todo mundo lembrar de travar, sempre, em todos os caminhos.
RWMutex não é "Mutex melhorado". Ele é mais lento em seção curta, penaliza escrita
(escritor bloqueia novos leitores para não morrer de fome) e tem um deadlock próprio:
RLock recursivo na mesma goroutine travando se um escritor entrou na fila no meio.
sync.Map quase nunca é o que você quer. Ele foi desenhado para chaves estáveis com
leitura predominante e conjuntos de chaves disjuntos por goroutine. Para uso geral,
map + RWMutex é mais rápido e muito mais fácil de raciocinar
(7. Memória, Slices e Maps por Dentro).
Nenhum dos dois protege de ordem de aquisição errada. Dois mutexes travados em ordens
diferentes por duas goroutines é deadlock — o mesmo bug do
deadlock do InnoDB, e a mesma correção: ordem determinística
de aquisição. Go detecta apenas o caso em que todas as goroutines estão bloqueadas
(fatal error: all goroutines are asleep - deadlock!); um deadlock entre duas goroutines
num servidor ativo passa como travamento parcial.
O trade-off central: mutex otimiza para acesso barato a estado compartilhado; channel otimiza para posse clara e fluxo explícito. Escolher errado não costuma dar bug — dá código mais complicado do que precisava. E há um caso em que é bug: usar channel onde o dado é estado compartilhado lido em paralelo transforma leitores concorrentes numa fila.
Exemplo prático
O mesmo problema, as duas soluções, medido
// --- A) Mutex: estado protegido no lugar ---
type PlacarMutex struct {
mu sync.Mutex
m map[string]int
}
func (p *PlacarMutex) Add(k string, n int) {
p.mu.Lock(); defer p.mu.Unlock()
p.m[k] += n
}
func (p *PlacarMutex) Get(k string) int {
p.mu.Lock(); defer p.mu.Unlock()
return p.m[k]
}
// --- B) Ator: uma goroutine dona ---
type PlacarAtor struct{ cmds chan func(map[string]int) }
func NovoPlacarAtor() *PlacarAtor {
p := &PlacarAtor{cmds: make(chan func(map[string]int), 128)}
go func() {
m := map[string]int{}
for fn := range p.cmds { fn(m) }
}()
return p
}
func (p *PlacarAtor) Add(k string, n int) { p.cmds <- func(m map[string]int) { m[k] += n } }
func (p *PlacarAtor) Get(k string) int {
resp := make(chan int, 1)
p.cmds <- func(m map[string]int) { resp <- m[k] }
return <-resp
}
go test -bench=Placar -benchmem -cpu=1,4,16 ./...
# BenchmarkPlacarMutex-16 28_450_112 42.1 ns/op 0 B/op 0 allocs/op
# BenchmarkPlacarAtor-16 4_120_338 291.0 ns/op 48 B/op 1 allocs/op
# ↑ ~7x mais caro, e aloca por chamada
O ator perde em vazão e ganha em impossibilidade de esquecer o lock. Para um placar simples, mutex. Para um agregado com dez operações e invariantes cruzadas, o ator se paga.
Verificação obrigatória
go vet ./... # copylocks: mutex copiado por valor
go test -race -count=10 ./... # data race + variação de entrelaçamento
go test -bench=. -benchmem -cpu=1,4,16 ./... # contenção só aparece com -cpu alto
-cpu=1,4,16 no benchmarkUm benchmark de sincronização com GOMAXPROCS=1 não mostra contenção nenhuma — é
justamente onde mutex parece perfeito. Rode com o paralelismo real da produção, e
prefira b.RunParallel para exercitar o caminho concorrente.
Como isso falha em produção
| Sintoma | Causa |
|---|---|
| Dados corrompidos sem erro no log | esqueceu o Lock num dos caminhos (o mais comum) |
go vet: "passes lock by value" |
receptor ou parâmetro por valor copiando o mutex |
| Deadlock imediato numa função | mutex não é reentrante: método travado chamando outro travado |
| Travamento parcial sob carga | dois mutexes adquiridos em ordens diferentes |
fatal error: all goroutines are asleep |
envio sem receptor, ou WaitGroup que nunca zera |
| Goroutines crescendo devagar | envio para channel cujo receptor morreu; use goroutineleak |
| Latência p99 alta com CPU baixa | I/O dentro da seção crítica |
RWMutex mais lento que Mutex |
seção curta, ou escrita frequente demais |
panic: sync: negative WaitGroup counter |
Done chamado mais vezes que Add |
WaitGroup retornando antes da hora |
wg.Add(1) dentro da goroutine, correndo com o Wait |
| Duas entradas para a mesma chave | check-then-act: race condition, não data race (8. Race Conditions e o Modelo de Memória) |
Aprofundamento aplicado
Cancelamento de uma chamada a um ator
Imagine uma goroutine dona de um mapa que recebe comandos e envia respostas. O cliente cancela depois de enviar seu comando. Se a resposta usa um canal sem buffer e ninguém mais recebe, o ator pode ficar bloqueado, impedindo todos os comandos seguintes.
Duas decisões precisam estar explícitas: se o comando aceito ainda será executado e como a resposta será descartada quando o cliente desistir. Um canal de resposta com capacidade 1 pode acomodar uma única resposta; isso não cancela o efeito do comando. Uma operação externa pode precisar de chave idempotente mesmo com o ator correto.
Comparação por propriedade
| Necessidade | Pergunta de projeto |
|---|---|
| Estado coerente | Qual conjunto de campos precisa mudar junto? |
| Fila limitada | O produtor bloqueia, rejeita ou descarta ao lotar? |
| Cancelamento | O trabalho já foi aceito quando o cliente desiste? |
| Leitura rápida | O custo de enfileirar cada leitura é aceitável? |
Exercício: cancele metade dos clientes depois de enviar comandos. Resultado esperado: o ator continua respondendo aos demais, sem goroutines presas no retorno. Compare a complexidade desse protocolo com uma operação curta protegida por mutex.
Referência: critérios de escolha de primitivas.
Relacionado
- 8. Race Conditions e o Modelo de Memória
- 2. Goroutines e Channels
- 6. Runtime, Scheduler e GC
- 7. Memória, Slices e Maps por Dentro
- 5. Go em Produção
- 3. Redo, Undo e Locks
- 3. MVCC e Isolamento na Prática
- Back Pressure
- Competing Consumers
- 4. Mutabilidade
Parte de MOC Go · Use a sync.Mutex or a channel? (wiki) · sync (pkg)