Come utilizzare il multithreading in Node.js

L’autore ha selezionato Open Sourcing Mental Illness per ricevere una donazione come parte del programma Write for DOnations.

Introduzione

Node.js esegue il codice JavaScript in un singolo thread, il che significa che il tuo codice può fare solo un compito alla volta. Tuttavia, Node.js stesso è multithreaded e fornisce thread nascosti attraverso la libreria libuv, che gestisce le operazioni di I/O come la lettura di file da disco o le richieste di rete. Attraverso l’uso di thread nascosti, Node.js fornisce metodi asincroni che consentono al tuo codice di effettuare richieste di I/O senza bloccare il thread principale.

Anche se Node.js dispone di thread nascosti, non è possibile utilizzarli per scaricare compiti intensivi per la CPU, come calcoli complessi, ridimensionamento di immagini o compressione video. Poiché JavaScript è single-threaded quando viene eseguito un compito intensivo per la CPU, blocca il thread principale e nessun altro codice viene eseguito fino al completamento del compito. Senza utilizzare altri thread, l’unico modo per accelerare un compito vincolato alla CPU è aumentare la velocità del processore.

Tuttavia, negli ultimi anni, le CPU non hanno continuato a diventare più veloci. Invece, i computer vengono forniti con core aggiuntivi, ed è ora più comune che i computer abbiano 8 o più core. Nonostante questa tendenza, il tuo codice non sfrutterà i core aggiuntivi sul tuo computer per velocizzare i compiti legati alla CPU o evitare di interrompere il thread principale perché JavaScript è single-threaded.

Per rimediare a questo, Node.js ha introdotto il modulo worker-threads, che ti consente di creare thread ed eseguire più compiti JavaScript in parallelo. Una volta che un thread ha completato un compito, invia un messaggio al thread principale che contiene il risultato dell’operazione in modo che possa essere utilizzato con altre parti del codice. Il vantaggio dell’uso dei thread dei lavoratori è che i compiti legati alla CPU non bloccano il thread principale e puoi dividere e distribuire un compito a più lavoratori per ottimizzarlo.

In questo tutorial, creerai un’app Node.js con un compito intensivo per la CPU che blocca il thread principale. Successivamente, utilizzerai il modulo worker-threads per spostare il compito intensivo per la CPU su un altro thread per evitare di bloccare il thread principale. Infine, dividerai il compito legato alla CPU e avrai quattro thread che lavorano su di esso in parallelo per velocizzare il compito.

Prerequisiti

Per completare questo tutorial, avrai bisogno:

Configurazione del Progetto e Installazione delle Dipendenze

In questa fase, creerai la directory del progetto, inizializzerai npm e installerai tutte le dipendenze necessarie.

Per iniziare, crea e spostati nella directory del progetto:

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

Il comando mkdir crea una directory e il comando cd cambia la directory di lavoro nella nuova directory creata.

Successivamente, inizializza la directory del progetto con npm utilizzando il comando npm init:

  1. npm init -y

L’opzione -y accetta tutte le opzioni predefinite.

Quando viene eseguito il comando, l’output sarà simile a questo:

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"
}

Successivamente, installa express, un framework web Node.js:

  1. npm install express

Utilizzerai Express per creare un’applicazione server che ha endpoint bloccanti e non bloccanti.

Node.js include di default il modulo worker-threads, quindi non è necessario installarlo.

Ora hai installato i pacchetti necessari. Successivamente, imparerai di più su processi e thread e su come vengono eseguiti su un computer.

Comprensione dei processi e dei thread

Prima di iniziare a scrivere compiti legati alla CPU e a scaricarli su thread separati, è necessario capire cosa sono i processi e i thread, e le differenze tra di essi. In particolare, si esaminerà come i processi e i thread vengono eseguiti su un sistema informatico single o multi-core.

Processo

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.

Per capire questo, creerai un programma Node.js con un loop infinito in modo che non esca quando viene eseguito.

Utilizzando nano, o il tuo editor di testo preferito, crea e apri il file process.js:

  1. nano process.js

Nel tuo file process.js, inserisci il seguente codice:

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}`);
  }
}

Nella prima riga, la proprietà process.argv restituisce un array contenente gli argomenti della riga di comando del programma. Successivamente, si attacca il metodo slice() di JavaScript con un argomento di 2 per fare una copia superficiale dell’array dall’indice 2 in poi. In questo modo si saltano i primi due argomenti, che sono il percorso Node.js e il nome del programma. Successivamente, si utilizza la sintassi della notazione tra parentesi quadre per recuperare il primo argomento dall’array tagliato e memorizzarlo nella variabile process_name.

Dopo di ciò, si definisce un ciclo `while` e si passa una condizione `true` per far eseguire il ciclo all’infinito. All’interno del ciclo, la variabile `count` viene incrementata di `1` durante ogni iterazione. Successivamente c’è un’istruzione `if` che controlla se `count` è uguale a `2000` o `4000`. Se la condizione è vera, il metodo `console.log()` registra un messaggio nel terminale.

Salva e chiudi il tuo file utilizzando `CTRL+X`, quindi premi `Y` per salvare le modifiche.

Esegui il programma utilizzando il comando `node`:

  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.

Quando esegui il programma, vedrai un output simile al seguente:

Output
[1] 7754 A: 2000 A: 4000

Il numero `7754` è un ID del processo che il sistema operativo gli ha assegnato. `A: 2000` e `A: 4000` sono gli output del programma.

Quando si esegue un programma utilizzando il comando `node`, si crea un processo. Il sistema operativo alloca memoria per il programma, localizza l’eseguibile del programma sul disco del computer e carica il programma in memoria. Quindi gli assegna un ID del processo e inizia l’esecuzione del programma. A quel punto, il tuo programma è diventato un processo.

Quando il processo è in esecuzione, il suo ID del processo viene aggiunto alla lista dei processi del sistema operativo e può essere visto con strumenti come htop, top o ps. Gli strumenti forniscono più dettagli sui processi, così come opzioni per interromperli o dar loro priorità.

Per ottenere un breve riepilogo di un processo Node, premi INVIO nel tuo terminale per riottenere il prompt. Successivamente, esegui il comando ps per visualizzare i processi Node:

  1. ps |grep node

Il comando ps elenca tutti i processi associati all’utente corrente nel sistema. L’operatore di pipe | passa l’output di ps al comando grep per filtrare i processi e elencare solo quelli di Node.

L’esecuzione del comando restituirà un output simile al seguente:

Output
7754 pts/0 00:21:49 node

Puoi creare innumerevoli processi da un singolo programma. Ad esempio, utilizza il seguente comando per creare altri tre processi con argomenti diversi e metterli in background:

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

Nel comando, hai creato altre tre istanze del programma process.js. Il simbolo & mette ciascun processo in background.

All’esecuzione del comando, l’output sarà simile al seguente (anche se l’ordine potrebbe essere diverso):

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

Come puoi vedere nell’output, ogni processo ha registrato il nome del processo nel terminale quando il conteggio ha raggiunto 2000 e 4000. Ogni processo non è consapevole di altri processi in esecuzione: il processo D non è consapevole del processo C, e viceversa. Quello che accade in uno dei processi non influirà sugli altri processi Node.js.

Se esamini attentamente l’output, noterai che l’ordine dell’output non è lo stesso dell’ordine che avevi quando hai creato i tre processi. Quando esegui il comando, gli argomenti dei processi erano nell’ordine di B, C e D. Ma ora, l’ordine è D, B e C. Il motivo è che il sistema operativo ha degli algoritmi di scheduling che decidono quale processo far eseguire sulla CPU in un dato momento.

Su una macchina single core, i processi vengono eseguiti concorrentemente. Cioè, il sistema operativo passa da un processo all’altro a intervalli regolari. Ad esempio, il processo D viene eseguito per un tempo limitato, quindi il suo stato viene salvato da qualche parte e il sistema operativo pianifica l’esecuzione del processo B per un tempo limitato, e così via. Questo avviene avanti e indietro fino a quando tutti i compiti sono stati completati. Dall’output, potrebbe sembrare che ogni processo sia stato eseguito fino al completamento, ma in realtà, lo scheduler del sistema operativo sta costantemente passando da uno all’altro.

Su un sistema multi-core, ipotizzando che tu abbia quattro core, il sistema operativo pianifica l’esecuzione di ciascun processo su ciascun core contemporaneamente. Questo è noto come parallelismo. Tuttavia, se crei altri quattro processi (portando il totale a otto), ogni core eseguirà due processi contemporaneamente fino al termine di essi.

Threads

I thread sono simili ai processi: hanno il loro puntatore di istruzioni e possono eseguire un’attività JavaScript alla volta. A differenza dei processi, i thread non hanno la propria memoria. Invece, risiedono nella memoria di un processo. Quando si crea un processo, può avere più thread creati con il modulo worker_threads che esegue codice JavaScript in parallelo. Inoltre, i thread possono comunicare tra loro attraverso il passaggio di messaggi o la condivisione di dati nella memoria del processo. Questo li rende leggeri in confronto ai processi, poiché generare un thread non richiede più memoria al sistema operativo.

Per quanto riguarda l’esecuzione dei thread, hanno un comportamento simile a quello dei processi. Se hai più thread in esecuzione su un sistema a singolo core, il sistema operativo passerà tra di essi a intervalli regolari, dando a ciascun thread la possibilità di eseguire direttamente sull’unico CPU. Su un sistema multi-core, il sistema operativo pianifica i thread su tutti i core ed esegue il codice JavaScript contemporaneamente. Se finisci per creare più thread di quanti core siano disponibili, ogni core eseguirà più thread contemporaneamente.

Con questo, premi ENTER, quindi arresta tutti i processi Node attualmente in esecuzione con il comando kill:

  1. sudo kill -9 `pgrep node`

pgrep restituisce gli ID di processo di tutti e quattro i processi Node al comando kill. L’opzione -9 istruisce kill a inviare un segnale SIGKILL.

Quando esegui il comando, vedrai un output simile al seguente:

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

A volte l’output potrebbe essere ritardato e comparire quando si esegue un’altra comando successivamente.

Ora che conosci la differenza tra un processo e un thread, lavorerai con i thread nascosti di Node.js nella prossima sezione.

Comprensione dei thread nascosti in Node.js

Node.js fornisce thread aggiuntivi, motivo per cui è considerato multithreaded. In questa sezione, esaminerai i thread nascosti in Node.js, che aiutano a rendere le operazioni di I/O non bloccanti.

Come menzionato nell’introduzione, JavaScript è single-threaded e tutto il codice JavaScript viene eseguito in un singolo thread. Questo include il codice sorgente del programma e le librerie di terze parti che includi nel tuo programma. Quando un programma effettua un’operazione di I/O per leggere un file o una richiesta di rete, questo blocca il thread principale.

Tuttavia, Node.js implementa la libreria libuv, che fornisce quattro thread aggiuntivi a un processo Node.js. Con questi thread, le operazioni di I/O vengono gestite separatamente e quando sono completate, il ciclo degli eventi aggiunge il callback associato al compito di I/O in una coda di microtask. Quando lo stack delle chiamate nel thread principale è libero, il callback viene inserito nello stack delle chiamate e quindi eseguito. Per rendere questo chiaro, il callback associato al compito di I/O specificato non viene eseguito in parallelo; tuttavia, il compito stesso di lettura di un file o di una richiesta di rete avviene in parallelo con l’aiuto dei thread. Una volta che il compito di I/O è completato, il callback viene eseguito nel thread principale.

In aggiunta a questi quattro thread, il motore V8 fornisce anche due thread per gestire operazioni come la raccolta automatica di rifiuti. Questo porta il numero totale di thread in un processo a sette: un thread principale, quattro thread Node.js e due thread V8.

Per confermare che ogni processo Node.js ha sette thread, esegui nuovamente il file process.js e mettilo in background:

  1. node process.js A &

Il terminale registrerà l’ID del processo, così come l’output del programma:

Output
[1] 9933 A: 2000 A: 4000

Nota l’ID del processo da qualche parte e premi ENTER in modo da poter riutilizzare il prompt.

Per visualizzare i thread, esegui il comando top e passagli l’ID del processo visualizzato nell’output:

  1. top -H -p 9933

-H istruisce top a visualizzare i thread in un processo. Il flag -p istruisce top a monitorare solo l’attività nel dato ID del processo.

Quando esegui il comando, l’output sarà simile al seguente:

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

Come puoi vedere nell’output, il processo Node.js ha in totale sette thread: un thread principale per l’esecuzione di JavaScript, quattro thread Node.js e due thread V8.

Come discusso in precedenza, i quattro thread Node.js vengono utilizzati per le operazioni di I/O al fine di renderle non bloccanti. Svolgono bene questo compito, e la creazione di thread personalizzati per le operazioni di I/O potrebbe addirittura peggiorare le prestazioni dell’applicazione. La stessa cosa non si può dire per le attività legate alla CPU. Un compito legato alla CPU non fa uso di thread extra disponibili nel processo e blocca il thread principale.

Ora premi q per uscire da top e arresta il processo Node con il seguente comando:

  1. kill -9 9933

Ora che conosci i thread in un processo Node.js, scriverai un compito legato alla CPU nella prossima sezione e osserverai come influisce sul thread principale.

Creazione di un Compito Legato alla CPU Senza Worker Threads

In questa sezione, costruirai un’app Express che ha una route non bloccante e una route bloccante che esegue un compito legato alla CPU.

Per prima cosa, apri index.js nel tuo editor preferito:

  1. nano index.js

Nel tuo file index.js, aggiungi il seguente codice per creare un server di base:

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}`);
});

Nel blocco di codice seguente, crei un server HTTP usando Express. Nella prima riga, importi il modulo express. Successivamente, imposti la variabile app per contenere un’istanza di Express. Dopo di ciò, definisci la variabile port, che contiene il numero di porta su cui il server dovrebbe ascoltare.

Successivamente, utilizzi app.get('/non-blocking') per definire il percorso a cui dovrebbero essere inviate le richieste GET. Infine, invochi il metodo app.listen() per istruire il server ad iniziare ad ascoltare sulla porta 3000.

Successivamente, definisci un altro percorso, /blocking/, che conterrà un’attività intensiva per la CPU:

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}`);
});

Definisci il percorso /blocking usando app.get("/blocking"), che accetta un callback asincrono prefissato con la parola chiave async come secondo argomento che esegue un’attività intensiva per la CPU. All’interno del callback, crei un ciclo for che itera 20 miliardi di volte e durante ogni iterazione, incrementa la variabile counter di 1. Questa attività viene eseguita sulla CPU e richiederà un paio di secondi per completarsi.

A questo punto, il tuo file index.js avrà questo aspetto:

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}`);
});

Salva ed esci dal tuo file, quindi avvia il server con il seguente comando:

  1. node index.js

Quando esegui il comando, vedrai un output simile al seguente:

Output
App listening on port 3000

Questo mostra che il server è in esecuzione e pronto per servire.

Adesso, visita http://localhost:3000/non-blocking nel tuo browser preferito. Vedrai una risposta istantanea con il messaggio Questa pagina è non bloccante.

Nota: Se stai seguendo il tutorial su un server remoto, puoi utilizzare il reindirizzamento della porta per testare l’applicazione nel browser.

Mentre il server Express è ancora in esecuzione, apri un’altra finestra del terminale sul tuo computer locale e inserisci il seguente comando:

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

Dopo esserti connesso al server, vai su http://localhost:3000/non-blocking nel browser web del tuo computer locale. Mantieni aperta la seconda finestra del terminale per tutto il resto di questo tutorial.

Successivamente, apri una nuova scheda e visita http://localhost:3000/blocking. Mentre la pagina si carica, apri rapidamente altre due schede e visita di nuovo http://localhost:3000/non-blocking. Noterai che non otterrai una risposta istantanea e le pagine continueranno a cercare di caricarsi. Solo dopo che il percorso /blocking ha terminato il caricamento e restituisce una risposta result is 20000000000, gli altri percorsi restituiranno una risposta.

Il motivo per cui tutti i percorsi /non-blocking non funzionano mentre il percorso /blocking si carica è a causa del ciclo for legato alla CPU, che blocca il thread principale. Quando il thread principale è bloccato, Node.js non può gestire alcuna richiesta finché il compito legato alla CPU non è stato completato. Quindi, se la tua applicazione ha migliaia di richieste GET simultanee per il percorso /non-blocking, è sufficiente una singola visita al percorso /blocking per rendere tutti i percorsi dell’applicazione non rispondenti.

Come puoi vedere, bloccare il thread principale può danneggiare l’esperienza dell’utente con la tua app. Per risolvere questo problema, dovrai spostare il compito legato alla CPU su un altro thread in modo che il thread principale possa continuare a gestire altre richieste HTTP.

Con questo, arresta il server premendo CTRL+C. Riavvierai il server nella prossima sezione dopo aver apportato ulteriori modifiche al file index.js. Il motivo per cui il server viene arrestato è che Node.js non si aggiorna automaticamente quando vengono apportate nuove modifiche al file.

Ora che comprendi l’impatto negativo che un compito intensivo per la CPU può avere sulla tua applicazione, cercherai di evitare di bloccare il thread principale utilizzando le promesse.

Spostamento di un compito legato alla CPU utilizzando le promesse

Spesso, quando gli sviluppatori apprendono degli effetti bloccanti dei compiti legati alla CPU, si rivolgono alle promesse per rendere il codice non bloccante. Questo istinto deriva dalla conoscenza dell’utilizzo di metodi I/O basati su promesse non bloccanti, come readFile() e writeFile(). Ma come hai imparato, le operazioni I/O fanno uso di thread nascosti di Node.js, che i compiti legati alla CPU non fanno. Tuttavia, in questa sezione, incapsulerai il compito legato alla CPU in una promessa come tentativo di renderlo non bloccante. Non funzionerà, ma ti aiuterà a capire il valore dell’utilizzo dei thread worker, che farai nella prossima sezione.

Apri nuovamente il file index.js nel tuo editor.

  1. nano index.js

Nel tuo file index.js, rimuovi il codice evidenziato che contiene il compito intensivo della CPU:

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}`);
});
...

Successivamente, aggiungi il seguente codice evidenziato che contiene una funzione che restituisce una promise:

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}`);
}

La funzione calculateCount() ora contiene i calcoli che avevi nella funzione del gestore /blocking. La funzione restituisce una promise, inizializzata con la sintassi new Promise. La promise prende una callback con i parametri resolve e reject, che gestiscono il successo o il fallimento. Quando il ciclo for termina, la promise si risolve con il valore nella variabile counter.

Successivamente, chiama la funzione calculateCount() nella funzione del gestore /blocking/ nel file index.js:

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

Qui chiami la funzione calculateCount() con la parola chiave await prefissa per attendere che la promise si risolva. Una volta che la promise si risolve, la variabile counter viene impostata sul valore risolto.

Il tuo codice completo avrà ora questo aspetto:

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}`);
});

Salva ed esci dal tuo file, quindi avvia nuovamente il server:

  1. node index.js

Nel tuo browser web, visita http://localhost:3000/blocking e, mentre si carica, ricarica rapidamente le schede http://localhost:3000/non-blocking. Come noterai, le route non-blocking sono ancora interessate e tutte aspetteranno che la route /blocking finisca di caricarsi. Poiché le route sono ancora interessate, le promise non rendono il codice JavaScript eseguibile in parallelo e non possono essere utilizzate per rendere non bloccanti i compiti legati alla CPU.

Con quello, arresta il server dell’applicazione con CTRL+C.

Ora che sai che le promesse non forniscono alcun meccanismo per rendere non bloccanti le attività legate alla CPU, utilizzerai il modulo worker-threads di Node.js per spostare un’attività legata alla CPU in un thread separato.

Spostamento di un’attività legata alla CPU con il modulo worker-threads

In questa sezione, sposterai un’attività intensiva per la CPU in un altro thread utilizzando il modulo worker-threads per evitare il blocco del thread principale. Per fare ciò, creerai un file worker.js che conterrà l’attività intensiva per la CPU. Nel file index.js, utilizzerai il modulo worker-threads per inizializzare il thread e avviare l’attività nel file worker.js per eseguire in parallelo al thread principale. Una volta completata l’attività, il thread worker invierà un messaggio contenente il risultato al thread principale.

Per iniziare, verifica di avere almeno 2 core utilizzando il comando nproc:

  1. nproc
Output
4

Se mostra due o più core, puoi procedere con questo passaggio.

Successivamente, crea e apri il file worker.js nel tuo editor di testo:

  1. nano worker.js

Nel tuo file worker.js, aggiungi il seguente codice per importare il modulo worker-threads e eseguire l’attività intensiva per la CPU:

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

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

La prima riga carica il modulo worker_threads ed estrae la classe parentPort. La classe fornisce metodi che puoi utilizzare per inviare messaggi al thread principale. Successivamente, hai il compito intensivo della CPU attualmente nella funzione calculateCount() nel file index.js. Più avanti in questo passaggio, eliminerai questa funzione da index.js.

In seguito, aggiungi il codice evidenziato di seguito:

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);

Qui invochi il metodo postMessage() della classe parentPort, che invia un messaggio al thread principale contenente il risultato del compito legato alla CPU memorizzato nella variabile counter.

Salva ed esci dal tuo file. Apri index.js nel tuo editor di testo:

  1. nano index.js

Poiché hai già il compito legato alla CPU in worker.js, rimuovi il codice evidenziato da 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}`);
});

Successivamente, nel callback di app.get("/blocking"), aggiungi il seguente codice per inizializzare il thread:

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}`);
  });
});
...

Prima, importi il modulo worker_threads e decomprimi la classe Worker. All’interno del callback di app.get("/blocking"), crei un’istanza di Worker utilizzando la parola chiave new seguita da una chiamata a Worker con il percorso del file worker.js come suo argomento. Questo crea un nuovo thread e il codice nel file worker.js inizia a eseguirsi nel thread su un altro core.

Seguendo questo, si collega un evento all’istanza worker utilizzando il metodo on("message") per ascoltare l’evento del messaggio. Quando viene ricevuto il messaggio contenente il risultato dal file worker.js, viene passato come parametro alla callback del metodo, che restituisce una risposta all’utente contenente il risultato del compito vincolato alla CPU.

Successivamente, si collega un altro evento all’istanza worker utilizzando il metodo on("error") per ascoltare l’evento dell’errore. Se si verifica un errore, la callback restituisce una risposta 404 contenente il messaggio di errore all’utente.

Il tuo file completo ora avrà questo aspetto:

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}`);
});

Salva ed esci dal tuo file, quindi avvia il server:

  1. node index.js

Visita di nuovo la scheda http://localhost:3000/blocking nel tuo browser web. Prima che finisca il caricamento, aggiorna tutte le schede http://localhost:3000/non-blocking. Ora dovresti notare che si caricano istantaneamente senza attendere che la route /blocking finisca il caricamento. Questo perché il compito vincolato alla CPU viene scaricato su un altro thread e il thread principale gestisce tutte le richieste in arrivo.

Adesso, ferma il tuo server utilizzando CTRL+C.

Ora che puoi rendere un compito intensivo per la CPU non bloccante utilizzando un thread worker, utilizzerai quattro thread worker per migliorare le prestazioni del compito intensivo per la CPU.

Ottimizzazione di un Compito Intensivo per la CPU Utilizzando Quattro Thread Worker

In questa sezione, dividerai il compito intensivo per la CPU tra quattro thread worker in modo che possano completare il compito più velocemente e abbreviare il tempo di caricamento della rotta /blocking.

Per avere più thread worker che lavorano sullo stesso compito, dovrai suddividere i compiti. Poiché il compito comporta il loop per 20 miliardi di volte, dividerai 20 miliardi per il numero di thread che desideri utilizzare. In questo caso, è 4. Calcolando 20_000_000_000 / 4 otterrai 5_000_000_000. Quindi ogni thread farà un loop da 0 a 5_000_000_000 e incrementerà counter di 1. Quando ciascun thread termina, invierà un messaggio al thread principale contenente il risultato. Una volta che il thread principale riceve i messaggi da tutti e quattro i thread separatamente, combinerai i risultati e invierai una risposta all’utente.

Puoi anche utilizzare lo stesso approccio se hai un compito che itera su grandi array. Ad esempio, se volessi ridimensionare 800 immagini in una directory, puoi creare un array contenente tutti i percorsi dei file delle immagini. Successivamente, dividi 800 per 4 (il conteggio dei thread) e fai lavorare ciascun thread su un intervallo. Il thread uno ridimensionerà le immagini dall’indice dell’array 0 a 199, il thread due dall’indice 200 a 399, e così via.

Prima, verifica di avere quattro o più core:

  1. nproc
Output
4

Fai una copia del file worker.js usando il comando cp:

  1. cp worker.js four_workers.js

I file attuali index.js e worker.js saranno lasciati intatti in modo che tu possa eseguirli nuovamente per confrontare le loro prestazioni con le modifiche in questa sezione più tardi.

Successivamente, apri il file four_workers.js nel tuo editor di testo:

  1. nano four_workers.js

Nel tuo file four_workers.js, aggiungi il codice evidenziato per importare l’oggetto workerData:

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);

Prima, estrai l’oggetto WorkerData, che conterrà i dati passati dal thread principale quando il thread viene inizializzato (che farai presto nel file index.js). L’oggetto ha una proprietà thread_count che contiene il numero di thread, che è 4. Successivamente nel ciclo for, il valore 20_000_000_000 viene diviso per 4, ottenendo 5_000_000_000.

Salva e chiudi il tuo file, quindi copia il file index.js:

  1. cp index.js index_four_workers.js

Apri il file index_four_workers.js nel tuo editor:

  1. nano index_four_workers.js

Nel tuo file index_four_workers.js, aggiungi il codice evidenziato per creare un’istanza del thread:

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) => {
  ...
})
...

Prima, definisci la costante THREAD_COUNT che contiene il numero di thread che desideri creare. In seguito, quando avrai più core sul tuo server, la scalabilità comporterà il cambiamento del valore di THREAD_COUNT al numero di thread che desideri utilizzare.

Successivo, la funzione createWorker() crea e restituisce una promessa. All’interno della callback della promessa, inizializzi un nuovo thread passando alla classe Worker il percorso del file four_workers.js come primo argomento. Successivamente, passi un oggetto come secondo argomento. Dopodiché, assegni all’oggetto la proprietà workerData che ha un altro oggetto come suo valore. Infine, assegni all’oggetto la proprietà thread_count il cui valore è il numero di thread nella costante THREAD_COUNT. L’oggetto workerData è quello a cui hai fatto riferimento nel file workers.js in precedenza.

Per assicurarti che la promessa si risolva o generi un errore, aggiungi le seguenti linee evidenziate:

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}`);
    });
  });
}
...

Quando il thread del worker invia un messaggio al thread principale, la promessa si risolve con i dati restituiti. Tuttavia, se si verifica un errore, la promessa restituisce un messaggio di errore.

Ora che hai definito la funzione che inizializza un nuovo thread e restituisce i dati dal thread, userai la funzione in app.get("/blocking") per generare nuovi thread.

Ma prima, rimuovi il codice evidenziato seguente, poiché hai già definito questa funzionalità nella funzione 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}`);
  });
});
...

Con il codice eliminato, aggiungi il seguente codice per inizializzare quattro nuovi thread di lavoro:

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());
  }
});
...

Prima, crei una variabile workerPromises, che contiene un array vuoto. Successivamente, iteri tante volte quanto il valore in THREAD_COUNT, che è 4. Durante ogni iterazione, chiami la funzione createWorker() per creare un nuovo thread. Poi inserisci l’oggetto promise che la funzione restituisce nell’array workerPromises usando il metodo push di JavaScript. Quando il ciclo finisce, workerPromises avrà quattro oggetti promise, ognuno restituito chiamando la funzione createWorker() quattro volte.

Adesso, aggiungi il seguente codice evidenziato di seguito per attendere che le promise si risolvano e restituiscano una risposta all’utente:

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}`);
});

Dato che l’array workerPromises contiene le promise restituite chiamando createWorker(), prefissi il metodo Promise.all() con la sintassi await e chiami il metodo all() con workerPromises come suo argomento. Il metodo Promise.all() attende che tutte le promise nell’array si risolvano. Quando ciò accade, la variabile thread_results contiene i valori che le promise hanno risolto. Dato che i calcoli sono stati divisi tra quattro lavoratori, li aggiungi tutti insieme ottenendo ciascun valore da thread_results usando la sintassi della notazione delle parentesi quadre. Una volta aggiunti, restituisci il valore totale alla pagina.

Il tuo file completo dovrebbe ora apparire così:

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}`);
});

Salva e chiudi il file. Prima di eseguire questo file, esegui prima index.js per misurarne il tempo di risposta:

  1. node index.js

Successivo, apri un nuovo terminale sul tuo computer locale e inserisci il seguente comando curl, che misura quanto tempo ci vuole per ottenere una risposta dalla rotta /blocking:

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

Il comando time misura quanto tempo il comando curl impiega per eseguirsi. Il comando curl invia una richiesta HTTP all’URL fornito e l’opzione --get istruisce curl a fare una richiesta GET.

Quando il comando viene eseguito, l’output sarà simile a questo:

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

L’output evidenziato mostra che ci vogliono circa 28 secondi per ottenere una risposta, il che potrebbe variare sul tuo computer.

Successivamente, arresta il server con CTRL+C ed esegui il file index_four_workers.js:

  1. node index_four_workers.js

Visita nuovamente la rotta /blocking nel tuo secondo terminale:

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

Vedrai un output coerente con il seguente:

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

L’output mostra che ci vogliono circa 8 secondi, il che significa che hai ridotto il tempo di caricamento di circa il 70%.

Hai ottimizzato con successo il compito legato alla CPU utilizzando quattro thread worker. Se hai una macchina con più di quattro core, aggiorna il THREAD_COUNT a quel numero e ridurrai ulteriormente il tempo di caricamento.

Conclusione

In questo articolo, hai costruito un’app Node con un compito legato alla CPU che blocca il thread principale. Successivamente, hai provato a rendere il compito non bloccante usando le promesse, senza successo. Dopo di ciò, hai utilizzato il modulo worker_threads per spostare il compito legato alla CPU su un altro thread per renderlo non bloccante. Infine, hai utilizzato il modulo worker_threads per creare quattro thread per velocizzare il compito intensivo della CPU.

Come prossimo passo, consulta la documentazione sui thread dei lavoratori di Node.js per saperne di più sulle opzioni. Inoltre, puoi dare un’occhiata alla libreria piscina, che ti consente di creare un pool di lavoratori per i tuoi compiti intensivi della CPU. Se vuoi continuare a imparare Node.js, consulta la serie di tutorial, Come Codificare in Node.js.

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