🧪 Problem-Driven Systems Lab

🐹 Caso 13 — Go 1.23#

⬅️ Caso 13 · ⚖️ Comparativa de los 7 stacks · 🐹 Perfil de Go · 🧬 Todos los perfiles

Stack Go del caso 13. Ráfaga de N goroutines sobre una clave que acaba de expirar, sin coordinación y con single-flight.

La respuesta canónica cabe en 25 líneas de stdlib#

Go tiene la solución oficial a este problema en golang.org/x/sync/singleflight. Pero eso es un módulo externo, y este lab compila sin red. Resulta que no hace falta: el patrón entero se escribe con la biblioteca estándar, y escribirlo a mano es más didáctico que importarlo.

go
type call struct {
    wg  sync.WaitGroup
    did bool
}

func do(key string, fn func() bool) (bool, bool) {
    flightMu.Lock()
    if c, ok := flights[key]; ok {
        flightMu.Unlock()      // soltar ANTES de esperar
        c.wg.Wait()
        return c.did, false
    }
    c := new(call)
    c.wg.Add(1)
    flights[key] = c
    flightMu.Unlock()

    c.did = fn()
    c.wg.Done()

    flightMu.Lock()
    delete(flights, key)
    flightMu.Unlock()
    return c.did, true
}

La pieza es sync.WaitGroup usada al revés de como se usa normalmente. En vez de «el coordinador espera a los trabajadores», acá el líder hace Add(1) antes de empezar y Done() al terminar, y los seguidores hacen Wait(). Un WaitGroup es un contador con espera; eso es exactamente un single-flight con una sola operación pendiente.

Primitivas nativas#

PrimitivaRol
sync.WaitGroupEl contador con espera que hace de single-flight.
sync.Mutex + mapEl registro de vuelos en curso.
chan struct{} cerradoLargada común de las N goroutines: close(start) las libera a todas de una vez.
sync/atomic + CompareAndSwapstampede_depth sin lock.

Por qué map con mutex y no sync.Map#

La regla práctica: sync.Map gana cuando las claves se escriben una vez y se leen muchas. Acá cada entrada se crea y se borra en cada expiración — justo el patrón en el que sync.Map va peor que un map normal bajo mutex.

Rutas#

RutaQué muestra
/healthliveness
/cache-naive?key=report-alpha&concurrency=16&cost=40origin_computations = concurrency: el origen recibe la ráfaga entera
/cache-singleflight?key=report-alpha&concurrency=16&cost=40origin_computations = 1, coalesced_waiters = concurrency - 1
/cache/stateedad, soft TTL, hard TTL y jitter aplicado por clave
/diagnostics/summaryacumulado por variante y origin_total_computations
/reset-labvacía cache y contadores

Parámetros: key (clave a golpear), concurrency (1–128 llamadores simultáneos), cost (1–400 rondas de trabajo del origen; cada ronda son 2.000 iteraciones de CPU real).

Hub#

bash
docker compose -f compose.go.yml up -d --build
curl "http://127.0.0.1:8600/13/cache-naive?key=k&concurrency=16&cost=40"
curl "http://127.0.0.1:8600/13/reset-lab"
curl "http://127.0.0.1:8600/13/cache-singleflight?key=k&concurrency=16&cost=40"

Ver esta carpeta en GitHub ↗