WebVR Partea a 2-a: Lucrătorii web și computerul Edge browser
Publicat: 2022-03-11Simulatorul nostru de astrofizică este alimentat de un amestec puternic de combustibil pentru rachete de speranță, hype și acces la o nouă putere de calcul.
Putem accesa această putere de calcul cu lucrători web . Dacă sunteți deja familiarizat cu lucrătorii web, poate doriți să înțelegeți codul și să treceți la WebAssembly, despre care va fi discutat în următorul articol.
JavaScript a devenit cel mai instalat, învățat și mai accesibil limbaj de programare, deoarece a adus câteva caracteristici incredibil de utile pe web-ul static:
- Buclă de evenimente cu un singur thread
- Cod asincron
- Colectarea gunoiului
- Date fără tastare rigidă
Single-thread înseamnă că nu trebuie să ne îngrijorăm prea mult cu privire la complexitatea și capcanele programării multithreaded.
Asincron înseamnă că putem trece în jurul funcțiilor ca parametri pentru a fi executați mai târziu - ca evenimente în bucla de evenimente.
Aceste funcții și investiția masivă a Google în performanța motorului JavaScript V8 al Chrome, împreună cu instrumentele bune pentru dezvoltatori, au făcut din JavaScript și Node.js alegerea perfectă pentru arhitecturile de microservicii.
Execuția cu un singur thread este excelentă și pentru producătorii de browsere care trebuie să izoleze și să ruleze în siguranță toate runtimele de filă de browser infestate de spyware pe mai multe nuclee ale unui computer.
Întrebare: Cum poate o filă de browser să acceseze toate nucleele CPU ale computerului dvs.?
Răspuns: lucrători web!
Lucrători Web și Threading
Lucrătorii web folosesc bucla de evenimente pentru a transmite mesaje asincron între fire, ocolind multe dintre capcanele potențiale ale programării multithreaded.
Lucrătorii web pot fi, de asemenea, utilizați pentru a muta calculul din firul principal de UI. Aceasta permite firului principal al interfeței de utilizare să gestioneze clicurile, animația și gestionarea DOM.
Să ne uităm la un cod din depozitul GitHub al proiectului.
Dacă vă amintiți diagrama noastră de arhitectură, am delegat întreaga simulare către nBodySimulator
, astfel încât acesta să gestioneze lucrătorul web.
Dacă vă amintiți din postarea introductivă, nBodySimulator
are o funcție step()
numită la fiecare 33ms din simulare. Apelează calculateForces()
, apoi actualizează pozițiile și revopsește.
// 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() }
Contribuția lucrătorului web este de a găzdui un fir separat pentru WebAssembly. Ca limbaj de nivel scăzut, WebAssembly înțelege doar numerele întregi și flotanți. Nu putem transmite șiruri sau obiecte JavaScript - doar indicii către „memorie liniară”. Deci, pentru comoditate, ne ambalăm „corpurile” într-o serie de flotoare: arrBodies
.
Vom reveni la asta în articolul nostru despre WebAssembly și AssemblyScript.
Aici, creăm un lucrător web pentru a rula calculateForces()
într-un fir separat. Acest lucru se întâmplă mai jos, când grupăm corpurile (x, y, z, masa) într-o serie de flotoare arrBodies
, iar apoi this.worker.postMessage()
către lucrător. Returnăm o promisiune pe care lucrătorul o va rezolva mai târziu în 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 }
De sus, browser-ul GET index.html
care rulează main.js
care creează un new nBodySimulator()
și în constructorul său găsim setupWebWorker()
.
// nBodySimulator.js /** * Our n-body system simulator */ export class nBodySimulator { constructor() { this.setupWebWorker() ...
new nBodySimulator()
locuiește în firul principal de UI, iar setupWebWorker()
creează lucrătorul web prin preluarea workerWasm.js
din rețea.
// 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}`); } ...
La new Worker()
, browserul preia și rulează workerWasm.js
într-un timp de rulare (și fir) JavaScript separat și începe să transmită mesaje.
Apoi, workerWasm.js
intră în miezul WebAssembly, dar este de fapt doar o singură this.onmessage()
care conține o instrucțiune switch()
.
Amintiți-vă că lucrătorii web nu pot accesa rețeaua, așa că firul de execuție principal al interfeței de utilizare trebuie să transmită codul WebAssembly compilat în worker-ul web ca mesaj de resolve("action packed")
. Vom aprofunda acest lucru în următoarea postare.
// 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 }) } }
Revenind la metoda setupWebWorker()
din clasa noastră nBodySimulation
, ascultăm mesajele web worker folosind același 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 } } } ...
În acest exemplu, calculateForces()
creează și returnează o promisiune salvând resolve()
și reject()
ca self.forcesReject()
și self.forcesResolve()
.
În acest fel, worker.onmessage()
poate rezolva promisiunea creată în calculateForces()
.
Dacă vă amintiți funcția step()
a buclei noastre de simulare:
/** * 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}`) }
Acest lucru ne permite să omitem calculateForces()
și să aplicăm din nou forțele anterioare dacă WebAssembly încă calculează.
Această funcție pas se declanșează la fiecare 33 ms. Dacă lucrătorul web nu este pregătit, acesta aplică și pictează forțele anterioare. Dacă calculateForces()
al unui anumit pas funcționează după începutul pasului următor, pasul următor va aplica forțe din poziția pasului anterior. Acele forțe anterioare fie sunt suficient de asemănătoare pentru a arăta „corect”, fie se întâmplă atât de repede încât să fie de neînțeles pentru utilizator. Acest compromis crește performanța percepută - chiar dacă nu este recomandat pentru călătoriile în spațiu uman real.
Ar putea fi îmbunătățit acest lucru? Da! O alternativă la setInterval
pentru funcția noastră pas este requestAnimationFrame()
.
Pentru scopul meu, acest lucru este suficient de bun pentru a explora Canvas, WebVR și WebAssembly. Dacă credeți că ceva ar putea fi adăugat sau schimbat, nu ezitați să comentați sau să contactați.
Dacă sunteți în căutarea unui design modern și complet de motor de fizică, consultați Matter.js, open-source.
Dar WebAssembly?
WebAssembly este un binar portabil care funcționează peste browsere și sisteme. WebAssembly poate fi compilat din mai multe limbaje (C/C++/Rust, etc.). Pentru propriul meu scop, am vrut să încerc AssemblyScript - un limbaj bazat pe TypeScript, care este un limbaj bazat pe JavaScript, pentru că sunt țestoase până la capăt.
AssemblyScript compilează codul TypeScript într-un binar portabil „cod obiect”, pentru a fi compilat „just-in-time” într-un nou runtime de înaltă performanță numit Wasm. Când compilați TypeScript în binarul .wasm
, este posibil să creați un format de „text asamblare web” .wat
, care poate fi citit de om, care descrie binarul.
Ultima parte a setupWebWorker()
începe următoarea noastră postare pe WebAssembly și arată cum să depășim limitările lucrătorului web privind accesul la rețea. fetch()
fișierul wasm
în firul principal de UI, apoi îl compilăm „just-in-time” într-un modul nativ wasm. postMessage()
acel modul ca un mesaj către lucrătorul 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
instanțiază apoi acel modul, astfel încât să îi putem apela funcțiile:
// 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);
Acesta este modul în care accesăm funcționalitatea WebAssembly. Dacă vă uitați la codul sursă neredactat, veți observa că ...
este o grămadă de coduri de gestionare a memoriei pentru a introduce datele noastre în dataRef
și rezultatele noastre din resultRef
. Gestionarea memoriei în JavaScript? Captivant!
Vom săpa în WebAssembly și AssemblyScript mai detaliat în următoarea postare.
Limite de execuție și memorie partajată
Mai este ceva de vorbit aici, care sunt limitele de execuție și memoria partajată.
Articolul WebAssembly este foarte tactic, așa că aici este un loc bun pentru a vorbi despre timpii de execuție. JavaScript și WebAssembly sunt runtime „emulate”. După cum a fost implementat, de fiecare dată când depășim o limită de rulare, facem o copie a datelor corpului nostru (x, y, z, masă). În timp ce copierea memoriei este ieftină, acesta nu este un design matur de înaltă performanță.
Din fericire, o mulțime de oameni foarte inteligenți lucrează la crearea de specificații și implementări ale acestor tehnologii de browser de ultimă oră.
JavaScript are SharedArrayBuffer pentru a crea un obiect de memorie partajată care ar elimina postMessage()
din (2) -> (3) pe apel și onmessage()
a arrForces
din (3) -> (2) pe rezultat .
WebAssembly are, de asemenea, un design Linear Memory care ar putea găzdui o memorie partajată pentru nBodyForces()
de la (3) -> (4). Lucrătorul web ar putea transmite, de asemenea, o memorie partajată pentru matricea de rezultate.
Alăturați-vă nouă data viitoare pentru o călătorie interesantă în gestionarea memoriei JavaScript.