Skip to content

Blockieren Sie nicht die Event Loop (oder den Worker Pool)

Sollten Sie diese Anleitung lesen?

Wenn Sie etwas Komplexeres als ein kurzes Kommandozeilen-Skript schreiben, sollte Ihnen das Lesen dieser Anleitung helfen, leistungsfähigere und sicherere Anwendungen zu schreiben.

Dieses Dokument ist auf Node.js-Server ausgerichtet, die Konzepte gelten aber auch für komplexe Node.js-Anwendungen. Wo sich betriebssystemspezifische Details unterscheiden, ist dieses Dokument Linux-zentriert.

Zusammenfassung

Node.js führt JavaScript-Code in der Event Loop (Initialisierung und Callbacks) aus und bietet einen Worker Pool zur Bearbeitung aufwendiger Aufgaben wie Datei-I/O. Node.js skaliert gut, manchmal sogar besser als schwergewichtigere Ansätze wie Apache. Das Geheimnis der Skalierbarkeit von Node.js liegt darin, dass es mit einer geringen Anzahl von Threads viele Clients bedient. Wenn Node.js mit weniger Threads auskommt, kann es mehr Zeit und Speicher Ihres Systems für die Arbeit an Clients verwenden, anstatt für den Overhead von Threads (Speicher, Kontextwechsel). Da Node.js aber nur wenige Threads hat, müssen Sie Ihre Anwendung so strukturieren, dass diese sinnvoll genutzt werden.

Hier ist eine gute Faustregel, um Ihren Node.js-Server schnell zu halten: Node.js ist schnell, wenn die Arbeit, die zu einem bestimmten Zeitpunkt mit jedem Client verbunden ist, "klein" ist.

Dies gilt für Callbacks in der Event Loop und für Aufgaben im Worker Pool.

Warum sollte ich vermeiden, die Event Loop und den Worker Pool zu blockieren?

Node.js verwendet eine kleine Anzahl von Threads, um viele Clients zu bedienen. In Node.js gibt es zwei Arten von Threads: eine Event Loop (auch Hauptschleife, Hauptthread, Event-Thread usw. genannt) und einen Pool von k Workern in einem Worker Pool (auch Threadpool genannt).

Wenn ein Thread lange Zeit benötigt, um einen Callback (Event Loop) oder eine Aufgabe (Worker) auszuführen, nennen wir ihn "blockiert". Während ein Thread im Auftrag eines Clients blockiert ist, kann er keine Anfragen von anderen Clients bearbeiten. Dies sind zwei Gründe, warum weder die Event Loop noch der Worker Pool blockiert werden sollte:

  1. Leistung: Wenn Sie regelmäßig aufwendige Aktivitäten in einem der beiden Threadtypen durchführen, wird der Durchsatz (Anfragen/Sekunde) Ihres Servers leiden.
  2. Sicherheit: Wenn es möglich ist, dass einer Ihrer Threads bei bestimmten Eingaben blockiert, könnte ein bösartiger Client diese "böse Eingabe" einreichen, Ihre Threads blockieren und sie daran hindern, an anderen Clients zu arbeiten. Dies wäre ein Denial of Service Angriff.

Eine kurze Übersicht über Node

Node.js verwendet die Event-Driven-Architektur: Es hat eine Event Loop für die Orchestrierung und einen Worker Pool für aufwändige Aufgaben.

Welcher Code läuft in der Event Loop?

Wenn sie beginnen, durchlaufen Node.js-Anwendungen zuerst eine Initialisierungsphase, require-Module und registrieren Callbacks für Ereignisse. Node.js-Anwendungen treten dann in die Event Loop ein und reagieren auf eingehende Client-Anfragen, indem sie den entsprechenden Callback ausführen. Dieser Callback wird synchron ausgeführt und kann asynchrone Anfragen registrieren, um die Verarbeitung nach Abschluss fortzusetzen. Die Callbacks für diese asynchronen Anfragen werden ebenfalls in der Event Loop ausgeführt.

Die Event Loop erfüllt auch die nicht-blockierenden asynchronen Anfragen, die von ihren Callbacks gestellt werden, z.B. Netzwerk-I/O.

Zusammenfassend lässt sich sagen, dass die Event Loop die JavaScript-Callbacks ausführt, die für Ereignisse registriert sind, und auch für die Erfüllung nicht-blockierender asynchroner Anfragen wie Netzwerk-I/O verantwortlich ist.

Welcher Code läuft im Worker Pool?

Der Worker Pool von Node.js ist in libuv implementiert (docs), das eine allgemeine API zur Aufgabenübermittlung bereitstellt.

Node.js verwendet den Worker Pool, um "aufwändige" Aufgaben zu bearbeiten. Dazu gehören I/O, für die ein Betriebssystem keine nicht-blockierende Version bereitstellt, sowie besonders CPU-intensive Aufgaben.

Dies sind die Node.js-Modul-APIs, die diesen Worker Pool verwenden:

  1. I/O-intensiv
    1. DNS: dns.lookup(), dns.lookupService().
    2. [Dateisystem][/api/fs]: Alle Dateisystem-APIs verwenden den Threadpool von libuv, außer fs.FSWatcher() und denjenigen, die explizit synchron sind.
  2. CPU-intensiv
    1. Crypto: crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes(), crypto.randomFill(), crypto.generateKeyPair().
    2. Zlib: Alle Zlib-APIs verwenden den Threadpool von libuv, außer denen, die explizit synchron sind.

In vielen Node.js-Anwendungen sind diese APIs die einzigen Quellen für Aufgaben für den Worker Pool. Anwendungen und Module, die ein C++-Add-on verwenden, können weitere Aufgaben an den Worker Pool übermitteln.

Der Vollständigkeit halber sei angemerkt, dass die Event Loop beim Aufruf einer dieser APIs aus einem Callback in der Event Loop geringe Einrichtungskosten trägt, wenn sie die Node.js C++-Bindings für diese API betritt und eine Aufgabe an den Worker Pool übermittelt. Diese Kosten sind im Vergleich zu den Gesamtkosten der Aufgabe vernachlässigbar, weshalb die Event Loop sie auslagert. Bei der Übermittlung einer dieser Aufgaben an den Worker Pool stellt Node.js einen Zeiger auf die entsprechende C++-Funktion in den Node.js C++-Bindings bereit.

Wie entscheidet Node.js, welcher Code als nächstes ausgeführt wird?

Abstrakt gesehen verwalten die Event Loop und der Worker Pool Warteschlangen für ausstehende Ereignisse bzw. ausstehende Aufgaben.

In Wahrheit verwaltet die Event Loop keine tatsächliche Warteschlange. Stattdessen verfügt sie über eine Sammlung von Dateideskriptoren, die sie das Betriebssystem mithilfe eines Mechanismus wie epoll (Linux), kqueue (OSX), Ereignisports (Solaris) oder IOCP (Windows) überwachen lässt. Diese Dateideskriptoren entsprechen Netzwerk-Sockets, allen Dateien, die sie überwacht, und so weiter. Wenn das Betriebssystem mitteilt, dass einer dieser Dateideskriptoren bereit ist, übersetzt die Event Loop ihn in das entsprechende Ereignis und ruft die mit diesem Ereignis verbundenen Callback(s) auf. Mehr über diesen Prozess erfahren Sie hier.

Im Gegensatz dazu verwendet der Worker Pool eine echte Warteschlange, deren Einträge zu verarbeitende Aufgaben sind. Ein Worker nimmt eine Aufgabe aus dieser Warteschlange und bearbeitet sie, und wenn er fertig ist, löst der Worker ein "Mindestens eine Aufgabe ist abgeschlossen"-Ereignis für die Event Loop aus.

Was bedeutet das für das Anwendungsdesign?

In einem System mit einem Thread pro Client wie Apache wird jedem ausstehenden Client ein eigener Thread zugewiesen. Wenn ein Thread, der einen Client bearbeitet, blockiert, unterbricht das Betriebssystem ihn und gibt einem anderen Client eine Chance. Das Betriebssystem stellt somit sicher, dass Clients, die wenig Arbeit benötigen, nicht durch Clients benachteiligt werden, die mehr Arbeit benötigen.

Da Node.js viele Clients mit wenigen Threads bearbeitet, kann es vorkommen, dass, wenn ein Thread bei der Bearbeitung der Anfrage eines Clients blockiert, ausstehende Client-Anfragen erst dann eine Chance erhalten, wenn der Thread seinen Callback oder seine Aufgabe beendet hat. Die faire Behandlung von Clients liegt somit in der Verantwortung Ihrer Anwendung. Das bedeutet, dass Sie für keinen Client zu viel Arbeit in einem einzigen Callback oder einer Aufgabe erledigen sollten.

Das ist ein Teil des Grundes, warum Node.js gut skalieren kann, aber es bedeutet auch, dass Sie für eine faire Planung verantwortlich sind. In den nächsten Abschnitten wird erläutert, wie Sie eine faire Planung für die Event Loop und für den Worker Pool sicherstellen können.

Den Event-Loop nicht blockieren

Der Event-Loop bemerkt jede neue Client-Verbindung und orchestriert die Generierung einer Antwort. Alle eingehenden Anfragen und ausgehenden Antworten laufen über den Event-Loop. Das bedeutet, dass wenn der Event-Loop an irgendeiner Stelle zu lange braucht, alle aktuellen und neuen Clients nicht an die Reihe kommen.

Du solltest sicherstellen, dass du den Event-Loop niemals blockierst. Mit anderen Worten, jeder deiner JavaScript-Callbacks sollte schnell abgeschlossen sein. Dies gilt natürlich auch für deine awaits, deine Promise.thens usw.

Eine gute Möglichkeit, dies sicherzustellen, ist, über die "Rechenkomplexität" deiner Callbacks nachzudenken. Wenn dein Callback eine konstante Anzahl von Schritten benötigt, unabhängig von seinen Argumenten, dann gibst du jedem wartenden Client immer eine faire Chance. Wenn dein Callback eine unterschiedliche Anzahl von Schritten in Abhängigkeit von seinen Argumenten benötigt, dann solltest du darüber nachdenken, wie lange die Argumente sein könnten.

Beispiel 1: Ein Callback mit konstanter Laufzeit.

js
app.get('/constant-time', (req, res) => {
  res.sendStatus(200)
})

Beispiel 2: Ein O(n) Callback. Dieser Callback wird für kleine n schnell und für große n langsamer laufen.

js
app.get('/countToN', (req, res) => {
  let n = req.query.n
  // n Iterationen bevor jemand anderes an die Reihe kommt
  for (let i = 0; i < n; i++) {
    console.log(`Iter ${i}`)
  }
  res.sendStatus(200)
})

Beispiel 3: Ein O(n^2) Callback. Dieser Callback wird für kleine n immer noch schnell laufen, aber für große n wird er viel langsamer laufen als das vorherige O(n) Beispiel.

js
app.get('/countToN2', (req, res) => {
  let n = req.query.n
  // n^2 Iterationen bevor jemand anderes an die Reihe kommt
  for (let i = 0; i < n; i++) {
    for (let j = 0; j < n; j++) {
      console.log(`Iter ${i}.${j}`)
    }
  }
  res.sendStatus(200)
})

Wie vorsichtig solltest du sein?

Node.js verwendet die Google V8 Engine für JavaScript, die für viele gängige Operationen recht schnell ist. Ausnahmen von dieser Regel sind Regexps und JSON-Operationen, die unten besprochen werden.

Bei komplexen Aufgaben solltest du jedoch in Erwägung ziehen, die Eingabe zu begrenzen und Eingaben, die zu lang sind, abzulehnen. Auf diese Weise stellst du sicher, dass dein Callback, selbst wenn er eine hohe Komplexität hat, durch die Begrenzung der Eingabe nicht länger als die Worst-Case-Zeit für die längste akzeptable Eingabe dauern kann. Du kannst dann die Worst-Case-Kosten dieses Callbacks bewerten und feststellen, ob seine Laufzeit in deinem Kontext akzeptabel ist.

Blockieren der Ereignisschleife: REDOS

Eine gängige Methode, um die Ereignisschleife katastrophal zu blockieren, ist die Verwendung eines "anfälligen" regulären Ausdrucks.

Vermeidung anfälliger regulärer Ausdrücke

Ein regulärer Ausdruck (Regexp) gleicht eine Eingabezeichenkette mit einem Muster ab. Normalerweise gehen wir davon aus, dass ein Regexp-Abgleich einen einzigen Durchlauf durch die Eingabezeichenkette erfordert --- O(n) Zeit, wobei n die Länge der Eingabezeichenkette ist. In vielen Fällen ist tatsächlich ein einziger Durchlauf ausreichend. Leider kann es in einigen Fällen vorkommen, dass der Regexp-Abgleich eine exponentielle Anzahl von Durchläufen durch die Eingabezeichenkette erfordert --- O(2^n) Zeit. Eine exponentielle Anzahl von Durchläufen bedeutet, dass, wenn die Engine x Durchläufe benötigt, um eine Übereinstimmung zu bestimmen, sie 2*x Durchläufe benötigt, wenn wir der Eingabezeichenkette nur ein weiteres Zeichen hinzufügen. Da die Anzahl der Durchläufe linear mit der benötigten Zeit zusammenhängt, besteht die Auswirkung dieser Auswertung darin, die Ereignisschleife zu blockieren.

Ein anfälliger regulärer Ausdruck ist ein solcher, bei dem Ihre Regexp-Engine möglicherweise exponentielle Zeit benötigt und Sie REDOS auf "böse Eingaben" aussetzt. Ob Ihr reguläres Ausdrucksmuster anfällig ist (d. h. die Regexp-Engine möglicherweise exponentielle Zeit dafür benötigt), ist eigentlich eine schwierig zu beantwortende Frage und variiert je nachdem, ob Sie Perl, Python, Ruby, Java, JavaScript usw. verwenden, aber hier sind einige Faustregeln, die für alle diese Sprachen gelten:

  1. Vermeiden Sie verschachtelte Quantifizierer wie (a+)*. Die Regexp-Engine von V8 kann einige davon schnell verarbeiten, andere sind jedoch anfällig.
  2. Vermeiden Sie ODER-Verknüpfungen mit überlappenden Klauseln wie (a|a)*. Auch diese sind manchmal schnell.
  3. Vermeiden Sie die Verwendung von Rückwärtsreferenzen wie (a.*) \1. Keine Regexp-Engine kann garantieren, dass diese in linearer Zeit ausgewertet werden.
  4. Wenn Sie einen einfachen Zeichenkettenvergleich durchführen, verwenden Sie indexOf oder das lokale Äquivalent. Es ist günstiger und wird niemals mehr als O(n) dauern.

Wenn Sie nicht sicher sind, ob Ihr regulärer Ausdruck anfällig ist, denken Sie daran, dass Node.js im Allgemeinen keine Probleme hat, eine Übereinstimmung selbst bei einem anfälligen Regexp und einer langen Eingabezeichenkette zu melden. Das exponentielle Verhalten wird ausgelöst, wenn es eine Nichtübereinstimmung gibt, aber Node.js kann sich erst sicher sein, wenn es viele Pfade durch die Eingabezeichenkette ausprobiert hat.

Ein REDOS-Beispiel

Hier ist ein Beispiel für eine anfällige Regexp, die ihren Server REDOS aussetzt:

js
app.get('/redos-me', (req, res) => {
  let filePath = req.query.filePath
  // REDOS
  if (filePath.match(/(\/.+)+$/)) {
    console.log('valid path')
  } else {
    console.log('invalid path')
  }
  res.sendStatus(200)
})

Die anfällige Regexp in diesem Beispiel ist eine (schlechte!) Möglichkeit, einen gültigen Pfad unter Linux zu überprüfen. Sie entspricht Zeichenketten, die eine Folge von durch "/" getrennten Namen sind, wie z. B. "/a/b/c". Sie ist gefährlich, weil sie gegen Regel 1 verstößt: Sie hat einen doppelt verschachtelten Quantifizierer.

Wenn ein Client mit filePath ///.../\n (100 /'s gefolgt von einem Zeilenumbruchzeichen, dem der "." der Regexp nicht entspricht) abfragt, wird die Ereignisschleife effektiv ewig dauern und die Ereignisschleife blockieren. Der REDOS-Angriff dieses Clients führt dazu, dass alle anderen Clients erst dann an der Reihe sind, wenn die Regexp-Übereinstimmung abgeschlossen ist.

Aus diesem Grund sollten Sie bei der Verwendung komplexer regulärer Ausdrücke zur Validierung von Benutzereingaben vorsichtig sein.

Anti-REDOS-Ressourcen

Es gibt einige Tools, um Ihre Regexps auf Sicherheit zu prüfen, wie z.B.

Allerdings erfasst keine dieser beiden alle anfälligen Regexps.

Ein anderer Ansatz ist die Verwendung einer anderen Regexp-Engine. Sie können das Modul node-re2 verwenden, das Googles blitzschnelle RE2 Regexp-Engine verwendet. Seien Sie jedoch gewarnt, RE2 ist nicht 100% kompatibel mit den Regexps von V8, prüfen Sie daher auf Regressionen, wenn Sie das node-re2-Modul zur Verarbeitung Ihrer Regexps austauschen. Und besonders komplizierte Regexps werden von node-re2 nicht unterstützt.

Wenn Sie etwas "Offensichtliches" wie eine URL oder einen Dateipfad abgleichen möchten, suchen Sie ein Beispiel in einer Regexp-Bibliothek oder verwenden Sie ein npm-Modul, z.B. ip-regex.

Blockieren der Ereignisschleife: Node.js-Kernmodule

Mehrere Node.js-Kernmodule verfügen über synchrone, aufwendige APIs, darunter:

Diese APIs sind aufwendig, da sie erhebliche Berechnungen (Verschlüsselung, Komprimierung) erfordern, E/A (Datei-E/A) benötigen oder möglicherweise beides (Child-Prozess). Diese APIs sind für die Scripting-Bequemlichkeit gedacht, aber nicht für den Einsatz im Serverkontext. Wenn Sie sie in der Event-Schleife ausführen, benötigen sie weitaus länger als eine typische JavaScript-Anweisung, um abgeschlossen zu werden, wodurch die Event-Schleife blockiert wird.

In einem Server sollten Sie die folgenden synchronen APIs aus diesen Modulen nicht verwenden:

  • Verschlüsselung:
    • crypto.randomBytes (synchrone Version)
    • crypto.randomFillSync
    • crypto.pbkdf2Sync
    • Sie sollten auch darauf achten, nicht zu große Eingaben an die Verschlüsselungs- und Entschlüsselungsroutinen zu übergeben.
  • Komprimierung:
    • zlib.inflateSync
    • zlib.deflateSync
  • Dateisystem:
    • Verwenden Sie nicht die synchronen Dateisystem-APIs. Wenn sich die Datei, auf die Sie zugreifen, beispielsweise in einem verteilten Dateisystem wie NFS befindet, können die Zugriffszeiten stark variieren.
  • Child-Prozess:
    • child_process.spawnSync
    • child_process.execSync
    • child_process.execFileSync

Diese Liste ist mit Stand von Node.js v9 einigermaßen vollständig.

Blockieren der Event-Schleife: JSON DOS

JSON.parse und JSON.stringify sind weitere potenziell kostspielige Operationen. Obwohl diese in der Länge der Eingabe O(n) sind, können sie für große n überraschend lange dauern.

Wenn Ihr Server JSON-Objekte manipuliert, insbesondere solche von einem Client, sollten Sie vorsichtig mit der Größe der Objekte oder Zeichenketten sein, mit denen Sie in der Event-Schleife arbeiten.

Beispiel: JSON-Blockierung. Wir erstellen ein Objekt obj der Größe 2^21 und JSON.stringify es, führen indexOf auf der Zeichenkette aus und dann JSON.parse es. Die JSON.stringify'd Zeichenkette ist 50 MB groß. Es dauert 0,7 Sekunden, um das Objekt zu stringifizieren, 0,03 Sekunden, um indexOf auf der 50-MB-Zeichenkette auszuführen, und 1,3 Sekunden, um die Zeichenkette zu parsen.

js
let obj = { a: 1 }
let niter = 20
let before, str, pos, res, took
for (let i = 0; i < niter; i++) {
  obj = { obj1: obj, obj2: obj } // Verdoppelt sich in jeder Iteration
}
before = process.hrtime()
str = JSON.stringify(obj)
took = process.hrtime(before)
console.log('JSON.stringify dauerte ' + took)
before = process.hrtime()
pos = str.indexOf('nomatch')
took = process.hrtime(before)
console.log('Reines indexof dauerte ' + took)
before = process.hrtime()
res = JSON.parse(str)
took = process.hrtime(before)
console.log('JSON.parse dauerte ' + took)

Es gibt npm-Module, die asynchrone JSON-APIs anbieten. Siehe zum Beispiel:

  • JSONStream, das Stream-APIs hat.
  • Big-Friendly JSON, das sowohl Stream-APIs als auch asynchrone Versionen der Standard-JSON-APIs mit dem unten beschriebenen Partitionierungs-auf-der-Event-Schleife-Paradigma hat.

Komplexe Berechnungen ohne Blockierung der Event-Schleife

Angenommen, Sie möchten komplexe Berechnungen in JavaScript durchführen, ohne die Event-Schleife zu blockieren. Sie haben zwei Möglichkeiten: Partitionierung oder Auslagerung.

Partitionierung

Sie können Ihre Berechnungen partitionieren, so dass jede in der Event-Schleife ausgeführt wird, aber regelmäßig andere ausstehende Ereignisse abgibt (dreht). In JavaScript ist es einfach, den Zustand einer laufenden Aufgabe in einem Closure zu speichern, wie in Beispiel 2 unten gezeigt.

Nehmen wir als einfaches Beispiel an, Sie möchten den Durchschnitt der Zahlen 1 bis n berechnen.

Beispiel 1: Unpartitionierter Durchschnitt, kostet O(n)

js
for (let i = 0; i < n; i++) sum += i
let avg = sum / n
console.log('avg: ' + avg)

Beispiel 2: Partitionierter Durchschnitt, jeder der n asynchronen Schritte kostet O(1).

js
function asyncAvg(n, avgCB) {
  // Speichern Sie die laufende Summe in JS Closure.
  let sum = 0
  function help(i, cb) {
    sum += i
    if (i == n) {
      cb(sum)
      return
    }
    // "Asynchrone Rekursion".
    // Nächste Operation asynchron planen.
    setImmediate(help.bind(null, i + 1, cb))
  }
  // Starten Sie den Helfer mit CB zum Aufrufen von avgCB.
  help(1, function (sum) {
    let avg = sum / n
    avgCB(avg)
  })
}
asyncAvg(n, function (avg) {
  console.log('avg von 1-n: ' + avg)
})

Sie können dieses Prinzip auf Array-Iterationen und so weiter anwenden.

Auslagerung

Wenn Sie etwas Komplexeres tun müssen, ist die Partitionierung keine gute Option. Das liegt daran, dass die Partitionierung nur die Event-Schleife verwendet und Sie nicht von mehreren Kernen profitieren, die auf Ihrer Maschine mit ziemlicher Sicherheit verfügbar sind. Denken Sie daran, dass die Event-Schleife Clientanfragen orchestrieren und nicht selbst erfüllen sollte. Bei einer komplizierten Aufgabe verschieben Sie die Arbeit von der Event-Schleife in einen Worker-Pool.

Wie man auslagert

Sie haben zwei Optionen für einen Ziel-Worker-Pool, an den Sie Arbeit auslagern können.

  1. Sie können den integrierten Node.js-Worker-Pool verwenden, indem Sie ein C++-Addon entwickeln. Bei älteren Node-Versionen erstellen Sie Ihr C++-Addon mit NAN und bei neueren Versionen mit N-API. node-webworker-threads bietet eine reine JavaScript-Methode, um auf den Node.js-Worker-Pool zuzugreifen.
  2. Sie können Ihren eigenen Worker-Pool erstellen und verwalten, der sich auf die Berechnung konzentriert und nicht auf den Node.js-I/O-orientierten Worker-Pool. Die einfachsten Möglichkeiten hierfür sind Child Process oder Cluster.

Sie sollten nicht einfach für jeden Client einen Child Process erstellen. Sie können Clientanfragen schneller empfangen, als Sie Kinder erstellen und verwalten können, und Ihr Server könnte zu einer Fork-Bombe werden.

Nachteile der Auslagerung Der Nachteil des Auslagerungsansatzes ist, dass er Overhead in Form von Kommunikationskosten verursacht. Nur die Event-Schleife darf den "Namespace" (JavaScript-Zustand) Ihrer Anwendung sehen. Von einem Worker aus können Sie kein JavaScript-Objekt im Namespace der Event-Schleife manipulieren. Stattdessen müssen Sie alle Objekte, die Sie freigeben möchten, serialisieren und deserialisieren. Dann kann der Worker an seiner eigenen Kopie dieser Objekte arbeiten und das modifizierte Objekt (oder einen "Patch") an die Event-Schleife zurückgeben.

Beachten Sie für Serialisierungsbedenken den Abschnitt zu JSON DOS.

Einige Vorschläge zur Auslagerung

Sie sollten zwischen CPU-intensiven und I/O-intensiven Aufgaben unterscheiden, da sie sich in ihren Eigenschaften deutlich unterscheiden.

Eine CPU-intensive Aufgabe macht nur dann Fortschritte, wenn ihr Worker eingeplant ist, und der Worker muss auf einem der logischen Kerne Ihrer Maschine eingeplant werden. Wenn Sie 4 logische Kerne und 5 Worker haben, kann einer dieser Worker keine Fortschritte machen. Infolgedessen zahlen Sie Overhead (Speicher- und Planung Kosten) für diesen Worker und erhalten keine Gegenleistung dafür.

I/O-intensive Aufgaben beinhalten das Abfragen eines externen Dienstanbieters (DNS, Dateisystem usw.) und das Warten auf dessen Antwort. Während ein Worker mit einer I/O-intensiven Aufgabe auf seine Antwort wartet, hat er nichts anderes zu tun und kann vom Betriebssystem descheduliert werden, sodass ein anderer Worker die Möglichkeit hat, seine Anfrage zu senden. Daher werden I/O-intensive Aufgaben Fortschritte machen, auch wenn der zugehörige Thread nicht ausgeführt wird. Externe Dienstanbieter wie Datenbanken und Dateisysteme wurden hochgradig optimiert, um viele ausstehende Anfragen gleichzeitig zu bearbeiten. Beispielsweise untersucht ein Dateisystem eine große Menge ausstehender Schreib- und Leseanfragen, um widersprüchliche Aktualisierungen zusammenzuführen und Dateien in einer optimalen Reihenfolge abzurufen.

Wenn Sie sich nur auf einen Worker-Pool verlassen, z. B. den Node.js-Worker-Pool, können die unterschiedlichen Eigenschaften von CPU-gebundener und I/O-gebundener Arbeit die Leistung Ihrer Anwendung beeinträchtigen.

Aus diesem Grund sollten Sie möglicherweise einen separaten Berechnungs-Worker-Pool verwalten.

Auslagerung: Schlussfolgerungen

Für einfache Aufgaben, wie das Iterieren über die Elemente eines beliebig langen Arrays, könnte die Partitionierung eine gute Option sein. Wenn Ihre Berechnung komplexer ist, ist die Auslagerung ein besserer Ansatz: Die Kommunikationskosten, d.h. der Overhead der Übergabe serialisierter Objekte zwischen der Ereignisschleife und dem Worker-Pool, werden durch den Vorteil der Verwendung mehrerer Kerne ausgeglichen.

Wenn Ihr Server jedoch stark auf komplexen Berechnungen basiert, sollten Sie darüber nachdenken, ob Node.js wirklich gut geeignet ist. Node.js zeichnet sich bei E/A-gebundenen Arbeiten aus, aber für aufwendige Berechnungen ist es möglicherweise nicht die beste Wahl.

Wenn Sie den Ansatz der Auslagerung wählen, beachten Sie den Abschnitt über die Vermeidung der Blockierung des Worker-Pools.

Den Worker-Pool nicht blockieren

Node.js verfügt über einen Worker-Pool, der aus k Workern besteht. Wenn Sie das oben beschriebene Auslagerungsmodell verwenden, haben Sie möglicherweise einen separaten Computational Worker Pool, für den die gleichen Prinzipien gelten. Gehen wir in beiden Fällen davon aus, dass k viel kleiner ist als die Anzahl der Clients, die Sie möglicherweise gleichzeitig bearbeiten. Dies steht im Einklang mit der Philosophie von Node.js "ein Thread für viele Clients", dem Geheimnis seiner Skalierbarkeit.

Wie oben erwähnt, schließt jeder Worker seine aktuelle Aufgabe ab, bevor er mit der nächsten in der Warteschlange des Worker-Pools fortfährt.

Nun wird es Unterschiede in den Kosten der Aufgaben geben, die zur Bearbeitung der Anfragen Ihrer Clients erforderlich sind. Einige Aufgaben können schnell erledigt werden (z. B. das Lesen kurzer oder zwischengespeicherter Dateien oder das Erzeugen einer kleinen Anzahl zufälliger Bytes), während andere länger dauern (z. B. das Lesen größerer oder nicht zwischengespeicherter Dateien oder das Erzeugen von mehr zufälligen Bytes). Ihr Ziel sollte es sein, die Schwankungen der Aufgabenzeiten zu minimieren, und Sie sollten die Aufgabenpartitionierung verwenden, um dies zu erreichen.

Minimierung der Schwankungen der Aufgabenzeiten

Wenn die aktuelle Aufgabe eines Workers viel aufwendiger ist als andere Aufgaben, steht er nicht für die Bearbeitung anderer anstehender Aufgaben zur Verfügung. Mit anderen Worten, jede relativ lange Aufgabe verringert die Größe des Worker-Pools effektiv um eins, bis sie abgeschlossen ist. Dies ist unerwünscht, da bis zu einem gewissen Punkt gilt: Je mehr Worker im Worker-Pool sind, desto höher ist der Durchsatz des Worker-Pools (Aufgaben/Sekunde) und damit desto höher ist der Durchsatz des Servers (Client-Anfragen/Sekunde). Ein Client mit einer relativ aufwendigen Aufgabe verringert den Durchsatz des Worker-Pools und damit auch den Durchsatz des Servers.

Um dies zu vermeiden, sollten Sie versuchen, die Schwankungen in der Länge der Aufgaben, die Sie dem Worker-Pool übermitteln, zu minimieren. Während es angemessen ist, die externen Systeme, auf die Ihre E/A-Anfragen zugreifen (DB, FS usw.), als Black Boxes zu behandeln, sollten Sie sich der relativen Kosten dieser E/A-Anfragen bewusst sein und Anfragen vermeiden, von denen Sie erwarten können, dass sie besonders lange dauern.

Zwei Beispiele sollen die möglichen Schwankungen in den Aufgabenzeiten veranschaulichen.

Variationsbeispiel: Lang andauernde Dateisystemlesevorgänge

Angenommen, Ihr Server muss Dateien lesen, um einige Clientanfragen zu bearbeiten. Nach Konsultation der Node.js Dateisystem-APIs haben Sie sich aus Gründen der Einfachheit für fs.readFile() entschieden. Allerdings ist fs.readFile() (derzeit) nicht partitioniert: Es übermittelt eine einzelne fs.read()-Aufgabe, die die gesamte Datei umfasst. Wenn Sie für einige Benutzer kürzere Dateien und für andere längere Dateien lesen, kann fs.readFile() erhebliche Abweichungen in der Länge der Aufgaben verursachen, was dem Durchsatz des Worker-Pools schadet.

Für ein Worst-Case-Szenario nehmen wir an, dass ein Angreifer Ihren Server dazu bringen kann, eine beliebige Datei zu lesen (dies ist eine Directory-Traversal-Schwachstelle). Wenn Ihr Server unter Linux läuft, kann der Angreifer eine extrem langsame Datei benennen: /dev/random. In der Praxis ist /dev/random unendlich langsam, und jeder Worker, der aufgefordert wird, aus /dev/random zu lesen, wird diese Aufgabe niemals beenden. Ein Angreifer sendet dann k Anfragen, eine für jeden Worker, und keine anderen Clientanfragen, die den Worker-Pool verwenden, werden Fortschritte machen.

Variationsbeispiel: Lang andauernde Kryptooperationen

Angenommen, Ihr Server generiert kryptografisch sichere Zufallsbytes mit crypto.randomBytes(). crypto.randomBytes() ist nicht partitioniert: Es erstellt eine einzelne randomBytes()-Aufgabe, um so viele Bytes zu generieren, wie Sie angefordert haben. Wenn Sie für einige Benutzer weniger Bytes und für andere mehr Bytes erstellen, ist crypto.randomBytes() eine weitere Quelle für Abweichungen in der Länge der Aufgaben.

Aufgabenpartitionierung

Aufgaben mit variablen Zeitkosten können den Durchsatz des Worker-Pools beeinträchtigen. Um die Variation der Aufgabenzeiten so weit wie möglich zu minimieren, sollten Sie jede Aufgabe in vergleichbare Subaufgaben aufteilen. Wenn eine Subaufgabe abgeschlossen ist, sollte sie die nächste Subaufgabe übermitteln, und wenn die letzte Subaufgabe abgeschlossen ist, sollte sie den Absender benachrichtigen.

Um das Beispiel fs.readFile() fortzusetzen, sollten Sie stattdessen fs.read() (manuelle Partitionierung) oder ReadStream (automatisch partitioniert) verwenden.

Das gleiche Prinzip gilt für CPU-gebundene Aufgaben; das asyncAvg-Beispiel ist möglicherweise ungeeignet für die Event-Schleife, aber es ist gut geeignet für den Worker-Pool.

Wenn Sie eine Aufgabe in Subaufgaben aufteilen, werden kürzere Aufgaben in eine kleine Anzahl von Subaufgaben erweitert, und längere Aufgaben werden in eine größere Anzahl von Subaufgaben erweitert. Zwischen jeder Subaufgabe einer längeren Aufgabe kann der Worker, dem sie zugewiesen wurde, an einer Subaufgabe einer anderen, kürzeren Aufgabe arbeiten, wodurch der Gesamtaufgabendurchsatz des Worker-Pools verbessert wird.

Beachten Sie, dass die Anzahl der abgeschlossenen Subaufgaben keine nützliche Metrik für den Durchsatz des Worker-Pools ist. Konzentrieren Sie sich stattdessen auf die Anzahl der abgeschlossenen Aufgaben.

Vermeidung der Aufgabenpartitionierung

Denken Sie daran, dass der Zweck der Aufgabenpartitionierung darin besteht, die Streuung der Aufgabenzeiten zu minimieren. Wenn Sie zwischen kürzeren und längeren Aufgaben unterscheiden können (z. B. das Summieren eines Arrays vs. das Sortieren eines Arrays), können Sie für jede Aufgabenklasse einen eigenen Worker Pool erstellen. Das Weiterleiten kürzerer Aufgaben und längerer Aufgaben an separate Worker Pools ist eine weitere Möglichkeit, die Streuung der Aufgabenzeit zu minimieren.

Für diesen Ansatz spricht, dass die Partitionierung von Aufgaben Overhead verursacht (die Kosten für die Erstellung einer Worker-Pool-Aufgabendarstellung und die Bearbeitung der Worker-Pool-Warteschlange) und das Vermeiden der Partitionierung die Kosten für zusätzliche Fahrten zum Worker Pool spart. Es verhindert auch, dass Sie Fehler bei der Partitionierung Ihrer Aufgaben machen.

Der Nachteil dieses Ansatzes ist, dass die Worker in all diesen Worker Pools Speicher- und Zeitoverhead verursachen und um CPU-Zeit konkurrieren. Denken Sie daran, dass jede CPU-gebundene Aufgabe nur dann Fortschritte macht, wenn sie geplant ist. Daher sollten Sie diesen Ansatz erst nach sorgfältiger Analyse in Betracht ziehen.

Worker Pool: Schlussfolgerungen

Unabhängig davon, ob Sie nur den Node.js Worker Pool verwenden oder separate Worker Pools unterhalten, sollten Sie den Aufgabendurchsatz Ihrer Pools optimieren.

Minimieren Sie dazu die Streuung der Aufgabenzeiten durch die Verwendung der Aufgabenpartitionierung.

Die Risiken von npm-Modulen

Während die Node.js-Kernmodule Bausteine für eine Vielzahl von Anwendungen bieten, wird manchmal etwas mehr benötigt. Node.js-Entwickler profitieren enorm vom npm-Ökosystem mit Hunderttausenden von Modulen, die Funktionen zur Beschleunigung Ihres Entwicklungsprozesses bieten.

Denken Sie jedoch daran, dass die Mehrheit dieser Module von Drittentwicklern geschrieben wurde und im Allgemeinen nur mit den besten Bemühungen veröffentlicht werden. Ein Entwickler, der ein npm-Modul verwendet, sollte sich um zwei Dinge kümmern, obwohl letzteres oft vergessen wird.

  1. Hält es seine APIs ein?
  2. Könnten seine APIs die Event Loop oder einen Worker blockieren? Viele Module unternehmen keine Anstrengungen, um die Kosten ihrer APIs anzugeben, was der Community schadet.

Bei einfachen APIs können Sie die Kosten der APIs abschätzen; die Kosten der Stringmanipulation sind nicht schwer zu verstehen. In vielen Fällen ist jedoch unklar, wie viel eine API kosten könnte.

Wenn Sie eine API aufrufen, die etwas Teures tun könnte, überprüfen Sie die Kosten. Bitten Sie die Entwickler, sie zu dokumentieren, oder untersuchen Sie den Quellcode selbst (und reichen Sie einen PR ein, der die Kosten dokumentiert).

Denken Sie daran, dass Sie selbst wenn die API asynchron ist, nicht wissen, wie viel Zeit sie in jedem ihrer Partitionen in einem Worker oder in der Event Loop verbringen könnte. Nehmen wir zum Beispiel an, dass im oben genannten Beispiel asyncAvg jeder Aufruf der Hilfsfunktion die Hälfte der Zahlen statt einer von ihnen summiert. Dann wäre diese Funktion immer noch asynchron, aber die Kosten jeder Partition wären O(n) und nicht O(1), was die Verwendung für beliebige Werte von n viel weniger sicher macht.

Schlussfolgerung

Node.js hat zwei Arten von Threads: einen Event Loop und k Worker. Der Event Loop ist für JavaScript-Callbacks und nicht-blockierende I/O zuständig, und ein Worker führt Aufgaben aus, die C++-Code entsprechen, der eine asynchrone Anfrage abschließt, einschließlich blockierender I/O und CPU-intensiver Arbeit. Beide Arten von Threads bearbeiten jeweils nicht mehr als eine Aktivität gleichzeitig. Wenn ein Callback oder eine Aufgabe lange dauert, wird der Thread, der ihn ausführt, blockiert. Wenn Ihre Anwendung blockierende Callbacks oder Aufgaben ausführt, kann dies bestenfalls zu einem verringerten Durchsatz (Clients/Sekunde) und schlimmstenfalls zu einem kompletten Denial-of-Service führen.

Um einen Webserver mit hohem Durchsatz und größerem DoS-Schutz zu schreiben, müssen Sie sicherstellen, dass weder Ihr Event Loop noch Ihre Worker bei gutartiger und bei bösartiger Eingabe blockieren.