If you write a lot of asynchronous or event-driven code, you’re probably
going to end up needing an asynchronous for
loop. That is, a loop that runs
each iteration sequentially but those iterations may contain non-blocking logic
that must halt the loop until the async action resumes. In my case, I need the
main loop of JS.Test
, the testing tool to be bundled with JS.Class 3.0,
to run each test in sequence but each test has to support a suspend/resume
system for async tests.
I’ll use a slightly more contrived example here: fetching a series of responses over Ajax in sequence using jQuery. It should be apparent that this will not do the job:
listOfUrls.forEach(function(url) {
$.get(url, function(response) {
// handle response
});
});
The requests are made in sequence, but they overlap because each async request
does not block the loop. We want each iteration to hold up the loop until the
request it opens has completed. To do this, I’m going to introduce a resume
callback as an argument to the iterator; each iteration must call this to
continue the loop.
listOfUrls.asyncEach(function(url, resume) {
$.get(url, function(response) {
// handle response
resume();
});
});
An initial stab at implementing asyncEach()
might look like this. The method
takes an iterator
function, and creates an internal function that moves a
counter forward one index. As long as we’ve not reached the end of the list, we
call iterator
with the current element and the internal function as the
resume
callback.
Array.prototype.asyncEach = function(iterator) {
var list = this,
n = list.length,
i = -1;
var resume = function() {
i += 1;
if (i === n) return;
iterator(list[i], resume);
};
resume();
};
This works just fine as long as every iteration contains an async action.
Async code allows us to empty the call stack and start again when the async
logic resumes. In JS.Test
, each test might contain async code, but probably
won’t, at least for my uses. If too many tests in a row don’t contain any async
code, we get a stack overflow because of resume
calling itself indirectly
without giving the stack a break.
So we need a looping construct for iterations that might contain async code,
that must on all JavaScript platforms only some of which have async functions
built-in. Initially we can get rid of the stack overflow problem by scheduling
the next iteration using setTimeout()
instead of calling directly and growing
the stack:
Array.prototype.asyncEach = function(iterator) {
var list = this,
n = list.length,
i = -1;
var iterate = function() {
i += 1;
if (i === n) return;
iterator(list[i], resume);
};
var resume = function() {
setTimeout(iterate, 1);
};
resume();
};
We should now be able to handle a large list of tests because the resume
callback uses setTimeout()
to essentially clear the call stack between
iterations. But we now have the problem that this won’t run on platforms without
setTimeout()
. How do we turn it into a simple loop on such platforms without
blowing the stack?
The trick is to have resume()
act as a scheduling device, but implement the
scheduling differently. On non-async platforms, we can do this by keeping a
count of iterations left to run: calling resume()
adds to this count, while
calling iterate()
decrements it. We keep a single loop running that runs the
iteration as long as there are calls remaining. The finished code looks like
this:
Array.prototype.asyncEach = function(iterator) {
var list = this,
n = list.length,
i = -1,
calls = 0,
looping = false;
var iterate = function() {
calls -= 1;
i += 1;
if (i === n) return;
iterator(list[i], resume);
};
var loop = function() {
if (looping) return;
looping = true;
while (calls > 0) iterate();
looping = false;
};
var resume = function() {
calls += 1;
if (typeof setTimeout === 'undefined') loop();
else setTimeout(iterate, 1);
};
resume();
};
Notice how the looping
flag blocks any more that one loop running at a time.
After the first call to loop()
, the effect of each iteration calling the
resume()
function is simply to increment calls
and keep the loop going. This
setup lets us iterate over a list on non-async platforms, and allows async code
to exist within iterations on compatible platforms, keeping all the complexity
in one place. Anywhere we want to iterate, we just call
list.asyncEach(function(item, resume) {
// handle item
resume();
});
and asyncEach
figures out the best way to handle the loop.