Concurrent Async and Normal Await Evaluation Blocks

Ah, I'm understanding things better now.

In your second approach, what would this do?

let tmp1 = 1;
let tmp2 = 2;
async {
  tmp1 = async f();
  tmp2 = async g();
  console.log(tmp1 + tmp2)
}

Does this log 3, because it used the old value of tmp1 and tmp2? Or is it a reference error? Does assigning to a variable put the variable in an undefined state until the promise has settled?


I'm going to focus on your first approach, as that currently makes more sense to me, due to the issue above. In this approach, if we ignore the case where you put async in the last expression (I'll come back to that later), then there's really no difference between this in the parallel const idea, except for potentially nicer syntax, and the fact that there's better support for fire-and-forget.

For example:

const res = async {
  const x = async f()
  const someParam = calcParam()
  const y = async g(someParam)
  async fireAndForget()
  x + y
}

// is the same as

const someParam = calcParam()
parallel const
  x = async f(),
  y = async g(someParam),
  _ = fireAndForget()

const res = x + y

Even though the async {} syntax allows for any arbitrary synchronous expression to be intermixed between the async ones, there's actually no need to do so. Those synchronous expressions can't ever depend on the results of these async operations, so they can always be placed beforehand instead of being intermixed. The async construct itself can only ever be used to assign to something, or in a fire-and-forget scenario - it's invalid to try and use it in any other way, as that would require using the resolved value, which would be a ReferenceError within the block (if we're not at the last expression).

I'm mostly pointing this out, because this async {} syntax has the appearance of being able to have much more flexibility than paralell const, but in reality, I think they're almost the same, except for the extra power it has in the last expression. This isn't a bad thing - it just took me some time to figure out that there wasn't anything extra going on with this except for a different syntax.

Now, about async being used in the last expression (e.g. async { async f() + async g() }) - from what I can understand, this will have pretty much the same semantics as the await.multi idea. You're going to run into the same issues that async.multi had, where we need to define a deterministic execution order for this expression. I'll just assume that it uses the same algorithm as await.multi (where it executes everything after async first, then the rest of the expression), unless you have a different idea you want to share.


Alright, with all of that in mind, I'll try to build yet another proposal using these previous ideas :p. I see that my previous await.multi syntax is not as powerful as the async {} block idea, but I also feel like there's some clumbsy parts to async {} that I want to take a stab at solving.

I'm proposing a do interpolated block, which acts like a do block, but you're allowed to interpolate expressions into it that run before the do block runs.

Example:

const result = do interpolated {
  await ${fireAndForget()}
  const value = await ${f()}
  value + await ${g()} + await ${h()}
}

// is the same as

const result = do {
  const $temp1 = fireAndForget()
  const $temp2 = f()
  const $temp3 = g()
  const $temp4 = h()

  await $temp1
  const value = await $temp2
  value + await $temp3 + await $temp4
}

This will let all of the async operations run in advance, before the actual do block executes and we start running things one-at-a-time. I think the interpolation syntax helps make it clear that the particular expression can't depend on anything created within the do block, i.e. this would be an error:

do interpolated {
  const x = f()
  2 + ${g(x)} // Error! x is not defined.
}

Thoughts?

I have a better (and simpler) idea, one that probably addresses this and several other proposals way better, blending async and sync without sacrificing explicitness or performance (and in reality improving performance somewhat even for optimal code). Here's a short demo:

// Fetch thing A
await let resultA = fetch("thing A")
await let a = resultA.json()

// Fetch thing B - done in parallel with `resultA` because it doesn't depend on it!
await let resultB = fetch("thing B")
await let b = resultB.json()

// If an error occurs, it stops here, not above.
console.log([a, b])

The idea is that you specify the variable as async, and it gets implicitly awaited before first use. The basic production is await var a = b (or similar with let or const), and everything else extends from there. It's simple to learn, and extremely easy to use.

1 Like

Nice idea! I think I like that better than my parallel const idea.

It, unfortunately, doesn't support the use case of doing multiple awaits in a single expression very well. e.g.

const data = {
  x: await.multi getX(),
  y: await.multi getY(),
}

Maybe my hope to support that situation in the same proposal is wishful thinking though and can be considered as a separate issue if there isn't an intuitive way to do so.

I do have one question with your await const proposal - what happens in this scenario?

async function doSomething(data) {
  await const value = fetch('something')
  return data.map(item => item + value) // This lambda is not async
}

(Also, your link is broken - could you fix that?)

Done. Forgot to make it public. :woman_facepalming:

That's by design. I want to keep it simple, and that...isn't simple. That can be a follow-on proposal (if you can find a semantic that doesn't lead to the pitfalls I explain in the proposal regarding child function contexts), but I explicitly want to keep it as simple and narrow as possible.

Edit: You could simulate that with this:

await const dataX = getX()
await const dataY = getY()
const data = { x: dataX, y: dataY }

There's intentionally no first-class support for it per my explanation regarding child function contexts, though. (That's only part of it - there's of course many other nasty demons I chose to avoid in that.)

Search for "child function contexts" in the proposal - that should answer it pretty well. (See my first part of this reply - you likely only asked because I forgot to make the repo public. :upside_down_face:)

1 Like

Ah, you've got a pretty thorough explanation in there about everything :)

So, you said the following in there:

Awaited variables are implicitly awaited, with errors thrown as applicable, before being placed in child function contexts, regardless of type.

Lets take the following example:

async function doSomething() {
  const async x = fetchX()
  const async getX = () => x // <----
  const async y = fetchY()
  return x + y
}

Does this mean the x would get awaited before the <---- line gets run? And if you wanted fetchX() and fetchY() to run in parallel, you would have to move the getX() definition further down the function? It's a little unfortunate, but it probably makes the most sense.

That is the correct understanding, yes. And I certainly tried to come up with a solution, as you could tell in that explainer.

Appreciate you wrote that up. I really like you chose await var, it much better conveys declaring a variable that'll be awaited, than my previous async var suggestion.

Also great you mention cancellation. That's something I definitely wanted to happen to variables that go out of scope before use, but was too lazy to write that up, given that cancellation is not a thing in JS yet.

Now to my issues with your proposal ;)

While implementing async var proof of concept, I realized one has to explicitly forbid mixing var with await var. Mixing let with await let is not an issue, because declaring a lexical twice is already illegal. But await var declarations are tricky, in that they could change the meaning of plain var somewhere else, which is one of the reasons I eventually dropped the ball on async var.

await let is also tricky, because then you have to specify whether it affects assignment as well.

await let x = JSON.parse("garb-age"); // rejection
x = JSON.parse("wut?"); // throw immediately, or wrap in rejection?

This ain't good. It would cause the exact same problem Promise.all currently has. And with await let it has the same problem as reassigning a variable without awaiting the promise it held previously.

await const a = Promise.reject();
await const b = Promise.resolve(1);
await const c = Promise.resolve(2);
return c ? b : a;

I have a proof of concept in the works for a future keyword (akin to your await const). After some consideration, I came to the conclusion that variables that go out of scope before use should not only be cancelled (once cancellation becomes a thing), but their rejections silently ignored. If you never needed the value, you don't care about failures in getting that value.

When is "before being placed"? Consider this example:

async function foo() {
  await const a = new Promise(res => setTimeout(res, 100, 14));
  await const b = new Promise(res => setTimeout(res, 100, 28));
  return f;
  function f() {
    return a + b;
  }
}

Getting confused by a syntax error is way better than getting confused by obscure runtime behaviour. Such as a variable being awaited (and possibly throwing an error from rejection) at the point of declaration of a function that might be using it, i.e. before the function is called, and without the function ever actually reading that value:

// why should this code block for 200ms?
await const a = new Promise(res => setTimeout(res, 200, 42));
const f = x => x ? x * a : 0;
return f(0);

In other words, in my proposal all references to the variable are simply replaced with an await, meaning the above code transpiles to invalid code with an easily explainable error:

// transpiled
const a = new Promise(res => setTimeout(res, 200, 42));
const f = x => x ? x * (await a) : 0;
return f(0);

To me it's not clear why you would want Promise.all(users.map(x => doSomething(x, asyncVar))) to be legal syntax. You're referencing an awaited variable in non-async function. "just because asyncVar was defined differently" is a pretty good reason in my book. The whole point of defining it differently is to make it behave differently.

Feel free to file those as issues against my repo if you haven't already, and I can address them there. It'd be much easier to address piecemeal over there.

I know it's been almost a year, but I noticed something with all of the logic presented. Things that need to be collectively awaited are almost always grouped together. That's why the notion of an await block sound good for this, but doesn't it all lend itself to a much simpler solution?

Automatically awaiting assignment to variables can be a messy and confusing thing if functions get a little long. So I'm not a big fan of the await const style declarations. Instead, I'll take a cue from @Clemdz and offer this:

What if, like started suggesting, we use async as a non-blocking await? Well, that means code will continue to fall through, possibly hitting something requiring one of the async'd calls to be resolved. Since the original goal is to simplify the syntax for Promise.all, why not just continue to use await as a measure of flow control?

async () => {
  let a = async getA();
  let b = async getB();
  let c = await getC();

  return await doSomething(a, b, c.prop);
}

This would be the same as:

async () => {
  let [a, b, c] = await (new Promise.all([ getA(), getB(), getC() ]));
  return await doSomething(a, b, c.prop);
}

Since await makes you wait for the result, it can be used as the cue to wait for all async calls made prior to it in the same function. Benefits:

  • No unnecessary structuring in the function.
  • Only 1 new syntax.
  • Flow control remains clear, concise, and explicit.
  • Errors can still be quickly discovered as unresolved assignments from async'd Promises will be in a TDZ.

IMO, this should be far simpler to implement and comprehend than these notions of async/await blocks.

It’s an interesting idea, though it might surprise people that rejections from getA() would appear to come from await getC()

I don't see this as all that big of an issue.

async () => {
  let, a, b, c;
  try {
    a = async getA();
    b = async getB();
    c = await getC();
  }
  catch(e) {
    console.error(e);
  }

  return await doSomething(a, b, c.prop);
}

Since the Promise instances are hidden, is there any real point in trying to determine where the error came from? If there is such a need, why not just wrap each individual call in an exception handler? I can imagine a way of handling the exceptions on a per Promise basis, but I don't think it's worth the effort to toy around with exception handling the way this would require. Better to just attach a .catch handler to the call.

async () => {
  let, a, b, c;
  a = (async getA()).catch(console.error);
  b = (async getB()).catch(console.error);
  c = (await getC()).catch(console.error);

  return await doSomething(a, b, c.prop);
}

The expansion of this would be the same as:

async () => {
  let handlePromise = (fn) => {
    return new Promise((resolve, reject) => {
      try {
        resolve(fn());
      }
      catch(e) {
        reject(e);
      }
    }).catch(console.error);
  }
  let [a, b, c] = await (new Promise.all([
    handlePromise(getA),
    handlePromise(getB),
    handlePromise(getC)
  ]));
  return await doSomething(a, b, c.prop);
}

The point is that the Promise.all generated by the await following one or more async calls should not necessarily appear to be the source of all errors. In the end, the async/await syntax is meant to hide the Promise objects and make the syntax appear to be more sequential and easy to understand, right?

This is an interesting idea.

Let me throw another wrench in it though.

How would this behave?

obj.x = async task1(); // may or may not trigger a setter function
console.log(obj);
console.log(async task2());
const x = transform(async task3());
const { a: { b } } = async task4(); // Assume this promise resolves to `undefined`
await something();

You've pared this async operator with a declaration in your examples, and then are treating the declaration as if it's in a TDZ until after the next await, but what if the user isn't immediately assigning the output to a simple variable? What if they're doing something else with it first?

The first line throws the value of obj.x into question. If obj.x already existed, then there's no issue and the next line reports the old value. Otherwise, obj.x should either be considered in a TDZ, or still non-existent. I'd prefer the TDZ. I'd much rather throw whenever a value set via async is read before it is initialized.

Assuming we got past the first 2 lines, the third line should react as if it were written like this:

...
console.log(obj);
{
  let v = async task2();
  console.log(v)
}
...

following the same rules as before. That means it throws because the parameter value is in a TDZ.

The 4th line behaves in exactly the same way, as the parameter for the call to transform is in a TDZ. So the declaration for const x essentially behaves as if it was

const x = (() => throw new Error())();

The 5th line is safe as the assignment waits for the async operation to complete. The 6th line, if somehow reached, causes all of the Promise instances created by either async or itself to be wrapped in a Promise.all() call. This causes the 5th line to resolve as const {a: {b}} = void 0; and behave as appropriate for that code.
This one is debatable though. I'm still thinking over whether or not the statement on line 5 should be considered ok. I can make an even better argument against it just by considering that it unpacks to this:

const b = (async task4()).a.b;

If I follow the same rules as before, then it expands to this:

{
   let c = async task4();
   const b = c.a.b;
}

which means the value is in a TDZ and trying to read property a causes it to throw. I think this one is for the engineers and board to sort out. The trouble I'm having is understanding whether or not the destructuring should simply immediately follow as it would in a naive Babel build of this idea, or if everything on the RHS should be held up as with a simple declaration. I'm leaning toward the latter as it makes more visual sense. The RHS of an assignment/initialization should always be in a TDZ until the LHS resolves.

TL;DR - Don't try to directly use the result of async somefunc() before it resolves. That puts you in a TDZ and will cause an error.

All the examples you gave, plus the ones I can think of all result in the creation of either a scoped variable or a parameter, which is still a variable. So the TDZ rules still apply. The point is that you can't use a value that doesn't exist yet.

One other note: It could easily be the case that an async expression resolves immediately. An immediately resolved async is no different than an await.

1 Like

Hmm, ok, interesting.

Is there ever a time where it's useful to use the async operator, and then do something besides immediately assigning it to something? If not, I almost wonder if it's possible to think of assignment + async as almost a single operator.

Not many that I can think of. The problem is that you have to wait for the resolution of the hidden Promise before you can use its value. Since async will advance to the next expression as soon as its operand has either completed, thrown, or fallen into an asynchronous process, you just cannot guarantee that the value it eventually produces (if any) is available to be used on the next expression.

As for combining async and =, I wouldn't. Consider this: what if TC39 decides to consider suspending the entire statement that async is a part of and moving on to the next statement instead of simply advancing to the next expression? Although I wouldn't recommend it for several reasons, the result of such a decision would change a lot about what I said before. It would also open up a wide array of other uses. However, while doing that, it would also either risk out-of-order execution or introduce unpredictable delays in any process using it, not to mention some complicated run-loop processing that's even more complicated than what they did for await.

The main reason I wouldn't merge the 2 tokens is simply because it would be far less intuitive. ES developers can look at let a = async b(); and pretty much guess what it means. Replacing it with something else means even more complicated training than simply remembering that async b() doesn't block.