Deep Dive into the Event Loop: Tại sao JavaScript đơn luồng lại chạy nhanh?
Lời mở đầu: Nghịch lý của JavaScript
Hãy tưởng tượng một nhà hàng chỉ có một đầu bếp (single-threaded), nhưng lại phục vụ được hàng trăm khách hàng đồng thời mà không ai phải chờ đợi quá lâu. Nghe có vẻ không thể? Đó chính là cách JavaScript hoạt động.
Trong khi Java, C++ dùng multi-threading để xử lý nhiều tasks cùng lúc, JavaScript chỉ có một thread duy nhất. Vậy tại sao nó không bị “nghẽn cổ chai” khi xử lý AJAX requests, setTimeout, hay event handlers? Câu trả lời nằm ở Event Loop - một kiến trúc thiên tài mà mọi JavaScript developer cần hiểu.
1. Call Stack: Nơi mọi thứ bắt đầu
Call Stack là gì?
Call Stack (ngăn xếp gọi hàm) là cấu trúc dữ liệu LIFO (Last In, First Out) - cái gì vào sau sẽ ra trước. Mỗi khi bạn gọi một function, nó được “push” vào stack. Khi function kết thúc, nó được “pop” ra.
Ví dụ đơn giản:
function multiply(a, b) {
return a * b;
}
function square(n) {
return multiply(n, n); // Gọi multiply
}
function printSquare(n) {
const result = square(n); // Gọi square
console.log(result);
}
printSquare(5);
Call Stack hoạt động như thế nào?
Step 1: printSquare(5) được gọi
Stack: [printSquare]
Step 2: Trong printSquare, gọi square(5)
Stack: [printSquare, square]
Step 3: Trong square, gọi multiply(5, 5)
Stack: [printSquare, square, multiply]
Step 4: multiply trả về 25 và bị pop
Stack: [printSquare, square]
Step 5: square trả về 25 và bị pop
Stack: [printSquare]
Step 6: console.log(25) chạy xong, printSquare pop
Stack: [] ← Empty!
Vấn đề của Synchronous Code
Nếu tất cả chạy trên Call Stack, một operation chậm sẽ block toàn bộ:
// Blocking code - BAD!
function slowFunction() {
const end = Date.now() + 5000; // Chạy 5 giây
while (Date.now() < end) {
// Busy waiting - Block thread!
}
return "Done";
}
console.log("Start");
slowFunction(); // UI freeze 5 giây!
console.log("End"); // Phải đợi slowFunction xong
Trong 5 giây đó, không thể làm gì khác: click button không respond, animation dừng, user nghĩ browser bị crash. Đây là lý do chúng ta cần async.
2. Web APIs: Lực lượng hỗ trợ bên ngoài
JavaScript runtime (browser hoặc Node.js) cung cấp Web APIs - những công cụ xử lý async:
setTimeout/setIntervalXMLHttpRequest/fetch- DOM Events (
click,scroll,keypress) IndexedDB- Web Workers
Điểm quan trọng: Những APIs này không chạy trên Call Stack. Chúng được browser engine xử lý ở background, không block JavaScript thread chính.
Ví dụ:
console.log("Start");
setTimeout(() => {
console.log("Timeout callback");
}, 2000);
console.log("End");
// Output:
// Start
// End
// (sau 2 giây) Timeout callback
Giải thích:
console.log("Start")chạy → In ra “Start”setTimeoutđược gọi:- Callback
() => {...}được chuyển cho Web API - Browser đặt timer 2 giây ở background
setTimeoutreturn ngay lập tức (không chờ!)
- Callback
console.log("End")chạy → In ra “End”- Sau 2 giây, Web API timer hết → Callback được đưa vào Callback Queue
- Event Loop thấy Call Stack trống → Đưa callback từ queue vào stack
- Callback chạy → In ra “Timeout callback”
3. Event Loop: Chỉ huy giao thông
Event Loop là một vòng lặp vô hạn với một nhiệm vụ duy nhất:
while (true) {
if (callStack.isEmpty()) {
if (microTaskQueue.hasItems()) {
// Ưu tiên Microtasks trước!
runAllMicrotasks();
} else if (macroTaskQueue.hasItems()) {
// Sau đó mới đến Macrotasks
runNextMacrotask();
}
}
}
Quy tắc vàng:
- Call Stack phải trống mới lấy task từ queue
- Microtasks được ưu tiên tuyệt đối trước Macrotasks
- Sau mỗi Macrotask, phải clear hết Microtasks mới chuyển sang Macrotask tiếp theo
4. Macrotasks vs Microtasks: Cuộc chiến ưu tiên
Macrotasks (Task Queue)
Macrotasks là các “task lớn” có độ ưu tiên thấp hơn:
setTimeoutsetIntervalsetImmediate(Node.js)- I/O operations
- UI rendering
Đặc điểm:
- Mỗi lần Event Loop chỉ chạy một Macrotask
- Sau đó phải kiểm tra Microtask Queue
Microtasks (Job Queue)
Microtasks là các “task nhỏ” có độ ưu tiên cực cao:
Promise.then/catch/finallyasync/await(internally uses Promises)queueMicrotask()MutationObserver
Đặc điểm:
- Event Loop phải chạy toàn bộ Microtasks trước khi chuyển sang Macrotask tiếp theo
- Nếu Microtask tạo ra Microtask mới → Cũng phải chạy luôn trong cùng một cycle
Ví dụ so sánh:
console.log('1: Sync');
setTimeout(() => {
console.log('2: setTimeout (Macrotask)');
}, 0);
Promise.resolve().then(() => {
console.log('3: Promise (Microtask)');
});
console.log('4: Sync');
// Output:
// 1: Sync
// 4: Sync
// 3: Promise (Microtask)
// 2: setTimeout (Macrotask)
Phân tích từng bước:
Step 1: Call Stack chạy sync code
- console.log('1: Sync') → Output: "1: Sync"
Step 2: setTimeout callback vào Macrotask Queue
Macrotask Queue: [setTimeout callback]
Step 3: Promise.then callback vào Microtask Queue
Microtask Queue: [Promise callback]
Step 4: console.log('4: Sync') → Output: "4: Sync"
Step 5: Call Stack trống, Event Loop kiểm tra:
- Microtask Queue có items? YES!
- Chạy Promise callback → Output: "3: Promise (Microtask)"
Step 6: Microtask Queue trống, chuyển sang Macrotask:
- Chạy setTimeout callback → Output: "2: setTimeout (Macrotask)"
Tại sao setTimeout(..., 0) không chạy ngay lập tức?
Vì nó là Macrotask, phải đợi tất cả Microtasks chạy xong!
5. Trường hợp phức tạp hơn: Nested callbacks
console.log('Start');
setTimeout(() => {
console.log('Timeout 1');
Promise.resolve().then(() => {
console.log('Promise in Timeout 1');
});
}, 0);
setTimeout(() => {
console.log('Timeout 2');
}, 0);
Promise.resolve().then(() => {
console.log('Promise 1');
setTimeout(() => {
console.log('Timeout in Promise');
}, 0);
});
console.log('End');
// Output:
// Start
// End
// Promise 1
// Timeout 1
// Promise in Timeout 1
// Timeout 2
// Timeout in Promise
Phân tích chi tiết:
Call Stack:
1. console.log('Start') → Output: "Start"
2. setTimeout 1 → Callback vào Macrotask Queue
3. setTimeout 2 → Callback vào Macrotask Queue
4. Promise 1 → Callback vào Microtask Queue
5. console.log('End') → Output: "End"
Macrotask Queue: [Timeout 1, Timeout 2]
Microtask Queue: [Promise 1]
Event Loop Cycle 1:
- Call Stack trống
- Microtask Queue: Chạy Promise 1
→ Output: "Promise 1"
→ setTimeout mới vào Macrotask Queue
- Macrotask Queue: [Timeout 1, Timeout 2, Timeout in Promise]
Event Loop Cycle 2:
- Chạy Timeout 1
→ Output: "Timeout 1"
→ Promise.then vào Microtask Queue
- Microtask Queue: [Promise in Timeout 1]
- Phải clear Microtask trước!
→ Output: "Promise in Timeout 1"
Event Loop Cycle 3:
- Chạy Timeout 2
→ Output: "Timeout 2"
Event Loop Cycle 4:
- Chạy Timeout in Promise
→ Output: "Timeout in Promise"
Key insight: Sau mỗi Macrotask, Event Loop phải kiểm tra và chạy hết Microtasks. Đây là lý do Promise luôn “chen ngang” trước các setTimeout còn lại.
6. Async/Await: Syntactic Sugar của Promises
async function fetchData() {
console.log('1: Start fetching');
const data = await fetch('/api/data');
// await = Promise.then() disguised!
console.log('2: Data received');
return data;
}
console.log('3: Before call');
fetchData();
console.log('4: After call');
// Output:
// 3: Before call
// 1: Start fetching
// 4: After call
// (sau khi fetch xong) 2: Data received
Giải thích:
awaitkhông “block” thread!- Nó chuyển phần code phía sau thành một Microtask callback
- Function return ngay sau khi gặp
await
Tương đương với:
function fetchData() {
console.log('1: Start fetching');
return fetch('/api/data').then(data => {
console.log('2: Data received');
return data;
});
}
7. V8 Engine: Tối ưu hóa ngầm
7.1. JIT Compilation
V8 không chỉ interpret JavaScript. Nó dùng JIT (Just-In-Time) Compiler:
- Ignition (Interpreter): Chuyển JS → Bytecode, chạy nhanh
- TurboFan (Optimizing Compiler): Nếu code chạy nhiều lần (hot code), compile thành native machine code
- Deoptimization: Nếu assumptions sai (type change), quay lại bytecode
Kết quả: Code chạy lâu → Càng nhanh (warm-up effect).
7.2. Hidden Classes & Inline Caching
V8 tạo “hidden classes” cho objects với cùng structure:
// Good: Same shape
const obj1 = { x: 1, y: 2 };
const obj2 = { x: 3, y: 4 };
// V8: Cùng hidden class → Optimize!
// Bad: Different shapes
const obj3 = { x: 1, y: 2 };
const obj4 = { y: 4, x: 3 }; // Different order!
// V8: Phải tạo hidden class mới → Slower
7.3. Garbage Collection
V8 dùng Generational GC:
- Young Generation: Objects mới → Collect thường xuyên (Minor GC - nhanh)
- Old Generation: Objects sống lâu → Collect ít hơn (Major GC - chậm)
Microtasks được ưu tiên cao vì:
- Thường là short-lived callbacks
- Chạy xong nhanh → Giải phóng memory sớm
- Giảm GC pressure
8. Best Practices: Tránh các “bẫy” của Event Loop
8.1. Tránh Blocking the Event Loop
// Bad: Sync heavy computation
function processLargeArray(arr) {
for (let i = 0; i < arr.length; i++) {
// Heavy computation
}
}
// Good: Chunking với setTimeout
function processLargeArrayAsync(arr, chunkSize = 1000) {
let index = 0;
function processChunk() {
const end = Math.min(index + chunkSize, arr.length);
for (; index < end; index++) {
// Process arr[index]
}
if (index < arr.length) {
setTimeout(processChunk, 0); // Yield control
}
}
processChunk();
}
Tại sao? Mỗi setTimeout cho phép Event Loop xử lý các events khác (UI clicks, etc.) giữa các chunks.
8.2. Sử dụng queueMicrotask khi cần Priority
// Schedule as Microtask
queueMicrotask(() => {
console.log('This runs before any setTimeout');
});
setTimeout(() => {
console.log('This runs later');
}, 0);
Use case: Khi bạn cần đảm bảo code chạy ngay sau current execution context nhưng trước bất kỳ I/O nào.
8.3. Hiểu rõ Promise chaining
// Nested Promises - Hard to read
fetchUser()
.then(user => {
return fetchPosts(user.id)
.then(posts => {
return { user, posts };
});
});
// Flat chaining - Better
fetchUser()
.then(user => {
return Promise.all([
Promise.resolve(user),
fetchPosts(user.id)
]);
})
.then(([user, posts]) => {
return { user, posts };
});
// Async/await - Best
async function getUserWithPosts() {
const user = await fetchUser();
const posts = await fetchPosts(user.id);
return { user, posts };
}
8.4. Error handling trong Async code
// Unhandled Promise rejection - Bad!
fetch('/api/data')
.then(response => response.json())
.then(data => {
// Process data
});
// Nếu fetch fail → Silent error!
// Proper error handling
fetch('/api/data')
.then(response => response.json())
.then(data => {
// Process data
})
.catch(error => {
console.error('Failed to fetch:', error);
});
// With async/await
async function fetchData() {
try {
const response = await fetch('/api/data');
const data = await response.json();
return data;
} catch (error) {
console.error('Failed to fetch:', error);
throw error; // Re-throw if needed
}
}
9. Visualizing the Event Loop
Hãy tưởng tượng Event Loop như một máy chủ nhà hàng thông minh:
Bếp (Call Stack):
- Chỉ nấu 1 món tại 1 thời điểm
- Nấu xong → Món ra → Nấu món tiếp theo
Người phục vụ (Web APIs):
- Nhận orders từ khách
- Gửi vào bếp khi cần
- Đồng thời theo dõi nhiều bàn
Quầy ưu tiên (Microtask Queue):
- VIP customers (Promises)
- Phải phục vụ ngay khi bếp rảnh
Quầy thường (Macrotask Queue):
- Regular customers (setTimeout)
- Chờ đến lượt sau khi VIPs xong
10. Debugging Event Loop Issues
Tool: Chrome DevTools Performance Tab
- Record performance
- Xem “Main Thread” timeline
- Tìm Long Tasks (>50ms) - những thứ block UI
Technique: Logging với microtask timing
console.log('A');
queueMicrotask(() => console.log('B - Micro'));
Promise.resolve().then(() => {
console.log('C - Promise 1');
queueMicrotask(() => console.log('D - Nested Micro'));
});
setTimeout(() => console.log('E - Macro'), 0);
console.log('F');
// Output: A, F, B - Micro, C - Promise 1, D - Nested Micro, E - Macro
Phân tích order để debug priority issues.
11. Real-world Example: Rate Limiting với Queue
class RateLimiter {
constructor(maxPerSecond) {
this.maxPerSecond = maxPerSecond;
this.queue = [];
this.running = 0;
}
async schedule(fn) {
return new Promise((resolve, reject) => {
this.queue.push({ fn, resolve, reject });
this.process();
});
}
async process() {
if (this.running >= this.maxPerSecond) return;
const item = this.queue.shift();
if (!item) return;
this.running++;
try {
const result = await item.fn();
item.resolve(result);
} catch (error) {
item.reject(error);
} finally {
this.running--;
// Schedule next item as Microtask
queueMicrotask(() => this.process());
}
}
}
// Usage
const limiter = new RateLimiter(5);
for (let i = 0; i < 100; i++) {
limiter.schedule(() => fetch(`/api/data/${i}`));
}
Tại sao dùng queueMicrotask?
Để đảm bảo process() chạy ngay sau khi item hiện tại hoàn thành, không bị delay bởi setTimeout.
12. Kết luận: Event Loop là trái tim của JavaScript
Key Takeaways:
✅ JavaScript = Single-threaded, nhưng non-blocking nhờ Event Loop
✅ Call Stack xử lý sync code, Web APIs xử lý async
✅ Microtasks > Macrotasks - Priority tuyệt đối
✅ Promise.then chạy trước setTimeout, dù setTimeout có delay 0ms
✅ async/await là Promise disguised - không block thread
✅ V8 Engine optimize code qua JIT compilation
✅ Hiểu Event Loop → Tránh performance bottlenecks
✅ Debug bằng Performance Tab và logging
Câu hỏi tự kiểm tra:
- Tại sao
setTimeout(() => {}, 0)không chạy ngay lập tức? - Điều gì xảy ra nếu Microtask tạo ra Microtask mới?
async/awaitkhác gì với Promise.then()?- Call Stack, Callback Queue, Event Loop - mối quan hệ ra sao?
- Làm thế nào để tránh block Event Loop với heavy computation?
Nếu trả lời được tất cả, bạn đã master Event Loop! 🚀
Resources để đào sâu hơn:
📚 What the heck is the event loop anyway? - Philip Roberts (JSConf) - Must watch!
🛠️ Loupe - Event Loop Visualizer
📖 Jake Archibald - Tasks, microtasks, queues and schedules
🔬 V8 Engine Deep Dive
Hiểu Event Loop không chỉ giúp bạn code tốt hơn, mà còn giúp bạn debug nhanh hơn và tối ưu performance hiệu quả hơn!
#JavaScript #EventLoop #Async #Promises #V8Engine #Performance