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
isundefined
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. Promise
s 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
});