The potentially asynchronous loop

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.