A quick introduction to parallelism in JavaScript

Written by Jawakar Durai on February 17, 2020; tagged under chrome, javascript

Parallelism in JavaScript? you might be thinking that we are already doing parallel programming in JavaScript using setTimeout()setInterval()XMLHttpRequest, async/await and event handlers. But that’s just not true. As developers, we have been mimicking parallel programming because of JavaScript’s single threading nature using event loop.

Yes, all of the above techniques are asynchronous and non-blocking. But, that doesn’t necessarily mean parallel programming. JavaScript’s asynchronous events are processed after the currently executing script has yielded.

Meet “Web Workers”

Web Workers introduce real parallel programming to JavaScript.

A Web Worker allows you to fire up long-running computationally expensive task(s) which helps the already-congested main thread to spend all of it’s time focusing on layout and paint 😃

Understand web worker in renderer process

Where Web Workers goes in a renderer process (a process in chrome, which is responsible for everything that happens inside a tab):

renderer

Every chrome tab has its own renderer process, chrome will also try to have a single renderer process for tabs with the same domain, if you want to know about how the same renderer process shares one v8 instance, Good luck!

Renderer process creates multiple threads:

Just for fun, you can check processes (not only renderer processes) running in chrome by:

Menu(3 verticle dots on top right) > More tools > Task Manager

How to use?

So, whenever we do

const worker = new Worker("worker.js")

new worker thread will be created and code we have in worker.js will run in there.

Communicating with worker

You can’t directly call functions that are in the worker thread. Communication between the main thread and the worker is done via sending and listening to message event.

Example: Finding length of a string:

In index.js

// message event helps to communicate between threads.

// to listen for incoming messages from the worker.
worker.addEventListener('message', e => {
  console.log(`Message from worker ${e.data}`)
})
// to send message to the worker
worker.postMessage("Hello from main")

AND

in worker.js

// to listen for incoming messages from the main thread or other worker.
// Yes, a worker can also create subworkers.
self.addEventListener('message', e => {
  console.log(`Message from main thread ${e.data}`)
  // send a message back to the main thread
  self.postMessage(e.data.length)
})

Here, postMessage takes one argument message, that will be available in the delivered event’s data key, value of the message should be supported by structured cloning algorithm.

You can also use second argument to send array of Transferable objects.

worker.postMessage(message, [transfer]);

Worker scope

Both self and this of the worker, references the global scope of the worker.

From previous example self.postMessages(e.data.length) can also be written as postMessages(e.data.length)

Limitations

Because of the multi-threading behavior and thread-safety of worker threads, it doesn’t have some features, which the main thread has:

See other available functions and classes in MDN.

Real-world example

Enough talk, let’s code.

We’ll write a small application that does some intense task using web workers: accept an image and apply filter.

Run in terminal

$ mkdir filter

$ touch index.html script.js worker.js

Html setup:

<!--index.html -->

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width" />
    <title>Worker</title>
  </head>
  <body>
    
    <!-- Input to select filter -->
    <p>
      <label>
        Choose a filter to apply
        <select id="filter">
          <option value="none">none</option>
          <option value="grayscale">grayscale</option>
          <option value="brighten">brighten by 20%</option>
          <option value="threshold">threshold</option>
        </select>
      </label>
    </p>

    <!-- Get image -->
    <form accept-charset="utf-8" action="asd">
      <label>Image to filter</label>
      <input type="file" name="image" id="image-to-filter" alt="Enter image to filter"> 
    </form>

     <!-- show output here -->
    <canvas id="output"></canvas>

    <script src="script.js"></script>
  </body>
</html>

Script setup:

//script.js

document.addEventListener('DOMContentLoaded', () => {

  const inputImage = document.getElementById('image-to-filter');
  const output  = document.getElementById('output');
  const filter = document.querySelector('#filter');

  // Context let you draw on the canvas
  const outputContext = output.getContext('2d');

  // This will return HTMLImageElement (<img>)
  const img = new Image();

  let imageData;

  const drawImg = () => {
    output.height = img.height;
    output.width = img.width;

    outputContext.drawImage(img, 0, 0);

    // returns ImageData object, we can get pixel data from ImageData.data 
    // https://developer.mozilla.org/en-US/docs/Web/API/ImageData/data
    imageData = outputContext.getImageData(0, 0, img.height, img.width);
  }

  // Getting image from the user
  inputImage.addEventListener('change', e => {
    
    // API to read files from the user
    const reader = new FileReader();

    // will be called once we read some file using any of the reader 
    // function available for FileReader
    reader.onload = e => {
      img.src = e.target.result;
      img.style.display = 'none';

      // will be called once the src is downloaded
      img.onload = () => {
        document.body.appendChild(img);
        drawImg();
      };
    };

     // reads the file as a dataURL
    reader.readAsDataURL(e.target.files[0]);
  });

})

Okay, what’s going on? This is mostly implementation details of how we are getting the image from the user and storing the values in imageData.

This will create a worker to run the code in worker.js:

// script.js

const worker = new Worker('worker.js');

Setting up a worker in worker.js:

If you are like me, first intuition you would get is, let’s just pass a function to call back to the worker and get it over with but, you can’t pass a function to the web worker, because functions are not supported by structured cloning algorithm.

// worker.js

const Filters = {};

Filters.none = function none() {};

Filters.grayscale = ({data: d}) => {
  for (let i = 0; i < d.length; i += 4) {
    const [r, g, b] = [d[i], d[i + 1], d[i + 2]];

    // CIE luminance for the RGB
    // The human eye is bad at seeing red and blue, so we de-emphasize them.
    d[i] = d[i + 1] = d[i + 2] = 0.2126 * r + 0.7152 * g + 0.0722 * b;
  }
};

Filters.brighten = ({data: d}) => {
  for (let i = 0; i < d.length; ++i) {
    d[i] *= 1.2;
  }
};

Filters.threshold = ({data: d}) => {
  for (var i = 0; i < d.length; i += 4) {
    var r = d[i];
    var g = d[i + 1];
    var b = d[i + 2];
    var v = 0.2126 * r + 0.7152 * g + 0.0722 * b >= 90 ? 255 : 0;
    d[i] = d[i + 1] = d[i + 2] = v;
  }
};

onmessage = e => {
  const {imageData, filter} = e.data;
  Filters[filter](imageData);
  postMessage(imageData);
};

Send a message to the worker:

// script.js

const filter = document.querySelector('#filter');
let imageData;

filter.addEventListener('change', e => sendImageDataToWorker())

const sendImageDataToWorker = () => { 
  worker.postMessage({imageData, filter: filter.value})
}

Here, we are listening for a change in the filter, then sending imageData and which filter to use to the worker.

Listen for the message from worker

//script.js

worker.onmessage = () => e => outputContext.putImageData(e.data, 0, 0);

We are listening for a message from the worker, then changing the image data in the context.

Finally script.js will be

// script.js

document.addEventListener('DOMContentLoaded', () => {
  const worker = new Worker('filter_worker.js');

  const inputImage = document.getElementById('image-to-filter');
  const outputC = document.getElementById('output');
  const filter = document.querySelector('#filter');
  const oCtx = outputC.getContext('2d');
  const img = new Image();
  let imageData;

  const sendDataToWorker = () =>
    worker.postMessage({imageData, filter: filter.value});

  const receiveFromWorker = e => oCtx.putImageData(e.data, 0, 0); 

  worker.onmessage = receiveFromWorker;

  const drawImg = () => {
    outputC.height = img.height;
    outputC.width = img.width;

    console.log(img.height);
    oCtx.drawImage(img, 0, 0); 
    imageData = oCtx.getImageData(0, 0, img.height, img.width);
    sendDataToWorker();
  };  

  inputImage.addEventListener('change', e => {
    const file = e.target.files[0];

    const reader = new FileReader();
    reader.onload = e => {
      img.src = e.target.result;
      img.style.display = 'none';
      img.onload = () => {
        document.body.appendChild(img);
        drawImg();
      };  
    };  

    reader.readAsDataURL(file);
  }); 

  filter.addEventListener('change', e => sendDataToWorker());
});

Try live example.

Code in GitHub.

Terminating a worker

You can terminate the worker from the main thread by calling worker.terminate()

worker.terminate()

If you want to terminate from the worker itself you can call the worker’s close() function


close()

Upon calling close(), any queued tasks present in the event loop are discarded and the web worker scope is closed. The web worker is also given no time to clean up, so abruptly terminating a worker may cause memory leaks.

Other web workers

In reality, there are two types of web worker: Dedicated and Shared, for the scope of this post we only used dedicated workers.

Use cases

If you have any questions or feedback, feel free to drop us a mail at team@codemancers.com.