Despite being single threaded, Node-Js allows asynchronous programming and non-blocking operations by using Event loop. Node has an event driven architecture and code is executed is in the form of callbacks. The Event Loop schedules the callbacks to be run in the single thread at a given point. Libuv is the library that provides the implementation of event loop and thread pool in Nodejs.
When a node process starts execution, the top level code is executed first, and all the callbacks are registered. It is after this, the event loop starts execution.
Thread Pool
There are some tasks which are too heavy to be executed in the event loop as they would block the single thread. Libuv provides 4 additional threads by default, which are separate from the single main thread and event loop automatically offloads heavy tasks to this thread pool. Few tasks which are expensive for the single thread in event loop are:
File System API’s
Cryptography Operations
Compressions
DNS Lookup
Phases in an Event Loop
Each phase of the event loop has a FIFO callback queue associated with it, which stores the callbacks to be executed. For timers, the callbacks are stored in a heap. Each iteration of the event loop is called a tick.
Timers
When callbacks are scheduled using
setTimeout()
andsetInterval()
, these callbacks are added to a heap. When event loop enters this phase, it executes the callbacks from the heap whose timers have expired. The timer or interval set for these functions, say 10ms, does not guarantee that the callback will be executed exactly after 10ms. In fact, the interval only ensures that the callback will not be executed before 10ms. The actual time after which the callback is executed is determined by the Poll Phase.Pending Callbacks
This phase executes callbacks for some system operations such as types of TCP errors.
Idle/Prepare Phase
Idle and prepare phases are for internal operations of node.
I/O Poll Phase
Polling refers to looking for new I/O events that are ready to be processed and putting them into the callback queue. In this phase, the event loop watches out for new async I/O callbacks. Usually, most of the code in the application is executed here as code usually runs inside a server waiting for requests.
When the event loop enters the poll phase, either of the two things can happen:
- If the poll queue is not empty, it will start executing them synchronously until all the callbacks have been executed from the callback queue in poll phase.
- If the poll queue is empty, which is when there are no callbacks in the queue, or if all callbacks have completed execution, the event loop will stay in the poll phase for a certain period of time or will move to next phase when:
- If there are callbacks sheduled by
setImmediate()
, the event loop will end the poll phase and move to the next phase, which is Check Phase. - If there are no callbacks sheduled by
setImmediate()
, but if there are expired timers waiting to be executed in Timers Phase, the event loops will immediately move through the next phases, which is Check phase and Close Callbacks Phase and will start its next tick from Timers Phase.
- If there are callbacks sheduled by
Check Phase
Callbacks sheduled with
setImmediate()
are executed in this phase.Close Callbacks Phase
In this phase, the event loop executes the callbacks associated with the closing events like socket.on(‘close’). After executing the callbacks in this queue, if there are no items to be processed in any queue and there are events waiting for requests, the loop will exit. If not, the loop will continue to the Timers phase again.
Next Tick Queue and Other Microtasks Queue
Any callbacks associated with process.nextTick()
, is added to the nextTickQueue and callbacks associated with resolved promises are added to microTaskQueue. Before node v11, the callbacks in these queues were run after each phase is complete. But since v11, the callbacks in these queues are processed after the currently executing callback is complete, irrespective of the phase of the event loop. Callbacks in microTaskQueue is run after the nextTickQueue.
Example
const fs = require('fs');
fs.readFile("test-file.txt", () => {
setTimeout(() => console.log('Timer-1 executed.'), 0);
setTimeout(() => console.log('Timer-2 executed.'), 5000);
setImmediate(() => console.log('Immediate-1 executed.'));
setTimeout(() => {
console.log('Timer-3 executed.')
setImmediate(() => console.log('Immediate-2 executed.'));
setTimeout(() => console.log('Timer-4 executed.'), 0);
setImmediate(() => console.log('Immediate-3 executed.'));
Promise.resolve().then(() => console.log('Resolved promise executed.'))
}, 500);
process.nextTick(() => console.log('Process.nextTick executed.'));
});
console.log("Top Level Code.")
Output of the above code will be:
Top Level Code.
Process.nextTick executed.
Immediate-1 executed.
Timer-1 executed.
Timer-3 executed.
Resolved promise executed.
Immediate-2 executed.
Immediate-3 executed.
Timer-4 executed.
Timer-2 executed.
- The top level code is executed first. The
fs.readFile
function registers a callback to be triggered after the file is read. Console statement for top level code is printed. The event loop then starts execution. - The event loop starts with Timer Phase, finds the heap empty and proceeds to I/O Polling Phase. When the file read operation is complete, the callback is added to the callback queue of I/O Polling phase. When event loop reaches the I/O polling phase, it will execute this callback.
- During the execution of the callback, Timer-1, Timer-2 and Timer-3 is added to the callback heap of Timer Phase. Callback of Immediate-1 is added to callback queue of Check Phase. The callback of
process.nextTick()
is added to nextTickQueue and is executed immediately. - Since there is no other callbacks in I/O polling phase, the event loop proceeds to Check Phase and executes Immediate-1.
- Event loop then proceeds to the next tick after passing through Close Phase, which did not have anything to execute.
- In the second tick, the loop enters the Timer Phase again, notices that Timer 1 and Timer 3 has expired and executes Timer 1 first. During execution of Timer-3, Timer-4 is added to the callback heap and Immediate-2 and Immediate-3 is added to the callback queue of Check Phase. Though Timer-4 expired immediately(interval is 0), it is executed only in the next tick. The resolved promise added to microTaskQueue is executed.
- The heap in Timer Phase is now empty and event loop moves to I/O polling phase. Since the queue here is empty and there are callbacks in Check Phase, it moves to the next phase and executes Immediate-2 and Immediate-3.
- In this next tick, Timer-4 is executed in the Timers Phase.
- It then proceeds to I/O phase and waits in this phase until Timer-2 expires. Once it expires, it executes Timer-2 in the next tick.
- Since there are no more I/O events to be processed, and no timers left to execute, the event loop will exit after the Close Phase in the same tick.
All the callbacks passed to
process.nextTick()
will be processed before event loop continues. So if there are recursive calls inprocess.nextTick()
, it will block the event loop.Unlike
process.nextTick()
, recursive calls tosetImmediate()
are executed only in the next tick and hence will not block the event loop.