Control Flow vor `async/await`
Ein Blick auf die winzige Bibliothek cflow zeigt, wie Node-Entwickler asynchrone Schritte von Hand zu einer Kette verdrahteten und warum der fehlende Fehlerkanal die eigentliche Lektion ist.
Control Flow vor async/await
Wer heute in Node mehrere asynchrone Schritte hintereinander ausführen will, hat ein Problem, für das die Sprache keine eingebaute Lösung mitbringt. JavaScript kennt Funktionen, Callbacks und einen Event Loop. Es kennt keinen Befehl, der sagt: „Mach das, warte auf das Ergebnis, mach dann das Nächste.“ Genau diese Reihenfolge muss man selbst herstellen. Und sobald drei oder vier Schritte aufeinander aufbauen, wird sichtbar, dass „warten“ in einer Single-Thread-Umgebung kein Sprachfeature ist, sondern eine Entwurfsaufgabe.
Ich habe mir dafür eine kleine Bibliothek gebaut, cflow. Sie ist bewusst minimal, und gerade deshalb taugt sie als Lehrstück. An ihr lässt sich zeigen, wo der Unterschied zwischen einer verschachtelten Callback-Kette und einer bewusst modellierten Pipeline liegt.
Das Problem: Reihenfolge aus Callbacks bauen
Eine asynchrone Operation in Node folgt einer Konvention. Sie nimmt als letztes Argument einen Callback, und dieser Callback bekommt als erstes Argument einen möglichen Fehler. Das nennt man error-first oder node-style callback:
fs.readFile("config.json", function (err, data) {
if (err) return handle(err);
// weiter mit data
});
Das ist für einen Schritt unauffällig. Es wird unangenehm, sobald der nächste Schritt das Ergebnis des vorherigen braucht. Dann landet der zweite Aufruf im Callback des ersten, der dritte im Callback des zweiten, und so weiter:
loadUser(id, function (err, user) {
if (err) return done(err);
loadAccount(user.accountId, function (err, account) {
if (err) return done(err);
loadInvoices(account.id, function (err, invoices) {
if (err) return done(err);
done(null, summarize(user, account, invoices));
});
});
});
Diese Form hat einen festen Namen bekommen: Pyramid of Doom, manchmal auch Callback-Hölle. Der Code wandert nach rechts, die schließenden Klammern stapeln sich, und die eigentliche Absicht – drei Dinge nacheinander laden und zusammenfassen – verschwindet hinter der Mechanik. Das ist nicht nur ein optisches Ärgernis. Jede Ebene wiederholt dieselbe Fehlerbehandlung, und genau dort schleichen sich Fehler ein.
Der Fehlerfall, den man fast immer einmal hat
Die error-first-Konvention verlangt eine Disziplin, die das Sprachmittel selbst nicht erzwingt: nach if (err) muss ein return stehen. Fehlt es, läuft die Funktion weiter:
loadUser(id, function (err, user) {
if (err) done(err); // Achtung: kein return
loadAccount(user.accountId, function (err, account) {
// ...
});
});
Im Fehlerfall passiert hier zweierlei gleichzeitig. done(err) wird aufgerufen, der Aufrufer hält das also für erledigt und meldet einen Fehler. Im selben Atemzug greift user.accountId auf ein user zu, das undefined ist, weil ja ein Fehler vorlag. Das wirft eine zweite, ganz andere Ausnahme, oder done wird ein zweites Mal aufgerufen – diesmal mit einem irreführenden Ergebnis. Ein Callback, der doppelt feuert, ist besonders gemein, weil die Reihenfolge der Meldungen nicht mehr garantiert ist. Mal kommt erst der echte Fehler, mal erst der Folgefehler. Solche Bugs sind im Log schwer zu lesen, weil die Ursache und ihre Auswirkung zeitlich auseinanderfallen.
Die Lehre daraus ist nicht „Callbacks sind schlecht“. Die Lehre ist: Wenn jeder Schritt seine Fehlerprüfung und seinen Übergang zum nächsten Schritt selbst von Hand schreibt, dann ist jede dieser Stellen eine Gelegenheit, es falsch zu machen. Was fehlt, ist eine Stelle, die den Kontrollfluss einmal modelliert, statt ihn an jeder Verschachtelung neu zu erfinden.
Bibliotheken, die den Fluss übernehmen
Damit ist man im Jahr 2014 nicht allein. Caolan McMahons async ist der etablierte Werkzeugkasten und bietet genau für diese Muster benannte Funktionen an. async.series führt Aufgaben nacheinander aus, async.parallel gleichzeitig, und async.waterfall reicht das Ergebnis jedes Schritts an den nächsten weiter:
async.waterfall([
function (next) { loadUser(id, next); },
function (user, next) { loadAccount(user.accountId, next); },
function (account, next) { loadInvoices(account.id, next); }
], function (err, invoices) {
if (err) return handle(err);
// fertig
});
Das ist flach statt verschachtelt, und es gibt nur noch einen Fehlerausgang. Parallel dazu reift ein zweiter Ansatz heran: Promises. Die Promises/A+-Spezifikation hat sich durchgesetzt, Bibliotheken wie Q, when.js und bluebird sind verbreitet, und man kann Ketten mit .then(...) schreiben. Ein eingebautes Promise gibt es in der stabilen Node-Version 0.10 allerdings noch nicht zuverlässig; das kommt erst mit der V8-Aktualisierung der 0.11er-Linie. Wer Promises will, bindet sie heute als Abhängigkeit ein.
cflow: der Kontrollfluss auf das Nötigste reduziert
cflow geht den waterfall-Weg, aber radikal klein. Die gesamte Bibliothek ist eine einzige Funktion:
var Flow = function () {
this.args = Array.prototype.slice.call(arguments);
var next = function () {
if (this.args.length > 0) {
this.args.shift();
this.args[0].apply(next, Array.prototype.slice.call(arguments));
}
}.bind(this);
this.args[0].apply(next);
};
Der Trick steckt in zwei Zeilen. Flow bekommt eine Liste von Funktionen. Jede dieser Funktionen wird mit this aufgerufen, das auf next gebunden ist – also auf die Fortsetzung. Ein Schritt ruft am Ende seiner Arbeit einfach this(...) auf. Das verwirft den aktuellen Schritt (shift) und ruft den nächsten mit genau den Argumenten auf, die man this übergeben hat. Die Werte fließen so von Schritt zu Schritt weiter.
In der Anwendung sieht das aus wie eine gerade Liste von Phasen statt wie ein Pyramidenbau:
var Flow = require("cflow");
new Flow(
function () {
slow("A", this); // this ist die Fortsetzung
},
function (data) {
slow2(data + "B", "!!!", this); // data ist "A"
},
function (data, data2) {
slow(data + "C" + data2, this); // data ist "AB", data2 ist "!!!"
},
function (data) {
console.log(data); // "ABC!!!"
}
);
function slow(value, callback) {
setTimeout(function () { callback(value); }, 200);
}
function slow2(value, value2, callback) {
setTimeout(function () { callback(value, value2); }, 300);
}
Jeder Schritt übergibt this als Callback an seine asynchrone Operation. Sobald die fertig ist, wird der nächste Schritt aktiv, und das Zwischenergebnis liegt direkt als Argument vor. Bemerkenswert ist, dass ein Schritt auch mehrere Werte weiterreichen kann – slow2 gibt zwei zurück, und der folgende Schritt nimmt zwei entgegen. Das ist exakt das Waterfall-Muster, nur ohne Array, ohne Optionen, ohne Abhängigkeiten. Die package.json listet unter dependencies schlicht nichts.
Kette gegen Pipeline
Der Unterschied zwischen den beiden Schreibweisen lässt sich gut nebeneinanderstellen. Die Callback-Kette schachtelt Zustand und Fehlerbehandlung mit jeder Ebene tiefer ineinander. Die Pipeline legt die Schritte nebeneinander und macht die Übergänge zu einem eigenen, benannten Mechanismus.
flowchart TB
subgraph Kette["Callback-Kette (verschachtelt)"]
direction TB
a1["Schritt 1"] --> a2[" Schritt 2"]
a2 --> a3[" Schritt 3"]
a3 --> a4[" Ergebnis"]
end
subgraph Pipeline["Pipeline (modelliert)"]
direction LR
b1["Schritt 1"] --> b2["Schritt 2"]
b2 --> b3["Schritt 3"]
b3 --> b4["Ergebnis"]
end
Optisch ist die Pipeline ruhiger. Wichtiger ist aber, was sich strukturell ändert. In der Pipeline existiert der Übergang von Schritt zu Schritt nur an einer einzigen Stelle, nämlich in der Bibliothek. Wenn man dort etwas verbessern will – Logging, ein Timeout, eine zentrale Fehlerweiche – dann hat man genau einen Ort dafür. In der verschachtelten Kette müsste man dieselbe Änderung in jeder Ebene wiederholen.
Wo cflow an seine Grenze stößt
Und hier liegt die ehrlichste Lektion dieses kleinen Stücks Code. cflow modelliert die Reihenfolge sauber, aber es modelliert keinen Fehler. Es gibt keinen error-first-Kanal, keinen Abbruch, kein try. Wirft ein Schritt eine Ausnahme oder ruft this mit einem Fehlerobjekt auf, dann behandelt Flow das nicht – der Fehler wird einfach als nächstes Argument weitergereicht oder fällt durch. Genau das, was die Callback-Konvention mit ihrem err-Argument zumindest vorsieht, fehlt hier ganz.
Das ist kein Versehen, sondern die Konsequenz aus „so einfach wie möglich“. Für eine Demonstration des Kontrollflusses reicht es. Für einen Service, der unter Last läuft, reicht es nicht. Damit benennt die Bibliothek selbst die Grenze, an der man weiterdenken muss: Ein Kontrollfluss ist erst dann vollständig, wenn auch der Fehlerpfad Teil des Modells ist. Wer eine eigene Flow-Funktion baut, sollte den nächsten Schritt deshalb so anlegen, dass ein Fehler die Kette abbricht und an einen einzigen Endpunkt geleitet wird, statt blind weiterzulaufen. async.waterfall macht genau das vor, indem es bei einem Fehler im ersten Argument sofort zum Abschluss-Callback springt.
Rückblick aus späterer Zeit
Wer diesen Text Jahre später liest, kennt die Auflösung. Mit ES2015 kamen Generatoren, die zusammen mit Bibliotheken wie co synchron aussehenden asynchronen Code erlaubten. Und mit ES2017 wurde async/await Teil der Sprache. Damit verschwindet die Frage, die diesen Artikel trägt, fast vollständig aus dem Alltag: Die Reihenfolge schreibt man wieder von oben nach unten, der Fehlerpfad ist ein normales try/catch, und das Warten ist ein Schlüsselwort.
Das macht die alte Übung aber nicht wertlos. Im Gegenteil. Ein await sieht harmlos und synchron aus, doch dahinter steht weiterhin ein nicht-blockierender Aufruf, der Ressourcen halten kann. Die Dinge, die man sich 2014 von Hand klarmachen musste – wo die Reihenfolge erzwungen wird, wo ein Fehler die Kette abbricht, wo ein Timeout fehlt, was bei doppelt gefeuerten Fortsetzungen passiert – sind alle noch da. Sie sind nur unter einer bequemen Syntax verborgen. Wer den expliziten Kontrollfluss einmal selbst gebaut hat, erkennt diese Grenzen auch dann noch, wenn die Sprache sie nicht mehr vor die Augen hält.
Weiterführende Quellen
- Repository cflow: https://github.com/MikeBild/cflow
- caolan/async, Dokumentation der Control-Flow-Funktionen: https://github.com/caolan/async
- Promises/A+ Spezifikation: https://promisesaplus.com/
Kommentare
Kommentar schreiben