Hoe Multithreading te Gebruiken in Node.js

De auteur heeft Open Sourcing Mental Illness geselecteerd om een donatie te ontvangen als onderdeel van het Write for DOnations-programma.

Introductie

Node.js voert JavaScript-code uit in een enkele thread, wat betekent dat je code slechts één taak tegelijk kan uitvoeren. Node.js zelf is echter multithreaded en biedt verborgen threads via de libuv-bibliotheek, die I/O-operaties afhandelt zoals het lezen van bestanden van een schijf of netwerkaanvragen. Door het gebruik van verborgen threads biedt Node.js asynchrone methoden waarmee je code I/O-verzoeken kan maken zonder de hoofdthread te blokkeren.

Hoewel Node.js verborgen threads heeft, kan je ze niet gebruiken om CPU-intensieve taken, zoals complexe berekeningen, het wijzigen van afbeeldingen of videocompressie, te verwerken. Aangezien JavaScript single-threaded is, wanneer een CPU-intensieve taak wordt uitgevoerd, blokkeert deze de hoofdthread en wordt er geen andere code uitgevoerd totdat de taak is voltooid. Zonder het gebruik van andere threads is de enige manier om een CPU-gebonden taak te versnellen door de processorsnelheid te verhogen.

Echter, in de afgelopen jaren zijn CPU’s niet sneller geworden. In plaats daarvan worden computers geleverd met extra kernen, en het is nu gebruikelijker dat computers 8 of meer kernen hebben. Ondanks deze trend zal je code geen gebruik maken van de extra kernen op je computer om CPU-gebonden taken te versnellen of te voorkomen dat de hoofdthread wordt onderbroken omdat JavaScript single-threaded is.

Om dit te verhelpen, introduceerde Node.js de worker-threads-module, waarmee je threads kunt maken en meerdere JavaScript-taken parallel kunt uitvoeren. Zodra een thread een taak voltooit, stuurt het een bericht naar de hoofdthread met het resultaat van de bewerking, zodat het kan worden gebruikt met andere delen van de code. Het voordeel van het gebruik van worker threads is dat CPU-gebonden taken de hoofdthread niet blokkeren en je een taak kunt verdelen en verdelen over meerdere workers om deze te optimaliseren.

In deze zelfstudie maak je een Node.js-app met een CPU-intensieve taak die de hoofdthread blokkeert. Vervolgens zal je de worker-threads-module gebruiken om de CPU-intensieve taak naar een andere thread te verplaatsen om te voorkomen dat de hoofdthread wordt geblokkeerd. Ten slotte zal je de CPU-gebonden taak verdelen en vier threads laten werken om deze parallel te versnellen.

Vereisten

Om deze zelfstudie te voltooien, heb je nodig:

Het opzetten van het project en het installeren van afhankelijkheden

In deze stap maak je de projectmap aan, initialiseer je npm, en installeer je alle benodigde afhankelijkheden.

Om te beginnen, maak en ga naar de projectmap:

  1. mkdir multi-threading_demo
  2. cd multi-threading_demo

De mkdir opdracht maakt een map aan en de cd opdracht verandert de werkmap naar de zojuist aangemaakte.

Vervolgens initialiseer je de projectmap met npm met behulp van de npm init opdracht:

  1. npm init -y

De -y optie accepteert alle standaardopties.

Als de opdracht wordt uitgevoerd, ziet je uitvoer er ongeveer zo uit:

Wrote to /home/sammy/multi-threading_demo/package.json:

{
  "name": "multi-threading_demo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

Vervolgens installeer je express, een Node.js webframework:

  1. npm install express

Je zult Express gebruiken om een servertoepassing te maken die zowel blokkerende als niet-blokkerende eindpunten heeft.

Node.js wordt standaard geleverd met de worker-threads module, dus je hoeft deze niet te installeren.

Je hebt nu de benodigde pakketten geïnstalleerd. Hierna leer je meer over processen en threads en hoe ze worden uitgevoerd op een computer.

Processen en Threads Begrijpen

Voordat je begint met het schrijven van CPU-gebonden taken en deze naar afzonderlijke threads verplaatst, moet je eerst begrijpen wat processen en threads zijn, en wat de verschillen tussen hen zijn. Belangrijker nog, je zult bekijken hoe de processen en threads worden uitgevoerd op een computer met één of meerdere kernen.

Proces

A process is a running program in the operating system. It has its own memory and cannot see nor access the memory of other running programs. It also has an instruction pointer, which indicates the instruction currently being executed in a program. Only one task can be executed at a time.

Om dit te begrijpen, zal je een Node.js-programma maken met een oneindige lus zodat het niet wordt afgesloten wanneer het wordt uitgevoerd.

Gebruik nano, of je favoriete teksteditor, om het bestand process.js te maken en te openen:

  1. nano process.js

In je bestand process.js voer je de volgende code in:

multi-threading_demo/process.js
const process_name = process.argv.slice(2)[0];

count = 0;
while (true) {
  count++;
  if (count == 2000 || count == 4000) {
    console.log(`${process_name}: ${count}`);
  }
}

In de eerste regel retourneert de process.argv-eigenschap een array met de programmacommando-regelargumenten. Vervolgens voeg je de JavaScript-methode slice() toe met een argument van 2 om een oppervlakkige kopie van de array te maken vanaf index 2. Hierdoor worden de eerste twee argumenten overgeslagen, namelijk het Node.js-pad en de bestandsnaam van het programma. Vervolgens gebruik je de haakjesnotatiesyntax om het eerste argument uit de gesneden array op te halen en op te slaan in de variabele process_name.

Daarna definieer je een while-lus en geef je deze een true-voorwaarde om de lus voor altijd te laten lopen. Binnen de lus wordt de telling-variabele tijdens elke iteratie met 1 verhoogd. Daarna volgt een if-verklaring die controleert of telling gelijk is aan 2000 of 4000. Als de voorwaarde waar is, registreert de console.log()-methode een bericht in de terminal.

Sla je bestand op en sluit het af met CTRL+X, druk vervolgens op Y om de wijzigingen op te slaan.

Voer het programma uit met het node-commando:

  1. node process.js A &

A is a command-line argument that is passed to the program and stored in the process_name variable. The & at end the allows the Node program to run in the background, which lets you enter more commands in the shell.

Wanneer je het programma uitvoert, zie je een uitvoer vergelijkbaar met het volgende:

Output
[1] 7754 A: 2000 A: 4000

Het nummer 7754 is een proces-ID dat door het besturingssysteem aan het programma is toegewezen. A: 2000 en A: 4000 zijn de uitvoer van het programma.

Wanneer je een programma uitvoert met het node-commando, creëer je een proces. Het besturingssysteem wijst geheugen toe voor het programma, zoekt het uitvoerbare programma op de schijf van je computer en laadt het programma in het geheugen. Vervolgens wijst het een proces-ID toe en begint het programma uit te voeren. Op dat moment is je programma nu een proces geworden.

Wanneer het proces wordt uitgevoerd, wordt de proces-ID toegevoegd aan de proceslijst van het besturingssysteem en kan deze worden gezien met tools zoals htop, top of ps. De tools bieden meer details over de processen, evenals opties om ze te stoppen of prioriteit te geven.

Om een snelle samenvatting van een Node-proces te krijgen, drukt u op ENTER in uw terminal om de prompt terug te krijgen. Voer vervolgens het ps commando uit om de Node-processen te zien:

  1. ps |grep node

Het ps commando geeft een lijst weer van alle processen die zijn gekoppeld aan de huidige gebruiker op het systeem. De pijpoperator | wordt gebruikt om alle uitvoer van ps door te geven aan de grep om alleen Node-processen weer te geven.

Het uitvoeren van het commando zal uitvoer opleveren die er ongeveer als volgt uitziet:

Output
7754 pts/0 00:21:49 node

U kunt talloze processen maken vanuit een enkel programma. Gebruik bijvoorbeeld het volgende commando om drie extra processen te maken met verschillende argumenten en plaats ze in de achtergrond:

  1. node process.js B & node process.js C & node process.js D &

In het commando hebt u drie extra exemplaren van het process.js programma gemaakt. Het symbool & plaatst elk proces in de achtergrond.

Na het uitvoeren van het commando ziet de uitvoer er ongeveer als volgt uit (hoewel de volgorde kan verschillen):

Output
[2] 7821 [3] 7822 [4] 7823 D: 2000 D: 4000 B: 2000 B: 4000 C: 2000 C: 4000

Zoals u kunt zien in de uitvoer, heeft elk proces de procesnaam gelogd in de terminal toen de telling 2000 en 4000 bereikte. Elk proces is niet op de hoogte van andere processen die worden uitgevoerd: proces D is niet op de hoogte van proces C, en vice versa. Alles wat er gebeurt in een van beide processen zal geen invloed hebben op andere Node.js processen.

Als je de output nauwkeurig bekijkt, zul je zien dat de volgorde van de output niet dezelfde volgorde is als toen je de drie processen creëerde. Bij het uitvoeren van het commando waren de argumenten van de processen in volgorde van B, C en D. Maar nu is de volgorde D, B en C. De reden hiervoor is dat het besturingssysteem planningsalgoritmen heeft die bepalen welk proces op een gegeven moment op de CPU wordt uitgevoerd.

Op een machine met één kern voeren de processen gelijktijdig uit. Dat wil zeggen, het besturingssysteem schakelt regelmatig tussen de processen. Bijvoorbeeld, proces D wordt voor een beperkte tijd uitgevoerd, vervolgens wordt de toestand ergens opgeslagen en plant het besturingssysteem proces B om voor een beperkte tijd uit te voeren, enzovoort. Dit gebeurt heen en weer totdat alle taken zijn voltooid. Vanuit de output lijkt het misschien alsof elk proces tot voltooiing is uitgevoerd, maar in werkelijkheid schakelt de OS-planner voortdurend tussen hen.

Op een multi-core systeem – als je bijvoorbeeld vier kernen hebt – plant het besturingssysteem elk proces om op elke kern tegelijkertijd uit te voeren. Dit staat bekend als parallelisme. Echter, als je vier extra processen creëert (waardoor het totaal op acht komt), voert elke kern twee processen tegelijkertijd uit totdat ze zijn voltooid.

Threads

Threads zijn vergelijkbaar met processen: ze hebben hun eigen instructiepointer en kunnen één JavaScript-taak tegelijk uitvoeren. In tegenstelling tot processen hebben threads echter geen eigen geheugen. In plaats daarvan bevinden ze zich binnen het geheugen van een proces. Wanneer je een proces creëert, kan het meerdere threads hebben die zijn gemaakt met de worker_threads-module die JavaScript-code parallel uitvoeren. Bovendien kunnen threads met elkaar communiceren door berichten door te geven of gegevens te delen in het geheugen van het proces. Dit maakt ze lichtgewicht in vergelijking met processen, aangezien het starten van een thread geen extra geheugen van het besturingssysteem vraagt.

Wat betreft de uitvoering van threads, vertonen ze vergelijkbaar gedrag als processen. Als je meerdere threads hebt die op een enkele core draaien, zal het besturingssysteem tussen hen schakelen in regelmatige intervallen, waarbij elke thread de kans krijgt om rechtstreeks op de enkele CPU uit te voeren. Op een multi-core systeem plant het OS de threads over alle cores en voert tegelijkertijd de JavaScript-code uit. Als je uiteindelijk meer threads creëert dan er beschikbare cores zijn, zal elke core meerdere threads tegelijk uitvoeren.

Daarmee, druk op ENTER, stop vervolgens alle momenteel draaiende Node-processen met het kill-commando:

  1. sudo kill -9 `pgrep node`

pgrep geeft de proces-ID’s van alle vier Node-processen terug aan het kill-commando. De -9-optie instrueert kill om een SIGKILL-signaal te sturen.

Wanneer je het commando uitvoert, zie je een uitvoer vergelijkbaar met het volgende:

Output
[1] Killed node process.js A [2] Killed node process.js B [3] Killed node process.js C [4] Killed node process.js D

Soms kan de output vertraagd zijn en verschijnen wanneer u later een andere opdracht uitvoert.

Nu je het verschil kent tussen een proces en een thread, zul je werken met verborgen threads in Node.js in de volgende sectie.

Begrip van verborgen threads in Node.js

Node.js biedt wel extra threads, daarom wordt het beschouwd als multithreaded. In deze sectie zul je verborgen threads in Node.js onderzoeken, die helpen bij het maken van niet-blokkerende I/O-operaties.

Zoals vermeld in de inleiding, is JavaScript single-threaded en wordt alle JavaScript-code uitgevoerd in een enkele thread. Dit omvat uw programma-broncode en third-party bibliotheken die u in uw programma opneemt. Wanneer een programma een I/O-operatie uitvoert om een bestand te lezen of een netwerkverzoek te doen, blokkeert dit de hoofdthread.

Echter implementeert Node.js de `libuv`-bibliotheek, die vier extra threads aan een Node.js-proces toevoegt. Met deze threads worden de I/O-operaties apart afgehandeld en wanneer ze zijn voltooid, voegt de event loop de callback die is gekoppeld aan de I/O-taak toe aan een microtask-queue. Wanneer de call stack in de hoofdthread leeg is, wordt de callback op de call stack geplaatst en vervolgens uitgevoerd. Om dit duidelijk te maken, wordt de callback die is gekoppeld aan de gegeven I/O-taak niet parallel uitgevoerd; echter, de taak zelf van het lezen van een bestand of een netwerkverzoek gebeurt parallel met behulp van de threads. Zodra de I/O-taak is voltooid, wordt de callback uitgevoerd in de hoofdthread.

Naast deze vier threads biedt de `V8-engine` ook twee threads voor het afhandelen van zaken zoals automatische garbage collection. Dit brengt het totale aantal threads in een proces op zeven: één hoofdthread, vier Node.js-threads en twee V8-threads.

Om te bevestigen dat elk Node.js-proces zeven threads heeft, voert u het bestand `process.js` opnieuw uit en plaatst u het in de achtergrond:

  1. node process.js A &

De terminal zal het proces-ID loggen, evenals de uitvoer van het programma:

Output
[1] 9933 A: 2000 A: 4000

Noteer ergens het proces-ID en druk op `ENTER` zodat u de prompt opnieuw kunt gebruiken.

Om de threads te bekijken, voert u het `top`-commando uit en geeft u het het proces-ID dat wordt weergegeven in de uitvoer:

  1. top -H -p 9933

`-H` geeft `top` de opdracht om threads in een proces weer te geven. De `-p` vlag geeft `top` de opdracht om alleen de activiteit in het opgegeven proces-ID te controleren.

Wanneer u het commando uitvoert, ziet uw uitvoer er ongeveer als volgt uit:

Output
top - 09:21:11 up 15:00, 1 user, load average: 0.99, 0.60, 0.26 Threads: 7 total, 1 running, 6 sleeping, 0 stopped, 0 zombie %Cpu(s): 24.8 us, 0.3 sy, 0.0 ni, 75.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st MiB Mem : 7951.2 total, 6756.1 free, 248.4 used, 946.7 buff/cache MiB Swap: 0.0 total, 0.0 free, 0.0 used. 7457.4 avail Mem PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 9933 node-us+ 20 0 597936 51864 33956 R 99.9 0.6 4:19.64 node 9934 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.00 node 9935 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.84 node 9936 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.83 node 9937 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.93 node 9938 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.83 node 9939 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.00 node

Zoals je kunt zien in de uitvoer heeft het Node.js-proces in totaal zeven threads: één hoofdthread voor het uitvoeren van JavaScript, vier Node.js-threads en twee V8-threads.

Zoals eerder besproken, worden de vier Node.js-threads gebruikt voor I/O-bewerkingen om ze niet-blokkerend te maken. Ze zijn daar goed voor geschikt, en het zelf maken van threads voor I/O-bewerkingen kan zelfs de prestaties van je applicatie verslechteren. Hetzelfde kan niet gezegd worden voor CPU-gebonden taken. Een CPU-gebonden taak maakt geen gebruik van extra threads die beschikbaar zijn in het proces en blokkeert de hoofdthread.

Druk nu op q om top af te sluiten en stop het Node-proces met de volgende opdracht:

  1. kill -9 9933

Nu je meer weet over de threads in een Node.js-proces, ga je in de volgende sectie een CPU-gebonden taak schrijven en observeren hoe dit de hoofdthread beïnvloedt.

Het maken van een CPU-gebonden taak zonder worker-threads

In deze sectie ga je een Express-applicatie bouwen die een niet-blokkerende route heeft en een blokkerende route die een CPU-gebonden taak uitvoert.

Open eerst index.js in je favoriete editor:

  1. nano index.js

Voeg in je index.js-bestand de volgende code toe om een basisserver te maken:

multi-threading_demo/index.js
const express = require("express");

const app = express();
const port = process.env.PORT || 3000;

app.get("/non-blocking/", (req, res) => {
  res.status(200).send("This page is non-blocking");
});

app.listen(port, () => {
  console.log(`App listening on port ${port}`);
});

In het volgende codeblok maak je een HTTP-server met behulp van Express. In de eerste regel importeer je de express-module. Vervolgens stel je de app-variabele in om een instantie van Express te bevatten. Daarna definieer je de port-variabele, die het poortnummer bevat waarop de server moet luisteren.

Hierna gebruik je app.get('/non-blocking') om de route te definiëren waarop GET-verzoeken moeten worden verzonden. Ten slotte roep je de app.listen()-methode aan om de server te instrueren te starten met luisteren op poort 3000.

Definieer vervolgens een andere route, /blocking/, die een CPU-intensieve taak zal bevatten:

multi-threading_demo/index.js
...
app.get("/blocking", async (req, res) => {
  let counter = 0;
  for (let i = 0; i < 20_000_000_000; i++) {
    counter++;
  }
  res.status(200).send(`result is ${counter}`);
});

app.listen(port, () => {
  console.log(`App listening on port ${port}`);
});

Je definieert de /blocking-route met behulp van app.get("/blocking"), die een asynchrone callback aanneemt met het async-trefwoord als tweede argument dat een CPU-intensieve taak uitvoert. Binnen de callback maak je een for-lus die 20 miljard keer iteraties doorloopt en tijdens elke iteratie wordt de counter-variabele met 1 verhoogd. Deze taak wordt uitgevoerd op de CPU en zal enkele seconden duren om te voltooien.

Op dit punt zal je index.js-bestand er als volgt uitzien:

multi-threading_demo/index.js
const express = require("express");

const app = express();
const port = process.env.PORT || 3000;

app.get("/non-blocking/", (req, res) => {
  res.status(200).send("This page is non-blocking");
});

app.get("/blocking", async (req, res) => {
  let counter = 0;
  for (let i = 0; i < 20_000_000_000; i++) {
    counter++;
  }
  res.status(200).send(`result is ${counter}`);
});

app.listen(port, () => {
  console.log(`App listening on port ${port}`);
});

Sla je bestand op en sluit het af, start vervolgens de server met de volgende opdracht:

  1. node index.js

Wanneer je de opdracht uitvoert, zie je een output vergelijkbaar met het volgende:

Output
App listening on port 3000

Dit toont aan dat de server draait en klaar is om te bedienen.

Bezoek nu http://localhost:3000/non-blocking in je favoriete browser. Je ziet direct een reactie met de boodschap This page is non-blocking.

Opmerking: Als u de handleiding volgt op een externe server, kunt u poortdoorsturing gebruiken om de app in de browser te testen.

Terwijl de Express-server nog steeds actief is, opent u een andere terminal op uw lokale computer en voert u het volgende commando in:

  1. ssh -L 3000:localhost:3000 your-non-root-user@yourserver-ip

Nadat u verbinding hebt gemaakt met de server, gaat u naar http://localhost:3000/non-blocking in de webbrowser van uw lokale machine. Houd de tweede terminal open gedurende de rest van deze handleiding.

Open vervolgens een nieuw tabblad en ga naar http://localhost:3000/blocking. Terwijl de pagina wordt geladen, opent u snel nog twee tabbladen en gaat u opnieuw naar http://localhost:3000/non-blocking. U zult merken dat u geen onmiddellijke reactie krijgt en dat de pagina’s blijven proberen te laden. Pas wanneer de /blocking-route is geladen en een reactie teruggeeft result is 20000000000, zullen de andere routes een reactie teruggeven.

De reden waarom alle /non-blocking-routes niet werken terwijl de /blocking-route wordt geladen, is vanwege de CPU-gebonden for-lus, die de hoofdthread blokkeert. Wanneer de hoofdthread geblokkeerd is, kan Node.js geen verzoeken verwerken totdat de CPU-gebonden taak is voltooid. Dus als uw applicatie duizenden gelijktijdige GET-verzoeken heeft naar de /non-blocking-route, is slechts één bezoek aan de /blocking-route voldoende om alle routes van de applicatie niet-responsief te maken.

Zoals je kunt zien, kan het blokkeren van de hoofdthread de gebruikerservaring met je app schaden. Om dit probleem op te lossen, moet je de CPU-gebonden taak overzetten naar een andere thread, zodat de hoofdthread andere HTTP-verzoeken kan blijven verwerken.

Daarmee stop je de server door op CTRL+C te drukken. Je zult de server opnieuw starten in de volgende sectie nadat je meer wijzigingen hebt aangebracht in het index.js bestand. De reden waarom de server wordt gestopt, is dat Node.js niet automatisch wordt vernieuwd wanneer er nieuwe wijzigingen in het bestand worden aangebracht.

Nu je begrijpt welke negatieve impact een CPU-intensieve taak kan hebben op je applicatie, zul je proberen om het blokkeren van de hoofdthread te vermijden door gebruik te maken van promises.

Het Overzetten van een CPU-Gebonden Taak met Promises

Vaak, wanneer ontwikkelaars leren over het blokkerende effect van CPU-gebonden taken, grijpen ze naar promises om de code niet-blokkerend te maken. Deze reactie komt voort uit de kennis van het gebruik van niet-blokkerende op promises gebaseerde I/O-methoden, zoals readFile() en writeFile(). Maar zoals je hebt geleerd, maken de I/O-operaties gebruik van verborgen threads in Node.js, wat CPU-gebonden taken niet doen. Desalniettemin zul je in deze sectie de CPU-gebonden taak omwikkelen in een promise in een poging om deze niet-blokkerend te maken. Het zal niet werken, maar het zal je helpen om de waarde te zien van het gebruik van worker threads, wat je in de volgende sectie zult doen.

Open opnieuw het index.js bestand in je editor:

  1. nano index.js

In uw index.js bestand, verwijder de gemarkeerde code met de CPU-intensieve taak:

multi-threading_demo/index.js
...
app.get("/blocking", async (req, res) => {
  let counter = 0;
  for (let i = 0; i < 20_000_000_000; i++) {
    counter++;
  }
  res.status(200).send(`result is ${counter}`);
});
...

Vervolgens, voeg de volgende gemarkeerde code toe die een functie bevat die een belofte retourneert:

multi-threading_demo/index.js
...
function calculateCount() {
  return new Promise((resolve, reject) => {
    let counter = 0;
    for (let i = 0; i < 20_000_000_000; i++) {
      counter++;
    }
    resolve(counter);
  });
}

app.get("/blocking", async (req, res) => {
  res.status(200).send(`result is ${counter}`);
}

De calculateCount() functie bevat nu de berekeningen die u had in de /blocking handler functie. De functie retourneert een belofte, die wordt geïnitialiseerd met de new Promise syntaxis. De belofte neemt een callback aan met resolve en reject parameters, die succes of falen behandelen. Wanneer de for lus klaar is met lopen, wordt de belofte opgelost met de waarde in de counter variabele.

Vervolgens, roep de calculateCount() functie aan in de /blocking/ handler functie in het index.js bestand:

multi-threading_demo/index.js
app.get("/blocking", async (req, res) => {
  const counter = await calculateCount();
  res.status(200).send(`result is ${counter}`);
});

Hier roept u de calculateCount() functie aan met het await trefwoord voorafgegaan om te wachten tot de belofte is opgelost. Zodra de belofte is opgelost, wordt de counter variabele ingesteld op de opgeloste waarde.

Uw volledige code zal er nu als volgt uitzien:

multi-threading_demo/index.js
const express = require("express");

const app = express();
const port = process.env.PORT || 3000;

app.get("/non-blocking/", (req, res) => {
  res.status(200).send("This page is non-blocking");
});

function calculateCount() {
  return new Promise((resolve, reject) => {
    let counter = 0;
    for (let i = 0; i < 20_000_000_000; i++) {
      counter++;
    }
    resolve(counter);
  });
}

app.get("/blocking", async (req, res) => {
  const counter = await calculateCount();
  res.status(200).send(`result is ${counter}`);
});

app.listen(port, () => {
  console.log(`App listening on port ${port}`);
});

Sla uw bestand op en sluit het, start vervolgens de server opnieuw:

  1. node index.js

In uw webbrowser, bezoek http://localhost:3000/blocking en terwijl het laadt, herlaad snel de tabbladen http://localhost:3000/non-blocking. Zoals u zult merken, worden de non-blocking routes nog steeds beïnvloed en zullen ze allemaal wachten tot de /blocking route klaar is met laden. Omdat de routes nog steeds beïnvloed zijn, maken beloftes JavaScript-code niet parallel uitvoeren en kunnen ze niet worden gebruikt om CPU-gebonden taken niet-blokkerend te maken.

Met dat, stop de toepassingsserver met CTRL+C.

Nu je weet dat promises geen enkel mechanisme bieden om CPU-gebonden taken niet-blokkerend te maken, zul je de Node.js worker-threads-module gebruiken om een CPU-gebonden taak te verplaatsen naar een aparte thread.

Het verplaatsen van een CPU-gebonden taak met de worker-threads-module

In dit gedeelte zul je een CPU-intensieve taak verplaatsen naar een andere thread met behulp van de worker-threads-module om te voorkomen dat de hoofdthread wordt geblokkeerd. Hiervoor maak je een bestand worker.js aan dat de CPU-intensieve taak zal bevatten. In het bestand index.js gebruik je de worker-threads-module om de thread te initialiseren en de taak in het bestand worker.js parallel aan de hoofdthread uit te voeren. Zodra de taak is voltooid, zal de werkerthread een bericht met het resultaat terugsturen naar de hoofdthread.

Om te beginnen, controleer of je 2 of meer cores hebt met behulp van het nproc-commando:

  1. nproc
Output
4

Als het twee of meer cores toont, kun je doorgaan met deze stap.

Vervolgens maak en open je het bestand worker.js in je teksteditor:

  1. nano worker.js

In je bestand worker.js voeg je de volgende code toe om de worker-threads-module te importeren en de CPU-intensieve taak uit te voeren:

multi-threading_demo/worker.js
const { parentPort } = require("worker_threads");

let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
  counter++;
}

De eerste regel laadt de module worker_threads en haalt de klasse parentPort eruit. De klasse biedt methoden die je kunt gebruiken om berichten naar de hoofdthread te sturen. Vervolgens heb je de CPU-intensieve taak die momenteel in de functie calculateCount() in het bestand index.js staat. Later in deze stap, zul je deze functie verwijderen uit index.js.

Hierna voeg je de gemarkeerde code hieronder toe:

multi-threading_demo/worker.js
const { parentPort } = require("worker_threads");

let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
  counter++;
}

parentPort.postMessage(counter);

Hier roep je de methode postMessage() aan van de klasse parentPort, die een bericht naar de hoofdthread stuurt met het resultaat van de CPU-gebonden taak opgeslagen in de variabele counter.

Sla je bestand op en sluit het af. Open index.js in je teksteditor:

  1. nano index.js

Aangezien je de CPU-gebonden taak al hebt in worker.js, verwijder je de gemarkeerde code uit index.js:

multi-threading_demo/index.js
const express = require("express");

const app = express();
const port = process.env.PORT || 3000;

app.get("/non-blocking/", (req, res) => {
  res.status(200).send("This page is non-blocking");
});

function calculateCount() {
  return new Promise((resolve, reject) => {
    let counter = 0;
    for (let i = 0; i < 20_000_000_000; i++) {
      counter++;
    }
    resolve(counter);
  });
}

app.get("/blocking", async (req, res) => {
  const counter = await calculateCount();
  res.status(200).send(`result is ${counter}`);
});

app.listen(port, () => {
  console.log(`App listening on port ${port}`);
});

Vervolgens, in de callback van app.get("/blocking"), voeg je de volgende code toe om de thread te initialiseren:

multi-threading_demo/index.js
const express = require("express");
const { Worker } = require("worker_threads");
...
app.get("/blocking", async (req, res) => {
  const worker = new Worker("./worker.js");
  worker.on("message", (data) => {
    res.status(200).send(`result is ${data}`);
  });
  worker.on("error", (msg) => {
    res.status(404).send(`An error occurred: ${msg}`);
  });
});
...

Eerst importeer je de module worker_threads en pak je de klasse Worker uit. Binnen de callback van app.get("/blocking"), maak je een instantie van Worker met het new-trefwoord gevolgd door een oproep naar Worker met het pad naar het bestand worker.js als argument. Dit maakt een nieuwe thread aan en de code in het bestand worker.js begint uit te voeren in de thread op een ander kern.

Volg hierop door een gebeurtenis aan de worker-instantie te koppelen met behulp van de on("message")-methode om te luisteren naar het berichtgebeurtenis. Wanneer het bericht wordt ontvangen dat het resultaat bevat van het bestand worker.js, wordt het doorgegeven als een parameter aan de callback van de methode, die een antwoord teruggeeft aan de gebruiker met het resultaat van de CPU-gebonden taak.

Vervolgens koppel je een andere gebeurtenis aan de worker-instantie met behulp van de on("error")-methode om te luisteren naar de foutgebeurtenis. Als er een fout optreedt, geeft de callback een 404-reactie terug met de foutmelding naar de gebruiker.

Je complete bestand zal er nu als volgt uitzien:

multi-threading_demo/index.js
const express = require("express");
const { Worker } = require("worker_threads");

const app = express();
const port = process.env.PORT || 3000;

app.get("/non-blocking/", (req, res) => {
  res.status(200).send("This page is non-blocking");
});

app.get("/blocking", async (req, res) => {
  const worker = new Worker("./worker.js");
  worker.on("message", (data) => {
    res.status(200).send(`result is ${data}`);
  });
  worker.on("error", (msg) => {
    res.status(404).send(`An error occurred: ${msg}`);
  });
});

app.listen(port, () => {
  console.log(`App listening on port ${port}`);
});

Sla je bestand op en sluit het af, en voer vervolgens de server uit:

  1. node index.js

Bezoek opnieuw het tabblad http://localhost:3000/blocking in je webbrowser. Voordat het laden is voltooid, vernieuw alle tabbladen van http://localhost:3000/non-blocking. Je zult nu merken dat ze direct laden zonder te wachten op het voltooien van de /blocking-route. Dit komt doordat de CPU-gebonden taak wordt uitbesteed aan een andere thread en de hoofdthread alle inkomende verzoeken afhandelt.

Stop nu je server met CTRL+C.

Nu je een CPU-intensieve taak niet-blokkerend kunt maken met behulp van een worker-thread, zul je vier worker-threads gebruiken om de prestaties van de CPU-intensieve taak te verbeteren.

Optimaliseren van een CPU-intensieve taak met behulp van vier werkersthreads

In dit gedeelte verdeel je de CPU-intensieve taak over vier werkersthreads, zodat ze de taak sneller kunnen voltooien en de laadtijd van de /blokkeren-route kunnen verkorten.

Om meer werkersthreads aan dezelfde taak te laten werken, moet je de taken opsplitsen. Aangezien de taak inhoudt dat er 20 miljard keer wordt gelust, deel je 20 miljard door het aantal threads dat je wilt gebruiken. In dit geval is dat 4. Berekenen van 20_000_000_000 / 4 resulteert in 5_000_000_000. Dus elke thread zal lussen van 0 tot 5_000_000_000 en de counter met 1 verhogen. Wanneer elke thread klaar is, stuurt deze een bericht naar de hoofdthread met het resultaat. Zodra de hoofdthread berichten van alle vier threads afzonderlijk ontvangt, combineer je de resultaten en stuur je een antwoord naar de gebruiker.

Je kunt dezelfde aanpak ook gebruiken als je een taak hebt die grote arrays doorloopt. Bijvoorbeeld, als je 800 afbeeldingen in een map wilt wijzigen, kun je een array maken met alle bestandspaden van de afbeeldingen. Verdeel vervolgens 800 door 4 (het aantal threads) en laat elke thread werken binnen een bereik. Thread één zal afbeeldingen wijzigen van array-index 0 tot 199, thread twee van index 200 tot 399, enzovoort.

Eerst controleren of je vier of meer cores hebt:

  1. nproc
Output
4

Maak een kopie van het bestand worker.js met het cp commando:

  1. cp worker.js four_workers.js

De huidige bestanden index.js en worker.js blijven intact zodat je ze later opnieuw kunt uitvoeren om hun prestaties te vergelijken met wijzigingen in dit gedeelte.

Open vervolgens het bestand four_workers.js in je teksteditor:

  1. nano four_workers.js

Voeg in je bestand four_workers.js de gemarkeerde code toe om het object workerData te importeren:

multi-threading_demo/four_workers.js
const { workerData, parentPort } = require("worker_threads");

let counter = 0;
for (let i = 0; i < 20_000_000_000 / workerData.thread_count; i++) {
  counter++;
}

parentPort.postMessage(counter);

Eerst haal je het object WorkerData eruit, dat de gegevens bevat die vanuit de hoofdthread worden doorgegeven wanneer de thread wordt geïnitialiseerd (wat je binnenkort zult doen in het bestand index.js). Het object heeft een eigenschap thread_count die het aantal threads bevat, namelijk 4. Vervolgens wordt in de for-lus de waarde 20_000_000_000 gedeeld door 4, wat resulteert in 5_000_000_000.

Sla je bestand op en sluit het, kopieer dan het bestand index.js:

  1. cp index.js index_four_workers.js

Open het bestand index_four_workers.js in je editor:

  1. nano index_four_workers.js

Voeg in je bestand index_four_workers.js de gemarkeerde code toe om een thread-instantie te maken:

multi-threading_demo/index_four_workers.js
...
const app = express();
const port = process.env.PORT || 3000;
const THREAD_COUNT = 4;
...
function createWorker() {
  return new Promise(function (resolve, reject) {
    const worker = new Worker("./four_workers.js", {
      workerData: { thread_count: THREAD_COUNT },
    });
  });
}

app.get("/blocking", async (req, res) => {
  ...
})
...

Eerst definieer je de constante THREAD_COUNT met het aantal threads dat je wilt maken. Later, wanneer je meer cores op je server hebt, zal het schalen inhouden dat je de waarde van THREAD_COUNT verandert naar het aantal threads dat je wilt gebruiken.

Volgende, de functie createWorker() maakt een belofte aan en retourneert deze. Binnen de callback van de belofte initialiseer je een nieuwe thread door de Worker klasse het bestandspad naar het bestand four_workers.js als eerste argument te geven. Vervolgens geef je een object door als tweede argument. Daarna wijs je aan het object de eigenschap workerData toe met een ander object als waarde. Ten slotte wijs je aan het object de eigenschap thread_count toe waarvan de waarde het aantal threads in de constante THREAD_COUNT is. Het object workerData is degene waarnaar je eerder hebt verwezen in het bestand workers.js.

Om ervoor te zorgen dat de belofte wordt ingelost of een fout wordt gegenereerd, voeg je de volgende gemarkeerde regels toe:

multi-threading_demo/index_four_workers.js
...
function createWorker() {
  return new Promise(function (resolve, reject) {
    const worker = new Worker("./four_workers.js", {
      workerData: { thread_count: THREAD_COUNT },
    });
    worker.on("message", (data) => {
      resolve(data);
    });
    worker.on("error", (msg) => {
      reject(`An error ocurred: ${msg}`);
    });
  });
}
...

Wanneer de werkthread een bericht naar de hoofdthread stuurt, wordt de belofte ingelost met de geretourneerde gegevens. Als er echter een fout optreedt, retourneert de belofte een foutmelding.

Nu je de functie hebt gedefinieerd die een nieuwe thread initialiseert en de gegevens van de thread retourneert, zul je de functie gebruiken in app.get("/blocking") om nieuwe threads te starten.

Maar verwijder eerst de volgende gemarkeerde code, aangezien je deze functionaliteit al hebt gedefinieerd in de functie createWorker():

multi-threading_demo/index_four_workers.js
...
app.get("/blocking", async (req, res) => {
  const worker = new Worker("./worker.js");
  worker.on("message", (data) => {
    res.status(200).send(`result is ${data}`);
  });
  worker.on("error", (msg) => {
    res.status(404).send(`An error ocurred: ${msg}`);
  });
});
...

Met de verwijderde code, voeg de volgende code toe om vier werkthreads te initialiseren:

multi-threading_demo/index_four_workers.js
...
app.get("/blocking", async (req, res) => {
  const workerPromises = [];
  for (let i = 0; i < THREAD_COUNT; i++) {
    workerPromises.push(createWorker());
  }
});
...

Eerst maak je een variabele workerPromises, die een lege array bevat. Vervolgens doorloop je zo vaak als de waarde in THREAD_COUNT, dat is 4. Tijdens elke iteratie roep je de functie createWorker() aan om een nieuwe thread te maken. Vervolgens voeg je het promise-object dat de functie retourneert toe aan de array workerPromises met behulp van de push-methode van JavaScript. Wanneer de lus eindigt, bevat de array workerPromises vier promise-objecten, elk verkregen door vier keer de functie createWorker() aan te roepen.

Voeg nu de volgende gemarkeerde code toe om te wachten tot de promises zijn opgelost en een antwoord aan de gebruiker te retourneren:

multi-threading_demo/index_four_workers.js
app.get("/blocking", async (req, res) => {
  const workerPromises = [];
  for (let i = 0; i < THREAD_COUNT; i++) {
    workerPromises.push(createWorker());
  }

  const thread_results = await Promise.all(workerPromises);
  const total =
    thread_results[0] +
    thread_results[1] +
    thread_results[2] +
    thread_results[3];
  res.status(200).send(`result is ${total}`);
});

Aangezien de array workerPromises promises bevat die zijn geretourneerd door het aanroepen van createWorker(), voeg je de Promise.all()-methode voorafgegaan door de await-syntax toe en roep je de all()-methode aan met workerPromises als argument. De Promise.all()-methode wacht tot alle promises in de array zijn opgelost. Wanneer dat gebeurt, bevat de variabele thread_results de waarden waarmee de promises zijn opgelost. Omdat de berekeningen zijn verdeeld over vier workers, tel je ze allemaal op door elke waarde uit thread_results te halen met behulp van de haakjesnotatiesyntax. Eenmaal opgeteld, retourneer je de totale waarde naar de pagina.

Je volledige bestand zou er nu zo uit moeten zien:

multi-threading_demo/index_four_workers.js
const express = require("express");
const { Worker } = require("worker_threads");

const app = express();
const port = process.env.PORT || 3000;
const THREAD_COUNT = 4;

app.get("/non-blocking/", (req, res) => {
  res.status(200).send("This page is non-blocking");
});

function createWorker() {
  return new Promise(function (resolve, reject) {
    const worker = new Worker("./four_workers.js", {
      workerData: { thread_count: THREAD_COUNT },
    });
    worker.on("message", (data) => {
      resolve(data);
    });
    worker.on("error", (msg) => {
      reject(`An error ocurred: ${msg}`);
    });
  });
}

app.get("/blocking", async (req, res) => {
  const workerPromises = [];
  for (let i = 0; i < THREAD_COUNT; i++) {
    workerPromises.push(createWorker());
  }
  const thread_results = await Promise.all(workerPromises);
  const total =
    thread_results[0] +
    thread_results[1] +
    thread_results[2] +
    thread_results[3];
  res.status(200).send(`result is ${total}`);
});

app.listen(port, () => {
  console.log(`App listening on port ${port}`);
});

Sla je bestand op en sluit het af. Voordat je dit bestand uitvoert, voer je eerst index.js uit om de responstijd te meten.

  1. node index.js

Volgende, open een nieuwe terminal op uw lokale computer en voer het volgende `curl` commando in, dat de tijd meet die het kost om een ​​reactie te krijgen van de `/blocking` route:

  1. time curl --get http://localhost:3000/blocking

Het `time` commando meet hoe lang het `curl` commando loopt. Het `curl` commando stuurt een HTTP-verzoek naar de opgegeven URL en de `–get` optie instrueert `curl` om een ​​`GET` verzoek te doen.

Wanneer het commando wordt uitgevoerd, ziet uw uitvoer er ongeveer zo uit:

Output
real 0m28.882s user 0m0.018s sys 0m0.000s

De gemarkeerde uitvoer laat zien dat het ongeveer 28 seconden duurt om een ​​reactie te krijgen, wat kan variëren op uw computer.

Volgende, stop de server met `CTRL+C` en voer het `index_four_workers.js` bestand uit:

  1. node index_four_workers.js

Bezoek opnieuw de `/blocking` route in uw tweede terminal:

  1. time curl --get http://localhost:3000/blocking

U ziet een uitvoer die consistent is met het volgende:

Output
real 0m8.491s user 0m0.011s sys 0m0.005s

De uitvoer laat zien dat het ongeveer 8 seconden duurt, wat betekent dat u de laadtijd met ongeveer 70% heeft verminderd.

U heeft de CPU-gebonden taak met succes geoptimaliseerd door vier werker threads te gebruiken. Als u een machine heeft met meer dan vier cores, update de `THREAD_COUNT` naar dat aantal en u zult de laadtijd nog verder kunnen verminderen.

Conclusie

In dit artikel heb je een Node-applicatie gebouwd met een CPU-gebonden taak die de hoofdthread blokkeert. Vervolgens heb je geprobeerd om de taak niet-blokkerend te maken met behulp van beloftes, wat onsuccesvol was. Daarna heb je de module worker_threads gebruikt om de CPU-gebonden taak naar een andere thread te verplaatsen om hem niet-blokkerend te maken. Uiteindelijk heb je de module worker_threads gebruikt om vier threads te maken om de CPU-intensieve taak te versnellen.

Als volgende stap, raadpleeg de documentatie van Node.js Worker threads om meer te weten te komen over de opties. Daarnaast kun je de piscina bibliotheek bekijken, waarmee je een werkerpool kunt maken voor je CPU-intensieve taken. Als je wilt doorgaan met het leren van Node.js, bekijk dan de tutorialserie, Hoe te coderen in Node.js.

Source:
https://www.digitalocean.com/community/tutorials/how-to-use-multithreading-in-node-js