Node.js avanzado: child_process, worker_threads, Cluster y estrategias de escalado

Node.js es monohilo por diseño, pero eso no significa que esté limitado a un único procesador o proceso. El módulo child_process, los worker_threads y el módulo cluster ofrecen mecanismos distintos para escalar según el tipo de carga: tareas CPU-intensivas, procesos externos o distribución de conexiones de red.

child_process: lanzar procesos externos

El módulo child_process permite ejecutar comandos del sistema, otros scripts Node o cualquier ejecutable externo. Las tres funciones principales son exec, spawn y fork:

import { exec, spawn, fork } from 'child_process';
import { promisify } from 'util';

// exec: para comandos cortos que devuelven texto (buffera la salida)
const execAsync = promisify(exec);

async function obtenerDiskUsage() {
  const { stdout, stderr } = await execAsync('df -h /');
  if (stderr) throw new Error(stderr);
  return stdout;
}

// spawn: para procesos de larga duración o salida grande (streaming)
function ejecutarFFmpeg(entrada, salida) {
  return new Promise((resolve, reject) => {
    const proceso = spawn('ffmpeg', [
      '-i', entrada,
      '-c:v', 'libx264',
      '-preset', 'fast',
      salida,
    ]);

    proceso.stderr.on('data', d => process.stderr.write(d));
    proceso.on('close', code => {
      if (code === 0) resolve(salida);
      else reject(new Error(`FFmpeg salió con código ${code}`));
    });
  });
}

// fork: especializado para otros scripts Node.js (incluye canal IPC)
const hijo = fork('./worker.js');

hijo.send({ tarea: 'procesar', datos: [1, 2, 3] });

hijo.on('message', (msg) => {
  console.log('Resultado del hijo:', msg.resultado);
});

hijo.on('exit', (code) => {
  console.log(`Hijo terminó con código ${code}`);
});

worker_threads: cálculos CPU-intensivos

Los worker threads son hilos de JavaScript que comparten memoria con el hilo principal a través de SharedArrayBuffer. A diferencia de child_process, tienen menor overhead y pueden compartir datos sin serialización:

// main.js
import { Worker, isMainThread, parentPort, workerData } from 'worker_threads';

if (isMainThread) {
  // Ejecutar cálculo pesado en un worker
  function calcularEnWorker(datos) {
    return new Promise((resolve, reject) => {
      const worker = new Worker(import.meta.url, {
        workerData: datos,
      });
      worker.on('message', resolve);
      worker.on('error', reject);
      worker.on('exit', code => {
        if (code !== 0) reject(new Error(`Worker terminó con código ${code}`));
      });
    });
  }

  console.time('paralelo');
  const resultados = await Promise.all([
    calcularEnWorker({ inicio: 0, fin: 5_000_000 }),
    calcularEnWorker({ inicio: 5_000_000, fin: 10_000_000 }),
  ]);
  console.timeEnd('paralelo');
  console.log('Total:', resultados.reduce((a, b) => a + b, 0));

} else {
  // Código del worker
  const { inicio, fin } = workerData;
  let suma = 0;
  for (let i = inicio; i < fin; i++) suma += i;
  parentPort.postMessage(suma);
}

SharedArrayBuffer: datos compartidos sin serialización

// Compartir un buffer entre el hilo principal y workers
import { Worker, isMainThread } from 'worker_threads';

if (isMainThread) {
  // Buffer compartido de 4 bytes (1 Int32)
  const sharedBuffer = new SharedArrayBuffer(4);
  const contador = new Int32Array(sharedBuffer);

  // Lanzar 4 workers que incrementan el contador
  const workers = Array.from({ length: 4 }, () =>
    new Worker(import.meta.url, { workerData: { sharedBuffer } })
  );

  await Promise.all(workers.map(w => new Promise(r => w.on('exit', r))));

  // Atomics garantiza operaciones atómicas sobre el buffer compartido
  console.log('Contador final:', Atomics.load(contador, 0));
  // Sin Atomics habría race conditions

} else {
  const { sharedBuffer } = workerData;
  const contador = new Int32Array(sharedBuffer);
  for (let i = 0; i < 250_000; i++) {
    Atomics.add(contador, 0, 1); // Operación atómica segura
  }
}

Cluster: distribuir conexiones HTTP entre procesos

El módulo cluster permite crear múltiples procesos Node.js que comparten el mismo puerto TCP. El proceso master distribuye las conexiones entrantes entre los workers:

import cluster from 'cluster';
import http from 'http';
import os from 'os';

const NUM_WORKERS = os.cpus().length;

if (cluster.isPrimary) {
  console.log(`Master PID ${process.pid}: lanzando ${NUM_WORKERS} workers`);

  // Crear un worker por CPU
  for (let i = 0; i < NUM_WORKERS; i++) {
    cluster.fork();
  }

  // Reiniciar workers que mueren
  cluster.on('exit', (worker, code, signal) => {
    console.log(`Worker ${worker.process.pid} murió (${signal || code}). Reiniciando...`);
    cluster.fork();
  });

  // Comunicación con workers
  Object.values(cluster.workers).forEach(worker => {
    worker.send({ tipo: 'config', datos: { timeout: 5000 } });
  });

} else {
  // Código del worker — cada instancia escucha en el mismo puerto
  const server = http.createServer((req, res) => {
    res.writeHead(200);
    res.end(`Respuesta del worker PID ${process.pid}n`);
  });

  server.listen(3000);
  console.log(`Worker ${process.pid} escuchando en el puerto 3000`);

  process.on('message', (msg) => {
    if (msg.tipo === 'config') {
      // Aplicar configuración recibida del master
    }
  });
}

PM2 para gestión de workers en producción

// ecosystem.config.cjs — configuración de PM2
module.exports = {
  apps: [{
    name: 'mi-api',
    script: './index.js',
    instances: 'max',           // Un proceso por CPU
    exec_mode: 'cluster',       // Modo cluster de PM2
    watch: false,
    max_memory_restart: '500M', // Reiniciar si supera 500MB
    env: {
      NODE_ENV: 'development',
      PORT: 3000,
    },
    env_production: {
      NODE_ENV: 'production',
      PORT: 80,
    },
    // Restart suave (zero-downtime)
    kill_timeout: 5000,
    listen_timeout: 5000,
  }],
};

// Comandos:
// pm2 start ecosystem.config.cjs --env production
// pm2 reload mi-api   ? zero-downtime reload
// pm2 status
// pm2 logs mi-api
// pm2 monit

¿Cuándo usar cada opción?

La elección entre las tres alternativas depende del tipo de trabajo:

// child_process.spawn / exec
// ? Ejecutar herramientas externas (ffmpeg, imagemagick, scripts shell)
// ? Integrar con procesos legacy en otros lenguajes
// ? El proceso hijo puede ser cualquier ejecutable

// worker_threads
// ? Cálculos CPU-intensivos en JavaScript (criptografía, procesado de imágenes, ML)
// ? Compartir memoria con SharedArrayBuffer
// ? Menor overhead que child_process (sin serialización JSON)

// cluster
// ? Escalar un servidor HTTP/TCP para aprovechar todos los cores
// ? La lógica de distribución de conexiones la maneja Node.js automáticamente
// ? En contenedores Docker suele preferirse múltiples contenedores a cluster

// PM2 en producción
// ? Gestiona el ciclo de vida, los reinicios y el modo cluster
// ? Útil en servidores bare-metal o VMs; en K8s se usa el escalado del pod

En entornos de contenedores modernos (Kubernetes, Docker Swarm), la estrategia habitual es escalar con múltiples contenedores de un proceso único en lugar de usar cluster. Pero en un único servidor o en VMs, cluster y PM2 siguen siendo la forma más eficiente de aprovechar todos los cores disponibles sin la complejidad de orquestar contenedores.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP