Search code examples
javascriptpromisesettimeouteventqueue

How Does the JavaScript Interpreter add Global Statements to the Event Queue?


I am not sure how statements in the global scope are placed into the JavaScript event queue. I first thought that the interpreter went through and added all global statements into the event queue line by line, then went and executed each event, but that logic does not line up with the example given below. How does the JavaScript interpreter add global statements to the event queue, and why is the output from the two examples given below different?

let handleResolved = (data) => {
  console.log(data);
}

let p = new Promise((resolve, reject) => {
      setTimeout(() => {resolve("1")}, 0)
});

p.then(handleResolved);

setTimeout(() => {
  console.log("2");
}, 0);

The console output to the above code is

1
2

Now consider this example. Here, the difference is on the body of the promise callback, as there is a nested setTimeout

let handleResolved = (data) => {
  console.log(data);
}

let p = new Promise((resolve, reject) => {   
  setTimeout(() = > {setTimeout(() => {resolve("1")}, 0)}, 0);
});

p.then(handleResolved);

setTimeout(() => {
  console.log("2");
}, 0);

The console output to the above code is

2
1

What I don't understand is the order in which things are added to the event queue. The first snippet implies that the promise p will run, and then during its execution, resolve is put in the event queue. Once all of p's stack frames are popped, then resolve is run. After than p.then(...) is run, and finally the last console.log("2");

In the second example, somehow the number 2 is being printed to the console before the number 1. But would things not be added to the event queue in this order

1.) p
2.) setTimeout( () => {resolve("1")}, 0)
3.) resolve("1")
4.) p.then(...)
5.) console.log("2")

I clearly have some sort of event queue logic wrong in my head, but I have been reading everything I can and I am stuck. Any help with this is greatly appreciated.


Solution

  • There are several confusing things in your question that I think show some misconceptions about what is happening so let's cover those initially.

    First, "statements" are not ever placed into the event queue. When an asynchronous task finishes running or when it is time for a timer to run, then something is inserted in the event queue. Nothing is in the queue before that. Right after you call setTimeout(), before the time has come for the setTimeout() to fire there is nothing in the event queue.

    Instead, setTimeout() runs synchronously, configures a timer in the internals of the JS environment, associates the callback function you passed to setTimeout() to that timer and then immediately returns where JS execution continues on the next line of code. Sometime later when the time has been reached for the timer to fire and control has returned back to the event loop, the event loop will call the callback for that timer. The internals of exactly how this works vary a bit according to which Javascript environment it is, but they all have the same effect relative to other things going on in the JS environment. In nodejs, for example, nothing is ever actually inserted into the event queue itself. Instead, there are phases of the event loop (different things to check to see if there's something to run) and one of the phases is to check to see if the current time is at or after the time that the next timer event is scheduled for (the soonest timer that has been scheduled). In nodejs, timers are stored in a sorted linked list with the soonest timer at the head of the list. The event loop compares the current time with the timer on the timer at the head of the list to see if its time to execute that timer yet or not. If not, it goes about its business looking for other types of events in the various queues. If so, it grabs the callback associated with that timer and calls the callback.

    Second, "events" are things that cause callback functions to get called and the code in that callback function is executed.

    Calling a function that may then cause something to be inserted into the event queue, either immediately or later (depending upon the function). So, when setTimeout() is executed, it schedules a timer and some time later, it will cause the event loop to call the callback associated with that timer.

    Third, there is not just a single event queue for every type of event. There are actually multiple queues and there are rules about what gets to run first if there are multiple different types of things waiting to run. For example, when a promise is resolved or rejected and thus has registered callbacks to call, those promise jobs get to run before timer related callbacks. Promises actually have their own separate queue for resolved or rejected promises waiting to call their appropriate callbacks.

    Fourth, setTimeout(), even when given a 0 time, always calls its callback in some future tick of the event loop. It never runs synchronously or immediately. So, the rest of the current thread of Javascript execution always finishes running before a setTimeout() callback ever gets called. Promises also always call .then() or .catch() handlers after the current thread of execution finishes and control returns back to the event loop. Pending promise operations in the event queues always get to run before any pending timer events.

    And to confuse things slightly, the Promise executor function (the callback fn you pass as in new Promise(fn)) does run synchronously. The event loop does not participate in running fn there. new Promise() is executed and that promise constructor immediately calls the executor callback function you passed to the promise constructor.

    Now, lets look at your first code block:

    let handleResolved = (data) => {
      console.log(data);
    }
    
    let p = new Promise((resolve, reject) => {
          setTimeout(() => {resolve("1")}, 0)
    });
    
    p.then(handleResolved);
    
    setTimeout(() => {
      console.log("2");
    }, 0);
    

    In order, here's what this does:

    1. Assign a function to the handleResolved variable.
    2. Call new Promise() which immediately and synchronously runs the promise executor callback you pass to it.
    3. That executor callback, then calls setTimeout(fn, 0) which schedules a timer to run soon.
    4. Assign the result of the new Promise() constructor to the p variable.
    5. Execute p.then(handleResolved) which just registers handleResolved as a callback function for when the promise p is resolved.
    6. Execute the second setTimeout() which schedules a timer to run soon.
    7. Return control back to the event loop.
    8. Shortly after returning control back to the event loop, the first timer you registered fires. Since it has the same execution time as the 2nd one you registered, the two timers will execute in the order they were originally registered. So, the first one calls its callback which calls resolve("1") to cause the promise p to change its state to be resolved. This schedules the .then() handlers for that promise by inserting a "job" into the promise queue. That job will be run after the current stack frame finishes executing and returns control back to the system.
    9. The call to resolve("1") finishes and control goes back to the event loop.
    10. Because pending promise operations are served before pending timers, handleResolved(1) is called. That functions runs, outputs "1" to the console and then returns control back to the event loop.
    11. The event loop then calls the callback associated with the remaining timer and "2" is output to the console.

    What I don't understand is the order in which things are added to the event queue. The first snippet implies that the promise p will run, and then during its execution, resolve is put in the event queue. Once all of p's stack frames are popped, then resolve is run. After than p.then(...) is run, and finally the last console.log("2");

    I can't really respond directly to this because this just isn't how things work at all. Promises don't "run". The new Promise() constructor is run. Promises themselves are just notification machines that notify registered listeners about changes in their state. resolve is not put in the event queue. resolve() is a function that gets called and changes the internal state of a promise when it gets called. p doesn't have stack frames. p.then() is run immediately, not later. It's just that all that p.then() does is register a callback so that callback can then be called later. Please see the above 1-11 steps for the sequence of how things work.


    In the second example, somehow the number 2 is being printed to the console before the number 1. But would things not be added to the event queue in this order

    In the second example, you have three calls to setTimeout() where the third one is nested inside the first one. This is what changes your timing relative to the first code block.

    We have mostly the same steps as the first example except that instead of this:

    setTimeout(() => {resolve("1")}, 0)
    

    you have this:

    setTimeout(() = > {setTimeout(() => {resolve("1")}, 0)}, 0);
    

    This means that the promise constructor is called and this outer timer is set. then, the rest of the synchronous code runs and the last timer in the code block is then set. Just like in the first code block, this first timer will get to call its callback before the second one. But, this time the first one just calls another setTimeout(fn, 0). Since timer callbacks are always executed in some future tick of the event loop (not immediately, even if the time is set to 0), that means that all the first timer does when it gets a chance to run is schedule another timer. Then, the last timer in the code block gets it's turn to run and you see the 2 in the console. Then, when that's done, the third timer (the one that was nested in the first timer) gets to run and you see the 1 in the console.