5

The function below prints each number twice. Could someone explain how it works? I tried debugging but all I can see is that the value of i only increases on every second iteration.

async function run(then) {
    for (let i = 1; i <= 10; i++) {
        console.log(i);
        then = await { then };
    }
}

run(run);

Concretely speaking, there are two things I don't understand.

  • Why does i not increase on every single iteration?
  • What does then = await { then }; exactly do? My first guess was that it would wait for a nested async call to run to finish before moving on to the next iteration, but this does not seem to be the case.
Leonor
  • 357
  • 2
  • 8
  • 1
    It's more related to the `run(run);` – VLAZ Feb 23 '22 at 20:52
  • Removing `then = await { then };` seems to work – K.K Designs Feb 23 '22 at 21:00
  • @VLAZ Aren't, you the person from the `Suggested edit queue is full` question? – K.K Designs Feb 23 '22 at 21:01
  • 3
    @K.KDesgins The question is "why does it work this way", not "how do I fix it". – Ivar Feb 23 '22 at 21:01
  • @K.KDesgins Yes, Hi. – VLAZ Feb 23 '22 at 21:02
  • Await doesn't care if it's in a loop before running the next iteration. All the iterations of the loop run immediately and whatever `then` is being reassigned to is just whatever arbitrary value is that last of the iterations to finish. – JakeAve Feb 23 '22 at 21:06
  • 2
    @JakeAve that is wrong and the `await` is *crucial* for how this code operates. – VLAZ Feb 23 '22 at 21:07
  • If you want to run something then wait for the next, you have to use recursion or an array of promises and a `Promise.all` / `Promise.allSettled` – JakeAve Feb 23 '22 at 21:07
  • 2
    The { then } looks like a [custom thenable](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await#thenable_objects). Very clever. I wonder what's the origin of this. – Wiktor Zychla Feb 23 '22 at 21:10
  • @JakeAve that is also wrong. `await` will sleep the entire function execution. Whether it's a loop or not is irrelevant. While the function is suspended it doesn't run. – VLAZ Feb 23 '22 at 21:10
  • @WiktorZychla bingo! – VLAZ Feb 23 '22 at 21:10
  • 3
    I almost see this as an interview question to a position that should remain open. – Wiktor Zychla Feb 23 '22 at 21:13
  • @WiktorZychla "*to a position that should remain open.*" very well said :D – VLAZ Feb 23 '22 at 21:16
  • @VLAZ you're right about my mistake. It's definitely executing the `run` function twice. Maybe it has to do with reassigning the variable. – JakeAve Feb 23 '22 at 21:16
  • While you're at it, explain this `const run = async (then) => { console.log("run"); for (let i = 1; i <= 10; i++) { console.log(i); const notThen = await { then }; } }; run(run);` – JakeAve Feb 23 '22 at 21:41

1 Answers1

8

We can make this a bit clearer with minor re-write to include logging:

async function run(callback) {
    let then = callback;
    for (let i = 1; i <= 10; i++) {
        console.log(callback === run ? "A" : "B", i);
        then = await { then };
    }
}

run(run);
.as-console-wrapper { max-height: 100% !important; }

This shows there are actually two loops started. For simplicity just called A and B. They log and await which means that their logs interleave and lead to A 1, B 1, A 2, B 2, etc.

This happens because of the first statement: run(run). Which passes the same function as a callback to itself. This does not call the callback but it is the first step to unravelling this.


The next step to understanding what is happening is await. You can await any value and in most cases if it's not a promise, it doesn't matter. If you have await 42; it just pretends the value was Promise.resolve(42) and continues the operation immediately with the next tick. That is true for most non-promises. The only exception is thenables - objects which have a .then() method.

When a thenable is awaited, its then() method is called:

const thenable = {
  then() {
    console.log("called");
  }
};

(async () => {
  await thenable;
})()

Which then explains the await { then } statement. This uses the shorthand for { then: then } where then is the callback passed to run. Thus it creates a thenable object which, when awaited, will execute the callback.

This means that the first time run() is executed and on the first iteration of the loop A the code is effectively await { then: run } which will execute run again which then starts loop B.

The value of then is overridden each time, hence why it only ever runs two loops in parallel, rather than more.


There is more to thenables that is relevant to fully grasp this code. I showed a simple one before which just shows that awaiting it calls the method. However, in reality await thenable will call .then() with two parameters - functions that can be called for success and failure. In the same way that the Promise constructor does it.

const badThenable = {
  then() {
    console.log("bad called");
  }
};

(async () => {
  await badThenable;
  console.log("never reached");
})();

const goodThenable = {
  then(resolve, reject) { //two callbacks
    console.log("good called");
    resolve(); //at least one needs to be called
  }
};

(async () => {
  await goodThenable;
  console.log("correctly reached");
})();

This is relevant because run() expects a callback and when the await { then: run } executes it calls run(builtInResolveFunction) which then gets passed to the next await { then: builtInResolveFunction } which in turn resolves causes the a await to resolve.


With all this aside, the interleaved logging is just a factor of how tasks resolve:

(async () => {
  for (let i = 1; i <= 10; i++){
    console.log("A", i);
    await Promise.resolve("just to force a minimal wait");
  } 
})();

(async () => {
  for (let i = 1; i <= 10; i++) {
    console.log("B", i);
    await Promise.resolve("just to force a minimal wait");
  } 
})();

If there are two async functions running and there is nothing to really wait for:

  1. One would run until it reaches an await and will then be suspended.
  2. The other would run until it reaches an await and will then be suspended.
  3. Repeat 1. and 2. until there are no more awaits.
VLAZ
  • 26,331
  • 9
  • 49
  • 67
  • 1
    I love how such a small/simple looking code-snippet can have so much depth. Well explained. :-) – Ivar Feb 23 '22 at 22:24
  • 2
    @Ivar there is actually a bit more. But it's bed time. And tomorrow is a bit busy for me. I'll try to add as soon as possible. Essentially, A and B toss the resolver function to each other and calling `await` on it *resolves the other `await`* and allows the other loop to continue. So, for example for `i = 3` in A the `await { then }` resolves the `await { then }` in B for `i = 2`. This back and forth not only allows the whole thing to work, it also means that for B `i = 10` the await never resolves because A has finished. I want to draw up a sequence diagram for it for better illustration. – VLAZ Feb 23 '22 at 22:44
  • I believe you could improve the fragment that explains why there's no infinite recursion. It' not entirely clear at the moment. – Wiktor Zychla Feb 23 '22 at 22:50
  • @WiktorZychla because `run(run)` is executed once and inside the `await { then: run }` is also executed once. The `then = await {then}` reassigns it. But each time `await { then }` is called it actually passes in the resolver function to the `.then()` method. It's a bit hard to grasp, since it's a lot of stuff happening at once. It's hard to fully and clearly explain. The next seciton which starts with "*There is more to thenables that is relevant to fully grasp this code.*" explains the resolver stuff. I wanted to explain one concept at a time. The `await` line just does so much here. – VLAZ Feb 23 '22 at 23:00
  • Thanks. In retrospect it's pretty obvious to me that there must be two loops running alternatingly to give the illusion of repeated iterations. I've never thought that it is possible to change the flow of async code using the callback functions of the awaited promises, but that also makes perfect sense. I'm still trying to figure out how the two loops alternate. When I call `run(run(run))` (3 times `run`) I get a more complex, but still reproducible pattern. – Leonor Feb 24 '22 at 22:37