/ Node.js

Node.js's Event Loop Explained

Node.js runs on events. It is an event-driven platform. We saw how the event emmiter plays a huge part of the platform on multiple core modules that made up Node.js. The event loop is what somehow organizing the callbacks or the responses of those events.

Defining the Event Loop

We know that JavaScript is single-threaded. However, Node.js is non-blocking I/O, and that is because of the event loop - provided actually by a library called libuv - which is the instrument that handles offloading operations to the system kernel whenever possible.

Since most modern kernels are multi-threaded, they can handle multiple operations executing in the background. When one of these processes completes, the kernel tells Node.js so that the appropriate callback may be added to the poll queue to be executed eventually.

Mistakenly, the event loop was thought of as a queue of asynchronous tasks that go through all of the phases it has and finally executes a callback when a task is completed. Despite the fact that it has a queue-like structure, the tasks do not go like that, but instead, each phase has specific set of tasks that get processed in a round-robin manner.

Phases of the Event Loop

We know now that the event loop is what keeping the Node.js running. Therefore, it gets initialized When Node.js starts. Then it processes the provided input script which may make async API calls, schedule timers, or call process.nextTick(), then begins processing the event loop.

The event loop has the following phases:

  • timers: this phase executes callbacks scheduled by setTimeout() and setInterval().
  • I/O callbacks: executes almost all callbacks with the exception of close callbacks, the ones scheduled by timers, and setImmediate().
  • idle, prepare: only used internally.
  • poll: retrieve new I/O events; node will block here when appropriate.
  • check: setImmediate() callbacks are invoked here.
  • close callbacks: e.g. socket.on('close', ...).

event-loop-phases

As mentioned, each phase has a queue of tasks to execute. While each phase is unique in its way, generally, when the event loop enters a given phase, it will perform any operations specific to that phase, then execute callbacks in that phase's queue until the queue has been exhausted or the maximum number of callbacks has run. When the queue has been exhausted, or the callback limit is reached, the event loop will move to the next phase, and so on.

Since any of these operations may schedule more operations and new events processed in the poll phase are queued by the kernel, poll events can be queued while polling events are being processed. As a result, long-running callbacks can allow the poll phase to run much longer than a timer's threshold.

Between each run of the event loop, Node.js checks if it is waiting for any asynchronous I/O or timers and shuts down cleanly if there are not any.

Timers

A timer specifies the threshold after which a provided callback may be executed rather than the exact time a person wants it to be executed. Timers callbacks will run as early as they can be scheduled after the specified amount of time has passed; however, Operating System scheduling or the running of other callbacks may delay them.

Note: Technically, the poll phase controls when timers are executed.

For example, say you schedule a timeout to execute after a 100 ms threshold, then your script starts asynchronously reading a file which takes 95 ms:

const fs = require('fs');

function someAsyncOperation(callback) {
  // Assume this takes 95ms to complete
  fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();

setTimeout(() => {
  const delay = Date.now() - timeoutScheduled;

  console.log(`${delay}ms have passed since I was scheduled`);
}, 100);


// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
  const startCallback = Date.now();

  // do something that will take 10ms...
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
});

When the event loop enters the poll phase, it has an empty queue (fs.readFile() has not completed), so it will wait for the number of ms remaining until the soonest timer's threshold is reached. While it is waiting 95 ms pass, fs.readFile() finishes reading the file and its callback which takes 10 ms to complete is added to the poll queue and executed. When the callback finishes, there are no more callbacks in the queue, so the event loop will see that the threshold of the soonest timer has been reached then wrap back to the timers phase to execute the timer's callback. In this example, you will see that the total delay between the timer being scheduled and its callback being executed will be 105ms.

I/O Callbacks

This phase executes callbacks for some system operations such as types of TCP errors. For example, if a TCP socket receives ECONNREFUSED when attempting to connect, some *nix systems want to wait to report the error. This will be queued to execute in the I/O callbacks phase.

Poll

The poll phase has two primary functions:

  1. Executing scripts for timers whose threshold has elapsed, then
  2. Processing events in the poll queue.

When the event loop enters the poll phase, and there are no timers scheduled, one of two things will happen:

  • If the poll queue is not empty, the event loop will iterate through its queue of callbacks executing them synchronously until either the queue has been exhausted, or the system-dependent hard limit is reached.

  • If the poll queue is empty, one of two more things will happen:

    • If scripts have been scheduled by setImmediate(), the event loop will end the poll phase and continue to the check phase to execute those scheduled scripts.
    • If scripts have not been scheduled by setImmediate(), the event loop will wait for callbacks to be added to the queue, then execute them immediately.

Once the poll queue is empty, the event loop will check for timers whose time thresholds have been reached. If one or more timers are ready, the event loop will wrap back to the timers phase to execute those timers' callbacks.

Check

This phase allows a person to execute callbacks immediately after the poll phase has completed. If the poll phase becomes idle and scripts have been queued with setImmediate(), the event loop may continue to the check phase rather than waiting.

setImmediate() is actually a special timer that runs in a separate phase of the event loop. It uses a libuv API that schedules callbacks to execute after the poll phase has completed.

Generally, as the code is executed, the event loop will eventually hit the poll phase where it will wait for an incoming connection, request, etc. However, if a callback has been scheduled with setImmediate() and the poll phase becomes idle, it will end and continue to the check phase rather than waiting for poll events.

Close Callbacks

If a socket or handle is closed abruptly (e.g. socket.destroy()), the 'close' event will be emitted in this phase. Otherwise it will be emitted via process.nextTick().

References

Anas Shekhamis

Anas Shekhamis

A software engineer who specializes in architecting and developing web applications. On a daily basis, I use Python, JavaScript, Ruby, and PHP to design and implement API's and build web applications.

Read More
Node.js's Event Loop Explained
Share this