libuv & async Io | Nodejs
Episode 06 - libuv & Async IO
Hey everyone! Welcome back to the Node.js tutorial series. I hope you remember that in the first episode, we read through the Node.js Wikipedia page.
One of the key points we came across was the statement that "Node.js has an event-driven architecture capable of asynchronous I/O."
So, this episode is focused on explaining that concept!
What we will cover:
- What is a Thread?
- Single-threaded vs Multi-threaded
- Synchronous vs Asynchronous
- JavaScript Engine Internals
- How Synchronous Code is Executed
- How Asynchronous Code is Executed
- libuv - The Superhero
- Event Loop
Let's Start with the Fundamentals of JavaScript
Q: What is a Thread?
A thread is the smallest unit of execution within a process in an operating system.
It represents a single sequence of instructions that can be managed independently by a scheduler.
Multiple threads can exist within a single process, sharing the same memory space but executing independently. This allows for parallel execution of tasks within a program, improving efficiency and responsiveness.
Threads can be either:
- Single-threaded
- Multi-threaded
Single-threaded vs Multi-threaded: ================================== Single-threaded: ---------------- Task 1 → Task 2 → Task 3 → Task 4 (One task at a time) Multi-threaded: --------------- Thread 1: Task 1 → Task 3 Thread 2: Task 2 → Task 4 (Multiple tasks at same time)
Q: What type of threading does JavaScript use?
JavaScript is a synchronous, single-threaded language!
This means there is only one thread in which the JavaScript engine (such as the V8 engine) runs. In JavaScript, code is executed line by line within this single thread.
JavaScript Execution: ===================== Line 1: let a = 10; ← Executes first Line 2: let b = 20; ← Executes after Line 1 Line 3: let c = a + b; ← Executes after Line 2 One line at a time! No parallel execution!
In other languages like C++ or Java, code can be executed across multiple threads. For example, a portion of the code might be executed in one thread, while another part runs simultaneously in a different thread.
However, JavaScript handles this process more straightforwardly—executing code one line after the other in sequence.
So, if you're executing line 2 in JavaScript, it will only run after line 1 has finished executing. This is the essence of synchronous execution: each task is performed one after the other, without overlap.
Synchronous vs Asynchronous JavaScript
Q: What is a Synchronous System?
In a synchronous system, tasks are completed one after another.
Think of this as if you have just one hand to accomplish 10 tasks. So, you have to complete one task at a time.
Synchronous System (Restaurant Example):
========================================
Order 1: Burger (5 min)
↓ Wait...
Order 2: Pizza (10 min)
↓ Wait...
Order 3: Ice Cream (3 min)
↓ Wait...
Order 4: Coke (1 min)
Total Time: 5 + 10 + 3 + 1 = 19 minutes!
Each order waits for the previous one to complete!
Here, an order can only be fulfilled once the previous order is fulfilled.
Q: What is an Asynchronous System?
In this system, tasks are completed independently!
Here, imagine that for 10 tasks, you have 10 hands. So, each hand can do each task independently and at the same time.
Asynchronous System (Restaurant Example): ========================================= Order 1: Burger ──────→ Ready in 5 min Order 2: Pizza ───────→ Ready in 10 min Order 3: Ice Cream ───→ Ready in 3 min Order 4: Coke ────────→ Ready in 1 min All orders being prepared SIMULTANEOUSLY! - Coke ready at 1 min - Ice Cream ready at 3 min - Burger ready at 5 min - Pizza ready at 10 min Total Time: 10 minutes (not 19!) No one has to wait for any other order!
So, JavaScript itself is synchronous, but with the power of Node.js, it can handle asynchronous operations, allowing JavaScript to perform multiple tasks concurrently!
Q: What are the portions inside the JS engine and how is synchronous code executed by JS Engine?
The JavaScript engine operates with a single call stack, and all the code you write is executed within this call stack. The engine runs on a single thread, meaning it can only perform one operation at a time.
JavaScript Engine Components: ============================= ┌─────────────────────────────────────┐ │ JavaScript Engine │ │ │ │ ┌─────────────┐ ┌─────────────┐ │ │ │ │ │ │ │ │ │ Call Stack │ │ Memory Heap │ │ │ │ │ │ │ │ │ │ (Code │ │ (Variables, │ │ │ │ Execution) │ │ Functions) │ │ │ │ │ │ │ │ │ └─────────────┘ └─────────────┘ │ │ │ │ + Garbage Collector │ │ │ └─────────────────────────────────────┘
In addition to the call stack, the JavaScript engine also includes a memory heap. This memory heap stores all the variables, numbers, and functions that your code uses.
Garbage Collector:
One key feature of the JavaScript V8 engine is its garbage collector. The garbage collector automatically identifies and removes variables that are no longer in use, freeing up memory.
Unlike languages like C++, where developers need to manually allocate and deallocate memory, JavaScript handles this process automatically. This means you don't have to worry about memory management—it's all taken care of by the engine!
How Synchronous Code is Executed - Step by Step
Let's see how code is executed inside the JS engine:
// Example Code:
let a = 10786;
let b = 20987;
function multiplyFn(x, y) {
const result = x * y;
return result;
}
let c = multiplyFn(a, b);
console.log(c);
Step 1: Global Execution Context Creation
As soon as the JavaScript engine begins executing the code, it creates a Global Execution Context (GEC).
This is the main environment where the top-level code is executed. The global execution context is unique and is always the first to be pushed onto the call stack.
Call Stack: =========== ┌─────────────────────┐ │ │ │ Global Execution │ ← Created first! │ Context (GEC) │ │ │ └─────────────────────┘
Step 2: Memory Creation Phase
Before any code is executed, the JavaScript engine enters the memory creation phase. During this phase:
- Variables a and b are declared in memory and initialized to undefined
- The function multiplyFn is also stored in memory, with its value set to the entire function definition
Memory Heap:
============
┌─────────────────────────────┐
│ a: undefined │
│ b: undefined │
│ multiplyFn: function {...} │
│ c: undefined │
└─────────────────────────────┘
Step 3: Code Execution Phase
Once the memory creation phase is complete, the engine moves to the code execution phase:
- Execution of let a = 10786; and let b = 20987; : The variables a and b are now assigned their respective values
- Execution of let c = multiplyFn(a, b); : The function multiplyFn is invoked, creating a new execution context specifically for this function
Memory Heap (Updated):
======================
┌─────────────────────────────┐
│ a: 10786 │ ← Value assigned!
│ b: 20987 │ ← Value assigned!
│ multiplyFn: function {...} │
│ c: undefined │
└─────────────────────────────┘
Step 4: Function Execution Context Creation
When multiplyFn(a, b) is called, the JavaScript engine:
- Creates a new execution context for multiplyFn and pushes it onto the top of the call stack
- In this new context, the parameters x and y are assigned the values of a and b
Call Stack: =========== ┌─────────────────────┐ │ multiplyFn │ ← New context pushed! │ Execution Context │ ├─────────────────────┤ │ Global Execution │ │ Context (GEC) │ └─────────────────────┘
Step 5: Memory Creation and Code Execution Inside multiplyFn
- Inside multiplyFn, the memory creation phase initializes result in memory with undefined
- Execution of const result = x * y; : The multiplication is performed, and result is assigned the value 226215682
- Execution of return result; : The function returns 226215682, and the multiplyFn execution context is popped off the call stack
Inside multiplyFn: ================== x = 10786 y = 20987 result = 10786 * 20987 = 226215682 return 226215682;
Step 6: Resuming Global Execution Context
Back in the global execution context, the returned value from multiplyFn (226215682) is assigned to the variable c.
Memory Heap (Final):
====================
┌─────────────────────────────┐
│ a: 10786 │
│ b: 20987 │
│ multiplyFn: function {...} │
│ c: 226215682 │ ← Result assigned!
└─────────────────────────────┘
Step 7: Code Execution Complete
Once the entire code is executed, the global execution context is also popped out, and the call stack becomes empty.
Call Stack: =========== ┌─────────────────────┐ │ │ │ EMPTY │ ← All done! │ │ └─────────────────────┘
Q: How is Asynchronous Code Executed?
Now comes the interesting part!
The JavaScript engine cannot do this alone; it needs superpowers!
This is where Node.js comes into the picture, giving it the ability to interact with operating system functionalities.
The Problem:
============
JavaScript Engine (V8)
│
│ "I need to read a file!"
│ "I need to make an API call!"
│ "I need to set a timer!"
│
▼
❌ Can't do it alone!
V8 only knows how to execute JavaScript.
It doesn't know how to talk to the OS!
libuv - The Superhero! 🦸
The JS engine gains its superpowers from Node.js. Node.js grants these powers through a library named libuv—our superhero!
The Solution:
=============
JavaScript Engine (V8)
│
│ "I need help!"
│
▼
┌─────────────────────┐
│ │
│ libuv │ ← The Superhero!
│ (Superpowers) │
│ │
└─────────────────────┘
│
│ Communicates with OS
│
▼
┌─────────────────────┐
│ Operating System │
│ (Files, Network, │
│ Timers, etc.) │
└─────────────────────┘
The JS engine cannot directly access OS files, so it calls on libuv. libuv, being very cool and full of superpowers, communicates with the OS, performs all the necessary tasks, and then returns the response to the JS engine.
He offloads the work and does wonders behind the scene!
Asynchronous Code Execution - Step by Step
Let's see how async code is executed:
// Example Code:
let a = 10;
let b = 20;
// API Call (Async)
fetch("https://api.example.com/data")
.then(response => console.log(response)); // Callback A
// Timer (Async)
setTimeout(() => {
console.log("Timer done!"); // Callback B
}, 1000);
// File Operation (Async)
fs.readFile("data.txt", (data) => {
console.log(data); // Callback C
});
let c = multiplyFn(a, b);
console.log(c);
Step 1: Synchronous Code Execution
The variables let a and let b are executed within the GEC (Global Execution Context) during the synchronous phase of the code execution process.
Memory Heap: ============ a: 10 b: 20
Step 2: API Call Encountered
When the code encounters an API call, the V8 engine, while still operating within the GEC, recognizes that it's dealing with an asynchronous operation.
At this point, the V8 engine signals libuv—the superhero of Node.js—to handle this API call.
V8 Engine: "Hey libuv! Handle this API call for me!" libuv: "Got it! I'll register it in my event loop!"
What happens next is that libuv registers this API call, including its associated callback function (Callback A), within its event loop, allowing the V8 engine to continue executing the rest of the code without waiting for the API call to complete.
libuv Event Loop: ================= ┌─────────────────────────────┐ │ Registered Callbacks: │ │ │ │ 1. API Call → Callback A │ │ │ └─────────────────────────────┘ V8 continues executing next line...
Step 3: setTimeout Encountered
Next, when the code encounters a setTimeout function, a similar process occurs.
The V8 engine identifies this as another asynchronous operation and once again notifies libuv.
libuv Event Loop: ================= ┌─────────────────────────────┐ │ Registered Callbacks: │ │ │ │ 1. API Call → Callback A │ │ 2. Timer → Callback B │ ← New! │ │ └─────────────────────────────┘ V8 continues executing next line...
Step 4: File Operation Encountered
Following this, when the code reaches a file operation (like reading or writing a file), the process is similar.
The V8 engine recognizes this as another asynchronous task and alerts libuv.
libuv then registers the file operation and its callback in the event loop.
libuv Event Loop: ================= ┌─────────────────────────────┐ │ Registered Callbacks: │ │ │ │ 1. API Call → Callback A │ │ 2. Timer → Callback B │ │ 3. File Read → Callback C │ ← New! │ │ └─────────────────────────────┘ V8 continues executing next line...
Step 5: Synchronous Function Execution
Next, when the code executes let c = multiplyFn(a, b);, the JavaScript engine creates a new function context for multiplyFn and pushes it onto the call stack.
The function takes two parameters, x and y, and within the function, the engine multiplies these values (a * b) and stores the result in the result variable.
The JavaScript engine handles this operation as part of the synchronous code execution.
Step 6: Function Context Popped
Once the multiplyFn completes its execution and returns the result, the function context is popped off the call stack, and the result is assigned to the variable c.
Step 7: Garbage Collection
Important Concept:
When the function execution context is popped off the call stack, the garbage collector may clear any memory allocated for that context in the memory heap, if it is no longer needed.
Step 8: Global Context Complete
After console.log(c) is executed and the value of c is printed to the console, the global execution context will also eventually be removed from the call stack if the code execution is complete.
With the global context popped off the call stack, the JavaScript engine has finished processing, and the program ends.
Now the call stack becomes empty, the JavaScript engine can relax, as there is no more synchronous code to execute.
Step 9: Event Loop Takes Over
But wait! The async operations are still pending!
libuv's event loop now checks if any callbacks are ready to be executed:
Event Loop Process: =================== 1. Timer (1000ms) completes → Callback B pushed to call stack → "Timer done!" printed → Callback B popped 2. File read completes → Callback C pushed to call stack → File data printed → Callback C popped 3. API call completes → Callback A pushed to call stack → Response printed → Callback A popped
The Complete Picture
Asynchronous Code Execution Flow:
=================================
┌─────────────────┐ ┌─────────────────┐
│ Your Code │ │ │
│ │ │ Call Stack │
│ Sync code │─────→│ (Executes │
│ Async code │ │ sync code) │
│ │ │ │
└─────────────────┘ └────────┬────────┘
│
Async operations
│
▼
┌─────────────────┐
│ │
│ libuv │
│ (Event Loop) │
│ │
└────────┬────────┘
│
Talks to OS
│
▼
┌─────────────────┐
│ Operating │
│ System │
│ (Files, Net, │
│ Timers) │
└────────┬────────┘
│
When complete
│
▼
┌─────────────────┐
│ Callback Queue │
└────────┬────────┘
│
Event loop pushes
callbacks to call stack
│
▼
┌─────────────────┐
│ Call Stack │
│ (Executes │
│ callbacks) │
└─────────────────┘
Quick Recap
| Concept | Description |
|---|---|
| Thread | Smallest unit of execution in a process |
| JavaScript | Synchronous, single-threaded language |
| Synchronous | Tasks completed one after another |
| Asynchronous | Tasks completed independently |
| Call Stack | Where code execution happens |
| Memory Heap | Where variables and functions are stored |
| Garbage Collector | Automatically frees unused memory |
| libuv | Superhero that handles async operations |
| Event Loop | Manages and executes async callbacks |
Interview Questions
Q: Is JavaScript single-threaded or multi-threaded?
"JavaScript is a synchronous, single-threaded language. It has only one call stack where code is executed line by line. However, with Node.js and libuv, it can handle asynchronous operations."
Q: What is the difference between synchronous and asynchronous?
"In synchronous execution, tasks are completed one after another - each task waits for the previous one. In asynchronous execution, tasks are completed independently - they don't wait for each other."
Q: What is libuv and why is it important?
"libuv is a C library that gives Node.js its superpowers. It handles asynchronous I/O operations like file system access, network requests, and timers. The V8 engine cannot do these operations alone, so it delegates them to libuv."
Q: What are the components of JavaScript engine?
"The JavaScript engine has two main components: the Call Stack (where code is executed) and the Memory Heap (where variables and functions are stored). It also has a Garbage Collector that automatically frees unused memory."
Q: How does async code execution work in Node.js?
"When V8 encounters async operations, it delegates them to libuv. libuv registers the operation and its callback in the event loop. V8 continues executing sync code. When the async operation completes, libuv pushes the callback to be executed on the call stack."
Key Points to Remember
- Thread = smallest unit of execution
- JavaScript = synchronous, single-threaded
- Synchronous = one task at a time
- Asynchronous = tasks run independently
- Call Stack = where code executes
- Memory Heap = stores variables/functions
- Garbage Collector = automatic memory cleanup
- GEC = Global Execution Context
- libuv = superhero for async operations
- Event Loop = manages async callbacks
- V8 delegates async work to libuv
- libuv registers callbacks in event loop
What's Next?
Now you understand how libuv and async I/O work in Node.js! In the next episode, we will:
- Deep dive into the Event Loop
- Understand different phases of Event Loop
- Learn about microtasks and macrotasks
Keep coding, keep learning! See you in the next one!
Post a Comment