Blog

sync, o race detector e atomic em Go

Duas goroutines que mudam a mesma variável sem coordenação causam um data race e perdem atualizações em silêncio. Veja como sync.Mutex, RWMutex, atomic e Once evitam isso, e como go run -race encontra o problema.

Goroutines que só leem os próprios dados são fáceis. O problema começa quando duas delas mudam a mesma variável. Nada quebra e nada avisa você, mas algumas das mudanças somem.

Este post mostra esse bug e depois as ferramentas que o Go dá para evitá-lo e para pegá-lo: sync.Mutex, sync.RWMutex, sync/atomic, sync.Once, o race detector e o go vet. Todo programa abaixo rodou no Go 1.26, e a saída foi colada da execução.

Um data race: duas goroutines, um contador

Um data race acontece quando duas goroutines mexem na mesma memória ao mesmo tempo, e pelo menos uma delas escreve. Aqui estão duas goroutines, cada uma somando 1 a um contador compartilhado mil vezes:

package main

import (
	"fmt"
	"sync"
)

func main() {
	count := 0
	var wg sync.WaitGroup
	for range 2 {
		wg.Go(func() {
			for range 1000 {
				count++
			}
		})
	}
	wg.Wait()
	fmt.Println("done")
}

Rodando com go run -race ., ele imprime um relatório como este (o ... representa linhas com endereços de memória e números de goroutine que mudam a cada execução):

WARNING: DATA RACE
...
done
exit status 66

O relatório aponta a linha, main.go:14, que é o count++. Ele mostra uma goroutine lendo ou escrevendo count depois que outra já tinha escrito, sem nada coordenando as duas. O programa ainda termina e imprime done. Depois, o race detector faz ele sair com status 66, então uma execução de testes ou um job de CI falha em vez de passar em silêncio. (wg.Go inicia uma goroutine e a conta, e wg.Wait espera todas terminarem. Tem um resumo curto mais abaixo.)

Este programa não imprime count de propósito. Você esperaria 2000, e numa execução qualquer pode até ver esse número. Também pode ver menos, e um número diferente na próxima vez. Um resultado que muda de uma execução para outra não pode ser colado como saída verificada, e esse é exatamente o problema: você não pode confiar nele.

Uma coisa nos surpreendeu quando rodamos isso no Go 1.26. Em algumas execuções, o relatório diz que uma goroutine já tinha terminado quando a outra mexeu em count. As duas nunca se sobrepuseram, e o detector mesmo assim reportou um race. Ele não espera ver uma colisão. Ele percebe que nada, nem lock nem channel, obrigou uma goroutine a ir antes da outra.

Por que a contagem sai errada

count++ parece um passo só, mas a máquina faz em três: ler o valor, somar 1, gravar o resultado de volta. Duas goroutines podem intercalar esses passos, e quando isso acontece, um incremento sobrescreve o outro.

G1 count G2 vai rodar count++ leu 0 gravou 1 vai rodar count++ travou, leu 0 gravou 1, liberou vai rodar count++ leu 0 gravou 1 esperando o lock travou, leu 1 gravou 2, liberou 0 1 0 1 2 mutex atualização perdida: 2 incrementos, count é 1 nada se perdeu: count é 2 sem lock: cada goroutine lê, soma 1 e grava com sync.Mutex: só o dono do lock mexe em count count é 0, e G1 e G2 rodam count++ uma vez cada G1 lê 0. Antes de G1 gravar, G2 também lê 0 G1 soma 1 ao 0 que leu e grava 1 G2 soma 1 ao 0 que leu e grava 1: a atualização de G1 some com mutex: G1 trava, lê 0, grava 1, destrava G2 trava, lê 1, grava 2, destrava: count é 2

Duas goroutines rodam count++ uma vez cada sobre um count compartilhado. Sem lock, as duas leem 0 e as duas gravam 1, então um incremento se perde. Com um mutex, só a goroutine que tem o lock pode ler e gravar, então a segunda vê 1 e grava 2. A ordem sem lock mostrada é uma intercalação possível: outras dão a resposta certa, e é por isso que o bug se esconde.

Aqui estão esses passos em palavras, caso a animação não rode para você:

  1. count é 0. G1 e G2 rodam count++ uma vez cada.
  2. G1 lê 0. Antes de G1 gravar qualquer coisa, G2 também lê 0.
  3. G1 soma 1 ao 0 que leu e grava 1.
  4. G2 soma 1 ao 0 que ela leu e grava 1 também. Dois incrementos rodaram, mas count é 1. A atualização de G1 se perdeu.
  5. Agora com um mutex. G1 trava, lê 0, grava 1 e destrava. G2 tem que esperar o lock.
  6. G2 trava, lê 1, grava 2 e destrava. count é 2.

Essa ordem sem lock é uma entre muitas. Se G1 terminar antes de G2 ler, a resposta sai certa. Quais ordens acontecem depende do scheduler e da máquina, então o bug vai e volta.

Explicado como se você tivesse dez anos

Duas crianças dividem uma lousa com um número escrito. A tarefa de cada uma é somar 1.

A criança A olha a lousa, vê 5 e começa a calcular 5 + 1 de cabeça. A criança B olha no mesmo instante, também vê 5 e também chega a 6. A criança A apaga o 5 e escreve 6. A criança B apaga esse 6 e escreve 6. As duas fizeram a tarefa, e o número só subiu um. Às vezes uma borra tanto a escrita da outra que nem dá para ler o número.

Um mutex é um único pincel. Só a criança com o pincel na mão pode olhar a lousa e escrever nela. Todo mundo espera até o pincel ser largado. É mais lento, porque tem criança parada esperando, mas o número sempre fica certo.

A versão precisa

count++ compila para uma leitura da memória que guarda count, uma soma num registrador da CPU e uma escrita de volta na memória. Esses três passos não são uma operação indivisível. Quando duas goroutines rodam esses passos sem sincronização, o scheduler, e numa máquina com vários núcleos o próprio hardware, pode intercalá-los em qualquer ordem. Um ler-modificar-gravar que se sobrepõe a outro sobrescreve o outro.

O memory model do Go chama isso de data race: duas goroutines acessam a mesma variável ao mesmo tempo, pelo menos um acesso é uma escrita, e nenhuma sincronização define a ordem entre eles. Um programa com data race não tem resultado garantido. Atualizações perdidas são o desfecho leve. Em valores de várias palavras de memória, como uma string, o header de um slice ou uma interface, quem lê pode ver metade de um valor antigo e metade de um novo.

Onde a analogia falha: as crianças veem uma à outra indo até a lousa. Goroutines não veem. Nada em count++ procura outra goroutine, e ele só espera se você adicionar um lock. E no race de verdade você não ganha uma borrada visível. Você ganha um número que parece perfeitamente normal e está errado.

sync.Mutex: uma goroutine de cada vez

Um sync.Mutex é um lock com dois métodos. Lock espera o mutex ficar livre e o pega, e Unlock o libera. O código entre os dois roda em uma goroutine de cada vez:

package main

import (
	"fmt"
	"sync"
)

func main() {
	count := 0
	var mu sync.Mutex
	var wg sync.WaitGroup
	for range 2 {
		wg.Go(func() {
			for range 1000 {
				mu.Lock()
				count++
				mu.Unlock()
			}
		})
	}
	wg.Wait()
	fmt.Println("count:", count)
}

Ele imprime:

count: 2000

Rodamos vinte vezes, com e sem -race, e deu count: 2000 todas as vezes, sem nenhum relatório de race. É a versão corrigida da animação, mil vezes por goroutine. O zero value de sync.Mutex é destravado, então var mu sync.Mutex já está pronto para uso.

O código entre Lock e Unlock é a seção crítica. Mantenha ela pequena. Toda outra goroutine que quer o lock espera enquanto você o segura, então faça o trabalho lento, como uma chamada de rede ou a leitura de um arquivo, antes de travar.

Protegendo os campos de uma struct

O jeito comum de usar um mutex é colocá-lo numa struct ao lado dos campos que ele protege, e fazer todo o travamento dentro dos métodos:

package main

import (
	"fmt"
	"sync"
)

// Stock counts items by name. It's safe to use from many goroutines.
type Stock struct {
	mu     sync.Mutex // guards counts
	counts map[string]int
}

func NewStock() *Stock {
	return &Stock{counts: make(map[string]int)}
}

func (s *Stock) Add(name string, n int) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.counts[name] += n
}

func (s *Stock) Get(name string) int {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.counts[name]
}

func main() {
	s := NewStock()
	var wg sync.WaitGroup
	for range 50 {
		wg.Go(func() {
			s.Add("apples", 2)
			s.Add("pears", 1)
		})
	}
	wg.Wait()
	fmt.Println(s.Get("apples"), s.Get("pears"))
}

Ele imprime:

100 50

Cinquenta goroutines somaram 2 maçãs e 1 pera cada, e nada se perdeu. Quem chama nunca vê o mutex. Chama Add e Get, e a struct cuida de si mesma.

defer s.mu.Unlock() logo depois do Lock é o padrão normal. O unlock roda seja qual for o jeito como o método retorna, até num panic, então um return antecipado não consegue deixar o lock preso. O lock fica com você até a função terminar, o que é mais um motivo para manter esses métodos curtos.

Mais duas convenções. Coloque o mutex logo acima dos campos que ele protege, com um comentário dizendo isso. E use pointer receivers, func (s *Stock): um value receiver trava uma cópia do mutex, o que não protege nada, como mostra a seção sobre go vet. Sem o mutex, este programa também poderia quebrar com fatal error: concurrent map writes, uma verificação que o runtime faz mesmo sem -race.

sync.RWMutex para dados lidos muito mais do que escritos

Um sync.RWMutex deixa qualquer número de leitores segurá-lo juntos, mas um escritor fica com ele sozinho. Use quando as leituras são frequentes e as escritas são raras, como uma configuração que um handler lê a cada requisição e que um admin muda uma vez por dia:

package main

import (
	"fmt"
	"sync"
)

type Config struct {
	mu       sync.RWMutex
	settings map[string]string
}

func (c *Config) Get(key string) string {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.settings[key]
}

func (c *Config) Set(key, value string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.settings[key] = value
}

func main() {
	c := &Config{settings: map[string]string{"mode": "fast"}}

	var wg sync.WaitGroup
	results := make([]string, 8)
	for i := range 8 {
		wg.Go(func() {
			results[i] = c.Get("mode")
		})
	}
	wg.Wait()
	fmt.Println(results)

	c.Set("mode", "safe")
	fmt.Println(c.Get("mode"))
}

Ele imprime:

[fast fast fast fast fast fast fast fast]
safe

RLock e RUnlock pegam o lado de leitura. Oito goroutines podem ler mode no mesmo instante, e nenhuma bloqueia a outra. Lock e Unlock pegam o lado de escrita, que espera todos os leitores saírem e impede a entrada de novos leitores enquanto escreve.

Cada goroutine escreve no próprio elemento, results[i], então elas nunca compartilham uma variável. É por isso que o slice em si não precisa de lock, e é também assim que a saída fica numa ordem fixa.

Não use RWMutex por padrão. Ele faz mais controle interno que um Mutex, então com leituras e escritas misturadas, ou com uma seção crítica minúscula, muitas vezes não ganha nada. Comece com Mutex e troque quando um profile mostrar leitores fazendo fila.

sync/atomic: contadores sem lock

O pacote sync/atomic tem tipos cujas operações são feitas como passos únicos que a CPU garante que não podem ser intercalados. Para um contador, é tudo de que você precisa:

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var count atomic.Int64
	var wg sync.WaitGroup
	for range 4 {
		wg.Go(func() {
			for range 1000 {
				count.Add(1)
			}
		})
	}
	wg.Wait()
	fmt.Println("count:", count.Load())
}

Ele imprime:

count: 4000

count.Add(1) faz a leitura, a soma e a escrita como um único passo indivisível, então não sobra nada para outra goroutine intercalar. Load lê o valor atual. O zero value é 0 e já está pronto para uso.

O tipo atomic.Int64, junto com Int32, Uint64, Bool e Pointer[T], chegou no Go 1.19. Código mais antigo chama atomic.AddInt64(&n, 1) sobre um int64 comum. Os tipos são mais seguros: você não consegue ler o valor a não ser por Load, e o go vet reclama se você copiar um.

Quando atomics não bastam

Atomics tornam uma operação segura. Eles não tornam segura uma sequência de operações. Esta função reserva um assento se ainda sobrar algum, e toda chamada nela é atômica, mas mesmo assim ela está quebrada:

func reserveBroken(seats *atomic.Int64) bool {
	if seats.Load() > 0 { // check...
		seats.Add(-1) // ...then act: another goroutine can run in between
		return true
	}
	return false
}

Com um assento sobrando, duas goroutines podem fazer Load e ler 1, as duas veem que é maior que 0, e as duas fazem Add(-1). Você vendeu o último assento duas vezes e seats fica em -1. O race detector também não vai apontar isso, porque todo acesso passou por um atomic. É um race de lógica, não um data race.

A correção é fazer a verificação e a atualização num passo só. CompareAndSwap(old, new) grava o novo valor só se o valor ainda for old, e informa se gravou:

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

// reserve takes one seat if any are left. The check and the update
// happen in a single CompareAndSwap, so no one can slip in between.
func reserve(seats *atomic.Int64) bool {
	for {
		n := seats.Load()
		if n <= 0 {
			return false
		}
		if seats.CompareAndSwap(n, n-1) {
			return true
		}
		// someone else changed seats since we loaded it: try again
	}
}

func main() {
	var seats atomic.Int64
	seats.Store(10)

	var booked atomic.Int64
	var wg sync.WaitGroup
	for range 100 {
		wg.Go(func() {
			if reserve(&seats) {
				booked.Add(1)
			}
		})
	}
	wg.Wait()
	fmt.Println("booked:", booked.Load(), "left:", seats.Load())
}

Ele imprime:

booked: 10 left: 0

Cem goroutines correram atrás de dez assentos, e exatamente dez conseguiram um. Esse loop funciona, mas já é mais difícil de ler do que um mutex seria. A regra prática é esta: use atomics para um único número, como um contador, uma flag ou um medidor. Assim que você precisar manter dois valores consistentes, ou verificar uma coisa e depois mudar outra, use um mutex.

sync.Once e sync.OnceValue: fazer exatamente uma vez

sync.Once roda uma função exatamente uma vez, não importa quantas goroutines a chamem nem como elas se sobreponham. É o jeito seguro de fazer uma inicialização preguiçosa em código concorrente. O Go 1.21 adicionou sync.OnceValue, que embrulha uma função que retorna um valor e guarda o resultado em cache:

package main

import (
	"fmt"
	"sync"
)

var (
	setupOnce sync.Once
	ready     bool
)

func setup() {
	fmt.Println("setting up")
	ready = true
}

var loadConfig = sync.OnceValue(func() map[string]string {
	fmt.Println("loading config")
	return map[string]string{"region": "eu"}
})

func main() {
	var wg sync.WaitGroup
	regions := make([]string, 5)
	for i := range 5 {
		wg.Go(func() {
			setupOnce.Do(setup)
			regions[i] = loadConfig()["region"]
		})
	}
	wg.Wait()
	fmt.Println(ready, regions)
}

Ele imprime:

setting up
loading config
true [eu eu eu eu eu]

Cinco goroutines chamaram setupOnce.Do(setup), e setting up apareceu uma vez. Se uma segunda goroutine chega enquanto setup ainda está rodando, Do faz ela esperar até setup retornar. É por isso que ready é seguro de ler depois, e por isso setting up sempre aparece antes de loading config.

loadConfig é uma função retornada por sync.OnceValue. A primeira chamada roda a função embrulhada, e toda chamada seguinte retorna o mesmo map sem rodá-la de novo. sync.OnceValues faz o mesmo para uma função que retorna um valor e um erro.

Código escrito à mão do tipo “se for nil, cria” tem o mesmo bug de verificar-e-agir do exemplo dos assentos. sync.Once é a correção.

sync.WaitGroup, rapidinho

sync.WaitGroup espera um conjunto de goroutines terminar, e todo programa deste post se apoia nele. A parte sobre goroutines tratou dele por completo. Resumindo: wg.Go(f) inicia f numa nova goroutine e a conta, e wg.Wait() bloqueia até todas elas retornarem. Antes do Go 1.25 você escrevia wg.Add(1), go func() { defer wg.Done(); ... }() à mão, e ainda vai ver essa forma na maior parte do código existente. Um WaitGroup só espera. Ele não protege dado nenhum, então não substitui um mutex.

O race detector: -race

O race detector vem embutido no toolchain do Go, e você o liga com uma flag. Ele funciona com run, test e build:

go run -race .
go test -race ./...
go build -race -o app .

-race compila seu programa com instrumentação extra, que registra todo acesso à memória e toda operação de lock, channel e WaitGroup. Quando duas goroutines acessam a mesma memória sem sincronização definindo a ordem, e uma delas escreve, ele imprime um relatório WARNING: DATA RACE com os dois stack traces. No fim, ele imprime Found N data race(s) e sai com status 66.

Ele encontra data races no código que de fato roda naquela execução. Como o primeiro exemplo mostrou, os dois acessos não precisam colidir no mesmo instante. Basta que não tenham ordem definida.

O que ele não consegue encontrar:

  • Races em código que não rodou. Se o caminho com race só roda quando uma requisição tem um certo header, e seu teste nunca manda esse header, -race não reporta nada. Uma execução limpa com -race quer dizer “nenhum race no que rodou”, não “nenhum race”.
  • Races de lógica. O assento vendido duas vezes só usou chamadas atômicas, então não há data race para reportar. O bug está na sua lógica.
  • Deadlocks. Essa é outra falha, e o runtime reporta alguns deles por conta própria.

Ele tem um custo real. Um programa sob -race usa várias vezes mais memória e roda várias vezes mais devagar, então você não coloca binários com ele em produção. Rode go test -race ./... a cada mudança, localmente e no CI. O detector nunca reporta falso positivo: se ele diz que tem um race, tem.

Copiar um mutex é bug, e o go vet pega

Um mutex não pode ser copiado depois do primeiro uso. Uma cópia é um lock separado, então travá-la não protege nada. O jeito fácil de copiar um sem querer é um value receiver:

type Counter struct {
	mu sync.Mutex
	n  int
}

func (c Counter) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
}

Inc recebe uma cópia do Counter inteiro, mutex incluído. Ele trava a cópia, incrementa o n da cópia e joga as duas fora. Chamar c.Inc() num contador novo e imprimir c.n imprime 0. Compila sem reclamar, mas o go vet recusa. Num módulo chamado example.com/counter, go vet . imprime:

main.go:13:9: Inc passes lock by value: example.com/counter.Counter contains sync.Mutex

e sai com status 1. Essa verificação se chama copylocks. Ela também aponta passar por valor, para uma função, uma struct que contém um mutex, atribuir uma a uma nova variável e percorrer com range um slice delas por valor. A correção aqui é o pointer receiver, func (c *Counter) Inc().

Não conte com o go test para pegar isso. Ele roda só um pequeno subconjunto das verificações do vet, e quando adicionamos um arquivo de teste a este pacote no Go 1.26, o go test passou. Rode go vet ./... você mesmo, e no CI.

Mutex ou channel?

O provérbio do Go é “don’t communicate by sharing memory; share memory by communicating” (não se comunique compartilhando memória; compartilhe memória se comunicando), e channels, o assunto da parte sobre channels e select, são a ferramenta para isso. É uma orientação, não uma proibição de mutexes. A biblioteca padrão usa os dois bastante.

Um jeito razoável de escolher:

  • Use um channel quando você está passando a posse de dados de uma goroutine para outra, ou coordenando etapas: um worker pool, um pipeline, um resultado devolvido, um sinal para parar.
  • Use um mutex quando várias goroutines compartilham um pedaço de estado que fica num lugar só: um cache, um contador, um map de sessões. Uma struct com um mutex e alguns métodos costuma ser mais curta e mais clara que uma goroutine dona do map atendendo pedidos por channels.
  • Use um atomic para um único número ou flag que muitas goroutines atualizam.

Se o código brigar com você, tente o outro.

sync.Map, e por que você normalmente não precisa dele

sync.Map é um map seguro para uso concorrente sem um lock seu. Parece a escolha óbvia, mas ele é especializado. Ele não tem tipo (chaves e valores são any), não tem len e é otimizado para dois casos: chaves escritas uma vez e depois só lidas, como um cache que só cresce, e muitas goroutines trabalhando cada uma com seu próprio conjunto separado de chaves. Para todo o resto, incluindo a maioria dos maps numa API REST, um map comum com um sync.Mutex ou sync.RWMutex ao lado é tipado, mais fácil de entender e muitas vezes mais rápido. Só use sync.Map quando um profile mostrar disputa pelo lock exatamente nesse padrão.

O que lembrar

  • Um data race são duas goroutines usando a mesma variável ao mesmo tempo, com pelo menos uma escrita e nenhuma sincronização. count++ é ler, somar, gravar, e escritas sobrepostas se perdem.
  • sync.Mutex deixa uma goroutine de cada vez entrar numa seção crítica. Trave, use defer no unlock, mantenha a seção pequena e deixe o mutex ao lado dos campos que ele protege.
  • sync.RWMutex permite muitos leitores ou um escritor. Use para dados muito lidos, não por padrão.
  • atomic.Int64 e companhia tornam uma única operação segura. Verificar-e-agir precisa de CompareAndSwap ou de um mutex, e o race detector não vai pegar o bug de lógica.
  • sync.Once e sync.OnceValue rodam a inicialização exatamente uma vez, não importa quantas goroutines peçam.
  • Rode go test -race ./... o tempo todo. Ele só encontra races em código que roda, e um relatório nunca é alarme falso.
  • Nunca copie um mutex. Use pointer receivers e deixe o go vet garantir isso.

Se duas goroutines podem mexer na mesma variável e uma delas escreve, alguma coisa tem que decidir quem vai primeiro.

Quanto este post te ajudou?

Clique em um coração para avaliar!

Média das avaliações 0 / 5. Total de votos: 0

Nenhum voto até agora. Seja o primeiro a avaliar este post.