Back

Workers and Promises

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.

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 itself
  • filename, which was used to create the worker
  • available, 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.

OpenReplay