// I wrote this demo to see if I could automatically wrap code with a worker thread to create the same interface between a naked object and a wrapped object, to ultimately then see if there is a significant difference in performance if I ran the math for updating bouncing balls in a worker thread rather than the main UI thread. // Because of the significant overhead for serializing and deserializing objects between threads, if your updates are simple, but your data is large, then you will probably be able to process more of them on the main thread than you can communicate between threads. But if your processing is expensive for a relatively small amount of data, the serialization overhead might be worth the effort. Also, running expensive updates on the worker thread will keep the UI thread responsive, so even if the updates can only run at, say, 10FPS, the rendering is still running at 60FPS. class WorkerWrapper { constructor(func) { // First, rebuild the script that defines the class. Since we're dealing with pre-ES6 browsers, we have to use ES5 syntax in the script, or invoke a conversion at a point post-script reconstruction, pre-workerization. // start with the constructor function let script = func.toString(), // strip out the name in a way that Internet Explorer also undrestands (IE doesn't have the Function.name property supported by Chrome and Firefox) name = script.match(/function (\w+)\(/)[1]; // then rebuild the member methods for (var k in func.prototype) { // We preserve some formatting so it's easy to read the code in the debug view. Yes, you'll be able to see the generated code in your browser's debugger. script += ` ${name}.prototype.${k} = ${func.prototype[k].toString()};`; } // Automatically instantiate an object out of the class inside the worker, in such a way that the user-defined function won't be able to get to it. script += ` (function(){ var instance = new ${name}();`; // Create a mapper from the events that the class defines to the worker-side postMessage method, to send message to the UI thread that one of the events occured. script += ` if(instance.addEventListener){ for(var k in instance.listeners) { instance.addEventListener(k, function(){ var args = Array.prototype.slice.call(arguments); postMessage(args); }.bind(this, k)); } }`; // Create a mapper from the worker-side onmessage event, to receive messages from the UI thread that methods were called on the object. script += ` onmessage = function(evt){ var f = evt.data.shift(); if(instance[f]){ instance[f].apply(instance, evt.data); } } })();`; // make a fake object to appease CodePen script = "var window = {CP:{shouldStopExecution(){}, exitedLoop(){}}}



" + script; // The binary-large-object can be used to convert the script from text to a data URI, because workers can only be created from same-origin URIs. let blob = new Blob([script], { type: "text/javascript" }), dataURI = URL.createObjectURL(blob); this.worker = new Worker(dataURI); // create a mapper from the UI-thread side onmessage event, to receive messages from the worker thread that events occured and pass them on to the UI thread. this.listeners = {}; this.worker.onmessage = (e) => { let f = e.data.shift(); if (this.listeners[f]) { this.listeners[f].forEach(g => g.apply(this, e.data)); } }; // create mappers from the UI-thread side method calls to the UI-thread side postMessage method, to inform the worker thread that methods were called, with parameters. for (var k in func.prototype) { // we skip the addEventListener method because we override it in a different way, to be able to pass messages across the thread boundary. if (k != "addEventListener") { (function(f) { this[f] = function() { // conver the varargs array to a real array var args = Array.prototype.slice.call(arguments); // push the name of the method on the front of the array args.unshift(f); // send the full array to the work this.worker.postMessage(args); }.bind(this); // we use the call method of the function so that the functions get created in their own scope context with the name of the function captured within. }).call(this, k); } } } // Adding an event listener just registers a function as being ready to receive events, it doesn't do anything with the worker thread yet. addEventListener(evt, thunk) { if (!this.listeners[evt]) { this.listeners[evt] = []; } this.listeners[evt].push(thunk); } } // This example class manages a set of bouncing "ball" objects. It iterates several times over the update process to simulate very heavy processing. This process works best when processing time is dependent on update algorithmic complexity rather than data size. function ObjectPool() { this.objects = []; this.width = 100; this.height = 100; this.lt = 0; this.mx = -1; this.my = -1; this.itersPerUpdate = 1; this.listeners = { updated: [] }; // We need to make sure the thread runs its own update loop. We want to avoid tying the update loop of the UI thread to the update loop of the worker thread, as that would completely defeat the purpose of the exercise. Also, behavior of the worker thread would be difficult to predict if it started running slower than the UI thread, because the messages from the UI thread would start backing up in the message queue. this.interval = setInterval(this.update.bind(this), 1000 / 75); } // Make sure you clear that timer out before dropping the object pool. Orphaned objects can end up still firing off events otherwise. ObjectPool.prototype.stop = function() { clearInterval(this.interval); }; // A convenience method for firing events. ObjectPool.prototype.notify = function() { var args = Array.prototype.slice.call(arguments), evt = args.shift(); if (this.listeners[evt]) { this.listeners[evt].forEach(f => f.apply(this, args)); } } ObjectPool.prototype.addEventListener = function(evt, thunk) { if (this.listeners[evt]) { this.listeners[evt].push(thunk); } }; // Let the object pool know the size of the stage, so it can make balls bounce off the walls. ObjectPool.prototype.setSize = function(w, h) { this.width = w; this.height = h; }; // Let the object pool know where the mouse is located ObjectPool.prototype.setMouse = function(x, y) { this.mx = x; this.my = y; }; // You can do object initialization in any way you want, so long as the thread manages it itself. You need everything to be compartmentalized in a single function scope block. ObjectPool.prototype.setObjectCount = function(n) { // make new objects... while (this.objects.length < n) { // we want to minimize the serialized representation of the data that will pass between the threads. this.objects.push([ Math.random() * (this.width - 11), Math.random() * (this.height - 11), Math.random() * 100 - 50, Math.random() * 100 - 50 ]); } // or delete the extras while (this.objects.length > n) { this.objects.pop(); } }; ObjectPool.prototype.setItersPerUpdate = function(n) { this.itersPerUpdate = n; }; ObjectPool.prototype.update = function() { var start = Date.now(); if (this.itersPerUpdate > 0 && this.lt > 0) { var dt = (start - this.lt) / 1000, ldt = dt / this.itersPerUpdate; // we use a few thousand iterations of the update process to make a relative small number of objects more taxing on the system. There is significant communication overhead that scales with the number of objects, as the messages passed between threads are first serialized to and deserialized from strings. // Later, if the update method ever boggs down, we can exploit the simplicity of the *real* update process to make estimates of where the objects should have moved since we last received an update. This is similar to how networked games work, receiving object locations over time and smoothing the transitions between updates. for (var i = 0; i < this.itersPerUpdate; ++i) { for (var j = 0; j < this.objects.length; ++j) { var o = this.objects[j]; o[0] += o[2] * ldt; o[1] += o[3] * ldt; if ((o[0] < 0 && o[2] < 0) || ((o[0] + 10) >= this.width && o[2] > 0)) { o[2] *= -1; } if ((o[1] < 0 && o[3] < 0) || ((o[1] + 10) >= this.height && o[3] > 0)) { o[3] *= -1; } } } // Part of the key of the technique is that we do not transmit data as often as we update it. this.notify("updated", this.objects, Date.now() - start); } this.lt = start; }; const TEXT_SIZE = 15 * devicePixelRatio, TEXT_LINE_HEIGHT = TEXT_SIZE * 1.25, STEP_SIZE = 100, FONT = `${TEXT_SIZE}px -apple-system, '.SFNSText-Regular', 'San Francisco', 'Roboto', 'Segoe UI', 'Helvetica Neue', 'Lucida Grande', sans-serif`, canv = document.getElementById("canv"), g = canv.getContext("2d"); let time = 0, lastTime = 0, dt = 0, useThreading = true, pool = null, curState = null, curItersPerUpdate = 1000, curObjectCount = Math.floor(10 / devicePixelRatio) * 100, curUpdateRate = 0, objectColors = []; function E(id, evt, thunk){ if(!(id instanceof Element)){ id = document.getElementById(id); } id.addEventListener(evt, thunk, false); } E("toggle", "click", () => { useThreading = !useThreading; makePool(); }); E("incIter", "click", () => { curItersPerUpdate += STEP_SIZE; pool.setItersPerUpdate(curItersPerUpdate); }); E("decIter", "click", () => { if (curItersPerUpdate > STEP_SIZE) { curItersPerUpdate -= STEP_SIZE; pool.setItersPerUpdate(curItersPerUpdate); } }); E("incObj", "click", () => { curObjectCount += STEP_SIZE; setObjectCount(); }); E("decObj", "click", () => { if (curObjectCount > STEP_SIZE) { curObjectCount -= STEP_SIZE; setObjectCount(); } }); E(canv, "mousemove", (e)=>{ var bounds = canv.getBoundingClientRect(); pool.setMouse( (e.clientX - bounds.left) * canv.width / bounds.width, (e.clientY - bounds.top) * canv.height / bounds.height); }); E(canv, "mouseout", ()=> pool.setMouse(-1, -1)); function makePool() { // we need to be careful to shut down any existing thread before starting a new one, because an orphaned thread will still be able to generate update events. if (pool) { pool.stop(); if (pool.worker) { pool.worker.terminate(); } } // toggling back and forth between making the pool on its own thread or the UI thread. if (useThreading) { pool = new WorkerWrapper(ObjectPool); } else { pool = new ObjectPool(); } // listen for whenever there are changes to the object state pool.addEventListener("updated", function(objs, dt) { curState = objs; curUpdateRate = dt; // we clear the amount of time since the last update, so the rendering function can make estimates of where to draw objects when we haven't received an update in a while. time = 0; }); pool.setSize(canv.width, canv.height); pool.setItersPerUpdate(curItersPerUpdate); setObjectCount(); } function setObjectCount() { while (objectColors.length < curObjectCount) { let r = Math.floor(Math.random() * 256), g = Math.floor(Math.random() * 256), b = Math.floor(Math.random() * 256); objectColors.push(`rgb(${r},${g},${b})`); } while (objectColors.length > curObjectCount) { objectColors.pop(); } pool.setObjectCount(curObjectCount); } function render(t) { requestAnimationFrame(render); dt = t - lastTime; time += dt / 1000; lastTime = t; g.fillStyle = "#222"; g.fillRect(0, 0, canv.width, canv.height); if (curState) { for (var i = 0; i < curState.length; ++i) { var o = curState[i]; // We keep the details of rendering the full object on the UI thread and only correlate it to data from the physics model in the worker thread to minimize the amount of data going between the two. g.fillStyle = objectColors[i]; // use the object data to make a guess as to where to draw the objects, regardless of whether or not we've received an update since the last draw operation. g.fillRect(o[0] + o[2] * time, o[1] + o[3] * time, 10, 10); } } g.font = FONT; g.fillStyle = "white"; g.fillText(`Rendering frame rate: ${Math.round(1000/dt)}`, 10, canv.height - TEXT_LINE_HEIGHT); g.fillText(`Update frame rate: ${Math.round(1000/curUpdateRate)}`, 10, canv.height - TEXT_LINE_HEIGHT * 2); g.fillText(`Iters per frame: ${curItersPerUpdate}`, 10, canv.height - TEXT_LINE_HEIGHT * 3); g.fillText(`Objects: ${curObjectCount}`, 10, canv.height - TEXT_LINE_HEIGHT * 4); g.fillText((useThreading ? "Using" : "Not using") + " workers.", 10, canv.height - TEXT_LINE_HEIGHT * 5); } function resize() { var bounds = canv.getBoundingClientRect(); canv.width = bounds.width * devicePixelRatio; canv.height = bounds.height * devicePixelRatio; if (!pool) { makePool(); } pool.setSize(canv.width, canv.height); } window.addEventListener("resize", resize, false); resize(); requestAnimationFrame(render);

!