Workers and Promises
JavaScript provides concurrency when using async functions; even if a single processor does all the work, several tasks may be ongoing simultaneously. To get better performance, you may use web workers (at the front end, in a browser) and worker threads (at the back end). This article will show you how to work with workers in a functional, efficient way.
Discover how at OpenReplay.com.
The basic mechanism for using workers is messaging, but that has some limitations—plus, it really isn’t very functional!— so let’s see how we may use workers in a functional way to optimize the performance of our code.
A sample worker
In a previous article, we saw that a naïve straightforward implementation of the Fibonacci numbers could have serious time performance issues. Let’s use that as a worker, so we’ll have something to do that might take a long time, depending on whatever argument we pass to it.
// File: ./workers/sample_fib_worker.mjs
import { parentPort } from 'worker_threads';
const fibonacci = (i) => (i < 2 ? i : fibonacci(i - 1) + fibonacci(i - 2));
function fakeWorker(n) {
const result = fibonacci(n);
parentPort.postMessage(`fib(${n})=${result}`);
}
parentPort.on('message', fakeWorker);
The parentPort
attribute is how the worker communicates with the parent. Then, we have the definition of the i-th Fibonacci number and how we’ll use it. Finally, the last line defines, by using the onmessage
method, what the worker should do with the argument it receives. The received value will be passed to the fakeWorker
function, which will use the postMessage
method to return the calculation’s result; in this case, something like fib(7)=13
.
Using workers through messages
Suppose we wanted to perform the Fibonacci calculations in a thread to avoid blocking Node’s event loop (at the back end) or seemingly showing a dead, non-responding screen (at the front end). The simplest way of doing this is as follows; we’ll use Node.js for simplicity, but code would be similar at a browser.
import { Worker } from 'worker_threads';
const worker = new Worker('./workers/sample_fib_worker.mjs');
worker.postMessage(40);
worker.postMessage(35);
worker.postMessage(45);
worker.postMessage(15);
worker.on('message', console.log);
process.stdin.resume(); // keep Node running
process.on('SIGINT', () => { // on Control+C, end
worker.terminate();
process.exit();
});
First, we create a worker based on the code we saw in the previous section. We send several messages (using the postMessage
method), and the four requests will be queued and processed in the order they are received. The last lines are just to keep Node running (otherwise, the program would end before the worker had a chance to do its job) and to clean up (by terminating the worker) on Control+C.
The output is the following.
fib(40)=102334155
fib(35)=9227465
fib(45)=1134903170
fib(15)=610
This works well, but if you wanted to call the same worker several times in parallel, that would require creating separate workers and handling messages in different ways. (As is, the four calls in our example are processed serially.) Also, message passing doesn’t look very functional, so let’s try using promises.
Using workers through promises
We can create a promise out of a worker and have it resolved when the worker sends its result.
import { Worker } from 'worker_threads';
const callWorker = (filename, value) =>
new Promise((resolve, reject) => {
const worker = new Worker(filename);
worker.on('message', (value) => {
resolve(value);
worker.terminate();
});
worker.on('error', (err) => {
reject(err);
worker.terminate();
});
worker.postMessage(value);
});
The callWorker
function builds a promise, given the worker to create and the value to pass to it. If the worker succeeds, the resolve
function is used to fulfill the promise; on an error, reject
is called instead. In both cases, we terminate the worker because it’s done.
Now, we can run several workers in parallel.
callWorker('./workers/sample_fib_worker.mjs', 40).then(console.log);
callWorker('./workers/sample_fib_worker.mjs', 35).then(console.log);
callWorker('./workers/sample_fib_worker.mjs', 45).then(console.log);
callWorker('./workers/sample_fib_worker.mjs', 15).then(console.log);
We are creating four promises that will run in parallel. We are just logging their results here, but we could obviously do other things. The output will be in different order because the four calls do not take the same time to complete.
fib(15)=610
fib(35)=9227465
fib(40)=102334155
fib(45)=1134903170
This is very good and enables us to parcel out work to threads and work in parallel as well. However, there’s an issue; every time you use callWorker,
a new worker has to be created (so code must be read and parsed again), which will cause delays. What can we do?
Using workers with a pool
As we saw earlier, workers can stay unterminated, and as they receive new messages, they can reply to them. This suggests a solution: keep the workers in a pool, do not terminate them, and reuse them if possible. When we want to call a worker, we first check the pool to see if there’s already an appropriate available (meaning, not in use) worker. If we find it, we can directly call it, but if not, we create a new one.
The pool entries will have three attributes:
worker
, the worker itselffilename
, which was used to create the workeravailable
, which is set to false when the worker is running, and to true when it’s done and ready to do a new calculation
The callWorker
method now becomes:
// file: ./workers/pool.mjs
import { Worker } from 'worker_threads';
const pool = [];
export const callWorker = (filename, value) => {
let available = pool
.filter((v) => !v.inUse)
.find((x) => x.filename === filename);
if (available === undefined) {
available = {
worker: new Worker(filename),
filename,
inUse: true
};
pool.push(available);
}
return new Promise((resolve, reject) => {
available.inUse = true;
available.worker.on('message', (x) => {
resolve(x);
available.inUse = false;
});
available.worker.on('error', (x) => {
reject(x);
available.inUse = false;
});
available.worker.postMessage(value);
});
};
The callWorker
code has two parts. Initially, it searches the pool for an available worker (inUse
should be false) with the correct filename
. If the search fails, a new worker is added to the pool. After that, the logic is similar to what we saw in the previous section; the main difference is that we must update the inUse
attribute appropriately.
Using this pool works the same way as earlier, but the difference is that it won’t create unnecessary workers if it can reuse previous ones. We still use promises and have parallel processing, but we now add a bit more efficiency; a win!
Conclusion
Using workers provides several benefits, like processing in different threads, which isn’t as common in JavaScript as in other languages such as Java or Rust. Using workers through promises enables a more functional working style, and the final addition of a pool gives extra performance; several wins for us!
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.