Cooperative async function

Motivation

Rust-style async function: Async functions that act in the Poll model run therefore user-land code can decide when to continue the execution, or even cancel the execution.

Design

I propose a syntax sugar to the generator function. In the old times, we use co and generator functions to simulate the async functions.

import co from 'co'

co(function* () {
    yield Promise.resolve(true)
    // eq to await Promise.resolve(true)
    return 1
}).then(console.log)

co is the userland scheduler and the generator is a cooperative async function. This is an old-time example.

WICG/scheduling-apis is the next generation Web scheduler API, here is an example of my proposal (to act with scheduling APIs).

lazy async function task() {
    const a = await fetch("./task")
    const b = await a.json()
    return b.tasks
}
const res = scheduler.postTask(task, { priority: 'background' });

The scheduler can "pause" the async function to continue if there is a high-priority task that comes in. With this manner, this syntax is a natural replacement of scheduler.yield() in the WICG/Scheduling-API proposal.

A userland example: cancellable async task

In many frameworks like React, Vue, and Angular, components have a "life circle". Async tasks should be canceled when the component is dismounted. This can be done with AbortSignal or manually check but there are much noises inside the code.

For example:

useEffect(() => {
    let stop = false
    (async () => {
        const data = await fetch('./data')
        const json = await data.json()
        if (stop) return
        setState(json)
    })()
    return () => stop = true
})

With an AbortSignal-friendly user land API (not real in React) it can be simpler:

useEffect(async signal => {
    const data = await fetch('./data', { signal })
    const json = await data.json()
    if (signal.aborted) return
    setState(json)
})

With this proposal, it can be much simpler:

useEffect(lazy async signal => {
    const data = await fetch('./data', { signal })
    const json = await data.json()
    setState(json)
})

As you can see, there is no need to check if the signal has expired. Because the userland scheduler useEffect will cancel the running async function.

Semantics: How it works

The lazy modifier on the async function creates a lazy async function. When it is called, it behaves like a Generator<Promise<any>>.

lazy async function task() {
    console.log("start")
    await true
    console.log("then")
    return fetch("/")
}

const t = task()
// t: Generator<suspended>
$ = t.next()
// Log: "start"
// { done: false, value: Promise<true> }
await $.value
$ = t.next()
// Log: "then"
// { done: false, value: Promise<fetch> }
await $.value
$ = t.next()
// { done: true, value: Resopnse }
  • The value passed into the next() WILL BE ignored. The await value will always be the result of the promise. The scheduler can only control the continue or not. It cannot replace the await result.
  • Return a Promise will be treated as return await $ret

Interact with other syntaxes

Await lazy async function in a normal async function:

async function a() {
    await Promise.from(b())
}
lazy asnyc function b() {}

A new API like Promise.from will convert a Generator<Promise<Yield>, Return> into Promise<Return>.

Await lazy async function in another lazy async function

lazy async function a() {
    await b()
}
lazy async function b() {}

No need to do anything. It works like a yield* $value, delegate all steps to the scheduler.

Why not generators?

  • Semantics do not match

Benefits

  • Stoppable async functions
  • Native scheduler (the WICG one) or the userland scheduler (React) can schedule tasks that take priority knowledge in mind.

What does this provide that GitHub - tc39/proposal-cancellation: Proposal for a Cancellation API for ECMAScript couldn't? Also, I'd suggest you suggest this alternate proposal there as well, for better visibility.

Oh, and I probably should've mentioned: I have my own alternative idea I created a while back that also offers potential solutions.

I like the idea of making it cooperative, but I can't say I'm a fan of making it quite that opt-in. I'd rather only opt into it wherever I'm adding cancellation semantics, because 1. that's less code modification (less surface area for bugs) and 2. I don't need to wait for whatever library/framework/runtime function I'm using to be updated to be aware of the feature for me to start using cancellation support in almost all cases. (It's actually one of my biggest complaints about abort signals, too, after having used them quite a bit - I often need to figure out ways to plumb the token through code, integration can get boilerplatey, and it's easy to just forget to wire it up.)

cancel is not the only purpose; it more like giving control out

From the examples you showed, generators make perfect sense to me. Imagine you had this:

useEffect(function*() {
    const data = yield fetch('./data')
    const json = yield data.json()
    setState(json)
})

It yields control back to the scheduler, giving it an async task you need fulfilled before continuing. The scheduler will send you the result once it's ready, unless cancelled.

2 Likes

One advantage of using a generator is that you can distinguish between points that are allowed to exit, and points that should ignore a cancel token. For example:

async function*(cancelToken) {
  // These can be canceled
  const data1 = yield fetchSomeData1(cancelToken)
  const data2 = yield fetchSomeData2(data1.x, cancelToken)
  // At this point, it should not be canceled
  await animatSomethingIn(data1)
  await animateSomethingElseIn(data2)
}
2 Likes