sync.Mutex y sync.RWMutex: Gestión de concurrencia segura

Cuando trabajas con concurrencia en Go, el problema real no es lanzar goroutines, sino gestionar qué sucede cuando múltiples goroutines intentan tocar la misma variable al mismo tiempo. sync.Mutex y sync.RWMutex son las herramientas fundamentales para proteger ese estado compartido y evitar race conditions (condiciones de carrera), donde el resultado de tu programa depende del orden impredecible de ejecución de las goroutines.

Un sync.Mutex es un mecanismo de exclusión mutua: cuando una goroutine solicita el bloqueo con .Lock(), cualquier otra que intente hacer lo mismo se detendrá hasta que la primera llame a .Unlock(). Es un acceso estrictamente secuencial. Por otro lado, sync.RWMutex es una versión más especializada que distingue entre operaciones de lectura y de escritura. Permite que un número ilimitado de lectores concurrentes acceda al recurso mediante .RLock(), siempre y cuando nadie esté escribiendo, pero garantiza que un escritor tenga acceso exclusivo mediante .Lock().

Debes usar un sync.Mutex por defecto para proteger cualquier estructura de datos que sea modificada por múltiples goroutines. Cambia a un sync.RWMutex solo cuando identifiques un patrón de “lectura masiva y escritura esporádica” (read-heavy workload) y la sección crítica sea lo suficientemente larga como para que el beneficio de permitir lecturas simultáneas compense el overhead adicional de la gestión interna del RWMutex. Si usas RWMutex en un escenario donde casi siempre hay escrituras, terminarás con un rendimiento peor que con un Mutex simple, debido a la complejidad extra de mantener el contador de lectores y la lógica de prioridad. Si aplicas mal estos bloqueos, lo más probable es que termines con un deadlock (el programa se queda congelado esperando un recurso que nunca se libera) o con datos corruptos si olvidaste bloquear una sección crítica.

package main

import (
	"fmt"
	"sync"
	"time"
)

// ConfigStore representa un almacén de configuración en memoria.
type ConfigStore struct {
	// Protegemos el mapa con un RWMutex para permitir
	// múltiples lectores simultáneos.
	mu   sync.RWMutex
	data map[string]string
}

// Get recupera un valor. Al usar RLock, múltiples goroutines
// pueden ejecutar este método al mismo tiempo.
func (c *ConfigStore) Get(key string) (string, bool) {
	c.mu.RLock()         // Bloqueo de lectura
	defer c.mu.RUnlock() // Aseguramos la liberación al salir
	val, ok := c.data[key]
	return val, ok
}

// Set actualiza un valor. Al usar Lock, bloquea tanto a otros
// lectores como a otros escritores.
func (c *ConfigStore) Set(key string, value string) {
	c.mu.Lock()         // Bloqueo exclusivo
	defer c.mu.Unlock() // Aseguramos la liberación
	c.data[key] = value
}

func main() {
	store := &ConfigStore{
		data: map[string]string{"api_url": "https://api.prod.com"},
	}

	var wg sync.WaitGroup

	// Simulamos 5 lectores concurrentes que consultan la config repetidamente.
	for i := 1; i <= 5; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			for j := 0; j < 3; j++ {
				url, _ := store.Get("api_url")
				fmt.Printf("[Lector %d] URL actual: %s\n", id, url)
				time.Sleep(20 * time.Millisecond)
			}
		}(i)
	}

	// Simulamos un proceso de actualización que ocurre ocasionalmente.
	wg.Add(1)
	go func() {
		defer wg.Done()
		time.Sleep(30 * time.Millisecond) // Esperamos a que los lectores empiecen
		fmt.Println(">>> [Escritor] Actualizando URL...")
		store.Set("api_url", "https://api.staging.com")
	}()

	wg.Wait()
	finalUrl, _ := store.Get("api_url")
	fmt.Printf("Configuración final: %s\n", finalUrl)
}

En el ejemplo anterior, el corazón de la sincronización es ConfigStore.mu. Observa cómo en el método Get, utilizamos c.mu.RLock(). Esto es crucial: si tuvieras 100 goroutines llamando a Get simultáneamente, todas podrían entrar al mapa de forma paralela sin bloquearse entre ellas, lo cual es extremadamente eficiente.

Sin embargo, en cuanto el goroutine escritor ejecuta store.Set(...), llama a c.mu.Lock(). En ese instante, el RWMutex impide que cualquier nuevo lector entre y espera a que los lectores actuales terminen para tomar el control exclusivo. Una vez que el escritor actualiza el mapa y ejecuta c.mu.Unlock(), los lectores que estaban en espera pueden retomar su ejecución. El uso de defer para el Unlock es una práctica estándar para evitar que un panic o un return prematuro dejen el mutex bloqueado para siempre.

El error frecuente

Un error clásico que suele pasar desapercibido hasta que el sistema falla bajo carga es copiar un Mutex por valor.

Si tienes una estructura que contiene un sync.Mutex y pasas esa estructura a una función por valor, estás copiando el estado interno del mutex.

type Counter struct {
    mu    sync.Mutex
    count int
}

// ERROR: El mutex 'c' es una COPIA de la original.
// Bloquearás la copia, pero la original seguirá desprotegida.
func increment(c Counter) {
    c.mu.Lock()
    c.count++
    c.mu.Unlock()
}

En este caso, la función increment bloquea una copia local del mutex que vive solo en el stack de esa función. La variable original en main nunca se bloquea, y tendrás una race condition inmediata. La regla de oro es: nunca copies un tipo que contenga un Mutex; pasa siempre punteros a la estructura para asegurar que todos los goroutines operen sobre la misma instancia del lock.

143

Dejar un comentario

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

Scroll al inicio