JavaScript/Web Worker

Aus SELFHTML-Wiki
Wechseln zu: Navigation, Suche

JavaScript ist eine Single-Thread-Umgebung, in der Scripte der Reihe nach abgearbeitet werden. Wenn ein Script sich verzögert oder abstürzt, sind keine weiteren Benutzereingaben mehr möglich.

Die Web Worker-API ermöglicht es, rechenintensive Skripte in einen sogenannten „Hintergrundthread“ auszulagern. Diese werden dann in ihrem eigenen Prozess, im Hintergrund und getrennt von der Website ausgeführt, ohne dabei die Benutzeroberfläche oder andere Skripts daran zu hindern, Interaktionen von Nutzern zu verarbeiten.

Details: caniuse.com

Anwendungsbeispiel

Bei dieser Berechnung wäre der Browser für längere Zeit blockiert, eine Hinweis- oder Fehlermeldung wie „Nicht reagierendes Skript“ o.ä. wäre die Folge.

Ohne WebWorker
let i, summe = 0;
for (i = 0; i < 900000000; i++) {
    summe += i * i + Math.random();
};
alert(summe);

Erstellung des WebWorkers

Der Sourcecode eines WebWorkers wird unabhängig vom Sourcecode der eigentlichen Seite geladen. Sie müssen den Code, der im Worker ausgeführt werden soll, deshalb in einer eigenen .js Datei bereitstellen. Voraussetzung ist allerdings, dass diese .js Datei den gleichen Ursprung (Origin) hat wie das HTML Dokument, das den Worker erzeugt.

Dieser Code darf nichts tun, was in irgendeiner Art auf das DOM zugreift oder eine Benutzerinteraktion benötigt. Worker laufen unabhängig von der HTML Umgebung und können mit ihr nur über ein Nachrichtenprotokoll kommunizieren. Dazu später mehr.

berechnung.js
let i, summe = 0;
for (i = 0; i < 900000000 ; i++) {
    summe += i * i + Math.random();
};

Dieser Code wird aus dem Hauptdokument entfernt und statt dessen der Worker erzeugt:

Hauptdokument
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>WebWorker</title>
</head>
<body>
  <script>
    const webworker = new Worker("berechnung.js");
  </script>
</body>
</html>

Der Worker-Konstruktor erwartet als erstes Argument die URL der JavaScript-Datei, die den auszuführenden Programmcode enthält. Nun erscheint die Meldung des Browsers zwar nicht mehr und der Browser ist auch nicht mehr blockiert, aber wir erhalten auch kein Ergebnis. Das liegt daran, dass das zuvor erwähnte Nachrichtenprotokoll noch nicht eingerichtet wurde.

Daten senden und empfangen

Das Worker-Nachrichtenprotokoll verwendet die Methode postMessage zum Versenden von Nachrichten. Beim Empfänger wird daraufhin ein message-Event ausgelöst, für das er einen Eventhandler registrieren muss.

Unser Hauptprogramm sieht damit so aus:

Hauptdokument
    const webworker = new Worker("berechnung.js");
    webworker.addEventListener("message", function(messageEvent) {
        alert("Ergebnis: " + messageEvent.data);
    };
    webworker.postMessage("Run");

Auf dem webworker-Objekt wird ein Eventhandler für das message Event registriert, um die Antwort des Workers entgegennehmen zu können. Dieser Eventhandler wird mit einem Objekt vom Typ MessageEvent aufgerufen. Die Eigenschaft data dieser Schnittstelle enthält den den empfangenen Wert. An Stelle einer addEventListener-Registrierung könnte man den Eventhandler auch der onmessage-Eigenschaft des Workers zuweisen.

Danach wird durch den Aufruf webworker.postMessage("Run") dem Worker der Text "Run" geschickt. Wenn eine Antwort eintrifft, wird der message-Eventhandler des Hauptprogramms ausgeführt und zeigt das Ergebnis in einem alert-Popup an.

Beachten Sie: postMessage und die data-Eigenschaft können einfache Werte, aber auch Objekte übertragen. Das Ergebnis der Übertragung ist aber nicht mit der Übergabe von Argumenten an eine Funktion vergleichbar. Objekte, die an eine Funktion übergeben werden, sind in der Funktion als Referenz verfügbar, d. h. eine Änderung im Objekt verändert das Objekt auch für den Aufrufer. Das Message-Protokoll erzeugt immer eine Kopie, ein Worker kann deshalb ein empfangenes Objekt nach Belieben ändern, ohne dass sich das für den Absender irgendwie auswirkt. Die Kopie wird mit Hilfe des Structured Clone-Mechanismus erzeugt, der gewissen Einschränkungen unterliegt. Details dazu finden Sie im verlinkten Glossar-Artikel.

In der Datei "berechung.js" müssen wir nun einbauen, dass die Nachricht vom Hauptprogramm empfangen wird. Als Reaktion darauf muss die Berechnung ausgeführt und das Ergebnis zurückgeschickt werden. Im Hauptprogramm sind wir so vorgegangen, sowohl für das message-Event wie auch für den postMessage-Aufruf das Worker-Objekt verwendet wurde. Dieses Objekt haben wir im Worker nicht zur Verfügung.

Was wir statt dessen haben, ist das globale Objekt des Workers. Es enthält analog zum window-Objekt des Hauptprogramms eine [JavaScript/DOM/EventTarget|EventTarget]-Schnittstelle und auch eine postMessage-Methode, es ist aber kein window-Objekt und es ist deshalb auch nicht in einer Variablen namens window zu finden. Statt dessen haben wir eine globale Variable namens self, hinter der sich ein DedicatedWorkerGlobalScope-Objekt verbirgt.

Hinweis:
Es gibt einige Konfusion in JavaScript, was den Fundort des globalen Objekts anbetrifft. Im Abschnitt Globale Variablen des Artikels über JavaScript-Variablen finden Sie weitere Hinweise dazu.

Das geänderte berechnung.js Script sieht so aus:

berechnung.js
self.addEventListener("message", function(messageEvent) {
    if (messageEvent.data != "Run")
        return;

    let i, summe = 0;
    for (i = 0; i < 900000000; i++) {
        summe += i * i + Math.random();
    }
    self.postMessage(summe);
});

Der Worker tut nun beim Starten nichts anderes mehr, als im globalen Objekt self den message-Eventhander zu registrieren. Erst, wenn er eine Nachricht mit dem Inhalt "Run" erhält, beginnt er mit der Arbeit. Wie im Hauptprogramm ist auch hier das Zuweisen des Eventhandlers an self.onmessage möglich.

Die Eventhandler-Funktion prüft zunächst, ob sie die erwartete "Run"-Nachricht erhält. Wenn nicht, tut sie einfach gar nichts. Kommt die "Run"-Nachricht an, wird die Additionsschleife durchgeführt und danach die Antwort verschickt. Die postMessage()-Methode des globalen Objekts weiß, von wem der Worker erzeugt wurde, und schickt die Nachricht automatisch dorthin.

Das Hauptprogramm kann während der Zeit, in der der Worker seiner Arbeit nachgeht, weitere Messages an den Worker schicken. Diese Nachrichten werden von JavaScript in eine Warteschlange gestellt, die Task Queue. Erst dann, wenn die Verarbeitung einer Nachricht abgeschlossen ist, wird der nächste Task aus der Queue verarbeitet.

Worker schließen

Wenn ein Worker nicht mehr benötigt wird, ist es natürlich ratsam, ihn wieder zu beenden. Ein Worker belegt zumindest Speicher, und möglicherweise auch Prozessorzeit (je nach Inhalt). Es gibt 2 Möglichkeiten, einen Worker zu beenden:

  • der Worker beendet sich selbst
  • derjenige Prozess, der den Worker erzeugt hat, beendet ihn.

Im Worker

Im Worker selbst findet sich die globale Methode self.close(). Sie löscht alle Nachrichten, die möglicherweise noch in der Warteschlange des Workers stehen und veranlasst, dass weitere Nachrichten, die mit postMessage an den Worker geschickt werden, automatisch verworfen werden. Sobald die Verarbeitung der aktuellen Message abgeschlossen ist, endet der Worker. Die Verarbeitung ist abgeschlossen, wenn der Event Task endet, in dem close() aufgerufen wurde. Sofern daraus noch Mikrotasks offen sind (z.B. erfüllte Promises), werden diese ebenfalls noch ausgeführt. Weitere Tasks, wie z.B. ein setTimeout-Callback, werden verworfen.

Hier nun der vollständige Code der Datei "berechnung.js. Nach dem Ausführen eines Run-Commands beendet sich der Worker selbst.

berechnung.js - Schließen eines Web Workers
self.onmessage = function(messageEvent) {
    if (messageEvent.data == "Run") {
        let i, summe = 0;
        for (i = 0; i < 900000000; i++) {
            summe += i * i + Math.random();
        }
        self.postMessage(summe);
        self.close();
    }
};

Die close() Methode könnte einen graceful shutdown eines Workers ermöglichen, also ein kontrolliertes Beenden mit der Möglichkeit, erforderliche Aufräumarbeiten zu erledigen. Das ist aber leider nicht so einfach, denn solange ein message-Eventhandler vor sich hin arbeitet, kommen keine neuen Messages in den Worker hinein. Das Hauptprogramm hat dadurch keinen Kanal, auf dem es ein kooperatives Stop-Kommando an den Worker übermitteln könnte. Um dies zu lösen, muss der Worker eine langlaufende Arbeit mittels setTimeout auf mehrere Arbeitsschritte verteilen, von denen jeder Einzelne nur eine klar begrenzte Zeit beansprucht. Ankommende Nachrichten muss er eigenständig in eine Queue legen, denn durch die setTimeout-Aufteilung können ja neue message-Events verarbeitet werden. Aber auf diesem Weg kann auch eine Stop-Nachricht durchkommen und Beachtung finden.


Hauptdokument

Im Hauptdokument kann eine ähnliche Wirkung durch die terminate()-Methode des Worker-Objekts erzielt werden. Der Unterschied zu self.close(); im Worker ist, dass terminate() den Worker unmittelbar beendet. Wird in dem Moment noch eine Message verarbeitet, bricht diese Verarbeitung ab.

Das ist problemlos, wenn der Worker nur vor sich hin rechnet und keine externen Ressourcen beansprucht. Ist aber eine Verarbeitung gefragt, die gewisse Aufräumtätigkeiten als Abschluss einer message-Verarbeitung erfordert, sollten Sie von einem derartigen harten Abschluss absehen und sich mit den graceful shutdown Überlegungen des vorigen Abschnitts beschäftigen.

Hier eine Variante der Hauptdatei, die den Worker aktiv beendet. Da der terminate() Aufruf stattfindet, wenn die Antwort des Workers eingetroffen ist und keine weitere Run-Anforderung geschickt wurde, ist der Worker in diesem Moment untätig, seine Beendigung kann also keine unangenehmen Folgen nach sich ziehen.

Hauptdokument - Schließen des Web Workers
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>WebWorker</title>
</head>
<body>
  <script>
    const webworker = new Worker("berechnung.js");
    webworker.postMessage("Run");
    webworker.onmessage = function(workerMessage) {
      alert("Ergebnis: " + workerMessage.data);
      webworker.terminate();    
    };
  </script>
</body>
</html>
Beachten Sie: Sie müssen nur einen der beiden Wege implementieren. Ein self.close() im Worker und ein worker.terminate() im Hauptprogramm zeitigen keinen Nutzen.

Muss es eine eigenständige .js Datei sein?

Genau genommen: Nein. Der Worker-Konstruktor erwartet eine URL, und eine URL kann auch etwas anderes sein als ein Verweis auf eine externe Ressource. Die Alternative zu einer externen URL besteht im Erstellen einer Object-URL oder einer Data-URL, wobei der Umgang mit einer Object-URL einfacher ist.

Das folgende Beispiel finden Sie in ähnlicher Form an vielen Stellen im Internet.

Laden eines Workers aus einer internen Ressource
<script id="worker" type="application/x-worker">
   console.log("Hier ist dein Wörker");
   self.postMessage("Wörk done!");
   self.close();
</script>

<script> 
   let workerSource = document.querySelector("script#worker");
   let workerBlob = new Blob([workerSource.text], { type: "application/javascript" });
   let workerUrl = URL.createObjectURL(workerBlob);
   let worker = new Worker(workerUrl);
   URL.revokeObjectURL(workerUrl)

   worker.addEventListener("message", function(messageEvent) {
      console.log("Der Wörker sagt: " + messageEvent.data);
   });
</script>

Dieser Auszug aus einer HTML Datei enthält zwei script-Elemente. Das erste Element hat eine Medientyp-Angabe von application/x-worker. Das x Präfix ist reserviert für private Medientypen. Andere Beispiele im Internet verwenden Medientypen wie javascript/worker, verletzen damit aber zwei wichtige Regeln. Erstens sind für den ersten Teil eines Medientypen nur die 8 Standardtypen aus RFC 6838 zugelassen und zweitens muss ein Medientyp, der nicht privat ist, bei der IANA registriert sein. Deswegen ist ein privater Medientyp zu bevorzugen.

Jedenfalls hat der Inhalt des script-Elements nun einen Medientyp, den der Browser nicht mit JavaScript assoziiert und deshalb auch nicht als Script ausführt. Er betrachtet es einfach als eine Textressource, mit der ein Script beliebig verfahren kann.

Das geschieht im zweiten script-Element. Es lokalisiert das script-Element mit dem Worker-Quellcode und erstellt ein Blob-Objekt aus seinem Inhalt (der in der Eigenschaft text zu finden ist). Blob steht für "binary large object" - letztlich ein Ablageort für beliebige Ressourcen. Eine wichtige Eigenschaft von Blobs ist, dass man ihrem Inhalt einen Medientyp zuweisen kann. Eine weitere Eigenschaft von Blobs ist, dass man sie als Quelle für Object-URLs verwenden kann, und das geschieht über die Methode createObjectURL des URL-Objekts.

Aus dieser URL kann nun der Worker erstellt werden. Wichtig ist, dass Object-URLs und die daran gebundenen Objekte solange weiterbestehen, bis die HTML Seite aus dem Speicher gelöscht wird (durch Schließen des Browser-Tab oder durch Laden eines anderen Dokuments). Um kein Speicherleck zu produzieren, muss eine Object-URL deshalb möglichst schnell wieder freigegeben werden. Direkt nach Aufruf des Worker-Konstruktors ist ein guter Zeitpunkt dafür - es sei denn, Sie möchten einen ganzen Schwarm von Workern mit den gleichen Quellcode erstellen.

Wie kann ich die strikte Same Origin Policy umgehen?

Worker (Dedicated und Shared) lassen sich nur nutzen, wenn der Origin von Dokument und Worker gleich ist. Nun gibt es natürlich Fälle, wo Sie in einer Seite mit Origin A ein Script verwenden wollen, das den Origin B hat. Sei es in einer kontrollierten Firmenumgebung, oder einfach nur verschiedene Subdomains Ihrer Homepage. Mit Hilfe von Cross-Origin Headern kann man erreichen, dass fetch-Anforderungen zu anderen Origins möglich sind. Das reicht aber nicht, um einen Worker zu laden.

In diesem Fall können Sie den Quelltext des Workers per fetch beschaffen und in einem String speichern. Sobald Sie diesen String haben, können Sie die Technik aus dem vorigen Abschnitt verwenden, um eine Object-URL zu erzeugen und den Worker daraus zu erzeugen.

Natürlich können böse Menschen das auch. Cross-Origin Freigaben sind deshalb immer nur mit Vorsicht zu erteilen.

SharedWorker

Die SharedWorker-Schnittstelle stellt einen Worker bereit, der von verschiedenen Fenstern, iframes oder auch anderen Workern erreicht werden kann. Im Unterschied zu den Webworkern verwendet sie eine andere Schnittstelle und hat ein anderes globales Scope, SharedWorkerGlobalScope.

Details: caniuse.com

Beachten Sie: Auf SharedWorker können Sie zwar von verschiedenen Browsing contexts zugreifen, sie müssen aber alle die gleiche Herkunft (Protokoll, Host und Port) haben. Siehe auch Same-Origin-Policy.

Siehe auch

Weblinks

Web Worker

SharedWorker