-
Notifications
You must be signed in to change notification settings - Fork 46
Conversation
The new explainer is opinionated, making the case for Variant B with no deadlock prevention.
Note, the examples here have sort of regressed in realistic-ness compared to Myles' previous explainer. Help in writing better examples (and any other improvements to this text) would be greatly appreciated. |
README.md
Outdated
|
||
## Semantics as desugaring | ||
|
||
Top-level `await` makes importing a module automatically block on any top-level `await`s. You can think of it like, each module exports a Promise, and after all the `import` statements, but before the rest of the module, the Promises are all `await`ed: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems very spooky-action-at-a-distance to me. I can add an await
in my module, and alter the execution order of all the modules importing me?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This sounds like an argument against the feature as a whole, rather than about Variant A vs Variant B. Did TC39 fail to consider this properly when proposing for Stage 2? (I encourage you to file an issue about your concerns; they are more likely to be lost here.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, this problem was brought to my attention right after the stage 2 meeting, and since it's never been brought back to the committee, and since #42 never happened, i've not had a chance to air my concern fully. Had this occurred to me prior to that meeting, I'd have objected to stage 2.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it documented clearly in any particular issue?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nope; I think it's the sort of thing that needs talking through before I'm able to verbalize it in an issue well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would strongly suggest raising an issue with a concrete example, as I don't understand the concern as raised. I think an issue with a textual example will be much clearer than trying to do so verbally.
|
||
It's true that top-level `await` gives developers a new tool to make their code wait. Our hope is that proper developer education can ensure that the semantics of top-level `await` are well-understood, so that people know to use it just when they intend that importers should block on it. | ||
|
||
We've seen this work well in the past. For example, it's easy to write code with async/await that serializes two tasks that could be done in parallel, but a deliberate developer education effort has popularized the use of `Promise.all` to avoid this hazard. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I very much disagree this is popularized; I have to tell people to use Promise.all
to avoid unnecessarily serializing their code with await
multiple times a day, every day, since async/await became popular. It's a commonly fired footgun, and education has not yet been sufficient to avoid it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interesting; maybe file an issue about it? Also seems like an argument about the feature as a whole.
|
||
The Optional Constraint would alleviate concerns of halting process. | ||
Currently (in a world without top-level `await`), polyfills are synchronous. So, the idiom of importing a polyfill (which modifies the global object) and then importing a module which should be affected by the polyfill will still work if top-level `await` is added. However, if a polyfill includes a top-level `await`, it will need to be imported by modules that depend on it in order to reliably take effect. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm glad to read this; this is an important guarantee to be preserving.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I think this property was never in doubt, and there were just some communication issues about it.
README.md
Outdated
``` | ||
|
||
If each of the modules above had a top level await present the loading would have similar execution order to | ||
None of the statements in `usage.mjs` will execute until the `await`s in `awaiting.mjs` have had their Promises resolve, so the race condition is avoided by design. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You could add something like "This is similar to [an extension of?] how in the current system none of the statements in usage.mjs will execute until awaiting.mjs
is loaded and all of its statements has executed."
README.md
Outdated
|
||
## Semantics as desugaring | ||
|
||
Top-level `await` makes importing a module automatically block on any top-level `await`s. You can think of it like, each module exports a Promise, and after all the `import` statements, but before the rest of the module, the Promises are all `await`ed: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I might approach this from a different direction (or, start out with a different explanation). Something like:
Currently, a module waits for all of its dependencies to execute all of their statements before the import is considered finished, and the module's code can run. While introducing await
, we want to keep this property: dependencies should still execute through to the end, even if you need to wait for that execution to finish asynchronously. The natural way to think of this is as if each module exported a promise...
README.md
Outdated
|
||
## Semantics as desugaring | ||
|
||
Top-level `await` makes importing a module automatically block on any top-level `await`s. You can think of it like, each module exports a Promise, and after all the `import` statements, but before the rest of the module, the Promises are all `await`ed: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would strongly suggest raising an issue with a concrete example, as I don't understand the concern as raised. I think an issue with a textual example will be much clearer than trying to do so verbally.
README.md
Outdated
import bPromise, { b } from './b.mjs' | ||
import cPromise, { c } from './c.mjs' | ||
|
||
Promise.all([aPromise, bPromise, cPromise]).then(() => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it worth including an export const dPromise =
at the beginning of this line, to show how it propagates?
README.md
Outdated
|
||
#### What exactly is blocked by a top-level `await`? | ||
|
||
When one module imports another one, the importing module will only start executing its module body once the exporting module's body has finished executing. If the exporting module reaches a top-level await, that will have to complete before the importing module's body starts executing. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think "dependency" is much clearer than "exporting module".
README.md
Outdated
|
||
#### Why doesn't top-level `await` block the import of an adjacent module? | ||
|
||
If one module wants to declare itself dependent on another module, for the purposes of waiting for that other module to complete its top-level `await` statements before the module body executes, it can delcare that other module as an import. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If one module wants to declare itself dependent on another module, for the purposes of waiting for that other module to complete its top-level `await` statements before the module body executes, it can delcare that other module as an import. | |
If one module wants to declare itself dependent on another module, for the purposes of waiting for that other module to complete its top-level `await` statements before the module body executes, it can declare that other module as an import. |
|
||
In a case such as the following, `"Y"` will be printed on the console before `"X"`, because importing one module "before" another does not create an implicit dependency. | ||
|
||
```mjs |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use separate code blocks for each file to make it a bit clearer.
In a case such as the following, `"Y"` will be printed on the console before `"X"`, because importing one module "before" another does not create an implicit dependency. | ||
|
||
```mjs | ||
// x.mjs |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would extend this example to show how if x.mjs logs before it awaits, that log ("X1") comes before "Y". So, execution order is preserved up until an await
.
Thanks for your constructive review, @domenic . These all seem like good ideas, so I've integrated changes from all of your suggestions. |
README.md
Outdated
console.log(a, b, c); | ||
})(); | ||
// usage.mjs | ||
import default, { output } from "./awaiting.mjs"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
default
is a keyword and cannot be used as an identifier name here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, fixed.
README.md
Outdated
|
||
Variant B offers a unique approach to blocking, as it will not block siblings execution. | ||
Regardless of whether top-level `await` is used, modules always start running in the same post-order traversal established in ES2015: execution of module bodies starts with the deepest imports, in the order that the import statements for them are reached. After a top-level `await` is reached, control may be passed to start the next module in this traversal order. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This still sounds a bit like Variant A, doesn't it? Considering
// u.mjs
console.log("u1");
await new Promise(r => setTimeout(r, 1000));
console.log("u2");
// x.mjs
import "u";
console.log("x");
// y.mjs
console.log("y");
// z.mjs
import "x";
import "y";
console.log("z");
Without TLA in u.mjs
, we'd get u - x - y - z.
But with the TLA in u.mjs
, I would expect to get u1 - y - u2 - x - z. Does y
really need to wait for x
, because of the established traversal?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
y doesn't need to wait for x. I agree with your expectations of ordering, and this text tries to explain them, alongside @guybedford's specification text for them in #33. Could you point a little more closely to what part of this text made you think otherwise?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the confirmation that my expectations aren't off.
I was confused by "modules always start running in the same post-order traversal established in ES2015", which seems to suggest that y
would need to start running after x
. The "After a top-level await
is reached, control may be passed to start the next module in this traversal order" seemed to contradict that a bit, but I thought it would be geared towards the x-y-z example in the previous section ("block the import of an adjacent module"), and there x
still starts before y
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe a few tiny changes could clear this up:
Regardless of whether top-level
await
is used, modules initially start running in the same post-order traversal established in ES2015: execution of module bodies starts with the deepest imports, in the order that the import statements for them are reached. After a top-levelawait
is reached, control will be passed to start the next (first?) ready-to-run module in this traversal order.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for clarifying. What do you think of the version I just uploaded?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like it :-)
``` | ||
|
||
Module a would need to finish executing before b or c could execute. | ||
However, this leaves us with a number of problems still: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another problem: you need to separate declaration from initialisation. One could not write export const example = await …
, but rather has to declare a top-level export let/*sic!*/ example
and then initialise it in the IIAFE.
So while this would indeed be a proper workaround, nobody would use it. People, who already can often not distinguish destructurable objects from aliased import/export bindings, would instead default-export a promise that fulfills with a "module" object, and use
import x from "x";
export default (async () => {
const {xa, xb} = await x; // the parts of the import
return { // the module "exports"
exportA() { … }
exportB: …
};
})();
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I agree that that is another unfortunate pattern that could emerge. Both patterns are bad; we could document both, but I thought this one would make the explainer clearer as it's closer to the solution.
Do we have any evidence that either of these two patterns is catching on? The GitHub search evidence that was previously used in the explainer seems rather weak.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can see the switch from let
to const
and the combination of declaration and definition in the code sample I included. Would it really help to spell this out with further text?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have no idea how to use Github search effectively for design patterns. This approach is however what I myself have recommended before, as it cleaner (no race conditions, less chance of forgetting the promise, simpler syntax) than the alternative.
Maybe introduce the closer-to-the-solution pattern only in the "Semantics as desugaring" section below?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do you think of the text I just uploaded, which includes your pattern?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, perfect!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks - this update to the explainer is very helpful.
|
||
If one module wants to declare itself dependent on another module, for the purposes of waiting for that other module to complete its top-level `await` statements before the module body executes, it can declare that other module as an import. | ||
|
||
In a case such as the following, the printed order will be `"X1"`, `"Y"`, `"X2"`, because importing one module "before" another does not create an implicit dependency. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm curious about the execution order for the example below, given the "always Promise.all" behavior described further down:
// a-1.js
console.log('a-1');
// a-2.js
console.log('a-2');
// a.js
import './a-1.js';
import './a-2.js';
console.log('a');
// b.js
console.log('b');
// main.js
import './a.js';
import './b.js';
console.log('main');
The current ES semantics will result in
a-1
a-2
a
b
main
With the suggested Promise.all
semantics, what will be the result?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It should - no, must - stay the same, as there is no await
anywhere in the example and we want to be backwards-compatible.
I think with Promise.all
semantics, the modules will load in the same order as previously, will wait for their dependencies (which are synchronous, so immediately fulfilled promises), and then execute in that same order as promise tasks run in the same order they were queued in. The only difference is that they will be run in microtasks, see #43, but that doesn't affect order.
Oh, wait, you are concerned that a
needs to wait for more promise resolution stuff than b
, and might be evaluated after it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right, the semantics of this proposal are that the order would stay the same as what you describe. The Promise.all affects how JS waits for dependencies, not how it launches their execution. (However, there is an observable change in that some turn(s) through the job queue would be taken in between modules.)
Seems like this property has been confusing and difficult to explain. Your help in pinpointing what causes this confusion would be appreciated.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@bergus That's right. It seems from the explainer that the result will now be:
a-1
a-2
b
a
main
Because "a" has to defer its body evaluation until Promise all'ing "a-1" and "a-2", which will push its evaluation job out past "b".
Does that make sense?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@littledan not sure whether it's just a mistake in my naive attempt at translating zenparsing's example to the "Promise.all
semantics" and the actual semantics in #33 handle this correctly (I haven't dived into the details yet), or whether it is an actual issue, but
const a1 = Promise.resolve() .then(() => console.log('a-1'));
const a2 = Promise.resolve() .then(() => console.log('a-2'));
const a = Promise.all([a1, a2]).then(() => console.log('a'));
const b = Promise.resolve() .then(() => console.log('b'));
Promise.all([a, b]) .then(() => console.log('main'));
does output the logs in the wrong order due to Promise.all([a1, a2])
queuing more promise jobs than the Promise.resolve
that b
waits for.
(edit) To clarify: this behaviour is not what we want. @zenparsing thanks for the confirmation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@zenparsing Oh, do you mean it would be pushed out because of several turns through the job queue would make "a" land later than "b" (in a sort of "race") where the delay isn't really triggered by waiting external resources?
Maybe what we're stumbling towards is that we should drain the microtask queue between sibling execution!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe, but explicit queue draining would be a wart imo.
I guess it's easier to have a "synchronous" version of Promise.all
, that checks whether all of its arguments are fulfilled already and if so immediately returns a result, falling back to the standard asynchronous procedure otherwise. (Yes, I know about Zalgo, but it could be warranted here). Even better if made explicit by using a result type of SynchronouslyEvaluated<ModuleRecord> | TlaEvaluated<Promise<ModuleRecord>>
.
The alternative would be to make the traversal explicit and maintain a graph or queue, instead of doing it implicitly in the recursive algorithm. This might even give way to explicitly allow for concurrent (pre)loading of module code.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's continue this discussion in #47 . Variant B has been proposed for a long time, and isn't introduced by this PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM. I'll land in 24 hours unless there is an explicit objection. We can continue to dig in and augment as improvements are identified
Thanks @littledan ! |
The new explainer is opinionated, making the case for Variant B with
no deadlock prevention.