Transferibles y memoria compartida: mover o compartir
Transferir mueve la propiedad de un búfer en coste constante y compartir memoria de verdad exige aislamiento de origen cruzado, dos escapatorias a la copia con precios muy distintos.
El clonado estructurado es la vía por defecto y su coste crece con lo que envías, así que la plataforma ofrece dos salidas. La primera es elegante y casi gratuita: en lugar de copiar un búfer, ceder su propiedad, dejando al emisor sin él —semántica de movimiento, la misma idea que Rust convirtió en el centro de su sistema de tipos, aquí impuesta en tiempo de ejecución por el motor—. La segunda es la puerta grande: memoria auténticamente compartida entre agentes, sin copia y sin cesión, los mismos bytes vistos desde dos hilos a la vez. La primera no tiene contraindicaciones serias y deberías usarla siempre que puedas. La segunda te devuelve todo lo que el paso de mensajes había eliminado, cobra un peaje de despliegue considerable y existe por razones que tienen menos que ver con el rendimiento que con Spectre.
- Distinguir con precisión copiar, transferir y compartir, y saber cuál aplica a cada tipo.
- Manejar el desprendimiento de un
ArrayBuffery la trampa de las vistas que quedan huérfanas. - Entender qué exige el aislamiento de origen cruzado y por qué el navegador lo exige.
- Usar
Atomicspara coordinar sin carreras y saber cuándoSharedArrayBufferno es la respuesta.
Transferir es mover, no copiar
Un objeto transferible puede cambiar de agente sin que se copie su contenido: lo que viaja es la propiedad. El motor desliga el búfer del agente emisor y lo vincula al receptor, y como los bytes ya estaban en el montón compartido del proceso, la operación es de coste constante sin importar si son diez kilobytes o quinientos megabytes. El precio lo paga el emisor en el acto: su referencia queda desprendida, con longitud cero y con cualquier acceso convertido en error.
const buf = new ArrayBuffer(256 * 1024 * 1024); // un cuarto de giga
worker.postMessage({ tipo: 'analizar', buf }, [buf]);
// forma equivalente y mas legible:
// worker.postMessage({ tipo: 'analizar', buf }, { transfer: [buf] });
buf.byteLength; // 0: ya no es tuyo
new Uint8Array(buf)[0]; // TypeError: el buffer esta desprendido
La trampa que atrapa a todo el mundo la primera vez es que una vista tipada no es transferible; lo es su búfer. Y como varias vistas pueden compartir un mismo búfer, transferirlo las desprende todas de golpe, incluidas las que no mencionaste y las que guardabas en otra parte del programa.
const buf = new ArrayBuffer(1024);
const cabecera = new Uint8Array(buf, 0, 16);
const cuerpo = new Float32Array(buf, 16);
worker.postMessage(cuerpo, [cuerpo.buffer]); // se transfiere el buffer entero
cabecera.length; // 0: victima colateral, aunque no la enviaste
Búferes y vistas
ArrayBuffer es el transferible canónico. Los datos binarios de un fichero, un audio decodificado o un tensor cruzan la frontera en tiempo constante en lugar de copiarse.
Puertos de mensaje
Un MessagePort se transfiere para montar topologías directas entre Workers. Es el mecanismo de la lección anterior y también es un transferible.
Píxeles y lienzos
ImageBitmap y OffscreenCanvas se ceden al Worker para rasterizar fuera del hilo principal. El lienzo pasa a ser suyo y el documento deja de poder pintarlo.
Streams
ReadableStream, WritableStream y TransformStream son transferibles, lo que permite consumir con contrapresión a través de la frontera en vez de mover un bloque gigante.
El patrón que mejor rentabiliza todo esto es el búfer de ida y vuelta: en lugar de crear memoria nueva en cada mensaje, el hilo principal transfiere un búfer al Worker, el Worker trabaja sobre él y lo transfiere de vuelta. Cada travesía es de coste constante, no se asigna memoria, no se recolecta basura y el recolector deja de aparecer en tus perfiles. Es exactamente la disciplina de los sistemas de audio y de gráficos, donde asignar memoria en el camino crítico está sencillamente prohibido.
Memoria realmente compartida
SharedArrayBuffer es otra cosa. No se copia ni se transfiere: se comparte. Los dos agentes reciben una referencia a la misma región física, y una escritura hecha en un hilo es visible desde el otro sin ningún mensaje de por medio. Es el modelo de memoria clásico devuelto a JavaScript, con todo lo bueno y todo lo malo, y de hecho ECMAScript tuvo que especificar un modelo de memoria completo —con accesos atómicos secuencialmente consistentes y accesos normales sin garantías de orden— para que esto tuviera un significado definido.
if (!self.crossOriginIsolated) {
throw new Error('sin aislamiento no hay SharedArrayBuffer');
}
const sab = new SharedArrayBuffer(1024);
const estado = new Int32Array(sab);
worker.postMessage(sab); // ni copia ni transferencia: comparticion
Como no hay copia, tampoco hay atomicidad regalada, y volvemos a necesitar las herramientas de siempre. El objeto Atomics provee operaciones indivisibles sobre vistas enteras y, sobre ellas, un mecanismo de espera y notificación que es el ladrillo con el que se construyen semáforos, colas sin cerrojos y barreras.
// productor, en cualquier agente
Atomics.store(estado, 0, 1);
Atomics.notify(estado, 0, 1); // despierta a un durmiente
// consumidor, SOLO dentro de un Worker
Atomics.wait(estado, 0, 0); // bloquea el hilo hasta que deje de valer 0
const v = Atomics.load(estado, 0);
// el hilo principal no puede bloquear, pero puede esperar sin bloquear
const { value } = Atomics.waitAsync(estado, 0, 0);
await value;
Fíjate en la asimetría, porque resume el nivel entero: Atomics.wait lanza excepción en el hilo principal y funciona en un Worker, por la misma razón que el manejador síncrono de OPFS. El derecho a bloquear pertenece a quien no es dueño de la pantalla.
flowchart TB A[Mensaje con objeto normal] --> A1[Copia profunda del grafo] A1 --> A2[Coste segun numero de nodos] B[Mensaje con transferencia] --> B1[Se mueve la propiedad del buffer] B1 --> B2[Coste constante y origen desprendido] C[SharedArrayBuffer] --> C1[Los dos agentes ven los mismos bytes] C1 --> C2[Coste cero y carreras de vuelta] style A2 fill:#f9e2af,color:#11111b style B2 fill:#a6e3a1,color:#11111b style C2 fill:#f38ba8,color:#11111b
El peaje del aislamiento
SharedArrayBuffer estuvo desactivado en todos los navegadores a partir de enero de 2018 y volvió con condiciones. El motivo es Spectre: memoria compartida más un reloj de alta resolución equivale a un temporizador construible por el propio programa, y con un temporizador suficientemente fino se pueden medir las diferencias de caché que delatan el contenido de memoria que no deberías poder leer. Ninguna de las dos piezas es peligrosa por separado; juntas convierten cualquier página en un lector potencial de la memoria de su proceso.
La solución fue cambiar la pregunta. En vez de intentar impedir la lectura —imposible, es una propiedad del hardware— el navegador exige que en ese proceso no haya nada que valga la pena leer. Eso es el aislamiento de origen cruzado, y se activa con dos cabeceras:
// en el servidor: las dos cabeceras que abren la puerta
respuesta.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
respuesta.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
// en el cliente: la unica comprobacion que vale
self.crossOriginIsolated; // true solo si ambas llegaron y todo coopero
La primera rompe la relación con cualquier ventana que te haya abierto o que abras tú. La segunda es la cara: obliga a que todo recurso de otro origen que cargues declare explícitamente su consentimiento, con Cross-Origin-Resource-Policy o con CORS. Fuentes de un CDN, imágenes de terceros, iframes de vídeo, píxeles de analítica, mapas incrustados: cualquiera que no coopere deja de cargar. La variante credentialless alivia el trámite enviando las peticiones sin credenciales, pero no elimina el trabajo de auditar todo lo que tu página incrusta.
Activar el aislamiento en una aplicación madura rara vez fracasa por dificultad de configuración: fracasa porque hay una etiqueta de márketing, un chat de soporte o un reproductor incrustado que no envía las cabeceras y cuyo proveedor no piensa cambiarlas. Antes de diseñar una arquitectura que dependa de SharedArrayBuffer, haz el inventario de terceros. Y comprueba siempre self.crossOriginIsolated en tiempo de ejecución con un camino alternativo preparado, porque la cabecera puede caerse en un despliegue y tu aplicación no debería morir con ella.
Cuándo vale la pena
La respuesta honesta es: menos veces de las que parece. Para datos de aplicación —listas, documentos, registros— la memoria compartida es casi siempre la herramienta equivocada, porque esos datos son grafos de objetos y SharedArrayBuffer solo contiene bytes; tendrías que serializarlos a mano a un formato binario y volver a interpretarlos, reinventando por el camino la gestión de memoria que el motor ya te da. Hay tres escenarios donde sí es la respuesta correcta, y son bastante reconocibles.
El primero son los hilos de WebAssembly. Un módulo compilado desde código con pthread necesita que su memoria lineal sea compartida; sin aislamiento no hay hilos en WebAssembly, y con ellos llegan la compresión, el cifrado o el motor de física repartidos entre núcleos. El segundo son los búferes circulares de tiempo real, típicamente entre un AudioWorklet y un Worker: el productor escribe y el consumidor lee sin asignar memoria ni encolar mensajes, porque un fotograma de audio no admite ni una pausa del recolector. El tercero es el puente síncrono: el patrón, hoy clásico, de un Worker que se bloquea con Atomics.wait sobre un índice compartido mientras otro agente hace el trabajo asíncrono y lo despierta, que es lo que permitió durante años ofrecer entrada y salida síncrona a motores como SQLite antes de que OPFS trajera su propio manejador.
Fuera de esos tres casos, la combinación de mensajes gruesos y búferes transferidos cubre prácticamente todo con mejor relación entre esfuerzo y riesgo. Y hay un argumento adicional que suele decidir la cuestión: transferir no exige cabeceras, no rompe a terceros, funciona en todos lados desde hace más de una década y nunca ha reintroducido una carrera de datos en la vida de nadie.
Copiar, transferir y compartir parecen tres puntos de una escala de rendimiento, y leerlos así es el error que lleva a elegir siempre el más rápido que el navegador permita. Son en realidad tres respuestas distintas a una pregunta que no es de velocidad sino de gobierno: quién es el dueño de estos bytes y quién responde de su estado. Copiar significa que cada agente tiene su propio dato y ninguno puede corromper al otro, al precio de que puedan divergir. Transferir significa que hay un dueño único y que la propiedad se cede en un instante nítido, verificable, que el motor impone dejando el origen desprendido para que cualquier acceso posterior sea un error inmediato en vez de un fallo silencioso —exactamente la razón por la que Rust convirtió el movimiento en una construcción del lenguaje, solo que aquí el chequeo ocurre en ejecución en lugar de en compilación—. Compartir significa que no hay dueño, y por tanto que la corrección ya no se deduce leyendo el código de un agente: hay que razonar sobre entrelazados posibles, sobre visibilidad y sobre órdenes de memoria, que es la disciplina que la web se había ahorrado. Vista así, la escala no va de lento a rápido sino de garantía fuerte a garantía nula, y el aislamiento de origen cruzado deja de parecer un trámite burocrático para revelarse como lo que es: la única cuota de entrada que la plataforma sabe cobrar por reabrir una puerta que el propio hardware había demostrado ser peligrosa. Fíjate además en la coherencia del conjunto, porque es notable: la misma plataforma que te niega el DOM en un Worker, que te niega el bloqueo en el hilo principal y que te niega la memoria compartida sin aislamiento no está siendo restrictiva por costumbre, está aplicando un principio único —cada capacidad peligrosa se concede únicamente allí donde el daño que puede causar está acotado—. Cuando elijas entre copiar, transferir y compartir no estás afinando un milisegundo: estás decidiendo qué invariantes tendrá que sostener tu equipo durante los próximos años, y qué categoría de fallo estás autorizando a existir.
- Envía un búfer de cien megabytes a un Worker copiando y transfiriendo, y compara los tiempos. Comprueba que el segundo es plano al variar el tamaño.
- Crea dos vistas sobre un mismo búfer, transfiere una y comprueba en la consola qué le ha pasado a la otra.
- Implementa el patrón de ida y vuelta: un único búfer que rebota entre hilo principal y Worker mil veces sin asignar memoria nueva.
- Activa las dos cabeceras de aislamiento en tu servidor de desarrollo, comprueba
self.crossOriginIsolatedy anota qué recursos de terceros dejaron de cargar. - Escribe un semáforo mínimo con
Atomics.waityAtomics.notifyentre dos Workers, y después comprueba qué ocurre exactamente si intentas ejecutar la espera en el hilo principal.