Page Content

Tutorials

What is the Thread Model of Node JS?

Thread Model of Node JS

In JavaScript, understanding the concept of threads is crucial, especially when dealing with performance-intensive applications. By default, JavaScript is single-threaded, meaning it can only execute one statement at a time. This implies that only one thread is available to perform all operations. While this single-threaded nature was a significant drawback, it led to the implementation of Server-side Asynchronous I/O to reduce race conditions in a multithreading environment.

In order to manage tasks like network requests and disk file reading, Node.js itself uses hidden threads using the libuv module. Node.js introduced the Worker Threads module to address the drawbacks of JavaScript’s single threaded architecture for CPU-bound operations.

Why Worker Threads Were Introduced

When running CPU-intensive programs, JavaScript’s single-threaded nature in Node.js presents its biggest problem. Reading large files from disk or doing intricate computations on big datasets are two examples of tasks that can block the main thread and stop other processes from running. A function is said to be “blocking” if the main event loop has to wait for it to finish before continuing with the subsequent command. On the other hand, a “non-blocking” function lets the main event loop go on right away, usually alerting the main loop with a callback when it’s done.

Node.js version v10 introduced worker threads as an experimental feature, while version v12 made them stable. Improving the efficiency of CPU-intensive processes is their main objective. It is crucial to differentiate them from I/O operations since worker threads are not made to be very helpful for tasks involving a lot of I/O.

How Worker Threads Work

In order to maintain a responsive application, worker threads offer APIs that allow CPU-intensive operations to be executed without causing the main thread to lag. By moving ArrayBuffer instances between themselves, worker threads can share memory, unlike clusters or child_process.

In order to enable the main thread’s event loop to continue handling incoming user requests, a new worker thread is generated and linked to a new event loop. Each worker thread is so segregated from the others. By enabling the development of isolated V8 runtimes, or V8 Isolates, the Chrome V8 engine which powers the Node process makes this separation possible. Every V8 Isolate has its own micro-task queues and JavaScript heaps.

As a result, although a typical Node.js process operates using:

  1. One process
  2. One thread (the main thread)
  3. One event loop
  4. One JS Engine Instance
  5. One Node.js Instance

Worker threads add more than one thread to that process. After that, each worker thread has its own V8 instance, Node.js instance, and event loop, allowing JavaScript code to run in parallel. For best performance, you should generally match the number of worker threads generated with the number of CPU cores in your system.

Distinguishing Features and APIs of Worker Threads

In contrast to regular threads, worker threads provide particular methods for memory management and inter-thread communication.

  • Memory Transfer: ArrayBuffers are the primary means of transferring memory across threads.
  • Shared Memory: SharedArrayBuffer makes it possible for threads to directly share binary data memory, which may be accessed from any thread.
  • Atomics: This makes it possible to execute multiple processes more effectively and concurrently, enabling JavaScript’s usage of conditional variables.
  • Communication Channels:
    • MessagePort: A communication channel that enables multiple threads to share data, including memory regions, structured data, and other MessagePorts.
    • MessageChannel: Depicts an asynchronous, two-way communication channel for inter-thread communication.
  • Startup Data: WorkerData: This is used to give a worker thread its starting data. It includes a duplicate of the information supplied to the worker’s constructor.

Important APIs for handling worker threads are as follows:

  1. const { Worker, parentPort } = require('worker_threads');: parentPort is an instance of a message port that may be used to communicate with the parent thread, while Worker is an independent JavaScript execution thread.
  2. new Worker(filename) or new Worker(code, { eval: true }): These two methods let you to create a worker thread from either direct code or a file path. It is advised to use a filename for production.
  3. parentPort.on('message'), parentPort.postMessage(data): The worker thread listens for messages from the parent and posts them back to it using parentPort.on(‘message’) and parentPort.postMessage(data).
  4. worker.on('message'), worker.postMessage(data): These are used by the parent thread to post messages to the worker and listen for messages from it.

Creating and Executing New Workers

Worker threads require a Node.js environment, a multi-core system (four cores or more are advised for performance gains), and knowledge of JavaScript concepts such as promises, callbacks, and event loops.

Let’s use a straightforward example to show how worker threads might relieve CPU-intensive tasks: a loop with a million iterations that stalls the main thread. The logic for the main thread and worker thread will be divided into files called main.js and worker.js, respectively.

main.js: This file contains the main thread’s code and is responsible for creating the worker thread.

// main.js
const { Worker } = require('worker_threads');
console.log('Main thread: Starting...');
// Create a new worker thread
// The filename argument points to the script the worker thread will execute
const worker = new Worker('./worker.js', {
    workerData: { valueRequired: 1000000000 } // Pass initial data to the worker
});
// Listen for messages from the worker thread
worker.on('message', (message) => {
    console.log(`Main thread: Message from worker - ${message}`);
});
// Listen for errors from the worker thread
worker.on('error', (err) => {
    console.error(`Main thread: Worker error - ${err}`);
});
// Listen for the worker thread to exit
worker.on('exit', (code) => {
    if (code !== 0) {
        console.error(`Main thread: Worker stopped with exit code ${code}`);
    } else {
        console.log('Main thread: Worker successfully exited');
    }
});
// Simulate other tasks in the main thread (this part should not be blocked)
console.log('Main thread: Doing other light tasks...');
for (let i = 0; i < 5; i++) {
    console.log(`Main thread: Light task ${i}`);
}
console.log('Main thread: Other tasks finished.');

In main.js, we create a Worker object, passing the path to worker.js and initial data via workerData. We also set up listeners for message, error, and exit events from the worker.

worker.js: This file contains the CPU-intensive code that the worker thread will execute.

// worker.js
const { parentPort, workerData } = require('worker_threads');
// A CPU-intensive function
function cpuIntensiveFunction(iterations) {
    let result = 0;
    for (let i = 0; i < iterations; i++) {
        result += i;
    }
    return result;
}
console.log(`Worker thread: Starting CPU-intensive task with ${workerData.valueRequired} iterations...`);
// Get the value passed from the main thread
const valueRequired = workerData.valueRequired;
// Execute the CPU-intensive function
const computationResult = cpuIntensiveFunction(valueRequired);
// Post a message back to the main thread with the result
parentPort.postMessage(`Task finished. Result: ${computationResult}. Worker thread is not blocking main thread.`);
console.log('Worker thread: CPU-intensive task finished.');

In worker.js, workerData is used to receive data from the main thread, and parentPort is used to post messages back to the main thread.

To run the above files: Open your terminal and execute:

node main.js

Output:

Main thread: Starting...
Main thread: Doing other light tasks...
Main thread: Light task 0
Main thread: Light task 1
Main thread: Light task 2
Main thread: Light task 3
Main thread: Light task 4
Main thread: Other tasks finished.
Worker thread: Starting CPU-intensive task with 1000000000 iterations...
Worker thread: CPU-intensive task finished.
Main thread: Message from worker - Task finished. Result: 499999999500000000. Worker thread is not blocking main thread.
Main thread: Worker successfully exited

The output makes it evident that there was no blocking of the main event loop. The “Main thread: Light task” notifications show up right away, meaning that the worker thread was occupied with the cpuIntensiveFunction while the main thread carried on with its tasks. Later, the worker thread’s message confirms that its computation took place in a different event loop.

Use Cases for Worker Threads

Tasks that are CPU-bound and benefit from parallel execution are especially well-suited for worker threads. Applications that are often used include:

Image Resizing: The CPU-intensive process of creating numerous sizes from an original image is known as image resizing.

  • Video Compression
  • Sorting large amounts of data
  • Search algorithms
  • Factorization of large numbers
  • Generating primes in a given range

In Conclusion

Node.js worker threads offer a strong way to carry out lengthy, CPU-intensive activities without interfering with or stopping the main execution thread, whilst JavaScript stays single-threaded. By providing each worker with an isolated environment, they let JavaScript code to run in parallel, greatly enhancing application responsiveness and performance.

Index