27 Performance How to Understand Parallelism and Concurrency in Java Script Part 1

27 Performance How to Understand Parallelism and Concurrency in JavaScript Part 1 #

Hello, I’m Ishikawa.

In the previous unit, we learned about the programming patterns in “The Way of JavaScript” and the syntax and algorithms in “The Law of JavaScript”. We also studied the design patterns in “The Art of JavaScript”. Starting today, we will delve into the non-functional optimization aspect of “The Art of JavaScript”. From our previous learning, we can see that programming patterns, algorithms, and design patterns are all interconnected. Similarly, parallelism, concurrency, and asynchronous programming, which we will discuss today, are an extension of the concepts of asynchronous programming mentioned in the previous lesson.

So today, let’s understand the relationship between parallelism, concurrency, and asynchronous programming, as well as the implementation of multi-threaded development in JavaScript.

Threads vs Processes, Concurrency vs Parallelism #

Before discussing concurrency and parallelism in JavaScript, let’s first take a look at the relationship between programs, threads, and processes. In a program, there can be multiple processes. Within a process, there can also be multiple threads. Memory is not shared between processes, but it can be shared within a thread. The following diagram provides a more intuitive understanding.

Image

Concurrency refers to tasks that are performed within the same time interval, while parallelism refers to tasks that are performed at the same moment. Unless there is a multi-core CPU, multiple threads themselves do not support parallelism and can only achieve concurrency through thread switching. The following diagram provides a more intuitive understanding of concurrency and parallelism.

Image

How do front-end and back-end languages support concurrent development? #

If we look at the language itself, JavaScript does not have a specification for threads and does not provide an interface for developers to create threads. So JavaScript itself is single-threaded, which means there is only one virtual machine, instruction pointer, and garbage collector in the JavaScript execution environment. Even in different realms, such as different iframes on the browser side or different contexts on the server side, only one JavaScript instruction is executed at a time. Therefore, JavaScript itself is not thread-friendly.

Image

This is quite different from other front-end languages. In iOS, Android, and Unity development, developers have powerful toolkits such as Grand Central Dispatch, WorkManager, and Job System to support concurrent development. These languages not only support multithreading, but also facilitate parallel development because the programs written in these languages have deep interaction with the front-end users. In the case of Unity, for example, a large amount of 3D calculations are required, so performance must be taken seriously to make the application respond quickly. Similarly, JavaScript, as a front-end language, falls short in this aspect compared to other front-end languages.

Image

In contrast to the front-end, in many advanced back-end languages such as Ruby or Python, regardless of whether the CPU is single-core or multi-core, threads are limited to concurrency due to the Global Interpreter Lock (GIL). Therefore, when doing multi-threaded development, it is important to consider whether parallelism is restricted, because if parallelism is restricted and only concurrency is allowed, the cost of thread switching may outweigh its benefits. In the JavaScript environment, there is no Global Interpreter Lock (GIL) restricting threads, but JS has its own limitations, such as restricting the direct sharing of objects between threads, which also affects the use of threads in the front-end from a certain perspective.

Image

Parallel Development in JavaScript #

From the example above, we can see that front-end languages have better support for threads and parallelism compared to back-end languages. While JavaScript does have support for functional programming and has strengthened the design concept of reactive programming in recent years, it still lacks capabilities in multi-threading and parallel development. However, this does not mean that multi-threading is completely impossible to achieve in the front-end. Let’s take a look at the possibilities of implementing parallel development in JavaScript.

Asynchrony in JavaScript #

First, let’s consider why we mentioned parallel development in relation to asynchronous programming. In recent years, most of the so-called “multi-tasking” in JavaScript is achieved by asynchronously executing task units after breaking them down into smaller tasks. These different tasks can be seen as a kind of “concurrency”.

The task decomposition mentioned here is done by using event callbacks and promises, as we discussed in the previous section. Even the await keyword is ultimately implemented using callbacks. In addition to networking, the same task decomposition principles also apply to user events and timeouts, as mentioned in the previous section. Since these callbacks are asynchronous, we know that the concurrency they bring is not parallel, meaning there is no other code executing at the same time as the code inside the callback. In other words, in an asynchronous scenario, only one call stack is active at any given moment in time.

In front-end development, user experience is mainly defined by smoothness and responsiveness. But how can we quantify these two aspects?

Responsiveness is generally measured by whether our program can respond within 100 milliseconds, while smoothness is measured by the rendering of 60 frames per second (fps). So, dividing 1000 milliseconds by 60 frames gives us approximately 16.6 milliseconds per frame, which is known as the frame budget in front-end development. However, in practice, these tasks are performed by the browser. As developers, our core work is to monitor user behavior on the page, trigger events, run related event handlers, calculate styles, render layouts, and so on.

In previous discussions on reactive programming and design patterns, we spent a lot of time learning how to use asynchronous and incremental approaches to allow applications to handle tasks “concurrently” in order to respond faster and render on demand. However, these optimizations can only solve performance problems to a certain extent. This is because the UI main thread of the browser is frame-synchronized (lock step), as mentioned earlier, which means that only one JavaScript instruction can be executed at a time. Therefore, asynchronous operations also come with costs, and different browsers may have different levels of support for them. So, responsive and progressive designs still operate on a serial basis rather than a parallel basis.

Implementing Parallelism in JavaScript with Web Workers #

To achieve true parallel development and make a qualitative difference, we can use Web Workers. Web Workers can break the frame synchronization and allow us to execute other instructions in parallel worker threads alongside the main thread. Now, you might wonder, didn’t we say earlier that JavaScript doesn’t support multi-threading? So why are we talking about Web Workers, which supports multi-threading? The reason is that, unlike other programming languages, JavaScript does not have a unified threading implementation.

As we know, browsers such as Chrome, Safari, and FireFox have their own virtual machines to run JavaScript. Like the file system, network, setTimeout, and device functionalities used in front-end development, they are all provided by the embedding environment of Node or the browser’s virtual machine, not by the language itself. Therefore, the API for multi-threading is also provided by the browser. The API provided by the browser or virtual machine to support multi-threading is Web Workers. It is defined by the World Wide Web Consortium (W3C) and the Web Hypertext Application Technology Working Group (WHATWG), not by TC39.

Creating a Web Worker is very simple. We just need to use a new statement similar to the following. Then, we can use postMessage to pass messages between the main.js and worker.js and both sides can receive messages from the other using onMessage.

// main.js
var worker = new Worker('worker.js');
worker.postMessage('Hello world');
worker.onmessage = (msg) => {
  console.log('message from worker', msg.data);
}

// worker.js
self.onmessage = (msg) => {
  postMessage('message sent from worker');
  console.log('message from main', msg.data);
}

In JavaScript, there are several different types of worker threads: dedicated workers, shared workers, and service workers. Let’s take a closer look at their uses. For now, let’s focus on dedicated workers.

A dedicated worker can only be used within a single realm. It can also have many levels, but having too many levels may cause confusion, so it needs to be used with caution.

Image

In contrast to dedicated workers, shared workers can be accessed by different tabs, iframes, and workers, as the name suggests. However, shared workers are restricted to JavaScript running on the same origin. Although the concept of shared workers makes sense, there is currently very limited support for shared workers among browsers or virtual machines, and it is almost impossible to polyfill the support. Therefore, we will not go into depth on this topic.

Image

Finally, let’s take a look at service workers. As the name suggests, since it is a “service”, it is related to the back-end. Its main feature is that it can run even when the front-end pages are closed. As mentioned in a previous section on reactive design patterns, concepts such as pre-caching and server push are closely related to service workers. When the browser makes the first request, the service worker can push the content that should be cached and loaded to the front-end for pre-caching. When the front-end makes a request again, it can first check if there is a cached response, and only make a request to the server if there is no cache. Through this pattern, we can greatly improve the performance of web applications.

Image

Information Transmission Mode #

To achieve parallel development with multiple threads in JavaScript, the core requirement is to enable the interaction of information between the main thread and worker threads. Now let’s see how we can achieve this through the definition provided by Web Worker and the interfaces provided by the JS engine.

Structured Clone Algorithm #

JavaScript is designed to be frame-synchronized (lock step), which means that only one JavaScript instruction can be executed at a time on the UI main thread running in main.js. This allows the frontend to focus on rendering and user interaction without having to put too much effort into interacting with the worker. However, this design also comes with some drawbacks. For example, JavaScript’s virtual machine design is usually not thread-safe. A thread-safe data structure can be shared between workers, and by using mutex locks, it can ensure that different threads can avoid side effects caused by race conditions when accessing and modifying the same data simultaneously.

Since JavaScript’s virtual machine is not thread-safe and there are no mutex locks, object data cannot be shared between workers. If data cannot be shared, how is the data passed between the main thread and the worker thread in the previous postMessage example?

This is because the information transmitted in the postMessage example is not directly accessed, but rather copied and then transmitted. The copying algorithm used here is similar to what we previously called deep copy, also known as the structured clone. This means that it is not possible to directly access and control the data in main.js from worker.js. This method of communication between the main and worker environments, through structured copying, is called message passing.

Request-Response Mode #

In the previous “hello world” example, we only passed a string, so there may not be any problems. But when we need to pass more complex data structures, problems arise.

For example, if we need to pass a function call with parameters as shown below, we need to first convert the function call into a sequence, which is a corresponding local call to a remote procedure call (RPC). Based on the asynchronous nature of postMessage, it returns a promise that can be awaited instead of returning a result.

isOdd(3);
is_odd|num:3

worker.postMessage('is_odd|num:3');
worker.postMessage('is_odd|num:5');

worker.onmessage = (result) => {
  // 'true'
  // 'false'
};

In other words, after the calculation is done in worker.js, the result will be returned. At this point, another problem arises: how can we know the correspondence between the returned result and the request if we have many different requests?

To solve this problem, we can use JSON format for JSON-RPC. The structured clone algorithm supports primitive data types other than Symbol, including Boolean, null, undefined, Number, BigInt, and the string that we used. The structured clone algorithm also supports various data structures, including arrays, dictionaries, and sets, etc. Additionally, ArrayBuffer, ArrayBufferView, and Blob, used to store binary data, can also be passed through postMessage. ArrayBuffer can also solve the performance issue in data transmission, but functions and classes cannot be passed through postMessage.

// worker.postMessage
{"jsonrpc": "2.0", "method": "isOdd", "params": [3], "id": 1}
{"jsonrpc": "2.0", "method": "isEven", "params": [5], "id": 2}
// worker.onmessage
{"jsonrpc": "2.0", "result": "false", "id": 2}
{"jsonrpc": "2.0", "result": "true", "id": 1}

Command and Dispatch Mode #

Previously, we saw the mapping between request and response. Now, let’s take a look at the mapping between command and dispatch. For example, we have two functions: one for determining odd numbers and the other for determining even numbers. However, these two functions exist in separate code paths. In this case, we need a way to map them. You can use a dictionary or a simpler object structure to support this kind of mapping relationship in instructions. This logic of command dispatch implemented by developers to ensure instruction dispatch is called the command dispatcher pattern.

var commands = {
  isOdd(num) { /*...*/ },
  isEven(num) { /*...*/ } 
}; 

function dispatch(method, args) { 
  if (commands.hasOwnProperty(method)) { 
    return commands[method](...args); 
  } 
  //...
}

By using the above structured clone algorithm, request and response, as well as command and dispatch modes, we can achieve information transmission between the main thread and worker threads through the Web Worker interface provided by the JS engine.

Summary #

Today, we have learned the differences between threads and processes, parallelism and concurrency, and compared the support for multi-threaded parallel development in different languages for front-end and back-end. In the case of JavaScript, we have also seen the core information transfer mechanisms in multi-threaded development, including the structured clone algorithm and the limitations of postMessage itself. We have also learned how to solve related problems through JSON-RPC and command dispatch.

Reflection Question #

This reflection question serves as a prelude to the next lesson. In our course, we mentioned that we can improve performance at the information transmission level by using ArrayBuffer. Do you know how it achieves this and the underlying principles?

Feel free to share your answers, exchange learning experiences, or ask questions in the comment section. If you find this helpful, please consider sharing today’s content with more friends. See you in the next class!