Monday 6 August 2018

Task.Run in JavaScript

Web Workers have been for sure a great addition to Javascript, but its usage has always seemed a bit odd to me. So you create an "execution unit" (the spec does not say if it'll be implemented as a threat or a process) from a js file that contains the code for that execution unit, and communicate with it via messages. So these messages should prompt the worker to run some action which result will be returned to you via a message. Comparing it to other environments, one could think of this as if each Web Worker were a ThreadPool of a single thread to which I send actions. This is fine, but I felt as if there was an scenario that was missing.

My first experiences with threads (in C#, like 15 years ago) involved creating a Thread object and passing to it a function object (a delegate). That code would be run by the thread and the thread would finish. This is quite close to how things work at the OS level with the CreateThread Win32 API. Creating Threads on your own this way is no longer recommended in the .Net world, as thread creation is expensive and we should leverage the Thread Pool. Task.Run provides us with a familiar "interface" (we pass it a delegate to be run), leverages the thread pool and furthermore returns a Task, so we can easily chain the next code to run (via ContinueWith or even better with await).

Let's say that I don't care that much about the code running in the ThreadPool or in a new one, what I really like is the "interface" provided by Task.Run: pass the code to run as a function and await for its result. So, could I somehow replicate it in Javascript? A web worker receiving a function to be run and returning a Promise on which I could await. Hopefully yes, we can do it

There are some ingredients that we have to mix to make this possible:

  • We have to create a Promise
  • We have to create a Web Worker and pass it a function via postMessage. The Web Worker should run this function. We can not directly post a function to the worker, so we'll pass it serialized into a string, and then the Web Worker will run it via eval
  • Once the function is finished, the Web Worker has to resolve (or reject) the Promise

The code for the Web Worker itself is pretty simple, in principle we'll put it in a separate "WebWorkerCode.js" file and pass its url to the Worker constructor

//e is an array with 2 elements: the function in string format and an array with the arguments to the function
onmessage = function(e){
    let fnSt = e.data[0];
    let args = e.data[1];
    
    //we need the () trick for eval to return the function
    let fn = eval("(" + fnSt + ")");
    let workerResult = fn(...args);
    console.log('Posting result back to main script: ' + workerResult);
    postMessage(workerResult);

}

Then we have a class (PromiseWorkerHelper) with a run method. The "constructor" creates a worker that waits for us to post it the function to execute. The "run" method creates a Promise, gets hold of the resolve/reject handlers of the Promise and posts the function to the worker. Once the worker finished with running the function will post a message with the result, that will be received by PromiseWorkerHelper that at this point will resolve the promise.

class PromiseWorkerHelper{
    constructor(){
        //one shot object, we create it and invoke its run method just once
        this.alreadyCalled = false;

        //this.worker = new Worker("WebWorkerCode.js");
        this.worker = this._createWorkerFromString();

        //this is executed when the worker posts a message
        this.worker.onmessage = (msg) => this.resolveFunc(msg.data);
        this.worker.onerror = (msg) => this.rejectFunc(msg); 
        this.resolveFunc = undefined;
        this.rejectFunc = undefined;
    }

   

    run(fn, ...args){
        if (this.alreadyCalled){
            throw "already used once";
        }
        this.alreadyCalled = true;

        let pr = new Promise((resolve, reject) => {
            this.resolveFunc = resolve;
            this.rejectFunc = reject;
        });

        this.worker.postMessage([fn.toString(), args]);
        return pr;

    }

   _createWorkerFromString(){
        let workerOnmessageHandler = function(e){
            let fnSt = e.data[0];
            let args = e.data[1];
            
            //we need the () trick for eval to return the function
            let fn = eval("(" + fnSt + ")");
            let workerResult = fn(...args);
            console.log('Posting result back to main script: ' + workerResult);
            postMessage(workerResult);
        
        };

        let str = "onmessage = " + workerOnmessageHandler.toString() + ";";
        let blob = new Blob([str], {type: 'application/javascript'});
        return new Worker(URL.createObjectURL(blob));

    }
}

The "_createWorkerFromString" private method is a nice addition. Rather than having the worker code in a separate file, we can have it here, create a Blob, create a URL for that in memory object and pass it to the Worker constructor. I learned this trick here

We can run it as easily like this:

function longFormatting(txt, formatCharacter){
    console.log("starting longFormatting");
    let res = formatCharacter + txt + formatCharacter;
    start = Date.now();
    while(Date.now() - start < 1000){}
    console.log("finishing longFormatting");
    return res;
}

function main(){
    document.getElementById("launchCalculationBt").addEventListener("click", async () => {
        console.log("Main.js, inside button handler");

        let txt = "hello";
        let formatCharacter = "---";

        let promise = new PromiseWorkerHelper().run(longFormatting, txt, formatCharacter);
        
        let res = await promise;

        console.log("Main.js, result: " + res);

    });
}

window.addEventListener("load", main, false);

I've put the code in a gist, and you can run it from here (check the debug console for the output).

Notice that we are passing to the run method the function and the parameters to that function, while Net Task.Run receives just the function. In .net if our original function expects parameters we have to create a closure to trap those parameters and invoke the original function with them. In JavaScript, as in the end we have to pass the function as a string to the Worker, the closure is not an option.

No comments:

Post a Comment