Los map en Go son estructuras de datos de alto rendimiento diseñadas para ser extremadamente rápidas en un único hilo de ejecución. Sin embargo, no son seguros para la concurrencia (not thread-safe). Esto significa que si intentas leer un mapa mientras otra goroutine lo está modificando, o si dos goroutines intentan escribir al mismo tiempo, el programa fallará.
Esta decisión de diseño existe por una cuestión de rendimiento: añadir un bloqueo (mutex) interno en la estructura del map haría que todas las operaciones —incluso las que ocurren en un solo hilo— fueran más lentas debido a la gestión de la sincronización. El runtime de Go prioriza la velocidad del caso de uso más común. Si el runtime detecta que esto está ocurriendo, lanzará un fatal error: concurrent map read and map write. Es crucial entender que esto no es un panic que puedas capturar con recover(); es un error fatal que mata el proceso de inmediato para evitar la corrupción de memoria. Debes proteger tus mapas con una estrategia de sincronización siempre que existan múltiples goroutines y al menos una de ellas realice una escritura.
package main
import (
"fmt"
"sync"
)
// SecureCache encapsula un mapa y un RWMutex para asegurar que el acceso
// sea seguro cuando múltiples goroutines interactúan con la estructura.
type SecureCache struct {
mu sync.RWMutex
data map[string]int
}
// Set utiliza un bloqueo exclusivo (Lock) para asegurar que solo una
// goroutine pueda modificar el mapa a la vez.
func (c *SecureCache) Set(key string, value int) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
// Get utiliza un bloqueo de lectura (RLock), lo que permite que
// múltiples goroutines lean el mapa simultáneamente, pero bloquea
// si hay una escritura en curso.
func (c *SecureCache) Get(key string) (int, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.data[key]
return val, ok
}
func main() {
cache := &SecureCache{
data: make(map[string]int),
}
var wg sync.WaitGroup
// Simulamos 10 goroutines escribiendo datos de forma concurrente.
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 100; j++ {
// Usamos una clave que causa colisiones para forzar la concurrencia.
key := fmt.Sprintf("key-%d", j%10)
cache.Set(key, id*100+j)
}
}(i)
}
// Simulamos 10 goroutines leyendo datos de forma concurrente.
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 500; j++ {
key := fmt.Sprintf("key-%d", j%10)
cache.Get(key)
}
}()
}
wg.Wait()
fmt.Println("Operación completada sin errores de concurrencia.")
}
Desglose del ejemplo
En el código anterior, hemos encapsulado el map dentro de una estructura SecureCache. No basta con tener un map y un sync.Mutex sueltos en el código; la clave es que el acceso al mapa esté ligado indisolublemente a su mecanismo de protección.
Para la escritura, Set utiliza c.mu.Lock(). Esto es un bloqueo exclusivo. Cuando una goroutine ejecuta Lock(), cualquier otra goroutine que intente ejecutar Lock() o RLock() se detendrá hasta que se llame a Unlock(). Esto es vital durante una escritura porque el map puede necesitar reorganizar sus buckets (el proceso de rehashing), y si alguien leyera mientras eso ocurre, apuntaría a una dirección de memoria inválida.
Para la lectura, Get utiliza c.mu.RLock(). Este es el punto donde ganamos rendimiento. A diferencia del Lock exclusivo, el RLock permite que cientos de goroutines lean el mapa al mismo tiempo sin bloquearse entre ellas. El bloqueo solo ocurre si una goroutine solicita un Lock() para escribir.
El error frecuente
Un error común es pensar que “si solo estoy leyendo, no pasa nada”. Sin embargo, si una goroutine está escribiendo (incluso si solo está actualizando un valor existente) y otra está leyendo, el runtime detectará la concurrencia y lanzará el error fatal.
// CÓDIGO QUE CAUSARÁ UN CRASH (FATAL ERROR)
m := make(map[string]int)
go func() {
for {
m["status"] = 1 // Escritura constante
}
}()
go func() {
for {
_ = m["status"] // Lectura concurrente -> El programa muere aquí
}
}()
Para casos muy específicos donde la contención es extrema o el patrón de uso es muy particular, existe sync.Map. No la uses por defecto, ya que es más costosa en términos de CPU y memoria para el uso general. sync.Map está optimizada únicamente para dos escenarios:
1. Cachés de solo lectura: Cuando las claves se escriben una vez y se leen miles de veces (como una configuración de sistema).
2. Conjuntos de claves disjuntos: Cuando las goroutines escriben en claves que casi nunca se solapan, permitiendo que la estructura interna de sync.Map evite los bloqueos mediante operaciones atómicas.
Si tu caso no encaja exactamente en uno de esos dos patrones, un map estándar protegido por un sync.RWMutex será siempre más rápido y predecible.
N° 55