En Go, el mapa (map[K]V) es probablemente la estructura de datos más usada en código de producción: cachés en memoria, índices de sesiones, deduplicación de mensajes, registros de servicios o routers de middleware. Es tan ubicuo que solemos usarlo sin preguntarnos cómo funciona por debajo: qué ocurre realmente en una búsqueda de clave, por qué una clave ausente devuelve el valor cero del tipo en lugar de un error, y qué demonios significa ese temido fatal error: concurrent map writes que tira un pod entero a las 3 de la mañana.
Este artículo hace el recorrido completo: desde la sintaxis que escribes todos los días hasta la memoria que el runtime administra por ti. Todos los ejemplos han sido compilados y ejecutados con Go 1.27, y el stack trace del error fatal que verás es real. Las cifras del benchmark son una medición concreta, no una propiedad universal: pueden variar según el hardware, el sistema operativo y la configuración de paralelismo.
1. El modismo “comma ok”: por qué existe
Empecemos por la pregunta que casi nadie se hace tras años escribiendo Go: ¿cómo saber si una clave existe?
package main
import "fmt"
type Session struct {
UserID string
TTL int
}
func main() {
sessions := map[string]Session{
"abc123": {UserID: "u-42", TTL: 3600},
}
// Sin "comma ok": Go devuelve el valor cero del tipo, nunca un error.
zero := sessions["xyz789"]
fmt.Printf("sin comma ok: %+v\n", zero)
// Output: sin comma ok: {UserID: TTL:0}
// Con "comma ok": distinguimos "no existe" de "existe con valor cero".
s, ok := sessions["xyz789"]
if !ok {
fmt.Println("sesión inexistente: obligamos a re-login")
} else {
fmt.Println("sesión encontrada:", s.UserID)
}
// Forma más idiomática: declarar ambas variables en el propio if.
if s, ok := sessions["abc123"]; ok {
fmt.Printf("sesión activa: %s (TTL %ds)\n", s.UserID, s.TTL)
}
// Insertar solo si la clave no existe (patrón "load or store").
if _, ok := sessions["def456"]; !ok {
sessions["def456"] = Session{UserID: "u-77", TTL: 60}
}
}
La expresión v, ok := m[clave] es el famoso “comma ok”: una asignación múltiple donde el compilador expande la búsqueda a dos valores: el valor asociado a la clave y un booleano que indica si la clave estaba presente.
¿Por qué es necesaria?
Porque en Go una clave ausente no es un error, es el valor cero. A diferencia de Python (KeyError) o Java (NullPointerException/Optional), el diseño de Go prioriza que el acceso a un mapa nunca provoque un pánico en lectura. La consecuencia es ambigua: cuando lees sessions["xyz789"].TTL == 0, no sabes si la sesión existe con TTL = 0 o si no existe.
Esto produce bugs sutiles en producción:
// BUG: nunca distingue "cuota agotada (0)" de "usuario sin registro".
if quotas[userID] == 0 {
return ErrNoQuota
}
// CORRECTO: el booleano desambigua.
used, ok := quotas[userID]
if !ok {
return ErrUnknownUser // la clave no existe
}
if used == 0 {
return ErrNoQuota // existe, pero a cero
}
Reglas prácticas del “comma ok”
- Si el valor cero es semánticamente válido para tu dominio (
map[string]bool, contadores donde el cero no aporta información), el acceso simplem[k]es perfectamente idiomático. - Si necesitas distinguir ausencia de valor cero, usa siempre el
ok. if _, ok := m[k]; !okes el patrón estándar para insertar solo si no existe cuando no hay acceso concurrente. Bajo concurrencia no es atómico y no equivale async.Map.LoadOrStore.delete(m, k)sobre una clave inexistente es una operación sin efecto ni error; no necesita comprobación previa.- Un
mapnulo (var m map[string]int) es legible (devuelve el valor cero yok = false) pero no escribible:m["a"] = 1provoca un pánico recuperableassignment to entry in nil map. Inicializa siempre conmake.
Ponte a prueba: ¿qué imprime este código?
m := map[string]int{"a": 0}
v1, ok1 := m["a"]
v2, ok2 := m["b"]
fmt.Println(v1, ok1, v2, ok2)
Opciones: (a) 0 true 0 false (b) 0 false 0 false (c) provoca un error porque la clave b no existe.
Respuesta: la (a). La clave "a" existe con valor 0 (0 true); la clave "b" no existe, por lo que se devuelve el valor cero del tipo y ok = false (0 false). Este es exactamente el caso donde el booleano es imprescindible: ambos valores leídos son 0, pero su significado es opuesto.
2. Anatomía interna: hmap y bmap
Todo lo anterior tiene una implementación muy concreta en el runtime. Durante casi una década, la arquitectura de los mapas de Go (en src/runtime/map.go) se basó en tablas hash con encadenamiento por buckets de desborde. Entenderla es rentable: explica el coste real de una búsqueda, el uso de memoria y el comportamiento bajo crecimiento. (Si trabajas con Go 1.24 o superior, quédate hasta la sección 2.3: la implementación cambió, pero los conceptos se mantienen).
2.1 La cabecera: hmap
Cada mapa que creas es, en realidad, un puntero a esta estructura (Go 1.23, simplificada de runtime/map.go):
type hmap struct {
count int // número de elementos vivos (lo que devuelve len())
flags uint8 // banderas de estado (iteración, escritura en curso...)
B uint8 // log2 del número de buckets: hay 2^B buckets
noverflow uint16 // número aproximado de buckets de desborde
hash0 uint32 // semilla aleatoria del hash (por mapa y por proceso)
buckets unsafe.Pointer // array de 2^B buckets
oldbuckets unsafe.Pointer // array anterior, solo durante un crecimiento
nevacuate uintptr // progreso de la evacuación incremental
extra *mapextra // estadísticas y buckets de desborde opcionales
}
Tres campos merecen atención especial:
hash0: una semilla aleatoria distinta por mapa. Go la randomiza para prevenir ataques de hash flooding (un atacante que envía claves diseñadas para colisionar en el mismo bucket y degradar el servicio aO(n)). Dos mapas con las mismas claves iteran en orden distinto — esa es la razón por la que la iteración de unmapes deliberadamente aleatoria.B: el tamaño de la tabla es siempre una potencia de dos (2^B), lo que permite indexar con una simple máscara de bits en lugar de un módulo.oldbuckets/nevacuate: el mecanismo de crecimiento incremental (más adelante).
2.2 El bucket: bmap, el arreglo tophash y la astucia de la caché
Cada bucket (bmap) almacena exactamente 8 celdas:
// runtime/map.go (Go 1.23), estructura real:
type bmap struct {
tophash [8]uint8 // byte alto del hash de cada clave
// Seguido en memoria, de forma contigua, por:
// - 8 claves (k0..k7)
// - 8 valores (v0..v7)
// - 1 puntero a bucket de desborde (overflow)
}
La estructura no está completamente declarada en el código fuente: el compilador “conoce” el resto del layout y calcula los offsets de claves y valores en tiempo de compilación. En memoria, un bucket se ve así:
un bucket (bmap) - 8 celdas
+--------------------------------------------------+
| tophash[0..7] 8 bytes |
+--------------------------------------------------+
| k0 | k1 | k2 | k3 | k4 | k5 | k6 | k7 | <- 8 claves contiguas
+--------------------------------------------------+
| v0 | v1 | v2 | v3 | v4 | v5 | v6 | v7 | <- 8 valores contiguos
+--------------------------------------------------+
| puntero al bucket de overflow |
+--------------------------------------------------+
El arreglo tophash guarda el byte más alto del hash de la clave alojada en cada celda. Su propósito es servir de filtro ultra barato: antes de comparar una clave candidata (que puede ser un string de 100 bytes), el runtime compara primero un solo byte. Si tophash[i] no coincide con el byte alto del hash buscado, descarta la celda sin tocar la clave. Si coincide, entonces sí compara la clave real con ==. La mayoría de las veces, 8 comparaciones de un byte resuelven la búsqueda.
Cuando el byte es menor que minTopHash (5), no representa un hash sino un estado de la celda:
emptyRest = 0 // celda vacía y todas las siguientes también (permite cortar el escaneo)
emptyOne = 1 // celda vacía
evacuatedX = 2 // clave evacuada a la primera mitad de la tabla nueva
evacuatedY = 3 // clave evacuada a la segunda mitad de la tabla nueva
evacuatedEmpty = 4 // celda vacía en un bucket ya evacuado
minTopHash = 5 // a partir de aquí, es un hash real (se compensa al guardar)
¿Por qué claves y valores van por separado y de forma contigua? Dos razones de rendimiento notablemente elegantes:
- Eliminación del padding: si cada celda fuera un par clave-valor emparejado, el alineamiento de memoria obligaría a rellenar huecos. Un
map[int8]int64clásico desperdiciaría 7 bytes por celda (8 celdas × 16 bytes = 128 bytes). Con el layout separado, las 8 claves ocupan 8 bytes contiguos y los 8 valores 64 bytes contiguos: sin un solo byte de relleno. - Localidad de caché: una búsqueda compara claves, no valores. Al estar las 8 claves juntas, escanearlas encaja en pocas cache lines consecutivas. Los valores, irrelevantes durante la búsqueda, no contaminan la caché con bytes que no vas a usar.
2.3 La búsqueda, paso a paso
Con todo lo anterior, v, ok := m[clave] ejecuta en el runtime (mapaccess2) este algoritmo:
- Calcula
hash := Hash(clave, hash0). - Índice de bucket:
hash & (2^B - 1)(bits bajos del hash). - Filtro rápido:
top := hash >> 56(bits altos, en máquinas de 64 bits) y lo compara contra los 8 bytes detophash. - Para cada celda con
topcoincidente, compara la clave real con==. - Si el bucket se agota, sigue la cadena de buckets de desborde (encadenamiento).
- Si encuentra
emptyRest, corta: no hay más claves que buscar. - Si el mapa está en medio de un crecimiento, consulta también
oldbucketshasta que las celdas implicadas hayan sido evacuadas.
Todo esto es lo que cuesta un m[k] bien dimensionado: un hash, una máscara, unas cuantas comparaciones de un byte y una o dos comparaciones de la clave real. Constante y baratísimo.
2.4 El crecimiento: evolución incremental
Cuando count > 6.5 × 2^B (el load factor clásico de Go: 13/2), o cuando se acumulan demasiados buckets de desborde, el mapa puede crecer. En el primer caso duplica B y asigna una tabla nueva; en el segundo puede hacer un crecimiento del mismo tamaño para reorganizar los desbordes. Pero no copia todo de golpe (construiría un pico de latencia fatal para un servidor): las entradas se evacúan progresivamente a medida que se escriben (el campo nevacuate marca el progreso). Durante la transición, lecturas y escrituras consultan ambas tablas, y las banderas evacuatedX/Y del tophash indican dónde vive cada clave. Los iteradores, además, empiezan por un bucket aleatorio — de ahí el orden aleatorio de range.
Go 1.24+: la era de las Swiss Tables (actualización del runtime)
Si ejecutas go version y ves Go 1.24 o superior, el mapa que tienes en producción ya no usa hmap/bmap. El equipo de Go reescribió la implementación basándose en Swiss Tables (el diseño de la librería absl de Google), en src/internal/runtime/maps/map.go. Puedes ampliar esta parte en el artículo oficial sobre Swiss Tables. La estructura actual (Go 1.27) es:
// src/internal/runtime/maps/map.go (Go 1.27)
type Map struct {
used uint64 // número de elementos (len())
seed uintptr // semilla aleatoria por mapa (como hash0)
// Directorio de tablas. Si el mapa cabe en un solo grupo de 8
// entradas, dirPtr apunta directamente a ese grupo (mapa pequeño).
dirPtr unsafe.Pointer
dirLen int
globalDepth uint8 // bits usados para indexar el directorio
globalShift uint8
// Bandera de escritura en curso: se alterna con XOR para maximizar
// la probabilidad de detectar escritores concurrentes.
writing uint8
tombstonePossible bool
clearSeq uint64
}
Los cambios conceptuales que te afectan como usuario del lenguaje:
- Grupos de 8 slots con control words: cada grupo tiene 8 bytes de control contiguos (uno por slot). Un byte de control vale
10000000(vacío),11111110(borrado, tombstone) o0hhhhhhh, donde los 7 bits bajos del hash (h2) sustituyen al viejotophash. En AMD64, el runtime comprueba los 8 bytes en paralelo con instrucciones SIMD. - Hash dividido: los 57 bits altos (
h1) localizan el grupo mediante sondeo; los 7 bits bajos (h2) filtran slots. La idea es la misma quetophash, pero vectorizada. - Load factor 7/8: el mapa crece cuando 7 de cada 8 slots están ocupados (antes 6.5/8), con directorio de tablas creciente (hashing extensible) en lugar del viejo array de buckets con desbordes.
- Optimización de mapa pequeño: si el mapa nunca supera 8 entradas, se almacena en un único grupo sin directorio (
dirLen == 0).
Lo que no cambia en absoluto: la semántica del lenguaje, el “comma ok”, la iteración aleatoria y, como veremos, el error fatal por concurrencia.
3. El peligro: concurrencia sin protección
Ahora la parte incómoda. Este programa es todo lo que necesitas para tumbar un servicio:
package main
import "sync"
func main() {
m := map[int]int{}
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1_000; j++ {
m[j] = j // fatal error: concurrent map writes
}
}()
}
wg.Wait()
}
Ejecutado con Go 1.27 en mi máquina, la salida es exactamente esta:
fatal error: concurrent map writes
goroutine 6 [running]:
internal/runtime/maps.fatal({0x7ff6bc730435?, 0x0?})
C:/Program Files/Go/src/runtime/panic.go:1195 +0x18
main.main.func1()
.../race/main.go:14 +0x65
created by main.main in goroutine 1
.../race/main.go:11 +0x48
exit status 2
Fíjate en el matiz crítico: dice fatal error, no panic. Un panic en Go se puede interceptar con recover(); un error fatal no se puede recuperar bajo ninguna circunstancia: el runtime termina el proceso entero con código de salida 2. No hay defer que valga, no hay middleware de recuperación que salve al pod. El detector del runtime cubre determinados accesos concurrentes; no sustituye al análisis con -race ni a la sincronización explícita.
¿Por qué tan drástico?
Porque el runtime no bloquea el mapa en el camino caliente (ningún Lock, ningún atómico por operación): lo hace por rendimiento. La protección es solo de detección, y se basa en la bandera writing del hmap (antes hashWriting): cada escritura alterna la bandera con XOR al entrar y al salir. Si al entrar a escribir encuentra la bandera ya activa, sabe que otra goroutine está escribiendo a la vez y detiene el proceso con fatal("concurrent map writes"). Las lecturas comprueban la misma bandera y emiten fatal("concurrent map read and map write").
¿Por qué no un panic recuperable? Por el estado interno. Dos escrituras entrelazadas pueden corromper el mapa de formas que sobreviven a la operación concreta:
- escrituras perdidas silenciosamente (datos corruptos que un
recoverdeclararía “gestionados”), - lecturas inconsistentes (memoria torn) durante la mutación de
buckets/oldbuckets, - bucles infinitos en cadenas de desborde o en la secuencia de sondeo.
A partir de ahí, el mapa queda en un estado indefinido para siempre, y la única garantía de memoria segura de Go queda violada. La filosofía del runtime es fail-fast: mejor morir de forma limpia y reiniciar (Kubernetes hará el restartPolicy: Always) que proseguir con memoria corrupta.
Detalles que sorprenden incluso a desarrolladores con años de Go:
m[k]++es lectura + escritura compuesta: también mata el proceso, aunque “solo incrementas”.- Una lectura concurrente con una escritura basta; ni siquiera hacen falta dos escritores.
- Iterar (
for k := range m) mientras otra goroutine escribe provocafatal error: concurrent map iteration and map write. - El detector de carreras de Go (
go run -race,go test -race) puede detectar esta condición en desarrollo y CI antes de que llegue a producción, aunque depende de que la ejecución cubra la intercalación problemática. Ejecuta tus tests de carga con-race: es una red de seguridad muy barata.
¿Por qué el runtime no sincroniza los mapas automáticamente?
Tres motivos de diseño, documentados en el propio código fuente:
- Rendimiento: la mayoría de los mapas en programas reales no son accedidos concurrentemente (locales, creados y consumidos en una sola goroutine). Pagar un atómico o un lock en cada acceso castigaría a la mayoría para proteger a la minoría. Go sigue el principio de Go proverbio: “Don’t pay for what you don’t use”.
- Composición: un
Lockinterno no resuelve el problema real, que es proteger secuencias completas de operaciones (comprobar-actualizar-escribir). Unsync.Map.Loadatómico no evita la carrera TOCTOU deif _, ok := m[k]; !ok { m[k] = v }. Necesitas control explícito del scope del bloqueo, y eso solo te lo da un mutex visible en tu API. - Errores claros y tempranos: forzar la decisión explícita ("¿quién es el dueño de este mapa?") produce arquitecturas mejores que un falso sentido de seguridad.
4. Soluciones: sync.Mutex, sync.RWMutex y sync.Map
La regla de oro: el mutex protege al mapa, no al revés. La forma idiomática de encapsularlos es un struct que no exponga nunca el mapa interior.
4.1 sync.Mutex: la opción por defecto
type SafeCounter struct {
mu sync.Mutex
m map[string]int
}
func NewSafeCounter() *SafeCounter {
return &SafeCounter{m: make(map[string]int)}
}
func (c *SafeCounter) Inc(key string) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[key]++
}
func (c *SafeCounter) Get(key string) (int, bool) {
c.mu.Lock()
defer c.mu.Unlock()
v, ok := c.m[key] // el "comma ok" también vive aquí, dentro del lock
return v, ok
}
Un solo cerrojo exclusivo: una goroutine a la vez, lea o escriba. Es la opción más simple, predecible y — como veremos en el benchmark — sorprendentemente competitiva.
4.2 sync.RWMutex: lectores en paralelo
type Cache struct {
mu sync.RWMutex
data map[string][]byte
}
func (c *Cache) Get(key string) ([]byte, bool) {
c.mu.RLock() // múltiples lectores simultáneos
defer c.mu.RUnlock()
v, ok := c.data[key]
return v, ok
}
func (c *Cache) Set(key string, val []byte) {
c.mu.Lock() // exclusivo: bloquea a lectores y escritores
defer c.mu.Unlock()
c.data[key] = val
}
RWMutex permite N lectores o 1 escritor. En teoría, domina en escenarios de lectura intensiva (>90 % de lecturas). En la práctica tiene una trampa: RLock/RUnlock son operaciones atómicas sobre un contador compartido que los lectores disputan línea a línea (falsa contención en la misma cache line), y el mecanismo que impide la inanición del escritor obliga a los nuevos lectores a esperar. En el benchmark de ejemplo, con contención alta, un RWMutex puede ser más lento que un Mutex simple. No lo asumas: mídelo.
4.3 sync.Map: el caso especial
sync.Map (desde Go 1.9) es un tipo distinto, no un reemplazo genérico. Su implementación clásica mantenía dos representaciones: un mapa de lectura (read) accedido sin lock (vía atómicos) y un mapa dirty protegido por mutex, con una contabilidad de misses que decide cuándo promover dirty a read. Desde Go 1.24, la implementación interna es un hash-trie concurrente, con mejoras especialmente visibles en modificaciones y en mapas grandes. La documentación oficial de sync.Map lo recomienda explícitamente para dos escenarios:
- Claves escritas una vez y leídas muchas (write-once, read-many): cachés que solo crecen, tablas de configuración, lookups inmutables.
- Goroutines que leen/escriben/sobreescriben conjuntos de claves disjuntos (sin solapamiento).
var configByTenant sync.Map // map[string]Config conceptualmente
func LoadConfig(tenant string) (Config, bool) {
v, ok := configByTenant.Load(tenant) // "comma ok", versión sync.Map
if !ok {
return Config{}, false
}
return v.(Config), true // requiere un type assertion: no hay tipado genérico
}
func StoreConfig(tenant string, cfg Config) {
configByTenant.Store(tenant, cfg)
}
// LoadOrStore resuelve atómicamente el patrón "insertar si no existe":
// devuelve el valor existente y loaded=true si ya estaba.
func GetOrCreate(tenant string, mkDefault func() Config) Config {
// mkDefault se evalúa antes de llamar a LoadOrStore. Si crear el valor
// es costoso o tiene efectos secundarios, usa un mutex o singleflight.
v, loaded := configByTenant.LoadOrStore(tenant, mkDefault())
if loaded {
return v.(Config)
}
return v.(Config) // acabamos de crearlo
}
Sus límites: la API es map[any]any (pérdida de tipado, que se paga con assertions y posibles pánicos), no tiene len() directo, su Range recorre todo el mapa y — antes de Go 1.24 — degradaba notablemente cuando las mismas claves se mutaban con frecuencia. Si necesitas tipado, encapsúlalo con genéricos:
type SafeMap[K comparable, V any] struct {
mu sync.RWMutex
m map[K]V
}
func NewSafeMap[K comparable, V any]() *SafeMap[K, V] {
return &SafeMap[K, V]{m: make(map[K]V)}
}
func (s *SafeMap[K, V]) Load(key K) (V, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
v, ok := s.m[key]
return v, ok
}
func (s *SafeMap[K, V]) Store(key K, value V) {
s.mu.Lock()
defer s.mu.Unlock()
s.m[key] = value
}
5. Tabla comparativa: ¿cuál uso?
Comparativa cualitativa
| Estrategia | Granularidad del bloqueo | Coste por lectura | Coste por escritura | Tipado | Caso de uso ideal | Cuándo evitarla |
|---|---|---|---|---|---|---|
map + sync.Mutex |
Mapa completo (exclusivo) | Media | Baja | Completo | Estado compartido genérico, escrituras frecuentes, secuencias multi-paso | Lecturas masivas concurrentes (el cerrojo se vuelve cuello de botella) |
map + sync.RWMutex |
Lectores en paralelo / 1 escritor | Baja (en teoría) | Alta | Completo | Lecturas muy dominantes (>90 %) con escrituras esporádicas | Escrituras frecuentes o contención alta: puede rendir peor que Mutex |
sync.Map |
Por clave, lecturas lock-free | Mínima | Media-alta | any (requiere assertions) |
Write-once/read-many, cachés append-only, claves disjuntas por goroutine | Claves mutadas a menudo, necesidad de len(), transacciones sobre varias claves |
| (Bonus) N mapas sharded con mutex | Por shard (p. ej. 32 mapas) | Baja | Baja | Completo | Escrituras intensivas a escala extrema | Complejidad: solo si el profiler lo justifica |
Ejemplo de benchmark (Go 1.27)
Números medidos en un Intel Core i7-11800H (8 núcleos / 16 hilos), 1 024 claves y GOMAXPROCS=16. b.RunParallel usa el paralelismo configurado por GOMAXPROCS; por tanto, no conviene presentar el número de goroutines como una constante del benchmark. La mezcla de operaciones es la siguiente:
| Escenario | Mutex |
RWMutex |
sync.Map |
|---|---|---|---|
| 100 % lecturas | 65 ns/op | 70 ns/op | 3,4 ns/op (~20× más rápido) |
| 90 % lecturas / 10 % escrituras | 90 ns/op | 211 ns/op | 9,6 ns/op |
| 50 % lecturas / 50 % escrituras | 92 ns/op | 227 ns/op | 32 ns/op |
Tres conclusiones que salen de la tabla y que conviene interiorizar:
sync.Maparrasa en lectura pura (~20× sobreMutex): su camino de lectura no toma ningún lock.RWMutexperdió contraMutexen todos los escenarios con contención. El costo del atómico compartido y de la coordinación con el escritor superó el beneficio del paralelismo de lecturas. Es el mejor argumento para no elegir estrategias por intuición.- Con 50 % de escrituras,
sync.Mapsigue razonable (gracias al hash-trie de Go 1.24+; con la implementación pre-1.24 el resultado en escrituras podía ser peor), pero elMutexsimple es el rey de esta carga mixta concreta.
El código completo del benchmark, para que lo repliques en tu hardware (los números absolutos cambiarán; las tendencias, normalmente no):
Código completo del benchmark (bench_test.go)
package bench
import (
"sync"
"testing"
)
const numKeys = 1024
type store interface {
Get(k int) (int, bool)
Set(k, v int)
}
type mutexMap struct {
mu sync.Mutex
m map[int]int
}
func newMutexMap() *mutexMap { return &mutexMap{m: make(map[int]int, numKeys)} }
func (s *mutexMap) Get(k int) (int, bool) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.m[k]
return v, ok
}
func (s *mutexMap) Set(k, v int) {
s.mu.Lock()
defer s.mu.Unlock()
s.m[k] = v
}
type rwMutexMap struct {
mu sync.RWMutex
m map[int]int
}
func newRWMutexMap() *rwMutexMap { return &rwMutexMap{m: make(map[int]int, numKeys)} }
func (s *rwMutexMap) Get(k int) (int, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
v, ok := s.m[k]
return v, ok
}
func (s *rwMutexMap) Set(k, v int) {
s.mu.Lock()
defer s.mu.Unlock()
s.m[k] = v
}
type syncMap struct{ m sync.Map }
func newSyncMap() *syncMap { return &syncMap{} }
func (s *syncMap) Get(k int) (int, bool) {
v, ok := s.m.Load(k)
if !ok {
return 0, false
}
return v.(int), true
}
func (s *syncMap) Set(k, v int) { s.m.Store(k, v) }
// benchmarkMix ejecuta operaciones concurrentes con un porcentaje
// de escrituras dado (0 = solo lecturas, 50 = mitad y mitad).
func benchmarkMix(b *testing.B, s store, writePct int) {
for i := 0; i < numKeys; i++ {
s.Set(i, i)
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for n := 0; pb.Next(); n++ {
k := n % numKeys
if n%100 < writePct {
s.Set(k, k)
} else {
s.Get(k)
}
}
})
}
func BenchmarkReadOnly(b *testing.B) {
b.Run("Mutex", func(b *testing.B) { benchmarkMix(b, newMutexMap(), 0) })
b.Run("RWMutex", func(b *testing.B) { benchmarkMix(b, newRWMutexMap(), 0) })
b.Run("SyncMap", func(b *testing.B) { benchmarkMix(b, newSyncMap(), 0) })
}
func BenchmarkRead90Write10(b *testing.B) {
b.Run("Mutex", func(b *testing.B) { benchmarkMix(b, newMutexMap(), 10) })
b.Run("RWMutex", func(b *testing.B) { benchmarkMix(b, newRWMutexMap(), 10) })
b.Run("SyncMap", func(b *testing.B) { benchmarkMix(b, newSyncMap(), 10) })
}
func BenchmarkRead50Write50(b *testing.B) {
b.Run("Mutex", func(b *testing.B) { benchmarkMix(b, newMutexMap(), 50) })
b.Run("RWMutex", func(b *testing.B) { benchmarkMix(b, newRWMutexMap(), 50) })
b.Run("SyncMap", func(b *testing.B) { benchmarkMix(b, newSyncMap(), 50) })
}
Ejecuta con: go test -bench . -benchtime 1s -benchmem -cpu 16 (ajusta -cpu a tu entorno).
6. Conclusiones
Los mapas de Go son una lección magistral de diseño: una API de tres caracteres (m[k]) que esconde un contrato de rendimiento preciso (búsqueda en tiempo constante amortizado, con hash flooding prevenido por semilla aleatoria), un layout de memoria obsesionado con la caché de la CPU (el tophash como filtro de un byte, claves y valores contiguos para eliminar padding) y una política de concurrencia deliberadamente fail-fast.
Pero ese diseño delega en ti, el desarrollador, la decisión más importante: quién es el dueño del mapa cuando hay más de una goroutine mirando. El runtime no sincroniza automáticamente los accesos y puede terminar el proceso al detectar determinados usos concurrentes inválidos. Y desde Go 1.24, aunque las entrañas (hmap/bmap → Swiss Tables) hayan cambiado por completo, ese contrato permanece intacto.
Key Takeaways
v, ok := m[clave]no es opcional cuando el valor cero es ambiguo: una clave ausente devuelve el valor cero del tipo, y solo el booleano distingue “no existe” de “existe a cero”. Si tu dominio trata el cero como dato, el “comma ok” es obligatorio.- La búsqueda es un filtro progresivo diseñado para la caché: máscara de bits para el bucket →
tophash(un byte) → comparación de la clave real. Claves y valores contiguos evitan padding y mantienen la localidad. El mapa no es “un array de pares”: es una máquina de rendimiento. fatal error: concurrent map writesno se recupera: no es unpanic. El runtime detecta la concurrencia con una bandera (writing) y prefiere morir antes que corromper memoria. Si hay más de una goroutine con acceso de escritura, sincroniza desde el día uno, y ejecutago test -raceen CI.- No hay una estrategia ganadora universal:
Mutexpara simplicidad y cargas mixtas,RWMutexsolo si las lecturas dominan claramente (¡y tras medir! puede rendir peor queMutex),sync.Mappara write-once/read-many y claves disjuntas. Encapsula siempre: mutex dentro de un struct, mapa jamás expuesto. - Los internals son un modelo mental, no una constante del lenguaje:
hmap/bmapdescribían el runtime hasta Go 1.23; desde Go 1.24 manda la implementación de Swiss Tables con load factor 7/8. La semántica del lenguaje es estable, pero mide conpprofy benchmarks antes de asumir costes internos. Las cifras de este artículo son orientativas y deben reproducirse en el entorno real.