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:
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
increment := func() {
mu.Lock()
defer mu.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
var counter int
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++
}()
}
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:
mu.Lock()
defer mu.Unlock()
riskyOperation()
mu.Lock()
riskyOperation()
mu.Unlock()
2. Mantén las secciones críticas pequeñas:
mu.Lock()
value := sharedData[key]
mu.Unlock()
processValue(value)
mu.Lock()
value := sharedData[key]
processValue(value)
mu.Unlock()
3. No copies un Mutex:
type Counter struct {
mu sync.Mutex
count int
}
func (c Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *Counter) Increment() {
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()
defer sm.mu.RUnlock()
value, exists := sm.data[key]
return value, exists
}
func (sm *SafeMap) Set(key string, value int) {
sm.mu.Lock()
defer sm.mu.Unlock()
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
Ejemplo: Múltiples Lectores y Escritores
sm := NewSafeMap()
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)
}
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:
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)
go func(id int) {
defer wg.Done()
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()
fmt.Println("All workers finished!")
Cómo funciona:
wg.Add(n): Incrementa el contador interno en n
wg.Done(): Decrementa el contador en 1 (equivalente a Add(-1))
wg.Wait(): Bloquea hasta que el contador llegue a 0
Patrón Recomendado
wg.Add(1)
go func() {
defer wg.Done()
riskyOperation()
}()
wg.Add(1)
go func() {
riskyOperation()
wg.Done()
}()
WaitGroup con Contador Dinámico
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
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{
}
})
return instance
}
for i := 0; i < 5; i++ {
go func() {
db := getDatabase()
fmt.Printf("Got database: %p\n", db)
}()
}
Características:
Comparación: Once vs Mutex
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
}
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
go func() {
mu.Lock()
for !ready {
fmt.Println("Worker: Waiting for condition...")
cond.Wait()
}
fmt.Println("Worker: Condition met, proceeding!")
mu.Unlock()
}()
time.Sleep(1 * time.Second)
mu.Lock()
ready = true
fmt.Println("Main: Signaling condition...")
cond.Signal()
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:
mu.Lock()
for !condition {
cond.Wait()
}
mu.Unlock()
mu.Lock()
if !condition {
cond.Wait()
}
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)
},
}
buf1 := pool.Get().([]byte)
fmt.Printf("Got buffer from pool: len=%d\n", len(buf1))
pool.Put(buf1)
buf2 := pool.Get().([]byte)
fmt.Printf("Got buffer from pool again: len=%d\n", len(buf2))
pool.Put(buf2)
Características:
Cuándo usar Pool:
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
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))
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
go run -race main.go
go test -race
go build -race
Ejemplo de Data Race Detectado
var counter int
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++
}()
}
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
ch := make(chan int, 1)
ch <- value
value := <-ch
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
mu.Lock()
defer mu.Unlock()
riskyOperation()
mu.Lock()
riskyOperation()
mu.Unlock()
3. Mantén Secciones Críticas Pequeñas
mu.Lock()
data := sharedMap[key]
mu.Unlock()
processData(data)
mu.Lock()
data := sharedMap[key]
processData(data)
mu.Unlock()
4. No Copies Mutexes
func (c Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
5. Usa RWMutex para Read-Heavy Workloads
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()
wg.Add(1)
go func() {
defer wg.Done()
riskyOperation()
}()
wg.Add(1)
go func() {
riskyOperation()
wg.Done()
}()
Errores Comunes
❌ Error 1: Olvidar Unlock
mu.Lock()
mu.Lock()
defer mu.Unlock()
❌ Error 2: Copiar Mutex
type Counter struct {
mu sync.Mutex
count int
}
func (c Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
❌ Error 3: Llamar Add() Dentro de la Goroutine
go func() {
wg.Add(1)
defer wg.Done()
}()
wg.Add(1)
go func() {
defer wg.Done()
}()
❌ Error 4: No Verificar Condición en Loop con Cond
mu.Lock()
if !ready {
cond.Wait()
}
mu.Unlock()
mu.Lock()
for !ready {
cond.Wait()
}
mu.Unlock()
❌ Error 5: Usar Mutex para Operaciones Simples
var mu sync.Mutex
var counter int64
func increment() {
mu.Lock()
counter++
mu.Unlock()
}
var counter int64
func increment() {
atomic.AddInt64(&counter, 1)
}
Comparación: Java vs Go
Java: synchronized y Locks
public class Counter {
private int count = 0;
private final Object lock = new Object();
public void increment() {
synchronized (lock) {
count++;
}
}
}
private final ReentrantLock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
Problemas:
Go: Mutex
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.