Skip to content
This repository has been archived by the owner on Jun 20, 2023. It is now read-only.

Sequential semantics of async DisposeResources #3

Open
zloirock opened this issue Nov 29, 2022 · 4 comments
Open

Sequential semantics of async DisposeResources #3

zloirock opened this issue Nov 29, 2022 · 4 comments
Labels
for follow-on proposal This issue should be investigated for a follow-on proposal.

Comments

@zloirock
Copy link

zloirock commented Nov 29, 2022

const d = new AsyncDisposableStack();
d.defer(async () => console.log(44));
d.defer(async () => console.log(43));
d.defer(() => new Promise(resolve => setTimeout(() => {
  console.log(42);
  resolve();
}, 1e4)));
await d.disposeAsync();
// only after 10 seconds: 42, 43, 44

That looks strange - why are some resources that can be disposed immediately should wait for something if it can be done in parallel? In case of errors, the tree can be built in the end. However, yes, here is stack in the name...

@zloirock
Copy link
Author

zloirock commented Nov 29, 2022

After rereading README, I see that this order can be helpful then some resources should be disposed in a custom order. However, in this case, it also could be helpful an alternative container where disposing can be done in parallel.

@rbuckton
Copy link
Collaborator

rbuckton commented Nov 30, 2022

After rereading README, I see that this order can be helpful then some resources should be disposed in a custom order. However, in this case, it also could be helpful an alternative container where disposing can be done in parallel.

In this case, I believe the core API should promote the more reliable case and match the behavior of using/async using. When working with multiple resources, these resources often have dependencies on one another. To ensure proper ordering, these dependencies likely need to be disposed in reverse order, and each resource should be fully disposed before the resource it depends on is disposed to avoid errors and/or deadlocks. As such, the current approach is the most reliable and safest.

Whether or not async resources can be safely disposed in parallel is an optimization decision that only the user can make.

In an effort to limit the scope of the proposal, we could consider a follow-on proposal to allow for parallel cleanup, but I don't think it meets the bar for the MVP.

A follow-on proposal or library could, for example, define a ParallelAsyncDisposableStack with a similar API to AsyncDisposableStack, except that each [Symbol.asyncDispose]() is called and passed to Promise.all() before awaiting. This might allow you to compose both serial and parallel disposal bases on your specific needs or specific optimizations:

const serial = new AsyncDisposableStack();
serial.defer(async () => console.log(44));

const parallel = serial.use(new ParallelAsyncDisposableStack());
parallel.defer(async () => console.log(43));
parallel.defer(async () => {
  await new Promise(resolve => setTimeout(resolve, 100));
  console.log(42);
});

serial.defer(async () => {
  await new Promise(resolve => setTimeout(resolve, 100));
  console.log(41);
});

await serial.disposeAsync();
// waits 100ms
// prints: 41
// prints: 43
// waits 100ms
// prints: 42
// prints: 44

@zloirock
Copy link
Author

I think that there can be added an option, for example,

new AsyncDisposableStack({ parallel: true });

@rbuckton rbuckton transferred this issue from tc39/proposal-explicit-resource-management Dec 1, 2022
@rbuckton rbuckton added the for follow-on proposal This issue should be investigated for a follow-on proposal. label Jan 17, 2023
@ggoodman
Copy link

I've been thinking about some related challenges in efficiently acquiring and disposing of resources that do and do not have inter-dependencies. Basically, in performance-sensitive code, the sequential disposal can be in conflict with performance objectives if the serialization of promises isn't necessary.

This can easily be the case in acquiring and then disposing of acquired resources.

At first, I was thinking that a user-space solution might be able to address this through something like the following.

{
  // I acknowledge that this example is a bit of nonsense. The idea is that there might be a
  // mix of resources having inter-dependencies and those without.
  // The `acquireWithDependencies` function is a user-space utility to attempt to acquire
  // resources with maximum concurrency where waterfall behaviour might otherwise be unavoidable.
  await using stack = acquireWithDependencies({
    job: [async () => takeJob()],
    jobPrerequisites: ['job', async (job) => lockPrerequisiteJobs(job.id)],
    machine: [async () => acquireMachineForJob()],
  });

  const { job, jobPrerequisites, machine } = stack.acquired;

  // Do stuff with the acquired resources

  // ...

  // The `machine` resource is disposed in parallel to the `jobPrerequisites` resource. The
  // `job` will only be freed once the `jobPrerequisites` have been freed but won't wait for
  // `machine`. The block will only complete once all have been disposed.
}     

While I can intuit that the above can be built using the new language feature, I wonder if the primitives are sufficient to avoid the sorts of RAII pitfalls @rbuckton described for the sync proposal.

One area where this becomes murky, IMHO is if the acquisition of machine fails after the job has been acquired and the attempt to acquire the jobPrerequisites is in flight. Perhaps AbortSignal can be used to smooth this nuance over but I'm struggling to wrap my head around the edges.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
for follow-on proposal This issue should be investigated for a follow-on proposal.
Projects
None yet
Development

No branches or pull requests

3 participants