JavaScript Event Loop: Hiểu đúng để Debug Đúng
JavaScript, Nodejs

JavaScript Event Loop: Hiểu đúng để Debug Đúng
JavaScript thường được giới thiệu bằng một câu rất đơn giản:
JavaScript là single-threaded.
Điều này đúng, nhưng nếu chỉ dừng ở đó thì rất dễ hiểu sai cách JavaScript thực sự chạy.
Nếu JavaScript chỉ có một thread và chỉ thực hiện một công việc tại một thời điểm, tại sao chúng ta có thể:
- gọi API;
- đọc file;
- chạy timer;
- xử lý hàng nghìn request;
- sử dụng
Promise; - chạy nhiều tác vụ asynchronous gần như đồng thời?
Câu trả lời nằm ở Event Loop và cách JavaScript phối hợp giữa Call Stack, Web APIs/Runtime APIs, Task Queue và Microtask Queue.
Hiểu Event Loop không chỉ giúp viết JavaScript tốt hơn.
Nó còn giúp debug những vấn đề rất thực tế trong Node.js:
- API bị chậm;
- Promise chạy sai thứ tự;
setTimeout()không chạy như mong đợi;- CPU-bound task block toàn bộ server;
- request tăng nhưng throughput giảm;
- một đoạn code nhỏ lại khiến Node.js "đứng hình".
1. JavaScript thực sự chạy như thế nào?
Ở mức đơn giản hóa, chúng ta có thể hình dung:
JavaScript Runtime
│
▼
┌───────────┐
│ Call Stack│
└─────┬─────┘
│
▼
┌───────────┐
│ Event Loop│
└─────┬─────┘
│
┌─────────┴─────────┐
▼ ▼
Microtask Queue Task Queue
JavaScript engine thực hiện JavaScript code trên Call Stack.
Khi gặp một asynchronous operation, runtime có thể giao việc đó cho các API bên ngoài JavaScript engine.
Sau khi operation hoàn thành, callback được đưa vào queue phù hợp.
Event Loop chịu trách nhiệm đưa những callback đó trở lại Call Stack khi có thể.
Điểm quan trọng:
Event Loop không làm JavaScript trở thành multi-threaded.
Nó giúp JavaScript phối hợp các công việc asynchronous một cách hiệu quả.
2. Một ví dụ rất đơn giản
Xét đoạn code:
console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
console.log("C");
Kết quả:
A
C
B
Nhiều người mới học JavaScript thường hỏi:
"Tại sao timeout là
0mà B không chạy trước C?"
Bởi vì:
setTimeout(..., 0)
không có nghĩa:
"Chạy callback ngay lập tức."
Nó gần hơn với:
"Đưa callback vào hàng đợi sau khi timer condition được thỏa mãn và runtime có cơ hội xử lý nó."
JavaScript vẫn phải hoàn thành synchronous code trước.
3. Call Stack
Call Stack là nơi JavaScript theo dõi các function đang thực thi.
Ví dụ:
function foo() {
bar();
}
function bar() {
console.log("Hello");
}
foo();
Có thể hình dung:
foo()
↓
bar()
↓
console.log()
Khi console.log() hoàn thành:
bar()
↓
foo()
Sau đó stack trở về trạng thái rỗng.
Đây là lý do một đoạn code synchronous chạy quá lâu có thể block JavaScript.
4. Và đây là vấn đề với Node.js
Node.js sử dụng JavaScript runtime để xây dựng server.
Ví dụ một HTTP server:
app.get("/users", async (req, res) => {
const users = await getUsers();
res.json(users);
});
Trong trường hợp này, việc chờ database không nhất thiết block toàn bộ JavaScript thread.
Trong thời gian database xử lý request:
Request A
│
├── Database query
│
│ ...
│
▼
Event Loop
│
├── Request B
├── Request C
└── Request D
Đây là một trong những lý do Node.js phù hợp với những hệ thống có nhiều I/O operations.
5. Nhưng async/await không tạo thread mới
Đây là một hiểu nhầm rất phổ biến.
Code:
const data = await fetchData();
không có nghĩa:
"JavaScript tạo một thread mới để chạy fetchData."
async/await chủ yếu là syntax giúp chúng ta viết asynchronous code theo cách dễ đọc hơn.
Ví dụ:
async function main() {
const data = await fetchData();
console.log(data);
}
Về bản chất, Promise vẫn là một phần của asynchronous programming model.
6. Microtask Queue
Đây là phần bắt đầu thú vị.
Promise callbacks được xử lý thông qua microtask queue.
Ví dụ:
console.log("A");
Promise.resolve().then(() => {
console.log("B");
});
console.log("C");
Kết quả:
A
C
B
Bởi vì:
A
↓
Promise callback được đưa vào Microtask Queue
↓
C
↓
Call Stack rỗng
↓
Microtask được xử lý
↓
B
7. Microtask vs Task
Một cách đơn giản để tư duy:
Synchronous code
↓
Microtasks
↓
Tasks / Macrotasks
Ví dụ:
console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
Promise.resolve().then(() => {
console.log("C");
});
console.log("D");
Kết quả:
A
D
C
B
Tại sao?
A
D
↓
Microtask Queue
↓
C
↓
Task Queue
↓
B
Đây là một trong những kiến thức rất hữu ích khi debug JavaScript.
8. Một ví dụ khó hơn
Xét:
console.log("1");
setTimeout(() => {
console.log("2");
}, 0);
Promise.resolve().then(() => {
console.log("3");
});
Promise.resolve().then(() => {
console.log("4");
});
console.log("5");
Kết quả:
1
5
3
4
2
Có thể mô phỏng:
Synchronous
────────────
1
5
Microtasks
──────────
3
4
Tasks
─────
2
Điểm quan trọng là:
Event Loop không đơn giản là lấy một task rồi chạy một task khác.
Runtime còn phải xử lý microtasks theo cơ chế riêng.
9. Vì sao một Promise có thể làm server "đứng"?
Bây giờ đến một vấn đề thực tế hơn.
Giả sử chúng ta viết:
Promise.resolve().then(() => {
while (true) {
// CPU intensive work
}
});
Microtask này không kết thúc.
JavaScript không thể tiếp tục xử lý những công việc khác trên cùng execution context.
Kết quả:
Microtask
│
▼
Infinite loop
│
X
Event Loop bị block
Vì vậy:
Asynchronous không đồng nghĩa với non-blocking.
Đây là distinction cực kỳ quan trọng trong Node.js.
10. I/O-bound và CPU-bound
Tôi thường chia vấn đề thành hai nhóm.
I/O-bound
Ví dụ:
- database query;
- HTTP request;
- file I/O;
- network operation.
Node.js xử lý những workload này rất tốt vì runtime có thể không cần giữ JavaScript execution context bận trong toàn bộ thời gian chờ.
CPU-bound
Ví dụ:
- xử lý image;
- encryption nặng;
- video processing;
- parsing dữ liệu cực lớn;
- machine learning computation;
- vòng lặp tính toán hàng triệu lần.
Ví dụ:
function heavyTask() {
for (let i = 0; i < 10_000_000_000; i++) {
// expensive computation
}
}
Nếu chạy trực tiếp trong main thread:
Request
↓
heavyTask()
↓
████████████████████
↓
Event Loop bị block
Các request khác phải chờ.
11. Đây là lúc kiến trúc bắt đầu quan trọng
Nếu application cần xử lý CPU-intensive workload, không nên đơn giản nghĩ:
"Dùng async/await là xong."
Có thể cần:
HTTP Request
│
▼
Node.js
│
├──── I/O operations
│
└──── CPU-heavy job
│
▼
Worker
│
▼
Result
Tùy bài toán, có thể sử dụng:
- Worker Threads;
- child processes;
- background workers;
- queue systems;
- separate services.
Đây là nơi kiến thức JavaScript bắt đầu kết nối với system architecture.
12. Một bài học khác: Đừng chỉ học syntax
JavaScript có rất nhiều syntax:
const
let
async
await
Promise
map
reduce
class
module
import
export
Nhưng việc thuộc syntax không đồng nghĩa với hiểu JavaScript.
Một engineer cần hiểu:
Syntax
↓
Runtime
↓
Execution Model
↓
Performance
↓
Architecture
Khi hiểu runtime, bạn sẽ bắt đầu đặt những câu hỏi khác:
Promise này đang được schedule như thế nào?
Đoạn code này có block Event Loop không?
Đây là I/O-bound hay CPU-bound?
Tại sao throughput giảm khi traffic tăng?
Có cần worker không?
Có cần queue không?
Những câu hỏi đó quan trọng hơn việc nhớ thêm một method của Array.
13. Event Loop không chỉ là câu hỏi phỏng vấn
Event Loop thường xuất hiện trong các câu hỏi interview:
"Output của đoạn code này là gì?"
Nhưng nếu chỉ học để trả lời interview thì khá phí.
Kiến thức này có giá trị hơn nhiều khi bạn làm production.
Khi một API Node.js bắt đầu chậm, bạn cần hiểu:
Request
↓
Application
↓
Event Loop
↓
I/O
↓
Database
↓
External API
↓
CPU workload
Để biết bottleneck nằm ở đâu.
Không phải cứ:
"API chậm → database chậm."
Có thể database rất nhanh nhưng application đang block Event Loop bởi một CPU-heavy operation.
14. Cách tôi tư duy về JavaScript
Sau một thời gian làm việc với Node.js và hệ sinh thái JavaScript, tôi thấy việc học JavaScript hiệu quả nhất không phải là học càng nhiều API càng tốt.
Tôi muốn hiểu nó theo tầng:
JavaScript
│
┌───────────┴───────────┐
↓ ↓
Language Runtime
│ │
↓ ↓
Syntax Event Loop
Scope Call Stack
Closure Promise
Prototype Microtask
Task Queue
│
↓
Node.js
│
┌──────────────┼──────────────┐
↓ ↓ ↓
I/O CPU Network
│ │ │
↓ ↓ ↓
Database Worker Service
Khi các tầng này kết nối được với nhau, JavaScript không còn là một ngôn ngữ chỉ để viết frontend.
Nó trở thành một công cụ để xây dựng systems.
Conclusion
Tôi từng nghĩ học JavaScript là học syntax.
Sau đó tôi nhận ra syntax chỉ là lớp đầu tiên.
Điều quan trọng hơn là hiểu:
Code của mình thực sự được runtime thực thi như thế nào.
Event Loop là một ví dụ rất rõ.
Nó giải thích tại sao JavaScript có thể xử lý asynchronous operations hiệu quả dù execution model của nó có tính chất single-threaded.
Nhưng nó cũng cho thấy giới hạn:
I/O concurrency tốt không có nghĩa CPU computation miễn phí.
Và khi hiểu được giới hạn đó, chúng ta bắt đầu đưa ra những quyết định kiến trúc tốt hơn:
Simple I/O
↓
Async/Await
Heavy workload
↓
Worker / Queue
Large system
↓
Architecture
Đó cũng là cách tôi muốn học JavaScript:
Không chỉ học cách viết code.
Hiểu code chạy như thế nào, vì sao nó chạy như vậy, và khi đưa vào production thì nó ảnh hưởng đến cả hệ thống ra sao.