Un Worker Pool es un patrón de concurrencia donde un conjunto fijo de goroutines (trabajadores o workers) consume tareas desde un canal de entrada (jobs) y envía los resultados a un canal de salida (results). A diferencia de simplemente lanzar una goroutine por cada tarea —lo cual puede saturar la memoria o los límites del sistema operativo—, el pool limita la cantidad de ejecución en paralelo.
Este diseño funciona porque los canales actúan como colas sincronizadas que gestionan el flujo de datos entre el dispatcher (quien envía el trabajo) y los workers. Al usar un for job := range jobs dentro de cada worker, delegamos la gestión del ciclo de vida al cierre del canal: cuando el dispatcher llama a close(jobs), todos los workers terminan su ejecución de forma elegante al salir del bucle de iteración.
Debes usar este patrón cuando tengas una carga de trabajo masiva y necesites aplicar backpressure o limitar el uso de recursos (como conexiones a una base de datos o ciclos de CPU). Si el tamaño del pool es muy pequeño, subutilizarás el hardware; si es demasiado grande, el costo de scheduling del runtime de Go y la contención de memoria/cache degradará el rendimiento. Para tareas CPU-bound (cálculos pesados), el número óptimo suele ser cercano al número de núcleos lógicos disponibles (runtime.GOMAXPROCS). Para tareas I/O-bound (peticiones HTTP, lectura de disco), puedes permitirte un número de workers mucho mayor para compensar los tiempos de espera.
Si implementas mal el flujo, podrías causar un deadlock (bloqueo mutuo) si los workers se quedan bloqueados intentando enviar resultados a un canal que nadie está leyendo, o un goroutine leak si dejas workers esperando en un canal que nunca se cierra.
package main
import (
"fmt"
"time"
)
// Job representa la unidad de trabajo.
type Job struct {
ID int
Value int
}
// Result representa el resultado de procesar un Job.
type Result struct {
JobID int
Data int
}
// worker es la función que ejecuta cada goroutine del pool.
func worker(id int, jobs <-chan Job, results chan<- Result) {
// El range sobre un channel es la forma idiomática de consumir hasta que se cierre.
for job := range jobs {
fmt.Printf("Worker %d: procesando tarea %d\n", id, job.ID)
// Simulamos un tiempo de procesamiento (I/O o CPU).
time.Sleep(time.Millisecond * 100)
results <- Result{
JobID: job.ID,
Data: job.Value * 2,
}
}
}
func main() {
const numJobs = 10
const numWorkers = 3
// Creamos canales con buffer para evitar bloqueos inmediatos.
jobs := make(chan Job, 100)
results := make(chan Result, 100)
// 1. Lanzamos el pool de workers.
for w := 1; w <= numWorkers; w++ {
go worker(w, jobs, results)
}
// 2. El Dispatcher: envía trabajos en una goroutine independiente.
go func() {
for i := 1; i <= numJobs; i++ {
jobs <- Job{ID: i, Value: i * 10}
}
// Es vital cerrar el canal para que los workers salgan del range.
close(jobs)
}()
// 3. El Aggregator: recolecta los resultados en el hilo principal.
for a := 1; a <= numJobs; a++ {
res := <-results
fmt.Printf("Resultado recibido: Tarea %d -> %d\n", res.JobID, res.Data)
}
}
Desglose del ejemplo
En el código anterior, hemos implementado un flujo de tres componentes: el dispatcher, el pool y el aggregator.
Observa cómo el worker utiliza for job := range jobs. Esto es fundamental: el bucle no se detiene cuando el canal está vacío, sino solo cuando el canal es cerrado explícitamente con close(jobs) y no hay más elementos pendientes. Si no llamáramos a close(jobs) en la goroutine del dispatcher, las tres goroutines de los workers se quedarían bloqueadas permanentemente esperando trabajo, provocando un leak de memoria.
El canal results se utiliza para enviar la salida de vuelta al hilo principal. En el main, el aggregator es el bucle for a := 1; a <= numJobs; a++. Este bucle es el que mantiene viva la ejecución del programa; si no leyéramos exactamente numJobs resultados, el programa podría terminar antes de tiempo o dejar workers bloqueados intentando enviar datos.
Finalmente, fíjate en la definición de los canales. Al usar make(chan Job, 100), estamos creando canales con buffer. Esto permite que el dispatcher envíe ráfagas de trabajo sin quedarse bloqueado inmediatamente si los workers están ocupados, suavizando los picos de carga.
El error frecuente
Un error clásico en este patrón ocurre cuando el canal de resultados (results) no tiene suficiente capacidad o no se consume simultáneamente, provocando un deadlock.
// ERROR: Deadlock potencial
results := make(chan Result) // Canal sin buffer
// Si lanzamos workers que envían a 'results' y el 'main' no está
// listo para recibir, los workers se bloquean en la línea:
// results <- Result{...}
// Si el dispatcher también se bloquea al intentar enviar a 'jobs'
// porque los workers están trabados en 'results', el programa colapsa.
Si el canal results no es lo suficientemente grande para albergar todos los resultados o si el consumidor (aggregator) solo empieza a leer cuando los workers ya terminaron, los workers se quedarán bloqueados intentando escribir en un canal sin receptor. Siempre, en producción, utiliza herramientas como pprof para detectar goroutines que se quedan bloqueadas esperando en canales que nunca se cierran.
N° 138