We know that JavaScript code runs in a single thread meaning that our code can perform only one task at a time. Similarly, Node.js also runs on a single thread and uses a single event loop that runs on the same thread. However, under the hood, Node.js provides us with many libraries like the libuv and worker_threads that helps in running codes in a multi-threaded environment.
Libuv library handles most of the I/O operations by offloading these tasks to other threads which helps in providing a non-blocking main thread. The problem arises when we need to perform CPU-intensive tasks such as image processing, complex calculations, etc. in parallel to our existing code. Running such CPU-intensive tasks on the main thread can block it and hinder the execution of other requests and tasks. This is where the worker_threads module comes in.
If you need to learn more about the Node.js Process model and architecture, I recommend you check this article.
Introduction to worker_threads Module
The worker_threads module in Node.js introduces ways to create threads and run multiple JavaScript tasks in parallel. Each thread also referred to as a worker has its own V8 and event loop instance and can share memory using ArrayBuffer and SharedArrayBuffer instances as well as communicate with the parent worker (the main thread that created the child worker) using an event-based messaging system.
Although worker_threads doesn’t make JavaScript or Node.js multi-threaded it provides methods to offload CPU-intensive tasks effectively to a Chrome V8 instance that can run JavaScript concurrently thereby saving the main thread from being blocked by such tasks and also utilize the high performance of modern CPU cores.
Note: worker_threads are suitable for CPU-intensive tasks and are not very well suited for I/O operations as Node.js built-in asynchronous I/O operations are more efficient than Workers can be.
Using the worker_threads Module in Node.js
worker_threads module comes pre-packaged with Node.js, we just need to import it into our JavaScript file to start working with it.
Create and move to the working directory
mkdir worker_threads_example
cd worker_threads_example
Now we create two files in the root of our directory, index.js which will form our parent worker and exampleWorker.js which will be the worker we spin up from index.js.
index.js
const { Worker } = require("node:worker_threads");
const worker = new Worker("./workerExample.js", { workerData: "hello" });
worker.on("message", msg => console.log(`Worker message received: ${msg}`));
worker.on("error", err => console.error(error));
worker.on("exit", code => console.log(`Worker exited with code ${code}.`));
Here we imported the Worker constructor in our index.js file for creating a new Worker instance. The Worker constructor takes a filename or path and an object containing workerOptions that can be used with the Worker as parameters. We passed a string with workerData as workerOptions which can later be accessed in the Worker thread that we have created.
We can use the on method to listen to events emitted by the new Worker and perform actions accordingly in the main thread.
workerExample.js
const { workerData, parentPort } = require("node:worker_threads");
console.log("Child Worker");
parentPort.postMessage(`Worker said \"${workerData}\".`);
Remember we passed workerData as a parameter when initializing our worker? We can access that workerData by importing it from worker_threads here. We have also imported parentPort which will help us establish communication between the parent worker and the child worker. The postMessage method of parentPort emits a message event which is listened to by the parent worker.
Now we can run the index.js file using the below command and observe the output.
node index.js
Output:
Child Worker
Worker message received: You said "Hello World".
Worker exited with code 0.
How do Worker Threads Work?
A worker thread’s job is to perform the tasks provided by the parent or main thread. Each worker thread performs its operations independently. Parent and worker threads are able to set up two-way communication via a message channel and can also share data and memory regions among parent and other worker threads. The child worker can write to the message channel using parentPort.postMessage method and the parent worker can write to the message channel by calling the worker.postMessage() function on the worker instance.
Node.js is built using the JavaScript V8 Engine of the Chromium project. V8 allows running isolated V8 runtimes. Isolated V8 instances have their own heaps and micro-task queues. Isolates and threads are in a 1:1 relationship. One isolate is associated with the main thread. One isolate is associated with one worker thread. This feature of V8 is used by the worker_threads module to create isolated V8 instances thus creating independent JavaScript execution threads.
Implementation of Worker Threads in Node.js
Instantiation of a new worker and the communication between a parent worker and a child worker is implemented in C++ at node_worker.cc. This worker implementation in C++ is exposed to our JavaScript files using the worker_threads module. The JavaScript implementation can be defined in two stages:
1. Initialization of the Worker
- This operation is majorly handled by the worker.js script.
- When a Worker object is instantiated in the parent worker script using the worker_threads module, an empty C++ worker object is created with a threadID that hasn’t started yet.
- When the worker object is created, an empty Initialization Message Channel is created by the parent worker.
- A public JavaScript message channel is created by the worker initialization script which is used to pass messages between the parent worker and child worker.
- The parent worker initialization script calls into C++ and passes the initialization metadata to the initialization message channel which is then passed to worker execution script.
2. Execution of the Worker
- Execution of the child worker is handled by the worker_thread.js script.
- An independent instance of the V8 runtime or V8 is created and assigned to the child worker.
- libuv is initialized, enabling the worker thread to have its own event loop that is independent of the parent thread.
- The worker’s event loop is started while the worker execution script executes by calling into C++ and reading the initialization metadata from the initialization message channel.
Conclusion
Worker_threads were introduced as an experimental feature in Node v10 but with Node v12 it became a stable feature. Worker threads don’t change the threading capabilities of the JavaScript language but enable applications to use multiple isolated JavaScript workers or V8 instances to execute CPU-heavy tasks without blocking the main thread.
Wondering what to read next? Learn about the Node.js child process here.
References
https://nodejs.org/api/worker_threads.html