Sincronización en Go: Mutex, RWMutex y WaitGroup
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
| Aspecto | Java | Go |
| Exclusión mutua | synchronized, ReentrantLock | sync.Mutex |
| Read-Write Lock | ReadWriteLock | sync.RWMutex |
| Esperar goroutines | CountDownLatch, join() | sync.WaitGroup |
| Inicialización única | volatile + double-check | sync.Once |
| Condition variables | Condition | sync.Cond |
| Operaciones atómicas | AtomicInteger, 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 vezCon
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:
wg.Add(n): Incrementa el contador interno ennwg.Done(): Decrementa el contador en 1 (equivalente aAdd(-1))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 esperandoBroadcast(): 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:
✅
RWMutexpara 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
defergarantiza unlockMá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.




