How does the Node Event loop work?
This article is aimed at explaining how the Node event loop work and the phases involved.
Introduction
Node.js is a runtime environment built on the google chrome V8 engine. It provides an event-driven, non-blocking, and I/O cross-platform to build scalable server-side applications using Javascript. The event loop is a fundamental concept of Node.js, it is the secret behind Node.js's asynchronous programming.
Goals
By the end of this article, you will have a good understanding of the event loop and its phases. We will also discuss how Node utilizes the underlying C library to perform non-blocking operations
Prerequisite
- Basic understanding of Node.js
Terms and Definition
We will be using the following terms in this article;
Tick: an iteration of the event loop.
Timer: a function that executes code after a set period of time
Process: the process starts when the application starts execution. The process starts when we run
node index.js
I/O: any task that involves the external hardware/OS: file system, network
operations, etc.Thread: this presents a linear series of instruction in the process to be executed
Processes and Threads.
Before we take a look at how the event loop works, let us take a brief detour and discuss processes and threads.
Node.js runs in a single process, a single thread is created in that process, and this is where our application runs. The event loop is situated inside the single thread.
This design has its flaw because our application will not make use of more than one core of the CPU to process requests. This can block, and slow down execution when it encounters a processing-heavy task in the application. Let's discuss how the event loop solves this issue.
When we run node index.js
, the execution of our code starts. When the event loop encounters a processing-heavy I/O task, it immediately offloads the task to the underlying C library called the libuv, and registers a callback in the event queue.
The event loop does not wait for the current task to be completed, it simply moves on to the next task. When the I/O operation is processed, the event loop calls the respective callback and returns a response. This is how the event loop allows Node to perform non-blocking operations.
Event Loop and its phases
The event loop consists of six phases:
┌───────────────────────────┐
┌─>│ poll
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ check
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ close callbacks
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ timers
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ idle, prepare
└───────────────────────────┘
Below is an overview of each phase from the Node docs .
- 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: such as socket.on(‘close’).
Each phase has a FIFO queue of callbacks to execute. While each phase is special in its own 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 executed. When the queue has been exhausted or the callback limit is reached, the event loop will move to the next phase, and so on. Source :- Node docs
Phases in Detail
timers
A timer specifies a threshold after which the callback is executed. The timer phase is executed directly by the event loop.
Timer callback does not run after the delay in milliseconds has passed, they run as early as scheduled or after running other callbacks.
This code snippet demonstrates the extra delay in running a callback of a timer function.
let start = Date.now()
setTimeout(() => console.log(Date.now() - start), 1000) //1017
setTimeout(() => console.log(Date.now() - start), 1000) //1042
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 system operations want to wait before executing a callback, this phase is used for that. This will be queued to execute in the pending callbacks phase.
idle, prepare
This phase is used internally.
Poll phase
The poll phase calculates the blocking time in every iteration to handle I/O callbacks, and processes events in the poll queue. This phase is where most of our code is executed.
check
The check phase handles any callback defined inside setImmediate()
. Callbacks in this phase will execute immediately after the poll phase has completed or become idle. setImmediate()
is actually a special timer that runs in a separate phase of the event loop.
close callbacks
This phase handles callbacks If a socket or handle is closed abruptly ( socket.close()
) , the 'close' event will be emitted in this phase.
When there are no more callbacks in any phases, the close callbacks phase calls the process.exit() and exits to the terminal.
Further abstractions
process.nextTick()
After the execution of each phase of the event loop and before moving to the next phase there is a nextTickQueue that is executed but isn’t officially a part of the event loop.
Any callback provided in the process.nextTick gets registered in the nextTickQueue of the next phase. Executing long-running codes in the nextTickQueue can starve the execution of different phases of the event loop’s iteration.
The code below demonstrate how the process.nextTick
works.
setImmediate(() => console.log('Hello setImmediate'))
console.log('Hello world')
process.nextTick(() => {
console.log('Hello nextTick')
})
We get this output
┌───────────────────────────┐
┌─>│ poll │---> prints Hello world
│ └─────────────┬─────────────┘
│ nextTickQueue ---> prints Hello nextTick
│ ┌─────────────┴─────────────┐
│ │ check │ ---> prints Hello setImmediate
│ └─────────────┬─────────────┘
│ nextTickQueue
│ ┌─────────────┴─────────────┐
│ │ close callbacks │
│ └─────────────┬─────────────┘
nextTickQueue nextTickQueue
│ ┌─────────────┴─────────────┐
│ │ timers │
│ └─────────────┬─────────────┘
│ nextTickQueue
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ nextTickQueue
│ ┌─────────────┴─────────────┐
└──┤ idle, prepare │
└───────────────────────────┘
Conclusion
In this article, we have learned how event loop works, and the phases involved. This will enable us to write more performance code.