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 tipo string y valores de tipo int.
  • stock["grape"] devuelve el valor cero (0) cuando la clave no existe. La variable ok te dice si la clave realmente está en el map o no.
  • delete elimina una clave. Llamar a delete con 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 counts está en minúsculas, así que el código fuera del paquete no puede acceder directamente al map.
  • Tanto Inc como Get bloquean el mutex, por lo que solo una goroutine accede al map en cada momento.
  • Snapshot devuelve una copia usando maps.Clone (disponible desde Go 1.21). Si devolvieras c.counts directamente, quien llama iteraría ese map sin el bloqueo, y volverías al error original.
  • El constructor NewVisitCounter inicializa 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: RWMutex no siempre es más rápido que Mutex. 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, Mutex suele 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:

  • Store escribe un par clave/valor.
  • LoadOrStore es una operación atómica. Si la clave ya existe, el método devuelve el valor antiguo con loaded = true y no sobrescribe. Esta es la forma correcta de manejar la situación check-then-act con sync.Map.
  • Range itera sobre el map. La función callback devuelve false para 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 con Range.
  • sync.Map está 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 map normal con Mutex o RWMutex suele ser más rápido, más legible y más seguro en tipos. No reemplaces automáticamente todos los maps con sync.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.

golangmapconcurrencygoroutinesync