Javascript : async

Javascript : async

Sixth article in my javascript journey and I look into asynchronous programming this time. This one of my favourite feature in C# .NET (called TPL) and I am comparing the two languages here.

I've mentionned 2 very useful methods in one of my previous articles which are setTimeout and setInterval. They have an asynchronous behavior based on callbacks, which is the legacy way of dealing with asynchrony. We'll see how they differ from async/await.

Asynchronous code

The use of callbacks can quickly becoming a mess and hard to follow (see pyramid of doom). In modern programs, callbacks have been replaced with Promises to deal with asynchronous execution of code.
The construct of a Promise takes 1 parameters which is the code to be executed. The signature of this method can take up to 2 parameters :

  • a callback to be executed on success
  • [optional] a callback to be executed on error
let myPromise = new Promise(function(onSuccess, onFailure){
    // Long processing
    // ...
    if(result === "OK") {
        onSuccess();
    }
    else {
        onFailure();
    }
});

The consuming code defines the actions to be executed when the promise returns with then and catch:

myPromise
  .then((response) => { /* success handling */ })
  .catch((error) => { /* error handling */ });

The promise will transition through these states:

  • Pending : myPromise.result is undefined
  • Fulfilled : myPromise.result is the result of execution (a value)
  • Rejected : myPromise.result is an error object

Here is a common use case for promises:

// Fetch requests some data from an API. It returns a promise
fetch('https://api.github.com/users/octocat')
  .then((response) => {
    return response.json(); // format response to json (also returns a promise)
  })
  // You can chain 'then' calls to do post-process received data more at each step
  .then((data) => {
    console.log(data); // log json
  })
  .catch((error) => {
    console.error(error)
  })

async / await

Using async keyword on a function makes it return a Promise.

async function do() {
    return "Hello";
}
// is the same as 
function do() {
    return Promise.resolve("Hello");
}

async keyword authorizes the use of await in the function to wait for a resolved promise before it continues.
It basically comes to the same as using then with the advantage of reading the code as if it was sequential (e.g. as if it was normal javascript).
Besides, you can encapsulate your logic inside try/catch and deal with your errors as if you were in a synchronous function.

async function do(promise) {
    try {
      await promise(); // blocks until promise is completed
      console.log("ok");
    }
    catch(error) {
      console.log("ko : " + error);
    }
}

Event loop

Javascript is a single threaded programming language with a synchronous execution model. A blocking API call prevents the UI from refreshing and this is where asynchronous execution becomes handy. It allows to execute several operations in parallel. But how can we execute parallel stuff with a single threaded program ?

The javascript host environment (i.e. the browser) uses a concept call the event loop to handle concurrency, which some kind of scheduler built around events, a stack and a queue.

This is what happens in a normal execution flow:

Program:                          Call stack:                           Queue:

doFirst(); ---------------------> push(doFirst)
                                  execute(doFirst)
                                  pop(doFirst)
                                               --------- any ? ------>
                                               <-------- no ----------
           <--------------------                                  
doSecond();---------------------> push(doSecond)
                                  execute(doSecond)
                                  pop(doSecond)
                                               --------- any ? ------>
                                               <-------- no ----------
           <--------------------                 

Each function is added to the stack, executed and popped out, in a sequential order. Everytime the stack is empty, the event loop checks the queue for any waiting task.
Now let's imagine that doFirst has some asynchronous processing, such as a call to setTimeout(do, 100).

Program:                          Call stack:                               Queue:

doFirst(); ---------------------> push(doFirst)
                                  execute(doFirst)
                                  push(setTimeout)
                                  execute(setTimeout) --------------------> add(do, 100)
                                  pop(setTimeout)
                                  pop(doFirst)
                                                      --------- any ? ------>
                                                      <-------- no ----------
           <--------------------                                  
doSecond();---------------------> push(doSecond)
                                  execute(doSecond)
                                  pop(doSecond)
                                                      --------- any ? ------>
                                                      <-------- no ----------
           <--------------------                 
                                  ( ~ 100 ms )
                                                      --------- any ? ------>
                                  push(do)            <--------- do() --------
                                  execute(do)
                                  remove(do)

setTimeout has a callback parameter. This callback should execute 100ms later. So when setTimeout is executed by the call stack, do is added to the macrotasks queue.
The queue is checked in-between sequential executions and if the due time is reached, the task is pulled out and pushed into the callstack for execution.

As you can see, the execution flow is always sequential which means that in reality, do() will be executed after 100 ms + sequential execution time. You can verify that by setting the timer value to 0 and you'll see that do() gets always executed last, after doSecond().

So there is no parallel execution. In reality, the execution timeframe is sliced into sequential and deferred execution tasks. Operating systems actually do the same thing with threads having the same priority (time slicing).

macrotasks queue only handles deferred callback executions. Promises are tackled by a second queue called microtasks queue, which has a higher priority than macrotasks.

Promise methods

For the following examples, we consider the promises listed below:

const p1 = new Promise((resolve, reject) => resolve('p1 ok'));
const p2 = new Promise((resolve, reject) => resolve('p2 ok'));
const p3 = new Promise((resolve, reject) => resolve('p3 ok'));

all()

// Wait resolution of all promises
Promise.all([p1, p2, p3])
  .then((result) => {
    console.log(result); // ["p1 ok", "p2 ok", "p3 ok"]
  })
  .catch((error) => {
    console.log(error);
  });

If any of p1, p2, p3 is rejected, all is rejected and error is the error of the first failed promise.

allSettled()

// Wait settlement of all promises
Promise.allSettled([p1, p2, p3])
  .then((result) => {
    console.log(result); // [ {status: "fulfilled", "value" = p1 ok" },  ...]
  });

allSettled always takes the resolution path (then) and is useful to review the status of all promises without error.

any()

// Wait resolution of at least one promise
Promise.any([p1, p2, p3])
  .then((result) => {
    console.log(result); // "p1 ok"
  })
  .catch((error) => {
    console.log(error);
  })

If p1 is resolved but p2 and/or p3 are rejected, we'll still get "p1 ok" printed.
If all promises are rejected, then only the catch method gets called.

race()

// Wait first promise to settle
Promise.race([p1, p2, p3])
  .then((result) => {
    console.log(result); // ["p1 ok"]
  })
  .catch((error) => {
    console.log(error);
  });

If the first promise to return is rejected, catch is called.

reject()

Same as Task.FromException in C#, reject returns a rejected promise with an error

// Just pass in any error value
Promise.reject('Bad')
  .then((result) => {
    console.log(result); 
  })
  .catch((error) => {
    console.log(error); // "Bad"
  });

resolve()

Same as Task.FromResult in C#, resolve returns a resolved promise with a value.

// Just pass in any value (could be a primitive or another promise)
Promise.resolve('Hero')
  .then((result) => {
    console.log(result); // "Hero"
  })
  .catch((error) => {
    console.log(error);
  });

finally()

// finally() will always run, irrespectively of the promise status
Promise.reject('Bad')
  .then((result) => {
    console.log(result); 
  })
  .catch((error) => {
    console.log(error); // "Bad" printed first
  })
  .finally(() => {
    console.log('Completed'); // "Completed" printed next
  });