JavaScript, die einzige Programmiersprache, die von Webbrowsern nativ ausgeführt wird, ist von Haus aus single-threaded. Das bedeutet, dass alle Webanwendungen in einem einzigen Thread ausgeführt werden. Dies ist für alle Standardfälle völlig ausreichend, aber manchmal gibt es Situationen, in denen große, teure Berechnungen so viel Zeit in Anspruch nehmen, dass die Anwendung nicht mehr auf den Benutzer reagiert. Typische Fälle sind Anwendungen, die komplexe 3D-Szenen berechnen. Glücklicherweise sind die OffscreenCanvas- und Web Worker-Technologien da, um dies zu ändern.
Sehen Sie sich diese Beispiele an, wie sie die Benutzerfreundlichkeit von WebGL-Anwendungen verbessern können.
Es gibt Online-Ressourcen, die darüber informieren, wie man OffscreenCanvas verwendet, sogar mit React (@react-three/offscreen). Aber die Technologie ist noch neu, und es ist immer noch unüblich, Frontend-Anwendungen als multithreaded zu betrachten. Die meisten Tutorials, die man online findet, gehen von einer isolierten WebGL-Anwendung aus, was bedeutet, dass sie keine Informationen mit dem Haupt-Thread austauscht.
Aus meiner Erfahrung kann ich sagen - dies ist eine sehr unwahrscheinliche Situation. Wir haben bei SABO mehrere komplexe Anwendungen mit React und WebGL implementiert.Und jedes Mal haben beide Teile ihre Ansichten aus den gleichen Daten gerendert!
In der Praxis werden Sie also nach einer Möglichkeit suchen, den Anwendungsstatus zwischen mehreren Threads zu synchronisieren.
Dieser Artikel befasst sich mit dem wahrscheinlicheren Szenario, bei dem sowohl React DOM als auch WebGL Teile der Anwendung Lese-/Schreibzugriff auf den Anwendungsstatus benötigen.
Stellen Sie sich eine Funktion zum Prüfen und Konfigurieren eines 3D-Objekts vor. Sie könnte wie folgt aussehen:
Durch das Verschieben von WebGL in einen Worker könnten wir folgendes erreichen:
Lassen Sie uns nun die Möglichkeiten beschreiben:
Es sollte inzwischen klar sein, dass der WebGL-Code benachrichtigt werden sollte, wenn wir das Attribut im Panel ändern und umgekehrt.
Im Allgemeinen können Sie von einem Thread aus nicht auf den Speicher eines anderen Threads zugreifen. Dies ist eine Designentscheidung, um thread-sichere Operationen zu gewährleisten. Worker sollen über Messages kommunizieren.
Deshalb wäre dereinfachste Ansatz die Implementierung einer nachrichtenbasierten Kommunikationzwischen zwei Workern. Doch bevor wir dazu kommen, möchte ich einen fortschrittlicheren Ansatz erwähnen, der darin besteht, den Anwendungsstatus in SharedArrayBuffer zu speichern.
Der SharedArrayBuffer ermöglicht den gemeinsamen Zugriff und unterstützt atomare Operationen aus verschiedenen Kontexten (Hauptthread oder Worker), ohne ihn zu kopieren. Die Datenstruktur selbst wird jedoch zur Darstellung eines generischen binären Rohdaten Puffers verwendet. Für die meiste Zeit ist dieser zu niedrig angesiedelt. Nichtsdestotrotz ist dies die Art und Weise, wie eine Manifold-Multithread-Enginedies erledigt.
SharedArrayBuffer ist die speicher- und geschwindigkeitseffizienteste, aber weit weniger benutzerfreundliche Methode. Auch mit Proxy-Objekte wie bitECS (die Sie mit Daten als JS-Objekte arbeiten können) erhalten Sie nirgends in der Nähe von geliebten JSON-Strukturen.
Nun, lassen Sieuns zurück zur nachrichtenbasierten Kommunikation und zu einer praktikablen Lösung kommen.
Die Kernidee ist, einen gemeinsamen Anwendungsspeicher zu verwenden. Jeder Teil wird seine neigenen lokalen Speicher haben und wir werden eine dünne Synchronisationsschicht zwischen den Teilen über die Broadcast Channel API schreiben. Am Ende sollte sich keiner der Teile um den anderen kümmern, die Synchronisation wird nahtlos sein.
Zunächst habe ich mich für valtio entschieden, um den Anwendungsstatus zu speichern, und bald werden Sie sehen, warum. Falls Sie damit nicht vertraut sind, handeltes sich um eine Bibliothek, die ein veränderbares, Proxy-basiertes reaktives Zustandsmanagement bietet.
Dies ist der Grundspeicher, den ich verwendet habe:
import { proxy } from 'valtio'
export const store = proxy({
name: 'Suzi',
color: '#FFA500'
})
Ich habe diesen dann mit meinem Inspektionspanel in React DOM verbunden:
import { useSnapshot } from 'valtio'
import { store } from './store'
import { Layout, Inspector } from './dom'
import { Canvas } from './webgl'
const setName = (name: string) => (store.name = name)
const setColor = (color: string) => (store.color = color)
export const App = () => {
const snapshot = useSnapshot(store)
return (
<Layout>
<Canvas />
<Inspector
name={snapshot.name}
onNameChange={setName}
color={snapshot.color}
onColorChange={setColor}
/>
</Layout>
)
}
Und ebenfalls zumeiner 3D-Szene:
import { store } from '../store'
import { invertColor } from '../utils'
import { Stage, Suzi, Label } from './components'
// example write operation
const invertAttributes = () => {
store.color = invertColor(store.color)
store.name = `Inverted ${store.name}`
}
export const Scene = () => {
const snapshot = useSnapshot(store)
return (
<Stage>
<Suzi color={snapshot.color} onClick={invertAttributes} />
<Label>{snapshot.name}</Label>
</Stage>
)
}
Ich habe mich für valtio entschieden, weil es schöne serialisierbare Patches für die Listener bereitstellt, die Sie an die Subscribe-Funktion übergeben.
Damit können wir andere Threads über Änderungen, die auf unserer Seite vorgenommen wurden, informieren.
import { subscribe } from 'valtio'
// const store = ...
export function syncStore() {
const channel = new BroadcastChannel ('sync')
subscribe(store, (ops) => {
channel.postMessage ({ ops })
})
}
Schritt für Schritt am Beispiel:
Der Betrieb von Valtio besteht aus:
Es gibt noch mehr, aber wir brauchen jetzt nicht mehr.
Nun wollen wir Nachrichten mit ops (Operations) verarbeiten, wenn sie von aus dem anderen Thread empfangen werden.
channel.onmessage = (event) => {
event.data.ops.forEach(([type, path, newValue]) => {
switch (type) {
case 'set':
setByPath(store, path, newValue)
break
case 'delete':
deleteByPath(store, path)
break
}
})
}
Um den neuen Wert auf einen Teilbaum unseres Speichers anzuwenden, führen wir Hilfsfunktionen ein, die das Speicherobjekt entsprechend dem Pfad durchlaufen.
function setByPath(obj: any, path: (string | symbol)[], value: any) {
const last = path.pop()
let current = obj
for (const p of path) {
current = current[p]
}
current[last!] = value
}
function deleteByPath(obj: any, path: (string | symbol)[]) {
const last = path.pop()
let current = obj
for (const p of path) {
current = current[p]
}
delete current[last!]
}
Das war's! Unsere Synchronisierungslogik ist fertig. Rufen Sie die Funktion im Hauptthread und im Render-Worker auf, um die Synchronisierung zu starten.
Wenn sich nun auf einem Thread etwas im Speicher ändert, erhält der andere Thread ein Array von Operationen und wendet es auf seine lokale Version des Speichers an.
Diese Lösung geht zu Lasten des Speichers und ist nicht für jedes Szenario geeignet, aber sie löst das Problem auf elegante und einfache Weise.
Sie können die bereitgestellte Version hier auf Netlify selbst testen oder den Code unter evstinik/multithreaded-react-webgl-example herunterladen.
Einige Inhalte wurden in diesem Dokument deaktiviert