While writing NodeJS it is important to know how the event loops work. We can handle lots of things with unconscious usage of await keywords and nested callbacks but it may cause performance issues.
Let's start with basic steps and try to understand what is happening.
Simple Synchronous Functions
function first() {
return "first";
}
function second() {
return "second";
}
function third() {
return "third";
}
console.time("execTimeFirst");
console.log(first());
console.timeEnd("execTimeFirst");
console.time("execTimeSecond");
console.log(second());
console.timeEnd("execTimeSecond");
console.time("execTimeThird");
console.log(third());
console.timeEnd("execTimeThird");
/*
first
execTimeFirst: 7.137ms
second
execTimeSecond: 0.333ms
third
execTimeThird: 0.385ms
*/
Everything works as expected and within the given order.
Now let's add some synchronous read operation and watch the input. We are reading from the file which contains 308,352 lines of lorem ipsum text.
Simple Synchronous Functions with Synchronous Read
const fs = require("fs");
function first() {
return "first";
}
function second() {
return "second";
}
function third() {
return "third";
}
// Synchronous read operation on step four
function fourth() {
fs.readFileSync("longNote.txt", "utf8");
return "fourth";
}
function fifth() {
return "fifth";
}
console.time("execTimeFirst");
console.log(first());
console.timeEnd("execTimeFirst");
console.time("execTimeSecond");
console.log(second());
console.timeEnd("execTimeSecond");
console.time("execTimeThird");
console.log(third());
console.timeEnd("execTimeThird");
console.time("execTimeFourth");
console.log(fourth());
console.timeEnd("execTimeFourth");
console.time("execTimeFifth");
console.log(fifth());
console.timeEnd("execTimeFifth");
/*
first
execTimeFirst: 6.228ms
second
execTimeSecond: 0.363ms
third
execTimeThird: 0.407ms
fourth
execTimeFourth: 123.141ms
fifth
execTimeFifth: 0.414ms
*/
As seen, in step 4, the code waited for 123.141 ms. This read operation blocked the system because of its synchronous structure.
At this point we should think about the situation of the fifth function, it doesn't need to wait for the previous read operation which is irrelevant.
Simply we change readFileSync to readFile.
Simple Synchronous Functions with Asynchronous Read
const fs = require("fs");
function first() {
return "first";
}
function second() {
return "second";
}
function third() {
return "third";
}
// Asynchronous read operation on step four
function fourth() {
fs.readFile("longNote.txt", "utf8", () =>
console.log("Reading a file completed")
);
return "fourth";
}
function fifth() {
return "fifth";
}
console.time("execTimeFirst");
console.log(first());
console.timeEnd("execTimeFirst");
console.time("execTimeSecond");
console.log(second());
console.timeEnd("execTimeSecond");
console.time("execTimeThird");
console.log(third());
console.timeEnd("execTimeThird");
console.time("execTimeFourth");
console.log(fourth());
console.timeEnd("execTimeFourth");
console.time("execTimeFifth");
console.log(fifth());
console.timeEnd("execTimeFifth");
/*
first
execTimeFirst: 5.889ms
second
execTimeSecond: 0.413ms
third
execTimeThird: 0.523ms
fourth
execTimeFourth: 1.738ms
fifth
execTimeFifth: 0.393ms
Reading a file completed
*/
readFile is an async operation that is not blocking our execution and here we should notice that the order not changed but added the Reading a file completed line below.
The question is how javascript handles asynchronous operations under a single-threaded environment.
With that question, we can deep diver into events but before diving that we should focus on the behavior of functions.
Synchronous Functions and Asynchronous Functions together
const fs = require("fs");
function first() {
return "first";
}
function second() {
return "second";
}
function third() {
return "third";
}
// Asynchronous function
async function fourth() {
return "fourth";
}
function fifth() {
return "fifth";
}
console.time("execTimeFirst");
console.log(first());
console.timeEnd("execTimeFirst");
console.time("execTimeSecond");
console.log(second());
console.timeEnd("execTimeSecond");
console.time("execTimeThird");
console.log(third());
console.timeEnd("execTimeThird");
console.time("execTimeFourth");
console.log(fourth());
console.timeEnd("execTimeFourth");
console.time("execTimeFifth");
console.log(fifth());
console.timeEnd("execTimeFifth");
/*
first
execTimeFirst: 5.374ms
second
execTimeSecond: 0.2ms
third
execTimeThird: 0.201ms
Promise { 'fourth' }
execTimeFourth: 2.482ms
fifth
execTimeFifth: 0.233ms
*/
Changing the function type from sync function to async function changes the type of return value String to Promise. Without changing the whole code let's change only the fourth function by async reading file.
// Asynchronous function
async function fourth() {
fs.readFile("longNote.txt", "utf8", () =>
console.log("Reading a file completed")
);
return "fourth";
}
/*
first
execTimeFirst: 5.463ms
second
execTimeSecond: 0.49ms
third
execTimeThird: 0.355ms
Promise { 'fourth' }
execTimeFourth: 4.313ms
fifth
execTimeFifth: 0.372ms
Reading a file completed
*/
From the 3rd section code, we are in the same position with the extra Promise { 'fourth' } part.
What if we add await keyword for reading the file? (We can only use await in async functions)
We can await promises so we should import fs differently.
// const fs = require("fs");
const { promises: fs } = require("fs");
// Asynchronous function
await fs
.readFile("longNote.txt", "utf8")
.then((rp) => console.log("Reading a file completed"));
return "fourth";
}
/*
first
execTimeFirst: 6.275ms
second
execTimeSecond: 0.488ms
third
execTimeThird: 0.495ms
Promise { <pending> }
execTimeFourth: 3.352ms
fifth
execTimeFifth: 0.458ms
Reading a file completed
*/
I hope you realized that Promise { 'fourth' } changed to Promise { < pending > } but execution time not blocked with await keyword.
Let's take the whole execution code into an async code block and await fourth
const { promises: fs } = require("fs");
function first() {
return "first";
}
function second() {
return "second";
}
function third() {
return "third";
}
// Asynchronous function
async function fourth() {
await fs
.readFile("longNote.txt", "utf8")
.then((rp) => console.log("Reading a file completed"));
return "fourth";
}
function fifth() {
return "fifth";
}
(async () => {
console.time("execTimeFirst");
console.log(first());
console.timeEnd("execTimeFirst");
console.time("execTimeSecond");
console.log(second());
console.timeEnd("execTimeSecond");
console.time("execTimeThird");
console.log(third());
console.timeEnd("execTimeThird");
console.time("execTimeFourth");
const result= await fourth();
console.log(result);
console.timeEnd("execTimeFourth");
console.time("execTimeFifth");
console.log(fifth());
console.timeEnd("execTimeFifth");
})();
/*
first
execTimeFirst: 6.848ms
second
execTimeSecond: 0.342ms
third
execTimeThird: 0.467ms
Reading a file completed
fourth
execTimeFourth: 502.961ms
fifth
execTimeFifth: 0.394ms
*/
Here we have waited for the response of the 4th function and finally, we blocked the execution as we did at readFileSync.
Now we can focus, behind the scenes.
Event Loop in NodeJS
In this part, I have used materials from Jonas Schmedthmann's course.
Inspired Philip Roberts: What the heck is the event loop anyway?
Nodejs is single-threaded but in the event loop, heavy tasks have been sending to Libuv's thread pool which handles heavy tasks. (We can change the thread pool size with proccess.env.UV_THREAD_POOLSIZE=4)
The libuv library maintains a pool of threads that are used by node.js to perform long-running operations in the background, without blocking its main thread. ( source )
So let's visualize our latest code block Simple Synchronous Functions with Asynchronous Read
As seen above, heavy tasks (I/O, Crypto, Network Requests, etc.) works after top-level code.
The thread pool does not close until every task finishes and continuously checks.
For a now it explains general logic and needs to be practiced by the readers.
I will continue writing here...