Web Worker-Grundlagen

Das Problem: JavaScript-Nebenläufigkeit

Es gibt eine Reihe von Engpässen, die verhindern, dass interessante Anwendungen (z. B. von serverlastigen Implementierungen) auf clientseitiges JavaScript übertragen werden. Dazu gehören Browserkompatibilität, statische Typisierung, Barrierefreiheit und Leistung. Glücklicherweise gehört Letzteres jedoch schnell der Vergangenheit an, da Browseranbieter schnell die Geschwindigkeit ihrer JavaScript-Engines verbessern.

Eine Sache, die JavaScript hindert, ist tatsächlich die Sprache selbst. JavaScript ist eine Single-Threaded-Umgebung, es können also nicht mehrere Skripts gleichzeitig ausgeführt werden. Stellen Sie sich zum Beispiel eine Website vor, die UI-Ereignisse verarbeiten, große Mengen an API-Daten abfragen und verarbeiten und das DOM manipulieren muss. Das kommt ganz schön häufig vor, oder? Leider kann dies aufgrund von Einschränkungen der JavaScript-Laufzeit von Browsern nicht gleichzeitig erfolgen. Die Skriptausführung erfolgt innerhalb eines einzelnen Threads.

Entwickler ahmen die Nebenläufigkeit mithilfe von Techniken wie setTimeout(), setInterval(), XMLHttpRequest und Event-Handlern nach. Ja, alle diese Funktionen werden asynchron ausgeführt, aber blockierungsfrei bedeutet nicht unbedingt Nebenläufigkeit. Asynchrone Ereignisse werden verarbeitet, nachdem das aktuelle Ausführungsskript ausgeführt wurde. Die gute Nachricht: HTML5 bietet uns etwas Besseres als diese Hacks!

Einführung in Web Workers: Threading in JavaScript

Die Web Workers-Spezifikation definiert eine API zum Erstellen von Hintergrundskripts in Ihrer Webanwendung. Mit Web Workern können Sie beispielsweise Skripts mit langer Ausführungszeit zur Verarbeitung rechenintensiver Aufgaben erstellen, ohne dabei die UI oder andere Skripts für Nutzerinteraktionen zu blockieren. Damit beenden Sie das lästige, nicht reagierende Skript, das wir alle lieben:

Dialogfeld mit nicht reagierendem Script
Häufiges Dialogfeld mit nicht reagierenden Skripts

Worker verwenden eine Thread-ähnliche Nachrichtenweitergabe, um Parallelität zu erzielen. Sie sind perfekt, um Ihre UI aktuell, leistungsfähig und responsiv für Nutzende zu halten.

Web Worker-Typen

In der Spezifikation werden zwei Arten von Web Workern behandelt: Dedicated Worker und gemeinsam genutzte Worker. In diesem Artikel werden nur engagierte Mitarbeiter behandelt. Ich bezeichne sie durchgehend als „Web Worker“ oder „Worker“.

Erste Schritte

Web Worker werden in einem isolierten Thread ausgeführt. Daher muss der Code, den sie ausführen, in einer separaten Datei enthalten sein. Bevor wir das tun, müssen Sie jedoch zuerst ein neues Worker-Objekt auf Ihrer Hauptseite erstellen. Der Konstruktor übernimmt den Namen des Worker-Skripts:

var worker = new Worker('task.js');

Wenn die angegebene Datei vorhanden ist, erstellt der Browser einen neuen Worker-Thread, der asynchron heruntergeladen wird. Der Worker beginnt erst, wenn die Datei vollständig heruntergeladen und ausgeführt wurde. Wenn der Pfad zu Ihrem Worker einen 404-Fehler zurückgibt, schlägt der Worker im Hintergrund fehl.

Nachdem Sie den Worker erstellt haben, starten Sie ihn durch Aufrufen der Methode postMessage():

worker.postMessage(); // Start the worker.

Mit einem Worker per Nachrichtenweitergabe kommunizieren

Die Kommunikation zwischen einer Arbeit und der übergeordneten Seite erfolgt über ein Ereignismodell und die Methode postMessage(). Je nach Browser bzw. Version akzeptiert postMessage() entweder einen String oder ein JSON-Objekt als einzelnes Argument. Die neuesten Versionen der modernen Browser unterstützen die Übergabe eines JSON-Objekts.

Im Folgenden finden Sie ein Beispiel für die Verwendung eines Strings, um „Hello World“ an einen Worker in doWork.js zu übergeben. Der Worker gibt einfach die an ihn übergebene Nachricht zurück.

Hauptskript:

var worker = new Worker('doWork.js');

worker.addEventListener('message', function(e) {
console.log('Worker said: ', e.data);
}, false);

worker.postMessage('Hello World'); // Send data to our worker.

doWork.js (der Worker):

self.addEventListener('message', function(e) {
self.postMessage(e.data);
}, false);

Wenn postMessage() von der Hauptseite aufgerufen wird, verarbeitet unser Worker diese Nachricht, indem er einen onmessage-Handler für das message-Ereignis definiert. Die Nachrichtennutzlast (in diesem Fall „Hello World“) ist in Event.data zugänglich. Auch wenn dieses Beispiel nicht sehr aufregend ist, zeigt es, dass mit postMessage() auch Daten zurück an den Hauptthread übergeben werden. Praktisch!

Nachrichten, die zwischen der Hauptseite und Workern übergeben werden, werden kopiert, nicht gemeinsam genutzt. Im nächsten Beispiel ist die Eigenschaft „msg“ der JSON-Nachricht an beiden Orten zugänglich. Es scheint, dass das Objekt direkt an den Worker übergeben wird, obwohl es in einem separaten, dafür vorgesehenen Bereich ausgeführt wird. Das Objekt wird in Wirklichkeit bei der Übergabe an den Worker partitioniert und anschließend am anderen Ende deserialisiert. Seite und Worker teilen sich nicht dieselbe Instanz, sodass bei jeder Karte bzw. jedem Ticket ein Duplikat erstellt wird. In den meisten Browsern wird diese Funktion implementiert, indem der Wert auf beiden Seiten automatisch in JSON codiert/decodiert wird.

Im Folgenden finden Sie ein komplexeres Beispiel, bei dem Nachrichten mithilfe von JSON-Objekten übergeben werden.

Hauptskript:

<button onclick="sayHI()">Say HI</button>
<button onclick="unknownCmd()">Send unknown command</button>
<button onclick="stop()">Stop worker</button>
<output id="result"></output>

<script>
function sayHI() {
worker.postMessage({'cmd': 'start', 'msg': 'Hi'});
}

function stop() {
// worker.terminate() from this script would also stop the worker.
worker.postMessage({'cmd': 'stop', 'msg': 'Bye'});
}

function unknownCmd() {
worker.postMessage({'cmd': 'foobard', 'msg': '???'});
}

var worker = new Worker('doWork2.js');

worker.addEventListener('message', function(e) {
document.getElementById('result').textContent = e.data;
}, false);
</script>

doWork2.js:

self.addEventListener('message', function(e) {
var data = e.data;
switch (data.cmd) {
case 'start':
    self.postMessage('WORKER STARTED: ' + data.msg);
    break;
case 'stop':
    self.postMessage('WORKER STOPPED: ' + data.msg +
                    '. (buttons will no longer work)');
    self.close(); // Terminates the worker.
    break;
default:
    self.postMessage('Unknown command: ' + data.msg);
};
}, false);

Übertragbare Objekte

In den meisten Browsern wird der Algorithmus zum strukturierten Klonen implementiert, mit dem sich komplexere Typen an und aus Workern wie File, Blob, ArrayBuffer und JSON-Objekte übergeben lassen. Wenn diese Datentypen jedoch mit postMessage() übergeben werden, wird trotzdem eine Kopie erstellt. Wenn Sie beispielsweise eine große Datei mit 50 MB übergeben, entsteht ein deutlicher Aufwand beim Abrufen der Datei zwischen dem Worker und dem Hauptthread.

Strukturiertes Klonen ist toll, aber eine Kopie kann mehrere Hundert Millisekunden in Anspruch nehmen. Sie können übertragbare Objekte verwenden, um die Treffer zu beseitigen.

Mit übertragbaren Objekten werden Daten von einem Kontext in einen anderen übertragen. Dabei handelt es sich um Zero-Copy-Verfahren, was die Leistung beim Senden von Daten an einen Worker erheblich verbessert. Betrachten Sie es als Referenz, wenn Sie aus der C/C++-Welt kommen. Im Gegensatz zur Pass-by-Referenz ist die „Version“ aus dem aufrufenden Kontext jedoch nicht mehr verfügbar, nachdem sie in den neuen Kontext übertragen wurde. Wenn Sie beispielsweise einen ArrayBuffer von Ihrer Haupt-App an einen Worker übertragen, wird die ursprüngliche ArrayBuffer gelöscht und kann nicht mehr verwendet werden. Sein Inhalt wird (im wahrsten Sinne des Wortes leise) an den Worker-Kontext übertragen.

Wenn Sie übertragbare Objekte verwenden möchten, verwenden Sie eine etwas andere Signatur von postMessage():

worker.postMessage(arrayBuffer, [arrayBuffer]);
window.postMessage(arrayBuffer, targetOrigin, [arrayBuffer]);

Im Worker-Fall ist das erste Argument die Daten und das zweite die Liste der Elemente, die übertragen werden sollen. Das erste Argument muss übrigens kein ArrayBuffer sein. Es kann sich beispielsweise um ein JSON-Objekt handeln:

worker.postMessage({data: int8View, moreData: anotherBuffer},
                [int8View.buffer, anotherBuffer]);

Wichtig ist: Das zweite Argument muss ein Array von ArrayBuffer-Werten sein. Dies ist die Liste der übertragbaren Artikel.

Weitere Informationen zu übertragbaren Inhalten finden Sie in unserem Beitrag unter developer.chrome.com.

Die Worker-Umgebung

Worker-Bereich

Im Kontext eines Workers verweisen sowohl self als auch this auf den globalen Geltungsbereich für den Worker. Daher könnte das vorherige Beispiel auch so geschrieben werden:

addEventListener('message', function(e) {
var data = e.data;
switch (data.cmd) {
case 'start':
    postMessage('WORKER STARTED: ' + data.msg);
    break;
case 'stop':
...
}, false);

Alternativ können Sie den Event-Handler onmessage direkt festlegen, obwohl addEventListener von JavaScript-Ninjas immer empfohlen wird.

onmessage = function(e) {
var data = e.data;
...
};

Für Mitarbeiter verfügbare Funktionen

Aufgrund ihres Multithread-Verhaltens haben Web Worker nur auf einen Teil der JavaScript-Funktionen Zugriff:

  • Das navigator-Objekt
  • Das location-Objekt (schreibgeschützt)
  • XMLHttpRequest
  • setTimeout()/clearTimeout() und setInterval()/clearInterval()
  • Anwendungscache
  • Externe Skripts mit der Methode importScripts() importieren
  • Weitere Web Worker werden erstellt

Worker haben KEINEN Zugriff auf:

  • Das DOM (nicht Thread-sicher)
  • Das window-Objekt
  • Das document-Objekt
  • Das parent-Objekt

Externe Skripts werden geladen

Mit der Funktion importScripts() können Sie externe Skriptdateien oder Bibliotheken in einen Worker laden. Die Methode verwendet null oder mehr Strings, die die Dateinamen für die zu importierenden Ressourcen darstellen.

In diesem Beispiel werden script1.js und script2.js in den Worker geladen:

worker.js:

importScripts('script1.js');
importScripts('script2.js');

Dies kann auch als einzelne Importanweisung geschrieben werden:

importScripts('script1.js', 'script2.js');

Untergeordnete Worker

Worker sind in der Lage, untergeordnete Worker zu erzeugen. Dies eignet sich hervorragend, um große Aufgaben während der Laufzeit weiter aufzuteilen. Untergeordnete Worker haben jedoch einige Einschränkungen:

  • Untergeordnete Worker müssen im selben Ursprung gehostet werden wie die übergeordnete Seite.
  • URIs innerhalb von untergeordneten Workern werden relativ zum Standort des übergeordneten Workers aufgelöst (im Gegensatz zur Hauptseite).

Beachten Sie, dass die meisten Browser separate Prozesse für jeden Worker erzeugen. Bevor Sie eine Worker-Farm erzeugen, sollten Sie vorsichtig sein, damit zu viele Systemressourcen des Nutzers beansprucht werden. Ein Grund dafür ist, dass Nachrichten, die zwischen Hauptseiten und Workern ausgetauscht werden, kopiert und nicht gemeinsam genutzt werden. Weitere Informationen finden Sie unter Mit einem Worker via Nachrichtenweitergabe kommunizieren.

Ein Beispiel zum Erstellen eines untergeordneten Workers finden Sie im Beispiel in der Spezifikation.

Inline-Worker

Wie können Sie Ihr Worker-Skript spontan oder eine eigenständige Seite erstellen, ohne separate Worker-Dateien erstellen zu müssen? Mit Blob() können Sie Ihren Worker in dieselbe HTML-Datei wie Ihre Hauptlogik einbetten. Dazu erstellen Sie ein URL-Handle zum Worker-Code als String:

var blob = new Blob([
"onmessage = function(e) { postMessage('msg from worker'); }"]);

// Obtain a blob URL reference to our worker 'file'.
var blobURL = window.URL.createObjectURL(blob);

var worker = new Worker(blobURL);
worker.onmessage = function(e) {
// e.data == 'msg from worker'
};
worker.postMessage(); // Start the worker.

Blob-URLs

Noch mehr Magie ist der Aufruf von window.URL.createObjectURL(). Mit dieser Methode wird ein einfacher URL-String erstellt, mit dem auf Daten verwiesen werden kann, die in einem DOM-File- oder Blob-Objekt gespeichert sind. Beispiel:

blob:http://localhost/c745ef73-ece9-46da-8f66-ebes574789b1

Blob-URLs sind eindeutig und bleiben für die Lebensdauer Ihrer Anwendung bestehen (z.B. bis document entladen wird). Wenn Sie viele Blob-URLs erstellen, empfiehlt es sich, nicht mehr benötigte Referenzen freizugeben. Sie können Blob-URLs explizit freigeben, indem Sie sie an window.URL.revokeObjectURL() übergeben:

window.URL.revokeObjectURL(blobURL);

In Chrome gibt es eine Seite, auf der alle erstellten Blob-URLs angezeigt werden können: chrome://blob-internals/.

Vollständiges Beispiel

Wenn wir einen Schritt weiter gehen, können wir erfahren, wie der JS-Code des Workers in unsere Seite eingebettet ist. Bei dieser Methode wird ein <script>-Tag verwendet, um den Worker zu definieren:

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>

<div id="log"></div>

<script id="worker1" type="javascript/worker">
// This script won't be parsed by JS engines
// because its type is javascript/worker.
self.onmessage = function(e) {
    self.postMessage('msg from worker');
};
// Rest of your worker code goes here.
</script>

<script>
function log(msg) {
    // Use a fragment: browser will only render/reflow once.
    var fragment = document.createDocumentFragment();
    fragment.appendChild(document.createTextNode(msg));
    fragment.appendChild(document.createElement('br'));

    document.querySelector("#log").appendChild(fragment);
}

var blob = new Blob([document.querySelector('#worker1').textContent]);

var worker = new Worker(window.URL.createObjectURL(blob));
worker.onmessage = function(e) {
    log("Received: " + e.data);
}
worker.postMessage(); // Start the worker.
</script>
</body>
</html>

Meiner Meinung nach ist dieser neue Ansatz etwas übersichtlicher und besser lesbar. Sie definiert ein Script-Tag mit id="worker1" und type='javascript/worker', sodass der Browser das JS nicht parsen muss. Dieser Code wird mit document.querySelector('#worker1').textContent als String extrahiert und an Blob() übergeben, um die Datei zu erstellen.

Externe Skripts werden geladen

Wenn Sie diese Methoden zum Einbetten Ihres Worker-Codes verwenden, funktioniert importScripts() nur, wenn Sie einen absoluten URI angeben. Wenn Sie versuchen, einen relativen URI zu übergeben, gibt der Browser einen Sicherheitsfehler zurück. Der Grund: Der Worker, der jetzt aus einer Blob-URL erstellt wurde, wird mit dem Präfix blob: aufgelöst, während die Anwendung über ein anderes Schema (vermutlich http://) ausgeführt wird. Der Fehler wird daher durch ursprungsübergreifende Einschränkungen verursacht.

Eine Möglichkeit, importScripts() in einem Inline-Worker zu verwenden, besteht darin, die aktuelle URL, über die Ihr Hauptskript ausgeführt wird, zu „einschleusen“. Dazu übergeben Sie sie an den Inline-Worker und generieren die absolute URL manuell. Dadurch wird sichergestellt, dass das externe Skript aus demselben Ursprung importiert wird. Angenommen, Ihre Hauptanwendung wird über http://example.com/index.html ausgeführt:

...
<script id="worker2" type="javascript/worker">
self.onmessage = function(e) {
var data = e.data;

if (data.url) {
var url = data.url.href;
var index = url.indexOf('index.html');
if (index != -1) {
    url = url.substring(0, index);
}
importScripts(url + 'engine.js');
}
...
};
</script>
<script>
var worker = new Worker(window.URL.createObjectURL(bb.getBlob()));
worker.postMessage(<b>{url: document.location}</b>);
</script>

Umgang mit Fehlern

Wie bei jeder JavaScript-Logik müssen Sie Fehler beheben, die in Ihren Web Workern ausgegeben werden. Wenn beim Ausführen eines Workers ein Fehler auftritt, wird ein ErrorEvent ausgelöst. Die Benutzeroberfläche enthält drei nützliche Eigenschaften, um herauszufinden, was schiefgelaufen ist: filename – der Name des Worker-Skripts, das den Fehler verursacht hat, lineno – die Nummer der Zeile, in der der Fehler aufgetreten ist, und message – eine aussagekräftige Beschreibung des Fehlers. Hier ist ein Beispiel für die Einrichtung eines onerror-Event-Handlers, mit dem die Attribute des Fehlers ausgegeben werden:

<output id="error" style="color: red;"></output>
<output id="result"></output>

<script>
function onError(e) {
document.getElementById('error').textContent = [
    'ERROR: Line ', e.lineno, ' in ', e.filename, ': ', e.message
].join('');
}

function onMsg(e) {
document.getElementById('result').textContent = e.data;
}

var worker = new Worker('workerWithError.js');
worker.addEventListener('message', onMsg, false);
worker.addEventListener('error', onError, false);
worker.postMessage(); // Start worker without a message.
</script>

Beispiel: WorkerWithError.js versucht, 1/x auszuführen, wobei x nicht definiert ist.

// AUFGABE: DevSite - Codebeispiel entfernt, da Inline-Event-Handler verwendet wurden

workerWithError.js:

self.addEventListener('message', function(e) {
postMessage(1/x); // Intentional error.
};

Ein Hinweis zum Thema Sicherheit

Einschränkungen beim lokalen Zugriff

Aufgrund der Sicherheitsbeschränkungen von Google Chrome werden Worker in den neuesten Versionen des Browsers nicht lokal ausgeführt (z.B. über file://). Stattdessen scheitern sie stillschweigend! Wenn Sie Ihre App über das Schema file:// ausführen möchten, führen Sie Chrome aus und setzen Sie das Flag --allow-file-access-from-files.

Bei anderen Browsern gibt es diese Einschränkung nicht.

Überlegungen zum selben Ursprung

Bei Worker-Skripts muss es sich um externe Dateien mit demselben Schema wie die aufrufende Seite handeln. Daher können Sie kein Script von einer data:- oder javascript:-URL laden. Außerdem können von einer https:-Seite keine Worker-Skripts gestartet werden, die mit http:-URLs beginnen.

Anwendungsfälle

Welche Art von App würde also Web Worker verwenden? Hier noch ein paar weitere Ideen, mit denen du deine grauen Zellen in Schwung bringen kannst:

  • Vorabruf und/oder Caching von Daten zur späteren Verwendung
  • Syntaxhervorhebung von Code oder andere Textformatierungen in Echtzeit.
  • Rechtschreibprüfung.
  • Video- oder Audiodaten analysieren
  • E/A im Hintergrund oder Abfragen von Webdiensten
  • Verarbeitung großer Arrays oder umfangreicher JSON-Antworten
  • Bildfilterung in <canvas>.
  • Viele Zeilen einer lokalen Webdatenbank aktualisieren

Weitere Informationen zu Anwendungsfällen mit der Web Workers API finden Sie in der Worker-Übersicht.

Demos

Verweise