April 21, 2024





JavaScript runtimes use a single processing thread. The engine does one thing at a time and must complete execution before it can do anything else. This rarely causes problems in a browser, because a single user interacts with the app. But Node.js apps could be handling hundreds of user requests. Multithreading can prevent bottlenecks in your app.

Consider a Node.js web application where a single user could trigger a complex, ten-second JavaScript calculation. The app would be unable to handle incoming requests from any other users until that calculation completes.

Languages such as PHP and Python are also single threaded, but they typically use a multi-threaded web server which launches a new instance of the interpreter on every request. This is resource-intensive, so Node.js applications often provide their own lightweight web server.

A Node.js web server runs on a single thread, but JavaScript alleviates performance problems with its non-blocking event loop. Apps can asynchronously execute operations such as file, database, and HTTP that run on other OS threads. The event loop continues and can handle other JavaScript tasks while it waits for I/O operations to complete.

Unfortunately, long-running JavaScript code β€” such as image processing β€” can hog the current iteration of the event loop. This article explains how to move processing to another thread using:

Table of Contents

Node.js Worker Threads

Worker threads are the Node.js equivalent of web workers. The main thread passes data to another script which (asynchronously) processes it on a separate thread. The main thread continues to run and runs a callback event when the worker has completed its work.

Note that JavaScript uses its structured clone algorithm to serialize data into a string when it’s passed to and from a worker. It can include native types such as strings, numbers, Booleans, arrays, and objects β€” but not functions. You won’t be able to pass complex objects β€” such as database connections β€” since most will have methods that can’t be cloned. However, you could:

  • Asynchronously read database data in the main thread and pass the resulting data to the worker.
  • Create another connection object in the worker. This will have a start-up cost, but may be practical if your function requires further database queries as part of the calculation.

The Node.js worker thread API is conceptually similar to the Web Workers API in the browser, but there are syntactical differences. Deno and Bun support both the browser and Node.js APIs.

Worker thread demonstration

The following demonstration shows a Node.js process which writes the current time to the console every second: Open Node.js demonstration in a new browser tab.

A long-running dice throwing calculation then launches on the main thread. The loop completes 100 million iterations, which stops the time being output:

  timer process 12:33:18 PM
  timer process 12:33:19 PM
  timer process 12:33:20 PM
NO THREAD CALCULATION STARTED...
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ (index) β”‚  Values  β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚    2    β”‚ 2776134  β”‚
β”‚    3    β”‚ 5556674  β”‚
β”‚    4    β”‚ 8335819  β”‚
β”‚    5    β”‚ 11110893 β”‚
β”‚    6    β”‚ 13887045 β”‚
β”‚    7    β”‚ 16669114 β”‚
β”‚    8    β”‚ 13885068 β”‚
β”‚    9    β”‚ 11112704 β”‚
β”‚   10    β”‚ 8332503  β”‚
β”‚   11    β”‚ 5556106  β”‚
β”‚   12    β”‚ 2777940  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
processing time: 2961ms
NO THREAD CALCULATION COMPLETE

timer process 12:33:24 PM

Once complete, the same calculation launches on a worker thread. The clock continues to run while dice processing occurs:

WORKER CALCULATION STARTED...
  timer process 12:33:27 PM
  timer process 12:33:28 PM
  timer process 12:33:29 PM
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ (index) β”‚  Values  β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚    2    β”‚ 2778246  β”‚
β”‚    3    β”‚ 5556129  β”‚
β”‚    4    β”‚ 8335780  β”‚
β”‚    5    β”‚ 11114930 β”‚
β”‚    6    β”‚ 13889458 β”‚
β”‚    7    β”‚ 16659456 β”‚
β”‚    8    β”‚ 13889139 β”‚
β”‚    9    β”‚ 11111219 β”‚
β”‚   10    β”‚ 8331738  β”‚
β”‚   11    β”‚ 5556788  β”‚
β”‚   12    β”‚ 2777117  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
processing time: 2643ms
WORKER CALCULATION COMPLETE

  timer process 12:33:30 PM

The worker process is a little faster than the main thread because it can concentrate on one task.

How to use worker threads

A dice.js file in the demonstration project defines a dice-throwing function. It’s passed the number of runs (throws), the number of dice, and the number of sides on each die. On each throw, the function calculates the dice sum and increments the number of times it’s observed in the stat array. The function returns the array when all throws are complete:


export function diceRun(runs = 1, dice = 2, sides = 6) 
  const stat = [];

  while (runs > 0)  0) + 1;
    runs--;
  

  return stat;

The main index.js script starts a timer process which outputs the current date and time every second:

const intlTime = new Intl.DateTimeFormat([],  timeStyle: "medium" );



timer = setInterval(() => 
  console.log(`  timer process $ intlTime.format(new Date()) `);
, 1000);

When the main thread executes diceRun() directly, the timer stops because nothing else can run while the calculation occurs:

import  diceRun  from "./dice.js";


const
  throws = 100_000_000,
  dice = 2,
  sides = 6;


const stat = diceRun(throws, dice, sides);
console.table(stat);

To run the calculation in another thread, the code defines a new Worker object with the filename of the worker script. It passes a workerData variable β€” an object with the properties throws, dice, and sides:

const worker = new Worker("./src/worker.js", 
  workerData:  throws, dice, sides 
);

This starts the worker script which executes diceRun() with the parameters passed in workerData:


import  workerData, parentPort  from "node:worker_threads";
import  diceRun  from "./dice.js";


const stat = diceRun(workerData.throws, workerData.dice, workerData.sides);


parentPort.postMessage(stat);

The parentPort.postMessage(stat); call passes the result back to the main thread. This raises a "message" event in index.js, which receives the result and displays it in the console:


worker.on("message", result => 
  console.table(result);
);

You can define handlers for other worker events:

  • The main script can use worker.postMessage(data) to send arbitrary data to the worker at any point. It triggers a "message" event in the worker script:
    parentPort.on("message", data => 
      console.log("from main:", data);
    );
    
  • "messageerror" triggers in the main thread when the worker receives data it can’t deserialize.
  • "online" triggers in the main thread when the worker thread starts to execute.
  • "error" triggers in the main thread when a JavaScript error occurs in the worker thread. You could use this to terminate the worker. For example:
    worker.on("error", e => 
      console.log(e);
      worker.terminate();
    );
    
  • "exit" triggers in the main thread when the worker terminates. This could be used for cleaning up, logging, performance monitoring, and so on:
    worker.on("exit", code => 
      
      console.log("worker complete");
    );
    

Inline worker threads

A single script file can contain both main and worker code. Your script should check whether it’s running on the main thread using isMainThread, then call itself as a worker using import.meta.url as the file reference in an ES module (or __filename in CommonJS):

import  Worker, isMainThread, workerData, parentPort  from "node:worker_threads";

if (isMainThread) 

  
  
  const worker = new Worker(import.meta.url, 
    workerData:  throws, dice, sides 
  );

  worker.on("message", msg => );
  worker.on("exit", code => );


else 

  
  const stat = diceRun(workerData.throws, workerData.dice, workerData.sides);
  parentPort.postMessage(stat);


Whether or not this is practical is another matter. I recommend you split main and worker scripts unless they’re using identical modules.

Thread data sharing

You can share data between threads using a SharedArrayBuffer object representing fixed-length raw binary data. The following main thread defines 100 numeric elements from 0 to 99, which it sends to a worker:

import  Worker  from "node:worker_threads";

const
  buffer = new SharedArrayBuffer(100 * Int32Array.BYTES_PER_ELEMENT),
  value = new Int32Array(buffer);

value.forEach((v,i) => value[i] = i);

const worker = new Worker("./worker.js");

worker.postMessage( value );

The worker can receive the value object:

import  parentPort  from 'node:worker_threads';

parentPort.on("message", value => 
  value[0] = 100;
);

At this point, either the main or worker threads can change elements in the value array and it’s changed in both. It results in efficiency gains because there’s no data serialization, but:

  • you can only share integers
  • it may be necessary to send messages to indicate data has changed
  • there’s a risk two threads could change the same value at the same time and lose synchronization

Few apps will require complex data sharing, but it could be a viable option in high-performance apps such as games.

Node.js Child Processes

Child processes launch another application (not necessarily a JavaScript one), pass data, and receive a result typically via a callback. They operate in a similar way to workers, but they’re generally less efficient and more process-intensive, because they’re dependent on processes outside Node.js. There may also be OS differences and incompatibilities.

Node.js has three general child process types with synchronous and asynchronous variations:

  • spawn: spawns a new process
  • exec: spawns a shell and runs a command within it
  • fork: spawns a new Node.js process

The following function uses spawn to run a command asynchronously by passing the command, an arguments array, and a timeout. The promise resolves or rejects with an object containing the properties complete (true or false), a code (generally 0 for success), and a result string:

import  spawn  from 'node:child_process';


function execute(cmd, args = [], timeout = 600000) {

  return new Promise((resolve, reject) => 

    try 

      const
        exec = spawn(cmd, args, 
          timeout
        );

      let ret = '';

      exec.stdout.on('data', data => 
        ret += '\n' + data;
      );

      exec.stderr.on('data', data => 
        ret += '\n' + data;
      );

      exec.on('close', code => 

        resolve(
          complete: !code,
          code,
          result: ret.trim()
        );

      );

    
    catch(err) 

      reject(
        complete: false,
        code: err.code,
        result: err.message
      );

    

  );

}

You can use it to run an OS command, such as listing the contents of the working directory as a string on macOS or Linux:

const ls = await execute('ls', ['-la'], 1000);
console.log(ls);

Node.js Clustering

Node.js clusters allow you to fork a number of identical processes to handle loads more efficiently. The initial primary process can fork itself β€” perhaps once for each CPU returned by os.cpus(). It can also handle restarts when an instance fails and broker communication messages between forked processes.

The cluster library offers properties and methods including:

  • .isPrimary or .isMaster: returns true for the main primary process
  • .fork(): spawns a child worker process
  • .isWorker: returns true for worker processes

The example below starts a web server worker process for each CPU/core on the device. A 4-core machine will spawn four instances of the web server, so it can handle up to four times the load. It also restarts any process that fails, so the application should be more robust:


import cluster from 'node:cluster';
import process from 'node:process';
import  cpus  from 'node:os';
import http from 'node:http';

const cpus = cpus().length;

if (cluster.isPrimary) 

  console.log(`Started primary process: $ process.pid `);

  
  for (let i = 0; i < cpus; i++) 
    cluster.fork();
  

  
  cluster.on('exit', (worker, code, signal) => 
    console.log(`worker $ worker.process.pid  failed`);
    cluster.fork();
  );


else 

  
  http.createServer((req, res) => 

    res.writeHead(200);
    res.end('Hello!');

  ).listen(8080);

  console.log(`Started worker process:  $ process.pid `);


All processes share port 8080 and any can handle an incoming HTTP request. The log when running the applications shows something like this:

$ node app.js
Started primary process: 1001
Started worker process:  1002
Started worker process:  1003
Started worker process:  1004
Started worker process:  1005

...etc...

worker 1002 failed
Started worker process:  1006

Few developers attempt clustering. The example above is simple and works well, but code can become increasingly complex as you attempt to handle failures, restarts, and messages between forks.

Process Managers

A Node.js process manager can help run multiple instances of a single Node.js application without having to write cluster code. The most well known is PM2. The following command starts an instance of your application for every CPU/core and restarts any when they fail:

pm2 start ./app.js -i max

App instances start in the background, so it’s ideal for using on a live server. You can examine which processes are running by entering pm2 status:

$ pm2 status

β”Œβ”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ id β”‚ name β”‚ namespace β”‚ version β”‚ mode    β”‚ pid  β”‚ uptime β”‚
β”œβ”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ 1  β”‚ app  β”‚ default   β”‚ 1.0.0   β”‚ cluster β”‚ 1001 β”‚ 4D     β”‚
β”‚ 2  β”‚ app  β”‚ default   β”‚ 1.0.0   β”‚ cluster β”‚ 1002 β”‚ 4D     β”‚
β””β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”˜

PM2 can also run non-Node.js applications written in Deno, Bun, Python, and so on.

Container Orchestration

Clusters and process managers bind an application to a specific device. If your server or an OS dependency fails, your application fails regardless of the number of running instances.

Containers are a similar concept to virtual machines but, rather than emulating hardware, they emulate an operating system. A container is a lightweight wrapper around a single application with all necessary OS, library, and executable files. A single container can contain an isolated instance of Node.js and your application, so it runs on a single device or across thousands of machines.

Container orchestration is beyond the scope of this article, so you should take a closer look at Docker and Kubernetes.

Conclusion

Node.js workers and similar multithreading methods improve application performance and reduce bottlenecks by running code in parallel. They can also make applications more robust by running dangerous functions in separate threads and terminating them when processing times exceed certain limits.

Workers have an overhead, so some experimentation may be necessary to ensure they improve results. You may not require them for heavy asynchronous I/O tasks, and process/container management can offer an easier way to scale applications.





Source link

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

π–‚π–†π–˜π–π–Žπ–“π–Œπ–™π–”π–“ π•½π–Šπ–†π–‰

error: Content is protected !!