Limitar la concurrencia en Go: errgroup, pools de trabajadores y backpressure

Limita el trabajo en curso, no solo canceles.

Índice

El abanico de tareas sin límite (unbounded fan-out) es el modo de fallo por defecto de la concurrencia en Go: cada sentencia go añade una tarea más en vuelo, sin techo ni propietario. Este artículo trata sobre cómo establecer ambos.

La cancelación y el acotamiento son controles separados. La cancelación decide cuándo se detiene el trabajo; el acotamiento decide cuántas tareas existen simultáneamente. Un servicio puede cancelar a la perfección y aún así derrumbarse porque diez mil goroutines decidieron, cada una de forma independiente, que ese era un buen momento para llamar a la base de datos.

Embudo de cristal que estrangula un flujo de partículas brillantes a través de una compuerta regulada

Las páginas relacionadas cubren la otra mitad del problema. Go context.Context Bien Hecho trata al contexto como el plano de control para detener el trabajo; aquí el tema es el plano de capacidad — errgroup.SetLimit, canales semáforo, pools de trabajadores y colas acotadas — además de la forma de fuga que sobrevive a errgroup.Wait incluso cuando todas las rutas de cancelación parecen estar conectadas.

Cinco primitivas, una tabla de decisión

Primitiva Resultados parciales Fallo rápido Backpressure Trabajo dinámico Mejor para
sync.WaitGroup + errors.Join sí no no no lotes donde cuenta cada resultado
errgroup.WithContext no sí no no abanico donde el primer error termina la solicitud
errgroup.SetLimit(n) no sí sí (bloquea Go) no abanico con límite y fallo rápido
Canal semáforo sí manual sí (bloquea adquisición) sí control de admisión personalizado
Pool de trabajadores sí manual sí (se llena la cola) sí flujos constantes, trabajadores de larga duración

Dos preguntas eligen la fila. Primera: cuando una tarea falla, ¿aún quieres los resultados de las otras? Si es así, la semántica de grupo de fallo rápido descarta trabajo que necesitabas. Segunda: ¿el trabajo llega de forma continua o es un lote fijo que puedes contar antes de lanzarlo? Los grupos manejan lotes; los pools y las colas manejan flujos.

errors.Join es lo que hace viable la fila del WaitGroup simple — recopila el error de cada trabajador en lugar de mantener solo el primero, lo cual se combina con las reglas de traducción de límites en Arquitectura de manejo de errores en Go.

La fuga que sobrevive a errgroup.Wait

El modo de fallo más conocido de errgroup no tiene nada que ver con el acotamiento. Es un vacío de cancelación, y se esconde tras código que parece correcto:

g, gctx := errgroup.WithContext(ctx)
results := make(chan int) // sin buffer

go func() { // productor — nadie posee esta goroutine
    for i := 0; i < 100; i++ {
        results <- i
    }
    close(results)
}()

g.Go(func() error { // consumidor — miembro del grupo
    for r := range results {
        if r == 5 {
            return fmt.Errorf("fallo al guardar")
        }
    }
    return nil
})

err := g.Wait() // retorna con el error del consumidor

Cuando el consumidor retorna, gctx se cancela y Wait retorna. El productor está estacionado en results <- i — y la cancelación del contexto no desbloquea un envío por canal. Nadie posee esa goroutine, por lo que permanece estacionada por toda la vida del proceso. Si ejecutas esta forma en un bucle, la fuga es exactamente lineal:

Variante Goroutines vivas tras 500 iteraciones
Envío sin un brazo ctx.Done 501 (+500 fugadas, una por iteración)
Envío envuelto en select con <-gctx.Done() 502 (+1 línea base)
// la solución: cada operación de canal dentro de una tarea cancelable
// obtiene un brazo Done
select {
case results <- i:
case <-gctx.Done():
    return
}

El mismo vacío existe en el lado de recepción. La regla es mecánica: cada operación de canal dentro de código que puede ser cancelado obtiene un brazo <-ctx.Done(), y cada goroutine tiene un propietario que espera por ella o la cancela. goleak en CI captura los casos que la revisión pasa por alto — encaja en la misma puerta de calidad automatizada que las herramientas en Linters de Go: Herramientas Esenciales para la Calidad del Código.

Dos formas relacionadas valen la pena nombrar. Si el productor es un miembro del grupo, el mismo código produce no una fuga sino un deadlock — Wait bloquea para siempre esperando el envío estacionado, lo cual al menos falla ruidosamente. Y un productor que envía con select pero recibe en un canal cuyo consumidor ha salido, fuga de la misma manera; los brazos Done pertenecen en ambos lados.

errgroup.SetLimit: abanico con límite y fallo rápido

SetLimit convierte un errgroup en un grupo con control de admisión. Cada llamada Go más allá del límite bloquea hasta que un hueco se libere:

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
for _, item := range items {
    g.Go(func() error {
        return process(ctx, item)
    })
}
err := g.Wait()

Tres semánticas importan en producción. Go bloquea, por lo que el bucle anterior aplica backpressure al productor — eso es usualmente lo que se quiere, pero significa que la goroutine del llamador puede detenerse, por lo que un manejador de solicitudes que alimenta un lote grande necesita su propio presupuesto de temporizador alrededor de todo el bucle. SetLimit(0) bloquea cada llamada Go para siempre. Y cambiar el límite mientras las goroutines están activas causa un pánico — el límite está fijo para la vida del grupo.

SetLimit hereda la semántica de grupo: el primer error cancela el contexto y Wait retorna el primer error, descartando los resultados del trabajo aún en vuelo. Es la primitiva correcta cuando fallar la operación completa ante el primer fallo es correcto — obtener una página de datos, llamar a un conjunto de comprobaciones de salud independientes, extender una solicitud a shards.

Canales semáforo: backpressure como característica

Un canal con buffer de capacidad n es un semáforo sin dependencias:

sem := make(chan struct{}, 8)
var wg sync.WaitGroup
for _, item := range items {
    wg.Add(1)
    sem <- struct{}{} // bloquea cuando 8 tareas están en vuelo
    go func() {
        defer wg.Done()
        defer func() { <-sem }()
        _ = process(ctx, item)
    }()
}
wg.Wait()

La adquisición ocurre antes de la sentencia go, por lo que las goroutines solo se crean cuando existe un hueco. El tamaño del buffer es el contrato: es simultáneamente el techo de concurrencia y la cola de admisiones pendientes.

La primitiva gana su lugar cuando necesitas control de admisión que los grupos no expresan — adquisición consciente del contexto, pools por descendiente, o resultados parciales ante el fallo:

select {
case sem <- struct{}{}: // hueco adquirido
case <-ctx.Done():      // el llamador se rindió antes de entrar
    return ctx.Err()
}

Medido en la misma carga de trabajo, el canal semáforo y SetLimit(8) terminan dentro del 1.5% de diferencia entre sí — la elección entre ellos es semántica, no rendimiento.

Pools de trabajadores: para flujos, no para lotes

Un pool fijo de trabajadores de larga duración separa la admisión (la cola) de la ejecución (los trabajadores):

func pool(ctx context.Context, workers int) chan<- func() {
    jobs := make(chan func())
    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for {
                select {
                case f, ok := <-jobs:
                    if !ok {
                        return
                    }
                    f()
                case <-ctx.Done():
                    return
                }
            }
        }()
    }
    return jobs
}

Los pools encajan con trabajo que llega de forma continua — consumidores de colas, bucles de sondeo, trabajadores de solicitudes — donde un grupo por lote se crearía y destruiría sin fin. Desde Go 1.25, sync.WaitGroup.Go elimina la plantilla de emparejamiento Add/Done para la forma común. El contrato de apagado es la parte a acertar: cerrar jobs termina los trabajadores después de que la cola se drene; cancelar ctx abandona el trabajo en cola. Ambos son legítimos, pero solo uno puede ser el comportamiento documentado. Go 1.27 continúa en la misma dirección — métodos genéricos, nuevos modernizadores go fix, y un httptest.NewTestServer en memoria — todo cubierto en la guía de actualización a Go 1.27.

Colas acotadas: bloquear o descartar

Un canal con buffer también es una cola con un techo. Una vez que el buffer se llena, el productor bloquea — y ese momento es la decisión de diseño. Bloquear propaga la presión aguas arriba a quien alimenta la cola; descartar descarta carga a costa de trabajo perdido. Un pipeline de monitoreo puede descartar; un pipeline de órdenes debe bloquear o desbordar a almacenamiento duradero.

select {
case queue <- event: // admitido
default:            // cola llena: la política de descarte vive aquí
    dropped.Add(1)
}

Sea cual sea la política, hazla explícita y medida. Una rama default silenciosa es donde los eventos van a desaparecer.

A qué cuesta el acotamiento — medido

La misma carga de trabajo, 10.000 tareas de 5 ms de E/S simulada cada una, Go 1.27.1:

Estrategia Goroutines pico Tiempo de pared
go por tarea sin límite ~10.000 lanzadas 18 ms
errgroup.SetLimit(8) 8 6,9 s
Canal semáforo (cap 8) 8 6,6 s

La ejecución sin límite gana en tiempo de pared porque nada en el experimento empuja de vuelta — diez mil goroutines de temporizador simplemente duermen en paralelo. Esa es exactamente la trampa. El costo del abanico sin límite nunca es CPU en tu propio proceso; son diez mil conexiones simultáneas, un sistema descendente que empieza a tener timeouts bajo el estallido, y un gráfico de memoria que sigue la profundidad de cola de a quien estás llamando. Con una dependencia real, la ejecución sin límite no termina en 18 ms — tiene timeout. Las ejecuciones acotadas pagan 6,6 segundos porque 10.000 tareas ÷ 8 huecos × 5 ms son 6,25 s de serialización inevitable, y el techo es el punto: convierte un estallido incontrolado en un drenaje predecible de 6,25 segundos.

Un refinamiento importa cuando el techo es más alto que un puñado de huecos: mantén el techo a o por debajo de lo que el sistema descendiente puede sostener concurrentemente, y dérivalo de ese límite (tamaño del pool de conexiones, límite de velocidad, capacidad del trabajador) en lugar de una sensación sobre la concurrencia “razonable”.

Medir y probar código acotado

Tres señales cubren la mayor parte. Tendencias de conteo de goroutines (/sched/goroutines:goroutines exportado vía el colector Go de Prometheus) capturan fugas como una pendiente. Latencia del planificador (/sched/latencies:seconds) captura la saturación — goroutines ejecutables esperando tiempo de CPU es presión real independientemente del conteo. Un delta pprof de dos volcados de goroutines pincha dónde viven las goroutines estacionadas.

Para las pruebas, los trabajadores acotados son el caso para el que Probando Código Concurrente en Go con synctest fue construido: el tiempo falso hace que un drenaje completo de 10.000 tareas se ejecute en milisegundos, synctest.Wait reemplaza el dormir-y-esperar por la quietud, y una burbuja falla rápido cuando un trabajador permanece bloqueado durablemente — la reproducción de la fuga anterior muere en una burbuja de prueba en lugar de en producción.

A escala de servicio, la misma pregunta de acotamiento reaparece entre servicios, donde el semáforo se convierte en un límite de velocidad y el disyuntor de circuito protege al descendiente — Patrón de Disyuntor de Circuito en Go cubre esa capa, y Microservicios en Go para Orquestación de IA/ML muestra las formas basadas en cola que la capa de orquestación adopta.

Elegir

flowchart TD A[Trabajo para ejecutar concurrentemente] --> B{Lote fijo o flujo continuo?} B -- flujo continuo --> C[Pool de trabajadores o cola acotada] B -- lote fijo --> D{¿Resultados parciales necesarios
ante el primer fallo?} D -- sí --> E[WaitGroup + errors.Join
o canal semáforo] D -- no --> F[errgroup.WithContext] F --> G{¿Necesitas un techo de concurrencia?} G -- sí --> H[errgroup.SetLimit] G -- no --> I[grupo sin límite
solo cuando el lote es demostrablemente pequeño] C --> J{Cola llena: bloquear o descartar?} J -- bloquear --> K[Backpressure aguas arriba] J -- descartar --> L[Descartar carga, contar los descartes]

La tabla y el diagrama de flujo se comprimen en un hábito: antes de escribir go, nombra el techo y el propietario. El techo es SetLimit, un semáforo, o una cola acotada; el propietario es un Wait, un brazo ctx.Done en cada operación de canal, o ambos. Cada sitio de creación de goroutines que pueda responder esos dos nombres de un vistazo es uno que no despertará a nadie a las 3 de la mañana.

Enlaces útiles

Suscribirse

Recibe nuevas publicaciones sobre sistemas, infraestructura e ingeniería de IA.