Pular para o conteúdo

Trilha de Go · parte 19 de 65

O pacote sync em Go: mutex, RWMutex, Once, Map e Pool

Quando goroutines disputam o mesmo dado, o resultado muda de uma execução para outra. Veja como as trancas do sync evitam isso e quando um canal serve melhor.

Publicada em · 29 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

Quando duas goroutines escrevem na mesma variável ao mesmo tempo, o programa passa a dar resultados que dependem da sorte. Às vezes a conta sai certa, às vezes não, e o erro costuma aparecer só sob carga, justamente quando ninguém quer ver erro. O pacote sync é a caixa de ferramentas que o Go oferece para pôr ordem nisso: trancas, uma forma de executar algo uma única vez, um mapa seguro para concorrência e um depósito de objetos reaproveitáveis. Esta aula percorre cada peça, mostra o erro que ela evita e explica quando um canal de Channels é a escolha melhor.

Você precisa saber lançar uma goroutine com go (Goroutine). Nos exemplos usamos waitGroup para esperar as goroutines terminarem, e a aula dele explica isso com calma. A corrida de dados é apresentada aqui do zero. A nota Race Condition aprofunda o assunto, mas você não precisa dela para acompanhar.

Todo o código foi rodado com Go 1.27.2 em macOS (arm64). Os blocos de saída são copiados da execução real, com caminhos encurtados quando estavam longos demais. Para reproduzir, crie uma pasta, rode go mod init exemplos/sync e salve cada arquivo na subpasta indicada.

Esta aula pertence à fase 3 da Trilha de Go, concorrência fundamental.

Mapa da aula

ParteSeçõesO que você vai aprender
Parte 1: o problema1 e 2O que é uma corrida de dados, por que total++ não é um passo só e quando um canal substitui a trava
Parte 2: trancas3 e 4sync.Mutex com Lock, Unlock e defer, e sync.RWMutex para quando a leitura domina
Parte 3: estruturas seguras5 a 7sync.Once para inicialização única, sync.Map para casos específicos e sync.Pool para reaproveitar objetos
Parte 4: erros e ferramentas8 a 10Copiar um mutex, go vet, go test -race lido linha por linha e os erros mais comuns
Parte 5: prática e fechamentoExercícios e glossário, depois o que não foi confirmado e as fontesTrês exercícios com solução de referência e os limites desta aula

Parte 1: o problema e as duas filosofias

1. a corrida de dados

Imagine o caderninho de fiado de uma padaria pequena, onde o balconista anota quanto cada cliente deve. Dois clientes chegam ao mesmo tempo. Os dois balconistas leem o caderno e veem que a conta está em 10 reais. Cada um soma 1 real e escreve 11 na mesma linha. A padaria recebeu dois reais, o caderno diz 11, e um real sumiu sem explicação. Nenhum dos balconistas errou sozinho. O erro está em tratar ler, somar e escrever como se fossem um passo só, quando são três, e a padaria nunca combinou a ordem entre eles.

Chamamos isso de condição de corrida: o resultado do programa depende da ordem em que partes concorrentes executam, e o código não controla essa ordem. Um caso particular, e o que o race detector procura, é a corrida de dados. Ela acontece quando duas goroutines acessam a mesma posição de memória, pelo menos uma delas escreve e nada as coordena. Não importa se o resultado parece certo na maioria das execuções. Acesso concorrente sem coordenação já é corrida.

O código abaixo é a versão errada, de propósito. Ele dispara mil goroutines que incrementam a mesma variável sem nenhuma proteção.

package democorrida

import "sync"

// ContaSemTrava é o contador errado de propósito: as goroutines escrevem
// na mesma variável sem nenhuma proteção.
func ContaSemTrava(n int) int {
	total := 0
	var wg sync.WaitGroup
	for i := 0; i < n; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			total++
		}()
	}
	wg.Wait()
	return total
}

O teste espera mil:

package democorrida

import "testing"

func TestContaSemTrava(t *testing.T) {
	const n = 1000
	if got := ContaSemTrava(n); got != n {
		t.Errorf("ContaSemTrava(%d) = %d, quero %d", n, got, n)
	}
}

Rodei o teste três vezes com go test comum, sem -race:

$ go test -count=1 ./democorrida
ok  	exemplos/sync/democorrida	0.199s
$ go test -count=1 ./democorrida
--- FAIL: TestContaSemTrava (0.00s)
    sem_trava_test.go:8: ContaSemTrava(1000) = 962, quero 1000
FAIL
FAIL	exemplos/sync/democorrida	0.142s
$ go test -count=1 ./democorrida
--- FAIL: TestContaSemTrava (0.00s)
    sem_trava_test.go:8: ContaSemTrava(1000) = 959, quero 1000
FAIL
FAIL	exemplos/sync/democorrida	0.139s

A primeira rodada passou, e isso não prova nada. Nas outras duas, o contador perdeu 38 e 41 incrementos. Numa quarta rodada o resultado foi 974. O erro aparece quando a sobreposição cai onde ele se manifesta, e isso muda com o número de núcleos, a carga e o agendamento do runtime. Por isso o teste precisa de -race, que veremos na seção 9.

Por baixo dos panos, total++ são três etapas: ler o valor da memória, somar 1 e escrever o resultado. Se duas goroutines intercalam essas etapas, as duas leituras acontecem antes da primeira escrita, e uma das somas desaparece. A figura mostra a intercalação mais simples desse erro.

Duas goroutines leem o contador em 10 ao mesmo tempo, gravam 11 cada uma, e um incremento se perde; o esperado seria 12.
figura 1 · Duas goroutines leem o mesmo valor e uma das somas se perde

A figura é uma linha do tempo com três participantes. No bloco par as duas goroutines leem a variável juntas e recebem o mesmo valor. As gravações vêm depois, e a segunda apaga a primeira. A nota no canto indica a conta que deveria ter saído. Tudo o que o código não controla está dentro do bloco par, e é nele que as ferramentas desta aula atuam.

2. compartilhar memória ou comunicar

O guia Effective Go, escrito pelo próprio time do Go, resume a filosofia da linguagem numa frase: “Do not communicate by sharing memory; instead, share memory by communicating.” Em tradução livre: não se comunique compartilhando memória; compartilhe memória comunicando-se. A mesma página faz logo depois uma ressalva importante: a ideia pode ser levada longe demais, e contadores de referência talvez fiquem melhor com um mutex em volta de um inteiro. Use a frase como ponto de partida, e a ressalva como freio.

Existem duas formas de fazer goroutines se entenderem sobre um dado. Na primeira, o dado fica num lugar fixo e as goroutines tomam turnos para mexer nele, e a trava é a regra dos turnos. Na segunda, o dado viaja: uma goroutine é a única dona do estado, e as outras pedem alterações enviando mensagens pelo canal. Como ninguém além da dona toca no valor, não há nada para travar.

A pergunta que decide é quem é o dono do dado. Se ele é um estado pequeno que várias partes consultam e alteram no próprio lugar, como um contador, um cache ou um mapa de configuração, um mutex costuma ser mais simples e mais direto de ler. Se o dado está sendo passado adiante, com trabalho chegando de um lado e resultados saindo do outro, ou se um evento precisa avisar outra parte, o canal expressa melhor a intenção. Pipelines, filas de tarefas e sinais de parada são casos típicos de canal. Contadores e caches são casos típicos de trava. O erro mais comum é usar canal para tudo, como se mensagem fosse a resposta para qualquer pergunta. Canal que faz o papel de mutex funciona, mas é um mutex com mais cerimônia.

Os dois exemplos abaixo produzem o mesmo contador. O primeiro usa trava:

type ContadorMutex struct {
	mu sync.Mutex
	n  int
}

func (c *ContadorMutex) Incrementa() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
}

func (c *ContadorMutex) Valor() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.n
}

func ContaComMutex(n int) int {
	var c ContadorMutex
	var wg sync.WaitGroup
	for i := 0; i < n; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			c.Incrementa()
		}()
	}
	wg.Wait()
	return c.Valor()
}

O segundo usa canal, e nenhuma goroutine toca no contador além da dona:

func ContaComCanal(n int) int {
	incrementos := make(chan int)
	total := make(chan int)

	go func() {
		soma := 0
		for v := range incrementos {
			soma += v
		}
		total <- soma
	}()

	var wg sync.WaitGroup
	for i := 0; i < n; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			incrementos <- 1
		}()
	}
	wg.Wait()
	close(incrementos)
	return <-total
}

Um teste para cada função chama a função com n igual a mil e compara o resultado com mil. Os dois passam com -race:

$ go test -race -count=1 ./contador
ok  	exemplos/sync/contador	1.274s

A versão com canal é mais longa para um caso tão simples. Não medi qual das duas é mais rápida. Se essa diferença importar para o seu caso, a resposta sai de um benchmark, como os da nota Benchmarking e Profiling de Testes.

A figura compara as duas filosofias com as mesmas três goroutines. Em cima, elas escrevem direto no contador, e cada escrita passa pela trava laranja. Embaixo, elas só enviam 1 pelo canal, e a soma fica com a dona. O que muda é o lugar onde a regra mora: na trava, no caso de cima, e na posse do dado, no caso de baixo.

Compartilhar memória com um mutex protegendo o contador, contra comunicar pelo canal, em que três goroutines enviam 1 e só a dona da soma escreve.
figura 2 · Compartilhar memória com mutex ou comunicar pelo canal

Parte 2: trancas

3. sync.Mutex: lock, unlock e defer

Pense no bastão de uma reunião de condomínio. Só fala quem está com o bastão. Quem termina devolve o bastão à mesa, e se ele sair da sala segurando o bastão, a reunião inteira fica esperando alguém que foi pegar café. O sync.Mutex é esse bastão. O nome vem de mutual exclusion, exclusão mútua: só uma goroutine por vez pode estar com a trava. Lock pega o bastão e Unlock devolve.

A documentação define o comportamento assim: “Lock locks m. If the lock is already in use, the calling goroutine blocks until the mutex is available.” Ou seja, a goroutine que chega enquanto a trava está em uso fica bloqueada até ela ser liberada. O valor zero de um sync.Mutex já é uma trava destrancada, então você declara o campo e usa, sem construtor. Existe também TryLock, que tenta pegar a trava sem esperar. A própria documentação avisa que os usos corretos dele são raros e que, na prática, ele costuma indicar um problema no desenho.

O contador da seção anterior é o exemplo básico. A trava fica dentro do struct, ao lado do dado que ela protege:

type ContadorMutex struct {
	mu sync.Mutex
	n  int
}

func (c *ContadorMutex) Incrementa() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
}

O defer agenda o Unlock para o fim da função. Isso vale mesmo quando a função sai por um return no meio do caminho ou por um panic, porque as funções adiadas rodam nesses casos também. Sem o defer, cada saída precisa lembrar de devolver o bastão, e é nessa lembrança que costumam morar os travamentos. Escreva o Lock e o defer Unlock em duas linhas seguidas e não separe as duas.

A figura mostra o bastão em ação com três goroutines disputando a mesma trava. G1 chega primeiro e passa pelo Lock. G2 e G3 batem no mesmo Lock e ficam bloqueadas, o que a figura marca com o X vermelho. Quando G1 chama Unlock, a trava volta a ficar disponível para uma das que esperam. A figura não promete qual sai primeiro, e a documentação também não promete.

Mutex com três goroutines: a que tem a chave entra na seção crítica e incrementa o contador, e as outras ficam bloqueadas até o Unlock soltar a próxima.
figura 3 · Mutex: só a goroutine que tem a chave entra na seção crítica

Um detalhe que pega muita gente é que o mutex do Go não é reentrante. Se a mesma goroutine chama Lock duas vezes, ela fica esperando a si mesma para sempre. O programa abaixo faz exatamente isso:

func main() {
	var mu sync.Mutex
	mu.Lock()
	fmt.Println("primeiro Lock feito")
	mu.Lock()
	fmt.Println("nunca chega aqui")
}

A saída:

primeiro Lock feito
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [sync.Mutex.Lock]:

Esse aviso aparece porque o main era a única goroutine. Se houvesse outras goroutines vivas, o runtime não perceberia o impasse, e o programa simplesmente pararia naquela linha, sem mensagem nenhuma. Destravar algo que não está travado também é fatal, e aparece como fatal error: sync: unlock of unlocked mutex. Para o deadlock em detalhe, veja deadlock.

4. sync.RWMutex: quando a leitura domina

Numa biblioteca, dez pessoas podem ler o mesmo livro ao mesmo tempo sem pedir licença a ninguém. Quando alguém vai reescrever uma página, porém, precisa da sala inteira vazia. O sync.RWMutex aplica essa regra: várias leituras juntas, escrita sozinha. Os métodos principais são quatro. Quem só lê usa RLock e RUnlock. Quem escreve usa Lock e Unlock, como no mutex comum.

A documentação descreve a regra: “The lock can be held by an arbitrary number of readers or a single writer.” Há três consequências que você precisa conhecer. A primeira é que um escritor que pede a trava enquanto há leitores espera os leitores saírem, e enquanto ele espera, os novos RLock também esperam. A documentação diz que isso garante que a trava acabe chegando ao escritor. A segunda é que não há promoção: um RLock não vira Lock sem soltar antes, e um Lock não desce para RLock. A terceira é que a documentação proíbe leitura recursiva. Se uma goroutine já tem um RLock e pede outro no mesmo caminho, ela pode travar caso um escritor chegue no meio, porque o escritor bloqueia as novas leituras.

O cache abaixo lê muito e escreve pouco, que é o perfil em que o RWMutex faz sentido:

type Cache struct {
	mu    sync.RWMutex
	dados map[string]string
}

func Novo() *Cache {
	return &Cache{dados: make(map[string]string)}
}

// Get usa RLock: várias goroutines podem ler ao mesmo tempo.
func (c *Cache) Get(chave string) (string, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	v, ok := c.dados[chave]
	return v, ok
}

// Set usa Lock: enquanto escreve, ninguém lê nem escreve.
func (c *Cache) Set(chave, valor string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.dados[chave] = valor
}

O teste dispara cinquenta leitores e dez escritores ao mesmo tempo. Com -race, ele passa:

$ go test -race -count=1 ./cache
ok  	exemplos/sync/cache	1.219s

O RWMutex faz mais contabilidade do que o Mutex, porque precisa separar leitores de escritores. Por isso ele compensa quando as leituras dominam e cada leitura faz trabalho que vale a sobreposição, e não quando apenas devolve um campo. Para decidir, meça com as proporções reais de leitura e escrita. Eu não medi a diferença nesta aula.

A linha do tempo a seguir mostra as regras no tempo. Dois leitores entram juntos no bloco par. O escritor chega, pede Lock e espera. Um RLock que chega depois do escritor também espera, e a nota da figura explica por quê. Só quando os leitores liberam a trava é que o escritor a recebe.

RWMutex com dois leitores lendo juntos, um escritor esperando por eles e um RLock novo bloqueado atrás do escritor até a trava ser liberada.
figura 4 · RWMutex: leitores dividem a trava, o escritor a tem sozinho

Parte 3: inicialização, mapa e reaproveitamento

5. sync.Once: uma vez, mesmo com pressa

A fita de inauguração de uma loja é cortada uma única vez, mesmo que a fila inteira grite para cortar de novo. O sync.Once é essa fita. Seu método Do executa a função passada na primeira chamada e ignora as demais, inclusive as que chegam ao mesmo tempo. A documentação diz: “Do calls the function f if and only if Do is being called for the first time for this instance of Once.” Enquanto a primeira execução não termina, as outras chamadas esperam, de modo que ninguém lê a configuração pela metade.

Três detalhes merecem atenção. Se a função entra em pânico, a documentação diz que Do considera que ela terminou, e as chamadas seguintes não a executam de novo. Então o Once não serve para inicializações que precisam de nova tentativa. Se a função passada a Do chamar Do de novo, o programa trava, porque a chamada interna espera a externa terminar, e a externa espera a interna. Por fim, a biblioteca traz OnceFunc, OnceValue e OnceValues, que embrulham uma função para garantir que ela rode uma única vez, e as duas últimas também guardam o resultado. Não as usei aqui, mas vale conhecê-las.

Um uso típico é a inicialização de um recurso caro, como um cliente HTTP compartilhado, a leitura de um arquivo de configuração ou a abertura de uma conexão. O exemplo abaixo simula a leitura com um campo que conta quantas vezes a inicialização rodou:

type Carregador struct {
	once     sync.Once
	cargas   int
	conteudo string
}

// Carrega executa a inicialização uma única vez, mesmo com várias chamadas simultâneas.
func (c *Carregador) Carrega() string {
	c.once.Do(func() {
		c.cargas++
		c.conteudo = "configuração lida"
	})
	return c.conteudo
}

O teste faz cinquenta goroutines chamarem Carrega ao mesmo tempo, e depois confere se a inicialização rodou exatamente uma vez:

$ go test -race -count=1 ./once
ok  	exemplos/sync/once	1.204s

6. sync.Map: quando o map com trava não basta

Um map protegido por mutex é como um armário com uma fechadura só. É fácil de entender, e qualquer pessoa lê o código e sabe o que ele faz. O sync.Map é outro armário, pensado para que quem mexe numa gaveta não atrapalhe quem mexe em outra. A documentação diz que ele “is optimized for two common use cases”: quando a entrada de uma chave é escrita uma vez e lida muitas vezes, como um cache que só cresce, e quando várias goroutines leem, escrevem e sobrescrevem entradas de conjuntos de chaves disjuntos. Fora desses casos, a mesma documentação recomenda o contrário: “Most code should use a plain Go map instead, with separate locking or coordination, for better type safety and to make it easier to maintain other invariants along with the map content.”

Veja a operação que costuma justificar o sync.Map, LoadOrStore. Ela devolve o valor já existente, se houver, ou grava o valor dado e o devolve. O segundo resultado diz se o valor foi carregado do mapa (true) ou gravado agora (false):

var m sync.Map
v, carregou := m.LoadOrStore("pedido:42", "pendente")
fmt.Println("primeira vez:", v, carregou)
v, carregou = m.LoadOrStore("pedido:42", "pago")
fmt.Println("segunda vez:", v, carregou)
primeira vez: pendente false
segunda vez: pendente true

Na segunda chamada o valor novo, “pago”, não entra, porque a chave já existia. Ele serve bem a caches em que a primeira gravação deve prevalecer.

Não use sync.Map por reflexo. Há quatro situações em que ele atrapalha em vez de ajudar. A primeira é quando a chave e o valor precisam de tipo: o mapa trabalha com any, então cada leitura exige uma conversão de tipo, e o compilador não ajuda. A segunda é quando o mapa precisa manter uma invariante com outros dados, como um total que deve bater com a soma das entradas, porque nenhuma operação do sync.Map cobre isso sozinha. A terceira é quando você precisa do tamanho ou de uma visão consistente do conteúdo: o tipo não tem método Len, e a documentação de Range diz que ele “does not necessarily correspond to any consistent snapshot of the Map’s contents”. A quarta é quando o caso não se encaixa nos dois da documentação. Nessa situação, um map com sync.Mutex costuma ser suficiente e mais fácil de manter.

7. sync.Pool: reaproveitar sem contar com isso

Numa festa, os copos de plástico são lavados e reaproveitados em vez de jogados fora a cada brinde. Só que a faxina do salão pode levar a pilha inteira quando passar, sem avisar ninguém. O sync.Pool funciona desse jeito: guarda objetos que ninguém está usando para não alocar tudo de novo, e a coleta de lixo pode esvaziá-lo quando quiser.

A documentação diz que o objetivo do pool é “cache allocated but unused items for later reuse, relieving pressure on the garbage collector”. O exemplo citado é o pacote fmt, que mantém buffers temporários de saída. Ela também avisa que um pool não serve como lista de reaproveitamento de objetos de vida curta, porque o custo não se paga nesse cenário. E o aviso que importa mais é este: “Any item stored in the Pool may be removed automatically at any time without notification.” Tudo o que você coloca no pool pode sumir.

O exemplo abaixo mostra o reaproveitamento e o que a coleta de lixo faz com ele:

var novas int

pool := sync.Pool{New: func() any {
	novas++
	return new(bytes.Buffer)
}}
b := pool.Get().(*bytes.Buffer)
b.WriteString("olá")
b.Reset()
pool.Put(b)
fmt.Println("novos depois do primeiro Get:", novas)

b2 := pool.Get().(*bytes.Buffer)
pool.Put(b2)
fmt.Println("novos depois do segundo Get:", novas)

// Duas coletas seguidas costumam esvaziar o pool.
runtime.GC()
runtime.GC()
pool.Get()
fmt.Println("novos depois de duas coletas:", novas)
novos depois do primeiro Get: 1
novos depois do segundo Get: 1
novos depois de duas coletas: 2

O New só roda quando o pool não tem nada livre. No primeiro Get ele roda, porque o pool começa vazio. O Put devolve o buffer, e o segundo Get reaproveita o mesmo buffer, sem chamar New. Depois de duas coletas, o pool estava vazio de novo, e o New rodou outra vez. Repare na palavra “costumam”. Esse é o comportamento observado nesta execução. A linguagem não promete esse momento. O código-fonte de sync/pool.go mostra que cada coleta move o conteúdo para uma segunda área e apaga a área anterior, mas o momento exato depende do runtime.

Com o race detector ligado, o comportamento muda de propósito. Rodei o mesmo programa com go run -race e o resultado foi outro:

novos depois do primeiro Get: 1
novos depois do segundo Get: 2
novos depois de duas coletas: 3

No código de sync/pool.go há um trecho com o comentário // Randomly drop x on floor.. Com o detector ativo, o Put descarta um item em cada quatro, de forma aleatória, para pegar código que dependa de reaproveitamento. Não escreva nada que dependa disso.

O outro risco é o objeto voltar sujo. Veja um buffer sem o Reset antes de usar:

var pool = sync.Pool{New: func() any { return new(bytes.Buffer) }}

func formataSemReset(partes ...string) string {
	b := pool.Get().(*bytes.Buffer)
	defer pool.Put(b)
	for _, p := range partes {
		b.WriteString(p)
	}
	return b.String()
}

// Chamadas: formataSemReset("ola", " ", "mundo") e depois formataSemReset("tchau")
ola mundo
ola mundotchau

A segunda chamada devolveu o texto da primeira, porque o buffer voltou ao pool com o conteúdo antigo. Por isso o padrão é fazer Reset logo depois do Get. Trate todo objeto que sai do pool como se viesse sujo. Três regras resumem a seção: o pool é cache e nunca fonte de verdade, cada objeto deve ser reiniciado antes do uso, e nada que precise existir depois (conexão, sessão, estado de negócio) entra no pool.

A figura mostra o ciclo. Quando o pool não tem item livre, o Get chama New. A goroutine usa o buffer e o devolve com Put. A seta rosa é a coleta de lixo, que apaga itens guardados sem avisar.

sync.Pool entrega um buffer livre ou chama New, recebe o buffer de volta com Put, e a coleta de lixo pode apagar os itens a qualquer momento.
figura 5 · sync.Pool: o buffer volta ao pool, mas a coleta de lixo pode apagá-lo
Pergunta de entrevistaQuando usar mutex e quando usar channel?

Para proteger um estado simples e compartilhado, como um contador ou um cache, com operações curtas, uso mutex. Ele é mais simples e mais barato. Uso channel quando o trabalho é um fluxo de dados ou quando uma goroutine deve ser dona do estado. A frase de Go diz para compartilhar memória comunicando, mas isso não é regra absoluta: para um mapa de cache, o mutex costuma ser a escolha mais clara.

Parte 4: erros que custam caro e a ferramenta que os pega

8. copiar um mutex

A documentação do pacote diz que valores dos tipos definidos nele não devem ser copiados: “Values containing the types defined in this package should not be copied.” A do Mutex é mais específica: “A Mutex must not be copied after first use.” Copiar um struct que contém um mutex copia também o estado interno da trava.

O exemplo abaixo tem o erro:

type Conta struct {
	mu    sync.Mutex
	saldo int
}

// Imprime recebe Conta por valor: o mutex é copiado junto com o saldo.
func Imprime(c Conta) {
	fmt.Println("saldo:", c.saldo)
}

O mutex copiado é outro mutex. Quem trava a cópia não impede quem trava o original de entrar, e a proteção desaparece sem nenhum aviso em tempo de execução. Se a cópia acontecer com a trava fechada, ela nasce fechada, e quem tentar travá-la depois vai esperar para sempre. Copiar um mutex é parecido com tirar uma foto de um cadeado e trancar a foto: a foto parece igual, e quem a tranca acha que está protegendo algo, enquanto o cadeado de verdade continua aberto na porta.

O go vet aponta o problema:

$ go vet ./copia
copia/copia_ruim.go:14:16: Imprime passes lock by value: exemplos/sync/copia.Conta contains sync.Mutex

O go test não roda essa checagem. Por padrão, ele usa só um subconjunto do go vet: atomic, bools, buildtag, directive, errorsas, ifaceassert, nilfunc, printf, stdversion, stringintconv e tests, como diz go help test. O analisador copylocks, que pega esse erro, não está nessa lista. Por isso o go vet ./... precisa rodar no seu pipeline além dos testes.

A correção é passar um ponteiro, para que o mutex continue no lugar original:

// ImprimeCerto recebe um ponteiro: nada do mutex é copiado.
func ImprimeCerto(c *Conta) {
	c.mu.Lock()
	defer c.mu.Unlock()
	fmt.Println("saldo:", c.saldo)
}
$ go vet ./copiacerta
(sem saída, código de saída 0)

9. go test -race: a saída de verdade

O race detector é uma ferramenta que o próprio Go oferece. Basta acrescentar -race ao comando de teste. A documentação diz que ele só encontra corridas que acontecem durante a execução, então um caminho de código que não roda não é examinado. O custo é alto: a documentação fala em memória de 5 a 10 vezes maior e tempo de execução de 2 a 20 vezes maior. Por isso ele fica no teste e no CI, fora da produção.

Esta é a saída do teste com a corrida de propósito, rodado com -race. Mostro o primeiro dos dois blocos WARNING, com caminhos encurtados. O segundo bloco é do mesmo tipo, com duas escritas que se atropelam:

$ go test -race -count=1 ./democorrida
==================
WARNING: DATA RACE
Read at 0x00c000108178 by goroutine 8:
  exemplos/sync/democorrida.ContaSemTrava.func1()
      .../democorrida/sem_trava.go:14 +0x68

Previous write at 0x00c000108178 by goroutine 10:
  exemplos/sync/democorrida.ContaSemTrava.func1()
      .../democorrida/sem_trava.go:14 +0x78

Goroutine 8 (running) created at:
  exemplos/sync/democorrida.ContaSemTrava()
      .../democorrida/sem_trava.go:12 +0x70
  exemplos/sync/democorrida.TestContaSemTrava()
      .../democorrida/sem_trava_test.go:7 +0x28
  ...
==================
--- FAIL: TestContaSemTrava (0.01s)
    sem_trava_test.go:8: ContaSemTrava(1000) = 770, quero 1000
    testing.go:1867: race detected during execution of test
FAIL
FAIL	exemplos/sync/democorrida	0.247s
FAIL

Para ler o relatório, comece pela linha WARNING: DATA RACE. As linhas Read at e Previous write at dizem quais duas goroutines acessaram a mesma posição de memória e se cada acesso foi leitura ou escrita. O número depois de goroutine identifica a goroutine, e a linha com o caminho do arquivo mostra o lugar do código. Em Goroutine 8 (running) created at, o relatório mostra onde a goroutine foi criada, o que ajuda a achar quem a disparou. A última parte é o teste. Ele falhou pelo contador, e a linha race detected during execution of test diz que o detector também derrubou o teste.

Agora o mesmo comando nos pacotes corretos. Os três passam:

$ go test -race -count=1 ./contador ./cache ./once
ok  	exemplos/sync/contador	1.274s
ok  	exemplos/sync/cache	1.219s
ok  	exemplos/sync/once	1.204s

Passar com -race não prova que o código está livre de corrida. Prova que o caminho exercitado pelo teste não teve corrida naquela execução. Como a ferramenta só enxerga o que o teste executa, a cobertura dos caminhos concorrentes pesa tanto quanto a flag.

10. erros comuns e boas práticas

Quem jura que nunca esqueceu um Unlock ainda não passou horas olhando um processo parado sem mensagem nenhuma. O erro mais frequente é o esquecimento, e o defer resolve a maior parte dele. Quando a função tem vários caminhos de saída e o Unlock está no fim, um return no meio faz a trava ficar presa para sempre. Use o defer logo depois do Lock e pronto.

O erro mais traiçoeiro é trancar só a escrita. Se o código escreve com Lock e lê sem nenhuma trava, o teste de escrita passa, e a leitura pode pegar um valor pela metade. A leitura também precisa de RLock quando o RWMutex é usado, e de Lock quando o dado é protegido por um Mutex. O race detector pega esse caso desde que o teste leia e escreva ao mesmo tempo, então escreva testes que façam as duas coisas.

Outro erro recorrente é a trava recursiva. Como o mutex do Go não é reentrante, um método que tranca o mutex e chama outro método que tenta trancar o mesmo mutex se trava sozinho. A regra prática é que métodos internos não peguem a trava de novo: quem já a tem chama uma versão do método que assume que ela está presa. Com RWMutex, o cuidado é maior, porque um RLock recursivo pode travar se um escritor chegar entre as duas leituras.

Um erro sutil é devolver de dentro da trava uma referência ao estado protegido. Um getter que retorna o map interno protege a leitura do campo, mas quem recebe o map usa o dado depois que a trava já foi solta. O exercício 2 mostra o caso. Num programa que lê o retorno enquanto outras goroutines escrevem, o -race acusou a corrida:

WARNING: DATA RACE
Write at 0x00c0001940f0 by goroutine 7:
  runtime.mapaccess2_faststr()
  ...

Outro erro é fazer trabalho lento dentro da seção crítica. Chamadas de rede, leitura de disco e espera por outro serviço transformam uma trava que deveria durar pouco numa fila de espera. A regra é manter a seção curta: pegue o dado, solte a trava, e só depois faça a chamada.

Há também erros que já foram tratados nas seções anteriores e vale repetir aqui. Copiar um mutex quebra a exclusão mútua sem aviso (seção 8). Guardar estado essencial num sync.Pool faz esse estado sumir numa coleta (seção 7). Trocar um map com trava por sync.Map sem olhar para o caso de uso é uma escolha pelo nome, não pelo problema (seção 6).

Por último, rodar os testes só sem -race é como revisar uma planilha sem somar a coluna. Coloque go test -race ./... no pipeline, e rode go vet ./... ao lado. Três hábitos resolvem a maior parte dos problemas desta aula: deixar o mutex no mesmo struct do dado e perto do campo que protege, escrever o Lock e o defer Unlock em sequência, e só tocar no dado protegido dentro dos métodos que cuidam da trava.

Parte 5: prática e fechamento

11. exercícios

Os exercícios têm solução de referência, rodada com -race. Antes de olhar a solução, tente escrever a sua.

Exercício 1: contador de visitas por página

Objetivo: escreva um tipo Contador com os métodos Registra(pagina string) e Total(pagina string) int. Ele deve aguentar cem goroutines registrando visitas em três páginas ao mesmo tempo, sem perder nenhuma.

Solução de referência:

package visitas

import "sync"

// Contador guarda visitas por página. O map não é seguro para uso
// concorrente, então o mutex protege tanto a escrita quanto a leitura.
type Contador struct {
	mu      sync.Mutex
	paginas map[string]int
}

func Novo() *Contador {
	return &Contador{paginas: make(map[string]int)}
}

func (c *Contador) Registra(pagina string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.paginas[pagina]++
}

func (c *Contador) Total(pagina string) int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.paginas[pagina]
}

O teste dispara cem goroutines por página e confere se cada página chegou a cem:

$ go test -race -count=1 ./exercicios/visitas
ok  	exemplos/sync/exercicios/visitas	1.183s

Exercício 2: o getter que vaza o map

Enunciado: o método abaixo protege a escrita, mas devolve o map interno. Explique por que o -race acusa uma corrida quando outra goroutine lê o retorno enquanto alguém escreve, e corrija.

func (r *Registro) Itens() map[string]int {
	r.mu.Lock()
	defer r.mu.Unlock()
	return r.itens
}

Solução de referência: a trava protege só o momento em que o map é entregue. Depois disso, quem recebeu o map lê e escreve sem trava. Num programa que lê o retorno enquanto outras goroutines escrevem, o -race acusa a corrida com essa versão. A correção é devolver uma cópia feita dentro da trava:

// Copia devolve um map novo. Devolver r.itens direto vazaria o map
// para fora da trava, e quem o lesse depois teria uma corrida.
func (r *Registro) Copia() map[string]int {
	r.mu.Lock()
	defer r.mu.Unlock()
	out := make(map[string]int, len(r.itens))
	for k, v := range r.itens {
		out[k] = v
	}
	return out
}
$ go test -race -count=1 ./exercicios/registro
ok  	exemplos/sync/exercicios/registro	1.178s

Exercício 3: o buffer que guarda o texto antigo

Enunciado: a função formataSemReset da seção 7 devolve ola mundotchau na segunda chamada. Explique o motivo e corrija sem trocar o pool por uma alocação nova a cada chamada.

Solução de referência: o buffer volta ao pool com o conteúdo da chamada anterior. Basta chamar Reset logo depois do Get:

package buffer

import (
	"bytes"
	"sync"
)

var pool = sync.Pool{
	New: func() any { return new(bytes.Buffer) },
}

// Formata usa um buffer do pool. Sem o Reset, o texto de uma chamada
// vazaria para a seguinte, quando o mesmo buffer voltasse ao pool.
func Formata(partes ...string) string {
	b := pool.Get().(*bytes.Buffer)
	defer pool.Put(b)
	b.Reset()
	for _, p := range partes {
		b.WriteString(p)
	}
	return b.String()
}
$ go test -race -count=1 ./exercicios/buffer
ok  	exemplos/sync/exercicios/buffer	1.358s

O Reset fica antes do uso, e não depois. Ele garante o estado limpo para quem pegou o objeto, qualquer que tenha sido o último uso dele.

12. glossário

  • Condição de corrida: situação em que o resultado depende da ordem em que partes concorrentes executam, e essa ordem não é controlada pelo código.
  • Corrida de dados: acesso concorrente à mesma posição de memória, com pelo menos uma escrita e sem coordenação. É o caso que o race detector procura.
  • Seção crítica: trecho de código que só pode ser executado por uma goroutine de cada vez, porque mexe num dado compartilhado.
  • Exclusão mútua: garantia de que duas goroutines não entram na mesma seção crítica ao mesmo tempo. Quem a fornece é o mutex.
  • Mutex: trava com Lock e Unlock. Uma goroutine segura a trava, e as outras esperam.
  • RWMutex: trava com dois modos, leitura compartilhada (RLock) e escrita exclusiva (Lock).
  • Inanição (starvation): situação em que uma goroutine nunca consegue a trava porque outras sempre passam na frente. A documentação do RWMutex garante que o escritor acabe recebendo a trava.
  • Reentrante: trava que pode ser pega de novo pela goroutine que já a tem. O mutex do Go não é reentrante.
  • Race detector: ferramenta ativada com -race, que detecta corridas de dados que acontecem durante a execução.
  • Pool de objetos: depósito de objetos livres para reaproveitamento (sync.Pool). Pode ser esvaziado a qualquer momento pela coleta de lixo.
  • Coleta de lixo (GC): mecanismo do runtime que libera a memória que o programa não usa mais.
  • Sincroniza antes (synchronizes before): relação que o modelo de memória do Go garante entre duas operações. Por exemplo, o Unlock de uma trava sincroniza antes do Lock seguinte na mesma trava.

O que não foi confirmado

Não medi o desempenho de nenhuma das comparações desta aula: mutex contra canal, RWMutex contra Mutex e sync.Map contra map com trava. As afirmações sobre quando cada um compensa são qualitativas, e a decisão real depende de benchmark com a carga da sua aplicação.

O comportamento do sync.Pool foi observado numa execução com Go 1.27.2. A documentação não promete nenhum momento para a coleta, e o descarte aleatório de Put sob o race detector vem do código-fonte, não da documentação pública. Esses dois pontos podem mudar entre versões.

Testei só com Go 1.27.2 em macOS (arm64). Não verifiquei os exemplos em versões anteriores. A página pública do pacote sync indica go1.27.3, e a toolchain que usei é a go1.27.2. As assinaturas e descrições que cito vieram da documentação local dessa versão e batem com a página pública, mas não comparei linha a linha.

A página do Effective Go diz que foi escrita para a versão de 2009 e que não é atualizada ativamente. A frase sobre compartilhar memória continua válida como orientação, mas a página não acompanha as versões mais novas da linguagem.

A saída do race detector e as mensagens de erro do runtime podem mudar de formato entre versões. Os trechos desta aula são cópias da execução com essa versão.

Fontes

  • Pacote sync, documentação oficial: https://pkg.go.dev/sync (consultada em 2026-11-03; a página indica go1.27.3). Conferência de assinaturas com go doc sync na toolchain go1.27.2 instalada, também em 2026-11-03.
  • Effective Go, seção de concorrência: https://go.dev/doc/effective_go (consultada em 2026-11-03).
  • Modelo de memória do Go: https://go.dev/ref/mem (cópia local da go1.27.2, consultada em 2026-11-03, para a relação “synchronizes before”).
  • Race detector: https://go.dev/doc/articles/race_detector (consultado em 2026-11-03).
  • Código-fonte de src/sync/pool.go da toolchain go1.27.2, consultado em 2026-11-03, para o descarte aleatório de Put com o race detector e para a movimentação de itens entre coletas.
  • Ajuda local de go test e go vet (go help test, go tool vet help) da toolchain go1.27.2, consultada em 2026-11-03, para o subconjunto de análises que o go test executa.

Relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?