- Published on
Multithreading in JavaScript, and why we need it
- Authors
- Name
- Nadir Tellai
Multithreading in JavaScript
Introduction
Around 2011 HTML5 introduced Web Workers, and in 2018 Node.js version 10.5.0 brought in Worker Threads and both are features that allows javascript to use multithreading. At first glance, JavaScript seems quite efficient in handling concurrent operations thanks to the event loop. This efficiency leads to an intriguing question: Why in the world would we need to consider multithreading in a language that has achieved concurrency with a single-threaded model?
In this blog I will try to demystify this confusion and explore the benefits of multithreading in JavaScript.
Multithreading
Differences Between Threads and Processes
Before jumping to multithreading we need to understand what are processes, threads and the relation between them.
Process: A process is an instance of a program that is being executed (a node.js server for example). It has its own memory and cannot see nor access the memory of other running programs.
Thread: A thread is a component of a process responsible for executing program instructions. Multiple threads can exist independently within the same process and share the same memory. Furthermore, threads can communicate with one another through messages.
Definition of multithreading
Multithreading is running different parts of a program simultaneously within a single process. for the goal of enhancing performance and efficiency.
Multithreading in JavaScript
By design, JavaScript is single-threaded, running on a single execution thread known as the "main thread" and handles concurrency through asynchronous programming, using events and callbacks.
However, certain scenarios require true parallel execution. these include heavy computation tasks which are CPU-intensive operations like data and image processing.
To understand this better, consider a function counter that counts to a given number.
function counter(n) {
return new Promise((resolve)=>{
console.time("counter execution time");
let count = 0
for (let i = 0; i<=n; i++)
count ++
console.timeEnd("counter execution time");
resolve()
})
}
As you can see we are measuring the execution time. Now let's run this function multiple times, while measuring the total execution time also.
async function Main(){
console.time("all counters execution time");
await counter(10**9)
await counter(10**9)
await counter(10**9)
await counter(10**9)
console.timeEnd("all counters execution time");
}
the output of running the Main function is this:
counter execution time: 667.787ms
counter execution time: 683.897ms
counter execution time: 671.317ms
counter execution time: 666.119ms
all counters execution time: 2.698s
The average execution time for the counter function is: 670 ms, multiply it by four, and you will get 2680 ms which is similar to the all counters execution time we got. make sense right!
now let's try running the four counter concurrently using the Promise.all.
async function Main(){
console.time("all counters execution time");
await Promise.all([
counter(10**9),
counter(10**9),
counter(10**9),
counter(10**9)
])
console.timeEnd("all counters execution time");
}
the result
counter execution time: 643.577ms
counter execution time: 634.094ms
counter execution time: 637.543ms
counter execution time: 637.39ms
all counters execution time: 2.558s
As we can notice the total execution time is still close to the sum of the execution time of all counters. which means that javascript didn't run the counters concurrently, due to the fact that each counter function is blocking the main thread, and using Promises is not going to magically make them run in parallel.
In real life blocking the main thread means blocking the entire app, your web app is frozen or your server can't handle more requests.
And that's where multithreading comes in handy, allowing us to carry the blocking operations out of the main thread.
import {Worker} from "worker_threads"
function counter(n) {
return new Promise((resolve)=>{
// creating a worker thread to handle the counting
const worker = new Worker("./counter_worker.js", {
workerData: {
iterations: n
}
})
worker.once('message', (data)=>{
// resolve the promise when the thread is done counting
resolve()
})
})
}
async function Main(){
console.time("all counters execution time");
await Promise.all([
counter(10**9),
counter(10**9),
counter(10**9),
counter(10**9)
])
console.timeEnd("all counters execution time");
}
// counter_worker.js
import { workerData, parentPort } from "worker_threads"
function counter(n) {
console.time("counter execution time");
let count = 0
for (let i = 0; i<=n; i++)
count ++
console.timeEnd("counter execution time");
}
counter(workerData.iterations)
// inform the main thread that the execution is done
parentPort.postMessage("done")
In the Example above we have used Node.js worker_threads to offload each counter function to a new thread.
the output:
counter execution time: 660.988ms
counter execution time: 661.331ms
counter execution time: 661.452ms
counter execution time: 660.4ms
all counters execution time: 727.027ms
As we can see thanks to the worker threads the total execution time is almost the same as the execution time of a single counter which means that we have achieved true concurrency through multithreading.
Conclusion
Multithreading in JavaScript, through Web Workers and Node.js Worker Threads, provides a powerful solution for CPU-intensive tasks that the single-threaded model struggles with. By enabling true parallelism, the JavaScript ecosystem continues to solidify its position as a powerful language for both client-side and server-side development.