NodeJS Threading und die Verantwortung der Entwickler – oder: die Krux mit dem JSON Parsing

Dieser Artikel ist eine weitere Fortsetzung meiner Blog-Reihe für NodeJS Entwickler. Heute geht es darum, was man als NodeJS Entwickler alles im Hinterkopf behalten muss, um die Applikation nicht zu blockieren. Vielleicht hast du ja auch schon mal deine Applikation ‚gebenchmarkt‘, und bist draufgekommen, dass du Regular Expressions „falsch“ verwendest, dein REST API Design eigentlich nicht stimmt, oder du npm Pakete verwendest, die du besser wieder ersetzt.

NodeJS Titelbild Blogbeitrag

Hier werden ein paar Probleme aufgezeigt, auf die man beim Thema Threading stößt. Im speziellen werde ich aufs JSON Parsing eingehen, weil vor allem hier diverse Verhaltensweisen nicht auf dem ersten Blick erkennbar sind.

Threading in NodeJS

Wie wir alle wissen, läuft das gesamte JavaScript in NodeJS in einem einzigen Thread. Im Bild unten (Quelle: medium.com) ist das nochmal schematisch dargestellt:

NodeJS

Die JavaScript Code-Häppchen werden nach und nach abgearbeitet. Hier ein Beispiel:


fs.writeFile('myFile.txt', 'some Text...', 'utf-8', (err) => {

     console.log('finished write');

});

console.log('after writeFile instruction');

Die Ausgabe sieht hier wie folgt aus:


> after writeFile instruction

> finished write

Warum ist das so? Der Code lässt das (native) fs Modul im Hintergrund die Dateisystemoperation ausführen. Inzwischen läuft unser JavaScript Code weiter und gibt „after writeFile instruction“ aus. Sobald die Schreiboperation beendet ist, wird das Callback ‘gequeued’, und dann später ausgeführt.

Abgesehen davon, dass JavaScript Multithreading nicht unterstützt, ist der große Vorteil des Single-Thread Application Codes das Wegfallen von Synchronisationsproblemen (Deadlocks, usw.). Auch eignet sich der Ansatz gut für Webserver: Den Großteil der Last an Webservern machen nämlich genau diese I/O Operationen aus (hauptsächlich Datenbank-, Dateisystem- und Netzwerkzugriffe). Genau diese Operationen passieren (wenn man‘s richtig macht) bei NodeJS optimiert im Hintergrund, und in der nativen Library.

Doch genau in dem „wenn man‘s richtig macht“ liegt der Hund begraben. Der Entwickler muss selbst darauf achten, nicht unnötig zu blockieren. Angenommen, wir hätten den Code oben so geschrieben:


fs.writeFileSync('myFile.txt', 'some Text...', 'utf-8')

Hier hätten wir dann automatisch das „Code-Häppchen“ in der Ausführungszeit verlängert. Während er also hier schreibt, kann kein weiterer JavaScript Code (bei Webservern zum Beispiel Request Handler) in der gesamten Applikation ausgeführt werden.

Jetzt kann man sagen „ich verwende doch eh nie diese …Sync Funktionen“, doch es gibt hier mehr Fallen, als man meinen möchte.

Neben diesen synchronen Library Calls blockiert man auch den Application Thread:

  • – beim Parsen einer regular Expression. Der Klassiker sind DataURLs, zum Beispiel mit mehreren MB großen Bildern. Wenn ich da den Bildcontent rausparsen will, kann ich doch eine RegExp, wie /data:image\/png;base64,(.*)/ nehmen, oder? Falsch! Hier wird der gesamte Base64 Content des Bildes durchgeparst, und das synchron. Also hier eine bessere regular Expression verwenden, oder mit Substrings arbeiten!
  • – beim Parsen großer JSONs (siehe unten)
  • – bei langen for-Schleifen oder Algorithmen im JavaScript Code
  • – bei synchronem Aufruf von nativem Code über C++ Addons, Node FFI, oder Emscripten-generiertem Code (siehe mein letzter Blogartikel)
  • – wenn einer dieser Punkte in einer eingebundenen Library passiert, oder deren Dependency zutrifft. Die Libraries auf npm sind keinem Review unterzogen, was die Sache verschärft.

Die meisten dieser Fallen lassen sich individuell oder mit der Holzhammermethode „Child Process API“ beheben. Bei letzterer wird „einfach“ für die blockierenden Teile des Codes ein Subprozess angelegt, der dann per IPC mit dem Hauptprozess kommuniziert. Dies ist natürlich mit einigem Overhead beim Programmieren verbunden. Speziell beim JSON Parsing hilft aber oft nicht einmal diese Methode!

JSON parsing

In NodeJS Applikationen, vor allem in Web Backends, werden relativ oft JSON‘s geparst. Wir verwenden hier meist express, und die express.json Middleware:


app.use(express.json({

  limit: '100kb' // that's the default value

}));

Mit dieser Middleware wird der Body aller JSON Serveranfragen geparst. Somit können spätere Request Handler direkt auf die JSON Daten zugreifen. Aber was, wenn der Request größer als 100kB ist? Wir hatten schon mit REST Schnittstellen zu tun, bei denen die JSON Requests größere Binärdaten, codiert in Base64 enthielten (von der Vorgehensweise ist, wenn man die Möglichkeit hat, grundsätzlich abzuraten).

Den Wert beliebig zu vergrößern ist jedenfalls die falsche Antwort. Die  express.json Middleware verwendet, wie man sich schon denken kann, die blockierende JSON.parse Funktion. Die „limit“ Option verhindert hier (wenn ich den Wert nicht zu hoch ansetze), dass diese Funktion mit zu großen Daten aufgerufen wird, und dann damit die gesamte Applikation blockiert. Dies ist auch für DOS Attacken wichtig, ein Angreifer könnte ja ebenfalls mit riesigen JSON Requests den Server auf einfache Weise lahmlegen. Die express.json Middleware ist auf Webservern einfach nicht für große JSONs ausgelegt und sollte daher für diese nicht verwendet werden. Aber wie kann ich dennoch mit großen JSONs auf Webservern arbeiten (wenn mir aus guten Gründen, wie z.B. 3rd Party Schnittstellen, nichts anderes übrigbleibt)?

Child Process API – der (oft wirkungslose) Holzhammer

NodeJS bietet mit der ChildProcess API einen relativ simplen Weg, um NodeJS Subprozesse zu starten und Nachrichten zwischen dem Haupt- und dem Subprozess zu verschicken. Die erste Idee wäre also, ein Skript anzulegen, welches das JSON parst und das Ergebnis zurück zum Hauptprozess schickt:


// DON'T DO THIS!!

// child.js

process.on(`message`, (jsString) => {

  process.send(JSON.parse(jsString));

});

 

// main.js

const { fork } = require('child_process');

// will start the child Process

const child = fork('./child.js');

// ...

// in some json handling function:

child.on('message', (json) => {

     // here is my parsed json...but why does it still block??

});

child.send(jsonString);

Natürlich brauche ich noch zusätzliche Logik, um mehrere JSON Anfragen parallel bearbeiten zu können (entweder mit einem Childprozess pro Message, oder mit zusätzlich mitgeschickten Informationen, mit denen ich die empfangenen Daten den zum Subprozess gesendeten zuordnen kann). Jedoch löst dieser naive Ansatz das blocking-Problem nicht: Bei der Kommunikation der beiden Prozesse müssen die Nachrichten nämlich wieder in Strings umgewandelt werden. Um daraus dann wieder das JSON zu erzeugen, wird im Hauptprozess vor dem Message Handler erst recht wieder das JSON blockierend geparst. Dennoch könnte man diesen Ansatz weiterspinnen und die gesamte Abarbeitung des großen JSONs in einem Subprozess durchführen. Dann muss man aber auch genau wissen, was man tut. Subprozesse laufen (bis auf die Interprozesskommunikation) unabhängig von Hauptprozess, teilen also nicht den Speicher, Variablen, oder ähnliches.

Das Cluster Modul abstrahiert die Verwendung der Child Process API. Die Serveranfragen werden an die Subprozesse verteilt. Das vermindert das Problem, schaltet es aber nicht aus. Wird für einen neuen Request ein Subprozess ausgewählt, der bereits einen länger blockierenden Task ausführt, muss auch der neue Request warten. Zumindest steht der Rest des Servers in dieser Zeit nicht – für Production Umgebungen ist dieser Kompromiss aber natürlich nicht ausreichend.

Soweit so schlecht, welche Möglichkeiten gibt es noch?

NPM Registry

Wir machen das, was wir in so einem Fall immer machen: wir durchforsten npm nach Packages. Hier ist, wie wir gleich sehen werden, große Vorsicht geboten. Wie viele Package Repositories, hat npm das Problem, dass es keine Qualitätssicherungsmechanismen gibt. Jeder kann dort also beliebige Packages registrieren.

Nach kurzer Suche findet man zum Beispiel das Paket json-parse-async (Link), das sich allerdings gleich als Negativbeispiel herausstellen wird. Das Interface ist recht simpel, man übergibt den JSON String, und optional ein Callback, das nach beendeten Parsen mit dem Ergebnis und einem eventuellen Fehler aufgerufen wird.

Nachdem wir jedoch bereits wissen, dass asynchrones JSON Parsing in JavaScript nicht gerade ein No-Brainer ist, wollen wir uns dieses Package doch etwas genauer anschauen. Und siehe da: Im Source werden wir fündig. Hier wird einfach JSON.parse und anschließend das Callback mit dem Ergebnis aufgerufen. Auch das spätere process.nextTick entschärft die Situation nicht. Es wird zwar das Callback in erst in der nächsten Iteration aufgerufen, das Blockieren ist allerdings zu dieser Zeit durch den parse Aufruf zuvor schon längst erfolgt. Bei den Issues findet sich sogar der Hinweis eines Users auf das Problem. Der Autor verweist hier auf sein neueres Modul json-future, welches jedoch ebenfalls blockiert.

Weitere Module…

Analog dazu gibt es noch einige weitere Module mit ähnlichen Problemen. Ein recht nützliches Modul ist allerdings stream-json. Es bietet ein SAX-ähnliches Streaming Interface, um JSONs zu parsen. Dabei wird der JSON String in Chunks von maximal 256 Bytes zerteilt, und „händisch“ per RegExp geparst. Die Arbeit wird also auf viele kleine Schritte aufgeteilt, was es der Applikation ermöglicht, zwischendurch sonstigen JavaScript Code auszuführen. Das Modul feuert Events wie startString, stringChunk, endString, startObject, usw. Man kann also am Webserver den Request nehmen, per .pipe mit dem Parser verbinden, und dann pro Event auf den Stream reagieren. Dies ist natürlich umständlicher als JSON.parse direkt zu verwenden. Bei zu großen JSONs auf Webservern ist dieser Overhead aber definitiv notwendig.

Zusammenfassung

Um den JavaScript Thread nicht zu blockieren, muss man als NodeJS Entwickler auf vieles achten. Neben den offensichtlichen Sachen, wie z.B. keine synchronen Library Calls zu machen, gibt es leider auch versteckte, wie schlecht implementierte Packages auf npm und RegExp oder JSON Parsing auf große Datenmengen. Letzteres lässt sich meist nicht einmal mit der Child Process API in den Griff bekommen. Das stream-json Package bietet hier noch die beste gefundene Methode. Noch besser wäre allerdings, so große JSONs von vorn herein zu vermeiden, daher z.B. bei REST Schnittstellen keine Binärdateien im JSON zu übertragen.