最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

javascript - Can I prevent an `AsyncGenerator` from yielding after its `return()` method has been invoked? - Stack Overflow

programmeradmin1浏览0评论

AsyncGenerator.prototype.return() - JavaScript | MDN states:

The return() method of an async generator acts as if a return statement is inserted in the generator's body at the current suspended position, which finishes the generator and allows the generator to perform any cleanup tasks when combined with a try...finally block.

Why then does the following code print 03 rather than only 02?

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const values = (async function* delayedIntegers() {
  let n = 0;
  while (true) {
    yield n++;
    await delay(100);
  }
})();

await Promise.all([
  (async () => {
    for await (const value of values) console.log(value);
  })(),
  (async () => {
    await delay(250);
    values.return();
  })(),
]);

I tried adding log statements to better understand where the "current suspended position" is and from what I can tell when I call the return() method the AsyncGenerator instance isn't suspended (the body execution isn't at a yield statement) and instead of returning once reaching the yield statement the next value is yielded and then suspended at which point the "return" finally happens.

Is there any way to detect that the return() method has been invoked and not yield afterwards?


I can implement the AsyncIterator interface myself but then I lose the yield syntax supported by async generators:

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const values = (() => {
  let n = 0;
  let done = false;
  return {
    [Symbol.asyncIterator]() {
      return this;
    },
    async next() {
      if (done) return { done, value: undefined };
      if (n !== 0) {
        await delay(100);
        if (done) return { done, value: undefined };
      }
      return { done, value: n++ };
    },
    async return() {
      done = true;
      return { done, value: undefined };
    },
  };
})();

await Promise.all([
  (async () => {
    for await (const value of values) console.log(value);
  })(),
  (async () => {
    await delay(250);
    values.return();
  })(),
]);

AsyncGenerator.prototype.return() - JavaScript | MDN states:

The return() method of an async generator acts as if a return statement is inserted in the generator's body at the current suspended position, which finishes the generator and allows the generator to perform any cleanup tasks when combined with a try...finally block.

Why then does the following code print 03 rather than only 02?

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const values = (async function* delayedIntegers() {
  let n = 0;
  while (true) {
    yield n++;
    await delay(100);
  }
})();

await Promise.all([
  (async () => {
    for await (const value of values) console.log(value);
  })(),
  (async () => {
    await delay(250);
    values.return();
  })(),
]);

I tried adding log statements to better understand where the "current suspended position" is and from what I can tell when I call the return() method the AsyncGenerator instance isn't suspended (the body execution isn't at a yield statement) and instead of returning once reaching the yield statement the next value is yielded and then suspended at which point the "return" finally happens.

Is there any way to detect that the return() method has been invoked and not yield afterwards?


I can implement the AsyncIterator interface myself but then I lose the yield syntax supported by async generators:

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const values = (() => {
  let n = 0;
  let done = false;
  return {
    [Symbol.asyncIterator]() {
      return this;
    },
    async next() {
      if (done) return { done, value: undefined };
      if (n !== 0) {
        await delay(100);
        if (done) return { done, value: undefined };
      }
      return { done, value: n++ };
    },
    async return() {
      done = true;
      return { done, value: undefined };
    },
  };
})();

await Promise.all([
  (async () => {
    for await (const value of values) console.log(value);
  })(),
  (async () => {
    await delay(250);
    values.return();
  })(),
]);
Share Improve this question edited Dec 3, 2022 at 20:06 mfulton26 asked Dec 1, 2022 at 15:49 mfulton26mfulton26 31.2k7 gold badges69 silver badges92 bronze badges 3
  • 1 Try yield ++n instead of yield n++ – zer00ne Commented Dec 3, 2022 at 19:51
  • 1 That will only change the values returned from 03 to 14 but I will still get a total of 4 yielded values rather than the desired 3. – mfulton26 Commented Dec 3, 2022 at 19:54
  • 1 We discussed this a bunch when async generators were being added, see github.com/tc39/proposal-async-iteration/issues/126 for example – Benjamin Gruenbaum Commented Dec 6, 2022 at 20:44
Add a comment  | 

4 Answers 4

Reset to default 10 +250

Why does the code print 0–3 rather than only 0–2? From what I can tell, when I call the return() method, the AsyncGenerator instance isn't suspended (the body execution isn't at a yield statement) and instead of returning once reaching the yield statement the next value is yielded and then suspended at which point the "return" finally happens.

Yes, precisely this is what happens. The generator is already running because the for await … of loop did call its .next() method, and so the generator will complete that before considering the .return() call.

All the methods that you invoke on an async generator are queued. (In a sync generator, you'd get a "TypeError: Generator is already running" instead). One can demonstrate this by immediately calling next multiple times:

const values = (async function*() {
  let i=0; while (true) {
    await new Promise(r => { setTimeout(r, 1000); });
    yield i++;
  }
})();
values.next().then(console.log, console.error);
values.next().then(console.log, console.error);
values.next().then(console.log, console.error);
values.return('done').then(console.log, console.error);
values.next().then(console.log, console.error);

Is there any way to detect that the return() method has been invoked and not yield afterwards?

No, not from within the generator. And really you probably still should yield the value if you already expended the effort to produce it.

It sounds like what you want to do is to ignore the produced value when you want the generator to stop. You should do that in your for await … of loop - and you can also use it to stop the generator by using a break statement:

const delay = (ms) => new Promise((resolve) => {
  setTimeout(resolve, ms);
});

async function* delayedIntegers() {
  let n = 0;
  while (true) {
    yield n++;
    await delay(1000);
  }
}

(async function main() {
  const start = Date.now();
  const values = delayedIntegers();
  for await (const value of values) {
    if (Date.now() - start > 2500) {
      console.log('done:', value);
      break;
    }
    console.log(value);
  }
})();

But if you really want to abort the generator from the outside, you need an out-of-band channel to signal the cancellation. You can use an AbortSignal for this:

const delay = (ms, signal) => new Promise((resolve, reject) => {
  function done() {
    resolve();
    signal?.removeEventListener("abort", stop);
  }
  function stop() {
    reject(this.reason);
    clearTimeout(handle);
  }
  signal?.throwIfAborted();
  const handle = setTimeout(done, ms);
  signal?.addEventListener("abort", stop, {once: true});
});

async function* delayedIntegers(signal) {
  let n = 0;
  while (true) {
    yield n++;
    await delay(1000, signal);
  }
}

(async function main() {
  try {
    const values = delayedIntegers(AbortSignal.timeout(2500));
    for await (const value of values) {
      console.log(value);
    }
  } catch(e) {
    if (e.name != "TimeoutError") throw e;
    console.log("done");
  }
})();

This will actually permit to stop the generator during the timeout, not after the full second has elapsed.

Is there a way to prevent this "extra yield" after invoking the return method? If not, are there libraries, patterns, etc. our there that avoid this while still implementing these AsyncIterator interface optional properties?

As @Bergi clearly explained, the extra yield cannot be avoided with the AsyncGenerator.return() method. This is a really interesting case, but I don't think you will find libraries that fix it. @Bergi proposed a clever solution using the AbortSignal, I have tried a different approach with only Promises:

(async function test() {
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const wrapIntoStoppable = function (generator) {
    const newGenerator = {
        isGeneratorStopped: false,
        resolveStopPromise: null,
        async *[Symbol.asyncIterator]() {
            let stoppedSymbol = Symbol('stoppedPromise')
            let stoppingPromise

            while (true) {
                if (this.isGeneratorStopped)
                    return
                stoppingPromise = new Promise((resolve, _) => this.resolveStopPromise = resolve)
                    .then(_ => stoppedSymbol)
                nextValuePromise = generator.next()
                const result = await Promise.race([nextValuePromise, stoppingPromise])
                this.resolveStopPromise() // resolve the promise in case it is still pending
                if (result === stoppedSymbol)
                    return
                else
                    yield result.value
            }
        },    
        stop: function() {
            this.resolveStopPromise()
            this.isGeneratorStopped = true
        }
    }

    const handler = {
        get: function(target, prop, receiver) {
            if (['next', 'return', 'throw'].includes(prop))
                return generator[prop].bind(generator)
            else
                return newGenerator[prop].bind(newGenerator)
        }
    }
  
    return new Proxy(newGenerator, handler)
}

const values = wrapIntoStoppable((async function* delayedIntegers() {
    let n = 0;
    while (true) {
        yield n++;
        await delay(100);
    }
})());

await Promise.all([
    (async () => {
        for await (const value of values) {
            console.log(Date.now())
            console.log(value);
        }
        console.log(Date.now())
        // console.log(await values.next())
        // console.log(await values.return())
        // console.log(await values.throw())
    })(),
    (async () => {
        await delay(250);
        values.stop()
    })(),
]);
})();

The idea is that I wrap an async generator with an object that has an async iterator. All the elements yielded by the wrapping generator are yielded by the original generator, but now 2 promises are started:

  • nextValuePromise that will return the value to yield
  • stoppingPromise that will end the iteration if resolved before the previous one

In this way, if the stop() method (which resolves stoppingPromise) is called before the first promise is resolved, then Promise.race() will immediately return a dummy Symbol. When the result of the race is this symbol, the iterator returns. The stop() function also sets the isGeneratorStopped flag that makes sure the iterator will eventually return if the stop() method is called after the stoppingPromise() is manually resolved.

I have also used a Proxy to make sure that the wrapping object behaves as a true AsyncGenerator. Calls to next(), return() or throw() are simply forwarded to the wrapped generator.


Let's see the pros:

  1. wrapIntoStoppable can become a util method that just wraps any async generator. This is certainly convenient because you don't have to use signals every time there is a pending Promise 1
  2. Once the stop() method is called on the async generator, the for await...of loop immediately returns. Note: this doesn't mean that pending Promises are aborted

And now the cons:

  1. Maybe too much code to maintain? Now the generator has a proxy that wraps another wrapper... I would like to simplify the design at least
  2. After the generator is stopped, the nextValuePromise() could be resolved in the meantime, causing some potential side effects. This is the main reason why it is a pretty dangerous library function.

  1. Actually, I think you could even merge @Bergi's and my solution and manage to abort a Promise when the stop() method is called. However, in this case, all the promises need to handle the abort signals.

await would suspend the async part of the function, but not the generator part, thus AsyncGenerator.return() can not act on the await suspension, but only yield suspension. And I think that's why AsyncGenerator.return() returns a promise, but Generator.return() does not.

Yes. Bergi is right. The for await loop invokes .next() right after the consumption and puts yield in charge before return. So what happens is;

@  0ms 0 gets yielded and .next() puts yield in charge to yield 1 once resolved.
@100ms 1 gets yielded and .next() puts yield in charge to yield 2 once resolved.
@200ms 2 gets yielded and .next() puts yield in charge to yield 3 once resolved.
@250ms a values.return() is enqueued but yield has already been queued to yield 3.
@300ms 3 gets yielded and generator finalizes along with the iterable values.

Now the thing is, if we find a way to resolve or reject the promise waiting for 3 prematurely @250ms then we are fine. Yet without using the abort abstraction you can still do this with naked promises and even without using an async generator. You just need to lift the resolve and reject functions out of the generator functions scope and invoke from there. I think it's best to reject prematurely and catch the rejection at the outer scope (silent or not).

Here is a way to accomplish this;

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
      prior = {};

const values = (function* delayedIntegers() {
  let n = 0,
      p = new Promise((v,x) => Object.assign(prior,{v,x}));
  prior.v(n++); // resolve p with 0 and increment n
  while (true) {
    yield p
    p = new Promise((v,x) => Object.assign(prior,{v,x}));
    delay(100).then(_ => prior.v(n++));
  }
})();

(async () => {
  try {
    for await (const value of values) console.log(value);
  }
  catch(e){
    console.log(e);
    values.return();
  }
})();

delay(250).then(_ => prior.x("Finalized..!"));

This is almost like your code but there is this prior object which holds the resolve and reject functions of a promise callback as v and x respectively.

Since prior object is accessible from within the outer context we can invoke it's x method (rejection) before the while loop in the generator resolves 3 and catch the rejection with the employed catch(e).

与本文相关的文章

发布评论

评论列表(0)

  1. 暂无评论