-
Notifications
You must be signed in to change notification settings - Fork 46
Normative: Synchronous based on a syntax and module graph #61
Conversation
This patch is a variant on tc39#49 which determines which module subgraphs are to be executed synchronously based on syntax (whether the module contains a top-level await syntactically) and the dependency graph (whether it imports a module which contains a top-level await, recursively). This fixed check is designed to be more predictable and analyzable. Abstract module record changes: - The [[Async]] field stores whether this module or dependencies are async. It is expected to be initialized by the Linking phase. - The [[ExecutionPromise]] field stores the Promise related to the evaluation of a module whose [[Async]] field is *true*. - Evaluate() returns a Promise for [[Async]] modules, a completion record for sync modules which throw, or undefined otherwise. Cyclic Module Record changes: - A new [[ModuleAsync]] field stores whether this particular module is asynchronous (dependencies aside). - The ExecuteModule method on Cyclic Module Records takes an argument for the Promise capability, but only if that particular module is [[ModuleAsync]]. - The Link/Instantiate phase is used to propagate the [[Async]] field up the module graph to dependencies. - When there's a cycle, with some modules sync and some async, the whole cycle is considered async, with the Promise of each module set to the entrypoint of the cycle, although the cycle-closing edge will not actually be awaited (since this would be a deadlock). Source Text Module Record changes: - The check for whether a module contains a top-level await locally is in a ContainsAwait algorithm (TBD writing this out, but it should be static since await may not appear in a direct eval) - Module execution works as before if ContainsAwait is false, and works like an async function if ContainsAwait is true. Closes tc39#47, tc39#48, tc39#43
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.
Great work, I mainly just have an architectural question about sync subgraphs with async leaves.
spec.html
Outdated
</emu-alg> | ||
<emu-note> | ||
<p>An implementation may parse module source text and analyse it for Early Error conditions prior to the evaluation of ParseModule for that module source text. However, the reporting of any errors must be deferred until the point where this specification actually performs ParseModule upon that source text.</p> | ||
</emu-note> | ||
<emu-note type=editor> | ||
<p>ContainsAwait needs a formal definition, but the idea is to check whether the code contains a top-level await.</p> |
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.
Are you working on this 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.
Not for today, need to pack for a trip. If you're interested in grammar algorithm hacking, go for it!
@@ -377,7 +478,8 @@ <h1>InnerModuleEvaluation( _module_, _stack_, _index_ )</h1> | |||
1. Remove the last element of _stack_. | |||
1. Set _requiredModule_.[[Status]] to `"evaluated"`. | |||
1. If _requiredModule_ and _module_ are the same Module Record, set _done_ to *true*. | |||
1. <ins>Otherwise, set _requiredModule_.[[ExecPromise]] to _module_.[[ExecPromise]].</ins> | |||
1. <ins>Otherwise, if _module_.[[Async]] is *true*,</ins> |
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 know it's unrelated to this PR, but it might be worth adding a note in this spec for this line that this code path ensures cyles have a single execution promise, which I think is an important concept?
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, that's a good point. This is rather subtle logic. (Do you agree with 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.
Yes, I completely agree with it, and think this is a crucial feature for TLA in cycles, that is worth noting more clearly.
spec.html
Outdated
1. Return Completion(_result_). | ||
1. <ins>Otherwise,</ins> | ||
1. <ins>Assert: _capability_ was provided.</ins> | ||
1. <ins>Perform ! AsyncModuleStart(_capability_, _module_.[[ECMAScriptCode]], _moduleCxt_, _module_).</ins> |
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 can't see where the AsyncModuleStart
is that is being referenced here? Should this have stayed AsyncBlockStart
?
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're right, this should be AsyncBlockStart.
README.md
Outdated
@@ -272,9 +272,9 @@ Currently (in a world without top-level `await`), polyfills are synchronous. So, | |||
|
|||
#### Does the `Promise.all` happen even if none of the imported modules have a top-level `await`? | |||
|
|||
Yes. In particular, if none of the imported modules have a top-level `await`, there will still be a delay of some turns on the Promise job queue until the module body executes. The goal here is to avoid too much synchronous behavior, which would break if something turns out to be asynchronous in the future, or even alternate between those two depending on runtime conditions ("releasing Zalgo"). Similar considerations led to the decision that `await` should always be asynchronous, even if passed a non-Promise. | |||
If the module's execution is deterministically synchronous synchronous (that is, if it and its dependencies each contain no top-level `await`), there will be no entry in the `Promise.all` for that module. In this case, it will run synchronously. |
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.
Repeated synchronous
.
1. <ins>Otherwise,</ins> | ||
1. <ins>Let _capability_ be ! NewPromiseCapability(%Promise%).</ins> | ||
1. <ins>Set _module_.[[EvaluationPromise]] to _capability_.[[Promise]].</ins> | ||
1. <ins>Perform ! ExecuteModuleWhenImportsReady(_module_, _asyncDependencies_, _capability_).</ins> |
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.
Consider a graph A imports B and C, and B imports D and E in that order:
A -> B -> D
B -> E
A -> C
The execution of the above graph when importing A is DEBCA.
Now if all of these modules are sync except for D, that means that we are entering this ExecuteModuleWhenImportsReady
for the entire graph of E, B, C, A execution, just for D being async.
What execution guarantees do we provide for the execution of the sync sequence EBCA here? Would there be microtasks or anything of that sort running there?
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.
Basically what I'm saying is, surely BCA could still execute completely sync here even despite B having an async dependency (as soon as D has completed)?
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 C and E would both execute synchronously, without any microtasks involved. The only Promise chains involved would be basically, D.then(() => B).then(() => A). B and A wait on D, so they can't start synchronously.
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.
But wouldn't it be more like D.then(() => Promise.all(B, C)).then(() => A)
? I guess the most concerning here is the C then A part being synchronous, since this means if I just have A->C and I add an import to B above the import to C in A, I'm now losing the execution order of C happening just before A synchronously? Or am I misdiagnosing this?
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.
C doesn't participate in the Promise.all, since it's synchronous.
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.
Your first summary is the ordering I was expecting. What would be the motivation for the other ordering? It seems more counterintuitive to 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.
I can get behind the first ordering as long as it is fully well-defined - that is that synchronous first part of the async function runs in normal sync execution order without invoking any microtasks before its execution. So if there was a dependency F that was synchronous before D, that FD would execute in sequence completely synchronously without microtasks or yielding. As long as that is ok, this sounds incredibly well defined to 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.
(where D above is the synchronous part of D before its first "await" statement)
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, that's what this spec text (aims to) describe.
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 that is the case, then this all sounds excellent to me!
Feedback from @sokra :
|
Since Babel doesn't currently have the capability of analyzing the depdency graph of a module, we can't toggle between "sync" and "async" import based on that. I have to admit that I don't know anything yet about this proposal, but the * "always" would still be configurable by the user. For example, in the module graph mentioned above (#61 (comment)), an user could configure Babel to only compile modules as async when needed:
// Babel config
module.exports = {
overrides: [{
include: ["A", "B", "D"],
plugins: [
["@babel/plugin-transform-modules-commonjs", { async: true }]
],
}, {
include: ["C", "E"],
plugins: [
["@babel/plugin-transform-modules-commonjs", { async: false }]
]
}],
}; |
I don't think the module body, excluding imports, would need to be transpiled differently based on whether the imports are async, would it? I was hoping this wouldn't affect Babel for that reason. |
import foo from "module";
console.log(foo); This could become either var _module = require("module");
console.log(_module.foo); or module.exports = async function () {
var exports = {};
var _module = await require("module");
console.log(_module.foo);
return exports;
}(); |
Babel would probably put the async logic in a helper function: import foo from "moduleA";
import bar from "moduleB";
console.log(foo, bar);
export default 42; var _moduleA = require("moduleA");
var _moduleB = require("moduleB");
module.exports = babelAsyncModuleHelper([_moduleA, _moduleB], ([_moduleA, _moduleB]) => {
console.log(_moduleA.default, _moduleB.default);
exports.default = 42;
return exports;
}); function babelAsyncModuleHelper(modules, fn) {
var hasPromise = modules.some(m => m instanceof Promise);
if(hasPromise) {
return Promise.all(modules).then(fn);
} else {
return fn(modules);
}
} So seems doable... |
That's a good idea! |
Nit: Rather than awaiting all modules when any one of them are a Promise, you can filter just the Promise ones, and then |
I don't think this makes a difference. Instead I think the effect of the patch is: // with this patch
function babelAsyncModuleHelper(modules, fn) {
var hasPromise = modules.some(m => m instanceof Promise);
if(hasPromise) {
return Promise.all(modules).then(fn);
} else {
return fn(modules);
}
} vs. // original proposal
function babelAsyncModuleHelper(modules, fn) {
return Promise.all(modules).then(fn);
} |
To be precise, the effect of this patch is // with this patch
function babelAsyncModuleHelper(modules, fn) {
var promises = modules.filter(m => m instanceof Promise);
if(promises.length !== 0) {
return Promise.all(promises).then(mods =>
fn(modules.map(m => m instanceof Promise ? mods.shift() : m)));
} else {
return fn(modules);
}
} |
That's to avoid awaiting modules which export |
Well, it's not intended to be any sort of comprehensive fix to that; if such a module has a top-level await, then it will still wait on its exported |
I haven't heard any strong objections to this proposal, though I understand if some folks prefer the restrictions of explicit |
This patch is a variant on #49 which determines which module subgraphs
are to be executed synchronously based on syntax (whether the module
contains a top-level await syntactically) and the dependency graph
(whether it imports a module which contains a top-level await,
recursively). This fixed check is designed to be more predictable and
analyzable.
Abstract module record changes:
async. It is expected to be initialized by the Linking phase.
evaluation of a module whose [[Async]] field is true.
record for sync modules which throw, or undefined otherwise.
Cyclic Module Record changes:
is asynchronous (dependencies aside).
argument for the Promise capability, but only if that particular
module is [[ModuleAsync]].
field up the module graph to dependencies.
whole cycle is considered async, with the Promise of each module
set to the entrypoint of the cycle, although the cycle-closing
edge will not actually be awaited (since this would be a deadlock).
Source Text Module Record changes:
is in a ContainsAwait algorithm (TBD writing this out, but it
should be static since await may not appear in a direct eval)
works like an async function if ContainsAwait is true.
Closes #47, #48, #43