Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reduce unnecessary Awaits for nullish values in blocks containing await using #219

Open
wants to merge 8 commits into
base: main
Choose a base branch
from

Conversation

rbuckton
Copy link
Collaborator

@rbuckton rbuckton commented Mar 22, 2024

This is an alternative to #216 and is based on another fix in #218.

Per #196 (comment), this reduces the number of Awaits in blocks containing await using and in AsyncDisposableStack by collapsing the Awaits introduced for null or undefined resources to a single Await.

As currently specified, the following code results in three Awaits when only one is necessary:

{
  await using x = null, y = undefined, z = { [Symbol.dispose]() {} };
}

This was also the case in an AsyncDisposableStack containing synchronous disposables:

const stack = new AsyncDisposableStack();
stack.use(null);
stack.use(undefined);
stack.use({ [Symbol.dispose]() { } });
await stack.disposeAsync(); // Awaits four times, including the explicit await on this line.

This PR changes this behavior such that we only need an implicit Await in the following cases:

  • Disposal of an async resource1 or a non-nullish async-from-sync resource2, so long as it does not throw synchronously.
  • When transitioning from a null or undefined async-from-sync resource to an async resource, non-nullish async-from-sync resource, or sync resource3.
  • When transitioning from a null or undeifned async-from-sync resource is the last disposal performed for a scope.

Example 1

try {
  using A = syncRes;
  await using B = null, C = undefined;
  HAPPENS_BEFORE
} catch { }
HAPPENS_AFTER
  1. C is undefined so no disposal happens, but we track that an Await must occur. [+0 turns]
  2. B is null so no disposal happens, but we track that an Await must occur. [+0 turns]
  3. A is neither null nor undefined and we have tracked that an Await must occur, so we Await(undefined). [+1 turn]
  4. A[@@dispose]() is invoked in the following turn. [+0 turns]
  5. HAPPENS_AFTER happens in the same turn as A[@@dispose](), but a turn after HAPPENS_BEFORE.

Example 2

try {
  using A = syncRes;
  await using B = null, C = asyncRes;
  HAPPENS_BEFORE
} catch { }
HAPPENS_AFTER
  1. C is neither null nor undefined so we invoke its @@asyncDispose method. If it completes normally we Await the result [+1 turns], but if it throws we continue on the same turn [+0 turns].
  2. B is null so no disposal happens, but we track that an Await must occur. [+0 turns]
  3. A is neither null nor undefined and we have tracked that an Await must occur, so we Await(undefined). [+1 turns]
  4. A[@@dispose]() is invoked in the following turn. [+0 turns]
  5. HAPPENS_AFTER happens in the same turn as A[@@dispose](), but between 1-2 turns after HAPPENS_BEFORE.

Example 3

try {
  using A = syncRes;
  await using B = asyncRes, C = null;
  HAPPENS_BEFORE
} catch { }
HAPPENS_AFTER
  1. C is null so no disposal happens, but we track that an Await must occur. [+0 turns]
  2. B is neither null nor undefined and we have tracked that an Await must occur, so we Await(undefined). [+1 turns]
  3. Next, we invoke B's @@asyncDispose method. If it completes normally we Await the result [+1 turns], but if it throws we continue on the same turn [+0 turns].
  4. A is neither null nor undefined so we invoke A[@@dispose]() either on the turn after B's disposal (if it was normal), or on the same turn (if it threw). [+0 turns]
  5. HAPPENS_AFTER happens in the same turn as A[@@dispose](), but between 1-2 turns after HAPPENS_BEFORE.

It is important to note that a synchronous throw completion from a call to @@asyncDispose will result in the Await being passed over, but this is consistent with for await and AsyncIterator.next() behavior. Following #218, this is unlikely to occur for a @@dispose method as it will be automatically wrapped in a new PromiseCapability, though it is still possible to throw synchronously if Promise.prototype.then is patched to throw an exception synchronously.

This PR also removes unnecessary Awaits for null/undefined resources in AsyncDisposableStack.prototype.disposeAsync().

The PR against ecma262 is being tracked in rbuckton/ecma262#10

Fixes #196
Fixes #208

Footnotes

  1. An async resource is an object with an @@asyncDispose method initialized in an await using declaration.

  2. An sync-from-sync resource is either null, undefined, or an object with a @@dispose method (but not an @@asyncDispose method) initialized in an await using declaration.

  3. A sync resource is either null, undefined, or an object with a @@dispose method initialized in a using declaration.

Copy link

A preview of this PR can be found at https://tc39.es/proposal-explicit-resource-management/pr/219.

@rbuckton rbuckton added needs-consensus A pull request that needs consensus at TC39 plenary normative Indicates a normative change to the specification labels Mar 22, 2024
@bakkot
Copy link

bakkot commented Mar 22, 2024

In example 2, if C[@@asyncDispose]() completes normally and so is await'd, I wouldn't expect another await to be necessary for B. We've already done an await, and we haven't done anything after that, so waiting another tick doesn't really do anything: that is, having that additional await undefined doesn't take any code that was previously going to run within a single turn and split it across two turns.

Did you mean to say that in step 3 there is only an await if C[@@asyncDispose]() throws?

@rbuckton rbuckton added the enhancement New feature or request label Mar 22, 2024
@rbuckton
Copy link
Collaborator Author

rbuckton commented Apr 1, 2024

In example 2, if C[@@asyncDispose]() completes normally and so is await'd, I wouldn't expect another await to be necessary for B. We've already done an await, and we haven't done anything after that, so waiting another tick doesn't really do anything: that is, having that additional await undefined doesn't take any code that was previously going to run within a single turn and split it across two turns.

I agree, in principle. My concern is this: should C[@@asyncDispose]() throw synchronously and we do not introduce a synthetic Await for B, then there would be no Await at all between HAPPENS_BEFORE and HAPPENS_AFTER, despite B being "disposed" after C. We could perhaps elide the Await if we are certain that C[@@asyncDispose]() did not throw synchronously.

Did you mean to say that in step 3 there is only an await if C[@@asyncDispose]() throws?

Example 3, steps 2 and 3:

  1. B is null so no disposal happens, but we track that an Await must occur. [+0 turns]
  2. A is neither null nor undefined and we have tracked that an Await must occur, so we Await(undefined). [+1 turns]

Step 3 performs an Await if one or more null/undefined values were encountered immediately before since we are switching from an async resource declared in an await using back to a sync resource declared in a using. At the moment, this occurs regardless as to whether C[@@asyncDispose]() throws in Step 1.

Essentially, this ensures that the following code will Await at least once, but up to twice depending on whether asyncNonNull throws synchronously from its @@asyncDispose method:

{
  using A = syncNonNull;
  await using B = null, C = null, D = undefined, E = asyncNonNull;
}

As we unwind disposal, we do the following:

  1. Async disposal of E (0-1 Awaits depending on whether it throws synchronously)
  2. Deterministic collapse of D, C, and B, indicates Await is needed
  3. transition from await using to using, so we Await if we indicated it was needed in the previous step.
  4. Sync disposal of A.

@rbuckton
Copy link
Collaborator Author

Consensus in plenary was to collapse all Awaits for null/undefined, not just contiguous runs of null/undefined. There was also tentative consensus to enforce an Await if there are a run of null/undefined resources prior to an async resource that throws synchronously, but that requires an update to this PR and further discussion.

@rbuckton
Copy link
Collaborator Author

rbuckton commented Apr 10, 2024

@nicolo-ribaudo, you asked to review the proposed changes here before this could reach consensus. I'd appreciate if you could review these changes as of the most recent commits.

The intent behind these changes is as follows:

  • null/undefined in an await using does not immediately trigger an Await during cleanup, but instead record that an Await is needed.
  • When non-null/non-undefined resource is disposed, so long as it does not throw synchronously, we Await the result of disposal and record that an Await has occurred.
  • Any time we transition from an async resource (i.e., one defined by await using) to a sync resource (i.e., one defined by using), if an Await is needed and has not occurred, we Await.
  • If DisposeResource exits and the last resource was an async resource (i.e., one defined by await using), if an Await is needed and has not occurred, we Await.
  • Does not distinguish between await using x = ?, y = ?; and await using x = ?; await using y = ?;

This has the following effects:


Given,

{
    await using x = null, y = null;
}

we record an Await is needed when disposing y and then x. As we exit DisposeResources, we see that an Await is needed but has not occurred, so we perform an Await. (Await count = 1)


Given,

{
  await using x = null, y = asyncDisposable, z = null;
}

we record an Await is needed when disposing z, we record an Await has occurred when disposing y, and record that an Await is neded when disposing x. As we exit DisposeResources, we see that an Await is needed and has occurred, so we do not Await. (Await count = 1)


Given,

{
  await using x = null, y = syncThrowInAsyncDispose, z = null;
}

we record an Await is needed when disposing z, disposing y throws synchronously is not Awaited, and record that an Await is neded when disposing x. As we exit DisposeResources, we see that an Await is needed and has not occurred, so we perform an Await. (Await count = 1)


Given,

{
  await using x = null, y = null, z = asyncDisposable;
}

we record an Await is needed when disposing z, we record an Await has occurred when disposing y, and record that an Await is neded when disposing x. As we exit DisposeResources, we see that an Await is needed and has occurred, so we do not Await. (Await count = 1)


Given,

{
  using a = syncDisposable;
  await using x = null, y = null;
}

we record an Await is needed when disposing y and then x. As we transition from the async resource x to the sync resource a, we see that an Await is needed but has not occurred, so we perform an Await. (Await count = 1)


Given,

{
  using a = syncDisposable;
  await using x = null, y = asyncDisposable, z = null;
}

we record an Await is needed when disposing z, we record an Await has occurred when disposing y, and record that an Await is neded when disposing x. As we transition from the async resource x to the sync resource a, we see that an Await is needed and has occurred, so we do not Await. (Await count = 1)


Given,

{
  using a = syncDisposable;
  await using x = null, y = syncThrowInAsyncDispose, z = null;
}

we record an Await is needed when disposing z, disposing y throws synchronously is not Awaited, and record that an Await is neded when disposing x. As we transition from the async resource x to the sync resource a, we see that an Await is needed and has not occurred, so we perform an Await. (Await count = 1)


Given,

{
  using a = syncDisposable;
  await using x = null, y = null, z = asyncDisposable;
}

we record an Await is needed when disposing z, we record an Await has occurred when disposing y, and record that an Await is neded when disposing x. As we transition from the async resource x to the sync resource a, we see that an Await is needed and has occurred, so we do not Await. (Await count = 1)

@rbuckton
Copy link
Collaborator Author

I'd also appreciate additional reviews from @syg, @waldemarhorwat, @michaelficarra, and @bakkot.

spec.emu Outdated Show resolved Hide resolved
@rbuckton
Copy link
Collaborator Author

Also, if @mhofman and @erights could take a look, I'd appreciate it.

@erights
Copy link

erights commented Apr 10, 2024

How can I see a rendered form of this?

@rbuckton
Copy link
Collaborator Author

How can I see a rendered form of this?

The first comment on this PR contains a link to the rendered spec text.

The specific section in question is here: https://tc39.es/proposal-explicit-resource-management/pr/219/#sec-disposeresources

Copy link
Member

@nicolo-ribaudo nicolo-ribaudo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good to me.

It's not explicit from the examples, but awaits are also collapsed across subsequent separate await using declarations right?

await using x = null;
await using y = null;

Keeps being the same as

await using x = null, y = null;

spec.emu Outdated Show resolved Hide resolved
@rbuckton
Copy link
Collaborator Author

It's not explicit from the examples, but awaits are also collapsed across subsequent separate await using declarations right?

Correct. I called this out:

  • Does not distinguish between await using x = ?, y = ?; and await using x = ?; await using y = ?;

@mhofman
Copy link
Member

mhofman commented Apr 11, 2024

I agree, in principle. My concern is this: should C[@@asyncDispose]() throw synchronously and we do not introduce a synthetic Await for B, then there would be no Await at all between HAPPENS_BEFORE and HAPPENS_AFTER, despite B being "disposed" after C. We could perhaps elide the Await if we are certain that C[@@asyncDispose]() did not throw synchronously.

I keep wondering if it wouldn't be simpler to always Await for an @@asyncDispose that throws synchronously. That way there would be no difference whether there was a null async resource disposed before or not, and you could just always collapse null async resources if any non-null async resource were added to the stack.

@rbuckton
Copy link
Collaborator Author

I agree, in principle. My concern is this: should C[@@asyncDispose]() throw synchronously and we do not introduce a synthetic Await for B, then there would be no Await at all between HAPPENS_BEFORE and HAPPENS_AFTER, despite B being "disposed" after C. We could perhaps elide the Await if we are certain that C[@@asyncDispose]() did not throw synchronously.

I keep wondering if it wouldn't be simpler to always Await for an @@asyncDispose that throws synchronously. That way there would be no difference whether there was a null async resource disposed before or not, and you could just always collapse null async resources if any non-null async resource were added to the stack.

That would not simplify the algorithm, it would make it more complex. I also see no reason to break with how for..of handles [Symbol.asyncIterator](), or how await f() works in general, when either function happens to throw synchronously.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request needs-consensus A pull request that needs consensus at TC39 plenary normative Indicates a normative change to the specification
Projects
None yet
6 participants