Concurrencia en Go — Building H2O (Admisión + Barrera Reutilizable)
Análisis de ingeniería del problema Building H2O: semáforos por canal para control de admisión y barrera reutilizable con generaciones sobre sync.Cond. Trade-offs frente a WaitGroup y framework para sincronización de grupos en Go.
Concurrencia en Go — Building H2O (Admisión + Barrera Reutilizable)
1. Thesis Statement & Problem Context
Building H2O es un problema de coordinación acotada: N goroutines de dos tipos (hidrógeno y oxígeno) llaman a callbacks en orden arbitrario, y la restricción es que cada molécula requiere exactamente 2H + 1O liberados de forma atómica respecto al grupo. El objetivo no es solo evitar condiciones de carrera, sino garantizar una invariante de agrupación: nunca se libera un átomo fuera de una molécula completa.
El reto real no es criptografía ni estado compartido complejo: es elegir el primitivo de sincronización correcto para dos requisitos que entran en conflicto:
- Control de admisión — nunca más de 2 hidrógenos y 1 oxígeno "en vuelo" hacia una misma molécula.
- Rendezvous de grupo — los 3 átomos deben llegar antes de que cualquiera libere su callback, y el mecanismo debe reutilizarse para cada nueva molécula.
Un sync.WaitGroup falla en el requisito 2: es de un solo uso y su reset externo introduce la misma carrera que intentamos eliminar. La solución correcta combina semáforos por canal (admisión) con una barrera reutilizable por generaciones (rendezvous).
2. Architectural Breakdown & Technical Trade-offs
2.1 Los dos primitivos
Semáforo de conteo como canal con buffer. Un chan struct{} de capacidad k es un semáforo: un send bloquea cuando el buffer está lleno (adquirir), un receive libera espacio (liberar). El envío de struct{}{} (0 bytes) hace explícito que el canal es un gate, no un transporte de datos.
hSem := make(chan struct{}, 2) // admite ≤2 H concurrentes
oSem := make(chan struct{}, 1) // admite ≤1 O concurrente
h.hSem <- struct{}{} // adquirir slot H (bloquea si 2 H ya están dentro)
<-h.hSem // liberar slot H
Barrera reutilizable con sync.Cond + generación. La barrera espera a que lleguen exactamente 3 goroutines, las libera a todas, y se reinicia sin carrera usando un contador de generación:
type H2O struct {
hSem chan struct{}
oSem chan struct{}
mu sync.Mutex
cond *sync.Cond
count int // goroutines en la barrera
gen int // generación de la barrera
}
func NewH2O() *H2O {
h := &H2O{hSem: make(chan struct{}, 2), oSem: make(chan struct{}, 1)}
h.cond = sync.NewCond(&h.mu)
return h
}
func (h *H2O) waitBarrier() {
h.mu.Lock()
g := h.gen // (1) snapshot de la generación actual
h.count++
if h.count == 3 { // (2) el tercer llegado cierra la ronda
h.count = 0
h.gen++ // (3) avanza la generación ANTES del broadcast
h.cond.Broadcast()
h.mu.Unlock()
return
}
for g == h.gen { // (4) esperar mientras la generación no cambie
h.cond.Wait()
}
h.mu.Unlock()
}
func (h *H2O) Hydrogen(releaseHydrogen func()) {
h.hSem <- struct{}{}
h.waitBarrier()
releaseHydrogen()
<-h.hSem
}
func (h *H2O) Oxygen(releaseOxygen func()) {
h.oSem <- struct{}{}
h.waitBarrier()
releaseOxygen()
<-h.oSem
}
2.2 Por qué la generación hace reutilizable la barrera
El detalle que desactiva la carrera de reset es el orden en el tercer llegado: count = 0 y luego gen++, todo bajo mu, y el broadcast ocurre después. Los waiters que duermen en cond.Wait() retoman con el mutex re-adquirido, leen su snapshot g (generación vieja) y salen del loop porque g != h.gen. Un "tercer llegado" que reentrara inmediatamente (la siguiente molécula) verá count == 0 y gen ya incrementado, de modo que nunca colisiona con los waiters de la ronda anterior. Sin la generación, un simple count = 0 tras el broadcast podría re-acomodar a un waiters rezagado como el primer elemento de la ronda siguiente — la carrera clásica del "barrier reset".
2.3 Flujo de sincronización
2.4 Trade-offs de diseño
| Enfoque | Ventaja | Costo |
|---|---|---|
| Canal con buffer (semáforo) | Idiomático en Go; composable; bloquea en send | Cada canal cubre un solo recurso; k canales para k tipos |
sync.WaitGroup de un solo uso |
Simple para una ronda | No reutilizable sin reset externo con carrera |
Mutex + sync.Cond (barrera) |
Rendezvous preciso; broadcast de grupo | Requiere el patrón de generación para reuso seguro |
| Atomic counters + spin | Latencia mínima para pocos hilos | Busy-wait; no escala con contención |
El diseño elegido separa responsabilidades de forma limpia: los canales controlan cuántos pasan (admisión, sin estado compartido) y la barrera controla cuándo pasan (rendezvous, con estado bajo mutex). Esta separación hace que la invariante 2H + 1O se mantenga por construcción: ningún átomo excede su cuota (semáforo) y ninguno se libera fuera de grupo (barrera).
3. Key Takeaways & Practical Framework for Engineers
3.1 Patrón reutilizable
- Admisión acotada →
chan struct{}con buffer. Si solo necesitas limitar cuántas goroutines entran a una sección, un canal con buffer es un semáforo de conteo sin dependencias extra. - Rendezvous de grupo → barrera con generación. Si N goroutines deben esperarse antes de continuar, y el punto se repite, usa
sync.Cond+count+gen. El snapshotg := h.genantes de esperar y elgen++del último llegado son la diferencia entre correcto y una barrera con carrera. - Una sola responsabilidad por primitivo. No mezcles admisión y rendezvous en un mismo lock: la legibilidad y la corrección mejoran al separar "cuántos" (canales) de "cuándo" (barrera).
3.2 Generalización a problemas reales
El mismo par semáforo + barrera aparece en: worker pools con admisión por cuota, ejecución por fases (barrier entre etapas de pipeline), batching de sesiones (N requests forman un lote antes de procesar), y rendezvous multi-parte en protocolos de consenso. Cuando un requisito diga "no más de K de estos a la vez", usa un semáforo; cuando diga "estos deben coordinarse antes de continuar", usa una barrera; cuando ambos, usa los dos.
3.3 Nota de verificación
El criterio de aceptación de una solución de coordinación no es "funciona en el caso feliz": es probar con miles de moléculas en interleavings adversos (p. ej. -race en Go) y comprobar que cada callback se ejecuta exactamente una vez por átomo y que la invariante 2H:1O se mantiene en cada output prefix. La generación en la barrera es precisamente lo que hace que esta propiedad se sostenga bajo repetición indefinida.