Using Web Workers

Monday, June 22, 2009

The Web Workers draft, originally part of HTML5 and now split out into its own document, specifies an elegant solution to a common need in Web applications. Workers allow Web pages to perform long-running computations in the background while remaining responsive to user input. This post demonstrates the dedicated worker API, using the 3box project as an example, with links to further reading in the Web Workers and HTML5 drafts.

Motivation

In many Web applications, long-running operations may be triggered frequently, e.g. in 3box, running an arbitrary JavaScript function and displaying the output every time the text in a textarea is changed.

In the JavaScript execution model of modern browsers, this leads to a delay during which the UI is unresponsive. The browser is effectively frozen or hung until the JavaScript event handler returns control. This can be partially worked around by minimizing the frequency of expensive calculations, trying to start them at "convenient" times, and yielding control explicitly during long-running operations, e.g. with setTimeout() . All these workarounds are awkward and have certain fundamental limitations.

Web Workers are the ideal solution to this class of problems. A Worker can be opened by the page, do work in the background without tying up the UI, and notify the page when the work is done. During this time, the page can respond to other events normally. The page can cancel the worker or start new workers at any time, and the worker and the page can communicate by passing any number of messages back and forth.

Subject to Change

The Web Workers draft is not yet final and is likely to continue to change. Please note the date of this post and be sure to consult the final specification or most recent draft for the current API details.

Note that at the time of this writing, the Firefox and Safari implementations both differ from the draft in several respects, and both the draft and early implementations may change. In the example code below I have written to the API given by the draft, but see the Firefox and Safari compatibility sections below for notes on those implementations and the actual code currently used by 3box for compatibility.

Creating a Worker

A worker is created by calling the Worker constructor, which takes the URL of a JavaScript program as its only argument. Before trying to create a worker, we must write the file that will be loaded.

Workers can be dedicated or shared. Since this will be working for a single Web page in a single window, 3box uses the dedicated worker API.

A worker can send and receive messages until closed, whether by itself, by the page that created it, or by the user closing or navigating away from that page.

Inside a dedicated worker, the work to be done will be received as messages from the page that created the worker (or in the case of a shared worker, from the pages connected to it). The page will use postMessage() to send these messages. In the worker script we write an event handler to catch the message events, do the work, and post messages back with results.

The event handler, like any DOM event handler, will be called with one argument, which will be the event object. This object implements the MessageEvent interface.

Messages can be values of any primitive ECMAScript data type: number, boolean, string, undefined, or null. A few specific Object types are supported, including Date, RegExp, and ImageData. Some values cannot be passed as messages, including host objects (such as DOM Nodes), functions, objects containing cyclical references, and some others. HTML5's structured cloning algorithm explains the details.

When a message is sent, the message value is cloned; the value received by the worker will not share any references to variables that exist in in the caller's context. For example, passing an object to a worker which then mutates the object it receives would not mutate the original object, since the worker is mutating the clone and not the original. This is a significant difference from ECMAScript function calls, which can mutate objects passed to them with observable results. This cloning of message values ensures that the only communication with the worker is through the message channel, which avoids introducing intractable concurrency issues into the existing single-threaded, event-based computational model of JavaScript in the browser. The simple practical advice would be to send over message channels only "simple" data types, and objects or arrays which are built up out of these types (but see the compatibility notes below).

In our case, we wish to pass an arbitrary JavaScript program and an expression from the page to the worker, let the worker evaluate that expression in the context of that program, and then return the result to the main page for display. Later we would like to extend the interface between the page and the worker to support more fine-grained control, so we will choose a very simple message structure that still allows some extensibility.

The page will send a message consisting of an Array value in which the first member is a string naming a particular operation and the remaining members are arguments. We will use two operations here, and call them "environment" and "eval". The "environment" operation expects a JavaScript program and evaluates it in the context of the worker. The "eval" operation takes an expression, evaluates it in the environment that has been set up, and then posts a message back to the page containing the result of evaluating the expression. If either "environment" or "eval" throws an exception, the worker will catch the exception and post a message to the main page.

In the worker script:

/* Note that since a worker does not share the main page's scope we can dispense with some of our usual caution in regard to the global namespace. */ environment=null /* onmessage is the event handler for messages sent from the page. */ onmessage=function(e){var op,args,data /* the clone of the value from the page's call to postMessage() is provided as e.data */ data=e.data op=data.shift() args=data switch(op){ case "environment": environment=setupEnvironment(args[0]) break case "eval": if(!environment)environment=setupEnvironment('') handleResult(environment.envEval(args[0])) break}}

The setupEnvironment returns an object which has an envEval property which may be used to evaluate an expression in that environment. The details of this function are not relevant to the use of Web Workers, and the version shown here is simplified.

function setupEnvironment(program){var js js=";(function(){" + program +"return {envEval:" +" function envEval(expr){" +" return eval(expr)}}})()" try{ return eval(js)} catch(e){ handleException(e)}}

For our purposes the important thing here is that when an "eval" message is received by the worker either handleException() or handleResult() will be called. These functions then are responsible for posting messages back to the page.

The messages posted from the worker take the same form as those received: an array containing a string as the first member, which will be either "result" or "exception". If the string is "result", the second member of the array will the result of evaluating the expression. If the string is "exception", the second member will be an object containing some details regarding the error.

In a dedicated worker, the postMessage() function is available as part of the DedicatedWorkerGlobalScope interface implemented by the worker's global object, which means we can simply call postMessage directly to pass messages back over the channel to the main page.

function handleResult(res){ postMessage(["result",res])} function handleException(e){ postMessage(["exception",{name:e.name,message:e.message}])}

Note that we rely on postMessage's structured cloning to pass these values back to the main page. How well this works will depend on the value we are passing to the page. Passing arbitrary values through postMessage is not transparent, as described above, so we may eventually change the interface to deal with this in a more robust manner, e.g. by doing output formatting in the worker, and passing back a string rather than an arbitrary JavaScript object.

Now all that remains is to update 3box to create the worker and communicate with it. Briefly, we provide a function which takes two values: a JavaScript environment and an expression. A simplified version of that function is presented below. 3box will take care of calling that function as appropriate, the details of registering the function with 3box are not relevant here and are not shown in the code below.

To create the worker, as previously mentioned, we call the Worker constructor, providing the relative URL reference of the worker script we just wrote as the argument. We then set a message event handler on the worker to handle the incoming "result" or "exception" messages we expect to get back from the worker. If the desired JavaScript environment should change, then we know that any result generated by a currently running worker will not be useful to us, so when we detect changes of this nature we will terminate the running worker and create a new one.

function registerPureFeedbackWorker(){ var wkr,state /* ... */ /* The function that manages the worker. 3box takes care of calling this function for us whenever the JavaScript environment or expression changes as a result of user input. It provides the old environment, the new environment, and the expression to evaluate. */ function(old_js_env,new_js_env,expr){ /* if the environment has changed, create a new worker. */ if(old_js_env != new_js_env){ if(wkr)wkr.terminate() wkr=new Worker('pureWorker.js') wkr.onmessage=onmsg wkr.postMessage(["environment",new_js_env])} /* now the worker has the new environment loaded so we post a message to it to request evaluation of the expression. */ wkr.postMessage(["eval",expr])} function onmsg(e){var op,args,data data=e.data op=data.shift();args=data switch(op){ case 'result': /* ... display the result ... */ break case 'exception': /* ... display the exception ... */ break}} /* ... */ }

Detecting Worker Support

In 3box, we have existing code which does not use workers, so we want to continue to use that code in existing browsers, but use workers in browsers that support them. To do this we use the standard technique called feature detection or object detection, i.e. checking whether the Worker constructor exists as a property of the window:

/* ... in an initialization function ... */ if('Worker' in this)registerPureFeedbackWorker() else registerPureFeedback()

Firefox Compatibility

The code presented above uses the Workers API as currently specified by the most recent draft. The current implementation in recent Firefox builds has some differences, the primary one being that the structured cloning algorithm is not implemented. Instead, Firefox appears to call toString() on the message argument to postMessage() if it is not already a string.

This can be worked around, but the solution depends to some extent on the data being passed. In the code above, we are passing Arrays, so we would never expect to see e.data of type 'string' in a message event handler. Hence we can use this in the event handler to detect when the message channel implementation is not using the cloning algorithm, and deserialize the string message ourselves. All that remains is to ensure that the string value sent when the cloning algorithm is not in use contains the information required. Since Firefox uses .toString() , if we do not intervene a string will be sent over the channel and information will be lost, but the intervention required is fairly trivial: we simply wrap the message argument to postMessage() in a utility function Fx() which adds an appropriate toString method. This has one side effect under the cloning algorithm, which is that the object passed will have a toString property set to null, but this is tolerable for our purposes.

Here is a snippet of the current workaround:

/* the message event handler: */ function onmsg(e){var op,args,x x=e.data if(typeof x=='string')x=JSON.parse(x) op=x.shift() /* ... */} /* the postMessage calls are actually written as, e.g: */ wkr.postMessage(Fx(["eval",expr]))} /* the Fx workaround: */ function Fx(x){ if(typeof x=='string')return x x.toString=function(){ delete this.toString return JSON.stringify(this)} return x}}

Values representable as JSON are a subset of those handled transparently by the structured cloning algorithm, so this is not an ideal workaround. Using .toSource() and eval() in place of JSON.stringify and .parse() is another possibility. Of course, we could also write custom functions to serialize and deserialize the data we send. None of these approaches is fully satisfactory, and hopefully this Firefox limitation will soon be lifted.

Safari Compatibility

Safari 4 has Worker support, but, like Firefox, does not support the structured cloning algorithm. It also does not have native JSON support, as used in the Firefox workaround. It seems to use the same toString() approach, so the same code path is followed, but the JSON reference throws an exception.

To work around this we include the json2.js library if necessary. In the main page, we can simply include this as we would any other script,

<script src=/compat/json2.js></script>

but in the Worker itself, we do not have access to global objects defined by the scripts on the main page.

To meet this requirement, the Workers draft includes a utilities API which can be used to load required script dependencies, among other things.

In the worker, we first detect whether the JSON object is present (as it is in recent Firefox builds), and if not, we use the importScripts() function to load json2.js.

if(!('JSON' in this)) importScripts('/3box/compat/json2.js')

With this change, the JSON code path works in Safari 4 just as it does in Firefox, only using the slower non-native implementation of JSON.

Demo

The code presented above is simplified, but the full working example can be seen in the 3box demo. You will need a recent Firefox 3.5 preview or nightly build for the Workers support. In Firefox 3, the earlier code will be used which does not rely on Workers.

Currently, the Worker-enabled 3box has the same features, only without hanging the UI. This is already a significant improvement, but there are more new features in the works that would simply be impractical without Web Workers. I hope to write more about Web Workers as these features are developed.

2009-06-25: added Safari compat notes.