trilha

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.

⚠️ Snapshot não é atômico como par: os dois Load sã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 benchmark

Um 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


Parte de MOC Go · Use a sync.Mutex or a channel? (wiki) · sync (pkg)

Buscar

Busca por título, seção e texto das notas