Map en Go y el error de concurrent map writes
1. Descripción general
Map es la estructura de datos más usada en Go, después de slice. Sin embargo, los maps no son seguros cuando múltiples goroutines acceden a ellos. Si escribes en un map desde múltiples goroutines, el programa puede detenerse por completo con el mensaje fatal error: concurrent map writes.
Este artículo repasa las operaciones básicas con maps. Luego explica por qué ocurre ese error, cómo detectarlo con race detector, y tres formas de solucionarlo: sync.Mutex, sync.RWMutex, sync.Map. Los ejemplos usan Go 1.22 en adelante.
2. Map en Go: lo que debes recordar
2.1. Operaciones 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))
}
Explicación:
map[string]int{...}crea un map con claves de tipostringy valores de tipoint.stock["grape"]devuelve el valor cero (0) cuando la clave no existe. La variableokte dice si la clave realmente está en el map o no.deleteelimina una clave. Llamar adeletecon una clave que no existe no causa error.
0 false
2
2.2. Map nil
Declarar var m map[K]V sin inicializar te da un map nil. Leer de un map nil funciona, pero escribir en él provoca un panic:
package main
func main() {
var orders map[string]int
_ = orders["A-001"] // OK, devuelve 0
orders["A-001"] = 1 // panic
}
panic: assignment to entry in nil map
La solución es siempre inicializar el map usando make(map[string]int) o mediante un literal map[string]int{}. Este error es común cuando el map es un campo en una struct y olvidas inicializarlo en el constructor.
2.3. Map es un tipo de referencia, el orden de iteración es aleatorio
Cuando asignas un map a otra variable o lo pasas a una función, ambas apuntan a una misma estructura de datos subyacente. Por lo tanto, dos goroutines que reciben “el mismo map” en realidad comparten la misma región de memoria. Esta es la raíz del problema en la siguiente sección.
Además, el orden de iteración en for k, v := range m es deliberadamente aleatorio en cada ejecución. No escribas código ni tests que dependan de ese orden. Si necesitas un orden fijo, extrae las claves, ordénalas y luego itera.
3. El error concurrent map writes
3.1. Reproducir el error
Supongamos que cuentas las visitas por usuario, cada request se ejecuta en una 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))
}
Si ejecutas esto varias veces, casi con seguridad verás:
fatal error: concurrent map writes
goroutine 23 [running]:
main.main.func1()
/tmp/visits/main.go:17 +0x...
...
exit status 2
La línea visits[...]++ implica una lectura y una escritura. Cuando 100 goroutines hacen esto simultáneamente, muchas goroutines pueden modificar la estructura interna del map (buckets, redimensionamiento) al mismo tiempo. El runtime de Go tiene un mecanismo de comprobación simple: el map mantiene una bandera “escribiendo”. Si una goroutine ve esa bandera activada cuando comienza a escribir, el runtime detiene el programa inmediatamente.
Hay una variante similar cuando una goroutine lee mientras otra escribe:
fatal error: concurrent map read and map write
3.2. Por qué no se puede recuperar
Muchos intentan envolver el código con recover:
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
visits["user-1"]++
}()
Este enfoque no funciona. concurrent map writes no es un panic. Es un fatal error que el runtime dispara con throw, y termina todo el proceso. recover no puede atraparlo. Esta decisión es intencional: si el map está corrupto, continuar ejecutándose es más peligroso que detenerse.
Nota: El mecanismo de detección del runtime es solo “best-effort”. Que el programa no se bloquee no significa que el código sea seguro. Es posible que simplemente no hayas encontrado el momento exacto en que dos goroutines colisionen, y los datos ya estén incorrectos silenciosamente.
3.3. Detectar temprano con race detector
Una herramienta más confiable es race detector, que se activa con la bandera -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...
==================
Race detector señala exactamente la línea de lectura y la línea de escritura que están en conflicto. Debes activar -race cuando ejecutes tests en CI. Los programas que se ejecutan con -race son más lentos y consumen más memoria, por lo que normalmente no uses esta bandera en builds de producción.
4. Primera solución: sync.Mutex
La forma más común y fácil de entender es envolver el map en una struct que tenga un mutex, y permitir el acceso al map solo a travé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)
}
Explicación:
- El campo
countsestá en minúsculas, así que el código fuera del paquete no puede acceder directamente al map. - Tanto
InccomoGetbloquean el mutex, por lo que solo una goroutine accede al map en cada momento. Snapshotdevuelve una copia usandomaps.Clone(disponible desde Go 1.21). Si devolvierasc.countsdirectamente, quien llama iteraría ese map sin el bloqueo, y volverías al error original.- El constructor
NewVisitCounterinicializa el map, evitando el error de nil map de la sección 2.2.
4.1. Escribir tests con -race
Go no usa JUnit ni AssertJ. El paquete testing estándar es suficiente para este ejemplo:
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
El test verifica dos cosas. Primero, el resultado es exactamente 100.000, lo que significa que no se perdió ningún incremento. Segundo, race detector no reporta DATA RACE. Si quitas c.mu.Lock() en Inc y ejecutas de nuevo, el test fallará inmediatamente.
4.2. Errores comunes con mutex
Copiar una struct que contiene un mutex. Si declaras un método con value receiver, como func (c VisitCounter) Inc(...), cada llamada al método usará una copia del mutex. El bloqueo en la copia no protege nada. Siempre usa pointer receiver. go vet te advertirá sobre esto mediante la verificación copylocks:
./visits.go:18:9: Inc passes lock by value: visits.VisitCounter contains sync.Mutex
Mantener el bloqueo demasiado tiempo. No hagas llamadas HTTP, consultas a base de datos ni escrituras de archivos mientras mantienes el bloqueo. Extrae los datos necesarios, libera el bloqueo, y luego realiza las operaciones lentas.
Check-then-act separado. El código siguiente tiene dos bloqueos separados, por lo que entre ellos otra goroutine puede intercalarse:
if counter.Get("bob") == 0 {
counter.Inc("bob") // otra goroutine pudo haber incrementado antes
}
Si necesitas la lógica “si no existe entonces crear”, escríbelo como un solo método y mantén el bloqueo durante todo el método.
5. Segunda solución: sync.RWMutex
Para datos que se leen mucho pero casi nunca se escriben, como cache de configuración o tabla de precios, sync.RWMutex permite que múltiples goroutines lean en 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últiples goroutines lean simultáneamente. Lock es un bloqueo exclusivo: espera a que todos los lectores terminen y bloquea nuevos lectores hasta que termina la escritura.
Nota:
RWMutexno siempre es más rápido queMutex. Tiene un overhead de gestión mayor. Cuando la tasa de escritura es significativa, o cuando el código dentro del bloqueo es muy corto,Mutexsuele ser comparable o más rápido. Mide con benchmarks antes de cambiar.
Un error común: llamar p.mu.RLock() y luego dentro llamar otro método que también hace RLock. Si hay un writer esperando entre las dos llamadas, el programa puede deadlock. La razón es que RWMutex bloquea nuevos lectores cuando hay un writer en la cola.
6. Tercera solución: sync.Map
El paquete sync proporciona sync.Map, un map seguro para concurrencia sin necesidad de gestionar bloqueos manualmente:
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
Explicación:
Storeescribe un par clave/valor.LoadOrStorees una operación atómica. Si la clave ya existe, el método devuelve el valor antiguo conloaded = truey no sobrescribe. Esta es la forma correcta de manejar la situación check-then-act consync.Map.Rangeitera sobre el map. La función callback devuelvefalsepara detenerse temprano.
sync.Map tiene limitaciones que debes conocer:
- Clave y valor son de tipo
any, así que pierdes comprobación de tipos en tiempo de compilación y necesitas type assertion en todas partes. - No tiene
Len(). Para contar elementos, debes iterar conRange. sync.Mapestá optimizado para dos casos específicos. Uno es cuando cada clave se escribe una sola vez y luego se lee muchas veces, como un cache que solo crece. Otro es cuando las goroutines leen y escriben en conjuntos de claves disjuntos.
Nota: Fuera de esos dos casos, un
mapnormal conMutexoRWMutexsuele ser más rápido, más legible y más seguro en tipos. No reemplaces automáticamente todos los maps consync.Map.
7. ¿Cuál es la solución a elegir?
| Situación | Solución recomendada |
|---|---|
| Lectura/escritura mezclada, lógica condicional | map + sync.Mutex |
| Lectura domina, escritura rara | map + sync.RWMutex (medir primero) |
| Cache de solo adición, o cada goroutine usa su propio conjunto de claves | sync.Map |
| Map usado solo en una goroutine | map normal, sin bloqueo |
Hay otra forma: dejar que una sola goroutine sea dueña del map y que las otras goroutines envíen solicitudes a través de un canal. Esto funciona bien cuando la actualización del map está vinculada a un bucle de procesamiento existente. Para un contador simple, este enfoque es más verboso que usar mutex sin aportar beneficios claros.
Nota adicional sobre maps de solo lectura: si el map se inicializa antes de que comiencen las goroutines y nadie escribe después, es seguro que múltiples goroutines lean simultáneamente sin necesidad de bloqueos.
8. Conclusión
Los maps en Go son rápidos y convenientes, pero no son seguros para concurrencia. Cuando múltiples goroutines escriben al mismo tiempo, o cuando hay lectura y escritura simultánea, el runtime puede detener el programa con fatal error: concurrent map writes. Este error no se puede recuperar. Que el programa no se bloquee tampoco prueba que el código sea correcto, así que ejecuta los tests con go test -race para detectar data races temprano.
En la mayoría de los casos, simplemente necesitas envolver el map en una struct con sync.Mutex, acceder a través de métodos con pointer receiver, y devolver copias en lugar del map original. Usa sync.RWMutex cuando la carga de lectura realmente domina, y usa sync.Map solo para los escenarios exactos para los que fue diseñado.