WebVR Część 2: Pracownicy sieci Web i przetwarzanie brzegowe przeglądarki

Opublikowany: 2022-03-11

Nasz symulator astrofizyki jest zasilany przez potężną mieszankę paliwa rakietowego nadziei, szumu i dostępu do nowej mocy obliczeniowej.

Możemy uzyskać dostęp do tej mocy obliczeniowej dzięki pracownikom sieciowym . Jeśli znasz już procesy robocze sieci Web, możesz chcieć przejrzeć kod i przejść do WebAssembly, który zostanie omówiony w następnym artykule.

JavaScript stał się najczęściej instalowanym, wyuczonym i dostępnym językiem programowania, ponieważ wprowadził do statycznej sieci kilka niezwykle przydatnych funkcji:

  • Jednowątkowa pętla zdarzeń
  • Kod asynchroniczny
  • Zbieranie śmieci
  • Dane bez sztywnego pisania

Jednowątkowy oznacza, że ​​nie musimy się zbytnio martwić złożonością i pułapkami programowania wielowątkowego.

Asynchroniczny oznacza, że ​​możemy przekazywać funkcje jako parametry do późniejszego wykonania - jako zdarzenia w pętli zdarzeń.

Te funkcje i ogromne inwestycje Google w wydajność silnika JavaScript V8 przeglądarki Chrome, wraz z dobrymi narzędziami dla programistów, sprawiły, że JavaScript i Node.js są idealnym wyborem dla architektur mikroserwisowych.

Wykonywanie jednowątkowe jest również świetne dla twórców przeglądarek, którzy muszą bezpiecznie izolować i uruchamiać wszystkie środowiska wykonawcze kart przeglądarki zawierające oprogramowanie szpiegujące na wielu rdzeniach komputera.

Pytanie: W jaki sposób jedna karta przeglądarki może uzyskać dostęp do wszystkich rdzeni procesora komputera?
Odpowiedź: Pracownicy sieci!

Pracownicy sieci Web i wątki

Pracownicy sieci Web używają pętli zdarzeń do asynchronicznego przekazywania komunikatów między wątkami, omijając wiele potencjalnych pułapek programowania wielowątkowego.

Pracowników sieci Web można również użyć do przeniesienia obliczeń poza główny wątek interfejsu użytkownika. Pozwala to głównemu wątkowi interfejsu użytkownika na obsługę kliknięć, animacji i zarządzanie DOM.

Spójrzmy na kod z repozytorium GitHub projektu.

Jeśli pamiętasz nasz diagram architektury, delegowaliśmy całą symulację do nBodySimulator , aby zarządzał pracownikiem sieciowym.

Schemat architektury

Jeśli pamiętasz z wpisu wprowadzającego, nBodySimulator posiada funkcję step() wywoływaną co 33 ms symulacji. Wywołuje calculateForces() , a następnie aktualizuje pozycje i odświeża.

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

Wkładem pracownika sieci Web jest hostowanie oddzielnego wątku dla WebAssembly. Jako język niskiego poziomu, WebAssembly rozumie tylko liczby całkowite i zmiennoprzecinkowe. Nie możemy przekazywać ciągów ani obiektów JavaScript - po prostu wskaźniki do „pamięci liniowej”. Dlatego dla wygody pakujemy nasze „ciała” w tablicę elementów pływających: arrBodies .

Wrócimy do tego w naszym artykule na temat WebAssembly i AssemblyScript.

Przenoszenie danych do/z web workera
Przenoszenie danych do/z web workera

Tutaj tworzymy pracownika sieci Web, który uruchamia calculateForces() w osobnym wątku. Dzieje się to poniżej, gdy łączymy ciała (x, y, z, mass) w tablicę elementów pływających arrBodies , a następnie this.worker.postMessage() do pracownika. Zwracamy obietnicę, którą pracownik rozwiąże później w 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 }

Od góry przeglądarka GET index.html , która uruchamia main.js , która tworzy new nBodySimulator() , aw jego konstruktorze znajdujemy setupWebWorker() .

n-ciało-wasm-płótno

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

Nasz new nBodySimulator() znajduje się w głównym wątku interfejsu użytkownika, a setupWebWorker() tworzy robota sieciowego, pobierając z sieci workerWasm.js .

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

W new Worker() przeglądarka pobiera i uruchamia workerWasm.js w oddzielnym środowisku wykonawczym JavaScript (i wątku) i rozpoczyna przekazywanie komunikatów.

Następnie workerWasm.js wchodzi w sedno WebAssembly, ale tak naprawdę jest to tylko pojedyncza this.onmessage() zawierająca instrukcję switch() .

Pamiętaj, że pracownicy sieci Web nie mogą uzyskać dostępu do sieci, więc główny wątek interfejsu użytkownika musi przekazać skompilowany kod WebAssembly do robota sieci Web jako komunikat resolve resolve("action packed") . Zagłębimy się w to w następnym poście.

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

Wracając do metody setupWebWorker() naszej klasy nBodySimulation , nasłuchujemy komunikatów robota sieciowego przy użyciu tego samego 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 } } } ...

W tym przykładzie funkcja calculateForces() tworzy i zwraca obietnicę oszczędzania funkcji resolve( resolve() i reject() jako self.forcesReject() i self.forcesResolve() .

W ten sposób worker.onmessage() może rozwiązać obietnicę stworzoną w calculateForces() .

Jeśli pamiętasz funkcję step() naszej pętli symulacyjnej:

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

To pozwala nam pominąć calculateForces() i ponownie zastosować poprzednie siły, jeśli zespół WebAssembly nadal oblicza.

Ta funkcja krokowa uruchamia się co 33 ms. Jeśli pracownik sieciowy nie jest gotowy, stosuje i maluje poprzednie siły. Jeśli funkcja calculateForces() konkretnego kroku działa po rozpoczęciu następnego kroku, następny krok zastosuje siły z pozycji poprzedniego kroku. Te poprzednie siły są albo na tyle podobne, że wyglądają „właściwie”, albo zachodzą tak szybko, że są niezrozumiałe dla użytkownika. Ten kompromis zwiększa postrzeganą wydajność — nawet jeśli nie jest to zalecane w przypadku rzeczywistych podróży kosmicznych.

Czy można to poprawić? TAk! Alternatywą dla setInterval dla naszej funkcji step jest requestAnimationFrame() .

W moim przypadku jest to wystarczająco dobre, aby zbadać Canvas, WebVR i WebAssembly. Jeśli uważasz, że coś można dodać lub zamienić, skomentuj lub skontaktuj się z nami.

Jeśli szukasz nowoczesnego, kompletnego projektu silnika fizyki, sprawdź Open Source Matter.js.

A co z WebAssembly?

WebAssembly to przenośny plik binarny, który działa w różnych przeglądarkach i systemach. WebAssembly można skompilować z wielu języków (C/C++/Rust itp.). Na własny użytek chciałem wypróbować AssemblyScript - język oparty na TypeScript, który jest językiem opartym na JavaScript, ponieważ jest to żółwie.

AssemblyScript kompiluje kod TypeScript do przenośnego pliku binarnego „kodu obiektowego”, który ma być skompilowany „dokładnie na czas” w nowym środowisku uruchomieniowym o wysokiej wydajności o nazwie Wasm. Podczas kompilowania TypeScript do pliku binarnego .wasm możliwe jest utworzenie .wat czytelnego dla człowieka „tekstu zestawu internetowego” opisującego plik binarny.

Ostatnia część setupWebWorker() rozpoczyna nasz następny post na WebAssembly i pokazuje, jak pokonać ograniczenia webworkera w dostępie do sieci. Pobieramy fetch() plik wasm w głównym wątku interfejsu użytkownika, a następnie „w samą porę” kompilujemy go do natywnego modułu wasm. WysyłamyMessage postMessage() ten moduł jako wiadomość do pracownika sieci:

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

Następnie workerWasm.js instancję tego modułu, dzięki czemu możemy wywołać jego funkcje:

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

W ten sposób uzyskujemy dostęp do funkcjonalności WebAssembly. Jeśli spojrzysz na niezredagowany kod źródłowy, zauważysz, że ... to wiązka kodu zarządzającego pamięcią, który przenosi nasze dane do dataRef , a wyniki z resultRef . Zarządzanie pamięcią w JavaScript? Ekscytujący!

Bardziej szczegółowo zagłębimy się w WebAssembly i AssemblyScript w następnym poście.

Granice wykonania i pamięć współdzielona

Jest tu jeszcze coś do omówienia, czyli granice wykonania i wspólna pamięć.

Cztery kopie danych naszych Organów
Cztery kopie danych naszych Organów

Artykuł WebAssembly jest bardzo taktyczny, więc tutaj jest dobre miejsce, aby porozmawiać o środowiskach wykonawczych. JavaScript i WebAssembly są „emulowanymi” środowiskami uruchomieniowymi. Zgodnie z implementacją, za każdym razem, gdy przekraczamy granicę środowiska wykonawczego, tworzymy kopię danych naszego ciała (x, y, z, masa). Chociaż kopiowanie pamięci jest tanie, nie jest to dojrzały projekt o wysokiej wydajności.

Na szczęście nad tworzeniem specyfikacji i implementacji tych najnowocześniejszych technologii przeglądarek pracuje wiele bardzo inteligentnych ludzi.

JavaScript ma SharedArrayBuffer do tworzenia obiektu pamięci współdzielonej, który eliminuje kopię postMessage() z (2) -> (3) w wywołaniu i kopię arrForces onmessage() z (3) -> (2) w wyniku .

WebAssembly ma również projekt pamięci liniowej, który może obsługiwać pamięć współdzieloną dla nBodyForces() z (3) -> (4). Pracownik sieciowy może również przekazać pamięć współdzieloną dla tablicy wyników.

Dołącz do nas następnym razem na ekscytującą podróż do zarządzania pamięcią JavaScript.