Logo in JS with Web Workers

One of the most common requests I get for my Logo in JavaScript page is "can you watch the turtle draw?"

This turns out be rather tricky. As currently written, the Logo interpreter converts Logo procedures into JavaScript functions, and evaluation takes place using the JavaScript stack. The "evaluate" step of the REPL is a single JavaScript call, which yields a JavaScript value. It would be possible to change the interpreter to not use this approach, but I'm rather fond of it, and it leads nicely towards converting the interpreter into a compiler. (It's most of the way there already, although in a non-traditional way.)

And unfortunately, JavaScript in the browser executes in the "UI" thread so there's no opportunity for the display to update within the evaluation of a JS function. When running a Logo procedure or program the display freezes and you need to wait for the entire drawing to complete before you see the results.

At least, that used to be the case.

Web Workers are one of the technologies within the HTML5 marketing umbrella. Basically, they're threads for JavaScript. The design is very elegant but necessarily forces asynchronous communication between threads to avoid deadlock situations. This also limits resource sharing - plain old data with no object references (basically, what you could serialize to JSON). This means a worker can't get access to the UI - like a Canvas's 2D context object necessary for drawing.

One approach is to define a domain-specific communication protocol between the worker and the main thread. For example, you could pass { "opcode": "draw_rect", "x": 100, "y": 100, "w": 50, "h": 50 } from the worker to the main thread, and change the calling code (in my case, the turtle object) to send this message instead of directly calling canvas context methods. Then you have the receiver interpret these opcodes and call the appropriate sequence of methods on the canvas context object.

I started down this approach, but I'm lazy. And laziness is a virtue. So instead I came up with a general JavaScript proxy mechanism that allows nearly all of the code to remain untouched. Here's what it looks like:

The main (only) thread used to have this code:

g_logo = new LogoInterpreter(
    new CanvasTurtle(canvas_elem.getContext('2d'), 
                     canvas_elem.width, canvas_elem.height)
);
// ... then g_logo.run(text) gets called

The main thread now has this code:


var proxies = {
    'canvas': canvas_elem.getContext('2d'),
    'window': window
};

var worker = new Worker('worker.js');
worker.onmessage = function(event) {
    var obj = proxies[event.data.obj];
    if (event.data.call) {
        obj[event.data.call].apply(obj, event.data.args);
    }
    else if (event.data.set) {
        obj[event.data.set] = event.data.value;
    }
    else {
        // bug!
    }
};

g_logo = {

    run: function(text) {

        worker.postMessage({'command': 'run', 'text': text});

    }

};




worker.postMessage({
 'command': 'init',

 'width': canvas_elem.width,

 'height': canvas_elem.height
}
);

The worker has this code:

// For converting arguments into something that can posted
function toArray(o) {
    var a = [], i, len = o.length;
    for (i = 0; i < len; i += 1) {
        a[i] = o[i];
    }
    return a;
}

// Make a remote method call
function remote_call(obj, method, args) {
    postMessage({'obj': obj, 'call': method, 'args': args});
}

// Make a remote property set
function remote_set(obj, prop, value) {
    postMessage({'obj': obj, 'set': prop, 'value': value});
}


// Create a new proxy class for the given methods and properties (lists of names)
function makeProxyClass(methods, properties) {

    var ctor = function(name) {
        this.name = name;
    };

    ctor.prototype = {};


    function proxy_method(obj, name) {
        obj[name] = function() {
            remote_call(this.name, name, toArray(arguments));
        };
    }

    function proxy_property(obj, name) {

        var getter = function() { return this['$' + name]; };
        var setter = function(s) { this['$' + name] = s; 
            remote_set(this.name, name, s); };

        if (typeof Object.defineProperty === 'function') {
            // ES5
            Object.defineProperty(obj, name, { 'get': getter, 'set': setter });
        }
        else if (typeof obj.__defineGetter__ === 'function') {
            // older versions of Firefox
            obj.__defineGetter__(name, getter);
            obj.__defineSetter__(name, setter);
        }
    }

    if (methods && methods.length) {
        methods.forEach(function(x) {
            proxy_method(ctor.prototype, x);
        });
    }
    
    if (properties && properties.length) {
        properties.forEach(function(x) {
            proxy_property(ctor.prototype, x);
        });
    }

    return ctor;
}

// Define CanvasProxy class
var CanvasProxy = makeProxyClass(
    ['beginPath', 'moveTo', 'lineTo', 'clearRect', 'fillText', 'stroke', 'fill'],
    ['lineCap', 'lineWidth', 'strokeStyle', 'fillStyle', 
     'globalCompositeOperation', 'font']
);

onmessage = function(event) {
    switch(event.data.command) {
        case 'init':
            g_logo = new LogoInterpreter(
                new CanvasTurtle(new CanvasProxy('canvas'), 
                                 event.data.width, event.data.height));
            break;
            
        case 'run':
            try {
                g_logo.run(event.data.text);
            }
            catch (e) {
                remote_call('window', 'alert', ['Error: ' + e]);
            }
            break;
    }
};

It's not a generalized proxy - you can't read back values from the other side - but it's good enough for now. That limitation means that certain logo operations - like READ - won't work.

I have to admit, in reality it's a little sleazier. With just the above code the screen still doesn't update on every operation (at least, on my old single-core computer) since the Logo thread is eating up 100% of the CPU. So I threw in one more trick:

// Sleazy yield - make an HTTP request that will fail. This causes the JavaScript
// thread to yield for a few milliseconds.
var xhr = new XMLHttpRequest()
function yield() {
    try {
        xhr.open("GET", "does_not_exist/" + Math.random(), false); // synchronous
        xhr.send();
    }
    catch (e) {
        // ignore
    }
}

I'm not the first to think of this (see comp.lang.javascript), but unlike use within a web page (where the whole browser - or at least tab - would block), use within a WebWorker is not entirely terrifying.

I combine this with the CanvasProxy and make calls to stroke() call yield(), which slows the graphics down enough that you can almost see individual lines being drawn.

Here's an example Logo program - click to see it in action:



Comments