trilha

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":

  1. 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.
  2. msg vazia. Não há garantia de que (1) seja visível antes de (2). O programa pode ver done == true e 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 ./...
A limitação que precisa estar clara

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 cgo

O 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


Parte de MOC Go · The Go Memory Model · Data Race Detector

Buscar

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