Compartir memoria entre hilos de trabajo de Node.js con SharedArrayBuffer y Atomics
¿Por qué postMessage proporciona a cada trabajador de Node.js una copia privada, cuando SharedArrayBuffer sería más adecuado, y cómo Atomics evita actualizaciones perdidas y permite la espera entre hilos?
Fuentes: The Day I Learned JavaScript Threads Don’t Actually Share Memory
Los hilos de trabajo de Node.js son hilos reales del sistema operativo, cada uno con su propia instancia del motor de JavaScript. Eso hace que sea tentador suponer que comparten memoria como lo hacen los hilos en C o Java, y esa suposición falla la primera vez que dos trabajadores deben coordinarse sobre los mismos datos. Este artículo muestra qué hace realmente postMessage con sus valores, cuándo está justificada una verdadera memoria compartida y cómo Atomics mantiene correctas las actualizaciones concurrentes.
Si es nuevo en los trabajadores en general, comience con descargar tareas intensivas en CPU en Node.js con hilos de trabajo y grupos; el enfoque aquí es más específico: qué ocurre con la memoria una vez que existe un trabajador.
postMessage envía una copia, no una referencia
El experimento comienza cuando el hilo principal crea un proceso auxiliar a partir de un archivo separado.
// main.js
const { Worker } = require("worker_threads");
const worker = new Worker("./worker.js");
Luego envía un array ordinario al proceso auxiliar y, un momento después, registra su propia copia de ese array.
const sharedLookingArray = [1, 2, 3];
worker.postMessage(sharedLookingArray);setTimeout(() => {
console.log("Main thread's array:", sharedLookingArray); // still [1, 2, 3]
}, 100);
Por otro lado, el proceso auxiliar recibe el array, agrega un elemento y registra el resultado.
// worker.js
const { parentPort } = require("worker_threads");
parentPort.on("message", (arr) => {
arr.push(4);
console.log("Worker's array:", arr); // [1, 2, 3, 4]
});
El proceso auxiliar imprime [1, 2, 3, 4], mientras que el hilo principal sigue imprimiendo [1, 2, 3] sin importar cuánto espere. La razón es que postMessage procesa el valor mediante el algoritmo de clonación estructurada, el mismo mecanismo expuesto globalmente como structuredClone. Los datos se serializan en el lado emisor y se reconstruyen como un objeto independiente en el lado receptor, por lo que después de la llamada existen dos arrays no relacionados, uno por hilo.
Este es un valor por defecto deliberado y seguro. Dos hilos no pueden competir por memoria que nunca les haya sido asignada a ambos. El costo es la propia copia, que aumenta con el tamaño de los datos y se paga en cada mensaje. (Para grandes cantidades de datos binarios, Node también puede transferir un ArrayBuffer al otro hilo como recurso transferible, lo que evita la copia pero impide que el remitente lo utilice; se trata de una transferencia, no de un intercambio.)
Cuándo la memoria compartida es la herramienta adecuada
La copia se convierte en una opción inadecuada cuando los hilos deben coordinarse sobre los mismos datos con alta frecuencia, tanto que enviarlos de un lado a otro costaría más que el trabajo que se está paralelizando. Para ese caso, JavaScript ofrece SharedArrayBuffer: un bloque de memoria en bruto al que varios hilos hacen referencia directamente, sin necesidad de copias.
El hilo principal vuelve a cargar el módulo del trabajador:
// main.js
const { Worker } = require("worker_threads");
Luego asigna un búfer compartido de cuatro bytes, suficiente para un entero de 32 bits, lo envuelve en una vista Int32Array y pasa el búfer al proceso de trabajo a través de workerData.
const sharedBuffer = new SharedArrayBuffer(4); // 4 bytes, one 32-bit integer
const sharedCounter = new Int32Array(sharedBuffer);const worker = new Worker("./worker.js", { workerData: { sharedBuffer } });
El proceso de trabajo crea su propia vista Int32Array sobre el mismo búfer:
// worker.js
const { workerData } = require("worker_threads");
const sharedCounter = new Int32Array(workerData.sharedBuffer);
A partir de este punto, cualquier escritura realizada a través de una de las vistas es visible desde la otra, ya que ambas apuntan a los mismos bytes.
sharedCounter[0] += 1; // this thread and the main thread are now touching the same memory
Esto representa una verdadera diferencia con respecto al comportamiento habitual de la concurrencia en JavaScript. El código dentro de un mismo ámbito se ejecuta en un único hilo, por lo que las situaciones habituales de competencia asíncrona, en las que dos funciones async se intercalan en un orden sorprendente, siguen siendo el resultado de que un solo hilo se turne en momentos bien definidos. Con un SharedArrayBuffer, dos hilos pueden leer y escribir en la misma memoria literalmente al mismo tiempo.
La actualización perdida que ahora es posible
Considere la forma obvia de incrementar el contador compartido:
// naive, and genuinely unsafe with real shared memory
sharedCounter[0] = sharedCounter[0] + 1;
Parece una sola operación, pero en realidad son tres: leer el valor actual, sumar uno y escribir el resultado de vuelta. Si otro hilo lee el contador entre la lectura y la escritura de este hilo, ambos calcularán el mismo nuevo valor y un incremento desaparecerá. Nada se lanza ni se registra; el número final es simplemente demasiado bajo. Dado que el resultado depende del momento exacto en que se ejecutan los dos hilos del sistema operativo, el error puede aparecer una vez cada mil ejecuciones y desaparecer al agregar registro de eventos.
Atómicos: operaciones indivisibles en memoria compartida
El objeto Atomics proporciona operaciones sobre arrays enteros compartidos que se ejecutan como una sola unidad. Ningún otro hilo puede observar ni interferir con una operación atómica a mitad de su ejecución.
Atomics.add(sharedCounter, 0, 1); // read, increment, and write, as one indivisible step
Si cada hilo incrementa el valor con Atomics.add en lugar de seguir el patrón leer-modificar-escribir, no se perderá ninguna actualización, ya que el motor garantiza que otras operaciones atómicas sobre ese elemento no puedan intercalarse dentro de esta. Esta garantía solo se mantiene si todos los escritores utilizan operaciones atómicas; un único hilo que siga realizando incrementos de forma tradicional vuelve a introducir el problema de competencia. Todo lo más complejo que se pueda construir sobre memoria compartida, ya sean contadores, bloqueos o colas, en última instancia se compone de operaciones como esta, que son atómicas a nivel de hardware y no simplemente “generalmente aceptables”.
Atomics.wait y Atomics.notify: bloqueo entre hilos
Atomics también ofrece algo que JavaScript carece por defecto: una forma de que un hilo deje de ejecutarse por completo hasta que otro hilo lo active. En el worker, Atomics.wait se bloquea mientras el contador siga mostrando 0:
// worker.js — waits for main thread to signal it's time to proceed
Atomics.wait(sharedCounter, 0, 0); // blocks this thread while sharedCounter[0] === 0
console.log("Signaled to continue, value is now:", sharedCounter[0]);
El hilo principal almacena un nuevo valor y luego activa a un hilo en espera:
// main.js — does the signaling
Atomics.store(sharedCounter, 0, 1);
Atomics.notify(sharedCounter, 0, 1); // wakes up one waiting thread
Es importante almacenar el valor antes de notificarlo. Atomics.wait verifica primero el valor y devuelve inmediatamente si ya no coincide con el esperado, por lo que una señal enviada antes de que el worker comience a esperar no se pierde.
Se trata de una llamada verdaderamente bloqueante, lo cual es inusual en JavaScript, donde el trabajo asíncrono normalmente fluye a través de las devoluciones de llamada del bucle de eventos. Bloquear el hilo que ejecuta el bucle de eventos congelaría todo lo demás que ese hilo maneja, y por eso los navegadores no permiten Atomics.wait en el hilo principal y, en general, este método pertenece a los hilos de trabajo. (Los navegadores también exigen que la página esté aislada entre orígenes antes de que SharedArrayBuffer esté disponible en absoluto; Node no tiene tal restricción.)
Un uso realista: progreso en tiempo real a través de un grupo de hilos de trabajo
Aquí hay un caso en el que la complejidad añadida rinde frutos. Varios procesos limitados por la CPU procesan cada uno un fragmento de un gran conjunto de datos, y el hilo principal desea contar el progreso en tiempo real sin que cada proceso envíe un mensaje por elemento. El hilo principal crea un contador compartido y le entrega su búfer a cada proceso junto con su fragmento:
// main.js
const progress = new Int32Array(new SharedArrayBuffer(4));
const workers = chunks.map((chunk) =>
new Worker("./process-chunk.js", { workerData: { chunk, progressBuffer: progress.buffer } })
);
Luego toma una muestra del contador cada medio segundo mediante Atomics.load, lo que garantiza que se lea un valor completo y actual:
setInterval(() => {
console.log(`Processed ${Atomics.load(progress, 0)} items so far`);
}, 500);
Cada proceso envuelve el búfer compartido en su propia vista:
// process-chunk.js
const { workerData } = require("worker_threads");
const progress = new Int32Array(workerData.progressBuffer);
Mientras procesa los elementos, incrementa el contador compartido de forma atómica:
for (const item of workerData.chunk) {
processItem(item);
Atomics.add(progress, 0, 1); // every worker safely increments the same shared counter
}
Todos los trabajadores escriben directamente en los mismos cuatro bytes; no existe sobrecarga por mensajes individuales, y el hilo principal puede leer un total preciso en cualquier momento sin tener que solicitar informes a nadie. Este tipo de problema, caracterizado por una coordinación frecuente y estrecha entre tareas realmente paralelas en la CPU, es lo que justifica el uso de memoria compartida en lugar de copiar datos.
La mayoría del código no debería recurrir a esto
El código típico de backend en Node rara vez necesita cualquiera de estas soluciones. La mayor parte de la concurrencia en un servidor API está relacionada con operaciones de E/S, las cuales ya son gestionadas adecuadamente por el bucle de eventos de un solo hilo. Para las tareas que dependen de la CPU, como el procesamiento de imágenes, la transformación de datos o la criptografía, generalmente son suficientes los hilos de trabajo que utilizan copias mediante postMessage sencillo.
SharedArrayBuffer y Atomics están destinados a situaciones más específicas: la coordinación de alta frecuencia entre hilos paralelos, donde la copia en cada intercambio se convertiría ella misma en un cuello de botella. Adoptar memoria compartida antes de haber medido ese problema no hace que el código sea más rápido; solo introduce competencias a nivel de hardware que el modelo de copia estaba diseñado para evitar.
Puntos clave
postMessageclona los datos de forma estructurada, de modo que cada hilo trabaja con su propia copia y las mutaciones nunca se transmiten entre ellos.SharedArrayBufferpermite que varios hilos tengan acceso directo a los mismos bytes a través de vistas de array tipado.- Una simple operación
x = x + 1en memoria compartida puede hacer que las actualizaciones se pierdan silenciosamente; useAtomics.addy funciones similares para cada escritura.
Atomics.wait realmente bloquea un hilo y debe utilizarse en los workers; úselo junto con Atomics.store y Atomics.notify.