Worker threads en Node.js: cuándo sacar trabajo CPU-intensivo del event loop sin bloquear el resto del servicio
Node.js resuelve la concurrencia de I/O sin necesidad de threads explícitos. Cuando un handler espera una respuesta de base de datos, una llamada a otro servicio o una lectura de disco, async/await libera el event loop mientras esa operación está en curso, y el mismo hilo sigue atendiendo el resto de las requests entrantes. Eso cubre la inmensa mayoría de lo que hace un servicio HTTP típico, porque casi todo lo que hace ese servicio es esperar a algo externo. El problema aparece con el otro tipo de trabajo, el que no espera nada: un cálculo de hash con bcrypt, la generación de un PDF, el redimensionado de una imagen, el parseo de un JSON de varios megabytes. Ese código ocupa la CPU de forma continua mientras se ejecuta y no cede el control en ningún punto intermedio, así que mientras corre, el event loop entero queda bloqueado. Ninguna otra request se procesa hasta que termina, ni siquiera una tan simple como un health check.
Por qué async/await no resuelve esto
async/await es azúcar sintáctico sobre promesas, y una promesa no ejecuta nada en paralelo por sí sola. Si el trabajo dentro de una función async es síncrono y pesado, marcarla como async no cambia nada: el bloque sigue corriendo en el mismo hilo, de principio a fin, sin ceder el control al event loop en ningún punto intermedio.
// Esto NO libera el event loop mientras corre
app.post('/reportes', async (req, res) => {
const datos = await obtenerDatos(req.body.id); // esto sí es async de verdad
const pdf = generarPdfPesado(datos); // esto es CPU pura, síncrono
res.type('application/pdf').send(pdf);
});
obtenerDatos libera el hilo mientras espera la respuesta de la base de datos. generarPdfPesado no espera nada: calcula, dibuja, comprime, todo dentro del mismo call stack. Mientras corre esa función, cualquier otra request que llegue al servidor -incluida una que solo necesita el health check- queda en cola sin que nadie la atienda, porque el único hilo de JavaScript está ocupado con el PDF.
Aislar el trabajo con worker_threads
El módulo worker_threads, parte del runtime desde Node 10 y estable desde hace varias versiones, crea hilos reales de sistema operativo con su propio motor V8 y su propio event loop, aislados del hilo principal salvo por los canales de comunicación que se abren explícitamente. El trabajo pesado se mueve al worker, y el hilo principal queda libre para seguir atendiendo requests mientras el cálculo corre en paralelo.
// worker-pdf.js
const { parentPort, workerData } = require('worker_threads');
const pdf = generarPdfPesado(workerData.datos);
parentPort.postMessage(pdf, [pdf.buffer]); // transferable: sin copiar el buffer
// main.js
const { Worker } = require('worker_threads');
function generarPdfEnWorker(datos) {
return new Promise((resolve, reject) => {
const worker = new Worker('./worker-pdf.js', { workerData: { datos } });
worker.once('message', resolve);
worker.once('error', reject);
worker.once('exit', (code) => {
if (code !== 0) reject(new Error(`worker terminó con código ${code}`));
});
});
}
Dos detalles importan más que el resto del código. El primero es cómo viajan los datos entre hilos: por defecto, postMessage usa el algoritmo de structured clone, que copia el valor completo de un hilo a otro. Para un objeto pequeño eso no se nota, pero para un buffer de varios megabytes la copia es un costo real, duplicado además porque hay que copiar tanto al enviar los datos de entrada como al devolver el resultado. La alternativa es pasar el segundo argumento de postMessage con la lista de objetos transferibles -típicamente ArrayBuffer-, que mueve la propiedad del buffer al otro hilo en vez de copiarlo. El hilo que transfiere pierde acceso a ese buffer; el que lo recibe lo obtiene sin copia.
El segundo detalle es que un Worker no es gratis de crear. Levantar un nuevo contexto V8 con su propio heap tiene un costo de arranque que, aunque pequeño en términos absolutos, deja de ser despreciable si se paga en cada request. La estrategia que usamos en producción no es crear un worker por request, sino mantener un pool fijo de workers vivos y una cola de tareas que se reparte entre ellos:
// pool.js
const { Worker } = require('worker_threads');
const os = require('os');
class WorkerPool {
constructor(script, size = os.availableParallelism() - 1) {
this.script = script;
this.libres = Array.from({ length: size }, () => new Worker(script));
this.cola = [];
}
ejecutar(datos) {
return new Promise((resolve, reject) => {
this.cola.push({ datos, resolve, reject });
this._procesar();
});
}
_procesar() {
if (this.cola.length === 0 || this.libres.length === 0) return;
const worker = this.libres.pop();
const { datos, resolve, reject } = this.cola.shift();
const onMessage = (resultado) => {
limpiar();
this.libres.push(worker);
resolve(resultado);
this._procesar();
};
const onError = (err) => {
limpiar();
// el worker que falló no vuelve al pool: se reemplaza
this.libres.push(new Worker(this.script));
reject(err);
this._procesar();
};
const limpiar = () => {
worker.off('message', onMessage);
worker.off('error', onError);
};
worker.once('message', onMessage);
worker.once('error', onError);
worker.postMessage(datos);
}
}
El tamaño del pool lo fijamos en availableParallelism() - 1, dejando un núcleo libre para el hilo principal y el resto de procesos del sistema; en máquinas con pocos núcleos ese margen importa más que en una con muchos.
Lo que no funcionó: un worker por request
La primera versión de este servicio no usaba pool: cada request de generación de PDF creaba su propio Worker, lo usaba una vez y lo descartaba. Funcionaba bien en pruebas con tráfico bajo, donde las requests llegaban espaciadas. El problema apareció con ráfagas de tráfico real, cuando varias requests de generación de PDF llegaban casi al mismo tiempo: cada una disparaba la creación de un contexto V8 nuevo, y esa creación compite por CPU con los workers que ya estaban corriendo, además de con el propio hilo principal. El resultado no era un error visible, era latencia que crecía de forma desproporcionada justo en los picos, que es el peor momento posible para que un servicio se ponga lento. Cambiar a un pool de tamaño fijo con cola no solo evitó el costo repetido de creación, también acotó la cantidad de trabajo CPU-bound corriendo en paralelo a un número conocido, en vez de dejar que creciera sin límite con el tráfico entrante.
Cuándo worker_threads no es la herramienta correcta
worker_threads comparte proceso con el hilo principal: mismo espacio de memoria del sistema operativo, mismo límite de memoria del proceso, y si un worker entra en un bucle infinito o consume memoria sin límite, puede degradar el proceso completo aunque los hilos estén técnicamente aislados en V8. Cuando el trabajo pesado necesita aislamiento real -memoria separada, la posibilidad de matar el trabajo sin afectar al proceso padre, o directamente invocar un binario o un intérprete distinto, como un script de Python para un modelo de machine learning-, la herramienta correcta es child_process, no worker_threads. Un proceso hijo tiene su propio espacio de memoria del sistema operativo; si se cuelga o se queda sin memoria, el proceso padre sigue vivo y puede simplemente matarlo y relanzarlo.
La otra distinción que conviene tener clara es frente al módulo cluster. cluster reparte las requests entrantes entre varios procesos Node para aprovechar múltiples núcleos a nivel de todo el servidor, pero cada proceso sigue teniendo su propio event loop de un solo hilo, así que una tarea CPU-bound dentro de un proceso de cluster lo sigue bloqueando mientras corre. cluster escala el servicio completo; worker_threads aísla una operación pesada puntual dentro de un servicio que, en general, sigue siendo I/O-bound. Los dos se combinan sin conflicto: varios procesos de cluster atendiendo tráfico, cada uno con su propio pool de workers para la porción de trabajo que de verdad ocupa CPU.
El criterio que aplicamos
Antes de mover algo a un worker nos preguntamos si el bloqueo es real: si la tarea tarda unos pocos milisegundos, el costo de serializar los datos, crear o reutilizar un worker y esperar el mensaje de vuelta puede superar el beneficio de no bloquear el hilo principal. Worker threads tiene sentido cuando el cálculo síncrono dura lo suficiente como para que otras requests noten la espera, y cuando el resultado se puede pasar de un hilo a otro sin necesidad de compartir estado mutable en tiempo real. Si el trabajo necesita aislamiento de memoria, puede fallar de forma catastrófica, o corre en un runtime distinto al de Node, el aislamiento correcto es un proceso separado, no un hilo dentro del mismo proceso.