Threading im Web mit Modul-Workern

Mit JavaScript-Modulen in Webworkern ist es jetzt einfacher, rechenintensive Aufgaben in Hintergrundthreads zu verlagern.

JavaScript ist Single-Threaded, d. h., es kann jeweils nur ein Vorgang ausgeführt werden. Das ist intuitiv und funktioniert in vielen Fällen im Web gut, kann aber problematisch werden, wenn wir anspruchsvolle Aufgaben wie Datenverarbeitung, Parsing, Berechnung oder Analyse ausführen müssen. Da immer mehr komplexe Anwendungen im Web bereitgestellt werden, steigt der Bedarf an Multithread-Verarbeitung.

Auf der Webplattform ist das wichtigste Primitive für Threading und Parallelität die Web Workers API. Worker sind eine einfache Abstraktion über Betriebssystem-Threads, die eine API für die Nachrichtenübergabe für die Thread-übergreifende Kommunikation bereitstellen. Das kann sehr nützlich sein, wenn Sie rechenintensive Vorgänge ausführen oder mit großen Datasets arbeiten. So kann der Hauptthread reibungslos ausgeführt werden, während die rechenintensiven Vorgänge in einem oder mehreren Hintergrundthreads ausgeführt werden.

Hier ist ein typisches Beispiel für die Verwendung von Workern, bei dem ein Worker-Script auf Nachrichten vom Hauptthread wartet und mit eigenen Nachrichten antwortet:

page.js:

const worker = new Worker('worker.js');
worker.addEventListener('message', e => {
  console.log(e.data);
});
worker.postMessage('hello');

worker.js:

addEventListener('message', e => {
  if (e.data === 'hello') {
    postMessage('world');
  }
});

Die Web Worker API ist seit über zehn Jahren in den meisten Browsern verfügbar. Das bedeutet zwar, dass Mitarbeiter eine hervorragende Browserunterstützung haben und die Browser gut optimiert sind, aber auch, dass sie lange vor JavaScript-Modulen entwickelt wurden. Da es beim Entwurf von Workern kein Modulsystem gab, ist die API zum Laden von Code in einen Worker und zum Erstellen von Skripts ähnlich wie die synchronen Skriptladeverfahren, die 2009 üblich waren.

Verlauf: Klassische Worker

Der Worker-Konstruktor verwendet eine klassische Script-URL, die relativ zur Dokument-URL ist. Es wird sofort eine Referenz auf die neue Worker-Instanz zurückgegeben, die eine Messaging-Schnittstelle sowie eine terminate()-Methode bereitstellt, mit der der Worker sofort beendet und zerstört wird.

const worker = new Worker('worker.js');

In Webworkern ist eine importScripts()-Funktion zum Laden von zusätzlichem Code verfügbar. Dadurch wird die Ausführung des Workers jedoch unterbrochen, um jedes Script abzurufen und auszuwerten. Außerdem werden Skripts im globalen Bereich wie bei einem klassischen <script>-Tag ausgeführt. Das bedeutet, dass die Variablen in einem Skript durch die Variablen in einem anderen Skript überschrieben werden können.

worker.js:

importScripts('greet.js');
// ^ could block for seconds
addEventListener('message', e => {
  postMessage(sayHello());
});

greet.js:

// global to the whole worker
function sayHello() {
  return 'world';
}

Aus diesem Grund haben Web-Worker in der Vergangenheit einen überproportionalen Einfluss auf die Architektur einer Anwendung gehabt. Entwickler mussten clevere Tools und Workarounds erstellen, um Web Workers verwenden zu können, ohne auf moderne Entwicklungspraktiken verzichten zu müssen. Bundler wie webpack betten beispielsweise eine kleine Modul-Loader-Implementierung in generierten Code ein, der importScripts() zum Laden von Code verwendet, aber Module in Funktionen einbettet, um Variablenkollisionen zu vermeiden und Importe und Exporte von Abhängigkeiten zu simulieren.

Modulmitarbeiter eingeben

In Chrome 80 wird ein neuer Modus für Web-Worker eingeführt, der die ergonomischen und leistungsbezogenen Vorteile von JavaScript-Modulen bietet. Er wird als „Modul-Worker“ bezeichnet. Der Worker-Konstruktor akzeptiert jetzt die neue Option {type:"module"}, mit der das Laden und Ausführen von Skripts an <script type="module"> angepasst wird.

const worker = new Worker('worker.js', {
  type: 'module'
});

Da Modul-Worker Standard-JavaScript-Module sind, können sie Import- und Exportanweisungen verwenden. Wie bei allen JavaScript-Modulen werden Abhängigkeiten in einem bestimmten Kontext (Hauptthread, Worker usw.) nur einmal ausgeführt. Alle zukünftigen Importe verweisen auf die bereits ausgeführte Modulinstanz. Das Laden und Ausführen von JavaScript-Modulen wird ebenfalls von Browsern optimiert. Die Abhängigkeiten eines Moduls können vor der Ausführung des Moduls geladen werden. So lassen sich ganze Modulbäume parallel laden. Beim Laden von Modulen wird auch der geparste Code im Cache gespeichert. Module, die im Hauptthread und in einem Worker verwendet werden, müssen also nur einmal geparst werden.

Durch die Umstellung auf JavaScript-Module kann auch dynamic import für das Lazy Loading von Code verwendet werden, ohne die Ausführung des Workers zu blockieren. Der dynamische Import ist viel expliziter als die Verwendung von importScripts() zum Laden von Abhängigkeiten, da die Exporte des importierten Moduls zurückgegeben werden, anstatt sich auf globale Variablen zu verlassen.

worker.js:

import { sayHello } from './greet.js';
addEventListener('message', e => {
  postMessage(sayHello());
});

greet.js:

import greetings from './data.js';
export function sayHello() {
  return greetings.hello;
}

Damit eine gute Leistung gewährleistet ist, ist die alte importScripts()-Methode in Modul-Workern nicht verfügbar. Wenn Sie Worker auf JavaScript-Module umstellen, wird der gesamte Code im Strict Mode geladen. Eine weitere wichtige Änderung ist, dass der Wert von this im Top-Level-Bereich eines JavaScript-Moduls undefined ist, während er in klassischen Workern der globale Bereich des Workers ist. Glücklicherweise gab es immer eine globale self, die einen Verweis auf den globalen Bereich bietet. Sie ist für alle Arten von Workern verfügbar, einschließlich Service Workern, sowie im DOM.

Worker mit modulepreload vorab laden

Eine wesentliche Leistungsverbesserung, die mit Modul-Workern einhergeht, ist die Möglichkeit, Worker und ihre Abhängigkeiten vorab zu laden. Bei Modul-Workern werden Skripts als Standard-JavaScript-Module geladen und ausgeführt. Das bedeutet, dass sie mit modulepreload vorab geladen und sogar vorab geparst werden können:

<!-- preloads worker.js and its dependencies: -->
<link rel="modulepreload" href="worker.js">

<script>
  addEventListener('load', () => {
    // our worker code is likely already parsed and ready to execute!
    const worker = new Worker('worker.js', { type: 'module' });
  });
</script>

Vorgeladene Module können sowohl vom Hauptthread als auch von Modul-Workern verwendet werden. Das ist nützlich für Module, die in beiden Kontexten importiert werden, oder in Fällen, in denen nicht im Voraus bekannt ist, ob ein Modul im Hauptthread oder in einem Worker verwendet wird.

Bisher waren die Optionen für das Vorladen von Webworker-Scripts begrenzt und nicht unbedingt zuverlässig. Klassische Worker hatten einen eigenen Ressourcentyp „worker“ für das Vorladen, aber keine Browser haben <link rel="preload" as="worker"> implementiert. Die primäre Technik zum Vorladen von Webworkern war daher die Verwendung von <link rel="prefetch">, die vollständig auf dem HTTP-Cache beruhte. In Kombination mit den richtigen Caching-Headern konnte so vermieden werden, dass die Worker-Instanziierung auf den Download des Worker-Skripts warten musste. Im Gegensatz zu modulepreload wurden jedoch keine Abhängigkeiten vorab geladen oder vorab geparst.

Wie sieht es mit gemeinsam genutzten Arbeitskräften aus?

Shared Workers wurden mit Chrome 83 aktualisiert und unterstützen jetzt JavaScript-Module. Wie bei dedizierten Workern wird beim Erstellen eines Shared Workers mit der Option {type:"module"} das Worker-Skript jetzt als Modul und nicht als klassisches Skript geladen:

const worker = new SharedWorker('/worker.js', {
  type: 'module'
});

Bevor JavaScript-Module unterstützt wurden, wurde für den SharedWorker()-Konstruktor nur eine URL und ein optionales name-Argument erwartet. Das funktioniert weiterhin für die klassische Verwendung von Shared Workers. Wenn Sie jedoch Module für Shared Workers erstellen möchten, müssen Sie das neue options-Argument verwenden. Die verfügbaren Optionen sind dieselben wie für einen dedizierten Worker, einschließlich der Option name, die das vorherige name-Argument ersetzt.

Was ist mit Service Workern?

Die Service Worker-Spezifikation wurde bereits aktualisiert, um die Annahme eines JavaScript-Moduls als Einstiegspunkt zu unterstützen. Dabei wird dieselbe {type:"module"}-Option wie bei Modul-Workern verwendet. Diese Änderung muss jedoch noch in Browsern implementiert werden. Danach ist es möglich, einen Service Worker mit einem JavaScript-Modul zu instanziieren. Verwenden Sie dazu den folgenden Code:

navigator.serviceWorker.register('/sw.js', {
  type: 'module'
});

Nachdem die Spezifikation aktualisiert wurde, beginnen Browser, das neue Verhalten zu implementieren. Das dauert eine Weile, da die Einbindung von JavaScript-Modulen in Service Workern mit einigen zusätzlichen Komplikationen verbunden ist. Bei der Registrierung von Service Workern muss verglichen werden, ob importierte Skripts mit ihren zuvor im Cache gespeicherten Versionen übereinstimmen. Dies muss für JavaScript-Module implementiert werden, wenn sie für Service Worker verwendet werden. Außerdem müssen Service Worker in bestimmten Fällen in der Lage sein, den Cache für Skripts zu umgehen, wenn sie nach Updates suchen.

Zusätzliche Ressourcen und weiterführende Informationen