Introduction
JavaScript is mostly executed on a single thread in Node.js and in the browser (with some exceptions such as worker threads, which is out of the scope of the current article). In this article, I will try to explain the mechanism of concurrency at Node.js which is the Event Loop.
Before starting to read this article you should be familiar with the stack and how it works, I wrote in the past about this idea, so check out Stack & Heap — Don’t start to code without understanding them — Moshe Binieli | Medium
Introduction Image
Examples
I believe that learning is the best by examples, therefore I will start with 4 simple code examples. I will analyze the examples and then I will dive into the architecture of Node.js.
Example 1:
console.log(1);
console.log(2);
console.log(3);
// Output:
// 1
// 2
// 3
This example is a pretty easy one, at the first step the console.log(1) goes into the call stack, being executed and then removed, at the second step the console.log(2) goes into the call stack, being executed and then removed, and so on for console.log(3).
Visualization of the call stack for Example 1
Example 2:
console.log(1);
setTimeout(function foo(){
console.log(2);
}, 0);
console.log(3);
// Output:
// 1
// 3
// 2
We can see in this example that we run setTimeout right away, so we would expect console.log(2) to be before console.log(3), but this is not the case and let’s understand the mechanism behind it.
Basic Event Loop Architecture (We will dive more into it later)
Stack & Heap: Check out my article about this one (I added link at the beginning of this article)
Web Apis: They are built into your web browser, and are able to expose data from the browser and surrounding computer environment and do useful complex things with it. They are not part of the JavaScript language itself, rather they are built on top of the core JavaScript language, providing you with extra superpowers to use in your JavaScript code. For example, the Geolocation API provides some simple JavaScript constructs for retrieving location data so you can say, plot your location on a Google Map. In the background, the browser is actually using some complex lower-level code (e.g. C++) to communicate with the device’s GPS hardware (or whatever is available to determine position data), retrieve position data, and return it to the browser environment to use in your code. But again, this complexity is abstracted away from you by the API.
Event Loop & Callback Queue: The functions that finished the Web Apis execution are being moved to the Callback Queue, this is a regular queue data structure, and the Event Loop is responsible for dequeuing the next function from the Callback Queue and sending the function to the call stack to execute the function.
Order of execution
All functions that are currently in the call stack get executed and then they get popped off the call-stack.
When the call stack is empty, all queued-up task are popped onto the call-stack one by one and get executed, and then they get popped off the call-stack.
Let’s understand example 2
console.log(1) method is called and placed on the call stack and being executed.
setTimeout method is called and placed on the call stack and being executed, this execution creates a new call to setTimeout Web Api for 0 milliseconds, when it finishes (right away, or if to be more precise then it would be better to say “as soon as possible”) the Web Api moves the call to the Callback Queue.
console.log(3) method is called and placed on the call stack and being executed.
The Event Loop sees that the Call Stack is empty and takes out “foo” method from the Callback Queue and places it in the Call Stack, then console.log(2) is being executed.
Visualization of the process for Example 2
So the delay parameter in setTimeout(function, delay) does not stand for the precise time delay after which the function is executed. It stands for the minimum time to wait after which at some point in time the function will be executed.
Example 3:
console.log(1);
setTimeout(function foo() {
console.log(‘foo’);
}, 3500);
setTimeout(function boo() {
console.log(‘boo’);
}, 1000);
console.log(2);
// Output:
// 1
// 2
// 'boo'
// 'foo'
Visualization of the process for Example 3
Example 4:
console.log(1);
setTimeout(function foo() {
console.log(‘foo’);
}, 6500);
setTimeout(function boo() {
console.log(‘boo’);
}, 2500);
setTimeout(function baz() {
console.log(‘baz’);
}, 0);
for (const value of [‘A’, ‘B’]) {
console.log(value);
}
function two() {
console.log(2);
}
two();
// Output:
// 1
// 'A'
// 'B'
// 2
// 'baz'
// 'boo'
// 'foo'
Visualization of the process for Example 4
The event loop proceeds to execute all the callbacks waiting in the task queue. Inside the task queue, the tasks are broadly classified into two categories, namely micro-tasks and macro-tasks.
Macro-Tasks (Task Queue) & Micro-Tasks
To be more accurate there are actually two types of queues.
- The macro-task queue (or just called the task queue).
- The micro-task queue.
There are a few more tasks that go into the macro-task queue and micro-task queue but I will cover the common ones.
Common Macro-Tasks are setTimeout, setInterval, and setImmediate.
Common Micro-Task are process.nextTick and Promise callback.
Order of execution
All functions that are currently in the call stack get executed and then they get popped off the call-stack.
When the call stack is empty, all queued-up micro-tasks are popped onto the call-stack one by one and get executed, and then they get popped off the call-stack.
When both the call-stack and micro-task queue are empty, all queued-up macro-tasks are popped onto the call-stack one by one and get executed, and then they get popped off the call-stack.
Example 5:
console.log(1);
setTimeout(function foo() {
console.log(‘foo’);
}, 0);
Promise.resolve()
.then(function boo() {
console.log(‘boo’);
});
console.log(2);
// Output:
// 1
// 2
// 'boo'
// 'foo'
console.log(1) method is called and placed on the call stack and being executed.
SetTimeout is being executed, the console.log(‘foo’) is moved to SetTimeout Web Api, and 0 milliseconds afterward it moves to Macro-Task Queue.
Promise.resolve() is being called, it is being resolved and then .then() method is moved to Micro-Task queue.
console.log(2) method is called and placed on the call stack and being executed.
Event Loop sees that the call-stack is empty, it takes firstly the task from Micro-Task queue which is the Promise task, puts the console.log(‘boo’) on the call-stack and executes it.
Event Loop sees that the call-stack is empty, then it sees that the Micro-Task is empty, then it takes the next task from the Macro-Task queue which is the SetTimeout task, puts the console.log(‘foo’) on the call-stack and executes it.
Visualization of the process for Example 5
Advanced understanding of the Event Loop
I was thinking of writing about the low level of how the Event Loop mechanism works, it could be a post in itself, so I decided to bring an introduction to the topic and attach good links that explain the topic in depth.
Event Loop lower level Explained
When Node.js starts, it initializes the event loop, processes the provided input script (or drops into the REPL) which may make async API calls, schedule timers, or call process.nextTick(), then begins processing the event loop.
The following diagram shows a simplified overview of the event loop’s order of operations. (Each box will be referred to as a “phase” of the event loop, please check out the introduction image to get a nice understanding of the cycle.)
Simplified overview of the event-loop order of operations
Each phase has a FIFO queue of callbacks to execute (I say it carefully here because there might be another data structure depending on the implementation). 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.
Phases Overview
Timers: this phase executes callbacks scheduled by setTimeout() and setInterval().
Pending callbacks: executes I/O callbacks deferred to the next loop iteration.
Idle, Prepare: only used internally.
Poll: retrieve new I/O events; execute I/O related callbacks (almost all with the exception of close callbacks, the ones scheduled by timers, and setImmediate()); node will block here when appropriate.
Check: setImmediate() callbacks are invoked here.
Close callbacks: some close callbacks, e.g. socket.on('close', ...).
How do the previous steps fit in here?
So the previous steps with only the “Callback Queue” and then with “Macro and Micro Queues” were abstract explanations about how the Event Loop works.
There is another IMPORTANT thing to mention, the event loop should process the micro-task queue entirely, after processing one macro-task from the macro-task queue.
Step 1: The event loop updates the loop time to the current time for the current execution.
Step 2: Micro-Queue is executed.
Step 3: A task from the Timers phase is executed.
Step 4: Checking if there is something in the Micro-Queue and executes the whole Micro-Queue if there is something.
Step 5: Returns to Step 3 until the Timers phase is empty.
Step 6: A task from the Pending Callbacks phase is executed.
Step 7: Checking if there is something in the Micro-Queue and executes the whole Micro-Queue if there is something.
Step 8: Returns to Step 6 until the Pending Callbacks phase is empty.
And then Idle… Micro-Queue … Poll … Micro-Queue … Check … Micro-Queue … Close CallBacks and then it starts over.
So I gave a nice overview of how the Event Loop actually works behind the scenes, there are a lot of missing parts that I didn’t mention here because the actual documentation is doing a great job in explaining it, I will provide great links for the documentation, I encourage you to invest 10–20 minutes and understand them.
Top comments (0)