Rendimiento de mapas en Go y cuándo usar sync.Map

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 capacidad n, 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 int como llave: En el runBenchmark, usamos int para las llaves. Si usáramos string, notaríamos que el rendimiento de ambos mapas cae, pero el impacto en la contención de sync.Map sería distinto debido al tiempo que el CPU pasa calculando el hash de la cadena antes de poder acceder a la estructura atómica.
  • CacheRWMutex y sync.RWMutex: Fíjate en c.mu.RLock(). Esto permite que múltiples goroutines lean el mapa simultáneamente. Sin embargo, si una goroutine llama a Set, se requiere un Lock() exclusivo, bloqueando a todas las demás.
  • La ventaja de sync.Map en el benchmark: En la función runBenchmark, el 99% de las operaciones son lecturas. En un sync.Map, la mayoría de esas lecturas ocurren en la capa read, 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().

57

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio