In letzter Zeit hab ich hin und wieder Dinge gebastelt, die schwergewichtige Rechenaufgaben in Browsern durchführen. Dank HTML5 gibt es hierfür ja mehr als genug Anlässe, wobei mein konkretes Anliegen die Analyse von Bilddaten aus Webcam-Feeds war. Das Grundproblem bei so etwas ist, dass lang laufende Scripts (z.B. für Gesichtserkennung) den Browser komplett blockieren können. In meinem Fall ruckelte meist nur das Video, aber der Extremfall dieses Problems ist das allseits bekannte Ein-Script-auf-dieser-Seite-läuft-zu-lange-Popup. All das will man nicht haben und eigentlich hat HTML5 auch hierzu eine Lösung parat. Das Dumme an der Lösung ist, dass ihre Benutzung recht viel Aufwand bedeutet, so dass ich etwas drumherum konstruiert habe, was die Angelegenheit in vielen Fällen auf einen JavaScript-Einzeiler reduziert: Unsync.

Web Workers werden gerne als „Threads für JavaScript“ beworben. Dieses Label ist auch nicht falsch, aber ob Web Workers ihren Job durch Threads oder mit Mitteln schwarzer Magie ausführen, ist eigentlich egal. Der interessante Effekt von Workers ist, dass sie synchronen Code asynchronisieren und die Blockade des Browsers verhindern. Dieses JS-Snippet bringt (Stand Mitte 2013) selbst die neueste Chrome-Beta ins Schwitzen:

for(var i = 0; i < 50000000; i++){
  Math.sqrt(Math.random() * 1000000000);
}
window.alert('Fertig!');

Bevor die Fertig-Meldung kommt, müssen wir mehrere Sekunden einen blockierten Browser ertragen. Web Workers erlauben es, die Blockade zu lösen. Dazu muss man den zeitkritischen Code in eine Extra-JS-Datei verfrachten und diese Extra-Datei durch einen Worker laden lassen:

// Haupt-JS-File
var myWorker = new Worker('worker.js');
myWorker.postMessage(null); // Startschuss geben
myWorker.onmessage = function(){ // Fertig-Meldung empfangen
  window.alert('Fertig!')
};

// worker.js
this.onmessage = function(){ // Startschuss empfangen
  for(var i = 0; i < 5000000000; i++){
    Math.sqrt(Math.random() * 1000000000);
  }
  this.postMessage(null); // Fertig-Meldung abschicken
};

Diese Lösung finde ich reichlich unzumutbar. Warum sollte man denn bitte den Code für die Rechenaufgabe in eine Extra-Datei verfrachten wollen? Code modularisiert man bitteschön nach rein inhaltlichen Gesichtspunkten festgelegten Grenzen, die Performance darf da nichts zu melden haben. Das ganze Gewurschtel mit Events und postMessage() könnte auch bequemer sein bzw. im Idealfall gar nicht stattfinden. Es wäre doch viel schöner wenn man einfach aus einer synchronen JS-Funktion mittels eines Einzeilers eine asnchrone Variante bauen könnte, in etwa so:

var asyncFn = macheAsynchron(synchroneFn);
asyncFn(arg1, arg2, function(result){
  console.log(result);
});

Meine kleine Library namens Unsync macht genau das. Und obwohl ein ganzer Haufen HTML5-Technik zum Einsatz kommt, ist die Funktionsweise eigentlich ganz simpel und die Browserunterstützung ist auch nicht vollends katastrophal.

Wie Unsync funktioniert

Einen neuen Web Worker startet man, indem man eine URL zu einem Script in den Worker-Constructor steckt:

var myWorker = new Worker('pfad/zu/worker.js');

Es steht aber nirgends geschrieben, dass ein Script für einen Worker tatsächlich in einer eigenen Datei existieren muss … die Spezifikationen sehen nur vor, dass die Constructorfunktion eine resource identified by url übergeben bekommen soll. Und Dateiressourcen nebst URL kann man in HTML5 einfach aus heißer Luft erzeugen, File API sei Dank. Der Blob-Constructor baut uns eine ausreichend dateiartige Ressource …

var myBlob = new Blob(['Hallo Welt!'], {
  type: 'text/plain'
});

… und die Funktion window.URL.createObjectURL() erzeugt eine URL auf diese Ressource, obwohl sie eigentlich nur ein JavaScript-Objekt in unserer JS-Sandbox ist:

var myBlobUrl = window.URL.createObjectURL(myBlob);
location.href = myBlobUrl;

Hiermit schickt uns der Browser an eine URL, die in etwa wie blob:http%3A//localhost/8f0a8135-ebef-43eb-b660-9ef69ce8cf3e aussieht und eine Plaintext-Datei mit dem Inhalt „Hallo Welt“ zu referenzieren scheint. Dass das Ganze nur innerhalb unserer JavaScript-Welt existiert, ist dabei für den Browser nicht von Belang – eine mit einer URL referenzierte Ressource ist eine mit einer URL referenzierte Ressource, egal woher. Und das klappt natürlich auch mit JavaScript:

var workerBlob = new Blob(['this.onmessage = function(){\
  for(var i = 0; i   5000000000; i++){\
    Math.sqrt(Math.random() * 1000000000);\
  }\
  this.postMessage(null);\
};'], {
  type: 'text/javascript'
});
var workerUrl = window.URL.createObjectURL(workerBlob);
var myWorker = new Worker(workerUrl);
myWorker.postMessage(null);
myWorker.onmessage = function(){
  window.alert('Fertig!')
};

… aber Code als String? Das geht ja nun mal gar nicht, schon gar nicht wenn es das performancekritische Herz unserer App ist. Lieber würde man eine Funktion in den Worker schieben. Das ist aber nicht ohne weiteres möglich – der für die Datenübermittlung in den Worker verwendete structured clone algorithm kann das nicht. Aber es gibt zum Glück Function.prototype.toString().

Funktionen haben, anders als die meisten anderen JavaScript-Objekte, eine sehr nützliche Variante der toString()-Methode. Diese spuckt tatsächlich eine brauchbare String-Repräsentation der betroffenen Funktion aus:

(function foo(){ return 42; }).toString()
// > "function foo(){ return 42; }"

Warum also nicht eine normale Funktion schreiben, sie stringifizieren und das ganze als Blob bzw. Objekt-URL in einen Web Worker schieben? Anfang (function berechnung(){) und Ende (}) der Funktion stehen hierbei freilich ein wenig im Weg, denn eigentlich wollen wir bloß den Inhalt der Funktion haben, nicht die gesamte Deklaration. Zum Glück können wir die Deklaration bequem in einen sofort ausgeführten Funktionsausdruck (IIFE) verwandeln. Hierfür müssen wir bloß ein paar Klammern vorn und hinten an den Funktionscode anbringen, was leicht ist, da der Blob-Constructor ohnehin als erstes Argument ein Array von Strings erwartet. Wir nehmen also die String-Repräsentation unserer Rechnen-Funktion, hängen vorn eine und hinten drei Klammern an und stecken das ganze via Blob und Blob-URL in den Worker:

// Quellfunktion
function berechnung(){
  this.onmessage = function(){
    for(var i = 0; i < 5000000000; i++){
      Math.sqrt(Math.random() * 1000000000);
    }
    this.postMessage(null);
  }
}

// Quellfunktion als String
var code = berechnung.toString();

// Blob aus dem String plus Klammern für die IIFE
var workerBlob = new Blob(['(', code, ')()'], {
  type: 'text/javascript'
});
var workerUrl = window.URL.createObjectURL(workerBlob);

// Worker erzeugen und benutzen
var myWorker = new Worker(workerUrl);
myWorker.postMessage(null);
myWorker.onmessage = function(){
  window.alert('Fertig!');
};

Die Quellfunktion berechnung() unterliegt einigen Beschränkungen. Es wird nicht die Funktion selbst, sondern nur ein diese Funktion abbildender String in den Web Worker geschoben, was bedeutet, dass die Funktion berechnung() selbst keine Seiteneffekte/Nebenwirkungen haben kann. Sie kann zum Beispiel nicht das DOM anfassen und kann keine Variablen aus übergeordneten Scopes verwenden. Außerdem muss sie zum jetzigen Zeitpunkt noch immer für Web Workers geschrieben werden, d.h. postMessage() zur Kommunikation über die Worker-Grenzen hinweg benutzen. Und das ist doof, denn der der Autor der Quellfunktion sollte nicht wissen müssen, wie ein Worker bzw. dessen Messaging-System funktioniert. Außerdem gibt es bisher keine Möglichkeit, Daten in den Worker hinein oder aus dem Worker herauszubekommen und wenn man den Worker nach getaner Arbeit schließen und seine Blob-URL deaktivieren könnte, wäre das ebenfalls nicht verkehrt. Und ohnehin wäre das ganze als Library-Funktion außerhalb des eigentlichen Moduls viel besser aufgeboben.

Die ersten beiden Hürden sind schnell genommen; nur der String, in den die Quellfunktion eingepackt wird, muss ein bisschen angepasst werden. Die Kommunikation zwischen Web Worker und Außenwelt wird über Events abgewickelt. Worker und Außenwelt verwenden die postMessage()-Methode, um auf der jeweiligen Gegenseite ein message-Event zu triggern. Das an postMessage() übergebene Argument wird als Nachricht übertragen, was mit so gut wie jedem JS-Datentyp funktioniert (Funktionen ausgenommen). Das wegzuabstrahieren ist recht simpel: wir ersetzen einfach die Klammern, die aus dem Funktionstemplate eine IIFE machen durch Code, der die Funktion in ein message-Event einpackt, die Arguments an das Funktionstemplate weiterleitet und das Ergebnis der Funktion mittels postMessage() zurücksendet. Aus alt …

var workerBlob = new Blob(['(', code, ')()'], {
  type: 'text/javascript'
});

 … wird neu:

var workerBlob = new Blob([
  'this.onmessage = function(evt){',
  '  var result = (', code, ').apply(null, evt.data);',
  '  this.postMessage(result);',
  '};'
], {
  type: 'text/javascript'
});

Jetzt kann als Quellfunktion wirklich jede Funktion herhalten, die ohne Seiteneffekte bzw. Nebenwirkungen und globale Variablen auskommt und der Autor der Funktion braucht nichts mehr über Web Workers zu wissen – dass es keine Seiteneffekte geben darf, sind die einzige verbleibende Beschränkung. Unsere Funktion berechnung() darf nun wieder aussehen wie eine ganz normale Funktion, ist schön zu lesen und durch die Verwendung im Web Worker aus nicht mehr blockierend:

// Quellfunktion
function berechnung(){
  for(var i = 0; i < 500000000; i++){
    Math.sqrt(Math.random() * 1000000000);
  }
}

// Quellfunktion als String
var code = berechnung.toString();

// Der Rest geht von selbst :)

Nun müsste nur noch der übrige Web-Worker-spezifische Code aus unserem Modul verschwinden. Außerdem gibt es noch zwei Details, die beachtet werden wollen und die sich nicht komplett abstrahieren lassen. Mit createObjectURL() erzeugte URLs sind potenziell immerwährende Referenzen auf das betroffene Objekt und daher ein schönes Speicherleck, was auch für die Web Workers selbst gilt – wenn wir die Hintergrundprozesse nicht terminieren, tut es niemand und sie verbrauchen bis ans Ende aller Tage Speicherplatz und Rechenpower. Die Unsyc-Library versucht all das so bequem wie möglich zu gestalten.

Unsync benutzen

Unsync besteht aus eigentlich nur einer einzigen Funktion namens unsync(), die aus einer synchronen Funktion ohne Seiteneffekte/Nebenwirkungen eine äquivalente asynchrone Funktion erzeugt:

var asyncFn = unsync(fn);
asyncFn(callback);

Wenn die originale Funktion n Arguments erwartet, erwartet die asynchrone Funktion n + 1 – alle normalen Arguments plus einem Callback am Ende. Dem Callback wird als erstes Argument das von der Quellfunktion errechnete Ergebnis übergeben:

// Quellfunktion; rechnet rum und gibt ein Benchmark zurück
function crunchNumbers(x){
  var startTime = new Date();
  for(var i = 0; i < x; i++){
    Math.sqrt(Math.random() * 1000000000);
  }
  var totalTime = new Date() - startTime;
  return totalTime;
}

// Asynchrone Version der obrigen Funktion erzeugen
var crunchAsync = unsync(crunchNumbers);

// Async-Function mit Arguments und Callback ausrufen
crunchAsync(5000000000, function(time){
  window.alert('Fertig nach ' + time);
});

So muss man fast gar nichts mehr über Web Workers wissen – fast. Das einzige, was sich nicht komplett automatsieren lässt, ist das Abschalten der Worker-Hintergrundprozesse, wenn diese nicht mehr gebraucht werden. Hierfür gibt es in Unsync zwei Möglichkeiten. Zum einem kann eine Einweg-Funktion erzeugt werden, die ihren Hintergrundprozess nach der erstmaligen Verwendung selbsttätig terminiert. Diese Funktion kann dann nur exakt einmal verwendet werden und reagiert auf neuerliche Aufruf-Versuche mit Exceptions. Um diesen Selbstzerstörungs-Mechanismus zu aktivieren einfach unsync() als zweites Argument true mitgeben:

var unsyncedEinweg = unsync(fn, true);
unsyncedEinweg(function(){      // Einmal klappt...
  unsyncedEinweg(function(){}); // Exception!
});

Alternativ können mit unsync() erzeugte Funktionen über ihre terminate()-Methode manuell ihren Worker ausgeknipst bekommen. Die Eigenschaft isTerminated verrät, ob der Worker einer Funktion terminiert wurde:

var unsynced = unsync(fn, true);
unsynced(); // Berechnung startet

// Wenn wir nach einer Minute nicht fertig sind und terminiert
// haben, ist es eh zu spät...
setTimeout(function(){
  if(!unsynced.isTerminated){
    unsynced.terminate();
    window.alert('Timeout!');
  }
}, 60 * 1000);

Das wäre eigentlich alles, was es zur Benutzung Unsync zu wissen gibt, wären da nicht die Browser bzw. die zwei ausgesuchten Prachtexemplare ihrer Zunft.

Wo ist der Haken?

Alle halbwegs aktuellen Versionen aller relevanten Browser unterstützen die nötigen APIs … bis auf den Android-Browser, der keine Web Workers kennt. Die IE 10 und 11 unterstützen zwar alle APIs, wissen aber anscheinend nicht, dass Blob-URLs dem gleichen Origin wie die sie erzeugende Webseite zuzuordnen sind. Für den IE gelten alle Blob-URLs gefährlicher Schadcode von außerhalb, der nicht ausgeführt werden darf. Das macht die komplette Objekt-URL-API im IE ziemlich nutzlos und verhindert natürlich auch, dass Unsync dort funktioniert. Es gibt mehrere Bug Reports zu dem Thema (das ist meiner, falls ihr ein paar me too-Kommentare loswerden wollt), aber Microsoft macht bisher nicht den Eindruck, als wäre ein Fix für den finalen IE 11 angedacht. Da dürfen wir wohl auf den IE 12 warten. Bis dahin gilt:

Browser Chrome Firefox Safari Opera iOS Android IE
Unterstützung

Bis dahin könnt ihr Unsync eigentlich in der Pfeife rauchen und dürft weiterhin mit Extra-Dateien für Web Workers jonglieren. Aber immerhin besteht zumindest für eure Enkel Hoffnung auf bequeme asynchrone Funktionen im IE 18 … und das ist doch auch schon mal was, oder?