Tecnologias/01 - Linguagens/01 - Go10 min
Race Conditions e o Modelo de Memória
Conceito
Duas coisas diferentes são chamadas de "race" e confundi-las é a raiz de muito bug que "não reproduz":
| Data race | Race condition | |
|---|---|---|
| O que é | duas goroutines acessam a mesma posição de memória ao mesmo tempo, ao menos uma escrevendo, sem sincronização | o resultado do programa depende da ordem/temporização dos eventos |
| Natureza do problema | memória — definido pelo modelo de memória da linguagem | lógica — definido pelo seu domínio |
| Ferramenta acha? | go test -race detecta conflitos exercitados |
testes de invariantes e ferramentas de análise podem revelar; -race sozinho não basta |
| Consequência | perda de garantias; valores inconsistentes, laço sem término ou falha | resultado errado, mas "válido" para a máquina |
| Como se corrige | adicionar sincronização | repensar a fronteira da operação atômica |
Todo data race é um bug. Mas corrigir todos os data races não elimina as race conditions — porque é perfeitamente possível ter todos os acessos protegidos por mutex e ainda assim uma lógica que depende da ordem. É o caso mais interessante, e está no exemplo prático.
O modelo de memória: por que "ele vai ler o valor velho" é otimismo
A intuição errada é achar que, sem sincronização, você lê um valor desatualizado. O que o modelo de memória do Go diz é mais forte:
Data races são erros, mas Go impõe restrições à implementação mesmo nesses casos.
Leituras de uma palavra devem observar valores escritos; estruturas de várias palavras podem ficar inconsistentes. O modelo não oferece as mesmas garantias de um programa sem races, mas também não equivale ao comportamento inteiramente indefinido de C/C++. Referência: restrições do modelo.
Sem sincronização, observar uma flag não publica automaticamente outros dados. Exemplo:
var done bool
var msg string
func main() {
go func() {
msg = "olá" // (1)
done = true // (2)
}()
for !done { // (3)
}
println(msg) // (4)
}
Dois modos de falha, e nenhum deles é "ler valor velho":
- Ausência de término garantido. O laço (3) não estabelece sincronização; o modelo não garante que ele observe a alteração e termine. Não use polling de uma variável comum como protocolo de publicação.
msgvazia. Não há garantia de que (1) seja visível antes de (2). O programa pode verdone == truee imprimir"".
Valores rasgados: por que "errar por pouco é aceitável" é falso
Em Go, vários tipos ocupam mais de uma palavra: interface (tipo + ponteiro), slice (ptr + len + cap), string (ptr + len), header de map. Um data race sobre eles pode produzir um valor que nunca foi escrito por ninguém:
var v any
go func() { for { v = 42 } }()
go func() { for { v = "quarenta" } }()
// v pode acabar com o TIPO de uma escrita e o PONTEIRO da outra
// → usar v causa SEGFAULT, não "valor inesperado"
O mesmo vale para um slice: você pode observar o ptr de um array com o len de outro,
e ler além do limite sem que o runtime perceba
(7. Memória, Slices e Maps por Dentro).
E há um caso em que o próprio runtime detecta e mata o processo, sem chance de
recover:
fatal error: concurrent map writes
fatal error: concurrent map read and map write
Isso não é um panic: é um fatal error. O map do Go tem detecção parcial de uso
concorrente, e a resposta dele é derrubar o processo — de propósito, porque continuar
significaria corromper a tabela hash. Ver 6. Hash Map.
O que dá garantia: happens-before
A sincronização em Go não serve só para "um de cada vez". Cada primitiva é também uma barreira de memória — ela cria uma relação happens-before que torna as escritas visíveis. As garantias que você recebe de graça:
| Se… | Então… |
|---|---|
go f() |
tudo antes do go é visível dentro de f |
| envio num channel | o envio happens-before o recebimento correspondente completar |
close(ch) |
o close happens-before o recebimento que devolve zero value |
| channel sem buffer | o recebimento happens-before o envio completar |
mu.Unlock() |
happens-before o próximo mu.Lock() retornar |
once.Do(f) |
f() happens-before qualquer Do retornar |
wg.Wait() retorna |
todos os wg.Done() são visíveis |
sync/atomic |
operações são sequencialmente consistentes (documentado desde o Go 1.19) |
Corolário prático: você não usa mutex "porque a operação é grande". Você usa mutex (ou atomic, ou channel) porque sem ele não existe garantia de visibilidade — nem para um
bool.
O detector de race
-race implementa o ThreadSanitizer: cada palavra de memória do programa ganha
"shadow memory" com relógios vetoriais, registrando qual goroutine acessou o quê e com
qual relação happens-before. Quando dois acessos conflitam sem ordenação, ele reporta.
go test -race ./... # o uso principal — deve estar na CI
go run -race ./cmd/api # desenvolvimento
go build -race -o app-canary ./cmd/api # binário de canário para staging
GORACE="halt_on_error=1 history_size=4" go test -race ./...
O detector só vê o que realmente executou, naquele entrelaçamento. Ele não prova
ausência de race: ele confirma presença. Um teste sequencial rodando com -race não
encontra nada — não porque o código é seguro, mas porque nenhuma concorrência
aconteceu.
Ou seja: -race é um limite inferior de bugs, e a qualidade do resultado depende
inteiramente de os seus testes exercitarem concorrência de verdade.
Custo: cerca de 2×–20× CPU e 5×–10× memória. É ferramenta de teste e staging, não
de produção — com a exceção comum de rodar um pod de canário com -race, que costuma
ser a forma mais eficiente de achar race que só aparece com tráfego real.
-race exige cgoO detector linka com o runtime do TSan, então CGO_ENABLED=0 (a configuração
recomendada para binário estático em 5. Go em Produção) desabilita o -race. Em
imagem Alpine/musl sem toolchain de C, go test -race falha. A CI precisa de uma
imagem com cgo habilitado para o passo de teste, mesmo que o build final seja estático.
Trade-offs
-race encontra data race e é cego para race condition. Um código com todos os
acessos sob mutex passa limpo no detector e pode estar logicamente errado. A ferramenta
resolve a metade mecânica do problema; a metade de design é sua.
Concorrência que o teste não exercita é concorrência não testada. Um teste com
-race só vale se ele criar goroutines concorrentes de verdade: t.Parallel(), N
goroutines batendo na mesma estrutura, -count=10 para variar o entrelaçamento. Um teste
tabelado sequencial dá falsa segurança.
"Funciona em produção há dois anos" não prova correção. Com data race, o programa pode funcionar por acidente do alinhamento de memória, da versão do
compilador ou de GOMAXPROCS=1 no ambiente antigo. Trocar a máquina para uma com mais
núcleos já é suficiente para o bug aparecer (6. Runtime, Scheduler e GC).
O trade-off central: Go tornou concorrência fácil de escrever e não tornou memória compartilhada segura. O contrato explícito de
error(3. Erros, Interfaces e o Estilo Go) também não verifica sincronização de campos acessados por duas goroutines. Essa verificação foi deliberadamente movida da compilação para uma ferramenta de execução — o que significa que ela só acontece se você a rodar.
Exemplo prático
Ler um relatório de race
==================
WARNING: DATA RACE
Write at 0x00c0000b4010 by goroutine 8:
main.(*Contador).Inc()
/app/contador.go:14 +0x3c ← quem escreveu AGORA
main.main.func2()
/app/main.go:27 +0x44
Previous read at 0x00c0000b4010 by goroutine 7:
main.(*Contador).Valor()
/app/contador.go:19 +0x2f ← quem leu ANTES, sem ordenação entre os dois
main.main.func1()
/app/main.go:22 +0x44
Goroutine 8 (running) created at:
main.main()
/app/main.go:26 +0x8c ← onde cada goroutine nasceu
==================
Leia sempre nesta ordem: o mesmo endereço (0x00c0000b4010) acessado por duas
goroutines, uma escrevendo, e as duas pilhas de criação para saber quem as lançou.
O caso interessante: race condition com zero data races
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]
return it, ok // ✅ protegido
}
func (c *Cache) Set(k string, it *Item) {
c.mu.Lock(); defer c.mu.Unlock()
c.m[k] = it // ✅ protegido
}
// ❌ E ESTA função é uma race condition, apesar de não ter NENHUM data race
func (c *Cache) GetOrCreate(k string) *Item {
if it, ok := c.Get(k); ok { // seção crítica 1 ... termina aqui
return it
}
it := criarCaro(k) // ... e aqui outra goroutine faz o mesmo
c.Set(k, it) // seção crítica 2
return it
}
Duas goroutines chamando GetOrCreate("a") ao mesmo tempo: as duas não encontram, as
duas chamam criarCaro, e uma sobrescreve a outra. -race passa limpo — cada acesso
individual está sincronizado. O bug é que a operação atômica devia ser o par
verificar + criar, e não cada metade.
Este é exatamente o lost update de 3. MVCC e Isolamento na Prática: ler, decidir, escrever, com uma janela no meio. O mesmo bug, na memória em vez do banco.
As três correções, em ordem de preferência:
// 1) Uma seção crítica só — simples e correta (serializa a criação)
func (c *Cache) GetOrCreate(k string) *Item {
c.mu.Lock(); defer c.mu.Unlock()
if it, ok := c.m[k]; ok { return it }
it := criarCaro(k) // ⚠️ com o lock preso: ver a ressalva abaixo
c.m[k] = it
return it
}
// 2) Double-checked: não segura o lock durante o trabalho caro,
// e ainda assim garante um único vencedor
func (c *Cache) GetOrCreate(k string) *Item {
if it, ok := c.Get(k); ok { return it }
it := criarCaro(k)
c.mu.Lock(); defer c.mu.Unlock()
if existente, ok := c.m[k]; ok { return existente } // alguém chegou antes: use o dele
c.m[k] = it
return it
}
// 3) singleflight: um único trabalho por chave, os outros esperam o resultado
var g singleflight.Group // golang.org/x/sync/singleflight
func (c *Cache) GetOrCreate(ctx context.Context, k string) (*Item, error) {
if it, ok := c.Get(k); ok { return it, nil }
v, err, _ := g.Do(k, func() (any, error) {
it, err := criarCaroCtx(ctx, k)
if err == nil { c.Set(k, it) }
return it, err
})
if err != nil { return nil, err }
return v.(*Item), nil
}
A opção 1 é a mais simples e a correta na maioria dos casos — mas repare na ressalva:
criarCaro roda com o lock preso, então se ela faz I/O (uma query, uma chamada HTTP)
todas as outras goroutines ficam bloqueadas durante isso. A opção 3 é a resposta certa
quando criarCaro é caro e a chave é disputada (o clássico cache stampede).
Testar concorrência de verdade
func TestContadorConcorrente(t *testing.T) {
const goroutines, incrementos = 50, 1000
c := NovoContador()
var wg sync.WaitGroup
for range goroutines {
wg.Go(func() { // sync.WaitGroup.Go: Go 1.25+
for range incrementos { c.Inc() }
})
}
wg.Wait()
// a asserção que pega race condition (o -race não pega esta parte)
if got := c.Valor(); got != goroutines*incrementos {
t.Fatalf("perdeu incrementos: got %d, want %d", got, goroutines*incrementos)
}
}
go test -race -count=20 ./... # -count varia o entrelaçamento; -race vê o acesso
O teste faz as duas verificações: -race acusa o data race, e a asserção de total
acusa a race condition (incrementos perdidos). Uma sem a outra deixa metade do problema
passar.
Como isso falha em produção
| Sintoma | Causa |
|---|---|
fatal error: concurrent map writes |
map compartilhado sem lock — derruba o processo, sem recover |
fatal error: concurrent map read and map write |
idem, com leitor e escritor |
| Segfault em código sem ponteiro explícito | valor rasgado de interface ou slice |
Laço for !flag {} nunca termina |
falta sincronização; término não garantido |
| Contador sempre um pouco menor que o esperado | x++ não é atômico (load, add, store) |
| Bug que só aparece na máquina nova | mais núcleos = entrelaçamentos que antes não ocorriam |
| Duas entradas criadas para a mesma chave | check-then-act: race condition sem data race |
| Só aparece sob carga, nunca em teste | o teste não exercita concorrência |
| Passou dois anos "funcionando" | race cujos efeitos não haviam aparecido |
Aprofundamento aplicado
Testar a propriedade, além de procurar data races
Suponha estoque inicial 1 e duas compras concorrentes. A propriedade importante é: “no máximo uma compra confirma e o estoque nunca fica negativo”. Um mutex usado em cada getter e setter separadamente pode eliminar data races e continuar violando essa regra.
O teste deve fazer as duas operações concorrerem, esperar ambas terminarem e conferir
confirmações e saldo. Para tornar o cenário reproduzível, use uma barreira de teste
na fronteira onde as duas decisões leem o mesmo estado; evite depender de Sleep.
Uma execução sem alerta de -race só diz que o detector não encontrou um conflito de
memória no caminho exercitado. Ela não prova serialização da regra de negócio.
Referência: detector de races.
Exercício: primeiro proteja leitura e escrita separadamente; depois proteja a operação “verificar e decrementar” inteira. Resultado esperado: somente a segunda fronteira preserva o estoque em todos os entrelaçamentos considerados pelo teste.
Relacionado
- 9. Mutex vs Channel
- 2. Goroutines e Channels
- 7. Memória, Slices e Maps por Dentro
- 6. Runtime, Scheduler e GC
- 5. Go em Produção
- 3. MVCC e Isolamento na Prática
- 4. Mutabilidade
- 6. Hash Map
- 1. Teste Unitário
Parte de MOC Go · The Go Memory Model · Data Race Detector