One of the particularities of Javascript in the browser is that its runtime is an event loop where your code is always executed in the main UI thread. This design allows to greatly simplify the front end code. You never need to worry about synchronizing access to resources as those resources are only accessed by one thread at the same time. Meanwhile, the event loop design allows to smoothly manage asynchonicity and keep your UI responsive even in the face of long running but low CPU tasks like fetching resources from a remote machine.
However, this design does not address the issue posed by long running high CPU tasks. As web applications become more complex, more tasks are offloaded to the browser and among those might be CPU intensive processing tasks related to image processing, encryption/decryption, compression/decompression, inference, etc. Implemented in Javascript, these tasks will be executed in the main thread blocking the UI, negatively impacting the user experience.
In the C/C++ world, this problem would be solved through the use of threads. By dividing the attention of your CPU (either by multi-tasking or multi-threading) you will allow your long running tasks to execute without blocking you UI thread. Of course, the experience might be impacted if your CPU can't handle the load but this becomes a hardware issue not a software issue. By upgrading you system, your experience will improve.
In the browser, there is a way to obtain a behavior similar to threads: Web Workers. The description on the MDN page says it all:
Web Workers make it possible to run a script operation in a background thread separate from the main execution thread of a web application. The advantage of this is that laborious processing can be performed in a separate thread, allowing the main (usually the UI) thread to run without being blocked/slowed down.
Here a simple example presenting how to use a web worker to execute a simple function that multiplies a number.
var myWorker = new Worker('worker.js');
myWorker.onmessage = function(event) {
console.log('Event received from worker', event.data);
}
myWorker.postMessage(21);
In a separate file called 'worker.js':
onmessage = function(event) {
console.log('Event received from main script', event);
postMessage(event.data * 2);
}
There is a few things to unpack. First, you will notice the mode of communication between the main thread and the worker is through message passing. It might seem needlessly complex, why not work through simple asynchronous function calls by the way of Promises? The problem with promises is that you can resolve them only once. And Observables are still not a thing in the browser so the way to allow for a worker to provide multiple responses during the task execution is through that messaging scheme.
Another important point is the fact that the worker code has to live in a separate Javascript file. For people delivering encapsulated components, this might be a show-stopper or at least a pesky requirements to satisfy. Why not provide a function to be executed asynchronously. But worry not, there is a workaround to this.
Finally, and this is not apparent here, the execution context of the worker is slightly different from the execution context of the main thread. For example the `window` object is not available as well as the DOM. Of course, you are not executing in the UI thread so you can't change the UI. Except from this, you will have access to most of the browser's API.
As we have seen, the Web Worker interface, though powerful can be a little awkward to use. Moreover, as any good gentle person these days, we fancy ourselves to be Typescript developers. Type safety is our creed. So we will try to address some of those points while reducing complexity and improving type safety.
My initial reaction to Web Workers was "why do I need to download an external script to use a Worker??". Indeed, in Javascript, functions are first class citizens. There are used everywhere. Why not provide a function to our worker? I don't pretend to know the rational of that design choice made by the standardization bodies of the Web ; My guess is that it would prevent developer to have the impression that the function can access closure from the Web Worker which is of course impossible. Isolating the worker code inside a separate script might make that clearer. Still, it's awkward...
There is a solution to this though. The Worker's constructor expects a URL. In Javscript we can convert any function to a string using the toString method. Then through the createObjectURL API we can convert any string to a URL. That's it:
function createWorker(fun) {
const funSerialized = `
onmessage = async (msg) => {
const result = await (${fun.toString()})(...msg.data);
postMessage(result);
};
`
const blob = new Blob([funSerialized], { type: 'application/javascript' });
const worker = new Worker(URL.createObjectURL(blob));
}
const worker = createWorker((param) => param * 2);
This way we can now use functions directly with workers, no need of an external script. We just need to keep in mind that this function will be serialized and deserialized and will not be executed in place so the usual access to closure is not possible here. Note that we await the function so that you can either provide a synchronous or an asynchronous function to the worker factory. Awaiting on a synchronous function as no effect nor performance impact.
In order to get some structure we will move that function in a specialized class which will be typed according to the type of the provided function to ensure type safety all the way.
export class ThreadBuilder<OutputType, T extends any[]> {
static create<OutputType, T extends any[]>(code: (...args: T) => OutputType)
: ThreadBuilder<OutputType, T> {
return new ThreadBuilder<OutputType, T>(ThreadBuilder.makeServer(code));
}
private static makeServer<OutputType, T extends any[]>(code: (...args: T) => OutputType): string {
return `
onmessage = async (msg) => {
const result = await (${code.toString()})(...msg.data);
postMessage(result);
};
`;
}
private constructor(private readonly server: string) {}
createThread(): Thread<OutputType, T> {
const blob = new Blob([this.server], { type: 'application/javascript' });
const worker = new Worker(URL.createObjectURL(blob));
return Thread.create(worker);
}
}
Nothing fancy. We just moved the code into a class and added a bunch of generics so the type checker has better clarity at what we are trying to do. Now we can just:
const thread = ThreadBuilder.create((param) => param * 2).createThread();
Note that we are not creating bare workers here. We are creating an instance of the class called Thread that we will describe below. The Thread class is just a wrapper around Worker so we can adjoin some types using generics. This way, the type checker can keep track of what that worker expects as parameters and what it will return.
export class Thread<OutputType, T extends any[]> {
static create<OutputType, T extends any[]>(worker: Worker): Thread<OutputType, T> {
return new Thread<OutputType, T>(worker);
}
private constructor(private readonly worker: Worker) {}
async run(...parameters: T): Promise<OutputType> {
return new Promise((resolve, reject) => {
this.worker.onmessage = function (msg: MessageEvent) {
resolve(msg.data);
};
this.worker.onmessageerror = function (error: MessageEvent) {
reject(error);
};
this.worker.postMessage(parameters);
});
}
}
Thread will provide some abstraction over worker and represent the worker execution as a promise. It is important to not that we introduce a limitation in our design. The aforementioned ability of workers to deliver multiple messages as response to a call is now obstructed by the Promise abstraction. But that's ok. Every abstraction is a trade-off and this one seems acceptable. Should you need a particular worker to stream multiple responses then you can always create workers manually.
Running the thread now is easy, just call the run method, provide your parameter(s) and await for the answer:
const result = await ThreadBuilder.create((param) => param * 2).createThread().run(21);
console.log(result); // 42
Trying to retrieve the result into a string or passing an object instead of a number will generate a type error from your favorite typescript compiler. This will ease troubleshooting and increase your code quality.
You can find all this and more in this library: https://www.npmjs.com/package/@jdmichaud/ts-workers Check out the Queue object which will allow you to define a worker pool and run multiple tasks through that pool. Should you run into a problem, create an issue here.
Enjoy !