← Writeups

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:

  1. Control de admisión — nunca más de 2 hidrógenos y 1 oxígeno "en vuelo" hacia una misma molécula.
  2. 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

Barrera reutilizable con generaciones en Building H2O Dos goroutines de hidrógeno y una de oxígeno adquieren sus slots semáforo, se bloquean en la barrera, el tercer llegado incrementa la generación y emite broadcast, las tres despiertan y liberan sus callbacks. ACQUIRE HSEM ACQUIRE HSEM ACQUIRE OSEM COUNT=3 · GEN++ WAKE · GEN+1 WAKE WAKE RELEASE H RELEASE H RELEASE O H H₁ goroutine Hydrogen() H H₂ goroutine Hydrogen() O O₁ goroutine Oxygen() SYNC Barrier sync.Cond · count/gen LEGEND Coordinador Adquirir slot Broadcast / wake Release callback
Fig. 1 — Admisión (semáforos) → barrera (count/gen) → broadcast → release. El orden count=0 → gen++ es lo que evita la carrera de reset.

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

  1. 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.
  2. 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 snapshot g := h.gen antes de esperar y el gen++ del último llegado son la diferencia entre correcto y una barrera con carrera.
  3. 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.