JavaScript is single-threaded and asynchronous operations are particularly important.

Whenever you use functions outside the engine, you need to interact with the outside world, thus creating asynchronous operations. Because there are so many asynchronous operations, JavaScript has to provide a lot of asynchronous syntax. It’s like, some people get hit all the time, and they have to become very resilient, or they’re screwed.

Node’s asynchronous syntax is more complex than the browser’s, because it talks to the kernel and has to make a special library, libuv, to do this. This library is responsible for the execution time of various callback functions, after all, asynchronous tasks end up back in the main thread, queued one by one.

To coordinate asynchronous tasks, Node actually provides four timers that allow tasks to run at specified times.

  • setTimeout()
  • setInterval()
  • setImmediate()
  • process.nextTick()

The first two are standard for the language, the last two are unique to Node. They are written in a similar way and serve a similar purpose, and are not very easy to distinguish from each other.

Can you tell the result of the following code?

1
2
3
4
5
6
// test.js
setTimeout(() => console.log(1));
setImmediate(() => console.log(2));
process.nextTick(() => console.log(3));
Promise.resolve().then(() => console.log(4));
(() => console.log(5))();

The results of the run are as follows.

1
2
3
4
5
6
7

$ node test.js
5
3
4
1
2

If you can get it right in one sitting, you probably don’t need to read any further. This article explains, in detail, how Node handles various timers, or more generally, how the libuv library schedules asynchronous tasks to execute on the main thread.

1. Synchronous and Asynchronous Tasks

First, synchronous tasks are always executed earlier than asynchronous tasks.

In the previous section of code, only the last line is a synchronous task, so it is executed earliest.

1
(() => console.log(5))();

2. Current loop and second loop

Asynchronous tasks can be divided into two types.

  • Asynchronous tasks appended to this loop
  • Asynchronous tasks added to next loop

The term “loop” refers to the event loop. This is how the JavaScript engine handles asynchronous tasks, and will be explained in more detail later. Just understand that the current loop must be executed before the next loop.

Node specifies that the callback functions for process.nextTick and Promise are appended to the current loop, i.e., they start executing as soon as the synchronous task is finished. The callbacks for setTimeout, setInterval, and setImmediate are appended to the second loop.

This means that the third and fourth lines of the code at the beginning of the text must be executed earlier than the first and second lines.

1
2
3
4
5
6
7

// The following two lines, the next round of loop execution
setTimeout(() => console.log(1));
setImmediate(() => console.log(2));
// In the following two lines, the cycle is executed
process.nextTick(() => console.log(3));
Promise.resolve().then(() => console.log(4));

3. process.nextTick()

The name process.nextTick is a bit misleading, it is executed in the current loop and is the fastest executed of all asynchronous tasks.

After Node executes all the synchronized tasks, the task queue of process.nextTick will be executed next. So, the following line of code is the second output.

1
process.nextTick(() => console.log(3));

Basically, if you want asynchronous tasks to execute as fast as possible, then use process.nextTick.

4. Microtasks

According to the language specification, the callback function of the Promise object goes to the microtask queue inside the asynchronous task.

The microtask queue is appended to the process.nextTick queue, which is also part of the current loop. So, the following code always outputs 3 first and then 4.

1
2
3
4
process.nextTick(() => console.log(3));
Promise.resolve().then(() => console.log(4));
// 3
// 4

Note that the next queue will be executed only after the previous queue has been emptied.

1
2
3
4
5
6
7
8
process.nextTick(() => console.log(1));
Promise.resolve().then(() => console.log(2));
process.nextTick(() => console.log(3));
Promise.resolve().then(() => console.log(4));
// 1
// 3
// 2
// 4

In the above code, all the callback functions of process.nextTick will be executed before Promise.

At this point, the execution order of the loop is finished.

  1. synchronize tasks
  2. process.nextTick()
  3. micro-task

5. The concept of event loops

The following is the order of execution of the second loop, which requires an understanding of what an event loop is.

Node’s official documentation describes it this way.

“When Node.js starts, it initializes the event loop, processes the provided input script which may make async API calls, schedule timers, or call process.nextTick(), then begins processing the event loop.”

This passage is important and needs to be read carefully. It expresses three levels of meaning.

First, some people assume that there is a separate event loop thread in addition to the main thread. This is not the case. There is only one main thread, and the event loop is done on the main thread.

Second, when Node starts executing a script, it will initialize the event loop first, but at that point the event loop has not yet started and will complete the following things first.

  • Synchronous tasks
  • Sending asynchronous requests
  • Planning when the timer will take effect
  • Execute process.nextTick() and so on

Finally, after all of the above is done, the event cycle officially begins.

6. The six stages of the event cycle

The event loop is executed indefinitely, round after round. Execution stops only when the queue of callback functions for asynchronous tasks is emptied.

Each round of the event loop is divided into six phases. These phases are executed sequentially.

  1. timers
  2. I/O callbacks
  3. idle, prepare
  4. poll
  5. check
  6. close callbacks

Each stage has a first-in-first-out queue of callback functions. Only when the callback function queue of a stage is emptied and all the callback functions that should be executed are executed, the event loop will move to the next stage.

The following is a brief description of what each stage means. For details, see the official documentation, or refer to libuv’s source code interpretation.

(1)timers

This is the timer phase, which handles the callback functions for setTimeout() and setInterval(). After entering this phase, the main thread checks the current time and whether the timer conditions are met. If it does, it executes the callback function, otherwise it leaves this phase.

(2)I/O callbacks

All callback functions are executed at this stage, except for the callback functions for the following operations.

  • The callback functions for setTimeout() and setInterval()
  • callback functions for setImmediate()
  • callback functions for closing requests, such as socket.on('close', ...)

(3)idle, prepare

This stage is only called internally by libuv and can be ignored here.

(4)Poll

This phase is polling time for I/O events that have not yet been returned, such as server responses, user mouse movements, etc.

This phase can be quite long. If there are no other asynchronous tasks to process (such as expiring timers), it will stay in this phase and wait for I/O requests to return results.

(5)check

This stage executes the setImmediate() callback function.

(6)close callbacks

This stage executes the callback function that closes the request, such as socket.on('close', ...) .

7. Example of an event loop

Here is an example from the official documentation.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const fs = require('fs');

const timeoutScheduled = Date.now();

// Asynchronous task I: Timer executed after 100ms
setTimeout(() => {
  const delay = Date.now() - timeoutScheduled;
  console.log(`${delay}ms`);
}, 100);

// Asynchronous task 2: After the file is read, there is a 200ms callback function
fs.readFile('test.js', () => {
  const startCallback = Date.now();
  while (Date.now() - startCallback < 200) {
    // Do nothing
  }
});

The above code has two asynchronous tasks, a timer that executes after 100ms and a file read, whose callback function takes 200ms. what is the result?

After the script enters the first event loop, there is no timer that expires and no I/O callback function that is already executable, so it enters the Poll phase and waits for the kernel to return the result of the file read. Since reading small files usually takes less than 100ms, the Poll phase will get the result before the timer expires, so it will continue to execute.

In the second event loop, there is still no timer that expires, but there is already an I/O callback function that can be executed, so it will enter the I/O callbacks phase and execute the fs.readFile callback function. This callback function takes 200ms, which means that halfway through its execution, the 100ms timer will expire. However, it must wait until this callback function finishes executing before it leaves this stage.

For the third event loop, there is already an expiring timer, so the timer will be executed in the timers phase. The final output is about 200ms or so.

8. setTimeout And setImmediate

Since setTimeout is executed in the timers phase, and setImmediate is executed in the check phase. So, setTimeout will finish before setImmediate.

1
2
3

setTimeout(() => console.log(1));
setImmediate(() => console.log(2));

The above code should output 1 first and then 2, but when it is executed, the result is indeterminate, sometimes outputting 2 first and then 1.

This is because the second parameter of setTimeout defaults to 0. But in practice, Node cannot do 0 ms, it needs at least 1 ms. According to official documentation, the second parameter can take values between 1 ms and 2147483647 milliseconds. That is, setTimeout(f, 0) is equivalent to setTimeout(f, 1).

In practice, after entering the event loop, it may or may not reach 1 millisecond, depending on the state of the system at the time. If it does not reach 1 millisecond, then the timers phase will be skipped and the check phase will be executed first with the setImmediate callback function.

However, the following code must output 2 first, then 1.

1
2
3
4
5
6
7

const fs = require('fs');

fs.readFile('test.js', () => {
  setTimeout(() => console.log(1));
  setImmediate(() => console.log(2));
});

The above code will enter the I/O callbacks phase first, then the check phase, and finally the timers phase. Therefore, setImmediate will be executed before setTimeout.


Reference https://www.ruanyifeng.com/blog/2018/02/node-event-loop.html