Kolumne: Per Anhalter durch den JavaScript-Dschungel

Asynchronität in JavaScript – ein Werkzeug für jedes Problem
Keine Kommentare

Der Fall bei Asynchronität in JavaScript ist eigentlich klar: Callbacks sind böse und Promises die einzig richtige Lösung. Nein, eigentlich ist ja async/await die Lösung, was aber wiederum auf Promises aufbaut, bei denen wiederum einige Callbacks im Spiel sind. Also ist doch nicht alles so klar, wie es scheint. Warum diese ganze Asynchronität notwendig ist, werden wir im Folgenden klären.

Der Fall bei Asynchronität in JavaScript ist eigentlich klar: Callbacks sind böse und Promises die einzig richtige Lösung. Nein, eigentlich ist ja async/await die Lösung, was aber wiederum auf Promises aufbaut, bei denen wiederum einige Callbacks im Spiel sind. Also ist doch nicht alles so klar, wie es scheint. Warum diese ganze Asynchronität notwendig ist, werden wir im Folgenden klären.

Warum Asynchronität? Egal, ob wir uns im Browser- oder Serverkontext bewegen, der Begriff ist allgegenwärtig. Das hat vor allem mit der grundlegenden Architektur von JavaScript Engines zu tun. Nahezu alle Implementierungen bestehen im Kern aus einem Prozess, der die gesamte Arbeit verrichtet. Natürlich gibt es Worker-Prozesse, sowohl im Browser als auch in Node.js. Dennoch werden die meisten Applikationen in nur einem Prozess ausgeführt und das ist auch gut so, denn dieser langweilt sich die meiste Zeit und wartet, dass er etwas zu tun bekommt. Und genau dieses Warten beziehungsweise die Reaktion auf ein bestimmtes Ereignis wird über die verschiedenen Sprachmittel von JavaScript gelöst.

Da das im ersten Moment zugegebenermaßen etwas abstrakt klingt, sehen wir uns dazu besser ein Beispiel an. Im Frontend unserer Applikation sollen Daten vom Server geladen werden. Wir nutzen das Fetch API des Browsers und formulieren die Anfrage. Der Aufruf des Fetch API führt dazu, dass die Anfrage gesendet wird. Würde der Browser an dieser Stelle synchron arbeiten, wäre es das jetzt, zumindest für kurze Zeit, mit jeglicher Interaktion zwischen dem Benutzer und unserer Applikation gewesen. Kein Button könnte geklickt und keine Eingabe getätigt werden – der Browser wäre eingefroren. Keine sehr schöne Vorstellung. Also doch lieber mit dem asynchronen Ansatz: Wir setzen die Anfrage an den Server ab, aber statt auf die Antwort zu warten, registrieren wir eine Funktion, die aufgerufen werden soll, sobald das Ergebnis vorliegt. In der Zwischenzeit kann der Browser wieder auf die Interaktion des Benutzers reagieren oder beliebige andere Aufgaben erledigen. Sobald die Antwort des Servers vorliegt, was im besten Fall nur Bruchteile einer Sekunde dauert, wird unsere registrierte Funktion ausgeführt und die Behandlung der Antwort läuft ab. Wir haben mit dieser Lösung also eine bessere Responsivität unserer Applikation gewonnen. Für den Benutzer fühlen sich viele Operationen viel flüssiger an, als sie tatsächlich sind. Der Browser verschiebt in diesem Fall nur einen Teil der Arbeit an eine andere Stelle und schafft sich Reaktionsmöglichkeiten.

Wie die Asynchronität jetzt genau umgesetzt ist, ob wie gerade skizziert mit Funktionen, über async/await oder ein Stream API, spielt an dieser Stelle keine Rolle. Für die Beantwortung der nächsten Frage, „Wann nutze ich welche Lösung und wo sind ihre jeweiligen Grenzen?“, müssen wir jedoch einen genaueren Blick auf die ganze Sache werfen.

Callbacks – die Geißel von JavaScript oder doch nicht?

Fangen wir doch mit der unbeliebtesten Variante an: den Callbacks. Wer asynchrone Aufgaben mit Callbacks löst, der isst auch kleine Kinder zum Frühstück. So oder so ähnlich hat es lange Zeit geheißen, wenn es um die Implementierung von asynchronen Lösungen ging. Werfen wir jedoch einen genaueren Blick auf eine beliebige JavaScript-Applikation, finden sich dort zuhauf Callback-Funktionen. Und das aus gutem Grund: Sie sind ein Sprachmittel zur Lösung einer ganzen Kategorie von Problemen und dabei ein Grundbaustein für weiterführende Architekturformen, wie beispielsweise einer Event-getriebenen Architektur.

Bleiben wir gleich beim Thema und sehen uns einen konkreten Anwendungsfall an: Events. In einer Applikation müssen wir auf diese reagieren. Das können einfache Klick-Events sein, aber auch Events auf einem Datenstrom. Sobald ein Event eines bestimmten Typs auftritt, wird die registrierte Callback-Funktion ausgeführt. Und da haben wir auch schon einen der häufigsten Einsatzzwecke: einfache Event Handler. Eine Callback-Funktion in dieser Rolle ist eine sehr leichtgewichtige Lösung. Der Quellcode bleibt übersichtlich, solange die Funktion knappgehalten und der Code, der nicht direkt etwas mit der Event-Behandlung zu tun hat, sauber ausgelagert werden. Ein weiterer großer Vorteil dieser Lösung: Die Callback-Funktion kann mehrfach ausgeführt werden – ganz im Gegensatz zu einem Promise. Bei allen asynchronen Operationen, die einfach behandelt werden können und vielleicht sogar mehrfach auftreten, kann also guten Gewissens zu Callbacks gegriffen werden. Aber was ist mit dieser vielzitierten Callback-Hölle? Die Callback-Hölle oder auch „Pyramid of Doom“ spielt auf ineinander geschachtelte Callbacks an. Rückt man den Quellcode sauber ein, entsteht hier ein Pyramidenmuster. Solche Strukturen lassen sich nur schlecht warten und die Fehlersuche macht keinen wirklichen Spaß. So richtig schlimm wird es aber erst, wenn der Erbauer der Pyramide auch noch Verzweigungen einbaut, also versucht, den asynchronen Programmfluss zu steuern. Und genau an dieser Stelle setzt das Promise API von JavaScript an.

Promises – weg mit den Callbacks

Ein Promise ist, ganz einfach gesprochen, das Versprechen auf die Erfüllung einer asynchronen Operation. Frei nach dem Motto: Alles wird gut. Ein solches Promise kann verschiedene Zustände annehmen. Zunächst ist es pending, schwebt also, und keiner kann sagen, ob sich alles zum Guten wendet oder ein Fehler auftritt. Das klärt sich erst im nächsten Schritt, in dem sich das Promise settled und entweder resolved oder rejected wird, ob also die Operation entweder erfolgreich oder ein Fehlschlag war. Für beide Fälle können Sie eine Callback-Funktion registrieren, die dann entsprechend ausgeführt wird. Das erreichen Sie mit der then-Methode des Promise-Objekts, dem Sie beide Callbacks übergeben können. Noch etwas schöner ist die Verwendung von then für den Erfolgsfall und der catch-Methode für den Fehlerfall.

Fassen wir zusammen: Statt einer Callback-Funktion haben wir jetzt sogar zwei Callback-Funktionen gewonnen. Großartig. Und wenn diese jetzt noch anfangen, Pyramiden zu bilden, sind wir hoffnungslos verloren. Aber genau das ist der Punkt: Das wird nicht passieren beziehungsweise sollte das nicht passieren. Sobald Sie ein then innerhalb der Callback-Funktion eines anderen then finden, ist das ein klares Anzeichen für ein Antipattern. An dieser Stelle fangen Sie tatsächlich an, Promise-Pyramiden aufzubauen, und machen damit sämtliche Vorteile dieser Schnittstelle zunichte. Der größte Vorteil ist, dass statt der erwähnten Pyramidenstruktur eine Kette von aufeinander aufbauenden Promises erzeugt werden kann. Diese ist deutlich besser lesbar und auch die Fehlersuche gestaltet sich einfacher. Ein weiterer Vorteil ist die Fehlerbehandlung. Statt Erfolgs- und Fehlerbehandlung in einem Stück wie bei Callbacks zu betreiben, können Sie beide Teile mit Promises sauber voneinander trennen. Ein typisches Beispiel von aufeinander aufbauenden asynchronen Operationen ist die Arbeit mit Dateien: Zunächst prüfen Sie, ob die Datei existiert, dann öffnen Sie den File Descriptor, im nächsten Schritt lesen Sie aus der Datei und schließlich schließen Sie die geöffnete Ressource wieder. Jede dieser aufeinander aufbauenden Aktionen ist asynchron und hat damit das Potenzial für eine Pyramidenstruktur (Listing 1).

doesFileExist(filename, () => {
  openFd(filename, (fd) => {
    readFile(fd, (content) => {
      // do stuff with the content
      closeFd(fd, () => {
        // ready
      })
    })
  })
})

Die einzelnen Funktionen akzeptieren als letztes Argument die Callback-Funktion, die Sie ausführen, sobald die Operation erledigt ist. Mit einigen wenigen Anpassungen kann diese Struktur in eine Promise-Kette umgewandelt werden. Dafür entfernen Sie die Callback-Funktion und geben stattdessen ein Promise-Objekt zurück, das Sie nach Beendigung der Operation entweder resolven oder rejecten.

doesFileExist(filename)
  .then(() => openFd(filename))
  .then(fd => readFile(fd))
  .then((fd, content) => {
    // do stuff with the content
    return fd;
  })
  .then(fd => {
    closeFd(fd);
  }).then(() => {
    // ready
  }).catch(e => console.log(e));

Als kleiner Bonus ist im zweiten Beispiel auch noch eine rudimentäre Fehlerbehandlung enthalten. Das bedeutet, wenn irgendwo in der Kette ein Fehler auftritt, wird er bis zum Catch am Ende durchgereicht und die Applikation stürzt nicht ab, nur weil es eine Exception beim Lesen des Inhalts der Datei aufgrund fehlender Berechtigungen gab. Mit async/await können Sie den Quellcode dann noch etwas schöner und ohne die störenden Callbacks schreiben (Listing 3).

try {
  await doesFileExist(filename);
  const fd = await openFd(filename);
  const content = await readFile(fd);
  // do stuff with the content
  await closeFd(fd);
  // ready 
} catch (e) {
  console.log(e);
}

Async/await basiert auf Promises und verbirgt den asynchronen Charakter von Operationen vor dem Entwickler. Die Ausführung des Codes wird dennoch bei jedem Schritt pausiert, bis die Operation beendet ist. Die Applikation bleibt jedoch reaktionsfähig und kann sich in der Zwischenzeit anderen Aufgaben widmen.

Streams – ein weiteres Werkzeug in unserem Werkzeugkasten

Jetzt werden gerade die Entwickler, die schon einmal mit Angular gearbeitet haben, zurecht einwerfen: „Stopp, da gibt’s doch noch mehr!“ Angular nutzt an vielen Stellen die Bibliothek RxJS zur Behandlung asynchroner Operationen, wie beispielsweise der Kommunikation mit einem Server. RxJS greift die Idee von asynchronen Datenströmen auf und modelliert jede Information in diesem Datenstrom als ein Event, das von der Quelle zum Ziel eine Reihe von Operatoren durchlaufen kann. Mit diesen Operatoren können Sie den Datenstrom modellieren und das Event auf seinem Weg anpassen – aber auch mehrere Ströme zusammenführen und viele weitere Operationen umsetzen.

Die Idee von Streams ist nicht wirklich neu und auch das Prinzip, dass zwischen der Quelle und dem Ziel eines Datenstroms verschiedene Operatoren oder Funktionen eingehängt werden, gewinnt keinen Innovationspreis mehr. Und dennoch handelt es sich bei Streams um ein sehr mächtiges und leider oft unterschätztes Konzept. Streams sind deutlich schwergewichtiger als Callbacks und auch als Promises. Für komplexere Problemstellungen sind sie jedoch eine hervorragende Lösung. Dafür spricht sowohl die Integration von RxJS in den Kern von Angular als auch das Stream-Modul von Node.js. Dieses Kernmodul verfolgt einen ähnlichen Ansatz wie RxJS, wenngleich es das Thema nicht so elegant löst und auch keine so umfangreiche Werkzeugsammlung wie RxJS mit sich bringt. In Node.js können Sie nahezu alles streamen. Angefangen von Eingaben auf der Konsole über das Streamen aus Dateien beziehungsweise in Dateien bis hin zu Netzwerk- oder Datenbank-Streams. Als kleines Beispiel sehen wir uns das Kopieren einer Datei an, wobei der Inhalt der Datei gleichzeitig noch in Großbuchstaben umgewandelt werden soll (Listing 4).

Import {createReadStream, createWriteStream} from 'fs';
Import toUpperCase from './util';
 
createReadStream('input.txt')
  .pipe(toUpperCase)
  .pipe(createWriteStream('output.txt');

Die createReadStream-Funktion erzeugt einen readable Stream, also einen Datenstrom, aus dem nur gelesen werden kann. Auf der anderen Seite des Stroms steht createWriteStream, die mit einem writable Stream das Gegenstück erzeugt. Zwischen diesen beiden Endpunkten können Sie eine beliebige Anzahl Zwischenstücke einfügen. Diese sind als Transform Streams umgesetzte les- und schreibbare Stream-Implementierungen, die aus einer gegebenen Eingabe eine Ausgabe erzeugen – und zwar asynchron.

Fazit

Die Macher von JavaScript hatten wohl Mitleid mit uns und haben deshalb kein Multi-Threading eingebaut, sodass wir uns glücklicherweise nicht mit Konzepten wie Threadsafety herumschlagen müssen. Einerseits ist das gut für die Komplexität unserer Applikationen, andererseits limitiert uns das bei einem kleinen Teil unserer Applikationen, wenn es um die lokale Skalierung geht. Natürlich könnte man über den Umweg über Worker-Prozesse eine echte Parallelisierung von Aufgaben erreichen. Allerdings ist das in den meisten Fällen zu schwerfällig und auch häufig gar nicht nötig.

Mit seiner „Ein Prozess, ein Thread“-Architektur fährt JavaScript in der Regel sehr gut, da es viele Aufgaben an andere Stellen auslagert, was uns dazu zwingt, in unseren Applikationen oft auf bestimmte Dinge warten zu müssen. Und hier bietet uns sowohl JavaScript als auch das Ökosystem eine ganze Palette von Lösungsansätzen. Angefangen von Callbacks über Promises bis hin zu Streams ist für jeden etwas dabei. Wichtig ist nur, dass für das jeweilige Problem die passende Lösung gefunden wird. Einen Klick-Handler für einen Button, den ein Benutzer mehr als nur ein Mal anklicken kann, mit einem Promise umzusetzen, ist wahrscheinlich nicht die beste Idee.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Abonnieren
Benachrichtige mich bei
guest
0 Comments
Inline Feedbacks
View all comments
X
- Gib Deinen Standort ein -
- or -