WebVR Parte 2: Web Worker e Browser Edge Computing

Pubblicato: 2022-03-11

Il nostro simulatore di astrofisica è alimentato da una potente miscela di carburante per missili di speranza, clamore e accesso a una nuova potenza di calcolo.

Possiamo accedere a questa potenza di calcolo con i web worker . Se hai già familiarità con i web worker, potresti voler modificare il codice e passare a WebAssembly, che verrà discusso nel prossimo articolo.

JavaScript è diventato il linguaggio di programmazione più installato, appreso e accessibile perché ha portato alcune funzionalità incredibilmente utili al Web statico:

  • Ciclo di eventi a thread singolo
  • Codice asincrono
  • Raccolta dei rifiuti
  • Dati senza tipizzazione rigida

Single-thread significa che non dobbiamo preoccuparci molto della complessità e delle insidie ​​della programmazione multithread.

Asincrono significa che possiamo passare le funzioni come parametri da eseguire in seguito, come eventi nel ciclo degli eventi.

Queste funzionalità e l'enorme investimento di Google nelle prestazioni del motore JavaScript V8 di Chrome, insieme a buoni strumenti di sviluppo, hanno reso JavaScript e Node.js la scelta perfetta per le architetture di microservizi.

L'esecuzione a thread singolo è ottima anche per i produttori di browser che devono isolare ed eseguire in modo sicuro tutti i runtime delle schede del browser infestati da spyware su più core di un computer.

Domanda: come può una scheda del browser accedere a tutti i core della CPU del tuo computer?
Risposta: Lavoratori del Web!

Lavoratori Web e Threading

I lavoratori Web utilizzano il ciclo di eventi per passare in modo asincrono i messaggi tra i thread, aggirando molte delle potenziali insidie ​​della programmazione multithread.

I Web worker possono essere utilizzati anche per spostare il calcolo fuori dal thread dell'interfaccia utente principale. Ciò consente al thread dell'interfaccia utente principale di gestire i clic, l'animazione e la gestione del DOM.

Diamo un'occhiata al codice dal repository GitHub del progetto.

Se ricordi il nostro diagramma dell'architettura, abbiamo delegato l'intera simulazione a nBodySimulator in modo che gestisca il web worker.

Diagramma di architettura

Se ricordi dal post introduttivo, nBodySimulator ha una funzione step() chiamata ogni 33 ms della simulazione. Chiama calculateForces() , quindi aggiorna le posizioni e ridipinge.

 // 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() }

Il contributo del web worker consiste nell'ospitare un thread separato per il WebAssembly. Essendo un linguaggio di basso livello, WebAssembly comprende solo numeri interi e float. Non possiamo passare stringhe o oggetti JavaScript, solo puntatori a "memoria lineare". Quindi, per comodità, imballiamo i nostri "corpi" in una serie di float: arrBodies .

Torneremo su questo nel nostro articolo su WebAssembly e AssemblyScript.

Spostamento dei dati dentro/fuori dal web worker
Spostamento dei dati dentro/fuori dal web worker

Qui, stiamo creando un web worker per eseguire calculateForces() in un thread separato. Ciò accade di seguito quando eseguiamo il marshalling dei corpi (x, y, z, massa) in un array di float arrBodies , e quindi this.worker.postMessage() al lavoratore. Restituiamo una promessa che il lavoratore risolverà in seguito in 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 }

Dall'alto, il browser GET index.html che esegue main.js che crea un new nBodySimulator() e nel suo costruttore troviamo setupWebWorker() .

n-body-wasm-tela

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

Il nostro new nBodySimulator() risiede nel thread principale dell'interfaccia utente e setupWebWorker() crea il web worker recuperando workerWasm.js dalla rete.

 // 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}`); } ...

A new Worker() , il browser recupera ed esegue workerWasm.js in un runtime (e thread) JavaScript separato e inizia a passare i messaggi.

Quindi, workerWasm.js entra nel merito di WebAssembly ma in realtà è solo una singola funzione this.onmessage() contenente un'istruzione switch() .

Ricorda che i web worker non possono accedere alla rete, quindi il thread dell'interfaccia utente principale deve passare il codice WebAssembly compilato nel web worker come messaggio resolve("action packed") . Ne approfondiremo nel prossimo post.

 // 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 }) } }

Tornando al metodo setupWebWorker() della nostra classe nBodySimulation , ascoltiamo i messaggi del web worker usando lo stesso 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 } } } ...

In questo esempio, CalcutForces calculateForces() crea e restituisce una promessa di salvataggio resolve() e require( reject() come self.forcesReject() e self.forcesResolve() .

In questo modo, worker.onmessage() può risolvere la promessa creata in calculateForces() .

Se ricordi la funzione step() del nostro ciclo di simulazione:

 /** * 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}`) }

Questo ci consente di saltare calculateForces() e riapplicare le forze precedenti se il WebAssembly sta ancora calcolando.

Questa funzione di passaggio si attiva ogni 33 ms. Se il web worker non è pronto, applica e dipinge le forze precedenti. Se il calculateForces() di un passaggio particolare funziona oltre l'inizio del passaggio successivo, il passaggio successivo applicherà le forze dalla posizione del passaggio precedente. Quelle forze precedenti o sono abbastanza simili da sembrare "giuste" o accadono così velocemente da essere incomprensibili per l'utente. Questo compromesso aumenta le prestazioni percepite, anche se non è raccomandato per i viaggi spaziali umani effettivi.

Questo potrebbe essere migliorato? Sì! Un'alternativa a setInterval per la nostra funzione step è requestAnimationFrame() .

Per il mio scopo, questo è abbastanza buono per esplorare Canvas, WebVR e WebAssembly. Se ritieni che qualcosa possa essere aggiunto o sostituito, sentiti libero di commentare o di metterti in contatto.

Se stai cercando un motore fisico moderno e completo, dai un'occhiata al Matter.js open source.

Che dire di WebAssembly?

WebAssembly è un binario portatile che funziona su browser e sistemi. WebAssembly può essere compilato da molti linguaggi (C/C++/Rust, ecc.). Per il mio scopo, volevo provare AssemblyScript, un linguaggio basato su TypeScript, che è un linguaggio basato su JavaScript, perché è una tartaruga fino in fondo.

AssemblyScript compila il codice TypeScript in un binario "codice oggetto" portatile, per essere compilato "just-in-time" in un nuovo runtime ad alte prestazioni chiamato Wasm. Quando si compila TypeScript nel binario .wasm , è possibile creare un .wat leggibile "testo assembly web" che descrive il binario.

L'ultima parte di setupWebWorker() inizia il nostro prossimo post su WebAssembly e mostra come superare i limiti di accesso alla rete dei web worker. Recuperiamo fetch() il file wasm nel thread principale dell'interfaccia utente, quindi lo compiliamo "just-in-time" in un modulo wasm nativo. postMessage() quel modulo come messaggio per il web worker:

 // 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 quindi istanzia quel modulo in modo che possiamo chiamarne le funzioni:

 // 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);

Questo è il modo in cui accediamo alla funzionalità WebAssembly. Se stai guardando il codice sorgente non redatto, noterai che ... è un insieme di codice di gestione della memoria per inserire i nostri dati in dataRef e i nostri risultati in resultRef . Gestione della memoria in JavaScript? Eccitante!

Approfondiremo WebAssembly e AssemblyScript in modo più dettagliato nel prossimo post.

Confini di esecuzione e memoria condivisa

C'è qualcos'altro di cui parlare qui, che sono i limiti di esecuzione e la memoria condivisa.

Quattro copie dei dati dei nostri Organismi
Quattro copie dei dati dei nostri Organismi

L'articolo di WebAssembly è molto tattico, quindi ecco un buon posto per parlare di runtime. JavaScript e WebAssembly sono runtime "emulati". Come implementato, ogni volta che superiamo un limite di runtime, stiamo facendo una copia dei nostri dati corporei (x, y, z, massa). Sebbene la copia della memoria sia economica, questo non è un design maturo ad alte prestazioni.

Fortunatamente, molte persone molto intelligenti stanno lavorando alla creazione di specifiche e implementazioni di queste tecnologie browser all'avanguardia.

JavaScript ha SharedArrayBuffer per creare un oggetto di memoria condivisa che eliminerebbe la copia di postMessage() da (2) -> (3) sulla chiamata e la copia di onmessage() di arrForces da (3) -> (2) sul risultato .

WebAssembly ha anche un design di memoria lineare che potrebbe ospitare una memoria condivisa per la chiamata nBodyForces() da (3) -> (4). Il web worker potrebbe anche passare una memoria condivisa per l'array dei risultati.

Unisciti a noi la prossima volta per un emozionante viaggio nella gestione della memoria JavaScript.