Erklärung zur Konkurrenz in Node.js: libuv, der Event Loop und der Thread Pool
Erfahren Sie, wie Node.js die OS-Primitiven von libuv sowie den Worker-Thread-Pool nutzt, um asynchrone I/O-Aufgaben zu verarbeiten, sowie welche häufigen Probleme beim Einsatz des Thread-Pools auftreten können und wie man ihn optimieren kann.
Bereits in der ersten Woche des Lernens der Plattform hört fast jeder Entwickler den Satz „Node.js ist single-threaded“. In der Praxis kann jedoch ein einzelner Node.js-Prozess Hunderte von Dateien gleichzeitig lesen, Tausende von DNS-Einträgen abrufen und Zehntausende offener Datenbankverbindungen verwalten, während er weiterhin ohne Pause Code ausführt.
Falls JavaScript selbst nur auf einem Thread läuft, wie kann ein Node.js-Server weiterhin auf Anfragen reagieren, während er eine mehrere Gigabyte große Datei von einer rotierenden Festplatte lädt?
Der Mechanismus dahinter ist libuv, eine für Node.js speziell entwickelte C-Bibliothek, die asynchrone, blockfreie Eingabe- und Ausgabevorgänge verwalten kann.
Eine echte Verständnis dafür zu entwickeln, wie libuv die Arbeit von der JavaScript-Thread ablädt, ist nicht nur theoretisches Wissen. Es erklärt, warum sich eine Datenbankabfrage im Vergleich zu einer Hashing-Operation in Bezug auf die Leistung anders verhält, warum das Anpassen einer einzigen Umgebungsvariable die Reaktionsgeschwindigkeit Ihrer Produktions-API erheblich beeinflussen kann – positiv oder negativ – und wie Sie versteckte Engpässe in Ihren Diensten erkennen und vermeiden können.
Was ist libuv?
Node.js ist kein einziger monolithischer Motor – es handelt sich um einen Stack aus mehreren zusammenarbeitenden Schichten:
+-------------------------------------------------------------+
| Your Application |
+-------------------------------------------------------------+
| Node.js Core (JS / C++) |
+------------------------------+------------------------------+
| V8 Engine (Google) | libuv |
| (Executes JavaScript) | (Event Loop & Async I/O) |
+------------------------------+------------------------------+
| Operating System Kernel |
+-------------------------------------------------------------+
- V8 (Google): Dieser Motor kompiliert und führt Ihren JavaScript-Code aus. Er verfügt über genau einen Aufrufstapel und führt den Code sequenziell, ausschließlich auf einem Thread aus.
Wenn Menschen Node.js als eintaktig beschreiben, meinen sie eigentlich, dass der JavaScript-Exekutionskontext auf einem einzigen Hauptthread läuft. libuv selbst ist jedoch in C geschrieben und somit von Natur aus mehrthreadig. Es nutzt die vom Betriebssystem bereitgestellten niedrigschwelligen Funktionen, um Aufgaben gleichzeitig auszuführen, ohne die JavaScript-Exekution zu blockieren.
Zwei Wege, wie libuv asynchrone Arbeit handhabt
Es ist üblich anzunehmen, dass libuv jede asynchrone Operation an einen Hintergrundthread weiterleitet. Das ist nicht ganz richtig – libuv teilt die Arbeit tatsächlich je nach Art der Aufgabe zwischen zwei verschiedenen Strategien auf:
- Eingebettete, nicht blockierende Betriebssystemfunktionen (für Netzwerkeingang und -ausgang)
- libuvs interner Thread-Pool (für Dateisystemzugriffe, DNS-Aufrufe und Kryptografie)
Das Verständnis dieser Aufteilung ist wohl das nützlichste Denkmodell, um über die Leistung des Node.js-Backends nachzudenken.
Incoming Async Task
│
├── Is it Network I/O? (TCP/UDP, HTTP sockets)
│ └──> Handled directly by OS Kernel mechanisms (epoll / kqueue / IOCP)
│ (Zero worker threads used)
│
└── Is it File I/O, DNS lookup, or CPU-bound crypto/compression?
└──> Handled by libuv Thread Pool (4 threads by default)
1. Netzwerkeingang und -ausgang: Betriebssystem-Primitiven
Moderne Betriebssysteme bieten spezielle, nicht blockierende APIs zur Verarbeitung von Netzwerksockets an:
- epoll unter Linux
- kqueue unter macOS und der BSD-Familie
- IOCP (Completion Ports für Eingang und Ausgang) unter Windows
Wenn eine Node.js-Anwendung einen TCP-Listener öffnet oder eine ausgehende HTTPS-Anfrage sendet, übergibt libuv diese Aufgabe nicht an einen Worker-Thread. Stattdessen registriert es den Dateidescriptor des Sockets direkt beim Betriebssystemkern und bittet im Grunde darum, benachrichtigt zu werden, sobald Daten auf diesem Socket eintreffen oder er bereit ist, weitere Schreibvorgänge entgegenzunehmen.
Von diesem Zeitpunkt an wartet libuv einfach ab. Es ist der Betriebssystemkern, der das Netzwerkhardware überwacht.
Sobald Pakete tatsächlich am Netzwerkkontakt ankommen, löst der Kernel ein Ereignis aus. libuv erfasst dieses während der Abfragephase seines Event-Loops und stellt anschließend den entsprechenden JavaScript-Callback in die Warteschlange zur Ausführung. V8 entnimmt ihn schließlich aus der Warteschlange und führt ihn im Hauptthread aus.
Da kein Worker-Thread untätig herumsteht und auf das Eintreffen von Bytes wartet, kann ein einzelner Node.js-Prozess problemlos Zehntausende von langsamen oder inaktiven Verbindungen verwalten, wobei dabei nur sehr wenig Speicher verbraucht wird.
2. Dateieingabe, -ausgabe und Systemoperationen: Der Worker-Thread-Pool
Da Netzwerk-Sockets auf Kernel-Ebene ohne Blockierung verarbeitet werden können, fragt man sich vielleicht, warum Datei-Lesungen und -Schreibvorgänge nicht auf dieselbe Weise funktionieren können.
Der Grund ist, dass die meisten Betriebssysteme keine echte, nicht-blockierende API für den Zugriff auf das Dateisystem haben. Auf POSIX-basierten Systemen wie Linux und macOS blockieren gewöhnliche Dateioperationen den jeweiligen Thread, der sie aufruft, bis das Speichergerät tatsächlich die angeforderten Daten zurückgibt.
Falls Node.js versuchen würde, eine Datei direkt auf dem Haupt-JavaScript-Thread zu lesen, würde die gesamte Laufzeit blockieren, bis der Festplattentreiber gestartet ist, die relevanten Blöcke abgerufen wurden und die Bytes zurückgegeben wurden. Während dieser gesamten Pause könnte keine weitere eingehende HTTP-Anfrage bearbeitet werden.
Um dieses Problem zu umgehen, unterhält libuv einen internen Arbeitsthread-Pool.
So verläuft der Ablauf, wenn Ihr Code fs.readFile() aufruft:
- Der JavaScript-Aufruf gelangt über die internen Bindungen von Node zu libuv.
- libuv packt den Datei-Lese-Antrag als Arbeitsauftrag zusammen und legt ihn in eine interne Arbeitswarteschlange.
- Einer der Hintergrundthreads im Pool holt diesen Antrag aus der Warteschlange.
- Der Thread führt anschließend den eigentlichen blockierenden Systemaufruf –
read()oderwrite()– sicher, fernab vom Hauptthread, aus.
Was läuft eigentlich im Thread-Pool?
Vier Hauptkategorien von Aufgaben nutzen den libuv-Thread-Pool:
- Dateisystemaufrufe: alle asynchronen Methoden unter
fs, wie z. B.fs.readFile,fs.statundfs.writeFile. - DNS-Aufrufe: insbesondere
dns.lookup(), der auf der blockierenden C-Funktiongetaddrinfo()beruht. Im Gegensatz dazu umgehtdns.resolve()den Thread-Pool völlig und kommuniziert direkt über nicht-blockierende Aufrufe mit dem Netzwerk.
crypto.pbkdf2(), crypto.scrypt() sowie Verfahren zur Schlüsselgenerierung.zlib-Methoden, beispielsweise zlib.gzip().Aufzeichnung der Arbeit des Thread Pools
Durch ein kurzes Experiment können Sie überprüfen, dass libuv auf einem Hintergrundpool beruht, und dessen Standardgröße einsehen:
// thread-pool-test.js
const crypto = require('crypto');
const start = Date.now();
const ITERATIONS = 6;for (let i = 1; i <= ITERATIONS; i++) {
crypto.pbkdf2('password123', 'salt-value', 100000, 64, 'sha512', () => {
const elapsed = Date.now() - start;
console.log(`Task ${i} completed in ${elapsed}ms`);
});
}
crypto.pbkdf2() ist ein guter Testfall, da er absichtlich rechenintensive CPU-Arbeit zur Erstellung eines Passworthashes durchführt, und diese Arbeit an den Thread Pool weitergeleitet wird.
Führen Sie das Skript aus Ihrer Terminal ein:
node thread-pool-test.js
Die Ausgabe wird ungefähr so aussehen:
Task 2 completed in 218ms
Task 1 completed in 220ms
Task 4 completed in 224ms
Task 3 completed in 226ms
Task 5 completed in 435ms
Task 6 completed in 437ms
Warum dauern die letzten beiden Aufgaben doppelt so lange?
Betrachten Sie die Zeiten genau. Die ersten vier Aufgaben sind alle ungefähr zur gleichen Zeit abgeschlossen, um die 220 ms herum. Die fünfte und sechste Aufgabe hingegen benötigen fast 435 ms – fast doppelt so lange.
Der Grund dafür ist, dass der Threadpool von libuv standardmäßig mit 4 Threads ausgeliefert wird.
Sobald der Loop beginnt, beanspruchen die ersten vier Aufgaben alle vier verfügbaren Worker-Threads. Die fünfte und sechste Aufgabe warten dann in Libuvs internem Queue. Erst wenn eine der ursprünglichen vier Aufgaben abgeschlossen ist und einen Thread freigibt, können die in der Queue wartenden Aufgaben ausgeführt werden.
Anpassung der Pool-Größe mit UV_THREADPOOL_SIZE
Sie können die Anzahl der von Libuv gestarteten Worker-Threads ändern, indem Sie die Umgebungsvariable UV_THREADPOOL_SIZE vor dem Start des Node.js-Prozesses setzen. Sie akzeptiert Werte von 1 bis 128.
Probieren Sie denselben Script erneut aus, diesmal mit einer Anfrage nach einem Pool von 8 Threads:
# On Linux / macOS:
UV_THREADPOOL_SIZE=8 node thread-pool-test.js
# On Windows (PowerShell):
$env:UV_THREADPOOL_SIZE=8; node thread-pool-test.js
Die Ergebnisse sehen jetzt anders aus:
Task 1 completed in 240ms
Task 3 completed in 242ms
Task 2 completed in 245ms
Task 6 completed in 249ms
Task 4 completed in 250ms
Task 5 completed in 252ms
Sobald genügend Worker-Threads verfügbar sind, werden alle sechs Aufgaben gleichzeitig ausgeführt anstatt in der Warteschlange zu bleiben.
Wichtige Einschränkung: Sie können den Threadpool nicht von innerhalb Ihres Skripts durch das Schreiben von
process.env.UV_THREADPOOL_SIZE = 8vergrößern. libuv liest diesen Wert bereits vor dem Ausführen Ihres JavaScript-Codes und sperrt ihn ab, sodass die Umgebungsvariable auf Shell-Ebene oder durch den Prozessmanager festgelegt werden muss, der Node startet – nicht von innerhalb der Anwendung selbst.
Ein häufiges Problem in der Produktion: Threads, die für unzusammenhängende Aufgaben genutzt werden
Da Dateioperationen, DNS-Aufrufe und kryptografische Aufgaben standardmäßig alle vom selben Pool aus vier Threads genutzt werden, kann eine intensive Nutzung in einer Kategorie unbemerkt etwas völlig Unverwandtes verlangsamen.
Bilden Sie sich diese Abfolge von Ereignissen vor:
- Eine Welle an Anmeldungen löst mehrere gleichzeitige Aufrufe von
crypto.pbkdf2aus, um Passwörter zu überprüfen. - Sämtliche vier libuv-Threads sind nun vollständig damit beschäftigt, Passworthashes zu berechnen.
- Zur gleichen Zeit ruft ein anderer Teil der Anwendung
fs.readFile()auf, um ein E-Mail-Muster zu laden, oder ruftdns.lookup()auf, um den Hostnamen der Datenbank zu ermitteln. - Sowohl diese Operationen müssen in der Warteschlange auf ihre Reihe warten.
Das Lesen einer Datei verbraucht kaum CPU-Ressourcen, wird dennoch verzögert, da jeder Arbeitsthread mit der Hash-Berechnung beschäftigt ist. Von außen sieht es so aus, als würden der Dateizugriff oder die Datenbankverbindung langsamer werden, obwohl der eigentliche Grund der Wettbewerb um die libuv-Threads ist.
Wie man den Wettbewerb um den Thread-Pool verringert:
- Mehr Threads für den Threadpool bereitstellen: Wenn Ihr Service viel Datei-Eingabe/Ausgabe oder kryptografische Aufgaben durchführt, kann eine Erhöhung von
UV_THREADPOOL_SIZEauf 16 oder 32 die Konkurrenz verringern, sofern der zugrunde liegende Rechner über ausreichend CPU-Ressourcen verfügt. - Kundenspezifische, CPU-intensive Aufgaben aus dem Pool herausführen: Für von Ihnen gesteuerte Aufgaben wie Berichtserstellung oder Bildverarbeitung sollten Sie versuchen, diese nicht über libuv abzuwickeln. Nutzen Sie stattdessen das
worker_threads-Modul, das separate V8-Instanzen auf eigenen OS-Threads startet. - Vermeiden Sie
dns.lookup(), wenn es möglich ist: Wählen Sie stattdessendns.resolve4()oder richten Sie Verbindungs-Pools ein, die explizite IP-Adressen verwenden, damit die Namensauflösung im Netzwerk nicht die begrenzten Worker-Plätze von libuv belegt.
Wo Entwickler häufig Fehler machen
Fehler Nummer eins: Annahme, dass async/await die Arbeit automatisch ablegt
Das Voranstellen einer Funktion mit async erzeugt keinen Hintergrundthread für sie. async/await ist lediglich eine sauberere Syntax, die auf Promises aufbaut. Wenn der Funktionskörper eine synchrone Schleife oder eine rechenintensive Operation enthält, wird dieser Code weiterhin direkt auf dem Haupt-JavaScript-Thread ausgeführt und blockiert somit weiterhin Ihren Server während der Ausführung.
Fehler Nummer zwei: Verwechslung des libuv-Threadpools mit worker_threads
- libuvs Threadpool wird intern durch natives C-Code verwaltet. Er kümmert sich um eingebaute Operationen wie
fs,cryptoundzlib. Es gibt keine Möglichkeit, eigene, beliebige JavaScript-Funktionen in diesen Pool einzubringen.
worker_threads-Modul, das seit Node.js 10.5 verfügbar ist, stellt eine API auf JavaScript-Ebene dar. Es ermöglicht es Ihnen, Ihren eigenen Code parallel auszuführen, wobei jeder Worker seinen eigenen unabhängigen V8-Engine und Event-Loop erhält.Fehler Nummer Drei: Den Thread-Pool zu groß zu gestalten
Es ist verlockend, überall UV_THREADPOOL_SIZE=128 einzustellen und anzunehmen, dass mehr immer besser ist. Doch Threads haben tatsächliche Kosten – jeder benötigt Speicher für seinen eigenen Ausführungsstapel. Wenn Hunderte von Threads um nur zwei oder vier CPU-Kerne konkurrieren, verbraucht das Betriebssystem erhebliche Zeit allein mit dem Wechseln zwischen ihnen.
Ein vernünftiger Ausgangspunkt ist es, den Pool so zu dimensionieren, dass er der Anzahl Ihrer logischen CPU-Kerne entspricht, wenn die Arbeitslast durch den Prozessor begrenzt wird – beispielsweise bei Kryptografie oder Komprimierung – oder die Anzahl der Kerne zweimal bis viermal zu verwenden, wenn die Arbeit hauptsächlich auf Disk-I/O wartet.
Zusammenfassung
Der eigentliche Trick hinter Node.js besteht nicht darin, Konkurrenz vollständig zu vermeiden, sondern darin, die Details der niedrigschwelligen Threadverwaltung in ein einfaches, ereignisgesteuertes Programmiermodell einzubetten.
- Die Ausführung von JavaScript bleibt eingleisig: Die Anwendungslogik wird Schritt für Schritt ausgeführt, wodurch Race Conditions sowie der Bedarf an Sperren vermieden werden.
- Netzwerkoperationen laufen über OS-Ebene-Mechanismen in libuv: Sockets werden durch die Polling-Systeme des Kernels wie
epoll,kqueueoderIOCPverwaltet und verbrauchen überhaupt keine Worker-Threads. - Dateizugriffe, DNS-Aufrufe und kryptografische Operationen nutzen den Thread-Pool: Vier Hintergrund-C-Threads übernehmen diese blockierenden Aufrufe, sodass der Hauptthread frei bleibt, um weiterhin neue Anfragen entgegenzunehmen.
Sobald Sie wissen, welchen dieser beiden Pfade eine bestimmte Operation nimmt, sind Sie in einer viel besseren Position, Leistungsprobleme aufzudecken, Ihre Server richtig zu dimensionieren und Backend-Dienste zu entwickeln, die auch unter hoher Last stabil funktionieren.
Verwandte Artikel
- Wie process.nextTick() den Node.js Event Loop stillschweigend lahmlegt – Erklärt, warum rekursive Aufrufe von process.nextTick() die Poll-Phase von libuv vollständig blockieren und wie man das Event-Loop-Problem mit setImmediate() behebt.
- Wahl zwischen Promise.all, Promise.race und sequentiellen Awaits – Erfahren Sie, wann Promise.all() Node.js-APIs beschleunigt, warum es bei jeder Ablehnung schnell versagt und ein Entscheidungsrahmen zur Auswahl des richtigen asynchronen Musters.