Limitar la concurrencia en Go: errgroup, pools de workers y backpressure
Limita el trabajo en curso, no solo cancélelo.
El fan-out sin límite es el modo de fallo por defecto en la concurrencia de Go: cada sentencia go añade una tarea más en curso, sin tope ni responsable. Este artículo trata sobre dotar al sistema de 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 a la vez. Un servicio puede cancelar a la perfección y aun así colapsar porque diez mil goroutines, cada una de forma independiente, decidieron que aquel era un buen momento para llamar a la base de datos.

Las páginas circundantes 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 sujeto es el plano de capacidad — errgroup.SetLimit, canales semáforo, pools de workers y colas acotadas — más la forma de fuga que sobrevive a errgroup.Wait incluso cuando todas las rutas de cancelación parecen estar cableadas.
Cinco primitivas, una tabla de decisión
| Primitiva | Resultados parciales | Fail-fast | Backpressure | Trabajo dinámico | Ideal para |
|---|---|---|---|---|---|
sync.WaitGroup + errors.Join |
sí | no | no | no | lotes de espera total donde cuenta cada resultado |
errgroup.WithContext |
no | sí | no | no | fan-out donde el primer error termina la solicitud |
errgroup.SetLimit(n) |
no | sí | sí (bloquea Go) |
no | fan-out acotado con fail-fast |
| Canal semáforo | sí | manual | sí (bloquea adquisición) | sí | control de admisión personalizado |
| Pool de workers | sí | manual | sí (cola llena) | sí | flujos constantes, workers de larga vida |
Dos preguntas eligen la fila. Primera: cuando una tarea falla, ¿todavía quieres los resultados de las otras? Si la respuesta es sí, la semántica de grupo fail-fast descarta trabajo que necesitabas. Segunda: ¿llega el trabajo 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 de WaitGroup plano — recopila el error de cada worker en lugar de mantener solo el primero, lo cual se combina con las reglas de traducción de límites de 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 una brecha de cancelación y se esconde detrás de código que se ve 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("save failed")
}
}
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 de contexto no desbloquea una envío de canal. Nadie posee esa goroutine, por lo que permanece estacionada durante 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 brazo de ctx.Done |
501 (+500 fugadas, una por iteración) |
Envío envuelto en select con <-gctx.Done() |
502 (+1 de base) |
// la solución: cada operación de canal dentro de una tarea cancelable
// obtiene un brazo de Done
select {
case results <- i:
case <-gctx.Done():
return
}
La misma brecha 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 en ella o la cancela. goleak en CI captura los casos que la revisión de código pasa por alto — se integra 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 merecen ser nombradas. Si el productor es un miembro del grupo, el mismo código produce no una fuga sino un deadlock — Wait se bloquea para siempre esperando el envío estacionado, lo que al menos falla ruidosamente. Y un productor que envía con select pero recibe en un canal cuyo consumidor ha salido sufre la misma fuga; los brazos Done pertenecen en ambos lados.
errgroup.SetLimit: fan-out acotado con fail-fast
SetLimit convierte un errgroup en un grupo con control de admisión. Cada llamada a Go más allá del límite se bloquea hasta que se libere un espacio:
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 se bloquea, por lo que el bucle anterior aplica backpressure al productor — eso suele ser lo que quieres, pero significa que la goroutine del llamador puede atascarse, por lo que un manejador de solicitud que alimenta un lote grande necesita su propio presupuesto de timeout alrededor de todo el bucle. SetLimit(0) bloquea cada llamada a Go para siempre. Y cambiar el límite mientras hay goroutines activas provoca un pánico — el límite es 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 curso. Es la primitiva adecuada cuando fallar toda la operación ante el primer fallo es correcto — obtener una página de datos, llamar a un conjunto de chequeos de salud independientes, distribuir 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{}{} // se bloquea cuando 8 tareas están en curso
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 espacio. El tamaño del buffer es el contrato: es simultáneamente el tope de concurrencia y la cola de admisiones pendientes.
La primitiva gana su lugar cuando necesitas un control de admisión que los grupos no expresan — adquisición consciente del contexto, pools por downstream, o resultados parciales ante el fallo:
select {
case sem <- struct{}{}: // espacio adquirido
case <-ctx.Done(): // el llamador se rindió antes de entrar
return ctx.Err()
}
Medido con la misma carga de trabajo, el canal semáforo y SetLimit(8) terminan dentro de un 1.5% del uno del otro — la elección entre ellos es de semántica, no de rendimiento.
Pools de workers: para flujos, no para lotes
Un pool fijo de workers de larga vida separa la admisión (la cola) de la ejecución (los workers):
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 el trabajo que llega de forma continua — consumidores de colas, bucles de sondeo, workers de solicitud — donde un grupo por lote se crearía y destruiría sin fin. Desde Go 1.25, sync.WaitGroup.Go elimina la plantilla de boilerplate de la pareja Add/Done para la forma común. El contrato de apagado es la parte que hay que acertar: cerrar jobs termina a los workers tras vaciar la cola; cancelar ctx abandona el trabajo en cola. Ambas son legítimas, pero solo una puede ser el comportamiento documentado.
Colas acotadas: bloquear o descartar
Un canal con buffer es también una cola con un tope. Una vez que el buffer se llena, el productor se 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. Una línea de monitoreo puede descartar; una línea de pedidos 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)
}
Cualquiera sea la política, hazla explícita y medida. Una rama default silenciosa es donde van a desaparecer los eventos.
Lo que cuesta acotar — 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 real |
|---|---|---|
go sin límite por tarea |
~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 real porque nada en el experimento empuja hacia atrás — diez mil goroutines de temporizador simplemente duermen en paralelo. Ese es exactamente el truco. El costo del fan-out sin límite nunca es la CPU en tu propio proceso; es diez mil conexiones simultáneas, un downstream que empieza a fallar por timeout bajo la ráfaga, y un gráfico de memoria que sigue la profundidad de la cola de a quien estés llamando. Con una dependencia real, la ejecución sin límite no termina en 18 ms — sufre timeout. Las ejecuciones acotadas pagan 6.6 segundos porque 10,000 tareas ÷ 8 espacios × 5 ms es 6.25 s de serialización ineludible, y el tope es el punto: convierte una ráfaga descontrolada en un drenaje predecible de 6.25 segundos.
Un refinamiento importa cuando el tope es más alto que un puñado de espacios: mantén el tope en o por debajo de lo que el downstream puede sostener en concurrencia, y derívalo de ese límite (tamaño del pool de conexiones, límite de tasa, capacidad del worker) en lugar de una sensación sobre la “paralelismo” razonable.
Medir y probar código acotado
Tres señales cubren la mayor parte de esto. Las tendencias de conteo de goroutines (/sched/goroutines:goroutines exportadas vía el colector de Go de Prometheus) capturan fugas como una pendiente. La latencia del programador (/sched/latencies:seconds) captura la saturación — goroutines ejecutables esperando tiempo de CPU es presión real sin importar el conteo. Una delta pprof de dos volcados de goroutines señala dónde viven las goroutines estacionadas.
Para las pruebas, los workers acotados son el caso para el que Pruebas de Código Concurrente en Go con synctest fue construido: el tiempo falso hace que una ejecución completa de drenaje de 10,000 tareas corra en milisegundos, synctest.Wait reemplaza el dormir-y-esperar para la quietud, y una burbuja falla rápido cuando un worker permanece bloqueado duraderamente — la reproducción de 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 tasa y el circuit breaker protege al downstream — Patrón Circuit Breaker en Go cubre esa capa, y Microservicios en Go para Orquestación de IA/ML muestra las formas basadas en cola que toma la capa de orquestación.
Elección
en el primer fallo?} D -- sí --> E[WaitGroup + errors.Join
o canal semáforo] D -- no --> F[errgroup.WithContext] F --> G{¿Se necesita un tope de concurrencia?} G -- sí --> H[errgroup.SetLimit] G -- no --> I[grupo sin límite
solo cuando el lote sea 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 solo hábito: antes de escribir go, nombra el tope y el propietario. El tope es SetLimit, un semáforo o una cola acotada; el propietario es una Wait, un brazo de ctx.Done en cada operación de canal, o ambos. Cada sitio de creación de goroutines que pueda responder a esos dos nombres en un vistazo es uno que no llamará a nadie a las 3 de la mañana.