Zeitversetzt und fehlerfrei: Wie man Callbacks richtig schreibt

Callbacks sind nicht nur in der funktionalen Programmierung im Allgemeinen, sondern auch in JavaScript und Node.js im Speziellen von essenzieller Bedeutung. Daher ist es enorm wichtig, sie richtig zu schreiben. Das scheint auf den ersten Blick nicht schwierig zu sein, doch wie so oft lauert der Teufel im Detail …

In Pocket speichern vorlesen Druckansicht 1 Kommentar lesen
Lesezeit: 5 Min.
Von
  • Golo Roden
Inhaltsverzeichnis

Der Blog-Eintrag "Callbacks, synchronous and asynchronous" beschreibt zwei Arten von Rückruffunktionen, die sich grundlegend voneinander unterscheiden: synchrone und asynchrone Callbacks. Während Node.js die synchrone Variante im Kontext der aufrufenden Funktion ausführt, gilt das für die asynchrone nicht.

Ein gutes Beispiel für einen synchronen Callback ist die in JavaScript integrierte reduce-Funktion, die ein Array mit einer als Parameter übergebenen Funktion auf einen einzigen Wert reduziert. Man kann sie beispielsweise verwenden, um ein verschachteltes Array zu ebnen:

var flattened = [[0, 1], [2, 3], [4, 5]].reduce(function (a, b) {
return a.concat(b);
});
console.log(flattened); // => [0, 1, 2, 3, 4, 5]

In diesem Beispiel wird deutlich, warum die reduce-Funktion die ihr übergebene Funktion synchron aufrufen muss: Fände der Aufruf asynchron statt, könnte Node.js das Ergebnis nicht der Variablen flattened zuweisen.

Allerdings gibt es durchaus auch Funktionen, in denen anstelle eines synchronen Aufrufs ein asynchroner Aufruf des angegebenen Callbacks sinnvoll ist. Das gilt unter anderem dann, wenn eine Funktion auf eine externe Ressource warten muss, wie eine Netzwerkverbindung oder das Dateisystem:

http.get('http://www.thenativeweb.io', function (res) {
console.log(res.statusCode); // => 200
});
console.log('Requesting...');

In diesem Beispiel gibt Node.js daher zunächst die Meldung Requesting... aus, bevor es auf den Statuscode des Netzwerk-Streams zugreifen kann. Das Verhalten spiegelt den von Node.js propagierten asynchronen, nichtblockierenden Zugriff auf I/O-Ressourcen wider.

Entscheidend für die Konsistenz und Verlässlichkeit einer API ist nun, dass sich eine Funktion stets gleichartig verhält: Wenn eine Funktion eine Rückruffunktion entgegennimmt, muss sie diese daher entweder stets synchron oder stets asynchron aufrufen – aber nicht von Fall zu Fall verschieden.

Der eingangs erwähnte Blog-Eintrag fasst das in der einfachen Regel Choose sync or async, but not both zusammen und begründet dies wie folgt:

"Because sync and async callbacks have different rules, they create different bugs. It’s very typical that the test suite only triggers the callback asynchronously, but then some less-common case in production runs it synchronously and breaks. (Or vice versa.) Requiring application developers to plan for and test both sync and async cases is just too hard, and it’s simple to solve in the library: If the callback must be deferred in any situation, always defer it."

Zu diesem Zweck kennt Node.js zwei Funktionen, die auf den ersten Blick austauschbar zu sein scheinen: process.nextTick und setImmediate. Beide erwarten einen Callback als Parameter und führen diesen zu einem späteren Zeitpunkt aus. Daher scheinen der Aufruf von

process.nextTick(function () {
// Do something ...
});

und der Aufruf von

setImmediate(function () {
// Do something ...
});

äquivalent zu sein. Intern unterscheiden sich die beiden Varianten allerdings deutlich voneinander. process.nextTick verzögert die Ausführung auf einen späteren Zeitpunkt, führt die Funktion aber aus, bevor Node.js I/O-Zugriffe durchführt und schließlich die Kontrolle wieder an die Eventloop übergibt.

Daher können rekursive Aufrufe von process.nextTick dazu führen, dass man diese Übergabe immer weiter hinauszögert und die Eventloop effektiv verhungern lässt. Das bezeichnet man dementsprechend auch als event loop starvation.

Die Funktion setImmediate umgeht dieses Problem, indem sie die Ausführung der angegebenen Funktion auf die nächste Runde der Eventloop verschiebt. Der Blog-Eintrag zur Veröffentlichung von Node.js 0.10.0 beschreibt die Unterschiede zwischen diesen beiden Funktionen nochmals in detaillierter Form.

In der Regel genügt allerdings der Aufruf von process.nextTick, um eine Rückruffunktion asynchron statt synchron aufzurufen. Auf diesem Weg kann man den teilweise synchron, teilweise asynchron arbeitenden Code

var cache = {};
var load = function (filename, callback) {
var data = cache[filename];
if (data) {
return callback(null, data); // Synchronous
}
fs.readFile(filename, function (err, data) {
if (err) { return callback(err); }
cache[filename] = data;
callback(null, data); // Asynchronous
});
};

in eine vollständig asynchrone Form bringen, was die Verwendung der load-Funktion konsistent und verlässlich macht:

var cache = {};
var load = function (filename, callback) {
var data = cache[filename];
if (data) {
return process.nextTick(function () {
callback(null, data); // Asynchronous
});

}
fs.readFile(filename, function (err, data) {
if (err) { return callback(err); }
cache[filename] = data;
callback(null, data); // Asynchronous
});
};

Auf diesem Weg umgeht man die eingangs thematisierten Probleme und erhält eine Funktion, die sich im Test- und im Produktivbetrieb einheitlich verhält. Allen Skeptikern sei abschließend der Blog-Eintrag "Designing APIs for Asynchrony" und dessen Warnung eindringlich ans Herz gelegt: Do Not Release Zalgo!

tl;dr: Funktionen sollten Callbacks stets entweder synchron oder asynchron aufrufen, andernfalls verhalten sich APIs weder konsistent noch verlässlich. Im Zweifelsfall muss man einen synchronen Aufruf daher künstlich in einen asynchronen umwandeln. Für diesen Zweck kennt Node.js die beiden Funktionen process.nextTick und setImmediate. ()