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

Suggestion: use syntax for async variables #113

Open
dead-claudia opened this issue Jan 29, 2025 · 5 comments
Open

Suggestion: use syntax for async variables #113

dead-claudia opened this issue Jan 29, 2025 · 5 comments

Comments

@dead-claudia
Copy link

dead-claudia commented Jan 29, 2025

I found a way to make everything but the variable get fast in this comment, and that one remaining optimization looks an awful lot like the inline caches used for types. The fallback path is about the only part that isn't a trivially inlined function.

And for semantics, there's concerns about excessive creation of async variables, and I do share them: #50

One way to quietly guide people to use async vars correctly (and to optimize async var usage in general) is to make them literal variable bindings. Also, admittedly, .get() is annoying boilerplate.

And for performance, this glides right in and makes it all both trivially tree-shakeable and essentially zero-cost.

So, what about this syntax?

The syntax here is intentionally somewhat anti-bikeshed. Concise enough to get the point across, but (especially for snapshots) ugly enough to move discussion along.

// Define one or more dynamic variable
dynamic foo = initialValue, bar = ...

// Get the variable's current value
let currentValue = foo

// Set a dynamic variable in a scope
// Automatically restored after exit
{
    dynamic using foo = otherValue
    dynamic using path.to.foo = otherValue
    // ...
}

// Get a snapshot of the current state
// Semantically changes on dynamic set, but engines can amortize that
const snapshot = function.context

// Use snapshot
dynamic using * from snapshot

From a spec standpoint, this would be a unique type of reference, alongside property values. Module property accesses would return dynamic values as references to dynamic values.

@dead-claudia
Copy link
Author

dead-claudia commented Jan 29, 2025

To show how this all would play out in practice, here's most of the README examples:

dynamic asyncVar;

// Sets the current value to 'top', and executes the `main` function.
{
    dynamic using asyncVar = "top";
    main()
}

function main() {
  // Dynamic variable is maintained through other platform queueing.
  setTimeout(() => {
    console.log(asyncVar); // => 'top'

    {
      dynamic using asyncVar = 'A'
      console.log(asyncVar); // => 'A'

      setTimeout(() => {
        console.log(asyncVar); // => 'A'
      }, randomTimeout());
    }
  }, randomTimeout());

  // Dynamic variable runs can be nested.
  {
    dynamic using asyncVar = "B";
    console.log(asyncVar); // => 'B'

    setTimeout(() => {
      console.log(asyncVar); // => 'B'
    }, randomTimeout());
  }

  // Dynamic variable was restored after the previous run.
  console.log(asyncVar); // => 'top'
}

function randomTimeout() {
  return Math.random() * 1000;
}
dynamic asyncVar;

let snapshot;
{
  dynamic using asyncVar = "A";
  // Captures the state of all dynamic variables at this moment.
  snapshot = function.context;
}

{
  dynamic using asyncVar = "B";
  console.log(asyncVar); // => 'B'

  // The snapshot will restore all dynamic variables to their snapshot
  // state and invoke the wrapped function. We pass a function which it will
  // invoke.
  dynamic using * from snapshot;
  // Despite being lexically nested inside 'B', the snapshot restored us to
  // to the snapshot 'A' state.
  console.log(asyncVar); // => 'A'
}
let queue = [];

export function enqueueCallback(cb: () => void) {
  // Each callback is stored with the context at which it was enqueued.
  const snapshot = function.context;
  queue.push({snapshot, cb});
}

runWhenIdle(() => {
  // All callbacks in the queue would be run with the current context if they
  // hadn't been wrapped.
  for (const {snapshot, cb} of queue) {
    dynamic using * from snapshot;
    cb();
  }
  queue = [];
});
// tracer.js

dynamic span;
export function run(cb) {
  // (a)
  dynamic using span = {
    startTime: Date.now(),
    traceId: randomUUID(),
    spanId: randomUUID(),
  };
  cb();
}

export function end() {
  // (b)
  span?.endTime = Date.now();
}
dynamic currentTask = { priority: "default" };
const scheduler = {
  postTask(task, options) {
    // In practice, the task execution may be deferred.
    // Here we simply run the task immediately.
    dynamic using currentTask = { priority: options.priority };
    task();
  },
  currentTask() {
    return currentTask;
  },
};

const res = await scheduler.postTask(task, { priority: "background" });
console.log(res);

async function task() {
  // Fetch remains background priority by referring to scheduler.currentTask().
  const resp = await fetch("/hello");
  const text = await resp.text();

  scheduler.currentTask(); // => { priority: 'background' }
  return doStuffs(text);
}

async function doStuffs(text) {
  // Some async calculation...
  return text;
}

@Qard
Copy link

Qard commented Jan 31, 2025

That's pretty much how I had originally thought async context in the spec should work, but some people were really against the idea of dynamic variable scoping and pushed hard against adding new syntax. It's really the most straightforward though, both from a semantic perspective and from a technical perspective. It's easier to understand from reading the code, and it's easier to optimize and control the allowable flow patterns by making it syntax.

It does add to the work involved in the start and end of every block though, which some deemed too performance-sensitive to change things...so instead pushed the proposal to follow a much more expensive model on the premise that this is some very rarely needed thing and not a thing that actually most apps use at some level, either through abstract implementation or through runtime-provided tooling like AsyncLocalStorage in Node.js.

@dead-claudia
Copy link
Author

dead-claudia commented Jan 31, 2025

It does add to the work involved in the start and end of every block though

@Qard

  1. The cost with my proposal is at the dynamic using site and at block end. There is also the implicit save/restore at await, of course.
  2. Adding stuff to block end already has precedent with the using resource declarations.
  3. The algorithm I linked to in the comment removes all the setup cost in practice for both this and the current object-based version.

@Qard
Copy link

Qard commented Jan 31, 2025

Yep, not saying otherwise, just sharing that there was some prior art in this area that never really went anywhere as there were some strong voices that pushed back against the idea of adding syntax for this. I personally actually think syntax is the ideal way to design such a capability. When you're working with something that is just another object you have to contort it in various ways to fit how every other object in the language behaves, but when you make it syntax you can define the behaviour with a lot less limitations imposed on how it could conceivably work.

@legendecas
Copy link
Member

IMO, the proposed syntax is not necessarily related to "dynamic scopes" even though it uses the term "dynamic". I'd prefer not confusing people with the term "dynamic".

I can find the similarity with https://github.com/tc39/proposal-async-context/blob/master/MUTATION-SCOPE.md#the-set-semantic-with-scope-enforcement that utilizes a syntax to provide a scoped mutation without introducing a new function like .run does. It's built on top of existing syntaxes and protocols and be an extension to the current proposal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants