2

I am confused about the examples below.

I understand why output2 is [1000,2000,3000] because of the closure and that's why all the async functions inside map() update the same array output2. (Please correct my concept if I am wrong.)

However, I don't understand why output1 is [3000].

May I know why run1 doesn't behave like run2? Could you please tell me the difference?

"use strict";

function sleep(ms) {
  return new Promise(resolve =>
    setTimeout(() => {
      resolve(ms);
    }, ms)
  );
}

const seconds = [1000, 3000, 2000];

let output1 = [];
let output2 = [];

(async function run1() {
  await Promise.all(
    seconds.map(async sec => {
      output1 = output1.concat([await sleep(sec)]);
    })
  );
  console.log({ output1 });
})();

(async function run2() {
  await Promise.all(
    seconds.map(async sec => {
      const res = await sleep(sec);
      output2 = output2.concat([res]);
    })
  );
  console.log({ output2 });
})();
ikhvjs
  • 4,417
  • 2
  • 6
  • 23
  • 3
    If I'd had to guess, the first one is probably transformed in some weird inline way that captures `output1` into a local scope in some way, and each of the three resolving promises do `[].concat(...)`. Whereas in the second case, the value of `output2` is not being captured because the transformation is different, and each promise actually looks up `output2`'s value at the time of the concatenation. – deceze Sep 01 '21 at 08:12
  • In fact, yes: https://stackoverflow.com/a/56272047/476 – deceze Sep 01 '21 at 08:20
  • Though I'm not really Javascript-guru, I think it is all about closures. Using `let` or `const` there instead of `var` or statement's itself in a raw manner, as you do in your example, creates a `block` scoped variable — in simple words, the new variable is created for each call. Closures referencing it will be able to capture the right value. – snr Sep 01 '21 at 08:25
  • @deceze, the answer from the suggested duplicated question just explains why `run1()` show the result, but it didn't explain my question why `run2()` doesn't behave like `run1()`. – ikhvjs Sep 01 '21 at 12:07
  • `run2` works as expected, right? `run1` is unexpected, and it's because the inlined `await` causes the entire expression to be interpreted differently, as explained in the dupe. – deceze Sep 01 '21 at 12:13
  • @deceze, yes. I hope an answer can try to explain why `run2` didn't behave as `run1` as well. If we follow `run1`'s logic, shouldn't `run2` should behave like `run1`? – ikhvjs Sep 01 '21 at 12:16
  • @deceze, I update my question to be more clear about what I want. – ikhvjs Sep 01 '21 at 12:45

2 Answers2

4

Looking at the statement

 output1 = output1.concat([await sleep(sec)]);

On the left hand side output1 is a variable identifier, used to provide the location in which to store the result of evaluating the right hand side. The variable's binding doesn't change and it always supplies the location of the variable's value.

On the right hand side output1 is a value - the value retrieved from the location supplied by the variable name.

Now if the JavaScript engine retrieves the value of output1 before continuing evaluation, all three map function calls

  • retrieve a reference to the empty array stored in output,
  • wait for the timer promise and set output1 to a new value being the array returned from the concat method.

Hence each map operation concatenates an array containing a timer value to an empty array and stores the result in output1 overwriting the result of previous awaited operations.

This explains why you only see the last array stored in output1 when Promise.all becomes settled. I will also retract the "if the JavaScript engine..." wording above. The JavaScript Engine does get the value of output1 before the await:

function sleep(ms) {
  return new Promise(resolve =>
    setTimeout(() => {
    console.log( output1.length);
      resolve(ms);
    }, ms)
  );
}

const seconds = [1000, 3000, 2000];

let output1 = [];
let output2 = [];

(async function run1() {
  await Promise.all(
    seconds.map(async sec => {
      output1 = output1.concat([await sleep(sec)]);
      //output1.push(await sleep(sec));
      console.log(output1[0]);
    })
  );
  console.log({ output1 });
})();

let count = 6;
let timer = setInterval( ()=> {
  console.log(output1[0])
  if(--count <=0 ) {
     clearInterval( timer);
  }
}, 500);

To clear up why the second method (run2) works (which is not related to the existence of closures):

The .map method calls the map function synchronously and the calls synchronously return a promise without waiting for timer promise fulfillment.

In the second version,

seconds.map(async sec => {
  const res = await sleep(sec);
  output2 = output2.concat([res]);
}

the const res = await sleep( sec) line saves the execution context and waits for the sleep promise to fulfill. When the promise is fulfilled, await restores the saved context and stores the promise value in res. The next line

  ouput2 = output2.concat([res]);

executes after timer expiry and on the right hand side will load the value of output2 current when the line is executed, as updated by a previous timer expiry if one has occurred.

Contrast this with run1 where the JavaScript engine essentially cached* the value of ouput1 when starting to evaluate the expression on the right hand side of the assignment operator and used the same empty array value for all iterations as demonstrated in the snippet.

*The accepted answer of the duplicate indicates that the left hand operand of an addition operation is retrieved from storage before the right hand operand is returned by await. In the case of run1 we are seeing that the object on which a method will be called (the value of output1) is retrieved before the value of the argument used to call the method has been determined. As described in comments to the linked answer, this is quite a "hidden pitfall".

traktor
  • 15,221
  • 4
  • 27
  • 50
  • `The JavaScript Engine does get the value of output1 before the await` because `await` is in the outer scope of the `run1()` function, right? Could you please elaborate on this a bit? I asked this because it didn't answer why `output2` can get the `[1000,2000,3000]` based on the logic you described. – ikhvjs Sep 01 '21 at 09:10
  • 1
    `and on the right hand side will load the value of output2 current when the line is executed` How come it can load the value of `output2`? I think it is about the closure. I don't understand why you said is nothing to do with the closure. What do you mean of `const res = await sleep( sec) line saves the execution context`? I don't understand this part as well. Could you please give more details? – ikhvjs Sep 01 '21 at 11:43
  • What do you mean by `closure`? What I mean `closure` is the `outerscope` contains `output1` and `output2`, once the `outerscope` executed and it supposed to be finish his context, the `run1` and `run2` somehow can still reference to `output1` and `output2` because of `closure`. – ikhvjs Sep 01 '21 at 12:25
  • We are talking about [Closures](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures). As `run1` and `run2` are async, the outerscope code will be executed and finished before `run1` and `run2` finish and somehow why `run1` and `run2` can access the outerscope variable `output1` or `ourput2` because of `Closures`. I just want to say `Closure` is involved when `run` function try to access `output`. However,, I don't understand what you mean `const res = await sleep( sec) line saves the execution context`. Could please add some more details about it? – ikhvjs Sep 01 '21 at 12:40
  • I have updated the answer in response to your comments. Thank you for helping me clarify your points of concern. – traktor Sep 01 '21 at 13:52
  • Thanks for your updates. Did you see the accepted answer mentioned in the dup question: `In each of the 4 closures, count is seen to be 0 before the awaits resolve, so only in the final await microtask`. He mentioned the `closures` matter and why `closures` are different in `run1` and `run2` is the key to the question. Therefore, your answer `which is not related to the existence of closures` may be not correct. – ikhvjs Sep 01 '21 at 14:09
  • @ikhvjs What I read here, you and traktor are talking about the same concept but using different terms. You (and the other answer) refer to the concept as "closure", whereas traktor refers to it as "execution context", and he thinks "closure" mean different thing. I agree with traktor’s view. – hackape Sep 02 '21 at 01:40
  • I think I understand what `saves the execution context` mean now. I also asked a similar question and post my answer there. https://stackoverflow.com/a/69026768/14032355 – ikhvjs Sep 02 '21 at 08:41
1

I add a line in between first example. Guess this will help you see the reason.

"use strict";

function sleep(ms) {
  return new Promise(resolve =>
    setTimeout(() => {
      resolve(ms);
    }, ms)
  );
}

const seconds = [1000, 3000, 2000];

let output1 = [];

(async function run1() {
  await Promise.all(
    seconds.map(async sec => {
      const dummy = output1;
      console.log(`dummy_${sec}`, dummy);
      output1 = dummy.concat([await sleep(sec)]);
    })
  );
  console.log({ output1 });
})();

Update:

Below is your original code transpiled to ES5 code, with generator polyfill. Pay attention to how transpiler manages to pause between the output1.concat([ /* pause here */ ]) call.

The trick is to bind .concat in-place to output1, before yield and wait for continuation.

At the moment this binding happens, output1 == [], this is what I wanna emphasis by re-assigning to dummy variable and print it in previous code.

var seconds = [1000, 3000, 2000];
var output1 = [];
(function run1() {
    return __awaiter(this, void 0, void 0, function () {
        var _this = this;
        return __generator(this, function (_a) {
            switch (_a.label) {
                case 0: return [4 /*yield*/, Promise.all(seconds.map(function (sec) { return __awaiter(_this, void 0, void 0, function () {
                        var _a, _b;
                        return __generator(this, function (_c) {
                            switch (_c.label) {
                                case 0:
                                    _b = (_a = output1).concat;
                                    return [4 /*yield*/, sleep(sec)];
                                case 1:
                                    output1 = _b.apply(_a, [[_c.sent()]]);
                                    return [2 /*return*/];
                            }
                        });
                    }); }))];
                case 1:
                    _a.sent();
                    console.log({ output1: output1 });
                    return [2 /*return*/];
            }
        });
    });
})();
hackape
  • 14,331
  • 1
  • 22
  • 48