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 tipostringe valores do tipoint.stock["grape"]retorna o valor zero (0) quando a chave não existe. A variávelokinforma se a chave de fato está no map ou não.deleteremove uma chave. Chamardeletecom 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. InceGetambas bloqueiam o mutex, então em cada momento apenas uma goroutine manipula o map.Snapshotretorna uma cópia usandomaps.Clone(disponível desde Go 1.21). Se você retornarc.countsdiretamente, o chamador iterará sobre esse map sem lock, e você voltará ao erro original.- O construtor
NewVisitCounterinicializa 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:
RWMutexnem sempre é mais rápido queMutex. Ele tem um overhead de gerenciamento maior. Quando a proporção de escritas é significativa, ou quando o código dentro do lock é muito curto,Mutexgeralmente é 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:
Storeescreve um par chave/valor.LoadOrStoreé uma operação atômica. Se a chave já existe, o método retorna o valor antigo eloaded = true, sem sobrescrever. Esta é a forma correta de lidar com o cenário check-then-act comsync.Map.Rangeitera sobre o map. A função de callback retornafalsepara 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 comRange. 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
mapcomMutexouRWMutexé geralmente mais rápido, mais legível e type-safe. Não substitua automaticamente todos os maps porsync.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.