El `SharedArrayBuffer` en TypeScript

El `SharedArrayBuffer` en TypeScript

Este artículo explica el SharedArrayBuffer en TypeScript.

Explicaremos SharedArrayBuffer en TypeScript con ejemplos prácticos.

YouTube Video

El SharedArrayBuffer en TypeScript

SharedArrayBuffer es un mecanismo para compartir el mismo espacio de memoria entre múltiples hilos, como los Web Workers. Al combinarlo con Atomics, puedes gestionar condiciones de carrera y realizar operaciones de memoria compartida de baja latencia.

Requisitos previos y notas

Al usar SharedArrayBuffer en el navegador, se requieren las cabeceras COOP y COEP para cumplir con los requisitos de seguridad conocidos como aislamiento de origen cruzado. En Node.js, la memoria compartida puede manejarse de manera relativamente sencilla usando worker_threads.

Conceptos Básicos

SharedArrayBuffer es un objeto que representa una secuencia de bytes de longitud fija, y puedes leer y escribir números mediante TypedArray y vistas similares. Las operaciones simples de lectura y escritura no están sincronizadas, por lo que usas la API Atomics para garantizar operaciones atómicas y utilizas los mecanismos wait y notify para la coordinación.

Contador simple (versión navegador)

En este ejemplo, el hilo principal crea un SharedArrayBuffer y lo pasa a un Web Worker, y ambos incrementan un contador compartido. Esto demuestra el patrón mínimo: suma atómica con Atomics.add y lectura con Atomics.load.

main.ts (lado navegador)

Este ejemplo muestra cómo el hilo principal crea un SharedArrayBuffer y lo comparte con un Worker para acceso multi-hilo.

 1// main.ts
 2// Create a 4-byte (Int32) shared buffer for one counter
 3const sab = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 1);
 4const counter = new Int32Array(sab); // view over the buffer
 5
 6// Create worker and transfer the SharedArrayBuffer
 7const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
 8worker.postMessage({ sab });
 9
10// Increase counter in main thread every 200ms
11setInterval(() => {
12  const newVal = Atomics.add(counter, 0, 1) + 1; // Atomically increment
13  console.log('Main incremented ->', newVal);
14}, 200);
15
16// Listen for messages (optional)
17worker.onmessage = (e) => {
18  console.log('From worker:', e.data);
19};
  • En este código, el hilo principal utiliza Atomics.add para incrementar el valor de forma atómica. En el lado de worker.ts, se puede acceder y manipular el mismo SharedArrayBuffer.

worker.ts (Worker de navegador)

Este es un ejemplo donde el worker recibe el mismo búfer compartido y lo decrementa o manipula periódicamente.

 1// worker.ts
 2self.onmessage = (ev: MessageEvent) => {
 3  const { sab } = ev.data as { sab: SharedArrayBuffer };
 4  const counter = new Int32Array(sab);
 5
 6  // Every 350ms, atomically add 2 (demonstration)
 7  setInterval(() => {
 8    const newVal = Atomics.add(counter, 0, 2) + 2;
 9    // Optionally notify main thread (postMessage)
10    self.postMessage(`Worker added 2 -> ${newVal}`);
11  }, 350);
12};
  • El Worker también manipula la misma memoria mediante Int32Array, y las actualizaciones se hacen sin condiciones de carrera gracias a Atomics.

Sincronización usando wait/notify

Al usar Atomics.wait y Atomics.notify, puedes suspender hilos hasta que se cumplen ciertas condiciones, permitiendo la sincronización dirigida por eventos dentro de los workers. En los navegadores, el enfoque más seguro es usar Atomics.wait dentro de un Worker.

producer.ts (Worker productor en navegador)

El productor escribe datos y notifica al consumidor usando notify.

 1// producer.ts (worker)
 2self.onmessage = (ev: MessageEvent) => {
 3  const { sab } = ev.data as { sab: SharedArrayBuffer };
 4  const state = new Int32Array(sab); // state[0] will be 0=empty,1=filled
 5
 6  // produce every 500ms
 7  setInterval(() => {
 8    // produce: set state to 1 (filled)
 9    Atomics.store(state, 0, 1);
10    Atomics.notify(state, 0, 1); // wake one waiter
11    self.postMessage('produced');
12  }, 500);
13};

consumer.ts (Worker consumidor en navegador)

El consumidor espera con Atomics.wait y reanuda el procesamiento cuando recibe la notificación del productor.

 1// consumer.ts (worker)
 2self.onmessage = (ev: MessageEvent) => {
 3  const { sab } = ev.data as { sab: SharedArrayBuffer };
 4  const state = new Int32Array(sab);
 5
 6  // consumer loop
 7  (async function consumeLoop() {
 8    while (true) {
 9      // if state is 0 => wait until notified (value changes)
10      while (Atomics.load(state, 0) === 0) {
11        // Blocks this worker until notified or timeout
12        Atomics.wait(state, 0, 0);
13      }
14      // consume (reset to 0)
15      // (processing simulated)
16      // read data from another shared buffer in real scenarios
17      Atomics.store(state, 0, 0);
18      // continue loop
19      self.postMessage('consumed');
20    }
21  })();
22};
  • En este patrón, el productor notifica al consumidor mediante Atomics.notify y el consumidor espera eficientemente usando Atomics.wait. Atomics.wait no puede llamarse en el hilo principal en los navegadores. Para evitar que la interfaz de usuario se congele, Atomics.wait está restringido para su uso solo dentro de los Workers.

Ejemplo práctico con Node.js (worker_threads)

En un entorno Node.js, puedes manejar la funcionalidad de SharedArrayBuffer usando worker_threads. A continuación se muestra un ejemplo tipado en TypeScript para Node.js.

main-node.ts

El hilo principal crea el búfer y lo pasa al Worker.

 1// main-node.ts
 2import { Worker } from 'worker_threads';
 3import path from 'path';
 4
 5// Create SharedArrayBuffer for one 32-bit integer
 6const sab = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
 7const counter = new Int32Array(sab);
 8
 9// Spawn worker and pass sab via workerData
10const worker = new Worker(path.resolve(__dirname, 'worker-node.js'), {
11  workerData: { sab }
12});
13
14// Log messages from worker
15worker.on('message', (msg) => console.log('Worker:', msg));
16
17// Increment in main thread periodically
18setInterval(() => {
19  const val = Atomics.add(counter, 0, 1) + 1;
20  console.log('Main increment ->', val);
21}, 300);

worker-node.ts

Este ejemplo usa parentPort y workerData de worker_threads en el lado del Worker.

 1// worker-node.ts
 2import { parentPort, workerData } from 'worker_threads';
 3
 4const sab = workerData.sab as SharedArrayBuffer;
 5const counter = new Int32Array(sab);
 6
 7// Periodically add 5
 8setInterval(() => {
 9  const val = Atomics.add(counter, 0, 5) + 5;
10  parentPort?.postMessage(`Added 5 -> ${val}`);
11}, 700);
  • En Node.js, no hay restricciones de COOP o COEP como en los navegadores, por lo que la memoria compartida puede manejarse fácilmente solo usando worker_threads. Al usar TypeScript, presta atención a si estás compilando con las configuraciones de CommonJS o ESM.

Puntos sobre tipado en TypeScript

SharedArrayBuffer y Atomics están incluidos en las definiciones de tipo estándar del DOM y de las bibliotecas, por lo que puedes usarlos directamente en TypeScript. Al intercambiar mensajes con Workers, es más seguro definir interfaces y especificar los tipos claramente.

1// Example: typed message
2type WorkerMessage = { type: 'init'; sab: SharedArrayBuffer } | { type: 'ping' };
  • Definir los tipos explícitamente hace que el manejo de postMessage y onmessage sea más seguro y permite la verificación de tipos.

Casos de uso prácticos

SharedArrayBuffer no siempre es necesario, pero es altamente efectivo en situaciones donde se requieren las ventajas de velocidad de la memoria compartida. Comprender las situaciones en las que es efectivo conduce a elecciones tecnológicas apropiadas.

  • Es adecuado para procesamiento de baja latencia que requiere búferes compartidos de alta velocidad, y puede ser utilizado en procesamiento de audio/video en tiempo real o física de juegos.
  • Para el intercambio de datos simple o transferencia de grandes cantidades de datos, puede ser más fácil usar Transferable ArrayBuffer o postMessage en vez de SharedArrayBuffer.

Limitaciones y seguridad

Para usar SharedArrayBuffer en el navegador, se requiere aislamiento de origen cruzado: establece COOP en same-origin-allow-popups y COEP en require-corp. SharedArrayBuffer estará deshabilitado a menos que se cumplan estos requisitos.

Consejos de rendimiento y depuración

Las operaciones atómicas (Atomics) son rápidas, pero las esperas frecuentes y la sincronización excesiva pueden aumentar la latencia.

Se deben revisar los siguientes puntos para manejar la memoria compartida de manera segura y eficiente.

  • Las vistas como Int32Array deben manejarse con la alineación de bytes adecuada.
  • Debes tener claro qué índices del mismo búfer compartido son usados por cada proceso, y mantener convenciones consistentes en tu código.
  • Si tratas un SharedArrayBuffer como un ArrayBuffer normal, ocurrirán condiciones de carrera. Escribir en el mismo índice desde múltiples hilos sin usar Atomics es peligroso.

Resumen

Al utilizar correctamente SharedArrayBuffer y Atomics, puedes lograr operaciones de memoria compartida seguras y rápidas incluso en TypeScript. Comenzar con patrones de sincronización simples como contadores o señales es más fácil de entender, y al gestionar cuidadosamente la sincronización y los índices correctos, puedes ser efectivo incluso en escenarios de baja latencia.

Puedes seguir el artículo anterior utilizando Visual Studio Code en nuestro canal de YouTube. Por favor, también revisa nuestro canal de YouTube.

YouTube Video