Node.js Visualized: Exploring Blocking, Non-blocking and Async
Let's learn the difference between blocking, non-blocking, and async in Node.
While working with Node.js, you might have read about the terms blocking, non-blocking, and async.
These are part of the core foundational concepts of Node.js and refer to how Node.js, in practice, performs actions without blocking the main thread at scale.
In this article, we will start by exploring the terms blocking, non-blocking, and async, and later how they differentiate in Node.js.
As Node.js main process is single-threaded, these concepts are very important to understand I/O and concurrent operations.
Blocking I/O
In traditional I/O blocking programming, the I/O request will block the main thread execution until that request is completed.
In Node.js, Blocking refers to when JavaScript code can't be executed by the main process until a non-JS operation is completed.
It is important to understand here that the term blocking in Node refers to the execution of non-JS operations that will prevent JS code execution
Some blocking methods in Node.js are
Synchronous Libuv methods
Library’s methods, starting with sync
syncReadFile()
const fs = require('node:fs');
const data = fs.readFileSync('/file.md');
readFileSync() is a blocking method.
A CPU-intensive task can block the main thread too, but in Node.js terms, that is not referred to as "blocking." But that does indeed "block" the main thread
What happens when the main thread is blocked?
The JavaScript code inside the app can’t be executed. The JS code can be anything from async operations to timeouts, loops, etc.
As a result, the execution will come to a halt unless the sync operation is completed and unblocks the main process.
What operations can be done with the blocked main process?
Although the main thread is blocked, the OS will continue to perform its actions, such as queuing incoming calls, until the main thread is blocked, as visualized below.
Non-Blocking I/O
The concept of non-blocking I/O is independent of Node.js, and we will discuss it generally and from a Node.js perspective.
Concept
Non-blocking I/O is simply an I/O operation that doesn’t block the main thread and waits for the I/O operation to complete.
Non-blocking refers to the behaviour of system calls that return immediately without waiting for the operation to complete. If the requested action cannot be completed immediately, the call will return an error or another indicator, rather than blocking the execution. The next operation in the call stack will execute.
It doesn’t block the main thread while waiting for I/O calls.
In traditional non-blocking systems, the caller usually polls to check the status of the non-blocking operation.
This concept is the inception of the event loop and event-driven architecture and supports concurrency, callbacks, and promises in a single thread.
Modern operating systems provide the underlying support required to build non-blocking operations. To make a system, implement the non-blocking approach; usually, system- and OS-level methods and APIs, such as file descriptors, epoll, etc., are used. Its implementation will be discussed in another post.
Approaches toward building non-blocking I/O
In traditional systems, a new thread is spawned when one is blocked and performs actions for other users. However, this approach is not an optimized solution.
Creating new threads each time is expensive as it consumes memory and is difficult to manage due to race conditions, sharing memory, etc.
It’s not scalable, and as the number of concurrent users increases, the server can’t handle enough requests at a time.
So creating multiple threads for multiple users is not scalable.
The solution here is to allow concurrency on a single thread.
The OS provides an underlying architecture that implements an event loop to allow thousands of concurrent connections on a single thread. I will discuss this in-depth in another post in the upcoming weeks.
But now here arise two questions:
1. Aren’t child threads created in Node.js using child processes?
Node.js supports creating child threads, which are usually created when CPU-intensive tasks can’t be performed without blocking the main thread. To overcome this, child threads are initiated.
2. Node.js itself has a worker pool with four threads
It’s true. But those are entirely managed by the Libuv Library, which manages some blocking I/O operations away from the main thread and ensures the main process is always available to execute the app’s code.
Non-blocking in Node.js
"Non-blocking" refers to the behaviour of certain operations (typically I/O) that do not stop the execution of JavaScript code while waiting for something to complete.
Node.js follows a non-blocking I/O model powered by Libuv and Event Loop to enable this behaviour in Node.js
As opposed to blocking, non-blocking refers to the behaviour of such an operation which doesn’t block the main thread or execution of JavaScript.
Non-blocking usually refers specifically to I/O operations that do not block execution.
Although non-blocking is a language-agnostic phenomenon, a few built-in APIs, such as fs.readFile(), http.get()
are called non-blocking APIs.
How does Node.js handle non-blocking I/O?
The Node.js Libuv engine manages non-blocking I/O operations by offloading them to workerpool threads.
When the worker pool thread is done, it will notify the main event loop and register its callback function.
It is important to understand that the worker pool thread, unblocking the main thread, will be blocked until it finishes the I/O.
const fs = require('fs');
fs.readFile('/path/to/file', (err, data) => {
if (err) throw err;
console.log(data);
});
console.log('File read operation started');
This example above is one of the most quoted non-blocking APIs in Node.js. The flow of how it gets executed is visualized below.
Async
Async operations enable a program to initiate a task and then move on to other tasks before the first one is completed.
Async tasks are performed in a non-linear and non-sequential manner.
When the async task completes, it notifies the main program using mechanisms like promises or callbacks, but the under-the-hood task queue and event loop do the work.
The non-blocking paradigm enables support for async by building up an event loop that follows an event-driven approach, and the callbacks and promises perform the intended actions.
In Node.js, it’s managed through an event loop, which manages and controls the execution of all asynchronous callbacks and events.
Using both Async and Non-blocking I/O in one operation
In this visual of combining async and non-blocking operations, we visulized the flow. and their relationship.
As soon as readFile is executed, it’s offloaded to the Libuv workerpool thread. So, this is an example of a non-blocking I/O.
In the Node.js world, this is now non-blocking.
The Libuv thread executing readFile() is now blocked for other non-blocking I/O.
The console.log() on the very next line gets executed.
Similarly, both setTimeout() gets executed and are added to the Libuv timer.
While the non-blocking fs.readFile() is in progress, the first timer is reached, and its callback gets executed.
Meanwhile, the fs.readFile() operation is completed, and it notifies the main thread, and it’s callback gets registered to the task queue and later executed in the call stack.
And in the end, the last timer gets executed.
Here, it is evident that Node.js complements both async and non-blocking seamlessly.
How do you differentiate between non-blocking and async?
Both terms are used in Node.js, and they both contribute to making Node.js do I/Os and operations without making threads irresponsive.
Non-blocking specifically describes I/O operations that don’t block the main thread, while asynchronous is a general programming paradigm.
Non-blocking is typically implemented by Node.js runtime to perform I/O but async can be done via callback, promises, etc.
Non-blocking I/O can be implemented without async, like fs.readFile(), but without underlying support for non-blocking, async won’t work.
Non-blocking I/O responds immediately if data is available and status or error if it is not available. In async, it is not expected to respond immediately but performs actions as per callbacks and promises.
Continuous polling checks the status of non-blocking I/O, but that’s not the case when using async. However, Node.js doesn’t follow this approach of polling.
Benefits of using non-blocking & Async
It improves responsiveness as multiple tasks can be handled now.
Improved scalability.
Optimized resource usage and less CPU cycles wasted.
Conclusion
So, we learned the concepts of blocking, non-blocking, and async, with a special preference for non-blocking and async.
Thanks for reading.
Let’s connect on LinkedIn