The await-on-need Pattern

Mar 26, 2020   #async/await  #Promise 

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 -

Produced by OmniGraffle 7.14.1 2020-03-26 08:46:27 +0000 Canvas 1 Layer 1 fetchSomething fetchSomethingElse talkToSomeone talktoSomeoneElse munge < 1? 42 divide YES NO

.. 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);
}
Produced by OmniGraffle 7.14.1 2020-03-26 08:45:21 +0000 Canvas 2 Layer 1 fetchSomething fetchSomethingElse talkToSomeone talktoSomeoneElse munge < 1? 42 divide YES NO

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 Promises .. 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 awaited 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.