11 oct 2025·8 min de lectura

Worker pools en Go vs goroutine por tarea para trabajos en segundo plano

Worker pools en Go vs una goroutine por tarea: aprende cómo cada modelo afecta throughput, uso de memoria y backpressure para procesamiento en segundo plano y workflows de larga duración.

Worker pools en Go vs goroutine por tarea para trabajos en segundo plano

¿Qué problema resolvemos?

La mayoría de servicios en Go hacen más que responder HTTP. También ejecutan trabajo en segundo plano: enviar correos, redimensionar imágenes, generar facturas, sincronizar datos, procesar eventos o reconstruir un índice de búsqueda. Algunos jobs son rápidos e independientes. Otros forman workflows largos donde cada paso depende del anterior (cobrar una tarjeta, esperar confirmación, luego notificar al cliente y actualizar reportes).

Cuando la gente compara "Go worker pools vs goroutine-per-task", normalmente intenta resolver un problema de producción: cómo ejecutar mucho trabajo en segundo plano sin que el servicio se vuelva lento, caro o inestable.

Se nota el impacto en varios frentes:

  • Latencia: el trabajo en segundo plano quita CPU, memoria, conexiones a la base de datos y ancho de banda de red a las peticiones orientadas al usuario.
  • Coste: la concurrencia sin control te empuja a máquinas más grandes, más capacidad de BD o facturas más altas por colas y APIs.
  • Estabilidad: ráfagas (importaciones, envíos de marketing, tormentas de reintentos) pueden disparar timeouts, crashes por OOM o fallos en cascada.

El verdadero tradeoff es sencillez vs control. Lanzar una goroutine por tarea es fácil de escribir y suele funcionar cuando el volumen es bajo o está naturalmente limitado. Un worker pool añade estructura: concurrencia fija, límites más claros y un lugar natural para poner timeouts, reintentos y métricas. El coste es código extra y decidir qué ocurre cuando el sistema está ocupado (¿las tareas esperan, se rechazan o se almacenan en otro sitio?).

Esto trata sobre el día a día del procesamiento en segundo plano: throughput, memoria y backpressure (cómo evitar la sobrecarga). No pretende cubrir todas las tecnologías de colas, motores de workflows distribuidos o semánticas de exactly-once.

Si construyes apps completas con lógica en segundo plano usando una plataforma como AppMaster, las mismas preguntas aparecen rápido. Tus procesos de negocio e integraciones siguen necesitando límites alrededor de BD, APIs externas y proveedores de email/SMS para que un workflow ocupado no ralentice todo lo demás.

Dos patrones comunes en términos sencillos

Goroutine por tarea

Es el enfoque más simple: cuando llega un job, arrancas una goroutine para procesarlo. La “cola” suele ser lo que desencadena el trabajo, como recibir en un channel o una llamada directa desde un handler HTTP.

La forma típica es: recibe un job y luego go handle(job). A veces todavía hay un channel, pero solo como punto de transferencia, no como limitador.

Suele funcionar bien cuando las tareas pasan la mayor parte del tiempo esperando I/O (llamadas HTTP, consultas a BD, uploads), el volumen es moderado y las ráfagas son pequeñas o previsibles.

La desventaja es que la concurrencia puede crecer sin un tope claro. Eso puede disparar la memoria, abrir demasiadas conexiones o sobrecargar un servicio downstream.

Worker pool

Un worker pool arranca un número fijo de goroutines worker y les da jobs desde una cola, normalmente un channel en memoria con buffer. Cada worker hace un loop: toma un job, lo procesa, repite.

La diferencia clave es el control. El número de workers es un límite de concurrencia. Si los jobs llegan más rápido de lo que los workers los procesan, los jobs esperan en la cola (o se rechazan si la cola está llena).

Los worker pools encajan bien cuando el trabajo es intensivo en CPU (procesar imágenes, generar reportes), cuando necesitas uso de recursos predecible o cuando debes proteger una BD o una API de terceros de ráfagas.

Dónde vive la cola

Ambos patrones pueden usar un channel en memoria, que es rápido pero desaparece al reiniciar. Para jobs que “no deben perderse” o workflows largos, la cola suele moverse fuera del proceso (una tabla en BD, Redis o un broker de mensajes). En ese escenario sigues eligiendo entre goroutine-per-task y worker pools, pero ahora actúan como consumidores de la cola externa.

Como ejemplo simple: si el sistema de repente necesita enviar 10.000 correos, goroutine-per-task puede intentar enviarlos todos a la vez. Un pool puede enviar 50 a la vez y mantener el resto esperando de forma controlada.

Throughput: qué cambia y qué no

Es común esperar una gran diferencia de throughput entre worker pools y goroutine-per-task. Casi siempre, el throughput bruto está limitado por otra cosa, no por cómo arrancas goroutines.

El throughput suele tocar techo en el recurso compartido más lento: la base de datos o límites de la API externa, ancho de banda de disco o red, trabajo intensivo en CPU (JSON/PDF/redimensionado de imágenes), locks y estado compartido, o servicios downstream que se degradan bajo carga.

Si un recurso compartido es el cuello de botella, lanzar más goroutines no acelera el trabajo. Crea más espera en ese punto de estrangulamiento.

Goroutine-per-task puede ganar cuando las tareas son cortas, mayormente I/O y no compiten por límites compartidos. Arrancar goroutines es barato, y Go programa bien grandes cantidades de ellas. En un patrón de “fetch, parse, write one row”, esto puede mantener las CPUs ocupadas y esconder latencia de red.

Los worker pools ganan cuando necesitas acotar recursos caros. Si cada job mantiene una conexión a BD, abre archivos, asigna buffers grandes o consume cuota de una API, la concurrencia fija mantiene el servicio estable mientras alcanzas el throughput seguro máximo.

La latencia (especialmente p99) es donde suele notarse la diferencia. Goroutine-per-task puede verse bien con poca carga y luego desplomarse cuando demasiadas tareas se acumulan. Los pools introducen demora por cola (jobs esperando por un worker libre), pero el comportamiento es más estable porque evitas una estampida peleando por el mismo límite.

Un modelo mental simple:

  • Si el trabajo es barato e independiente, más concurrencia puede aumentar el throughput.
  • Si el trabajo está limitado por un tope compartido, más concurrencia sobre todo aumenta la espera.
  • Si te importa el p99, mide el tiempo en cola por separado del tiempo de procesamiento.

Uso de memoria y recursos

Gran parte del debate se trata en realidad de memoria. La CPU a menudo puede escalarse hacia arriba o hacia fuera. Las fallas por memoria son más repentinas y pueden tumbar todo el servicio.

Una goroutine es barata, pero no gratis. Cada una arranca con una pila pequeña que crece al llamar funciones más profundas o al retener variables locales grandes. También hay bookkeeping del scheduler y runtime. Diez mil goroutines pueden estar bien. Cien mil pueden sorprender si cada una mantiene referencias a datos grandes.

El costo oculto mayor suele ser lo que la goroutine mantiene vivo. Si las tareas llegan más rápido de lo que terminan, goroutine-per-task crea un backlog sin límite. La “cola” puede ser implícita (goroutines esperando locks o I/O) o explícita (un channel buffer, un slice, un batch en memoria). De cualquier forma, la memoria crece con el backlog.

Los worker pools ayudan porque fuerzan un tope. Con workers fijos y una cola acotada tienes un límite real de memoria y un modo de fallo claro: una vez que la cola está llena, bloqueas, descargas carga o haces push upstream.

Regla rápida para estimar:

  • Goroutines pico = workers + jobs en vuelo + jobs “en espera” que creaste
  • Memoria por job = payload (bytes) + metadatos + cualquier cosa referenciada (requests, JSON decodificado, filas de BD)
  • Memoria de backlog pico ~= jobs en espera * memoria por job

Ejemplo: si cada job tiene un payload de 200 KB (o referencia un objeto de 200 KB) y permites que 5.000 jobs se acumulen, son ~1 GB solo en payloads. Aunque las goroutines fuesen mágicamente gratis, el backlog no lo es.

Backpressure: evitar que el sistema se funda

Orchestrate long workflows
Mapea jobs en varios pasos como exports y notificaciones usando procesos de negocio drag-and-drop.
Try the BP Editor

Backpressure es simple: cuando el trabajo llega más rápido de lo que puedes terminarlo, el sistema responde de forma controlada en vez de acumular silenciosamente. Sin eso, no solo te vuelves más lento. Obtienes timeouts, crecimiento de memoria y fallos difíciles de reproducir.

Sueles notar la falta de backpressure cuando una ráfaga (imports, correos, exports) produce patrones como memoria que sube y no baja, tiempo en cola creciendo mientras la CPU está ocupada, picos de latencia en peticiones no relacionadas, reintentos acumulándose o errores como “too many open files” y agotamiento del pool de conexiones.

Una herramienta práctica es un channel acotado: limita cuántos jobs pueden esperar. Los productores bloquean cuando el channel está lleno, lo que ralentiza la creación de jobs en la fuente.

Bloquear no siempre es la elección correcta. Para trabajo opcional, elige una política explícita para que la sobrecarga sea predecible:

  • Descartar tareas de bajo valor (por ejemplo, notificaciones duplicadas)
  • Agrupar muchas tareas pequeñas en una sola escritura o llamada API
  • Retrasar trabajo con jitter para evitar picos de reintentos
  • Delegar a una cola persistente y devolver rápido
  • Descargar carga con un error claro cuando ya estás sobrecargado

Rate limiting y timeouts también son herramientas de backpressure. El rate limiting limita la velocidad a la que impactas una dependencia (proveedor de email, BD, API externa). Los timeouts limitan cuánto puede quedarse un worker atascado. Juntos, evitan que una dependencia lenta se convierta en un outage total.

Ejemplo: generación de extractos de fin de mes. Si 10.000 solicitudes llegan a la vez, goroutines ilimitadas pueden disparar 10.000 renders de PDF y uploads. Con una cola acotada y workers fijos, renderizas y reintentas a un ritmo seguro.

Cómo construir un worker pool paso a paso

Keep control with source
Obtén código fuente real que puedas revisar, poseer y ejecutar donde lo necesites.
Generate Code

Un worker pool acota la concurrencia ejecutando un número fijo de workers y alimentándolos desde una cola.

1) Elige un límite de concurrencia seguro

Empieza por en qué pasan tiempo tus jobs.

  • Para trabajo intensivo en CPU, mantén workers cerca del conteo de núcleos.
  • Para trabajo I/O (BD, HTTP, almacenamiento), puedes subir, pero para cuando las dependencias empiezan a hacer timeouts o aplicar throttling, para de subir.
  • Para trabajo mixto, mide y ajusta. Un rango inicial razonable suele ser 2x a 10x los cores de CPU, luego afina.
  • Respeta límites compartidos. Si el pool de BD tiene 20 conexiones, 200 workers solo pelearán por esas 20.

2) Elige la cola y su tamaño

Un channel con buffer es común porque está integrado y es fácil de razonar. El buffer es tu amortiguador para ráfagas.

Buffers pequeños detectan sobrecarga pronto (los productores bloquean antes). Buffers grandes suavizan picos pero pueden ocultar problemas e incrementar memoria y latencia. Dimensiona el buffer a propósito y decide qué pasa cuando se llena.

3) Haz cada tarea cancelable

Pasa un context.Context a cada job y asegúrate de que el código del job lo use (DB, HTTP). Así cierras limpiamente en deploys, shutdowns y timeouts.

func StartPool(ctx context.Context, workers, queueSize int, handle func(context.Context, Job) error) chan\u003c- Job {
    jobs := make(chan Job, queueSize)
    for i := 0; i \u003c workers; i++ {
        go func() {
            for {
                select {
                case \u003c-ctx.Done():
                    return
                case j := \u003c-jobs:
                    _ = handle(ctx, j)
                }
            }
        }()
    }
    return jobs
}

4) Añade las métricas que realmente usarás

Si solo rastreas unos cuantos números, que sean estos:

  • Profundidad de la cola (qué tan atrasado estás)
  • Tiempo ocupado de los workers (qué tan saturado está el pool)
  • Duración de tareas (p50, p95, p99)
  • Tasa de errores (y conteos de reintentos si aplicas reintentos)

Eso basta para afinar número de workers y tamaño de cola con evidencia, no con suposiciones.

Errores comunes y trampas

La mayoría de equipos no sufren por elegir el patrón “equivocado”. Sufren por pequeñas decisiones por defecto que se convierten en outages cuando el tráfico estalla.

Cuando las goroutines se multiplican

La trampa clásica es arrancar una goroutine por job durante una ráfaga. Unos cientos están bien. Cientos de miles pueden inundar el scheduler, el heap, los logs y los sockets de red. Aunque cada goroutine sea pequeña, el costo total suma y la recuperación toma tiempo porque el trabajo ya está en vuelo.

Otro error es tratar un channel muy grande como “backpressure”. Un buffer grande es solo una cola oculta. Puede comprar tiempo, pero también oculta problemas hasta que chocas contra un muro de memoria. Si necesitas una cola, ponle un tamaño deliberado y decide qué pasa cuando se llena (bloquear, descartar, reintentar más tarde o persistir en almacenamiento).

Cuellos de botella ocultos

Muchos jobs en segundo plano no están limitados por CPU. Están limitados por algo downstream. Si ignoras esos límites, un productor rápido aplasta a un consumidor lento.

Trampas comunes:

  • No hay cancelación ni timeout, así que workers pueden quedarse bloqueados para siempre en una petición a una API o una consulta a BD
  • Conteos de workers elegidos sin verificar límites reales como conexiones a BD, I/O de disco o límites de terceros
  • Reintentos que amplifican carga (reintentos inmediatos sobre 1.000 jobs fallidos)
  • Un lock compartido o una única transacción que serialize todo, de modo que “más workers” solo añade overhead
  • Falta de visibilidad: sin métricas para profundidad de cola, edad del job, conteo de reintentos y utilización de workers

Ejemplo: una exportación nocturna dispara 20.000 tareas de “enviar notificación”. Si cada tarea golpea tu BD y un proveedor de email, es fácil exceder pools de conexión o cuotas. Un pool de 50 workers con timeouts por tarea y una cola pequeña hace el límite obvio. Una goroutine por tarea más un buffer gigante hace que el sistema parezca bien hasta que deja de estarlo.

Ejemplo: exports y notificaciones con ráfagas

Turn data into automation
Diseña tus datos con modelos PostgreSQL-first y mantiene la lógica de los jobs cerca de ellos.
Create Backend

Imagina un equipo de soporte que necesita datos para una auditoría. Una persona hace clic en "Export", unos cuantos compañeros hacen lo mismo y de repente tienes 5.000 jobs de export creados en un minuto. Cada export lee de la BD, formatea un CSV, almacena un archivo y envía una notificación (email o Telegram) cuando está listo.

Con goroutine-per-task, el sistema se siente bien por un momento. Las 5.000 tareas arrancan casi al instante y parece que la cola se vacía rápido. Luego aparecen los costes: miles de consultas concurrentes compiten por conexiones, la memoria sube porque jobs mantienen buffers a la vez y aparecen timeouts. Jobs que podrían terminar rápido quedan atascados detrás de reintentos y consultas lentas.

Con un worker pool, el inicio es más lento pero el proceso es más tranquilo. Con 50 workers, solo 50 exports hacen trabajo pesado a la vez. El uso de BD se mantiene en un rango predecible, los buffers se reutilizan más y la latencia es más estable. El tiempo total de finalización también es más fácil de estimar: aproximadamente (jobs / workers) * duración media de job, más algo de overhead.

La diferencia clave no es que los pools sean mágicamente más rápidos. Es que impiden que el sistema se haga daño durante las ráfagas. Un proceso controlado de 50 a la vez suele terminar antes que 5.000 jobs compitiendo entre sí.

Dónde aplicar backpressure depende de qué quieras proteger:

  • En la capa API, rechaza o retrasa nuevas requests de export cuando el sistema está ocupado.
  • En la cola, acepta requests pero encola jobs y los drena a una velocidad segura.
  • En el worker pool, limita la concurrencia para las partes caras (lecturas BD, generación de archivos, envío de notificaciones).
  • Por recurso, divide en límites separados (por ejemplo, 40 workers para exports pero solo 10 para notificaciones).
  • En llamadas externas, aplica rate limiting a email/SMS/Telegram para no ser bloqueado.

Lista rápida antes de lanzar

Make backpressure visible
Añade herramientas de administración y controles de jobs junto a tu API para que ops vea qué está pasando.
Build an App

Antes de ejecutar jobs en producción, revisa límites, visibilidad y manejo de fallos. La mayoría de incidentes no vienen de “código lento”. Vienen de la ausencia de guardrails cuando la carga sube o una dependencia falla.

  • Fija máxima concurrencia por dependencia. No pongas un número global esperando que encaje todo. Limita escrituras a BD, llamadas HTTP salientes y trabajo intensivo en CPU por separado.
  • Haz la cola acotada y observable. Pon un límite real en jobs pendientes y expón métricas: profundidad de cola, edad del job más antiguo y tasa de procesamiento.
  • Añade reintentos con jitter y una dead-letter. Reintenta selectivamente, distribuye los reintentos y tras N fallos mueve el job a una dead-letter o tabla de “failed” con detalle suficiente para revisar y reejecutar.
  • Verifica comportamiento en shutdown: drenar, cancelar, reanudar con seguridad. Decide qué pasa en deploy o crash. Haz jobs idempotentes para que reprocesar sea seguro y guarda progreso para workflows largos.
  • Protege con timeouts y circuit breakers. Cada llamada externa necesita timeout. Si una dependencia está caída, falla rápido (o pausa la entrada) en vez de acumular trabajo.

Pasos prácticos siguientes

Elige el patrón que encaje con cómo se ve tu sistema en un día normal, no en un día perfecto. Si el trabajo llega en ráfagas (uploads, exports, blasts de email), un worker pool fijo con cola acotada suele ser la opción más segura por defecto. Si el trabajo es estable y cada tarea es pequeña, goroutine-per-task puede valer, siempre que aún apliques límites en algún sitio.

La elección ganadora suele ser la que hace que el fallo sea aburrido. Los pools hacen los límites evidentes. Goroutine-per-task hace fácil olvidar límites hasta el primer pico serio.

Empieza simple y añade límites y visibilidad

Empieza con algo sencillo, pero añade dos controles pronto: un tope en la concurrencia y una forma de ver colas y fallos.

Un plan de despliegue práctico:

  • Define la forma de tu carga: ráfagas, estable o mixta (y cómo se ve el “peak”).
  • Pon un tope en trabajo en vuelo (tamaño del pool, semáforo o channel acotado).
  • Decide qué pasa cuando se alcanza el tope: bloquear, descartar o devolver un error claro.
  • Añade métricas básicas: profundidad de cola, tiempo en cola, tiempo de procesamiento, reintentos y dead letters.
  • Prueba con una ráfaga 5x tu pico esperado y observa memoria y latencia.

Cuando un pool no es suficiente

Si los workflows duran minutos o días, un pool simple puede quedarse corto porque el trabajo no es solo “hacerlo una vez”. Necesitas estado, reintentos y reanudabilidad. Eso suele significar persistir progreso, usar pasos idempotentes y aplicar backoff. También puede significar dividir un job grande en pasos más pequeños para poder reanudar con seguridad tras un crash.

Si quieres entregar un backend completo con workflows más rápido, AppMaster (appmaster.io) puede ser una opción práctica: modelas datos y lógica visualmente y genera código Go real para el backend, de modo que mantienes la disciplina alrededor de límites de concurrencia, colas y backpressure sin cablear todo a mano.

FAQ

When should I use a worker pool instead of starting a goroutine for every task?

Default a un pool de trabajadores cuando los jobs puedan llegar en ráfagas o toquen límites compartidos como conexiones a DB, CPU o cuotas de API externas. Usa una goroutine por tarea cuando el volumen sea moderado, las tareas sean cortas y aún tengas algún límite claro en algún lugar (por ejemplo, un semáforo o limitador de tasa).

What’s the real tradeoff between goroutine-per-task and a worker pool?

Arrancar una goroutine por tarea es rápido de escribir y puede ofrecer gran throughput con baja carga, pero puede crear un backlog sin límite durante picos. Un pool de trabajadores añade un tope de concurrencia y un lugar claro para aplicar timeouts, reintentos y métricas, lo que suele hacer el comportamiento en producción más predecible.

Will a worker pool reduce throughput compared to goroutine-per-task?

Normalmente no mucha diferencia. En la mayoría de sistemas, el throughput está limitado por un cuello de botella compartido como la base de datos, una API externa, I/O de disco o pasos pesados de CPU. Más goroutines no superan ese límite; más bien aumentan la espera y la contención.

How do these patterns affect latency (especially p99)?

Goroutine-per-task suele tener mejor latencia con poca carga, pero puede empeorar mucho con alta carga porque todo compite al mismo tiempo. Un pool puede añadir retraso por cola, pero tiende a mantener el p99 más estable al evitar una estampida sobre las mismas dependencias.

Why can goroutine-per-task cause memory spikes?

El costo mayor no suele ser la goroutine en sí, sino el backlog. Si las tareas se acumulan y cada una mantiene payloads o estructuras grandes, la memoria puede subir rápidamente. Un pool con cola acotada convierte eso en un techo de memoria definido y un comportamiento de sobrecarga predecible.

What is backpressure, and how do I add it in Go?

Backpressure significa que ralentizas o dejas de aceptar trabajo cuando el sistema ya está ocupado en lugar de dejar que el trabajo se acumule de forma invisible. Una cola acotada es una forma simple: cuando está llena, los productores bloquean o devuelves un error, lo que evita consumo descontrolado de memoria y agotamiento de conexiones.

How do I choose the right number of workers?

Parte de empezar por el límite real. Para jobs pesados en CPU, comienza cerca del número de núcleos de CPU. Para trabajos I/O, puedes ir más alto, pero deja de subir cuando la base de datos, la red o APIs externas empiecen a hacer timeouts o a aplicar throttling, y respeta tamaños de pools de conexiones.

How big should the job queue/buffer be?

Elige un tamaño que absorba picos normales pero que no oculte problemas durante minutos. Buffers pequeños exponen sobrecarga rápido; buffers grandes aumentan uso de memoria y hacen que los usuarios esperen más antes de ver fallos. Decide de antemano qué pasa cuando la cola está llena: bloquear, rechazar, descartar o persistir en otro lugar.

How do I prevent workers from getting stuck forever?

Usa context.Context por job y asegúrate de que las llamadas a DB y HTTP lo respeten. Pon timeouts en llamadas externas y define un comportamiento de apagado claro para que los workers puedan parar limpiamente sin dejar goroutines colgadas o trabajo a medias.

What metrics should I monitor for background jobs?

Mide profundidad de cola, tiempo en cola, duración de la tarea (p50/p95/p99) y recuentos de error/reintentos. Esas métricas te indican si necesitas más workers, una cola más pequeña, timeouts más estrictos o rate limiting más fuerte contra una dependencia.

Fácil de empezar
Crea algo sorprendente

Experimente con AppMaster con plan gratuito.
Cuando esté listo, puede elegir la suscripción adecuada.

Empieza