Skip to main content

Command Palette

Search for a command to run...

Sincronización en Go: Mutex, RWMutex y WaitGroup

Updated
13 min read

En los posts anteriores exploramos las goroutines, channels, select y context. Hoy vamos a descubrir las primitivas de sincronización de bajo nivel del paquete sync. Si vienes de Java, estas son similares a synchronized, ReentrantLock, ReadWriteLock y CountDownLatch, pero con una filosofía diferente.

¿Por Qué Necesitamos Sincronización?

Cuando múltiples goroutines acceden a datos compartidos sin sincronización, pueden ocurrir race conditions (condiciones de carrera). Esto puede causar:

  • ❌ Valores incorrectos

  • ❌ Pérdida de datos

  • ❌ Comportamiento impredecible

  • ❌ Crashes en producción

Comparación: Java vs Go

AspectoJavaGo
Exclusión mutuasynchronized, ReentrantLocksync.Mutex
Read-Write LockReadWriteLocksync.RWMutex
Esperar goroutinesCountDownLatch, join()sync.WaitGroup
Inicialización únicavolatile + double-checksync.Once
Condition variablesConditionsync.Cond
Operaciones atómicasAtomicInteger, etc.sync/atomic

sync.Mutex: Exclusión Mutua

Mutex (mutual exclusion) protege secciones críticas del código. Solo una goroutine puede tener el lock a la vez.

Ejemplo Básico: Contador Thread-Safe

var mu sync.Mutex
counter := 0

// Incrementar contador de forma segura
increment := func() {
    mu.Lock()
    defer mu.Unlock() // ← Siempre usar defer para unlock
    counter++
}

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

wg.Wait()
fmt.Printf("Counter: %d (should be 1000)\n", counter)

Características importantes:

  • Lock() bloquea hasta que el mutex esté disponible

  • Unlock() libera el mutex

  • Siempre usa defer mu.Unlock() para asegurar que se libere incluso si hay panic

  • ✅ Solo una goroutine puede tener el lock a la vez

❌ Sin Mutex: Data Race

// ❌ MAL: Data race
var counter int

var wg sync.WaitGroup
for i := 0; i < 10; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        counter++ // ← Múltiples goroutines escriben sin lock
    }()
}

wg.Wait()
fmt.Printf("Counter: %d (may not be 10 due to race condition)\n", counter)

Ejecutar con race detector:

go run -race main.go

El race detector detectará el problema y mostrará warnings.

Mejores Prácticas con Mutex

1. Siempre usa defer para Unlock:

// ✅ BIEN
mu.Lock()
defer mu.Unlock()
// Código que puede hacer panic
riskyOperation()

// ❌ MAL: Puede no liberar el lock si hay panic
mu.Lock()
riskyOperation() // Si hace panic, el lock nunca se libera
mu.Unlock()

2. Mantén las secciones críticas pequeñas:

// ✅ BIEN: Sección crítica pequeña
mu.Lock()
value := sharedData[key]
mu.Unlock()
processValue(value) // Procesar fuera del lock

// ❌ MAL: Sección crítica grande
mu.Lock()
value := sharedData[key]
processValue(value) // Bloquea el lock durante procesamiento largo
mu.Unlock()

3. No copies un Mutex:

// ❌ MAL: Copiar mutex
type Counter struct {
    mu sync.Mutex
    count int
}

func (c Counter) Increment() { // ← Recibe por valor, copia el mutex
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

// ✅ BIEN: Usar pointer receiver
func (c *Counter) Increment() { // ← Recibe por referencia
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

sync.RWMutex: Read-Write Mutex

RWMutex permite múltiples lectores simultáneos o un escritor exclusivo. Es más eficiente que Mutex cuando tienes muchas lecturas y pocas escrituras.

Ejemplo: Map Thread-Safe

type SafeMap struct {
    mu   sync.RWMutex
    data map[string]int
}

func NewSafeMap() *SafeMap {
    return &SafeMap{
        data: make(map[string]int),
    }
}

func (sm *SafeMap) Get(key string) (int, bool) {
    sm.mu.RLock()         // Lock para lectura (múltiples lectores permitidos)
    defer sm.mu.RUnlock() // Unlock para lectura
    value, exists := sm.data[key]
    return value, exists
}

func (sm *SafeMap) Set(key string, value int) {
    sm.mu.Lock()         // Lock para escritura (exclusivo)
    defer sm.mu.Unlock() // Unlock para escritura
    sm.data[key] = value
}

Características:

  • RLock(): Lock para lectura (múltiples goroutines pueden leer simultáneamente)

  • RUnlock(): Unlock para lectura

  • Lock(): Lock para escritura (exclusivo, bloquea lectores y escritores)

  • Unlock(): Unlock para escritura

Cuándo Usar RWMutex vs Mutex

// ✅ Usa RWMutex cuando:
// - Tienes muchas lecturas y pocas escrituras
// - Las lecturas son independientes
// - Quieres mejor rendimiento en lectura

// ✅ Usa Mutex cuando:
// - Tienes escrituras frecuentes
// - Las operaciones son simples
// - No necesitas optimización de lectura

Ejemplo: Múltiples Lectores y Escritores

sm := NewSafeMap()

// Escritores
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        sm.Set(fmt.Sprintf("key%d", id), id)
    }(i)
}

// Lectores (pueden leer simultáneamente)
for i := 0; i < 5; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        value, exists := sm.Get(fmt.Sprintf("key%d", id))
        if exists {
            fmt.Printf("Read key%d: %d\n", id, value)
        }
    }(i)
}

wg.Wait()

Ventaja de rendimiento:

  • Con Mutex: Solo una goroutine puede leer a la vez

  • Con RWMutex: Múltiples goroutines pueden leer simultáneamente

sync.WaitGroup: Esperar Múltiples Goroutines

WaitGroup permite esperar a que múltiples goroutines terminen. Es similar a CountDownLatch en Java.

Ejemplo Básico

var wg sync.WaitGroup

for i := 0; i < 5; i++ {
    wg.Add(1) // Incrementar contador
    go func(id int) {
        defer wg.Done() // Decrementar contador cuando termine
        fmt.Printf("Worker %d: Starting\n", id)
        time.Sleep(time.Duration(id) * 100 * time.Millisecond)
        fmt.Printf("Worker %d: Finished\n", id)
    }(i)
}

fmt.Println("Waiting for all workers...")
wg.Wait() // Bloquea hasta que el contador llegue a 0
fmt.Println("All workers finished!")

Cómo funciona:

  1. wg.Add(n): Incrementa el contador interno en n

  2. wg.Done(): Decrementa el contador en 1 (equivalente a Add(-1))

  3. wg.Wait(): Bloquea hasta que el contador llegue a 0

Patrón Recomendado

// ✅ BIEN: Siempre usar defer wg.Done()
wg.Add(1)
go func() {
    defer wg.Done() // ← Siempre se ejecuta, incluso si hay panic
    riskyOperation()
}()

// ❌ MAL: Puede no ejecutarse si hay panic
wg.Add(1)
go func() {
    riskyOperation() // Si hace panic, Done() nunca se llama
    wg.Done()
}()

WaitGroup con Contador Dinámico

var wg sync.WaitGroup

// Agregar trabajos dinámicamente
for i := 0; i < 10; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()

        // Este worker puede crear más trabajos
        if id%2 == 0 {
            wg.Add(1)
            go func(subID int) {
                defer wg.Done()
                fmt.Printf("Sub-worker %d-%d\n", id, subID)
            }(id * 10)
        }
    }(i)
}

wg.Wait()

⚠️ Importante: Add() debe llamarse antes de lanzar la goroutine, no dentro de ella.

sync.Once: Ejecutar Una Vez

Once garantiza que una función se ejecute solo una vez, incluso si múltiples goroutines la llaman. Es útil para inicialización lazy y singletons.

Ejemplo: Inicialización Lazy

var once sync.Once
var instance *Database

func getDatabase() *Database {
    once.Do(func() {
        fmt.Println("Initializing database (should only see this once)")
        instance = &Database{
            // Inicialización costosa
        }
    })
    return instance
}

// Múltiples goroutines pueden llamar getDatabase()
// Pero la inicialización solo ocurre una vez
for i := 0; i < 5; i++ {
    go func() {
        db := getDatabase()
        fmt.Printf("Got database: %p\n", db)
    }()
}

Características:

  • ✅ Thread-safe

  • ✅ Garantiza ejecución única

  • ✅ Más eficiente que usar Mutex para inicialización

Comparación: Once vs Mutex

// ❌ Con Mutex (más verboso)
var mu sync.Mutex
var initialized bool
var instance *Database

func getDatabase() *Database {
    mu.Lock()
    defer mu.Unlock()
    if !initialized {
        instance = &Database{}
        initialized = true
    }
    return instance
}

// ✅ Con Once (más simple)
var once sync.Once
var instance *Database

func getDatabase() *Database {
    once.Do(func() {
        instance = &Database{}
    })
    return instance
}

sync.Cond: Condition Variables

Cond permite que goroutines esperen por condiciones específicas. Es útil cuando necesitas esperar por un estado particular.

Ejemplo: Worker que Espera Condición

var mu sync.Mutex
cond := sync.NewCond(&mu)
ready := false

// Worker que espera condición
go func() {
    mu.Lock()
    for !ready {
        fmt.Println("Worker: Waiting for condition...")
        cond.Wait() // Libera el lock y espera
    }
    fmt.Println("Worker: Condition met, proceeding!")
    mu.Unlock()
}()

// Simular trabajo antes de señalizar
time.Sleep(1 * time.Second)

// Señalizar condición
mu.Lock()
ready = true
fmt.Println("Main: Signaling condition...")
cond.Signal() // Despierta una goroutine esperando
mu.Unlock()

Métodos importantes:

  • Wait(): Libera el lock y espera. Debe llamarse con el lock adquirido.

  • Signal(): Despierta una goroutine esperando

  • Broadcast(): Despierta todas las goroutines esperando

⚠️ Importante: Wait() debe llamarse con el lock adquirido y dentro de un loop que verifica la condición:

// ✅ BIEN: Loop con verificación
mu.Lock()
for !condition {
    cond.Wait()
}
// Condición es verdadera aquí
mu.Unlock()

// ❌ MAL: Sin loop (puede tener spurious wakeups)
mu.Lock()
if !condition {
    cond.Wait() // Puede despertar sin que la condición sea verdadera
}
mu.Unlock()

sync.Pool: Object Pooling

Pool mantiene un conjunto de objetos reutilizables para reducir allocations. Es útil cuando crear objetos es costoso.

Ejemplo: Pool de Buffers

var pool = sync.Pool{
    New: func() interface{} {
        fmt.Println("Creating new buffer")
        return make([]byte, 1024)
    },
}

// Obtener del pool
buf1 := pool.Get().([]byte)
fmt.Printf("Got buffer from pool: len=%d\n", len(buf1))

// Devolver al pool
pool.Put(buf1)

// Obtener de nuevo (puede ser el mismo objeto)
buf2 := pool.Get().([]byte)
fmt.Printf("Got buffer from pool again: len=%d\n", len(buf2))

pool.Put(buf2)

Características:

  • ✅ Reduce allocations

  • ✅ Los objetos pueden ser recolectados por GC

  • ✅ Thread-safe

  • ⚠️ No garantiza que obtengas el mismo objeto

Cuándo usar Pool:

  • Crear objetos es costoso

  • Los objetos se reutilizan frecuentemente

  • Quieres reducir presión en el GC

Operaciones Atómicas: sync/atomic

Para operaciones simples en tipos primitivos, las operaciones atómicas son más eficientes que Mutex.

Ejemplo: Contador Atómico

var counter int64

// Incrementar atómicamente
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        atomic.AddInt64(&counter, 1)
    }()
}

wg.Wait()
fmt.Printf("Counter: %d (should be 1000)\n", atomic.LoadInt64(&counter))

// Compare and swap
oldValue := atomic.LoadInt64(&counter)
newValue := oldValue + 100
if atomic.CompareAndSwapInt64(&counter, oldValue, newValue) {
    fmt.Printf("CAS succeeded: %d -> %d\n", oldValue, newValue)
}

Operaciones atómicas disponibles:

  • AddInt64, AddInt32, AddUint64, etc.

  • LoadInt64, LoadInt32, etc.

  • StoreInt64, StoreInt32, etc.

  • CompareAndSwapInt64, CompareAndSwapInt32, etc.

  • SwapInt64, SwapInt32, etc.

Cuándo usar atomic vs Mutex:

  • Atomic: Operaciones simples en tipos primitivos (contadores, flags)

  • Mutex: Operaciones complejas, múltiples variables, estructuras

Ejemplo Práctico: Cache Thread-Safe

Combinando RWMutex y atomic operations:

type Cache struct {
    mu    sync.RWMutex
    data  map[string]interface{}
    stats struct {
        hits   int64
        misses int64
    }
}

func NewCache() *Cache {
    return &Cache{
        data: make(map[string]interface{}),
    }
}

func (c *Cache) Get(key string) (interface{}, bool) {
    c.mu.RLock()
    value, exists := c.data[key]
    c.mu.RUnlock()

    if exists {
        atomic.AddInt64(&c.stats.hits, 1)
    } else {
        atomic.AddInt64(&c.stats.misses, 1)
    }

    return value, exists
}

func (c *Cache) Set(key string, value interface{}) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data[key] = value
}

func (c *Cache) Stats() (hits, misses int64) {
    return atomic.LoadInt64(&c.stats.hits), atomic.LoadInt64(&c.stats.misses)
}

Características del diseño:

  • RWMutex para el map (muchas lecturas, pocas escrituras)

  • ✅ Operaciones atómicas para estadísticas (más eficiente que Mutex)

  • ✅ Sección crítica pequeña en Get() (solo para leer el map)

Detectar Data Races

Go tiene un race detector incorporado que detecta race conditions en tiempo de ejecución.

Ejecutar con Race Detector

# Ejecutar programa
go run -race main.go

# Ejecutar tests
go test -race

# Compilar binario
go build -race

Ejemplo de Data Race Detectado

// ❌ Código con data race
var counter int

var wg sync.WaitGroup
for i := 0; i < 10; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        counter++ // ← Data race aquí
    }()
}

wg.Wait()

Ejecutar con -race:

WARNING: DATA RACE
Read at 0x00c00001a0a8 by goroutine 8:
  main.main.func1()
      /path/to/main.go:361: +0x44

Previous write at 0x00c00001a0a8 by goroutine 7:
  main.main.func1()
      /path/to/main.go:361: +0x60

Mejores Prácticas

1. Prefiere Channels sobre Mutex cuando sea Posible

// ✅ BIEN: Usar channels (más idiomático en Go)
ch := make(chan int, 1)
ch <- value
value := <-ch

// ⚠️ Usar Mutex solo cuando channels no son apropiados
var mu sync.Mutex
mu.Lock()
sharedValue = newValue
mu.Unlock()

Regla general: "Don't communicate by sharing memory; share memory by communicating"

2. Siempre Usa defer para Unlock

// ✅ BIEN
mu.Lock()
defer mu.Unlock()
riskyOperation()

// ❌ MAL
mu.Lock()
riskyOperation() // Si hace panic, el lock nunca se libera
mu.Unlock()

3. Mantén Secciones Críticas Pequeñas

// ✅ BIEN: Sección crítica pequeña
mu.Lock()
data := sharedMap[key]
mu.Unlock()
processData(data) // Procesar fuera del lock

// ❌ MAL: Sección crítica grande
mu.Lock()
data := sharedMap[key]
processData(data) // Bloquea el lock durante procesamiento
mu.Unlock()

4. No Copies Mutexes

// ❌ MAL: Copiar mutex
func (c Counter) Increment() { // ← Recibe por valor
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

// ✅ BIEN: Usar pointer receiver
func (c *Counter) Increment() { // ← Recibe por referencia
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

5. Usa RWMutex para Read-Heavy Workloads

// ✅ BIEN: Muchas lecturas, pocas escrituras
type Cache struct {
    mu sync.RWMutex
    data map[string]interface{}
}

func (c *Cache) Get(key string) interface{} {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.data[key]
}

func (c *Cache) Set(key string, value interface{}) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data[key] = value
}

6. Siempre Usa defer con WaitGroup.Done()

// ✅ BIEN
wg.Add(1)
go func() {
    defer wg.Done()
    riskyOperation()
}()

// ❌ MAL
wg.Add(1)
go func() {
    riskyOperation()
    wg.Done() // Puede no ejecutarse si hay panic
}()

Errores Comunes

❌ Error 1: Olvidar Unlock

// ❌ MAL: Deadlock
mu.Lock()
// Olvidamos Unlock()
// Todas las demás goroutines bloquean para siempre

// ✅ BIEN: Siempre usar defer
mu.Lock()
defer mu.Unlock()

❌ Error 2: Copiar Mutex

// ❌ MAL: Copiar mutex no funciona
type Counter struct {
    mu sync.Mutex
    count int
}

func (c Counter) Increment() { // ← Copia el mutex
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++ // Solo modifica la copia
}

// ✅ BIEN: Usar pointer receiver
func (c *Counter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

❌ Error 3: Llamar Add() Dentro de la Goroutine

// ❌ MAL: Add() dentro de la goroutine puede causar race
go func() {
    wg.Add(1) // ← Puede ejecutarse después de Wait()
    defer wg.Done()
    // ...
}()

// ✅ BIEN: Add() antes de lanzar la goroutine
wg.Add(1)
go func() {
    defer wg.Done()
    // ...
}()

❌ Error 4: No Verificar Condición en Loop con Cond

// ❌ MAL: Puede tener spurious wakeups
mu.Lock()
if !ready {
    cond.Wait() // Puede despertar sin que ready sea true
}
mu.Unlock()

// ✅ BIEN: Verificar en loop
mu.Lock()
for !ready {
    cond.Wait()
}
mu.Unlock()

❌ Error 5: Usar Mutex para Operaciones Simples

// ❌ MAL: Mutex para operación simple
var mu sync.Mutex
var counter int64

func increment() {
    mu.Lock()
    counter++
    mu.Unlock()
}

// ✅ BIEN: Usar atomic
var counter int64

func increment() {
    atomic.AddInt64(&counter, 1)
}

Comparación: Java vs Go

Java: synchronized y Locks

// Java
public class Counter {
    private int count = 0;
    private final Object lock = new Object();

    public void increment() {
        synchronized (lock) {
            count++;
        }
    }
}

// O con ReentrantLock
private final ReentrantLock lock = new ReentrantLock();

public void increment() {
    lock.lock();
    try {
        count++;
    } finally {
        lock.unlock();
    }
}

Problemas:

  • Sintaxis verbosa

  • Fácil olvidar unlock en finally

  • No hay defer equivalente

Go: Mutex

// Go
type Counter struct {
    mu sync.Mutex
    count int
}

func (c *Counter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

Ventajas:

  • Sintaxis simple

  • defer garantiza unlock

  • Más idiomático

Conclusiones

Las primitivas de sincronización en Go son poderosas y simples:

Mutex: Exclusión mutua simple y efectiva

RWMutex: Optimizado para read-heavy workloads

WaitGroup: Esperar múltiples goroutines de forma elegante

Once: Inicialización thread-safe garantizada

Cond: Esperar por condiciones específicas

Pool: Reducir allocations con object pooling

Atomic: Operaciones atómicas eficientes para tipos primitivos

Race Detector: Herramienta incorporada para detectar race conditions

Si vienes de Java, verás que Go simplifica mucho la sincronización. La filosofía de Go es usar channels cuando sea posible, y solo usar Mutex cuando sea absolutamente necesario. Sin embargo, cuando necesitas sincronización de bajo nivel, el paquete sync proporciona todas las herramientas necesarias.

Próximos Pasos

En el siguiente post exploraremos patrones avanzados de concurrencia en Go, incluyendo worker pools, pipelines, fan-out/fan-in, y otros patrones comunes que combinan goroutines, channels, context y sync.


¿Has usado primitivas de sincronización en tus proyectos de Go? ¿Prefieres channels o Mutex? Comparte tus experiencias y casos de uso en los comentarios. Y si quieres ver el código completo de estos ejemplos, puedes encontrarlo en mi repositorio go-mastery-lab.

More from this blog

JoeDayz

53 posts

Community Guy | Java Champion | AWS Architect | Software Architect