WebVR Partie 2 : Travailleurs Web et calcul en périphérie de navigateur
Publié: 2022-03-11Notre simulateur d'astrophysique est propulsé par un puissant mélange de carburant de fusée d'espoir, de battage médiatique et d'accès à une nouvelle puissance de calcul.
Nous pouvons accéder à cette puissance de calcul avec les web workers . Si vous connaissez déjà les travailleurs Web, vous voudrez peut-être approfondir le code et passer à WebAssembly, qui sera abordé dans le prochain article.
JavaScript est devenu le langage de programmation le plus installé, le plus appris et le plus accessible car il a apporté des fonctionnalités incroyablement utiles au Web statique :
- Boucle d'événement à thread unique
- Code asynchrone
- Collecte des ordures
- Données sans typage rigide
Le monothread signifie que nous n'avons pas à nous soucier de la complexité et des pièges de la programmation multithread.
Asynchrone signifie que nous pouvons transmettre des fonctions en tant que paramètres à exécuter ultérieurement - en tant qu'événements dans la boucle d'événements.
Ces fonctionnalités et l'investissement massif de Google dans les performances du moteur JavaScript V8 de Chrome, ainsi que de bons outils de développement, ont fait de JavaScript et de Node.js le choix idéal pour les architectures de microservices.
L'exécution à thread unique est également idéale pour les fabricants de navigateurs qui doivent isoler et exécuter en toute sécurité tous les runtimes d'onglets de navigateur infestés de logiciels espions sur les multiples cœurs d'un ordinateur.
Question : Comment un onglet de navigateur peut-il accéder à tous les cœurs de processeur de votre ordinateur ?
Réponse : Web workers !
Travailleurs Web et threading
Les travailleurs Web utilisent la boucle d'événements pour transmettre de manière asynchrone des messages entre les threads, en contournant de nombreux pièges potentiels de la programmation multithread.
Les travailleurs Web peuvent également être utilisés pour déplacer le calcul hors du thread principal de l'interface utilisateur. Cela permet au thread principal de l'interface utilisateur de gérer les clics, l'animation et la gestion du DOM.
Regardons un peu de code du référentiel GitHub du projet.
Si vous vous souvenez de notre schéma d'architecture, nous avons délégué l'intégralité de la simulation à nBodySimulator
afin qu'il gère le web worker.
Si vous vous souvenez de l'article d'introduction, nBodySimulator
a une fonction step()
appelée toutes les 33 ms de la simulation. Il appelle calculateForces()
, puis met à jour les positions et repeint.
// 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 contribution du web worker consiste à héberger un thread séparé pour le WebAssembly. En tant que langage de bas niveau, WebAssembly ne comprend que les entiers et les flottants. Nous ne pouvons pas transmettre de chaînes ou d' objets JavaScript - juste des pointeurs vers la "mémoire linéaire". Donc, pour plus de commodité, nous regroupons nos « corps » dans un tableau de flottants : arrBodies
.
Nous y reviendrons dans notre article sur WebAssembly et AssemblyScript.
Ici, nous créons un web worker pour exécuter calculateForces()
dans un thread séparé. Cela se produit ci-dessous lorsque nous rassemblons les corps (x, y, z, masse) dans un tableau de flotteurs arrBodies
, puis this.worker.postMessage()
au travailleur. Nous renvoyons une promesse que le travailleur résoudra plus tard dans 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 }
Du haut, le navigateur GET index.html
qui exécute main.js
qui crée un new nBodySimulator()
et dans son constructeur nous trouvons setupWebWorker()
.
// nBodySimulator.js /** * Our n-body system simulator */ export class nBodySimulator { constructor() { this.setupWebWorker() ...
Notre new nBodySimulator()
vit dans le thread principal de l'interface utilisateur et setupWebWorker()
crée le web worker en récupérant workerWasm.js
sur le réseau.
// 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}`); } ...
À new Worker()
, le navigateur récupère et exécute workerWasm.js
dans un environnement d'exécution (et un thread) JavaScript séparé et commence à transmettre des messages.
Ensuite, workerWasm.js
entre dans le vif du sujet de WebAssembly, mais il ne s'agit en réalité que d'une seule fonction this.onmessage()
contenant une instruction switch()
.
N'oubliez pas que les web workers ne peuvent pas accéder au réseau, donc le thread principal de l'interface utilisateur doit transmettre le code WebAssembly compilé au web worker sous la forme d'un message resolve("action packed")
. Nous approfondirons cela dans le prochain article.
// 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 }) } }
En revenant à la méthode setupWebWorker()
de notre classe nBodySimulation
, nous écoutons les messages du travailleur Web en utilisant le même 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 } } } ...
Dans cet exemple, calculateForces()
crée et renvoie une promesse en sauvegardant resolve( resolve()
et reject()
en tant que self.forcesReject()
et self.forcesResolve()
.
De cette façon, worker.onmessage()
peut résoudre la promesse créée dans calculateForces()
.
Si vous vous souvenez de la fonction step()
de notre boucle de simulation :
/** * 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}`) }
Cela nous permet d'ignorer calculateForces()
et de réappliquer les forces précédentes si le WebAssembly est toujours en train de calculer.
Cette fonction pas à pas se déclenche toutes les 33 ms. Si le web worker n'est pas prêt, il applique et peint les forces précédentes. Si calculateForces()
d'une étape particulière fonctionne après le début de l'étape suivante, l'étape suivante appliquera des forces à partir de la position de l'étape précédente. Ces forces précédentes sont soit suffisamment similaires pour sembler "correctes", soit se produisent si rapidement qu'elles sont incompréhensibles pour l'utilisateur. Ce compromis augmente les performances perçues - même s'il n'est pas recommandé pour les voyages spatiaux humains réels.
Cela pourrait-il être amélioré ? Oui! Une alternative à setInterval
pour notre fonction d'étape est requestAnimationFrame()
.
Pour mon objectif, c'est assez bon pour explorer Canvas, WebVR et WebAssembly. Si vous pensez que quelque chose pourrait être ajouté ou remplacé, n'hésitez pas à commenter ou à nous contacter.
Si vous recherchez une conception de moteur physique moderne et complète, consultez l'open-source Matter.js.
Qu'en est-il de WebAssembly ?
WebAssembly est un binaire portable qui fonctionne sur tous les navigateurs et systèmes. WebAssembly peut être compilé à partir de nombreux langages (C/C++/Rust, etc.). Pour mon propre usage, je voulais essayer AssemblyScript - un langage basé sur TypeScript, qui est un langage basé sur JavaScript, car il s'agit de tortues jusqu'au bout.
AssemblyScript compile le code TypeScript en un binaire « code objet » portable, pour être compilé « juste à temps » dans un nouveau runtime hautes performances appelé Wasm. Lors de la compilation du TypeScript dans le binaire .wasm
, il est possible de créer un format de "texte d'assemblage Web" lisible par l'homme .wat
décrivant le binaire.
La dernière partie de setupWebWorker()
commence notre prochain article sur WebAssembly et montre comment surmonter les limitations d'accès au réseau du Web Worker. Nous récupérons fetch()
le fichier wasm
dans le thread principal de l'interface utilisateur, puis le compilons "juste à temps" dans un module wasm natif. Nous postMessage()
ce module en tant que message au 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
instancie ensuite ce module afin que nous puissions appeler ses fonctions :
// 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);
C'est ainsi que nous accédons à la fonctionnalité WebAssembly. Si vous regardez le code source non expurgé, vous remarquerez que le ...
est un tas de code de gestion de la mémoire pour obtenir nos données dans dataRef
et nos résultats hors de resultRef
. Gestion de la mémoire en JavaScript ? Passionnant!
Nous approfondirons WebAssembly et AssemblyScript plus en détail dans le prochain article.
Limites d'exécution et mémoire partagée
Il y a autre chose dont il faut parler ici, à savoir les limites d'exécution et la mémoire partagée.
L'article WebAssembly est très tactique, c'est donc un bon endroit pour parler des runtimes. JavaScript et WebAssembly sont des runtimes "émulés". Comme implémenté, chaque fois que nous franchissons une limite d'exécution, nous faisons une copie de nos données corporelles (x, y, z, masse). Bien que la copie de mémoire soit bon marché, il ne s'agit pas d'une conception hautes performances mature.
Heureusement, de nombreuses personnes très intelligentes travaillent à la création de spécifications et d'implémentations de ces technologies de navigateur de pointe.
JavaScript a SharedArrayBuffer pour créer un objet de mémoire partagée qui éliminerait la copie de postMessage()
de (2) -> (3) sur l'appel et la copie de onmessage()
des arrForces
de (3) -> (2) sur le résultat .
WebAssembly a également une conception de mémoire linéaire qui pourrait héberger une mémoire partagée pour l'appel nBodyForces()
de (3) -> (4). Le travailleur Web peut également transmettre une mémoire partagée pour le tableau de résultats.
Rejoignez-nous la prochaine fois pour un voyage passionnant dans la gestion de la mémoire JavaScript.