Tiefgreifendes Verständnis von Worker-Threads in Node.js

Tiefgreifendes Verständnis von Worker-Threads in Node.js

Überblick

Viele Jahre lang war Node.js nicht die beste Wahl für die Implementierung von Anwendungen mit hoher CPU-Intensivität, was hauptsächlich an der Single-Thread-Natur von JavaScript lag. Als Lösung für dieses Problem führte Node.js v10.5.0 das experimentelle Konzept der „Worker-Threads“ über das Modul worker_threads ein, und ab Node.js v12 LTS wurde es zu einer stabilen Funktion. In diesem Artikel wird die Funktionsweise erläutert und wie Sie durch die Verwendung von Worker-Threads die optimale Leistung erzielen.

Die Geschichte der CPU-gebundenen Anwendungen in Node.js

Vor Worker-Threads gab es mehrere Möglichkeiten, CPU-intensive Anwendungen in Node.js auszuführen. Einige davon sind:

  • Verwenden Sie das Modul child_process und führen Sie CPU-intensiven Code in einem untergeordneten Prozess aus
  • Verwenden Sie das Clustermodul, um mehrere CPU-intensive Vorgänge in mehreren Prozessen auszuführen
  • Verwendung von Drittanbietermodulen wie Microsoft Napa.js

Aufgrund von Leistungseinschränkungen, zusätzlicher Komplexität, geringer Akzeptanz und mangelhafter Dokumentation hat sich jedoch keine dieser Lösungen auf breiter Front durchgesetzt.

Verwenden von Arbeitsthreads für CPU-intensive Vorgänge

Obwohl worker_threads eine elegante Lösung für das Parallelitätsproblem von JavaScript darstellt, bringt es keine Multithreading-Funktionen in JavaScript selbst. Im Gegensatz dazu erreicht worker_threads Parallelität, indem Ihre Anwendung unter Verwendung mehrerer isolierter JavaScript-Worker ausgeführt wird, wobei die Kommunikation zwischen den Workern und dem übergeordneten Worker von Node bereitgestellt wird. Sind Sie verwirrt? ‍♂️

In Node.js hat jeder Worker seine eigene V8-Instanz und Ereignisschleife. Aber anders als bei child_process teilen sich Worker den Speicher nicht.

Die oben genannten Konzepte werden später erläutert. Werfen wir zunächst einen kurzen Blick auf die Verwendung von Worker-Threads. Ein naiver Anwendungsfall würde so aussehen:

// arbeiter-simple.js

const {Worker, isMainThread, parentPort, workerData} = erfordern('worker_threads');
wenn (istHauptthread) {
 const worker = neuer Worker(__filename, {workerData: {num: 5}});
 worker.once('Nachricht', (Ergebnis) => {
 console.log('Das Quadrat von 5 ist:', Ergebnis);
 })
} anders {
 parentPort.postMessage(Arbeitnehmerdaten.Anzahl * Arbeitsnehmerdaten.Anzahl)
}

Im obigen Beispiel haben wir jedem einzelnen Arbeiter eine Zahl übergeben, um seinen Quadratwert zu berechnen. Nach der Berechnung sendet der untergeordnete Worker das Ergebnis zurück an den Haupt-Worker-Thread. Trotz seiner scheinbaren Einfachheit kann es für Node.js-Neulinge etwas verwirrend sein.

Wie funktionieren Worker-Threads?

Die JavaScript-Sprache verfügt nicht über Multithreading-Funktionen. Daher verhalten sich Node.js-Worker-Threads anders als herkömmliches Multithreading in vielen anderen höheren Programmiersprachen.

In Node.js besteht die Aufgabe eines Workers darin, einen vom übergeordneten Worker bereitgestellten Codeabschnitt (Worker-Skript) auszuführen. Dieses Worker-Skript wird isoliert von anderen Workern ausgeführt und kann Nachrichten zwischen sich und dem übergeordneten Worker austauschen. Das Worker-Skript kann entweder eine eigenständige Datei oder ein Textskript sein, das von eval analysiert werden kann. In unserem Fall verwenden wir __filename als Worker-Skript, da sich sowohl der übergeordnete als auch der untergeordnete Worker-Code in derselben Skriptdatei befinden und die Eigenschaft isMainThread die Rolle dieser Datei bestimmt.

Jeder Arbeiter ist über einen Nachrichtenkanal mit seinem übergeordneten Arbeiter verbunden. Der untergeordnete Worker kann die Funktion parentPort.postMessage() verwenden, um Nachrichten in den Nachrichtenkanal zu schreiben, und der übergeordnete Worker schreibt Nachrichten in den Nachrichtenkanal, indem er die Funktion worker.postMessage() für die Worker-Instanz aufruft. Schauen Sie sich Abbildung 1 an:

Ein Nachrichtenkanal ist ein einfacher Kommunikationskanal mit zwei Enden, die „Ports“ genannt werden. In der JavaScript/NodeJS-Terminologie werden die beiden Enden eines Nachrichtenkanals als Port1 und Port2 bezeichnet.

Wie arbeiten Node.js-Worker parallel?

Jetzt kommt die entscheidende Frage. JavaScript bietet keine direkte Parallelität. Wie können also zwei Node.js-Worker parallel ausgeführt werden? Die Antwort ist V8-Isolat.

Ein V8-Isolat ist eine separate Instanz der Chrome V8-Laufzeit mit eigenem JS-Stapel und einer Microtask-Warteschlange. Dadurch kann jeder Node.js-Worker seinen JavaScript-Code völlig isoliert von anderen Workern ausführen. Der Nachteil besteht darin, dass Worker nicht direkt auf die Heap-Daten anderer Worker zugreifen können.

Weiterführende Literatur: Wie funktioniert JS in Browsern und Node?

Somit verfügt jeder Worker über seine eigene Kopie der Libuv-Ereignisschleife, unabhängig vom übergeordneten Worker und anderen Workern.

Überschreiten der JS/C++-Grenze

Das Instanziieren eines neuen Workers und die Kommunikation mit übergeordneten/geschwisterlichen JS-Skripten werden alle von der C++-Version des Workers durchgeführt. Zum Zeitpunkt des Schreibens ist die Implementierung worker.cc (https://github.com/nodejs/node/blob/921493e228/src/node_worker.cc).

Worker-Implementierungen werden als JavaScript-Skripte auf Benutzerebene über das Modul worker_threads bereitgestellt. Die JS-Implementierung ist in zwei Skripte aufgeteilt, die ich wie folgt bezeichne:

  • Initialisierungsskript worker.js – Verantwortlich für die Initialisierung der Worker-Instanz und die Herstellung der anfänglichen Kommunikation zwischen übergeordnetem und untergeordnetem Worker, um sicherzustellen, dass die Worker-Metadaten vom übergeordneten Worker an den untergeordneten Worker weitergegeben werden. (https://github.com/nodejs/node/blob/921493e228/lib/internal/worker.js)
  • Führen Sie das Skript worker_thread.js aus – Führen Sie das Worker-JS-Skript des Benutzers entsprechend den vom Benutzer bereitgestellten workerData-Daten und anderen vom übergeordneten Worker bereitgestellten Metadaten aus. (https://github.com/nodejs/node/blob/921493e228/lib/internal/main/worker_thread.js)

Abbildung 2 verdeutlicht diesen Vorgang deutlicher:

Auf dieser Grundlage können wir den Einrichtungsprozess des Mitarbeiters in zwei Phasen unterteilen:

  • Initialisierung des Workers
  • Ausführen des Workers

Schauen wir uns an, was in den einzelnen Phasen passiert:

Initialisierungsschritte

1. Das Skript auf Benutzerebene erstellt eine Worker-Instanz mithilfe von worker_threads

2. Das Initialisierungsskript des übergeordneten Workers des Knotens ruft C++ auf und erstellt ein leeres Worker-Objekt. Zu diesem Zeitpunkt ist der erstellte Worker nur ein einfaches C++-Objekt, das noch nicht gestartet wurde.

3. Wenn das C++-Workerobjekt erstellt wird, generiert es eine Thread-ID und weist sie sich selbst zu

4. Gleichzeitig wird vom übergeordneten Worker ein leerer Initialisierungsnachrichtenkanal (nennen wir ihn IMC) erstellt. Dies wird im grauen Abschnitt „Initialisierungsnachrichtenkanal“ in Abbildung 2 angezeigt.

5. Ein öffentlicher JS-Nachrichtenkanal (PMC genannt) wird vom Worker-Initialisierungsskript erstellt. Dieser Kanal wird von JS auf Benutzerebene verwendet, um Nachrichten zwischen übergeordneten und untergeordneten Workern zu übermitteln. Dieser Teil wird hauptsächlich in Abbildung 1 beschrieben und ist auch in Abbildung 2 rot markiert.

6. Das Initialisierungsskript des übergeordneten Node-Workers ruft C++ auf und schreibt die anfänglichen Metadaten, die an das Worker-Ausführungsskript an den IMC gesendet werden müssen.

Was sind anfängliche Metadaten? Das sind die Daten, die das Skript kennen muss, um den Worker zu starten, einschließlich Skriptname, Worker-Daten, Port2 des PMC und einige andere Informationen.

In unserem Beispiel lauten die Initialisierungsmetadaten wie folgt:

:Telefon: Hey! Der Worker führt das Skript aus. Könnten Sie bitte worker-simple.js mit Worker-Daten wie {num: 5} ausführen? Bitte übergeben Sie ihm auch den Port2 des PMC, damit der Worker Daten vom PMC lesen kann.

Der folgende Ausschnitt zeigt, wie Initialisierungsdaten in den IMC geschrieben werden:

const kPublicPort = Symbol('kPublicPort');
// ...

const { port1, port2 } = neuer MessageChannel();
dies[kPublicPort] = port1;
this[kPublicPort].on('Nachricht', (Nachricht) => this.emit('Nachricht', Nachricht));
// ...

dies[kPort].postMessage({
  Typ: "loadScript",
  Dateiname,
  doEval: !!Optionen.eval,
  cwdCounter: cwdCounter || workerIo.sharedCwdCounter,
  Arbeiterdaten: Optionen.Arbeiterdaten,
  öffentlicherPort: Port2,
  // ...
  hasStdin: !!Optionen.stdin
}, [port2]);

Dieser[kPort] im Code ist der Endpunkt des IMC im Initialisierungsskript. Obwohl das Worker-Initialisierungsskript Daten in den IMC schreibt, kann das Worker-Ausführungsskript nicht auf diese Daten zugreifen.

Schritte ausführen

An diesem Punkt ist die Initialisierung abgeschlossen; als Nächstes ruft das Worker-Initialisierungsskript C++ auf und startet den Worker-Thread.

1. Ein neues V8-Isolat wird erstellt und dem Arbeiter zugewiesen. Wie bereits erwähnt, ist ein „v8-Isolat“ eine separate Instanz der Chrome V8-Laufzeit. Dadurch wird der Ausführungskontext des Arbeitsthreads vom Rest Ihres Anwendungscodes isoliert.

2.libuv wird initialisiert. Dadurch wird sichergestellt, dass der Arbeitsthread seine eigene Ereignisschleife unabhängig vom Rest der Anwendung aufrechterhält.

3. Das Worker-Ausführungsskript wird ausgeführt und die Ereignisschleife des Workers wird gestartet.

4. Der Worker führt das Skript aus, das C++ aufruft, und liest die Initialisierungsmetadaten aus dem IMC.

5. Der Worker führt das Skript aus und führt die entsprechende Datei oder den entsprechenden Code (in unserem Fall worker-simple.js) aus, um als Worker ausgeführt zu werden.

Sehen Sie sich den folgenden Codeausschnitt an, um zu sehen, wie das Worker-Ausführungsskript Daten aus dem IMC liest:

const publicWorker = erfordern('worker_threads');

// ...

port.on('Nachricht', (Nachricht) => {
  wenn (Nachricht.Typ === 'loadScript') {
    Konstante {
      cwdZähler,
      Dateiname,
      bewerte es,
      ArbeiterDaten,
      öffentlicherPort,
      manifestSrc,
      manifestURL,
      hasStdin
    } = Nachricht;

    // ...
    initialisierenCJSLoader();
    initialisierenESMLoader();
    
    publicWorker.parentPort = öffentlicherPort;
    publicWorker.workerData = ArbeiterDaten;

    // ...
    
    port.unref();
    port.postMessage({ Typ: AKTIV_UND_LÄUFT });
    wenn (doEval) {
      const { evalScript } = erfordern('intern/Prozess/Ausführung');
      evalScript('[worker eval]', Dateiname);
    } anders {
      process.argv[1] = Dateiname; // Skriptdateiname
      erfordern('Modul').runMain();
    }
  }
  // ...

Haben Sie im obigen Snippet bemerkt, dass die Eigenschaften workerData und parentPort dem Objekt publicWorker zugewiesen sind? Letzteres wird durch require('worker_threads') im Worker-Ausführungsskript eingeführt.

Aus diesem Grund sind die Eigenschaften workerData und parentPort nur innerhalb des untergeordneten Worker-Threads verfügbar, nicht jedoch im Code des übergeordneten Workers.

Wenn Sie versuchen, auf eine der Eigenschaften im übergeordneten Workercode zuzugreifen, wird null zurückgegeben.

Nutzen Sie die Worker-Threads voll aus

Nachdem wir nun verstehen, wie die Worker-Threads von Node.js funktionieren, können wir bei der Verwendung von Worker-Threads tatsächlich die beste Leistung erzielen. Beim Schreiben von Anwendungen, die komplexer sind als worker-simple.js, müssen zwei Hauptaspekte berücksichtigt werden:

Obwohl Worker-Threads leichter sind als echte Prozesse, kann es dennoch kostspielig sein, Worker häufig mit schwerer Arbeit zu betrauen.

Die Verwendung von Worker-Threads zur Verarbeitung paralleler E/A-Vorgänge ist noch immer nicht kosteneffizient, da der native E/A-Mechanismus von Node.js schneller ist, als für dieselbe Aufgabe einen Worker-Thread von Grund auf neu zu starten.

Um das Problem in Punkt 1 zu lösen, müssen wir einen „Worker-Thread-Pool“ implementieren.

Arbeitsthreadpool

Der Node.js-Worker-Thread-Pool ist eine Reihe von Worker-Threads, die ausgeführt werden und von nachfolgenden Aufgaben verwendet werden können. Wenn eine neue Aufgabe eintrifft, kann sie über den Eltern-Kind-Nachrichtenkanal an einen verfügbaren Mitarbeiter weitergegeben werden. Sobald die Aufgabe abgeschlossen ist, kann der untergeordnete Mitarbeiter dem übergeordneten Mitarbeiter das Ergebnis über denselben Nachrichtenkanal mitteilen.

Bei ordnungsgemäßer Implementierung können Thread-Pools die Leistung erheblich verbessern, indem sie den Aufwand für die Erstellung neuer Threads reduzieren. Außerdem muss berücksichtigt werden, dass die Anzahl der Threads, die effektiv parallel ausgeführt werden können, immer durch die Hardware begrenzt ist. Daher ist es auch unwahrscheinlich, dass die Erstellung einer großen Anzahl von Threads gut funktioniert.

Die folgende Abbildung ist ein Leistungsvergleich von drei Node.js-Servern, die alle eine Zeichenfolge empfangen und einen Bcrypt-Hash mit 12 Salting-Runden zurückgeben. Die drei Server sind:

  • Kein Multithreading
  • Multithreading, kein Thread-Pool
  • Ein Threadpool mit 4 Threads

Auf den ersten Blick ist erkennbar, dass die Verwendung eines Thread-Pools bei steigender Last einen deutlich geringeren Overhead verursacht.

Zum Zeitpunkt des Schreibens dieses Artikels sind Thread-Pools jedoch kein standardmäßiges natives Feature von Node.js. Daher müssen Sie sich weiterhin auf Implementierungen von Drittanbietern verlassen oder Ihren eigenen Worker-Pool schreiben.

Hoffentlich haben Sie jetzt ein gutes Verständnis für die Funktionsweise von Arbeitsthreads und können mit dem Experimentieren und der Nutzung von Arbeitsthreads zum Schreiben Ihrer CPU-gebundenen Anwendungen beginnen.

Das Obige ist der detaillierte Inhalt für ein vertieftes Verständnis von Worker-Threads in Node.js. Weitere Informationen zu Node.js finden Sie in den anderen verwandten Artikeln auf 123WORDPRESS.COM!

Das könnte Sie auch interessieren:
  • Javascript Web Worker mit Prozessanalyse
  • Detaillierte Erklärung von Yii2 kombiniert mit Workermans WebSocket-Beispiel
  • Forschung zur Web Worker Multithreading API in JavaScript
  • Detailliertes Beispiel für sharedWorker in JavaScript zur Realisierung einer mehrseitigen Kommunikation
  • So verwenden Sie worker_threads zum Erstellen neuer Threads in nodejs
  • Codebeispiel für einen Javascript Worker-Sub-Thread
  • Grundlegendes zur Worker-Event-API in JavaScript
  • So verwenden Sie webWorker in JS

<<:  Lernen Sie die MySQL-Zeichensatzeinstellungen in 5 Minuten kennen

>>:  So verschieben Sie ein rotes Rechteck mit der Maus im Linux-Zeichenterminal

Artikel empfehlen

So verwenden Sie Vue3-Mixin

Inhaltsverzeichnis 1. Wie verwende ich Mixin? 2. ...

Grafisches Tutorial zur Installation und Konfiguration von MySQL 5.7.17 winx64

Ich habe die vorherigen Hinweise zur Installation...

Native JS realisiert einheitliche Bewegungen verschiedener Sportarten

In diesem Artikel wird eine einheitliche Bewegung...

20 hervorragende Beispiele für die Farbabstimmung auf ausländischen Webseiten

In diesem Artikel werden 20 hervorragende Beispiel...

Zusammenfassung der CSS-Methoden zum Löschen von Floats

Float wird häufig im Layout von Webseiten verwend...

Detailliertes Installations- und Deinstallationstutorial für MySQL 8.0.12

1. Installationsschritte für MySQL-Version 8.0.12...

Mit CSS3 implementierte Schaltfläche zum Hovern von Bildern

Ergebnis:Implementierungscode html <ul Klasse=...

Detaillierte Erklärung des Sidecar-Modus in Docker Compose

Inhaltsverzeichnis Was ist Docker Compose Anforde...

Vue-Routing - Methode zum Sprung relativer Pfade

Inhaltsverzeichnis Relativer Pfadsprung im Vue-Ro...