WebVR Parte 2: trabajadores web y computación perimetral del navegador

Publicado: 2022-03-11

Nuestro simulador de astrofísica funciona con una potente mezcla de combustible para cohetes de esperanza, exageración y acceso a un nuevo poder de cómputo.

Podemos acceder a este poder de cómputo con trabajadores web . Si ya está familiarizado con los trabajadores web, es posible que desee asimilar el código y saltar a WebAssembly, que se analizará en el próximo artículo.

JavaScript se convirtió en el lenguaje de programación más instalado, aprendido y accesible porque trajo algunas características increíblemente útiles a la web estática:

  • Bucle de eventos de un solo subproceso
  • Código asíncrono
  • Recolección de basura
  • Datos sin tipeo rígido

Un solo subproceso significa que no tenemos que preocuparnos mucho por la complejidad y las trampas de la programación multiproceso.

Asíncrono significa que podemos pasar funciones como parámetros para que se ejecuten más tarde, como eventos en el bucle de eventos.

Estas características y la gran inversión de Google en el rendimiento del motor JavaScript V8 de Chrome, junto con buenas herramientas para desarrolladores, hicieron de JavaScript y Node.js la elección perfecta para las arquitecturas de microservicios.

La ejecución de subproceso único también es ideal para los fabricantes de navegadores que tienen que aislar y ejecutar de forma segura todos los tiempos de ejecución de las pestañas del navegador infestadas de spyware en los múltiples núcleos de una computadora.

Pregunta: ¿Cómo puede una pestaña del navegador acceder a todos los núcleos de CPU de su computadora?
Respuesta: ¡Trabajadores web!

Web Workers y Threading

Los trabajadores web utilizan el bucle de eventos para pasar mensajes de forma asincrónica entre subprocesos, evitando muchos de los peligros potenciales de la programación multiproceso.

Los trabajadores web también se pueden usar para mover el cálculo fuera del subproceso principal de la interfaz de usuario. Esto permite que el subproceso principal de la interfaz de usuario maneje los clics, la animación y la administración del DOM.

Veamos un poco de código del repositorio de GitHub del proyecto.

Si recuerda nuestro diagrama de arquitectura, delegamos toda la simulación a nBodySimulator para que administre el trabajador web.

Diagrama de arquitectura

Si recuerda la publicación de introducción, nBodySimulator tiene una función de step() que se llama cada 33 ms de la simulación. Llama a calculateForces() , luego actualiza las posiciones y vuelve a pintar.

 // Methods from class nBodySimulator /** * The simulation loop */ start() { // This is the simulation loop. step() calls visualize() const step = this.step.bind(this) setInterval(step, this.simulationSpeed) } /** * A step in the simulation loop. */ async step() { // Skip calculation if worker not ready. Runs every 33ms (30fps), expect it to skip. if (this.ready()) { await this.calculateForces() } else { console.log(`Skipping calculation: WorkerReady: ${this.workerReady} WorkerCalculating: ${this.workerCalculating}`) } // Remove any "debris" that has traveled out of bounds - this is for the button this.trimDebris() // Now Update forces. Reuse old forces if we skipped calculateForces() above this.applyForces() // Ta-dah! this.visualize() }

La contribución del trabajador web es alojar un subproceso separado para WebAssembly. Como lenguaje de bajo nivel, WebAssembly solo entiende números enteros y flotantes. No podemos pasar cadenas u objetos de JavaScript, solo punteros a "memoria lineal". Entonces, por conveniencia, empaquetamos nuestros "cuerpos" en una matriz de flotadores: arrBodies .

Volveremos a esto en nuestro artículo sobre WebAssembly y AssemblyScript.

Mover datos dentro/fuera del trabajador web
Mover datos dentro/fuera del trabajador web

Aquí, estamos creando un trabajador web para ejecutar calculateForces() en un hilo separado. Esto sucede a continuación cuando clasificamos los cuerpos (x, y, z, masa) en una matriz de cuerpos flotantes arrBodies y luego this.worker.postMessage() al trabajador. Devolvemos una promesa que el trabajador resolverá más adelante en this.worker.onMessage() .

 // src/nBodySimulator.js /** * Use our web worker to calculate the forces to apply on our bodies. */ calculateForces() { this.workerCalculating = true this.arrBodies = [] // Copy data to array into this.arrBodies ... // return promise that worker.onmessage will fulfill const ret = new Promise((resolve, reject) => { this.forcesResolve = resolve this.forcesReject = reject }) // postMessage() to worker to start calculation // Execution continues in workerWasm.js worker.onmessage() this.worker.postMessage({ purpose: 'nBodyForces', arrBodies: this.arrBodies, }) // Return promise for completion // Promise is resolve()d in this.worker.onmessage() below. // Once resolved, execution continues in step() above - await this.calculateForces() return ret }

Desde arriba, el navegador GET's index.html que ejecuta main.js que crea un new nBodySimulator() y en su constructor encontramos setupWebWorker() .

n-cuerpo-wasm-lienzo

 // nBodySimulator.js /** * Our n-body system simulator */ export class nBodySimulator { constructor() { this.setupWebWorker() ...

Nuestro new nBodySimulator() vive en el subproceso principal de la interfaz de usuario, y setupWebWorker() crea el trabajador web al obtener el workerWasm.js de la red.

 // nBodySimulator.js // Main UI thread - Class nBodySimulator method setupWebWorker() { // Create a web worker (separate thread) that we'll pass the WebAssembly module to. this.worker = new Worker("workerWasm.js"); // Console errors from workerWasm.js this.worker.onerror = function (evt) { console.log(`Error from web worker: ${evt.message}`); } ...

En new Worker() , el navegador obtiene y ejecuta workerWasm.js en un tiempo de ejecución (y un hilo) de JavaScript separado, y comienza a pasar mensajes.

Luego, workerWasm.js se mete en la arena de WebAssembly, pero en realidad es solo una sola función this.onmessage() que contiene una instrucción switch() .

Recuerde que los trabajadores web no pueden acceder a la red, por lo que el subproceso principal de la interfaz de usuario debe pasar el código WebAssembly compilado al trabajador web como un mensaje resolve("action packed") . Profundizaremos en eso en la próxima publicación.

 // workerWasm.js - runs in a new, isolated web worker runtime (and thread) this.onmessage = function (evt) { // message from UI thread var msg = evt.data switch (msg.purpose) { // Message: Load new wasm module case 'wasmModule': // Instantiate the compiled module we were passed. ... // Tell nBodySimulation.js we are ready this.postMessage({ purpose: 'wasmReady' }) return // Message: Given array of floats describing a system of bodies (x, y, z, mass), // calculate the Grav forces to be applied to each body case 'nBodyForces': ... // Do the calculations in this web worker thread synchronously const resultRef = wasm.nBodyForces(dataRef); ... // See nBodySimulation.js' this.worker.onmessage return this.postMessage({ purpose: 'nBodyForces', arrForces }) } }

Volviendo al método setupWebWorker() de nuestra clase nBodySimulation , escuchamos los mensajes del trabajador web usando el mismo onmessage() + switch() .

 // Continuing class nBodySimulator's setupWebWorker() in the main UI thread // Listen for messages from workerWasm.js postMessage() const self = this this.worker.onmessage = function (evt) { if (evt && evt.data) { // Messages are dispatched by purpose const msg = evt.data switch (msg.purpose) { // Worker's reply that it has loaded the wasm module we compiled and sent. Let the magic begin! // See postmessage at the bottom of this function. case 'wasmReady': self.workerReady = true break // wasm has computed forces for us // Response to postMessage() in nBodySimulator.calculateForces() above case 'nBodyForces': self.workerCalculating = false // Resolve await this.calculateForces() in step() above if (msg.error) { self.forcesReject(msg.error) } else { self.arrForces = msg.arrForces self.forcesResolve(self.arrForces) } break } } } ...

En este ejemplo, calculateForces() crea y devuelve una promesa que guarda resolve() y reject() como self.forcesReject() y self.forcesResolve() .

De esta forma, worker.onmessage() puede resolver la promesa creada en computeForces( calculateForces() .

Si recuerda la función step() de nuestro bucle de simulación:

 /** * This is the simulation loop. */ async step() { // Skip calculation if worker not ready. Runs every 33ms (30fps), expect it to skip. if (this.ready()) { await this.calculateForces() } else { console.log(`Skipping calculation: WorkerReady: ${this.workerReady} WorkerCalculating: ${this.workerCalculating}`) }

Esto nos permite omitir las fuerzas de calculateForces() y volver a aplicar las fuerzas anteriores si WebAssembly todavía está calculando.

Esta función de paso se activa cada 33 ms. Si el trabajador web no está listo, aplica y pinta las fuerzas anteriores. Si la función de calculateForces() de un paso en particular funciona más allá del inicio del siguiente paso, el siguiente paso aplicará fuerzas desde la posición del paso anterior. Esas fuerzas anteriores son lo suficientemente similares como para parecer "correctas" o ocurren tan rápido que son incomprensibles para el usuario. Esta compensación aumenta el rendimiento percibido, incluso si no se recomienda para viajes espaciales humanos reales.

¿Se podría mejorar esto? ¡Sí! Una alternativa a setInterval para nuestra función de paso es requestAnimationFrame() .

Para mi propósito, esto es lo suficientemente bueno para explorar Canvas, WebVR y WebAssembly. Si cree que se podría agregar o cambiar algo, no dude en comentar o ponerse en contacto.

Si está buscando un diseño de motor de física moderno y completo, consulte Matter.js de código abierto.

¿Qué pasa con WebAssembly?

WebAssembly es un binario portátil que funciona en todos los navegadores y sistemas. WebAssembly se puede compilar desde muchos lenguajes (C/C++/Rust, etc.). Para mi propio propósito, quería probar AssemblyScript, un lenguaje basado en TypeScript, que es un lenguaje basado en JavaScript, porque es tortugas hasta el final.

AssemblyScript compila el código TypeScript en un binario de "código de objeto" portátil, para ser compilado "justo a tiempo" en un nuevo tiempo de ejecución de alto rendimiento llamado Wasm. Al compilar el TypeScript en el binario .wasm , es posible crear un formato de "texto de ensamblaje web" legible por humanos .wat que describa el binario.

La última parte de setupWebWorker() comienza nuestra próxima publicación en WebAssembly y muestra cómo superar las limitaciones del trabajador web en el acceso a la red. Obtenemos fetch() el archivo wasm en el subproceso principal de la interfaz de usuario, luego lo compilamos "justo a tiempo" en un módulo wasm nativo. postMessage() ese módulo como un mensaje para el trabajador web:

 // completing setupWebWorker() in the main UI thread … // Fetch and compile the wasm module because web workers cannot fetch() WebAssembly.compileStreaming(fetch("assembly/nBodyForces.wasm")) // Send the compiled wasm module to the worker as a message .then(wasmModule => { self.worker.postMessage({ purpose: 'wasmModule', wasmModule }) }); } }

workerWasm.js luego crea una instancia de ese módulo para que podamos llamar a sus funciones:

 // wasmWorker.js - web worker onmessage function this.onmessage = function (evt) { // message from UI thread var msg = evt.data switch (msg.purpose) { // Message: Load new wasm module case 'wasmModule': // Instantiate the compiled module we were passed. wasm = loader.instantiate(msg.wasmModule, importObj) // Throws // Tell nBodySimulation.js we are ready this.postMessage({ purpose: 'wasmReady' }) return case 'nBodyForces': ... // Do the calculations in this thread synchronously const resultRef = wasm.nBodyForces(dataRef);

Así es como accedemos a la funcionalidad de WebAssembly. Si está mirando el código fuente no redactado, notará que ... es un montón de código de administración de memoria para obtener nuestros datos en dataRef y nuestros resultados fuera de resultRef . ¿Gestión de memoria en JavaScript? ¡Excitante!

Profundizaremos en WebAssembly y AssemblyScript con más detalle en la próxima publicación.

Límites de ejecución y memoria compartida

Hay algo más de lo que hablar aquí, que son los límites de ejecución y la memoria compartida.

Cuatro copias de nuestros datos de cuerpos
Cuatro copias de nuestros datos de cuerpos

El artículo de WebAssembly es muy táctico, por lo que este es un buen lugar para hablar sobre los tiempos de ejecución. JavaScript y WebAssembly son tiempos de ejecución "emulados". Como se implementó, cada vez que cruzamos un límite de tiempo de ejecución, estamos haciendo una copia de los datos de nuestro cuerpo (x, y, z, masa). Si bien copiar memoria es económico, este no es un diseño maduro de alto rendimiento.

Afortunadamente, muchas personas muy inteligentes están trabajando en la creación de especificaciones e implementaciones de estas tecnologías de navegador de vanguardia.

JavaScript tiene SharedArrayBuffer para crear un objeto de memoria compartida que eliminaría la copia de postMessage() de (2) -> (3) en la llamada y la copia de onmessage() de arrForces de (3) -> (2) en el resultado .

WebAssembly también tiene un diseño de memoria lineal que podría albergar una memoria compartida para la llamada nBodyForces() desde (3) -> (4). El trabajador web también podría pasar una memoria compartida para la matriz de resultados.

Únase a nosotros la próxima vez para un emocionante viaje a la gestión de memoria de JavaScript.