Prerequisite
A basic understanding of JavaScript programming concepts, including functions, callbacks, and event-driven programming. Familiarity with asynchronous programming concepts would also be beneficial.
Additionally, readers should have a general understanding of how I/O operations work in computer systems and their significance in application development.
What is Blocking I/O?
Blocking I/O refers to a synchronous operation where the execution of code is paused until the I/O operation completes.
During this time, the entire program is blocked from executing further instructions, leading to potential performance bottlenecks and reduced responsiveness.
Example: Blocking File Read Operation(Create a txt file in the same directory and add some random content in it.)
const fs = require('fs');
// Blocking file read operation
const now = Date.now();
const data = fs.readFileSync('path/to/file');
console.log('Completed after : ', (Date.now() - now), ' milliseconds'); // io execution duration
console.log('Data:', data.toString());
console.log('Continuing other execution...');
Looking at the above code, the program will halt execution at the `readFileSync` function call until the file read operation is complete. This can cause delays and impact the overall performance of the application.
Since the io operation depends so many factors outside your control, like ; system specification, file location and so on, the more negatively impactful this factors are, the longer it will take to complete.
At the time of writing, I'm on Apple M1 Pro, and the line console.log('Completed after : ', (Date.now() - now), ' milliseconds'); // io execution duration
give me 1 millisecond
.
What is Non-blocking I/O?
Non-blocking I/O, on the other hand, allows the program to continue executing other tasks while waiting for I/O operations to complete. This asynchronous approach improves responsiveness and scalability by avoiding unnecessary pauses in execution.
Example: Non-blocking File Read Operation
const fs = require('fs');
// Blocking file read operation
const now = Date.now();
fs.readFile('./play.json', (err, data) => {
console.log('Completed after : ', (Date.now() - now), ' milliseconds'); // io execution duration
if (err) {
console.error('Error:', err);
return;
}
console.log('Data:', data.toString());
});
// Non-blocking file read operation
console.log('Continuing execution...');
Breakdown: the `readFile` function initiates the file read operation asynchronously, allowing the program to continue executing without waiting for the operation to finish.
At the time of writing, I'm on Apple M1 Pro, and the line console.log('Completed after : ', (Date.now() - now), ' milliseconds'); // io execution duration
give me 10 milliseconds
.
Isn't it supposed to be faster 🤔? Well there is a simple reasoning behind this, which is covered below.
Note that in the non-blocking version, it is up to the author to decide whether an error should throw as shownComparison of Blocking and Non-blocking I/O
Blocking I/O can lead to decreased performance and scalability, especially in applications with high concurrency requirements. On the other hand, non-blocking I/O allows for better resource utilization and improved responsiveness, making it ideal for handling multiple concurrent requests efficiently.
Now let revist the question above: `How is my non-blocking code 10 times slower that its blocking counterpart? `
In Node.js, blocking file read operations are sometimes perceived as faster than their non-blocking counterparts due to how they are executed and perceived by developers, but this perception can be misleading. Here's why:
1. Synchronous Nature: Blocking file read operations (synchronous) execute sequentially and block the execution of the entire program until the operation is complete.
This can give the impression of speed because subsequent code won't execute until the file read is finished, so there's no need to handle callback functions or promises.
However, during this time, the program is essentially idle and not doing anything else.
2. Event Loop Overhead: Non-blocking file read operations (asynchronous) utilize Node.js's event loop and do not block the execution of other code.
While this can lead to perceived slowness if you're not used to handling asynchronous code, it allows Node.js to handle multiple requests concurrently, leading to better overall performance and scalability, especially in applications handling multiple I/O operations.
3. Concurrency vs. Parallelism: Blocking operations are synchronous and executed in sequence, while non-blocking operations are asynchronous and can be executed concurrently.
While a blocking operation may complete quickly, it can't handle multiple operations simultaneously. In contrast, non-blocking operations allow nodeJS to efficiently manage I/O operations by not waiting for one operation to complete before starting another.
4. Perception vs. Reality: While synchronous operations may appear faster based on our examples above, they can lead to poor performance and scalability in applications with high concurrency requirements.
Asynchronous operations, despite their initial complexity, provide better performance and responsiveness, especially in applications with heavy I/O operations or real-time requirements.
So basically, the more i/o operations your app needs to handle, the longer it will take to complete your code execution in synchronous or blocking mode. Hope this clarifies the confusion if any😉.
Concurrency and Throughput
Concurrency in Node.js refers to the ability to handle multiple tasks simultaneously, which is crucial for optimizing I/O operations. By leveraging non-blocking I/O and asynchronous programming, developers can improve the throughput of their applications and handle concurrent requests more efficiently.
So efficient handling of I/O operations, such as database queries, is crucial for maximizing concurrency and throughput in web server applications.
Consider a scenario where each incoming request to a web server takes approximately 50ms to complete. Out of this 50ms, 45ms is spent on database I/O, which can be performed asynchronously.
For Example, let's setup some concurrent file read operations.
const fs = require('fs');
// Perform multiple file reads concurrently
fs.readFile('path/to/file1', (err, data1) => {
if (err) {
console.error('Error reading file1:', err);
return;
}
console.log('File1 Data:', data1.toString());
});
fs.readFile('path/to/file2', (err, data2) => {
if (err) {
console.error('Error reading file2:', err);
return;
}
console.log('File2 Data:', data2.toString());
});
// Continue with other tasks while file reads are in progress
console.log('Continuing execution...');
Dangers of Mixing Blocking and Non-blocking Code
There are some patterns that should be avoided at all cost, when dealing with I/O. Here is an example:
const fs = require('node:fs');
fs.readFile('path/to/file', (err, data) => {
if (err) throw err;
console.log(data);
});
fs.unlinkSync('path/to/file');
You can't guarantee that fs.readFile
will complete first before fs.unlinkSync
is executed which is the intend in this code
One of the key dangers of mixing blocking and non-blocking code in Node.js is the potential for `Zalgo`.
Zalgo, named after a fictional demonic entity, refers to the unpredictable behavior that can arise when asynchronous and synchronous code execution paths interact unexpectedly.
You can read more on don't releasing zalgo if you're interested.
Let's consider an example:
const fs = require('fs');
function readFileAsync(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, (err, data) => {
if (err) {
reject(err);
return;
}
resolve(data);
});
});
}
// Mixing blocking and non-blocking code
function mixedOperation() {
let data;
readFileAsync('./play.json')
.then((result) => {
data = result;
});
console.log('Data:', data); // Potential for Zalgo : Output => Data: undefined
}
mixedOperation();
Breakdown: In our example, the `mixedOperation` function combines non-blocking asynchronous code (using Promises) with synchronous code (the console.log
statement).
Due to the asynchronous nature of `readFileAsync`, the console.log
statement may execute before the data
variable is assigned, leading to unpredictable behavior and potential bugs. This phenomenon is what is termed Zalgo.
Best Practices for Handling I/O Operations
To optimize I/O operations in Node.js, developers should adhere to the following best practices:
- 1.Prioritize non-blocking I/O operations to maximize concurrency and throughput.
- 2.Use asynchronous programming patterns such as callbacks, Promises, or async/await to handle I/O operations efficiently.
- 3.Avoid mixing blocking and non-blocking code to maintain consistency and predictability in application behavior.
- 4.Monitor and profile I/O-intensive operations to identify bottlenecks and optimize performance.
Conclusion
Optimizing I/O operations in Node.js is essential for building high-performance and scalable applications.
By understanding the principles of concurrency, avoiding mixing blocking and non-blocking code, and following best practices for handling I/O operations, you can enhance the responsiveness and efficiency of their nodeJS applications.
Chapter 1 , Part 1 : Introduction to NodeJS
In this series part, I introduce nodeJS and some technical concepts associated with it. I also show how easy it is to setup and start a simple nodeJS web server.
Chapter 1 , Part 2 : How to Install and Setup NodeJS
In this series part, I run you through the various ways to install nodeJS. I also discuss how to install nvm and use it to switch between different node versions.
Chapter 1 , Part 3 : How much JavaScript do you need to learn NodeJS
In this series part, we explore the nuanced relationship between JavaScript and NodeJS, highlighting some subtle distinctions between the two environments.
Chapter 1 , Part 4 : The v8 Engine and the difference Between NodeJS and the browser
In this series part, we explore the V8 engine and how it interacts with nodeJS. We also discuss node’s event loop and uncover the mystery behinds node’s ability to handle concurrent operations.
Chapter 1 , Part 5 : NPM, the NodeJS package manager
Discover the essentials of npm, the powerful package manager for Node.js. Learn installation, management, publishing, and best practices
Chapter 1 , Part 6 : NodeJS in Development Vs Production
Explore how Node.js behaves differently in development and production environments. Learn key considerations for deploying Node.js applications effectively.
Chapter 2 , Part 1 : Asynchronous Flow Control
In this series part, we'll explore various aspects of asynchronous flow control in Node.js, from basic concepts to advanced techniques.
Chapter 2 , Part 2 : Blocking vs Non-blocking I/O
Explore the differences between blocking and non-blocking I/O in Node.js, and learn how to optimize performance and scalability.
Chapter 2 , Part 3 : Understanding NodeJS Event loop
Exploring the Node.js event loop by understanding its phases, kernel integration, and processes enabling seamless handling of asynchronous operations in your applications.
Chapter 2 , Part 4 : The NodeJS EventEmitter
Explore the power of Node.js EventEmitter: an essential tool for building scalable and event-driven applications. Learn how to utilize it effectively!
Chapter 3 , Part 1 : Working with files in NodeJS
Gain comprehensive insights into file management in Node.js, covering file stats, paths, and descriptors, to streamline and enhance file operations in your applications.
Chapter 3 , Part 2 : Reading and Writing Files in NodeJS
Uncover the fundamentals of reading and writing files in nodeJS with comprehensive examples and use cases for some widely used methods.
Chapter 3 , Part 3 : Working with Folders in NodeJS
Unlock the secrets of folder manipulation in Node.js! Explore essential techniques and methods for working with directories efficiently
Chapter 4 , Part 1 : Running NodeJS Scripts
Master the command line interface for executing nodeJS scripts efficiently. Learn common options and best practices for seamless script execution
Chapter 4 , Part 2 : Reading Environment Variables in NodeJS
Learn how to efficiently manage environment variables in nodeJS applications. Explore various methods and best practices for security and portability
Chapter 4 , Part 3 : Writing Outputs to the Command Line in NodeJS
Learn essential techniques for writing outputs in nodeJS CLI. From basic logging to formatting and understanding stdout/stderr.
Chapter 4 , Part 4 : Reading Inputs from the Command Line in NodeJS
Learn the various ways and strategies to efficiently read command line inputs in nodeJS, making your program more interactive and flexible.
Chapter 4 , Part 5 : The NodeJS Read, Evaluate, Print, and Loop (REPL)
Explore the power of nodeJS's Read, Evaluate, Print, and Loop (REPL). Learn how to use this interactive environment for rapid prototyping, debugging, and experimentation.
Chapter 5 , Part 1 : Introduction to Testing in NodeJS
Discover the fundamentals of testing in nodeJS! Learn about testing types, frameworks, and best practices for building reliable applications.
Chapter 5 , Part 2 : Debugging Tools and Techniques in NodeJS
Explore essential debugging tools and techniques in Node.js development. From built-in options to advanced strategies, and best practices for effective debugging.
Chapter 6 , Part 1 : Project Planning and Setup
Discuss the planning and design process for building our interactive file explorer in Node.js, focusing on core features, UI/UX design, and implementation approach and initial setup.
Chapter 6 , Part 2 : Implementing Basic functionalities
In this guide, we'll implement the basic functionalities of our app which will cover initial welcome and action prompts.
Chapter 6 , Part 3 : Implementating Core Features and Conclusion
In this guide, we'll complete the rest of the more advanced functionalities of our app including, create, search, sort, delete, rename and navigate file directories.