Shell-Skripting mit Node.js
Sie können die Offline-Version dieses Buches (HTML, PDF, EPUB, MOBI) kaufen und damit die kostenlose Online-Version unterstützen.
(Werbung, bitte nicht blockieren.)

4 Ein Überblick über Node.js: Architektur, APIs, Event Loop, Nebenläufigkeit



Dieses Kapitel gibt einen Überblick darüber, wie Node.js funktioniert.

4.1 Die Node.js-Plattform

Das folgende Diagramm gibt einen Überblick darüber, wie Node.js strukturiert ist.

Die APIs, die einer Node.js-App zur Verfügung stehen, bestehen aus

Die Node.js-APIs sind teilweise in JavaScript, teilweise in C++ implementiert. Letzteres ist notwendig, um mit dem Betriebssystem zu interagieren.

Node.js führt JavaScript über eine eingebettete V8-JavaScript-Engine aus (dieselbe Engine, die auch vom Chrome-Browser von Google verwendet wird).

4.1.1 Globale Node.js-Variablen

Hier sind einige Highlights von Nodes globalen Variablen.

Weitere globale Variablen werden in diesem Kapitel erwähnt.

4.1.1.1 Module anstelle von globalen Variablen verwenden

Die folgenden integrierten Module bieten Alternativen zu globalen Variablen.

Grundsätzlich ist die Verwendung von Modulen sauberer als die Verwendung globaler Variablen. Da jedoch die Verwendung der globalen Variablen console und process so etablierte Muster sind, hat eine Abweichung davon auch Nachteile.

4.1.2 Die integrierten Node.js-Module

Die meisten Node-APIs werden über Module bereitgestellt. Dies sind einige häufig verwendete (in alphabetischer Reihenfolge):

Das Modul 'node:module' enthält die Funktion builtinModules(), die ein Array mit den Spezifikationen aller integrierten Module zurückgibt.

import * as assert from 'node:assert/strict';
import {builtinModules} from 'node:module';
// Remove internal modules (whose names start with underscores)
const modules = builtinModules.filter(m => !m.startsWith('_'));
modules.sort();
assert.deepEqual(
  modules.slice(0, 5),
  [
    'assert',
    'assert/strict',
    'async_hooks',
    'buffer',
    'child_process',
  ]
);

4.1.3 Die verschiedenen Stile von Node.js-Funktionen

In diesem Abschnitt verwenden wir folgenden Import:

import * as fs from 'node:fs';

Nodes Funktionen gibt es in drei verschiedenen Stilen. Betrachten wir das integrierte Modul 'node:fs' als Beispiel:

Die drei gerade gesehenen Beispiele zeigen die Namenskonvention für Funktionen mit ähnlicher Funktionalität.

Werfen wir einen genaueren Blick darauf, wie diese drei Stile funktionieren.

4.1.3.1 Synchrone Funktionen

Synchrone Funktionen sind am einfachsten – sie geben sofort Werte zurück und werfen Fehler als Ausnahmen.

try {
  const result = fs.readFileSync('/etc/passwd', {encoding: 'utf-8'});
  console.log(result);
} catch (err) {
  console.error(err);
}
4.1.3.2 Promise-basierte Funktionen

Promise-basierte Funktionen geben Promises zurück, die mit Ergebnissen erfüllt und mit Fehlern abgelehnt werden.

import * as fsPromises from 'node:fs/promises'; // (A)

try {
  const result = await fsPromises.readFile(
    '/etc/passwd', {encoding: 'utf-8'});
  console.log(result);
} catch (err) {
  console.error(err);
}

Beachten Sie den Modulspezifizierer in Zeile A: Die Promise-basierte API befindet sich in einem anderen Modul.

Promises werden detaillierter in „JavaScript for impatient programmers“ erklärt.

4.1.3.3 Callback-basierte Funktionen

Callback-basierte Funktionen übergeben Ergebnisse und Fehler an Callbacks, die ihre letzten Parameter sind.

fs.readFile('/etc/passwd', {encoding: 'utf-8'},
  (err, result) => {
    if (err) {
      console.error(err);
      return;
    }
    console.log(result);
  }
);

Dieser Stil wird detaillierter in der Node.js-Dokumentation erklärt.

4.2 Die Node.js-Event-Loop

Standardmäßig führt Node.js allen JavaScript in einem einzigen Thread aus, dem Haupt-Thread. Der Haupt-Thread führt kontinuierlich die Event-Loop aus – eine Schleife, die JavaScript-Chunks ausführt. Jeder Chunk ist ein Callback und kann als kooperativ geplanter Task betrachtet werden. Der erste Task enthält den Code (aus einem Modul oder der Standardeingabe), mit dem wir Node.js starten. Andere Tasks werden normalerweise später hinzugefügt, aufgrund von

Eine erste Annäherung an die Event-Loop sieht so aus:

Das heißt, der Haupt-Thread führt Code aus, der dem folgenden ähnelt:

while (true) { // event loop
  const task = taskQueue.dequeue(); // blocks
  task();
}

Die Event-Loop nimmt Callbacks aus einer Task-Queue und führt sie im Haupt-Thread aus. Das Entnehmen von Tasks blockiert (pausiert den Haupt-Thread), wenn die Task-Queue leer ist.

Wir werden später zwei Themen behandeln:

Warum wird diese Schleife Event-Loop genannt? Viele Tasks werden als Reaktion auf Ereignisse hinzugefügt, z.B. solche, die vom Betriebssystem gesendet werden, wenn Eingabedaten zur Verarbeitung bereitstehen.

Wie werden Callbacks zur Task-Queue hinzugefügt? Dies sind gängige Möglichkeiten:

Der folgende Code zeigt eine asynchrone Callback-basierte Operation in Aktion. Er liest eine Textdatei aus dem Dateisystem:

import * as fs from 'node:fs';

function handleResult(err, result) {
  if (err) {
    console.error(err);
    return;
  }
  console.log(result); // (A)
}
fs.readFile('reminder.txt', 'utf-8',
  handleResult
);
console.log('AFTER'); // (B)

Dies ist die Ausgabe:

AFTER
Don’t forget!

fs.readFile() führt den Code, der die Datei liest, in einem anderen Thread aus. In diesem Fall ist der Code erfolgreich und fügt diesen Callback zur Task-Queue hinzu.

() => handleResult(null, 'Don’t forget!')

4.2.1 Bis zum Abschluss ausführen vereinfacht den Code

Eine wichtige Regel für die Ausführung von JavaScript-Code in Node.js lautet: Jeder Task wird bis zum Ende ausgeführt („läuft bis zum Abschluss“), bevor andere Tasks ausgeführt werden. Das sehen wir im vorherigen Beispiel: 'NACH' in Zeile B wird protokolliert, bevor das Ergebnis in Zeile A protokolliert wird, da der ursprüngliche Task abgeschlossen ist, bevor der Task mit dem Aufruf von handleResult() ausgeführt wird.

Bis zum Abschluss ausführen bedeutet, dass die Lebensdauern von Tasks nicht überlappen und wir uns keine Sorgen machen müssen, dass gemeinsam genutzte Daten im Hintergrund geändert werden. Das vereinfacht Node.js-Code. Das nächste Beispiel demonstriert dies. Es implementiert einen einfachen HTTP-Server:

// server.mjs
import * as http from 'node:http';

let requestCount = 1;
const server = http.createServer(
  (_req, res) => { // (A)
    res.writeHead(200);
    res.end('This is request number ' + requestCount); // (B)
    requestCount++; // (C)
  }
);
server.listen(8080);

Wir führen diesen Code über node server.mjs aus. Danach startet der Code und wartet auf HTTP-Anfragen. Wir können sie senden, indem wir mit einem Webbrowser zu https://:8080 gehen. Jedes Mal, wenn wir diese HTTP-Ressource neu laden, ruft Node.js den Callback auf, der in Zeile A beginnt. Er liefert eine Nachricht mit dem aktuellen Wert der Variablen requestCount (Zeile B) und inkrementiert sie (Zeile C).

Jeder Aufruf des Callbacks ist ein neuer Task und die Variable requestCount wird zwischen Tasks gemeinsam genutzt. Durch das Ausführen bis zum Abschluss ist sie leicht zu lesen und zu aktualisieren. Es ist keine Synchronisation mit anderen gleichzeitig laufenden Tasks erforderlich, da es keine gibt.

4.2.2 Warum läuft Node.js-Code in einem einzigen Thread?

Warum läuft Node.js-Code standardmäßig in einem einzigen Thread (mit einer Event-Loop)? Das hat zwei Vorteile:

Da einige von Nodes asynchronen Operationen in anderen Threads als dem Haupt-Thread ausgeführt werden (dazu bald mehr) und über die Task-Queue an JavaScript zurückmelden, ist Node.js nicht wirklich Single-Threaded. Stattdessen verwenden wir einen einzigen Thread, um Operationen zu koordinieren, die gleichzeitig und asynchron (im Haupt-Thread) ausgeführt werden.

Damit ist unser erster Blick auf die Event-Loop abgeschlossen. Sie können den Rest dieses Abschnitts überspringen, wenn eine oberflächliche Erklärung für Sie ausreicht. Lesen Sie weiter, um mehr Details zu erfahren.

4.2.3 Die echte Event-Loop hat mehrere Phasen

Die echte Event-Loop hat mehrere Task-Queues, aus denen sie in mehreren Phasen liest (Sie können sich einen Teil des JavaScript-Codes im GitHub-Repository nodejs/node ansehen). Das folgende Diagramm zeigt die wichtigsten dieser Phasen:

Was machen die im Diagramm gezeigten Event-Loop-Phasen?

Jede Phase läuft, bis ihre Queue leer ist oder bis eine maximale Anzahl von Tasks verarbeitet wurde. Mit Ausnahme von „poll“ wartet jede Phase bis zu ihrer nächsten Runde, bevor sie Tasks verarbeitet, die während ihres Laufs hinzugefügt wurden.

4.2.3.1 Phase „poll“

Wenn diese Phase länger als ein systemabhängiges Zeitlimit dauert, endet sie und die nächste Phase wird ausgeführt.

4.2.4 Next-tick-Aufgaben und Microtasks

Nach jedem ausgeführten Task läuft eine „Sub-Schleife“, die aus zwei Phasen besteht:

Die Sub-Phasen bearbeiten:

Next-tick-Aufgaben sind Node.js-spezifisch, Microtasks sind ein plattformübergreifender Webstandard (siehe MDNs Kompatibilitätstabelle).

Diese Sub-Schleife läuft, bis beide Queues leer sind. Tasks, die während ihres Laufs hinzugefügt werden, werden sofort verarbeitet – die Sub-Schleife wartet nicht bis zu ihrer nächsten Runde.

4.2.5 Vergleich verschiedener Wege zur direkten Aufgabenplanung

Wir können die folgenden Funktionen und Methoden verwenden, um Callbacks zu einer der Task-Queues hinzuzufügen:

Es ist wichtig zu beachten, dass wir bei der Zeitplanung einer Aufgabe über eine Verzögerung die frühestmögliche Zeit angeben, zu der die Aufgabe ausgeführt wird. Node.js kann sie nicht immer genau zur geplanten Zeit ausführen, da es nur zwischen Tasks prüfen kann, ob zeitgesteuerte Tasks fällig sind. Daher kann eine lang laufende Aufgabe dazu führen, dass zeitgesteuerte Tasks verspätet ausgeführt werden.

4.2.5.1 Next-tick-Aufgaben und Microtasks im Vergleich zu normalen Aufgaben

Betrachten Sie den folgenden Code

function enqueueTasks() {
  Promise.resolve().then(() => console.log('Promise reaction 1'));
  queueMicrotask(() => console.log('queueMicrotask 1'));
  process.nextTick(() => console.log('nextTick 1'));
  setImmediate(() => console.log('setImmediate 1')); // (A)
  setTimeout(() => console.log('setTimeout 1'), 0);
  
  Promise.resolve().then(() => console.log('Promise reaction 2'));
  queueMicrotask(() => console.log('queueMicrotask 2'));
  process.nextTick(() => console.log('nextTick 2'));
  setImmediate(() => console.log('setImmediate 2')); // (B)
  setTimeout(() => console.log('setTimeout 2'), 0);
}

setImmediate(enqueueTasks);

Wir verwenden setImmediate(), um eine Besonderheit von ESM-Modulen zu vermeiden: Sie werden in Microtasks ausgeführt, was bedeutet, dass, wenn wir Microtasks auf der obersten Ebene eines ESM-Moduls enqueuen, sie vor Next-tick-Tasks ausgeführt werden. Wie wir gleich sehen werden, ist das in den meisten anderen Kontexten anders.

Dies ist die Ausgabe des vorherigen Codes:

nextTick 1
nextTick 2
Promise reaction 1
queueMicrotask 1
Promise reaction 2
queueMicrotask 2
setTimeout 1
setTimeout 2
setImmediate 1
setImmediate 2

Beobachtungen

4.2.5.2 Enqueuing von Next-tick-Aufgaben und Microtasks während ihrer Phasen

Der folgende Code untersucht, was passiert, wenn wir eine Next-tick-Aufgabe während der Next-tick-Phase und eine Microtask während der Microtask-Phase enqueuen:

setImmediate(() => {
  setImmediate(() => console.log('setImmediate 1'));
  setTimeout(() => console.log('setTimeout 1'), 0);

  process.nextTick(() => {
    console.log('nextTick 1');
    process.nextTick(() => console.log('nextTick 2'));
  });

  queueMicrotask(() => {
    console.log('queueMicrotask 1');
    queueMicrotask(() => console.log('queueMicrotask 2'));
    process.nextTick(() => console.log('nextTick 3'));
  });
});

Dies ist die Ausgabe:

nextTick 1
nextTick 2
queueMicrotask 1
queueMicrotask 2
nextTick 3
setTimeout 1
setImmediate 1

Beobachtungen

4.2.5.3 Aushungern von Event-Loop-Phasen

Der folgende Code untersucht, welche Arten von Tasks Event-Loop-Phasen aushungern (sie durch unendliche Rekursion am Laufen hindern) können:

import * as fs from 'node:fs/promises';

function timers() { // OK
  setTimeout(() => timers(), 0);
}
function immediate() { // OK
  setImmediate(() => immediate());
}

function nextTick() { // starves I/O
  process.nextTick(() => nextTick());
}

function microtasks() { // starves I/O
  queueMicrotask(() => microtasks());
}

timers();
console.log('AFTER'); // always logged
console.log(await fs.readFile('./file.txt', 'utf-8'));

Die Phasen „timers“ und „immediate“ führen keine Tasks aus, die während ihrer Phasen enqueued werden. Deshalb hungern timers() und immediate() fs.readFile() nicht aus, das während der „poll“-Phase zurückmeldet (es gibt auch eine Promise-Reaktion, aber ignorieren wir diese hier).

Aufgrund der Art und Weise, wie Next-tick-Aufgaben und Microtasks geplant werden, verhindern sowohl nextTick() als auch microtasks() die Ausgabe in der letzten Zeile.

4.2.6 Wann wird eine Node.js-App beendet?

Am Ende jeder Iteration der Event-Loop prüft Node.js, ob es Zeit zum Beenden ist. Es führt einen Referenzzähler für ausstehende Timeouts (für zeitgesteuerte Aufgaben).

Wenn der Referenzzähler am Ende einer Event-Loop-Iteration Null ist, beendet sich Node.js.

Das sehen wir im folgenden Beispiel:

function timeout(ms) {
  return new Promise(
    (resolve, _reject) => {
      setTimeout(resolve, ms); // (A)
    }
  );
}
await timeout(3_000);

Node.js wartet, bis das von timeout() zurückgegebene Promise erfüllt ist. Warum? Weil die Aufgabe, die wir in Zeile A planen, die Event-Loop am Leben erhält.

Im Gegensatz dazu erhöht das Erstellen von Promises nicht den Referenzzähler:

function foreverPending() {
  return new Promise(
    (_resolve, _reject) => {}
  );
}
await foreverPending(); // (A)

In diesem Fall verlässt die Ausführung diese (Haupt-)Task während await in Zeile A vorübergehend. Am Ende der Event-Loop ist der Referenzzähler Null und Node.js wird beendet. Allerdings ist die Beendigung nicht erfolgreich. Das heißt, der Exit-Code ist nicht 0, sondern 13 („Unfinished Top-Level Await“).

Wir können manuell steuern, ob ein Timeout die Event-Loop am Leben erhält: Standardmäßig halten Tasks, die über setImmediate(), setInterval() und setTimeout() geplant werden, die Event-Loop so lange am Leben, wie sie ausstehen. Diese Funktionen geben Instanzen der Klasse Timeout zurück, deren Methode .unref() diese Standardeinstellung ändert, sodass das aktive Timeout Node.js nicht am Beenden hindert. Methode .ref() stellt die Standardeinstellung wieder her.

Tim Perry erwähnt einen Anwendungsfall für .unref(): Seine Bibliothek verwendete setInterval(), um wiederholt eine Hintergrundaufgabe auszuführen. Diese Aufgabe verhinderte, dass Anwendungen beendet wurden. Er löste das Problem über .unref().

4.3 libuv: die plattformübergreifende Bibliothek, die asynchrone I/O (und mehr) für Node.js handhabt

libuv ist eine in C geschriebene Bibliothek, die viele Plattformen (Windows, macOS, Linux usw.) unterstützt. Node.js verwendet sie zur Handhabung von I/O und mehr.

4.3.1 Wie libuv asynchrone I/O handhabt

Netzwerk-I/O ist asynchron und blockiert nicht den aktuellen Thread. Solche I/O umfasst:

Zur Handhabung asynchroner I/O verwendet libuv native Kernel-APIs und abonniert I/O-Ereignisse (epoll unter Linux; kqueue unter BSD Unix inkl. macOS; Event Ports unter SunOS; IOCP unter Windows). Es erhält dann Benachrichtigungen, wenn diese auftreten. All diese Aktivitäten, einschließlich der I/O selbst, finden auf dem Haupt-Thread statt.

4.3.2 Wie libuv blockierende I/O handhabt

Einige native I/O-APIs sind blockierend (nicht asynchron) – zum Beispiel Dateie/o und einige DNS-Dienste. libuv ruft diese APIs aus Threads in einem Thread-Pool (dem sogenannten „Worker-Pool“) auf. Dies ermöglicht es dem Haupt-Thread, diese APIs asynchron zu verwenden.

4.3.3 libuv-Funktionalität jenseits von I/O

libuv hilft Node.js bei mehr als nur I/O. Weitere Funktionalitäten umfassen:

Nebenbei bemerkt, hat libuv seine eigene Event-Loop, deren Quellcode Sie im GitHub-Repository libuv/libuv (Funktion uv_run()) einsehen können.

4.4 Den Haupt-Thread mit User-Code verlassen

Wenn wir möchten, dass Node.js auf I/O reagiert, sollten wir langlaufende Berechnungen in Haupt-Thread-Tasks vermeiden. Es gibt zwei Optionen dafür:

Die nächsten Unterabschnitte behandeln einige Optionen zur Auslagerung.

4.4.1 Worker-Threads

Worker Threads implementieren die plattformübergreifende Web Workers API mit einigen Unterschieden – z.B.:

Einerseits sind Worker Threads wirklich Threads: Sie sind leichter als Prozesse und laufen im selben Prozess wie der Haupt-Thread.

Andererseits:

Weitere Informationen finden Sie in der Node.js-Dokumentation zu Worker Threads.

4.4.2 Cluster

Cluster ist eine Node.js-spezifische API. Sie ermöglicht uns das Ausführen von Clustern von Node.js-Prozessen, die wir zur Verteilung von Arbeitslasten verwenden können. Die Prozesse sind vollständig isoliert, teilen sich aber Server-Ports. Sie können kommunizieren, indem sie JSON-Daten über Kanäle übergeben.

Wenn wir keine Prozessisolation benötigen, können wir Worker Threads verwenden, die leichter sind.

4.4.3 Kindprozesse

Child process ist eine weitere Node.js-spezifische API. Sie ermöglicht uns das Erzeugen neuer Prozesse, die native Befehle ausführen (oft über native Shells). Diese API wird in §12 „Ausführen von Shell-Befehlen in Kindprozessen“ behandelt.

4.5 Quellen dieses Kapitels

Node.js Event Loop

Videos zur Event-Loop (die einige Hintergrundkenntnisse auffrischen, die für dieses Kapitel erforderlich sind):

libuv

JavaScript-Nebenläufigkeit

4.5.1 Danksagung