Konsole & …: Koroutinen

the next big thing  –  0 Kommentare

"Konsole & Kontext" ist eine gemeinsame Serie von Golo Roden und Michael Wiedeking, in der sich die beiden regelmäßig mit Konzepten der (funktionalen) Programmierung beschäftigen. Während "Konsole & …" die praktische Anwendung dieser Konzepte in JavaScript und Node.js zeigt, erläutert "… & Kontext" deren jeweiligen eher theoretischen Hintergrund.

Die vergangene Folge hat das Schlüsselwort yield und die darauf aufbauenden Generatorfunktionen vorgestellt. Deren Ausführung lässt sich nach einer Unterbrechung durch yield mit der next-Funktion fortsetzen, was im Rahmen der for..of-Schleife erklärt wurde. En passant wurde zudem erwähnt,

"dass der Aufruf von next in JavaScript die Angabe eines Parameters zulässt, der als Rückgabewert von yield verarbeitet wird."

Dieser Nebensatz birgt weitaus mehr Potenzial, als der erste Blick vermuten lässt: Was wäre, wenn man eine Möglichkeit fände, den Aufruf einer asynchronen Funktion mit yield zurückzugeben und erst dann die next-Funktion aufzurufen, wenn deren Callback ausgelöst wurde?

Die Antwort ist so naheliegend wie verführerisch: Wenn das möglich wäre, ließen sich asynchrone Funktionen auf die gleiche Art wie ihre synchronen Verwandten aufzurufen:

var data = yield fs.readFile('/etc/passwd', { encoding: 'utf8' });

Dieser Aufruf liest sich weitaus schöner als die von Node.js vorgesehene Callback-basierte Variante, die naturgemäß zu einer weiteren Einrückungsebene führt:

fs.readFile('/etc/passwd', { encoding: 'utf8' }, function (err, data) {
// ...
});

Die Auswirkungen wären aber noch weitaus größer: So würde sich nicht nur die Syntax des Funktionsaufrufs an sich ändern, zusätzlich ließen sich auch klassische Kontrollstrukturen wie Schleifen oder die Ausnahmebehandlung mit asynchronen Funktionen verwenden. Eine schöne Vorstellung!

Koroutinen

Die gewünschte Funktionalität beschreibt das Konzept der Koroutinen, die in der Wikipedia wie folgt definiert werden:

"Der prinzipielle Unterschied zwischen Koroutinen und Prozeduren ist, dass Koroutinen ihren Ablauf unterbrechen und später wieder aufnehmen können, wobei sie ihren Status beibehalten."

Die gute Nachricht lautet, dass genau dies in Node.js implementiert werden kann: yield und die Generatorfunktionen bilden die Grundlage dafür. Allerdings funktioniert es nicht ganz ohne Handarbeit, schließlich gehen die in Node.js enthaltenen Funktionen wie fs.readFile davon aus, mit einem Callback und nicht mit yield aufgerufen zu werden.

Der erste Schritt besteht daher drin, die Callback-basierten Funktionen derart zu verändern, dass sie einen Wert zurückgeben und ohne Callback aufrufbar sind. Es wird also eine Funktion benötigt, die den eigentlichen Funktionsaufruf kapselt, was in JavaScript dank Funktionen höherer Ordnung problemlos machbar ist.

Eine solche Hilfsfunktion wird in der funktionalen Programmierung als Thunk bezeichnet. Eine bestehende, Callback-basierte Funktion in einen Thunk zu verwandeln, ist in wenigen Zeilen erledigt. Im Prinzip wird so aus der ursprünglichen Funktion eine Funktion, die ohne Callback auskommt und eine neue Funktion zurückgibt, die nur noch den Callback als Parameter erwartet:

var fsReadFile = function (filename, options) {
return function (callback) {
fs.readFile(filename, options, callback);
};
};

Der Aufruf dieser Funktion erfolgt nun in zwei Stufen:

fsReadFile('/etc/passwd', { encoding: 'utf8' })(function (err, data) {
// ...
});

Damit ist man dem eigentlichen Ziel, die Funktion fs.readFile wie gewünscht mit yield aufrufen zu können, einen Schritt näher gekommen: Die Funktion lässt sich zum einen ohne Callback aufrufen, zum anderen gibt es nun einen Rückgabewert, den yield an den Aufrufer gibt. Diesem Aufrufer obliegt es nun, den Aufruf des Callbacks abzuwarten und das Ergebnis anschließend mit next wieder zurückzugeben.

Von der Koroutine zur co(function)

Praktischerweise muss man diesen Mechanismus nicht von Hand bauen. Das Modul co, das von TJ Holowaychuk entwickelt wird, übernimmt genau diese Aufgabe. Installiert wird es, wie unter Node.js üblich, mit npm in den lokalen Kontext der Anwendung:

$ npm install co

Anschließend lässt es sich durch einen Aufruf der require-Funktion einbinden:

var co = require('co');

Ruft man co auf, erwartet es die Angabe einer Generatorfunktion als Parameter. Außerdem gibt es selbst einen Thunk zurück, den man erneut aufrufen muss. Das Grundgerüst für die Verwendung von co sieht daher wie folgt aus:

co(function * () {
// ...
})();

Innerhalb der Generatorfunktion lässt sich nun beliebiger Code ausführen, einschließlich des scheinbar synchronen Aufrufs asynchroner Funktionen. Als Voraussetzung dafür gilt allerdings, dass diese in einen Thunk umgewandelt wurden:

co(function * () {
try {
var passwords = yield fsReadFile('/etc/passwd', {
encoding: 'utf8'
});
console.log(passwords);
} catch (e) {
console.log(e);
}
})();

Bemerkenswert hierbei ist, dass co sich automatisch um das Behandeln von Fehlern kümmert und diese als Ausnahme innerhalb der Generatorfunktion auslöst, sodass man die Fehlerbehandlung wie auch bei synchronem Code mit try..catch durchführen kann.

Besonders nützlich ist das ebenfalls von TJ Holowaychuk entwickelte Modul node-thunkify, das das Umwandeln einer klassischen, Callback-basierten Funktion in einen Thunk automatisiert. Nach dessen Installation und Integration mit require genügt daher die folgende Zeile, um die Funktion fs.readFile in eine für co taugliche Version zu konvertieren:

var fsReadFile = thunkify(fs.readFile);

Sequenziell, parallel & Co.

Führt man nun mehrere Lesezugriffe hintereinander aus, werden diese sequenziell verarbeitet: Das heißt, jeder Aufruf von yield unterbricht die Funktion so lange, bis das Ergebnis des Aufrufs zur Verfügung steht. Unter Umständen ist das jedoch nicht erwünscht: Gelegentlich ist es nämlich durchaus sinnvoll, mehrere Anfragen parallel auszuführen und die Ausführung anschließend synchronisiert fortzuführen, wenn alle Ergebnisse vorliegen.

Glücklicherweise unterstützt co auch das: Ruft man die einzelnen Funktionen nämlich nicht individuell mit yield, sondern ganz regulär auf, lässt sich anschließend auf alle Ergebnisse gemeinsam warten, wie das folgende Beispiel zeigt:

co(function * () {
var passwords = fsReadFile('/etc/passwd', { encoding: 'utf8' }),
hosts = fsReadFile('/etc/hosts', { encoding: 'utf8' });

var results = yield [ passwords, hosts ];
console.log(results);
})();

Übrigens unterstützt co außer Thunks auch Promises und einige weitere Möglichkeiten, um asynchrone Funktionen mit yield aufzurufen. Allerdings empfiehlt es sich, nach Möglichkeit nur eine Variante zu verwenden. Da fast alle für Node.js verfügbaren Module auf Callbacks basieren, ist node-thunkify die einfachste und naheliegendste Lösung.

Abschließend sei noch darauf hingewiesen, dass co mindestens die Version 0.11 von Node.js voraussetzt, die zudem mit dem Parameter --harmony gestartet werden muss, um auf die ECMAScript-6-kompatiblen Funktionen wie yield zugreifen zu können.

tl;dr: yield bietet nicht nur eine bequeme Möglichkeit, Generatorfunktionen zu schreiben, sondern ermöglicht auch die Implementierung von Koroutinen in Node.js. Damit lässt sich asynchroner Code wie synchroner Code aufrufen, zudem können alle klassischen Kontrollstrukturen wie Schleifen und die Ausnahmebehandlung auch für asynchronen Code verwendet werden. Das Modul co kümmert sich hierbei um die Details.