Eine Architektur, bei der Aufgaben nicht im Hauptthread ausgeführt werden, kann die Zuverlässigkeit und Nutzerfreundlichkeit Ihrer App erheblich verbessern.
In den letzten 20 Jahren hat sich das Web von statischen Dokumenten mit einigen wenigen Stilen und Bildern zu komplexen, dynamischen Anwendungen entwickelt. Eines hat sich jedoch kaum verändert: Wir haben nur einen Thread pro Browser-Tab (mit einigen Ausnahmen), um unsere Websites zu rendern und unser JavaScript auszuführen.
Dadurch wird der Hauptthread überlastet. Da Web-Apps immer komplexer werden, wird der Hauptthread zu einem erheblichen Leistungsengpass. Erschwerend kommt hinzu, dass die Zeit, die zum Ausführen von Code im Hauptthread für einen bestimmten Nutzer benötigt wird, fast vollständig unvorhersehbar ist, da die Gerätefunktionen einen großen Einfluss auf die Leistung haben. Diese Unvorhersehbarkeit wird nur noch zunehmen, da Nutzer über eine immer größere Vielfalt an Geräten auf das Web zugreifen, von stark eingeschränkten Feature-Phones bis hin zu leistungsstarken Flaggschiffgeräten mit hoher Bildwiederholrate.
Wenn wir möchten, dass anspruchsvolle Web-Apps Leistungsrichtlinien wie die Core Web Vitals, die auf empirischen Daten zur menschlichen Wahrnehmung und Psychologie basieren, zuverlässig erfüllen, benötigen wir Möglichkeiten, unseren Code außerhalb des Haupt-Threads (OMT) auszuführen.
Warum Web Workers?
JavaScript ist standardmäßig eine Sprache mit einem einzigen Thread, die Aufgaben im Hauptthread ausführt. Web-Worker bieten jedoch eine Art Ausweg aus dem Hauptthread, da Entwickler separate Threads erstellen können, um Aufgaben außerhalb des Hauptthreads zu verarbeiten. Der Umfang von Webworkern ist zwar begrenzt und sie bieten keinen direkten Zugriff auf das DOM, sie können aber sehr nützlich sein, wenn es viel zu tun gibt, was den Hauptthread sonst überfordern würde.
Bei Core Web Vitals kann es von Vorteil sein, Aufgaben außerhalb des Hauptthreads auszuführen. Insbesondere das Auslagern von Aufgaben vom Hauptthread an Web-Worker kann die Belastung des Hauptthreads verringern, was den Reaktionsschnelligkeitsmesswert Interaction to Next Paint (INP) einer Seite verbessern kann. Wenn der Haupt-Thread weniger Arbeit zu verarbeiten hat, kann er schneller auf Nutzerinteraktionen reagieren.
Weniger Arbeit im Hauptthread, insbesondere beim Start, kann sich auch positiv auf den LCP-Wert (Largest Contentful Paint) auswirken, da lange Aufgaben reduziert werden. Das Rendern eines LCP-Elements erfordert Zeit im Hauptthread – entweder zum Rendern von Text oder Bildern, die häufige und gängige LCP-Elemente sind. Wenn Sie die Arbeit im Hauptthread insgesamt reduzieren, ist es weniger wahrscheinlich, dass das LCP-Element Ihrer Seite durch aufwendige Aufgaben blockiert wird, die stattdessen von einem Web-Worker ausgeführt werden könnten.
Threading mit Webworkern
Andere Plattformen unterstützen in der Regel paralleles Arbeiten, indem Sie einem Thread eine Funktion zuweisen können, die parallel zum Rest Ihres Programms ausgeführt wird. Sie können von beiden Threads aus auf dieselben Variablen zugreifen. Der Zugriff auf diese freigegebenen Ressourcen kann mit Mutexes und Semaphoren synchronisiert werden, um Race-Bedingungen zu verhindern.
In JavaScript können wir eine ähnliche Funktionalität mit Webworkern erreichen, die seit 2007 verfügbar sind und seit 2012 von allen wichtigen Browsern unterstützt werden. Web-Worker werden parallel zum Hauptthread ausgeführt. Im Gegensatz zu Betriebssystem-Threads können sie jedoch keine Variablen gemeinsam nutzen.
Um einen Web-Worker zu erstellen, übergeben Sie eine Datei an den Worker-Konstruktor. Dadurch wird die Datei in einem separaten Thread ausgeführt:
const worker = new Worker("./worker.js");
Kommunizieren Sie mit dem Web-Worker, indem Sie Nachrichten über die postMessage API senden. Übergeben Sie den Nachrichtenwert als Parameter im postMessage-Aufruf und fügen Sie dem Worker dann einen Nachrichtenereignis-Listener hinzu:
main.js
const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);
worker.js
addEventListener('message', event => {
const [a, b] = event.data;
// Do stuff with the message
// ...
});
Wenn Sie eine Nachricht zurück an den Hauptthread senden möchten, verwenden Sie dieselbe postMessage API im Web-Worker und richten Sie einen Event-Listener im Hauptthread ein:
main.js
const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);
worker.addEventListener('message', event => {
console.log(event.data);
});
worker.js
addEventListener('message', event => {
const [a, b] = event.data;
// Do stuff with the message
postMessage(a + b);
});
Dieser Ansatz ist allerdings etwas eingeschränkt. Bisher wurden Web-Worker hauptsächlich verwendet, um eine einzelne rechenintensive Aufgabe aus dem Haupt-Thread zu entfernen. Der Versuch, mehrere Vorgänge mit einem einzelnen Webworker zu verarbeiten, wird schnell unübersichtlich: Sie müssen nicht nur die Parameter, sondern auch den Vorgang in der Nachricht codieren und Buch führen, um Antworten Anfragen zuzuordnen. Diese Komplexität ist wahrscheinlich der Grund dafür, dass Web-Worker nicht weiter verbreitet sind.
Wenn wir jedoch einige der Schwierigkeiten bei der Kommunikation zwischen dem Hauptthread und Webworkern beseitigen könnten, wäre dieses Modell für viele Anwendungsfälle sehr gut geeignet. Glücklicherweise gibt es eine Bibliothek, die genau das tut.
Comlink: Web-Worker einfacher nutzen
Comlink ist eine Bibliothek, mit der Sie Web-Worker verwenden können, ohne sich um die Details von postMessage kümmern zu müssen. Mit Comlink können Sie Variablen zwischen Webworkern und dem Hauptthread freigeben, ähnlich wie in anderen Programmiersprachen, die Threading unterstützen.
Sie richten Comlink ein, indem Sie es in einen Webworker importieren und eine Reihe von Funktionen definieren, die für den Hauptthread verfügbar gemacht werden sollen. Anschließend importieren Sie Comlink im Hauptthread, umschließen den Worker und greifen auf die bereitgestellten Funktionen zu:
worker.js
import {expose} from 'comlink';
const api = {
someMethod() {
// ...
}
}
expose(api);
main.js
import {wrap} from 'comlink';
const worker = new Worker('./worker.js');
const api = wrap(worker);
Die Variable api im Hauptthread verhält sich genauso wie die im Web-Worker, mit der Ausnahme, dass jede Funktion ein Promise für einen Wert anstelle des Werts selbst zurückgibt.
Welchen Code sollten Sie in einen Web-Worker verschieben?
Web-Worker haben keinen Zugriff auf das DOM und viele APIs wie WebUSB, WebRTC oder Web Audio. Daher können Sie keine Teile Ihrer App, die auf einen solchen Zugriff angewiesen sind, in einen Worker einfügen. Dennoch schafft jedes kleine Code-Snippet, das in einen Worker verschoben wird, mehr Spielraum im Hauptthread für Dinge, die dort sein müssen, z. B. das Aktualisieren der Benutzeroberfläche.
Ein Problem für Webentwickler besteht darin, dass die meisten Web-Apps auf einem UI-Framework wie Vue oder React basieren, um alles in der App zu organisieren. Alles ist eine Komponente des Frameworks und daher untrennbar mit dem DOM verbunden. Das würde die Migration zu einer OMT-Architektur erschweren.
Wenn wir jedoch zu einem Modell wechseln, in dem UI-Belange von anderen Belangen wie der Statusverwaltung getrennt sind, können Web-Worker auch bei Framework-basierten Apps sehr nützlich sein. Genau das ist der Ansatz, der bei PROXX verfolgt wird.
PROXX: Eine OMT-Fallstudie
Das Google Chrome-Team hat PROXX als Minesweeper-Klon entwickelt, der die Anforderungen an Progressive Web-Apps erfüllt, einschließlich der Offline-Funktionalität und einer ansprechenden Nutzererfahrung. Leider liefen frühe Versionen des Spiels auf Geräten mit eingeschränkten Ressourcen wie Featurephones schlecht. Das Team erkannte, dass der Haupt-Thread ein Engpass war.
Das Team entschied sich, Web-Worker zu verwenden, um den visuellen Status des Spiels von seiner Logik zu trennen:
- Der Hauptthread ist für das Rendern von Animationen und Übergängen zuständig.
- Ein Web-Worker verarbeitet die Spielelogik, die rein rechenintensiv ist.
OMT hatte interessante Auswirkungen auf die Leistung von PROXX auf Featurephones. In der Version ohne OMT wird die Benutzeroberfläche sechs Sekunden lang eingefroren, nachdem der Nutzer mit ihr interagiert hat. Es gibt kein Feedback und der Nutzer muss die vollen sechs Sekunden warten, bevor er etwas anderes tun kann.
In der OMT-Version dauert es jedoch 12 Sekunden, bis eine Aktualisierung der Benutzeroberfläche abgeschlossen ist. Das mag zwar wie ein Leistungsverlust erscheinen, führt aber tatsächlich zu mehr Feedback für den Nutzer. Die Verlangsamung tritt auf, weil die App mehr Frames als die Nicht-OMT-Version sendet, die überhaupt keine Frames sendet. Der Nutzer weiß also, dass etwas passiert, und kann weiterspielen, während die Benutzeroberfläche aktualisiert wird. Das Spiel fühlt sich dadurch viel besser an.
Das ist ein bewusster Kompromiss: Wir bieten Nutzern von Geräten mit eingeschränkten Ressourcen eine bessere Nutzererfahrung, ohne Nutzer von High-End-Geräten zu benachteiligen.
Auswirkungen einer OMT-Architektur
Wie das PROXX-Beispiel zeigt, sorgt OMT dafür, dass Ihre App auf einer größeren Bandbreite von Geräten zuverlässig ausgeführt wird, aber nicht, dass sie schneller wird:
- Sie verlagern die Arbeit nur vom Hauptthread, reduzieren sie aber nicht.
- Der zusätzliche Kommunikationsaufwand zwischen dem Web-Worker und dem Hauptthread kann die Ausführung manchmal geringfügig verlangsamen.
Vor- und Nachteile abwägen
Da der Hauptthread während der Ausführung von JavaScript für die Verarbeitung von Nutzerinteraktionen wie Scrollen zur Verfügung steht, gibt es weniger ausgelassene Frames, auch wenn die gesamte Wartezeit geringfügig länger sein kann. Es ist besser, den Nutzer ein wenig warten zu lassen, als ein Frame zu verlieren, da die Fehlermarge bei verlorenen Frames geringer ist: Ein Frame geht in Millisekunden verloren, während Sie Hunderte von Millisekunden Zeit haben, bevor ein Nutzer die Wartezeit wahrnimmt.
Da die Leistung auf verschiedenen Geräten unvorhersehbar ist, geht es bei der OMT-Architektur in erster Linie darum, Risiken zu verringern und Ihre App angesichts der sehr variablen Laufzeitbedingungen robuster zu machen. Die Leistungssteigerung durch Parallelisierung ist dabei zweitrangig. Die erhöhte Ausfallsicherheit und die Verbesserungen der Nutzerfreundlichkeit sind den geringen Geschwindigkeitsverlust mehr als wert.
Hinweis zu Tools
Web-Worker sind noch nicht weit verbreitet, daher werden sie von den meisten Modul-Tools wie webpack und Rollup nicht standardmäßig unterstützt. (Parcel schon!) Glücklicherweise gibt es Plugins, mit denen Web-Worker mit Webpack und Rollup funktionieren:
- worker-plugin für webpack
- rollup-plugin-off-main-thread für Rollup
Zusammenfassung
Damit unsere Apps möglichst zuverlässig und zugänglich sind, insbesondere in einem zunehmend globalisierten Markt, müssen wir Geräte mit eingeschränkten Ressourcen unterstützen. Die meisten Nutzer weltweit greifen über solche Geräte auf das Web zu. OMT bietet eine vielversprechende Möglichkeit, die Leistung auf solchen Geräten zu steigern, ohne Nutzer von High-End-Geräten zu beeinträchtigen.
OMT hat auch sekundäre Vorteile:
- Dadurch werden die Kosten für die JavaScript-Ausführung in einen separaten Thread verlagert.
- Dadurch werden die Parsing-Kosten verschoben, sodass die Benutzeroberfläche möglicherweise schneller gestartet wird. Dadurch kann der First Contentful Paint-Wert oder sogar der Time to Interactive-Wert sinken, was wiederum Ihren Lighthouse-Wert erhöhen kann.
Webworker müssen nicht kompliziert sein. Tools wie Comlink nehmen den Mitarbeitern die Arbeit ab und machen sie zu einer praktikablen Option für eine Vielzahl von Webanwendungen.