asynchronousasync-awaitpromiseiteratorgenerator

Confusion on promise-based version of setInterval() function


My problem relates to David Flanagan's book: JavaScript the definitive guide, 7th edition. It makes a claim about code which I find confusing and I need help understanding it.

Specifically, the claim is made in chapter 13, Asynchronous JavaScript.

This is an iterator-based version of a clock function that is supposed to simulate setInterval using promises:

function clock(interval, max=Infinity) {     
    function until(time) {
        return new Promise(resolve => setTimeout(resolve, time - Date.now()));
    }     

    // Return an asynchronously iterable object     
    return { 
        startTime: Date.now(),// Remember when we started
        count: 1,// Remember which iteration we're on
        async next() {
             if (this.count > max) {    // Are we done?
                return { done: true };  // Iteration result indicating done
             } 
            // Figure out when the next iteration should begin,
            let targetTime = this.startTime + this.count * interval;
            // wait until that time,
            await until(targetTime);
            // and return the count value in an iteration result object.
            return { value: this.count++ };
        },
        // This method means that this iterator object is also an iterable.
        [Symbol.asyncIterator]() { return this; }     };
}

This is the Generator-based version of clock():

function elapsedTime(ms) {
     return new Promise(resolve => setTimeout(resolve, ms));
}
// An async generator function that increments a counter and yields it 
// a specified (or infinite) number of times at a specified interval.
async function* clock(interval, max=Infinity) {
     for(let count = 1; count <= max; count++) { // regular for loop
         await elapsedTime(interval);            // wait for time to pass
         yield count;                            // yield the counter
     } 
}

This is the clock() function used in a for...await loop. In the book, this clock() refers to the generator-based version:

async function test() { // Async so we can use for/await
     for await (let tick of clock(300, 100)) { // Loop 100 times every 300ms
         console.log(tick);
    } 
}

This iterator-based approach is said to have an advantage over the generator-based version. The author writes:

With the generator-based version of clock(), if you call the next() method three times sequentially, you’ll get three Promises that will all fulfill at almost exactly the same time, which is probably not what you want. The iterator-based version we’ve implemented here does not have that problem.

Now I have evaluated this claim myself, and I find it to be wrong, although I'm not so sure. For the iterator-based version, I understand that count is supposed to multiply the interval making the second iteration wait twice as long and the third, thrice as long and so on. However, If next() is called a second time before the first promise resolves, I expect that count should still remain 1. This is because, in the code, count is incremented after the wait is over — when the iterator result object is being returned, so if next() is called before the first promise resolves, count should still be one, and if that's the case, the second promise should resolve at approximately the same time as the first.

Thus, the advantage the text claims would be false. Can you help point out where I'm going wrong?


Solution

  • Well spotted: you are correct in your analysis of the iterator-based version.

    I believe this is an unintended mistake in the text (almost like a typo), because the author had correctly stated (just before the part you quoted), that:

    if you use an asynchronous iterator without a for/await loop, there is nothing to prevent you from calling the next() method whenever you want.

    This is correct, as it refers to the non-generator implementation. But then the author swaps the terms "iterator-based" and "generator-based" in the part you quoted. I believe this swap was unintended and the text should be read as follows:

    With the iterator-based version of clock(), if you call the next() method three times sequentially, you’ll get three Promises that will all fulfill at almost exactly the same time, which is probably not what you want. The generator-based version we’ve implemented here does not have that problem.

    We can demonstrate that claim with the following two snippets:

    Generator-based version:

    This version does not have the problem that the author describes:

    function elapsedTime(ms) {
         return new Promise(resolve => setTimeout(resolve, ms));
    }
    
    async function* clock(interval, max=Infinity) {
         for(let count = 1; count <= max; count++) { // regular for loop
             await elapsedTime(interval);            // wait for time to pass
             yield count;                            // yield the counter
         } 
    }
    
    console.log("start clock");
    const timer = clock(1000); // Set the interval to 1 second.
    // Call `next` method three times without waiting;
    // attach `then` callbacks to the three promises we get
    // and see that the promises resolve with the required interval between those events:
    [timer.next(), timer.next(), timer.next()].map(p => p.then(obj => console.log(obj.value)));

    Iterator-based version

    It is this version that suffers from the problem, as can be seen in this snippet:

    function clock(interval, max=Infinity) {
        // A Promise-ified version of setTimeout that we can use await with.
        // Note that this takes an absolute time instead of an interval.
        function until(time) {
            return new Promise(resolve => setTimeout(resolve, time - Date.now()));
        }
    
        // Return an asynchronously iterable object
        return {
            startTime: Date.now(),  // Remember when we started
            count: 1,               // Remember which iteration we're on
            async next() {          // The next() method makes this an iterator
                if (this.count > max) {     // Are we done?
                    return { done: true };  // Iteration result indicating done
                }
                // Figure out when the next iteration should begin,
                let targetTime = this.startTime + this.count * interval;
                // wait until that time,
                await until(targetTime);
                // and return the count value in an iteration result object.
                return { value: this.count++ };
            },
            // This method means that this iterator object is also an iterable.
            [Symbol.asyncIterator]() { return this; }
        };
    }
    
    console.log("start clock");
    const timer = clock(1000); // Set the interval to 1 second.
    // Call `next` method three times without waiting;
    // attach `then` callbacks to the three promises we get
    // and see that the promises resolve all three at about the same time:
    [timer.next(), timer.next(), timer.next()].map(p => p.then(({value}) => console.log(value)));

    The problem here is that all three next calls read the same value of this.count, which none of them will update until after their until promise resolves, and so they all have about the same value for targetTime.

    Fix

    This problem can be easily fixed by incrementing this.count before the await instead of after it, and getting a snapshot of it in a local variable (the copy cannot be altered by another next call):

    function clock(interval, max=Infinity) {
        // A Promise-ified version of setTimeout that we can use await with.
        // Note that this takes an absolute time instead of an interval.
        function until(time) {
            return new Promise(resolve => setTimeout(resolve, time - Date.now()));
        }
    
        // Return an asynchronously iterable object
        return {
            startTime: Date.now(),  // Remember when we started
            count: 1,               // Remember which iteration we're on
            async next() {          // The next() method makes this an iterator
                if (this.count > max) {     // Are we done?
                    return { done: true };  // Iteration result indicating done
                }
                // Take a local snapshot of the current count value,
                // ...and increment it already now.
                const value = this.count++;
                // Figure out when the next iteration should begin,
                let targetTime = this.startTime + value * interval;
                // wait until that time,
                await until(targetTime);
                // and return the COPIED value in an iteration result object.
                return { value };
            },
            // This method means that this iterator object is also an iterable.
            [Symbol.asyncIterator]() { return this; }
        };
    }
    
    console.log("start clock");
    const timer = clock(1000); // Set the interval to 1 second.
    // Call `next` method three times without waiting;
    // attach `then` callbacks to the three promises we get
    // and see that the promises resolve all three at about the same time:
    [timer.next(), timer.next(), timer.next()].map(p => p.then(({value}) => console.log(value)));