Write Your Own Asynchronous Task Queue with Concurrency Control in JavaScript (Part 1)

It should be quite simple for a qualified JavaScript programmer to write an asynchronous task queue. If you are not sure about it, you may take this tutorial a test of your skill as well.

Goals

Sometimes in practice we need to queue specific tasks due to varieties of reasons:

  • Writing a spider
  • Managing Email delivering
  • Resource sensitive tasks
  • And so on

To deal with these real-life problems, we may want features like:

  • Queuing asynchronous tasks
  • Handling errors
  • Reporting progress
  • Concurrency number control

And some extras:

  • Ability to pause and resume
  • Ability to retry when a task fails

Asynchronous Tasks

An asynchronous task could be in the form of function with a callback:

function getSomethingDone(callback) {
    setTimeout(function () {
        callback();
    }, 1000);
}

Function getSomethingDone simulates an asynchronous task that takes 1000ms to complete. But how would we know if this task fails to complete at the beginning or after several hundreds of milliseconds?

  1. Throw an error in the handler synchronously.
  2. Callback with a value that satisfies !!value === true as the error object.

E.g., a task that fails synchronously:

function taskThatFailsSynchronously(callback) {
    throw new Error('Oops');
}

And a task that fails asynchronously:

function taskThatFailsAsynchronously(callback) {
    setTimeout(function () {
        callback(new Error('Async oops'));
        // NEVER throw an error here or any other asynchronous handlers
        // if you are not sure what's going on.
    }, 1000);
}

Before Action

Now we have the idea of how the tasks would look like, and that's "almost everything" we need to know for the implementation of the task queue.

But before that, let's think about HOW.

To make things easier, we'll start from a task queue that will run tasks one by one.

One by One

Let's not worry about the concurrency issue and start from a basic one-by-one task queue.

Fixed Tasks

When we run a fixed list of asynchronous tasks, it could be really easy and intuitive:

taskOne();

function taskOne() {
    console.log('Running task one...');
    
    // Simulates asynchronous operation by `setTimeout`.
    setTimeout(function () {
        taskTwo();
    }, 500);
}

function taskTwo() {
    console.log('Running task two...');
    setTimeout(function () {
        taskThree();
    }, 500);
}

function taskThree() {
    console.log('Running task three...');
    setTimeout(function () {
        console.log('Done.');
    }, 500);
}

Array of Tasks

Having written some fixed tasks, we can summary the pattern:

  1. Start the first/next task.
  2. When the current task is done.
    2.1. If it's not the last task, continue with step 1.
    2.2. If it is the last task, go to step 3.
  3. Complete.

I really wish this pattern could now be longer, though we will have a longer one later.

Before actually writing the task queue, let's fabricate some tasks with no error:

var tasks = [
    function taskOne(callback) {
        console.log('Running task one...');
        setTimeout(function () {
            callback();
        }, 200);
    },
    function taskTwo(callback) {
        console.log('Running task two...');
        setTimeout(function () {
            callback();
        }, 200);
    },
    function taskThree(callback) {
        console.log('Running task three...');
        setTimeout(function () {
            callback();
        }, 200);
    }
];

To run these tasks one by one, the first thing that comes to your mind might be for loop. However, for loop is not an option.

We need a function that can start a task as described in step 1, and can be called after a task is done.

Personally I use the name next most of the time:

function next() {
    // Get and remove the first task in the queue.
    var task = tasks.shift();
    
    // Run the task.
    task(function () {
        // Will be called when this task completes.
    });
}

Now when we call function next, we'll get the next task to run. The task accepts a callback to tell when it's done. So to run the next task after the current one completes, we can simply call next again if there's still tasks in the queue:

function next() {
    var task = tasks.shift();
    
    task(function () {
        if (tasks.length > 0) {
            next();
        } else {
            console.log('Done.');
        }
    });
}

Now we got a working task queue for a queue with more than one tasks if all of them would finish without error.

To deal with an empty task queue, we could either add another if at the beginning of function next, or slightly modify our logic by moving if to the beginning:

function next() {
    if (tasks.length === 0) {
        console.log('Done.');
        // Return here to prevent subsequent lines from running.
        return;
    }

    var task = tasks.shift();
    
    task(function () {
        next();
    });
}

How to start this task queue? Well if you have to ask, call next.

Assemble The Helper

Now we can handle the simplest situation with code above, but certainly we want to get things in something that can be reused. The code is now directly logging a string "Done." when all tasks get done, which should be something else.

Imagine, we want to run some tasks and get to be notified when everything's done. An interface like this might be applicable:

runTasks(tasks, function () {
    console.log('Done.');
});

If you really understand function in JavaScript, it should be extremely easy to wrap the next stuffs into a function like what we thought of just now.

function runTasks(tasks, callback) {
    // Start everything.
    next();
    
    function next() {
        if (tasks.length === 0) {
            // Replace `console.log` with a call to `callback`.
            callback();
            return;
        }
    
        var task = tasks.shift();
        
        task(function () {
            next();
        });
    }
}

Summary of Part 1

In this part, we addressed the practical issue of queuing asynchronous tasks and listed the desired features. To get things done, we started from simplifying the needs and began the implementation with basis.

In the next part, we'll continue with error handling and progress reporting. :)