Conditional await, anyone?

# Andrea Giammarchi (2 months ago)

When developers use async functions, they'll likely also await unconditionally any callback, even if such callback could return a non promise value, for whatever reason.

Engines are great at optimizing stuff, but as of today, if we measure the performance difference between this code:

(async () => {
  console.time('await');
  const result = await (async () => [await 1, await 2, await 3])();
  console.timeEnd('await');
  return result;
})();

and the following one:

(/* sync */ () => {
  console.time('sync');
  const result = (() => [1, 2, 3])();
  console.timeEnd('sync');
  return result;
})();

we'll notice the latter is about 10 to 100 times faster than the asynchronous one.

Sure thing, engines might infer returned values in some hot code and skip the microtask dance once it's sure some callback might return values that are not promises, but what if developers could give hints about this possibility?

In a twitter exchange, one dev mentioned the following:

I often write: let value = mightBePromise() if (value && value.then) value = await value in hot code in my async functions, for precisely this reason (I too have

measured the large perf difference).

so ... how about accepting a question mark after await so that it's clear the developer knows the function might return non Promise based results, hence the code could be faster?

const value = await? callback();

Above code is basically what's this thread/proposal about, adding a conditional await syntax, so that if the returned value is not thenable, there's no reason to waste a microtask on it.

What are your thoughts?

# Gus Caplan (2 months ago)

Sure thing, engines might infer returned values in some hot code and skip

the microtask dance once it's sure some callback might return values that are not promises, but what if developers could give hints about this possibility?

Engines can't do this, because it would change the observable order of running code.

so ... how about accepting a question mark after await so that it's

clear the developer knows the function might return non Promise based results, hence the code could be faster?

This is actually considered an antipattern (google "zalgo javascript"), and promises try to stop that from happening.

# Tab Atkins Jr. (2 months ago)

I'm not sure I understand the intended use-case here. If the author knows the function they're calling is async, they can use await normally. If they know it's not async, they can avoid await altogether. If they have no idea whether it's async or not, that means they just don't understand what the function is returning, which sounds like a really bad thing that they should fix? And in that case, as Gus says, await?'s semantics would do some confusing things to execution order, making the line after the await? either run before or after the calling code, depending on whether the await'd value was a promise or not.

# Dan Peddle (2 months ago)

Have to agree, mixing sync and async code like this looks like a disaster waiting to happen. Knowing which order your code will be executed in might seem not so important for controlled environments where micro optimisations are attractive, but thinking about trying to track down a bug through this would drive me nuts.

Imagine you have a cached value which can be retrieved synchronously - other code which runs in order, and perhaps not directly part of this chain, would be fine. When it’s not there, zalgo would indeed be released. The solution to this is to use promises (as I’m sure you know) so you have a consistent way of saying when something is ready... otherwise it’s thenable sniffing all the way through the codebase.

Async infers some kind of IO or deferred process, scheduling. If there’s a dependency, then we need to express that. That it may be in some cases available synchronously seems like something to be extremely wary of.

# Andrea Giammarchi (2 months ago)

I don't know why this went in a completely unrelated direction so ... I'll try to explain again what is await? about.

My first two examples show a relevant performance difference between the async code and the sync one.

The async code though, has zero reasons to be async and so much slower.

(async () => {
  console.time('await');
  const result = await (async () => [await 1, await 2, await 3])();
  console.timeEnd('await');
  return result;
})();

Why would await 1 ever need to create a micro task, if already executed into a scheduled one via async?

Or in general, why any callback that would early return a value that is not a promise should create a micro task?

So the proposal was implemented in an attempt to de-sugar await? into the steps proposed by the dev I've interacted with:

const value = await? callback();

// as sugar for
let value = callback();
if ('then' in value)
  value = await value;

The order is guaranteed and linear in every case, so that nothing actually change logically speaking, and the hint would be about performance, 'cause engines don't apparently optimize non-promise based cases.

However, since the initial intent/proposal about performance got translated into everything else, I've instantly lost interest myself as it's evident an await? would causes more confusion than it solves.

I am also not answering other points as not relevant for this idea/proposal.

Thanks regardless for sharing your thoughts, it helped me see it would confuse developers.

Best

# Isiah Meadows (2 months ago)

I had a similar example in real-world code, but it was just to merge sync and async into the same code path. I handled it by using generators and basically running them myself: isiahmeadows/mithril-node-render/blob/v2/index.js#L195-L206

In either case, I'm not sure it's worth adding new syntax for it.


Isiah Meadows contact at isiahmeadows.com, www.isiahmeadows.com

# Herby Vojčík (2 months ago)

First, experiences of this guy medium.com/@bluepnume/intentionally-unleashing-zalgo-with-promises-ab3f63ead2fd seem to refute the problematicity of zalgo.

Second, I actually have the use case of this pattern (though actually it's not a use case for a new syntax). In Amber Smalltalk (implementation running of top of JS engine), it would immensely help to be able to have classical "proxied message send" case, where message send is asynchronous in the background. As Amber compiles the code itself, you may say "compile it so you simply await every message send", but then, code that must be synchronous (callbacks used in external JS libs API) will fail. Having two modes is not an option. So I would really use the option to have message sends synchronous by default, but in the case they can be asynchronous, to be able to let them be that way.

Actually, the paypal guy mentioned in the first paragraph has similar case (inter-frame RPC). Using his ZalgoPromise I could compile the things like

ZalgoPromise.resolve(make the message send).then(function (result) ...

# Tab Atkins Jr. (2 months ago)

On Wed, Oct 9, 2019 at 12:08 AM Andrea Giammarchi <andrea.giammarchi at gmail.com> wrote:

I don't know why this went in a completely unrelated direction so ... I'll try to explain again what is await? about.

Nah, we got it. Our complaint was still about the semantics.

const value = await? callback();

// as sugar for
let value = callback();
if ('then' in value)
  value = await value;

The order is guaranteed and linear in every case, so that nothing actually change logically speaking, and the hint would be about performance, 'cause engines don't apparently optimize non-promise based cases.

Expand that code so there's a caller:

function one() {
  two();
  console.log("one");
}
async function two() {
  await? maybeAsync();
  console.log("two");
}

What's the order of the logs?

If maybeAsync() is synchronous, then one() calls two(), two() calls maybeAsync() which returns immediately, and it continues to log "two" before ending and returning execution to one(), which then logs "one" and ends.

If maybeAsync() returns a promise, then one() calls two(), two calls maybeAsync() then freezes while it waits for the promise to resolve, returning execution to one(). Since one() isn't awaiting the promise returned by two(), it just immediately continues and logs "one" then ends. At some point later in execution, the maybeAsync() promise resolves, two() unfreezes, then it logs "two".

So no, the order is not guaranteed. It's unpredictable and depends on whether the function being await?'d returns a promise or not. If you don't know what maybeAsync() returns, you won't be able to predict your own execution flow, which is dangerous and a very likely source of bugs.

# Andrea Giammarchi (2 months ago)

What's the order of the logs?

Exactly the same, as the await? is inevitably inside an async function which would grant a single microtask instead of N.

Example:

async function tasks() {
  await? maybeAsync();
  await? maybeAsync();
  await? maybeAsync();
}

tasks();

If maybeAsync returns twice non promises results, there is only one microtask within the async tasks function, that would linearly collect all non promises, so that above example could have 1, 2, max 4 microtasks, instead of always 4 . To explain await? in steps:

  • is the awaited value a promise? schedule microtask
  • otherwise schedule a single microtask if none has been scheduled already, or queue this result to the previous scheduled one

This would grant same linear order and save time.

However, like others said already in the twitter thread, we all wish await was already working like that by default, while it seems to unconditionally create micro tasks even when it's not strictly necessary.

# Tab Atkins Jr. (2 months ago)

On Wed, Oct 9, 2019 at 11:17 PM Andrea Giammarchi <andrea.giammarchi at gmail.com> wrote:

What's the order of the logs?

Exactly the same, as the await? is inevitably inside an async function which would grant a single microtask instead of N.

I think you're misreading my example? Check this out: software.hixie.ch/utilities/js/live-dom-viewer/saved/7271

<!DOCTYPE html>
<script>

function one() {
  oneAsync();
  w("one A");
}
async function oneAsync() {
  await Promise.resolve();
  w("one B");
}

function two() {
  twoAsync();
  w("two A");
}

async function twoAsync() {
  // await? true;
  w("two B");
}

one();
two();
</script>

This script logs:

log: one A
log: two B
log: two A
log: one B

A and B are logged in different order depends on whether there's an await creating a microtask checkpoint or not. await? true won't create a microtask checkpoint, so you'll get the two() behavior, printing B then A. await? Promise.resolve(true) will create one, so you'll get the one() behavior, printing A then B.

If maybeAsync returns twice non promises results, there is only one microtask within the async tasks function, that would linearly collect all non promises, so that above example could have 1, 2, max 4 microtasks, instead of always 4 . To explain await? in steps:

  • is the awaited value a promise? schedule microtask
  • otherwise schedule a single microtask if none has been scheduled already, or queue this result to the previous scheduled one

This would grant same linear order and save time.

Ah, your earlier posts didn't say that await? nonPromise would schedule a microtask in some cases! That does change things. Hm, I wonder if this is observably different from the current behavior?

# Andrea Giammarchi (2 months ago)

Again, the await? is sugar for the following:

const value = await? callback();

// as sugar for
let value = callback();
if ('then' in value)
  value = await value;

but since I've stated already I have no interest anymore in this proposal, we can also stop explaining to each others things we know already.

Best

# Tab Atkins Jr. (2 months ago)

On Fri, Oct 11, 2019 at 1:15 AM Andrea Giammarchi <andrea.giammarchi at gmail.com> wrote:

Again, the await? is sugar for the following:

const value = await? callback();

// as sugar for
let value = callback();
if ('then' in value)
  value = await value;

Okay, so that has the "you can't predict execution order any more" problem. But that's not consistent with what you said in "otherwise schedule a single microtask if none has been scheduled already, or queue this result to the previous scheduled one", which implies a different desugaring:

let value = callback();
if('then' in value || thisFunctionHasntAwaitedYet)
  value = await value;

This desugaring has a consistent execution order, and still meets your goal of "don't add a bunch of microtask checkpoints for synchronous values".

Put another way, this await? is equivalent to await if you're still in the "synchronously execute until you hit the first await" phase of executing an async function; but it's equivalent to your simpler desugaring ("await only if this is a thenable") after that.

but since I've stated already I have no interest anymore in this proposal, we can also stop explaining to each others things we know already.

I'm fine if you want to drop it, but we're not explaining things we already know to each other. At least one of us is confused about what's being proposed. And the altered desugaring I give up above at least has a chance of happening; the only issue might be the observability of how many microtasks get scheduled. If that's not a problem, it might be possible to suggest this as an actual change to how await works.

# Andrea Giammarchi (2 months ago)

in order to work, await must be executed in an async scope/context (either top level or within a closure).

In such case, either somebody is awaiting the result of that async execution, or the order doesn't matter.

The following two examples produce indeed the very same result:

(async () => { return 1; })().then(console.log);

console.log(2);

(async () => { return await 1; })().then(console.log);

console.log(2);

Except the second one will be scheduled a micro task too far.

Since nobody counts the amount of microtasks per async function execution, the result is practically the same, except the second example is always slower.

Putting all together:

(async () => { return await 'third'; })().then(console.log);
(async () => { return 'second'; })().then(console.log);

console.log('first');

If you await the return of an async function, you are consuming that microtask regardless, which is what await? here would like to avoid: do not create a micro task when it's not necessary.

There's no footgun as the await? is an explicit intent from the developer, so if the developer knows what s/he's doing, can use await?, otherwise if the order of the microtask matters at all, can always just use await.

As summary: the proposal was to help engines be faster when it's possible, but devs are confused by the syntax, and maybeat the end there wouldn't be as many benefits compared to the apparent confusion this proposal would add.

I hope I've explained properly what was this about.

# Tab Atkins Jr. (2 months ago)

On Sat, Oct 12, 2019 at 7:19 AM Andrea Giammarchi <andrea.giammarchi at gmail.com> wrote:

in order to work, await must be executed in an async scope/context (either top level or within a closure).

In such case, either somebody is awaiting the result of that async execution, or the order doesn't matter.

That's definitely not true. I gave you an explicit example where the order differs. That example code is realistic if you're using the async call for side-effects only (and thus don't care about the returned promise), or if you're storing the returned promise in a variable so you can pass it to one of the promise combinators later. In either of these cases the order of execution between the sync and async code can definitely matter.

The following two examples produce indeed the very same result:

(async () => { return 1; })().then(console.log);
console.log(2);

(async () => { return await 1; })().then(console.log);
console.log(2);

In both of these cases, you're doing no additional work after the "maybe async" point. That is the exact part that moves in execution order between the two cases, so obviously you won't see any difference. Here's a slightly altered version that shows off the difference:

(async () => { 1; console.log("async"); return 3; })().then(console.log);

console.log(2);

(async () => { await 1; console.log("async"); return 3; })().then(console.log);

console.log(2);

In the first you'll log "async", "2", "3". In the second you'll log "2", "async", "3".

As summary: the proposal was to help engines be faster when it's possible, but devs are confused by the syntax, and maybeat the end there wouldn't be as many benefits compared to the apparent confusion this proposal would add.

You still seem to be misunderstanding what the execution order difference is about. Nobody's confused about the syntax; it's clear enough. It just does bad, confusing things as you've presented it.

As I said in earlier message, there is a way to eliminate the execution-order difference (making it so the only difference would be the number of microtasks when your function awaits multiple sync values), which I thought you'd come up with at some point, but I'm pretty sure it was just me misunderstanding what you'd said.

# Andrea Giammarchi (2 months ago)

You still seem to be misunderstanding what the execution order difference

is about.

If to stop this thread you need me to say I am confused about anything then fine, "I am confused", but if you keep changing my examples to make your point then this conversation goes nowhere, so I am officially out of this thread.

Best .

# #!/JoePea (a month ago)

I haven't read the whole thread, but I also disliked the ugly conditional checking I had to do to get faster results.

I agree that this would changed perceived code execution order (unexpectedly, a breaking change).

I believe that a better solution would be to make it explicit in the function definition, so that it carries up into caller code:

async? function foo() { ... }

Now, the user of the function must be required to use await? and thus has to be forced to think that the foo function might possibly run async, or might not. Using await on an async? function would be a runtime error, to further prevent possible confusion and errors.

Only with a construct like async? function could I see this becoming possible, so that there aren't any surprising breaking changes in downstream projects.