Performance von Node.js
Node.js ist ein JavaScript-basiertes Framework zur Entwicklung Server-seitiger Anwendungen und Dienste. Aufgrund des Ereignis-gesteuerten und asynchronen Programmiermodells ist es besonders performant und reduziert Latenzen in verteilten Systemen.
Diese oder ähnliche Aussagen höre ich seit einiger Zeit immer häufiger. Doch was ist wirklich dran an diesen Aussagen? Mein Gefühl und meine Erfahrung sagen mir etwas anderes. Nur wie lässt sich das vermitteln? Statt mit Argumenten versuche ich es einfach mal mit einem kleinen Beispiel und ein paar Performance-Messungen.
Ich habe mich für einen TCP-Echo-Dienst entschieden, der mehrere TCP-Verbindungen parallel annimmt und für jede Verbindung die empfangenen Daten an die zugehörige Gegenstelle zurückschickt. Auf der Node.js-Projektseite findet sich dazu der folgende Code, den man in dieser Form direkt mit der Node.js-Laufzeitumgebung ausführen kann.
var net = require('net');
var server = net.createServer(function (socket) {
socket.pipe(socket);
});
server.listen(8080, '127.0.0.1');
Der eigentliche Kern des Programms befindet sich in der pipe
-Funktion der genutzten Bibliothek, wodurch leider ein verzerrter Eindruck von der Komplexität entsteht. Für mein Experiment spielt das jedoch keine Rolle. Vergleichen werde ich die Performance mit einer C++‑Implementierung, die im Gegensatz zu obigem Code auf Worker-Threads setzt. Diese Threads behandeln TCP-Verbindungen jeweils ganzheitlich. Darüber hinaus unterscheidet sich die C++‑Implementierung noch durch vollständige Fehler- und Timeout-Behandlung.
Für meinen ersten Test lasse ich beide Implementierungen zunächst auf jeweils einem CPU-Kern laufen. Die Laufzeitunterschiede sind deutlich erkennbar: Das in C++ geschriebene Programm ist um einen Faktor 2,3 schneller und braucht nur 74,4 Sekunden gegenüber 173,0 Sekunden von Node.js, um die gleiche Menge an Client-Requests zu verarbeiten.
Erklären lässt sich der Zeitunterschied durch drei einfache Beobachtungen: Erstens ist die Ausführungsgeschwindigkeit von JavaScript zwar mittlerweile sehr gut – zumindest wenn man sie mit der Geschwindigkeit von vor einigen Jahren vergleicht. Doch mit der Geschwindigkeit von C++ oder Java kann sie im Allgemeinen nicht mithalten. Zweitens ist der reaktive JavaScript-Code nicht so Speicher-lokal wie der C++‑Code, und verliert Zeit in den L1- und L2‑Caches. Und drittens benötigt die Node.js-Implementierung deutlich mehr Systemaufrufe – nämlich fast 2,5 Mal so viele wie die C++‑Implementierung.
Ergänzend zum zweiten Punkt: Der folgende Ausschnitt zeigt die C++‑Funktion, die alle eingehenden Datenpakete an den Client zurückschickt. Beachtenswert dabei ist insbesondere, wie relativ natürlich immer wieder der gleiche Speicherbereich für das Lesen und Schreiben verwendet wird, so dass die Cache-Strukturen der CPU effektiv genutzt werden.
void worker(tcp::connection&& connection)
{
std::size_t size = 1 << 14;
std::unique_ptr<char[]> buffer{new char[size]};
tcp::deadline deadline{steady_clock::now() + std::chrono::seconds{300}};
while (std::size_t count = connection.read(buffer.get(), size, deadline)) {
for (std::size_t offset = 0; offset != count; ) {
offset += connection.write(
buffer.get() + offset, count - offset, deadline);
}
}
}
Mein lokaler Rechner mit Atom-CPU ist nicht wirklich für repräsentative Performance-Tests geeignet. Also führe ich den gleichen Test auf einer Maschine mit jeweils zwei 8‑Kern-Xeon-Prozessoren und schnellem Netzwerk aus. Man könnte nun meinen, dass sich die Verteilung auf mehrere Kerne negativ auf die Performance der C++‑Implementierung auswirkt. Doch dem ist keineswegs so. Die Unterstützung der Prozessoren und Betriebssysteme für solche Art von Programmen ist mittlerweile so gut, dass der Durchsatz fast linear mit der Anzahl Kerne skaliert. Im Ergebnis ist die C++‑Implementierung damit auf dieser Maschine fast 40 Mal so schnell wie die mit Node.js, da Letztere ohne Weiteres die zusätzlichen Kerne nicht nutzen kann.
Mein Testprogramm macht keine Aussage zu Latenzzeiten oder dem Verhalten, wenn das Programm mit sehr vielen TCP-Verbindungen umgehen muss, auf denen nur gelegentlich Daten fließen. Dazu nur zwei Anmerkungen: Erstens liegt die Latenzzeit auf dem Netzwerk um Größenordnungen über der Dauer für Kontext-Wechsel zwischen Threads. Und zweitens können moderne Betriebssysteme eine schier unglaubliche Menge paralleler Threads mit vernachlässigbarem Overhead verwalten – weit über 100 000 Threads hinaus.
Performance ist aus meiner Sicht also keine Eigenschaft, die für den Einsatz von Node.js spricht. Eher umgekehrt: Performance-Anforderungen könnten ein Argument dagegen sein. In der Praxis sollte man allerdings nicht vergessen, dass die meisten Systeme überhaupt keine so hohen Anforderungen an den Durchsatz haben – und für hundert Anfragen pro Sekunde reicht Node.js allemal aus.
Andererseits gibt es viele Dinge, die für den Einsatz von Node.js sprechen. Besonders attraktiv finde ich das Framework für die Entwicklung JavaScript-basierter Web-Anwendungen, bei denen die Frontend-Entwickler die Server-seitige und zum Frontend gehörende Anbindung an die führenden Systeme gleich mitentwickeln können – vorausgesetzt natürlich man verfügt über genügend viele und hinreichend qualifizierte JavaScript-Entwickler.