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 thenext()
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?
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:
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)));
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
.
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)));