Un map en Go es una implementación de una tabla hash que permite búsquedas rápidas, con una complejidad teórica de $O(1)$ amortizado. Sin embargo, esta “velocidad constante” tiene un costo: el hashing. Cada vez que accedes a un elemento, el runtime debe calcular el hash de la llave para encontrar su ubicación; si usas un string muy largo, ese cálculo es más costoso que usar un int, que es prácticamente instantáneo.
Cuando el mapa se llena y su factor de carga es alto, Go debe expandir sus buckets mediante un proceso de rehashing, moviendo los elementos a una nueva estructura de mayor tamaño. Este proceso es costoso en términos de CPU y memoria. Por eso, si sabes de antemano cuántos elementos vas a almacenar, lo ideal es usar make(map[K]V, capacidad) para pre-alocar el espacio necesario y evitar las re-asignaciones durante la ejecución.
En entornos concurrentes, el map estándar no es seguro para escritura simultánea. Si dos goroutines intentan escribir al mismo tiempo, el programa lanzará un panic inmediato. Para solucionar esto, lo común es envolver el mapa en un sync.RWMutex. No obstante, si tu patrón de uso implica que las llaves son “estables” (es decir, escribes poco pero lees constantemente las mismas llaves), sync.Map es una opción más eficiente. Internamente, sync.Map utiliza una estructura de dos capas: un mapa de lectura (read) que se accede mediante operaciones atómicas sin bloqueos, y un mapa de escritura (dirty) que se usa para las nuevas entradas. Si te equivocas y usas sync.Map en un escenario con escrituras constantes, el rendimiento caerá drásticamente debido al costo de sincronizar ambas capas.
package main
import (
"fmt"
"sync"
"time"
)
// CacheRWMutex es el patrón estándar: un mapa con un RWMutex.
type CacheRWMutex struct {
mu sync.RWMutex
m map[int]string
}
func (c *CacheRWMutex) Get(key int) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.m[key]
return val, ok
}
func (c *CacheRWMutex) Set(key int, val string) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[key] = val
}
func main() {
const n = 1_000_000
const workers = 10
// 1. Pre-alocación: Evitamos rehashes al saber el tamaño aproximado.
// Si no usáramos el segundo argumento, Go tendría que redimensionar
// el mapa varias veces mientras crece.
m := make(map[int]string, n)
for i := 0; i < n; i++ {
m[i] = "data"
}
fmt.Printf("Mapa pre-alocado con %d elementos.\n", len(m))
// 2. Comparativa de concurrencia
// Escenario: Read-heavy (99% lecturas, 1% escrituras)
// Aquí sync.Map debería brillar por su capa 'read' atómica.
syncMap := &sync.Map{}
rwCache := &CacheRWMutex{m: make(map[int]string)}
// Inicializamos datos
for i := 0; i < 100; i++ {
syncMap.Store(i, "data")
rwCache.Set(i, "data")
}
fmt.Println("Iniciando benchmark (Read-Heavy)...")
start := time.Now()
runBenchmark(syncMap, rwCache, workers, n)
fmt.Printf("Tiempo sync.Map: %v\n", time.Since(start))
start = time.Now()
runBenchmark(rwCache, nil, workers, n) // nil para el caso de sync.Map
fmt.Printf("Tiempo RWMutex: %v\n", time.Since(start))
}
func runBenchmark(sm *sync.Map, rw *CacheRWMutex, workers int, totalOps int) {
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for j := 0; j < totalOps/workers; j++ {
// 99% Lecturas
if j%100 != 0 {
if sm != nil {
sm.Load(j % 100)
} else {
rw.Get(j % 100)
}
continue
}
// 1% Escrituras
if sm != nil {
sm.Store(j%100, "updated")
} else {
rw.Set(j%100, "updated")
}
}
}(i)
}
wg.Wait()
}
// Nota: El benchmark real requiere el paquete 'testing' y 'go test -bench',
// pero esta simulación muestra la diferencia de contención.
Concept Breakdown
- Pre-alocación en
make(map[int]string, n): Al pasarle la capacidadn, le indicamos al runtime cuántos buckets debe preparar inicialmente. Esto evita que el recolector de basura y el scheduler pierdan ciclos de CPU realizando el “evacuating” de buckets cada vez que el mapa supera su carga máxima. - Uso de
intcomo llave: En elrunBenchmark, usamosintpara las llaves. Si usáramosstring, notaríamos que el rendimiento de ambos mapas cae, pero el impacto en la contención desync.Mapsería distinto debido al tiempo que el CPU pasa calculando el hash de la cadena antes de poder acceder a la estructura atómica. CacheRWMutexysync.RWMutex: Fíjate enc.mu.RLock(). Esto permite que múltiples goroutines lean el mapa simultáneamente. Sin embargo, si una goroutine llama aSet, se requiere unLock()exclusivo, bloqueando a todas las demás.- La ventaja de
sync.Mapen el benchmark: En la funciónrunBenchmark, el 99% de las operaciones son lecturas. En unsync.Map, la mayoría de esas lecturas ocurren en la caparead, la cual no requiere adquisición de mutex, evitando la contención de caché entre núcleos de CPU.
El error frecuente
Si intentas acceder a un mapa estándar desde múltiples goroutines sin un mecanismo de sincronización, Go no te dará un error de compilación, sino un fallo en tiempo de ejecución.
// ESTO CAUSARÁ UN PANIC
func unsafeMap() {
m := make(map[string]int)
go func() {
for { m["a"] = 1 } // Escritura en goroutine 1
}()
go func() {
for { _ = m["a"] } // Lectura/Escritura en goroutine 2
}()
}
El mensaje de error será fatal error: concurrent map writes o fatal error: concurrent map read and map write. A diferencia de otros errores, este es un error fatal del runtime diseñado para evitar la corrupción de memoria, por lo que no puedes recuperarte con un recover().
N° 57