Map em Go e o erro concurrent map writes

1. Visão geral

Map é a estrutura de dados mais usada em Go, depois do slice. Maps não são seguros quando múltiplas goroutines acessam simultaneamente. Se você escreve em um map a partir de múltiplas goroutines, o programa pode parar completamente com a mensagem fatal error: concurrent map writes.

Este artigo revisa as operações básicas com maps. Em seguida, explica por que esse erro ocorre, como detectá-lo com o race detector, e três maneiras de consertar: sync.Mutex, sync.RWMutex, sync.Map. Os exemplos usam Go 1.22 ou superior.

2. Map em Go: coisas que você precisa saber

2.1. Operações básicas

package main

import "fmt"

func main() {
	stock := map[string]int{"apple": 10, "banana": 5}
	stock["orange"] = 7

	qty, ok := stock["grape"]
	fmt.Println(qty, ok)

	delete(stock, "banana")
	fmt.Println(len(stock))
}

Explicação:

  • map[string]int{...} cria um map com chaves do tipo string e valores do tipo int.
  • stock["grape"] retorna o valor zero (0) quando a chave não existe. A variável ok informa se a chave de fato está no map ou não.
  • delete remove uma chave. Chamar delete com uma chave inexistente não causa erro.
0 false
2

2.2. Nil map

Declarar var m map[K]V sem inicializar resulta em um nil map. Ler de um nil map funciona, mas escrever nele causa panic:

package main

func main() {
	var orders map[string]int
	_ = orders["A-001"] // OK, retorna 0
	orders["A-001"] = 1 // panic
}
panic: assignment to entry in nil map

A solução é sempre inicializar o map com make(map[string]int) ou com um literal map[string]int{}. Esse erro é comum quando o map é um campo em uma struct e você esquece de inicializá-lo no construtor.

2.3. Map é um tipo de referência, ordem de iteração aleatória

Quando você atribui um map a outra variável ou passa para uma função, ambos apontam para a mesma estrutura de dados subjacente. Assim, duas goroutines que recebem o “mesmo map” estão de fato compartilhando a mesma região de memória. Essa é a raiz do problema discutido na próxima seção.

Além disso, a ordem de iteração for k, v := range m é intencionalmente embaralhada pelo runtime. Não escreva código ou testes que dependam dessa ordem. Se você precisa de uma ordem fixa, extraia as chaves, ordene-as e depois itere.

3. O erro concurrent map writes

3.1. Reproduzindo o erro

Suponha que você conte o número de acessos por usuário, com cada solicitação executando em uma goroutine:

package main

import (
	"fmt"
	"sync"
)

func main() {
	visits := make(map[string]int)
	var wg sync.WaitGroup

	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := 0; j < 1000; j++ {
				visits[fmt.Sprintf("user-%d", j%10)]++
			}
		}()
	}

	wg.Wait()
	fmt.Println(len(visits))
}

Se você executar várias vezes, quase com certeza verá:

fatal error: concurrent map writes

goroutine 23 [running]:
main.main.func1()
	/tmp/visits/main.go:17 +0x...
...
exit status 2

A linha visits[...]++ consiste em uma leitura e uma escrita. Quando 100 goroutines fazem isso ao mesmo tempo, múltiplas goroutines podem modificar a estrutura interna do map (buckets, processo de crescimento) simultaneamente. O runtime do Go tem um mecanismo de verificação simples: o map mantém uma flag “em escrita”. Se uma goroutine vir que essa flag já foi ativada quando ela começa a escrever, o runtime interrompe o programa imediatamente.

Há uma variante semelhante quando uma goroutine lê enquanto outra escreve:

fatal error: concurrent map read and map write

3.2. Por que não conseguir recuperar disso

Muitas pessoas tentam envolver o código com recover:

go func() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("recovered:", r)
		}
	}()
	visits["user-1"]++
}()

Essa abordagem não funciona. concurrent map writes não é um panic. É um fatal error gerado por uma chamada throw do runtime, que encerra o processo inteiro. recover não consegue capturá-lo. Essa decisão é intencional: continuar executando com um map corrompido é mais perigoso do que parar completamente.

Nota: O mecanismo de detecção do runtime é apenas “best-effort”. O programa não travar não significa que o código é seguro. Talvez você simplesmente ainda não tenha encontrado o momento exato em que as duas goroutines se chocam, e os dados já podem estar incorretos silenciosamente.

3.3. Detecção precoce com o race detector

Uma ferramenta mais confiável é o race detector, ativado com a flag -race:

go run -race main.go
==================
WARNING: DATA RACE
Read at 0x00c000... by goroutine 8:
  runtime.mapaccess1_faststr()
  main.main.func1()
      /tmp/visits/main.go:17 +0x...

Previous write at 0x00c000... by goroutine 7:
  runtime.mapassign_faststr()
  main.main.func1()
      /tmp/visits/main.go:17 +0x...
==================

O race detector aponta exatamente a linha de leitura e a linha de escrita que conflitam. Você deve ativar -race ao executar testes em CI. O programa executado com -race é mais lento e consome mais memória, portanto geralmente você não usa essa flag para a build de produção.

4. Primeira solução: sync.Mutex

A maneira mais comum e fácil de entender é envolver o map em uma struct com um mutex, depois permitir o acesso ao map apenas através de métodos:

package visits

import (
	"maps"
	"sync"
)

type VisitCounter struct {
	mu     sync.Mutex
	counts map[string]int
}

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

func (c *VisitCounter) Inc(user string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.counts[user]++
}

func (c *VisitCounter) Get(user string) int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.counts[user]
}

func (c *VisitCounter) Snapshot() map[string]int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return maps.Clone(c.counts)
}

Explicação:

  • O campo counts é escrito em minúscula para que código fora do pacote não possa acessar o map diretamente.
  • Inc e Get ambas bloqueiam o mutex, então em cada momento apenas uma goroutine manipula o map.
  • Snapshot retorna uma cópia usando maps.Clone (disponível desde Go 1.21). Se você retornar c.counts diretamente, o chamador iterará sobre esse map sem lock, e você voltará ao erro original.
  • O construtor NewVisitCounter inicializa o map, evitando o erro de nil map da seção 2.2.

4.1. Escrevendo testes com -race

Go não usa JUnit ou AssertJ. O pacote padrão testing é suficiente para este exemplo:

package visits

import (
	"sync"
	"testing"
)

func TestVisitCounter_ConcurrentInc(t *testing.T) {
	counter := NewVisitCounter()
	var wg sync.WaitGroup

	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := 0; j < 1000; j++ {
				counter.Inc("alice")
			}
		}()
	}
	wg.Wait()

	if got := counter.Get("alice"); got != 100_000 {
		t.Fatalf("Get(alice) = %d, want 100000", got)
	}
}
go test -race ./...
ok  	example.com/visits	1.214s

O teste verifica duas coisas. Primeiro, o resultado deve ser exatamente 100.000, ou seja, nenhum incremento foi perdido. Segundo, o race detector não reporta DATA RACE. Tente remover c.mu.Lock() em Inc e execute novamente - o teste falhará imediatamente.

4.2. Erros comuns com mutex

Copiar struct contendo mutex. Se você declara um método com value receiver, como func (c VisitCounter) Inc(...), cada chamada do método usará uma cópia do mutex. O lock na cópia não protege nada. Sempre use pointer receiver. go vet aviará sobre isso através da verificação copylocks:

./visits.go:18:9: Inc passes lock by value: visits.VisitCounter contains sync.Mutex

Manter o lock por muito tempo. Não faça chamadas HTTP, consulte banco de dados ou escreva em arquivo enquanto mantém o lock. Extraia os dados necessários, libere o lock e depois faça o trabalho lento.

Check-then-act separado. O código abaixo tem dois locks separados, então entre os dois, outra goroutine pode intervir:

if counter.Get("bob") == 0 {
	counter.Inc("bob") // outra goroutine pode ter incrementado antes
}

Se você precisa da lógica “se não existe, crie”, escreva isso como um único método e mantenha o lock durante todo ele.

5. Segunda solução: sync.RWMutex

Para dados que são lidos muito frequentemente mas raramente escritos, como cache de configuração ou tabela de preços de produtos, sync.RWMutex permite que múltiplas goroutines leiam em paralelo:

package pricing

import "sync"

type PriceTable struct {
	mu     sync.RWMutex
	prices map[string]int64
}

func NewPriceTable() *PriceTable {
	return &PriceTable{prices: make(map[string]int64)}
}

func (p *PriceTable) Price(sku string) (int64, bool) {
	p.mu.RLock()
	defer p.mu.RUnlock()
	v, ok := p.prices[sku]
	return v, ok
}

func (p *PriceTable) SetPrice(sku string, cents int64) {
	p.mu.Lock()
	defer p.mu.Unlock()
	p.prices[sku] = cents
}

RLock permite que múltiplas goroutines leiam ao mesmo tempo. Lock é um lock exclusivo: ele espera que todos os leitores terminem e bloqueia novos leitores até que a escrita seja concluída.

Nota: RWMutex nem sempre é mais rápido que Mutex. Ele tem um overhead de gerenciamento maior. Quando a proporção de escritas é significativa, ou quando o código dentro do lock é muito curto, Mutex geralmente é equivalente ou mais rápido. Meça com benchmark antes de fazer a mudança.

Um erro fácil de cometer: chamar p.mu.RLock() e depois chamar outro método que também faz RLock. Se houver um writer esperando entre os dois, o programa pode fazer deadlock. A razão é que RWMutex bloqueia novos leitores quando há um writer na fila.

6. Terceira solução: sync.Map

O pacote sync tem sync.Map, um map seguro para concorrência que não requer gerenciamento manual de locks:

package main

import (
	"fmt"
	"sync"
)

type Session struct {
	UserID string
}

func main() {
	var sessions sync.Map

	sessions.Store("token-abc", Session{UserID: "alice"})

	actual, loaded := sessions.LoadOrStore("token-abc", Session{UserID: "bob"})
	fmt.Println(actual.(Session).UserID, loaded)

	if v, ok := sessions.Load("token-abc"); ok {
		fmt.Println(v.(Session).UserID)
	}

	sessions.Range(func(key, value any) bool {
		fmt.Println(key, value.(Session).UserID)
		return true
	})
}
alice true
alice
token-abc alice

Explicação:

  • Store escreve um par chave/valor.
  • LoadOrStore é uma operação atômica. Se a chave já existe, o método retorna o valor antigo e loaded = true, sem sobrescrever. Esta é a forma correta de lidar com o cenário check-then-act com sync.Map.
  • Range itera sobre o map. A função de callback retorna false para parar cedo.

sync.Map tem algumas limitações que você precisa conhecer:

  • Chaves e valores têm tipo any, então você perde verificação de tipo em tempo de compilação e precisa fazer type assertions em todo lugar.
  • Não há um método Len(). Para contar elementos, você precisa iterar com Range.
  • sync.Map é otimizado para dois casos específicos. Um é quando cada chave é escrita uma vez e depois lida muitas vezes, como um cache que apenas cresce. Outro é quando goroutines leem e escrevem em conjuntos de chaves disjuntos.

Nota: Fora desses dois casos, um map com Mutex ou RWMutex é geralmente mais rápido, mais legível e type-safe. Não substitua automaticamente todos os maps por sync.Map.

7. Qual solução escolher?

Cenário Recomendação
Leituras/escritas intercaladas, lógica condicional map + sync.Mutex
Leituras dominam, escritas raras map + sync.RWMutex (medir antes)
Cache apenas de crescimento, ou cada goroutine usa conjunto de chaves próprio sync.Map
Map usado apenas em uma goroutine map comum, sem lock necessário

Há outro modo: deixar uma única goroutine possuir o map, e as outras goroutines enviarem solicitações através de channels. Essa abordagem funciona bem quando a atualização do map está ligada a um loop de processamento existente. Para um simples contador, essa abordagem é mais verbosa que usar mutex sem trazer benefícios claros.

Observação adicional sobre maps somente leitura: se o map for inicializado antes que as goroutines comecem e ninguém escrever nele depois, múltiplas goroutines lendo simultaneamente é seguro e não requer locks.

8. Conclusão

Maps em Go são rápidos e convenientes, mas não são seguros para concorrência. Quando múltiplas goroutines escrevem, ou quando há leitura e escrita simultâneas, o runtime pode parar o programa com fatal error: concurrent map writes. Este erro não pode ser capturado com recover. O programa não travar não prova que o código está correto, então execute os testes com go test -race para detectar data races cedo.

Na maioria dos casos, você só precisa envolver o map em uma struct com sync.Mutex, acessar através de métodos com pointer receiver, e retornar uma cópia em vez do map original. Use sync.RWMutex quando a carga de leitura for realmente dominante, e use sync.Map apenas para os cenários específicos para os quais foi projetado.

golangmapconcurrencygoroutinesync