Unless otherwise specified, the context of the event loop refers to NodeJS

The nodeJS environment used in this paper is: The operating system Window10 + NodeJS version is V12.16.2

What is an Event loop?

Event Loop is a mechanism provided by Libuv to implement non-blocking I/O. Specifically, because javascript is a single-threaded programming language, NodeJS can only transfer the implementation of asynchronous I/O (non-blocking I/O is asynchronous I/O) to Libuv. Because I/O can occur on many different operating systems (Unix, Linux,Mac OX,Window), it can be divided into many different types of I/O (File I/O, Network I/O, DNS I/O, Database I/O, etc.). So, for Libuv, if the current system provides asynchronous interfaces for certain types of I/O operations, then Libuv uses those ready-made interfaces, otherwise it starts a thread pool to implement it itself. This is what the official documentation says: “Event loops enable Node.js to perform non-blocking I/O operations (even though JavaScript is single-threaded) by moving operations to the system kernel.”

The event loop is what allows node.js to perform non-blocking I/O operations — despite The fact that JavaScript is Single-threaded — by offloading operations to the system kernel whenever possible.

Nodejs architecture

Before moving on to nodeJS event loop, let’s take a look at the nodeJS architecture diagram:

As you can see from the architecture diagram above, Libuv is at the bottom of the architecture. The implementation of the Event loop is provided by Libuv. You should now have a complete picture in your mind and know exactly where the Event loop is.

It is important to note that neither the Event loop in Chrome nor the Event loop in NodeJS is actually implemented by the V8 engine.

A few misconceptions about event loop

Myth # 1: Event loop and user code run on different threads

It is often said that the user’s javascript code runs on the main thread and the rest of the NodeJS javascript code (not written by the user) runs on the event loop thread. Each time an asynchronous operation occurs, the main thread hands off the implementation of the I/O operation to the Event Loop thread. When the asynchronous I/O has a result, the Event Loop thread notifies the main thread of the result, which executes the user’s registered callback function.

The truth

All javascript code, whether written by the user or built into NodeJS itself (the NodeJS API), runs in the same thread. From nodeJS ‘point of view, all javascript code is either synchronous or asynchronous. Perhaps we can say that all synchronous code execution is done by V8, and all asynchronous code execution is done by the Event loop function module provided by Libuv. So how does event Loop relate to V8? We can look at the source code below:

Environment* CreateEnvironment(Isolate* isolate, uv_loop_t* loop, Handle<Context> context, int argc, const char* const* argv, int exec_argc, const char* const* exec_argv) {
  HandleScope handle_scope(isolate);

  Context::Scope context_scope(context);
  Environment* env = Environment::New(context, loop);

  isolate->SetAutorunMicrotasks(false);

  uv_check_init(env->event_loop(), env->immediate_check_handle());
  uv_unref(reinterpret_cast<uv_handle_t*>(env->immediate_check_handle()));
  uv_idle_init(env->event_loop(), env->immediate_idle_handle());
  uv_prepare_init(env->event_loop(), env->idle_prepare_handle());
  uv_check_init(env->event_loop(), env->idle_check_handle());
  uv_unref(reinterpret_cast<uv_handle_t*>(env->idle_prepare_handle()));
  uv_unref(reinterpret_cast<uv_handle_t*>(env->idle_check_handle()));

  // Register handle cleanups
  env->RegisterHandleCleanup(reinterpret_cast<uv_handle_t*>(env->immediate_check_handle()), HandleCleanup, nullptr);
  env->RegisterHandleCleanup(reinterpret_cast<uv_handle_t*>(env->immediate_idle_handle()), HandleCleanup, nullptr);
  env->RegisterHandleCleanup(reinterpret_cast<uv_handle_t*>(env->idle_prepare_handle()), HandleCleanup, nullptr);
  env->RegisterHandleCleanup(reinterpret_cast<uv_handle_t*>(env->idle_check_handle()), HandleCleanup, nullptr);

  if (v8_is_profiling) {
    StartProfilerIdleNotifier(env);
  }

  Local<FunctionTemplate> process_template = FunctionTemplate::New(isolate);
  process_template->SetClassName(FIXED_ONE_BYTE_STRING(isolate, "process"));

  Local<Object> process_object = process_template->GetFunction()->NewInstance();
  env->set_process_object(process_object);

  SetupProcessObject(env, argc, argv, exec_argc, exec_argv);
  LoadAsyncWrapperInfo(env);

  return env;
}
Copy the code

As you can see, NodeJS passes libuv’s default Event loop as a parameter when creating the V8 environment. Event Loop is a function module used by V8. Therefore, we can say that V8 includes event loops.

For this single thread, some people call it a V8 thread, some call it an Event loop thread, and some call it a Node thread. Since NodeJS is mostly referred to as javascript runtime, I prefer to call it “Node threads”. Again, though: “Whatever it’s called, the essence is the same. They all refer to the thread in which all javascript is running.”

Myth # 2: All asynchronous operations are handed over to Libuv’s thread pool

Asynchronous operations, such as reading and writing to the file system, making HTTP requests, or reading and writing to the database, are all loaded off to Libuv’s thread pool.

The truth

Libuv does create a thread pool with four threads. However, today, many operating systems already provide interfaces to implement asynchronous I/O (for example, AIO on Linux), and libuv internally prioritized using these off-the-shelf APIS for asynchronous I/O. Libuv uses thread + polling in the thread pool to implement asynchronous I/O only in specific cases (when an operating system does not provide an asynchronous interface for a certain type of I/O).

Myth 3: An event loop is a stack or queue

The Event Loop continuously FIFO a queue full of asynchronous Task callbacks, and when the task completes, the Event Loop executes its corresponding callback.

The truth

The Event loop mechanism does involve queue-like data structures, but there is not just one such “queue”. In fact, the Event loop traverses phases, each of which has a corresponding queue of callback functions (called a callback queue). When a certain stage is executed, the Event loop will traverse the corresponding callback queue of this stage.

Event Loop has six phases

First, let’s take a look at the location of the Event loop in terms of the nodeJS application lifecycle:

In the figure above, the Mainline code refers to our nodeJS entry file. Entry files are treated as synchronous code, executed by V8. In the process of interpreting/compiling from top to bottom, if a request is made to execute asynchronous code, NodeJS hands it over to the Event Loop.

In NodeJS, there are many types of asynchronous code, such as timers, process.nexttick (), and various I/O operations. The above chart singles out asynchronous I/O mainly because it occupies a significant part of the asynchronous code in NodeJS. The “Event Demultiplexer” refers to the collection of I/O function modules encapsulated by Libuv (see the libuv architecture diagram above). When the Event Demultiplexer gets the I/O result from the operating system, it notifies the Event loop to enqueue the corresponding callback/handler to the corresponding queue.

The Event loop is a single-threaded, semi-infinite loop. It is “semi-infinite” because when there are no more tasks (more asynchronous I/O requests or timers) to do, the Event loop exits and the entire NodeJS program completes.

This is the location of the Event loop throughout the nodeJS application lifecycle. When we expand the Event loop separately, in fact, it mainly includes six stages:

  1. timers
  2. pending callbacks
  3. idle/prepare
  4. poll
  5. The check.
  6. close callbacks

The Event loop goes through each of these stages in turn. Each phase has a callback queue corresponding to it. The Event loop iterates through the callback queue, executing each callback. The Event loop does not move to the next phase until the Callback queue is empty or the number of current callback executions exceeds a certain threshold.

1. timers

At this stage, the Event Loop checks to see if any expired timers are available to execute. If yes, run the command. Callbacks passed to the setTimeout or setInterval methods are queued to the Timers Callback Queue after a specified delay. As with the setTimeout and setInterval methods in the browser environment, the delay passed in when the call is made is not the exact time the callback will execute. Timer callback execution points cannot be guaranteed to be stable and consistent because their execution is affected by the operating system scheduling layer and the time of other callback function calls. So, the correct expectation for a delay parameter passed to a setTimeout or setInterval method is: After the delay I specified, Nodejs, I want you to help me execute my callback as soon as possible. This means that the timer callback function will execute later than the scheduled time, but not earlier than the scheduled time.

Technically, the poll phase actually controls when the Timer callback is executed.

2. pending callbacks

This phase is primarily a callback function that performs some system-level operations. For example, an error callback when A TCP error occurs. If ECONNREFUSED occurs when a TCP socket attempts to establish a connection, nodeJS will queue the ECONNREFUSED error into the pending callback queue and execute it immediately to notify the operating system.

3. idle/prepare

Nodejs phase for internal use only. For developers, it’s almost negligible.

poll

Before entering the polling phase, the Event Loop checks whether the Timer callback queue is empty. If it is not empty, the Event Loop reverts to the Timer phase and executes all the Timer callbacks in sequence before returning to the polling phase.

After entering the polling phase, the Event loop does two things:

  1. According to the actual situation of different operating systems, calculate the length of the event loop time that should be occupied in the polling stage.
  2. Multiplexer and execute the CALLBACK in the I/O callback queue.

Because NodeJS is intended for I/O intensive software, it will spend a large percentage of its time in the polling phase in an Event loop. At this stage, the Event loop is either in the I/O callback state or in the polling wait state. Of course, there is a limit to how much time the polling phase can take up with the Event loop. This is the first thing to do – calculate a maximum time value that is appropriate for the current operating system environment. Event loop Exits the current polling phase with two conditions:

  1. Condition 1: The duration of the current polling phase has exceeded the threshold calculated by NodeJS.
  2. Condition 2: The I/O callback queue is empty and the IMMEDIATE callback is not empty.

Once one of the two conditions is met, the Event Loop exits the polling phase and enters the Check phase.

From the above description, we can see that the polling phase is related to the timer phase and the immediate phase. The relationship between them can be illustrated by the following flow chart:

check

When the poll is idle (i.e. when the I/Ocallback queue is empty), the event loop finds that the IMMEDIATE callback queue is queued. The Event loop will exit the polling phase and immediately enter the check phase.

The callback passed through when setImmediate() is called is passed to the Immediate Callback Queue. The Event loop executes the callbacks in the queue until the queue is empty and then moves on to the next phase.

SetImmediate () actually performs a timer in another phase. In the internal implementation, it uses an interface of Libuv responsible for scheduling code to execute the corresponding code after the poll phase.

close callback

Execute the phases that register the callback on the closing event. For example: socket.on(‘close’,callback). There is less asynchronous code for this type, so I won’t elaborate.

For more details

As explained in the previous section, of the six phases, pending Callbacks and Idle/Prepare are used internally by NodeJS, and only four phases are related to user code. Our asynchronous code is eventually pushed into the callback queue corresponding to each of the four phases. So the Event loop itself has the following queues:

  • timer callback queue
  • I/O callback queue
  • immediate callback queue
  • close callback queue

In addition to the four queues of the Event loop, there are two queues worth noting:

  • NextTick callback queue. Callbacks passed in when process.nexttick () is called are enqueued here.
  • Microtask callback queue. A callback passed in as a Promise object reslove or Reject is enqueued here.

These two queues are not part of the Event loop, but they are part of the NodeJS asynchronous mechanism. From the perspective of the six queues involved in the Event loop mechanism, the event loop operation mechanism can be described as follows:

Event Loop diagram

Process. NextTick () and Promise/then ()

After the nodeJS entry file (mainline code) is executed, next Tick Callback and MicorTask Callback are executed before entering the Event loop. Some technical articles refer to the Next Tick Callback as a MicroTask callback, which co-exists in a queue, and emphasize its priority over other microtasks such as Promise. Nodejs executes the next Tick Queue and then the MicroTask callback Queue. Either way, the described results are the same. Obviously, this paper prefers to adopt the latter.

After calling process.nexttick (), callback will be queued to the Next Tick Callback queue. After calling Promise/then(), the corresponding callback goes to the MicroTask Callback Queue. Even if both queues are not empty at the same time, NodeJS always executes the Next Tick Callback queue first and does not execute the MicroTask callback queue until the entire queue is empty. When the MicroTask Callback Queue is empty, NodeJS checks the next Tick Callback queue again. Nodejs will enter the Event loop only if both queues are empty. If we look closely, we can see that the recursive queuing features of the two queues are the same as those of the MicrTask queue in the browser’s Event loop. From this perspective, it makes sense for some technical articles to refer to the Next Tick Callback as a MicroTask callback. When the MicroTask callback is queued indefinitely, an event loop starvation occurs. That is, it blocks the Event loop. Although this feature does not cause the NodeJS program to report stack overflow, in fact, NodeJS is already in a state that cannot be faked. Therefore, we do not recommend infinite recursion into queues.

It can be seen that the execution of the Next Tick Callback and microTask callback has formed a small loop. Nodejs must jump this small loop to enter the event loop.

setTimeout VS setImmediate()

If the timer callback queue and immediate callback queue are not empty, which queue should be executed first? You might think it’s a timer callback queue. B: Yes, that would be the case under normal circumstances. Because the timer phase comes before the check phase. In some cases, the immediate callback queue is executed first and then the timer callback queue is executed. What’s going on? The poll phase (or I/O callback code, so to speak) is where the poll action takes place. Why is that? The poll phase is idle. If the event loop detects that your Immediate callback queue has a callback, it exits the poll phase and executes all immediate callback requests in the Check phase. There is no phase fallback as occurs before the poll phase, that is, no priority fallback to the Timer phase to execute all timer callbacks. In fact, the execution of the Timer callback already takes place in the next Event loop. If both the timer callback and immediate callback are queued in the I/O callback at the same time, the event loop always executes the latter before the former.

If mainline code has this code:

// timeout_vs_immediate.js
setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});
Copy the code

Must “timeout” be printed first, then “immediate”? The answer is not necessarily. Because the enqueueing time of the Timer callback may be affected by the process performance (other applications running on the machine affect the NodeJS application process performance), the Timer callback is not queued as expected before the Event loop enters the Timer phase. At this point, the Event Loop has moved on to the next stage. Therefore, the print order of the above code is not guaranteed. Sometimes “timeout” is printed first, sometimes “immediate” is printed first:

$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout
Copy the code

Big loop vs. small loop

The major loop refers to the Event loop, and the minor loop refers to the minor loop composed of the Next Tick Callback Queue and microTask Callback Queue. We can conclude that once the major loop is in place, the minor loop must be checked after each major loop callback is executed. If the minor loop has a callback to execute, all of the minor loop calbacks must be executed before returning to the major loop. Note that nodeJS does not enter the minor loop by emptying the current phase of the event loop, but by executing a callback. Here’s what the official document says about this:

. This is because process.nextTick() is not technically part of the event loop. Instead, the nextTickQueue will be processed after the current operation is completed, regardless of the current phase of the event loop. Here, an operation is defined as a transition from the underlying C/C++ handler, and handling the JavaScript that needs to be executed.

Note: Prior to Node V11.15.0 (excluding itself), this point was different. In these versions, the event loop will execute all the callbacks in the current callback queue before entering the minor loop. You can verify this in Runkit.

To help us understand, take a look at the following code:

setImmediate(() => console.log('this is set immediate 1'));

setImmediate(() => {
  Promise.resolve().then(()=>{
    console.log('this is promise1 in setImmediate2');
  });
  process.nextTick(() => console.log('this is process.nextTick1 added inside setImmediate2'));
  Promise.resolve().then(()=>{
    console.log('this is promise2 in setImmediate2');
  });
  process.nextTick(() => console.log('this is process.nextTick2 added inside setImmediate2'));
  console.log('this is set immediate 2')});setImmediate(() => console.log('this is set immediate 3'));
Copy the code

If all the immediate callback is executed at once before entering the minor loop, the output should look like this:

this is set immediate 1
this is set immediate 2
this is set immediate 3
this is process.nextTick1 added inside setImmediate2
this is process.nextTick2 added inside setImmediate2
this is promise1 in setImmediate2
this is promise2 in setImmediate2
Copy the code

But the actual print looks like this:

See, after executing the second immediate, the minor loop already has a callback in the queue. At this point, NodeJS will execute the callback in the minor loop first. If small loops form an infinite loop by recursion, the aforementioned “Event Loop starvation” occurs. The immediate callback is an example, and it is the same for any other queue in the Event loop.

You might be wondering what happens if you join a small loop callback inside a small loop callback. What would be the result of running the following code?

process.nextTick(()=>{
  console.log('this is process.nextTick 1')}); process.nextTick(()=>{ console.log('this is process.nextTick 2')
  process.nextTick(() => console.log('this is process.nextTick added inside process.nextTick 2'));
});

process.nextTick(()=>{
  console.log('this is process.nextTick 3')});Copy the code

The running results are as follows:

this is  process.nextTick 1
this is  process.nextTick 2
this is  process.nextTick 3
this is process.nextTick added inside process.nextTick 2
Copy the code

As you can see, the queued callback does not cut to the middle of the queue, but is inserted to the end of the queue. This behavior is different from the behavior of being enrolled in the Event Loop. This is the difference between a major loop and a minor loop when executing the enqueue Next Tick Callback and microTask callback.

The difference between NodeJS and Event loop in Browser

There are similarities and differences between the two. Again, the following conclusions are based on Node V12.16.2.

The same

In essence, there is no difference between the two. To be specific, Macrotask Callback if mainline code in nodeJS event loop and callback in each stage are generalized to MacroTask callback, If the next Tick callback and other microTask callbacks such as Promise/then() are generalized as microTask callbacks, the two event loop mechanisms are roughly the same: A MacroTask callback is executed first, followed by a complete MicroTask Callback queue. Microtask callbacks are recursive into queues, and infinite recursive queues generate “Event loop starvation” consequences. The next MacroTask callback will not be executed until all callbacks in the MicroTask Callback Queue have been executed.

The difference between

From a technical point of view, there are a few differences:

  • There is no macroTask in the NodeJS event Loop implementation.
  • Nodejs Event Loop is divided by stages, with six stages corresponding to six types of queues (two of which are for internal use only). Browser Event Loops are not divided by stages, and there are only two types of queues, macroTask Queue and MicroTask Queue. Another way to think about it is that nodeJS Event loop has 2 microTask queues and 4 MacroTask queues; The browser Event Loop has only one MicroTask queue and one MacroTask queue.
  • The biggest difference is that nodeJS EVnet Loop has a polling phase. Browser Event Loop exits the Event Loop (or goes to sleep) when all queues in the EVNet Loop are empty. The NodeJS Event loop, however, keeps hitting the polling phase and waiting there for the I/O callback in its pending state. Nodejs exits the Event loop only when the wait time exceeds the limit calculated by NodeJS or when there are no more outstanding I/O tasks. This is the biggest difference between nodeJS Event Loop and Browser Event Loop.

The resources

  1. The Node.js Event Loop, Timers, and process.nextTick();
  2. How is asynchronous javascript interpreted and executed in Node.js? ;
  3. What you should know to really understand the Node.js Event Loop;
  4. Relationship between event loop,libuv and v8 engine;
  5. Introduction to the event loop in Node.js;
  6. Event Loop and the Big Picture;
  7. How does Node.js work? ;