JavaScript là một ngôn ngữ lập trình đơn luồng (single-threaded), nghĩa là tại một thời điểm, nó chỉ có thể thực hiện một tác vụ duy nhất. Tuy nhiên, trong thực tế, JavaScript vẫn xử lý mượt mà các thao tác như phản hồi người dùng, gửi yêu cầu mạng hay chạy bộ định thời mà không gây treo ứng dụng. Khả năng này có được nhờ vào cơ chế Event Loop (Vòng lặp sự kiện).
Khái niệm về Đồng bộ và Bất đồng bộ
Trước khi đi sâu vào Event Loop, chúng ta cần phân biệt hai cách thức thực thi mã nguồn:
- Đồng bộ (Synchronous): Các câu lệnh được thực hiện tuần tự từ trên xuống dưới. Lệnh sau phải chờ lệnh trước hoàn thành mới được bắt đầu.
- Bất đồng bộ (Asynchronous): Các tác vụ tốn thời gian (như đọc file, gọi API) sẽ được đẩy ra ngoài luồng chính. Chương trình tiếp tục chạy các lệnh khác thay vì đứng đợi.
Ví dụ về mã đồng bộ:
console.log('Bước 1');
console.log('Bước 2');
console.log('Bước 3');
// Kết quả: Bước 1 -> Bước 2 -> Bước 3
Ví dụ về mã bất đồng bộ với setTimeout:
console.log('Bắt đầu');
setTimeout(() => {
console.log('Xử lý sau 500ms');
}, 500);
console.log('Kết thúc');
// Kết quả: Bắt đầu -> Kết thúc -> Xử lý sau 500ms
Nguyên lý hoạt động của Event Loop
Hệ thống thực thi của JavaScript bao gồm hai thành phần quan trọng nhất:
- Call Stack (Ngăn xếp thực thi): Nơi chứa các hàm đang được thực hiện. Khi một hàm được gọi, nó được đẩy vào Stack; khi chạy xong, nó bị rút ra.
- Task Queue (Hàng đợi tác vụ): Nơi chứa các callback (hàm phản hồi) từ các tác vụ bất đồng bộ sau khi chúng hoàn tất việc chờ đợi.
Cơ chế Event Loop đóng vai trò như một người điều phối. Nó liên tục kiểm tra Call Stack. Nếu Call Stack trống, nó sẽ lấy tác vụ đầu tiên từ Task Queue và đẩy vào Call Stack để thực thi.
Phân loại tác vụ: Macrotask và Microtask
Trong JavaScript, các tác vụ bất đồng bộ không được đối xử như nhau. Chúng được chia thành hai loại với độ ưu tiên khác nhau:
1. Macrotask (Tác vụ vĩ mô)
Bao gồm các thao tác như:
setTimeoutsetInterval- Các sự kiện I/O (đọc/ghi dữ liệu)
- Thực thi mã script ban đầu (toàn bộ file .js)
2. Microtask (Tác vụ vi mô)
Có độ ưu tiên cao hơn Macrotask, bao gồm:
Promise.then/catch/finallyMutationObserverprocess.nextTick(trong môi trường Node.js)
Thứ tự ưu tiên thực hiện
Quy trình của một vòng lặp sự kiện diễn ra như sau:
- Thực thi toàn bộ mã đồng bộ trong Call Stack.
- Kiểm tra và thực thi tất cả các tác vụ trong Microtask Queue cho đến khi hàng đợi này trống rỗng.
- Lấy một tác vụ từ Macrotask Queue để thực thi.
- Quay lại bước 2 (kiểm tra lại Microtask Queue trước khi sang Macrotask tiếp theo).
Hãy cùng phân tích ví dụ phức tạp dưới đây để hiểu rõ hơn:
console.log("Khởi tạo hệ thống");
setTimeout(() => {
console.log("Macrotask: Hẹn giờ 1");
Promise.resolve().then(() => {
console.log("Microtask phát sinh trong Macrotask");
});
}, 0);
Promise.resolve().then(() => {
console.log("Microtask 1");
}).then(() => {
console.log("Microtask 2");
});
console.log("Hoàn tất thiết lập");
Phân tích luồng thực thi:
- Dòng
console.log("Khởi tạo hệ thống")chạy ngay lập tức. setTimeoutđẩy một callback vào hàng đợi Macrotask.Promise.thenđẩy một callback vào hàng đợi Microtask.- Dòng
console.log("Hoàn tất thiết lập")chạy đồng bộ. - Call Stack trống, hệ thống kiểm tra Microtask Queue: Thực thi "Microtask 1", sau đó là "Microtask 2".
- Microtask Queue trống, lấy một tác vụ từ Macrotask Queue: Thực thi "Macrotask: Hẹn giờ 1".
- Bên trong Macrotask này, một Promise mới được tạo ra và đẩy vào Microtask Queue.
- Sau khi xong Macrotask hiện tại, Event Loop lại ưu tiên Microtask vừa phát sinh: Thực thi "Microtask phát sinh trong Macrotask".
Kết quả đầu ra:
Khởi tạo hệ thống
Hoàn tất thiết lập
Microtask 1
Microtask 2
Macrotask: Hẹn giờ 1
Microtask phát sinh trong Macrotask
Việc nắm vững cách vận hành của Event Loop giúp lập trình viên kiểm soát tốt luồng dữ liệu, tối ưu hóa hiệu năng ứng dụng và tránh được các lỗi logic khó phát hiện khi làm việc với các tác vụ bất đồng bộ trong JavaScript.