Einführung in die asynchrone JavaScript-Programmierung

Konsistenz, Vor- und Nachteile

Konsistent asynchrone APIs

Entscheidend für die Konsistenz und Verlässlichkeit einer API ist zudem, dass sich eine Funktion stets gleichartig verhält: Wenn sie einen Callback entgegennimmt, sollte er entweder immer synchron oder immer asynchron aufgerufen werden, aber nicht von Fall zu Fall wechseln.

Der zuvor erwähnte Blog-Eintrag fasst das in der einfachen Regel "Choose sync or async, but not both" zusammen und begründet sie 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."

Auch Isaac Z. Schlueter, der Autor von npm, warnt in seinem Blog-Eintrag "Designing APIs for Asynchrony" davor, APIs so zu gestalten, dass ihr Verhalten in Bezug auf synchrone beziehungsweise asynchrone Ausführung nicht deterministisch ist.

Um dem Problem zu begegnen, existieren zwei Funktionen, die auf den ersten Blick austauschbar zu sein scheinen: process.nextTick und setImmediate. Beide erwarten einen Callback als Parameter und führen ihn zu einem späteren Zeitpunkt aus. Daher scheinen der Aufruf von

process.nextTick(() => {
// Do something...
});

und der von

setImmediate(() => {
// Do something...
});

äquivalent zu sein. Intern unterscheiden sich die beiden Varianten allerdings: process.nextTick verzögert das Ausführen des Callbacks auf einen späteren Zeitpunkt, führt ihn aber aus, bevor I/O-Zugriffe erfolgen und die Eventloop wieder die Kontrolle übernimmt.

Daher können rekursive Aufrufe der Funktion dazu führen, die Übergabe immer weiter hinauszuzögern und die Event Loop effektiv "verhungern" zu lassen. Den Effekt bezeichnet man dementsprechend als "Event Loop Starvation".

Die Funktion setImmediate umgeht das Problem, indem sie das Ausführen des Callbacks auf die nächste Iteration der Ereignisschleife verschiebt. Der Blog-Eintrag zur Veröffentlichung von Node.js 0.10 beschreibt die Unterschiede zwischen den beiden Funktionen in detaillierter Form.

In der Regel genügt allerdings process.nextTick, um einen Callback asynchron statt synchron aufzurufen. Auf dem Weg lässt sich Code, der auf beide Weisen arbeitet, generell asynchron ausführen:

let load = function (filename, callback) {
load.cache = load.cache || {};

let data = load.cache[filename];

if (data) {
return callback(null, data.toString('utf8')); // Synchronous
}

fs.readFile(filename, (err, data) => {
if (err) {
return callback(err);
}

load.cache[filename] = data;
callback(null, data.toString('utf8')); // Asynchronous
});
};

Bringt man den Code mit process.nextTick in eine vollständig asynchrone Form, ist die Verwendung konsistent und verlässlich möglich. Allerdings gilt es, darauf zu achten, die Position der return-Anweisung dem neuen Ablauf anzupassen:

let load = function (filename, callback) {
load.cache = load.cache ||Â {};

let data = load.cache[filename];

if (data) {
return process.nextTick(() => {
callback(null, data.toString('utf8')); // Now asynchronous as well
});
}

fs.readFile(filename, (err, data) => {
if (err) {
return callback(err);
}

load.cache[filename] = data;
callback(null, data.toString('utf8')); // Asynchronous
});
};

Vor- und Nachteile von synchronem und asynchronem Code

Vergleicht man die asynchrone Implementierung der load-Funktion mit der synchronen Variante fällt auf, dass der synchrone Code kürzer und besser verständlich ist. Außerdem entfällt das Risiko, einen Fehler zu verschlucken. Schlägt synchroner Code fehl, wird eine Ausnahme ausgelöst, die unbehandelt zum Abbruch des Prozesses führt.

let load = function (filename) {
load.cache = load.cache ||Â {};

let data = load.cache[filename];
if (data) {
return data.toString('utf8');
}

data = fs.readFileSync(filename);
load.cache[filename] = data;

return data.toString('utf8');
};

Synchroner Code erlaubt außerdem den Einsatz der klassischen Mittel zur Ablaufsteuerung wie for-Schleifen oder try-catch-Blöcke. Der einzige Nachteil besteht im Stillstand, während auf eine externe Ressource gewartet wird. Die Dokumentation von Node.js empfiehlt daher, den Einsatz synchroner Funktionen zu vermeiden, wenn ein asynchrones Pendant zur Verfügung steht:

"In busy processes, the programmer is strongly encouraged to use the asynchronous versions of these calls. The synchronous versions will block the entire process until they complete – halting all connections."

Die Entscheidung zwischen synchronem und asynchronem Code ist also letztlich ein Abwägen zwischen guter Lesbarkeit auf der einen und performanter Ausführung auf der anderen Seite. Wünschenswert wäre, beides zu vereinen.