loading...

Async/await: A slight design flaw.

joeyhub profile image Joey Hernández Updated on ・14 min read

My experience with async/await is that it's amazing for simple cases and fulfils expectations. If your use cases are all very basic, for example waiting for either things to finish in sequence or for all things to finish then you might be alright.

Due to a flaw in the design it doesn't work as you might expect in scenarios going beyond this. This can be hard to detect because it doesn't always behave in the wrong way all the time.

In most cases you'll be fine but keep using it heavily without thinking just as though it's normal asynchronous callback based coding and you'll eventually find yourself staring into the corrupted faces of Zalgo in all their overflowing glory. It will not be a question of if he'll come but when he'll come.

At first it might present as inexplicable performance anomalies you might put on unseen factors such as the GC but down the line if you keep using async/await naively eventually it'll start doing weird things that don't happen when using callbacks.

There will come a point where you'll realise that multiple callstacks are being executed concurrently that shouldn't be and that you're receiving returns in effectively arbitrary order. You'll start to realise this is complicating a lot of your error handling as you'll be having cases where multiple exceptions can be thrown simultaneously but only one should be thrown at a time. You'll also find it more complicated where an exception does occur and then there's several times more stacks being implemented concurrently to rollback rather than the track you're on.

It's very subtle. Experiment with this to see:

// Key:
// f = function
// d = depth
// p = promise (or awaitable)
// i = iterative identity for an instance of an initial invocation
// t = time / timeout
// a, b, c = multiple instances of the same thing

async function f(d, p, i) {
  console.log(`Called -> Chain ${i} and Depth ${d}`);

  if(d)
    await f(d - 1, p, i);
  else
    await p;

  console.log(`Chain ${i} and Depth ${d} <- Returned`);
}

function sleep(t) {
  return new Promise(resolve => {
    // Change to requestAnimationFrame or other to rule out timing differences.
    setTimeout(resolve, t);
  });
}

async function test() {
  var a = sleep(10), b = sleep(10), c = sleep(10), i = 0;

  console.log('Listening once each to two timers that finish at the same time.');
  await Promise.all([f(1, a, i++), f(1, b, i++)]);
  console.log('Listening twice to the same timer.');
  await Promise.all([f(1, c, i++), f(1, c, i++)]);
}

test();

// Listening once each to two timers that finish at the same time.
// Called -> Chain 0 and Depth 1
// Called -> Chain 0 and Depth 0
// Called -> Chain 1 and Depth 1
// Called -> Chain 1 and Depth 0
// Chain 0 and Depth 0 <- Returned
// Chain 0 and Depth 1 <- Returned
// Chain 1 and Depth 0 <- Returned
// Chain 1 and Depth 1 <- Returned
// Listening twice to the same timer.
// Called -> Chain 2 and Depth 1
// Called -> Chain 2 and Depth 0
// Called -> Chain 3 and Depth 1
// Called -> Chain 3 and Depth 0
// Chain 2 and Depth 0 <- Returned
// Chain 3 and Depth 0 <- Returned
// Chain 2 and Depth 1 <- Returned
// Chain 3 and Depth 1 <- Returned

You'll see a different set of behaviours between each of these cases. Quick summary, attach two promises to the same event it does one thing, attach one promise to the event and reuse that for the second time then it does another thing. These results should produce the same result in the same way that (1 * 1) + (1 * 1) or 1 * (1 + 1) would produce the same result (n * (a + b)).

If you're struggling to understand why this might be unusual it is easily explained.

When we reduce the number of asynchronous operations from one to two, the result is a more asynchronous pattern. Less asynchronous operations results in more asynchronicity. Why is that?

It is based on Promises/A+ which means in certain scenarios it breaks. Promises/A+ made a design decision to run resolve "asynchronously" based on a misinterpretation of a blog originally about Java and Scala resulting in a crude over simplification that's since been fudged by separating it from the event queue to make it at least half work which is why you rarely see this problem day to day. It has been pushed around the next corner and not many people turn so many corners.

The blog on which Promises/A+ based its design decision on stated that if something is maybe async, it should be made either always sync or always async. While that's not something I always agree with (though good practice in some cases), Promises/A+ can be shown to take that which is always sync and to make it always async. When this is taken into account it starts to become clear why reducing asynchronous operations can result in more asynchronous patterns.

This can be shown to not only not solve the problem but to exacerbate it. Streams emit end as maybe async and this behaviour in promises causes that to become a problem forcing you to fix it yourself in the way promises claim to be fixing it for you, by manually deferring the events. The problem can only be properly addressed at the source (in a manner of speaking) and promises are nested as to be guaranteed to not be at the source most of the time when used behind async/await. It ends up violating the principle of if it's not broken don't fix it.

The problem arises from a clash of two or more concerns on a few fronts. The main front:

  • Is it a set of flow control keywords for full internal restored flow control after a deferral?
  • Is it merely syntactic sugar for A+ Promises which are a class not necessarily designed for full functional composition which do not restore functional control flow?

The async/await are keywords to write asynchronous functions as if synchronous. A+ promises are something else, they're not a full faithful reproduction of functional programming but instead classes, with a very specific set of behaviours that deviate from the way in which functions normally work. It's trying to be two things at once. Promises as a concept can be used to power async/await but not all specific implementations are compatible with async/await as keywords relating to functional composition preserving flow of execution where normally possible, that includes the A+ implementation which is a lossy abstraction.

The intent of async/await is to make that which is asynchronous as synchronous as possible. This is incompatible with A+ promises, the purpose of which is to take that which is synchronous and to make it asynchronous.

This can be formally verified. It is asynchronous actions that disrupt order. We can prove that even when there is no asynchronous action that order is disrupted by this promise implementation and that the implementation does not replicate the behaviour exhibited by functions making it an inappropriate replacement for functional behaviour:

(() => {
    // This test reveals the nature of 
    const a = new function() {this.then = callback => (callback(), this)};
    console.log('a Start');
    a.then(() => console.log('a First')).then(() => console.log('a Second'));
    a.then(() => console.log('a Third'));
    console.log('a End');

    const b = Promise.resolve();
    console.log('b Start');
    b.then(() => console.log('b First')).then(() => console.log('b Second'));
    b.then(() => console.log('b Third'));
    console.log('b Start');
})();

// a Start
// a First
// a Second
// a Third
// a End
// b Start
// b End
// b First
// b Third
// b Second

This can be visualised.

Normal:

---
\
 \
  \ -> A
---
\
 \
  \ -> B
---
  / <- A
 /
/
---
  / <- B
 /
/
---

While the order of <- A/B is not guaranteed, the steps down after that normally are in callback implementations and with async/await implementations in virtually any other programming language so long as the async/await operations are all being run on the same thread or fully synchronised (which in JS is always the case).

Async/await:

---
\
 \
  \ -> A
---
\
 \
  \ -> B
---
  / <- A
---
  / <- B
---
 /
---
 /
---
/
---
/
---

In these two cases the end result is the same but the time at which they reach the bottom is changed with async/await. Observe that A once on the downward slope finished after 5 steps rather than after 3 steps. Also observe that despite only requiring two asynchronous workflows you now somehow have 6 asynchronous workflows s̪p̫͎̖ewi͉̪͎̼͎̪ͅn̖̦̰̘̩͖g̣̤͕͎ ̬͔forth.

If you try changing the depth between A and B see what happens to the ordering. In this case we no long see a change in timings but also in the final outcome.

If you do something like race it gets sorted by stack depth rather than by when each was resolved / the order they listened in questioning what the utility of race is in this particular circumstance as it does nothing but sort by the depth of the callstack.

Promises are not isolated. Multiple listeners will have their order scrambled by callstack as it executed breadth first. However also resolving two different promises in the same microtask will have their order scrambled as well so the one you resolved second can finish before the one you resolved first.

When performance isn't a concern then you might be able to avoid at least some of this but there are trivial use cases where without doing anything super complex nor unreasonable this will create a problem.

Performance also isn't always something that can be ignored. If it is, why do anything in parallel at all? It's far simpler to do everything one at a time.

The difference things such as this make don't fall into the category of micro-optimisations. The extra blocking can become significant and not necessarily a few % here or there. It can also add complexity to have to reorder things you usually wouldn't.

If you transpile to a non-standard implementation then you don't have any of these problems in the first place, you get all the benefit without any deficit, for example you no longer have to sacrifice performance and behaviour that you could previously reason about while still retaining an elegant syntactical solution.

There is no design objective achieved by this behaviour. It does not solve the occasional problem of maybe async. You still need to defer things onto the event queue in places to restore order which doesn't work in all circumstances. All design objectives stated to justify this behaviour have been proven inapplicable. It appears to be an oversight. The async/await mechanism is meant to represent functions and for functions you need a stack. Instead it uses a queue (FIFO, not a priority queue). This was overlooked when reusing the A+ promise specification to underpin a mechanism meant to reproduce the behaviour of functions.

To give an example of a mistaken argument, this is a work flow based on a typical callback based solution that has to get A and then B (B depends on A). The previous examples would show a parallel scenario where as this is a sequence scenario.

---
\
 \
  \ --> A
---
  / <-- A
 /
/
\
 \
  \ --> B
---
  / <-- B
 /
/
---

We see that when we pass a callback there is a side effect, the callstack is reset each time preventing a stack overflow. If the system however may use a cache and return directly this does not happen:

---
\
 \
  \ --> A
  / <-- A
 /
/
\
 \
  \ --> B
  / <-- B
 /
/
---

Here we get just one stack. This would be a problem with callback based handling as you can't just use something such as a while meaning that the main loop would effectively be sustained by recursive functions instead which have a limit on stack depth. If you had a UI widget that could run for an indeterminate amount of time, as long as the user had it open, it could continually spool a stack. This was made more problematic with a very low limit for stacks imposed in many engines forcing users to break up perfectly acceptable cases that did not run for ever but had a fair bit of recursion. In rare cases, this is a problem for fully synchronous code as well. A small stack is a headache for any situation with a large application.

We already eliminate this in most cases with async/await because we can now use normal while loops rather than playing ping pong. That means we restore a function stack and return back up it. With callbacks returns are in effect converted to new function calls which means inserts onto the stack would never get undone, it would become an insert only structure destined to eventually exceed memory capacity. Async/await does ultimately allow functions to be returned allowing the stack to shrink. Although internally it has callbacks, those serve only to resume functions and once they have done that their internal stack can be removed. However, even for handling callbacks the Promise/A+ specification is mistaken so it's not only a problem with async/await.

This is easily observed as to restore the long chain example seen with synchronous callbacks to match the broken up chain seen with asynchronous callbacks we only need to reset the stack twice but with Promise/A+ we see the stack getting reset many more times than that. It makes an assumption that the promise will be at the root of a callback invocation which is incorrect.

It is not the responsibility of the promise to decide if the stack needs to be reset but rather that of the event emitter. We see we only need solve the problem at two points. Promise/A+ "solves" it at four additional points. Therefore in four cases out of six it fixed what wasn't broken. There's a reason we do not fix what isn't broken. That's because if it wasn't broken then there's nothing good you can achieve from fixing it. You always waste time and the only change you can make to it is to break it. In this case we see both effects.

It's only the promise resolver that knows if it's going to resolve immediately, ever or never or not any of those. The only purpose the promise serves is to relay the message as soon as it can. The promise itself always resolving later exceeds its purpose and is a naive solution. It's not the responsibility of the promise to further postpone the message. The purpose of a promise and future is to act as each end of a fuse, it's not much more complicated than that. When they start creating and lighting their own fuses they become active rather than passive. It is the event emitter that holds the matches, not the fuse.

If you power your firework display using Zalgo fuses expect it to all go awry when your order gets scrambled because Zalgo chopped up all of your fuses screwing with their lengths and made it so multiple fuses get lit at once when only one fuse was meant to go at a time. Don't play with matches. People have actually died from these kinds of mistakes. It happened at a fireworks display when I was a kid because someone accidentally made the fuses go simultaneously rather than sequentially due to some attempts to be clever with the wiring. One minute you think you're a genius making things complex in the name of making things simple and the next moment...

Does our insurance cover this?

Oops.

When criminal negligence investigations for these circumstances are carried out a factor that often emerges is over reliance on safety measures which obscure the real nature of the mechanism. It's a safety fuse, what could go wrong? If something does go wrong then responsibility has been deferred from the operator to that which is being operated and without a sense of liability people are shielded from culpability from tragic consequences so no longer have a motivation to avoid them.

To compound this the specification has an issue that causes it to build an infinite stack making the claim that deferral stops it from "blowing up the stack" laughable.

It can be shown that other languages do not make this mistake, python for example behaves as one would expect functions to behave:

import asyncio

async def f(d, p, i):
        print("Called -> Chain %i and Depth %i" % (i, d))

        if d:
                await f(d - 1, p, i)
        else:
                await p

        print("Chain %i and Depth %i <- Returned" % (i, d))

async def sleep(t):
        await asyncio.sleep(t)

async def main():
        p = asyncio.gather(sleep(0.01))
        a = f(1, p, 0)
        b = f(1, p, 1)
        await asyncio.gather(a, b)

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())
    loop.close()

# Called -> Chain 0 and Depth 1
# Called -> Chain 0 and Depth 0
# Called -> Chain 1 and Depth 1
# Called -> Chain 1 and Depth 0
# Chain 0 and Depth 0 <- Returned
# Chain 0 and Depth 1 <- Returned
# Chain 1 and Depth 0 <- Returned
# Chain 1 and Depth 1 <- Returned

It's also possible to demonstrate a minimal implementation of promises that uses a stack instead of a queue for the promise loop preserving functional order while also deferring each return onto its own stack (not necessarily recommended as it still presents a mismatch):

var queue = [];

function OrderlyPromise() {
    var tooSoon, result;

    this.resolve = (...arg) => {
        if(arg.length > 1 || result)
            throw new Error();

        result = arg;

        if(tooSoon) {
            queue.push(...tooSoon.map(tooSoon => [tooSoon, arg]));
            tooSoon = undefined;
        }
    };

    this.then = callback => {
        const child = new OrderlyPromise();

        if(result)
            queue.push([[callback, child], result]);
        else {
            if(!tooSoon)
                tooSoon = [];

            tooSoon.push([callback, child]);
        }

        return child;
    };
}

var p = new OrderlyPromise();

console.log('Start');
p.then(() => console.log('First')).then(() => console.log('Second'));
p.then(() => console.log('Third'));
console.log('End');

p.resolve();

const stack = [];

const toStack = () => {
    if(!queue.length)
        return;

    stack.push(...queue.reverse());
    queue = [];
};

toStack();

while(stack.length) {
    const [[callback, child], arg] = stack.pop(), result = callback(...arg);

    if(result)
        result.then(child.resolve);
    else
        child.resolve(result);

    toStack();        
}

// Start
// End
// First
// Second
// Third

With A+ promises, they lose the ability to count down (but do count up correctly). You'll get a count down of 3 1 2 where as with this implementation using a stack you'll get a count down of 3 2 1.

We now get the following pattern:

---
\
 \
  \ -> A
---
\
 \
  \ -> B
---
  / <- A
---
 /
---
/
---
  / <- B
---
 /
---
/
---

This does not answer all possible questions. We're still left of the question of why this is still different to callbacks and what does it do.

Starting from callbacks it does something like this:

// Start with:

function go(then0, then1) {
    doStuffFirst();
    then0();
    doStuffMiddle();
    then1();
    doStuffAfter();
}

go();

// Promises always hoists callbacks to the bottom:

function go(then0, then1) {
    doStuffFirst();
    doStuffMiddle();
    doStuffAfter();
    then0();
    then1();
}

go();

// In reality, the very bottom of the ocean:

function go(then0, then1) {
    doStuffFirst();
    doStuffMiddle();
    doStuffAfter();
}

go();
then0();
then1();

If developers want that then they could do it themselves. Why take away the ability to call a function where ever you like? The utility of this escapes me.

This only applies when resolving promises that are not from async functions. That's because the end of the function and its promise resolution are one and the same in case of calling async functions. This raises the question of what does that behaviour then achieve? It can easily be removed for async functions as it now represents a meaningless set of operations once things are back in order.

We have for(let cycles = 0; cycles < 1000; cycles++); every time an async function returns. For a developer to reproduce this they must ensure their return or throw is always a variable and then put the same as sleep(123); immediately before every return or throw statement. It is literally a waste of time.

Many people would find this complicated and it's not normally the kind of thing you would expect to have the think about which is half of its problem. Functions should behave as functions, it should be possible to take that for granted. Instead this complexity has been introduced where you cannot take knowing how functions behave for granted.

What can we do?

Proposing a sync keyword as an alternative to async to go with await that ensures the correct behaviour along with a full promise specification is unlikely to yield any result within a reasonable time frame.

If you have studied this carefully you will learn that this is neither a joke nor the same mistake many others make when reporting that asynchronous calls return out of order as we can formally prove that even synchronous calls return out of order.

There is some good news. I've always not wanted to add too many compile passes to run things backend. However the ESM module loader does make it far more viable to avoid the pain of a full compile pass each run since the loader can compile on demand. Note that dynamicInstantiate didn't work well for me, instead I just used the resolve and loaded compiled JS as a data URI.

This means that for me while native async/await is a bust, I can potentially replace it with a mechanism that works properly in a way that doesn't violate my aversion to a full compile pass each time.

The other good news is that yield should do the correct thing so implementations to the same effect as what we expect from async/await but using */yield instead can be provided and behave appropriately.

Watch this space for more information on how to do that, my Promise/A* specification (which includes cancellation + SOC) and the benchmarks for a world liberated from Zalgo and his groupthink minions.

Discussion

pic
Editor guide