The async/await
facility in ES5 is perhaps the single biggest readability
boost to async code in Javascript - both in NodeJS and in client-side JS code.
With async/await
, code that does async activities starts to read like
synchronous code with the keywords async
and await
thrown in at appropriate
points. But don’t lose sight of opportunities for concurrency while settling
into the comfort zone of synchronous-looking code.
One way to think of and use async/await
usage in JS is like this -
function computeSomething(v1, v2) {
if (v2 < 1) {
return 42;
}
return v1 / v2;
}
async function timeTaker1(href) {
let val2 = await fetchSomethingElse();
let val1 = await fetchSomething(href);
return computeSomething(val1, val2);
}
async function timeTaker2() {
let val3 = await timeTaker1('/something.json');
let val4 = await talkToSomeone(val3);
let val5 = await talkToSomeoneElse(val3);
return munge(val4, val5);
}
Now, when you call await timeTaker2()
, the following sequence of operations
gets executed -
.. in EXACTLY that order, although we can now multiplex this whole task with something else. This way, we stay comfortably in the “Ah! This is just like sequential code!” mode.
However, notice that the time taken by this sequence of operations is equal to the sum of the time taken for the individual operations. Especially when there are a number of I/O operations to be done with comparably less computation, this strict sequentialization of the operations can be wasteful and result in increased latency for the whole task.
With async/await
, it is pretty easy to mentally switch to a mode which can
give you much greater concurrency by default across your APIs.
The trick is simple and is based on the fact that an async
function can be
used exactly like a normal function that returns a Promise
object. It also
uses the fact that when await
is used on a non-Promise
value, it simply
produces that value immediately. Combining these, we can express our maximum
concurrrency rule simply as -
PRINCIPLE: await
only when you need the value.
With this principle, we can now redesign the above stupid code as below -
async function computeSomething(v1, v2) {
if (await v2 < 1) {
return 42;
}
return (await v1) / (await v2);
}
function timeTaker1(href) {
let val2 = fetchSomethingElse();
let val1 = fetchSomething(href);
return computeSomething(val1, val2);
}
async function timeTaker2() {
let val3 = timeTaker1('/something.json');
let val4 = talkToSomeone(val3);
let val5 = talkToSomeoneElse(val3);
return munge(await val4, await val5);
}
Now, fetchSomething
and fetchSomethingElse
will run concurrently, but
will synchronize with computeSomething
. If talkToSomeone
ends up deciding
to not use val3
, then both timeTaker1
and talkToSomeone
can also run
concurrently, with timeTaker2
finishing when talkToSomeone
finishes,
regardless of timeTaker1
. In computeSomething
, in case v2
turns out to
be less than 1, then we don’t require the value of v1
at all, so it would
be wasteful to wait for it to be computed. We thus also gain a bit of
improvement for the latency of the whole operation.
Also note that we’ve now turned computeSomething
from a synchronous
function to something async that can also be used like a sync function. From
a function design perspective, a client doesn’t really need to care whether a
function is async or sync and can generalize its usage by placing an await
in front of it to force waiting for an actual value. This is particularly
useful for higher order functions.
To recap, we need to consider the following code transformations -
Removing early await
let x = await f(...args);
=>
let x_p = f(...args);
With this transformation, we’ve “spawned off” the f(...args)
computation,
but have a handle on the result in the future through the x_p
variable ..
where the _p
is a reminder that it is a Promise
. Having x_p
is useful
because we can now pass around this “expectation of a value in the future” –
which is why they are also called “futures” – like a normal value.
Inserting late await
rawComputation(arg1, arg2)
=>
rawComputation(await arg1, await arg2)
Whichever function wraps the rawComputation
call will now be generalized to
accept either normal values for arg1
and arg2
or Promise
s .. i.e.
“expectations of future values”, resulting in maximum concurrency and minimum
latency. Here I assume rawComputation
to be like +
or *
operators which
require all of arguments to be actual values instead of being values promised
in the future.
Other advantages
As we saw, the result of an async task T
may end up being used in multiple
places. Say one such result is used within two other async tasks at some
point. To get maximum concurrency, the first task to need the result of
T
should be the one to wait for the result. Since which one ends up
being the first task to need it is non-determinstic, it is easiest to
arrange for the result to be await
ed in both places.
async function T() { ... }
async function consumer1(t) {
await doSomething();
await doSomethingElse(await t);
// ...
}
async function consumer2(t) {
await performSomething();
await performSomethingElse(await t);
// ...
}
let t = T();
consumer1(t);
consumer2(t);
In the above (and, once more, stupid) example, you find that the three
operations T
, doSomething
and performSomething
are all happening
concurrently. The Promise
spec guarantees that both consumer1
and
consumer2
will get the same result value for their await t
expressions.
Additionally, you may want to pass in just t
instead of await t
to
doSomethingElse
and performSomethingElse
if those functions have been
designed without assuming the immediate availability of the argument value.
Pushing it with thenables
From the picture above, we see that fetchSomething
will run even if we
don’t need its value in the end because of what fetchSomethingElse
produced. So the computation of fetchSomething
is eventually wasted.
What we truly want in such scenarios is that fetchSomething
should not even
be started unless its value is needed. i.e. We want the computation to be
done “lazily”. With many light I/O applications, the extra consideration for
such laziness may not be worth the additional contractual complexities in the
code, so you’ll want to use it judiciously.
While the Promise
spec doesn’t provide for such laziness, we can exploit
the fact that async
and await
work with “thenables” in general - i.e.
objects that have a then(succ, fail)
method to improve things just a bit
.. perhaps just enough.
function lazy(f, ...args) {
// We have to compute `f` once only. So we need to keep the
// result around. We keep it in a {value:} object.
let result = null;
return {
isLazy: true,
then: (succ, fail) => {
try {
// Compute the function if it hasn't already been computed.
if (!result) {
result = {value: f(...args)};
}
// Whether result.value is a plain value or
// a Promise for a value, this will result in
// an await all through the promise chain, until
// we have the final non-Promise value.
succ(result.value);
} catch (e) {
fail(e);
}
}
};
}
With this definition of laziness, the receiver doesn’t need to know when the
computation is actually kicked off and can just pretend it is a Promise-like
object. Under the hood, the computation will be triggered when needed.
However, what “needed” means is still somewhat less general with this. Not
only does “needing a value” mean doing an await value
, doing a return value;
within an async function
also counts. That isn’t ideal if the
returned value is going to be discarded eventually, but this is at least
better than performing some potentially long drawn computation that won’t be
used in the end.
If you want to experience truly elegant implementation of such async coordination using “data flow variables” as a primitive, checkout the Mozart/Oz language.