WebVR Teil 2: Web Worker und Browser Edge Computing

Veröffentlicht: 2022-03-11

Unser Astrophysik-Simulator wird von einer potenten Raketentreibstoffmischung aus Hoffnung, Hype und Zugang zu neuer Rechenleistung angetrieben.

Auf diese Rechenleistung können wir mit Webworkern zugreifen. Wenn Sie bereits mit Webworkern vertraut sind, möchten Sie vielleicht den Code groken und zu WebAssembly springen, das im nächsten Artikel behandelt wird.

JavaScript wurde zur am häufigsten installierten, erlernten und zugänglichen Programmiersprache, weil es einige unglaublich nützliche Funktionen in das statische Web brachte:

  • Singlethread-Ereignisschleife
  • Asynchroner Code
  • Müllabfuhr
  • Daten ohne starre Typisierung

Single-Threaded bedeutet, dass wir uns nicht viele Gedanken über die Komplexität und die Fallstricke der Multithread-Programmierung machen müssen.

Asynchron bedeutet, dass wir Funktionen als Parameter weitergeben können, die später ausgeführt werden - als Ereignisse in der Ereignisschleife.

Diese Funktionen und die massiven Investitionen von Google in die Leistung der V8-JavaScript-Engine von Chrome sowie gute Entwicklertools machten JavaScript und Node.js zur perfekten Wahl für Microservice-Architekturen.

Die Single-Thread-Ausführung eignet sich auch hervorragend für Browser-Hersteller, die alle Ihre von Spyware befallenen Browser-Tab-Laufzeiten sicher isolieren und auf mehreren Kernen eines Computers ausführen müssen.

Frage: Wie kann ein Browser-Tab auf alle CPU-Kerne Ihres Computers zugreifen?
Antwort: Webworker!

Web Worker und Threading

Web-Worker verwenden die Ereignisschleife, um Nachrichten asynchron zwischen Threads weiterzuleiten, wodurch viele der potenziellen Fallstricke der Multithread-Programmierung umgangen werden.

Web Worker können auch verwendet werden, um Berechnungen aus dem Haupt-UI-Thread zu verschieben. Dadurch kann der Haupt-UI-Thread Klicks, Animationen und die Verwaltung des DOM verarbeiten.

Sehen wir uns etwas Code aus dem GitHub-Repo des Projekts an.

Wenn Sie sich an unser Architekturdiagramm erinnern, haben wir die gesamte Simulation an nBodySimulator delegiert, damit er den Webworker verwaltet.

Architekturdiagramm

Wenn Sie sich an den Einführungspost erinnern, hat nBodySimulator eine step() Funktion, die alle 33 ms der Simulation aufgerufen wird. Es ruft computeForces calculateForces() auf, aktualisiert dann die Positionen und zeichnet neu.

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

Der Beitrag des Webworkers besteht darin, einen separaten Thread für die WebAssembly zu hosten. Als Low-Level-Sprache versteht WebAssembly nur Integer und Floats. Wir können keine JavaScript -Strings oder -Objekte übergeben – nur Zeiger auf „linearen Speicher“. Der Einfachheit halber packen wir unsere „Körper“ in eine Reihe von Floats: arrBodies .

Wir werden in unserem Artikel über WebAssembly und AssemblyScript darauf zurückkommen.

Verschieben von Daten in/aus dem Web Worker
Verschieben von Daten in/aus dem Web Worker

Hier erstellen wir einen Webworker zum Ausführen von calculateForces() in einem separaten Thread. Dies geschieht unten, wenn wir die Körper (x, y, z, Masse) in ein Array von Floats arrBodies und dann this.worker.postMessage() an den Worker marshallen. Wir geben ein Versprechen zurück, das der Worker später in this.worker.onMessage() auflösen wird.

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

Von oben GET die index.html des Browsers, die main.js , die einen new nBodySimulator() erstellt, und in seinem Konstruktor finden wir setupWebWorker() .

n-Körper-Wasm-Leinwand

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

Unser new nBodySimulator() sich im Haupt-UI-Thread, und setupWebWorker() erstellt den Webworker, indem workerWasm.js aus dem Netzwerk holt.

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

Bei new Worker() ruft der Browser workerWasm.js ab und führt es in einer separaten JavaScript-Laufzeit (und einem Thread) aus und beginnt mit der Weiterleitung von Nachrichten.

Dann gerät workerWasm.js in den Kern von WebAssembly, aber es ist wirklich nur eine einzelne Funktion this.onmessage() , die eine switch() Anweisung enthält.

Denken Sie daran, dass Webworker nicht auf das Netzwerk zugreifen können, sodass der Haupt-UI-Thread den kompilierten WebAssembly-Code als Nachricht resolve("action packed") an den Webworker übergeben muss. Darauf gehen wir im nächsten Beitrag ein.

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

Zurück zur Methode setupWebWorker() unserer Klasse nBodySimulation lauschen wir die Nachrichten des Webworkers mit dem gleichen Muster 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 diesem Beispiel erstellt computeForces( calculateForces() eine Zusage und gibt sie zurück, wobei resolve() und reject() “ als self.forcesReject() und self.forcesResolve() .

Auf diese Weise kann worker.onmessage() das in computeForces( calculateForces() erstellte Promise auflösen.

Wenn Sie sich an die Funktion step() unserer Simulationsschleife erinnern:

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

Dadurch können wir computeForces calculateForces() überspringen und die vorherigen Kräfte erneut anwenden, wenn die WebAssembly noch berechnet.

Diese Schrittfunktion wird alle 33 ms ausgelöst. Wenn der Webworker nicht bereit ist, wendet er die vorherigen Kräfte an und malt sie. Wenn calculateForces() eines bestimmten Schritts über den Start des nächsten Schritts hinaus funktioniert, wendet der nächste Schritt Kräfte von der Position des vorherigen Schritts an. Diese vorherigen Kräfte sind entweder ähnlich genug, um „richtig“ auszusehen, oder treten so schnell auf, dass sie für den Benutzer unverständlich sind. Dieser Kompromiss erhöht die wahrgenommene Leistung – auch wenn er für die eigentliche bemannte Raumfahrt nicht empfohlen wird.

Könnte dies verbessert werden? Jawohl! Eine Alternative zu setInterval für unsere Step-Funktion ist requestAnimationFrame() .

Für meinen Zweck ist dies gut genug, um Canvas, WebVR und WebAssembly zu erkunden. Wenn Sie der Meinung sind, dass etwas hinzugefügt oder ausgetauscht werden könnte, können Sie dies gerne kommentieren oder sich mit uns in Verbindung setzen.

Wenn Sie nach einem modernen, vollständigen Physik-Engine-Design suchen, sehen Sie sich die Open-Source-Datei Matter.js an.

Was ist mit WebAssembly?

WebAssembly ist eine portable Binärdatei, die browser- und systemübergreifend funktioniert. WebAssembly kann aus vielen Sprachen kompiliert werden (C/C++/Rust, etc.). Für meine eigenen Zwecke wollte ich AssemblyScript ausprobieren - eine Sprache, die auf TypeScript basiert, einer Sprache, die auf JavaScript basiert, weil es ganz unten Schildkröten gibt.

AssemblyScript kompiliert TypeScript-Code in eine portable „Objektcode“-Binärdatei, die „just-in-time“ in eine neue Hochleistungslaufzeit namens Wasm kompiliert wird. Beim Kompilieren des TypeScripts in die .wasm -Binärdatei ist es möglich, ein .wat Menschen lesbares „Web-Assembly-Text“-Format zu erstellen, das die Binärdatei beschreibt.

Der letzte Teil von setupWebWorker() unseren nächsten Beitrag zu WebAssembly ein und zeigt, wie die Beschränkungen von Webworkern beim Zugriff auf das Netzwerk überwunden werden können. Wir fetch() die wasm -Datei im Haupt-UI-Thread und kompilieren sie dann „just-in-time“ zu einem nativen wasm-Modul. Wir postMessage() dieses Modul als Nachricht an den Webworker:

 // 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 instanziiert dann dieses Modul, damit wir seine Funktionen aufrufen können:

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

So greifen wir auf die WebAssembly-Funktionalität zu. Wenn Sie sich den nicht redigierten Quellcode ansehen, werden Sie feststellen, dass ... ein Haufen Speicherverwaltungscode ist, um unsere Daten in dataRef und unsere Ergebnisse aus resultRef zu bekommen. Speicherverwaltung in JavaScript? Aufregend!

Wir werden uns im nächsten Beitrag ausführlicher mit WebAssembly und AssemblyScript befassen.

Ausführungsgrenzen und Shared Memory

Hier gibt es noch etwas anderes zu besprechen, nämlich Ausführungsgrenzen und gemeinsames Gedächtnis.

Vier Kopien unserer Körperdaten
Vier Kopien unserer Körperdaten

Der WebAssembly-Artikel ist sehr taktisch, daher ist hier ein guter Ort, um über Laufzeiten zu sprechen. JavaScript und WebAssembly sind „emulierte“ Laufzeiten. Wie implementiert, erstellen wir jedes Mal, wenn wir eine Laufzeitgrenze überschreiten, eine Kopie unserer Körperdaten (x, y, z, Masse). Während das Kopieren von Speicher billig ist, ist dies kein ausgereiftes Hochleistungsdesign.

Glücklicherweise arbeiten viele sehr kluge Leute daran, Spezifikationen und Implementierungen dieser hochmodernen Browsertechnologien zu erstellen.

JavaScript verfügt über SharedArrayBuffer, um ein Shared-Memory-Objekt zu erstellen, das die Kopie von postMessage() von (2) -> (3) beim Aufruf und die Kopie von onmessage() der arrForces von (3) -> (2) im Ergebnis eliminiert .

WebAssembly hat auch ein lineares Speicherdesign, das einen gemeinsam genutzten Speicher für den nBodyForces() -Aufruf von (3) -> (4) hosten könnte. Der Webworker könnte auch einen gemeinsamen Speicher für das Ergebnisarray übergeben.

Begleiten Sie uns das nächste Mal auf eine spannende Reise in die JavaScript-Speicherverwaltung.