mutex-atomic-evitando-race-conditions

Mutex y atomic en Go para evitar condiciones de carrera

  • 6 min

Un mutex en Go es un bloqueo que protege una zona crítica del código para que varias goroutines no modifiquen el mismo dato a la vez.

En el artículo anterior vimos cómo las goroutines pueden comunicarse pasándose mensajes a través de canales. Esa es la forma “elegante” de Go.

Pero, a veces simplemente necesitas tener un mapa global, un contador o una caché compartida por muchas goroutines. En ese momento, volvemos al modelo clásico: Memoria Compartida.

Sin esa sincronización aparece una condición de carrera (data race). Vamos a ver cómo detectarla y cómo proteger el estado usando mutexes y operaciones atómicas.

Qué es una condición de carrera

Una condición de carrera ocurre cuando dos o más goroutines intentan acceder a la misma variable compartida a la vez, y al menos una de ellas intenta escribir.

El comportamiento deja de ser fiable: podemos perder actualizaciones, observar estados incoherentes o incluso provocar un fallo.

Ejemplo del desastre

Imagina que queremos contar cuántas veces visitan nuestra web. Lanzamos 1000 goroutines y cada una suma 1 a un contador.

package main

import (
    "fmt"
    "sync"
)

var contador int // Variable compartida

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            // CRÍTICO: Lectura + Modificación + Escritura
            contador++
        }()
    }

    wg.Wait()
    fmt.Println("Contador final:", contador)
}
Copied!

Esperaríamos ver 1000, pero podemos obtener un número menor o, por casualidad, el esperado. El programa nunca es correcto mientras exista la carrera.

¿Por qué? Porque la operación contador++ no es atómica. En realidad, la CPU hace tres pasos:

  1. Leer el valor actual de contador (ej: 5).
  2. Sumarle 1 (5 + 1 = 6).
  3. Guardar el 6 en contador.

Si dos goroutines leen el “5” a la vez, ambas escribirán un “6”, perdiéndose uno de los incrementos.

Proteger una sección con sync.Mutex

La forma habitual de arreglarlo es usar un mutex de exclusión mutua.

Un Mutex es como un candado.

  1. Antes de tocar la variable compartida, bloqueamos (Lock) el candado. Si otra goroutine llega, tiene que esperar.
  2. Hacemos la operación.
  3. Desbloqueamos (Unlock) el candado para que pase el siguiente.

La zona de código protegida por el candado se llama Sección Crítica.

package main

import (
    "fmt"
    "sync"
)

var (
    contador int
    mu       sync.Mutex // El candado que protege a 'contador'
)

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()

            mu.Lock()   // 🔒 Cerramos el candado. Nadie más pasa.
            contador++  // Sección crítica (ahora es segura)
            mu.Unlock() // 🔓 Abrimos el candado.
        }()
    }

    wg.Wait()
    fmt.Println("Contador final:", contador) // Siempre será 1000
}
Copied!

Buenas prácticas con Mutex: Siempre que sea posible, usa defer mu.Unlock() justo después del Lock(). Esto garantiza que el candado se abra incluso si la función entra en pánico o tiene un return temprano, evitando Deadlocks.

Separar lecturas y escrituras con sync.RWMutex

Supongamos que una caché se lee miles de veces por segundo y apenas se actualiza. Un Mutex normal obliga a los lectores a entrar de uno en uno; un RWMutex puede permitir que varios lean a la vez.

Para esto existe sync.RWMutex (Read-Write Mutex). Tiene dos tipos de candados:

  • RLock(): bloqueo de lectura. Permite varios lectores simultáneos, pero ningún escritor.
  • Lock(): Bloqueo de Escritura. Bloqueo total (ni lectores ni otros escritores pueden entrar).
var cache = make(map[string]string)
var mu sync.RWMutex

// Muchos pueden leer a la vez
func Obtener(key string) string {
    mu.RLock()         // Candado verde (lectura)
    defer mu.RUnlock()
    return cache[key]
}

// Solo uno puede escribir (y echa a los lectores)
func Guardar(key, valor string) {
    mu.Lock()          // Candado rojo (escritura)
    defer mu.Unlock()
    cache[key] = valor
}
Copied!

Un RWMutex tiene más coste interno que un Mutex y no siempre mejora el rendimiento. Solo compensa en determinados patrones con muchas lecturas y suficiente contención; si importa, conviene medir.

Operaciones sencillas con sync/atomic

Para actualizar un contador o un indicador aislado podemos usar operaciones atómicas. Son primitivas de bajo nivel y debemos mantener atómicos todos los accesos a ese dato.

Los tipos modernos del paquete sync/atomic ofrecen métodos como Add, Load, Store y CompareAndSwap.

import "sync/atomic"

var contador atomic.Int64 // Su valor cero está listo para usar

func main() {
    // ... setup de waitgroup ...
    go func() {
        contador.Add(1)
    }()
    // ...

    // Para leer también debemos usar atomic
    valorFinal := contador.Load()
}
Copied!

atomic exige razonar con cuidado sobre la sincronización. Para invariantes que abarcan varios campos, mapas, slices o lógica compleja, un mutex o un canal suele ser más claro.

El detector de carreras

A veces, las condiciones de carrera son muy sutiles y difíciles de ver a ojo. Puedes tener un bug latente que solo aparece una vez al mes bajo mucha carga.

Go incluye un detector para encontrar carreras durante la ejecución. Añadimos la opción -race al ejecutar o probar el código.

go run -race main.go
# o
go test -race ./...
Copied!

Si encuentra un acceso concurrente conflictivo, muestra las trazas de los accesos y de las goroutines implicadas. Solo detecta las rutas que llegan a ejecutarse, así que una prueba limpia no demuestra por sí sola que el programa esté libre de carreras.

No conviene distribuir como binario habitual una compilación con -race, porque aumenta mucho el consumo de CPU y memoria. Úsalo en pruebas, integración continua y, cuando haga falta, bajo una carga representativa.

Canales, mutex o atomic

¿Cuándo uso qué? Esta es una guía rápida:

HerramientaCaso de UsoFilosofía
ChannelsPasar datos, coordinar tareas, workers pipelines.“Compartir memoria comunicando”
MutexProteger estado interno (structs), cachés, mapas.“Proteger la zona crítica”
AtomicContadores simples, métricas, flags booleanos.“Rendimiento máximo simple”