La lección anterior repartió Escena Viva entre siete trabajadores y multiplicó por casi cinco la capacidad del servidor. Pero dejó un problema intacto y lo dijimos sin rodeos: cluster no desbloquea el bucle de eventos. Cuando un trabajador se pone a componer el PDF con el código QR de las 500 entradas del estreno del Festival de Jazz de Primavera (evt-003, Auditorio Ribera), ese proceso deja de atender a todo el mundo durante el tiempo que dure el cálculo.

En esta lección medimos ese bloqueo con el instrumental del módulo 2, entendemos por qué los hilos son la herramienta correcta para el trabajo de CPU (y cluster no lo es), y construimos en Escena Viva un PoolDeHilos propio, porque crear un hilo por petición es peor que no usar hilos.

Contenido

  1. El bloqueo, medido con monitorEventLoopDelay
  2. Cluster frente a worker threads: la regla de decisión
  3. node:worker_threads: el mecanismo básico
  4. Clonado estructurado: qué viaja entre hilos y qué cuesta
  5. Transferencia frente a copia: ArrayBuffer, SharedArrayBuffer y Atomics
  6. El hilo de Escena Viva: src/trabajadores/generar-entradas.js
  7. PoolDeHilos: por qué un hilo por petición es un error
  8. Errores dentro del hilo y cómo se propagan
  9. Medición antes y después
  10. Cuándo NO usar hilos

  1. El bloqueo, medido con monitorEventLoopDelay

En el módulo 3 dejamos src/utiles/qr-entrada.js con { codificarQr, decodificarQr }: firma el contenido con HMAC y lo codifica en base64url. Al generar las entradas de una sesión completa hay que hacerlo una vez por entrada, más la composición del PDF. Es trabajo síncrono y de CPU: no hay E/S que delegar, solo cálculo.

Vamos a medirlo antes de opinar. Este script simula la generación de las 500 entradas del estreno mientras un temporizador intenta latir cada 20 ms:

'use strict';

const { monitorEventLoopDelay } = require('node:perf_hooks');
const { generarLoteDeEntradas } = require('../src/servicios/entradas-pdf.js');

const histograma = monitorEventLoopDelay({ resolution: 10 });
histograma.enable();

// Latido regular: si el bucle esta libre, se ejecuta cada ~20 ms.
let latidos = 0;
const latido = setInterval(() => { latidos += 1; }, 20);

(async function medir() {
  const inicio = process.hrtime.bigint();
  await generarLoteDeEntradas({ sesionId: 'ses-003-1', eventoId: 'evt-003', cantidad: 500 });
  const msTotales = Number(process.hrtime.bigint() - inicio) / 1e6;
  clearInterval(latido);
  histograma.disable();

  console.log(`Generacion: ${msTotales.toFixed(0)} ms`);
  console.log(`Latidos: ${latidos} (esperados ~${Math.round(msTotales / 20)})`);
  console.log(`Retraso maximo: ${(histograma.max / 1e6).toFixed(2)} ms`);
})();

Resultado en la máquina de referencia: Generacion: 4180 ms, Latidos: 3 (esperados ~209), Retraso maximo: 4176.44 ms.

Tres latidos donde debería haber 209. Durante 4,18 segundos el proceso no ejecutó absolutamente nada más: ni respondió peticiones, ni atendió la conexión a PostgreSQL, ni procesó el keep-alive. Con SCHED_RR, el primario le siguió mandando conexiones —no sabe que está ocupado— y esas conexiones se quedaron en la cola del sistema. Cuando el trabajador volvió, tenía un atasco esperándole y el percentil 99 de toda la aplicación se disparó. Con 7 trabajadores el daño es 1/7 del tráfico; pero si tres organizadores generan entradas a la vez (algo normal la noche del estreno), tres de siete trabajadores están congelados.

  1. Cluster frente a worker threads: la regla de decisión

Aspecto cluster (procesos) worker_threads (hilos)
Unidad Proceso del sistema operativo Hilo dentro del mismo proceso
Instancia de V8 Una por proceso Una por hilo (aisladas)
Bucle de eventos Uno por proceso Uno por hilo
Montículo Independiente, sin compartir Independiente, pero se puede compartir memoria con SharedArrayBuffer
Coste de arranque ~40-80 ms (proceso Node completo) ~10-25 ms
Coste de memoria ~40-80 MB por proceso ~5-15 MB por hilo
Comunicación IPC serializado (JSON o avanzado) postMessage con clonado estructurado, o memoria compartida
Comparten puerto Sí (el primario reparte) No (pero pueden compartir un socket con net, avanzado)
Un fallo mata... Solo ese proceso Solo ese hilo, pero un SharedArrayBuffer corrupto afecta a todos
Sirve para Concurrencia de E/S: más peticiones a la vez Trabajo de CPU: no bloquear el hilo principal

La regla de decisión, que conviene memorizar: cluster para escalar la E/S, hilos para sacar la CPU del camino. No son alternativas: en Escena Viva usaremos las dos. Siete trabajadores de cluster, y dentro de cada uno un pool pequeño de hilos para el trabajo pesado. Y ojo con la aritmética: 7 trabajadores × 4 hilos = 28 hilos de CPU en una máquina de 8 núcleos es sobreventa. Volveremos a ello en el apartado 7.

  1. node:worker_threads: el mecanismo básico

El módulo expone lo esencial en muy pocas piezas:

Elemento Dónde vive Para qué
new Worker(ruta, opciones) Hilo principal Crea el hilo y ejecuta el fichero indicado
workerData Hilo trabajador Datos iniciales clonados en el arranque
parentPort Hilo trabajador Puerto de comunicación con quien lo creó
isMainThread / threadId Ambos En qué hilo estamos y con qué identificador
MessageChannel / MessagePort Ambos Canales adicionales entre hilos cualesquiera

Un ejemplo mínimo, con los eventos que importan:

'use strict';

const path = require('node:path');
const { Worker } = require('node:worker_threads');

function ejecutarEnHilo(ruta, datos) {
  return new Promise((resolver, rechazar) => {
    const hilo = new Worker(path.join(__dirname, ruta), { workerData: datos });
    hilo.on('message', (resultado) => resolver(resultado));
    // 'error': excepcion no capturada dentro del hilo. 'online': el hilo
    // ya ejecuta JavaScript (util para medir el coste de arranque).
    hilo.on('error', (error) => rechazar(error));
    hilo.on('exit', (codigo) => {
      if (codigo !== 0) rechazar(new Error(`El hilo termino con codigo ${codigo}`));
    });
  });
}

Y el fichero del hilo, donde workerData llega ya clonado (es una copia, no una referencia):

'use strict';

const { parentPort, workerData, threadId } = require('node:worker_threads');

const suma = workerData.numeros.reduce((acumulado, valor) => acumulado + valor, 0);
parentPort.postMessage({ hilo: threadId, suma });

Dos matices importantes. Primero, postMessage no termina el hilo; el hilo vive hasta que se queda sin trabajo pendiente o alguien llama a terminate(), y puede seguir recibiendo tareas por parentPort.on('message', ...). Esto es precisamente lo que permite reutilizarlo en un pool. Segundo, cada hilo carga sus propios módulos: si generar-entradas.js hace require de Sequelize, ese require se ejecuta una vez por hilo, con su coste de arranque y de memoria.

  1. Clonado estructurado: qué viaja entre hilos y qué cuesta

Los mensajes entre hilos no comparten memoria por defecto: se copian con el algoritmo de clonado estructurado (el mismo de postMessage en el navegador y de structuredClone). Es más capaz que JSON.stringify, pero tiene límites estrictos.

Tipo ¿Viaja? Nota
Primitivos (null, undefined, BigInt incluidos) Sí
Objetos y arrays planos Sí Incluidas referencias circulares
Date, RegExp Sí Se conservan como tales, no como cadenas
Map, Set Sí Ventaja clara sobre JSON
Buffer, TypedArray, ArrayBuffer Sí Copiado, salvo que se transfiera
Error Sí Se conservan message, name y stack
Funciones No DataCloneError
Clases (la identidad) No Llega el objeto plano, sin prototipo ni métodos
Getters, símbolos, WeakMap No Se pierden silenciosamente o fallan
Conexiones, sockets, handles de fs No No tienen sentido fuera de su hilo

Consecuencia directa para Escena Viva: no puedes enviar una instancia de Entrada ni de Pedido y esperar que conserve sus métodos. Nuestro dominio (src/dominio/) usa clases; al cruzar la frontera del hilo hay que serializar a datos planos y reconstruir al otro lado, y por eso lo diseñaremos así: el hilo recibe datos primitivos y devuelve datos primitivos. El coste de copiar tampoco es cero: clonar 500 objetos de entrada es del orden de un milisegundo, pero clonar un PDF de 8 MB como Buffer es del orden de decenas. Si el resultado es grande, transfiérelo en lugar de copiarlo.

  1. Transferencia frente a copia: ArrayBuffer, SharedArrayBuffer y Atomics

postMessage acepta un segundo argumento con la lista de objetos transferibles: parentPort.postMessage({ datos: arrayBuffer }, [arrayBuffer]). Un ArrayBuffer transferido no se copia, cambia de dueño, y el hilo emisor se queda con un buffer desconectado (byteLength === 0). Es la forma correcta de devolver un PDF de varios megabytes, y la veremos aplicada en el apartado 6.

SharedArrayBuffer va un paso más allá: la misma memoria es visible desde varios hilos a la vez, sin copia ni transferencia. Es la herramienta para contadores compartidos, como el número de entradas generadas en el estreno:

'use strict';

// En el hilo principal: 2 enteros de 32 bits compartidos, que se envian
// a cada hilo en workerData SIN copiarse.
// contadores[0] = entradas generadas, contadores[1] = errores.
const contadores = new Int32Array(new SharedArrayBuffer(8));

// Atomics.add lee, suma y escribe sin que otro hilo pueda colarse en
// medio. Con contadores[0] += 1 perderiamos incrementos.
const registrarEntradaGenerada = (c) => Atomics.add(c, 0, 1);
// Atomics.load garantiza ver el valor mas reciente publicado por
// cualquier hilo, sin reordenaciones del compilador o la CPU.
const leerTotal = (c) => Atomics.load(c, 0);

module.exports = { contadores, registrarEntradaGenerada, leerTotal };

Advertencia seria. Hasta ahora, en JavaScript de un solo hilo, las condiciones de carrera de memoria no existían: entre dos líneas de tu función nadie podía modificar tus variables. SharedArrayBuffer abre esa puerta. contadores[0] += 1 son tres operaciones (leer, sumar, escribir) y dos hilos pueden entrelazarlas y perder incrementos. Por eso existe Atomics (add, load, store, compareExchange, wait, notify). Úsalo siempre para tocar memoria compartida, y usa memoria compartida solo cuando de verdad haga falta: para datos estructurados, el clonado es más lento pero infinitamente más seguro.

  1. El hilo de Escena Viva: src/trabajadores/generar-entradas.js

El hilo recibe los datos planos de un lote de entradas, genera para cada una su QR firmado reutilizando codificarQr del módulo 3, compone el PDF y devuelve el resultado transfiriendo el buffer.

'use strict';

const { parentPort } = require('node:worker_threads');
const { codificarQr } = require('../utiles/qr-entrada.js');
const { componerPdfDeEntradas } = require('../servicios/pdf.js');

// Genera el QR y el PDF de un lote de entradas. Trabajo puramente de CPU.
function generarLote({ pedidoId, eventoTitulo, salaNombre, fechaSesion, entradas }) {
  const entradasConQr = entradas.map((entrada) => ({
    codigo: entrada.codigo, // formato EV-2026-004871
    butaca: entrada.butaca,
    precioCentimos: entrada.precioCentimos,
    // codificarQr firma con HMAC y devuelve base64url (modulo 3).
    qr: codificarQr({ codigo: entrada.codigo, sesionId: entrada.sesionId }),
  }));

  const bufferPdf = componerPdfDeEntradas({
    pedidoId, eventoTitulo, salaNombre, fechaSesion, entradas: entradasConQr,
  });
  return { bufferPdf, generadas: entradasConQr.length };
}

// El hilo permanece vivo escuchando tareas: asi lo reutiliza el pool.
parentPort.on('message', ({ idTarea, peticion }) => {
  try {
    const { bufferPdf, generadas } = generarLote(peticion);
    const arrayBuffer = bufferPdf.buffer.slice(
      bufferPdf.byteOffset, bufferPdf.byteOffset + bufferPdf.byteLength
    );
    parentPort.postMessage(
      { idTarea, ok: true, resultado: { pdf: arrayBuffer, generadas } },
      [arrayBuffer] // transferencia sin copia
    );
  } catch (error) {
    // Nunca dejamos que la excepcion mate el hilo: la devolvemos como dato.
    parentPort.postMessage({
      idTarea, ok: false,
      error: { mensaje: error.message, nombre: error.name, pila: error.stack },
    });
  }
});

Fíjate en la decisión de diseño: el hilo no lanza excepciones hacia arriba, las convierte en respuestas. Un throw sin capturar dispararía el evento error y mataría el hilo, obligando al pool a crear otro. Capturar dentro y responder con ok: false mantiene el hilo reutilizable.

  1. PoolDeHilos: por qué un hilo por petición es un error

Medimos el coste de arranque de un hilo que hace require de nuestro código: entre 18 y 40 ms. Si la tarea dura 4 000 ms, ese coste es ruido. Pero para lotes pequeños —una entrada suelta de Lucia para ses-001-1— la tarea dura 12 ms y el arranque cuesta el triple que el trabajo. Peor aún: 50 peticiones simultáneas crearían 50 hilos, es decir 50 instancias de V8 compitiendo por 8 núcleos, con cientos de megabytes de memoria y un colapso por cambio de contexto.

La solución es un pool: un número fijo y pequeño de hilos creados al arrancar, una cola de tareas, y reutilización.

'use strict';

const path = require('node:path');
const os = require('node:os');
const crypto = require('node:crypto');
const { Worker } = require('node:worker_threads');

const RUTA_HILO = path.join(__dirname, 'generar-entradas.js');

class PoolDeHilos {
  // tamano pequeno por defecto: el trabajo de CPU no escala mas alla de
  // los nucleos, y convivimos con los trabajadores de cluster (10-01).
  constructor({ tamano, rutaHilo = RUTA_HILO, tiempoLimiteMs = 30_000 } = {}) {
    this.tamano = tamano || Math.max(1, Math.floor(os.availableParallelism() / 2));
    this.rutaHilo = rutaHilo;
    this.tiempoLimiteMs = tiempoLimiteMs;
    this.hilos = [];             // todos los hilos vivos
    this.libres = [];            // hilos sin tarea asignada
    this.cola = [];              // tareas esperando hilo
    this.pendientes = new Map(); // idTarea -> { resolver, rechazar, temporizador }
    this.cerrado = false;
    for (let i = 0; i < this.tamano; i += 1) this.crearHilo();
  }

  crearHilo() {
    const hilo = new Worker(this.rutaHilo);
    hilo.tareaActual = null;

    hilo.on('message', (mensaje) => {
      const pendiente = this.pendientes.get(mensaje.idTarea);
      if (pendiente) {
        clearTimeout(pendiente.temporizador);
        this.pendientes.delete(mensaje.idTarea);
        if (mensaje.ok) pendiente.resolver(mensaje.resultado);
        else pendiente.rechazar(Object.assign(new Error(mensaje.error.mensaje), {
          name: mensaje.error.nombre, stack: mensaje.error.pila,
        }));
      }
      this.devolverHilo(hilo);
    });

    // El hilo murio por una excepcion no capturada: rechazamos su tarea
    // en curso y lo sustituimos para mantener el tamano del pool.
    hilo.on('error', (error) => {
      this.rechazarTareaDe(hilo, error);
      this.retirarHilo(hilo);
      if (!this.cerrado) this.crearHilo();
    });
    hilo.on('exit', (codigo) => {
      if (codigo !== 0 && !this.cerrado) { this.retirarHilo(hilo); this.crearHilo(); }
    });

    this.hilos.push(hilo);
    this.libres.push(hilo);
  }

  retirarHilo(hilo) {
    this.hilos = this.hilos.filter((otro) => otro !== hilo);
    this.libres = this.libres.filter((otro) => otro !== hilo);
  }

  rechazarTareaDe(hilo, error) {
    const pendiente = hilo.tareaActual && this.pendientes.get(hilo.tareaActual);
    if (pendiente) {
      clearTimeout(pendiente.temporizador);
      this.pendientes.delete(hilo.tareaActual);
      pendiente.rechazar(error);
    }
    hilo.tareaActual = null;
  }

  // Al quedar libre, el hilo toma la siguiente tarea de la cola si la hay.
  devolverHilo(hilo) {
    hilo.tareaActual = null;
    const siguiente = this.cola.shift();
    if (siguiente) this.asignar(hilo, siguiente);
    else this.libres.push(hilo);
  }

  asignar(hilo, tarea) {
    hilo.tareaActual = tarea.idTarea;
    hilo.postMessage({ idTarea: tarea.idTarea, peticion: tarea.peticion });
  }

  // API publica: devuelve una promesa con el resultado del hilo.
  ejecutar(peticion) {
    if (this.cerrado) return Promise.reject(new Error('El pool esta cerrado'));
    const idTarea = crypto.randomUUID();

    return new Promise((resolver, rechazar) => {
      const temporizador = setTimeout(() => {
        this.pendientes.delete(idTarea);
        rechazar(new Error(`La tarea ${idTarea} supero ${this.tiempoLimiteMs} ms`));
      }, this.tiempoLimiteMs);

      this.pendientes.set(idTarea, { resolver, rechazar, temporizador });
      const hilo = this.libres.pop();
      if (hilo) this.asignar(hilo, { idTarea, peticion });
      else this.cola.push({ idTarea, peticion }); // todos ocupados: espera turno
    });
  }

  // estado() expone { tamano, libres, enCola } para el endpoint de salud.
  // cerrar() marca el pool como cerrado y hace terminate() de cada hilo,
  // y se engancha en el apagado ordenado del M6.
}

module.exports = { PoolDeHilos };

Sobre el tamaño del pool: para trabajo puramente de CPU, más hilos que núcleos no aporta nada, solo cambio de contexto. Y como cada trabajador de cluster tiene su propio pool, el cálculo real es trabajadoresCluster × tamañoDelPool ≤ núcleos. Con 7 trabajadores en 8 núcleos, el pool debería ser de 1 o 2 hilos. Esta tensión —cluster y hilos compitiendo por los mismos núcleos— es la razón por la que muchas arquitecturas acaban sacando el trabajo pesado del servidor web por completo, a un proceso aparte con una cola: la lección 10-03. En producción, además, la biblioteca piscina hace todo esto y más (cancelación con AbortSignal, reciclado de hilos tras N tareas, límites de cola, métricas); hemos escrito el nuestro para entender qué hace por dentro.

El servicio de aplicación queda así de simple:

'use strict';

// Un unico pool por proceso, creado al arrancar. Nunca uno por peticion.
function crearServicioDeEntradas({ poolDeHilos }) {
  return {
    async generarPdfDePedido(datosPedido) {
      const { pdf, generadas } = await poolDeHilos.ejecutar(datosPedido);
      // 'pdf' llega como ArrayBuffer transferido; lo envolvemos sin copiar.
      return { buffer: Buffer.from(pdf), generadas };
    },
  };
}

module.exports = { crearServicioDeEntradas };

Encaja sin fricción con el refactor del módulo 9: los controladores son fábricas con dependencias inyectadas, así que el pool se inyecta como una dependencia más y en las pruebas se sustituye por un doble de Sinon.

  1. Errores dentro del hilo y cómo se propagan

Situación en el hilo Qué ocurre Cómo lo maneja el pool
throw capturado por nuestro try/catch Se responde { ok: false, error } Rechaza la promesa; el hilo sigue vivo
throw no capturado Evento error en el padre; el hilo muere Rechaza la tarea en curso, retira y recrea el hilo
Rechazo de promesa no gestionado Evento error (política por defecto) Igual que el anterior
process.exit() dentro del hilo Evento exit con código Recrea el hilo; la tarea queda huérfana si no la rechazamos
Bucle infinito Nada: el hilo no responde jamás Salta el tiempoLimiteMs y se rechaza; conviene terminate()
Memoria agotada Muere el hilo (o el proceso entero) Limita el tamaño de los lotes

Un matiz sobre el tiempo límite: nuestro ejecutar rechaza la promesa, pero el hilo sigue calculando. Para un bucle infinito real hay que llamar a terminate() y recrear el hilo (ejercicio 2).

  1. Medición antes y después

Escenario: estreno del Festival de Jazz, con una carga base de 500 peticiones por segundo al catálogo y, cada 2 segundos, un organizador que solicita el PDF de un lote de 500 entradas.

Métrica Sin hilos (síncrono) Con PoolDeHilos (2 hilos)
Retraso máximo del bucle de eventos 4 176 ms 11 ms
Latencia p50 del catálogo 1 240 ms 18 ms
Latencia p99 del catálogo 6 810 ms 74 ms
Peticiones con 503 / tiempo agotado 217 0
Duración de la generación del lote 4 180 ms 4 390 ms

Lee la última fila con atención: la generación tarda un poco más (4 390 frente a 4 180 ms), porque hay que clonar la petición de entrada, transferir el resultado y coordinar el pool. Los hilos no aceleran la tarea pesada; lo que hacen es sacarla del camino de todos los demás. El p99 del catálogo cae de 6,8 segundos a 74 milisegundos, y esa es la única cifra que le importa a quien está intentando comprar una entrada mientras se genera el lote.

  1. Cuándo NO usar hilos

Los hilos tienen un coste real (memoria, arranque, complejidad, serialización). No los uses si:

  1. La tarea es de E/S. Leer un fichero, consultar PostgreSQL, llamar a la API de divisas: eso ya es asíncrono y no bloquea nada. Meter una consulta SQL en un hilo no la acelera; solo añade una copia de datos y un salto entre hilos. Este es el malentendido más frecuente.
  2. La tarea es trivial. Si el trabajo dura 2 ms, el clonado y el traspaso cuestan más que hacerlo en el sitio. Umbral orientativo: por debajo de ~10-20 ms de CPU, no compensa.
  3. La tarea se puede trocear. En el módulo 2 vimos cómo partir un bucle largo con setImmediate para devolver el control al bucle entre trozos. Si el trabajo se deja cortar limpiamente y no es enorme, trocear es más simple que un pool.
'use strict';

// Alternativa del modulo 2: trocear en lugar de usar hilos. Procesa el
// lote en bloques de 25 y cede el control entre bloques, de modo que el
// servidor responde peticiones entre bloque y bloque.
function procesarPorBloques(elementos, procesar, tamanoBloque = 25) {
  return new Promise((resolver) => {
    const resultados = [];
    let indice = 0;
    (function siguienteBloque() {
      const fin = Math.min(indice + tamanoBloque, elementos.length);
      for (; indice < fin; indice += 1) resultados.push(procesar(elementos[indice]));
      if (indice < elementos.length) setImmediate(siguienteBloque);
      else resolver(resultados);
    })();
  });
}

Trocear reduce el retraso máximo del bucle a la duración de un bloque (unos 200 ms en nuestro caso), lo cual ya es aceptable para muchas aplicaciones, y no cuesta ni un hilo ni un megabyte. La contrapartida es que la tarea total tarda más y sigue consumiendo el único hilo principal.

  1. La tarea es realmente larga. Si generar y enviar por correo las 500 entradas del estreno tarda minutos, ni el hilo ni el troceado son la respuesta: la petición HTTP no debería esperar en absoluto. Hay que sacar el trabajo del proceso y encolarlo, responder 202 Accepted con un identificador, y que el cliente consulte el estado. Ese es el tema de la lección siguiente.

Errores Comunes y Consejos

  • Crear un Worker por petición. El error más caro. Pool siempre, creado al arrancar el proceso.
  • Enviar instancias de clases del dominio. Llegan como objetos planos sin métodos: serializa a datos y reconstruye al otro lado.
  • Meter E/S en un hilo. No mejora nada: la E/S ya era asíncrona.
  • Usar SharedArrayBuffer sin Atomics, u olvidar que un ArrayBuffer transferido queda vacío en el emisor.
  • No poner tiempo límite a las tareas. Un hilo colgado retiene su plaza del pool para siempre y acaba parando toda la generación.
  • Consejo: expón pool.estado() en tu endpoint de salud. Una cola que crece sin parar te dice que el pool está infradimensionado o que hay que encolar fuera del proceso.
  • Consejo: mide siempre con monitorEventLoopDelay antes y después. Si el retraso no baja, los hilos no eran el problema.

Ejercicios

Ejercicio 1: demostrar el bloqueo y medirlo

Escribe un script que arranque un servidor HTTP mínimo con una ruta /bloquear que calcule 500 hashes SHA-256 con 100 000 iteraciones cada uno de forma síncrona, y una ruta /ping que responda { ok: true }. Con autocannon contra /ping, mide el p99 en reposo y mientras se ejecuta /bloquear. Registra el retraso del bucle con monitorEventLoopDelay.

Ejercicio 2: añadir terminate al tiempo límite

Modifica PoolDeHilos para que, al agotarse tiempoLimiteMs, además de rechazar la promesa, llame a terminate() sobre el hilo bloqueado y lo sustituya. Comprueba el comportamiento con un hilo que ejecute while (true) {}.

Ejercicio 3: contador compartido con Atomics

Con 4 hilos incrementando el mismo Int32Array sobre SharedArrayBuffer 100 000 veces cada uno, compara el resultado usando contadores[0] += 1 frente a Atomics.add(contadores, 0, 1). Explica la diferencia.

Soluciones

Ejercicio 1. En reposo, /ping da un p99 de 2-4 ms. Durante /bloquear, todas las peticiones a /ping se acumulan y el p99 sube a varios segundos: exactamente la duración del cálculo síncrono. El histograma muestra un max casi idéntico a esa duración, porque el bucle no dio ni una vuelta. La lectura correcta es que /ping no tiene ningún problema: el problema es un vecino ruidoso en el mismo hilo. Es el argumento completo de esta lección en 30 líneas de código.

Ejercicio 2. Dentro del setTimeout de ejecutar, se localiza el hilo con this.hilos.find((h) => h.tareaActual === idTarea), se retira del pool, se llama a await hiloColgado.terminate() —que mata el hilo aunque esté en bucle— y se crea uno nuevo antes de rechazar la promesa. Con while (true) {} en el hilo, sin terminate() la plaza del pool se pierde para siempre y tras N tareas colgadas el pool queda inutilizable. Con terminate(), el pool se recupera solo. Nota: terminate() mata el hilo de forma inmediata y no hay apagado ordenado posible dentro de él.

Ejercicio 3. Con contadores[0] += 1 el total esperado es 400 000 pero el observado ronda las 120 000-250 000, y varía en cada ejecución. El motivo es que += son tres pasos (leer, sumar, escribir) y dos hilos pueden leer el mismo valor y escribir el mismo resultado, perdiendo un incremento. Con Atomics.add el total es exactamente 400 000, siempre: la operación es indivisible a nivel de CPU. La moraleja es doble: la memoria compartida introduce una clase de errores que en JavaScript no existía, y esos errores son no deterministas, así que tus pruebas del módulo 9 pueden pasar mil veces y fallar en producción la noche del estreno.

Conclusión

Hemos convertido un bloqueo de 4,18 segundos en un retraso de 11 milisegundos. Medimos el problema con monitorEventLoopDelay, distinguimos con claridad cuándo toca cluster (concurrencia de E/S) y cuándo hilos (trabajo de CPU), entendimos qué viaja entre hilos con el clonado estructurado y qué no, aprendimos a transferir en lugar de copiar y a compartir memoria con Atomics sin abrir la puerta a condiciones de carrera, y construimos src/trabajadores/generar-entradas.js y src/trabajadores/pool.js con cola, reutilización, tiempo límite y recuperación ante errores. También aprendimos los límites: los hilos no aceleran la tarea, solo la apartan; no sirven para E/S; no compensan para trabajo trivial; y compiten por los mismos núcleos que los trabajadores de cluster, lo que obliga a dimensionar con cabeza.

Y queda una pieza fuera de sitio. Generar el PDF ya no bloquea, pero el organizador del Auditorio Ribera sigue esperando cuatro segundos y medio con la petición HTTP abierta a que termine. Si además hay que enviar por correo esas 500 entradas, la espera se vuelve absurda. La respuesta no es un hilo más rápido: es no esperar. En la lección siguiente, Caché y Colas de Trabajo con Redis, sacaremos el trabajo pesado fuera del proceso, responderemos 202 Accepted con un identificador de trabajo, cachearemos el catálogo que hoy se recalcula en cada petición, y de paso saldaremos la deuda que dejó la lección 10-01: sesiones y límites de peticiones compartidos entre todos los trabajadores.

Curso de Node.js: De Principiante a Avanzado

Módulo 1: Introducción a Node.js

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados