Asynchronous Processing Unit (APU)
Version 2.1 — 16th November 2011
The Asynchronous Processing Unit (APU) is a fast and highly-controllable abstraction for performing intensive computation in JavaScript, without freezing-up the browser.
It's basically a threading trick using asynchronous timers, but it scores over earlier, less-formal implementations of this idea (including my own), in two significant ways:
- it performs multiple iterations per timer loop — producing dramatically faster results, compared with a pure implementation that equates a single iteration to a single timer loop.
- it's highly controllable and its instances can be nested — because the timer loops are explicitly controlled by internal callbacks, rather than being intervals that run unchecked once begun.
The APU
abstraction satisfies a need that
Web Workers
can't — because they have no DOM,
no access to the parent document or its environment,
and (with the notable exception of XMLHttpRequest
)
almost no accessible host objects.
But APUs
are just ordinary code, with the same access to the
DOM and environment
as any other code, and the same ability to extend or limit
their scope as any other object.
The APU works in all current browsers, with no known bugs or incompatibilities.
I've been tinkering with this abstraction for several years, and it's crucial functionality in both versions of Dust-Me Selectors, as well being used in all five versions of CodeBurner. But it's only recently got to a stable and user-friendly point, where it can be released as a standalone abstraction.
Using APUs there is no limit to the depth and complexity of computation you can do — providing it can be broken-down into discreet, iterative steps. If each of those steps alone is not enough to freeze-up the browser, then the number of steps it takes to produce the end result is insignificant — it could be hundreds-of-thousands, or billions, occupying the browser for an indefinite length of time, without ever locking-up its thread!
It's not a perfect solution, of course — you can't have parallel instances, and it can't return — but nonetheless it fills a niche that would otherwise be empty, making it possible to do stuff in JavaScript that would otherwise be unfeasible.
Get the script
Download the zipfile [6K] and unpack it, then use the code as you need it — either including the script file as it is, or pasting the codebase into yours.
The script contains a single function — the APU
constructor and its prototypes:
function APU(chunksize, oninstance, oncomplete, onabort)
{
this.argument = function(value, fallback)
{
return (typeof value == 'number' && value > 0 ? parseInt(value, 10) : fallback);
};
var
apu = this,
timer = null,
chunk = 0,
chunksize = this.argument(chunksize, 1);
this.i = 0;
this.stopped = false;
this.call = function(fn)
{
if(typeof fn == 'function')
{
fn.call(this, this.i);
}
};
this.docomplete = function()
{
this.call(oncomplete);
};
this.doinstance = function(speed)
{
if(apu.i == 0)
{
this.call(oninstance);
}
else if((++ chunk) == chunksize)
{
chunk = 0;
timer = window.setTimeout(function()
{
apu.call(oninstance);
}, apu.argument(speed, 10));
}
else
{
this.call(oninstance);
}
};
this.doabort = function()
{
window.clearTimeout(timer);
this.call(onabort);
};
}
APU.prototype =
{
start : function()
{
this.i = 0;
this.stopped = false;
this.doinstance();
},
next : function(increment, speed)
{
if(this.stopped) { return; }
this.i += this.argument(increment, 1);
this.doinstance(speed);
},
complete : function()
{
if(this.stopped) { return; }
this.stopped = true;
this.docomplete();
},
abort : function()
{
if(this.stopped) { return; }
this.stopped = true;
this.doabort();
}
};
Using the APU
The basic idea is that the APU encloses iterative code —
code that you would otherwise put in a for
or while
loop.
You enclose the code in a callback function, and the
APU
will then call that function repeatedly — passing
an incremental counter variable to each iteration.
But that iterative process is itself contained within an asynchronous timer, and the timer also loops around repeatedly — after each group of n iterations, the timer loops around once, then performs another group of iterations, then another timer ... and so on.
The number of iterations in each group is referred to as the chunksize
,
and it's the combination of chunks and timers that's the key to how the
APU works —
asynchronous timers prevent the browser thread from locking-up,
while the fact that a whole bunch of iterations are performed within each timer,
speeds it up.
The APU Constructor
To use the APU you must first create a new instance, using the constructor function, which takes two required and two optional arguments:
var processor = new APU(
50, //chunksize [REQUIRED Number (Integer)]
function(i){ ... }, //oninstance callback [REQUIRED Function]
function(i){ ... }, //oncomplete callback [OPTIONAL Function]
function(i){ ... } //onabort callback [OPTIONAL Function]
);
- chunksize [REQUIRED Number (Integer)]
-
A
Number
which specifies the number of process iterations (orchunks
) that will be performed within each timer loop, of whatever iterative code the APU is running (as defined in the oninstance callback). This value must be an integer greater than zero; floats will be rounded, and invalid values default to1
.For example, let's say you had an array of 365 dates, and each of those dates had to be parsed in some way. Using an APU with a
chunksize
of50
, your parsing code will iterate 50 times, then the pause of a singlesetTimeout
will happen, then another 50 iterations ... and so on until you get to the final, residual 15 iterations.The value you define here should be guided by the complexity of the work — the more it has to do within each iteration, the lower the chunksize may need to be to avoid locking-up the browser. A value of
50
is a good place to start; but if the code is particularly heavy it might be better to start with1
, then experiment with higher values for as long as the browser is still responsive! - oninstance callback [REQUIRED Function]
-
A
Function
that encloses the primary iterative code you want the APU to run.Each time this function is called it's passed a counter variable, which starts at
0
then increments by1
after each iteration (by default).The contents of this function represent the smallest iterative step your code can be reduced to. So if you find that the browser is unresponsive even with a chunksize of
1
, then you're doing too much work withinoninstance
. In that situation, you would need to break it down into smaller iterative steps; once they're small enough not to lock-up the browser anymore, then you can try to increase thechunksize
again, to make it run faster.You control when an APU iterates, using the next() control method — with which you can also override the counter increment and timer speed.
- oncomplete callback [OPTIONAL Function]
-
A
Function
that encloses any additional code you want to run when the APU's work is complete.This function is also passed the counter variable, with whatever residual value it has by then — so if the counter was being incremented by
1
and the APU did 100 iterations in total, then the residual value passed tooncomplete
will also be100
.You control when an APU completes, using the complete() control method.
- onabort callback [OPTIONAL Function]
-
A
Function
that encloses any additional code you want to run if the APU is aborted prematurely.This function is also passed the counter variable, the value of which will obviously depend on when it's called, and what its increments were.
You control when and if an APU aborts prematurely, using the abort() control method.
Control Methods
Once constructed, an APU will still not run on its own — it must be explicitly started, incremented and completed; you can also abort it at any point, should that prove necessary.
To acheive this, the APU provides four control methods:
start()
-
The
start()
method is used to start an APU. Typical usage is to create a new processor and start it immediately:var processor = new APU(1, function(i) { alert("counter = " + i); }); processor.start();
When you use the
start()
method, the first iteration of the code within oninstance will be called immediately, with no preceding timer instance even if the chunksize is1
. The counter value passed to this first iteration will always be0
.You may not actually need to create that reference variable. If you want to be able to control an APU externally — such as starting it from an event, or aborting it from another process; or controlling a parent instance from a nested instance — then you will need a reference to do so. But if not, you can dispense with the reference and chain the
start()
command directly onto the end of the instantiation:new APU(1, function(i) { alert("counter = " + i); }).start();
From within a single APU, all the other control methods can be referred to using
"this"
. complete()
-
The
complete()
method is used to complete an APU once its work is done. Typical usage is to define a condition at the very beginning of your oninstance code, that determines whether or not it's time to complete:var processor = new APU(1, function(i) { if(i == data.length) { this.complete(); } else { alert("counter = " + i); } }); processor.start();
Although the expression in an ordinary
for
loop is evaluated before the iterative code, with an APU the counter is incremented after each iteration. So evaluating thecomplete()
condition at the start of oninstance is logically equivalent (and produces the same residual value).If the evaluation above were written in a
for
loop, it would be like this:for(var i = 0; i != data.length; i ++)
Though in practise it would usually be written like this:
for(var i = 0; i < data.length; i ++)
Which is not exactly the same, of course, but amounts to the same thing in most cases (ie. where nothing else affects the value of
i
). By the same thinking then, the original evaluation could also have been written like this:if(!(i < data.length)) { this.complete(); }
Alternatively ... if you'd rather evaluate
complete()
at the end of your oninstance code, you can adjust the condition to suit that location:function(i) { alert("counter = " + i); if(i == (data.length - 1)) { this.complete(); } }
In that example there would be ten iterations, completing at the end of the 10th. By contrast, in the earlier example there are eleven iterations, it's just that the 11th is interrupted before it does anything.
But more importantly, the placement of
complete()
affects the residual counter value passed to oncomplete — in the earlier example the counter is incremented one extra time, compared with the latter, hence the residual value will be one-higher.I guess the thing to do is whatever makes for the simplest logic in each case. There are no hard and fast rules, only my conventions, and I'm sure you have your own ideas about the way you like to code!
next()
-
The
next()
method is used to iterate the APU. Typical usage is to put this command at the end of your oninstance code:var processor = new APU(1, function(i) { if(i == data.length) { this.complete(); } else { alert("counter = " + i); this.next(); } }); processor.start();
And with that, we now have the minimum code required for a functional APU — it starts, iterates over some code, then completes.
For a demo of this example, see :: Basic Implementation Demo
The
next()
function also has two optional arguments, allowing you to override the counter increment and/or the timer speed:- counter increment [OPTIONAL Number (Integer)]
-
A
Number
which specifies by how much to increment the counter this iteration, thereby controlling the value that will be passed to the next oninstance. This value must be an integer greater than zero; floats will be rounded, and invalid values default to1
.So for example, if you wanted to increment the counter by
3
instead of1
, simply pass that number as the first argument tonext()
— the counter will then go0
,3
,6
... and so on:this.next(3);
Of course you don't have to pass the same value every time — you might need to increment the counter by different amounts at different times, or perhaps only override the default in certain circumstances. This ability may be essential when working with non-contiguously numbered data:
function(i) { alert(data[i]); if(i == limit) { this.complete(); } else { var n = 1; while(typeof data[i + n] == "undefined") { n ++; } this.next(n); } }
What we're effectively doing there is defining the counter manually, for every iteration after zero, and working it out by skipping-over undefined data indices. But we need to avoid a situation where the
while
loop evaluates a counter that's now higher than the highest index in the data — or it'll get stuck and run forever. So we need to know the highest index (shown above in thelimit
var) and use that value for the complete() condition.For a demo of this example, see :: Non-Contiguous Numbering Demo
- timer speed [OPTIONAL Number (Integer)]
-
A
Number
which specifies the speed of the next timer loop (in milliseconds). This value must be an integer greater than zero; floats will be rounded, and invalid values default to10
.As we noted earlier, the timers are there as a threading trick to avoid locking-up the browser — they're not really designed for performance animation and so the actual speed of the timer is not usually significant. But it does create a pause in execution, so other things being equal, we want it as fast as possible.
Now in practise, most browsers can't or won't run a timer faster than
10ms
, and if we set a speed faster than that it could actually come out slower than that, as the browser strains to try to keep up. Since the whole point of the APU is to reduce computational stress, it would be self-defeating to reduce it in one way only to increase it again in another.So if you find that your APU is not performing as fast as you want, you should look to increase the chunksize rather than the timer speed; or just get it to do less work!
But there may be situations where you need to define a specific — and much slower — speed for the timer, to purposely delay execution in order to wait for something else; or just for testing or debugging. Combined with a chunksize of
1
, this argument effectively allows you to create a controlled asynchronous timer loop, the speed of which can also be overriden on a per-loop basis.
abort()
-
The
abort()
method can be used to prematurely abort an APU before it's finished. There's no typical usage for this method, it all depends on when and why you need to abort the process.When you use the
abort()
method the processor doesn't stop immediately, it merely sets a flag that the timer won't proceed past — so aborting an APU will make it stop after the current set of chunks. This of course implies that it will stop immediately if the chunksize is1
.In the example below a random-number is used for the
abort()
condition, effectively implementing a 1-in-5 chance that it will abort before each iteration:function(i) { if(i == data.length) { this.complete(); } else if(Math.ceil(Math.random() * 5) == 1) { this.abort(); } else { alert("counter = " + i); this.next(); } }
For a demo of this example, see :: Randomly Aborting Demo
More likely in practise, an external process might be the trigger to abort an APU. In Dust-Me Selectors, for example, a tabbed-dialog displays a list of unused CSS selectors, output which is compiled and built using an APU. But if the user selects a different tab before it's finished compiling, the APU is aborted and the compilation stops. In that example then, it's a user-event on the tabs that triggers
abort()
:tabs.addEventListener("click", function(e) { compiler.abort(); tabSelect(e.target); }, false);
The onabort callback is used in turn, to close the output and add a message that says it's incomplete.