Web DevelopmentWeb Workers

Overview

Some of the new things that are coming with HTML 5 are pretty cool. One of them is Web Workers which are designed to allow the browser to spawn a background task which runs JavaScript in parallel with the main page. This allows you to do some serious number crunching in the background without freezing the interface.

The basics of web workers are really quite simple. If you have some JavaScript in an external file, you can create a new worker that will run this JavaScript on a separate thread to the UI, you simply do

var oWorker = new Worker('worker_src.js');

Now that isn't much use unless you can get some information back from the worker. To do this the worker's source script has to post messages back to the worker object, this can be done simply by doing the following within the worker_src.js file

postMessage(oMessage);

The value oMessage that we send can be a string, or more usefully, a JSON object literal. The worker object will recieve this message and can be handled by doing

oWorker.onmessage = function(e)
{
    var oMessage = e.data; 
    /* process message here */ 
};

Sending messages the other way is just as straightforward, all we do is call the postMessage method on the worker object, again passing some JSON object literal as the message, as follows

oWorker.postMessage(oMessage);

and handle the message in the worker source file by simply including something like

onmessage = function(e)
{
    var oMessage = e.data; 
    /* process message here */
};

That's all very straightforward, but it's not much use if you can't use it across all the browsers, so what we need is some way of emulating the web worker functionality in those browsers that don't yet support it. Let's see how...

Cross Browser Implementation

To allow people to start programming for web workers before they're implemented across all browsers what we need is a way to emulate their behaviour in browsers that don't currently support them. Obviously there's no way to achieve the multi-threading nature on the client if it's not natively supported, but we can create our own Worker object that behaves the same as the native one in all other respects. The way we do it is as follows...

First we check for native support of the Worker object. If it is not present then we create our own Worker class.

if(typeof Worker !== 'function')
{
    function Worker(sURL)
    {
        this.m_bReady = false;
        this.m_sWorkerVariableName = '$oWorker'; //variable name by which the loaded script refers to this object
        this.m_aoMessageQueue = [];
        this.m_sURL = sURL;
        this.m_asScripts = {};
        this.m_abScriptRequests = {};
        this._LoadScript(sURL);
    }

In this constructor we initialise various properties which we will need and call the _LoadScript method, which takes care of loading up all the necessary code from source sURL. We'll get to the implementation of this later. The worker object will also need onmessage and onerror methods, the user will override these functions to handle messages from the worker's code.

    Worker.prototype.onmessage = function(e)
    {
        //the worker's script postMessage(o) calls into this.onmessage({data: o})
        //override this to handle messages from the worker script
    };
    
    Worker.prototype.onerror = function(e)
    {
        //if the worker's script postMessage(o) throws an error, it will be caught and enter into here
        //override this to handle errors from the worker script
        //TODO - asynchronous events which cause an error in the 
        //       worker script will not trigger onerror to be called
    };

Our implementation will emulate messaging between this worker object and the worker source code with direct function calls. In more detail we're going to be re-writing the worker source code in such a way that wherever it would normally call postMessage it will now call a method directly on the worker object - let's call it _onmessage - which calls into the worker object's onmessage method.

    Worker.prototype._onmessage = function(oMessage)
    {
        this.onmessage({data: oMessage});
    };

Likewise, we need to handle calls to postMessage on the worker object itself. Calls to this method should trigger the onmessage handler in the worker source code. We handle this similarly to the previous case, this time we specify that postMessage on the worker object calls another method - let's call it _postMessage. When we rewrite the worker source code we replace assignments to the onmessage handler with an assignment to the worker objects _postMessage method instead.

    Worker.prototype._postMessage = function(e)
    {
    };
    
    Worker.prototype.postMessage = function(oMessage)
    {
        //passes messages to the worker script's self.onmessage function
        //if the worker script is not yet ready then add to a message queue
        if(this.m_bReady)
        {
            try
            {
                this._postMessage({data: oMessage});
            }
            catch(e)
            {
                this.onerror(e);
            }
        }
        else
        {
            this.m_aoMessageQueue.push(oMessage);
        }
    };

    Worker.prototype._FlushMessageQueue = function()
    {
        for(var nCursor = 0, nCount = this.m_aoMessageQueue.length; nCursor < nCount; nCursor++)
        {
            this.postMessage(this.m_aoMessageQueue[nCursor]);
        }
        this.m_aoMessageQueue.length = 0;
    };

Note here that we're checking if the Worker has fully loaded up all it's scripts with the property this.m_bReady. If the worker is not yet ready then any postMessage requests are added to a queue which can be flushed when loading is complete. So, on to the loading...

    Worker.prototype._LoadScript = function(sURL)
    {
        if((this.m_abScriptRequests[sURL] !== true) && 
           (this.m_asScripts[sURL] == null))
        {
            var pMe = this;
            this.m_abScriptRequests[sURL] = true;
            XHR.LoadData(sURL, true, function(sScript){pMe._OnLoadScript(sURL, sScript);}, XHR.TEXT);
        }
    };

The _LoadScript method will, so long as the script hasn't already been requested, make an XMLHttpRequest for the text located at sURL. I'm using my own object XHR that wraps up all the XMLHttpRequest functionality for convenience, you can implement this however you desire. When the response is recieved the string is passed to the _OnLoadScript method for processing.

    Worker.prototype._OnLoadScript = function(sURL, sScript)
    {
        //find all scripts that need importing and load them
        //TODO - allow the script to specify the name of the file dynamically
        var pMe = this;
        var reImportScripts = /importScripts\(['|"](.*)["|']\)/g;
        var fnImportReplacer = function(sMatch, sURL, nOffset, sScript)
        {
            pMe._LoadScript(sURL);
            return sMatch;
        }
        sScript = sScript.replace(reImportScripts, fnImportReplacer);
    
        //replace the worker script's postMessage method with a direct call to the worker's onmessage handler 
        //(script.postMessage triggers worker.onmessage)
        sScript = sScript.replace(/(self\.)?postMessage/g, this.m_sWorkerVariableName + '._onmessage');
        
        //replace the workers script's onmessage handler with the worker's _postMessage method 
        //(worker.postMessage triggers script.onmessage)
        //note, make sure we don't replace the _onmessage that we just added by checking the _
        sScript = sScript.replace(/[^_](self\.)?onmessage/g, this.m_sWorkerVariableName + '._postMessage');

        delete this.m_abScriptRequests[sURL];
        this.m_asScripts[sURL] = sScript;
        
        for(var sKey in this.m_abScriptRequests)
        {
            //still have pending requests
            return;
        }
        
        //only get here when have finished
        this.m_abProcessedImport = [];
        var sScript = this._ProcessImport(this.m_sURL);
        
        var fn = new Function(
            this.m_sWorkerVariableName, 
            'try{' + sScript + '}catch(e){' + this.m_sWorkerVariableName  + '.onerror(e);}'
        );
        fn(this);
        
        this.m_bReady = true;
        this._FlushMessageQueue();
    };
    
    Worker.prototype._ProcessImport = function(sURL)
    {
        if(this.m_abProcessedImport[sURL] !== true)
        {
            var pMe = this;
            var sScript = this.m_asScripts[sURL];

            //TODO - allow the script to specify the name of the file dynamically
            var reImportScripts = /importScripts\(['|"](.*)["|']\)/g;
            var fnImportReplacer = function(sMatch, sURL, nOffset, sScript)
            {
                return pMe._ProcessImport(sURL);
            }
            sScript = sScript.replace(reImportScripts, fnImportReplacer);
            
            this.m_asScripts[sURL] = sScript;
            this.m_abProcessedImport[sURL] = true;
        }
        return this.m_asScripts[sURL];
    };
}

Let's step through this. Firstly we need to deal with any scripts that are imported into the script we're loading. We do this by using a regular expression to find all calls to importScripts, extract the script to be loaded and call _LoadScript for it. We leave these importScripts calls in place for later and move on.

The next step is to rewrite any postMessage calls in this worker script, again using a regular expression. This will, for example replace something like postMessage("hello") with $oWorker._onmessage("hello").

Similarly we rewrite the onmessage handler of this worker script. For example replacing onmessage = function(e){} with $oWorker._postMessage = function(e){}.

Next we check if this is the final script we need to load (remember the importScripts calls triggered further loading). If it is we continue to process all the imported scripts. We start from the originally requested file and recursively call the _ProcessImport method which replaces the importScripts code with the actual script itself.

So, now the processing of the script text is complete we use it to create a function fn. The code for this function is the script text we just created. We also specify that the function takes one argument, called $oWorker, which explains our previous replacements using the $oWorker variable. Finally we call this function passing this as the $oWorker argument, which hooks up the worker script to this worker object. That's it, magic!

There are a couple of things still to do to improve this. Firstly an importScripts call must take a single literal string as its argument, if there are dynamically determined arguments, or if there is more than one argument, then the import will not be handled. Also any script errors that occur in the worker script that were initiated asynchronously will not be caught by the onerror handler. But despite these it's fairly functional.

So let's see this in use...

Examples

Let's first take the simplest example from the Web Worker documentation, slightly modified, a rudimentary prime number search. The script on the page is given by

var nMax = 0;
var worker = new Worker('prime_search.js');
worker.onmessage = function (event)
{
	var n = event.data;
	if(n > nMax)
	{
		nMax = n;
		document.getElementById('result').innerHTML = n;
	}
};

The code contained in prime_search.js run by the worker is

var n = 1;
search: while(n < 50000)
{
	n += 1;
	for(var i = 2; i <= Math.sqrt(n); i += 1)
	{
		if(n % i == 0)
		{
			continue search;
		}
	}
	// found a prime!
	postMessage(n);
}

This finds all primes up to 50000, outputing the highest to the interface. Try it out:

Native Web Worker support:
Generate Primes
Highest prime found: --

So this should work whether you have Native Web Worker support or not. Note that in the cases where Workers are native the calculation does no interfere with the interface, however when native support is not present the calculation is being performed on the same thread as the interface and things lock up until the calculation is complete.

Well that was pretty boring, huh? So let's quit this town and move on to a much more exciting use of web workers in my JavaScript Paint application.