In the previous article, we learned that JavaScript/Node.js has only one main thread to execute JavaScript code. We also understood the difference between synchronous and asynchronous I/O tasks. JavaScript/Node.js smartly adopts an asynchronous handling mechanism to avoid blocking the call stack. In today's article, we will explore how Node.js handles asynchronous operations.
First, let’s get acquainted with a new component: Libuv.
Let’s revisit the example from the previous section.
fs.readFile(file.pdf)
.then(pdf => console.log("pdf size", pdf.size));
fs.readFile(file.doc)
.then(doc => console.log("doc size", doc.size));
The order in which functions are pushed onto the call stack is as follows.
+------------------------------+
| |
| fs.readFile(file.pdf) |
+------------------------------+
| Call stack |
|------------------------------|
|
v
+------------------------------+
| |
| fs.readFile(file.doc) |
+------------------------------+
| Call stack |
|------------------------------|
Since the results of asynchronous functions are not returned immediately, the call stack executes the two commands above very quickly. So, where and how are the results of these two functions handled? In other words, where does the code inside then
go?
Libuv is where asynchronous I/O is handled. Libuv is an external library chosen by Node.js to handle I/O. JavaScript in browsers does not use Libuv but implements something called Web APIs.
When the call stack encounters asynchronous functions, it immediately offloads them to libuv or Web APIs, so it barely does any processing here. Once the asynchronous function has a result, libuv attaches the result to a callback
function, as well as the function inside then
(note that the callback or then
is always a function with a parameter, which is the result returned by libuv), and pushes it into a place called the Callback Queue.
So, what happens to the Callback Queue after libuv has the result?
The Event Loop is an essential component of JavaScript/Node.js. It is an infinite loop that never stops while the program is running. The Event Loop's job is to bring callback
functions from the Callback Queue back to the call stack.
The Event Loop always monitors the call stack because it only starts transferring callback
functions from the Callback Queue to the call stack when the call stack is empty. This is also the reason why the example in the first section prints World
before Hello
.
setTimeout(function() {
console.log("Hello");
}, 0);
console.log("World");
setTimeout
with a value of 0
has almost no delay, so why is Hello
not printed before World
? It’s simple: the callback
function inside setTimeout
was pushed out of the call stack to libuv or Web APIs before the Event Loop could bring it back to the call stack.
The Event Loop has only one job: to bring callback
functions from the Callback Queue back to the call stack. It does not directly execute JavaScript code. Code is only executed when it is placed in the call stack. If the call stack is not empty, the functions in the Callback Queue are never brought back to the call stack. This is why it is often said never to block the Event Loop. If blocked, the program becomes sluggish because requests will never have their results returned to the user.
Thanks to the presence of critical components like libuv, Web APIs, the Callback Queue, and the Event Loop, Node.js can process the results of asynchronous functions. This process can be summarized in the following diagram.
+------------------------------+
| Call Stack |
|----------------------------- |
| Encounter an asynchronous |
| function |
| |
| |
| -> Push to libuv/Web APIs |
+------------------------------+
|
v
+------------------------------+
| libuv/Web APIs |
|----------------------------- |
| Process I/O |
| Once done, push callback |
| into Callback Queue |
+------------------------------+
|
v
+------------------------------+
| Callback Queue |
|----------------------------- |
| [I/O result and callback] |
| |
+------------------------------+
|
v
+------------------------------+
| Event Loop |
|----------------------------- |
| Check: Is Call Stack empty? |
| If empty, push callback from |
| Callback Queue to Call Stack |
+------------------------------+
|
v
+------------------------------+
| Call Stack |
|----------------------------- |
| Execute callback function |
+------------------------------+
With its single-threaded architecture, Node.js has chosen an asynchronous handling mechanism to optimize performance and prevent the call stack from being blocked by I/O tasks. This mechanism relies on the collaboration of critical components: libuv (a library for asynchronous I/O handling) or Web APIs, the Callback Queue (a queue containing callback functions after I/O completion), and the Event Loop (a loop responsible for bringing callbacks back to the call stack when it is empty). When an asynchronous task is called, Node.js offloads it to libuv or Web APIs for processing. After completion, the result and callback function are placed into the Callback Queue, waiting for the Event Loop to push them onto the call stack for execution. Thanks to this seamless operation, Node.js achieves efficient handling of asynchronous tasks without interrupting the main thread.
In summary, the strength of Node.js in handling asynchronous operations lies in the combination of libuv, the Callback Queue, and the Event Loop. This allows Node.js to maintain high performance but also requires developers to be cautious not to block the Event Loop, which could hinder program optimization. Understanding this mechanism enables us to leverage Node.js more effectively, especially when working with complex I/O tasks.
In the next article, let’s delve deeper into the Event Loop.
The secret stack of Blog
As a developer, are you curious about the technology secrets or the technical debts of this blog? All secrets will be revealed in the article below. What are you waiting for, click now!
Subscribe to receive new article notifications
Comments (0)