This is a screenshot of me searching for nodeJS Event loop in Google Images. Most of the results were wrong, or at a very abstract level to describe the actual cycle of events. Because of these descriptions, developers are often prone to misunderstanding.

Some common misconceptions

The event loop inside the JS engine

One of the most common misconceptions is that event loops are part of Javascript engines (V8, spiderMonkey, etc.). In fact, event loops primarily use Javascript engines to execute code.

There’s a stack or queue

First, there is no stack, and second, the process is complex and involves multiple queues (like queues in data structures). But most developers know how many callbacks are pushed into a single queue, which is completely wrong.

The event loop runs in a single thread

Due to the faulty NodeJS event loop diagram, a few of us (I was one of them early on) thought there were two threads. One executes Javascript and the other executes an event loop. It’s actually all running in one thread.

There is an asynchronous OS system involved in setTimeout

Another very big misconception is that setTimeout’s callback function is pushed (either by the OS or the kernel) into a queue after a given delay completes. We’ll talk about that mechanism in a moment.

SetImmediate places the callback function in the first place

As a common event loop, there is only one queue; So some developers argue that setImmediate places callbacks in front of the work queue. This is completely wrong, in Javascript the work queue is all first in first out.

Architecture of event loops

As we begin to describe the workflow of the event loop, it is important to know its architecture. The previous diagram was wrong, but this is how the event loop actually works.

The different boxes in the picture represent different stages, each of which performs a specific task. There is a queue for each stage. The actual data structure may not be a queue), and Javascript can be executed at any stage (except idle & Prepare). NextTickQueue and microTaskQueue, which you can also see in the picture, are not part of the loop and their callbacks can be executed at any stage. They have a higher priority to execute.

Now you know that the event loop is a combination of different phases and different queues; Below is a description of each stage.

Timer phase

This is the stage at the beginning of the event cycle, and the queue bound to this stage retains the callback of the timer (setTimeout, setInterval). Although it does not push the callback onto the queue, it maintains the timer with the smallest heap and executes the callback after the specified time has been reached.

Pending I/O callback phase

This phase performs the callback in the pending_queue event loop. These callbacks are pushed in by previous operations. For example, when you try to write something to TCP, the job is done, and then the callback is pushed onto the queue. The error handling callback is also here.

Idle, Prepare phase

Although the name is idle, each tick runs. Prepare also runs before the polling phase begins. Either way, these are the phases where Node does most of its internal operations; Therefore, we will not discuss it here. Not today.

Poll stage

Perhaps the most important phase of the entire event cycle is the poll phase. This phase accepts new incoming connections (new Socket setup, etc.) and data (file reading, etc.). We can divide the polling phase into several different parts.

  • If there are things in the watch_queue (which is bound to the polling phase), they will be executed one after another until the queue is empty or the system reaches its maximum limit.

  • Once the queue is empty, Node waits for a new connection. The amount of time to wait or sleep depends on a variety of factors, which we’ll discuss later.

Check phase

The next phase of polling is the Check phase, which is dedicated to setImmediate. Why do you need a dedicated queue to handle setImmediate callbacks? This is because of the behavior of the polling phase, which is discussed later in the process section. Just remember that the check phase is primarily used to handle the setImmediate() callbacks.

Close the callback

The closing of the callback (socket.on(‘ close ‘, ()=>{})) is handled here, more like a cleanup phase

nextTickQueue & microTaskQueue

Tasks in nextTickQueue retain callbacks triggered by process.nexttick (). MicroTaskQueue retains callbacks triggered by promises. None of these are part of the event loop (not developed in libUV), but in Node.js. When C/C++ and Javascript intersect, they are both called as quickly as possible. Therefore, they should be executed after the current operation (not necessarily after the current JS callback).

Event loop workflow

When running Node my-script.js on your console, Node sets the event loop and then runs your main module (my-script.js).

Outside of the event loop

. Once the main module has finished executing, Node will check to see if the loop is still alive. If not, it will exit after the exit callback is executed. Process,on(‘exit’, foo) callback (exit callback). But if the loop is still alive, Node will enter the loop from the timer phase.

Workflow for the Timer phase

The event loop enters the timer phase and checks if anything needs to be executed in the timer queue. Ok, that sounds simple enough, but the event loop actually performs some steps to find the appropriate callback. The timer scripts are actually stored in the heap memory in ascending order. It first retrieves an executor and checks whether the now-registeredTime == delta? If so, he takes the callback for the row timer and checks for the next timer. Until it finds one that hasn’t reached the agreed time, it stops checking the other timers (because they’re all set in ascending order) and moves on to the next phase.

Suppose you call setTimeout four times to create four timers, one relative to the timetfor100.200.300.400The difference between the values.

Suppose the event loop enters the timer phase at T +250. It’s going to look at timer A first, and it’s going to expire at t+100. But now the time is t+250. So it performs the callback bound to timer A. It then checks the timer B and finds that it expires at t+200, so the callback to B is also executed. It will now check C and discover that its expiration time is T +300, so it will leave it. The event loop does not check D because timers are arranged in ascending order; So D has a higher threshold than C. However, this phase has a system-specific hard limit, and if the maximum number of system-dependent limits is reached, it will move to the next phase even if there are unexecuted timers.

A Pengding Phase I/O phase workflow

After the timer phase, the event loop will enter the pending I/O phase and then check to see if the pengding_queue has callbacks from previous pending tasks. If so, execute one after another until the queue is empty or the maximum limit of the system is reached. After that, the event loop moves to the Idle handler phase, followed by the preparation phase for some internal operations. And then eventually probably the most important phase poll phase.

Poll Phase workflows

As the name suggests, this is a period of observation. Observe if any new requests or connections come in. When the event loop enters the polling phase, it executes the script in watcher_queue, containing file read responses, new socket or HTTP connection requests, until time runs out or the system dependency limit is reached as in other phases. Assuming there are no callbacks to execute, polling will wait for a while under certain conditions. If a task is waiting in a Check queue, pending queue, or closing callbacks queue or idle handler queue, it will wait for 0 ms. However, it will wait to execute the first timer (if available) based on the timer stack. If the threshold for the first timer passes, there is no doubt that it will not wait.

Check Phase workflow

Immediately after the polling phase, the inspection phase. This phase of the queue has callbacks triggered by API setImmediate. It will execute one after another like any other phase until the queue is empty or the maximum limit of the dependent system is reached.

Close the workflow of the callback

After completing the tasks in the check phase, the next destination of the event loop is to handle the close callback of type close or destroy. After the event loop completes the callback in the queue for this phase, it checks to see if the loop is still alive and exits if not. But if there’s work to be done, it goes through the next loop; So in the timer phase. If you thought timers (A & B) in the previous example were expired, the timer phase will now check for expiration starting with timer C.

nextTickQueue & microTaskQueue

So, when do the callbacks for these two queues run? They certainly run as fast as possible before moving from the current phase to the next. Unlike the other phases, both of them have no maximum limit of system dependence, and Node runs them until both queues are empty. However, nextTickQueue will have a higher task priority than microTaskQueue.

Process pool (Thread-pool)

A common term I hear from JavaScript developers is ThreadPool. A common misconception is that NodeJS has a pool of processes that handle all asynchronous operations. But the process pool is actually in the libUV library, the third-party library nodeJS uses to handle async. I didn’t draw it in the picture, because it’s not part of the loop. We can talk about libUV in another of your articles. For now, I can only tell you that not every asynchronous task is handled by a process pool. LibUv has the flexibility to use the OS’s asynchronous apis to keep the environment event-driven. However, the OS API cannot do file reads, DNS queries, etc. These are handled by the process pool, which has only 4 processes by default. You can increase the number of processes up to 128 by setting the uv_threadPOOL_size environment variable.

Workflow with examples

Hopefully you understand how the event loop works. The synchronous while in C helps Javascript become asynchronous. You do one thing at a time but it’s hard to block. Of course, no matter how we describe a theory, I believe it is best understood as an example. So let’s walk through some code snippets to understand the script.

Segment 1 — Basic understanding

setTimeout(() => {    console.log('setTimeout');
}, 0);
setImmediate(() => {  console.log('setImmediate');
});
Copy the code

Can you guess the output above? Well, you might think that setTimeout will print out first, but there’s no guarantee, why? After executing the main module and entering the timer phase, it may not or may find that your timer is exhausted. Why is that? A timer script is registered based on the system time and the incremental time you provide. While setTimeout is called, the timer script is written to memory, and depending on your machine’s performance and other operations running on it (not Node), there may be a small delay. Another point is that Node simply sets a variable now before entering the timer phase (each iteration), using now as the current time. So you could say there’s a little bit of a problem with precise timing. That’s where the uncertainty comes in. If you execute the same code in a timer code callback (e.g., setTimeout), you will get the same result.

However, if you move this code into the I/O cycle, guarantee that the setImmediate callback will run before setTimeout.

fs.readFile('my-file-path.txt', () => {
  setTimeout(() => {    console.log('setTimeout');
  }, 0);
  setImmediate(() => {    console.log('setImmediate');
  });
});
Copy the code

Segment 2 — Better understand timers

var i = 0; var start = new Date(); function foo () { i++; if (i < 1000) { setImmediate(foo); } else { var end = new Date(); console.log("Execution time: ", (end - start)); } } foo();Copy the code

The above example is very simple. Foo is called through setImmediate and then foo is recursively called through setImmediate up to 1000. On my macbook Pro, Node version 8.9.1 took 6 to 8 milliseconds. Now modify the code above to replace setImmediate(foo) with setTimeout(foo, 0).

var i = 0; var start = new Date(); function foo () { i++; if (i < 1000) { setTimeout(foo, 0); } else { var end = new Date(); console.log("Execution time: ", (end - start)); } } foo();Copy the code

It now takes 1400+ms to run this code on my computer. Why is that? They both have no I/O events and should be the same. The above two examples have a wait time of 0. Why did it take so long? Bias was found through time comparisons, cpu-intensive tasks that took more time. Registering timer scripts also takes time. Each phase of a timer needs to do something to determine if a timer should be executed. Longer execution also results in more ticks. However, in setImmediate, there is no checking this phase, as if in a queue and just execute.

Segment 3 — Understand nextTick() & timer execution

var i = 0; function foo(){ i++; if(i>20){ return; } console.log("foo"); setTimeout(()=>{ console.log("setTimeout"); }, 0); process.nextTick(foo); } setTimeout(foo, 2);Copy the code

What do you think the output from above is? Yeah, it prints foo and then it prints setTimeout. After 2 seconds, foo(), recursively called by nextTickQueue, prints the first foo. When all nexttickQueues are finished, start executing other (such as setTimeout callbacks).

So is it after each callback that nextTickQueue is checked? Let’s change the code and look at it.

var i = 0; function foo(){ i++; if(i>20){ return; } console.log("foo", i); setTimeout(()=>{ console.log("setTimeout", i); }, 0); process.nextTick(foo); } setTimeout(foo, 2); setTimeout(()=>{ console.log("Other setTimeout"); }, 2);Copy the code

After setTimeout, I simply add another setTimeout that prints other setTimeout with the same delay time. It is possible, though not guaranteed, to print Other setTimeout after the first foo. The same timers are grouped, and nextTickQueue is executed after the ongoing callback group finishes executing.

Some common problems

Where does the JavaScript code execute?

Most of us think of the event loop as being in a single thread, pushing callbacks into a queue and executing them one after the other. First-time readers of this article may be wondering where JavaScript is executed. As I said earlier, there is only one thread, and Javascript code from its own event loop using V8 or another engine is also run here. Execution is synchronous, and the event loop does not propagate if the current JavaScript execution has not completed.

We have setTimeout(fn, 0), why do we need setImmediate?

First of all, it’s not 0, it’s 1. When you set a timer for less than 1, or more than 2147483647ms, it will automatically set to 1. So if you set setTimeout to 0, it will automatically set to 1.

We talked earlier about setImmediate reducing the amount of extra checking. So setImmediate executes a little bit faster. It is also placed after the polling phase, so the setImmediate callback from any incoming request will be executed immediately.

Why does setImmediate get called?

SetImmediate and Process.nexttick () are both misnamed. So functionally, setImmediate executes on the nextTick, and nextTick executes right away.

Can Javascript code be blocked?

As we’ve already discussed, nextTickQueue has no restriction on callback execution. So if you recursively execute process.nexttick (), your program may never get out of the event loop, no matter what you have in other phases.

What if I call setTimeout again in the exit callback phase?

It might initialize the timer, but the callback might never be called. Because if node is in the exit callback phase, it has already broken out of the event loop. So they didn’t go back.

Some short conclusions

  • Event loops have no working stack

  • The event loop is not in a single thread, and JavaScript execution is not as simple as popping a callback execution from a queue.

  • SetImmediate does not push the callback to the head of the work queue and has a dedicated phase and queue.

  • SetImmediate executes in the next loop, and nextTick actually executes right away.

  • Beware that nextTickQueue may block your Node code if called recursively.