Friday, August 2, 2024
No menu items!
HomeCloud ComputingIntro to multithreaded JavaScript

Intro to multithreaded JavaScript

The JavaScript language is one of the wonders of the software world. It is incredibly powerful, flexible, and versatile. One limitation of its fundamental design, however, is its single-threaded nature. Traditional JavaScript appears to handle parallel tasks, but that is a trick of syntax. To achieve true parallelism, you need to use modern multithreading approaches like web workers and worker threads.

Parallelism vs. concurrency

The most basic way to understand the difference between parallelism and concurrency is that concurrency is semantic whereas parallelism is implementation. What I mean is that concurrency lets you tell the system (semantics) to do more than one thing at once. Parallelism simply performs multiple tasks simultaneously (implementation). All parallel processing is concurrent, but not all concurrent programming is parallel.

In vanilla JavaScript, you can tell the platform to do a couple of things:

function fetchPerson(id) {
return new Promise((resolve, reject) => {
fetch(`https://swapi.dev/api/people/${id}`)
.then(response => response.json())
.then(data => resolve(data))
.catch(error => reject(error));
});
}

const lukeId = 1;
const leiaId = 5;

console.log(“Fetching Star Wars characters…”);

// Fetch character data concurrently (non-blocking)
Promise.all([fetchPerson(lukeId), fetchPerson(leiaId)])
.then(data => {
console.log(“Characters received:”);
console.log(data[0]); // Data for Luke Skywalker (ID: 1)
console.log(data[1]); // Data for Leia Organa (ID: 5)
})
.catch(error => console.error(“Error fetching characters:”, error));

console.log(“Moving on to other things…”);

// Fetching Star Wars characters…
// Moving on to other things…

Characters received:
{name: ‘Luke Skywalker’, height: ‘172’, mass: ’77’, …}
{name: ‘Leia Organa’, height: ‘150’, mass: ’49’, …}

This appears to fetch data on Luke and Leia at the same time, by using Promise.all to execute two fetch calls together. In truth, though, JavaScript will schedule each task to be handled by the one application thread. 

This is because JavaScript uses an event loop. The loop picks stuff off of a queue so fast that it often appears to happen concurrently—but it’s not a truly concurrent process.

To really do two things at once, we need multiple threads. Threads are an abstraction of the underlying operating system’s processes and their access to the hardware, including multi-core processors.

Multithreading with web workers

Web workers give you a way to spawn threads in a web browser. You can just load a separate worker script from the main script, and it will handle asynchronous messages. Each message handler is run in its own thread, giving you true parallelism.

For our simple Star Wars API example, we want to spawn threads that will handle or fetch requests. Using web workers for this is overkill, obviously, but it keeps things simple. We want to create a web worker that will accept a message from the main thread and issue the requests.

Here’s what our main script (main.js) looks like now:

function fetchPersonWithWorker(id) {
return new Promise((resolve, reject) => {
const worker = new Worker(‘worker.js’);

worker.onmessage = function(event) {
if (event.data.error) {
reject(event.data.error);
} else {
resolve(event.data);
}
worker.terminate(); // Clean up the worker after receiving the data
}

worker.postMessage({ url: `https://swapi.dev/api/people/${id}` });
});
}

const lukeId = 1; const leiaId = 5;

console.log(“Fetching Star Wars characters with web worker…”);

// Fetch character data concurrently (truly parallel)
Promise.all([fetchPersonWithWorker(lukeId), fetchPersonWithWorker(leiaId)])
.then(data => {
console.log(“Characters received:”);
console.log(data[0]); // Data for Luke Skywalker (ID: 1)
console.log(data[1]); // Data for Leia Organa (ID: 5)
})
.catch(error => console.error(“Error fetching characters:”, error));

console.log(“Moving on to other things…”);

This is similar to the first example, but instead of using a function that works locally in Promise.all, we pass in the fetchPersonWithWorker function. This latter function creates a Worker object called worker, which is configured with the worker.js file. 

To learn more about JavaScript promises, see 5 ways to use JavaScript promises.

Once the worker object is created, we provide an onmessage event on it. We will use this to handle the messages coming back from the worker. In our case, we resolve or reject the promise we are returning (consumed by Promise.all in the main script), then we terminate the worker.

After that, we call worker.postMessage() and pass in a simple JSON object with a URL field set to the URL we want to call. 

The web worker

Here’s the other side of the equation, in worker.js:

// worker.js
onmessage = function(event) {
console.log(“onmessage: “ + event.data); // {“url”:”https://swapi.dev/api/people/1″}
const { url } = event.data;
fetch(url)
.then(response => response.json())
.then(data => postMessage(data))
.catch(error => postMessage({ error }));
}

Our simple onmessage handler accepts the event and uses the URL field to issue the same fetch calls as before, but this time we use postMessage() to communicate the results back to main.js.

So, you can see we communicate between the two worlds with messages using postMessage and onmessage. Remember: the onmessage handlers in the worker occur asynchronously in their own threads. (Don’t use local variables to store data—they are likely to be wiped away).

Shared workers

A variation on web workers, the shared worker, gives you a web worker-like construct that can be shared between contexts like iframes and windows, instead of just accessed from the script that spawns it.

Server-side threading with worker threads

Now let’s take a look at the server side, using Node.js. In this case, instead of web workers, we use the concept of a worker thread. A worker thread is similar to a web worker in that we pass messages back and forth from the main thread to the worker. 

For example, let’s say we have two files, main.js and worker.js. We’ll run main.js (using the command: node main.js) and it will spawn a thread by loading worker.js as a worker thread. Here is our main.js file:

const { Worker } = require(‘worker_threads’);

function fetchPersonWithWorker(id) {
return new Promise((resolve, reject) => {
const worker = new Worker(‘./worker.js’, { workerData: id });

worker.on(‘message’, (data) => {
if (data.error) {
reject(data.error);
} else {
resolve(data);
}
worker.terminate();
});

worker.on(‘error’, (error) => reject(error));

let url = `https://swapi.dev/api/people/${id}`;
worker.postMessage({ url });
});
}

const lukeId = 1;
const leiaId = 5;

console.log(“Fetching Star Wars characters with worker threads…”);

Promise.all([fetchPersonWithWorker(lukeId), fetchPersonWithWorker(leiaId)])
.then(data => {
console.log(“Characters received: “+ JSON.stringify(data) );
console.log(data[0]); // Data for Luke Skywalker (ID: 1)
console.log(data[1]); // Data for Leia Organa (ID: 5)
})
.catch(error => console.error(“Error fetching characters:”, error));

console.log(“Moving on to other things…”);

We import Worker from the worker_threads module, but note that it’s built into Node, so we don’t need NPM for this. To launch the worker, we create a new Worker object and give it the worker.js file as a parameter. Once that is done, we add a message listener that resolves or rejects our promise—this is exactly like we did for the web worker. We also terminate the worker when done, to clean up the resources.

Finally, we send the worker a new message containing the URL we want to retrieve.

The worker thread

Here’s a look at worker.js:

const { parentPort } = require(‘worker_threads’);

parentPort.on(‘message’, (msg) => {
console.log(“message(worker): ” + msg.url);

fetch(msg.url)
.then(response => response.json())
.then(data => parentPort.postMessage(data))
.catch(error => parentPort.postMessage({ error }));
});

Again we import from worker_threads, this time the parentPort object. This is an object that allows us to communicate with the main thread. In our case, we listen for the message event, and when it is received, we unpack the url field from it and use that to issue a request.

In this way we have achieved truly concurrent requests to the URLs. If you run the sample with node main.js, you’ll see the data from both URLs output to the console.

Conclusion

You’ve seen the fundamental mechanisms for achieving truly parallel threads in JavaScript, both in the browser and on the server. How these are run depends on the operating system and hardware profile of the actual host environment, but in general, they give you access to multithreaded processes.

While JavaScript doesn’t support the range and depth of concurrent programming found in a language like Java, web workers and worker threads get you the basic mechanism for parallelism when you need it.

You can find the runnable samples for this article on GitHub here. To run the web worker example, type

$ node server.js

from the root directory.

To run the worker thread example, type:

~/worker-thread $ node main.js

All the brilliance of AI on minimalist platforms | InfoWorldRead More

RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Most Popular

Recent Comments